diff --git a/.github/copilot-commit-instructions.md b/.github/copilot-commit-instructions.md new file mode 100644 index 000000000..8fc455207 --- /dev/null +++ b/.github/copilot-commit-instructions.md @@ -0,0 +1,30 @@ +## **Commit messages (Conventional Commits)** + +Use the **Conventional Commits** spec for all commits (and for the final **squash** commit message when squashing). This is the most widely adopted modern standard for readable history and tooling like changelogs/release automation. + +Spec: [Conventional Commits v1.0.0](https://www.conventionalcommits.org/en/v1.0.0/) + +Format: + +```text +[optional scope][!]: + +[optional body] + +[optional footer(s)] +``` + +- **type**: one of `feat`, `fix`, `docs`, `refactor`, `test`, `chore`, `build`, `ci`, `perf`, `revert` +- **scope (optional)**: short, lowercase area name (examples: `scripts`, `wt`, `stack`, `srv`, `env`, `docs`) +- **description**: imperative mood, present tense, no trailing period (example: “add”, “fix”, “remove”) +- **breaking changes**: add `!` (preferred) and/or a footer `BREAKING CHANGE: ...` +- **issue references (optional)**: add in footers (example: `Refs #123`, `Closes #123`) + +Examples: + +```text +feat(wt): add --stash option to update-all +fix(ports): avoid collisions when multiple stacks start +docs(agents): document Conventional Commits +refactor(stack): split env loading into helpers +``` diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 000000000..b3b5b14d3 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,413 @@ +name: Tests + +on: + pull_request: + push: + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + expo-app: + name: Expo App Tests + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: ${{ env.ACT == 'true' && 'npm' || 'yarn' }} + cache-dependency-path: yarn.lock + + - name: Enable Corepack + run: | + corepack enable + corepack prepare yarn@1.22.22 --activate + + - name: Install dependencies + run: yarn install --frozen-lockfile --ignore-scripts + + - name: Build workspace link deps (agents/protocol) + run: | + set -euo pipefail + yarn --cwd packages/agents -s build + mkdir -p packages/protocol/node_modules/@happy + ln -sfn "$(pwd)/packages/agents" "packages/protocol/node_modules/@happy/agents" + yarn --cwd packages/protocol -s build + + - name: Run tests + working-directory: expo-app + run: yarn test + + server: + name: Server Tests + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: ${{ env.ACT == 'true' && 'npm' || 'yarn' }} + cache-dependency-path: server/yarn.lock + + - name: Enable Corepack + run: | + corepack enable + corepack prepare yarn@1.22.22 --activate + + - name: Install dependencies + working-directory: server + run: yarn install --frozen-lockfile + + - name: Build @happy/agents (local workspace link) + working-directory: server + run: ./node_modules/.bin/tsc -p ../packages/agents/tsconfig.json + + - name: Link @happy/agents for protocol build (CI only) + run: | + set -euo pipefail + mkdir -p packages/protocol/node_modules/@happy + ln -sfn "$(pwd)/packages/agents" "packages/protocol/node_modules/@happy/agents" + + - name: Build @happy/protocol (local workspace link) + working-directory: server + run: ./node_modules/.bin/tsc -p ../packages/protocol/tsconfig.json + + - name: Run tests + working-directory: server + run: yarn test + + cli: + name: CLI Tests (incl. tmux integration) + runs-on: ubuntu-latest + timeout-minutes: 25 + env: + HAPPY_CLI_TMUX_INTEGRATION: "1" + HAPPY_CLI_DAEMON_REATTACH_INTEGRATION: "1" + HAPPY_NO_BROWSER_OPEN: "1" + TMPDIR: /tmp + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: ${{ env.ACT == 'true' && 'npm' || 'yarn' }} + cache-dependency-path: cli/yarn.lock + + - name: Enable Corepack + run: | + corepack enable + corepack prepare yarn@1.22.22 --activate + + - name: Install system dependencies + run: | + export DEBIAN_FRONTEND=noninteractive + APT_FLAGS="-o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30" + if [ "${ACT:-}" = "true" ] || [ -d /var/run/act ]; then + . /etc/os-release || true + CODENAME="${VERSION_CODENAME:-noble}" + ARCH="$(dpkg --print-architecture)" + BASE_URL="http://ports.ubuntu.com/ubuntu-ports" + if [ "${ARCH}" = "amd64" ]; then BASE_URL="http://archive.ubuntu.com/ubuntu"; fi + if command -v sudo >/dev/null 2>&1; then + sudo rm -f /etc/apt/sources.list.d/*.list /etc/apt/sources.list.d/*.sources || true + echo "deb ${BASE_URL} ${CODENAME} main universe" | sudo tee /etc/apt/sources.list >/dev/null + else + rm -f /etc/apt/sources.list.d/*.list /etc/apt/sources.list.d/*.sources || true + echo "deb ${BASE_URL} ${CODENAME} main universe" > /etc/apt/sources.list + fi + fi + if command -v sudo >/dev/null 2>&1; then + sudo apt-get ${APT_FLAGS} update + sudo apt-get ${APT_FLAGS} install -y --no-install-recommends tmux + else + apt-get ${APT_FLAGS} update + apt-get ${APT_FLAGS} install -y --no-install-recommends tmux + fi + tmux -V + + - name: Install dependencies + working-directory: cli + env: + YARN_PRODUCTION: "false" + npm_config_production: "false" + run: yarn install --frozen-lockfile + + - name: Build @happy/agents (local workspace link) + working-directory: cli + run: ./node_modules/.bin/tsc -p ../packages/agents/tsconfig.json + + - name: Link @happy/agents for protocol build (CI only) + run: | + set -euo pipefail + mkdir -p packages/protocol/node_modules/@happy + ln -sfn "$(pwd)/packages/agents" "packages/protocol/node_modules/@happy/agents" + + - name: Build @happy/protocol (local workspace link) + working-directory: cli + run: ./node_modules/.bin/tsc -p ../packages/protocol/tsconfig.json + + - name: Disable remote logging for CI (avoid hanging test process) + working-directory: cli + run: | + set -euo pipefail + if [ -f .env.integration-test ]; then + sed -i 's/^DANGEROUSLY_LOG_TO_SERVER_FOR_AI_AUTO_DEBUGGING=.*/DANGEROUSLY_LOG_TO_SERVER_FOR_AI_AUTO_DEBUGGING=/' .env.integration-test + sed -i 's/^DEBUG=.*/DEBUG=/' .env.integration-test + fi + + - name: Run tests + working-directory: cli + run: yarn test + + cli-daemon-e2e: + name: CLI + Server (light/sqlite) E2E + runs-on: ubuntu-latest + timeout-minutes: 35 + env: + PORT: "3005" + HAPPY_SERVER_URL: http://localhost:3005 + HAPPY_WEBAPP_URL: http://localhost:3005 + DANGEROUSLY_LOG_TO_SERVER_FOR_AI_AUTO_DEBUGGING: "true" + HAPPY_SERVER_LIGHT_DATA_DIR: /tmp/happy-server-light + DATABASE_URL: file:///tmp/happy-server-light/happy-server-light.sqlite + HANDY_MASTER_SECRET: happy-ci-master-secret-not-a-real-secret + HAPPY_NO_BROWSER_OPEN: "1" + TMPDIR: /tmp + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: ${{ env.ACT == 'true' && 'npm' || 'yarn' }} + cache-dependency-path: | + cli/yarn.lock + server/yarn.lock + + - name: Enable Corepack + run: | + corepack enable + corepack prepare yarn@1.22.22 --activate + + - name: Install system dependencies + run: | + export DEBIAN_FRONTEND=noninteractive + APT_FLAGS="-o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30" + if [ "${ACT:-}" = "true" ] || [ -d /var/run/act ]; then + . /etc/os-release || true + CODENAME="${VERSION_CODENAME:-noble}" + ARCH="$(dpkg --print-architecture)" + BASE_URL="http://ports.ubuntu.com/ubuntu-ports" + if [ "${ARCH}" = "amd64" ]; then BASE_URL="http://archive.ubuntu.com/ubuntu"; fi + if command -v sudo >/dev/null 2>&1; then + sudo rm -f /etc/apt/sources.list.d/*.list /etc/apt/sources.list.d/*.sources || true + echo "deb ${BASE_URL} ${CODENAME} main universe" | sudo tee /etc/apt/sources.list >/dev/null + else + rm -f /etc/apt/sources.list.d/*.list /etc/apt/sources.list.d/*.sources || true + echo "deb ${BASE_URL} ${CODENAME} main universe" > /etc/apt/sources.list + fi + fi + if command -v sudo >/dev/null 2>&1; then + sudo apt-get ${APT_FLAGS} update + sudo apt-get ${APT_FLAGS} install -y --no-install-recommends tmux + else + apt-get ${APT_FLAGS} update + apt-get ${APT_FLAGS} install -y --no-install-recommends tmux + fi + tmux -V + + - name: Install server dependencies + working-directory: server + run: yarn install --frozen-lockfile + + - name: Prepare SQLite schema (light) + working-directory: server + run: yarn -s db:push:light + + - name: Install CLI dependencies + working-directory: cli + env: + YARN_PRODUCTION: "false" + npm_config_production: "false" + run: yarn install --frozen-lockfile + + - name: Build @happy/agents (local workspace link) + working-directory: cli + run: ./node_modules/.bin/tsc -p ../packages/agents/tsconfig.json + + - name: Link @happy/agents for protocol build (CI only) + run: | + set -euo pipefail + mkdir -p packages/protocol/node_modules/@happy + ln -sfn "$(pwd)/packages/agents" "packages/protocol/node_modules/@happy/agents" + + - name: Build @happy/protocol (local workspace link) + working-directory: cli + run: ./node_modules/.bin/tsc -p ../packages/protocol/tsconfig.json + + - name: Build CLI + working-directory: cli + run: yarn build + + - name: Install provider CLI stubs (CI only) + run: | + set -euo pipefail + + STUB_BIN_DIR="/tmp/ci-bin" + mkdir -p "${STUB_BIN_DIR}" + + write_stub() { + local name="$1" + local version="$2" + cat > "${STUB_BIN_DIR}/${name}" </dev/null || true + while true; do sleep 3600; done + fi + EOF + chmod +x "${STUB_BIN_DIR}/${name}" + } + + write_stub "auggie" "0.0.0-ci-stub" + write_stub "claude" "0.0.0-ci-stub" + write_stub "codex" "0.0.0-ci-stub" + write_stub "gemini" "0.0.0-ci-stub" + write_stub "opencode" "0.0.0-ci-stub" + + echo "${STUB_BIN_DIR}" >> "${GITHUB_PATH}" + export PATH="${STUB_BIN_DIR}:${PATH}" + + command -v auggie && auggie --version + command -v claude && claude --version + command -v codex && codex --version + command -v gemini && gemini --version + command -v opencode && opencode --version + + - name: Run daemon integration suite (with light server) + run: | + set -euo pipefail + + mkdir -p "${HAPPY_SERVER_LIGHT_DATA_DIR}" + + nohup yarn --cwd server start:light > "/tmp/happy-server-light.log" 2>&1 & + SERVER_PID=$! + echo "${SERVER_PID}" > "/tmp/happy-server-light.pid" + + cleanup() { + if [ -n "${SERVER_PID:-}" ]; then + kill "${SERVER_PID}" || true + fi + } + trap cleanup EXIT + + for i in $(seq 1 60); do + if curl -fsS "http://127.0.0.1:${PORT}/health" >/dev/null 2>&1; then + echo "Server is healthy" + break + fi + sleep 1 + done + + if ! curl -fsS "http://127.0.0.1:${PORT}/health" >/dev/null 2>&1; then + echo "Server failed to become healthy within timeout" + tail -n 200 "/tmp/happy-server-light.log" || true + exit 1 + fi + + # Create a real account + token via the normal `/v1/auth` flow (ed25519 signature), + # then write the credentials file expected by `.env.integration-test`. + (cd server && node --input-type=module - <<'NODE' + import fs from "node:fs"; + import os from "node:os"; + import path from "node:path"; + import nacl from "tweetnacl"; + + const keyPair = nacl.sign.keyPair(); + const challenge = nacl.randomBytes(32); + const signature = nacl.sign.detached(challenge, keyPair.secretKey); + const encode64 = (bytes) => Buffer.from(bytes).toString("base64"); + + const serverUrl = process.env.HAPPY_SERVER_URL || "http://127.0.0.1:3005"; + const url = new URL("/v1/auth", serverUrl); + if (url.hostname === "localhost") url.hostname = "127.0.0.1"; + + const res = await fetch(url.toString(), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + publicKey: encode64(keyPair.publicKey), + challenge: encode64(challenge), + signature: encode64(signature), + }), + }); + if (!res.ok) { + const text = await res.text().catch(() => ""); + console.error("Failed to create CI auth token:", res.status, res.statusText, text); + process.exit(1); + } + const data = await res.json(); + if (!data?.token) { + console.error("Auth response missing token:", data); + process.exit(1); + } + + const happyHomeDir = path.join(os.homedir(), ".happy-dev-test"); + fs.mkdirSync(happyHomeDir, { recursive: true }); + const secret = Buffer.alloc(32, 7).toString("base64"); + fs.writeFileSync( + path.join(happyHomeDir, "access.key"), + JSON.stringify({ token: data.token, secret }, null, 2), + { mode: 0o600 }, + ); + NODE + ) + + yarn --cwd cli -s vitest run src/daemon/daemon.integration.test.ts + + - name: Dump server logs (on failure) + if: failure() + run: tail -n 300 "/tmp/happy-server-light.log" || true + + - name: Dump daemon logs (on failure) + if: failure() + run: | + LOG_DIR="$HOME/.happy-dev-test/logs" + ls -la "$LOG_DIR" || true + ls -1t "$LOG_DIR"/*-daemon.log 2>/dev/null | head -n 8 | while read -r f; do + echo "=== tail: $f ===" + tail -n 200 "$f" || true + echo + done diff --git a/.gitignore b/.gitignore index a4abbf5dc..5bd0c4164 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ web-build/ expo-env.d.ts /ios /android +packages/*/dist/ # Native .kotlin/ @@ -45,6 +46,7 @@ yarn-error.* CLAUDE.local.md .dev/worktree/* +.project/ # Development planning notes (keep local, don't commit) -notes/ \ No newline at end of file +notes/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..0441e746a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,32 @@ +# Repository Conventions (Happy monorepo) + +This file provides cross-cutting guidance for Claude Code (claude.ai/code) when working in this monorepo. + +Package-specific guidance lives in: +- `cli/CLAUDE.md` (Happy CLI) +- `expo-app/CLAUDE.md` (Expo app) +- `server/CLAUDE.md` (Server) + +## Naming conventions (shared) + +These are repo-wide defaults. **If a package-specific `CLAUDE.md` conflicts with this file, the package-specific file wins** (e.g. the server has its own directory naming conventions). + +### Folders +- Buckets: lowercase (e.g. `components`, `hooks`, `utils`, `modules`, `types`) +- Feature folders: `camelCase` (e.g. `newSession`, `agentInput`) +- Avoid `_folders` except special/framework files and `__tests__` +- Prefer not to create a folder that contains only a single file (unless it groups platform variants like `Thing.ios.tsx`/`Thing.web.tsx`, or it’s clearly about to grow). + +### Files +- React components: `PascalCase.tsx` +- Hooks: `useThing.ts` +- Plain TS modules: `camelCase.ts` + +### Allowed `_*.ts` markers (organization only) + +Allowed only inside “module-ish” directories (e.g. `modules/`, `ops/`, `phases/`, `helpers/`, `domains/`): +- `_types.ts` +- `_shared.ts` +- `_constants.ts` + +No other `_*.ts` file names should be introduced. diff --git a/cli/AGENTS.md b/cli/AGENTS.md new file mode 120000 index 000000000..681311eb9 --- /dev/null +++ b/cli/AGENTS.md @@ -0,0 +1 @@ +CLAUDE.md \ No newline at end of file diff --git a/cli/CLAUDE.md b/cli/CLAUDE.md index 3f07517b9..cd17cbd46 100644 --- a/cli/CLAUDE.md +++ b/cli/CLAUDE.md @@ -42,6 +42,47 @@ Happy CLI (`handy-cli`) is a command-line tool that wraps Claude Code to enable - Console output only for user-facing messages - Special handling for large JSON objects with truncation +## Folder Structure & Naming Conventions (2026-01) + +These conventions are **additive** to the guidelines above. The goal is to keep the CLI easy to reason about and avoid “god files”. + +### Naming +- Buckets are lowercase (e.g. `api`, `daemon`, `terminal`, `ui`, `commands`, `integrations`, `utils`). +- Feature folders are `camelCase` (e.g. `agentInput`, `newSession`). +- Allowed `_*.ts` markers (organization only) inside module-ish folders: `_types.ts`, `_shared.ts`, `_constants.ts`. + +### CLI taxonomy (target intent) + +Top-level domains are “first class” and should remain few: +- `src/agent/` — agent runtime framework (ACP, transports, adapters, factories) +- `src/api/` — server communication, crypto, queues, RPC +- `src/rpc/handlers/` — RPC method registration + validation (session surface) +- `src/capabilities/` — capability engine (probes, registry, snapshots, deps) +- `src/daemon/` — daemon lifecycle/control/diagnostics +- `src/integrations/` — OS/tool wrappers and services (tmux, ripgrep, difftastic, proxy, watcher) +- `src/terminal/` — terminal UX/runtime integration (flags, attach plans, headless helpers) +- `src/ui/` — user-facing UI and logging (Ink, formatting, QR, auth UI) +- `src/commands/` — user-facing subcommands +- `src/backends/claude/`, `src/backends/codex/`, `src/backends/gemini/`, `src/backends/opencode/` — agent backends (vendor-specific logic + entrypoints) +- `src/cli/` — argument parsing and command dispatch (keeps `src/index.ts` small) +- `src/utils/` — shared helpers; prefer named subfolders under `utils/` over dumping unrelated code at the root of `utils/` + +### Specific structure goals + +- `tmux` is an integration → prefer `src/integrations/tmux/*`. +- Shared “agent startup/runtime” helpers live in agent runtime → prefer `src/agent/runtime/*`. +- `toolTrace` is runtime instrumentation → prefer `src/agent/tools/trace/*`. +- CLI parsing is CLI domain → prefer `src/cli/parsers/*`. + +### When to create subfolders + +Avoid flat folders growing without structure: +- If a domain folder becomes “busy” (many files, multiple concerns), add subfolders by subdomain (e.g. `api/session`, `daemon/control`, `daemon/diagnostics`). +- Prefer “noun folders” (e.g. `api/session/`, `daemon/lifecycle/`) over `misc/`. + +### “Canonical entrypoints” rule +If a file path is already the established entrypoint (e.g. `api/apiMachine.ts`, `daemon/run.ts`), keep it as the entrypoint and extract internals under subfolders so the file stays readable and reviewable. + ## Architecture & Key Components ### 1. API Module (`/src/api/`) @@ -59,7 +100,7 @@ Handles server communication and encryption. - Optimistic concurrency control for state updates - RPC handler registration for remote procedure calls -### 2. Claude Integration (`/src/claude/`) +### 2. Claude Integration (`/src/backends/claude/`) Core Claude Code integration layer. - **`loop.ts`**: Main control loop managing interactive/remote modes @@ -225,4 +266,4 @@ When using --resume: 1. Must handle new session ID in responses 2. Original session remains as historical record 3. All context preserved but under new session identity -4. Session ID in stream-json output will be the new one, not the resumed one \ No newline at end of file +4. Session ID in stream-json output will be the new one, not the resumed one diff --git a/cli/bin/happy-dev.mjs b/cli/bin/happy-dev.mjs index 4a576e51a..6fd3190d9 100755 --- a/cli/bin/happy-dev.mjs +++ b/cli/bin/happy-dev.mjs @@ -9,15 +9,15 @@ import { homedir } from 'os'; const hasNoWarnings = process.execArgv.includes('--no-warnings'); const hasNoDeprecation = process.execArgv.includes('--no-deprecation'); +// Set development environment variables +process.env.HAPPY_HOME_DIR = join(homedir(), '.happy-dev'); +process.env.HAPPY_VARIANT = 'dev'; + if (!hasNoWarnings || !hasNoDeprecation) { // Re-execute with the flags const __filename = fileURLToPath(import.meta.url); const scriptPath = join(dirname(__filename), '../dist/index.mjs'); - // Set development environment variables - process.env.HAPPY_HOME_DIR = join(homedir(), '.happy-dev'); - process.env.HAPPY_VARIANT = 'dev'; - try { execFileSync( process.execPath, @@ -33,9 +33,5 @@ if (!hasNoWarnings || !hasNoDeprecation) { } } else { // Already have the flags, import normally - // Set development environment variables - process.env.HAPPY_HOME_DIR = join(homedir(), '.happy-dev'); - process.env.HAPPY_VARIANT = 'dev'; - await import('../dist/index.mjs'); } diff --git a/cli/bin/happy-mcp.mjs b/cli/bin/happy-mcp.mjs index 6a903ed35..cb3e82cae 100755 --- a/cli/bin/happy-mcp.mjs +++ b/cli/bin/happy-mcp.mjs @@ -10,7 +10,7 @@ const hasNoDeprecation = process.execArgv.includes('--no-deprecation'); if (!hasNoWarnings || !hasNoDeprecation) { const projectRoot = dirname(dirname(fileURLToPath(import.meta.url))); - const entrypoint = join(projectRoot, 'dist', 'codex', 'happyMcpStdioBridge.mjs'); + const entrypoint = join(projectRoot, 'dist', 'backends', 'codex', 'happyMcpStdioBridge.mjs'); try { execFileSync(process.execPath, [ @@ -27,6 +27,5 @@ if (!hasNoWarnings || !hasNoDeprecation) { } } else { // Already have desired flags; import module directly - import('../dist/codex/happyMcpStdioBridge.mjs'); + import('../dist/backends/codex/happyMcpStdioBridge.mjs'); } - diff --git a/cli/demo-project/main.go b/cli/demo-project/main.go deleted file mode 100644 index 30ed04952..000000000 --- a/cli/demo-project/main.go +++ /dev/null @@ -1,7 +0,0 @@ -package main - -import "fmt" - -func main() { - fmt.Println("Hello World") -} \ No newline at end of file diff --git a/cli/docs/agents-catalog.md b/cli/docs/agents-catalog.md new file mode 100644 index 000000000..e44993b48 --- /dev/null +++ b/cli/docs/agents-catalog.md @@ -0,0 +1,237 @@ +# Agent Catalog - CLI + +This doc explains how the **Agent Catalog** works in Happy, and how to add a new agent/backend without guessing. + +--- + +## Terms (what each thing means) + +- **AgentId**: the canonical id for an agent, shared across CLI + Expo (+ server). + - Source of truth: `@happy/agents` (`packages/agents`). +- **Agent Catalog (CLI)**: the declarative mapping of `AgentId -> integration hooks` used to drive: + - CLI command routing + - capability detection/checklists + - daemon spawn wiring + - optional ACP backend factories + - Source: `cli/src/backends/catalog.ts` +- **Backend folder**: provider/agent-specific code and wiring. + - Source: `cli/src/backends//**` +- **Protocol**: shared cross-boundary contracts between UI and CLI daemon. + - Source: `@happy/protocol` (`packages/protocol`). + +--- + +## Sources of truth + +### 1) Shared core manifest: `@happy/agents` + +Where: `packages/agents/src/manifest.ts` + +What belongs here: +- canonical ids (`AgentId`) +- identity/aliases for parsing or migration (e.g. `flavorAliases`) +- resume core capabilities (e.g. `resume.runtimeGate`, `resume.vendorResume`) +- cloud connect core mapping (if any): `cloudConnect` + +What does **not** belong here: +- UI assets (icons/images) +- UI routes +- CLI implementation details (argv, env, paths) + +### 2) Cross-boundary contracts: `@happy/protocol` + +Where: `packages/protocol/src/*` + +What belongs here: +- daemon RPC result shapes that the Expo app needs to interpret deterministically +- stable error codes (e.g. spawn/resume failures) + +Example: +- `packages/protocol/src/spawnSession.ts` defines `SpawnSessionErrorCode` + `SpawnSessionResult`. + +### 3) CLI agent catalog: `cli/src/backends/catalog.ts` + +Where the CLI assembles all backends into a single map: +- `export const AGENTS: Record = { ... }` +- helper resolvers like `resolveCatalogAgentId(...)` + +--- + +## CLI backend layout (recommended) + +Each backend folder exports one canonical entry object from its `index.ts`: + +- `cli/src/backends//index.ts` exports: + - `export const agent = { ... } satisfies AgentCatalogEntry;` + +The global catalog imports those entries and assembles them: +- `cli/src/backends/catalog.ts` + +This keeps backend-specific wiring co-located, while preserving a deterministic, explicit catalog (no self-registration side effects). + +--- + +## AgentCatalogEntry hooks (CLI) + +Type: `cli/src/backends/types.ts` (`AgentCatalogEntry`) + +### Required + +- `id: AgentId` +- `cliSubcommand: AgentId` +- `vendorResumeSupport: VendorResumeSupportLevel` (from `@happy/agents`) + +### Optional hooks (what they do) + +- `getCliCommandHandler(): Promise` + - Provides the `happy ...` CLI subcommand handler. + - Used by `cli/src/cli/commandRegistry.ts`. + +- `getCliCapabilityOverride(): Promise` + - Defines the `cli.` capability descriptor, if the generic one is not sufficient. + +- `getCapabilities(): Promise` + - Adds extra capabilities beyond `cli.`, typically: + - `dep.${string}` (dependency checks) + - `tool.${string}` (tool availability) + - Example: Codex contributes `dep.codex-acp` etc. + +- `getCliDetect(): Promise` + - Provides version/login-status probe argv patterns used by the CLI snapshot. + - Consumed by `cli/src/capabilities/snapshots/cliSnapshot.ts`. + +- `getCloudConnectTarget(): Promise` + - Enables `happy connect ` for this agent. + - The preferred source-of-truth for connect availability + vendor mapping is `@happy/agents` (and this hook returns the implementation object). + +- `getDaemonSpawnHooks(): Promise` + - Allows per-agent spawn customizations in the daemon, while keeping the wiring co-located with the backend. + +- `getHeadlessTmuxArgvTransform(): Promise<(argv: string[]) => string[]>` + - Optional argv rewrite for `--tmux` / headless launching. + +- `getAcpBackendFactory(): Promise<(opts: unknown) => { backend: AgentBackend }>` + - Provides an ACP backend factory for agents that run via ACP. + +- `checklists?: AgentChecklistContributions` + - Optional additions to the capability checklists system. + - Prefer data-only contributions (no side-effect registration). + +--- + +## Capabilities + checklists contract (CLI ↔ Expo) + +### Capability id conventions (CLI) + +Defined in `cli/src/capabilities/types.ts`: +- `cli.`: base “agent detected + login status + (optional) ACP capability surface” probe +- `tool.${string}`: tool capability (e.g. `tool.tmux`) +- `dep.${string}`: dependency capability (e.g. `dep.codex-acp`) + +### Checklist id conventions (CLI) + +Checklist ids are strings; we treat these as stable API between daemon and UI: +- `new-session` +- `machine-details` +- `resume.` (one per agent) + +### ACP resume runtime gate + +Some agents don’t have “vendor resume” universally enabled, but can be resumable depending on whether ACP `loadSession` is supported on the machine. + +In `@happy/agents`, this is represented as: +- `resume.runtimeGate === 'acpLoadSession'` + +In the CLI, this is implemented by making `resume.` checklists include an agent probe request that sets `includeAcpCapabilities: true`. + +So UI logic can treat “ACP resume supported” as: +- the daemon’s `resume.` checklist result contains a `cli.` capability whose `acpCapabilities.loadSession` indicates support (shape defined by the CLI capability implementation). + +--- + +## Adding a new agent/backend (CLI) + +### Step 0 — Pick the id contract + +Decide a new canonical id (example): `myagent`. + +We strongly prefer: +- `AgentId === CLI subcommand === detectKey` + +### Step 1 — Add the agent to `@happy/agents` + +Edit: +- `packages/agents/src/manifest.ts` + +Add: +- `AgentId` entry +- any `flavorAliases` +- `resume.vendorResume` (`supported|unsupported|experimental`) +- optional `resume.runtimeGate` (e.g. `'acpLoadSession'`) +- optional `cloudConnect` mapping if it participates in cloud connect UX + +### Step 2 — Create a backend folder in the CLI + +Create folder: +- `cli/src/backends/myagent/` + +Add whatever you need (examples): +- `cli/command.ts` (subcommand handler) +- `cli/capability.ts` (optional override for `cli.myagent`) +- `cli/detect.ts` (version/login probe spec) +- `daemon/spawnHooks.ts` (if needed) +- `acp/backend.ts` (if it’s an ACP backend) +- `cloud/connect.ts` (if it supports connect) + +### Step 3 — Export the catalog entry from `index.ts` + +Create: +- `cli/src/backends/myagent/index.ts` + +Pattern: +```ts +import { AGENTS_CORE } from '@happy/agents'; +import type { AgentCatalogEntry } from '../types'; + +export const agent = { + id: AGENTS_CORE.myagent.id, + cliSubcommand: AGENTS_CORE.myagent.cliSubcommand, + vendorResumeSupport: AGENTS_CORE.myagent.resume.vendorResume, + getCliCommandHandler: async () => (await import('./cli/command')).handleMyAgentCliCommand, + getCliDetect: async () => (await import('./cli/detect')).cliDetect, + // other hooks as needed... +} satisfies AgentCatalogEntry; +``` + +### Step 4 — Add it to the catalog assembly + +Edit: +- `cli/src/backends/catalog.ts` + +Add: +```ts +import { agent as myagent } from '@/backends/myagent'; + +export const AGENTS = { + // ... + myagent, +} satisfies Record; +``` + +### Step 5 — Verify + +Run: +```bash +yarn --cwd cli typecheck +yarn --cwd cli test +``` + +--- + +## What not to do (anti-patterns) + +- Don’t “auto-discover” backends by scanning the filesystem. We want deterministic bundling and explicit reviewable changes. +- Don’t do side-effect self-registration (“import this file and it registers itself”). It makes ordering brittle and behavior hard to audit. +- Don’t leave long-lived “stubs” (re-export shims) as an architectural layer. Prefer canonical entrypoints and direct imports. + + diff --git a/cli/docs/bug-fix-plan-2025-01-15-athundt.md b/cli/docs/bug-fix-plan-2025-01-15-athundt.md deleted file mode 100644 index 488f43cba..000000000 --- a/cli/docs/bug-fix-plan-2025-01-15-athundt.md +++ /dev/null @@ -1,337 +0,0 @@ -# Minimal Fix Plan for Happy-CLI Bugs with TDD -# Date: 2025-01-15 -# Created by: Andrew Hundt -# Bugs: Session ID conflict + Server crash - -## Overview -Two targeted fixes with concrete error messages and TDD tests to verify behavior. - -## Bug 1: Session ID Conflict with --continue Flag - -**Problem**: When running `./bin/happy.mjs --continue`, Claude CLI returns error: -``` -Error: --session-id cannot be used with --continue or --resume -``` - -**Root Cause Analysis**: -- This is a Claude Code 2.0.64+ design constraint, NOT a happy-cli bug -- Happy-CLI generates a NEW session ID and adds `--session-id ` for all local sessions -- When user passes `--continue`, Claude Code sees: `--continue --session-id ` → REJECTS -- The conflict occurs ONLY in local mode (claudeLocal.ts), not remote mode - -**Two Different Pathways**: - -1. **Local Mode (Path with conflict)**: - ``` - user: happy --continue - → index.ts (claudeArgs = ["--continue"]) - → runClaude.ts - → loop.ts - → claudeLocalLauncher.ts - → claudeLocal.ts - ├─ Generates NEW session ID - ├─ Adds --session-id - └─ Claude sees both flags → ERROR - ``` - -2. **Remote Mode (No conflict)**: - ``` - user: happy --continue - → ... → claudeRemote.ts → SDK query.ts - → SDK passes --continue to Claude - → No --session-id added by happy-cli - → Works fine - ``` - -**Claude Session File Analysis**: - -- Claude creates session files at: `~/.claude/projects/{project-id}/` -- Format: `{session-id}.jsonl` with UUID or agent-* IDs -- `--continue` creates NEW session with copied history -- `--resume {id}` continues EXISTING session with same ID -- Claude 2.0.64+ rejects `--session-id` with `--continue`/`--resume` - -## Solution Approach Analysis - -| Method | Description | Upsides | Downsides | Complexity | Risk | -|--------|-------------|---------|-----------|------------|------| -| **Convert --continue → --resume** | Find last valid session, convert flag | ✅ Exact --continue behavior
✅ Native Claude support
✅ Simple implementation | ❌ Needs session finding logic
❌ Fails if no sessions exist | Medium | Medium | -| Environment Variables | Set session ID via env var | ✅ Simple
✅ No file system deps | ❌ Non-obvious to users
❌ Hard to debug | Low | Low | -| Post-process Extraction | Run Claude, extract session ID from output | ✅ Always gets correct ID
✅ Works with any Claude version | ❌ Complex parsing
❌ Race conditions
❌ High complexity | High | High | -| Hybrid | Try --continue, fallback if fails | ✅ Minimal changes
✅ Graceful fallback | ❌ Inconsistent behavior
❌ Two code paths | Medium | Medium | - -**Recommended Solution: Convert --continue to --resume** - -This approach: -- Uses Claude's native --resume mechanism -- Maintains exact --continue behavior (new session with copied history) -- Transparent to users -- Works with existing session infrastructure - -```typescript -// In claudeLocal.ts (around line 35, after startFrom initial check) - -// Convert --continue to --resume with last session -if (!startFrom && opts.claudeArgs?.includes('--continue')) { - const lastSession = claudeFindLastSession(opts.path); - if (lastSession) { - startFrom = lastSession; - logger.debug(`[ClaudeLocal] Converting --continue to --resume ${lastSession}`); - } else { - logger.debug('[ClaudeLocal] No sessions found for --continue, creating new session'); - } - // Remove --continue from claudeArgs since we're handling it - opts.claudeArgs = opts.claudeArgs?.filter(arg => arg !== '--continue'); -} - -// Then existing logic: -if (startFrom) { - args.push('--resume', startFrom); // Will continue the found session -} else { - args.push('--session-id', newSessionId!); // New session -} -``` - -## Bug 2: Happy Server Unavailability Crash - -**Problem**: Happy-CLI crashes when Happy API server is unreachable - -**Server Details**: -- Default server: `https://api.cluster-fluster.com` -- Environment variable: `HAPPY_SERVER_URL` (overrides default) -- Local development: `http://localhost:3005` -- The server handles session management and real-time communication for Happy CLI - -**Fixes with Clear Messages**: - -1. **apiSession.ts** (line 152) - Socket connection failure: -```typescript -try { - this.socket.connect(); -} catch (error) { - console.log('⚠️ Cannot connect to Happy server - continuing in local mode'); - logger.debug('[API] Socket connection failed:', error); - // Don't throw - continue without socket -} -``` - -2. **api.ts** (catch block around line 75) - HTTP API failure: -```typescript -} catch (error) { - if (error.code === 'ECONNREFUSED' || error.code === 'ENOTFOUND') { - console.log('⚠️ Happy server unreachable - working in offline mode'); - return null; // Let caller handle fallback - } - throw error; // Re-throw other errors -} -``` - -## TDD Tests (Test-First Development) - -### Test File 1: src/claude/claudeLocal.test.ts -```typescript -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { claudeLocal } from './claudeLocal'; - -describe('claudeLocal --continue handling', () => { - let mockSpawn: any; - let onSessionFound: any; - - beforeEach(() => { - mockSpawn = vi.fn(); - vi.mock('child_process', () => ({ - spawn: mockSpawn - })); - onSessionFound = vi.fn(); - mockSpawn.mockReturnValue({ - stdio: [null, null, null, null], - on: vi.fn(), - addListener: vi.fn(), - removeListener: vi.fn(), - kill: vi.fn(), - on: vi.fn(), - stdout: { on: vi.fn() }, - stderr: { on: vi.fn() }, - stdin: { on: vi.fn(), end: vi.fn() } - }); - }); - - it('should pass --continue to Claude without --session-id when user requests continue', async () => { - await claudeLocal({ - abort: new AbortController().signal, - sessionId: null, - path: '/tmp', - onSessionFound, - claudeArgs: ['--continue'] // User wants to continue last session - }); - - // Verify spawn was called with --continue but WITHOUT --session-id - expect(mockSpawn).toHaveBeenCalled(); - const spawnArgs = mockSpawn.mock.calls[0][2]; - - // Should contain --continue - expect(spawnArgs).toContain('--continue'); - - // Should NOT contain --session-id (this was causing the conflict) - expect(spawnArgs).not.toContain('--session-id'); - - // Should notify about continue - expect(onSessionFound).toHaveBeenCalledWith('continue-pending'); - }); - - it('should add --session-id for normal new sessions', async () => { - await claudeLocal({ - abort: new AbortController().signal, - sessionId: null, - path: '/tmp', - onSessionFound, - claudeArgs: [] // No session flags - new session - }); - - // Verify spawn was called with --session-id for new sessions - expect(mockSpawn).toHaveBeenCalled(); - const spawnArgs = mockSpawn.mock.calls[0][2]; - expect(spawnArgs).toContain('--session-id'); - expect(spawnArgs).not.toContain('--continue'); - }); - - it('should handle --resume with session ID without conflict', async () => { - await claudeLocal({ - abort: new AbortController().signal, - sessionId: 'existing-session-123', - path: '/tmp', - onSessionFound, - claudeArgs: [] // No --continue - }); - - // Should use --resume with session ID - const spawnArgs = mockSpawn.mock.calls[0][2]; - expect(spawnArgs).toContain('--resume'); - expect(spawnArgs).toContain('existing-session-123'); - expect(spawnArgs).not.toContain('--session-id'); - }); -}); -``` - -### Test File 2: src/api/apiSession.test.ts -```typescript -import { describe, it, expect } from 'vitest'; -import { ApiSessionClient } from './apiSession'; - -describe('ApiSessionClient connection handling', () => { - it('should handle socket connection failure gracefully', async () => { - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); - - // Mock socket.connect() to throw - const mockSocket = { - connect: vi.fn(() => { throw new Error('ECONNREFUSED'); }), - on: vi.fn() - }; - - // Should not throw - expect(() => { - new ApiSessionClient('fake-token', { id: 'test' } as any); - }).not.toThrow(); - - // Should show user-friendly message - expect(consoleSpy).toHaveBeenCalledWith( - '⚠️ Cannot connect to Happy server - continuing in local mode' - ); - - consoleSpy.mockRestore(); - }); -}); -``` - -### Test File 3: src/api/api.test.ts -```typescript -import { describe, it, expect, vi } from 'vitest'; -import { Api } from './api'; - -describe('Api server error handling', () => { - it('should return null when Happy server is unreachable', async () => { - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); - - // Mock axios to throw connection error - vi.mock('axios', () => ({ - default: { - post: vi.fn(() => Promise.reject({ code: 'ECONNREFUSED' })) - } - })); - - const api = new Api('fake-key'); - const result = await api.getOrCreateSession({ machineId: 'test' }); - - expect(result).toBeNull(); - expect(consoleSpy).toHaveBeenCalledWith( - '⚠️ Happy server unreachable - working in offline mode' - ); - - consoleSpy.mockRestore(); - }); -}); -``` - -## Implementation Steps (TDD Flow) - -1. **Create Local Plan Copy**: - ```bash - # Copy plan with date and author to project docs - cp /Users/athundt/.claude/plans/lively-plotting-snowflake.md \ - ./docs/bug-fix-plan-2025-01-15-athundt.md - git add ./docs/bug-fix-plan-2025-01-15-athundt.md - git commit -m "docs: add bug fix plan for session conflict and server crash" - ``` - -2. **Red Phase**: - - Write the 3 test files above - - Run tests - they should fail (bugs not fixed yet) - -3. **Green Phase - Bug 1 (Session ID Conflict)**: - - Apply fix to src/claude/claudeLocal.ts (around line 35): - - Import claudeFindLastSession from src/claude/utils/claudeFindLastSession.ts - - Detect --continue flag - - Convert to --resume with last session ID using claudeFindLastSession() - - Remove --continue from claudeArgs - - Use existing logic to add --resume or --session-id - - Run tests - they should pass - -4. **Green Phase - Bug 2 (Server Crash)**: - - Apply fixes to src/api/apiSession.ts, src/api/api.ts - - Add graceful error handling with user messages - - Run tests - they should pass - -5. **Refactor Phase**: - - Add session ID extraction for --continue (future enhancement): - - Monitor Claude's session file creation - - Extract real session ID from ~/.claude/projects/*/session-id.jsonl - - Update Happy's session metadata with Claude's ID - - Ensure code is clean and minimal - -6. **Manual Verification**: - ```bash - # Test Bug 1 fix: - ./bin/happy.mjs --continue # Should work without error - # Verify mobile/daemon still work with session ID - - # Test Bug 2 fix: - HAPPY_SERVER_URL=http://invalid:9999 ./bin/happy.mjs # Should show warning, not crash - # Or test with unreachable default server: - # Temporarily block network access to test default server fallback - ``` - -## Success Criteria - -**Bug 1 Fixed**: -- Test: `./bin/happy.mjs --continue` exits with code 0 -- No "session-id cannot be used" error - -**Bug 2 Fixed**: -- Test: `HAPPY_SERVER_URL=http://invalid:9999 ./bin/happy.mjs` shows warning message -- Process continues in local mode instead of crashing -- Clear user feedback: "⚠️ Happy server unreachable - working in offline mode" - -**All Tests Pass**: -- Unit tests: 100% pass -- Integration tests: Verify actual CLI behavior -- No regression in existing functionality \ No newline at end of file diff --git a/cli/package.json b/cli/package.json index 87040fab0..ade1b037b 100644 --- a/cli/package.json +++ b/cli/package.json @@ -36,14 +36,14 @@ "default": "./dist/lib.mjs" } }, - "./codex/happyMcpStdioBridge": { + "./backends/codex/happyMcpStdioBridge": { "require": { - "types": "./dist/codex/happyMcpStdioBridge.d.cts", - "default": "./dist/codex/happyMcpStdioBridge.cjs" + "types": "./dist/backends/codex/happyMcpStdioBridge.d.cts", + "default": "./dist/backends/codex/happyMcpStdioBridge.cjs" }, "import": { - "types": "./dist/codex/happyMcpStdioBridge.d.mts", - "default": "./dist/codex/happyMcpStdioBridge.mjs" + "types": "./dist/backends/codex/happyMcpStdioBridge.d.mts", + "default": "./dist/backends/codex/happyMcpStdioBridge.mjs" } } }, @@ -65,7 +65,12 @@ "dev:integration-test-env": "$npm_execpath run build && tsx --env-file .env.integration-test src/index.ts", "prepublishOnly": "$npm_execpath run build && $npm_execpath test", "release": "$npm_execpath install && release-it", - "postinstall": "node scripts/unpack-tools.cjs", + "build:shared": "node scripts/buildSharedDeps.mjs", + "pretypecheck": "yarn -s build:shared", + "prebuild": "yarn -s build:shared", + "pretest": "yarn -s build:shared", + "postinstall": "node scripts/unpack-tools.cjs && yarn -s build:shared", + "tool:trace:extract": "tsx scripts/tool-trace-extract.ts", "// ==== Dev/Stable Variant Management ====": "", "stable": "node scripts/env-wrapper.cjs stable", "dev:variant": "node scripts/env-wrapper.cjs dev", @@ -118,6 +123,8 @@ "zod": "^3.23.8" }, "devDependencies": { + "@happy/agents": "link:../packages/agents", + "@happy/protocol": "link:../packages/protocol", "@eslint/compat": "^1", "@types/node": ">=20", "cross-env": "^10.1.0", diff --git a/cli/scripts/__tests__/ripgrep_launcher.test.ts b/cli/scripts/__tests__/ripgrep_launcher.test.ts index 258dcb72c..9eb9a8853 100644 --- a/cli/scripts/__tests__/ripgrep_launcher.test.ts +++ b/cli/scripts/__tests__/ripgrep_launcher.test.ts @@ -1,5 +1,11 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; import { describe, it, expect, vi, beforeEach } from 'vitest'; +function readLauncherFile(): string { + return readFileSync(join(__dirname, '../ripgrep_launcher.cjs'), 'utf8'); +} + describe('Ripgrep Launcher Runtime Compatibility', () => { beforeEach(() => { vi.clearAllMocks(); @@ -8,9 +14,7 @@ describe('Ripgrep Launcher Runtime Compatibility', () => { it('has correct file structure', () => { // Test that the launcher file has the correct structure expect(() => { - const fs = require('fs'); - const path = require('path'); - const content = fs.readFileSync(path.join(__dirname, '../ripgrep_launcher.cjs'), 'utf8'); + const content = readLauncherFile(); // Check for required elements expect(content).toContain('#!/usr/bin/env node'); @@ -22,9 +26,7 @@ describe('Ripgrep Launcher Runtime Compatibility', () => { it('handles --version argument gracefully', () => { // Test that --version handling logic exists expect(() => { - const fs = require('fs'); - const path = require('path'); - const content = fs.readFileSync(path.join(__dirname, '../ripgrep_launcher.cjs'), 'utf8'); + const content = readLauncherFile(); // Check that --version handling is present expect(content).toContain('--version'); @@ -35,9 +37,7 @@ describe('Ripgrep Launcher Runtime Compatibility', () => { it('detects runtime correctly', () => { // Test runtime detection function exists expect(() => { - const fs = require('fs'); - const path = require('path'); - const content = fs.readFileSync(path.join(__dirname, '../ripgrep_launcher.cjs'), 'utf8'); + const content = readLauncherFile(); // Check that runtime detection logic is present expect(content).toContain('detectRuntime'); @@ -50,9 +50,7 @@ describe('Ripgrep Launcher Runtime Compatibility', () => { it('contains fallback chain logic', () => { // Test that fallback logic is present expect(() => { - const fs = require('fs'); - const path = require('path'); - const content = fs.readFileSync(path.join(__dirname, '../ripgrep_launcher.cjs'), 'utf8'); + const content = readLauncherFile(); // Check that fallback chain is present expect(content).toContain('loadRipgrepNative'); @@ -65,9 +63,7 @@ describe('Ripgrep Launcher Runtime Compatibility', () => { it('contains cross-platform logic', () => { // Test that cross-platform logic is present expect(() => { - const fs = require('fs'); - const path = require('path'); - const content = fs.readFileSync(path.join(__dirname, '../ripgrep_launcher.cjs'), 'utf8'); + const content = readLauncherFile(); // Check for platform-specific logic expect(content).toContain('process.platform'); @@ -81,14 +77,22 @@ describe('Ripgrep Launcher Runtime Compatibility', () => { it('provides helpful error messages', () => { // Test that helpful error messages are present expect(() => { - const fs = require('fs'); - const path = require('path'); - const content = fs.readFileSync(path.join(__dirname, '../ripgrep_launcher.cjs'), 'utf8'); + const content = readLauncherFile(); // Check for helpful messages expect(content).toContain('brew install ripgrep'); expect(content).toContain('winget install BurntSushi.ripgrep'); expect(content).toContain('Search functionality unavailable'); + expect(content).toContain('Missing arguments: expected JSON-encoded argv'); + }).not.toThrow(); + }); + + it('does not treat signal termination as success', () => { + expect(() => { + const content = readLauncherFile(); + + expect(content).not.toContain('result.status || 0'); + expect(content).toContain('result.signal'); }).not.toThrow(); }); -}); \ No newline at end of file +}); diff --git a/cli/scripts/buildSharedDeps.mjs b/cli/scripts/buildSharedDeps.mjs new file mode 100644 index 000000000..22efd4708 --- /dev/null +++ b/cli/scripts/buildSharedDeps.mjs @@ -0,0 +1,38 @@ +import { execFileSync } from 'node:child_process'; +import { existsSync, mkdirSync, rmSync, symlinkSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(__dirname, '..', '..'); + +const tscBin = resolve(repoRoot, 'cli', 'node_modules', '.bin', process.platform === 'win32' ? 'tsc.cmd' : 'tsc'); + +function runTsc(tsconfigPath) { + execFileSync(tscBin, ['-p', tsconfigPath], { stdio: 'inherit' }); +} + +function ensureSymlink({ linkPath, targetPath }) { + try { + rmSync(linkPath, { recursive: true, force: true }); + } catch { + // ignore + } + mkdirSync(resolve(linkPath, '..'), { recursive: true }); + symlinkSync(targetPath, linkPath, process.platform === 'win32' ? 'junction' : 'dir'); +} + +// Ensure @happy/agents is resolvable from the protocol workspace. +ensureSymlink({ + linkPath: resolve(repoRoot, 'packages', 'protocol', 'node_modules', '@happy', 'agents'), + targetPath: resolve(repoRoot, 'packages', 'agents'), +}); + +runTsc(resolve(repoRoot, 'packages', 'agents', 'tsconfig.json')); +runTsc(resolve(repoRoot, 'packages', 'protocol', 'tsconfig.json')); + +const protocolDist = resolve(repoRoot, 'packages', 'protocol', 'dist', 'index.js'); +if (!existsSync(protocolDist)) { + throw new Error(`Expected @happy/protocol build output missing: ${protocolDist}`); +} + diff --git a/cli/scripts/claude_version_utils.cjs b/cli/scripts/claude_version_utils.cjs index 2184917ae..0b8a7e250 100644 --- a/cli/scripts/claude_version_utils.cjs +++ b/cli/scripts/claude_version_utils.cjs @@ -131,7 +131,9 @@ function detectSourceFromPath(resolvedPath) { // Windows-specific detection (detect by path patterns, not current platform) if (normalizedPath.includes('appdata') || normalizedPath.includes('program files') || normalizedPath.endsWith('.exe')) { // Windows npm - if (normalizedPath.includes('appdata') && normalizedPath.includes('npm') && normalizedPath.includes('node_modules')) { + if (normalizedPath.includes('appdata') && normalizedPath.includes('npm') && normalizedPath.includes('node_modules') && + normalizedPath.includes('@anthropic-ai') && normalizedPath.includes('claude-code') && + !normalizedPath.includes('.claude-code-')) { return 'npm'; } @@ -477,6 +479,27 @@ function getClaudeCliPath() { * Run Claude CLI, handling both JavaScript and binary files * @param {string} cliPath - Path to CLI (from getClaudeCliPath) */ +function attachChildSignalForwarding(child, proc = process) { + const forwardSignal = (signal) => { + try { + if (child && child.pid && !child.killed) { + child.kill(signal); + } + } catch { + // ignore + } + }; + + const signals = ['SIGTERM', 'SIGINT']; + if (proc.platform !== 'win32') { + signals.push('SIGHUP'); + } + + for (const signal of signals) { + proc.on(signal, () => forwardSignal(signal)); + } +} + function runClaudeCli(cliPath) { const { pathToFileURL } = require('url'); const { spawn } = require('child_process'); @@ -497,6 +520,11 @@ function runClaudeCli(cliPath) { stdio: 'inherit', env: process.env }); + + // Forward signals to child process so it gets killed when parent is killed. + // This prevents orphaned Claude processes when switching between local/remote modes. + attachChildSignalForwarding(child); + child.on('exit', (code) => { process.exit(code || 0); }); @@ -514,6 +542,6 @@ module.exports = { getVersion, compareVersions, getClaudeCliPath, - runClaudeCli + runClaudeCli, + attachChildSignalForwarding }; - diff --git a/cli/scripts/claude_version_utils.test.ts b/cli/scripts/claude_version_utils.test.ts index d83e16ad0..b5b2d5610 100644 --- a/cli/scripts/claude_version_utils.test.ts +++ b/cli/scripts/claude_version_utils.test.ts @@ -32,9 +32,9 @@ describe('Claude Version Utils - Cross-Platform Detection', () => { expect(result).toBe('npm'); }); - it('should detect npm with different scoped packages', () => { + it('should not classify unrelated scoped npm packages as npm', () => { const result = detectSourceFromPath('C:/Users/test/AppData/Roaming/npm/node_modules/@babel/core/cli.js'); - expect(result).toBe('npm'); + expect(result).toBe('PATH'); }); it('should detect npm through Homebrew', () => { diff --git a/cli/scripts/env-wrapper.cjs b/cli/scripts/env-wrapper.cjs index 2ec562afd..b485984fc 100755 --- a/cli/scripts/env-wrapper.cjs +++ b/cli/scripts/env-wrapper.cjs @@ -51,6 +51,15 @@ if (!variant || !VARIANTS[variant]) { process.exit(1); } +if (!command) { + console.error('Usage: node scripts/env-wrapper.js [...args]'); + console.error(''); + console.error('Examples:'); + console.error(' node scripts/env-wrapper.js stable daemon start'); + console.error(' node scripts/env-wrapper.js dev auth login'); + process.exit(1); +} + const config = VARIANTS[variant]; // Create home directory if it doesn't exist diff --git a/cli/scripts/ripgrep_launcher.cjs b/cli/scripts/ripgrep_launcher.cjs index ab00d9a87..6ebd49409 100644 --- a/cli/scripts/ripgrep_launcher.cjs +++ b/cli/scripts/ripgrep_launcher.cjs @@ -40,7 +40,7 @@ function findSystemRipgrep() { try { const result = execFileSync(cmd, args, { encoding: 'utf8', - stdio: 'ignore' + stdio: ['ignore', 'pipe', 'ignore'] }); if (result) { @@ -93,7 +93,9 @@ function createRipgrepWrapper(binaryPath) { stdio: 'inherit', cwd: process.cwd() }); - return result.status || 0; + if (typeof result.status === 'number') return result.status; + if (result.signal) return 1; + return 0; } }; } @@ -170,6 +172,11 @@ const args = process.argv.slice(2); // Parse the JSON-encoded arguments let parsedArgs; try { + if (!args[0]) { + console.error('Missing arguments: expected JSON-encoded argv as the first parameter.'); + console.error('Example: node scripts/ripgrep_launcher.cjs \'["--version"]\''); + process.exit(1); + } parsedArgs = JSON.parse(args[0]); } catch (error) { console.error('Failed to parse arguments:', error.message); @@ -183,4 +190,4 @@ try { } catch (error) { console.error('Ripgrep error:', error.message); process.exit(1); -} \ No newline at end of file +} diff --git a/cli/scripts/test-continue-fix.sh b/cli/scripts/test-continue-fix.sh index f37355113..ea1ce1cf3 100755 --- a/cli/scripts/test-continue-fix.sh +++ b/cli/scripts/test-continue-fix.sh @@ -14,7 +14,7 @@ echo echo "2. Testing session finder with current directory..." node -e " const { resolve, join } = require('path'); -const { readdirSync, statSync, readFileSync } = require('fs'); +const { readdirSync, statSync, readFileSync, existsSync } = require('fs'); const { homedir } = require('os'); const workingDirectory = process.cwd(); @@ -22,6 +22,11 @@ const projectId = resolve(workingDirectory).replace(/[\\\\\\/\.:]/g, '-'); const claudeConfigDir = process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude'); const projectDir = join(claudeConfigDir, 'projects', projectId); +if (!existsSync(projectDir)) { + console.log('ERROR: Project directory does not exist:', projectDir); + process.exit(1); +} + const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\$/i; const files = readdirSync(projectDir) diff --git a/cli/scripts/tool-trace-extract.ts b/cli/scripts/tool-trace-extract.ts new file mode 100644 index 000000000..033df68a4 --- /dev/null +++ b/cli/scripts/tool-trace-extract.ts @@ -0,0 +1,53 @@ +import { readFileSync, writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { extractToolTraceFixturesFromJsonlLines } from '../src/toolTrace/extractToolTraceFixtures'; + +function parseArgs(argv: string[]): { inputs: string[]; outFile: string | null } { + const inputs: string[] = []; + let outFile: string | null = null; + + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === '--out' || arg === '-o') { + const next = argv[i + 1]; + if (!next) throw new Error('Missing value for --out'); + outFile = next; + i++; + continue; + } + inputs.push(arg); + } + + return { inputs, outFile }; +} + +function readJsonlLines(filePath: string): string[] { + const raw = readFileSync(filePath, 'utf8'); + return raw.split('\n').filter((l) => l.trim().length > 0); +} + +function main() { + const { inputs, outFile } = parseArgs(process.argv.slice(2)); + if (inputs.length === 0) { + // eslint-disable-next-line no-console + console.error('Usage: tsx scripts/tool-trace-extract.ts [--out out.json] '); + process.exit(1); + } + + const allLines: string[] = []; + for (const input of inputs) { + allLines.push(...readJsonlLines(resolve(input))); + } + + const fixtures = extractToolTraceFixturesFromJsonlLines(allLines); + const json = `${JSON.stringify(fixtures, null, 2)}\n`; + + if (outFile) { + writeFileSync(resolve(outFile), json, 'utf8'); + } else { + process.stdout.write(json); + } +} + +main(); + diff --git a/cli/src/utils/BaseReasoningProcessor.ts b/cli/src/agent/BaseReasoningProcessor.ts similarity index 100% rename from cli/src/utils/BaseReasoningProcessor.ts rename to cli/src/agent/BaseReasoningProcessor.ts diff --git a/cli/src/agent/acp/AcpBackend.ts b/cli/src/agent/acp/AcpBackend.ts index b44e91e7c..6ae246592 100644 --- a/cli/src/agent/acp/AcpBackend.ts +++ b/cli/src/agent/acp/AcpBackend.ts @@ -7,7 +7,6 @@ */ import { spawn, type ChildProcess } from 'node:child_process'; -import { Readable, Writable } from 'node:stream'; import { ClientSideConnection, ndJsonStream, @@ -18,6 +17,7 @@ import { type RequestPermissionResponse, type InitializeRequest, type NewSessionRequest, + type LoadSessionRequest, type PromptRequest, type ContentBlock, } from '@agentclientprotocol/sdk'; @@ -33,18 +33,6 @@ import type { import { logger } from '@/ui/logger'; import { delay } from '@/utils/time'; import packageJson from '../../../package.json'; - -/** - * Retry configuration for ACP operations - */ -const RETRY_CONFIG = { - /** Maximum number of retry attempts for init/newSession */ - maxAttempts: 3, - /** Base delay between retries in ms */ - baseDelayMs: 1000, - /** Maximum delay between retries in ms */ - maxDelayMs: 5000, -} as const; import { type TransportHandler, type StderrContext, @@ -57,27 +45,57 @@ import { DEFAULT_IDLE_TIMEOUT_MS, DEFAULT_TOOL_CALL_TIMEOUT_MS, handleAgentMessageChunk, + handleUserMessageChunk, handleAgentThoughtChunk, handleToolCallUpdate, handleToolCall, handleLegacyMessageChunk, handlePlanUpdate, handleThinkingUpdate, + handleAvailableCommandsUpdate, + handleCurrentModeUpdate, } from './sessionUpdateHandlers'; +import { nodeToWebStreams } from './nodeToWebStreams'; +import { + pickPermissionOutcome, + type PermissionOptionLike, +} from './permissions/permissionMapping'; +import { + extractPermissionInputWithFallback, + extractPermissionToolNameHint, + resolvePermissionToolName, + type PermissionRequestLike, +} from './permissions/permissionRequest'; +import { AcpReplayCapture, type AcpReplayEvent } from './history/acpReplayCapture'; + +/** + * Retry configuration for ACP operations + */ +const RETRY_CONFIG = { + /** Maximum number of retry attempts for init/newSession */ + maxAttempts: 3, + /** Base delay between retries in ms */ + baseDelayMs: 1000, + /** Maximum delay between retries in ms */ + maxDelayMs: 5000, +} as const; /** * Extended RequestPermissionRequest with additional fields that may be present */ type ExtendedRequestPermissionRequest = RequestPermissionRequest & { toolCall?: { + toolCallId?: string; id?: string; kind?: string; toolName?: string; + rawInput?: Record; input?: Record; arguments?: Record; content?: Record; }; kind?: string; + rawInput?: Record; input?: Record; arguments?: Record; content?: Record; @@ -88,29 +106,8 @@ type ExtendedRequestPermissionRequest = RequestPermissionRequest & { }>; }; -/** - * Extended SessionNotification with additional fields - */ -type ExtendedSessionNotification = SessionNotification & { - update?: { - sessionUpdate?: string; - toolCallId?: string; - status?: string; - kind?: string | unknown; - content?: { - text?: string; - error?: string | { message?: string }; - [key: string]: unknown; - } | string | unknown; - locations?: unknown[]; - messageChunk?: { - textDelta?: string; - }; - plan?: unknown; - thinking?: unknown; - [key: string]: unknown; - }; -} +// SessionNotification payload shape differs across ACP SDK versions (some use `update`, some use `updates[]`). +// We normalize dynamically in `handleSessionUpdate` and avoid relying on the SDK type here. /** * Permission handler interface for ACP backends @@ -127,7 +124,7 @@ export interface AcpPermissionHandler { toolCallId: string, toolName: string, input: unknown - ): Promise<{ decision: 'approved' | 'approved_for_session' | 'denied' | 'abort' }>; + ): Promise<{ decision: 'approved' | 'approved_for_session' | 'approved_execpolicy_amendment' | 'denied' | 'abort' }>; } /** @@ -162,66 +159,6 @@ export interface AcpBackendOptions { hasChangeTitleInstruction?: (prompt: string) => boolean; } -/** - * Convert Node.js streams to Web Streams for ACP SDK - * - * NOTE: This function registers event handlers on stdout. If you also register - * handlers directly on stdout (e.g., for logging), both will fire. - */ -function nodeToWebStreams( - stdin: Writable, - stdout: Readable -): { writable: WritableStream; readable: ReadableStream } { - // Convert Node writable to Web WritableStream - const writable = new WritableStream({ - write(chunk) { - return new Promise((resolve, reject) => { - const ok = stdin.write(chunk, (err) => { - if (err) { - logger.debug(`[AcpBackend] Error writing to stdin:`, err); - reject(err); - } - }); - if (ok) { - resolve(); - } else { - stdin.once('drain', resolve); - } - }); - }, - close() { - return new Promise((resolve) => { - stdin.end(resolve); - }); - }, - abort(reason) { - stdin.destroy(reason instanceof Error ? reason : new Error(String(reason))); - } - }); - - // Convert Node readable to Web ReadableStream - // Filter out non-JSON debug output from gemini CLI (experiments, flags, etc.) - const readable = new ReadableStream({ - start(controller) { - stdout.on('data', (chunk: Buffer) => { - controller.enqueue(new Uint8Array(chunk)); - }); - stdout.on('end', () => { - controller.close(); - }); - stdout.on('error', (err) => { - logger.debug(`[AcpBackend] Stdout error:`, err); - controller.error(err); - }); - }, - cancel() { - stdout.destroy(); - } - }); - - return { writable, readable }; -} - /** * Helper to run an async operation with retry logic */ @@ -270,6 +207,7 @@ export class AcpBackend implements AgentBackend { private connection: ClientSideConnection | null = null; private acpSessionId: string | null = null; private disposed = false; + private replayCapture: AcpReplayCapture | null = null; /** Track active tool calls to prevent duplicate events */ private activeToolCalls = new Set(); private toolCallTimeouts = new Map(); @@ -283,6 +221,10 @@ export class AcpBackend implements AgentBackend { /** Map from real tool call ID to tool name for auto-approval */ private toolCallIdToNameMap = new Map(); + private toolCallIdToInputMap = new Map>(); + + /** Cache last selected permission option per tool call id (handles duplicate permission prompts) */ + private lastSelectedPermissionOptionIdByToolCallId = new Map(); /** Track if we just sent a prompt with change_title instruction */ private recentPromptHadChangeTitle = false; @@ -321,349 +263,412 @@ export class AcpBackend implements AgentBackend { } } - async startSession(initialPrompt?: string): Promise { - if (this.disposed) { - throw new Error('Backend has been disposed'); - } + private buildAcpMcpServersForSessionRequest(): NewSessionRequest['mcpServers'] { + if (!this.options.mcpServers) return [] as unknown as NewSessionRequest['mcpServers']; + const mcpServers = Object.entries(this.options.mcpServers).map(([name, config]) => ({ + name, + command: config.command, + args: config.args || [], + env: config.env + ? Object.entries(config.env).map(([envName, envValue]) => ({ name: envName, value: envValue })) + : [], + })); + return mcpServers as unknown as NewSessionRequest['mcpServers']; + } - const sessionId = randomUUID(); - this.emit({ type: 'status', status: 'starting' }); + private async createConnectionAndInitialize(params: { operationId: string }): Promise<{ initTimeout: number }> { + logger.debug(`[AcpBackend] Starting process + initializing connection (op=${params.operationId})`); - try { - logger.debug(`[AcpBackend] Starting session: ${sessionId}`); - // Spawn the ACP agent process - const args = this.options.args || []; - - // On Windows, spawn via cmd.exe to handle .cmd files and PATH resolution - // This ensures proper stdio piping without shell buffering - if (process.platform === 'win32') { - const fullCommand = [this.options.command, ...args].join(' '); - this.process = spawn('cmd.exe', ['/c', fullCommand], { - cwd: this.options.cwd, - env: { ...process.env, ...this.options.env }, - stdio: ['pipe', 'pipe', 'pipe'], - windowsHide: true, - }); - } else { - this.process = spawn(this.options.command, args, { - cwd: this.options.cwd, - env: { ...process.env, ...this.options.env }, - // Use 'pipe' for all stdio to capture output without printing to console - // stdout and stderr will be handled by our event listeners - stdio: ['pipe', 'pipe', 'pipe'], - }); - } - - // Ensure stderr doesn't leak to console - redirect to logger only - // This prevents gemini CLI debug output from appearing in user's console - if (this.process.stderr) { - // stderr is already handled by the event listener below - // but we ensure it doesn't go to parent's stderr - } - - if (!this.process.stdin || !this.process.stdout || !this.process.stderr) { - throw new Error('Failed to create stdio pipes'); - } + if (this.process || this.connection) { + throw new Error('ACP backend is already initialized'); + } - // Handle stderr output via transport handler - this.process.stderr.on('data', (data: Buffer) => { - const text = data.toString(); - if (!text.trim()) return; + try { + // Spawn the ACP agent process + const args = this.options.args || []; + + // On Windows, spawn via cmd.exe to handle .cmd files and PATH resolution + // This ensures proper stdio piping without shell buffering + if (process.platform === 'win32') { + const fullCommand = [this.options.command, ...args].join(' '); + this.process = spawn('cmd.exe', ['/c', fullCommand], { + cwd: this.options.cwd, + env: { ...process.env, ...this.options.env }, + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true, + }); + } else { + this.process = spawn(this.options.command, args, { + cwd: this.options.cwd, + env: { ...process.env, ...this.options.env }, + // Use 'pipe' for all stdio to capture output without printing to console + // stdout and stderr will be handled by our event listeners + stdio: ['pipe', 'pipe', 'pipe'], + }); + } - // Build context for transport handler - const hasActiveInvestigation = this.transport.isInvestigationTool - ? Array.from(this.activeToolCalls).some(id => this.transport.isInvestigationTool!(id)) - : false; + if (!this.process.stdin || !this.process.stdout || !this.process.stderr) { + throw new Error('Failed to create stdio pipes'); + } - const context: StderrContext = { - activeToolCalls: this.activeToolCalls, - hasActiveInvestigation, - }; + // Handle stderr output via transport handler + this.process.stderr.on('data', (data: Buffer) => { + const text = data.toString(); + if (!text.trim()) return; - // Log to file (not console) - if (hasActiveInvestigation) { - logger.debug(`[AcpBackend] 🔍 Agent stderr (during investigation): ${text.trim()}`); - } else { - logger.debug(`[AcpBackend] Agent stderr: ${text.trim()}`); - } + // Build context for transport handler + const hasActiveInvestigation = this.transport.isInvestigationTool + ? Array.from(this.activeToolCalls).some(id => this.transport.isInvestigationTool!(id)) + : false; - // Let transport handler process stderr and optionally emit messages - if (this.transport.handleStderr) { - const result = this.transport.handleStderr(text, context); - if (result.message) { - this.emit(result.message); - } - } - }); + const context: StderrContext = { + activeToolCalls: this.activeToolCalls, + hasActiveInvestigation, + }; - this.process.on('error', (err) => { - // Log to file only, not console - logger.debug(`[AcpBackend] Process error:`, err); - this.emit({ type: 'status', status: 'error', detail: err.message }); - }); + // Log to file (not console) + if (hasActiveInvestigation) { + logger.debug(`[AcpBackend] 🔍 Agent stderr (during investigation): ${text.trim()}`); + } else { + logger.debug(`[AcpBackend] Agent stderr: ${text.trim()}`); + } - this.process.on('exit', (code, signal) => { - if (!this.disposed && code !== 0 && code !== null) { - logger.debug(`[AcpBackend] Process exited with code ${code}, signal ${signal}`); - this.emit({ type: 'status', status: 'stopped', detail: `Exit code: ${code}` }); + // Let transport handler process stderr and optionally emit messages + if (this.transport.handleStderr) { + const result = this.transport.handleStderr(text, context); + if (result.message) { + this.emit(result.message); } - }); - - // Create Web Streams from Node streams - const streams = nodeToWebStreams( - this.process.stdin, - this.process.stdout - ); - const writable = streams.writable; - const readable = streams.readable; - - // Filter stdout via transport handler before ACP parsing - // Some agents output debug info that breaks JSON-RPC parsing - const transport = this.transport; - const filteredReadable = new ReadableStream({ - async start(controller) { - const reader = readable.getReader(); - const decoder = new TextDecoder(); - const encoder = new TextEncoder(); - let buffer = ''; - let filteredCount = 0; - - try { - while (true) { - const { done, value } = await reader.read(); - if (done) { - // Flush any remaining buffer - if (buffer.trim()) { - const filtered = transport.filterStdoutLine?.(buffer); - if (filtered === undefined) { - controller.enqueue(encoder.encode(buffer)); - } else if (filtered !== null) { - controller.enqueue(encoder.encode(filtered)); - } else { - filteredCount++; - } - } - if (filteredCount > 0) { - logger.debug(`[AcpBackend] Filtered out ${filteredCount} non-JSON lines from ${transport.agentName} stdout`); - } - controller.close(); - break; - } - - // Decode and accumulate data - buffer += decoder.decode(value, { stream: true }); + } + }); - // Process line by line (ndJSON is line-delimited) - const lines = buffer.split('\n'); - buffer = lines.pop() || ''; // Keep last incomplete line in buffer + this.process.on('error', (err) => { + // Log to file only, not console + logger.debug(`[AcpBackend] Process error:`, err); + this.emit({ type: 'status', status: 'error', detail: err.message }); + }); - for (const line of lines) { - if (!line.trim()) continue; + this.process.on('exit', (code, signal) => { + if (!this.disposed && code !== 0 && code !== null) { + logger.debug(`[AcpBackend] Process exited with code ${code}, signal ${signal}`); + this.emit({ type: 'status', status: 'stopped', detail: `Exit code: ${code}` }); + } + }); - // Use transport handler to filter lines - // Note: filterStdoutLine returns null to filter out, string to keep - // If method not implemented (undefined), pass through original line - const filtered = transport.filterStdoutLine?.(line); + // Create Web Streams from Node streams + const streams = nodeToWebStreams( + this.process.stdin, + this.process.stdout + ); + const writable = streams.writable; + const readable = streams.readable; + + // Filter stdout via transport handler before ACP parsing + // Some agents output debug info that breaks JSON-RPC parsing + const transport = this.transport; + const filteredReadable = new ReadableStream({ + async start(controller) { + const reader = readable.getReader(); + const decoder = new TextDecoder(); + const encoder = new TextEncoder(); + let buffer = ''; + let filteredCount = 0; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) { + // Flush any remaining buffer + if (buffer.trim()) { + const filtered = transport.filterStdoutLine?.(buffer); if (filtered === undefined) { - // Method not implemented, pass through - controller.enqueue(encoder.encode(line + '\n')); + controller.enqueue(encoder.encode(buffer)); } else if (filtered !== null) { - // Method returned transformed line - controller.enqueue(encoder.encode(filtered + '\n')); + controller.enqueue(encoder.encode(filtered)); } else { - // Method returned null, filter out filteredCount++; } } + if (filteredCount > 0) { + logger.debug(`[AcpBackend] Filtered out ${filteredCount} non-JSON lines from ${transport.agentName} stdout`); + } + controller.close(); + break; + } + + // Decode and accumulate data + buffer += decoder.decode(value, { stream: true }); + + // Process line by line (ndJSON is line-delimited) + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; // Keep last incomplete line in buffer + + for (const line of lines) { + if (!line.trim()) continue; + + // Use transport handler to filter lines + // Note: filterStdoutLine returns null to filter out, string to keep + // If method not implemented (undefined), pass through original line + const filtered = transport.filterStdoutLine?.(line); + if (filtered === undefined) { + // Method not implemented, pass through + controller.enqueue(encoder.encode(line + '\n')); + } else if (filtered !== null) { + // Method returned transformed line + controller.enqueue(encoder.encode(filtered + '\n')); + } else { + // Method returned null, filter out + filteredCount++; + } } - } catch (error) { - logger.debug(`[AcpBackend] Error filtering stdout stream:`, error); - controller.error(error); - } finally { - reader.releaseLock(); } + } catch (error) { + logger.debug(`[AcpBackend] Error filtering stdout stream:`, error); + controller.error(error); + } finally { + reader.releaseLock(); } - }); + } + }); - // Create ndJSON stream for ACP - const stream = ndJsonStream(writable, filteredReadable); + // Create ndJSON stream for ACP + const stream = ndJsonStream(writable, filteredReadable); - // Create Client implementation - const client: Client = { - sessionUpdate: async (params: SessionNotification) => { - this.handleSessionUpdate(params); - }, - requestPermission: async (params: RequestPermissionRequest): Promise => { - - const extendedParams = params as ExtendedRequestPermissionRequest; - const toolCall = extendedParams.toolCall; - let toolName = toolCall?.kind || toolCall?.toolName || extendedParams.kind || 'Unknown tool'; - // Use toolCallId as the single source of truth for permission ID - // This ensures mobile app sends back the same ID that we use to store pending requests - const toolCallId = toolCall?.id || randomUUID(); - const permissionId = toolCallId; // Use same ID for consistency! - - // Extract input/arguments from various possible locations FIRST (before checking toolName) - let input: Record = {}; - if (toolCall) { - input = toolCall.input || toolCall.arguments || toolCall.content || {}; - } else { - // If no toolCall, try to extract from params directly - input = extendedParams.input || extendedParams.arguments || extendedParams.content || {}; - } - - // If toolName is "other" or "Unknown tool", try to determine real tool name - const context: ToolNameContext = { - recentPromptHadChangeTitle: this.recentPromptHadChangeTitle, - toolCallCountSincePrompt: this.toolCallCountSincePrompt, - }; - toolName = this.transport.determineToolName?.(toolName, toolCallId, input, context) ?? toolName; - - if (toolName !== (toolCall?.kind || toolCall?.toolName || extendedParams.kind || 'Unknown tool')) { - logger.debug(`[AcpBackend] Detected tool name: ${toolName} from toolCallId: ${toolCallId}`); - } - - // Increment tool call counter for context tracking - this.toolCallCountSincePrompt++; - - const options = extendedParams.options || []; - - // Log permission request for debugging (include full params to understand structure) - logger.debug(`[AcpBackend] Permission request: tool=${toolName}, toolCallId=${toolCallId}, input=`, JSON.stringify(input)); - logger.debug(`[AcpBackend] Permission request params structure:`, JSON.stringify({ - hasToolCall: !!toolCall, - toolCallKind: toolCall?.kind, - toolCallId: toolCall?.id, - paramsKind: extendedParams.kind, - paramsKeys: Object.keys(params), - }, null, 2)); - - // Emit permission request event for UI/mobile handling - this.emit({ - type: 'permission-request', - id: permissionId, - reason: toolName, - payload: { - ...params, - permissionId, + // Create Client implementation + const client: Client = { + sessionUpdate: async (params: SessionNotification) => { + this.handleSessionUpdate(params); + }, + requestPermission: async (params: RequestPermissionRequest): Promise => { + + const extendedParams = params as ExtendedRequestPermissionRequest; + const toolCall = extendedParams.toolCall; + const options = extendedParams.options || []; + // ACP spec: toolCall.toolCallId is the correlation ID. Fall back to legacy fields when needed. + const toolCallId = + (typeof toolCall?.toolCallId === 'string' && toolCall.toolCallId.trim().length > 0) + ? toolCall.toolCallId.trim() + : (typeof toolCall?.id === 'string' && toolCall.id.trim().length > 0) + ? toolCall.id.trim() + : randomUUID(); + const permissionId = toolCallId; + + const toolNameHint = extractPermissionToolNameHint(extendedParams as PermissionRequestLike); + const input = extractPermissionInputWithFallback( + extendedParams as PermissionRequestLike, + toolCallId, + this.toolCallIdToInputMap + ); + let toolName = resolvePermissionToolName({ + toolNameHint, + toolCallId, + toolCallIdToNameMap: this.toolCallIdToNameMap, + }); + + // If the agent re-prompts with the same toolCallId, reuse the previous selection when possible. + const cachedOptionId = this.lastSelectedPermissionOptionIdByToolCallId.get(toolCallId); + if (cachedOptionId && options.some((opt) => opt.optionId === cachedOptionId)) { + logger.debug(`[AcpBackend] Duplicate permission prompt for ${toolCallId}, reusing cached optionId=${cachedOptionId}`); + return { outcome: { outcome: 'selected', optionId: cachedOptionId } }; + } + + // If toolName is "other" or "Unknown tool", try to determine real tool name + const context: ToolNameContext = { + recentPromptHadChangeTitle: this.recentPromptHadChangeTitle, + toolCallCountSincePrompt: this.toolCallCountSincePrompt, + }; + toolName = this.transport.determineToolName?.(toolName, toolCallId, input, context) ?? toolName; + + if (toolName !== (toolCall?.kind || toolCall?.toolName || extendedParams.kind || 'Unknown tool')) { + logger.debug(`[AcpBackend] Detected tool name: ${toolName} from toolCallId: ${toolCallId}`); + } + + // Increment tool call counter for context tracking + this.toolCallCountSincePrompt++; + + const inputKeys = input && typeof input === 'object' && !Array.isArray(input) + ? Object.keys(input as Record) + : []; + logger.debug(`[AcpBackend] Permission request: tool=${toolName}, toolCallId=${toolCallId}, inputKeys=${inputKeys.join(',')}`); + logger.debug(`[AcpBackend] Permission request params structure:`, JSON.stringify({ + hasToolCall: !!toolCall, + toolCallToolCallId: toolCall?.toolCallId, + toolCallKind: toolCall?.kind, + toolCallToolName: toolCall?.toolName, + toolCallId: toolCall?.id, + paramsKind: extendedParams.kind, + options: options.map((opt) => ({ optionId: opt.optionId, kind: opt.kind, name: opt.name })), + paramsKeys: Object.keys(params), + }, null, 2)); + + // Emit permission request event for UI/mobile handling + this.emit({ + type: 'permission-request', + id: permissionId, + reason: toolName, + payload: { + ...params, + permissionId, + toolCallId, + toolName, + input, + options: options.map((opt) => ({ + id: opt.optionId, + name: opt.name, + kind: opt.kind, + })), + }, + }); + + // Use permission handler if provided, otherwise auto-approve + if (this.options.permissionHandler) { + try { + const result = await this.options.permissionHandler.handleToolCall( toolCallId, toolName, - input, - options: options.map((opt) => ({ - id: opt.optionId, - name: opt.name, - kind: opt.kind, - })), - }, - }); - - // Use permission handler if provided, otherwise auto-approve - if (this.options.permissionHandler) { - try { - const result = await this.options.permissionHandler.handleToolCall( - toolCallId, - toolName, - input - ); - - // Map permission decision to ACP response - // ACP uses optionId from the request options - let optionId = 'cancel'; // Default to cancel/deny - - if (result.decision === 'approved' || result.decision === 'approved_for_session') { - // Find the appropriate optionId from the request options - // Look for 'proceed_once' or 'proceed_always' in options - const proceedOnceOption = options.find((opt: any) => - opt.optionId === 'proceed_once' || opt.name?.toLowerCase().includes('once') - ); - const proceedAlwaysOption = options.find((opt: any) => - opt.optionId === 'proceed_always' || opt.name?.toLowerCase().includes('always') - ); - - if (result.decision === 'approved_for_session' && proceedAlwaysOption) { - optionId = proceedAlwaysOption.optionId || 'proceed_always'; - } else if (proceedOnceOption) { - optionId = proceedOnceOption.optionId || 'proceed_once'; - } else if (options.length > 0) { - // Fallback to first option if no specific match - optionId = options[0].optionId || 'proceed_once'; - } - - // Emit tool-result with permissionId so UI can close the timer - // This is needed because tool_call_update comes with a different ID - this.emit({ - type: 'tool-result', - toolName, - result: { status: 'approved', decision: result.decision }, - callId: permissionId, - }); - } else { - // Denied or aborted - find cancel option - const cancelOption = options.find((opt: any) => - opt.optionId === 'cancel' || opt.name?.toLowerCase().includes('cancel') - ); - if (cancelOption) { - optionId = cancelOption.optionId || 'cancel'; - } - - // Emit tool-result for denied/aborted - this.emit({ - type: 'tool-result', - toolName, - result: { status: 'denied', decision: result.decision }, - callId: permissionId, - }); - } - - return { outcome: { outcome: 'selected', optionId } }; - } catch (error) { - // Log to file only, not console - logger.debug('[AcpBackend] Error in permission handler:', error); - // Fallback to deny on error - return { outcome: { outcome: 'selected', optionId: 'cancel' } }; + input + ); + + const isApproved = result.decision === 'approved' + || result.decision === 'approved_for_session' + || result.decision === 'approved_execpolicy_amendment'; + + await this.respondToPermission(permissionId, isApproved); + const outcome = pickPermissionOutcome(options as PermissionOptionLike[], result.decision); + if (outcome.outcome === 'selected') { + this.lastSelectedPermissionOptionIdByToolCallId.set(toolCallId, outcome.optionId); + } else { + this.lastSelectedPermissionOptionIdByToolCallId.delete(toolCallId); } + return { outcome }; + } catch (error) { + // Log to file only, not console + logger.debug('[AcpBackend] Error in permission handler:', error); + // Fallback to deny on error + return { outcome: { outcome: 'cancelled' } }; } - - // Auto-approve with 'proceed_once' if no permission handler - // optionId must match one from the request options (e.g., 'proceed_once', 'proceed_always', 'cancel') - const proceedOnceOption = options.find((opt) => - opt.optionId === 'proceed_once' || (typeof opt.name === 'string' && opt.name.toLowerCase().includes('once')) - ); - const defaultOptionId = proceedOnceOption?.optionId || (options.length > 0 && options[0].optionId ? options[0].optionId : 'proceed_once'); - return { outcome: { outcome: 'selected', optionId: defaultOptionId } }; - }, - }; + } - // Create ClientSideConnection - this.connection = new ClientSideConnection( - (agent: Agent) => client, - stream - ); + // Auto-approve once if no permission handler. + const outcome = pickPermissionOutcome(options as PermissionOptionLike[], 'approved'); + if (outcome.outcome === 'selected') { + this.lastSelectedPermissionOptionIdByToolCallId.set(toolCallId, outcome.optionId); + } else { + this.lastSelectedPermissionOptionIdByToolCallId.delete(toolCallId); + } + return { outcome }; + }, + }; - // Initialize the connection with timeout and retry - const initRequest: InitializeRequest = { - protocolVersion: 1, - clientCapabilities: { - fs: { - readTextFile: false, - writeTextFile: false, - }, - }, - clientInfo: { - name: 'happy-cli', - version: packageJson.version, + // Create ClientSideConnection + this.connection = new ClientSideConnection( + (_agent: Agent) => client, + stream + ); + + // Initialize the connection with timeout and retry + const initRequest: InitializeRequest = { + protocolVersion: 1, + clientCapabilities: { + fs: { + readTextFile: false, + writeTextFile: false, }, + }, + clientInfo: { + name: 'happy-cli', + version: packageJson.version, + }, + }; + + const initTimeout = this.transport.getInitTimeout(); + logger.debug(`[AcpBackend] Initializing connection (timeout: ${initTimeout}ms)...`); + + await withRetry( + async () => { + let timeoutHandle: NodeJS.Timeout | null = null; + try { + const result = await Promise.race([ + this.connection!.initialize(initRequest).then((res) => { + if (timeoutHandle) { + clearTimeout(timeoutHandle); + timeoutHandle = null; + } + return res; + }), + new Promise((_, reject) => { + timeoutHandle = setTimeout(() => { + reject(new Error(`Initialize timeout after ${initTimeout}ms - ${this.transport.agentName} did not respond`)); + }, initTimeout); + }), + ]); + return result; + } finally { + if (timeoutHandle) { + clearTimeout(timeoutHandle); + } + } + }, + { + operationName: 'Initialize', + maxAttempts: RETRY_CONFIG.maxAttempts, + baseDelayMs: RETRY_CONFIG.baseDelayMs, + maxDelayMs: RETRY_CONFIG.maxDelayMs, + } + ); + + logger.debug(`[AcpBackend] Initialize completed`); + return { initTimeout }; + } catch (error) { + logger.debug('[AcpBackend] Initialization failed; cleaning up process/connection', error); + const proc = this.process; + this.process = null; + this.connection = null; + this.acpSessionId = null; + if (proc) { + try { + // On Windows, signals are not reliably supported; `kill()` uses TerminateProcess. + if (process.platform === 'win32') { + proc.kill(); + } else { + proc.kill('SIGTERM'); + } + } catch { + // best-effort cleanup + } + } + throw error; + } + } + + async startSession(initialPrompt?: string): Promise { + if (this.disposed) { + throw new Error('Backend has been disposed'); + } + + this.emit({ type: 'status', status: 'starting' }); + // Reset per-session caches + this.lastSelectedPermissionOptionIdByToolCallId.clear(); + this.toolCallIdToNameMap.clear(); + this.toolCallIdToInputMap.clear(); + + try { + const { initTimeout } = await this.createConnectionAndInitialize({ operationId: randomUUID() }); + + // Create a new session with retry + const newSessionRequest: NewSessionRequest = { + cwd: this.options.cwd, + mcpServers: this.buildAcpMcpServersForSessionRequest(), }; - const initTimeout = this.transport.getInitTimeout(); - logger.debug(`[AcpBackend] Initializing connection (timeout: ${initTimeout}ms)...`); + logger.debug(`[AcpBackend] Creating new session...`); - await withRetry( + const sessionResponse = await withRetry( async () => { let timeoutHandle: NodeJS.Timeout | null = null; try { const result = await Promise.race([ - this.connection!.initialize(initRequest).then((res) => { + this.connection!.newSession(newSessionRequest).then((res) => { if (timeoutHandle) { clearTimeout(timeoutHandle); timeoutHandle = null; @@ -672,7 +677,7 @@ export class AcpBackend implements AgentBackend { }), new Promise((_, reject) => { timeoutHandle = setTimeout(() => { - reject(new Error(`Initialize timeout after ${initTimeout}ms - ${this.transport.agentName} did not respond`)); + reject(new Error(`New session timeout after ${initTimeout}ms - ${this.transport.agentName} did not respond`)); }, initTimeout); }), ]); @@ -684,39 +689,74 @@ export class AcpBackend implements AgentBackend { } }, { - operationName: 'Initialize', + operationName: 'NewSession', maxAttempts: RETRY_CONFIG.maxAttempts, baseDelayMs: RETRY_CONFIG.baseDelayMs, maxDelayMs: RETRY_CONFIG.maxDelayMs, } ); - logger.debug(`[AcpBackend] Initialize completed`); + this.acpSessionId = sessionResponse.sessionId; + const sessionId = sessionResponse.sessionId; + logger.debug(`[AcpBackend] Session created: ${sessionId}`); - // Create a new session with retry - const mcpServers = this.options.mcpServers - ? Object.entries(this.options.mcpServers).map(([name, config]) => ({ - name, - command: config.command, - args: config.args || [], - env: config.env - ? Object.entries(config.env).map(([envName, envValue]) => ({ name: envName, value: envValue })) - : [], - })) - : []; + this.emitIdleStatus(); - const newSessionRequest: NewSessionRequest = { + // Send initial prompt if provided + if (initialPrompt) { + this.sendPrompt(sessionId, initialPrompt).catch((error) => { + // Log to file only, not console + logger.debug('[AcpBackend] Error sending initial prompt:', error); + this.emit({ type: 'status', status: 'error', detail: String(error) }); + }); + } + + return { sessionId }; + + } catch (error) { + // Log to file only, not console + logger.debug('[AcpBackend] Error starting session:', error); + this.emit({ + type: 'status', + status: 'error', + detail: error instanceof Error ? error.message : String(error) + }); + throw error; + } + } + + async loadSession(sessionId: SessionId): Promise { + if (this.disposed) { + throw new Error('Backend has been disposed'); + } + + const normalized = typeof sessionId === 'string' ? sessionId.trim() : ''; + if (!normalized) { + throw new Error('Session ID is required'); + } + + this.emit({ type: 'status', status: 'starting' }); + // Reset per-session caches + this.lastSelectedPermissionOptionIdByToolCallId.clear(); + this.toolCallIdToNameMap.clear(); + this.toolCallIdToInputMap.clear(); + + try { + const { initTimeout } = await this.createConnectionAndInitialize({ operationId: randomUUID() }); + + const loadSessionRequest: LoadSessionRequest = { + sessionId: normalized, cwd: this.options.cwd, - mcpServers: mcpServers as unknown as NewSessionRequest['mcpServers'], + mcpServers: this.buildAcpMcpServersForSessionRequest() as unknown as LoadSessionRequest['mcpServers'], }; - logger.debug(`[AcpBackend] Creating new session...`); + logger.debug(`[AcpBackend] Loading session: ${normalized}`); - const sessionResponse = await withRetry( + await withRetry( async () => { let timeoutHandle: NodeJS.Timeout | null = null; try { const result = await Promise.race([ - this.connection!.newSession(newSessionRequest).then((res) => { + this.connection!.loadSession(loadSessionRequest).then((res) => { if (timeoutHandle) { clearTimeout(timeoutHandle); timeoutHandle = null; @@ -725,7 +765,7 @@ export class AcpBackend implements AgentBackend { }), new Promise((_, reject) => { timeoutHandle = setTimeout(() => { - reject(new Error(`New session timeout after ${initTimeout}ms - ${this.transport.agentName} did not respond`)); + reject(new Error(`Load session timeout after ${initTimeout}ms - ${this.transport.agentName} did not respond`)); }, initTimeout); }), ]); @@ -737,40 +777,40 @@ export class AcpBackend implements AgentBackend { } }, { - operationName: 'NewSession', + operationName: 'LoadSession', maxAttempts: RETRY_CONFIG.maxAttempts, baseDelayMs: RETRY_CONFIG.baseDelayMs, maxDelayMs: RETRY_CONFIG.maxDelayMs, } ); - this.acpSessionId = sessionResponse.sessionId; - logger.debug(`[AcpBackend] Session created: ${this.acpSessionId}`); - this.emitIdleStatus(); - - // Send initial prompt if provided - if (initialPrompt) { - this.sendPrompt(sessionId, initialPrompt).catch((error) => { - // Log to file only, not console - logger.debug('[AcpBackend] Error sending initial prompt:', error); - this.emit({ type: 'status', status: 'error', detail: String(error) }); - }); - } - - return { sessionId }; + this.acpSessionId = normalized; + logger.debug(`[AcpBackend] Session loaded: ${normalized}`); + this.emitIdleStatus(); + return { sessionId: normalized }; } catch (error) { - // Log to file only, not console - logger.debug('[AcpBackend] Error starting session:', error); - this.emit({ - type: 'status', - status: 'error', - detail: error instanceof Error ? error.message : String(error) + logger.debug('[AcpBackend] Error loading session:', error); + this.emit({ + type: 'status', + status: 'error', + detail: error instanceof Error ? error.message : String(error) }); throw error; } } + async loadSessionWithReplayCapture(sessionId: SessionId): Promise { + this.replayCapture = new AcpReplayCapture(); + try { + const result = await this.loadSession(sessionId); + const replay = this.replayCapture.finalize(); + return { ...result, replay }; + } finally { + this.replayCapture = null; + } + } + /** * Create handler context for session update processing */ @@ -781,6 +821,7 @@ export class AcpBackend implements AgentBackend { toolCallStartTimes: this.toolCallStartTimes, toolCallTimeouts: this.toolCallTimeouts, toolCallIdToNameMap: this.toolCallIdToNameMap, + toolCallIdToInputMap: this.toolCallIdToInputMap, idleTimeout: this.idleTimeout, toolCallCountSincePrompt: this.toolCallCountSincePrompt, emit: (msg) => this.emit(msg), @@ -801,15 +842,67 @@ export class AcpBackend implements AgentBackend { } private handleSessionUpdate(params: SessionNotification): void { - const notification = params as ExtendedSessionNotification; - const update = notification.update; + const raw = params as unknown as Record; + const update = ( + (raw as any).update + ?? (Array.isArray((raw as any).updates) ? (raw as any).updates[0] : undefined) + ) as SessionUpdate | undefined; if (!update) { logger.debug('[AcpBackend] Received session update without update field:', params); return; } - const sessionUpdateType = update.sessionUpdate; + const sessionUpdateType = (update as any).sessionUpdate as string | undefined; + + const isGeminiAcpDebugEnabled = (() => { + const stacks = process.env.HAPPY_STACKS_GEMINI_ACP_DEBUG; + const local = process.env.HAPPY_LOCAL_GEMINI_ACP_DEBUG; + return stacks === '1' || local === '1' || stacks === 'true' || local === 'true'; + })(); + + const sanitizeForLogs = (value: unknown, depth = 0): unknown => { + if (depth > 4) return '[truncated depth]'; + if (typeof value === 'string') { + const max = 400; + if (value.length <= max) return value; + return `${value.slice(0, max)}… [truncated ${value.length - max} chars]`; + } + if (Array.isArray(value)) { + if (value.length > 50) { + return [...value.slice(0, 50).map((v) => sanitizeForLogs(v, depth + 1)), `… [truncated ${value.length - 50} items]`]; + } + return value.map((v) => sanitizeForLogs(v, depth + 1)); + } + if (value && typeof value === 'object') { + const obj = value as Record; + const out: Record = {}; + for (const [k, v] of Object.entries(obj)) { + out[k] = sanitizeForLogs(v, depth + 1); + } + return out; + } + return value; + }; + + if (this.replayCapture) { + try { + this.replayCapture.handleUpdate(update as SessionUpdate); + } catch (error) { + logger.debug('[AcpBackend] Replay capture failed (non-fatal)', { error }); + } + + // Suppress transcript-affecting updates during loadSession replay. + const suppress = sessionUpdateType === 'user_message_chunk' + || sessionUpdateType === 'agent_message_chunk' + || sessionUpdateType === 'agent_thought_chunk' + || sessionUpdateType === 'tool_call' + || sessionUpdateType === 'tool_call_update' + || sessionUpdateType === 'plan'; + if (suppress) { + return; + } + } // Log session updates for debugging (but not every chunk to avoid log spam) if (sessionUpdateType !== 'agent_message_chunk') { @@ -823,6 +916,18 @@ export class AcpBackend implements AgentBackend { }, null, 2)); } + // Gemini ACP deep debug: dump raw terminal tool updates to verify where tool outputs live. + if ( + isGeminiAcpDebugEnabled && + this.transport.agentName === 'gemini' && + (sessionUpdateType === 'tool_call_update' || sessionUpdateType === 'tool_call') && + (update.status === 'completed' || update.status === 'failed' || update.status === 'cancelled') + ) { + const keys = Object.keys(update as any); + logger.debug('[AcpBackend] [GeminiACP] Terminal tool update keys:', keys); + logger.debug('[AcpBackend] [GeminiACP] Terminal tool update payload:', JSON.stringify(sanitizeForLogs(update), null, 2)); + } + const ctx = this.createHandlerContext(); // Dispatch to appropriate handler based on update type @@ -831,6 +936,11 @@ export class AcpBackend implements AgentBackend { return; } + if (sessionUpdateType === 'user_message_chunk') { + handleUserMessageChunk(update as SessionUpdate, ctx); + return; + } + if (sessionUpdateType === 'tool_call_update') { const result = handleToolCallUpdate(update as SessionUpdate, ctx); if (result.toolCallCountSincePrompt !== undefined) { @@ -849,6 +959,21 @@ export class AcpBackend implements AgentBackend { return; } + if (sessionUpdateType === 'available_commands_update') { + handleAvailableCommandsUpdate(update as SessionUpdate, ctx); + return; + } + + if (sessionUpdateType === 'current_mode_update') { + handleCurrentModeUpdate(update as SessionUpdate, ctx); + return; + } + + if (sessionUpdateType === 'plan') { + handlePlanUpdate(update as SessionUpdate, ctx); + return; + } + // Handle legacy and auxiliary update types handleLegacyMessageChunk(update as SessionUpdate, ctx); handlePlanUpdate(update as SessionUpdate, ctx); @@ -857,12 +982,25 @@ export class AcpBackend implements AgentBackend { // Log unhandled session update types for debugging // Cast to string to avoid TypeScript errors (SDK types don't include all Gemini-specific update types) const updateTypeStr = sessionUpdateType as string; - const handledTypes = ['agent_message_chunk', 'tool_call_update', 'agent_thought_chunk', 'tool_call']; + const handledTypes = [ + 'agent_message_chunk', + 'user_message_chunk', + 'tool_call_update', + 'agent_thought_chunk', + 'tool_call', + 'available_commands_update', + 'current_mode_update', + 'plan', + ]; + const updateAny = update as any; if (updateTypeStr && !handledTypes.includes(updateTypeStr) && - !update.messageChunk && - !update.plan && - !update.thinking) { + !updateAny.messageChunk && + !updateAny.plan && + !updateAny.thinking && + !updateAny.availableCommands && + !updateAny.currentModeId && + !updateAny.entries) { logger.debug(`[AcpBackend] Unhandled session update type: ${updateTypeStr}`, JSON.stringify(update, null, 2)); } } @@ -1078,5 +1216,9 @@ export class AcpBackend implements AgentBackend { this.toolCallTimeouts.clear(); this.toolCallStartTimes.clear(); this.pendingPermissions.clear(); + this.permissionToToolCallMap.clear(); + this.toolCallIdToNameMap.clear(); + this.toolCallIdToInputMap.clear(); + this.lastSelectedPermissionOptionIdByToolCallId.clear(); } } diff --git a/cli/src/agent/acp/bridge/acpCommonHandlers.test.ts b/cli/src/agent/acp/bridge/acpCommonHandlers.test.ts new file mode 100644 index 000000000..551059039 --- /dev/null +++ b/cli/src/agent/acp/bridge/acpCommonHandlers.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it, vi } from 'vitest'; +import { forwardAcpPermissionRequest } from './acpCommonHandlers'; + +describe('forwardAcpPermissionRequest', () => { + it('copies toolCall into options.input when input is empty', () => { + const sendAgentMessage = vi.fn(); + const session = { sendAgentMessage } as any; + + const msg = { + type: 'permission-request', + id: 'write_file-1', + reason: 'write', + payload: { + toolName: 'write', + input: {}, + toolCall: { + kind: 'edit', + title: 'Writing to .tmp/happy-tool-ux.txt', + locations: [{ path: '/tmp/happy-tool-ux.txt' }], + content: [{ type: 'diff', path: 'happy-tool-ux.txt', oldText: 'a', newText: 'b' }], + status: 'pending', + toolCallId: 'write_file-1', + }, + }, + } as any; + + forwardAcpPermissionRequest({ msg, session, agent: 'gemini' as any }); + + expect(sendAgentMessage).toHaveBeenCalledTimes(1); + const [, message] = sendAgentMessage.mock.calls[0]; + expect(message).toMatchObject({ + type: 'permission-request', + permissionId: 'write_file-1', + toolName: 'write', + options: { + input: { + kind: 'edit', + toolCallId: 'write_file-1', + }, + }, + }); + }); + + it('copies toolCall into options.options.input when nested input is empty', () => { + const sendAgentMessage = vi.fn(); + const session = { sendAgentMessage } as any; + + const msg = { + type: 'permission-request', + id: 'edit_file-1', + reason: 'edit', + payload: { + toolName: 'edit', + options: { + input: {}, + toolCall: { + kind: 'edit', + title: '.tmp/happy-tool-ux.txt: b => beta', + rawInput: { + path: '/tmp/happy-tool-ux.txt', + oldText: 'b', + newText: 'beta', + }, + }, + }, + }, + } as any; + + forwardAcpPermissionRequest({ msg, session, agent: 'gemini' as any }); + + expect(sendAgentMessage).toHaveBeenCalledTimes(1); + const [, message] = sendAgentMessage.mock.calls[0]; + expect(message).toMatchObject({ + type: 'permission-request', + permissionId: 'edit_file-1', + toolName: 'edit', + options: { + options: { + input: { + path: '/tmp/happy-tool-ux.txt', + }, + }, + }, + }); + }); + + it('preserves non-empty input', () => { + const sendAgentMessage = vi.fn(); + const session = { sendAgentMessage } as any; + + const msg = { + type: 'permission-request', + id: 'read_file-1', + reason: 'read', + payload: { + toolName: 'read', + input: { locations: [{ path: '/tmp/x' }] }, + toolCall: { kind: 'read', title: 'ignored' }, + }, + } as any; + + forwardAcpPermissionRequest({ msg, session, agent: 'gemini' as any }); + + const [, message] = sendAgentMessage.mock.calls[0]; + expect((message as any).options.input).toEqual({ locations: [{ path: '/tmp/x' }] }); + }); +}); diff --git a/cli/src/agent/acp/bridge/acpCommonHandlers.ts b/cli/src/agent/acp/bridge/acpCommonHandlers.ts new file mode 100644 index 000000000..60f227d8f --- /dev/null +++ b/cli/src/agent/acp/bridge/acpCommonHandlers.ts @@ -0,0 +1,121 @@ +import type { AgentMessage } from '@/agent/core'; +import type { MessageBuffer } from '@/ui/ink/messageBuffer'; +import type { ApiSessionClient } from '@/api/apiSession'; + +type AgentKey = Parameters[0]; +type AgentPayload = Parameters[1]; +type SessionWithKeepAlive = Pick; +type SessionWithSendOnly = Pick; + +export function handleAcpModelOutputDelta(params: { + delta: string; + messageBuffer: MessageBuffer; + getIsResponseInProgress: () => boolean; + setIsResponseInProgress: (value: boolean) => void; + appendToAccumulatedResponse: (delta: string) => void; +}): void { + const delta = params.delta ?? ''; + if (!delta) return; + + if (!params.getIsResponseInProgress()) { + params.messageBuffer.removeLastMessage('system'); + params.messageBuffer.addMessage(delta, 'assistant'); + params.setIsResponseInProgress(true); + } else { + params.messageBuffer.updateLastMessage(delta, 'assistant'); + } + + params.appendToAccumulatedResponse(delta); +} + +export function handleAcpStatusRunning(params: { + session: SessionWithKeepAlive; + agent: AgentKey; + messageBuffer: MessageBuffer; + onThinkingChange: (thinking: boolean) => void; + getTaskStartedSent: () => boolean; + setTaskStartedSent: (value: boolean) => void; + makeId: () => string; +}): void { + params.onThinkingChange(true); + params.session.keepAlive(true, 'remote'); + + if (!params.getTaskStartedSent()) { + const payload: AgentPayload = { type: 'task_started', id: params.makeId() }; + params.session.sendAgentMessage(params.agent, payload); + params.setTaskStartedSent(true); + } + + params.messageBuffer.addMessage('Thinking...', 'system'); +} + +export function forwardAcpPermissionRequest(params: { + msg: AgentMessage; + session: SessionWithSendOnly; + agent: AgentKey; +}): void { + if (params.msg.type !== 'permission-request') return; + const payload = (params.msg as any).payload || {}; + + const hasMeaningfulInput = (input: unknown): boolean => { + if (Array.isArray(input)) return input.length > 0; + if (!input || typeof input !== 'object') return false; + return Object.keys(input as Record).length > 0; + }; + + const backfillInputFromToolCall = (container: unknown): unknown => { + if (!container || typeof container !== 'object') return container; + if (Array.isArray(container)) return container; + + const record = container as Record; + const input = record.input; + if (hasMeaningfulInput(input)) return container; + + const toolCall = record.toolCall; + if (!toolCall || typeof toolCall !== 'object' || Array.isArray(toolCall)) return container; + + const toolCallRecord = toolCall as Record; + const toolCallRawInput = toolCallRecord.rawInput; + const backfilledInput = hasMeaningfulInput(toolCallRawInput) ? toolCallRawInput : toolCall; + + return { ...record, input: backfilledInput }; + }; + + const normalizedPayload = (() => { + const topLevel = backfillInputFromToolCall(payload); + if (!topLevel || typeof topLevel !== 'object' || Array.isArray(topLevel)) return topLevel; + const record = topLevel as Record; + const maybeOptions = record.options; + const nextOptions = backfillInputFromToolCall(maybeOptions); + if (nextOptions === maybeOptions) return topLevel; + return { ...record, options: nextOptions }; + })(); + + const message: AgentPayload = { + type: 'permission-request', + permissionId: (params.msg as any).id, + toolName: payload.toolName || (params.msg as any).reason || 'unknown', + description: (params.msg as any).reason || payload.toolName || '', + options: normalizedPayload, + }; + + params.session.sendAgentMessage(params.agent, message); +} + +export function forwardAcpTerminalOutput(params: { + msg: AgentMessage; + messageBuffer: MessageBuffer; + session: SessionWithSendOnly; + agent: AgentKey; + getCallId: (msg: AgentMessage) => string; +}): void { + if (params.msg.type !== 'terminal-output') return; + const data = (params.msg as any).data as string; + params.messageBuffer.addMessage(data, 'result'); + const message: AgentPayload = { + type: 'terminal-output', + data, + callId: params.getCallId(params.msg), + }; + params.session.sendAgentMessage(params.agent, message); +} diff --git a/cli/src/agent/acp/commands/publishSlashCommands.ts b/cli/src/agent/acp/commands/publishSlashCommands.ts new file mode 100644 index 000000000..3f76dc893 --- /dev/null +++ b/cli/src/agent/acp/commands/publishSlashCommands.ts @@ -0,0 +1,61 @@ +import type { ApiSessionClient } from '@/api/apiSession'; +import { logger } from '@/ui/logger'; + +export type SlashCommandDetail = { + command: string; + description?: string; +}; + +function normalizeCommandName(name: unknown): string | null { + if (typeof name !== 'string') return null; + const trimmed = name.trim(); + if (!trimmed) return null; + return trimmed.startsWith('/') ? trimmed.slice(1) : trimmed; +} + +export function normalizeAvailableCommands(input: unknown): SlashCommandDetail[] { + if (!Array.isArray(input)) return []; + const details: SlashCommandDetail[] = []; + const seen = new Set(); + + for (const item of input) { + if (!item || typeof item !== 'object') continue; + const obj = item as Record; + const command = normalizeCommandName(obj.name); + if (!command) continue; + if (seen.has(command)) continue; + seen.add(command); + const description = typeof obj.description === 'string' ? obj.description.trim() : undefined; + details.push({ command, ...(description ? { description } : {}) }); + } + + details.sort((a, b) => a.command.localeCompare(b.command)); + return details; +} + +export function publishSlashCommandsToMetadata(params: { + session: ApiSessionClient; + details: SlashCommandDetail[]; +}): void { + const { session, details } = params; + const names = details.map((d) => d.command); + + try { + session.updateMetadata((metadata: any) => { + const prevNames = Array.isArray(metadata?.slashCommands) ? metadata.slashCommands : []; + const prevDetails = Array.isArray(metadata?.slashCommandDetails) ? metadata.slashCommandDetails : []; + const sameNames = JSON.stringify(prevNames) === JSON.stringify(names); + const sameDetails = JSON.stringify(prevDetails) === JSON.stringify(details); + if (sameNames && sameDetails) return metadata; + + return { + ...metadata, + slashCommands: names, + slashCommandDetails: details, + }; + }); + } catch (error) { + logger.debug('[ACP] Failed to publish slash commands to metadata (non-fatal)', { error }); + } +} + diff --git a/cli/src/agent/acp/createAcpBackend.ts b/cli/src/agent/acp/createAcpBackend.ts index a8b000076..cc794298f 100644 --- a/cli/src/agent/acp/createAcpBackend.ts +++ b/cli/src/agent/acp/createAcpBackend.ts @@ -5,7 +5,7 @@ * Use this when you need to create a generic ACP backend without agent-specific * configuration (timeouts, filtering, etc.). * - * For agent-specific backends, use the factories in src/agent/factories/: + * For agent-specific backends, use the agent ACP backends in: * - createGeminiBackend() - Gemini CLI with GeminiTransport * - createCodexBackend() - Codex CLI with CodexTransport * - createClaudeBackend() - Claude CLI with ClaudeTransport @@ -54,7 +54,7 @@ export interface CreateAcpBackendOptions { * * ```typescript * // Prefer this: - * import { createGeminiBackend } from '@/agent/factories'; + * import { createGeminiBackend } from '@/backends/gemini/acp/backend'; * const backend = createGeminiBackend({ cwd: '/path/to/project' }); * * // Over this: diff --git a/cli/src/agent/acp/createCatalogAcpBackend.ts b/cli/src/agent/acp/createCatalogAcpBackend.ts new file mode 100644 index 000000000..2e6eb3983 --- /dev/null +++ b/cli/src/agent/acp/createCatalogAcpBackend.ts @@ -0,0 +1,33 @@ +import type { AgentBackend } from '@/agent/core'; +import { AGENTS, type CatalogAgentId } from '@/backends/catalog'; +import type { CatalogAcpBackendCreateResult, CatalogAcpBackendFactory } from '@/backends/types'; + +const cachedFactoryPromises = new Map>(); + +async function loadCatalogAcpFactory(agentId: CatalogAgentId): Promise { + const entry = AGENTS[agentId]; + if (!entry.getAcpBackendFactory) { + throw new Error(`Agent '${agentId}' does not support ACP backends`); + } + return await entry.getAcpBackendFactory(); +} + +async function getCatalogAcpFactory(agentId: CatalogAgentId): Promise { + const existing = cachedFactoryPromises.get(agentId); + if (existing) return await existing; + + const promise = loadCatalogAcpFactory(agentId); + cachedFactoryPromises.set(agentId, promise); + return await promise; +} + +export async function createCatalogAcpBackend< + TOptions, + TResult extends CatalogAcpBackendCreateResult = CatalogAcpBackendCreateResult, +>( + agentId: CatalogAgentId, + opts: TOptions, +): Promise { + const factory = await getCatalogAcpFactory(agentId); + return factory(opts as unknown) as TResult; +} diff --git a/cli/src/agent/acp/history/acpReplayCapture.ts b/cli/src/agent/acp/history/acpReplayCapture.ts new file mode 100644 index 000000000..5329e4cda --- /dev/null +++ b/cli/src/agent/acp/history/acpReplayCapture.ts @@ -0,0 +1,99 @@ +import type { SessionUpdate } from '../sessionUpdateHandlers'; +import { extractTextFromContentBlock } from '../sessionUpdateHandlers'; + +export type AcpReplayEvent = + | { type: 'message'; role: 'user' | 'agent'; text: string } + | { + type: 'tool_call'; + toolCallId: string; + title?: string; + kind?: string; + rawInput?: unknown; + } + | { + type: 'tool_result'; + toolCallId: string; + status?: string; + rawOutput?: unknown; + content?: unknown; + }; + +export class AcpReplayCapture { + private currentRole: 'user' | 'agent' | null = null; + private currentText = ''; + private events: AcpReplayEvent[] = []; + + private flushMessage(): void { + if (!this.currentRole) return; + const role = this.currentRole; + const text = this.currentText; + this.currentRole = null; + this.currentText = ''; + if (text.trim().length === 0) return; + this.events.push({ type: 'message', role, text }); + } + + private pushMessage(role: 'user' | 'agent', textDelta: string): void { + if (this.currentRole && this.currentRole !== role) { + this.flushMessage(); + } + if (!this.currentRole) { + this.currentRole = role; + this.currentText = ''; + } + this.currentText += textDelta; + } + + handleUpdate(update: SessionUpdate): void { + const kind = String(update.sessionUpdate || ''); + if (kind === 'user_message_chunk') { + const text = extractTextFromContentBlock(update.content); + if (text) this.pushMessage('user', text); + return; + } + if (kind === 'agent_message_chunk') { + const text = extractTextFromContentBlock(update.content); + if (text) this.pushMessage('agent', text); + return; + } + + if (kind === 'tool_call') { + this.flushMessage(); + const toolCallId = typeof update.toolCallId === 'string' ? update.toolCallId : ''; + if (!toolCallId) return; + const title = typeof (update as any).title === 'string' ? (update as any).title : undefined; + const toolKind = typeof (update as any).kind === 'string' ? (update as any).kind : undefined; + const rawInput = (update as any).rawInput; + this.events.push({ + type: 'tool_call', + toolCallId, + title, + kind: toolKind, + rawInput, + }); + return; + } + + if (kind === 'tool_call_update') { + const toolCallId = typeof update.toolCallId === 'string' ? update.toolCallId : ''; + if (!toolCallId) return; + const status = typeof (update as any).status === 'string' ? (update as any).status : undefined; + const rawOutput = (update as any).rawOutput; + const content = (update as any).content; + // Only record results when status indicates completion/error or when rawOutput is present. + if (status && (status === 'completed' || status === 'error' || status === 'failed' || status === 'cancelled')) { + this.flushMessage(); + this.events.push({ type: 'tool_result', toolCallId, status, rawOutput, content }); + } else if (rawOutput !== undefined) { + this.flushMessage(); + this.events.push({ type: 'tool_result', toolCallId, status, rawOutput, content }); + } + return; + } + } + + finalize(): AcpReplayEvent[] { + this.flushMessage(); + return this.events.slice(); + } +} diff --git a/cli/src/agent/acp/history/importAcpReplayHistory.ts b/cli/src/agent/acp/history/importAcpReplayHistory.ts new file mode 100644 index 000000000..ac33f4f92 --- /dev/null +++ b/cli/src/agent/acp/history/importAcpReplayHistory.ts @@ -0,0 +1,289 @@ +import { createHash } from 'node:crypto'; + +import type { ACPProvider, ApiSessionClient } from '@/api/apiSession'; +import type { AcpPermissionHandler } from '@/agent/acp/AcpBackend'; +import type { AcpReplayEvent } from './acpReplayCapture'; +import { logger } from '@/ui/logger'; + +type TranscriptTextItem = { role: 'user' | 'agent'; text: string }; + +function normalizeTextForMatch(text: string): string { + return text.replace(/\r\n/g, '\n').replace(/\s+/g, ' ').trim(); +} + +function fingerprintItem(item: TranscriptTextItem): string { + return `${item.role}:${normalizeTextForMatch(item.text)}`; +} + +function sha256(input: string): string { + return createHash('sha256').update(input).digest('hex'); +} + +function computeBestTailOverlap(existing: TranscriptTextItem[], replay: TranscriptTextItem[]): { + ok: true; + replayStartIndex: number; + matchedCount: number; +} | { + ok: false; + reason: 'no_overlap' | 'ambiguous_overlap'; +} { + if (existing.length === 0) { + return { ok: true, replayStartIndex: 0, matchedCount: 0 }; + } + + const existingFp = existing.map(fingerprintItem); + const replayFp = replay.map(fingerprintItem); + + const maxK = Math.min(30, existingFp.length, replayFp.length); + const minRequired = Math.min(3, existingFp.length); + + for (let k = maxK; k >= 1; k--) { + const needle = existingFp.slice(-k); + const matches: number[] = []; + for (let i = 0; i <= replayFp.length - k; i++) { + let ok = true; + for (let j = 0; j < k; j++) { + if (replayFp[i + j] !== needle[j]) { + ok = false; + break; + } + } + if (ok) matches.push(i); + } + + if (matches.length === 0) continue; + if (matches.length > 1) { + return { ok: false, reason: 'ambiguous_overlap' }; + } + + if (k < minRequired) { + return { ok: false, reason: 'no_overlap' }; + } + + const startIndex = matches[0] + k; + return { ok: true, replayStartIndex: startIndex, matchedCount: k }; + } + + return { ok: false, reason: 'no_overlap' }; +} + +function extractReplayTextItems(replay: AcpReplayEvent[]): { + messages: TranscriptTextItem[]; + hasToolEvents: boolean; +} { + const messages: TranscriptTextItem[] = []; + let hasToolEvents = false; + for (const event of replay) { + if (event.type === 'message') { + messages.push({ role: event.role, text: event.text }); + } else if (event.type === 'tool_call' || event.type === 'tool_result') { + hasToolEvents = true; + } + } + return { messages, hasToolEvents }; +} + +function makeImportLocalId(params: { provider: string; remoteSessionId: string; index: number; role: string; text: string }): string { + const textHash = sha256(`${params.role}:${normalizeTextForMatch(params.text)}`).slice(0, 12); + return `acp-import:v1:${params.provider}:${params.remoteSessionId}:${params.index}:${textHash}`; +} + +function makeImportEventLocalId(params: { provider: string; remoteSessionId: string; index: number; key: string }): string { + const short = sha256(params.key).slice(0, 12); + return `acp-import:v1:${params.provider}:${params.remoteSessionId}:e${params.index}:${short}`; +} + +export async function importAcpReplayHistoryV1(params: { + session: ApiSessionClient; + provider: ACPProvider; + remoteSessionId: string; + replay: AcpReplayEvent[]; + permissionHandler: AcpPermissionHandler; +}): Promise { + const { messages: replayMessages } = extractReplayTextItems(params.replay); + if (replayMessages.length === 0) return; + + const existing = await params.session.fetchRecentTranscriptTextItemsForAcpImport({ take: 150 }); + const overlap = computeBestTailOverlap(existing, replayMessages); + + if (!overlap.ok) { + // Divergence: prompt user, do nothing automatically. + const remoteHash = sha256(replayMessages.map(fingerprintItem).join('|')).slice(0, 12); + const permissionId = `AcpHistoryImport:v1:${params.provider}:${params.remoteSessionId}:${remoteHash}`; + + const localTail = existing.slice(-3).map((m) => ({ role: m.role, text: normalizeTextForMatch(m.text).slice(0, 200) })); + const remoteTail = replayMessages.slice(-3).map((m) => ({ role: m.role, text: normalizeTextForMatch(m.text).slice(0, 200) })); + + logger.debug('[ACP History] Divergence detected; prompting user', { + provider: params.provider, + remoteSessionId: params.remoteSessionId, + overlapReason: overlap.reason, + localCount: existing.length, + remoteCount: replayMessages.length, + }); + + // Use the standard permission flow so UI can render it as a tool card. + const decisionPromise = params.permissionHandler.handleToolCall(permissionId, 'AcpHistoryImport', { + provider: params.provider, + remoteSessionId: params.remoteSessionId, + localCount: existing.length, + remoteCount: replayMessages.length, + localTail, + remoteTail, + reason: overlap.reason, + note: 'History differs from this session. Importing may duplicate messages.', + }); + + void decisionPromise.then(async (decision) => { + if (decision.decision !== 'approved' && decision.decision !== 'approved_for_session' && decision.decision !== 'approved_execpolicy_amendment') { + logger.debug('[ACP History] User skipped divergent history import', { provider: params.provider }); + return; + } + + logger.debug('[ACP History] User approved divergent history import; importing full remote history', { provider: params.provider }); + await importFullReplay(params, params.replay); + }).catch((error) => { + logger.debug('[ACP History] Divergent history import prompt failed', { error }); + }); + + return; + } + + const startIndex = overlap.replayStartIndex; + if (startIndex >= replayMessages.length) { + return; + } + + const newMessages = replayMessages.slice(startIndex); + if (newMessages.length === 0) return; + + logger.debug('[ACP History] Importing new replay messages', { + provider: params.provider, + remoteSessionId: params.remoteSessionId, + newCount: newMessages.length, + matchedCount: overlap.matchedCount, + }); + + await importMessageDeltas(params, replayMessages, startIndex); +} + +async function importMessageDeltas( + params: { + session: ApiSessionClient; + provider: ACPProvider; + remoteSessionId: string; + }, + replayMessages: TranscriptTextItem[], + startIndex: number, +): Promise { + for (let i = startIndex; i < replayMessages.length; i++) { + const msg = replayMessages[i]; + const localId = makeImportLocalId({ + provider: params.provider, + remoteSessionId: params.remoteSessionId, + index: i, + role: msg.role, + text: msg.text, + }); + + if (msg.role === 'user') { + params.session.sendUserTextMessage(msg.text, { localId, meta: { importedFrom: 'acp-history' } }); + } else { + params.session.sendAgentMessage( + params.provider, + { type: 'message', message: msg.text }, + { localId, meta: { importedFrom: 'acp-history', remoteSessionId: params.remoteSessionId } }, + ); + } + } + + // Best-effort metadata watermark; failure is non-fatal. + try { + const last = replayMessages[replayMessages.length - 1]; + params.session.updateMetadata((m: any) => ({ + ...m, + acpHistoryImportV1: { + v: 1, + provider: params.provider, + remoteSessionId: params.remoteSessionId, + importedAt: Date.now(), + lastImportedFingerprint: sha256(fingerprintItem(last)).slice(0, 16), + }, + })); + } catch (error) { + logger.debug('[ACP History] Failed to update import watermark (non-fatal)', { error }); + } +} + +async function importFullReplay( + params: { + session: ApiSessionClient; + provider: ACPProvider; + remoteSessionId: string; + }, + replay: AcpReplayEvent[], +): Promise { + for (let i = 0; i < replay.length; i++) { + const event = replay[i]; + if (event.type === 'message') { + const localId = makeImportEventLocalId({ + provider: params.provider, + remoteSessionId: params.remoteSessionId, + index: i, + key: `${event.role}:${event.text}`, + }); + if (event.role === 'user') { + params.session.sendUserTextMessage(event.text, { localId, meta: { importedFrom: 'acp-history' } }); + } else { + params.session.sendAgentMessage( + params.provider, + { type: 'message', message: event.text }, + { localId, meta: { importedFrom: 'acp-history', remoteSessionId: params.remoteSessionId } }, + ); + } + continue; + } + + if (event.type === 'tool_call') { + const localId = makeImportEventLocalId({ + provider: params.provider, + remoteSessionId: params.remoteSessionId, + index: i, + key: `tool_call:${event.toolCallId}:${event.kind ?? ''}:${JSON.stringify(event.rawInput ?? null)}`, + }); + params.session.sendAgentMessage( + params.provider, + { + type: 'tool-call', + callId: event.toolCallId, + name: event.kind ?? event.title ?? 'tool', + input: event.rawInput ?? {}, + id: `import-${event.toolCallId}`, + }, + { localId, meta: { importedFrom: 'acp-history', remoteSessionId: params.remoteSessionId } }, + ); + continue; + } + + if (event.type === 'tool_result') { + const localId = makeImportEventLocalId({ + provider: params.provider, + remoteSessionId: params.remoteSessionId, + index: i, + key: `tool_result:${event.toolCallId}:${event.status ?? ''}:${JSON.stringify(event.rawOutput ?? event.content ?? null)}`, + }); + const isError = event.status === 'error' || event.status === 'failed'; + params.session.sendAgentMessage( + params.provider, + { + type: 'tool-result', + callId: event.toolCallId, + output: event.rawOutput ?? event.content ?? null, + id: `import-${event.toolCallId}-result`, + isError, + }, + { localId, meta: { importedFrom: 'acp-history', remoteSessionId: params.remoteSessionId } }, + ); + } + } +} diff --git a/cli/src/agent/acp/index.ts b/cli/src/agent/acp/index.ts index a921f7b60..dbf1158d6 100644 --- a/cli/src/agent/acp/index.ts +++ b/cli/src/agent/acp/index.ts @@ -6,7 +6,7 @@ * * Uses the official @agentclientprotocol/sdk from Zed Industries. * - * For agent-specific backends, use the factories in src/agent/factories/. + * For agent-specific backends, use the provider ACP backends (e.g. `@/backends/gemini/acp/backend`). */ // Core ACP backend @@ -31,11 +31,12 @@ export { handlePlanUpdate, handleThinkingUpdate, } from './sessionUpdateHandlers'; - // Factory helper for generic ACP backends export { createAcpBackend, type CreateAcpBackendOptions } from './createAcpBackend'; +// Catalog-driven ACP backend creation +export * from './createCatalogAcpBackend'; + // Legacy aliases for backwards compatibility export { AcpBackend as AcpSdkBackend } from './AcpBackend'; export type { AcpBackendOptions as AcpSdkBackendOptions } from './AcpBackend'; - diff --git a/cli/src/agent/acp/nodeToWebStreams.test.ts b/cli/src/agent/acp/nodeToWebStreams.test.ts new file mode 100644 index 000000000..37db6b577 --- /dev/null +++ b/cli/src/agent/acp/nodeToWebStreams.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest'; +import { EventEmitter } from 'node:events'; +import { Readable } from 'node:stream'; +import { nodeToWebStreams } from './nodeToWebStreams'; + +class FakeStdin extends EventEmitter { + writeImpl: (chunk: Uint8Array, cb: (err?: Error | null) => void) => boolean; + + constructor(writeImpl: (chunk: Uint8Array, cb: (err?: Error | null) => void) => boolean) { + super(); + this.writeImpl = writeImpl; + } + + write(chunk: Uint8Array, cb: (err?: Error | null) => void): boolean { + return this.writeImpl(chunk, cb); + } + + end(cb?: () => void) { + cb?.(); + } + + destroy(_reason?: unknown) { } +} + +describe('nodeToWebStreams', () => { + it('rejects when stdin write callback reports an error even if write() returned true', async () => { + const stdin = new FakeStdin((_chunk, cb) => { + queueMicrotask(() => cb(new Error('boom'))); + return true; + }); + const stdout = new Readable({ read() { } }); + + const { writable } = nodeToWebStreams(stdin as any, stdout); + const writer = writable.getWriter(); + await expect(writer.write(new Uint8Array([1, 2, 3]))).rejects.toThrow('boom'); + writer.releaseLock(); + }); + + it('waits for drain when stdin backpressures', async () => { + let capturedCb: ((err?: Error | null) => void) | null = null; + const stdin = new FakeStdin((_chunk, cb) => { + capturedCb = cb; + return false; + }); + const stdout = new Readable({ read() { } }); + + const { writable } = nodeToWebStreams(stdin as any, stdout); + const writer = writable.getWriter(); + const promise = writer.write(new Uint8Array([1])); + + // Simulate successful write completion, but keep backpressure until drain fires. + queueMicrotask(() => capturedCb?.(null)); + queueMicrotask(() => stdin.emit('drain')); + + await expect(promise).resolves.toBeUndefined(); + writer.releaseLock(); + }); + + it('does not hang if drain fires synchronously during write', async () => { + let stdin: FakeStdin | null = null; + stdin = new FakeStdin((_chunk, cb) => { + stdin?.emit('drain'); + queueMicrotask(() => cb(null)); + return false; + }); + + const stdout = new Readable({ read() { } }); + + const { writable } = nodeToWebStreams(stdin as any, stdout); + const writer = writable.getWriter(); + const promise = writer.write(new Uint8Array([1])); + + await expect( + Promise.race([ + promise, + new Promise((_, reject) => setTimeout(() => reject(new Error('write() hung waiting for drain')), 50)), + ]), + ).resolves.toBeUndefined(); + + writer.releaseLock(); + }); +}); diff --git a/cli/src/agent/acp/nodeToWebStreams.ts b/cli/src/agent/acp/nodeToWebStreams.ts new file mode 100644 index 000000000..d304e1a7a --- /dev/null +++ b/cli/src/agent/acp/nodeToWebStreams.ts @@ -0,0 +1,95 @@ +import type { Readable, Writable } from 'node:stream'; +import { logger } from '@/ui/logger'; + +/** + * Convert Node.js streams to Web Streams for ACP SDK. + */ +export function nodeToWebStreams( + stdin: Writable, + stdout: Readable, +): { writable: WritableStream; readable: ReadableStream } { + const writable = new WritableStream({ + write(chunk) { + return new Promise((resolve, reject) => { + let drained = false; + let wrote = false; + let settled = false; + + const onDrain = () => { + drained = true; + if (!wrote) return; + if (settled) return; + settled = true; + stdin.off('drain', onDrain); + resolve(); + }; + + // Register the drain handler up-front to avoid missing a synchronous `drain` emission + // from custom Writable implementations (or odd edge cases). + stdin.once('drain', onDrain); + + const ok = stdin.write(chunk, (err) => { + wrote = true; + if (err) { + logger.debug(`[nodeToWebStreams] Error writing to stdin:`, err); + if (!settled) { + settled = true; + stdin.off('drain', onDrain); + reject(err); + } + return; + } + + if (ok) { + if (!settled) { + settled = true; + stdin.off('drain', onDrain); + resolve(); + } + return; + } + + if (drained && !settled) { + settled = true; + stdin.off('drain', onDrain); + resolve(); + } + }); + + drained = drained || ok; + if (ok) { + // No drain will be emitted for this write; remove the listener immediately. + stdin.off('drain', onDrain); + } + }); + }, + close() { + return new Promise((resolve) => { + stdin.end(resolve); + }); + }, + abort(reason) { + stdin.destroy(reason instanceof Error ? reason : new Error(String(reason))); + }, + }); + + const readable = new ReadableStream({ + start(controller) { + stdout.on('data', (chunk: Buffer) => { + controller.enqueue(new Uint8Array(chunk)); + }); + stdout.on('end', () => { + controller.close(); + }); + stdout.on('error', (err) => { + logger.debug(`[nodeToWebStreams] Stdout error:`, err); + controller.error(err); + }); + }, + cancel() { + stdout.destroy(); + }, + }); + + return { writable, readable }; +} diff --git a/cli/src/agent/acp/permissions/permissionMapping.test.ts b/cli/src/agent/acp/permissions/permissionMapping.test.ts new file mode 100644 index 000000000..44b6ebc35 --- /dev/null +++ b/cli/src/agent/acp/permissions/permissionMapping.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect } from 'vitest'; +import { pickPermissionOutcome, pickPermissionOptionId } from './permissionMapping'; + +describe('ACP permission mapping', () => { + it('prefers allow_once by kind for approved', () => { + const options = [ + { optionId: 'allow-once', kind: 'allow_once' }, + { optionId: 'reject-once', kind: 'reject_once' }, + ]; + expect(pickPermissionOptionId(options, 'approved')).toBe('allow-once'); + expect(pickPermissionOutcome(options, 'approved')).toEqual({ outcome: 'selected', optionId: 'allow-once' }); + }); + + it('maps approved_for_session to allow_always by kind', () => { + const options = [ + { optionId: 'ask', kind: 'allow_once' }, + { optionId: 'code', kind: 'allow_always' }, + { optionId: 'reject', kind: 'reject_once' }, + ]; + expect(pickPermissionOptionId(options, 'approved_for_session')).toBe('code'); + }); + + it('maps denied to reject-once optionId when kind missing', () => { + const options = [ + { optionId: 'allow-once' }, + { optionId: 'reject-once' }, + ]; + expect(pickPermissionOptionId(options, 'denied')).toBe('reject-once'); + }); + + it('maps abort to cancelled outcome', () => { + const options = [ + { optionId: 'allow-once', kind: 'allow_once' }, + { optionId: 'reject-once', kind: 'reject_once' }, + ]; + expect(pickPermissionOutcome(options, 'abort')).toEqual({ outcome: 'cancelled' }); + }); +}); + diff --git a/cli/src/agent/acp/permissions/permissionMapping.ts b/cli/src/agent/acp/permissions/permissionMapping.ts new file mode 100644 index 000000000..57ff9d455 --- /dev/null +++ b/cli/src/agent/acp/permissions/permissionMapping.ts @@ -0,0 +1,115 @@ +export type PermissionOptionKind = + | 'allow_once' + | 'allow_always' + | 'reject_once' + | 'reject_always' + | string; + +export type PermissionDecision = + | 'approved' + | 'approved_for_session' + | 'approved_execpolicy_amendment' + | 'denied' + | 'abort'; + +export type PermissionOptionLike = { + optionId?: string; + name?: string; + kind?: unknown; +}; + +export function normalizePermissionOptionKind(kind: unknown): PermissionOptionKind { + if (typeof kind !== 'string') return ''; + return kind.trim().toLowerCase(); +} + +export function normalizePermissionDecision(decision: string): PermissionDecision | string { + return decision.trim().toLowerCase(); +} + +export type PermissionOutcomeSelected = { outcome: 'selected'; optionId: string }; +export type PermissionOutcomeCancelled = { outcome: 'cancelled' }; +export type PermissionOutcome = PermissionOutcomeSelected | PermissionOutcomeCancelled; + +function findByKind(options: PermissionOptionLike[], kinds: string[]): PermissionOptionLike | undefined { + return options.find( + (opt) => kinds.includes(normalizePermissionOptionKind(opt.kind)) && typeof opt.optionId === 'string' && opt.optionId.length > 0, + ); +} + +function findByOptionIdIncludes(options: PermissionOptionLike[], needle: string): PermissionOptionLike | undefined { + return options.find( + (opt) => typeof opt.optionId === 'string' && opt.optionId.toLowerCase().includes(needle), + ); +} + +export function pickPermissionOptionId(options: PermissionOptionLike[], decision: PermissionDecision | string): string | null { + const decisionLower = normalizePermissionDecision(String(decision)); + + const allowAlways = + findByKind(options, ['allow_always', 'allowalways']) + ?? findByOptionIdIncludes(options, 'allow-always') + ?? findByOptionIdIncludes(options, 'always'); + const allowOnce = + findByKind(options, ['allow_once', 'allowonce']) + ?? findByOptionIdIncludes(options, 'allow-once') + ?? findByOptionIdIncludes(options, 'once'); + const rejectAlways = + findByKind(options, ['reject_always', 'rejectalways']) + ?? findByOptionIdIncludes(options, 'reject-always'); + const rejectOnce = + findByKind(options, ['reject_once', 'rejectonce']) + ?? findByOptionIdIncludes(options, 'reject-once') + ?? findByOptionIdIncludes(options, 'reject') + ?? findByOptionIdIncludes(options, 'deny'); + + if (decisionLower === 'approved_for_session') { + return ( + allowAlways?.optionId + ?? allowOnce?.optionId + ?? (typeof options[0]?.optionId === 'string' ? options[0]?.optionId : null) + ); + } + + if (decisionLower === 'approved' || decisionLower === 'approved_execpolicy_amendment') { + return ( + allowOnce?.optionId + ?? allowAlways?.optionId + ?? (typeof options[0]?.optionId === 'string' ? options[0]?.optionId : null) + ); + } + + if (decisionLower === 'denied') { + return ( + rejectOnce?.optionId + ?? rejectAlways?.optionId + ?? (typeof options[0]?.optionId === 'string' ? options[0]?.optionId : null) + ); + } + + // abort (or unknown): prefer rejecting once if possible; callers may choose to return cancelled instead. + return ( + rejectOnce?.optionId + ?? rejectAlways?.optionId + ?? findByOptionIdIncludes(options, 'cancel')?.optionId + ?? (typeof options[0]?.optionId === 'string' ? options[0]?.optionId : null) + ); +} + +export function pickPermissionOutcome(options: PermissionOptionLike[], decision: PermissionDecision | string): PermissionOutcome { + const decisionLower = normalizePermissionDecision(String(decision)); + + // Spec: clients can return cancelled outcome for aborted permission prompts. + if (decisionLower === 'abort') { + return { outcome: 'cancelled' }; + } + + const optionId = pickPermissionOptionId(options, decision); + if (!optionId) { + // Fail closed: we can't select a meaningful option without an id. + return { outcome: 'cancelled' }; + } + + return { outcome: 'selected', optionId }; +} + diff --git a/cli/src/agent/acp/permissions/permissionRequest.test.ts b/cli/src/agent/acp/permissions/permissionRequest.test.ts new file mode 100644 index 000000000..7dfb3ce57 --- /dev/null +++ b/cli/src/agent/acp/permissions/permissionRequest.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; + +import { extractPermissionInputWithFallback } from './permissionRequest'; + +describe('extractPermissionInputWithFallback', () => { + it('uses params input when present', () => { + expect( + extractPermissionInputWithFallback( + { toolCall: { rawInput: { filePath: '/tmp/a' } } }, + 'call_1', + new Map([['call_1', { filePath: '/tmp/fallback' }]]) + ) + ).toEqual({ filePath: '/tmp/a' }); + }); + + it('uses toolCallId fallback when params input is empty', () => { + expect( + extractPermissionInputWithFallback( + { toolCall: { kind: 'other' } }, + 'call_2', + new Map([['call_2', { filePath: '/tmp/fallback' }]]) + ) + ).toEqual({ filePath: '/tmp/fallback' }); + }); + + it('returns empty object when nothing is available', () => { + expect(extractPermissionInputWithFallback({}, 'call_3', new Map())).toEqual({}); + }); +}); + diff --git a/cli/src/agent/acp/permissions/permissionRequest.ts b/cli/src/agent/acp/permissions/permissionRequest.ts new file mode 100644 index 000000000..21680b9d9 --- /dev/null +++ b/cli/src/agent/acp/permissions/permissionRequest.ts @@ -0,0 +1,73 @@ +export type PermissionToolCallLike = { + kind?: unknown; + toolName?: unknown; + rawInput?: unknown; + input?: unknown; + arguments?: unknown; + content?: unknown; +}; + +export type PermissionRequestLike = { + toolCall?: PermissionToolCallLike | null; + kind?: unknown; + rawInput?: unknown; + input?: unknown; + arguments?: unknown; + content?: unknown; +}; + +export function extractPermissionInput(params: PermissionRequestLike): Record { + const toolCall = params.toolCall ?? undefined; + const input = + (toolCall && (toolCall.rawInput ?? toolCall.input ?? toolCall.arguments ?? toolCall.content)) + ?? params.rawInput + ?? params.input + ?? params.arguments + ?? params.content; + if (input && typeof input === 'object' && !Array.isArray(input)) { + return input as Record; + } + return {}; +} + +export function extractPermissionInputWithFallback( + params: PermissionRequestLike, + toolCallId: string, + toolCallIdToInputMap?: Map> +): Record { + const extracted = extractPermissionInput(params); + if (Object.keys(extracted).length > 0) return extracted; + + const fallback = toolCallIdToInputMap?.get(toolCallId); + if (fallback && typeof fallback === 'object' && !Array.isArray(fallback) && Object.keys(fallback).length > 0) { + return fallback; + } + return {}; +} + +export function extractPermissionToolNameHint(params: PermissionRequestLike): string { + const toolCall = params.toolCall ?? undefined; + const kind = typeof toolCall?.kind === 'string' ? toolCall.kind.trim() : ''; + const toolName = typeof toolCall?.toolName === 'string' ? toolCall.toolName.trim() : ''; + const paramsKind = typeof params.kind === 'string' ? params.kind.trim() : ''; + + // ACP agents may send `kind: other` for permission prompts while also providing a more specific `toolName`. + // Prefer the more specific name when kind is generic. + const genericKind = kind.toLowerCase(); + if (kind && genericKind !== 'other' && genericKind !== 'unknown') return kind; + if (toolName) return toolName; + if (paramsKind) return paramsKind; + return 'Unknown tool'; +} + +export function resolvePermissionToolName(opts: { + toolNameHint: string; + toolCallId: string; + toolCallIdToNameMap?: Map; +}): string { + const mapped = opts.toolCallIdToNameMap?.get(opts.toolCallId); + if (typeof mapped === 'string' && mapped.trim().length > 0) { + return mapped.trim(); + } + return opts.toolNameHint; +} diff --git a/cli/src/agent/acp/sessionUpdateHandlers.test.ts b/cli/src/agent/acp/sessionUpdateHandlers.test.ts new file mode 100644 index 000000000..68844a462 --- /dev/null +++ b/cli/src/agent/acp/sessionUpdateHandlers.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { HandlerContext, SessionUpdate } from './sessionUpdateHandlers'; +import { handleToolCall, handleToolCallUpdate } from './sessionUpdateHandlers'; +import { defaultTransport } from '../transport/DefaultTransport'; +import { GeminiTransport } from '@/backends/gemini/acp/transport'; + +function createCtx(opts?: { transport?: HandlerContext['transport'] }): HandlerContext & { emitted: any[] } { + const emitted: any[] = []; + return { + transport: opts?.transport ?? defaultTransport, + activeToolCalls: new Set(), + toolCallStartTimes: new Map(), + toolCallTimeouts: new Map(), + toolCallIdToNameMap: new Map(), + toolCallIdToInputMap: new Map(), + idleTimeout: null, + toolCallCountSincePrompt: 0, + emit: (msg) => emitted.push(msg), + emitIdleStatus: () => emitted.push({ type: 'status', status: 'idle' }), + clearIdleTimeout: () => {}, + setIdleTimeout: () => {}, + emitted, + }; +} + +describe('sessionUpdateHandlers tool call tracking', () => { + it('does not treat update.title as the tool name', () => { + const ctx = createCtx(); + + const update: SessionUpdate = { + sessionUpdate: 'tool_call', + toolCallId: 'call_test_1', + status: 'in_progress', + kind: 'execute', + title: 'Run echo hello', + content: { command: ['/bin/zsh', '-lc', 'echo hello'] }, + }; + + handleToolCall(update, ctx); + + const toolCall = ctx.emitted.find((m) => m.type === 'tool-call'); + expect(toolCall).toBeTruthy(); + expect(toolCall.toolName).toBe('execute'); + expect(toolCall.args?._acp?.title).toBe('Run echo hello'); + }); + + it('does not start an execution timeout while status is pending, but arms timeout when in_progress arrives', () => { + vi.useFakeTimers(); + const ctx = createCtx(); + + const pendingUpdate: SessionUpdate = { + sessionUpdate: 'tool_call', + toolCallId: 'call_test_pending', + status: 'pending', + kind: 'read', + title: 'Read /etc/hosts', + content: { filePath: '/etc/hosts' }, + }; + + handleToolCall(pendingUpdate, ctx); + expect(ctx.activeToolCalls.has('call_test_pending')).toBe(true); + expect(ctx.toolCallTimeouts.has('call_test_pending')).toBe(false); + + const inProgressUpdate: SessionUpdate = { + sessionUpdate: 'tool_call_update', + toolCallId: 'call_test_pending', + status: 'in_progress', + kind: 'read', + title: 'Read /etc/hosts', + content: { filePath: '/etc/hosts' }, + meta: {}, + }; + + handleToolCallUpdate(inProgressUpdate, ctx); + expect(ctx.toolCallTimeouts.has('call_test_pending')).toBe(true); + + vi.useRealTimers(); + }); + + it('infers tool kind/name for terminal tool_call_update events when kind/start are missing (Gemini)', () => { + vi.useFakeTimers(); + const ctx = createCtx({ transport: new GeminiTransport() }); + + const failedUpdate: SessionUpdate = { + sessionUpdate: 'tool_call_update', + toolCallId: 'read_file-1', + status: 'failed', + title: 'Read /etc/hosts', + locations: [{ path: '/etc/hosts' }], + content: { filePath: '/etc/hosts' }, + meta: {}, + }; + + handleToolCallUpdate(failedUpdate, ctx); + + const toolCall = ctx.emitted.find((m) => m.type === 'tool-call' && m.callId === 'read_file-1'); + expect(toolCall).toBeTruthy(); + expect(toolCall.toolName).toBe('read'); + + const toolResult = ctx.emitted.find((m) => m.type === 'tool-result' && m.callId === 'read_file-1'); + expect(toolResult).toBeTruthy(); + expect(toolResult.toolName).toBe('read'); + expect(toolResult.result?._acp?.kind).toBe('read'); + + expect(ctx.toolCallTimeouts.size).toBe(0); + vi.useRealTimers(); + }); + + it('extracts tool output from update.result when output/rawOutput/content are absent', () => { + const ctx = createCtx(); + + const completedUpdate: SessionUpdate = { + sessionUpdate: 'tool_call_update', + toolCallId: 'read_file-1', + status: 'completed', + kind: 'read', + title: 'Read /tmp/a.txt', + // Gemini-style: result may be carried in a non-standard field. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ...( { result: { content: 'hello' } } as any ), + }; + + handleToolCallUpdate(completedUpdate, ctx); + + const toolResult = ctx.emitted.find((m) => m.type === 'tool-result' && m.callId === 'read_file-1'); + expect(toolResult).toBeTruthy(); + expect(toolResult.result).toMatchObject({ content: 'hello' }); + }); +}); diff --git a/cli/src/agent/acp/sessionUpdateHandlers.ts b/cli/src/agent/acp/sessionUpdateHandlers.ts index 4c68c46d1..d51d95588 100644 --- a/cli/src/agent/acp/sessionUpdateHandlers.ts +++ b/cli/src/agent/acp/sessionUpdateHandlers.ts @@ -11,6 +11,7 @@ import type { AgentMessage } from '../core'; import type { TransportHandler } from '../transport'; import { logger } from '@/ui/logger'; +import { normalizeAcpToolArgs, normalizeAcpToolResult } from './toolNormalization'; /** * Default timeout for idle detection after message chunks (ms) @@ -31,9 +32,23 @@ export interface SessionUpdate { toolCallId?: string; status?: string; kind?: string | unknown; + title?: string; + rawInput?: unknown; + rawOutput?: unknown; + input?: unknown; + output?: unknown; + // Some ACP providers (notably Gemini CLI) may surface tool outputs in other fields. + result?: unknown; + liveContent?: unknown; + live_content?: unknown; + meta?: unknown; + availableCommands?: Array<{ name?: string; description?: string } | unknown>; + currentModeId?: string; + entries?: unknown; content?: { text?: string; error?: string | { message?: string }; + type?: string; [key: string]: unknown; } | string | unknown; locations?: unknown[]; @@ -59,6 +74,8 @@ export interface HandlerContext { toolCallTimeouts: Map; /** Map of tool call ID to tool name */ toolCallIdToNameMap: Map; + /** Map of tool call ID to the most-recent raw input (for permission prompts that omit args) */ + toolCallIdToInputMap: Map>; /** Current idle timeout handle */ idleTimeout: NodeJS.Timeout | null; /** Tool call counter since last prompt */ @@ -90,12 +107,156 @@ export function parseArgsFromContent(content: unknown): Record if (Array.isArray(content)) { return { items: content }; } + if (typeof content === 'string') { + return { value: content }; + } if (content && typeof content === 'object' && content !== null) { return content as Record; } return {}; } +function extractToolInput(update: SessionUpdate): unknown { + if (update.rawInput !== undefined) return update.rawInput; + if (update.input !== undefined) return update.input; + return update.content; +} + +function extractToolOutput(update: SessionUpdate): unknown { + if (update.rawOutput !== undefined) return update.rawOutput; + if (update.output !== undefined) return update.output; + if (update.result !== undefined) return update.result; + if (update.liveContent !== undefined) return update.liveContent; + if (update.live_content !== undefined) return update.live_content; + return update.content; +} + +function asRecord(value: unknown): Record | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + return value as Record; +} + +function extractMeta(update: SessionUpdate): Record | null { + const meta = update.meta; + if (!meta || typeof meta !== 'object' || Array.isArray(meta)) return null; + return meta as Record; +} + +function hasMeaningfulToolUpdate(update: SessionUpdate): boolean { + if (typeof update.title === 'string' && update.title.trim().length > 0) return true; + if (update.rawInput !== undefined) return true; + if (update.input !== undefined) return true; + if (update.content !== undefined) return true; + if (Array.isArray(update.locations) && update.locations.length > 0) return true; + const meta = extractMeta(update); + if (meta) { + if (meta.terminal_output) return true; + if (meta.terminal_exit) return true; + } + return false; +} + +function attachAcpMetadataToArgs(args: Record, update: SessionUpdate, toolKind: string, rawInput: unknown): void { + const meta = extractMeta(update); + const acp: Record = { kind: toolKind }; + + if (typeof update.title === 'string' && update.title.trim().length > 0) { + acp.title = update.title; + // Prevent "empty tool" UIs when a provider omits rawInput/content but provides a title. + if (typeof args.description !== 'string' || args.description.trim().length === 0) { + args.description = update.title; + } + } + + if (rawInput !== undefined) acp.rawInput = rawInput; + if (Array.isArray(update.locations) && update.locations.length > 0) acp.locations = update.locations; + if (meta) acp.meta = meta; + + // Only attach when we have something beyond kind (keeps payloads small). + if (Object.keys(acp).length > 1) { + (args as any)._acp = { ...(asRecord((args as any)._acp) ?? {}), ...acp }; + } +} + +function emitTerminalOutputFromMeta(update: SessionUpdate, ctx: HandlerContext): void { + const meta = extractMeta(update); + if (!meta) return; + const entry = meta.terminal_output; + const obj = asRecord(entry); + if (!obj) return; + const data = typeof obj.data === 'string' ? obj.data : null; + if (!data) return; + const toolCallId = update.toolCallId; + if (!toolCallId) return; + const toolKindStr = typeof update.kind === 'string' ? update.kind : undefined; + const toolName = + ctx.toolCallIdToNameMap.get(toolCallId) + ?? ctx.transport.extractToolNameFromId?.(toolCallId) + ?? toolKindStr + ?? 'unknown'; + + // Represent terminal output as a streaming tool-result update for the same toolCallId. + // The UI reducer can append stdout/stderr without marking the tool as completed. + ctx.emit({ + type: 'tool-result', + toolName, + callId: toolCallId, + result: { + stdoutChunk: data, + _stream: true, + _terminal: true, + }, + }); +} + +function emitToolCallRefresh( + toolCallId: string, + toolKind: string | unknown, + update: SessionUpdate, + ctx: HandlerContext +): void { + const toolKindStr = typeof toolKind === 'string' ? toolKind : undefined; + + const rawInput = extractToolInput(update); + if (rawInput && typeof rawInput === 'object' && !Array.isArray(rawInput)) { + ctx.toolCallIdToInputMap.set(toolCallId, rawInput as Record); + } + + const baseName = + ctx.toolCallIdToNameMap.get(toolCallId) + ?? ctx.transport.extractToolNameFromId?.(toolCallId) + ?? toolKindStr + ?? 'unknown'; + const realToolName = ctx.transport.determineToolName?.( + baseName, + toolCallId, + (rawInput && typeof rawInput === 'object' && !Array.isArray(rawInput)) + ? (rawInput as Record) + : {}, + { recentPromptHadChangeTitle: false, toolCallCountSincePrompt: ctx.toolCallCountSincePrompt } + ) ?? baseName; + + const parsedArgs = parseArgsFromContent(rawInput); + const args = normalizeAcpToolArgs({ + toolKind: toolKindStr, + toolName: realToolName, + rawInput, + args: parsedArgs, + }); + + if (update.locations && Array.isArray(update.locations)) { + args.locations = update.locations; + } + attachAcpMetadataToArgs(args, update, toolKindStr || 'unknown', rawInput); + + ctx.emit({ + type: 'tool-call', + toolName: realToolName, + args, + callId: toolCallId, + }); +} + /** * Extract error detail from update content */ @@ -129,6 +290,16 @@ export function extractErrorDetail(content: unknown): string | undefined { return undefined; } +export function extractTextFromContentBlock(content: unknown): string | null { + if (!content) return null; + if (typeof content === 'string') return content; + if (typeof content !== 'object' || Array.isArray(content)) return null; + const obj = content as Record; + if (typeof obj.text === 'string') return obj.text; + if (obj.type === 'text' && typeof obj.text === 'string') return obj.text; + return null; +} + /** * Format duration for logging */ @@ -154,16 +325,11 @@ export function handleAgentMessageChunk( update: SessionUpdate, ctx: HandlerContext ): HandlerResult { - const content = update.content; - - if (!content || typeof content !== 'object' || !('text' in content)) { - return { handled: false }; - } - - const text = (content as { text?: string }).text; - if (typeof text !== 'string') { - return { handled: false }; - } + const text = extractTextFromContentBlock(update.content); + if (typeof text !== 'string' || text.length === 0) return { handled: false }; + // Some ACP providers emit whitespace-only chunks (often "\n") as keepalives. + // Dropping these avoids spammy blank lines and reduces unnecessary UI churn. + if (!text.trim()) return { handled: true }; // Filter out "thinking" messages (start with **...**) const isThinking = /^\*\*[^*]+\*\*\n/.test(text); @@ -206,16 +372,9 @@ export function handleAgentThoughtChunk( update: SessionUpdate, ctx: HandlerContext ): HandlerResult { - const content = update.content; - - if (!content || typeof content !== 'object' || !('text' in content)) { - return { handled: false }; - } - - const text = (content as { text?: string }).text; - if (typeof text !== 'string') { - return { handled: false }; - } + const text = extractTextFromContentBlock(update.content); + if (typeof text !== 'string' || text.length === 0) return { handled: false }; + if (!text.trim()) return { handled: true }; // Log thinking chunks when tool calls are active if (ctx.activeToolCalls.size > 0) { @@ -232,6 +391,48 @@ export function handleAgentThoughtChunk( return { handled: true }; } +export function handleUserMessageChunk( + update: SessionUpdate, + ctx: HandlerContext +): HandlerResult { + const text = extractTextFromContentBlock(update.content); + if (typeof text !== 'string' || text.length === 0) return { handled: false }; + ctx.emit({ + type: 'event', + name: 'user_message_chunk', + payload: { text }, + }); + return { handled: true }; +} + +export function handleAvailableCommandsUpdate( + update: SessionUpdate, + ctx: HandlerContext +): HandlerResult { + const commands = Array.isArray(update.availableCommands) ? update.availableCommands : null; + if (!commands) return { handled: false }; + ctx.emit({ + type: 'event', + name: 'available_commands_update', + payload: { availableCommands: commands }, + }); + return { handled: true }; +} + +export function handleCurrentModeUpdate( + update: SessionUpdate, + ctx: HandlerContext +): HandlerResult { + const modeId = typeof update.currentModeId === 'string' ? update.currentModeId : null; + if (!modeId) return { handled: false }; + ctx.emit({ + type: 'event', + name: 'current_mode_update', + payload: { currentModeId: modeId }, + }); + return { handled: true }; +} + /** * Start tracking a new tool call */ @@ -246,45 +447,67 @@ export function startToolCall( const toolKindStr = typeof toolKind === 'string' ? toolKind : undefined; const isInvestigation = ctx.transport.isInvestigationTool?.(toolCallId, toolKindStr) ?? false; - // Extract real tool name from toolCallId + const rawInput = extractToolInput(update); + if (rawInput && typeof rawInput === 'object' && !Array.isArray(rawInput)) { + ctx.toolCallIdToInputMap.set(toolCallId, rawInput as Record); + } + + // Determine a stable tool name (never use `update.title`, which is human-readable and can vary per call). const extractedName = ctx.transport.extractToolNameFromId?.(toolCallId); - const realToolName = extractedName ?? (toolKindStr || 'unknown'); + const baseName = extractedName ?? toolKindStr ?? 'unknown'; + const toolName = ctx.transport.determineToolName?.( + baseName, + toolCallId, + (rawInput && typeof rawInput === 'object' && !Array.isArray(rawInput)) + ? (rawInput as Record) + : {}, + { recentPromptHadChangeTitle: false, toolCallCountSincePrompt: ctx.toolCallCountSincePrompt } + ) ?? baseName; // Store mapping for permission requests - ctx.toolCallIdToNameMap.set(toolCallId, realToolName); + ctx.toolCallIdToNameMap.set(toolCallId, toolName); ctx.activeToolCalls.add(toolCallId); ctx.toolCallStartTimes.set(toolCallId, startTime); logger.debug(`[AcpBackend] ⏱️ Set startTime for ${toolCallId} at ${new Date(startTime).toISOString()} (from ${source})`); - logger.debug(`[AcpBackend] 🔧 Tool call START: ${toolCallId} (${toolKind} -> ${realToolName})${isInvestigation ? ' [INVESTIGATION TOOL]' : ''}`); + logger.debug(`[AcpBackend] 🔧 Tool call START: ${toolCallId} (${toolKind} -> ${toolName})${isInvestigation ? ' [INVESTIGATION TOOL]' : ''}`); if (isInvestigation) { logger.debug(`[AcpBackend] 🔍 Investigation tool detected - extended timeout (10min) will be used`); } - // Set timeout for tool call completion - const timeoutMs = ctx.transport.getToolCallTimeout?.(toolCallId, toolKindStr) ?? DEFAULT_TOOL_CALL_TIMEOUT_MS; - - if (!ctx.toolCallTimeouts.has(toolCallId)) { - const timeout = setTimeout(() => { - const duration = formatDuration(ctx.toolCallStartTimes.get(toolCallId)); - logger.debug(`[AcpBackend] ⏱️ Tool call TIMEOUT (from ${source}): ${toolCallId} (${toolKind}) after ${(timeoutMs / 1000).toFixed(0)}s - Duration: ${duration}, removing from active set`); - - ctx.activeToolCalls.delete(toolCallId); - ctx.toolCallStartTimes.delete(toolCallId); - ctx.toolCallTimeouts.delete(toolCallId); - - if (ctx.activeToolCalls.size === 0) { - logger.debug('[AcpBackend] No more active tool calls after timeout, emitting idle status'); - ctx.emitIdleStatus(); - } - }, timeoutMs); - - ctx.toolCallTimeouts.set(toolCallId, timeout); - logger.debug(`[AcpBackend] ⏱️ Set timeout for ${toolCallId}: ${(timeoutMs / 1000).toFixed(0)}s${isInvestigation ? ' (investigation tool)' : ''}`); + // Set timeout for tool call completion. + // Some ACP providers send `status: pending` while waiting for a user permission response. Do not start + // the execution timeout until the tool is actually in progress, otherwise long permission waits can + // cause spurious timeouts and confusing UI state. + if (update.status !== 'pending') { + const timeoutMs = ctx.transport.getToolCallTimeout?.(toolCallId, toolKindStr) ?? DEFAULT_TOOL_CALL_TIMEOUT_MS; + + if (!ctx.toolCallTimeouts.has(toolCallId)) { + const timeout = setTimeout(() => { + const duration = formatDuration(ctx.toolCallStartTimes.get(toolCallId)); + logger.debug(`[AcpBackend] ⏱️ Tool call TIMEOUT (from ${source}): ${toolCallId} (${toolKind}) after ${(timeoutMs / 1000).toFixed(0)}s - Duration: ${duration}, removing from active set`); + + ctx.activeToolCalls.delete(toolCallId); + ctx.toolCallStartTimes.delete(toolCallId); + ctx.toolCallTimeouts.delete(toolCallId); + ctx.toolCallIdToNameMap.delete(toolCallId); + ctx.toolCallIdToInputMap.delete(toolCallId); + + if (ctx.activeToolCalls.size === 0) { + logger.debug('[AcpBackend] No more active tool calls after timeout, emitting idle status'); + ctx.emitIdleStatus(); + } + }, timeoutMs); + + ctx.toolCallTimeouts.set(toolCallId, timeout); + logger.debug(`[AcpBackend] ⏱️ Set timeout for ${toolCallId}: ${(timeoutMs / 1000).toFixed(0)}s${isInvestigation ? ' (investigation tool)' : ''}`); + } else { + logger.debug(`[AcpBackend] Timeout already set for ${toolCallId}, skipping`); + } } else { - logger.debug(`[AcpBackend] Timeout already set for ${toolCallId}, skipping`); + logger.debug(`[AcpBackend] Tool call ${toolCallId} is pending permission; skipping execution timeout setup`); } // Clear idle timeout - tool call is starting @@ -294,13 +517,21 @@ export function startToolCall( ctx.emit({ type: 'status', status: 'running' }); // Parse args and emit tool-call event - const args = parseArgsFromContent(update.content); + const parsedArgs = parseArgsFromContent(rawInput); + const args = normalizeAcpToolArgs({ + toolKind: toolKindStr, + toolName, + rawInput, + args: parsedArgs, + }); // Extract locations if present if (update.locations && Array.isArray(update.locations)) { args.locations = update.locations; } + attachAcpMetadataToArgs(args, update, toolKindStr || 'unknown', rawInput); + // Log investigation tool objective if (isInvestigation && args.objective) { logger.debug(`[AcpBackend] 🔍 Investigation tool objective: ${String(args.objective).substring(0, 100)}...`); @@ -308,7 +539,7 @@ export function startToolCall( ctx.emit({ type: 'tool-call', - toolName: toolKindStr || 'unknown', + toolName, args, callId: toolCallId, }); @@ -320,15 +551,18 @@ export function startToolCall( export function completeToolCall( toolCallId: string, toolKind: string | unknown, - content: unknown, + update: SessionUpdate, ctx: HandlerContext ): void { const startTime = ctx.toolCallStartTimes.get(toolCallId); const duration = formatDuration(startTime); const toolKindStr = typeof toolKind === 'string' ? toolKind : 'unknown'; + const resolvedToolName = ctx.toolCallIdToNameMap.get(toolCallId) ?? toolKindStr; ctx.activeToolCalls.delete(toolCallId); ctx.toolCallStartTimes.delete(toolCallId); + ctx.toolCallIdToNameMap.delete(toolCallId); + ctx.toolCallIdToInputMap.delete(toolCallId); const timeout = ctx.toolCallTimeouts.get(toolCallId); if (timeout) { @@ -336,12 +570,23 @@ export function completeToolCall( ctx.toolCallTimeouts.delete(toolCallId); } - logger.debug(`[AcpBackend] ✅ Tool call COMPLETED: ${toolCallId} (${toolKindStr}) - Duration: ${duration}. Active tool calls: ${ctx.activeToolCalls.size}`); + logger.debug(`[AcpBackend] ✅ Tool call COMPLETED: ${toolCallId} (${resolvedToolName}) - Duration: ${duration}. Active tool calls: ${ctx.activeToolCalls.size}`); + + const normalized = normalizeAcpToolResult(extractToolOutput(update)); + const record = asRecord(normalized); + if (record) { + const meta = extractMeta(update); + const acp: Record = { kind: toolKindStr }; + if (typeof update.title === 'string' && update.title.trim().length > 0) acp.title = update.title; + if (Array.isArray(update.locations) && update.locations.length > 0) acp.locations = update.locations; + if (meta) acp.meta = meta; + record._acp = { ...(asRecord(record._acp) ?? {}), ...acp }; + } ctx.emit({ type: 'tool-result', - toolName: toolKindStr, - result: content, + toolName: resolvedToolName, + result: normalized, callId: toolCallId, }); @@ -360,12 +605,13 @@ export function failToolCall( toolCallId: string, status: 'failed' | 'cancelled', toolKind: string | unknown, - content: unknown, + update: SessionUpdate, ctx: HandlerContext ): void { const startTime = ctx.toolCallStartTimes.get(toolCallId); const duration = startTime ? Date.now() - startTime : null; const toolKindStr = typeof toolKind === 'string' ? toolKind : 'unknown'; + const resolvedToolName = ctx.toolCallIdToNameMap.get(toolCallId) ?? toolKindStr; const isInvestigation = ctx.transport.isInvestigationTool?.(toolCallId, toolKindStr) ?? false; const hadTimeout = ctx.toolCallTimeouts.has(toolCallId); @@ -384,7 +630,7 @@ export function failToolCall( } } - logger.debug(`[AcpBackend] 🔍 Investigation tool FAILED - full content:`, JSON.stringify(content, null, 2)); + logger.debug(`[AcpBackend] 🔍 Investigation tool FAILED - full content:`, JSON.stringify(extractToolOutput(update), null, 2)); logger.debug(`[AcpBackend] 🔍 Investigation tool timeout status BEFORE cleanup: ${hadTimeout ? 'timeout was set' : 'no timeout was set'}`); logger.debug(`[AcpBackend] 🔍 Investigation tool startTime status BEFORE cleanup: ${startTime ? `set at ${new Date(startTime).toISOString()}` : 'not set'}`); } @@ -392,6 +638,8 @@ export function failToolCall( // Cleanup ctx.activeToolCalls.delete(toolCallId); ctx.toolCallStartTimes.delete(toolCallId); + ctx.toolCallIdToNameMap.delete(toolCallId); + ctx.toolCallIdToInputMap.delete(toolCallId); const timeout = ctx.toolCallTimeouts.get(toolCallId); if (timeout) { @@ -403,10 +651,10 @@ export function failToolCall( } const durationStr = formatDuration(startTime); - logger.debug(`[AcpBackend] ❌ Tool call ${status.toUpperCase()}: ${toolCallId} (${toolKindStr}) - Duration: ${durationStr}. Active tool calls: ${ctx.activeToolCalls.size}`); + logger.debug(`[AcpBackend] ❌ Tool call ${status.toUpperCase()}: ${toolCallId} (${resolvedToolName}) - Duration: ${durationStr}. Active tool calls: ${ctx.activeToolCalls.size}`); // Extract error detail - const errorDetail = extractErrorDetail(content); + const errorDetail = extractErrorDetail(extractToolOutput(update)); if (errorDetail) { logger.debug(`[AcpBackend] ❌ Tool call error details: ${errorDetail.substring(0, 500)}`); } else { @@ -416,10 +664,18 @@ export function failToolCall( // Emit tool-result with error ctx.emit({ type: 'tool-result', - toolName: toolKindStr, - result: errorDetail - ? { error: errorDetail, status } - : { error: `Tool call ${status}`, status }, + toolName: resolvedToolName, + result: (() => { + const base = errorDetail + ? { error: errorDetail, status } + : { error: `Tool call ${status}`, status }; + const meta = extractMeta(update); + const acp: Record = { kind: toolKindStr }; + if (typeof update.title === 'string' && update.title.trim().length > 0) acp.title = update.title; + if (Array.isArray(update.locations) && update.locations.length > 0) acp.locations = update.locations; + if (meta) acp.meta = meta; + return { ...base, _acp: acp }; + })(), callId: toolCallId, }); @@ -446,20 +702,70 @@ export function handleToolCallUpdate( return { handled: false }; } - const toolKind = update.kind || 'unknown'; + const toolKind = + typeof update.kind === 'string' + ? update.kind + : (ctx.transport.extractToolNameFromId?.(toolCallId) ?? 'unknown'); let toolCallCountSincePrompt = ctx.toolCallCountSincePrompt; + // Some ACP providers stream terminal output via tool_call_update.meta. + emitTerminalOutputFromMeta(update, ctx); + + const isTerminalStatus = status === 'completed' || status === 'failed' || status === 'cancelled'; + // Some ACP providers (notably Gemini CLI) can emit a terminal tool_call_update without ever sending an + // in_progress/pending update first. Seed a synthetic tool-call so the UI has enough context to render + // the tool input/locations, and so tool-result can attach a non-"unknown" kind. + if (isTerminalStatus && !ctx.toolCallIdToNameMap.has(toolCallId)) { + startToolCall( + toolCallId, + toolKind, + { ...update, status: 'pending' }, + ctx, + 'tool_call_update' + ); + } + if (status === 'in_progress' || status === 'pending') { if (!ctx.activeToolCalls.has(toolCallId)) { toolCallCountSincePrompt++; startToolCall(toolCallId, toolKind, update, ctx, 'tool_call_update'); } else { - logger.debug(`[AcpBackend] Tool call ${toolCallId} already tracked, status: ${status}`); + // If the tool call was previously pending permission, it may not have an execution timeout yet. + // Arm the timeout as soon as it transitions to in_progress. + if (status === 'in_progress' && !ctx.toolCallTimeouts.has(toolCallId)) { + const toolKindStr = typeof toolKind === 'string' ? toolKind : undefined; + const timeoutMs = ctx.transport.getToolCallTimeout?.(toolCallId, toolKindStr) ?? DEFAULT_TOOL_CALL_TIMEOUT_MS; + const timeout = setTimeout(() => { + const duration = formatDuration(ctx.toolCallStartTimes.get(toolCallId)); + logger.debug(`[AcpBackend] ⏱️ Tool call TIMEOUT (from tool_call_update): ${toolCallId} (${toolKind}) after ${(timeoutMs / 1000).toFixed(0)}s - Duration: ${duration}, removing from active set`); + + ctx.activeToolCalls.delete(toolCallId); + ctx.toolCallStartTimes.delete(toolCallId); + ctx.toolCallTimeouts.delete(toolCallId); + ctx.toolCallIdToNameMap.delete(toolCallId); + ctx.toolCallIdToInputMap.delete(toolCallId); + + if (ctx.activeToolCalls.size === 0) { + logger.debug('[AcpBackend] No more active tool calls after timeout, emitting idle status'); + ctx.emitIdleStatus(); + } + }, timeoutMs); + ctx.toolCallTimeouts.set(toolCallId, timeout); + logger.debug(`[AcpBackend] ⏱️ Set timeout for ${toolCallId}: ${(timeoutMs / 1000).toFixed(0)}s (armed on in_progress)`); + } + + if (hasMeaningfulToolUpdate(update)) { + // Refresh the existing tool call message with updated title/rawInput/locations (without + // resetting timeouts/start times). + emitToolCallRefresh(toolCallId, toolKind, update, ctx); + } else { + logger.debug(`[AcpBackend] Tool call ${toolCallId} already tracked, status: ${status}`); + } } } else if (status === 'completed') { - completeToolCall(toolCallId, toolKind, update.content, ctx); + completeToolCall(toolCallId, toolKind, update, ctx); } else if (status === 'failed' || status === 'cancelled') { - failToolCall(toolCallId, status, toolKind, update.content, ctx); + failToolCall(toolCallId, status, toolKind, update, ctx); } return { handled: true, toolCallCountSincePrompt }; @@ -524,17 +830,25 @@ export function handlePlanUpdate( update: SessionUpdate, ctx: HandlerContext ): HandlerResult { - if (!update.plan) { - return { handled: false }; + if (update.sessionUpdate === 'plan' && update.entries !== undefined) { + ctx.emit({ + type: 'event', + name: 'plan', + payload: { entries: update.entries }, + }); + return { handled: true }; } - ctx.emit({ - type: 'event', - name: 'plan', - payload: update.plan, - }); + if (update.plan !== undefined) { + ctx.emit({ + type: 'event', + name: 'plan', + payload: update.plan, + }); + return { handled: true }; + } - return { handled: true }; + return { handled: false }; } /** diff --git a/cli/src/agent/acp/toolNormalization.test.ts b/cli/src/agent/acp/toolNormalization.test.ts new file mode 100644 index 000000000..46b678401 --- /dev/null +++ b/cli/src/agent/acp/toolNormalization.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest'; + +import { normalizeAcpToolArgs } from './toolNormalization'; + +describe('normalizeAcpToolArgs', () => { + it('normalizes shell command aliases into command', () => { + expect( + normalizeAcpToolArgs({ + toolKind: 'exec', + toolName: 'other', + rawInput: null, + args: { cmd: ' ls -la ' }, + }).command + ).toBe('ls -la'); + }); + + it('normalizes file path aliases into file_path', () => { + expect( + normalizeAcpToolArgs({ + toolKind: 'read', + toolName: 'read', + rawInput: null, + args: { filePath: '/tmp/a.txt' }, + }).file_path + ).toBe('/tmp/a.txt'); + }); + + it('normalizes edit oldString/newString into oldText/newText', () => { + const normalized = normalizeAcpToolArgs({ + toolKind: 'edit', + toolName: 'edit', + rawInput: null, + args: { oldString: 'a', newString: 'b', filePath: '/tmp/x' }, + }); + + expect(normalized.oldText).toBe('a'); + expect(normalized.newText).toBe('b'); + expect(normalized.path).toBe('/tmp/x'); + }); + + it('normalizes ACP diff items[] into file_path and content for write', () => { + const normalized = normalizeAcpToolArgs({ + toolKind: 'write', + toolName: 'write', + rawInput: null, + args: { + items: [{ path: '/tmp/a.txt', oldText: 'old', newText: 'new', type: 'diff' }], + }, + }); + + expect(normalized.file_path).toBe('/tmp/a.txt'); + expect(normalized.content).toBe('new'); + }); + + it('normalizes ACP diff items[] into file_path and oldText/newText for edit', () => { + const normalized = normalizeAcpToolArgs({ + toolKind: 'edit', + toolName: 'edit', + rawInput: null, + args: { + items: [{ path: '/tmp/a.txt', oldText: 'old', newText: 'new', type: 'diff' }], + }, + }); + + expect(normalized.file_path).toBe('/tmp/a.txt'); + expect(normalized.oldText).toBe('old'); + expect(normalized.newText).toBe('new'); + }); +}); diff --git a/cli/src/agent/acp/toolNormalization.ts b/cli/src/agent/acp/toolNormalization.ts new file mode 100644 index 000000000..f566be7d3 --- /dev/null +++ b/cli/src/agent/acp/toolNormalization.ts @@ -0,0 +1,268 @@ +type UnknownRecord = Record; + +function asRecord(value: unknown): UnknownRecord | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + return value as UnknownRecord; +} + +function asStringArray(value: unknown): string[] | null { + if (!Array.isArray(value)) return null; + const out: string[] = []; + for (const item of value) { + if (typeof item !== 'string') return null; + out.push(item); + } + return out; +} + +function isShellToolName(name: string): boolean { + const lower = name.toLowerCase(); + return lower === 'bash' || lower === 'execute' || lower === 'shell' || lower === 'exec' || lower === 'run'; +} + +function normalizeShellCommandFromArgs(args: UnknownRecord): string | string[] | null { + const command = args.command; + if (typeof command === 'string' && command.trim().length > 0) return command.trim(); + const cmdArray = asStringArray(command); + if (cmdArray && cmdArray.length > 0) return cmdArray; + + const cmd = args.cmd; + if (typeof cmd === 'string' && cmd.trim().length > 0) return cmd.trim(); + const cmdArray2 = asStringArray(cmd); + if (cmdArray2 && cmdArray2.length > 0) return cmdArray2; + + const argv = args.argv; + const argvArray = asStringArray(argv); + if (argvArray && argvArray.length > 0) return argvArray; + + const items = asStringArray(args.items); + if (items && items.length > 0) return items; + + return null; +} + +function coerceSingleLocationPath(locations: unknown): string | null { + if (!Array.isArray(locations) || locations.length !== 1) return null; + const first = locations[0]; + if (!first || typeof first !== 'object') return null; + const obj = first as Record; + const path = + (typeof obj.path === 'string' && obj.path.trim()) + ? obj.path.trim() + : (typeof obj.filePath === 'string' && obj.filePath.trim()) + ? obj.filePath.trim() + : null; + return path; +} + +function coerceFirstItemDiff(items: unknown): Record | null { + if (!Array.isArray(items) || items.length === 0) return null; + const first = items[0]; + if (!first || typeof first !== 'object' || Array.isArray(first)) return null; + return first as Record; +} + +function coerceItemPath(item: Record | null): string | null { + if (!item) return null; + const path = + (typeof item.path === 'string' && item.path.trim()) + ? item.path.trim() + : (typeof item.filePath === 'string' && item.filePath.trim()) + ? item.filePath.trim() + : null; + return path; +} + +function coerceItemText(item: Record | null, key: 'old' | 'new'): string | null { + if (!item) return null; + const candidates = + key === 'old' + ? [item.oldText, item.old_string, item.oldString] + : [item.newText, item.new_string, item.newString]; + for (const c of candidates) { + if (typeof c === 'string' && c.trim().length > 0) return c; + } + return null; +} + +function normalizeUrlFromArgs(args: UnknownRecord): string | null { + const url = args.url; + if (typeof url === 'string' && url.trim().length > 0) return url.trim(); + + const uri = args.uri; + if (typeof uri === 'string' && uri.trim().length > 0) return uri.trim(); + + const link = args.link; + if (typeof link === 'string' && link.trim().length > 0) return link.trim(); + + const href = args.href; + if (typeof href === 'string' && href.trim().length > 0) return href.trim(); + + return null; +} + +function normalizeSearchQueryFromArgs(args: UnknownRecord): string | null { + const query = args.query; + if (typeof query === 'string' && query.trim().length > 0) return query.trim(); + + const q = args.q; + if (typeof q === 'string' && q.trim().length > 0) return q.trim(); + + const pattern = args.pattern; + if (typeof pattern === 'string' && pattern.trim().length > 0) return pattern.trim(); + + const text = args.text; + if (typeof text === 'string' && text.trim().length > 0) return text.trim(); + + return null; +} + +/** + * Normalize ACP tool-call arguments into a shape that our UI renderers and permission matching + * can consistently understand across providers. + * + * NOTE: This must be conservative: only fill well-known aliases; do not delete unknown fields. + */ +export function normalizeAcpToolArgs(opts: { + toolKind: string | undefined; + toolName: string; + rawInput: unknown; + args: UnknownRecord; +}): UnknownRecord { + const toolKindLower = (opts.toolKind ?? '').toLowerCase(); + const toolNameLower = opts.toolName.toLowerCase(); + const raw = opts.rawInput; + + const out: UnknownRecord = { ...opts.args }; + + // Shell / exec tools: normalize command into `command` (string or string[]). + if (isShellToolName(toolKindLower) || isShellToolName(toolNameLower)) { + const fromArgs = normalizeShellCommandFromArgs(out); + const fromRawArray = asStringArray(raw); + const normalized = fromArgs ?? (fromRawArray && fromRawArray.length > 0 ? fromRawArray : null); + if (normalized) { + out.command = normalized; + } + } + + // File ops: normalize common path aliases. + const filePath = + (typeof out.file_path === 'string' && out.file_path.length > 0) + ? out.file_path + : (typeof out.path === 'string' && out.path.length > 0) + ? out.path + : (typeof out.filePath === 'string' && out.filePath.length > 0) + ? out.filePath + : null; + if (filePath && typeof out.file_path !== 'string') { + out.file_path = filePath; + } + + // ACP often provides file context via `locations` without rawInput. When we have exactly one + // location and this looks like a file tool, surface it as `file_path` for our existing views. + if (typeof out.file_path !== 'string') { + const locPath = coerceSingleLocationPath(out.locations); + const isFileTool = + toolNameLower === 'read' || toolNameLower === 'edit' || toolNameLower === 'write' + || toolKindLower === 'read' || toolKindLower === 'edit' || toolKindLower === 'write'; + if (isFileTool && locPath) { + out.file_path = locPath; + } + } + + // ACP diff tools often provide file context + content in args.items[0]. + const firstItem = coerceFirstItemDiff(out.items); + const itemPath = coerceItemPath(firstItem); + if (itemPath && typeof out.file_path !== 'string') { + out.file_path = itemPath; + } + + // Write: normalize `content` from common aliases. + if (toolNameLower === 'write' || toolKindLower === 'write') { + if (typeof out.content !== 'string') { + const content = + typeof out.text === 'string' + ? out.text + : typeof out.data === 'string' + ? out.data + : typeof out.newText === 'string' + ? out.newText + : null; + const fromItem = coerceItemText(firstItem, 'new'); + if (typeof content === 'string') out.content = content; + else if (fromItem) out.content = fromItem; + } + } + + // Edit: normalize common field aliases used by ACP agents. + // (Gemini edit view supports oldText/newText and old_string/new_string, but not oldString/newString.) + if (toolNameLower === 'edit' || toolKindLower === 'edit') { + const oldFromItem = coerceItemText(firstItem, 'old'); + const newFromItem = coerceItemText(firstItem, 'new'); + if (typeof out.oldText !== 'string' && typeof out.old_string !== 'string') { + if (typeof out.oldString === 'string') out.oldText = out.oldString; + else if (oldFromItem) out.oldText = oldFromItem; + } + if (typeof out.newText !== 'string' && typeof out.new_string !== 'string') { + if (typeof out.newString === 'string') out.newText = out.newString; + else if (newFromItem) out.newText = newFromItem; + } + if (typeof out.path !== 'string' && typeof out.filePath === 'string') { + out.path = out.filePath; + } + } + + // Search: normalize pattern for glob/grep tools. + if (toolNameLower === 'glob') { + if (typeof out.pattern !== 'string' && typeof out.glob === 'string') { + out.pattern = out.glob; + } + } + if (toolNameLower === 'grep') { + if (typeof out.pattern !== 'string' && typeof out.query === 'string') { + out.pattern = out.query; + } + } + + // Web fetch/search helpers: ensure our existing renderers can find `url` / `query`. + const isFetchTool = + toolNameLower === 'webfetch' + || toolNameLower === 'web_fetch' + || toolNameLower === 'fetch' + || toolKindLower === 'webfetch' + || toolKindLower === 'web_fetch' + || toolKindLower === 'fetch'; + if (isFetchTool && typeof out.url !== 'string') { + const normalizedUrl = normalizeUrlFromArgs(out); + if (normalizedUrl) out.url = normalizedUrl; + } + + const isWebSearchTool = + toolNameLower === 'websearch' + || toolNameLower === 'web_search' + || toolNameLower === 'search' + || toolKindLower === 'websearch' + || toolKindLower === 'web_search' + || toolKindLower === 'search'; + if (isWebSearchTool && typeof out.query !== 'string') { + const normalizedQuery = normalizeSearchQueryFromArgs(out); + if (normalizedQuery) out.query = normalizedQuery; + } + + return out; +} + +/** + * Normalize ACP tool-result payloads. + * Keep as-is unless we recognize an obvious wrapper shape. + */ +export function normalizeAcpToolResult(raw: unknown): unknown { + const obj = asRecord(raw); + if (!obj) return raw; + + // Some agents wrap results under { output: ... } or { result: ... }. + if ('output' in obj) return obj.output; + if ('result' in obj) return obj.result; + + return raw; +} diff --git a/cli/src/agent/adapters/MobileMessageFormat.ts b/cli/src/agent/adapters/MobileMessageFormat.ts index b24ee04d4..280ab0940 100644 --- a/cli/src/agent/adapters/MobileMessageFormat.ts +++ b/cli/src/agent/adapters/MobileMessageFormat.ts @@ -8,10 +8,12 @@ * @module MobileMessageFormat */ +import type { AgentId } from '@happy/agents'; + /** * Supported agent types for the mobile app */ -export type MobileAgentType = 'gemini' | 'codex' | 'claude' | 'opencode'; +export type MobileAgentType = AgentId; /** * Message roles for the mobile app diff --git a/cli/src/agent/core/AgentBackend.ts b/cli/src/agent/core/AgentBackend.ts index 61367988c..918afa750 100644 --- a/cli/src/agent/core/AgentBackend.ts +++ b/cli/src/agent/core/AgentBackend.ts @@ -12,6 +12,8 @@ * - Stream model output and events */ +import type { AgentId as CatalogAgentId } from '@happy/agents'; + /** Unique identifier for an agent session */ export type SessionId = string; @@ -48,7 +50,7 @@ export interface McpServerConfig { export type AgentTransport = 'native-claude' | 'mcp-codex' | 'acp'; /** Agent identifier */ -export type AgentId = 'claude' | 'codex' | 'gemini' | 'opencode' | 'claude-acp' | 'codex-acp'; +export type AgentId = CatalogAgentId; /** * Configuration for creating an agent backend @@ -109,6 +111,24 @@ export interface AgentBackend { * @returns Promise resolving to session information */ startSession(initialPrompt?: string): Promise; + + /** + * Load an existing agent session (vendor-level resume). + * + * Not all agents support this. ACP agents may advertise this capability + * via the protocol (e.g. Codex ACP). + * + * When unsupported, callers should fall back to starting a new session. + */ + loadSession?(sessionId: SessionId): Promise; + + /** + * Load an existing agent session and capture the replayed history. + * + * ACP agents that implement session/load may replay the full conversation via session/update. + * This hook allows Happy CLI to capture that replay and import it into the Happy transcript. + */ + loadSessionWithReplayCapture?(sessionId: SessionId): Promise; /** * Send a prompt to an existing session. diff --git a/cli/src/agent/core/AgentFactory.ts b/cli/src/agent/core/AgentFactory.ts new file mode 100644 index 000000000..44963e77b --- /dev/null +++ b/cli/src/agent/core/AgentFactory.ts @@ -0,0 +1,12 @@ +import type { AgentBackend } from './AgentBackend'; + +export interface AgentFactoryOptions { + /** Working directory for the agent */ + cwd: string; + + /** Environment variables to pass to the agent */ + env?: Record; +} + +export type AgentFactory = (opts: AgentFactoryOptions) => TBackend; + diff --git a/cli/src/agent/core/AgentRegistry.ts b/cli/src/agent/core/AgentRegistry.ts deleted file mode 100644 index 4a67ce882..000000000 --- a/cli/src/agent/core/AgentRegistry.ts +++ /dev/null @@ -1,89 +0,0 @@ -/** - * AgentRegistry - Registry for agent backend factories - * - * This module provides a central registry for creating agent backends. - * It allows registering factory functions for different agent types - * and creating instances of those backends. - */ - -import type { AgentBackend, AgentId } from './AgentBackend'; - -/** Options passed to agent factory functions */ -export interface AgentFactoryOptions { - /** Working directory for the agent */ - cwd: string; - - /** Environment variables to pass to the agent */ - env?: Record; -} - -/** Factory function type for creating agent backends */ -export type AgentFactory = (opts: AgentFactoryOptions) => AgentBackend; - -/** - * Registry for agent backend factories. - * - * Use this to register and create agent backends by their identifier. - * - * @example - * ```ts - * const registry = new AgentRegistry(); - * registry.register('gemini', createGeminiBackend); - * - * const backend = registry.create('gemini', { cwd: process.cwd() }); - * await backend.startSession('Hello!'); - * ``` - */ -export class AgentRegistry { - private factories = new Map(); - - /** - * Register a factory function for an agent type. - * - * @param id - The agent identifier - * @param factory - Factory function to create the backend - */ - register(id: AgentId, factory: AgentFactory): void { - this.factories.set(id, factory); - } - - /** - * Check if an agent type is registered. - * - * @param id - The agent identifier to check - * @returns true if the agent is registered - */ - has(id: AgentId): boolean { - return this.factories.has(id); - } - - /** - * Get the list of registered agent identifiers. - * - * @returns Array of registered agent IDs - */ - list(): AgentId[] { - return Array.from(this.factories.keys()); - } - - /** - * Create an agent backend instance. - * - * @param id - The agent identifier - * @param opts - Options for creating the backend - * @returns The created agent backend - * @throws Error if the agent type is not registered - */ - create(id: AgentId, opts: AgentFactoryOptions): AgentBackend { - const factory = this.factories.get(id); - if (!factory) { - const available = this.list().join(', ') || 'none'; - throw new Error(`Unknown agent: ${id}. Available agents: ${available}`); - } - return factory(opts); - } -} - -/** Global agent registry instance */ -export const agentRegistry = new AgentRegistry(); - diff --git a/cli/src/agent/core/index.ts b/cli/src/agent/core/index.ts index 434764d6a..5be8bf07b 100644 --- a/cli/src/agent/core/index.ts +++ b/cli/src/agent/core/index.ts @@ -25,18 +25,10 @@ export type { } from './AgentBackend'; // ============================================================================ -// AgentRegistry - Factory registry +// AgentFactory - Factory types (catalog-driven) // ============================================================================ -export { - AgentRegistry, - agentRegistry, -} from './AgentRegistry'; - -export type { - AgentFactory, - AgentFactoryOptions, -} from './AgentRegistry'; +export type { AgentFactory, AgentFactoryOptions } from './AgentFactory'; // ============================================================================ // AgentMessage - Detailed message types with type guards diff --git a/cli/src/agent/factories/index.ts b/cli/src/agent/factories/index.ts deleted file mode 100644 index b653073ac..000000000 --- a/cli/src/agent/factories/index.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Agent Factories - * - * Factory functions for creating agent backends with proper configuration. - * Each factory includes the appropriate transport handler for the agent. - * - * @module factories - */ - -// Gemini factory -export { - createGeminiBackend, - registerGeminiAgent, - type GeminiBackendOptions, - type GeminiBackendResult, -} from './gemini'; - -// Future factories: -// export { createCodexBackend, registerCodexAgent, type CodexBackendOptions } from './codex'; -// export { createClaudeBackend, registerClaudeAgent, type ClaudeBackendOptions } from './claude'; -// export { createOpenCodeBackend, registerOpenCodeAgent, type OpenCodeBackendOptions } from './opencode'; diff --git a/cli/src/agent/index.ts b/cli/src/agent/index.ts index e350ad4f6..ac3bea3ba 100644 --- a/cli/src/agent/index.ts +++ b/cli/src/agent/index.ts @@ -23,22 +23,7 @@ export type { AgentFactoryOptions, } from './core'; -export { AgentRegistry, agentRegistry } from './core'; - // ACP backend (low-level) export * from './acp'; -// Agent factories (high-level, recommended) -export * from './factories'; - -/** - * Initialize all agent backends and register them with the global registry. - * - * Call this function during application startup to make all agents available. - */ -export function initializeAgents(): void { - // Import and register agents from factories - const { registerGeminiAgent } = require('./factories/gemini'); - registerGeminiAgent(); -} - +// Note: ACP backend creation is catalog-driven (see `@/agent/acp/createCatalogAcpBackend`). diff --git a/cli/src/agent/permissions/BasePermissionHandler.allowlist.test.ts b/cli/src/agent/permissions/BasePermissionHandler.allowlist.test.ts new file mode 100644 index 000000000..afa641622 --- /dev/null +++ b/cli/src/agent/permissions/BasePermissionHandler.allowlist.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect } from 'vitest'; +import { BasePermissionHandler, type PermissionResult } from './BasePermissionHandler'; + +class FakeRpcHandlerManager { + handlers = new Map any>(); + registerHandler(_name: string, handler: any) { + this.handlers.set(_name, handler); + } +} + +class FakeSession { + rpcHandlerManager = new FakeRpcHandlerManager(); + agentState: any = { requests: {}, completedRequests: {} }; + + updateAgentState(updater: any) { + this.agentState = updater(this.agentState); + return this.agentState; + } +} + +class TestPermissionHandler extends BasePermissionHandler { + protected getLogPrefix(): string { + return '[Test]'; + } + + request(toolCallId: string, toolName: string, input: unknown): Promise { + return new Promise((resolve, reject) => { + this.pendingRequests.set(toolCallId, { resolve, reject, toolName, input }); + this.addPendingRequestToState(toolCallId, toolName, input); + }); + } + + isAllowed(toolName: string, input: unknown): boolean { + return this.isAllowedForSession(toolName, input); + } +} + +describe('BasePermissionHandler allowlist', () => { + it('remembers approved_for_session tool identifiers and clears them on reset', async () => { + const session = new FakeSession(); + const handler = new TestPermissionHandler(session as any); + + const input = { command: ['bash', '-lc', 'echo hello'] }; + const promise = handler.request('perm-1', 'bash', input); + + const rpc = session.rpcHandlerManager.handlers.get('permission'); + expect(rpc).toBeDefined(); + await rpc!({ id: 'perm-1', approved: true, decision: 'approved_for_session' }); + + const result = await promise; + expect(result.decision).toBe('approved_for_session'); + expect(handler.isAllowed('bash', input)).toBe(true); + + handler.reset(); + expect(handler.isAllowed('bash', input)).toBe(false); + }); + + it('invokes onAbortRequested when user responds with abort', async () => { + const session = new FakeSession(); + let aborted = false; + const handler = new TestPermissionHandler(session as any, { + onAbortRequested: () => { + aborted = true; + }, + }); + + const promise = handler.request('perm-1', 'read', { filepath: '/tmp/x' }); + + const rpc = session.rpcHandlerManager.handlers.get('permission'); + expect(rpc).toBeDefined(); + await rpc!({ id: 'perm-1', approved: false, decision: 'abort' }); + + const result = await promise; + expect(result.decision).toBe('abort'); + expect(aborted).toBe(true); + expect(session.agentState.completedRequests['perm-1']).toEqual( + expect.objectContaining({ + status: 'denied', + decision: 'abort', + }) + ); + }); +}); diff --git a/cli/src/agent/permissions/BasePermissionHandler.toolTrace.test.ts b/cli/src/agent/permissions/BasePermissionHandler.toolTrace.test.ts new file mode 100644 index 000000000..557f12f34 --- /dev/null +++ b/cli/src/agent/permissions/BasePermissionHandler.toolTrace.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { mkdtempSync, readFileSync, existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { BasePermissionHandler, type PermissionResult } from './BasePermissionHandler'; +import { __resetToolTraceForTests } from '@/agent/tools/trace/toolTrace'; + +class FakeRpcHandlerManager { + handlers = new Map any>(); + registerHandler(_name: string, handler: any) { + this.handlers.set(_name, handler); + } +} + +class FakeSession { + sessionId = 'test-session-id'; + rpcHandlerManager = new FakeRpcHandlerManager(); + agentState: any = { requests: {}, completedRequests: {} }; + + updateAgentState(updater: any) { + this.agentState = updater(this.agentState); + return this.agentState; + } + + getAgentStateSnapshot() { + return this.agentState; + } +} + +class TestPermissionHandler extends BasePermissionHandler { + protected getLogPrefix(): string { + return '[Test]'; + } + + request(toolCallId: string, toolName: string, input: unknown): Promise { + return new Promise((resolve, reject) => { + this.pendingRequests.set(toolCallId, { resolve, reject, toolName, input }); + this.addPendingRequestToState(toolCallId, toolName, input); + }); + } +} + +describe('BasePermissionHandler tool trace', () => { + afterEach(() => { + delete process.env.HAPPY_STACKS_TOOL_TRACE; + delete process.env.HAPPY_STACKS_TOOL_TRACE_FILE; + __resetToolTraceForTests(); + }); + + it('records permission-request events when tool tracing is enabled', async () => { + const dir = mkdtempSync(join(tmpdir(), 'happy-tool-trace-permissions-')); + const filePath = join(dir, 'tool-trace.jsonl'); + process.env.HAPPY_STACKS_TOOL_TRACE = '1'; + process.env.HAPPY_STACKS_TOOL_TRACE_FILE = filePath; + + const session = new FakeSession(); + const handler = new TestPermissionHandler(session as any, { + toolTrace: { protocol: 'codex', provider: 'codex' }, + } as any); + + void handler.request('perm-1', 'bash', { command: ['bash', '-lc', 'echo hello'] }); + + expect(existsSync(filePath)).toBe(true); + const raw = readFileSync(filePath, 'utf8'); + const lines = raw.trim().split('\n'); + expect(lines).toHaveLength(1); + expect(JSON.parse(lines[0])).toMatchObject({ + v: 1, + direction: 'outbound', + sessionId: 'test-session-id', + protocol: 'codex', + provider: 'codex', + kind: 'permission-request', + payload: expect.objectContaining({ + type: 'permission-request', + permissionId: 'perm-1', + toolName: 'bash', + }), + }); + }); + + it('records permission-response events when a permission is resolved', async () => { + const dir = mkdtempSync(join(tmpdir(), 'happy-tool-trace-permissions-')); + const filePath = join(dir, 'tool-trace.jsonl'); + process.env.HAPPY_STACKS_TOOL_TRACE = '1'; + process.env.HAPPY_STACKS_TOOL_TRACE_FILE = filePath; + + const session = new FakeSession(); + const handler = new TestPermissionHandler(session as any, { + toolTrace: { protocol: 'codex', provider: 'codex' }, + } as any); + + const pending = handler.request('perm-1', 'bash', { command: ['bash', '-lc', 'echo hello'] }); + const rpcHandler = session.rpcHandlerManager.handlers.get('permission'); + expect(rpcHandler).toBeDefined(); + + await rpcHandler?.({ id: 'perm-1', approved: true, decision: 'approved' }); + await pending; + + expect(existsSync(filePath)).toBe(true); + const raw = readFileSync(filePath, 'utf8'); + const lines = raw.trim().split('\n'); + expect(lines).toHaveLength(2); + expect(JSON.parse(lines[1])).toMatchObject({ + v: 1, + direction: 'inbound', + sessionId: 'test-session-id', + protocol: 'codex', + provider: 'codex', + kind: 'permission-response', + payload: { + type: 'permission-response', + permissionId: 'perm-1', + approved: true, + decision: 'approved', + }, + }); + }); +}); diff --git a/cli/src/agent/permissions/BasePermissionHandler.ts b/cli/src/agent/permissions/BasePermissionHandler.ts new file mode 100644 index 000000000..3fcf62006 --- /dev/null +++ b/cli/src/agent/permissions/BasePermissionHandler.ts @@ -0,0 +1,362 @@ +/** + * Base Permission Handler + * + * Abstract base class for permission handlers that manage tool approval requests. + * Shared by Codex and Gemini permission handlers. + * + * @module BasePermissionHandler + */ + +import { logger } from "@/ui/logger"; +import { ApiSessionClient } from "@/api/apiSession"; +import { AgentState } from "@/api/types"; +import { isToolAllowedForSession, makeToolIdentifier } from './permissionToolIdentifier'; +import { recordToolTraceEvent, type ToolTraceProtocol } from '@/agent/tools/trace/toolTrace'; + +/** + * Permission response from the mobile app. + */ +export interface PermissionResponse { + id: string; + approved: boolean; + decision?: 'approved' | 'approved_for_session' | 'approved_execpolicy_amendment' | 'denied' | 'abort'; + // When the user chooses "don't ask again (session)", the UI may send a tool allowlist. + allowedTools?: string[]; + allowTools?: string[]; // legacy alias + execPolicyAmendment?: { + command: string[]; + }; +} + +/** + * Pending permission request stored while awaiting user response. + */ +export interface PendingRequest { + resolve: (value: PermissionResult) => void; + reject: (error: Error) => void; + toolName: string; + input: unknown; +} + +/** + * Result of a permission request. + */ +export interface PermissionResult { + decision: 'approved' | 'approved_for_session' | 'approved_execpolicy_amendment' | 'denied' | 'abort'; + execPolicyAmendment?: { + command: string[]; + }; +} + +/** + * Abstract base class for permission handlers. + * + * Subclasses must implement: + * - `getLogPrefix()` - returns the log prefix (e.g., '[Codex]') + */ +export abstract class BasePermissionHandler { + protected pendingRequests = new Map(); + protected session: ApiSessionClient; + private isResetting = false; + private allowedToolIdentifiers = new Set(); + private readonly onAbortRequested: (() => void | Promise) | null; + private readonly toolTrace: { protocol: ToolTraceProtocol; provider: string } | null; + + /** + * Returns the log prefix for this handler. + */ + protected abstract getLogPrefix(): string; + + constructor( + session: ApiSessionClient, + opts?: { + onAbortRequested?: (() => void | Promise) | null; + toolTrace?: { protocol: ToolTraceProtocol; provider: string } | null; + } + ) { + this.session = session; + this.onAbortRequested = typeof opts?.onAbortRequested === 'function' ? opts.onAbortRequested : null; + this.toolTrace = + opts?.toolTrace && typeof opts.toolTrace === 'object' + ? { + protocol: opts.toolTrace.protocol, + provider: opts.toolTrace.provider, + } + : null; + this.setupRpcHandler(); + this.seedAllowedToolsFromAgentState(); + } + + /** + * Update the session reference (used after offline reconnection swaps sessions). + * This is critical for avoiding stale session references after onSessionSwap. + */ + updateSession(newSession: ApiSessionClient): void { + logger.debug(`${this.getLogPrefix()} Session reference updated`); + this.session = newSession; + // Re-setup RPC handler with new session + this.setupRpcHandler(); + this.seedAllowedToolsFromAgentState(); + } + + private seedAllowedToolsFromAgentState(): void { + try { + const snapshot = this.session.getAgentStateSnapshot?.() ?? null; + const completed = snapshot?.completedRequests; + if (!completed) return; + + for (const entry of Object.values(completed)) { + if (!entry || entry.status !== 'approved') continue; + // Legacy sessions may still have `allowTools`; prefer canonical `allowedTools`. + const list = (entry as any).allowedTools ?? (entry as any).allowTools; + if (!Array.isArray(list)) continue; + for (const item of list) { + if (typeof item === 'string' && item.trim().length > 0) { + this.allowedToolIdentifiers.add(item.trim()); + } + } + } + } catch (error) { + logger.debug(`${this.getLogPrefix()} Failed to seed allowlist from agentState`, error); + } + } + + /** + * Setup RPC handler for permission responses. + */ + protected setupRpcHandler(): void { + this.session.rpcHandlerManager.registerHandler( + 'permission', + async (response) => { + const pending = this.pendingRequests.get(response.id); + if (!pending) { + logger.debug(`${this.getLogPrefix()} Permission request not found or already resolved`); + return; + } + + // Remove from pending + this.pendingRequests.delete(response.id); + + // Resolve the permission request + let result: PermissionResult; + + if (response.approved) { + const wantsExecpolicyAmendment = response.decision === 'approved_execpolicy_amendment' + && Boolean(response.execPolicyAmendment?.command?.length); + + if (wantsExecpolicyAmendment) { + result = { + decision: 'approved_execpolicy_amendment', + execPolicyAmendment: response.execPolicyAmendment, + }; + } else if (response.decision === 'approved_for_session') { + result = { decision: 'approved_for_session' }; + } else { + result = { decision: 'approved' }; + } + } else { + result = { decision: response.decision === 'denied' ? 'denied' : 'abort' }; + } + + // Per-session allowlist: if user chooses "approved_for_session", remember this tool (and for + // shell/exec tools, remember the exact command) so future prompts can auto-approve. + const responseAllowedTools = response.allowedTools ?? response.allowTools; + if (response.approved) { + if (Array.isArray(responseAllowedTools)) { + for (const item of responseAllowedTools) { + if (typeof item === 'string' && item.trim().length > 0) { + this.allowedToolIdentifiers.add(item.trim()); + } + } + } else if (result.decision === 'approved_for_session') { + this.allowedToolIdentifiers.add(makeToolIdentifier(pending.toolName, pending.input)); + } + } + + pending.resolve(result); + + if (this.toolTrace) { + recordToolTraceEvent({ + direction: 'inbound', + sessionId: this.session.sessionId, + protocol: this.toolTrace.protocol, + provider: this.toolTrace.provider, + kind: 'permission-response', + payload: { + type: 'permission-response', + permissionId: response.id, + approved: response.approved, + decision: result.decision, + }, + }); + } + + if (result.decision === 'abort') { + try { + const cb = this.onAbortRequested; + if (cb) { + Promise.resolve(cb()).catch((error) => { + logger.debug(`${this.getLogPrefix()} onAbortRequested failed (non-fatal)`, error); + }); + } + } catch (error) { + logger.debug(`${this.getLogPrefix()} onAbortRequested threw (non-fatal)`, error); + } + } + + const derivedAllowTools = + Array.isArray(responseAllowedTools) + ? responseAllowedTools + : (result.decision === 'approved_for_session' + ? [makeToolIdentifier(pending.toolName, pending.input)] + : undefined); + + // Move request to completed in agent state + this.session.updateAgentState((currentState) => { + const request = currentState.requests?.[response.id]; + if (!request) return currentState; + + const { [response.id]: _, ...remainingRequests } = currentState.requests || {}; + + let res = { + ...currentState, + requests: remainingRequests, + completedRequests: { + ...currentState.completedRequests, + [response.id]: { + ...request, + completedAt: Date.now(), + status: response.approved ? 'approved' : 'denied', + decision: result.decision, + // Persist allowlist for the UI and for future CLI reconnects. + ...(derivedAllowTools ? { allowedTools: derivedAllowTools } : null), + } + } + } satisfies AgentState; + return res; + }); + + logger.debug(`${this.getLogPrefix()} Permission ${response.approved ? 'approved' : 'denied'} for ${pending.toolName}`); + } + ); + } + + protected isAllowedForSession(toolName: string, input: unknown): boolean { + return isToolAllowedForSession(this.allowedToolIdentifiers, toolName, input); + } + + protected recordAutoDecision( + toolCallId: string, + toolName: string, + input: unknown, + decision: PermissionResult['decision'] + ): void { + const allowedTools = decision === 'approved_for_session' + ? [makeToolIdentifier(toolName, input)] + : undefined; + this.session.updateAgentState((currentState) => ({ + ...currentState, + completedRequests: { + ...currentState.completedRequests, + [toolCallId]: { + tool: toolName, + arguments: input, + createdAt: Date.now(), + completedAt: Date.now(), + status: decision === 'denied' || decision === 'abort' ? 'denied' : 'approved', + decision, + ...(allowedTools ? { allowedTools } : null), + }, + }, + })); + } + + /** + * Add a pending request to the agent state. + */ + protected addPendingRequestToState(toolCallId: string, toolName: string, input: unknown): void { + if (this.toolTrace) { + recordToolTraceEvent({ + direction: 'outbound', + sessionId: this.session.sessionId, + protocol: this.toolTrace.protocol, + provider: this.toolTrace.provider, + kind: 'permission-request', + payload: { + type: 'permission-request', + permissionId: toolCallId, + toolName, + description: `${toolName} permission`, + options: { input }, + }, + }); + } + + this.session.updateAgentState((currentState) => ({ + ...currentState, + requests: { + ...currentState.requests, + [toolCallId]: { + tool: toolName, + arguments: input, + createdAt: Date.now() + } + } + })); + } + + /** + * Reset state for new sessions. + * This method is idempotent - safe to call multiple times. + */ + reset(): void { + // Guard against re-entrant/concurrent resets + if (this.isResetting) { + logger.debug(`${this.getLogPrefix()} Reset already in progress, skipping`); + return; + } + this.isResetting = true; + + try { + // Snapshot pending requests to avoid Map mutation during iteration + const pendingSnapshot = Array.from(this.pendingRequests.entries()); + this.pendingRequests.clear(); // Clear immediately to prevent new entries being processed + + // Reject all pending requests from snapshot + for (const [id, pending] of pendingSnapshot) { + try { + pending.reject(new Error('Session reset')); + } catch (err) { + logger.debug(`${this.getLogPrefix()} Error rejecting pending request ${id}:`, err); + } + } + + // Clear requests in agent state + this.session.updateAgentState((currentState) => { + const pendingRequests = currentState.requests || {}; + const completedRequests = { ...currentState.completedRequests }; + + // Move all pending to completed as canceled + for (const [id, request] of Object.entries(pendingRequests)) { + completedRequests[id] = { + ...request, + completedAt: Date.now(), + status: 'canceled', + reason: 'Session reset' + }; + } + + return { + ...currentState, + requests: {}, + completedRequests + }; + }); + + this.allowedToolIdentifiers.clear(); + logger.debug(`${this.getLogPrefix()} Permission handler reset`); + } finally { + this.isResetting = false; + } + } +} diff --git a/cli/src/agent/permissions/permissionToolIdentifier.test.ts b/cli/src/agent/permissions/permissionToolIdentifier.test.ts new file mode 100644 index 000000000..78e159fff --- /dev/null +++ b/cli/src/agent/permissions/permissionToolIdentifier.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from 'vitest'; +import { extractShellCommand, isToolAllowedForSession, makeToolIdentifier } from './permissionToolIdentifier'; + +describe('permissionToolIdentifier', () => { + it('extracts command from bash -lc wrapper arrays', () => { + expect(extractShellCommand({ command: ['bash', '-lc', 'echo hello'] })).toBe('echo hello'); + }); + + it('joins command arrays when not a shell wrapper', () => { + expect(extractShellCommand({ command: ['git', 'status', '--porcelain'] })).toBe('git status --porcelain'); + }); + + it('extracts command from items[] wrapper', () => { + expect(extractShellCommand({ items: ['bash', '-lc', 'echo hello'] })).toBe('echo hello'); + }); + + it('builds a specific identifier for bash with a command', () => { + expect(makeToolIdentifier('bash', { command: ['bash', '-lc', 'echo hello'] })).toBe('bash(echo hello)'); + }); + + it('keeps non-shell tool identifiers as toolName only', () => { + expect(makeToolIdentifier('read', { path: 'foo' })).toBe('read'); + }); + + it('accepts shell-tool synonyms for exact matches', () => { + const allowed = new Set(['execute(git status)']); + expect(isToolAllowedForSession(allowed, 'bash', { command: 'git status' })).toBe(true); + }); + + it('accepts shell-tool synonyms for prefix matches', () => { + const allowed = new Set(['execute(git status:*)']); + expect(isToolAllowedForSession(allowed, 'bash', { command: 'git status --porcelain' })).toBe(true); + }); + + it('accepts prefix matches even with leading env assignments', () => { + const allowed = new Set(['execute(git:*)']); + expect(isToolAllowedForSession(allowed, 'bash', { command: 'FOO=bar git status --porcelain' })).toBe(true); + }); + + it('does not treat chained commands as allowed unless each segment is allowed', () => { + const allowed = new Set(['execute(git:*)']); + expect(isToolAllowedForSession(allowed, 'bash', { command: 'git status && rm -rf /tmp/x' })).toBe(false); + }); + + it('allows chained commands when each segment is allowed', () => { + const allowed = new Set(['execute(git:*)', 'execute(rm:*)']); + expect(isToolAllowedForSession(allowed, 'bash', { command: 'git status && rm -rf /tmp/x' })).toBe(true); + }); + + it('does not treat pipelines as allowed unless each segment is allowed', () => { + const allowed = new Set(['execute(git:*)']); + expect(isToolAllowedForSession(allowed, 'bash', { command: 'git diff | cat' })).toBe(false); + }); + + it('allows pipelines when each segment is allowed', () => { + const allowed = new Set(['execute(git:*)', 'execute(cat:*)']); + expect(isToolAllowedForSession(allowed, 'bash', { command: 'git diff | cat' })).toBe(true); + }); +}); diff --git a/cli/src/agent/permissions/permissionToolIdentifier.ts b/cli/src/agent/permissions/permissionToolIdentifier.ts new file mode 100644 index 000000000..2d126d79a --- /dev/null +++ b/cli/src/agent/permissions/permissionToolIdentifier.ts @@ -0,0 +1,121 @@ +import { isShellCommandAllowed } from './shellCommandAllowlist'; + +type UnknownRecord = Record; + +function asRecord(value: unknown): UnknownRecord | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + return value as UnknownRecord; +} + +function extractStringArray(value: unknown): string[] | null { + if (!Array.isArray(value)) return null; + const out: string[] = []; + for (const item of value) { + if (typeof item !== 'string') return null; + out.push(item); + } + return out; +} + +const SHELL_TOOL_NAMES = new Set(['bash', 'execute', 'shell']); + +function isShellToolName(name: string): boolean { + return SHELL_TOOL_NAMES.has(name.toLowerCase()); +} + +function parseParenIdentifier(value: string): { name: string; spec: string } | null { + const match = value.match(/^([^(]+)\((.*)\)$/); + if (!match) return null; + return { name: match[1], spec: match[2] }; +} + +export function extractShellCommand(input: unknown): string | null { + const obj = asRecord(input); + if (!obj) return null; + + const command = obj.command; + if (typeof command === 'string' && command.trim().length > 0) return command.trim(); + + const cmdArray = extractStringArray(command); + if (cmdArray && cmdArray.length > 0) { + if ( + cmdArray.length >= 3 + && (cmdArray[0] === 'bash' || cmdArray[0] === '/bin/bash' || cmdArray[0] === 'zsh' || cmdArray[0] === '/bin/zsh') + && cmdArray[1] === '-lc' + && typeof cmdArray[2] === 'string' + ) { + return cmdArray[2]; + } + return cmdArray.join(' '); + } + + const cmd = obj.cmd; + if (typeof cmd === 'string' && cmd.trim().length > 0) return cmd.trim(); + const cmdArray2 = extractStringArray(cmd); + if (cmdArray2 && cmdArray2.length > 0) return extractShellCommand({ command: cmdArray2 }); + + const argvArray = extractStringArray(obj.argv); + if (argvArray && argvArray.length > 0) return extractShellCommand({ command: argvArray }); + + const itemsArray = extractStringArray(obj.items); + if (itemsArray && itemsArray.length > 0) return extractShellCommand({ command: itemsArray }); + + const toolCall = asRecord(obj.toolCall); + const rawInput = toolCall ? asRecord(toolCall.rawInput) : null; + if (rawInput) return extractShellCommand(rawInput); + + return null; +} + +export function makeToolIdentifier(toolName: string, input: unknown): string { + const command = extractShellCommand(input); + if (command && isShellToolName(toolName)) { + return `${toolName}(${command})`; + } + return toolName; +} + +export function isToolAllowedForSession( + allowedIdentifiers: Iterable, + toolName: string, + input: unknown +): boolean { + const command = extractShellCommand(input); + const isShell = isShellToolName(toolName); + + // Fast path: exact match on canonical identifier. + const exact = makeToolIdentifier(toolName, input); + for (const item of allowedIdentifiers) { + if (item === exact) return true; + } + + // Shell tools: accept per-command identifiers across shell-tool synonyms and prefix patterns. + if (isShell && command) { + const patterns: Array<{ kind: 'exact'; value: string } | { kind: 'prefix'; value: string }> = []; + for (const item of allowedIdentifiers) { + if (typeof item !== 'string') continue; + const parsed = parseParenIdentifier(item); + if (!parsed) continue; + if (!isShellToolName(parsed.name)) continue; + + const spec = parsed.spec; + if (spec.endsWith(':*')) { + const prefix = spec.slice(0, -2).trim(); + if (prefix) patterns.push({ kind: 'prefix', value: prefix }); + } else if (spec.trim().length > 0) { + patterns.push({ kind: 'exact', value: spec.trim() }); + } + } + + if (patterns.length > 0 && isShellCommandAllowed(command, patterns)) return true; + } + + // Non-shell tools: allow direct tool-name identifiers (legacy). + if (!isShell) { + for (const item of allowedIdentifiers) { + if (item === toolName) return true; + } + } + + return false; +} diff --git a/cli/src/agent/permissions/shellCommandAllowlist.ts b/cli/src/agent/permissions/shellCommandAllowlist.ts new file mode 100644 index 000000000..d3e64fc1f --- /dev/null +++ b/cli/src/agent/permissions/shellCommandAllowlist.ts @@ -0,0 +1,173 @@ +type ShellSplitResult = + | { ok: true; segments: string[] } + | { ok: false }; + +function matchesPrefixTokenBoundary(command: string, prefix: string): boolean { + if (!command || !prefix) return false; + if (!command.startsWith(prefix)) return false; + if (command.length === prefix.length) return true; + if (prefix.endsWith(' ')) return true; + return command[prefix.length] === ' '; +} + +/** + * Strip a simple leading env-prelude like `FOO=bar BAR=baz ...` from a shell command. + * + * This intentionally does NOT try to implement full shell parsing. It's a UX-oriented helper + * to reduce duplicate approvals for common env-var prefixes. + * + * Note: quoted assignment values (e.g. `FOO="bar baz" cmd`) are not supported and may be stripped + * incorrectly. This is acceptable for this UX-oriented best-effort helper. + */ +export function stripSimpleEnvPrelude(command: string): string { + const parts = command.trim().split(/\s+/); + let i = 0; + while (i < parts.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(parts[i])) { + i++; + } + return parts.slice(i).join(' '); +} + +/** + * Split a shell command into top-level segments by control operators (`&&`, `||`, `|`, `;`, `&`, newlines). + * + * This is a conservative parser: + * - It respects single/double quotes and backslash escapes. + * - If we detect command substitution/backticks (`$(` or `` ` ``), we fail-closed (ok: false). + * - If quotes are unbalanced, we fail-closed (ok: false). + */ +export function splitShellCommandTopLevel(command: string): ShellSplitResult { + const src = command.trim(); + if (!src) return { ok: true, segments: [] }; + + let inSingle = false; + let inDouble = false; + let escaped = false; + + const segments: string[] = []; + let current = ''; + + const flush = () => { + const trimmed = current.trim(); + if (trimmed.length > 0) segments.push(trimmed); + current = ''; + }; + + for (let i = 0; i < src.length; i++) { + const ch = src[i]; + + if (escaped) { + current += ch; + escaped = false; + continue; + } + + if (ch === '\\') { + current += ch; + escaped = true; + continue; + } + + if (!inDouble && ch === '\'') { + inSingle = !inSingle; + current += ch; + continue; + } + + if (!inSingle && ch === '"') { + inDouble = !inDouble; + current += ch; + continue; + } + + if (!inSingle && !inDouble) { + // Fail-closed on command substitution / backticks: too hard to reason safely. + if (ch === '`') return { ok: false }; + if (ch === '$' && src[i + 1] === '(') return { ok: false }; + + // Control operators. + if (ch === '\n' || ch === ';') { + flush(); + continue; + } + + if (ch === '&') { + if (src[i + 1] === '&') i++; // consume second & + flush(); + continue; + } + + if (ch === '|') { + if (src[i + 1] === '|') i++; // consume second | + flush(); + continue; + } + } + + current += ch; + } + + if (escaped || inSingle || inDouble) return { ok: false }; + flush(); + return { ok: true, segments }; +} + +type ShellAllowPattern = + | { kind: 'exact'; value: string } + | { kind: 'prefix'; value: string }; + +function isSegmentAllowed(segment: string, patterns: ShellAllowPattern[]): boolean { + const raw = segment.trim(); + if (!raw) return false; + + for (const p of patterns) { + if (p.kind === 'exact') { + if (raw === p.value) return true; + } + } + + const effective = stripSimpleEnvPrelude(raw); + const firstWord = effective.split(/\s+/).filter(Boolean)[0] ?? ''; + + for (const p of patterns) { + if (p.kind !== 'prefix') continue; + const normalizedPrefix = stripSimpleEnvPrelude(p.value).trim(); + if (!normalizedPrefix) continue; + + // Command-name pattern: `git:*` should match `git ...` but not `github ...`. + if (!/\s/.test(normalizedPrefix)) { + if (firstWord === normalizedPrefix) return true; + continue; + } + + if (matchesPrefixTokenBoundary(effective, normalizedPrefix)) return true; + } + + return false; +} + +/** + * Check whether a shell command is allowed by a set of explicit allow patterns. + * + * If the command contains control operators, we only allow it when *every* top-level segment is allowed. + * This prevents `git:*` from accidentally allowing `git status && rm -rf ...`. + */ +export function isShellCommandAllowed(command: string, patterns: ShellAllowPattern[]): boolean { + const raw = command.trim(); + if (!raw) return false; + + // Exact match on the full command always wins (including chained/piped commands). + for (const p of patterns) { + if (p.kind === 'exact' && p.value === raw) return true; + } + + const split = splitShellCommandTopLevel(raw); + if (!split.ok) return false; + + // If there are no operators, split.segments will be [raw] and this behaves like a normal match. + for (const segment of split.segments) { + if (!isSegmentAllowed(segment, patterns)) return false; + } + + return split.segments.length > 0; +} diff --git a/cli/src/agent/runtime/changeTitleInstruction.ts b/cli/src/agent/runtime/changeTitleInstruction.ts new file mode 100644 index 000000000..bd0c15656 --- /dev/null +++ b/cli/src/agent/runtime/changeTitleInstruction.ts @@ -0,0 +1,11 @@ +import { trimIdent } from '@/utils/trimIdent'; + +/** + * Instruction for changing chat title. + * + * Used in system prompts to instruct agents to call `functions.happy__change_title`. + */ +export const CHANGE_TITLE_INSTRUCTION = trimIdent( + `Based on this message, call functions.happy__change_title to change chat session title that would represent the current task. If chat idea would change dramatically - call this function again to update the title.` +); + diff --git a/cli/src/agent/runtime/createBaseSessionForAttach.ts b/cli/src/agent/runtime/createBaseSessionForAttach.ts new file mode 100644 index 000000000..da813cc86 --- /dev/null +++ b/cli/src/agent/runtime/createBaseSessionForAttach.ts @@ -0,0 +1,29 @@ +import type { AgentState, Metadata, Session as ApiSession } from '@/api/types'; +import { readSessionAttachFromEnv } from '@/agent/runtime/sessionAttach'; + +export async function createBaseSessionForAttach(opts: { + existingSessionId: string; + metadata: Metadata; + state: AgentState; +}): Promise { + const existingSessionId = opts.existingSessionId.trim(); + if (!existingSessionId) { + throw new Error('Missing existingSessionId'); + } + + const attach = await readSessionAttachFromEnv(); + if (!attach) { + throw new Error(`Cannot resume session ${existingSessionId}: missing session attach secret`); + } + + return { + id: existingSessionId, + seq: 0, + encryptionKey: attach.encryptionKey, + encryptionVariant: attach.encryptionVariant, + metadata: opts.metadata, + metadataVersion: -1, + agentState: opts.state, + agentStateVersion: -1, + }; +} diff --git a/cli/src/agent/runtime/createSessionMetadata.test.ts b/cli/src/agent/runtime/createSessionMetadata.test.ts new file mode 100644 index 000000000..2671c3c93 --- /dev/null +++ b/cli/src/agent/runtime/createSessionMetadata.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('@/configuration', () => ({ + configuration: { + happyHomeDir: '/tmp/happy-home', + }, +})); + +vi.mock('@/projectPath', () => ({ + projectPath: () => '/tmp/happy-lib', +})); + +vi.mock('../../package.json', () => ({ + default: { version: '0.0.0-test' }, +})); + +import { createSessionMetadata } from './createSessionMetadata'; + +describe('createSessionMetadata', () => { + it('seeds messageQueueV1 so the app can safely detect queue support', () => { + const { metadata } = createSessionMetadata({ + flavor: 'claude', + machineId: 'machine-1', + startedBy: 'terminal', + }); + + expect(metadata.messageQueueV1).toEqual({ + v: 1, + queue: [], + }); + }); +}); + diff --git a/cli/src/utils/createSessionMetadata.ts b/cli/src/agent/runtime/createSessionMetadata.ts similarity index 59% rename from cli/src/utils/createSessionMetadata.ts rename to cli/src/agent/runtime/createSessionMetadata.ts index 4511b0c83..0d6273e3c 100644 --- a/cli/src/utils/createSessionMetadata.ts +++ b/cli/src/agent/runtime/createSessionMetadata.ts @@ -10,15 +10,19 @@ import os from 'node:os'; import { resolve } from 'node:path'; -import type { AgentState, Metadata } from '@/api/types'; +import type { AgentId } from '@happy/agents'; + +import type { AgentState, Metadata, PermissionMode } from '@/api/types'; import { configuration } from '@/configuration'; import { projectPath } from '@/projectPath'; -import packageJson from '../../package.json'; +import packageJson from '../../../package.json'; +import type { TerminalRuntimeFlags } from '@/terminal/terminalRuntimeFlags'; +import { buildTerminalMetadataFromRuntimeFlags } from '@/terminal/terminalMetadata'; /** * Backend flavor identifier for session metadata. */ -export type BackendFlavor = 'claude' | 'codex' | 'gemini'; +export type BackendFlavor = AgentId; /** * Options for creating session metadata. @@ -28,8 +32,16 @@ export interface CreateSessionMetadataOptions { flavor: BackendFlavor; /** Machine ID for server identification */ machineId: string; + /** Working directory for the session (defaults to process.cwd()). */ + directory?: string; /** How the session was started */ startedBy?: 'daemon' | 'terminal'; + /** Internal terminal runtime flags passed by the spawner (daemon/tmux wrapper). */ + terminalRuntime?: TerminalRuntimeFlags | null; + /** Initial permission mode to publish for the session (optional) */ + permissionMode?: PermissionMode; + /** Timestamp (ms) for permissionMode, used for arbitration across devices (optional) */ + permissionModeUpdatedAt?: number; } /** @@ -67,11 +79,16 @@ export function createSessionMetadata(opts: CreateSessionMetadataOptions): Sessi controlledByUser: false, }; + const profileIdEnv = process.env.HAPPY_SESSION_PROFILE_ID; + const profileId = profileIdEnv === undefined ? undefined : (profileIdEnv.trim() || null); + const metadata: Metadata = { - path: process.cwd(), + path: opts.directory ?? process.cwd(), host: os.hostname(), version: packageJson.version, os: os.platform(), + ...(opts.terminalRuntime ? { terminal: buildTerminalMetadataFromRuntimeFlags(opts.terminalRuntime) } : {}), + ...(profileIdEnv !== undefined ? { profileId } : {}), machineId: opts.machineId, homeDir: os.homedir(), happyHomeDir: configuration.happyHomeDir, @@ -82,7 +99,12 @@ export function createSessionMetadata(opts: CreateSessionMetadataOptions): Sessi startedBy: opts.startedBy || 'terminal', lifecycleState: 'running', lifecycleStateSince: Date.now(), - flavor: opts.flavor + flavor: opts.flavor, + ...(opts.permissionMode && { permissionMode: opts.permissionMode }), + ...(typeof opts.permissionModeUpdatedAt === 'number' && { permissionModeUpdatedAt: opts.permissionModeUpdatedAt }), + // Seed messageQueueV1 so the app can detect queue support without relying on machine capabilities. + // Older CLIs won't write this field, so the app will fall back to direct send. + messageQueueV1: { v: 1, queue: [] }, }; return { state, metadata }; diff --git a/cli/src/agent/runtime/mergeSessionMetadataForStartup.test.ts b/cli/src/agent/runtime/mergeSessionMetadataForStartup.test.ts new file mode 100644 index 000000000..3f2c12c49 --- /dev/null +++ b/cli/src/agent/runtime/mergeSessionMetadataForStartup.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest'; + +import { mergeSessionMetadataForStartup } from './mergeSessionMetadataForStartup'; + +describe('mergeSessionMetadataForStartup', () => { + it('seeds messageQueueV1 when missing', () => { + const nowMs = 123; + const merged = mergeSessionMetadataForStartup({ + current: { lifecycleState: 'archived' } as any, + next: { hostPid: 1 } as any, + nowMs, + }); + + expect(merged.messageQueueV1).toEqual({ v: 1, queue: [] }); + expect(merged.lifecycleState).toBe('running'); + expect(merged.lifecycleStateSince).toBe(nowMs); + }); + + it('preserves existing messageQueueV1 contents when next metadata seeds an empty queue', () => { + const nowMs = 999; + const merged = mergeSessionMetadataForStartup({ + current: { messageQueueV1: { v: 1, queue: [{ localId: 'a' }] } } as any, + next: { messageQueueV1: { v: 1, queue: [] } } as any, + nowMs, + }); + + expect(merged.messageQueueV1?.queue).toEqual([{ localId: 'a' }]); + }); + + it('prefers next messageQueueV1 when current is invalid', () => { + const nowMs = 1; + const merged = mergeSessionMetadataForStartup({ + current: { messageQueueV1: { v: 0, queue: 'nope' } } as any, + next: { messageQueueV1: { v: 1, queue: [{ localId: 'b' }] } } as any, + nowMs, + }); + + expect(merged.messageQueueV1?.queue).toEqual([{ localId: 'b' }]); + }); + + it('preserves existing provider resume ids when next does not define them', () => { + const nowMs = 1; + const merged = mergeSessionMetadataForStartup({ + current: { geminiSessionId: 'g1', codexSessionId: 'c1' } as any, + next: { hostPid: 2 } as any, + nowMs, + }); + + expect((merged as any).geminiSessionId).toBe('g1'); + expect((merged as any).codexSessionId).toBe('c1'); + expect(merged.hostPid).toBe(2); + }); + + it('preserves permissionMode when no override is provided', () => { + const nowMs = 50; + const merged = mergeSessionMetadataForStartup({ + current: { permissionMode: 'ask', permissionModeUpdatedAt: 10 } as any, + next: { permissionMode: 'default', permissionModeUpdatedAt: 20 } as any, + nowMs, + }); + + expect(merged.permissionMode).toBe('ask'); + expect(merged.permissionModeUpdatedAt).toBe(10); + }); + + it('applies explicit permissionMode override when it is newer than existing metadata', () => { + const nowMs = 50; + const merged = mergeSessionMetadataForStartup({ + current: { permissionMode: 'ask', permissionModeUpdatedAt: 10 } as any, + next: { permissionMode: 'default', permissionModeUpdatedAt: 20 } as any, + nowMs, + permissionModeOverride: { mode: 'default', updatedAt: 25 }, + }); + + expect(merged.permissionMode).toBe('default'); + expect(merged.permissionModeUpdatedAt).toBe(25); + }); + + it('ensures permissionModeUpdatedAt is monotonic when an override is provided with an older timestamp', () => { + const nowMs = 50; + const merged = mergeSessionMetadataForStartup({ + current: { permissionMode: 'ask', permissionModeUpdatedAt: 100 } as any, + next: {} as any, + nowMs, + permissionModeOverride: { mode: 'default', updatedAt: 1 }, + }); + + expect(merged.permissionMode).toBe('default'); + expect(merged.permissionModeUpdatedAt).toBe(101); + }); +}); + diff --git a/cli/src/agent/runtime/mergeSessionMetadataForStartup.ts b/cli/src/agent/runtime/mergeSessionMetadataForStartup.ts new file mode 100644 index 000000000..7328aec68 --- /dev/null +++ b/cli/src/agent/runtime/mergeSessionMetadataForStartup.ts @@ -0,0 +1,106 @@ +import type { Metadata, PermissionMode } from '@/api/types'; + +export type PermissionModeOverride = { + mode: PermissionMode; + updatedAt?: number | null; +}; + +function isValidMessageQueueV1(value: unknown): value is NonNullable { + if (!value || typeof value !== 'object') return false; + const v = value as any; + return v.v === 1 && Array.isArray(v.queue); +} + +function resolveMessageQueueV1(opts: { + current: Metadata['messageQueueV1'] | undefined; + next: Metadata['messageQueueV1'] | undefined; +}): NonNullable { + if (isValidMessageQueueV1(opts.current)) return opts.current; + if (isValidMessageQueueV1(opts.next)) return opts.next; + return { v: 1, queue: [] }; +} + +function resolvePermissionModeForStartup(opts: { + current: Metadata; + next: Metadata; + nowMs: number; + override?: PermissionModeOverride | null; +}): { mode: PermissionMode; updatedAt: number } | null { + const currentMode = opts.current.permissionMode; + const currentAt = typeof opts.current.permissionModeUpdatedAt === 'number' ? opts.current.permissionModeUpdatedAt : null; + + const nextMode = opts.next.permissionMode; + const nextAt = typeof opts.next.permissionModeUpdatedAt === 'number' ? opts.next.permissionModeUpdatedAt : null; + + let mode: PermissionMode | null = null; + let updatedAt: number | null = null; + + if (currentMode) { + mode = currentMode; + updatedAt = currentAt; + } else if (nextMode) { + mode = nextMode; + updatedAt = nextAt; + } else { + return null; + } + + if (updatedAt === null) { + updatedAt = opts.nowMs; + } + + const override = opts.override; + if (override) { + const overrideAt = typeof override.updatedAt === 'number' ? override.updatedAt : opts.nowMs; + if (overrideAt <= updatedAt) { + if (override.mode === mode) { + return { mode, updatedAt }; + } + return { mode: override.mode, updatedAt: updatedAt + 1 }; + } + return { mode: override.mode, updatedAt: overrideAt }; + } + + return { mode, updatedAt }; +} + +/** + * Merge session metadata at process startup (new session or resume attach). + * + * Key invariants: + * - messageQueueV1 is seeded only if missing/invalid; never reset when present. + * - permissionMode is preserved unless an explicit override is provided. + * - lifecycleState is set to running. + */ +export function mergeSessionMetadataForStartup(opts: { + current: Metadata; + next: Metadata; + nowMs: number; + permissionModeOverride?: PermissionModeOverride | null; +}): Metadata { + const merged: Metadata = { + ...opts.current, + ...opts.next, + lifecycleState: 'running', + lifecycleStateSince: opts.nowMs, + }; + + merged.messageQueueV1 = resolveMessageQueueV1({ + current: opts.current.messageQueueV1, + next: opts.next.messageQueueV1, + }); + + const perm = resolvePermissionModeForStartup({ + current: opts.current, + next: opts.next, + nowMs: opts.nowMs, + override: opts.permissionModeOverride, + }); + if (perm) { + merged.permissionMode = perm.mode; + merged.permissionModeUpdatedAt = perm.updatedAt; + } + + return merged; +} + diff --git a/cli/src/agent/runtime/permissionModeMetadata.test.ts b/cli/src/agent/runtime/permissionModeMetadata.test.ts new file mode 100644 index 000000000..c4689142f --- /dev/null +++ b/cli/src/agent/runtime/permissionModeMetadata.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect, vi } from 'vitest'; +import { maybeUpdatePermissionModeMetadata } from './permissionModeMetadata'; + +describe('maybeUpdatePermissionModeMetadata', () => { + it("doesn't bump permissionModeUpdatedAt when permission mode is unchanged", () => { + const updateMetadata = vi.fn(); + const nowMs = () => 123; + + const res = maybeUpdatePermissionModeMetadata({ + currentPermissionMode: 'acceptEdits', + nextPermissionMode: 'acceptEdits', + updateMetadata, + nowMs, + }); + + expect(res).toEqual({ didChange: false, currentPermissionMode: 'acceptEdits' }); + expect(updateMetadata).not.toHaveBeenCalled(); + }); + + it('bumps permissionModeUpdatedAt when permission mode changes', () => { + const updateMetadata = vi.fn(); + const nowMs = () => 456; + + const res = maybeUpdatePermissionModeMetadata({ + currentPermissionMode: 'default', + nextPermissionMode: 'bypassPermissions', + updateMetadata, + nowMs, + }); + + expect(res).toEqual({ didChange: true, currentPermissionMode: 'bypassPermissions' }); + expect(updateMetadata).toHaveBeenCalledTimes(1); + const updater = updateMetadata.mock.calls[0]?.[0]; + expect(typeof updater).toBe('function'); + expect(updater({ somethingElse: 1 })).toEqual({ + somethingElse: 1, + permissionMode: 'bypassPermissions', + permissionModeUpdatedAt: 456, + }); + }); +}); + diff --git a/cli/src/agent/runtime/permissionModeMetadata.ts b/cli/src/agent/runtime/permissionModeMetadata.ts new file mode 100644 index 000000000..fbb884cde --- /dev/null +++ b/cli/src/agent/runtime/permissionModeMetadata.ts @@ -0,0 +1,22 @@ +import type { PermissionMode } from '@/api/types'; + +export function maybeUpdatePermissionModeMetadata(opts: { + currentPermissionMode: PermissionMode | undefined; + nextPermissionMode: PermissionMode; + updateMetadata: (updater: (current: any) => any) => void; + nowMs?: () => number; +}): { didChange: boolean; currentPermissionMode: PermissionMode } { + if (opts.currentPermissionMode === opts.nextPermissionMode) { + return { didChange: false, currentPermissionMode: opts.nextPermissionMode }; + } + + const nowMs = opts.nowMs ?? Date.now; + opts.updateMetadata((current) => ({ + ...current, + permissionMode: opts.nextPermissionMode, + permissionModeUpdatedAt: nowMs(), + })); + + return { didChange: true, currentPermissionMode: opts.nextPermissionMode }; +} + diff --git a/cli/src/agent/runtime/sessionAttach.test.ts b/cli/src/agent/runtime/sessionAttach.test.ts new file mode 100644 index 000000000..65c182901 --- /dev/null +++ b/cli/src/agent/runtime/sessionAttach.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test, vi } from 'vitest'; + +describe('readSessionAttachFromEnv', () => { + test('reads, validates, and deletes attach file', async () => { + const { mkdtemp, writeFile, stat } = await import('node:fs/promises'); + const { tmpdir } = await import('node:os'); + const { join } = await import('node:path'); + + const dir = await mkdtemp(join(tmpdir(), 'happy-attach-')); + process.env.HAPPY_HOME_DIR = dir; + + vi.resetModules(); + + const { encodeBase64 } = await import('@/api/encryption'); + const { readSessionAttachFromEnv } = await import('./sessionAttach'); + + const attachDir = join(dir, 'tmp'); + await (await import('node:fs/promises')).mkdir(attachDir, { recursive: true }); + const filePath = join(attachDir, 'attach.json'); + + const key = new Uint8Array(32).fill(9); + const payload = { + encryptionKeyBase64: encodeBase64(key, 'base64'), + encryptionVariant: 'dataKey', + }; + + await writeFile(filePath, JSON.stringify(payload), { mode: 0o600 }); + process.env.HAPPY_SESSION_ATTACH_FILE = filePath; + + const res = await readSessionAttachFromEnv(); + expect(res?.encryptionVariant).toBe('dataKey'); + expect(res?.encryptionKey).toEqual(key); + + // File should be deleted. + await expect(stat(filePath)).rejects.toBeTruthy(); + }); +}); + diff --git a/cli/src/agent/runtime/sessionAttach.ts b/cli/src/agent/runtime/sessionAttach.ts new file mode 100644 index 000000000..b1fb0d9be --- /dev/null +++ b/cli/src/agent/runtime/sessionAttach.ts @@ -0,0 +1,58 @@ +import { decodeBase64 } from '@/api/encryption'; +import { configuration } from '@/configuration'; +import { logger } from '@/ui/logger'; +import { readFile, unlink, stat } from 'node:fs/promises'; +import { resolve, sep } from 'node:path'; +import * as z from 'zod'; + +const SessionAttachPayloadSchema = z.object({ + encryptionKeyBase64: z.string().min(1), + encryptionVariant: z.union([z.literal('legacy'), z.literal('dataKey')]), +}); + +export type SessionAttachPayload = z.infer; + +export async function readSessionAttachFromEnv(): Promise<{ encryptionKey: Uint8Array; encryptionVariant: 'legacy' | 'dataKey' } | null> { + const rawPath = typeof process.env.HAPPY_SESSION_ATTACH_FILE === 'string' ? process.env.HAPPY_SESSION_ATTACH_FILE.trim() : ''; + if (!rawPath) return null; + + const filePath = resolve(rawPath); + + // Basic safety: require attach file to live within HAPPY_HOME_DIR. + // This prevents accidental reads from arbitrary locations when a user sets env vars manually. + if (!filePath.startsWith(resolve(configuration.happyHomeDir) + sep)) { + throw new Error('Invalid session attach file location'); + } + + try { + if (process.platform !== 'win32') { + const s = await stat(filePath); + // Ensure file is not readable by group/others (0600). + if ((s.mode & 0o077) !== 0) { + throw new Error('Session attach file permissions are too permissive'); + } + } + + const raw = await readFile(filePath, 'utf-8'); + const parsed = SessionAttachPayloadSchema.safeParse(JSON.parse(raw)); + if (!parsed.success) { + logger.debug('[sessionAttach] Failed to parse attach file', parsed.error); + throw new Error('Invalid session attach file'); + } + + const payload = parsed.data; + const key = decodeBase64(payload.encryptionKeyBase64, 'base64'); + if (key.length !== 32) { + throw new Error('Invalid session encryption key length'); + } + + return { encryptionKey: key, encryptionVariant: payload.encryptionVariant }; + } finally { + // Best-effort cleanup to keep the key short-lived on disk. + try { + await unlink(filePath); + } catch { + // ignore + } + } +} diff --git a/cli/src/agent/runtime/signalForwarding.test.ts b/cli/src/agent/runtime/signalForwarding.test.ts new file mode 100644 index 000000000..14f521cc3 --- /dev/null +++ b/cli/src/agent/runtime/signalForwarding.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it, vi } from 'vitest'; +import { EventEmitter } from 'node:events'; +import { attachProcessSignalForwardingToChild } from './signalForwarding'; + +class FakeProc { + platform: NodeJS.Platform; + pid = 999; + handlers = new Map void)[]>(); + off = vi.fn((event: string, handler: () => void) => { + const list = this.handlers.get(event) ?? []; + this.handlers.set(event, list.filter((h) => h !== handler)); + }); + kill = vi.fn(); + + constructor(platform: NodeJS.Platform) { + this.platform = platform; + } + + on = vi.fn((event: string, handler: () => void) => { + const list = this.handlers.get(event) ?? []; + list.push(handler); + this.handlers.set(event, list); + }); +} + +class FakeChild extends EventEmitter { + pid = 123; + killed = false; + kill = vi.fn(); +} + +describe('attachProcessSignalForwardingToChild', () => { + it('removes process signal listeners when the child emits error', () => { + const proc = new FakeProc('darwin'); + const child = new FakeChild() as any; + + attachProcessSignalForwardingToChild(child, proc as any); + + expect(proc.on).toHaveBeenCalledWith('SIGTERM', expect.any(Function)); + expect(proc.on).toHaveBeenCalledWith('SIGINT', expect.any(Function)); + expect(proc.on).toHaveBeenCalledWith('SIGHUP', expect.any(Function)); + + child.emit('error', new Error('spawn failed')); + + expect(proc.off).toHaveBeenCalledWith('SIGTERM', expect.any(Function)); + expect(proc.off).toHaveBeenCalledWith('SIGINT', expect.any(Function)); + expect(proc.off).toHaveBeenCalledWith('SIGHUP', expect.any(Function)); + }); + + it('does not register SIGHUP on Windows', () => { + const proc = new FakeProc('win32'); + const child = new FakeChild() as any; + + attachProcessSignalForwardingToChild(child, proc as any); + + expect(proc.handlers.has('SIGHUP')).toBe(false); + }); + + it('forwards SIGINT to the child without swallowing the parent signal', () => { + const proc = new FakeProc('darwin'); + const child = new FakeChild() as any; + + attachProcessSignalForwardingToChild(child, proc as any); + + const handler = (proc.handlers.get('SIGINT') ?? [])[0]; + expect(typeof handler).toBe('function'); + + handler(); + + expect(child.kill).toHaveBeenCalledWith('SIGINT'); + expect(proc.kill).toHaveBeenCalledWith(proc.pid, 'SIGINT'); + expect(proc.off).toHaveBeenCalledWith('SIGINT', expect.any(Function)); + }); +}); diff --git a/cli/src/agent/runtime/signalForwarding.ts b/cli/src/agent/runtime/signalForwarding.ts new file mode 100644 index 000000000..a888bce13 --- /dev/null +++ b/cli/src/agent/runtime/signalForwarding.ts @@ -0,0 +1,47 @@ +import type { ChildProcess } from 'node:child_process'; + +type SignalForwardingProcess = Pick; + +export function attachProcessSignalForwardingToChild( + child: ChildProcess, + proc: SignalForwardingProcess = process, +): void { + const forwardSignal = (signal: NodeJS.Signals) => { + if (child.pid && !child.killed) { + child.kill(signal); + } + }; + + const signals: NodeJS.Signals[] = ['SIGTERM', 'SIGINT']; + if (proc.platform !== 'win32') { + signals.push('SIGHUP'); + } + + let cleanedUp = false; + const handlers = new Map void>(); + const cleanup = () => { + if (cleanedUp) return; + cleanedUp = true; + for (const [signal, handler] of handlers.entries()) { + proc.off(signal, handler); + } + }; + + for (const signal of signals) { + const handler = () => { + forwardSignal(signal); + cleanup(); + try { + proc.kill(proc.pid, signal); + } catch { + // ignore + } + }; + handlers.set(signal, handler); + proc.on(signal, handler); + } + + child.on('exit', cleanup); + child.on('close', cleanup); + child.on('error', cleanup); +} diff --git a/cli/src/agent/runtime/startupMetadataUpdate.test.ts b/cli/src/agent/runtime/startupMetadataUpdate.test.ts new file mode 100644 index 000000000..3adca5ba5 --- /dev/null +++ b/cli/src/agent/runtime/startupMetadataUpdate.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; + +import type { Metadata } from '@/api/types'; + +import { + applyStartupMetadataUpdateToSession, + buildPermissionModeOverride, +} from './startupMetadataUpdate'; + +describe('startupMetadataUpdate', () => { + it('returns null when no explicit permissionMode is provided', () => { + expect(buildPermissionModeOverride({})).toBeNull(); + }); + + it('builds a permissionMode override when permissionMode is provided', () => { + expect(buildPermissionModeOverride({ permissionMode: 'yolo', permissionModeUpdatedAt: 123 })).toEqual({ + mode: 'yolo', + updatedAt: 123, + }); + }); + + it('applies mergeSessionMetadataForStartup via session.updateMetadata', () => { + const updates: Metadata[] = []; + const fakeSession = { + updateMetadata: (updater: (current: Metadata) => Metadata) => { + const current = { + lifecycleState: 'archived', + messageQueueV1: { v: 1, queue: [{ localId: 'a', message: 'hello' }] }, + } as any as Metadata; + updates.push(updater(current)); + }, + }; + + applyStartupMetadataUpdateToSession({ + session: fakeSession, + next: { hostPid: 42, messageQueueV1: { v: 1, queue: [] } } as any, + nowMs: 999, + permissionModeOverride: null, + }); + + expect(updates).toHaveLength(1); + expect(updates[0].lifecycleState).toBe('running'); + expect((updates[0] as any).hostPid).toBe(42); + expect((updates[0] as any).messageQueueV1?.queue).toEqual([{ localId: 'a', message: 'hello' }]); + }); +}); + diff --git a/cli/src/agent/runtime/startupMetadataUpdate.ts b/cli/src/agent/runtime/startupMetadataUpdate.ts new file mode 100644 index 000000000..5989e9982 --- /dev/null +++ b/cli/src/agent/runtime/startupMetadataUpdate.ts @@ -0,0 +1,37 @@ +import type { Metadata, PermissionMode } from '@/api/types'; + +import { mergeSessionMetadataForStartup } from './mergeSessionMetadataForStartup'; + +export type PermissionModeOverride = { + mode: PermissionMode; + updatedAt?: number; +} | null; + +export function buildPermissionModeOverride(opts: { + permissionMode?: PermissionMode; + permissionModeUpdatedAt?: number; +}): PermissionModeOverride { + if (typeof opts.permissionMode !== 'string') { + return null; + } + return { mode: opts.permissionMode, updatedAt: opts.permissionModeUpdatedAt }; +} + +export function applyStartupMetadataUpdateToSession(opts: { + session: { updateMetadata: (updater: (current: Metadata) => Metadata) => void }; + next: Metadata; + nowMs?: number; + permissionModeOverride: PermissionModeOverride; +}): void { + const nowMs = typeof opts.nowMs === 'number' ? opts.nowMs : Date.now(); + + opts.session.updateMetadata((currentMetadata) => + mergeSessionMetadataForStartup({ + current: currentMetadata, + next: opts.next, + nowMs, + permissionModeOverride: opts.permissionModeOverride ?? null, + }) + ); +} + diff --git a/cli/src/agent/runtime/startupSideEffects.ts b/cli/src/agent/runtime/startupSideEffects.ts new file mode 100644 index 000000000..41a306193 --- /dev/null +++ b/cli/src/agent/runtime/startupSideEffects.ts @@ -0,0 +1,62 @@ +import type { ApiSessionClient } from '@/api/apiSession'; +import type { Metadata } from '@/api/types'; +import { configuration } from '@/configuration'; +import { notifyDaemonSessionStarted } from '@/daemon/controlClient'; +import { writeTerminalAttachmentInfo } from '@/terminal/terminalAttachmentInfo'; +import { buildTerminalFallbackMessage } from '@/terminal/terminalFallbackMessage'; +import { logger } from '@/ui/logger'; + +export function primeAgentStateForUi(session: ApiSessionClient, logPrefix: string): void { + // Bump agentStateVersion early so the UI can reliably treat the agent as "ready" to receive messages. + // The server does not currently persist agentState during initial session creation; it starts at version 0 + // and only changes via 'update-state'. The UI uses agentStateVersion > 0 as its readiness signal. + try { + session.updateAgentState((currentState) => ({ ...currentState })); + } catch (e) { + logger.debug(`${logPrefix} Failed to prime agent state (non-fatal)`, e); + } +} + +export async function persistTerminalAttachmentInfoIfNeeded(opts: { + sessionId: string; + terminal: Metadata['terminal'] | undefined; +}): Promise { + if (!opts.terminal) return; + try { + await writeTerminalAttachmentInfo({ + happyHomeDir: configuration.happyHomeDir, + sessionId: opts.sessionId, + terminal: opts.terminal, + }); + } catch (error) { + logger.debug('[START] Failed to persist terminal attachment info', error); + } +} + +export function sendTerminalFallbackMessageIfNeeded(opts: { + session: ApiSessionClient; + terminal: Metadata['terminal'] | undefined; +}): void { + if (!opts.terminal) return; + const fallbackMessage = buildTerminalFallbackMessage(opts.terminal); + if (!fallbackMessage) return; + opts.session.sendSessionEvent({ type: 'message', message: fallbackMessage }); +} + +export async function reportSessionToDaemonIfRunning(opts: { + sessionId: string; + metadata: Metadata; +}): Promise { + try { + logger.debug(`[START] Reporting session ${opts.sessionId} to daemon`); + const result = await notifyDaemonSessionStarted(opts.sessionId, opts.metadata); + if (result.error) { + logger.debug(`[START] Failed to report to daemon (may not be running):`, result.error); + } else { + logger.debug(`[START] Reported session ${opts.sessionId} to daemon`); + } + } catch (error) { + logger.debug('[START] Failed to report to daemon (may not be running):', error); + } +} + diff --git a/cli/src/agent/runtime/waitForMessagesOrPending.test.ts b/cli/src/agent/runtime/waitForMessagesOrPending.test.ts new file mode 100644 index 000000000..2421fc554 --- /dev/null +++ b/cli/src/agent/runtime/waitForMessagesOrPending.test.ts @@ -0,0 +1,110 @@ +import { describe, it, expect } from 'vitest'; +import { MessageQueue2 } from '@/utils/MessageQueue2'; +import { waitForMessagesOrPending } from './waitForMessagesOrPending'; + +describe('waitForMessagesOrPending', () => { + it('returns immediately when a queue message exists', async () => { + type Mode = { id: string }; + const mode: Mode = { id: 'm1' }; + + const queue = new MessageQueue2(() => 'hash'); + queue.pushImmediate('hello', mode); + + const result = await waitForMessagesOrPending({ + messageQueue: queue, + abortSignal: new AbortController().signal, + popPendingMessage: async () => false, + waitForMetadataUpdate: async () => false, + }); + + expect(result?.message).toBe('hello'); + }); + + it('wakes on metadata update and then processes a pending item', async () => { + type Mode = { id: string }; + const mode: Mode = { id: 'm1' }; + + const queue = new MessageQueue2(() => 'hash'); + + let pendingText: string | null = null; + const popPendingMessage = async () => { + if (!pendingText) return false; + const text = pendingText; + pendingText = null; + queue.pushImmediate(text, mode); + return true; + }; + + const metadataWaiters: Array<(ok: boolean) => void> = []; + const waitForMetadataUpdate = async (abortSignal?: AbortSignal) => { + if (abortSignal?.aborted) return false; + return await new Promise((resolve) => { + const onAbort = () => resolve(false); + abortSignal?.addEventListener('abort', onAbort, { once: true }); + metadataWaiters.push((ok) => { + abortSignal?.removeEventListener('abort', onAbort); + resolve(ok); + }); + }); + }; + + const abortController = new AbortController(); + const promise = waitForMessagesOrPending({ + messageQueue: queue, + abortSignal: abortController.signal, + popPendingMessage, + waitForMetadataUpdate, + }); + + // Wait until the helper is actually listening for a metadata update. + for (let i = 0; i < 50 && metadataWaiters.length === 0; i++) { + await new Promise((r) => setTimeout(r, 0)); + } + expect(metadataWaiters.length).toBeGreaterThan(0); + + pendingText = 'from-pending'; + // Wake the waiter as if metadata changed due to a new pending enqueue. + metadataWaiters.shift()?.(true); + + const result = await promise; + expect(result?.message).toBe('from-pending'); + }); + + it('does not hang when abort races with listener registration', async () => { + type Mode = { id: string }; + const mode: Mode = { id: 'm1' }; + const queue = new MessageQueue2(() => 'hash'); + + let aborted = false; + const abortSignal = { + get aborted() { + return aborted; + }, + addEventListener: () => { + aborted = true; + }, + removeEventListener: () => { }, + } as any as AbortSignal; + + const waitForMetadataUpdate = async (signal?: AbortSignal) => { + if (signal?.aborted) return false; + return await new Promise((resolve) => { + signal?.addEventListener('abort', () => resolve(false), { once: true } as any); + }); + }; + + const p = waitForMessagesOrPending({ + messageQueue: queue, + abortSignal, + popPendingMessage: async () => false, + waitForMetadataUpdate, + }); + + await expect( + Promise.race([ + p, + new Promise((_, reject) => setTimeout(() => reject(new Error('waitForMessagesOrPending hung')), 50)), + ]), + ).resolves.toBeNull(); + }); +}); diff --git a/cli/src/agent/runtime/waitForMessagesOrPending.ts b/cli/src/agent/runtime/waitForMessagesOrPending.ts new file mode 100644 index 000000000..b6563ae31 --- /dev/null +++ b/cli/src/agent/runtime/waitForMessagesOrPending.ts @@ -0,0 +1,62 @@ +import { MessageQueue2 } from '@/utils/MessageQueue2'; + +export type MessageBatch = { + message: string; + mode: T; + isolate: boolean; + hash: string; +}; + +export async function waitForMessagesOrPending(opts: { + messageQueue: MessageQueue2; + abortSignal: AbortSignal; + popPendingMessage: () => Promise; + waitForMetadataUpdate: (abortSignal?: AbortSignal) => Promise; +}): Promise | null> { + while (true) { + if (opts.abortSignal.aborted) { + return null; + } + + // Fast path + if (opts.messageQueue.size() > 0) { + return await opts.messageQueue.waitForMessagesAndGetAsString(opts.abortSignal); + } + + // Give pending queue a chance to materialize a message before we park. + await opts.popPendingMessage(); + + // If queue is still empty, wait for either: + // - a new transcript message (via normal update delivery), OR + // - a metadata change (e.g. a new pending enqueue) + const controller = new AbortController(); + const onAbort = () => controller.abort(); + opts.abortSignal.addEventListener('abort', onAbort, { once: true }); + if (opts.abortSignal.aborted) { + controller.abort(); + } + + try { + const winner = await Promise.race([ + opts.messageQueue + .waitForMessagesAndGetAsString(controller.signal) + .then((batch) => ({ kind: 'batch' as const, batch })), + opts.waitForMetadataUpdate(controller.signal).then((ok) => ({ kind: 'meta' as const, ok })), + ]); + + controller.abort('waitForMessagesOrPending'); + + if (winner.kind === 'batch') { + return winner.batch; + } + + if (!winner.ok) { + return null; + } + + // Metadata updated – loop to try popPendingMessage again. + } finally { + opts.abortSignal.removeEventListener('abort', onAbort); + } + } +} diff --git a/cli/src/agent/tools/trace/extractToolTraceFixtures.test.ts b/cli/src/agent/tools/trace/extractToolTraceFixtures.test.ts new file mode 100644 index 000000000..1af05963f --- /dev/null +++ b/cli/src/agent/tools/trace/extractToolTraceFixtures.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; +import { extractToolTraceFixturesFromJsonlLines } from './extractToolTraceFixtures'; + +describe('extractToolTraceFixturesFromJsonlLines', () => { + it('groups tool events by protocol/provider/kind/tool name', () => { + const fixtures = extractToolTraceFixturesFromJsonlLines([ + JSON.stringify({ + v: 1, + ts: 1, + direction: 'outbound', + sessionId: 's1', + protocol: 'acp', + provider: 'opencode', + kind: 'tool-call', + payload: { type: 'tool-call', name: 'read', input: { filePath: '/etc/hosts' } }, + }), + JSON.stringify({ + v: 1, + ts: 2, + direction: 'outbound', + sessionId: 's1', + protocol: 'acp', + provider: 'opencode', + kind: 'message', + payload: { type: 'message', message: 'hello' }, + }), + JSON.stringify({ + v: 1, + ts: 3, + direction: 'outbound', + sessionId: 's1', + protocol: 'codex', + provider: 'codex', + kind: 'tool-call', + payload: { type: 'tool-call', name: 'CodexBash', input: { command: 'ls' } }, + }), + ]); + + expect(fixtures.v).toBe(1); + expect(Object.keys(fixtures.examples)).toEqual( + expect.arrayContaining(['acp/opencode/tool-call/read', 'codex/codex/tool-call/CodexBash']) + ); + expect(fixtures.examples['acp/opencode/tool-call/read']).toHaveLength(1); + expect(fixtures.examples['codex/codex/tool-call/CodexBash']).toHaveLength(1); + expect(fixtures.examples['acp/opencode/message']).toBeUndefined(); + }); +}); + diff --git a/cli/src/agent/tools/trace/extractToolTraceFixtures.ts b/cli/src/agent/tools/trace/extractToolTraceFixtures.ts new file mode 100644 index 000000000..3dcecafc8 --- /dev/null +++ b/cli/src/agent/tools/trace/extractToolTraceFixtures.ts @@ -0,0 +1,103 @@ +import type { ToolTraceEventV1 } from './toolTrace'; + +export type ToolTraceFixturesV1 = { + v: 1; + generatedAt: number; + examples: Record; +}; + +function isRecordableKind(kind: string): boolean { + return ( + kind === 'tool-call' || + kind === 'tool-result' || + kind === 'tool-call-result' || + kind === 'permission-request' || + kind === 'file-edit' || + kind === 'terminal-output' + ); +} + +function getToolNameForKey(event: ToolTraceEventV1): string | null { + if (event.kind === 'tool-call') { + const payload = event.payload as any; + const name = payload?.name; + return typeof name === 'string' && name.length > 0 ? name : null; + } + if (event.kind === 'permission-request') { + const payload = event.payload as any; + const toolName = payload?.toolName; + return typeof toolName === 'string' && toolName.length > 0 ? toolName : null; + } + return null; +} + +function truncateDeep(value: unknown, opts?: { maxString?: number; maxArray?: number; maxObjectKeys?: number }): unknown { + const maxString = opts?.maxString ?? 2_000; + const maxArray = opts?.maxArray ?? 50; + const maxObjectKeys = opts?.maxObjectKeys ?? 200; + + if (typeof value === 'string') { + if (value.length <= maxString) return value; + return `${value.slice(0, maxString)}…(truncated ${value.length - maxString} chars)`; + } + + if (typeof value !== 'object' || value === null) return value; + + if (Array.isArray(value)) { + const sliced = value.slice(0, maxArray).map((v) => truncateDeep(v, opts)); + if (value.length <= maxArray) return sliced; + return [...sliced, `…(truncated ${value.length - maxArray} items)`]; + } + + const entries = Object.entries(value as Record); + const sliced = entries.slice(0, maxObjectKeys); + const out: Record = {}; + for (const [k, v] of sliced) out[k] = truncateDeep(v, opts); + if (entries.length > maxObjectKeys) out._truncatedKeys = entries.length - maxObjectKeys; + return out; +} + +function sanitizeEventForFixture(event: ToolTraceEventV1): ToolTraceEventV1 { + return { + ...event, + payload: truncateDeep(event.payload), + }; +} + +export function extractToolTraceFixturesFromJsonlLines(lines: string[]): ToolTraceFixturesV1 { + const examples: Record = {}; + + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed.length === 0) continue; + + let parsed: unknown; + try { + parsed = JSON.parse(trimmed); + } catch { + continue; + } + + const event = parsed as Partial; + if (event?.v !== 1) continue; + if (typeof event.kind !== 'string' || typeof event.protocol !== 'string') continue; + if (!isRecordableKind(event.kind)) continue; + + const provider = typeof event.provider === 'string' && event.provider.length > 0 ? event.provider : 'unknown'; + const baseKey = `${event.protocol}/${provider}/${event.kind}`; + const toolName = getToolNameForKey(event as ToolTraceEventV1); + const key = toolName ? `${baseKey}/${toolName}` : baseKey; + + const current = examples[key] ?? []; + if (current.length >= 3) continue; + current.push(sanitizeEventForFixture(event as ToolTraceEventV1)); + examples[key] = current; + } + + return { + v: 1, + generatedAt: Date.now(), + examples, + }; +} + diff --git a/cli/src/agent/tools/trace/toolTrace.test.ts b/cli/src/agent/tools/trace/toolTrace.test.ts new file mode 100644 index 000000000..c859c7e81 --- /dev/null +++ b/cli/src/agent/tools/trace/toolTrace.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it, vi } from 'vitest'; +import { mkdtempSync, readFileSync, readdirSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { __resetToolTraceForTests, recordToolTraceEvent, ToolTraceWriter } from './toolTrace'; + +describe('ToolTraceWriter', () => { + it('writes JSONL events', () => { + const dir = mkdtempSync(join(tmpdir(), 'happy-tool-trace-')); + const filePath = join(dir, 'trace.jsonl'); + const writer = new ToolTraceWriter({ filePath }); + + writer.record({ + v: 1, + ts: 1700000000000, + direction: 'outbound', + sessionId: 'sess_123', + protocol: 'acp', + provider: 'codex', + kind: 'tool-call', + payload: { name: 'read', input: { filePath: '/etc/hosts' } }, + }); + + const raw = readFileSync(filePath, 'utf8'); + const lines = raw.trim().split('\n'); + expect(lines).toHaveLength(1); + expect(JSON.parse(lines[0])).toMatchObject({ + v: 1, + sessionId: 'sess_123', + protocol: 'acp', + provider: 'codex', + kind: 'tool-call', + }); + }); +}); + +describe('recordToolTraceEvent', () => { + it('writes multiple events to a single file when only DIR is set', () => { + vi.useFakeTimers(); + const dir = mkdtempSync(join(tmpdir(), 'happy-tool-trace-dir-')); + process.env.HAPPY_STACKS_TOOL_TRACE = '1'; + process.env.HAPPY_STACKS_TOOL_TRACE_DIR = dir; + delete process.env.HAPPY_STACKS_TOOL_TRACE_FILE; + __resetToolTraceForTests(); + + vi.setSystemTime(new Date('2026-01-25T10:00:00.000Z')); + recordToolTraceEvent({ + direction: 'outbound', + sessionId: 'sess_1', + protocol: 'acp', + provider: 'codex', + kind: 'tool-call', + payload: { type: 'tool-call', name: 'read', input: { filePath: '/etc/hosts' } }, + }); + vi.setSystemTime(new Date('2026-01-25T10:00:01.000Z')); + recordToolTraceEvent({ + direction: 'outbound', + sessionId: 'sess_1', + protocol: 'acp', + provider: 'codex', + kind: 'tool-result', + payload: { type: 'tool-result', callId: 'c1', output: { ok: true } }, + }); + + const files = readdirSync(dir).filter((f) => f.endsWith('.jsonl')); + expect(files).toHaveLength(1); + + const raw = readFileSync(join(dir, files[0]), 'utf8'); + expect(raw.trim().split('\n')).toHaveLength(2); + + delete process.env.HAPPY_STACKS_TOOL_TRACE; + delete process.env.HAPPY_STACKS_TOOL_TRACE_DIR; + __resetToolTraceForTests(); + vi.useRealTimers(); + }); +}); diff --git a/cli/src/agent/tools/trace/toolTrace.ts b/cli/src/agent/tools/trace/toolTrace.ts new file mode 100644 index 000000000..23d7f1239 --- /dev/null +++ b/cli/src/agent/tools/trace/toolTrace.ts @@ -0,0 +1,103 @@ +import { appendFileSync, mkdirSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { configuration } from '@/configuration'; + +export type ToolTraceProtocol = 'acp' | 'codex' | 'cloud' | 'claude'; + +export type ToolTraceDirection = 'outbound' | 'inbound'; + +export type ToolTraceEventV1 = { + v: 1; + ts: number; + direction: ToolTraceDirection; + sessionId: string; + protocol: ToolTraceProtocol; + provider?: string; + kind: string; + payload: unknown; + localId?: string; +}; + +export class ToolTraceWriter { + private readonly filePath: string; + + constructor(params: { filePath: string }) { + this.filePath = params.filePath; + mkdirSync(dirname(this.filePath), { recursive: true }); + } + + record(event: ToolTraceEventV1): void { + appendFileSync(this.filePath, `${JSON.stringify(event)}\n`, 'utf8'); + } +} + +function isTruthyEnv(value: string | undefined): boolean { + if (!value) return false; + return ['1', 'true', 'yes', 'on'].includes(value.toLowerCase()); +} + +function resolveToolTraceFilePath(): string { + const fileFromEnv = + process.env.HAPPY_STACKS_TOOL_TRACE_FILE ?? + process.env.HAPPY_LOCAL_TOOL_TRACE_FILE ?? + process.env.HAPPY_TOOL_TRACE_FILE; + if (typeof fileFromEnv === 'string' && fileFromEnv.length > 0) return fileFromEnv; + + const dirFromEnv = + process.env.HAPPY_STACKS_TOOL_TRACE_DIR ?? + process.env.HAPPY_LOCAL_TOOL_TRACE_DIR ?? + process.env.HAPPY_TOOL_TRACE_DIR; + const dir = + typeof dirFromEnv === 'string' && dirFromEnv.length > 0 + ? dirFromEnv + : join(configuration.happyHomeDir, 'tool-traces'); + + if (cachedDefaultTraceFilePath && cachedDefaultTraceDir === dir) return cachedDefaultTraceFilePath; + + const stamp = new Date().toISOString().replaceAll(':', '-').replaceAll('.', '-'); + cachedDefaultTraceDir = dir; + cachedDefaultTraceFilePath = join(dir, `${stamp}-pid-${process.pid}.jsonl`); + return cachedDefaultTraceFilePath; +} + +function isToolTraceEnabled(): boolean { + return ( + isTruthyEnv(process.env.HAPPY_STACKS_TOOL_TRACE) || + isTruthyEnv(process.env.HAPPY_LOCAL_TOOL_TRACE) || + isTruthyEnv(process.env.HAPPY_TOOL_TRACE) + ); +} + +let cachedWriter: ToolTraceWriter | null = null; +let cachedFilePath: string | null = null; +let cachedDefaultTraceFilePath: string | null = null; +let cachedDefaultTraceDir: string | null = null; + +export function recordToolTraceEvent(params: Omit & { ts?: number }): void { + if (!isToolTraceEnabled()) return; + + const filePath = resolveToolTraceFilePath(); + if (!cachedWriter || cachedFilePath !== filePath) { + cachedFilePath = filePath; + cachedWriter = new ToolTraceWriter({ filePath }); + } + + cachedWriter.record({ + v: 1, + ts: typeof params.ts === 'number' ? params.ts : Date.now(), + direction: params.direction, + sessionId: params.sessionId, + protocol: params.protocol, + provider: params.provider, + kind: params.kind, + payload: params.payload, + localId: params.localId, + }); +} + +export function __resetToolTraceForTests(): void { + cachedWriter = null; + cachedFilePath = null; + cachedDefaultTraceFilePath = null; + cachedDefaultTraceDir = null; +} diff --git a/cli/src/agent/transport/DefaultTransport.ts b/cli/src/agent/transport/DefaultTransport.ts index a12dd0b6a..8beac0603 100644 --- a/cli/src/agent/transport/DefaultTransport.ts +++ b/cli/src/agent/transport/DefaultTransport.ts @@ -14,6 +14,7 @@ import type { StderrResult, ToolNameContext, } from './TransportHandler'; +import { filterJsonObjectOrArrayLine } from './utils/jsonStdoutFilter'; /** * Default timeout values (in milliseconds) @@ -57,24 +58,7 @@ export class DefaultTransport implements TransportHandler { * Default: pass through all lines that are valid JSON objects/arrays */ filterStdoutLine(line: string): string | null { - const trimmed = line.trim(); - if (!trimmed) { - return null; - } - // Only pass through lines that start with { or [ (JSON) - if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) { - return null; - } - // Validate it's actually parseable JSON and is an object/array - try { - const parsed = JSON.parse(trimmed); - if (typeof parsed !== 'object' || parsed === null) { - return null; - } - return line; - } catch { - return null; - } + return filterJsonObjectOrArrayLine(line); } /** diff --git a/cli/src/agent/transport/handlers/index.ts b/cli/src/agent/transport/handlers/index.ts deleted file mode 100644 index 8c75763db..000000000 --- a/cli/src/agent/transport/handlers/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * Transport Handler Implementations - * - * Agent-specific transport handlers for different CLI agents. - * - * @module handlers - */ - -export { GeminiTransport, geminiTransport } from './GeminiTransport'; - -// Future handlers: -// export { CodexTransport, codexTransport } from './CodexTransport'; -// export { ClaudeTransport, claudeTransport } from './ClaudeTransport'; -// export { OpenCodeTransport, openCodeTransport } from './OpenCodeTransport'; diff --git a/cli/src/agent/transport/index.ts b/cli/src/agent/transport/index.ts index 0e485ecee..a1f59f302 100644 --- a/cli/src/agent/transport/index.ts +++ b/cli/src/agent/transport/index.ts @@ -18,10 +18,5 @@ export type { // Default implementation export { DefaultTransport, defaultTransport } from './DefaultTransport'; -// Agent-specific handlers -export { GeminiTransport, geminiTransport } from './handlers'; - -// Future handlers will be exported from ./handlers: -// export { CodexTransport, codexTransport } from './handlers'; -// export { ClaudeTransport, claudeTransport } from './handlers'; -// export { OpenCodeTransport, openCodeTransport } from './handlers'; +// Note: provider-specific ACP transport handlers live with the provider +// implementation (e.g. `@/backends/gemini/acp/transport`). diff --git a/cli/src/agent/transport/utils/jsonStdoutFilter.test.ts b/cli/src/agent/transport/utils/jsonStdoutFilter.test.ts new file mode 100644 index 000000000..b4687c9ff --- /dev/null +++ b/cli/src/agent/transport/utils/jsonStdoutFilter.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; + +import { filterJsonObjectOrArrayLine } from './jsonStdoutFilter'; + +describe('filterJsonObjectOrArrayLine', () => { + it('drops empty and whitespace lines', () => { + expect(filterJsonObjectOrArrayLine('')).toBeNull(); + expect(filterJsonObjectOrArrayLine(' \n')).toBeNull(); + }); + + it('drops non-JSON lines', () => { + expect(filterJsonObjectOrArrayLine('hello world')).toBeNull(); + expect(filterJsonObjectOrArrayLine('INFO: started')).toBeNull(); + }); + + it('drops JSON primitives', () => { + expect(filterJsonObjectOrArrayLine('42')).toBeNull(); + expect(filterJsonObjectOrArrayLine('"ok"')).toBeNull(); + expect(filterJsonObjectOrArrayLine('true')).toBeNull(); + expect(filterJsonObjectOrArrayLine('null')).toBeNull(); + }); + + it('keeps JSON objects and arrays', () => { + expect(filterJsonObjectOrArrayLine('{"jsonrpc":"2.0","method":"x"}\n')).toBe('{"jsonrpc":"2.0","method":"x"}\n'); + expect(filterJsonObjectOrArrayLine('[{"jsonrpc":"2.0","method":"x"}]\n')).toBe('[{"jsonrpc":"2.0","method":"x"}]\n'); + }); + + it('drops invalid JSON that looks like JSON', () => { + expect(filterJsonObjectOrArrayLine('{not json}\n')).toBeNull(); + expect(filterJsonObjectOrArrayLine('[1,\n')).toBeNull(); + }); +}); + diff --git a/cli/src/agent/transport/utils/jsonStdoutFilter.ts b/cli/src/agent/transport/utils/jsonStdoutFilter.ts new file mode 100644 index 000000000..6025adaf9 --- /dev/null +++ b/cli/src/agent/transport/utils/jsonStdoutFilter.ts @@ -0,0 +1,28 @@ +/** + * JSON stdout filtering helpers for ACP transports. + * + * ACP messages are sent as ndJSON where each line must be a JSON object (or an array for batches). + * Many CLIs emit debug/progress output on stdout; we must drop those lines to avoid breaking ACP parsing. + */ + +/** + * Returns the original line when it is valid JSON and parses to an object/array; otherwise null. + * Keeps the original `line` string (including its whitespace/newline) to preserve ndJSON framing. + */ +export function filterJsonObjectOrArrayLine(line: string): string | null { + const trimmed = line.trim(); + if (!trimmed) return null; + + // Fast-path: must start like JSON object/array. + if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) return null; + + // Validate it is parseable JSON and not a primitive. + try { + const parsed = JSON.parse(trimmed); + if (typeof parsed !== 'object' || parsed === null) return null; + return line; + } catch { + return null; + } +} + diff --git a/cli/src/agent/transport/utils/toolPatternInference.ts b/cli/src/agent/transport/utils/toolPatternInference.ts new file mode 100644 index 000000000..4178dc508 --- /dev/null +++ b/cli/src/agent/transport/utils/toolPatternInference.ts @@ -0,0 +1,91 @@ +import type { ToolPattern } from '../TransportHandler'; + +export type ToolPatternWithInputFields = ToolPattern & Readonly<{ + /** + * Fields in input that indicate this tool (heuristic). + * Used when the agent reports toolName as "other"/unknown. + */ + inputFields?: readonly string[]; + /** + * When true, this tool is the default when input is empty and the agent reports toolName as "other". + * (Some providers omit inputs for tools like change_title.) + */ + emptyInputDefault?: boolean; +}>; + +function normalizeKey(value: string): string { + return value.trim().toLowerCase(); +} + +export function isEmptyToolInput(input: Record | undefined | null): boolean { + if (!input) return true; + if (Array.isArray(input)) return input.length === 0; + return Object.keys(input).length === 0; +} + +export function findToolNameFromId( + toolCallId: string, + patterns: readonly ToolPatternWithInputFields[], + opts?: Readonly<{ preferLongestMatch?: boolean }>, +): string | null { + const lowerId = toolCallId.toLowerCase(); + const preferLongestMatch = opts?.preferLongestMatch === true; + + if (!preferLongestMatch) { + for (const toolPattern of patterns) { + for (const pattern of toolPattern.patterns) { + if (lowerId.includes(pattern.toLowerCase())) { + return toolPattern.name; + } + } + } + return null; + } + + // Prefer the most-specific match (longest substring). This avoids fragile ordering when IDs contain + // multiple tool substrings (e.g. "write_todos-..." contains "write"). + let bestName: string | null = null; + let bestLen = 0; + + for (const toolPattern of patterns) { + for (const pattern of toolPattern.patterns) { + const needle = pattern.toLowerCase(); + if (!needle) continue; + if (!lowerId.includes(needle)) continue; + if (needle.length > bestLen) { + bestLen = needle.length; + bestName = toolPattern.name; + } + } + } + + return bestName; +} + +export function findToolNameFromInputFields( + input: Record, + patterns: readonly ToolPatternWithInputFields[], +): string | null { + if (!input || typeof input !== 'object' || Array.isArray(input)) return null; + + const inputKeys = new Set(Object.keys(input).map(normalizeKey)); + if (inputKeys.size === 0) return null; + + for (const toolPattern of patterns) { + const fields = toolPattern.inputFields; + if (!fields || fields.length === 0) continue; + if (fields.some((field) => inputKeys.has(normalizeKey(field)))) { + return toolPattern.name; + } + } + + return null; +} + +export function findEmptyInputDefaultToolName( + patterns: readonly ToolPatternWithInputFields[], +): string | null { + const found = patterns.find((p) => p.emptyInputDefault === true); + return found?.name ?? null; +} + diff --git a/cli/src/api/api.test.ts b/cli/src/api/api.test.ts index ab7343257..35b377b73 100644 --- a/cli/src/api/api.test.ts +++ b/cli/src/api/api.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { ApiClient } from './api'; import axios from 'axios'; -import { connectionState } from '@/utils/serverConnectionErrors'; +import { connectionState } from '@/api/offline/serverConnectionErrors'; // Use vi.hoisted to ensure mock functions are available when vi.mock factory runs const { mockPost, mockIsAxiosError } = vi.hoisted(() => ({ @@ -176,64 +176,74 @@ describe('Api server error handling', () => { connectionState.reset(); const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); - // Mock axios to return 500 error - mockPost.mockRejectedValue({ - response: { status: 500 }, - isAxiosError: true - }); - - const result = await api.getOrCreateSession({ - tag: 'test-tag', - metadata: testMetadata, - state: null - }); - - expect(result).toBeNull(); - expect(consoleSpy).toHaveBeenCalledWith( - expect.stringContaining('⚠️ Happy server unreachable') - ); - consoleSpy.mockRestore(); + try { + // Mock axios to return 500 error + mockPost.mockRejectedValue({ + response: { status: 500 }, + isAxiosError: true + }); + + const result = await api.getOrCreateSession({ + tag: 'test-tag', + metadata: testMetadata, + state: null + }); + + expect(result).toBeNull(); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('⚠️ Happy server unreachable') + ); + } finally { + consoleSpy.mockRestore(); + } }); it('should return null when server returns 503 Service Unavailable', async () => { connectionState.reset(); const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); - // Mock axios to return 503 error - mockPost.mockRejectedValue({ - response: { status: 503 }, - isAxiosError: true - }); - - const result = await api.getOrCreateSession({ - tag: 'test-tag', - metadata: testMetadata, - state: null - }); - - expect(result).toBeNull(); - expect(consoleSpy).toHaveBeenCalledWith( - expect.stringContaining('⚠️ Happy server unreachable') - ); - consoleSpy.mockRestore(); + try { + // Mock axios to return 503 error + mockPost.mockRejectedValue({ + response: { status: 503 }, + isAxiosError: true + }); + + const result = await api.getOrCreateSession({ + tag: 'test-tag', + metadata: testMetadata, + state: null + }); + + expect(result).toBeNull(); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('⚠️ Happy server unreachable') + ); + } finally { + consoleSpy.mockRestore(); + } }); it('should re-throw non-connection errors', async () => { - // Mock axios to throw a different type of error (e.g., authentication error) - const authError = new Error('Invalid API key'); - (authError as any).code = 'UNAUTHORIZED'; - mockPost.mockRejectedValue(authError); - - await expect( - api.getOrCreateSession({ tag: 'test-tag', metadata: testMetadata, state: null }) - ).rejects.toThrow('Failed to get or create session: Invalid API key'); - - // Should not show the offline mode message const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); - expect(consoleSpy).not.toHaveBeenCalledWith( - expect.stringContaining('⚠️ Happy server unreachable') - ); - consoleSpy.mockRestore(); + + try { + // Mock axios to throw a different type of error (e.g., authentication error) + const authError = new Error('Invalid API key'); + (authError as any).code = 'UNAUTHORIZED'; + mockPost.mockRejectedValue(authError); + + await expect( + api.getOrCreateSession({ tag: 'test-tag', metadata: testMetadata, state: null }) + ).rejects.toThrow('Failed to get or create session: Invalid API key'); + + // Should not show the offline mode message + expect(consoleSpy).not.toHaveBeenCalledWith( + expect.stringContaining('⚠️ Happy server unreachable') + ); + } finally { + consoleSpy.mockRestore(); + } }); }); @@ -310,4 +320,4 @@ describe('Api server error handling', () => { consoleSpy.mockRestore(); }); }); -}); \ No newline at end of file +}); diff --git a/cli/src/api/api.ts b/cli/src/api/api.ts index fc3811804..f78170cd2 100644 --- a/cli/src/api/api.ts +++ b/cli/src/api/api.ts @@ -3,12 +3,16 @@ import { logger } from '@/ui/logger' import type { AgentState, CreateSessionResponse, Metadata, Session, Machine, MachineMetadata, DaemonState } from '@/api/types' import { ApiSessionClient } from './apiSession'; import { ApiMachineClient } from './apiMachine'; -import { decodeBase64, encodeBase64, getRandomBytes, encrypt, decrypt, libsodiumEncryptForPublicKey } from './encryption'; +import { decodeBase64, encodeBase64, encrypt, decrypt } from './encryption'; import { PushNotificationClient } from './pushNotifications'; import { configuration } from '@/configuration'; -import chalk from 'chalk'; import { Credentials } from '@/persistence'; -import { connectionState, isNetworkError } from '@/utils/serverConnectionErrors'; + +import { resolveMachineEncryptionContext, resolveSessionEncryptionContext } from './client/encryptionKey'; +import { + shouldReturnMinimalMachineForGetOrCreateMachineError, + shouldReturnNullForGetOrCreateSessionError, +} from './client/offlineErrors'; export class ApiClient { @@ -32,28 +36,7 @@ export class ApiClient { metadata: Metadata, state: AgentState | null }): Promise { - - // Resolve encryption key - let dataEncryptionKey: Uint8Array | null = null; - let encryptionKey: Uint8Array; - let encryptionVariant: 'legacy' | 'dataKey'; - if (this.credential.encryption.type === 'dataKey') { - - // Generate new encryption key - encryptionKey = getRandomBytes(32); - encryptionVariant = 'dataKey'; - - // Derive and encrypt data encryption key - // const contentDataKey = await deriveKey(this.secret, 'Happy EnCoder', ['content']); - // const publicKey = libsodiumPublicKeyFromSecretKey(contentDataKey); - let encryptedDataKey = libsodiumEncryptForPublicKey(encryptionKey, this.credential.encryption.publicKey); - dataEncryptionKey = new Uint8Array(encryptedDataKey.length + 1); - dataEncryptionKey.set([0], 0); // Version byte - dataEncryptionKey.set(encryptedDataKey, 1); // Data key - } else { - encryptionKey = this.credential.encryption.secret; - encryptionVariant = 'legacy'; - } + const { encryptionKey, encryptionVariant, dataEncryptionKey } = resolveSessionEncryptionContext(this.credential); // Create session try { @@ -90,48 +73,10 @@ export class ApiClient { } catch (error) { logger.debug('[API] [ERROR] Failed to get or create session:', error); - // Check if it's a connection error - if (error && typeof error === 'object' && 'code' in error) { - const errorCode = (error as any).code; - if (isNetworkError(errorCode)) { - connectionState.fail({ - operation: 'Session creation', - caller: 'api.getOrCreateSession', - errorCode, - url: `${configuration.serverUrl}/v1/sessions` - }); - return null; - } - } - - // Handle 404 gracefully - server endpoint may not be available yet - const is404Error = ( - (axios.isAxiosError(error) && error.response?.status === 404) || - (error && typeof error === 'object' && 'response' in error && (error as any).response?.status === 404) - ); - if (is404Error) { - connectionState.fail({ - operation: 'Session creation', - errorCode: '404', - url: `${configuration.serverUrl}/v1/sessions` - }); + if (shouldReturnNullForGetOrCreateSessionError(error, { url: `${configuration.serverUrl}/v1/sessions` })) { return null; } - // Handle 5xx server errors - use offline mode with auto-reconnect - if (axios.isAxiosError(error) && error.response?.status) { - const status = error.response.status; - if (status >= 500) { - connectionState.fail({ - operation: 'Session creation', - errorCode: String(status), - url: `${configuration.serverUrl}/v1/sessions`, - details: ['Server encountered an error, will retry automatically'] - }); - return null; - } - } - throw new Error(`Failed to get or create session: ${error instanceof Error ? error.message : 'Unknown error'}`); } } @@ -145,24 +90,7 @@ export class ApiClient { metadata: MachineMetadata, daemonState?: DaemonState, }): Promise { - - // Resolve encryption key - let dataEncryptionKey: Uint8Array | null = null; - let encryptionKey: Uint8Array; - let encryptionVariant: 'legacy' | 'dataKey'; - if (this.credential.encryption.type === 'dataKey') { - // Encrypt data encryption key - encryptionVariant = 'dataKey'; - encryptionKey = this.credential.encryption.machineKey; - let encryptedDataKey = libsodiumEncryptForPublicKey(this.credential.encryption.machineKey, this.credential.encryption.publicKey); - dataEncryptionKey = new Uint8Array(encryptedDataKey.length + 1); - dataEncryptionKey.set([0], 0); // Version byte - dataEncryptionKey.set(encryptedDataKey, 1); // Data key - } else { - // Legacy encryption - encryptionKey = this.credential.encryption.secret; - encryptionVariant = 'legacy'; - } + const { encryptionKey, encryptionVariant, dataEncryptionKey } = resolveMachineEncryptionContext(this.credential); // Helper to create minimal machine object for offline mode (DRY) const createMinimalMachine = (): Machine => ({ @@ -210,64 +138,10 @@ export class ApiClient { }; return machine; } catch (error) { - // Handle connection errors gracefully - if (axios.isAxiosError(error) && error.code && isNetworkError(error.code)) { - connectionState.fail({ - operation: 'Machine registration', - caller: 'api.getOrCreateMachine', - errorCode: error.code, - url: `${configuration.serverUrl}/v1/machines` - }); + if (shouldReturnMinimalMachineForGetOrCreateMachineError(error, { url: `${configuration.serverUrl}/v1/machines` })) { return createMinimalMachine(); } - // Handle 403/409 - server rejected request due to authorization conflict - // This is NOT "server unreachable" - server responded, so don't use connectionState - if (axios.isAxiosError(error) && error.response?.status) { - const status = error.response.status; - - if (status === 403 || status === 409) { - // Re-auth conflict: machine registered to old account, re-association not allowed - console.log(chalk.yellow( - `⚠️ Machine registration rejected by the server with status ${status}` - )); - console.log(chalk.yellow( - ` → This machine ID is already registered to another account on the server` - )); - console.log(chalk.yellow( - ` → This usually happens after re-authenticating with a different account` - )); - console.log(chalk.yellow( - ` → Run 'happy doctor clean' to reset local state and generate a new machine ID` - )); - console.log(chalk.yellow( - ` → Open a GitHub issue if this problem persists` - )); - return createMinimalMachine(); - } - - // Handle 5xx - server error, use offline mode with auto-reconnect - if (status >= 500) { - connectionState.fail({ - operation: 'Machine registration', - errorCode: String(status), - url: `${configuration.serverUrl}/v1/machines`, - details: ['Server encountered an error, will retry automatically'] - }); - return createMinimalMachine(); - } - - // Handle 404 - endpoint may not be available yet - if (status === 404) { - connectionState.fail({ - operation: 'Machine registration', - errorCode: '404', - url: `${configuration.serverUrl}/v1/machines` - }); - return createMinimalMachine(); - } - } - // For other errors, rethrow throw error; } diff --git a/cli/src/api/apiMachine.spawnSession.test.ts b/cli/src/api/apiMachine.spawnSession.test.ts new file mode 100644 index 000000000..273f68384 --- /dev/null +++ b/cli/src/api/apiMachine.spawnSession.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from 'vitest'; + +import type { Machine } from '@/api/types'; +import { encodeBase64, encrypt } from '@/api/encryption'; + +import { ApiMachineClient } from './apiMachine'; + +describe('ApiMachineClient spawn-happy-session handler', () => { + it('forwards terminal spawn options to daemon spawnSession handler', async () => { + const machine: Machine = { + id: 'machine-test', + encryptionKey: new Uint8Array(32).fill(7), + encryptionVariant: 'legacy', + metadata: null, + metadataVersion: 0, + daemonState: null, + daemonStateVersion: 0, + }; + + const client = new ApiMachineClient('token', machine); + + let captured: any = null; + client.setRPCHandlers({ + spawnSession: async (options) => { + captured = options; + return { type: 'success', sessionId: 'session-1' }; + }, + stopSession: async () => true, + requestShutdown: () => {}, + }); + + const rpc = (client as any).rpcHandlerManager; + const params = { + directory: '/tmp', + terminal: { mode: 'tmux', tmux: { sessionName: 'happy', isolated: true } }, + }; + const encrypted = encodeBase64(encrypt(machine.encryptionKey, machine.encryptionVariant, params)); + + await rpc.handleRequest({ + method: `${machine.id}:spawn-happy-session`, + params: encrypted, + }); + + expect(captured).toEqual( + expect.objectContaining({ + directory: '/tmp', + terminal: { mode: 'tmux', tmux: { sessionName: 'happy', isolated: true } }, + }), + ); + }); + + it('forwards resume-session vendor resume id to daemon spawnSession handler', async () => { + const machine: Machine = { + id: 'machine-test', + encryptionKey: new Uint8Array(32).fill(7), + encryptionVariant: 'legacy', + metadata: null, + metadataVersion: 0, + daemonState: null, + daemonStateVersion: 0, + }; + + const client = new ApiMachineClient('token', machine); + + let captured: any = null; + client.setRPCHandlers({ + spawnSession: async (options) => { + captured = options; + return { type: 'success', sessionId: 'session-1' }; + }, + stopSession: async () => true, + requestShutdown: () => {}, + }); + + const rpc = (client as any).rpcHandlerManager; + const sessionKeyBase64 = encodeBase64(new Uint8Array(32).fill(3), 'base64'); + const params = { + type: 'resume-session', + sessionId: 'happy-session-1', + directory: '/tmp', + agent: 'codex', + resume: 'codex-session-123', + sessionEncryptionKeyBase64: sessionKeyBase64, + sessionEncryptionVariant: 'dataKey', + experimentalCodexResume: true, + }; + const encrypted = encodeBase64(encrypt(machine.encryptionKey, machine.encryptionVariant, params)); + + await rpc.handleRequest({ + method: `${machine.id}:spawn-happy-session`, + params: encrypted, + }); + + expect(captured).toEqual( + expect.objectContaining({ + directory: '/tmp', + agent: 'codex', + existingSessionId: 'happy-session-1', + resume: 'codex-session-123', + sessionEncryptionKeyBase64: sessionKeyBase64, + sessionEncryptionVariant: 'dataKey', + experimentalCodexResume: true, + }), + ); + }); +}); diff --git a/cli/src/api/apiMachine.ts b/cli/src/api/apiMachine.ts index b8e2b570d..799473808 100644 --- a/cli/src/api/apiMachine.ts +++ b/cli/src/api/apiMachine.ts @@ -7,73 +7,14 @@ import { io, Socket } from 'socket.io-client'; import { logger } from '@/ui/logger'; import { configuration } from '@/configuration'; import { MachineMetadata, DaemonState, Machine, Update, UpdateMachineBody } from './types'; -import { registerCommonHandlers, SpawnSessionOptions, SpawnSessionResult } from '../modules/common/registerCommonHandlers'; +import { registerSessionHandlers } from '@/rpc/handlers/registerSessionHandlers'; import { encodeBase64, decodeBase64, encrypt, decrypt } from './encryption'; import { backoff } from '@/utils/time'; import { RpcHandlerManager } from './rpc/RpcHandlerManager'; +import { SOCKET_RPC_EVENTS } from '@happy/protocol/socketRpc'; -interface ServerToDaemonEvents { - update: (data: Update) => void; - 'rpc-request': (data: { method: string, params: string }, callback: (response: string) => void) => void; - 'rpc-registered': (data: { method: string }) => void; - 'rpc-unregistered': (data: { method: string }) => void; - 'rpc-error': (data: { type: string, error: string }) => void; - auth: (data: { success: boolean, user: string }) => void; - error: (data: { message: string }) => void; -} - -interface DaemonToServerEvents { - 'machine-alive': (data: { - machineId: string; - time: number; - }) => void; - - 'machine-update-metadata': (data: { - machineId: string; - metadata: string; // Encrypted MachineMetadata - expectedVersion: number - }, cb: (answer: { - result: 'error' - } | { - result: 'version-mismatch' - version: number, - metadata: string - } | { - result: 'success', - version: number, - metadata: string - }) => void) => void; - - 'machine-update-state': (data: { - machineId: string; - daemonState: string; // Encrypted DaemonState - expectedVersion: number - }, cb: (answer: { - result: 'error' - } | { - result: 'version-mismatch' - version: number, - daemonState: string - } | { - result: 'success', - version: number, - daemonState: string - }) => void) => void; - - 'rpc-register': (data: { method: string }) => void; - 'rpc-unregister': (data: { method: string }) => void; - 'rpc-call': (data: { method: string, params: any }, callback: (response: { - ok: boolean - result?: any - error?: string - }) => void) => void; -} - -type MachineRpcHandlers = { - spawnSession: (options: SpawnSessionOptions) => Promise; - stopSession: (sessionId: string) => boolean; - requestShutdown: () => void; -} +import type { DaemonToServerEvents, ServerToDaemonEvents } from './machine/socketTypes'; +import { registerMachineRpcHandlers, type MachineRpcHandlers } from './machine/rpcHandlers'; export class ApiMachineClient { private socket!: Socket; @@ -92,7 +33,7 @@ export class ApiMachineClient { logger: (msg, data) => logger.debug(msg, data) }); - registerCommonHandlers(this.rpcHandlerManager, process.cwd()); + registerSessionHandlers(this.rpcHandlerManager, process.cwd()); } setRPCHandlers({ @@ -100,59 +41,9 @@ export class ApiMachineClient { stopSession, requestShutdown }: MachineRpcHandlers) { - // Register spawn session handler - this.rpcHandlerManager.registerHandler('spawn-happy-session', async (params: any) => { - const { directory, sessionId, machineId, approvedNewDirectoryCreation, agent, token, environmentVariables } = params || {}; - logger.debug(`[API MACHINE] Spawning session with params: ${JSON.stringify(params)}`); - - if (!directory) { - throw new Error('Directory is required'); - } - - const result = await spawnSession({ directory, sessionId, machineId, approvedNewDirectoryCreation, agent, token, environmentVariables }); - - switch (result.type) { - case 'success': - logger.debug(`[API MACHINE] Spawned session ${result.sessionId}`); - return { type: 'success', sessionId: result.sessionId }; - - case 'requestToApproveDirectoryCreation': - logger.debug(`[API MACHINE] Requesting directory creation approval for: ${result.directory}`); - return { type: 'requestToApproveDirectoryCreation', directory: result.directory }; - - case 'error': - throw new Error(result.errorMessage); - } - }); - - // Register stop session handler - this.rpcHandlerManager.registerHandler('stop-session', (params: any) => { - const { sessionId } = params || {}; - - if (!sessionId) { - throw new Error('Session ID is required'); - } - - const success = stopSession(sessionId); - if (!success) { - throw new Error('Session not found or failed to stop'); - } - - logger.debug(`[API MACHINE] Stopped session ${sessionId}`); - return { message: 'Session stopped' }; - }); - - // Register stop daemon handler - this.rpcHandlerManager.registerHandler('stop-daemon', () => { - logger.debug('[API MACHINE] Received stop-daemon RPC request'); - - // Trigger shutdown callback after a delay - setTimeout(() => { - logger.debug('[API MACHINE] Initiating daemon shutdown from RPC'); - requestShutdown(); - }, 100); - - return { message: 'Daemon stop request acknowledged, starting shutdown sequence...' }; + registerMachineRpcHandlers({ + rpcHandlerManager: this.rpcHandlerManager, + handlers: { spawnSession, stopSession, requestShutdown } }); } @@ -165,6 +56,11 @@ export class ApiMachineClient { await backoff(async () => { const updated = handler(this.machine.metadata); + // No-op: don't write if nothing changed. + if (this.machine.metadata && JSON.stringify(updated) === JSON.stringify(this.machine.metadata)) { + return; + } + const answer = await this.socket.emitWithAck('machine-update-metadata', { machineId: this.machine.id, metadata: encodeBase64(encrypt(this.machine.encryptionKey, this.machine.encryptionVariant, updated)), @@ -213,7 +109,15 @@ export class ApiMachineClient { }); } - connect() { + emitSessionEnd(payload: { sid: string; time: number; exit?: any }) { + // May be called before connect() finishes; best-effort only. + if (!this.socket) { + return; + } + this.socket.emit('session-end', payload); + } + + connect(params?: { onConnect?: () => void | Promise }) { const serverUrl = configuration.serverUrl.replace(/^http/, 'ws'); logger.debug(`[API MACHINE] Connecting to ${serverUrl}`); @@ -250,6 +154,13 @@ export class ApiMachineClient { // Start keep-alive this.startKeepAlive(); + + // Optional hook for callers that need a "connected" moment + if (params?.onConnect) { + Promise.resolve(params.onConnect()).catch(() => { + // Best-effort hook; ignore errors to avoid destabilizing the daemon. + }); + } }); this.socket.on('disconnect', () => { @@ -259,7 +170,7 @@ export class ApiMachineClient { }); // Single consolidated RPC handler - this.socket.on('rpc-request', async (data: { method: string, params: string }, callback: (response: string) => void) => { + this.socket.on(SOCKET_RPC_EVENTS.REQUEST, async (data: { method: string, params: string }, callback: (response: string) => void) => { logger.debugLargeJson(`[API MACHINE] Received RPC request:`, data); callback(await this.rpcHandlerManager.handleRequest(data)); }); @@ -327,4 +238,4 @@ export class ApiMachineClient { logger.debug('[API MACHINE] Socket closed'); } } -} \ No newline at end of file +} diff --git a/cli/src/api/apiSession.test.ts b/cli/src/api/apiSession.test.ts index 977e9ee84..6ad317d9d 100644 --- a/cli/src/api/apiSession.test.ts +++ b/cli/src/api/apiSession.test.ts @@ -1,9 +1,15 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { ApiSessionClient } from './apiSession'; +import type { RawJSONLines } from '@/backends/claude/types'; +import { encodeBase64, encrypt } from './encryption'; +import { existsSync, mkdtempSync, readFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { __resetToolTraceForTests } from '@/agent/tools/trace/toolTrace'; // Use vi.hoisted to ensure mock function is available when vi.mock factory runs const { mockIo } = vi.hoisted(() => ({ - mockIo: vi.fn() + mockIo: vi.fn(), })); vi.mock('socket.io-client', () => ({ @@ -12,6 +18,7 @@ vi.mock('socket.io-client', () => ({ describe('ApiSessionClient connection handling', () => { let mockSocket: any; + let mockUserSocket: any; let consoleSpy: any; let mockSession: any; @@ -20,13 +27,30 @@ describe('ApiSessionClient connection handling', () => { // Mock socket.io client mockSocket = { + connected: false, connect: vi.fn(), on: vi.fn(), off: vi.fn(), - disconnect: vi.fn() + disconnect: vi.fn(), + close: vi.fn(), + emit: vi.fn(), }; - mockIo.mockReturnValue(mockSocket); + mockUserSocket = { + connected: false, + connect: vi.fn(), + on: vi.fn(), + off: vi.fn(), + disconnect: vi.fn(), + close: vi.fn(), + emit: vi.fn(), + }; + + mockIo.mockReset(); + mockIo + .mockImplementationOnce(() => mockSocket) + .mockImplementationOnce(() => mockUserSocket) + .mockImplementation(() => mockSocket); // Create a proper mock session with metadata mockSession = { @@ -48,6 +72,12 @@ describe('ApiSessionClient connection handling', () => { }; }); + afterEach(() => { + delete process.env.HAPPY_STACKS_TOOL_TRACE; + delete process.env.HAPPY_STACKS_TOOL_TRACE_FILE; + __resetToolTraceForTests(); + }); + it('should handle socket connection failure gracefully', async () => { // Should not throw during client creation // Note: socket is created with autoConnect: false, so connection happens later @@ -56,6 +86,167 @@ describe('ApiSessionClient connection handling', () => { }).not.toThrow(); }); + it('records outbound ACP tool messages when tool tracing is enabled', () => { + const dir = mkdtempSync(join(tmpdir(), 'happy-tool-trace-apiSession-')); + const filePath = join(dir, 'tool-trace.jsonl'); + process.env.HAPPY_STACKS_TOOL_TRACE = '1'; + process.env.HAPPY_STACKS_TOOL_TRACE_FILE = filePath; + + const client = new ApiSessionClient('fake-token', mockSession); + client.sendAgentMessage('codex', { + type: 'tool-call', + callId: 'call-1', + name: 'read', + input: { filePath: '/etc/hosts' }, + id: 'msg-1', + }); + + const raw = readFileSync(filePath, 'utf8'); + const lines = raw.trim().split('\n'); + expect(lines).toHaveLength(1); + expect(JSON.parse(lines[0])).toMatchObject({ + v: 1, + direction: 'outbound', + sessionId: 'test-session-id', + protocol: 'acp', + provider: 'codex', + kind: 'tool-call', + }); + }); + + it('sets isError on outbound ACP tool-result messages when output looks like an error', () => { + const dir = mkdtempSync(join(tmpdir(), 'happy-tool-trace-apiSession-')); + const filePath = join(dir, 'tool-trace.jsonl'); + process.env.HAPPY_STACKS_TOOL_TRACE = '1'; + process.env.HAPPY_STACKS_TOOL_TRACE_FILE = filePath; + + const client = new ApiSessionClient('fake-token', mockSession); + client.sendAgentMessage('gemini', { + type: 'tool-result', + callId: 'call-1', + output: { error: 'Tool call failed', status: 'failed' }, + id: 'msg-1', + }); + + const raw = readFileSync(filePath, 'utf8'); + const lines = raw.trim().split('\n'); + expect(lines).toHaveLength(1); + expect(JSON.parse(lines[0])).toMatchObject({ + protocol: 'acp', + provider: 'gemini', + kind: 'tool-result', + payload: expect.objectContaining({ + type: 'tool-result', + isError: true, + }), + }); + }); + + it('does not record outbound ACP non-tool messages when tool tracing is enabled', () => { + const dir = mkdtempSync(join(tmpdir(), 'happy-tool-trace-apiSession-')); + const filePath = join(dir, 'tool-trace.jsonl'); + process.env.HAPPY_STACKS_TOOL_TRACE = '1'; + process.env.HAPPY_STACKS_TOOL_TRACE_FILE = filePath; + + const client = new ApiSessionClient('fake-token', mockSession); + client.sendAgentMessage('codex', { + type: 'message', + message: 'hello', + }); + + expect(existsSync(filePath)).toBe(false); + }); + + it('records Claude tool_use/tool_result blocks when tool tracing is enabled', () => { + const dir = mkdtempSync(join(tmpdir(), 'happy-tool-trace-claude-')); + const filePath = join(dir, 'tool-trace.jsonl'); + process.env.HAPPY_STACKS_TOOL_TRACE = '1'; + process.env.HAPPY_STACKS_TOOL_TRACE_FILE = filePath; + + const client = new ApiSessionClient('fake-token', mockSession); + client.sendClaudeSessionMessage({ + type: 'assistant', + uuid: 'uuid-1', + message: { + content: [ + { type: 'tool_use', id: 'toolu_1', name: 'Read', input: { file_path: '/etc/hosts' } }, + { type: 'tool_result', tool_use_id: 'toolu_1', content: 'ok' }, + ], + }, + } as any); + + const raw = readFileSync(filePath, 'utf8'); + const lines = raw.trim().split('\n'); + expect(lines).toHaveLength(2); + expect(JSON.parse(lines[0])).toMatchObject({ + v: 1, + direction: 'outbound', + sessionId: 'test-session-id', + protocol: 'claude', + provider: 'claude', + kind: 'tool-call', + }); + expect(JSON.parse(lines[1])).toMatchObject({ + v: 1, + direction: 'outbound', + sessionId: 'test-session-id', + protocol: 'claude', + provider: 'claude', + kind: 'tool-result', + }); + }); + + it('records Claude tool_result blocks sent as user messages when tool tracing is enabled', () => { + const dir = mkdtempSync(join(tmpdir(), 'happy-tool-trace-claude-user-tool-result-')); + const filePath = join(dir, 'tool-trace.jsonl'); + process.env.HAPPY_STACKS_TOOL_TRACE = '1'; + process.env.HAPPY_STACKS_TOOL_TRACE_FILE = filePath; + + const session = { ...mockSession, id: 'test-session-id-user-tool-result' }; + const client = new ApiSessionClient('fake-token', session); + client.sendClaudeSessionMessage({ + type: 'user', + uuid: 'uuid-2', + message: { + content: [ + { type: 'tool_result', tool_use_id: 'toolu_1', content: 'ok' }, + ], + }, + } as any); + + const raw = existsSync(filePath) ? readFileSync(filePath, 'utf8') : ''; + const lines = raw.trim().length > 0 ? raw.trim().split('\n') : []; + const parsed = lines.map((l) => JSON.parse(l)); + expect(parsed).toContainEqual(expect.objectContaining({ + v: 1, + direction: 'outbound', + sessionId: 'test-session-id-user-tool-result', + protocol: 'claude', + provider: 'claude', + kind: 'tool-result', + payload: expect.objectContaining({ + type: 'tool_result', + tool_use_id: 'toolu_1', + }), + })); + }); + + it('does not record Claude user text messages when tool tracing is enabled', () => { + const dir = mkdtempSync(join(tmpdir(), 'happy-tool-trace-claude-')); + const filePath = join(dir, 'tool-trace.jsonl'); + process.env.HAPPY_STACKS_TOOL_TRACE = '1'; + process.env.HAPPY_STACKS_TOOL_TRACE_FILE = filePath; + + const client = new ApiSessionClient('fake-token', mockSession); + client.sendClaudeSessionMessage({ + type: 'user', + uuid: 'uuid-2', + message: { content: 'hello' }, + } as any); + + expect(existsSync(filePath)).toBe(false); + }); + it('should emit correct events on socket connection', () => { const client = new ApiSessionClient('fake-token', mockSession); @@ -65,8 +256,633 @@ describe('ApiSessionClient connection handling', () => { expect(mockSocket.on).toHaveBeenCalledWith('error', expect.any(Function)); }); - afterEach(() => { - consoleSpy.mockRestore(); - vi.restoreAllMocks(); + it('close closes both the session-scoped and user-scoped sockets', async () => { + const client = new ApiSessionClient('fake-token', mockSession); + + await client.close(); + + expect(mockSocket.close).toHaveBeenCalledTimes(1); + expect(mockUserSocket.close).toHaveBeenCalledTimes(1); + }); + + it('waitForMetadataUpdate ensures the user-scoped socket is connected so metadata updates can wake idle agents', async () => { + const client = new ApiSessionClient('fake-token', mockSession); + + const controller = new AbortController(); + const promise = client.waitForMetadataUpdate(controller.signal); + + expect(mockUserSocket.connect).toHaveBeenCalledTimes(1); + + controller.abort(); + await expect(promise).resolves.toBe(false); }); -}); \ No newline at end of file + + it('emits messages even when disconnected (socket.io will buffer)', () => { + mockSocket.connected = false; + + const client = new ApiSessionClient('fake-token', mockSession); + + const payload: RawJSONLines = { + type: 'user', + uuid: 'test-uuid', + message: { + content: 'hello', + }, + } as const; + + client.sendClaudeSessionMessage(payload); + + expect(mockSocket.emit).toHaveBeenCalledWith( + 'message', + expect.objectContaining({ + sid: mockSession.id, + message: expect.any(String), + }) + ); + }); + + it('attaches server localId onto decrypted user messages', async () => { + const client = new ApiSessionClient('fake-token', mockSession); + + const onUserMessage = vi.fn(); + client.onUserMessage(onUserMessage); + + const updateHandler = (mockSocket.on.mock.calls.find((call: any[]) => call[0] === 'update') ?? [])[1]; + expect(typeof updateHandler).toBe('function'); + + const plaintext = { + role: 'user', + content: { type: 'text', text: 'hello' }, + meta: { sentFrom: 'web' }, + }; + const encrypted = encodeBase64(encrypt(mockSession.encryptionKey, mockSession.encryptionVariant, plaintext)); + + updateHandler({ + id: 'update-1', + seq: 1, + createdAt: Date.now(), + body: { + t: 'new-message', + sid: mockSession.id, + message: { + id: 'msg-1', + seq: 1, + localId: 'local-1', + content: { t: 'encrypted', c: encrypted }, + }, + }, + } as any); + + expect(onUserMessage).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.objectContaining({ text: 'hello' }), + localId: 'local-1', + }), + ); + }); + + it('waitForMetadataUpdate resolves when session metadata updates', async () => { + const client = new ApiSessionClient('fake-token', mockSession); + + const waitPromise = client.waitForMetadataUpdate(); + + const updateHandler = (mockSocket.on.mock.calls.find((call: any[]) => call[0] === 'update') ?? [])[1]; + expect(typeof updateHandler).toBe('function'); + + const nextMetadata = { ...mockSession.metadata, path: '/tmp/next' }; + const encrypted = encodeBase64(encrypt(mockSession.encryptionKey, mockSession.encryptionVariant, nextMetadata)); + + updateHandler({ + id: 'update-2', + seq: 2, + createdAt: Date.now(), + body: { + t: 'update-session', + sid: mockSession.id, + metadata: { + version: 1, + value: encrypted, + }, + }, + } as any); + + await expect(waitPromise).resolves.toBe(true); + }); + + it('waitForMetadataUpdate resolves when the user-scoped socket connects (wakes idle agents)', async () => { + const client = new ApiSessionClient('fake-token', mockSession); + + const waitPromise = client.waitForMetadataUpdate(); + + const connectHandlers = mockUserSocket.on.mock.calls + .filter((call: any[]) => call[0] === 'connect') + .map((call: any[]) => call[1]); + const lastConnectHandler = connectHandlers[connectHandlers.length - 1]; + expect(typeof lastConnectHandler).toBe('function'); + + lastConnectHandler(); + await expect(waitPromise).resolves.toBe(true); + }); + + it('waitForMetadataUpdate resolves when session metadata updates (server sends update-session with id)', async () => { + const client = new ApiSessionClient('fake-token', mockSession); + + const waitPromise = client.waitForMetadataUpdate(); + + const updateHandler = (mockSocket.on.mock.calls.find((call: any[]) => call[0] === 'update') ?? [])[1]; + expect(typeof updateHandler).toBe('function'); + + const nextMetadata = { ...mockSession.metadata, path: '/tmp/next2' }; + const encrypted = encodeBase64(encrypt(mockSession.encryptionKey, mockSession.encryptionVariant, nextMetadata)); + + updateHandler({ + id: 'update-2b', + seq: 3, + createdAt: Date.now(), + body: { + t: 'update-session', + id: mockSession.id, + metadata: { + version: 1, + value: encrypted, + }, + }, + } as any); + + await expect(waitPromise).resolves.toBe(true); + }); + + it('waitForMetadataUpdate resolves false when user-scoped socket disconnects', async () => { + const client = new ApiSessionClient('fake-token', mockSession); + + const waitPromise = client.waitForMetadataUpdate(); + + const disconnectHandlers = mockUserSocket.on.mock.calls + .filter((call: any[]) => call[0] === 'disconnect') + .map((call: any[]) => call[1]); + const lastDisconnectHandler = disconnectHandlers[disconnectHandlers.length - 1]; + expect(typeof lastDisconnectHandler).toBe('function'); + + lastDisconnectHandler(); + await expect(waitPromise).resolves.toBe(false); + }); + + it('waitForMetadataUpdate does not miss fast user-scoped update-session wakeups', async () => { + const client = new ApiSessionClient('fake-token', mockSession); + + const updateHandler = (mockUserSocket.on.mock.calls.find((call: any[]) => call[0] === 'update') ?? [])[1]; + expect(typeof updateHandler).toBe('function'); + + mockUserSocket.connect.mockImplementation(() => { + const nextMetadata = { ...mockSession.metadata, path: '/tmp/fast' }; + const encrypted = encodeBase64(encrypt(mockSession.encryptionKey, mockSession.encryptionVariant, nextMetadata)); + updateHandler({ + id: 'update-fast', + seq: 999, + createdAt: Date.now(), + body: { + t: 'update-session', + sid: mockSession.id, + metadata: { + version: 2, + value: encrypted, + }, + }, + } as any); + }); + + const controller = new AbortController(); + const promise = client.waitForMetadataUpdate(controller.signal); + + queueMicrotask(() => controller.abort()); + await expect(promise).resolves.toBe(true); + }); + + it('waitForMetadataUpdate does not miss snapshot sync updates started before handlers attach', async () => { + const client = new ApiSessionClient('fake-token', mockSession); + + (client as any).metadataVersion = -1; + (client as any).agentStateVersion = -1; + + (client as any).syncSessionSnapshotFromServer = () => { + (client as any).metadataVersion = 1; + (client as any).agentStateVersion = 1; + client.emit('metadata-updated'); + return Promise.resolve(); + }; + + const promise = client.waitForMetadataUpdate(); + await expect( + Promise.race([ + promise, + new Promise((_, reject) => + setTimeout(() => reject(new Error('waitForMetadataUpdate() hung after snapshot sync')), 50) + ) + ]) + ).resolves.toBe(true); + }); + + it('updateMetadata syncs a snapshot first when metadataVersion is unknown', async () => { + const sessionSocket: any = { + connected: false, + connect: vi.fn(), + on: vi.fn(), + off: vi.fn(), + disconnect: vi.fn(), + close: vi.fn(), + emit: vi.fn(), + }; + + const userSocket: any = { + connected: false, + connect: vi.fn(), + on: vi.fn(), + off: vi.fn(), + disconnect: vi.fn(), + close: vi.fn(), + emit: vi.fn(), + }; + + const serverMetadata = { + ...mockSession.metadata, + messageQueueV1: { + v: 1, + queue: [{ + localId: 'local-p1', + message: 'encrypted-user-record', + createdAt: 1, + updatedAt: 1, + }], + inFlight: null, + }, + }; + const encryptedServerMetadata = encodeBase64(encrypt(mockSession.encryptionKey, mockSession.encryptionVariant, serverMetadata)); + + const emitWithAck = vi.fn().mockResolvedValueOnce({ + result: 'success', + version: 6, + metadata: encryptedServerMetadata, + }); + sessionSocket.emitWithAck = emitWithAck; + + mockIo.mockReset(); + mockIo + .mockImplementationOnce(() => sessionSocket) + .mockImplementationOnce(() => userSocket); + + const axiosMod = await import('axios'); + const axios = axiosMod.default as any; + vi.spyOn(axios, 'get').mockResolvedValueOnce({ + data: { + sessions: [{ + id: mockSession.id, + metadataVersion: 5, + metadata: encryptedServerMetadata, + agentStateVersion: 0, + agentState: null, + }], + }, + }); + + const client = new ApiSessionClient('fake-token', { + ...mockSession, + metadataVersion: -1, + metadata: { + ...mockSession.metadata, + messageQueueV1: { v: 1, queue: [] }, + }, + }); + + let observedQueuedMessage = false; + client.updateMetadata((metadata) => { + const mq = (metadata as any).messageQueueV1; + observedQueuedMessage = Array.isArray(mq?.queue) && mq.queue.length === 1; + return metadata; + }); + + await vi.waitFor(() => { + expect(observedQueuedMessage).toBe(true); + expect(emitWithAck).toHaveBeenCalledWith( + 'update-metadata', + expect.objectContaining({ expectedVersion: 5 }), + ); + }); + }); + + it('clears messageQueueV1 inFlight only after observing the materialized user message', async () => { + const sessionSocket: any = { + connected: true, + connect: vi.fn(), + on: vi.fn(), + off: vi.fn(), + disconnect: vi.fn(), + emit: vi.fn(), + }; + + const userSocket: any = { + connected: true, + connect: vi.fn(), + on: vi.fn(), + off: vi.fn(), + disconnect: vi.fn(), + emit: vi.fn(), + }; + + const metadataBase = { + ...mockSession.metadata, + messageQueueV1: { + v: 1, + queue: [{ + localId: 'local-p1', + message: 'encrypted-user-record', + createdAt: 1, + updatedAt: 1, + }], + inFlight: null, + }, + }; + + // Minimal emitWithAck mock for metadata claim + later clear + const emitWithAck = vi.fn() + // 1) claim succeeds + .mockResolvedValueOnce({ + result: 'success', + version: 1, + metadata: encodeBase64(encrypt(mockSession.encryptionKey, mockSession.encryptionVariant, { + ...metadataBase, + messageQueueV1: { + v: 1, + queue: [], + inFlight: { + localId: 'local-p1', + message: 'encrypted-user-record', + createdAt: 1, + updatedAt: 1, + claimedAt: 100, + }, + }, + })), + }) + // 2) clear succeeds + .mockResolvedValueOnce({ + result: 'success', + version: 2, + metadata: encodeBase64(encrypt(mockSession.encryptionKey, mockSession.encryptionVariant, { + ...metadataBase, + messageQueueV1: { + v: 1, + queue: [], + inFlight: null, + }, + })), + }); + + sessionSocket.emitWithAck = emitWithAck; + + mockIo.mockReset(); + mockIo + .mockImplementationOnce(() => sessionSocket) + .mockImplementationOnce(() => userSocket); + + // Recreate client with our two-socket setup. + const clientWithTwoSockets = new ApiSessionClient('fake-token', { + ...mockSession, + metadata: metadataBase, + }); + + const popped = await clientWithTwoSockets.popPendingMessage(); + expect(popped).toBe(true); + + // Should have emitted the transcript message but NOT yet cleared inFlight. + expect(sessionSocket.emit).toHaveBeenCalledWith('message', expect.objectContaining({ localId: 'local-p1' })); + expect(emitWithAck).toHaveBeenCalledTimes(1); + + const userUpdateHandler = (userSocket.on.mock.calls.find((call: any[]) => call[0] === 'update') ?? [])[1]; + expect(typeof userUpdateHandler).toBe('function'); + + const plaintext = { + role: 'user', + content: { type: 'text', text: 'hello' }, + }; + const encrypted = encodeBase64(encrypt(mockSession.encryptionKey, mockSession.encryptionVariant, plaintext)); + + // Simulate server broadcast of the materialized message with the same localId (arriving on user-scoped socket). + userUpdateHandler({ + id: 'update-3', + seq: 3, + createdAt: Date.now(), + body: { + t: 'new-message', + sid: mockSession.id, + message: { + id: 'msg-2', + seq: 2, + localId: 'local-p1', + content: { t: 'encrypted', c: encrypted }, + }, + }, + } as any); + + // Allow queued async clear to run. + await new Promise((r) => setTimeout(r, 0)); + expect(emitWithAck).toHaveBeenCalledTimes(2); + }); + + it('recovers an already-inFlight queued message by fetching the transcript (no server echo required)', async () => { + const sessionSocket: any = { + connected: true, + connect: vi.fn(), + on: vi.fn(), + off: vi.fn(), + disconnect: vi.fn(), + emit: vi.fn(), + }; + + const userSocket: any = { + connected: true, + connect: vi.fn(), + on: vi.fn(), + off: vi.fn(), + disconnect: vi.fn(), + emit: vi.fn(), + }; + + mockIo.mockReset(); + mockIo + .mockImplementationOnce(() => sessionSocket) + .mockImplementationOnce(() => userSocket); + + const metadataBase = { + ...mockSession.metadata, + messageQueueV1: { + v: 1, + queue: [], + inFlight: { + localId: 'local-inflight-1', + message: 'encrypted-user-record', + createdAt: 1, + updatedAt: 1, + claimedAt: Date.now(), + }, + }, + }; + + const plaintext = { + role: 'user', + content: { type: 'text', text: 'hello' }, + }; + const encrypted = encodeBase64(encrypt(mockSession.encryptionKey, mockSession.encryptionVariant, plaintext)); + + const axiosMod = await import('axios'); + const axios = axiosMod.default as any; + vi.spyOn(axios, 'get').mockResolvedValueOnce({ + data: { + messages: [{ + id: 'msg-xyz', + seq: 1, + localId: 'local-inflight-1', + content: { t: 'encrypted', c: encrypted }, + createdAt: Date.now(), + updatedAt: Date.now(), + }], + }, + }); + + const emitWithAck = vi.fn().mockResolvedValueOnce({ + result: 'success', + version: 2, + metadata: encodeBase64(encrypt(mockSession.encryptionKey, mockSession.encryptionVariant, { + ...metadataBase, + messageQueueV1: { + v: 1, + queue: [], + inFlight: null, + }, + })), + }); + sessionSocket.emitWithAck = emitWithAck; + + const client = new ApiSessionClient('fake-token', { + ...mockSession, + metadata: metadataBase, + }); + + const popped = await client.popPendingMessage(); + expect(popped).toBe(true); + + // Should not re-emit the transcript message when it already exists. + expect(sessionSocket.emit).not.toHaveBeenCalledWith('message', expect.anything()); + + // Allow queued async clear to run. + await new Promise((r) => setTimeout(r, 0)); + expect(emitWithAck).toHaveBeenCalledTimes(1); + }); + + it('syncs a server snapshot on connect for resumed sessions (metadataVersion=-1) so queued messages enqueued before attach can be popped', async () => { + const sessionSocket: any = { + connected: true, + connect: vi.fn(), + on: vi.fn(), + off: vi.fn(), + disconnect: vi.fn(), + close: vi.fn(), + emit: vi.fn(), + }; + + const userSocket: any = { + connected: false, + connect: vi.fn(), + on: vi.fn(), + off: vi.fn(), + disconnect: vi.fn(), + close: vi.fn(), + emit: vi.fn(), + }; + + mockIo.mockReset(); + mockIo + .mockImplementationOnce(() => sessionSocket) + .mockImplementationOnce(() => userSocket); + + const serverMetadata = { + ...mockSession.metadata, + messageQueueV1: { + v: 1, + queue: [{ + localId: 'local-p1', + message: 'encrypted-user-record', + createdAt: 1, + updatedAt: 1, + }], + inFlight: null, + }, + }; + + const axiosMod = await import('axios'); + const axios = axiosMod.default as any; + vi.spyOn(axios, 'get').mockResolvedValueOnce({ + data: { + sessions: [{ + id: mockSession.id, + seq: 0, + createdAt: Date.now(), + updatedAt: Date.now(), + active: true, + activeAt: Date.now(), + metadata: encodeBase64(encrypt(mockSession.encryptionKey, mockSession.encryptionVariant, serverMetadata)), + metadataVersion: 10, + agentState: null, + agentStateVersion: 0, + dataEncryptionKey: null, + lastMessage: null, + }], + }, + }); + + const emitWithAck = vi.fn().mockResolvedValueOnce({ + result: 'success', + version: 11, + metadata: encodeBase64(encrypt(mockSession.encryptionKey, mockSession.encryptionVariant, { + ...serverMetadata, + messageQueueV1: { + v: 1, + queue: [], + inFlight: { + localId: 'local-p1', + message: 'encrypted-user-record', + createdAt: 1, + updatedAt: 1, + claimedAt: 100, + }, + }, + })), + }); + sessionSocket.emitWithAck = emitWithAck; + + const client = new ApiSessionClient('fake-token', { + ...mockSession, + metadata: { ...mockSession.metadata }, + metadataVersion: -1, + agentStateVersion: -1, + }); + + // Simulate socket.io connect event (resume/reattach). + const connectHandler = (sessionSocket.on.mock.calls.find((call: any[]) => call[0] === 'connect') ?? [])[1]; + expect(typeof connectHandler).toBe('function'); + connectHandler(); + + // Allow snapshot sync to run. + await new Promise((r) => setTimeout(r, 0)); + + const popped = await client.popPendingMessage(); + expect(popped).toBe(true); + + expect(sessionSocket.emit).toHaveBeenCalledWith('message', expect.objectContaining({ localId: 'local-p1' })); + expect(emitWithAck).toHaveBeenCalledTimes(1); + }); + + afterEach(() => { + consoleSpy.mockRestore(); + vi.restoreAllMocks(); + }); +}); diff --git a/cli/src/api/apiSession.ts b/cli/src/api/apiSession.ts index 187ce5b82..aa8c575a4 100644 --- a/cli/src/api/apiSession.ts +++ b/cli/src/api/apiSession.ts @@ -1,15 +1,24 @@ import { logger } from '@/ui/logger' import { EventEmitter } from 'node:events' -import { io, Socket } from 'socket.io-client' +import axios from 'axios'; +import { Socket } from 'socket.io-client' import { AgentState, ClientToServerEvents, MessageContent, Metadata, ServerToClientEvents, Session, Update, UserMessage, UserMessageSchema, Usage } from './types' import { decodeBase64, decrypt, encodeBase64, encrypt } from './encryption'; import { backoff } from '@/utils/time'; import { configuration } from '@/configuration'; -import { RawJSONLines } from '@/claude/types'; +import type { RawJSONLines } from '@/backends/claude/types'; import { randomUUID } from 'node:crypto'; import { AsyncLock } from '@/utils/lock'; import { RpcHandlerManager } from './rpc/RpcHandlerManager'; -import { registerCommonHandlers } from '../modules/common/registerCommonHandlers'; +import { registerSessionHandlers } from '@/rpc/handlers/registerSessionHandlers'; +import { addDiscardedCommittedMessageLocalIds } from './queue/discardedCommittedMessageLocalIds'; +import { claimMessageQueueV1Next, clearMessageQueueV1InFlight, discardMessageQueueV1All, parseMessageQueueV1 } from './queue/messageQueueV1'; +import { fetchSessionSnapshotUpdateFromServer, shouldSyncSessionSnapshotOnConnect } from './session/snapshotSync'; +import { createSessionScopedSocket, createUserScopedSocket } from './session/sockets'; +import { isToolTraceEnabled, recordAcpToolTraceEventIfNeeded, recordClaudeToolTraceEvents, recordCodexToolTraceEventIfNeeded } from './session/toolTrace'; +import { updateSessionAgentStateWithAck, updateSessionMetadataWithAck } from './session/stateUpdates'; +import type { CatalogAgentId } from '@/backends/types'; +import { SOCKET_RPC_EVENTS } from '@happy/protocol/socketRpc'; /** * ACP (Agent Communication Protocol) message data types. @@ -36,7 +45,7 @@ export type ACPMessageData = // Usage/metrics | { type: 'token_count'; [key: string]: unknown }; -export type ACPProvider = 'gemini' | 'codex' | 'claude' | 'opencode'; +export type ACPProvider = CatalogAgentId; export class ApiSessionClient extends EventEmitter { private readonly token: string; @@ -46,6 +55,7 @@ export class ApiSessionClient extends EventEmitter { private agentState: AgentState | null; private agentStateVersion: number; private socket: Socket; + private userSocket: Socket; private pendingMessages: UserMessage[] = []; private pendingMessageCallback: ((message: UserMessage) => void) | null = null; readonly rpcHandlerManager: RpcHandlerManager; @@ -53,6 +63,28 @@ export class ApiSessionClient extends EventEmitter { private metadataLock = new AsyncLock(); private encryptionKey: Uint8Array; private encryptionVariant: 'legacy' | 'dataKey'; + private disconnectedSendLogged = false; + private readonly pendingMaterializedLocalIds = new Set(); + private userSocketDisconnectTimer: ReturnType | null = null; + private closed = false; + private snapshotSyncInFlight: Promise | null = null; + + /** + * Returns the latest known agentState (may be stale if socket is disconnected). + * Useful for rebuilding in-memory caches (e.g. permission allowlists) without server changes. + */ + getAgentStateSnapshot(): AgentState | null { + return this.agentState; + } + + private logSendWhileDisconnected(context: string, details?: Record): void { + if (this.socket.connected || this.disconnectedSendLogged) return; + this.disconnectedSendLogged = true; + logger.debug( + `[API] Socket not connected; emitting ${context} anyway (socket.io should buffer until reconnection).`, + details + ); + } constructor(token: string, session: Session) { super() @@ -72,27 +104,23 @@ export class ApiSessionClient extends EventEmitter { encryptionVariant: this.encryptionVariant, logger: (msg, data) => logger.debug(msg, data) }); - registerCommonHandlers(this.rpcHandlerManager, this.metadata.path); + registerSessionHandlers(this.rpcHandlerManager, this.metadata.path); // // Create socket // - this.socket = io(configuration.serverUrl, { - auth: { - token: this.token, - clientType: 'session-scoped' as const, - sessionId: this.sessionId - }, - path: '/v1/updates', - reconnection: true, - reconnectionAttempts: Infinity, - reconnectionDelay: 1000, - reconnectionDelayMax: 5000, - transports: ['websocket'], - withCredentials: true, - autoConnect: false - }); + this.socket = createSessionScopedSocket({ token: this.token, sessionId: this.sessionId }); + + // A user-scoped socket is used to observe our own materialized pending-queue messages. + // + // Server-side broadcasting skips the sender connection, so a session-scoped agent that emits a + // transcript message will not receive its own "new-message" update. Without observing the + // materialized message, the agent can't enqueue it for processing or clear messageQueueV1.inFlight. + // + // A second (user-scoped) connection will still receive the broadcast, letting us safely + // drive the normal update pipeline without server changes. + this.userSocket = createUserScopedSocket({ token: this.token }); // // Handlers @@ -100,11 +128,20 @@ export class ApiSessionClient extends EventEmitter { this.socket.on('connect', () => { logger.debug('Socket connected successfully'); + this.disconnectedSendLogged = false; this.rpcHandlerManager.onSocketConnect(this.socket); + + // Resumed sessions (inactive-session-resume) start with metadataVersion/agentStateVersion = -1. + // If the user enqueued pending messages before this agent connected, the corresponding metadata + // update happened "in the past" and won't be replayed over the socket. Syncing a snapshot here + // ensures messageQueueV1 is visible so popPendingMessage() can materialize the first queued item. + if (shouldSyncSessionSnapshotOnConnect({ metadataVersion: this.metadataVersion, agentStateVersion: this.agentStateVersion })) { + void this.syncSessionSnapshotFromServer({ reason: 'connect' }); + } }) // Set up global RPC request handler - this.socket.on('rpc-request', async (data: { method: string, params: string }, callback: (response: string) => void) => { + this.socket.on(SOCKET_RPC_EVENTS.REQUEST, async (data: { method: string, params: string }, callback: (response: string) => void) => { callback(await this.rpcHandlerManager.handleRequest(data)); }) @@ -119,64 +156,257 @@ export class ApiSessionClient extends EventEmitter { }) // Server events - this.socket.on('update', (data: Update) => { + this.socket.on('update', (data: Update) => this.handleUpdate(data, { source: 'session-scoped' })); + + this.userSocket.on('update', (data: Update) => this.handleUpdate(data, { source: 'user-scoped' })); + + // DEATH + this.socket.on('error', (error) => { + logger.debug('[API] Socket error:', error); + }); + + // + // Connect (after short delay to give a time to add handlers) + // + + this.socket.connect(); + } + + private syncSessionSnapshotFromServer(opts: { reason: 'connect' | 'waitForMetadataUpdate' }): Promise { + if (this.closed) return Promise.resolve(); + if (this.snapshotSyncInFlight) return this.snapshotSyncInFlight; + + const p = (async () => { try { - logger.debugLargeJson('[SOCKET] [UPDATE] Received update:', data); + const update = await fetchSessionSnapshotUpdateFromServer({ + token: this.token, + sessionId: this.sessionId, + encryptionKey: this.encryptionKey, + encryptionVariant: this.encryptionVariant, + currentMetadataVersion: this.metadataVersion, + currentAgentStateVersion: this.agentStateVersion, + }); - if (!data.body) { - logger.debug('[SOCKET] [UPDATE] [ERROR] No body in update!'); - return; + if (update.metadata) { + this.metadata = update.metadata.metadata; + this.metadataVersion = update.metadata.metadataVersion; + this.emit('metadata-updated'); } - if (data.body.t === 'new-message' && data.body.message.content.t === 'encrypted') { - const body = decrypt(this.encryptionKey, this.encryptionVariant, decodeBase64(data.body.message.content.c)); + if (update.agentState) { + this.agentState = update.agentState.agentState; + this.agentStateVersion = update.agentState.agentStateVersion; + } + } catch (error) { + logger.debug('[API] Failed to sync session snapshot from server', { reason: opts.reason, error }); + } + })(); - logger.debugLargeJson('[SOCKET] [UPDATE] Received update:', body) + this.snapshotSyncInFlight = p.finally(() => { + if (this.snapshotSyncInFlight === p) { + this.snapshotSyncInFlight = null; + } + }); - // Try to parse as user message first - const userResult = UserMessageSchema.safeParse(body); - if (userResult.success) { - // Server already filtered to only our session - if (this.pendingMessageCallback) { - this.pendingMessageCallback(userResult.data); - } else { - this.pendingMessages.push(userResult.data); - } - } else { - // If not a user message, it might be a permission response or other message type - this.emit('message', body); - } - } else if (data.body.t === 'update-session') { - if (data.body.metadata && data.body.metadata.version > this.metadataVersion) { - this.metadata = decrypt(this.encryptionKey, this.encryptionVariant,decodeBase64(data.body.metadata.value)); - this.metadataVersion = data.body.metadata.version; + return this.snapshotSyncInFlight; + } + + private kickUserSocketConnect(): void { + if (this.closed) return; + if (this.userSocketDisconnectTimer) { + clearTimeout(this.userSocketDisconnectTimer); + this.userSocketDisconnectTimer = null; + } + if (this.userSocket.connected) return; + try { + this.userSocket.connect(); + } catch { + // ignore; transcript recovery will handle missed updates + } + } + + private maybeScheduleUserSocketDisconnect(): void { + if (this.closed) return; + if (this.pendingMaterializedLocalIds.size > 0) return; + if (!this.userSocket.connected) return; + if (this.userSocketDisconnectTimer) return; + + // Short idle grace to avoid thrashing if multiple pending items get materialized back-to-back. + this.userSocketDisconnectTimer = setTimeout(() => { + this.userSocketDisconnectTimer = null; + if (this.pendingMaterializedLocalIds.size > 0) return; + if (!this.userSocket.connected) return; + try { + this.userSocket.disconnect(); + } catch { + // ignore + } + }, 2_000); + this.userSocketDisconnectTimer.unref?.(); + } + + private handleUpdate(data: Update, opts: { source: 'session-scoped' | 'user-scoped' }): void { + try { + logger.debugLargeJson(`[SOCKET] [UPDATE:${opts.source}] Received update:`, data); + + if (!data.body) { + logger.debug('[SOCKET] [UPDATE] [ERROR] No body in update!'); + return; + } + + if (data.body.t === 'new-message') { + if (data.body.sid !== this.sessionId) return; + if (data.body.message.content.t !== 'encrypted') return; + + const localId = data.body.message.localId ?? null; + if (opts.source === 'user-scoped') { + if (!localId) return; + if (!this.pendingMaterializedLocalIds.has(localId)) { + return; } - if (data.body.agentState && data.body.agentState.version > this.agentStateVersion) { - this.agentState = data.body.agentState.value ? decrypt(this.encryptionKey, this.encryptionVariant, decodeBase64(data.body.agentState.value)) : null; - this.agentStateVersion = data.body.agentState.version; + // Avoid double-processing if we get multiple copies. + this.pendingMaterializedLocalIds.delete(localId); + this.maybeScheduleUserSocketDisconnect(); + } + + const body = decrypt(this.encryptionKey, this.encryptionVariant, decodeBase64(data.body.message.content.c)); + const bodyWithLocalId = + data.body.message.localId === undefined + ? body + : { + ...(body as any), + localId: data.body.message.localId, + }; + + logger.debugLargeJson('[SOCKET] [UPDATE] Received update:', bodyWithLocalId) + + // Try to parse as user message first + const userResult = UserMessageSchema.safeParse(bodyWithLocalId); + if (userResult.success) { + if (this.pendingMessageCallback) { + this.pendingMessageCallback(userResult.data); + } else { + this.pendingMessages.push(userResult.data); } - } else if (data.body.t === 'update-machine') { - // Session clients shouldn't receive machine updates - log warning - logger.debug(`[SOCKET] WARNING: Session client received unexpected machine update - ignoring`); + this.emit('user-message', userResult.data); + void this.maybeClearPendingInFlight(userResult.data.localId ?? null); } else { // If not a user message, it might be a permission response or other message type - this.emit('message', data.body); + this.emit('message', body); } - } catch (error) { - logger.debug('[SOCKET] [UPDATE] [ERROR] Error handling update', { error }); + return; } - }); - // DEATH - this.socket.on('error', (error) => { - logger.debug('[API] Socket error:', error); - }); + if (data.body.t === 'update-session') { + const sid = (data.body as any).sid ?? (data.body as any).id; + if (sid !== this.sessionId) return; + if (data.body.metadata && data.body.metadata.version > this.metadataVersion) { + this.metadata = decrypt(this.encryptionKey, this.encryptionVariant, decodeBase64(data.body.metadata.value)); + this.metadataVersion = data.body.metadata.version; + this.emit('metadata-updated'); + } + if (data.body.agentState && data.body.agentState.version > this.agentStateVersion) { + this.agentState = data.body.agentState.value ? decrypt(this.encryptionKey, this.encryptionVariant, decodeBase64(data.body.agentState.value)) : null; + this.agentStateVersion = data.body.agentState.version; + } + return; + } - // - // Connect (after short delay to give a time to add handlers) - // + if (data.body.t === 'update-machine') { + // Session clients shouldn't receive machine updates - log warning + logger.debug(`[SOCKET] WARNING: Session client received unexpected machine update - ignoring`); + return; + } - this.socket.connect(); + // If not a user message, it might be a permission response or other message type + this.emit('message', data.body); + } catch (error) { + logger.debug('[SOCKET] [UPDATE] [ERROR] Error handling update', { error }); + } + } + + private async waitForTranscriptLocalId(localId: string, opts?: { maxWaitMs?: number }): Promise<{ + id: string; + seq: number; + localId: string | null; + content: { t: 'encrypted'; c: string }; + } | null> { + const maxWaitMs = opts?.maxWaitMs ?? 5_000; + const startedAt = Date.now(); + while (Date.now() - startedAt < maxWaitMs) { + const found = await this.findTranscriptMessageByLocalId(localId); + if (found) return found; + await new Promise((r) => setTimeout(r, 150)); + } + return null; + } + + private async findTranscriptMessageByLocalId(localId: string): Promise<{ + id: string; + seq: number; + localId: string | null; + content: { t: 'encrypted'; c: string }; + } | null> { + try { + const response = await axios.get(`${configuration.serverUrl}/v1/sessions/${this.sessionId}/messages`, { + headers: { + Authorization: `Bearer ${this.token}`, + 'Content-Type': 'application/json', + }, + timeout: 10_000, + }); + const messages = (response?.data as any)?.messages; + if (!Array.isArray(messages)) return null; + const found = messages.find((m: any) => m && typeof m === 'object' && m.localId === localId); + if (!found) return null; + const content = found.content; + if (!content || content.t !== 'encrypted' || typeof content.c !== 'string') return null; + if (typeof found.id !== 'string') return null; + if (typeof found.seq !== 'number') return null; + const foundLocalId = typeof found.localId === 'string' ? found.localId : null; + return { id: found.id, seq: found.seq, localId: foundLocalId, content: { t: 'encrypted', c: content.c } }; + } catch (error) { + logger.debug('[API] Failed to fetch transcript messages for pending-queue recovery', { error }); + return null; + } + } + + private async recoverMaterializedLocalId(localId: string, opts?: { maxWaitMs?: number }): Promise { + const found = await this.waitForTranscriptLocalId(localId, opts); + if (!found) return false; + + // Prevent later user-scoped updates from double-processing this localId. + this.pendingMaterializedLocalIds.delete(localId); + this.maybeScheduleUserSocketDisconnect(); + + const update: Update = { + id: `recovered-${localId}`, + seq: 0, + createdAt: Date.now(), + body: { + t: 'new-message', + sid: this.sessionId, + message: { + id: found.id, + seq: found.seq, + localId: found.localId ?? undefined, + content: found.content, + }, + }, + } as Update; + + this.handleUpdate(update, { source: 'session-scoped' }); + return true; + } + + private scheduleMaterializationRecovery(localId: string): void { + // Belt-and-suspenders: if we fail to observe the user-scoped update (connect race, brief disconnect), + // recover by scanning the transcript and re-injecting the message into the normal update pipeline. + const timer = setTimeout(() => { + if (!this.pendingMaterializedLocalIds.has(localId)) return; + void this.recoverMaterializedLocalId(localId, { maxWaitMs: 7_500 }); + }, 500); + timer.unref?.(); } onUserMessage(callback: (data: UserMessage) => void) { @@ -186,15 +416,137 @@ export class ApiSessionClient extends EventEmitter { } } + waitForMetadataUpdate(abortSignal?: AbortSignal): Promise { + if (abortSignal?.aborted) { + return Promise.resolve(false); + } + + const startMetadataVersion = this.metadataVersion; + const startAgentStateVersion = this.agentStateVersion; + if (startMetadataVersion < 0 || startAgentStateVersion < 0) { + void this.syncSessionSnapshotFromServer({ reason: 'waitForMetadataUpdate' }); + } + return new Promise((resolve) => { + let cleanedUp = false; + const shouldWatchConnect = !this.userSocket.connected; + const onUpdate = () => { + cleanup(); + resolve(true); + }; + const onConnect = () => { + cleanup(); + resolve(true); + }; + const onAbort = () => { + cleanup(); + resolve(false); + }; + const onDisconnect = () => { + cleanup(); + resolve(false); + }; + const cleanup = () => { + if (cleanedUp) return; + cleanedUp = true; + this.off('metadata-updated', onUpdate); + abortSignal?.removeEventListener('abort', onAbort); + if (shouldWatchConnect) { + this.userSocket.off('connect', onConnect); + } + this.userSocket.off('disconnect', onDisconnect); + this.maybeScheduleUserSocketDisconnect(); + }; + + this.on('metadata-updated', onUpdate); + if (shouldWatchConnect) { + this.userSocket.on('connect', onConnect); + } + abortSignal?.addEventListener('abort', onAbort, { once: true }); + this.userSocket.on('disconnect', onDisconnect); + + // Ensure we can observe metadata updates even when the server broadcasts them only to user-scoped clients. + // This keeps idle agents wakeable without requiring server changes. + this.kickUserSocketConnect(); + + if (abortSignal?.aborted) { + onAbort(); + return; + } + + // Avoid lost wakeups if a snapshot sync or socket event raced with handler registration. + if (this.metadataVersion !== startMetadataVersion || this.agentStateVersion !== startAgentStateVersion) { + onUpdate(); + return; + } + if (shouldWatchConnect && this.userSocket.connected) { + onConnect(); + return; + } + }); + } + + private async maybeClearPendingInFlight(localId: string | null): Promise { + if (!localId) return; + if (!this.socket.connected) return; + if (!this.metadata) return; + + try { + await this.metadataLock.inLock(async () => { + await backoff(async () => { + const current = this.metadata as unknown as Record; + const mq = parseMessageQueueV1((current as any).messageQueueV1); + const inFlightLocalId = mq?.inFlight?.localId ?? null; + if (inFlightLocalId !== localId) { + return; + } + + const cleared = clearMessageQueueV1InFlight(current, localId); + if (cleared === current) { + return; + } + + const answer = await this.socket.emitWithAck('update-metadata', { + sid: this.sessionId, + expectedVersion: this.metadataVersion, + metadata: encodeBase64(encrypt(this.encryptionKey, this.encryptionVariant, cleared)), + }); + if (answer.result === 'success') { + this.metadata = decrypt(this.encryptionKey, this.encryptionVariant, decodeBase64(answer.metadata)); + this.metadataVersion = answer.version; + return; + } + if (answer.result === 'version-mismatch') { + if (answer.version > this.metadataVersion) { + this.metadataVersion = answer.version; + this.metadata = decrypt(this.encryptionKey, this.encryptionVariant, decodeBase64(answer.metadata)); + } + throw new Error('Metadata version mismatch'); + } + }); + }); + } catch (error) { + logger.debug('[API] failed to clear messageQueueV1 inFlight', { error }); + } + } + /** * Send message to session * @param body - Message body (can be MessageContent or raw content for agent messages) */ sendClaudeSessionMessage(body: RawJSONLines) { + if (isToolTraceEnabled()) { + recordClaudeToolTraceEvents({ sessionId: this.sessionId, body }); + } + let content: MessageContent; // Check if body is already a MessageContent (has role property) - if (body.type === 'user' && typeof body.message.content === 'string' && body.isSidechain !== true && body.isMeta !== true) { + if ( + body.type === 'user' && + typeof body.message.content === 'string' && + body.isSidechain !== true && + body.isMeta !== true + ) { content = { role: 'user', content: { @@ -221,11 +573,7 @@ export class ApiSessionClient extends EventEmitter { logger.debugLargeJson('[SOCKET] Sending message through socket:', content) - // Check if socket is connected before sending - if (!this.socket.connected) { - logger.debug('[API] Socket not connected, cannot send Claude session message. Message will be lost:', { type: body.type }); - return; - } + this.logSendWhileDisconnected('Claude session message', { type: body.type }); const encrypted = encodeBase64(encrypt(this.encryptionKey, this.encryptionVariant, content)); this.socket.emit('message', { @@ -265,13 +613,12 @@ export class ApiSessionClient extends EventEmitter { sentFrom: 'cli' } }; - const encrypted = encodeBase64(encrypt(this.encryptionKey, this.encryptionVariant, content)); + + recordCodexToolTraceEventIfNeeded({ sessionId: this.sessionId, body }); - // Check if socket is connected before sending - if (!this.socket.connected) { - logger.debug('[API] Socket not connected, cannot send message. Message will be lost:', { type: body.type }); - // TODO: Consider implementing message queue or HTTP fallback for reliability - } + this.logSendWhileDisconnected('Codex message', { type: body?.type }); + + const encrypted = encodeBase64(encrypt(this.encryptionKey, this.encryptionVariant, content)); this.socket.emit('message', { sid: this.sessionId, @@ -286,28 +633,134 @@ export class ApiSessionClient extends EventEmitter { * @param provider - The agent provider sending the message (e.g., 'gemini', 'codex', 'claude') * @param body - The message payload (type: 'message' | 'reasoning' | 'tool-call' | 'tool-result') */ - sendAgentMessage(provider: 'gemini' | 'codex' | 'claude' | 'opencode', body: ACPMessageData) { + sendAgentMessage( + provider: ACPProvider, + body: ACPMessageData, + opts?: { localId?: string; meta?: Record }, + ) { + const normalizedBody: ACPMessageData = (() => { + if (body.type !== 'tool-result') return body; + if (typeof (body as any).isError === 'boolean') return body; + const output = (body as any).output as unknown; + if (!output || typeof output !== 'object' || Array.isArray(output)) return body; + const record = output as Record; + const status = typeof record.status === 'string' ? record.status : null; + const error = typeof record.error === 'string' ? record.error : null; + const isError = Boolean(error && error.length > 0) || status === 'failed' || status === 'cancelled' || status === 'error'; + return isError ? ({ ...(body as any), isError: true } as ACPMessageData) : body; + })(); + let content = { role: 'agent', content: { type: 'acp', provider, - data: body + data: normalizedBody }, meta: { - sentFrom: 'cli' + sentFrom: 'cli', + ...(opts?.meta && typeof opts.meta === 'object' ? opts.meta : {}), } }; + + if ( + normalizedBody.type === 'tool-call' || + normalizedBody.type === 'tool-result' || + normalizedBody.type === 'permission-request' || + normalizedBody.type === 'file-edit' || + normalizedBody.type === 'terminal-output' + ) { + recordAcpToolTraceEventIfNeeded({ sessionId: this.sessionId, provider, body: normalizedBody, localId: opts?.localId }); + } - logger.debug(`[SOCKET] Sending ACP message from ${provider}:`, { type: body.type, hasMessage: 'message' in body }); - + logger.debug(`[SOCKET] Sending ACP message from ${provider}:`, { type: normalizedBody.type, hasMessage: 'message' in normalizedBody }); + this.logSendWhileDisconnected(`${provider} ACP message`, { type: normalizedBody.type }); const encrypted = encodeBase64(encrypt(this.encryptionKey, this.encryptionVariant, content)); + this.socket.emit('message', { sid: this.sessionId, - message: encrypted + message: encrypted, + localId: opts?.localId, }); } + sendUserTextMessage(text: string, opts?: { localId?: string; meta?: Record }) { + const content: MessageContent = { + role: 'user', + content: { type: 'text', text }, + meta: { + sentFrom: 'cli', + ...(opts?.meta && typeof opts.meta === 'object' ? opts.meta : {}), + }, + }; + + this.logSendWhileDisconnected('User text message', { length: text.length }); + const encrypted = encodeBase64(encrypt(this.encryptionKey, this.encryptionVariant, content)); + this.socket.emit('message', { + sid: this.sessionId, + message: encrypted, + localId: opts?.localId, + }); + } + + async fetchRecentTranscriptTextItemsForAcpImport(opts?: { take?: number }): Promise> { + const take = typeof opts?.take === 'number' && opts.take > 0 ? Math.min(opts.take, 150) : 150; + try { + const response = await axios.get(`${configuration.serverUrl}/v1/sessions/${this.sessionId}/messages`, { + headers: { + Authorization: `Bearer ${this.token}`, + 'Content-Type': 'application/json', + }, + timeout: 10_000, + }); + const raw = (response?.data as any)?.messages; + if (!Array.isArray(raw)) return []; + const sliced = raw.slice(0, take); + + const items: Array<{ role: 'user' | 'agent'; text: string; createdAt: number }> = []; + for (const msg of sliced) { + const content = msg?.content; + if (!content || content.t !== 'encrypted' || typeof content.c !== 'string') continue; + const decrypted = decrypt(this.encryptionKey, this.encryptionVariant, decodeBase64(content.c)) as any; + const role = decrypted?.role; + if (role !== 'user' && role !== 'agent') continue; + + let text: string | null = null; + const body = decrypted?.content; + if (role === 'user') { + if (body?.type === 'text' && typeof body.text === 'string') { + text = body.text; + } + } else { + if (body?.type === 'text' && typeof body.text === 'string') { + text = body.text; + } else if (body?.type === 'acp') { + const data = body?.data; + if (data?.type === 'message' && typeof data.message === 'string') { + text = data.message; + } else if (data?.type === 'reasoning' && typeof data.message === 'string') { + text = data.message; + } + } + } + + if (!text || text.trim().length === 0) continue; + items.push({ + role, + text, + createdAt: typeof msg.createdAt === 'number' ? msg.createdAt : 0, + }); + } + + // API returns newest first; normalize to chronological. + items.sort((a, b) => a.createdAt - b.createdAt); + return items.map((v) => ({ role: v.role, text: v.text })); + } catch (error) { + logger.debug('[API] Failed to fetch transcript messages for ACP import', { error }); + return []; + } + } + sendSessionEvent(event: { type: 'switch', mode: 'local' | 'remote' } | { @@ -325,7 +778,11 @@ export class ApiSessionClient extends EventEmitter { data: event } }; + + this.logSendWhileDisconnected('session event', { eventType: event.type }); + const encrypted = encodeBase64(encrypt(this.encryptionKey, this.encryptionVariant, content)); + this.socket.emit('message', { sid: this.sessionId, message: encrypted @@ -373,8 +830,7 @@ export class ApiSessionClient extends EventEmitter { cache_read: usage.cache_read_input_tokens || 0 }, cost: { - // TODO: Calculate actual costs based on pricing - // For now, using placeholder values + // Costs are not currently calculated (placeholder values). total: 0, input: 0, output: 0 @@ -390,21 +846,21 @@ export class ApiSessionClient extends EventEmitter { */ updateMetadata(handler: (metadata: Metadata) => Metadata) { this.metadataLock.inLock(async () => { - await backoff(async () => { - let updated = handler(this.metadata!); // Weird state if metadata is null - should never happen but here we are - const answer = await this.socket.emitWithAck('update-metadata', { sid: this.sessionId, expectedVersion: this.metadataVersion, metadata: encodeBase64(encrypt(this.encryptionKey, this.encryptionVariant, updated)) }); - if (answer.result === 'success') { - this.metadata = decrypt(this.encryptionKey, this.encryptionVariant, decodeBase64(answer.metadata)); - this.metadataVersion = answer.version; - } else if (answer.result === 'version-mismatch') { - if (answer.version > this.metadataVersion) { - this.metadataVersion = answer.version; - this.metadata = decrypt(this.encryptionKey, this.encryptionVariant, decodeBase64(answer.metadata)); - } - throw new Error('Metadata version mismatch'); - } else if (answer.result === 'error') { - // Hard error - ignore - } + await updateSessionMetadataWithAck({ + socket: this.socket as any, + sessionId: this.sessionId, + encryptionKey: this.encryptionKey, + encryptionVariant: this.encryptionVariant, + getMetadata: () => this.metadata, + setMetadata: (metadata) => { + this.metadata = metadata; + }, + getMetadataVersion: () => this.metadataVersion, + setMetadataVersion: (version) => { + this.metadataVersion = version; + }, + syncSessionSnapshotFromServer: () => this.syncSessionSnapshotFromServer({ reason: 'waitForMetadataUpdate' }), + handler, }); }); } @@ -416,23 +872,21 @@ export class ApiSessionClient extends EventEmitter { updateAgentState(handler: (metadata: AgentState) => AgentState) { logger.debugLargeJson('Updating agent state', this.agentState); this.agentStateLock.inLock(async () => { - await backoff(async () => { - let updated = handler(this.agentState || {}); - const answer = await this.socket.emitWithAck('update-state', { sid: this.sessionId, expectedVersion: this.agentStateVersion, agentState: updated ? encodeBase64(encrypt(this.encryptionKey, this.encryptionVariant, updated)) : null }); - if (answer.result === 'success') { - this.agentState = answer.agentState ? decrypt(this.encryptionKey, this.encryptionVariant, decodeBase64(answer.agentState)) : null; - this.agentStateVersion = answer.version; - logger.debug('Agent state updated', this.agentState); - } else if (answer.result === 'version-mismatch') { - if (answer.version > this.agentStateVersion) { - this.agentStateVersion = answer.version; - this.agentState = answer.agentState ? decrypt(this.encryptionKey, this.encryptionVariant, decodeBase64(answer.agentState)) : null; - } - throw new Error('Agent state version mismatch'); - } else if (answer.result === 'error') { - // console.error('Agent state update error', answer); - // Hard error - ignore - } + await updateSessionAgentStateWithAck({ + socket: this.socket as any, + sessionId: this.sessionId, + encryptionKey: this.encryptionKey, + encryptionVariant: this.encryptionVariant, + getAgentState: () => this.agentState, + setAgentState: (agentState) => { + this.agentState = agentState; + }, + getAgentStateVersion: () => this.agentStateVersion, + setAgentStateVersion: (version) => { + this.agentStateVersion = version; + }, + syncSessionSnapshotFromServer: () => this.syncSessionSnapshotFromServer({ reason: 'waitForMetadataUpdate' }), + handler, }); }); } @@ -454,8 +908,250 @@ export class ApiSessionClient extends EventEmitter { }); } + /** + * Read-only snapshot of the currently known session metadata (decrypted). + * + * This is useful for spawn-time decisions that depend on previous metadata values + * (e.g. session-scoped feature toggles) without requiring a metadata write. + */ + getMetadataSnapshot(): Metadata | null { + return this.metadata; + } + async close() { logger.debug('[API] socket.close() called'); + this.closed = true; + if (this.userSocketDisconnectTimer) { + clearTimeout(this.userSocketDisconnectTimer); + this.userSocketDisconnectTimer = null; + } + this.pendingMaterializedLocalIds.clear(); + try { + this.userSocket.close(); + } catch { + // ignore + } this.socket.close(); } + + peekPendingMessageQueueV1Preview(opts?: { maxPreview?: number }): { count: number; preview: string[] } { + const maxPreview = opts?.maxPreview ?? 3; + if (!this.metadata) return { count: 0, preview: [] }; + const mq = parseMessageQueueV1((this.metadata as any).messageQueueV1); + if (!mq) return { count: 0, preview: [] }; + + const items = [ + ...(mq.inFlight ? [mq.inFlight] : []), + ...mq.queue, + ]; + + const preview: string[] = []; + for (const item of items.slice(0, maxPreview)) { + try { + const raw = decrypt(this.encryptionKey, this.encryptionVariant, decodeBase64(item.message)) as any; + const displayText = raw?.meta?.displayText; + const text = raw?.content?.text; + const resolved = typeof displayText === 'string' ? displayText : typeof text === 'string' ? text : null; + preview.push(resolved ? resolved : ''); + } catch { + preview.push(''); + } + } + + return { count: items.length, preview }; + } + + async discardPendingMessageQueueV1All(opts: { reason: 'switch_to_local' | 'manual' }): Promise { + if (!this.socket.connected) { + return 0; + } + if (!this.metadata) { + return 0; + } + + let discardedCount = 0; + + await this.metadataLock.inLock(async () => { + await backoff(async () => { + const current = this.metadata as unknown as Record; + const result = discardMessageQueueV1All(current, { now: Date.now(), reason: opts.reason }); + if (!result || result.discarded.length === 0) { + discardedCount = 0; + return; + } + + const answer = await this.socket.emitWithAck('update-metadata', { + sid: this.sessionId, + expectedVersion: this.metadataVersion, + metadata: encodeBase64(encrypt(this.encryptionKey, this.encryptionVariant, result.metadata)), + }); + + if (answer.result === 'success') { + this.metadata = decrypt(this.encryptionKey, this.encryptionVariant, decodeBase64(answer.metadata)); + this.metadataVersion = answer.version; + discardedCount = result.discarded.length; + return; + } + + if (answer.result === 'version-mismatch') { + if (answer.version > this.metadataVersion) { + this.metadataVersion = answer.version; + this.metadata = decrypt(this.encryptionKey, this.encryptionVariant, decodeBase64(answer.metadata)); + } + throw new Error('Metadata version mismatch'); + } + + // Hard error - ignore + discardedCount = 0; + }); + }); + + return discardedCount; + } + + async discardCommittedMessageLocalIds(opts: { localIds: string[]; reason: 'switch_to_local' | 'manual' }): Promise { + if (!this.socket.connected) { + return 0; + } + if (!this.metadata) { + return 0; + } + + const localIds = opts.localIds.filter((id) => typeof id === 'string' && id.length > 0); + if (localIds.length === 0) { + return 0; + } + + let addedCount = 0; + + await this.metadataLock.inLock(async () => { + await backoff(async () => { + const current = this.metadata as unknown as Record; + + const existingRaw = (current as any).discardedCommittedMessageLocalIds; + const existing = Array.isArray(existingRaw) ? existingRaw.filter((v) => typeof v === 'string') : []; + const existingSet = new Set(existing); + const uniqueNew = localIds.filter((id) => !existingSet.has(id)); + if (uniqueNew.length === 0) { + addedCount = 0; + return; + } + + const nextMetadata = addDiscardedCommittedMessageLocalIds(current, uniqueNew); + const answer = await this.socket.emitWithAck('update-metadata', { + sid: this.sessionId, + expectedVersion: this.metadataVersion, + metadata: encodeBase64(encrypt(this.encryptionKey, this.encryptionVariant, nextMetadata)), + }); + + if (answer.result === 'success') { + this.metadata = decrypt(this.encryptionKey, this.encryptionVariant, decodeBase64(answer.metadata)); + this.metadataVersion = answer.version; + addedCount = uniqueNew.length; + return; + } + + if (answer.result === 'version-mismatch') { + if (answer.version > this.metadataVersion) { + this.metadataVersion = answer.version; + this.metadata = decrypt(this.encryptionKey, this.encryptionVariant, decodeBase64(answer.metadata)); + } + throw new Error('Metadata version mismatch'); + } + + // Hard error - ignore + addedCount = 0; + }); + }); + + return addedCount; + } + + /** + * Materialize one metadata-backed queued message (messageQueueV1) into the normal session transcript. + * + * We claim the oldest queued item in encrypted session metadata, then emit it through + * the normal transcript message pipeline (idempotent via (sessionId, localId)). + * + * The inFlight marker is cleared only after we observe the materialized user message + * coming back from the server (to avoid losing messages on crashes between emit and persist). + */ + async popPendingMessage(): Promise { + if (!this.socket.connected) { + return false; + } + if (!this.metadata) { + return false; + } + try { + // Start the user-scoped socket early so it has time to connect before we emit the materialized + // transcript message (otherwise we may miss the broadcast update and need transcript recovery). + this.kickUserSocketConnect(); + + const inFlight = await this.metadataLock.inLock<{ localId: string; message: string; wasExistingInFlight: boolean } | null>(async () => { + let claimedInFlight: { localId: string; message: string; wasExistingInFlight: boolean } | null = null; + await backoff(async () => { + const current = this.metadata as unknown as Record; + const claimed = claimMessageQueueV1Next(current, Date.now()); + if (!claimed) { + claimedInFlight = null; + return; + } + + // Persist claim (if needed) so other agents don't process the same queued item. + const wasExistingInFlight = claimed.metadata === current; + if (claimed.metadata !== current) { + const answer = await this.socket.emitWithAck('update-metadata', { + sid: this.sessionId, + expectedVersion: this.metadataVersion, + metadata: encodeBase64(encrypt(this.encryptionKey, this.encryptionVariant, claimed.metadata)), + }); + if (answer.result === 'success') { + this.metadata = decrypt(this.encryptionKey, this.encryptionVariant, decodeBase64(answer.metadata)); + this.metadataVersion = answer.version; + } else if (answer.result === 'version-mismatch') { + if (answer.version > this.metadataVersion) { + this.metadataVersion = answer.version; + this.metadata = decrypt(this.encryptionKey, this.encryptionVariant, decodeBase64(answer.metadata)); + } + throw new Error('Metadata version mismatch'); + } + } + + claimedInFlight = { localId: claimed.inFlight.localId, message: claimed.inFlight.message, wasExistingInFlight }; + }); + return claimedInFlight; + }); + + if (!inFlight) { + return false; + } + const inFlightLocalId = inFlight.localId; + + // If the queue already had an inFlight item, we may have missed the socket update (or restarted) + // and re-emitting with the same localId will be idempotent server-side (no broadcast update). + // Recover by checking the transcript first. + if (inFlight.wasExistingInFlight) { + const recovered = await this.recoverMaterializedLocalId(inFlightLocalId, { maxWaitMs: 1_500 }); + if (recovered) { + return true; + } + } + + // Materialize the pending item into the transcript via the normal message pipeline. + // This is idempotent because SessionMessage has a unique (sessionId, localId) constraint. + this.pendingMaterializedLocalIds.add(inFlightLocalId); + this.socket.emit('message', { + sid: this.sessionId, + message: inFlight.message, + localId: inFlightLocalId, + }); + this.scheduleMaterializationRecovery(inFlightLocalId); + + return true; + } catch (error) { + logger.debug('[API] popPendingMessage failed', { error }); + return false; + } + } } diff --git a/cli/src/api/client/encryptionKey.ts b/cli/src/api/client/encryptionKey.ts new file mode 100644 index 000000000..4eab00c34 --- /dev/null +++ b/cli/src/api/client/encryptionKey.ts @@ -0,0 +1,62 @@ +import type { Credentials } from '@/persistence'; + +import { getRandomBytes, libsodiumEncryptForPublicKey } from '../encryption'; + +export type EncryptionContext = { + encryptionKey: Uint8Array; + encryptionVariant: 'legacy' | 'dataKey'; + dataEncryptionKey: Uint8Array | null; +}; + +export function resolveSessionEncryptionContext(credential: Credentials): EncryptionContext { + // Resolve encryption key + let dataEncryptionKey: Uint8Array | null = null; + let encryptionKey: Uint8Array; + let encryptionVariant: 'legacy' | 'dataKey'; + + if (credential.encryption.type === 'dataKey') { + // Generate new encryption key + encryptionKey = getRandomBytes(32); + encryptionVariant = 'dataKey'; + + // Derive and encrypt data encryption key + // const contentDataKey = await deriveKey(this.secret, 'Happy EnCoder', ['content']); + // const publicKey = libsodiumPublicKeyFromSecretKey(contentDataKey); + let encryptedDataKey = libsodiumEncryptForPublicKey(encryptionKey, credential.encryption.publicKey); + dataEncryptionKey = new Uint8Array(encryptedDataKey.length + 1); + dataEncryptionKey.set([0], 0); // Version byte + dataEncryptionKey.set(encryptedDataKey, 1); // Data key + } else { + encryptionKey = credential.encryption.secret; + encryptionVariant = 'legacy'; + } + + return { encryptionKey, encryptionVariant, dataEncryptionKey }; +} + +export function resolveMachineEncryptionContext(credential: Credentials): EncryptionContext { + // Resolve encryption key + let dataEncryptionKey: Uint8Array | null = null; + let encryptionKey: Uint8Array; + let encryptionVariant: 'legacy' | 'dataKey'; + + if (credential.encryption.type === 'dataKey') { + // Encrypt data encryption key + encryptionVariant = 'dataKey'; + encryptionKey = credential.encryption.machineKey; + let encryptedDataKey = libsodiumEncryptForPublicKey( + credential.encryption.machineKey, + credential.encryption.publicKey + ); + dataEncryptionKey = new Uint8Array(encryptedDataKey.length + 1); + dataEncryptionKey.set([0], 0); // Version byte + dataEncryptionKey.set(encryptedDataKey, 1); // Data key + } else { + // Legacy encryption + encryptionKey = credential.encryption.secret; + encryptionVariant = 'legacy'; + } + + return { encryptionKey, encryptionVariant, dataEncryptionKey }; +} + diff --git a/cli/src/api/client/offlineErrors.ts b/cli/src/api/client/offlineErrors.ts new file mode 100644 index 000000000..696ea4cf7 --- /dev/null +++ b/cli/src/api/client/offlineErrors.ts @@ -0,0 +1,107 @@ +import axios from 'axios'; +import chalk from 'chalk'; + +import { connectionState, isNetworkError } from '@/api/offline/serverConnectionErrors'; + +export function shouldReturnNullForGetOrCreateSessionError( + error: unknown, + params: Readonly<{ url: string }> +): boolean { + // Check if it's a connection error + if (error && typeof error === 'object' && 'code' in error) { + const errorCode = (error as any).code; + if (isNetworkError(errorCode)) { + connectionState.fail({ + operation: 'Session creation', + caller: 'api.getOrCreateSession', + errorCode, + url: params.url, + }); + return true; + } + } + + // Handle 404 gracefully - server endpoint may not be available yet + const is404Error = + (axios.isAxiosError(error) && error.response?.status === 404) || + (error && typeof error === 'object' && 'response' in error && (error as any).response?.status === 404); + if (is404Error) { + connectionState.fail({ + operation: 'Session creation', + errorCode: '404', + url: params.url, + }); + return true; + } + + // Handle 5xx server errors - use offline mode with auto-reconnect + if (axios.isAxiosError(error) && error.response?.status) { + const status = error.response.status; + if (status >= 500) { + connectionState.fail({ + operation: 'Session creation', + errorCode: String(status), + url: params.url, + details: ['Server encountered an error, will retry automatically'], + }); + return true; + } + } + + return false; +} + +export function shouldReturnMinimalMachineForGetOrCreateMachineError( + error: unknown, + params: Readonly<{ url: string }> +): boolean { + // Handle connection errors gracefully + if (axios.isAxiosError(error) && error.code && isNetworkError(error.code)) { + connectionState.fail({ + operation: 'Machine registration', + caller: 'api.getOrCreateMachine', + errorCode: error.code, + url: params.url, + }); + return true; + } + + // Handle 403/409 - server rejected request due to authorization conflict + // This is NOT "server unreachable" - server responded, so don't use connectionState + if (axios.isAxiosError(error) && error.response?.status) { + const status = error.response.status; + + if (status === 403 || status === 409) { + // Re-auth conflict: machine registered to old account, re-association not allowed + console.log(chalk.yellow(`⚠️ Machine registration rejected by the server with status ${status}`)); + console.log(chalk.yellow(` → This machine ID is already registered to another account on the server`)); + console.log(chalk.yellow(` → This usually happens after re-authenticating with a different account`)); + console.log(chalk.yellow(` → Run 'happy doctor clean' to reset local state and generate a new machine ID`)); + console.log(chalk.yellow(` → Open a GitHub issue if this problem persists`)); + return true; + } + + // Handle 5xx - server error, use offline mode with auto-reconnect + if (status >= 500) { + connectionState.fail({ + operation: 'Machine registration', + errorCode: String(status), + url: params.url, + details: ['Server encountered an error, will retry automatically'], + }); + return true; + } + + // Handle 404 - endpoint may not be available yet + if (status === 404) { + connectionState.fail({ + operation: 'Machine registration', + errorCode: '404', + url: params.url, + }); + return true; + } + } + + return false; +} diff --git a/cli/src/api/machine/rpcHandlers.ts b/cli/src/api/machine/rpcHandlers.ts new file mode 100644 index 000000000..febb0f354 --- /dev/null +++ b/cli/src/api/machine/rpcHandlers.ts @@ -0,0 +1,196 @@ +import { logger } from '@/ui/logger'; + +import { + SPAWN_SESSION_ERROR_CODES, + type SpawnSessionOptions, + type SpawnSessionResult, +} from '@/rpc/handlers/registerSessionHandlers'; +import { RPC_METHODS } from '@happy/protocol/rpc'; + +import type { RpcHandlerManager } from '../rpc/RpcHandlerManager'; + +export type MachineRpcHandlers = { + spawnSession: (options: SpawnSessionOptions) => Promise; + stopSession: (sessionId: string) => Promise; + requestShutdown: () => void; +}; + +export function registerMachineRpcHandlers(params: Readonly<{ + rpcHandlerManager: RpcHandlerManager; + handlers: MachineRpcHandlers; +}>): void { + const { rpcHandlerManager, handlers } = params; + const { spawnSession, stopSession, requestShutdown } = handlers; + + // Register spawn session handler + rpcHandlerManager.registerHandler(RPC_METHODS.SPAWN_HAPPY_SESSION, async (params: any) => { + const { + directory, + sessionId, + machineId, + approvedNewDirectoryCreation, + agent, + token, + environmentVariables, + profileId, + terminal, + resume, + permissionMode, + permissionModeUpdatedAt, + experimentalCodexResume, + experimentalCodexAcp + } = params || {}; + const envKeys = environmentVariables && typeof environmentVariables === 'object' + ? Object.keys(environmentVariables as Record) + : []; + const maxEnvKeysToLog = 20; + const envKeySample = envKeys.slice(0, maxEnvKeysToLog); + logger.debug('[API MACHINE] Spawning session', { + directory, + sessionId, + machineId, + agent, + approvedNewDirectoryCreation, + profileId, + hasToken: !!token, + terminal, + permissionMode, + permissionModeUpdatedAt: typeof permissionModeUpdatedAt === 'number' ? permissionModeUpdatedAt : undefined, + environmentVariableCount: envKeys.length, + environmentVariableKeySample: envKeySample, + environmentVariableKeysTruncated: envKeys.length > maxEnvKeysToLog, + hasResume: typeof resume === 'string' && resume.trim().length > 0, + experimentalCodexResume: experimentalCodexResume === true, + experimentalCodexAcp: experimentalCodexAcp === true, + }); + + // Handle resume-session type for inactive session resumption + if (params?.type === 'resume-session') { + const { + sessionId: existingSessionId, + directory, + agent, + resume, + sessionEncryptionKeyBase64, + sessionEncryptionVariant, + experimentalCodexResume, + experimentalCodexAcp + } = params; + logger.debug(`[API MACHINE] Resuming inactive session ${existingSessionId}`); + + if (!directory) { + return { + type: 'error', + errorCode: SPAWN_SESSION_ERROR_CODES.INVALID_REQUEST, + errorMessage: 'Directory is required', + }; + } + if (!existingSessionId) { + return { + type: 'error', + errorCode: SPAWN_SESSION_ERROR_CODES.INVALID_REQUEST, + errorMessage: 'Session ID is required for resume', + }; + } + if (!sessionEncryptionKeyBase64) { + return { + type: 'error', + errorCode: SPAWN_SESSION_ERROR_CODES.RESUME_MISSING_ENCRYPTION_KEY, + errorMessage: 'Session encryption key is required for resume', + }; + } + if (sessionEncryptionVariant !== 'dataKey') { + return { + type: 'error', + errorCode: SPAWN_SESSION_ERROR_CODES.RESUME_UNSUPPORTED_ENCRYPTION_VARIANT, + errorMessage: 'Unsupported session encryption variant for resume', + }; + } + + const result = await spawnSession({ + directory, + agent, + existingSessionId, + approvedNewDirectoryCreation: true, + resume: typeof resume === 'string' ? resume : undefined, + sessionEncryptionKeyBase64, + sessionEncryptionVariant, + permissionMode, + permissionModeUpdatedAt, + experimentalCodexResume: Boolean(experimentalCodexResume), + experimentalCodexAcp: Boolean(experimentalCodexAcp), + }); + + if (result.type === 'error') { + return result; + } + + // For resume, we don't return a new session ID - we're reusing the existing one + return { type: 'success' }; + } + + if (!directory) { + return { type: 'error', errorCode: SPAWN_SESSION_ERROR_CODES.INVALID_REQUEST, errorMessage: 'Directory is required' }; + } + + const result = await spawnSession({ + directory, + sessionId, + machineId, + approvedNewDirectoryCreation, + agent, + token, + environmentVariables, + profileId, + terminal, + resume, + permissionMode, + permissionModeUpdatedAt, + experimentalCodexResume, + experimentalCodexAcp + }); + + switch (result.type) { + case 'success': + logger.debug(`[API MACHINE] Spawned session ${result.sessionId}`); + return { type: 'success', sessionId: result.sessionId }; + + case 'requestToApproveDirectoryCreation': + logger.debug(`[API MACHINE] Requesting directory creation approval for: ${result.directory}`); + return { type: 'requestToApproveDirectoryCreation', directory: result.directory }; + + case 'error': + return result; + } + }); + + // Register stop session handler + rpcHandlerManager.registerHandler(RPC_METHODS.STOP_SESSION, async (params: any) => { + const { sessionId } = params || {}; + + if (!sessionId) { + throw new Error('Session ID is required'); + } + + const success = await stopSession(sessionId); + if (!success) { + throw new Error('Session not found or failed to stop'); + } + + logger.debug(`[API MACHINE] Stopped session ${sessionId}`); + return { message: 'Session stopped' }; + }); + + // Register stop daemon handler + rpcHandlerManager.registerHandler(RPC_METHODS.STOP_DAEMON, () => { + logger.debug('[API MACHINE] Received stop-daemon RPC request'); + + // Trigger shutdown callback after a delay + setTimeout(() => { + logger.debug('[API MACHINE] Initiating daemon shutdown from RPC'); + requestShutdown(); + }, 100); + + return { message: 'Daemon stop request acknowledged, starting shutdown sequence...' }; + }); +} diff --git a/cli/src/api/machine/socketTypes.ts b/cli/src/api/machine/socketTypes.ts new file mode 100644 index 000000000..e0f01e6e1 --- /dev/null +++ b/cli/src/api/machine/socketTypes.ts @@ -0,0 +1,44 @@ +import type { Update } from '../types'; +import { SOCKET_RPC_EVENTS } from '@happy/protocol/socketRpc'; + +export interface ServerToDaemonEvents { + update: (data: Update) => void; + [SOCKET_RPC_EVENTS.REQUEST]: (data: { method: string; params: string }, callback: (response: string) => void) => void; + [SOCKET_RPC_EVENTS.REGISTERED]: (data: { method: string }) => void; + [SOCKET_RPC_EVENTS.UNREGISTERED]: (data: { method: string }) => void; + [SOCKET_RPC_EVENTS.ERROR]: (data: { type: string; error: string }) => void; + auth: (data: { success: boolean; user: string }) => void; + error: (data: { message: string }) => void; +} + +export interface DaemonToServerEvents { + 'machine-alive': (data: { machineId: string; time: number }) => void; + 'session-end': (data: { sid: string; time: number; exit?: any }) => void; + + 'machine-update-metadata': ( + data: { machineId: string; metadata: string; expectedVersion: number }, + cb: ( + answer: + | { result: 'error' } + | { result: 'version-mismatch'; version: number; metadata: string } + | { result: 'success'; version: number; metadata: string } + ) => void + ) => void; + + 'machine-update-state': ( + data: { machineId: string; daemonState: string; expectedVersion: number }, + cb: ( + answer: + | { result: 'error' } + | { result: 'version-mismatch'; version: number; daemonState: string } + | { result: 'success'; version: number; daemonState: string } + ) => void + ) => void; + + [SOCKET_RPC_EVENTS.REGISTER]: (data: { method: string }) => void; + [SOCKET_RPC_EVENTS.UNREGISTER]: (data: { method: string }) => void; + [SOCKET_RPC_EVENTS.CALL]: ( + data: { method: string; params: any }, + callback: (response: { ok: boolean; result?: any; error?: string }) => void + ) => void; +} diff --git a/cli/src/api/offline/offlineSessionStub.test.ts b/cli/src/api/offline/offlineSessionStub.test.ts new file mode 100644 index 000000000..30137a926 --- /dev/null +++ b/cli/src/api/offline/offlineSessionStub.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest'; +import { createOfflineSessionStub } from '@/api/offline/offlineSessionStub'; + +describe('createOfflineSessionStub', () => { + it('returns an EventEmitter-compatible ApiSessionClient', () => { + const session = createOfflineSessionStub('tag'); + + let calls = 0; + session.on('message', () => { + calls += 1; + }); + session.emit('message', { ok: true }); + + expect(calls).toBe(1); + }); +}); diff --git a/cli/src/api/offline/offlineSessionStub.ts b/cli/src/api/offline/offlineSessionStub.ts new file mode 100644 index 000000000..4555f1293 --- /dev/null +++ b/cli/src/api/offline/offlineSessionStub.ts @@ -0,0 +1,97 @@ +/** + * Offline Session Stub Factory + * + * Creates a no-op session stub for offline mode that can be used across all backends + * (Claude, Codex, Gemini, etc.). All session methods become no-ops until reconnection. + * + * This follows DRY principles by providing a single implementation for all backends, + * satisfying REQ-8 from serverConnectionErrors.ts. + * + * @module offlineSessionStub + */ + +import { EventEmitter } from 'node:events'; +import type { ACPMessageData, ACPProvider, ApiSessionClient } from '@/api/apiSession'; +import { RpcHandlerManager } from '@/api/rpc/RpcHandlerManager'; +import type { AgentState, Metadata, Usage, UserMessage } from '@/api/types'; +import type { RawJSONLines } from '@/backends/claude/types'; + +type ApiSessionClientStubContract = Pick< + ApiSessionClient, + | 'sessionId' + | 'rpcHandlerManager' + | 'sendCodexMessage' + | 'sendAgentMessage' + | 'sendClaudeSessionMessage' + | 'sendSessionEvent' + | 'keepAlive' + | 'sendSessionDeath' + | 'sendUsageData' + | 'updateMetadata' + | 'updateAgentState' + | 'onUserMessage' + | 'flush' + | 'close' +>; + +class OfflineSessionStub extends EventEmitter implements ApiSessionClientStubContract { + readonly sessionId: string; + readonly rpcHandlerManager: RpcHandlerManager; + + constructor(sessionId: string) { + super(); + this.sessionId = sessionId; + this.rpcHandlerManager = new RpcHandlerManager({ + scopePrefix: this.sessionId, + encryptionKey: new Uint8Array(32), + encryptionVariant: 'legacy', + logger: () => undefined, + }); + } + + sendCodexMessage(_body: unknown): void {} + sendAgentMessage(_provider: ACPProvider, _body: ACPMessageData): void {} + sendClaudeSessionMessage(_body: RawJSONLines): void {} + sendSessionEvent( + _event: + | { type: 'switch'; mode: 'local' | 'remote' } + | { type: 'message'; message: string } + | { type: 'permission-mode-changed'; mode: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' } + | { type: 'ready' }, + _id?: string + ): void {} + keepAlive(_thinking: boolean, _mode: 'local' | 'remote'): void {} + sendSessionDeath(): void {} + sendUsageData(_usage: Usage): void {} + updateMetadata(_handler: (metadata: Metadata) => Metadata): void {} + updateAgentState(_handler: (metadata: AgentState) => AgentState): void {} + onUserMessage(_callback: (data: UserMessage) => void): void {} + async flush(): Promise {} + async close(): Promise {} +} + +/** + * Creates a no-op session stub for offline mode. + * + * The stub implements the ApiSessionClient interface with no-op methods, + * allowing the application to continue running while offline. When reconnection + * succeeds, the real session replaces this stub. + * + * @param sessionTag - Unique session tag (used to create offline session ID) + * @returns A no-op ApiSessionClient stub + * + * @example + * ```typescript + * const offlineStub = createOfflineSessionStub(sessionTag); + * let session: ApiSessionClient = offlineStub; + * + * // When reconnected: + * session = api.sessionSyncClient(response); + * ``` + */ +export function createOfflineSessionStub(sessionTag: string): ApiSessionClient { + const stub = new OfflineSessionStub(`offline-${sessionTag}`); + const _typecheck: ApiSessionClientStubContract = stub; + void _typecheck; + return stub as unknown as ApiSessionClient; +} diff --git a/cli/src/utils/serverConnectionErrors.test.ts b/cli/src/api/offline/serverConnectionErrors.test.ts similarity index 97% rename from cli/src/utils/serverConnectionErrors.test.ts rename to cli/src/api/offline/serverConnectionErrors.test.ts index 9f9e2d5e3..eca92842d 100644 --- a/cli/src/utils/serverConnectionErrors.test.ts +++ b/cli/src/api/offline/serverConnectionErrors.test.ts @@ -189,6 +189,7 @@ describe('startOfflineReconnection', () => { }, 20000); it('should increment failure count on each retry', async () => { + const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0); let attemptCount = 0; const healthCheck = async () => { attemptCount++; @@ -197,13 +198,14 @@ describe('startOfflineReconnection', () => { const { handle } = createTestHandle({ healthCheck }); - // With real exponential backoff (5s + 10s delays with jitter), - // we need ~20s to reach attempt 3 - await waitForReconnection(handle, 25000); - - expect(attemptCount).toBe(3); - - handle.cancel(); + try { + // With jittered backoff, this can be non-deterministic. Use a deterministic RNG here so the test is stable. + await waitForReconnection(handle, 25000); + expect(attemptCount).toBe(3); + } finally { + handle.cancel(); + randomSpy.mockRestore(); + } }, 30000); }); diff --git a/cli/src/utils/serverConnectionErrors.ts b/cli/src/api/offline/serverConnectionErrors.ts similarity index 100% rename from cli/src/utils/serverConnectionErrors.ts rename to cli/src/api/offline/serverConnectionErrors.ts diff --git a/cli/src/utils/setupOfflineReconnection.ts b/cli/src/api/offline/setupOfflineReconnection.ts similarity index 96% rename from cli/src/utils/setupOfflineReconnection.ts rename to cli/src/api/offline/setupOfflineReconnection.ts index 97ea3d00f..aa5f2113b 100644 --- a/cli/src/utils/setupOfflineReconnection.ts +++ b/cli/src/api/offline/setupOfflineReconnection.ts @@ -11,8 +11,8 @@ import type { ApiClient } from '@/api/api'; import type { ApiSessionClient } from '@/api/apiSession'; import type { AgentState, Metadata, Session } from '@/api/types'; import { configuration } from '@/configuration'; -import { createOfflineSessionStub } from '@/utils/offlineSessionStub'; -import { startOfflineReconnection } from '@/utils/serverConnectionErrors'; +import { createOfflineSessionStub } from '@/api/offline/offlineSessionStub'; +import { startOfflineReconnection } from '@/api/offline/serverConnectionErrors'; /** * Options for setting up offline reconnection. diff --git a/cli/src/api/queue/discardedCommittedMessageLocalIds.test.ts b/cli/src/api/queue/discardedCommittedMessageLocalIds.test.ts new file mode 100644 index 000000000..f39a5be94 --- /dev/null +++ b/cli/src/api/queue/discardedCommittedMessageLocalIds.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; +import { addDiscardedCommittedMessageLocalIds } from './discardedCommittedMessageLocalIds'; + +describe('addDiscardedCommittedMessageLocalIds', () => { + it('adds new ids and preserves existing entries', () => { + const next = addDiscardedCommittedMessageLocalIds( + { discardedCommittedMessageLocalIds: ['a'] }, + ['b', 'a', 'c'], + { max: 10 }, + ); + + expect(next.discardedCommittedMessageLocalIds).toEqual(['a', 'b', 'c']); + }); + + it('caps the list to the last max entries', () => { + const next = addDiscardedCommittedMessageLocalIds( + { discardedCommittedMessageLocalIds: ['a', 'b'] }, + ['c', 'd'], + { max: 3 }, + ); + + expect(next.discardedCommittedMessageLocalIds).toEqual(['b', 'c', 'd']); + }); +}); + diff --git a/cli/src/api/queue/discardedCommittedMessageLocalIds.ts b/cli/src/api/queue/discardedCommittedMessageLocalIds.ts new file mode 100644 index 000000000..9cbb91e7b --- /dev/null +++ b/cli/src/api/queue/discardedCommittedMessageLocalIds.ts @@ -0,0 +1,31 @@ +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((v) => typeof v === 'string'); +} + +export function addDiscardedCommittedMessageLocalIds( + metadata: Record, + localIds: string[], + opts?: { max?: number }, +): Record { + const max = opts?.max ?? 500; + + const existingRaw = (metadata as any).discardedCommittedMessageLocalIds; + const existing = isStringArray(existingRaw) ? existingRaw : []; + const existingSet = new Set(existing); + + const next = [...existing]; + for (const id of localIds) { + if (typeof id !== 'string' || !id) continue; + if (existingSet.has(id)) continue; + existingSet.add(id); + next.push(id); + } + + const capped = next.length > max ? next.slice(-max) : next; + + return { + ...metadata, + discardedCommittedMessageLocalIds: capped, + }; +} + diff --git a/cli/src/api/queue/messageQueueV1.test.ts b/cli/src/api/queue/messageQueueV1.test.ts new file mode 100644 index 000000000..e4bf97b8a --- /dev/null +++ b/cli/src/api/queue/messageQueueV1.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest'; + +import { claimMessageQueueV1Next, clearMessageQueueV1InFlight, parseMessageQueueV1 } from './messageQueueV1'; + +describe('messageQueueV1', () => { + it('parses v1 queue with optional inFlight', () => { + const parsed = parseMessageQueueV1({ + v: 1, + queue: [{ localId: 'a', message: 'm', createdAt: 1, updatedAt: 1 }], + inFlight: null, + }); + expect(parsed?.v).toBe(1); + expect(parsed?.queue[0]?.localId).toBe('a'); + expect(parsed?.inFlight).toBe(null); + }); + + it('rejects invalid inFlight objects', () => { + const parsed = parseMessageQueueV1({ + v: 1, + queue: [], + inFlight: { localId: 'x', message: 'mx', createdAt: 0, updatedAt: 0 }, + }); + expect(parsed).toBe(null); + }); + + it('claims the first queue item into inFlight', () => { + const result = claimMessageQueueV1Next({ + messageQueueV1: { + v: 1, + queue: [ + { localId: 'a', message: 'm1', createdAt: 1, updatedAt: 1 }, + { localId: 'b', message: 'm2', createdAt: 2, updatedAt: 2 }, + ], + }, + }, 10); + + expect(result?.inFlight.localId).toBe('a'); + expect((result?.metadata as any).messageQueueV1.inFlight.claimedAt).toBe(10); + expect((result?.metadata as any).messageQueueV1.queue.map((q: any) => q.localId)).toEqual(['b']); + }); + + it('returns existing inFlight without mutating metadata', () => { + const input = { + messageQueueV1: { + v: 1, + queue: [{ localId: 'a', message: 'm1', createdAt: 1, updatedAt: 1 }], + inFlight: { localId: 'x', message: 'mx', createdAt: 0, updatedAt: 0, claimedAt: 9 }, + }, + }; + const result = claimMessageQueueV1Next(input, 10); + expect(result?.inFlight.localId).toBe('x'); + expect(result?.metadata).toBe(input); + }); + + it('reclaims stale inFlight by re-claiming it with a fresh claimedAt', () => { + const input = { + messageQueueV1: { + v: 1, + queue: [], + inFlight: { localId: 'x', message: 'mx', createdAt: 0, updatedAt: 0, claimedAt: 0 }, + }, + }; + const result = claimMessageQueueV1Next(input, 61_000); + expect(result?.inFlight.localId).toBe('x'); + expect(result?.inFlight.claimedAt).toBe(61_000); + expect(result?.metadata).not.toBe(input); + }); + + it('clears inFlight only when localId matches', () => { + const input = { + messageQueueV1: { + v: 1, + queue: [], + inFlight: { localId: 'x', message: 'mx', createdAt: 0, updatedAt: 0, claimedAt: 9 }, + }, + }; + expect(clearMessageQueueV1InFlight(input, 'nope')).toBe(input); + const cleared = clearMessageQueueV1InFlight(input, 'x'); + expect((cleared as any).messageQueueV1.inFlight).toBe(null); + }); +}); diff --git a/cli/src/api/queue/messageQueueV1.ts b/cli/src/api/queue/messageQueueV1.ts new file mode 100644 index 000000000..b4937ae98 --- /dev/null +++ b/cli/src/api/queue/messageQueueV1.ts @@ -0,0 +1,209 @@ +export type MessageQueueV1Item = { + localId: string; + message: string; + createdAt: number; + updatedAt: number; +}; + +export type MessageQueueV1InFlight = MessageQueueV1Item & { + claimedAt: number; +}; + +export type MessageQueueV1DiscardedReason = 'switch_to_local' | 'manual'; + +export type MessageQueueV1DiscardedItem = MessageQueueV1Item & { + discardedAt: number; + discardedReason: MessageQueueV1DiscardedReason; +}; + +export type MessageQueueV1 = { + v: 1; + queue: MessageQueueV1Item[]; + inFlight?: MessageQueueV1InFlight | null; +}; + +const MESSAGE_QUEUE_V1_RECLAIM_IN_FLIGHT_AFTER_MS = 60_000; + +function isPlainObject(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function parseQueueItem(raw: unknown): MessageQueueV1Item | null { + if (!isPlainObject(raw)) return null; + const localId = raw.localId; + const message = raw.message; + const createdAt = raw.createdAt; + const updatedAt = raw.updatedAt; + if (typeof localId !== 'string') return null; + if (typeof message !== 'string') return null; + if (typeof createdAt !== 'number') return null; + if (typeof updatedAt !== 'number') return null; + return { localId, message, createdAt, updatedAt }; +} + +function parseInFlight(raw: unknown): MessageQueueV1InFlight | null { + if (!isPlainObject(raw)) return null; + const claimedAt = raw.claimedAt; + const item = parseQueueItem(raw); + if (!item) return null; + if (typeof claimedAt !== 'number') return null; + return { ...item, claimedAt }; +} + +function parseDiscardedItem(raw: unknown): MessageQueueV1DiscardedItem | null { + if (!isPlainObject(raw)) return null; + const item = parseQueueItem(raw); + if (!item) return null; + const discardedAt = (raw as any).discardedAt; + const discardedReason = (raw as any).discardedReason; + if (typeof discardedAt !== 'number') return null; + if (discardedReason !== 'switch_to_local' && discardedReason !== 'manual') return null; + return { ...item, discardedAt, discardedReason }; +} + +export function parseMessageQueueV1(raw: unknown): MessageQueueV1 | null { + if (!isPlainObject(raw)) return null; + if (raw.v !== 1) return null; + const queueRaw = raw.queue; + if (!Array.isArray(queueRaw)) return null; + const queue: MessageQueueV1Item[] = []; + for (const entry of queueRaw) { + const parsed = parseQueueItem(entry); + if (!parsed) return null; + queue.push(parsed); + } + + const inFlightRaw = (raw as any).inFlight; + let inFlight: MessageQueueV1InFlight | null | undefined; + if (inFlightRaw === undefined) { + inFlight = undefined; + } else if (inFlightRaw === null) { + inFlight = null; + } else { + const parsed = parseInFlight(inFlightRaw); + if (!parsed) return null; + inFlight = parsed; + } + + return { + v: 1, + queue, + ...(inFlightRaw !== undefined ? { inFlight: inFlight ?? null } : {}), + }; +} + +function parseDiscardedList(raw: unknown): MessageQueueV1DiscardedItem[] | null { + if (raw === undefined || raw === null) return []; + if (!Array.isArray(raw)) return null; + const result: MessageQueueV1DiscardedItem[] = []; + for (const entry of raw) { + const parsed = parseDiscardedItem(entry); + if (!parsed) return null; + result.push(parsed); + } + return result; +} + +export function claimMessageQueueV1Next(metadata: Record, now: number): { metadata: Record; inFlight: MessageQueueV1InFlight } | null { + const mqRaw = (metadata as any).messageQueueV1; + const mq = parseMessageQueueV1(mqRaw); + if (!mq) return null; + + if (mq.inFlight) { + const ageMs = now - mq.inFlight.claimedAt; + if (ageMs < MESSAGE_QUEUE_V1_RECLAIM_IN_FLIGHT_AFTER_MS) { + return { metadata, inFlight: mq.inFlight }; + } + + // If the inFlight claim is stale (agent crash or missed acknowledgement), + // move it back to the front of the queue and re-claim it with a fresh claimedAt. + const { claimedAt: _claimedAt, ...item } = mq.inFlight; + const recoveredQueue = [item, ...mq.queue]; + const inFlight: MessageQueueV1InFlight = { ...item, claimedAt: now }; + const nextMq: MessageQueueV1 = { + ...mq, + queue: recoveredQueue.slice(1), + inFlight, + }; + + return { + metadata: { + ...metadata, + messageQueueV1: nextMq, + }, + inFlight, + }; + } + + const first = mq.queue[0]; + if (!first) return null; + + const inFlight: MessageQueueV1InFlight = { ...first, claimedAt: now }; + const nextMq: MessageQueueV1 = { + ...mq, + queue: mq.queue.slice(1), + inFlight, + }; + + return { + metadata: { + ...metadata, + messageQueueV1: nextMq, + }, + inFlight, + }; +} + +export function clearMessageQueueV1InFlight(metadata: Record, localId: string): Record { + const mqRaw = (metadata as any).messageQueueV1; + const mq = parseMessageQueueV1(mqRaw); + if (!mq?.inFlight) return metadata; + if (mq.inFlight.localId !== localId) return metadata; + return { + ...metadata, + messageQueueV1: { + ...mq, + inFlight: null, + }, + }; +} + +export function discardMessageQueueV1All(metadata: Record, opts: { now: number; reason: MessageQueueV1DiscardedReason; maxDiscarded?: number }): { metadata: Record; discarded: MessageQueueV1DiscardedItem[] } | null { + const mqRaw = (metadata as any).messageQueueV1; + const mq = parseMessageQueueV1(mqRaw); + if (!mq) return null; + + const toDiscard: MessageQueueV1Item[] = []; + if (mq.inFlight) { + const { claimedAt: _claimedAt, ...rest } = mq.inFlight; + toDiscard.push(rest); + } + for (const item of mq.queue) { + toDiscard.push(item); + } + if (toDiscard.length === 0) { + return { metadata, discarded: [] }; + } + + const existingDiscarded = parseDiscardedList((metadata as any).messageQueueV1Discarded) ?? []; + const discarded = toDiscard.map((item) => ({ + ...item, + discardedAt: opts.now, + discardedReason: opts.reason, + })); + const maxDiscarded = opts.maxDiscarded ?? 50; + const nextDiscarded = [...existingDiscarded, ...discarded].slice(-maxDiscarded); + + return { + metadata: { + ...metadata, + messageQueueV1: { + ...mq, + queue: [], + inFlight: null, + }, + messageQueueV1Discarded: nextDiscarded, + }, + discarded, + }; +} diff --git a/cli/src/api/rpc/RpcHandlerManager.ts b/cli/src/api/rpc/RpcHandlerManager.ts index 36ffd0962..2e5cce3cb 100644 --- a/cli/src/api/rpc/RpcHandlerManager.ts +++ b/cli/src/api/rpc/RpcHandlerManager.ts @@ -12,6 +12,8 @@ import { RpcHandlerConfig, } from './types'; import { Socket } from 'socket.io-client'; +import { SOCKET_RPC_EVENTS } from '@happy/protocol/socketRpc'; +import { RPC_ERROR_CODES, RPC_ERROR_MESSAGES } from '@happy/protocol/rpc'; export class RpcHandlerManager { private handlers: RpcHandlerMap = new Map(); @@ -43,7 +45,7 @@ export class RpcHandlerManager { this.handlers.set(prefixedMethod, handler); if (this.socket) { - this.socket.emit('rpc-register', { method: prefixedMethod }); + this.socket.emit(SOCKET_RPC_EVENTS.REGISTER, { method: prefixedMethod }); } } @@ -60,7 +62,7 @@ export class RpcHandlerManager { if (!handler) { this.logger('[RPC] [ERROR] Method not found', { method: request.method }); - const errorResponse = { error: 'Method not found' }; + const errorResponse = { error: RPC_ERROR_MESSAGES.METHOD_NOT_FOUND, errorCode: RPC_ERROR_CODES.METHOD_NOT_FOUND }; const encryptedError = encodeBase64(encrypt(this.encryptionKey, this.encryptionVariant, errorResponse)); return encryptedError; } @@ -89,7 +91,7 @@ export class RpcHandlerManager { onSocketConnect(socket: Socket): void { this.socket = socket; for (const [prefixedMethod] of this.handlers) { - socket.emit('rpc-register', { method: prefixedMethod }); + socket.emit(SOCKET_RPC_EVENTS.REGISTER, { method: prefixedMethod }); } } @@ -135,4 +137,4 @@ export class RpcHandlerManager { */ export function createRpcHandlerManager(config: RpcHandlerConfig): RpcHandlerManager { return new RpcHandlerManager(config); -} \ No newline at end of file +} diff --git a/cli/src/api/session/snapshotSync.ts b/cli/src/api/session/snapshotSync.ts new file mode 100644 index 000000000..ef7e4fdb6 --- /dev/null +++ b/cli/src/api/session/snapshotSync.ts @@ -0,0 +1,69 @@ +import axios from 'axios'; +import { configuration } from '@/configuration'; +import type { AgentState, Metadata } from '../types'; +import { decodeBase64, decrypt } from '../encryption'; + +export function shouldSyncSessionSnapshotOnConnect(opts: { metadataVersion: number; agentStateVersion: number }): boolean { + return opts.metadataVersion < 0 || opts.agentStateVersion < 0; +} + +export async function fetchSessionSnapshotUpdateFromServer(opts: { + token: string; + sessionId: string; + encryptionKey: Uint8Array; + encryptionVariant: 'legacy' | 'dataKey'; + currentMetadataVersion: number; + currentAgentStateVersion: number; +}): Promise<{ + metadata?: { metadata: Metadata; metadataVersion: number }; + agentState?: { agentState: AgentState | null; agentStateVersion: number }; +}> { + const response = await axios.get(`${configuration.serverUrl}/v1/sessions`, { + headers: { + Authorization: `Bearer ${opts.token}`, + 'Content-Type': 'application/json', + }, + timeout: 10_000, + }); + + const sessions = (response?.data as any)?.sessions; + if (!Array.isArray(sessions)) { + return {}; + } + + const raw = sessions.find((s: any) => s && typeof s === 'object' && s.id === opts.sessionId); + if (!raw) { + return {}; + } + + const out: { + metadata?: { metadata: Metadata; metadataVersion: number }; + agentState?: { agentState: AgentState | null; agentStateVersion: number }; + } = {}; + + // Sync metadata if it is newer than our local view. + const nextMetadataVersion = typeof raw.metadataVersion === 'number' ? raw.metadataVersion : null; + const rawMetadata = typeof raw.metadata === 'string' ? raw.metadata : null; + if (rawMetadata && nextMetadataVersion !== null && nextMetadataVersion > opts.currentMetadataVersion) { + const decrypted = decrypt(opts.encryptionKey, opts.encryptionVariant, decodeBase64(rawMetadata)); + if (decrypted) { + out.metadata = { + metadata: decrypted, + metadataVersion: nextMetadataVersion, + }; + } + } + + // Sync agent state if it is newer than our local view. + const nextAgentStateVersion = typeof raw.agentStateVersion === 'number' ? raw.agentStateVersion : null; + const rawAgentState = typeof raw.agentState === 'string' ? raw.agentState : null; + if (nextAgentStateVersion !== null && nextAgentStateVersion > opts.currentAgentStateVersion) { + out.agentState = { + agentState: rawAgentState ? decrypt(opts.encryptionKey, opts.encryptionVariant, decodeBase64(rawAgentState)) : null, + agentStateVersion: nextAgentStateVersion, + }; + } + + return out; +} + diff --git a/cli/src/api/session/sockets.ts b/cli/src/api/session/sockets.ts new file mode 100644 index 000000000..8bffcec91 --- /dev/null +++ b/cli/src/api/session/sockets.ts @@ -0,0 +1,39 @@ +import { configuration } from '@/configuration'; +import type { ClientToServerEvents, ServerToClientEvents } from '../types'; +import { io, Socket } from 'socket.io-client' + +export function createSessionScopedSocket(opts: { token: string; sessionId: string }): Socket { + return io(configuration.serverUrl, { + auth: { + token: opts.token, + clientType: 'session-scoped' as const, + sessionId: opts.sessionId, + }, + path: '/v1/updates', + reconnection: true, + reconnectionAttempts: Infinity, + reconnectionDelay: 1000, + reconnectionDelayMax: 5000, + transports: ['websocket'], + withCredentials: true, + autoConnect: false, + }); +} + +export function createUserScopedSocket(opts: { token: string }): Socket { + return io(configuration.serverUrl, { + auth: { + token: opts.token, + clientType: 'user-scoped' as const, + }, + path: '/v1/updates', + reconnection: true, + reconnectionAttempts: Infinity, + reconnectionDelay: 1000, + reconnectionDelayMax: 5000, + transports: ['websocket'], + withCredentials: true, + autoConnect: false, + }); +} + diff --git a/cli/src/api/session/stateUpdates.ts b/cli/src/api/session/stateUpdates.ts new file mode 100644 index 000000000..7f2870927 --- /dev/null +++ b/cli/src/api/session/stateUpdates.ts @@ -0,0 +1,103 @@ +import { logger } from '@/ui/logger' +import { backoff } from '@/utils/time'; +import type { AgentState, Metadata } from '../types'; +import { decodeBase64, decrypt, encodeBase64, encrypt } from '../encryption'; + +type AckableSocket = { + emitWithAck: (event: string, ...args: any[]) => Promise; +}; + +export async function updateSessionMetadataWithAck(opts: { + socket: AckableSocket; + sessionId: string; + encryptionKey: Uint8Array; + encryptionVariant: 'legacy' | 'dataKey'; + getMetadata: () => Metadata | null; + setMetadata: (metadata: Metadata | null) => void; + getMetadataVersion: () => number; + setMetadataVersion: (version: number) => void; + syncSessionSnapshotFromServer: () => Promise; + handler: (metadata: Metadata) => Metadata; +}): Promise { + await backoff(async () => { + if (opts.getMetadataVersion() < 0) { + await opts.syncSessionSnapshotFromServer(); + if (opts.getMetadataVersion() < 0) { + logger.debug('[API] updateMetadata skipped: metadataVersion is still unknown'); + return; + } + } + + const current = opts.getMetadata(); + const updated = opts.handler(current!); + const answer = await opts.socket.emitWithAck('update-metadata', { + sid: opts.sessionId, + expectedVersion: opts.getMetadataVersion(), + metadata: encodeBase64(encrypt(opts.encryptionKey, opts.encryptionVariant, updated)), + }); + + if (answer.result === 'success') { + opts.setMetadata(decrypt(opts.encryptionKey, opts.encryptionVariant, decodeBase64(answer.metadata))); + opts.setMetadataVersion(answer.version); + return; + } + + if (answer.result === 'version-mismatch') { + if (answer.version > opts.getMetadataVersion()) { + opts.setMetadataVersion(answer.version); + opts.setMetadata(decrypt(opts.encryptionKey, opts.encryptionVariant, decodeBase64(answer.metadata))); + } + throw new Error('Metadata version mismatch'); + } + + // Hard error - ignore + }); +} + +export async function updateSessionAgentStateWithAck(opts: { + socket: AckableSocket; + sessionId: string; + encryptionKey: Uint8Array; + encryptionVariant: 'legacy' | 'dataKey'; + getAgentState: () => AgentState | null; + setAgentState: (agentState: AgentState | null) => void; + getAgentStateVersion: () => number; + setAgentStateVersion: (version: number) => void; + syncSessionSnapshotFromServer: () => Promise; + handler: (agentState: AgentState) => AgentState; +}): Promise { + await backoff(async () => { + if (opts.getAgentStateVersion() < 0) { + await opts.syncSessionSnapshotFromServer(); + if (opts.getAgentStateVersion() < 0) { + logger.debug('[API] updateAgentState skipped: agentStateVersion is still unknown'); + return; + } + } + + const updated = opts.handler(opts.getAgentState() || {}); + const answer = await opts.socket.emitWithAck('update-state', { + sid: opts.sessionId, + expectedVersion: opts.getAgentStateVersion(), + agentState: updated ? encodeBase64(encrypt(opts.encryptionKey, opts.encryptionVariant, updated)) : null, + }); + + if (answer.result === 'success') { + opts.setAgentState(answer.agentState ? decrypt(opts.encryptionKey, opts.encryptionVariant, decodeBase64(answer.agentState)) : null); + opts.setAgentStateVersion(answer.version); + logger.debug('Agent state updated', opts.getAgentState()); + return; + } + + if (answer.result === 'version-mismatch') { + if (answer.version > opts.getAgentStateVersion()) { + opts.setAgentStateVersion(answer.version); + opts.setAgentState(answer.agentState ? decrypt(opts.encryptionKey, opts.encryptionVariant, decodeBase64(answer.agentState)) : null); + } + throw new Error('Agent state version mismatch'); + } + + // Hard error - ignore + }); +} + diff --git a/cli/src/api/session/toolTrace.ts b/cli/src/api/session/toolTrace.ts new file mode 100644 index 000000000..6195e6029 --- /dev/null +++ b/cli/src/api/session/toolTrace.ts @@ -0,0 +1,130 @@ +import { recordToolTraceEvent } from '@/agent/tools/trace/toolTrace'; +import type { RawJSONLines } from '@/backends/claude/types'; + +export function isToolTraceEnabled(): boolean { + return ( + ['1', 'true', 'yes', 'on'].includes((process.env.HAPPY_STACKS_TOOL_TRACE ?? '').toLowerCase()) || + ['1', 'true', 'yes', 'on'].includes((process.env.HAPPY_LOCAL_TOOL_TRACE ?? '').toLowerCase()) || + ['1', 'true', 'yes', 'on'].includes((process.env.HAPPY_TOOL_TRACE ?? '').toLowerCase()) + ); +} + +export function recordClaudeToolTraceEvents(opts: { sessionId: string; body: RawJSONLines }): void { + const redactClaudeToolPayload = (value: unknown, key?: string): unknown => { + const REDACT_KEYS = new Set([ + 'content', + 'text', + 'old_string', + 'new_string', + 'oldContent', + 'newContent', + ]); + + if (typeof value === 'string') { + if (key && REDACT_KEYS.has(key)) return `[redacted ${value.length} chars]`; + if (value.length <= 1_000) return value; + return `${value.slice(0, 1_000)}…(truncated ${value.length - 1_000} chars)`; + } + + if (typeof value !== 'object' || value === null) return value; + + if (Array.isArray(value)) { + const sliced = value.slice(0, 50).map((v) => redactClaudeToolPayload(v)); + if (value.length <= 50) return sliced; + return [...sliced, `…(truncated ${value.length - 50} items)`]; + } + + const entries = Object.entries(value as Record); + const out: Record = {}; + const sliced = entries.slice(0, 200); + for (const [k, v] of sliced) out[k] = redactClaudeToolPayload(v, k); + if (entries.length > 200) out._truncatedKeys = entries.length - 200; + return out; + }; + + // Claude tool calls/results are embedded inside message.content[] (tool_use/tool_result). + // Record only tool blocks (never user text). + // + // Note: tool_result blocks can appear in either assistant or user messages depending on Claude + // control mode and SDK message routing. We key off the presence of structured blocks, not role. + const contentBlocks = (opts.body as any)?.message?.content; + if (Array.isArray(contentBlocks)) { + for (const block of contentBlocks) { + if (!block || typeof block !== 'object') continue; + const type = (block as any)?.type; + if (type === 'tool_use') { + const id = (block as any)?.id; + const name = (block as any)?.name; + if (typeof id !== 'string' || typeof name !== 'string') continue; + recordToolTraceEvent({ + direction: 'outbound', + sessionId: opts.sessionId, + protocol: 'claude', + provider: 'claude', + kind: 'tool-call', + payload: { + type: 'tool_use', + id, + name, + input: redactClaudeToolPayload((block as any)?.input), + }, + }); + } else if (type === 'tool_result') { + const toolUseId = (block as any)?.tool_use_id; + if (typeof toolUseId !== 'string') continue; + recordToolTraceEvent({ + direction: 'outbound', + sessionId: opts.sessionId, + protocol: 'claude', + provider: 'claude', + kind: 'tool-result', + payload: { + type: 'tool_result', + tool_use_id: toolUseId, + content: redactClaudeToolPayload((block as any)?.content, 'content'), + }, + }); + } + } + } +} + +export function recordCodexToolTraceEventIfNeeded(opts: { sessionId: string; body: any }): void { + if (opts.body?.type !== 'tool-call' && opts.body?.type !== 'tool-call-result') return; + + recordToolTraceEvent({ + direction: 'outbound', + sessionId: opts.sessionId, + protocol: 'codex', + provider: 'codex', + kind: opts.body.type, + payload: opts.body, + }); +} + +export function recordAcpToolTraceEventIfNeeded(opts: { + sessionId: string; + provider: string; + body: any; + localId?: string; +}): void { + if ( + opts.body?.type !== 'tool-call' && + opts.body?.type !== 'tool-result' && + opts.body?.type !== 'permission-request' && + opts.body?.type !== 'file-edit' && + opts.body?.type !== 'terminal-output' + ) { + return; + } + + recordToolTraceEvent({ + direction: 'outbound', + sessionId: opts.sessionId, + protocol: 'acp', + provider: opts.provider, + kind: opts.body.type, + payload: opts.body, + localId: opts.localId, + }); +} diff --git a/cli/src/api/types.ts b/cli/src/api/types.ts index e1b4878d5..5dcc684b0 100644 --- a/cli/src/api/types.ts +++ b/cli/src/api/types.ts @@ -1,8 +1,9 @@ import { z } from 'zod' -import { UsageSchema } from '@/claude/types' +import { UsageSchema } from '@/api/usage' +import { SOCKET_RPC_EVENTS } from '@happy/protocol/socketRpc' /** - * Permission mode type - includes both Claude and Codex modes + * Permission mode values - includes both Claude and Codex modes * Must match MessageMetaSchema.permissionMode enum values * * Claude modes: default, acceptEdits, bypassPermissions, plan @@ -13,7 +14,45 @@ import { UsageSchema } from '@/claude/types' * - safe-yolo → default * - read-only → default */ -export type PermissionMode = 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' | 'read-only' | 'safe-yolo' | 'yolo' +const CODEX_GEMINI_NON_DEFAULT_PERMISSION_MODES = ['read-only', 'safe-yolo', 'yolo'] as const +export const CODEX_GEMINI_PERMISSION_MODES = ['default', ...CODEX_GEMINI_NON_DEFAULT_PERMISSION_MODES] as const + +const CLAUDE_ONLY_PERMISSION_MODES = ['acceptEdits', 'bypassPermissions', 'plan'] as const + +// Keep stable ordering for readability/help text: +// default, claude-only, then codex/gemini-only. +export const PERMISSION_MODES = [ + 'default', + ...CLAUDE_ONLY_PERMISSION_MODES, + ...CODEX_GEMINI_NON_DEFAULT_PERMISSION_MODES, +] as const + +export type PermissionMode = (typeof PERMISSION_MODES)[number] + +export function isPermissionMode(value: string): value is PermissionMode { + return PERMISSION_MODES.includes(value as PermissionMode) +} + +export type CodexGeminiPermissionMode = (typeof CODEX_GEMINI_PERMISSION_MODES)[number] + +export function isCodexGeminiPermissionMode(value: PermissionMode): value is CodexGeminiPermissionMode { + return (CODEX_GEMINI_PERMISSION_MODES as readonly string[]).includes(value) +} + +// Codex supports the Codex/Gemini subset, plus bypassPermissions as an alias for yolo/full access. +export const CODEX_PERMISSION_MODES = [ + 'default', + 'read-only', + 'safe-yolo', + 'yolo', + 'bypassPermissions', +] as const + +export type CodexPermissionMode = (typeof CODEX_PERMISSION_MODES)[number] + +export function isCodexPermissionMode(value: PermissionMode): value is CodexPermissionMode { + return (CODEX_PERMISSION_MODES as readonly string[]).includes(value) +} /** * Usage data type from Claude @@ -37,6 +76,7 @@ export const UpdateBodySchema = z.object({ message: z.object({ id: z.string(), seq: z.number(), + localId: z.string().nullish().optional(), content: SessionMessageContentSchema }), sid: z.string(), // Session ID @@ -47,7 +87,9 @@ export type UpdateBody = z.infer export const UpdateSessionBodySchema = z.object({ t: z.literal('update-session'), - sid: z.string(), + // Server payloads historically used `sid`, but some deployments send `id`. + sid: z.string().optional(), + id: z.string().optional(), metadata: z.object({ version: z.number(), value: z.string() @@ -56,6 +98,10 @@ export const UpdateSessionBodySchema = z.object({ version: z.number(), value: z.string() }).nullish() +}).superRefine((value, ctx) => { + if (!value.sid && !value.id) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Missing session id (sid/id)' }) + } }) export type UpdateSessionBody = z.infer @@ -99,10 +145,10 @@ export type Update = z.infer */ export interface ServerToClientEvents { update: (data: Update) => void - 'rpc-request': (data: { method: string, params: string }, callback: (response: string) => void) => void - 'rpc-registered': (data: { method: string }) => void - 'rpc-unregistered': (data: { method: string }) => void - 'rpc-error': (data: { type: string, error: string }) => void + [SOCKET_RPC_EVENTS.REQUEST]: (data: { method: string, params: string }, callback: (response: string) => void) => void + [SOCKET_RPC_EVENTS.REGISTERED]: (data: { method: string }) => void + [SOCKET_RPC_EVENTS.UNREGISTERED]: (data: { method: string }) => void + [SOCKET_RPC_EVENTS.ERROR]: (data: { type: string, error: string }) => void ephemeral: (data: { type: 'activity', id: string, active: boolean, activeAt: number, thinking: boolean }) => void auth: (data: { success: boolean, user: string }) => void error: (data: { message: string }) => void @@ -113,7 +159,7 @@ export interface ServerToClientEvents { * Socket events from client to server */ export interface ClientToServerEvents { - message: (data: { sid: string, message: any }) => void + message: (data: { sid: string, message: any, localId?: string | null }) => void 'session-alive': (data: { sid: string; time: number; @@ -144,9 +190,9 @@ export interface ClientToServerEvents { agentState: string | null }) => void) => void, 'ping': (callback: () => void) => void - 'rpc-register': (data: { method: string }) => void - 'rpc-unregister': (data: { method: string }) => void - 'rpc-call': (data: { method: string, params: string }, callback: (response: { + [SOCKET_RPC_EVENTS.REGISTER]: (data: { method: string }) => void + [SOCKET_RPC_EVENTS.UNREGISTER]: (data: { method: string }) => void + [SOCKET_RPC_EVENTS.CALL]: (data: { method: string, params: string }, callback: (response: { ok: boolean result?: string error?: string @@ -218,7 +264,7 @@ export type Machine = { id: string, encryptionKey: Uint8Array; encryptionVariant: 'legacy' | 'dataKey'; - metadata: MachineMetadata, + metadata: MachineMetadata | null, metadataVersion: number, daemonState: DaemonState | null, daemonStateVersion: number, @@ -242,7 +288,7 @@ export type SessionMessage = z.infer */ export const MessageMetaSchema = z.object({ sentFrom: z.string().optional(), // Source identifier - permissionMode: z.enum(['default', 'acceptEdits', 'bypassPermissions', 'plan', 'read-only', 'safe-yolo', 'yolo']).optional(), // Permission mode for this message + permissionMode: z.enum(PERMISSION_MODES).optional(), // Permission mode for this message model: z.string().nullable().optional(), // Model name for this message (null = reset) fallbackModel: z.string().nullable().optional(), // Fallback model for this message (null = reset) customSystemPrompt: z.string().nullable().optional(), // Custom system prompt for this message (null = reset) @@ -278,6 +324,7 @@ export const UserMessageSchema = z.object({ type: z.literal('text'), text: z.string() }), + localId: z.string().nullish().optional(), localKey: z.string().optional(), // Mobile messages include this meta: MessageMetaSchema.optional() }) @@ -305,14 +352,49 @@ export type Metadata = { version?: string, name?: string, os?: string, + /** + * Terminal/attach metadata for this Happy session (non-secret). + * Used by the UI (Session Details) and CLI attach flows. + */ + terminal?: { + mode: 'plain' | 'tmux', + requested?: 'plain' | 'tmux', + fallbackReason?: string, + tmux?: { + target: string, + tmpDir?: string | null, + }, + }, + /** + * Session-scoped profile identity (non-secret). + * Used for display/debugging across devices; runtime behavior is still driven by env vars at spawn. + * Null indicates "no profile". + */ + profileId?: string | null, summary?: { text: string, updatedAt: number }, machineId?: string, claudeSessionId?: string, // Claude Code session ID + codexSessionId?: string, // Codex session/conversation ID (uuid) + geminiSessionId?: string, // Gemini ACP session ID (opaque) + opencodeSessionId?: string, // OpenCode ACP session ID (opaque) + auggieSessionId?: string, // Auggie ACP session ID (opaque) + auggieAllowIndexing?: boolean, // Auggie indexing enablement (spawn-time) tools?: string[], slashCommands?: string[], + slashCommandDetails?: Array<{ + command: string, + description?: string + }>, + acpHistoryImportV1?: { + v: 1, + provider: 'gemini' | 'codex' | 'opencode' | string, + remoteSessionId: string, + importedAt: number, + lastImportedFingerprint?: string + }, homeDir: string, happyHomeDir: string, happyLibDir: string, @@ -325,11 +407,43 @@ export type Metadata = { lifecycleStateSince?: number, archivedBy?: string, archiveReason?: string, - flavor?: string + flavor?: string, + /** + * Current permission mode for the session, published by the CLI so the app can seed UI state + * even when there are no user messages carrying meta.permissionMode yet (e.g. local-only start). + */ + permissionMode?: PermissionMode, + /** Timestamp (ms) for permissionMode, used for "latest wins" arbitration across devices. */ + permissionModeUpdatedAt?: number, + /** + * Encrypted, session-scoped pending queue (v1) stored in session metadata. + * + * This queue is consumed by agents on the machine to materialize user messages into the + * server transcript when the user has chosen a "pending queue" send mode. + */ + messageQueueV1?: { + v: 1, + queue: Array<{ + localId: string, + message: string, + createdAt: number, + updatedAt: number + }>, + inFlight?: { + localId: string, + message: string, + createdAt: number, + updatedAt: number, + claimedAt: number + } | null + } }; export type AgentState = { controlledByUser?: boolean | null | undefined + capabilities?: { + askUserQuestionAnswersInPermission?: boolean | null | undefined + } | null | undefined requests?: { [id: string]: { tool: string, @@ -346,8 +460,9 @@ export type AgentState = { status: 'canceled' | 'denied' | 'approved', reason?: string, mode?: PermissionMode, - decision?: 'approved' | 'approved_for_session' | 'denied' | 'abort', - allowTools?: string[] + decision?: 'approved' | 'approved_for_session' | 'approved_execpolicy_amendment' | 'denied' | 'abort', + allowedTools?: string[] + allowTools?: string[] // legacy alias } } } diff --git a/cli/src/api/usage.ts b/cli/src/api/usage.ts new file mode 100644 index 000000000..dcfbdc200 --- /dev/null +++ b/cli/src/api/usage.ts @@ -0,0 +1,13 @@ +import { z } from 'zod'; + +export const UsageSchema = z.object({ + // Usage statistics for assistant messages. + // This is intentionally passthrough() to keep forward-compatible with new vendor fields. + input_tokens: z.number().int().nonnegative(), + cache_creation_input_tokens: z.number().int().nonnegative().optional(), + cache_read_input_tokens: z.number().int().nonnegative().optional(), + output_tokens: z.number().int().nonnegative(), + service_tier: z.string().optional(), +}).passthrough(); + +export type Usage = z.infer; diff --git a/cli/src/backends/auggie/acp/backend.ts b/cli/src/backends/auggie/acp/backend.ts new file mode 100644 index 000000000..67559776c --- /dev/null +++ b/cli/src/backends/auggie/acp/backend.ts @@ -0,0 +1,44 @@ +/** + * Auggie ACP Backend - Auggie CLI agent via ACP. + * + * Auggie must be installed and available in PATH. + * ACP mode: `auggie --acp` + * + * Indexing: + * - When enabled, we pass `--allow-indexing` (Auggie 0.7.0+). + */ + +import { AcpBackend, type AcpBackendOptions, type AcpPermissionHandler } from '@/agent/acp/AcpBackend'; +import type { AgentBackend, AgentFactoryOptions, McpServerConfig } from '@/agent/core'; +import { auggieTransport } from '@/backends/auggie/acp/transport'; + +export interface AuggieBackendOptions extends AgentFactoryOptions { + mcpServers?: Record; + permissionHandler?: AcpPermissionHandler; + allowIndexing?: boolean; +} + +export function createAuggieBackend(options: AuggieBackendOptions): AgentBackend { + const allowIndexing = options.allowIndexing === true; + + const args = ['--acp', ...(allowIndexing ? ['--allow-indexing'] : [])]; + + const backendOptions: AcpBackendOptions = { + agentName: 'auggie', + cwd: options.cwd, + command: 'auggie', + args, + env: { + ...options.env, + // Keep output clean; ACP must own stdout. + NODE_ENV: 'production', + DEBUG: '', + }, + mcpServers: options.mcpServers, + permissionHandler: options.permissionHandler, + transportHandler: auggieTransport, + }; + + return new AcpBackend(backendOptions); +} + diff --git a/cli/src/backends/auggie/acp/runtime.ts b/cli/src/backends/auggie/acp/runtime.ts new file mode 100644 index 000000000..cd0e7640d --- /dev/null +++ b/cli/src/backends/auggie/acp/runtime.ts @@ -0,0 +1,293 @@ +import { randomUUID } from 'node:crypto'; + +import { logger } from '@/ui/logger'; +import type { AgentBackend, AgentMessage, McpServerConfig } from '@/agent'; +import { createCatalogAcpBackend } from '@/agent/acp'; +import type { MessageBuffer } from '@/ui/ink/messageBuffer'; +import { + handleAcpModelOutputDelta, + handleAcpStatusRunning, + forwardAcpPermissionRequest, + forwardAcpTerminalOutput, +} from '@/agent/acp/bridge/acpCommonHandlers'; +import type { ApiSessionClient } from '@/api/apiSession'; +import type { AcpPermissionHandler } from '@/agent/acp/AcpBackend'; +import { normalizeAvailableCommands, publishSlashCommandsToMetadata } from '@/agent/acp/commands/publishSlashCommands'; +import { importAcpReplayHistoryV1 } from '@/agent/acp/history/importAcpReplayHistory'; +import type { AuggieBackendOptions } from '@/backends/auggie/acp/backend'; +import { maybeUpdateAuggieSessionIdMetadata } from '@/backends/auggie/utils/auggieSessionIdMetadata'; + +export function createAuggieAcpRuntime(params: { + directory: string; + session: ApiSessionClient; + messageBuffer: MessageBuffer; + mcpServers: Record; + permissionHandler: AcpPermissionHandler; + onThinkingChange: (thinking: boolean) => void; + allowIndexing: boolean; +}) { + const lastPublishedAuggieSessionId = { value: null as string | null }; + + let backend: AgentBackend | null = null; + let sessionId: string | null = null; + + let accumulatedResponse = ''; + let isResponseInProgress = false; + let taskStartedSent = false; + let turnAborted = false; + let loadingSession = false; + + const resetTurnState = () => { + accumulatedResponse = ''; + isResponseInProgress = false; + taskStartedSent = false; + turnAborted = false; + }; + + const publishSessionIdToMetadata = () => { + maybeUpdateAuggieSessionIdMetadata({ + getAuggieSessionId: () => sessionId, + updateHappySessionMetadata: (updater) => params.session.updateMetadata(updater), + lastPublished: lastPublishedAuggieSessionId, + }); + }; + + const attachMessageHandler = (b: AgentBackend) => { + b.onMessage((msg: AgentMessage) => { + if (loadingSession) { + if (msg.type === 'status' && msg.status === 'error') { + turnAborted = true; + params.session.sendAgentMessage('auggie', { type: 'turn_aborted', id: randomUUID() }); + } + return; + } + + switch (msg.type) { + case 'model-output': { + handleAcpModelOutputDelta({ + delta: msg.textDelta ?? '', + messageBuffer: params.messageBuffer, + getIsResponseInProgress: () => isResponseInProgress, + setIsResponseInProgress: (value) => { isResponseInProgress = value; }, + appendToAccumulatedResponse: (delta) => { accumulatedResponse += delta; }, + }); + break; + } + + case 'status': { + if (msg.status === 'running') { + handleAcpStatusRunning({ + session: params.session, + agent: 'auggie', + messageBuffer: params.messageBuffer, + onThinkingChange: params.onThinkingChange, + getTaskStartedSent: () => taskStartedSent, + setTaskStartedSent: (value) => { taskStartedSent = value; }, + makeId: () => randomUUID(), + }); + } + + if (msg.status === 'error') { + turnAborted = true; + params.session.sendAgentMessage('auggie', { type: 'turn_aborted', id: randomUUID() }); + } + break; + } + + case 'tool-call': { + params.messageBuffer.addMessage(`Executing: ${msg.toolName}`, 'tool'); + params.session.sendAgentMessage('auggie', { + type: 'tool-call', + callId: msg.callId, + name: msg.toolName, + input: msg.args, + id: randomUUID(), + }); + break; + } + + case 'tool-result': { + const maybeStream = + msg.result + && typeof msg.result === 'object' + && !Array.isArray(msg.result) + && (typeof (msg.result as any).stdoutChunk === 'string' || (msg.result as any)._stream === true); + if (!maybeStream) { + const outputText = typeof msg.result === 'string' + ? msg.result + : JSON.stringify(msg.result ?? '').slice(0, 200); + params.messageBuffer.addMessage(`Result: ${outputText}`, 'result'); + } + params.session.sendAgentMessage('auggie', { + type: 'tool-result', + callId: msg.callId, + output: msg.result, + id: randomUUID(), + }); + break; + } + + case 'fs-edit': { + params.messageBuffer.addMessage(`File edit: ${msg.description}`, 'tool'); + params.session.sendAgentMessage('auggie', { + type: 'file-edit', + description: msg.description, + diff: msg.diff, + filePath: msg.path || 'unknown', + id: randomUUID(), + }); + break; + } + + case 'terminal-output': { + forwardAcpTerminalOutput({ + msg, + messageBuffer: params.messageBuffer, + session: params.session, + agent: 'auggie', + getCallId: () => randomUUID(), + }); + break; + } + + case 'permission-request': { + forwardAcpPermissionRequest({ msg, session: params.session, agent: 'auggie' }); + break; + } + + case 'event': { + const name = (msg as any).name as string | undefined; + if (name === 'available_commands_update') { + const payload = (msg as any).payload; + const details = normalizeAvailableCommands(payload?.availableCommands ?? payload); + publishSlashCommandsToMetadata({ session: params.session, details }); + } + if (name === 'thinking') { + const text = ((msg as any).payload?.text ?? '') as string; + if (text) { + params.session.sendAgentMessage('auggie', { type: 'thinking', text }); + } + } + break; + } + } + }); + }; + + const ensureBackend = async (): Promise => { + if (backend) return backend; + + const created = await createCatalogAcpBackend('auggie', { + cwd: params.directory, + mcpServers: params.mcpServers, + permissionHandler: params.permissionHandler, + allowIndexing: params.allowIndexing, + }); + + backend = created.backend; + attachMessageHandler(backend); + logger.debug('[AuggieACP] Backend created'); + return backend; + }; + + return { + getSessionId: () => sessionId, + + beginTurn(): void { + turnAborted = false; + }, + + async cancel(): Promise { + if (!sessionId) return; + const b = await ensureBackend(); + await b.cancel(sessionId); + }, + + async reset(): Promise { + sessionId = null; + resetTurnState(); + loadingSession = false; + + if (backend) { + try { + await backend.dispose(); + } catch (e) { + logger.debug('[AuggieACP] Failed to dispose backend (non-fatal)', e); + } + backend = null; + } + }, + + async startOrLoad(opts: { resumeId?: string | null }): Promise { + const b = await ensureBackend(); + + const resumeId = typeof opts.resumeId === 'string' ? opts.resumeId.trim() : ''; + if (resumeId) { + const loadWithReplay = (b as any).loadSessionWithReplayCapture as ((id: string) => Promise<{ sessionId: string; replay?: unknown[] }>) | undefined; + const loadSession = (b as any).loadSession as ((id: string) => Promise<{ sessionId: string }>) | undefined; + if (!loadSession && !loadWithReplay) { + throw new Error('Auggie ACP backend does not support loading sessions'); + } + + loadingSession = true; + let replay: unknown[] | null = null; + try { + if (loadWithReplay) { + const loaded = await loadWithReplay(resumeId); + sessionId = loaded.sessionId ?? resumeId; + replay = Array.isArray(loaded.replay) ? loaded.replay : null; + } else { + const loaded = await loadSession!(resumeId); + sessionId = loaded.sessionId ?? resumeId; + } + } finally { + loadingSession = false; + } + + if (replay) { + importAcpReplayHistoryV1({ + session: params.session, + provider: 'auggie', + remoteSessionId: resumeId, + replay: replay as any[], + permissionHandler: params.permissionHandler, + }).catch((e) => { + logger.debug('[AuggieACP] Failed to import replay history (non-fatal)', e); + }); + } + } else { + const started = await b.startSession(); + sessionId = started.sessionId; + } + + publishSessionIdToMetadata(); + return sessionId!; + }, + + async sendPrompt(prompt: string): Promise { + if (!sessionId) { + throw new Error('Auggie ACP session was not started'); + } + + const b = await ensureBackend(); + await b.sendPrompt(sessionId, prompt); + if (b.waitForResponseComplete) { + await b.waitForResponseComplete(120_000); + } + publishSessionIdToMetadata(); + }, + + flushTurn(): void { + if (accumulatedResponse.trim()) { + params.session.sendAgentMessage('auggie', { type: 'message', message: accumulatedResponse }); + } + + if (!turnAborted) { + params.session.sendAgentMessage('auggie', { type: 'task_complete', id: randomUUID() }); + } + + resetTurnState(); + }, + }; +} + diff --git a/cli/src/backends/auggie/acp/transport.ts b/cli/src/backends/auggie/acp/transport.ts new file mode 100644 index 000000000..143727626 --- /dev/null +++ b/cli/src/backends/auggie/acp/transport.ts @@ -0,0 +1,138 @@ +/** + * Auggie Transport Handler + * + * TransportHandler for Auggie's ACP mode (`auggie --acp`). + */ + +import type { + TransportHandler, + ToolPattern, + StderrContext, + StderrResult, + ToolNameContext, +} from '@/agent/transport/TransportHandler'; +import type { AgentMessage } from '@/agent/core'; +import { filterJsonObjectOrArrayLine } from '@/agent/transport/utils/jsonStdoutFilter'; +import { + findToolNameFromId, + findToolNameFromInputFields, + type ToolPatternWithInputFields, +} from '@/agent/transport/utils/toolPatternInference'; + +export const AUGGIE_TIMEOUTS = { + init: 60_000, + toolCall: 120_000, + investigation: 600_000, + think: 30_000, + idle: 500, +} as const; + +const AUGGIE_TOOL_PATTERNS: readonly ToolPatternWithInputFields[] = [ + { + name: 'change_title', + patterns: ['change_title', 'change-title', 'happy__change_title', 'mcp__happy__change_title'], + inputFields: ['title'], + }, + { + name: 'save_memory', + patterns: ['save_memory', 'save-memory'], + inputFields: ['memory', 'content'], + }, + { + name: 'think', + patterns: ['think'], + inputFields: ['thought', 'thinking'], + }, + { + name: 'read', + patterns: ['read', 'read_file'], + inputFields: ['filePath', 'file_path', 'path', 'locations'], + }, + { + name: 'write', + patterns: ['write', 'write_file'], + inputFields: ['filePath', 'file_path', 'path', 'content'], + }, + { + name: 'edit', + patterns: ['edit', 'replace'], + inputFields: ['oldText', 'newText', 'old_string', 'new_string', 'oldString', 'newString'], + }, + { + name: 'execute', + patterns: ['run_shell_command', 'shell', 'exec', 'bash'], + inputFields: ['command', 'cmd'], + }, +] as const; + +export class AuggieTransport implements TransportHandler { + readonly agentName = 'auggie'; + + getInitTimeout(): number { + return AUGGIE_TIMEOUTS.init; + } + + filterStdoutLine(line: string): string | null { + return filterJsonObjectOrArrayLine(line); + } + + handleStderr(text: string, _context: StderrContext): StderrResult { + const trimmed = text.trim(); + if (!trimmed) return { message: null, suppress: true }; + + // Avoid being clever; we mainly need stdout hygiene for ACP. + // Emit actionable auth hints when possible. + const lower = trimmed.toLowerCase(); + if (lower.includes('unauthorized') || lower.includes('authentication') || lower.includes('api key') || lower.includes('token')) { + const errorMessage: AgentMessage = { + type: 'status', + status: 'error', + detail: 'Authentication error. Run `auggie login` or set AUGMENT_SESSION_AUTH in your environment.', + }; + return { message: errorMessage }; + } + + return { message: null, suppress: false }; + } + + getToolPatterns(): ToolPattern[] { + return [...AUGGIE_TOOL_PATTERNS]; + } + + extractToolNameFromId(toolCallId: string): string | null { + return findToolNameFromId(toolCallId, AUGGIE_TOOL_PATTERNS, { preferLongestMatch: true }); + } + + determineToolName( + toolName: string, + toolCallId: string, + input: Record, + _context: ToolNameContext, + ): string { + const idToolName = findToolNameFromId(toolCallId, AUGGIE_TOOL_PATTERNS, { preferLongestMatch: true }); + if (idToolName) return idToolName; + + if (toolName !== 'other' && toolName !== 'Unknown tool') return toolName; + + const inputToolName = findToolNameFromInputFields(input, AUGGIE_TOOL_PATTERNS); + if (inputToolName) return inputToolName; + + return toolName; + } + + getToolCallTimeout(toolCallId: string, toolKind?: string): number { + const lowerId = toolCallId.toLowerCase(); + if (lowerId.includes('investigat') || lowerId.includes('index') || lowerId.includes('search')) { + return AUGGIE_TIMEOUTS.investigation; + } + if (toolKind === 'think') return AUGGIE_TIMEOUTS.think; + return AUGGIE_TIMEOUTS.toolCall; + } + + getIdleTimeout(): number { + return AUGGIE_TIMEOUTS.idle; + } +} + +export const auggieTransport = new AuggieTransport(); + diff --git a/cli/src/backends/auggie/cli/capability.ts b/cli/src/backends/auggie/cli/capability.ts new file mode 100644 index 000000000..0d5c1fd15 --- /dev/null +++ b/cli/src/backends/auggie/cli/capability.ts @@ -0,0 +1,39 @@ +import type { Capability } from '@/capabilities/service'; +import { buildCliCapabilityData } from '@/capabilities/probes/cliBase'; +import { probeAcpAgentCapabilities } from '@/capabilities/probes/acpProbe'; +import { auggieTransport } from '@/backends/auggie/acp/transport'; +import { normalizeCapabilityProbeError } from '@/capabilities/utils/normalizeCapabilityProbeError'; +import { resolveAcpProbeTimeoutMs } from '@/capabilities/utils/acpProbeTimeout'; + +export const cliCapability: Capability = { + descriptor: { id: 'cli.auggie', kind: 'cli', title: 'Auggie CLI' }, + detect: async ({ request, context }) => { + const entry = context.cliSnapshot?.clis?.auggie; + const base = buildCliCapabilityData({ request, entry }); + + const includeAcpCapabilities = Boolean((request.params ?? {}).includeAcpCapabilities); + if (!includeAcpCapabilities || base.available !== true || !base.resolvedPath) { + return base; + } + + const probe = await probeAcpAgentCapabilities({ + command: base.resolvedPath, + args: ['--acp'], + cwd: process.cwd(), + env: { + // Keep output clean to avoid ACP stdout pollution. + NODE_ENV: 'production', + DEBUG: '', + }, + transport: auggieTransport, + timeoutMs: resolveAcpProbeTimeoutMs('auggie'), + }); + + const acp = probe.ok + ? { ok: true, checkedAt: probe.checkedAt, loadSession: probe.agentCapabilities?.loadSession === true } + : { ok: false, checkedAt: probe.checkedAt, error: normalizeCapabilityProbeError(probe.error) }; + + return { ...base, acp }; + }, +}; + diff --git a/cli/src/backends/auggie/cli/checklists.ts b/cli/src/backends/auggie/cli/checklists.ts new file mode 100644 index 000000000..546670cd4 --- /dev/null +++ b/cli/src/backends/auggie/cli/checklists.ts @@ -0,0 +1,6 @@ +import type { AgentChecklistContributions } from '@/backends/types'; + +export const checklists = { + 'resume.auggie': [{ id: 'cli.auggie', params: { includeAcpCapabilities: true, includeLoginStatus: true } }], +} satisfies AgentChecklistContributions; + diff --git a/cli/src/backends/auggie/cli/command.ts b/cli/src/backends/auggie/cli/command.ts new file mode 100644 index 000000000..2ee3f35ab --- /dev/null +++ b/cli/src/backends/auggie/cli/command.ts @@ -0,0 +1,53 @@ +import chalk from 'chalk'; + +import { CODEX_GEMINI_PERMISSION_MODES, isCodexGeminiPermissionMode } from '@/api/types'; +import { authAndSetupMachineIfNeeded } from '@/ui/auth'; +import { parseSessionStartArgs } from '@/cli/sessionStartArgs'; + +import type { CommandContext } from '@/cli/commandRegistry'; + +export async function handleAuggieCliCommand(context: CommandContext): Promise { + try { + const { runAuggie } = await import('@/backends/auggie/runAuggie'); + + const { startedBy, permissionMode, permissionModeUpdatedAt } = parseSessionStartArgs(context.args); + if (permissionMode && !isCodexGeminiPermissionMode(permissionMode)) { + console.error( + chalk.red( + `Invalid --permission-mode for auggie: ${permissionMode}. Valid values: ${CODEX_GEMINI_PERMISSION_MODES.join(', ')}`, + ), + ); + console.error(chalk.gray('Tip: use --yolo for full bypass-like behavior.')); + process.exit(1); + } + + const readFlagValue = (flag: string): string | undefined => { + const idx = context.args.indexOf(flag); + if (idx === -1) return undefined; + const value = context.args[idx + 1]; + if (!value || value.startsWith('-')) return undefined; + return value; + }; + + const existingSessionId = readFlagValue('--existing-session'); + const resume = readFlagValue('--resume'); + + const { credentials } = await authAndSetupMachineIfNeeded(); + await runAuggie({ + credentials, + startedBy, + terminalRuntime: context.terminalRuntime, + permissionMode, + permissionModeUpdatedAt, + existingSessionId, + resume, + }); + } catch (error) { + console.error(chalk.red('Error:'), error instanceof Error ? error.message : 'Unknown error'); + if (process.env.DEBUG) { + console.error(error); + } + process.exit(1); + } +} + diff --git a/cli/src/backends/auggie/cli/detect.ts b/cli/src/backends/auggie/cli/detect.ts new file mode 100644 index 000000000..0bf86f037 --- /dev/null +++ b/cli/src/backends/auggie/cli/detect.ts @@ -0,0 +1,8 @@ +import type { CliDetectSpec } from '@/backends/types'; + +export const cliDetect = { + versionArgsToTry: [['--version'], ['version'], ['-v']], + // Avoid probing login status by default (some commands may print sensitive tokens). + loginStatusArgs: null, +} satisfies CliDetectSpec; + diff --git a/cli/src/backends/auggie/constants.ts b/cli/src/backends/auggie/constants.ts new file mode 100644 index 000000000..5fcde57d8 --- /dev/null +++ b/cli/src/backends/auggie/constants.ts @@ -0,0 +1,2 @@ +export const HAPPY_AUGGIE_ALLOW_INDEXING_ENV = 'HAPPY_AUGGIE_ALLOW_INDEXING'; + diff --git a/cli/src/backends/auggie/index.ts b/cli/src/backends/auggie/index.ts new file mode 100644 index 000000000..ae6c6bff7 --- /dev/null +++ b/cli/src/backends/auggie/index.ts @@ -0,0 +1,19 @@ +import { AGENTS_CORE } from '@happy/agents'; + +import { checklists } from './cli/checklists'; +import type { AgentCatalogEntry } from '../types'; + +export const agent = { + id: AGENTS_CORE.auggie.id, + cliSubcommand: AGENTS_CORE.auggie.cliSubcommand, + getCliCommandHandler: async () => (await import('@/backends/auggie/cli/command')).handleAuggieCliCommand, + getCliCapabilityOverride: async () => (await import('@/backends/auggie/cli/capability')).cliCapability, + getCliDetect: async () => (await import('@/backends/auggie/cli/detect')).cliDetect, + vendorResumeSupport: AGENTS_CORE.auggie.resume.vendorResume, + getAcpBackendFactory: async () => { + const { createAuggieBackend } = await import('@/backends/auggie/acp/backend'); + return (opts) => ({ backend: createAuggieBackend(opts as any) }); + }, + checklists, +} satisfies AgentCatalogEntry; + diff --git a/cli/src/backends/auggie/runAuggie.ts b/cli/src/backends/auggie/runAuggie.ts new file mode 100644 index 000000000..bab508542 --- /dev/null +++ b/cli/src/backends/auggie/runAuggie.ts @@ -0,0 +1,409 @@ +/** + * Auggie CLI Entry Point + * + * Runs the Auggie agent through Happy CLI using ACP. + */ + +import { render } from 'ink'; +import React from 'react'; +import { randomUUID } from 'node:crypto'; +import { join } from 'node:path'; + +import { ApiClient } from '@/api/api'; +import type { ApiSessionClient } from '@/api/apiSession'; +import type { PermissionMode } from '@/api/types'; +import { logger } from '@/ui/logger'; +import type { Credentials } from '@/persistence'; +import { readSettings } from '@/persistence'; +import { initialMachineMetadata } from '@/daemon/run'; +import { connectionState } from '@/api/offline/serverConnectionErrors'; +import { setupOfflineReconnection } from '@/api/offline/setupOfflineReconnection'; +import { projectPath } from '@/projectPath'; +import { startHappyServer } from '@/mcp/startHappyServer'; +import { createSessionMetadata } from '@/agent/runtime/createSessionMetadata'; +import { createBaseSessionForAttach } from '@/agent/runtime/createBaseSessionForAttach'; +import { + persistTerminalAttachmentInfoIfNeeded, + primeAgentStateForUi, + reportSessionToDaemonIfRunning, + sendTerminalFallbackMessageIfNeeded, +} from '@/agent/runtime/startupSideEffects'; +import { maybeUpdatePermissionModeMetadata } from '@/agent/runtime/permissionModeMetadata'; +import { applyStartupMetadataUpdateToSession, buildPermissionModeOverride } from '@/agent/runtime/startupMetadataUpdate'; +import { registerKillSessionHandler } from '@/rpc/handlers/killSession'; +import { stopCaffeinate } from '@/integrations/caffeinate'; +import { MessageQueue2 } from '@/utils/MessageQueue2'; +import { hashObject } from '@/utils/deterministicJson'; +import { parseSpecialCommand } from '@/cli/parsers/specialCommands'; +import { MessageBuffer } from '@/ui/ink/messageBuffer'; + +import type { McpServerConfig } from '@/agent'; +import { AuggiePermissionHandler } from '@/backends/auggie/utils/permissionHandler'; +import { createAuggieAcpRuntime } from '@/backends/auggie/acp/runtime'; +import { waitForNextAuggieMessage } from '@/backends/auggie/utils/waitForNextAuggieMessage'; +import { readAuggieAllowIndexingFromEnv } from '@/backends/auggie/utils/env'; +import { AuggieTerminalDisplay } from '@/backends/auggie/ui/AuggieTerminalDisplay'; + +function formatAuggiePromptError(err: unknown): { message: string; isAuthError: boolean } { + if (err instanceof Error) { + const lower = err.message.toLowerCase(); + return { message: err.message, isAuthError: lower.includes('unauthorized') || lower.includes('authentication') || lower.includes('401') }; + } + if (typeof err === 'string') { + const lower = err.toLowerCase(); + return { message: err, isAuthError: lower.includes('unauthorized') || lower.includes('authentication') || lower.includes('401') }; + } + if (err && typeof err === 'object') { + const maybeMessage = (err as { message?: unknown }).message; + const maybeCode = (err as { code?: unknown }).code; + const maybeDetails = (err as { data?: unknown }).data as { details?: unknown } | undefined; + + const message = typeof maybeMessage === 'string' ? maybeMessage : null; + const details = typeof maybeDetails?.details === 'string' ? maybeDetails.details : null; + const code = typeof maybeCode === 'number' ? maybeCode : null; + + const combined = + details && message ? `${message}${typeof code === 'number' ? ` (code ${code})` : ''}: ${details}` : (details ?? message); + if (combined) { + const lower = combined.toLowerCase(); + return { message: combined, isAuthError: lower.includes('unauthorized') || lower.includes('authentication') || lower.includes('api key') || lower.includes('token') || lower.includes('401') }; + } + + try { + const json = JSON.stringify(err); + const lower = json.toLowerCase(); + return { message: json, isAuthError: lower.includes('unauthorized') || lower.includes('authentication') || lower.includes('401') }; + } catch { + return { message: String(err), isAuthError: false }; + } + } + return { message: String(err), isAuthError: false }; +} + +export async function runAuggie(opts: { + credentials: Credentials; + startedBy?: 'daemon' | 'terminal'; + terminalRuntime?: import('@/terminal/terminalRuntimeFlags').TerminalRuntimeFlags | null; + permissionMode?: PermissionMode; + permissionModeUpdatedAt?: number; + existingSessionId?: string; + resume?: string; +}): Promise { + const sessionTag = randomUUID(); + + connectionState.setBackend('Auggie'); + + const api = await ApiClient.create(opts.credentials); + + const settings = await readSettings(); + const machineId = settings?.machineId; + if (!machineId) { + console.error(`[START] No machine ID found in settings. Please report this issue on https://github.com/slopus/happy-cli/issues`); + process.exit(1); + } + await api.getOrCreateMachine({ machineId, metadata: initialMachineMetadata }); + + const initialPermissionMode = opts.permissionMode ?? 'default'; + + const allowIndexingFromEnv = readAuggieAllowIndexingFromEnv(); + + const { state, metadata } = createSessionMetadata({ + flavor: 'auggie', + machineId, + startedBy: opts.startedBy, + terminalRuntime: opts.terminalRuntime ?? null, + permissionMode: initialPermissionMode, + permissionModeUpdatedAt: typeof opts.permissionModeUpdatedAt === 'number' ? opts.permissionModeUpdatedAt : Date.now(), + }); + + // Persist the indexing choice in metadata so it can be inspected/toggled from the app. + metadata.auggieAllowIndexing = allowIndexingFromEnv; + + const terminal = metadata.terminal; + let session: ApiSessionClient; + let permissionHandler: AuggiePermissionHandler; + let reconnectionHandle: { cancel: () => void } | null = null; + + const normalizedExistingSessionId = typeof opts.existingSessionId === 'string' ? opts.existingSessionId.trim() : ''; + + let allowIndexing = allowIndexingFromEnv; + + if (normalizedExistingSessionId) { + logger.debug(`[auggie] Attaching to existing Happy session: ${normalizedExistingSessionId}`); + const baseSession = await createBaseSessionForAttach({ existingSessionId: normalizedExistingSessionId, metadata, state }); + session = api.sessionSyncClient(baseSession); + + applyStartupMetadataUpdateToSession({ + session, + next: metadata, + nowMs: Date.now(), + permissionModeOverride: buildPermissionModeOverride({ + permissionMode: opts.permissionMode, + permissionModeUpdatedAt: opts.permissionModeUpdatedAt, + }), + }); + + // If the UI has toggled indexing for this session, prefer the stored metadata. + // Env var remains the highest priority override (useful for debugging/local runs). + const current = session.getMetadataSnapshot?.() ?? null; + const stored = typeof current?.auggieAllowIndexing === 'boolean' ? current.auggieAllowIndexing : null; + if (!allowIndexingFromEnv && typeof stored === 'boolean') { + allowIndexing = stored; + } + + primeAgentStateForUi(session, '[Auggie]'); + await reportSessionToDaemonIfRunning({ sessionId: normalizedExistingSessionId, metadata }); + await persistTerminalAttachmentInfoIfNeeded({ sessionId: normalizedExistingSessionId, terminal }); + sendTerminalFallbackMessageIfNeeded({ session, terminal }); + } else { + const response = await api.getOrCreateSession({ tag: sessionTag, metadata, state }); + if (!response) { + throw new Error('Failed to create session'); + } + + const { session: initialSession, reconnectionHandle: rh } = setupOfflineReconnection({ + api, + sessionTag, + metadata, + state, + response, + onSessionSwap: (newSession) => { + session = newSession; + if (permissionHandler) { + permissionHandler.updateSession(newSession); + } + }, + }); + session = initialSession; + reconnectionHandle = rh; + + primeAgentStateForUi(session, '[Auggie]'); + await reportSessionToDaemonIfRunning({ sessionId: response.id, metadata }); + await persistTerminalAttachmentInfoIfNeeded({ sessionId: response.id, terminal }); + sendTerminalFallbackMessageIfNeeded({ session, terminal }); + } + + // Start Happy MCP server for `change_title` tool exposure (bridged to ACP via happy-mcp.mjs). + const happyServer = await startHappyServer(session); + + const bridgeCommand = join(projectPath(), 'bin', 'happy-mcp.mjs'); + const mcpServers: Record = { + happy: { command: bridgeCommand, args: ['--url', happyServer.url] }, + }; + + let abortRequestedCallback: (() => void | Promise) | null = null; + permissionHandler = new AuggiePermissionHandler(session, { + onAbortRequested: () => abortRequestedCallback?.(), + }); + permissionHandler.setPermissionMode(initialPermissionMode); + + const messageQueue = new MessageQueue2<{ permissionMode: PermissionMode }>((mode) => hashObject({ + permissionMode: mode.permissionMode, + })); + + let currentPermissionMode: PermissionMode | undefined = initialPermissionMode; + + session.onUserMessage((message) => { + let messagePermissionMode = currentPermissionMode; + if (message.meta?.permissionMode) { + const nextPermissionMode = message.meta.permissionMode as PermissionMode; + const res = maybeUpdatePermissionModeMetadata({ + currentPermissionMode, + nextPermissionMode, + updateMetadata: (updater) => session.updateMetadata(updater), + }); + currentPermissionMode = res.currentPermissionMode; + messagePermissionMode = currentPermissionMode; + } + + const mode = { permissionMode: messagePermissionMode || 'default' }; + const special = parseSpecialCommand(message.content.text); + if (special.type === 'clear') { + messageQueue.pushIsolateAndClear(message.content.text, mode); + } else { + messageQueue.push(message.content.text, mode); + } + }); + + const messageBuffer = new MessageBuffer(); + const hasTTY = process.stdout.isTTY && process.stdin.isTTY; + let inkInstance: ReturnType | null = null; + if (hasTTY) { + console.clear(); + inkInstance = render(React.createElement(AuggieTerminalDisplay, { + messageBuffer, + logPath: process.env.DEBUG ? logger.getLogPath() : undefined, + onExit: async () => { + shouldExit = true; + await handleAbort(); + }, + }), { exitOnCtrlC: false, patchConsole: false }); + } + + let thinking = false; + let shouldExit = false; + let abortController = new AbortController(); + session.keepAlive(thinking, 'remote'); + const keepAliveInterval = setInterval(() => session.keepAlive(thinking, 'remote'), 2000); + + const runtime = createAuggieAcpRuntime({ + directory: metadata.path, + session, + messageBuffer, + mcpServers, + permissionHandler, + onThinkingChange: (value) => { thinking = value; }, + allowIndexing, + }); + + const handleAbort = async () => { + logger.debug('[Auggie] Abort requested'); + session.sendAgentMessage('auggie', { type: 'turn_aborted', id: randomUUID() }); + permissionHandler.reset(); + messageQueue.reset(); + try { + abortController.abort(); + abortController = new AbortController(); + await runtime.cancel(); + } catch (e) { + logger.debug('[Auggie] Failed to cancel current operation (non-fatal)', e); + } + }; + abortRequestedCallback = handleAbort; + + const handleKillSession = async () => { + logger.debug('[Auggie] Kill session requested'); + shouldExit = true; + await handleAbort(); + try { + if (session) { + session.updateMetadata((currentMetadata) => ({ + ...currentMetadata, + lifecycleState: 'archived', + lifecycleStateSince: Date.now(), + archivedBy: 'cli', + archiveReason: 'User terminated', + })); + session.sendSessionDeath(); + await session.flush(); + await session.close(); + } + } finally { + clearInterval(keepAliveInterval); + reconnectionHandle?.cancel(); + stopCaffeinate(); + happyServer.stop(); + await runtime.reset(); + inkInstance?.unmount(); + process.exit(0); + } + }; + + session.rpcHandlerManager.registerHandler('abort', handleAbort); + registerKillSessionHandler(session.rpcHandlerManager, handleKillSession); + + const sendReady = () => { + session.sendSessionEvent({ type: 'ready' }); + try { + api.push().sendToAllDevices("It's ready!", 'Auggie is waiting for your command', { sessionId: session.sessionId }); + } catch (pushError) { + logger.debug('[Auggie] Failed to send ready push', pushError); + } + }; + + let wasStarted = false; + let storedSessionIdForResume: string | null = null; + if (typeof opts.resume === 'string' && opts.resume.trim()) { + storedSessionIdForResume = opts.resume.trim(); + } + + try { + let currentModeHash: string | null = null; + type QueuedMessage = { message: string; mode: { permissionMode: PermissionMode }; hash: string }; + let pending: QueuedMessage | null = null; + + while (!shouldExit) { + let message: QueuedMessage | null = pending; + pending = null; + + if (!message) { + const next = await waitForNextAuggieMessage({ + messageQueue, + abortSignal: abortController.signal, + session, + }); + if (!next) continue; + message = { message: next.message, mode: next.mode, hash: next.hash }; + } + if (!message) continue; + + permissionHandler.setPermissionMode(message.mode.permissionMode); + + if (currentModeHash && message.hash !== currentModeHash) { + currentModeHash = message.hash; + } else { + currentModeHash = message.hash; + } + + messageBuffer.addMessage(message.message, 'user'); + + const special = parseSpecialCommand(message.message); + if (special.type === 'clear') { + messageBuffer.addMessage('Resetting Auggie session…', 'status'); + await runtime.reset(); + wasStarted = false; + permissionHandler.reset(); + thinking = false; + session.keepAlive(thinking, 'remote'); + messageBuffer.addMessage('Session reset.', 'status'); + sendReady(); + continue; + } + + try { + runtime.beginTurn(); + if (!wasStarted) { + const resumeId = storedSessionIdForResume?.trim(); + if (resumeId) { + storedSessionIdForResume = null; // consume once + messageBuffer.addMessage('Resuming previous context…', 'status'); + try { + await runtime.startOrLoad({ resumeId }); + } catch (e) { + logger.debug('[Auggie] Resume failed; starting a new session instead', e); + messageBuffer.addMessage('Resume failed; starting a new session.', 'status'); + session.sendAgentMessage('auggie', { type: 'message', message: 'Resume failed; starting a new session.' }); + await runtime.startOrLoad({}); + } + } else { + await runtime.startOrLoad({}); + } + wasStarted = true; + } + await runtime.sendPrompt(message.message); + } catch (error) { + logger.debug('[Auggie] Error during prompt:', error); + const formatted = formatAuggiePromptError(error); + const extraHint = formatted.isAuthError + ? 'Auggie appears not authenticated. Run `auggie login` on this machine (the same user running the daemon) and try again.' + : null; + session.sendAgentMessage('auggie', { + type: 'message', + message: `Error: ${formatted.message}${extraHint ? `\n\n${extraHint}` : ''}`, + }); + } finally { + runtime.flushTurn(); + thinking = false; + session.keepAlive(thinking, 'remote'); + sendReady(); + } + } + } finally { + clearInterval(keepAliveInterval); + reconnectionHandle?.cancel(); + stopCaffeinate(); + happyServer.stop(); + await runtime.reset(); + inkInstance?.unmount(); + } +} diff --git a/cli/src/backends/auggie/ui/AuggieTerminalDisplay.tsx b/cli/src/backends/auggie/ui/AuggieTerminalDisplay.tsx new file mode 100644 index 000000000..979f11660 --- /dev/null +++ b/cli/src/backends/auggie/ui/AuggieTerminalDisplay.tsx @@ -0,0 +1,32 @@ +/** + * AuggieTerminalDisplay + * + * Read-only terminal UI for Auggie sessions started by Happy. + * This UI intentionally does not accept prompts from stdin; it displays logs and exit controls only. + */ + +import React from 'react'; + +import { AgentLogShell } from '@/ui/ink/AgentLogShell'; +import { MessageBuffer } from '@/ui/ink/messageBuffer'; +import { buildReadOnlyFooterLines } from '@/ui/ink/readOnlyFooterLines'; + +export type AuggieTerminalDisplayProps = { + messageBuffer: MessageBuffer; + logPath?: string; + onExit?: () => void | Promise; +}; + +export const AuggieTerminalDisplay: React.FC = ({ messageBuffer, logPath, onExit }) => { + return ( + + ); +}; + diff --git a/cli/src/backends/auggie/utils/auggieSessionIdMetadata.test.ts b/cli/src/backends/auggie/utils/auggieSessionIdMetadata.test.ts new file mode 100644 index 000000000..f38af4018 --- /dev/null +++ b/cli/src/backends/auggie/utils/auggieSessionIdMetadata.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; + +import { maybeUpdateAuggieSessionIdMetadata } from './auggieSessionIdMetadata'; + +describe('maybeUpdateAuggieSessionIdMetadata', () => { + it('publishes auggieSessionId once per new session id and preserves other metadata', () => { + const published: any[] = []; + const last = { value: null as string | null }; + + maybeUpdateAuggieSessionIdMetadata({ + getAuggieSessionId: () => 'a1', + updateHappySessionMetadata: (updater) => published.push(updater({ keep: true } as any)), + lastPublished: last, + }); + + maybeUpdateAuggieSessionIdMetadata({ + getAuggieSessionId: () => 'a1', + updateHappySessionMetadata: (updater) => published.push(updater({ keep: true } as any)), + lastPublished: last, + }); + + maybeUpdateAuggieSessionIdMetadata({ + getAuggieSessionId: () => 'a2', + updateHappySessionMetadata: (updater) => published.push(updater({ keep: true } as any)), + lastPublished: last, + }); + + expect(published).toEqual([ + { keep: true, auggieSessionId: 'a1' }, + { keep: true, auggieSessionId: 'a2' }, + ]); + }); +}); + diff --git a/cli/src/backends/auggie/utils/auggieSessionIdMetadata.ts b/cli/src/backends/auggie/utils/auggieSessionIdMetadata.ts new file mode 100644 index 000000000..1ffb7731b --- /dev/null +++ b/cli/src/backends/auggie/utils/auggieSessionIdMetadata.ts @@ -0,0 +1,21 @@ +import type { Metadata } from '@/api/types'; + +export function maybeUpdateAuggieSessionIdMetadata(params: { + getAuggieSessionId: () => string | null; + updateHappySessionMetadata: (updater: (metadata: Metadata) => Metadata) => void; + lastPublished: { value: string | null }; +}): void { + const raw = params.getAuggieSessionId(); + const next = typeof raw === 'string' ? raw.trim() : ''; + if (!next) return; + + if (params.lastPublished.value === next) return; + params.lastPublished.value = next; + + params.updateHappySessionMetadata((metadata) => ({ + ...metadata, + // Happy metadata field name. Value is Auggie ACP sessionId (opaque; stable resume id when loadSession is supported). + auggieSessionId: next, + })); +} + diff --git a/cli/src/backends/auggie/utils/env.ts b/cli/src/backends/auggie/utils/env.ts new file mode 100644 index 000000000..18a366f73 --- /dev/null +++ b/cli/src/backends/auggie/utils/env.ts @@ -0,0 +1,10 @@ +import { HAPPY_AUGGIE_ALLOW_INDEXING_ENV } from '@/backends/auggie/constants'; + +export function readAuggieAllowIndexingFromEnv(env: NodeJS.ProcessEnv = process.env): boolean { + const raw = typeof env[HAPPY_AUGGIE_ALLOW_INDEXING_ENV] === 'string' + ? String(env[HAPPY_AUGGIE_ALLOW_INDEXING_ENV]).trim().toLowerCase() + : ''; + if (!raw) return false; + return raw === '1' || raw === 'true' || raw === 'yes' || raw === 'on'; +} + diff --git a/cli/src/backends/auggie/utils/permissionHandler.ts b/cli/src/backends/auggie/utils/permissionHandler.ts new file mode 100644 index 000000000..296f6520d --- /dev/null +++ b/cli/src/backends/auggie/utils/permissionHandler.ts @@ -0,0 +1,99 @@ +/** + * Auggie Permission Handler + * + * Handles tool permission requests and responses for Auggie ACP sessions. + * Uses the same mobile permission RPC flow as Codex/Gemini/OpenCode. + */ + +import { logger } from '@/ui/logger'; +import type { ApiSessionClient } from '@/api/apiSession'; +import type { PermissionMode } from '@/api/types'; +import { + BasePermissionHandler, + type PermissionResult, + type PendingRequest, +} from '@/agent/permissions/BasePermissionHandler'; + +export type { PermissionResult, PendingRequest }; + +function isAuggieWriteLikeToolName(toolName: string): boolean { + const lower = toolName.toLowerCase(); + if (lower === 'other' || lower === 'unknown tool' || lower === 'unknown') return true; + + const writeish = [ + 'edit', + 'write', + 'patch', + 'delete', + 'remove', + 'create', + 'mkdir', + 'rename', + 'move', + 'copy', + 'exec', + 'bash', + 'shell', + 'run', + 'terminal', + ]; + return writeish.some((k) => lower === k || lower.includes(k)); +} + +export class AuggiePermissionHandler extends BasePermissionHandler { + private currentPermissionMode: PermissionMode = 'default'; + + protected getLogPrefix(): string { + return '[Auggie]'; + } + + setPermissionMode(mode: PermissionMode): void { + this.currentPermissionMode = mode; + logger.debug(`${this.getLogPrefix()} Permission mode set to: ${mode}`); + } + + private shouldAutoApprove(toolName: string, toolCallId: string): boolean { + // Conservative always-auto-approve list. + const alwaysAutoApproveNames = ['change_title', 'save_memory', 'think']; + if (alwaysAutoApproveNames.some((n) => toolName.toLowerCase().includes(n))) return true; + if (alwaysAutoApproveNames.some((n) => toolCallId.toLowerCase().includes(n))) return true; + + switch (this.currentPermissionMode) { + case 'yolo': + return true; + case 'safe-yolo': + return !isAuggieWriteLikeToolName(toolName); + case 'read-only': + return !isAuggieWriteLikeToolName(toolName); + case 'default': + case 'acceptEdits': + case 'bypassPermissions': + case 'plan': + default: + return false; + } + } + + async handleToolCall(toolCallId: string, toolName: string, input: unknown): Promise { + if (this.isAllowedForSession(toolName, input)) { + logger.debug(`${this.getLogPrefix()} Auto-approving (allowed for session) tool ${toolName} (${toolCallId})`); + this.recordAutoDecision(toolCallId, toolName, input, 'approved_for_session'); + return { decision: 'approved_for_session' }; + } + + if (this.shouldAutoApprove(toolName, toolCallId)) { + const decision: PermissionResult['decision'] = + this.currentPermissionMode === 'yolo' ? 'approved_for_session' : 'approved'; + logger.debug(`${this.getLogPrefix()} Auto-approving tool ${toolName} (${toolCallId}) in ${this.currentPermissionMode} mode`); + this.recordAutoDecision(toolCallId, toolName, input, decision); + return { decision }; + } + + return new Promise((resolve, reject) => { + this.pendingRequests.set(toolCallId, { resolve, reject, toolName, input }); + this.addPendingRequestToState(toolCallId, toolName, input); + logger.debug(`${this.getLogPrefix()} Permission request sent for tool: ${toolName} (${toolCallId}) in ${this.currentPermissionMode} mode`); + }); + } +} + diff --git a/cli/src/backends/auggie/utils/waitForNextAuggieMessage.ts b/cli/src/backends/auggie/utils/waitForNextAuggieMessage.ts new file mode 100644 index 000000000..6728a1c8b --- /dev/null +++ b/cli/src/backends/auggie/utils/waitForNextAuggieMessage.ts @@ -0,0 +1,19 @@ +import type { ApiSessionClient } from '@/api/apiSession'; +import type { PermissionMode } from '@/api/types'; +import type { MessageBatch } from '@/agent/runtime/waitForMessagesOrPending'; +import { waitForMessagesOrPending } from '@/agent/runtime/waitForMessagesOrPending'; +import type { MessageQueue2 } from '@/utils/MessageQueue2'; + +export async function waitForNextAuggieMessage(opts: { + messageQueue: MessageQueue2<{ permissionMode: PermissionMode }>; + abortSignal: AbortSignal; + session: ApiSessionClient; +}): Promise | null> { + return await waitForMessagesOrPending({ + messageQueue: opts.messageQueue, + abortSignal: opts.abortSignal, + popPendingMessage: () => opts.session.popPendingMessage(), + waitForMetadataUpdate: (signal) => opts.session.waitForMetadataUpdate(signal), + }); +} + diff --git a/cli/src/backends/catalog.test.ts b/cli/src/backends/catalog.test.ts new file mode 100644 index 000000000..32b7a39e2 --- /dev/null +++ b/cli/src/backends/catalog.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; + +import { AGENT_IDS, DEFAULT_AGENT_ID } from '@happy/agents'; +import { AGENTS_CORE } from '@happy/agents'; + +import { AGENTS } from './catalog'; +import { DEFAULT_CATALOG_AGENT_ID } from './types'; + +describe('AGENTS', () => { + it('has unique cliSubcommand values', () => { + const values = Object.values(AGENTS).map((entry) => entry.cliSubcommand); + expect(new Set(values).size).toBe(values.length); + }); + + it('keys match entry ids', () => { + for (const [key, entry] of Object.entries(AGENTS)) { + expect(key).toBe(entry.id); + } + }); + + it('declares vendor resume support for every agent', () => { + for (const entry of Object.values(AGENTS)) { + expect(entry.vendorResumeSupport).toBeTruthy(); + } + }); + + it('matches shared agent ids', () => { + const keys = Object.keys(AGENTS).slice().sort(); + const shared = [...AGENT_IDS].slice().sort(); + expect(keys).toEqual(shared); + }); + + it('uses the shared default agent id', () => { + expect(DEFAULT_CATALOG_AGENT_ID).toBe(DEFAULT_AGENT_ID); + }); + + it('keeps cloud connect config in sync with catalog entries', async () => { + for (const id of AGENT_IDS) { + const core = AGENTS_CORE[id]; + const entry = AGENTS[id]; + + if (core.cloudConnect) { + expect(entry.getCloudConnectTarget).toBeTruthy(); + const target = await entry.getCloudConnectTarget!(); + expect(target.vendorKey).toBe(core.cloudConnect.vendorKey); + expect(target.status).toBe(core.cloudConnect.status); + } else { + expect(entry.getCloudConnectTarget).toBeFalsy(); + } + } + }); +}); diff --git a/cli/src/backends/catalog.ts b/cli/src/backends/catalog.ts new file mode 100644 index 000000000..d1a24d8cd --- /dev/null +++ b/cli/src/backends/catalog.ts @@ -0,0 +1,57 @@ +import type { AgentId } from '@/agent/core'; +import { agent as auggie } from '@/backends/auggie'; +import { agent as claude } from '@/backends/claude'; +import { agent as codex } from '@/backends/codex'; +import { agent as gemini } from '@/backends/gemini'; +import { agent as opencode } from '@/backends/opencode'; +import { DEFAULT_CATALOG_AGENT_ID } from './types'; +import type { AgentCatalogEntry, CatalogAgentId, VendorResumeSupportFn } from './types'; + +export type { AgentCatalogEntry, AgentChecklistContributions, CatalogAgentId, CliDetectSpec } from './types'; + +export const AGENTS: Record = { + claude, + codex, + gemini, + opencode, + auggie, +}; + +const cachedVendorResumeSupportPromises = new Map>(); + +export async function getVendorResumeSupport(agentId?: AgentId | null): Promise { + const catalogId = resolveCatalogAgentId(agentId); + const existing = cachedVendorResumeSupportPromises.get(catalogId); + if (existing) return await existing; + + const entry = AGENTS[catalogId]; + const promise = (async () => { + if (entry.vendorResumeSupport === 'supported') { + return () => true; + } + if (entry.vendorResumeSupport === 'unsupported') { + return () => false; + } + if (entry.getVendorResumeSupport) { + return await entry.getVendorResumeSupport(); + } + return () => false; + })(); + + cachedVendorResumeSupportPromises.set(catalogId, promise); + return await promise; +} + +export function resolveCatalogAgentId(agentId?: AgentId | null): CatalogAgentId { + const raw = agentId ?? DEFAULT_CATALOG_AGENT_ID; + const base = raw.split('-')[0] as CatalogAgentId; + if (Object.prototype.hasOwnProperty.call(AGENTS, base)) { + return base; + } + return DEFAULT_CATALOG_AGENT_ID; +} + +export function resolveAgentCliSubcommand(agentId?: AgentId | null): CatalogAgentId { + const catalogId = resolveCatalogAgentId(agentId); + return AGENTS[catalogId].cliSubcommand; +} diff --git a/cli/src/claude/claudeLocal.test.ts b/cli/src/backends/claude/claudeLocal.test.ts similarity index 83% rename from cli/src/claude/claudeLocal.test.ts rename to cli/src/backends/claude/claudeLocal.test.ts index b8557daf4..065796dac 100644 --- a/cli/src/claude/claudeLocal.test.ts +++ b/cli/src/backends/claude/claudeLocal.test.ts @@ -229,4 +229,43 @@ describe('claudeLocal --continue handling', () => { const spawnArgs = mockSpawn.mock.calls[0][1]; expect(spawnArgs).toContain('-r'); }); -}); \ No newline at end of file + + it('should preserve --continue in hook mode (do not convert using local heuristics)', async () => { + mockClaudeFindLastSession.mockReturnValue('should-not-be-used'); + + await claudeLocal({ + abort: new AbortController().signal, + sessionId: null, + path: '/tmp', + onSessionFound, + claudeArgs: ['--continue'], + hookSettingsPath: '/tmp/hooks.json', + }); + + const spawnArgs = mockSpawn.mock.calls[0][1]; + + // RED: current implementation strips --continue and may try to convert it. + expect(spawnArgs).toContain('--continue'); + expect(spawnArgs).not.toContain('--resume'); + expect(spawnArgs).not.toContain('--session-id'); + expect(onSessionFound).not.toHaveBeenCalled(); + }); + + it('should preserve --session-id in hook mode (Claude should control session ID)', async () => { + await claudeLocal({ + abort: new AbortController().signal, + sessionId: null, + path: '/tmp', + onSessionFound, + claudeArgs: ['--session-id', '123e4567-e89b-12d3-a456-426614174999'], + hookSettingsPath: '/tmp/hooks.json', + }); + + const spawnArgs = mockSpawn.mock.calls[0][1]; + + // RED: current implementation extracts --session-id and ignores it in hook mode. + expect(spawnArgs).toContain('--session-id'); + expect(spawnArgs).toContain('123e4567-e89b-12d3-a456-426614174999'); + expect(onSessionFound).not.toHaveBeenCalled(); + }); +}); diff --git a/cli/src/claude/claudeLocal.ts b/cli/src/backends/claude/claudeLocal.ts similarity index 83% rename from cli/src/claude/claudeLocal.ts rename to cli/src/backends/claude/claudeLocal.ts index d4f7ac0bd..d823e442b 100644 --- a/cli/src/claude/claudeLocal.ts +++ b/cli/src/backends/claude/claudeLocal.ts @@ -4,6 +4,7 @@ import { createInterface } from "node:readline"; import { mkdirSync, existsSync } from "node:fs"; import { randomUUID } from "node:crypto"; import { logger } from "@/ui/logger"; +import { attachProcessSignalForwardingToChild } from '@/agent/runtime/signalForwarding'; import { claudeCheckSession } from "./utils/claudeCheckSession"; import { claudeFindLastSession } from "./utils/claudeFindLastSession"; import { getProjectPath } from "./utils/path"; @@ -28,13 +29,15 @@ export async function claudeLocal(opts: { hookSettingsPath?: string }) { + const claudeConfigDir = opts.claudeEnvVars?.CLAUDE_CONFIG_DIR ?? null; + // Ensure project directory exists - const projectDir = getProjectPath(opts.path); + const projectDir = getProjectPath(opts.path, claudeConfigDir); mkdirSync(projectDir, { recursive: true }); // Check if claudeArgs contains --continue or --resume (user passed these flags) - const hasContinueFlag = opts.claudeArgs?.includes('--continue'); - const hasResumeFlag = opts.claudeArgs?.includes('--resume'); + const hasContinueFlag = opts.claudeArgs?.includes('--continue') || opts.claudeArgs?.includes('-c'); + const hasResumeFlag = opts.claudeArgs?.includes('--resume') || opts.claudeArgs?.includes('-r'); const hasUserSessionControl = hasContinueFlag || hasResumeFlag; // Determine if we have an existing session to resume @@ -78,39 +81,44 @@ export async function claudeLocal(opts: { return { found: false }; }; - // 1. Check for --session-id (explicit new session with specific ID) - const sessionIdFlag = extractFlag(['--session-id'], true); - if (sessionIdFlag.found && sessionIdFlag.value) { - startFrom = null; // Force new session mode, will use this ID below - logger.debug(`[ClaudeLocal] Using explicit --session-id: ${sessionIdFlag.value}`); - } + // Session-flag interception is only needed in offline mode (no hook server), + // where we must determine the session ID ourselves. + let sessionIdFlag: { found: boolean; value?: string } = { found: false }; + if (!opts.hookSettingsPath) { + // 1. Check for --session-id (explicit new session with specific ID) + sessionIdFlag = extractFlag(['--session-id'], true); + if (sessionIdFlag.found && sessionIdFlag.value) { + startFrom = null; // Force new session mode, will use this ID below + logger.debug(`[ClaudeLocal] Using explicit --session-id: ${sessionIdFlag.value}`); + } - // 2. Check for --resume / -r (resume specific session) - if (!startFrom && !sessionIdFlag.value) { - const resumeFlag = extractFlag(['--resume', '-r'], true); - if (resumeFlag.found) { - if (resumeFlag.value) { - startFrom = resumeFlag.value; - logger.debug(`[ClaudeLocal] Using provided session ID from --resume: ${startFrom}`); - } else { - // --resume without value: find last session - const lastSession = claudeFindLastSession(opts.path); - if (lastSession) { - startFrom = lastSession; - logger.debug(`[ClaudeLocal] --resume: Found last session: ${lastSession}`); + // 2. Check for --resume / -r (resume specific session) + if (!startFrom && !sessionIdFlag.value) { + const resumeFlag = extractFlag(['--resume', '-r'], true); + if (resumeFlag.found) { + if (resumeFlag.value) { + startFrom = resumeFlag.value; + logger.debug(`[ClaudeLocal] Using provided session ID from --resume: ${startFrom}`); + } else { + // --resume without value: find last session + const lastSession = claudeFindLastSession(opts.path, claudeConfigDir); + if (lastSession) { + startFrom = lastSession; + logger.debug(`[ClaudeLocal] --resume: Found last session: ${lastSession}`); + } } } } - } - // 3. Check for --continue / -c (resume last session) - if (!startFrom && !sessionIdFlag.value) { - const continueFlag = extractFlag(['--continue', '-c'], false); - if (continueFlag.found) { - const lastSession = claudeFindLastSession(opts.path); - if (lastSession) { - startFrom = lastSession; - logger.debug(`[ClaudeLocal] --continue: Found last session: ${lastSession}`); + // 3. Check for --continue / -c (resume last session) + if (!startFrom && !sessionIdFlag.value) { + const continueFlag = extractFlag(['--continue', '-c'], false); + if (continueFlag.found) { + const lastSession = claudeFindLastSession(opts.path, claudeConfigDir); + if (lastSession) { + startFrom = lastSession; + logger.debug(`[ClaudeLocal] --continue: Found last session: ${lastSession}`); + } } } } @@ -172,7 +180,6 @@ export async function claudeLocal(opts: { // Session/resume args depend on whether we're in offline mode or hook mode if (!opts.hookSettingsPath) { // Offline mode: We control session ID - const hasResumeFlag = opts.claudeArgs?.includes('--resume') || opts.claudeArgs?.includes('-r'); if (startFrom) { // Resume existing session (Claude preserves the session ID) args.push('--resume', startFrom) @@ -199,17 +206,17 @@ export async function claudeLocal(opts: { args.push('--allowedTools', opts.allowedTools.join(',')); } - // Add custom Claude arguments - if (opts.claudeArgs) { - args.push(...opts.claudeArgs) - } - // Add hook settings for session tracking (when available) if (opts.hookSettingsPath) { args.push('--settings', opts.hookSettingsPath); logger.debug(`[ClaudeLocal] Using hook settings: ${opts.hookSettingsPath}`); } + // Add custom Claude arguments LAST (so prompt/slash commands are at the end) + if (opts.claudeArgs) { + args.push(...opts.claudeArgs) + } + if (!claudeCliPath || !existsSync(claudeCliPath)) { throw new Error('Claude local launcher not found. Please ensure HAPPY_PROJECT_ROOT is set correctly for development.'); } @@ -232,6 +239,11 @@ export async function claudeLocal(opts: { env, }); + // Forward signals to child process to prevent orphaned processes + // Note: signal: opts.abort handles programmatic abort (mode switching), + // but direct OS signals (e.g., kill, Ctrl+C) need explicit forwarding + attachProcessSignalForwardingToChild(child); + // Listen to the custom fd (fd 3) for thinking state tracking if (child.stdio[3]) { const rl = createInterface({ diff --git a/cli/src/backends/claude/claudeLocalLauncher.test.ts b/cli/src/backends/claude/claudeLocalLauncher.test.ts new file mode 100644 index 000000000..376f3b3ad --- /dev/null +++ b/cli/src/backends/claude/claudeLocalLauncher.test.ts @@ -0,0 +1,424 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { MessageQueue2 } from '@/utils/MessageQueue2'; +import { Session } from './session'; + +let readlineAnswer = 'n'; +vi.mock('node:readline', () => ({ + createInterface: () => ({ + question: (_q: string, cb: (answer: string) => void) => cb(readlineAnswer), + close: () => {}, + }), +})); + +const mockClaudeLocal = vi.fn(); +vi.mock('./claudeLocal', () => ({ + claudeLocal: mockClaudeLocal, +})); + +const mockCreateSessionScanner = vi.fn(); +vi.mock('./utils/sessionScanner', () => ({ + createSessionScanner: mockCreateSessionScanner, +})); + +vi.mock('@/ui/logger', () => ({ + logger: { + debug: vi.fn(), + debugLargeJson: vi.fn(), + warn: vi.fn(), + }, +})); + +describe('claudeLocalLauncher', () => { + beforeEach(() => { + vi.clearAllMocks(); + readlineAnswer = 'n'; + }); + + it('surfaces Claude process errors to the UI', async () => { + const sendSessionEvent = vi.fn(); + const client = { + keepAlive: vi.fn(), + updateMetadata: vi.fn(), + rpcHandlerManager: { registerHandler: vi.fn() }, + sendClaudeSessionMessage: vi.fn(), + sendSessionEvent, + peekPendingMessageQueueV1Preview: vi.fn(() => ({ count: 0, preview: [] })), + discardPendingMessageQueueV1All: vi.fn().mockResolvedValue(0), + } as any; + + const session = new Session({ + api: {} as any, + client, + path: '/tmp', + logPath: '/tmp/log', + sessionId: null, + mcpServers: {}, + messageQueue: new MessageQueue2(() => 'mode'), + onModeChange: () => {}, + hookSettingsPath: '/tmp/hooks.json', + }); + + mockCreateSessionScanner.mockResolvedValue({ + cleanup: vi.fn(async () => {}), + onNewSession: vi.fn(), + }); + + mockClaudeLocal + .mockImplementationOnce(async () => { + throw new Error('boom'); + }) + .mockImplementationOnce(async () => {}); + + const { claudeLocalLauncher } = await import('./claudeLocalLauncher'); + const result = await claudeLocalLauncher(session); + + expect(result).toBe('exit'); + expect(sendSessionEvent).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'message', + message: expect.any(String), + }), + ); + + session.cleanup(); + }); + + it('surfaces transcript missing warnings to the UI', async () => { + const sendSessionEvent = vi.fn(); + const client = { + keepAlive: vi.fn(), + updateMetadata: vi.fn(), + rpcHandlerManager: { registerHandler: vi.fn() }, + sendClaudeSessionMessage: vi.fn(), + sendSessionEvent, + peekPendingMessageQueueV1Preview: vi.fn(() => ({ count: 0, preview: [] })), + discardPendingMessageQueueV1All: vi.fn().mockResolvedValue(0), + } as any; + + const session = new Session({ + api: {} as any, + client, + path: '/tmp', + logPath: '/tmp/log', + sessionId: null, + mcpServers: {}, + messageQueue: new MessageQueue2(() => 'mode'), + onModeChange: () => {}, + hookSettingsPath: '/tmp/hooks.json', + }); + + mockCreateSessionScanner.mockImplementation(async (opts: any) => { + opts.onTranscriptMissing?.({ sessionId: 'sess_1', filePath: '/tmp/sess_1.jsonl' }); + return { + cleanup: vi.fn(async () => {}), + onNewSession: vi.fn(), + }; + }); + + mockClaudeLocal.mockImplementationOnce(async () => {}); + + const { claudeLocalLauncher } = await import('./claudeLocalLauncher'); + const result = await claudeLocalLauncher(session); + + expect(result).toBe('exit'); + expect(sendSessionEvent).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'message', + message: expect.any(String), + }), + ); + + session.cleanup(); + }); + + it('passes transcriptPath to sessionScanner when already known', async () => { + const sendSessionEvent = vi.fn(); + const client = { + keepAlive: vi.fn(), + updateMetadata: vi.fn(), + rpcHandlerManager: { registerHandler: vi.fn() }, + sendClaudeSessionMessage: vi.fn(), + sendSessionEvent, + peekPendingMessageQueueV1Preview: vi.fn(() => ({ count: 0, preview: [] })), + discardPendingMessageQueueV1All: vi.fn().mockResolvedValue(0), + } as any; + + const session = new Session({ + api: {} as any, + client, + path: '/tmp', + logPath: '/tmp/log', + sessionId: null, + mcpServers: {}, + messageQueue: new MessageQueue2(() => 'mode'), + onModeChange: () => {}, + hookSettingsPath: '/tmp/hooks.json', + }); + + // Simulate a session started in remote mode where hook already provided transcript_path + session.onSessionFound('sess_1', { transcript_path: '/alt/sess_1.jsonl' } as any); + + mockCreateSessionScanner.mockResolvedValue({ + cleanup: vi.fn(async () => {}), + onNewSession: vi.fn(), + }); + + mockClaudeLocal.mockImplementationOnce(async () => {}); + + const { claudeLocalLauncher } = await import('./claudeLocalLauncher'); + const result = await claudeLocalLauncher(session); + + expect(result).toBe('exit'); + expect(mockCreateSessionScanner).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: 'sess_1', + transcriptPath: '/alt/sess_1.jsonl', + }), + ); + + session.cleanup(); + }); + + it('clears sessionId and transcriptPath before spawning a local resume session', async () => { + const sendSessionEvent = vi.fn(); + const handlersByMethod: Record = {}; + const client = { + keepAlive: vi.fn(), + updateMetadata: vi.fn(), + rpcHandlerManager: { + registerHandler: vi.fn((method: string, handler: any) => { + handlersByMethod[method] = handlersByMethod[method] || []; + handlersByMethod[method].push(handler); + }), + }, + sendClaudeSessionMessage: vi.fn(), + sendSessionEvent, + peekPendingMessageQueueV1Preview: vi.fn(() => ({ count: 0, preview: [] })), + discardPendingMessageQueueV1All: vi.fn().mockResolvedValue(0), + } as any; + + const session = new Session({ + api: {} as any, + client, + path: '/tmp', + logPath: '/tmp/log', + sessionId: null, + mcpServers: {}, + messageQueue: new MessageQueue2(() => 'mode'), + onModeChange: () => { }, + hookSettingsPath: '/tmp/hooks.json', + }); + + // Simulate an existing session we are about to resume locally. + session.onSessionFound('sess_0', { transcript_path: '/tmp/sess_0.jsonl' } as any); + + mockCreateSessionScanner.mockResolvedValue({ + cleanup: vi.fn(async () => { }), + onNewSession: vi.fn(), + }); + + let optsSessionId: string | null | undefined; + let sessionIdAtSpawn: string | null | undefined; + let transcriptPathAtSpawn: string | null | undefined; + + mockClaudeLocal.mockImplementationOnce(async (opts: any) => { + optsSessionId = opts.sessionId; + sessionIdAtSpawn = session.sessionId; + transcriptPathAtSpawn = session.transcriptPath; + + await new Promise((resolve) => { + if (opts.abort?.aborted) return resolve(); + opts.abort?.addEventListener('abort', () => resolve(), { once: true }); + }); + }); + + const { claudeLocalLauncher } = await import('./claudeLocalLauncher'); + + const launcherPromise = claudeLocalLauncher(session); + + // Wait for handlers to register + while (!handlersByMethod.switch?.length) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + + // Hook reports the real active session shortly after spawn (resume forks). + session.onSessionFound('sess_1', { transcript_path: '/tmp/sess_1.jsonl' } as any); + + const switchHandler = handlersByMethod.switch[0]; + expect(await switchHandler({ to: 'remote' })).toBe(true); + await expect(launcherPromise).resolves.toBe('switch'); + + expect(optsSessionId).toBe('sess_0'); + expect(sessionIdAtSpawn).toBeNull(); + expect(transcriptPathAtSpawn).toBeNull(); + + expect(mockCreateSessionScanner).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: 'sess_0', + transcriptPath: '/tmp/sess_0.jsonl', + }), + ); + + session.cleanup(); + }); + + it('respects switch RPC params and returns boolean', async () => { + const sendSessionEvent = vi.fn(); + const handlersByMethod: Record = {}; + const client = { + keepAlive: vi.fn(), + updateMetadata: vi.fn(), + rpcHandlerManager: { + registerHandler: vi.fn((method: string, handler: any) => { + handlersByMethod[method] = handlersByMethod[method] || []; + handlersByMethod[method].push(handler); + }), + }, + sendClaudeSessionMessage: vi.fn(), + sendSessionEvent, + peekPendingMessageQueueV1Preview: vi.fn(() => ({ count: 0, preview: [] })), + discardPendingMessageQueueV1All: vi.fn().mockResolvedValue(0), + } as any; + + const session = new Session({ + api: {} as any, + client, + path: '/tmp', + logPath: '/tmp/log', + sessionId: null, + mcpServers: {}, + messageQueue: new MessageQueue2(() => 'mode'), + onModeChange: () => {}, + hookSettingsPath: '/tmp/hooks.json', + }); + + // Avoid switch waiting on hook data in test; simulate known session. + session.onSessionFound('sess_1', { transcript_path: '/tmp/sess_1.jsonl' } as any); + + mockCreateSessionScanner.mockResolvedValue({ + cleanup: vi.fn(async () => {}), + onNewSession: vi.fn(), + }); + + // Block until aborted + mockClaudeLocal.mockImplementationOnce(async (opts: any) => { + await new Promise((resolve) => { + if (opts.abort?.aborted) return resolve(); + opts.abort?.addEventListener('abort', () => resolve(), { once: true }); + }); + }); + + const { claudeLocalLauncher } = await import('./claudeLocalLauncher'); + + const launcherPromise = claudeLocalLauncher(session); + + // Wait for handlers to register + while (!handlersByMethod.switch?.length) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + + const switchHandler = handlersByMethod.switch[0]; + expect(await switchHandler({ to: 'local' })).toBe(false); + + // Switching to remote should abort and exit local launcher + expect(await switchHandler({ to: 'remote' })).toBe(true); + await expect(launcherPromise).resolves.toBe('switch'); + + session.cleanup(); + }); + + it('declines remote→local switch when queued messages exist and user does not confirm discard', async () => { + const sendSessionEvent = vi.fn(); + const client = { + keepAlive: vi.fn(), + updateMetadata: vi.fn(), + rpcHandlerManager: { registerHandler: vi.fn() }, + sendClaudeSessionMessage: vi.fn(), + sendSessionEvent, + peekPendingMessageQueueV1Preview: vi.fn(() => ({ count: 0, preview: [] })), + discardPendingMessageQueueV1All: vi.fn().mockResolvedValue(0), + } as any; + + const session = new Session({ + api: {} as any, + client, + path: '/tmp', + logPath: '/tmp/log', + sessionId: null, + mcpServers: {}, + messageQueue: new MessageQueue2(() => 'mode'), + onModeChange: () => {}, + hookSettingsPath: '/tmp/hooks.json', + }); + + Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); + Object.defineProperty(process.stdout, 'isTTY', { value: true, configurable: true }); + + session.queue.push('hello from app', { permissionMode: 'default' }); + + mockCreateSessionScanner.mockResolvedValue({ + cleanup: vi.fn(async () => {}), + onNewSession: vi.fn(), + }); + + const { claudeLocalLauncher } = await import('./claudeLocalLauncher'); + const result = await claudeLocalLauncher(session); + + expect(result).toBe('switch'); + expect(mockClaudeLocal).not.toHaveBeenCalled(); + + session.cleanup(); + }); + + it('discards queued messages when user confirms, then continues into local mode', async () => { + const sendSessionEvent = vi.fn(); + const client = { + keepAlive: vi.fn(), + updateMetadata: vi.fn(), + rpcHandlerManager: { registerHandler: vi.fn() }, + sendClaudeSessionMessage: vi.fn(), + sendSessionEvent, + peekPendingMessageQueueV1Preview: vi.fn(() => ({ count: 0, preview: [] })), + discardPendingMessageQueueV1All: vi.fn().mockResolvedValue(0), + } as any; + + const session = new Session({ + api: {} as any, + client, + path: '/tmp', + logPath: '/tmp/log', + sessionId: null, + mcpServers: {}, + messageQueue: new MessageQueue2(() => 'mode'), + onModeChange: () => {}, + hookSettingsPath: '/tmp/hooks.json', + }); + + Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); + Object.defineProperty(process.stdout, 'isTTY', { value: true, configurable: true }); + + readlineAnswer = 'y'; + session.queue.push('hello from app', { permissionMode: 'default' }); + + mockCreateSessionScanner.mockResolvedValue({ + cleanup: vi.fn(async () => {}), + onNewSession: vi.fn(), + }); + + mockClaudeLocal.mockImplementationOnce(async () => {}); + + const { claudeLocalLauncher } = await import('./claudeLocalLauncher'); + const result = await claudeLocalLauncher(session); + + expect(result).toBe('exit'); + expect(session.queue.size()).toBe(0); + expect(sendSessionEvent).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'message', + message: expect.any(String), + }), + ); + + session.cleanup(); + }); +}); diff --git a/cli/src/backends/claude/claudeLocalLauncher.ts b/cli/src/backends/claude/claudeLocalLauncher.ts new file mode 100644 index 000000000..96156e131 --- /dev/null +++ b/cli/src/backends/claude/claudeLocalLauncher.ts @@ -0,0 +1,340 @@ +import { logger } from "@/ui/logger"; +import { claudeLocal } from "./claudeLocal"; +import { Session, type SessionFoundInfo } from "./session"; +import { Future } from "@/utils/future"; +import { createSessionScanner } from "./utils/sessionScanner"; +import { formatErrorForUi } from '@/ui/formatErrorForUi'; +import type { PermissionMode } from "@/api/types"; +import { mapToClaudeMode } from "./utils/permissionMode"; +import { createInterface } from "node:readline"; + +function upsertClaudePermissionModeArgs(args: string[] | undefined, mode: PermissionMode): string[] | undefined { + const filtered: string[] = []; + const input = args ?? []; + + for (let i = 0; i < input.length; i++) { + const arg = input[i]; + + // Remove any existing permission mode flags so we can enforce the session's current mode. + if (arg === '--permission-mode') { + // Skip value if present + if (i + 1 < input.length) { + i++; + } + continue; + } + if (arg === '--dangerously-skip-permissions') { + continue; + } + filtered.push(arg); + } + + const claudeMode = mapToClaudeMode(mode); + if (claudeMode !== 'default') { + filtered.push('--permission-mode', claudeMode); + } + + return filtered.length > 0 ? filtered : undefined; +} + +export async function claudeLocalLauncher(session: Session): Promise<'switch' | 'exit'> { + + // Create scanner + const scanner = await createSessionScanner({ + sessionId: session.sessionId, + transcriptPath: session.transcriptPath, + claudeConfigDir: session.claudeEnvVars?.CLAUDE_CONFIG_DIR ?? null, + workingDirectory: session.path, + onMessage: (message) => { + // Block SDK summary messages - we generate our own + if (message.type !== 'summary') { + session.client.sendClaudeSessionMessage(message) + } + }, + onTranscriptMissing: () => { + session.client.sendSessionEvent({ + type: 'message', + message: 'Claude transcript file not found yet — waiting for it to appear…' + }); + }, + }); + + // Register callback to notify scanner when session ID is found via hook + // This is important for --continue/--resume where session ID is not known upfront + const scannerSessionCallback = (info: SessionFoundInfo) => { + scanner.onNewSession({ sessionId: info.sessionId, transcriptPath: info.transcriptPath }); + }; + session.addSessionFoundCallback(scannerSessionCallback); + + + // Handle abort + let exitReason: 'switch' | 'exit' | null = null; + const processAbortController = new AbortController(); + let exutFuture = new Future(); + try { + async function abort() { + + // Send abort signal + if (!processAbortController.signal.aborted) { + processAbortController.abort(); + } + + // Await full exit + await exutFuture.promise; + } + + async function ensureSessionInfoBeforeSwitch(): Promise { + const needsSessionId = session.sessionId === null; + const needsTranscriptPath = session.transcriptPath === null; + if (!needsSessionId && !needsTranscriptPath) return; + + session.client.sendSessionEvent({ + type: 'message', + message: needsSessionId + ? 'Waiting for Claude session to initialize before switching…' + : 'Waiting for Claude transcript info before switching…', + }); + + await session.waitForSessionFound({ + timeoutMs: 2000, + requireTranscriptPath: needsTranscriptPath, + }); + } + + async function doAbort() { + logger.debug('[local]: doAbort'); + + // Switching to remote mode + if (!exitReason) { + exitReason = 'switch'; + } + + // Reset sent messages + session.queue.reset(); + + // Abort + await ensureSessionInfoBeforeSwitch(); + await abort(); + } + + async function doSwitch() { + logger.debug('[local]: doSwitch'); + + // Switching to remote mode + if (!exitReason) { + exitReason = 'switch'; + } + + // Abort + await ensureSessionInfoBeforeSwitch(); + await abort(); + } + + // When to abort + session.client.rpcHandlerManager.registerHandler('abort', doAbort); // Abort current process, clean queue and switch to remote mode + session.client.rpcHandlerManager.registerHandler('switch', async (params: any) => { + // Newer clients send a target mode. Older clients send no params. + // Local launcher is already in local mode, so {to:'local'} is a no-op. + const to = params && typeof params === 'object' ? (params as any).to : undefined; + if (to === 'local') return false; + await doSwitch(); + return true; + }); // When user wants to switch to remote mode + session.queue.setOnMessage((message: string, mode) => { + session.setLastPermissionMode(mode.permissionMode); + // Switch to remote mode when message received + void doSwitch(); + }); // When any message is received, abort current process, clean queue and switch to remote mode + + const queued = session.queue.queue.map((item) => item.message); + const queuedCount = session.queue.size(); + const queuedLocalIds = session.queue.queue + .map((item) => item.mode?.localId) + .filter((id): id is string => typeof id === 'string' && id.length > 0); + const serverPending = session.client.peekPendingMessageQueueV1Preview({ maxPreview: 3 }); + + if (queuedCount > 0 || serverPending.count > 0) { + const confirmed = await confirmDiscardQueuedMessages({ + queuedCount, + queuedPreview: queued, + serverCount: serverPending.count, + serverPreview: serverPending.preview, + }); + if (!confirmed) { + return 'switch'; + } + + // Discard server-side pending messages first, so remote mode does not replay them later. + let discardedServerCount = 0; + try { + if (serverPending.count > 0) { + discardedServerCount = await session.client.discardPendingMessageQueueV1All({ reason: 'switch_to_local' }); + } + } catch (e) { + session.client.sendSessionEvent({ + type: 'message', + message: `Failed to discard pending messages before switching to local mode: ${formatErrorForUi(e)}`, + }); + return 'switch'; + } + + // Mark committed queued remote messages as discarded in session metadata so the UI can render them correctly. + try { + if (queuedLocalIds.length > 0) { + await session.client.discardCommittedMessageLocalIds({ localIds: queuedLocalIds, reason: 'switch_to_local' }); + } + } catch (e) { + session.client.sendSessionEvent({ + type: 'message', + message: `Failed to mark queued messages as discarded before switching to local mode: ${formatErrorForUi(e)}`, + }); + return 'switch'; + } + + if (queuedCount > 0) { + session.queue.reset(); + } + + const parts: string[] = []; + if (discardedServerCount > 0) { + parts.push(`${discardedServerCount} pending UI message${discardedServerCount === 1 ? '' : 's'}`); + } + if (queuedCount > 0) { + parts.push(`${queuedCount} queued remote message${queuedCount === 1 ? '' : 's'}`); + } + + if (parts.length > 0) { + session.client.sendSessionEvent({ + type: 'message', + message: `Discarded ${parts.join(' and ')} to switch to local mode. Please resend them from this terminal if needed.`, + }); + } + } + + // Handle session start + const handleSessionStart = (sessionId: string) => { + session.onSessionFound(sessionId); + scanner.onNewSession(sessionId); + } + + // Run local mode + while (true) { + // If we already have an exit reason, return it + if (exitReason) { + return exitReason; + } + + const resumeFromSessionId = session.sessionId; + const resumeFromTranscriptPath = session.transcriptPath; + const expectsFork = resumeFromSessionId !== null; + if (expectsFork) { + // Starting local mode from an existing session uses `--resume`, which forks + // to a new Claude session ID and transcript file. Clear the current + // session info so a fast local→remote switch waits for the new hook data, + // instead of resuming the stale pre-fork sessionId/transcriptPath. + session.clearSessionId(); + } + + // Launch + logger.debug('[local]: launch'); + try { + // Ensure local Claude Code is spawned with the current session permission mode. + // This is essential for remote → local switches where the app-selected mode must carry over. + session.claudeArgs = upsertClaudePermissionModeArgs(session.claudeArgs, session.lastPermissionMode); + + await claudeLocal({ + path: session.path, + sessionId: resumeFromSessionId, + onSessionFound: handleSessionStart, + onThinkingChange: session.onThinkingChange, + abort: processAbortController.signal, + claudeEnvVars: session.claudeEnvVars, + claudeArgs: session.claudeArgs, + mcpServers: session.mcpServers, + allowedTools: session.allowedTools, + hookSettingsPath: session.hookSettingsPath, + }); + + // Consume one-time Claude flags after spawn + // For example we don't want to pass --resume flag after first spawn + session.consumeOneTimeFlags(); + + // Normal exit + if (!exitReason) { + exitReason = 'exit'; + break; + } + } catch (e) { + logger.debug('[local]: launch error', e); + if (expectsFork && session.sessionId === null) { + // If the local spawn failed before Claude reported the forked session, + // restore the previous session info so remote mode can still resume it. + session.sessionId = resumeFromSessionId; + session.transcriptPath = resumeFromTranscriptPath; + } + if (!exitReason) { + session.client.sendSessionEvent({ type: 'message', message: `Claude process error: ${formatErrorForUi(e)}` }); + continue; + } else { + break; + } + } + logger.debug('[local]: launch done'); + } + } finally { + + // Resolve future + exutFuture.resolve(undefined); + + // Set handlers to no-op + session.client.rpcHandlerManager.registerHandler('abort', async () => { }); + session.client.rpcHandlerManager.registerHandler('switch', async () => false); + session.queue.setOnMessage(null); + + // Remove session found callback + session.removeSessionFoundCallback(scannerSessionCallback); + + // Cleanup + await scanner.cleanup(); + } + + // Return + return exitReason || 'exit'; +} + async function confirmDiscardQueuedMessages(opts: { queuedCount: number; queuedPreview: string[]; serverCount: number; serverPreview: string[] }): Promise { + if (!process.stdin.isTTY || !process.stdout.isTTY) { + return false; + } + + const rl = createInterface({ input: process.stdin, output: process.stdout }); + const question = (text: string) => new Promise((resolve) => rl.question(text, resolve)); + + const renderPreview = (messages: string[]) => + messages + .filter((m) => m.trim().length > 0) + .slice(0, 3) + .map((m, i) => ` ${i + 1}. ${m.length > 120 ? `${m.slice(0, 120)}…` : m}`) + .join('\n'); + + const blocks: string[] = []; + if (opts.serverCount > 0) { + const preview = renderPreview(opts.serverPreview); + blocks.push(preview + ? `Pending UI messages (${opts.serverCount}):\n${preview}` + : `Pending UI messages (${opts.serverCount}).`); + } + if (opts.queuedCount > 0) { + const preview = renderPreview(opts.queuedPreview); + blocks.push(preview + ? `Queued remote messages (${opts.queuedCount}):\n${preview}` + : `Queued remote messages (${opts.queuedCount}).`); + } + + process.stdout.write(`\n${blocks.join('\n\n')}\n\n`); + + const answer = await question('Discard these messages and switch to local mode? (y/N) '); + rl.close(); + + const normalized = answer.trim().toLowerCase(); + return normalized === 'y' || normalized === 'yes'; + } diff --git a/cli/src/backends/claude/claudeRemote.test.ts b/cli/src/backends/claude/claudeRemote.test.ts new file mode 100644 index 000000000..c56e6bbc4 --- /dev/null +++ b/cli/src/backends/claude/claudeRemote.test.ts @@ -0,0 +1,180 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mockQuery = vi.fn() + +vi.mock('@/backends/claude/sdk', () => ({ + query: mockQuery, + AbortError: class AbortError extends Error {}, +})) + +// RED: current implementation waits for the session file to exist (up to 10s) +// which can block sessionId propagation and switching. We should not call this. +vi.mock('@/integrations/watcher/awaitFileExist', () => ({ + awaitFileExist: vi.fn(() => { + throw new Error('awaitFileExist should not be called') + }), +})) + + +vi.mock('./utils/claudeCheckSession', () => ({ + claudeCheckSession: vi.fn(() => false), +})) + +vi.mock('./utils/claudeFindLastSession', () => ({ + claudeFindLastSession: vi.fn(() => 'last-session-id'), +})) + +vi.mock('@/lib', () => ({ + logger: { + debug: vi.fn(), + debugLargeJson: vi.fn(), + }, +})) + +describe('claudeRemote', () => { + beforeEach(() => { + mockQuery.mockReset() + }) + + it('keeps resume sessionId even if claudeCheckSession returns false (avoid false-negative context loss)', async () => { + mockQuery.mockReturnValue( + (async function* () { + yield { type: 'result' } as any + })(), + ) + + const { claudeRemote } = await import('./claudeRemote') + + const onSessionFound = vi.fn() + const onReady = vi.fn() + const onMessage = vi.fn() + const canCallTool = vi.fn() + + const nextMessage = vi.fn(async () => ({ message: 'hello', mode: { permissionMode: 'default' } as any })) + + await claudeRemote({ + sessionId: 'sess_should_resume', + transcriptPath: null, + path: '/tmp', + allowedTools: [], + mcpServers: {}, + hookSettingsPath: '/tmp/hooks.json', + canCallTool, + isAborted: () => false, + nextMessage, + onReady, + onSessionFound, + onMessage, + } as any) + + expect(mockQuery).toHaveBeenCalledTimes(1) + const call = mockQuery.mock.calls[0]?.[0] + expect(call?.options?.resume).toBe('sess_should_resume') + }) + + it('honors --continue in remote mode by passing continue=true to the SDK', async () => { + mockQuery.mockReturnValue( + (async function* () { + yield { type: 'result' } as any + })(), + ) + + const { claudeRemote } = await import('./claudeRemote') + + const nextMessage = vi.fn(async () => ({ message: 'hello', mode: { permissionMode: 'default' } as any })) + + await claudeRemote({ + sessionId: null, + transcriptPath: null, + path: '/tmp', + allowedTools: [], + mcpServers: {}, + hookSettingsPath: '/tmp/hooks.json', + claudeArgs: ['--continue'], + canCallTool: vi.fn(), + isAborted: () => false, + nextMessage, + onReady: vi.fn(), + onSessionFound: vi.fn(), + onMessage: vi.fn(), + } as any) + + expect(mockQuery).toHaveBeenCalledTimes(1) + const call = mockQuery.mock.calls[0]?.[0] + expect(call?.options?.continue).toBe(true) + }) + + it('treats --resume (no id) as resume-last-session in remote mode', async () => { + mockQuery.mockReturnValue( + (async function* () { + yield { type: 'result' } as any + })(), + ) + + const { claudeRemote } = await import('./claudeRemote') + + const nextMessage = vi.fn(async () => ({ message: 'hello', mode: { permissionMode: 'default' } as any })) + + await claudeRemote({ + sessionId: null, + transcriptPath: null, + path: '/tmp', + allowedTools: [], + mcpServers: {}, + hookSettingsPath: '/tmp/hooks.json', + claudeArgs: ['--resume'], + canCallTool: vi.fn(), + isAborted: () => false, + nextMessage, + onReady: vi.fn(), + onSessionFound: vi.fn(), + onMessage: vi.fn(), + } as any) + + expect(mockQuery).toHaveBeenCalledTimes(1) + const call = mockQuery.mock.calls[0]?.[0] + expect(call?.options?.resume).toBe('last-session-id') + }) + + it('calls onSessionFound from system init without waiting for transcript file', async () => { + mockQuery.mockReturnValue( + (async function* () { + yield { type: 'system', subtype: 'init', session_id: 'sess_1' } as any + yield { type: 'result' } as any + })(), + ) + + const { claudeRemote } = await import('./claudeRemote') + + const onSessionFound = vi.fn() + const onReady = vi.fn() + const onMessage = vi.fn() + const canCallTool = vi.fn() + + let nextCount = 0 + const nextMessage = vi.fn(async () => { + nextCount++ + if (nextCount === 1) { + return { message: 'hello', mode: { permissionMode: 'default' } as any } + } + return null + }) + + await claudeRemote({ + sessionId: null, + transcriptPath: null, + path: '/tmp', + allowedTools: [], + mcpServers: {}, + hookSettingsPath: '/tmp/hooks.json', + canCallTool, + isAborted: () => false, + nextMessage, + onReady, + onSessionFound, + onMessage, + } as any) + + expect(onSessionFound).toHaveBeenCalledWith('sess_1') + }) +}) diff --git a/cli/src/claude/claudeRemote.ts b/cli/src/backends/claude/claudeRemote.ts similarity index 71% rename from cli/src/claude/claudeRemote.ts rename to cli/src/backends/claude/claudeRemote.ts index d93215c8c..a07b929f5 100644 --- a/cli/src/claude/claudeRemote.ts +++ b/cli/src/backends/claude/claudeRemote.ts @@ -1,14 +1,13 @@ import { EnhancedMode } from "./loop"; -import { query, type QueryOptions, type SDKMessage, type SDKSystemMessage, AbortError, SDKUserMessage } from '@/claude/sdk' +import { query, type QueryOptions, type SDKMessage, type SDKSystemMessage, AbortError, SDKUserMessage } from '@/backends/claude/sdk' import { mapToClaudeMode } from "./utils/permissionMode"; import { claudeCheckSession } from "./utils/claudeCheckSession"; +import { claudeFindLastSession } from "./utils/claudeFindLastSession"; import { join, resolve } from 'node:path'; import { projectPath } from "@/projectPath"; -import { parseSpecialCommand } from "@/parsers/specialCommands"; +import { parseSpecialCommand } from "@/cli/parsers/specialCommands"; import { logger } from "@/lib"; import { PushableAsyncIterable } from "@/utils/PushableAsyncIterable"; -import { getProjectPath } from "./utils/path"; -import { awaitFileExist } from "@/modules/watcher/awaitFileExist"; import { systemPrompt } from "./utils/systemPrompt"; import { PermissionResult } from "./sdk/types"; import type { JsRuntime } from "./runClaude"; @@ -17,6 +16,7 @@ export async function claudeRemote(opts: { // Fixed parameters sessionId: string | null, + transcriptPath: string | null, path: string, mcpServers?: Record, claudeEnvVars?: Record, @@ -39,38 +39,53 @@ export async function claudeRemote(opts: { onThinkingChange?: (thinking: boolean) => void, onMessage: (message: SDKMessage) => void, onCompletionEvent?: (message: string) => void, - onSessionReset?: () => void + onSessionReset?: () => void, + setUserMessageSender?: (sender: ((message: SDKUserMessage) => void) | null) => void, }) { - // Check if session is valid + // Determine how we should (re)start the Claude session. + // + // IMPORTANT: do not "fail closed" to a fresh session just because our local transcript check + // can't validate the session yet. That can cause context loss during fast local↔remote switching + // (the session file may exist but not contain "uuid/messageId" lines yet). let startFrom = opts.sessionId; - if (opts.sessionId && !claudeCheckSession(opts.sessionId, opts.path)) { - startFrom = null; + let shouldContinue = false; + if (opts.sessionId && !claudeCheckSession(opts.sessionId, opts.path, opts.transcriptPath)) { + logger.debug(`[claudeRemote] Session ${opts.sessionId} did not pass transcript validation yet; attempting resume anyway`); } - // Extract --resume from claudeArgs if present (for first spawn) + // If we don't have an explicit sessionId to resume from, honor one-time session flags. + // (These are consumed by Session.consumeOneTimeFlags() after the first successful spawn.) if (!startFrom && opts.claudeArgs) { + // --continue / -c: let Claude pick the last session, but still run in SDK mode + if (opts.claudeArgs.includes('--continue') || opts.claudeArgs.includes('-c')) { + shouldContinue = true; + } + + // --resume / -r: in remote mode we can't show the interactive picker, so: + // - `--resume ` / `-r ` resumes that id + // - `--resume` / `-r` resumes the most recent valid UUID session in this project for (let i = 0; i < opts.claudeArgs.length; i++) { - if (opts.claudeArgs[i] === '--resume') { - // Check if next arg exists and looks like a session ID - if (i + 1 < opts.claudeArgs.length) { - const nextArg = opts.claudeArgs[i + 1]; - // If next arg doesn't start with dash and contains dashes, it's likely a UUID - if (!nextArg.startsWith('-') && nextArg.includes('-')) { - startFrom = nextArg; - logger.debug(`[claudeRemote] Found --resume with session ID: ${startFrom}`); - break; - } else { - // Just --resume without UUID - SDK doesn't support this - logger.debug('[claudeRemote] Found --resume without session ID - not supported in remote mode'); - break; - } + const arg = opts.claudeArgs[i]; + if (arg !== '--resume' && arg !== '-r') continue; + + const maybeValue = i + 1 < opts.claudeArgs.length ? opts.claudeArgs[i + 1] : undefined; + if (maybeValue && !maybeValue.startsWith('-')) { + startFrom = maybeValue; + logger.debug(`[claudeRemote] Found ${arg} with session ID: ${startFrom}`); + } else { + const lastSession = claudeFindLastSession(opts.path, opts.claudeEnvVars?.CLAUDE_CONFIG_DIR ?? null); + if (lastSession) { + startFrom = lastSession; + logger.debug(`[claudeRemote] Found ${arg} without id; using last session: ${startFrom}`); } else { - // --resume at end of args - SDK doesn't support this - logger.debug('[claudeRemote] Found --resume without session ID - not supported in remote mode'); - break; + logger.debug(`[claudeRemote] Found ${arg} without id but no valid last session was found`); } } + + // Explicit resume overrides --continue semantics. + shouldContinue = false; + break; } } @@ -115,6 +130,7 @@ export async function claudeRemote(opts: { let mode = initial.mode; const sdkOptions: QueryOptions = { cwd: opts.path, + continue: shouldContinue || undefined, resume: startFrom ?? undefined, mcpServers: opts.mcpServers, permissionMode: mapToClaudeMode(initial.mode.permissionMode), @@ -147,6 +163,7 @@ export async function claudeRemote(opts: { // Push initial message let messages = new PushableAsyncIterable(); + opts.setUserMessageSender?.((message: SDKUserMessage) => messages.push(message)); messages.push({ type: 'user', message: { @@ -177,14 +194,10 @@ export async function claudeRemote(opts: { updateThinking(true); const systemInit = message as SDKSystemMessage; - - // Session id is still in memory, wait until session file is written to disk - // Start a watcher for to detect the session id if (systemInit.session_id) { - logger.debug(`[claudeRemote] Waiting for session file to be written to disk: ${systemInit.session_id}`); - const projectDir = getProjectPath(opts.path); - const found = await awaitFileExist(join(projectDir, `${systemInit.session_id}.jsonl`)); - logger.debug(`[claudeRemote] Session file found: ${systemInit.session_id} ${found}`); + // Do not block on filesystem writes here. + // The session scanner can handle missing files via watcher retries + UI warnings. + logger.debug(`[claudeRemote] Session initialized: ${systemInit.session_id}`); opts.onSessionFound(systemInit.session_id); } } @@ -237,6 +250,7 @@ export async function claudeRemote(opts: { throw e; } } finally { + opts.setUserMessageSender?.(null); updateThinking(false); } -} \ No newline at end of file +} diff --git a/cli/src/backends/claude/claudeRemoteLauncher.test.ts b/cli/src/backends/claude/claudeRemoteLauncher.test.ts new file mode 100644 index 000000000..2a28383e0 --- /dev/null +++ b/cli/src/backends/claude/claudeRemoteLauncher.test.ts @@ -0,0 +1,238 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { MessageQueue2 } from '@/utils/MessageQueue2' +import { Session } from './session' + +const mockClaudeRemote = vi.fn() +vi.mock('./claudeRemote', () => ({ + claudeRemote: mockClaudeRemote, +})) + +const mockResetParentChain = vi.fn() +const mockUpdateSessionId = vi.fn() +vi.mock('./utils/sdkToLogConverter', () => ({ + SDKToLogConverter: vi.fn().mockImplementation(() => ({ + resetParentChain: mockResetParentChain, + updateSessionId: mockUpdateSessionId, + convert: () => null, + convertSidechainUserMessage: () => null, + generateInterruptedToolResult: () => null, + })), +})) + +vi.mock('@/ui/logger', () => ({ + logger: { + debug: vi.fn(), + debugLargeJson: vi.fn(), + warn: vi.fn(), + }, +})) + +vi.mock('@/lib', () => ({ + logger: { + debug: vi.fn(), + debugLargeJson: vi.fn(), + warn: vi.fn(), + }, +})) + +describe('claudeRemoteLauncher', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('does not double-reset parent chain when sessionId changes during a remote run', async () => { + const handlersByMethod: Record = {} + + const client = { + sessionId: 'happy_sess_1', + keepAlive: vi.fn(), + updateMetadata: vi.fn(), + updateAgentState: vi.fn((updater: any) => updater({})), + rpcHandlerManager: { + registerHandler: vi.fn((method: string, handler: any) => { + handlersByMethod[method] = handlersByMethod[method] || [] + handlersByMethod[method].push(handler) + }), + }, + sendClaudeSessionMessage: vi.fn(), + sendSessionEvent: vi.fn(), + } as any + + const api = { + push: () => ({ sendToAllDevices: vi.fn() }), + } as any + + const session = new Session({ + api, + client, + path: '/tmp', + logPath: '/tmp/log', + sessionId: 'sess_0', + mcpServers: {}, + messageQueue: new MessageQueue2(() => 'mode'), + onModeChange: () => {}, + hookSettingsPath: '/tmp/hooks.json', + }) + + mockClaudeRemote + .mockImplementationOnce(async (opts: any) => { + // Session changes while the remote run is active (system init / hook) + opts.onSessionFound?.('sess_1') + }) + .mockImplementationOnce(async (opts: any) => { + // Block until aborted by a switch call. + await new Promise((resolve) => { + if (opts.signal?.aborted) return resolve() + opts.signal?.addEventListener('abort', () => resolve(), { once: true }) + }) + }) + + const { claudeRemoteLauncher } = await import('./claudeRemoteLauncher') + const launcherPromise = claudeRemoteLauncher(session) + + while (!handlersByMethod.switch?.length) { + await new Promise((resolve) => setTimeout(resolve, 0)) + } + + // Ensure we entered the 2nd iteration (where the regression happens). + while (mockClaudeRemote.mock.calls.length < 2) { + await new Promise((resolve) => setTimeout(resolve, 0)) + } + + // Trigger exit from the 2nd remote run + const switchHandler = handlersByMethod.switch[0] + expect(await switchHandler({ to: 'local' })).toBe(true) + + await expect(launcherPromise).resolves.toBe('switch') + + expect(mockClaudeRemote).toHaveBeenCalledTimes(2) + + // First iteration is a new session (sess_0 vs null) → one reset. + // SessionId changes during the run (sess_1) should NOT cause a second reset on the next loop iteration. + expect(mockResetParentChain).toHaveBeenCalledTimes(1) + + session.cleanup() + }, 10_000) + + it('respects switch RPC params and is idempotent', async () => { + const handlersByMethod: Record = {} + const sendSessionEvent = vi.fn() + + const client = { + sessionId: 'happy_sess_1', + keepAlive: vi.fn(), + updateMetadata: vi.fn(), + updateAgentState: vi.fn((updater: any) => updater({})), + rpcHandlerManager: { + registerHandler: vi.fn((method: string, handler: any) => { + handlersByMethod[method] = handlersByMethod[method] || [] + handlersByMethod[method].push(handler) + }), + }, + sendClaudeSessionMessage: vi.fn(), + sendSessionEvent, + } as any + + const api = { + push: () => ({ sendToAllDevices: vi.fn() }), + } as any + + const session = new Session({ + api, + client, + path: '/tmp', + logPath: '/tmp/log', + sessionId: null, + mcpServers: {}, + messageQueue: new MessageQueue2(() => 'mode'), + onModeChange: () => {}, + hookSettingsPath: '/tmp/hooks.json', + }) + + session.onSessionFound('sess_1', { transcript_path: '/tmp/sess_1.jsonl' } as any) + + mockClaudeRemote.mockImplementationOnce(async (opts: any) => { + await new Promise((resolve) => { + if (opts.signal?.aborted) return resolve() + opts.signal?.addEventListener('abort', () => resolve(), { once: true }) + }) + }) + + const { claudeRemoteLauncher } = await import('./claudeRemoteLauncher') + + const launcherPromise = claudeRemoteLauncher(session) + + while (!handlersByMethod.switch?.length) { + await new Promise((resolve) => setTimeout(resolve, 0)) + } + + const switchHandler = handlersByMethod.switch[0] + + // Already remote; should be a no-op + expect(await switchHandler({ to: 'remote' })).toBe(false) + + // Switch to local should abort and exit remote launcher + expect(await switchHandler({ to: 'local' })).toBe(true) + await expect(launcherPromise).resolves.toBe('switch') + + session.cleanup() + }) + + it('treats null sessionId as a new session boundary', async () => { + const handlersByMethod: Record = {} + + const client = { + sessionId: 'happy_sess_1', + keepAlive: vi.fn(), + updateMetadata: vi.fn(), + updateAgentState: vi.fn((updater: any) => updater({})), + rpcHandlerManager: { + registerHandler: vi.fn((method: string, handler: any) => { + handlersByMethod[method] = handlersByMethod[method] || [] + handlersByMethod[method].push(handler) + }), + }, + sendClaudeSessionMessage: vi.fn(), + sendSessionEvent: vi.fn(), + } as any + + const api = { + push: () => ({ sendToAllDevices: vi.fn() }), + } as any + + const session = new Session({ + api, + client, + path: '/tmp', + logPath: '/tmp/log', + sessionId: null, + mcpServers: {}, + messageQueue: new MessageQueue2(() => 'mode'), + onModeChange: () => { }, + hookSettingsPath: '/tmp/hooks.json', + }) + + mockClaudeRemote.mockImplementationOnce(async (opts: any) => { + await new Promise((resolve) => { + if (opts.signal?.aborted) return resolve() + opts.signal?.addEventListener('abort', () => resolve(), { once: true }) + }) + }) + + const { claudeRemoteLauncher } = await import('./claudeRemoteLauncher') + + const launcherPromise = claudeRemoteLauncher(session) + + while (!handlersByMethod.switch?.length) { + await new Promise((resolve) => setTimeout(resolve, 0)) + } + + const switchHandler = handlersByMethod.switch[0] + expect(await switchHandler({ to: 'local' })).toBe(true) + await expect(launcherPromise).resolves.toBe('switch') + + expect(mockResetParentChain).toHaveBeenCalledTimes(1) + + session.cleanup() + }) +}) diff --git a/cli/src/claude/claudeRemoteLauncher.ts b/cli/src/backends/claude/claudeRemoteLauncher.ts similarity index 70% rename from cli/src/claude/claudeRemoteLauncher.ts rename to cli/src/backends/claude/claudeRemoteLauncher.ts index 81e6454ab..a25cdd19a 100644 --- a/cli/src/claude/claudeRemoteLauncher.ts +++ b/cli/src/backends/claude/claudeRemoteLauncher.ts @@ -1,20 +1,22 @@ import { render } from "ink"; import { Session } from "./session"; import { MessageBuffer } from "@/ui/ink/messageBuffer"; -import { RemoteModeDisplay } from "@/ui/ink/RemoteModeDisplay"; +import { RemoteModeDisplay } from "@/backends/claude/ui/RemoteModeDisplay"; import React from "react"; import { claudeRemote } from "./claudeRemote"; import { PermissionHandler } from "./utils/permissionHandler"; import { Future } from "@/utils/future"; -import { SDKAssistantMessage, SDKMessage, SDKUserMessage } from "./sdk"; +import { AbortError, SDKAssistantMessage, SDKMessage, SDKUserMessage } from "./sdk"; import { formatClaudeMessageForInk } from "@/ui/messageFormatterInk"; import { logger } from "@/ui/logger"; import { SDKToLogConverter } from "./utils/sdkToLogConverter"; -import { PLAN_FAKE_REJECT } from "./sdk/prompts"; import { EnhancedMode } from "./loop"; -import { RawJSONLines } from "@/claude/types"; +import { RawJSONLines } from "@/backends/claude/types"; import { OutgoingMessageQueue } from "./utils/OutgoingMessageQueue"; import { getToolName } from "./utils/getToolName"; +import { formatErrorForUi } from '@/ui/formatErrorForUi'; +import { waitForMessagesOrPending } from '@/agent/runtime/waitForMessagesOrPending'; +import { cleanupStdinAfterInk } from '@/ui/ink/cleanupStdinAfterInk'; interface PermissionsField { date: number; @@ -23,6 +25,50 @@ interface PermissionsField { allowedTools?: string[]; } +type LaunchErrorInfo = { + asString: string; + name?: string; + message?: string; + code?: string; + stack?: string; +}; + +function getLaunchErrorInfo(e: unknown): LaunchErrorInfo { + let asString = '[unprintable error]'; + try { + asString = typeof e === 'string' ? e : String(e); + } catch { + // Ignore + } + + if (!e || typeof e !== 'object') { + return { asString }; + } + + const err = e as { name?: unknown; message?: unknown; code?: unknown; stack?: unknown }; + + const name = typeof err.name === 'string' ? err.name : undefined; + const message = typeof err.message === 'string' ? err.message : undefined; + const code = typeof err.code === 'string' || typeof err.code === 'number' ? String(err.code) : undefined; + const stack = typeof err.stack === 'string' ? err.stack : undefined; + + return { asString, name, message, code, stack }; +} + +function isAbortError(e: unknown): boolean { + if (e instanceof AbortError) return true; + + if (!e || typeof e !== 'object') { + return false; + } + + const err = e as { name?: unknown; code?: unknown }; + if (typeof err.name === 'string' && err.name === 'AbortError') return true; + if (typeof err.code === 'string' && err.code === 'ABORT_ERR') return true; + + return false; +} + export async function claudeRemoteLauncher(session: Session): Promise<'switch' | 'exit'> { logger.debug('[claudeRemoteLauncher] Starting remote launcher'); @@ -59,11 +105,12 @@ export async function claudeRemoteLauncher(session: Session): Promise<'switch' | } if (hasTTY) { + // Ensure we can capture keypresses for the remote-mode UI. + // Avoid forcing stdin encoding here; Ink (and Node) should handle key decoding safely. process.stdin.resume(); if (process.stdin.isTTY) { process.stdin.setRawMode(true); } - process.stdin.setEncoding("utf8"); } // Handle abort @@ -83,17 +130,43 @@ export async function claudeRemoteLauncher(session: Session): Promise<'switch' | await abort(); } + async function ensureSessionInfoBeforeSwitch(): Promise { + const needsSessionId = session.sessionId === null; + const needsTranscriptPath = session.transcriptPath === null; + if (!needsSessionId && !needsTranscriptPath) return; + + session.client.sendSessionEvent({ + type: 'message', + message: needsSessionId + ? 'Waiting for Claude session to initialize before switching…' + : 'Waiting for Claude transcript info before switching…', + }); + + await session.waitForSessionFound({ + timeoutMs: 2000, + requireTranscriptPath: needsTranscriptPath, + }); + } + async function doSwitch() { logger.debug('[remote]: doSwitch'); if (!exitReason) { exitReason = 'switch'; } + await ensureSessionInfoBeforeSwitch(); await abort(); } // When to abort session.client.rpcHandlerManager.registerHandler('abort', doAbort); // When abort clicked - session.client.rpcHandlerManager.registerHandler('switch', doSwitch); // When switch clicked + session.client.rpcHandlerManager.registerHandler('switch', async (params: any) => { + // Newer clients send a target mode. Older clients send no params. + // Remote launcher is already in remote mode, so {to:'remote'} is a no-op. + const to = params && typeof params === 'object' ? (params as any).to : undefined; + if (to === 'remote') return false; + await doSwitch(); + return true; + }); // When switch clicked // Removed catch-all stdin handler - now handled by RemoteModeDisplay keyboard handlers // Create permission handler @@ -117,10 +190,6 @@ export async function claudeRemoteLauncher(session: Session): Promise<'switch' | }, permissionHandler.getResponses()); - // Handle messages - let planModeToolCalls = new Set(); - let ongoingToolCalls = new Map(); - function onMessage(message: SDKMessage) { // Write to message log @@ -129,38 +198,11 @@ export async function claudeRemoteLauncher(session: Session): Promise<'switch' | // Write to permission handler for tool id resolving permissionHandler.onMessage(message); - // Detect plan mode tool call - if (message.type === 'assistant') { - let umessage = message as SDKAssistantMessage; - if (umessage.message.content && Array.isArray(umessage.message.content)) { - for (let c of umessage.message.content) { - if (c.type === 'tool_use' && (c.name === 'exit_plan_mode' || c.name === 'ExitPlanMode')) { - logger.debug('[remote]: detected plan mode tool call ' + c.id!); - planModeToolCalls.add(c.id! as string); - } - } - } - } - - // Track active tool calls - if (message.type === 'assistant') { - let umessage = message as SDKAssistantMessage; - if (umessage.message.content && Array.isArray(umessage.message.content)) { - for (let c of umessage.message.content) { - if (c.type === 'tool_use') { - logger.debug('[remote]: detected tool use ' + c.id! + ' parent: ' + umessage.parent_tool_use_id); - ongoingToolCalls.set(c.id!, { parentToolCallId: umessage.parent_tool_use_id ?? null }); - } - } - } - } if (message.type === 'user') { let umessage = message as SDKUserMessage; if (umessage.message.content && Array.isArray(umessage.message.content)) { for (let c of umessage.message.content) { if (c.type === 'tool_result' && c.tool_use_id) { - ongoingToolCalls.delete(c.tool_use_id); - // When tool result received, release any delayed messages for this tool call messageQueue.releaseToolCall(c.tool_use_id); } @@ -171,36 +213,6 @@ export async function claudeRemoteLauncher(session: Session): Promise<'switch' | // Convert SDK message to log format and send to client let msg = message; - // Hack plan mode exit - if (message.type === 'user') { - let umessage = message as SDKUserMessage; - if (umessage.message.content && Array.isArray(umessage.message.content)) { - msg = { - ...umessage, - message: { - ...umessage.message, - content: umessage.message.content.map((c) => { - if (c.type === 'tool_result' && c.tool_use_id && planModeToolCalls.has(c.tool_use_id!)) { - if (c.content === PLAN_FAKE_REJECT) { - logger.debug('[remote]: hack plan mode exit'); - logger.debugLargeJson('[remote]: hack plan mode exit', c); - return { - ...c, - is_error: false, - content: 'Plan approved', - mode: c.mode - } - } else { - return c; - } - } - return c; - }) - } - } - } - } - const logMessage = sdkToLogConverter.convert(msg); if (logMessage) { // Add permissions field to tool result content @@ -227,8 +239,9 @@ export async function claudeRemoteLauncher(session: Session): Promise<'switch' | permissions.mode = response.mode; } - if (response.allowTools && response.allowTools.length > 0) { - permissions.allowedTools = response.allowTools; + const allowedTools = response.allowedTools ?? response.allowTools; + if (allowedTools && allowedTools.length > 0) { + permissions.allowedTools = allowedTools; } // Add permissions directly to the tool_result content object @@ -300,18 +313,20 @@ export async function claudeRemoteLauncher(session: Session): Promise<'switch' | // without starting a new session. Only reset parent chain when session ID // actually changes (e.g., new session started or /clear command used). // See: https://github.com/anthropics/happy-cli/issues/143 - let previousSessionId: string | null = null; + let previousSessionId: string | null | undefined = undefined; + let forceNewSession = false; while (!exitReason) { logger.debug('[remote]: launch'); messageBuffer.addMessage('═'.repeat(40), 'status'); // Only reset parent chain and show "new session" message when session ID actually changes - const isNewSession = session.sessionId !== previousSessionId; + const isNewSession = forceNewSession || session.sessionId !== previousSessionId; if (isNewSession) { messageBuffer.addMessage('Starting new Claude session...', 'status'); permissionHandler.reset(); // Reset permissions before starting new session sdkToLogConverter.resetParentChain(); // Reset parent chain for new conversation logger.debug(`[remote]: New session detected (previous: ${previousSessionId}, current: ${session.sessionId})`); + forceNewSession = false; } else { messageBuffer.addMessage('Continuing Claude session...', 'status'); logger.debug(`[remote]: Continuing existing session: ${session.sessionId}`); @@ -326,6 +341,7 @@ export async function claudeRemoteLauncher(session: Session): Promise<'switch' | try { const remoteResult = await claudeRemote({ sessionId: session.sessionId, + transcriptPath: session.transcriptPath, path: session.path, allowedTools: session.allowedTools ?? [], mcpServers: session.mcpServers, @@ -335,20 +351,30 @@ export async function claudeRemoteLauncher(session: Session): Promise<'switch' | isAborted: (toolCallId: string) => { return permissionHandler.isAborted(toolCallId); }, - nextMessage: async () => { - if (pending) { - let p = pending; - pending = null; - permissionHandler.handleModeChange(p.mode.permissionMode); - return p; - } - - let msg = await session.queue.waitForMessagesAndGetAsString(controller.signal); - - // Check if mode has changed - if (msg) { - if ((modeHash && msg.hash !== modeHash) || msg.isolate) { - logger.debug('[remote]: mode has changed, pending message'); + nextMessage: async () => { + if (pending) { + let p = pending; + pending = null; + permissionHandler.handleModeChange(p.mode.permissionMode); + return p; + } + + const msg = await waitForMessagesOrPending({ + messageQueue: session.queue, + abortSignal: controller.signal, + popPendingMessage: async () => { + // Only materialize pending items when there are no committed transcript messages + // queued locally; committed messages must be processed first. + if (session.queue.size() > 0) return false; + return await session.client.popPendingMessage(); + }, + waitForMetadataUpdate: (signal) => session.client.waitForMetadataUpdate(signal), + }); + + // Check if mode has changed + if (msg) { + if ((modeHash && msg.hash !== modeHash) || msg.isolate) { + logger.debug('[remote]: mode has changed, pending message'); pending = msg; return null; } @@ -379,6 +405,7 @@ export async function claudeRemoteLauncher(session: Session): Promise<'switch' | }, onSessionReset: () => { logger.debug('[remote]: Session reset'); + forceNewSession = true; session.clearSessionId(); }, onReady: () => { @@ -401,25 +428,27 @@ export async function claudeRemoteLauncher(session: Session): Promise<'switch' | session.client.sendSessionEvent({ type: 'message', message: 'Aborted by user' }); } } catch (e) { - logger.debug('[remote]: launch error', e); + const abortError = isAbortError(e); + logger.debug('[remote]: launch error', { + ...getLaunchErrorInfo(e), + abortError, + }); + if (!exitReason) { - session.client.sendSessionEvent({ type: 'message', message: 'Process exited unexpectedly' }); + if (abortError) { + if (controller.signal.aborted) { + session.client.sendSessionEvent({ type: 'message', message: 'Aborted by user' }); + } + continue; + } + + session.client.sendSessionEvent({ type: 'message', message: `Claude process error: ${formatErrorForUi(e)}` }); continue; } } finally { logger.debug('[remote]: launch finally'); - // Terminate all ongoing tool calls - for (let [toolCallId, { parentToolCallId }] of ongoingToolCalls) { - const converted = sdkToLogConverter.generateInterruptedToolResult(toolCallId, parentToolCallId); - if (converted) { - logger.debug('[remote]: terminating tool call ' + toolCallId + ' parent: ' + parentToolCallId); - session.client.sendClaudeSessionMessage(converted); - } - } - ongoingToolCalls.clear(); - // Flush any remaining messages in the queue logger.debug('[remote]: flushing message queue'); await messageQueue.flush(); @@ -434,6 +463,10 @@ export async function claudeRemoteLauncher(session: Session): Promise<'switch' | permissionHandler.reset(); modeHash = null; mode = null; + // Session IDs can change during a remote run (system init / resume / fork / compact). + // Keep previousSessionId in sync so we don't treat the same session as "new" again + // on the next outer loop iteration. + previousSessionId = session.sessionId; } } } finally { @@ -442,13 +475,17 @@ export async function claudeRemoteLauncher(session: Session): Promise<'switch' | permissionHandler.reset(); // Reset Terminal - process.stdin.off('data', abort); if (process.stdin.isTTY) { process.stdin.setRawMode(false); } if (inkInstance) { inkInstance.unmount(); } + + // Give Ink a brief moment to release stdin/tty state, then drain any buffered input + // (e.g. “double space” spam) so it doesn't leak into the next interactive process. + await cleanupStdinAfterInk({ stdin: process.stdin as any, drainMs: 75 }); + messageBuffer.clear(); // Resolve abort future @@ -458,4 +495,4 @@ export async function claudeRemoteLauncher(session: Session): Promise<'switch' | } return exitReason || 'exit'; -} \ No newline at end of file +} diff --git a/cli/src/backends/claude/claude_version_utils.signalForwarding.test.ts b/cli/src/backends/claude/claude_version_utils.signalForwarding.test.ts new file mode 100644 index 000000000..e33410c50 --- /dev/null +++ b/cli/src/backends/claude/claude_version_utils.signalForwarding.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect, vi } from 'vitest'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const { attachChildSignalForwarding } = require('../../../scripts/claude_version_utils.cjs') as any; + +describe('claude_version_utils attachChildSignalForwarding', () => { + it('forwards SIGTERM and SIGINT to child', () => { + const handlers = new Map void)[]>(); + const proc = { + platform: 'darwin', + on: (event: string, handler: () => void) => { + const list = handlers.get(event) ?? []; + list.push(handler); + handlers.set(event, list); + }, + } as any; + + const child = { + pid: 123, + killed: false, + kill: vi.fn(), + } as any; + + attachChildSignalForwarding(child, proc); + + for (const handler of handlers.get('SIGTERM') ?? []) handler(); + for (const handler of handlers.get('SIGINT') ?? []) handler(); + + expect(child.kill).toHaveBeenCalledWith('SIGTERM'); + expect(child.kill).toHaveBeenCalledWith('SIGINT'); + }); + + it('does not register SIGHUP on Windows', () => { + const handlers = new Map void)[]>(); + const proc = { + platform: 'win32', + on: (event: string, handler: () => void) => { + const list = handlers.get(event) ?? []; + list.push(handler); + handlers.set(event, list); + }, + } as any; + + const child = { pid: 123, killed: false, kill: vi.fn() } as any; + attachChildSignalForwarding(child, proc); + + expect(handlers.has('SIGHUP')).toBe(false); + }); +}); diff --git a/cli/src/backends/claude/cli/capability.ts b/cli/src/backends/claude/cli/capability.ts new file mode 100644 index 000000000..c9882d8e2 --- /dev/null +++ b/cli/src/backends/claude/cli/capability.ts @@ -0,0 +1,10 @@ +import type { Capability } from '@/capabilities/service'; +import { buildCliCapabilityData } from '@/capabilities/probes/cliBase'; + +export const cliCapability: Capability = { + descriptor: { id: 'cli.claude', kind: 'cli', title: 'Claude CLI' }, + detect: async ({ request, context }) => { + const entry = context.cliSnapshot?.clis?.claude; + return buildCliCapabilityData({ request, entry }); + }, +}; diff --git a/cli/src/backends/claude/cli/command.ts b/cli/src/backends/claude/cli/command.ts new file mode 100644 index 000000000..acdf3c48e --- /dev/null +++ b/cli/src/backends/claude/cli/command.ts @@ -0,0 +1,187 @@ +import { execFileSync } from 'node:child_process'; + +import chalk from 'chalk'; +import { z } from 'zod'; + +import { PERMISSION_MODES, isPermissionMode } from '@/api/types'; +import { runClaude, type StartOptions } from '@/backends/claude/runClaude'; +import { claudeCliPath } from '@/backends/claude/claudeLocal'; +import { isDaemonRunningCurrentlyInstalledHappyVersion } from '@/daemon/controlClient'; +import { logger } from '@/ui/logger'; +import { authAndSetupMachineIfNeeded } from '@/ui/auth'; +import { spawnHappyCLI } from '@/utils/spawnHappyCLI'; +import packageJson from '../../../../package.json'; + +import type { CommandContext } from '@/cli/commandRegistry'; + +export async function handleClaudeCliCommand(context: CommandContext): Promise { + const args = [...context.args]; + + // Support `happy claude ...` while keeping `happy ...` as the default Claude flow. + if (args.length > 0 && args[0] === 'claude') { + args.shift(); + } + + // Parse command line arguments for main command + const options: StartOptions = {}; + let showHelp = false; + let showVersion = false; + const unknownArgs: string[] = []; // Collect unknown args to pass through to claude + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + + if (arg === '-h' || arg === '--help') { + showHelp = true; + unknownArgs.push(arg); + } else if (arg === '-v' || arg === '--version') { + showVersion = true; + unknownArgs.push(arg); + } else if (arg === '--happy-starting-mode') { + options.startingMode = z.enum(['local', 'remote']).parse(args[++i]); + } else if (arg === '--yolo') { + // Shortcut for --dangerously-skip-permissions + unknownArgs.push('--dangerously-skip-permissions'); + } else if (arg === '--started-by') { + options.startedBy = args[++i] as 'daemon' | 'terminal'; + } else if (arg === '--permission-mode') { + if (i + 1 >= args.length) { + console.error(chalk.red(`Missing value for --permission-mode. Valid values: ${PERMISSION_MODES.join(', ')}`)); + process.exit(1); + } + const value = args[++i]; + if (!isPermissionMode(value)) { + console.error(chalk.red(`Invalid --permission-mode value: ${value}. Valid values: ${PERMISSION_MODES.join(', ')}`)); + process.exit(1); + } + options.permissionMode = value; + } else if (arg === '--permission-mode-updated-at') { + if (i + 1 >= args.length) { + console.error(chalk.red('Missing value for --permission-mode-updated-at (expected: unix ms timestamp)')); + process.exit(1); + } + const raw = args[++i]; + const parsedAt = Number(raw); + if (!Number.isFinite(parsedAt) || parsedAt <= 0) { + console.error(chalk.red(`Invalid --permission-mode-updated-at value: ${raw}. Expected a positive number (unix ms)`)); + process.exit(1); + } + options.permissionModeUpdatedAt = Math.floor(parsedAt); + } else if (arg === '--js-runtime') { + const runtime = args[++i]; + if (runtime !== 'node' && runtime !== 'bun') { + console.error(chalk.red(`Invalid --js-runtime value: ${runtime}. Must be 'node' or 'bun'`)); + process.exit(1); + } + options.jsRuntime = runtime; + } else if (arg === '--existing-session') { + // Used by daemon to reconnect to an existing session (for inactive session resume) + options.existingSessionId = args[++i]; + } else if (arg === '--claude-env') { + // Parse KEY=VALUE environment variable to pass to Claude + const envArg = args[++i]; + if (envArg && envArg.includes('=')) { + const eqIndex = envArg.indexOf('='); + const key = envArg.substring(0, eqIndex); + const value = envArg.substring(eqIndex + 1); + options.claudeEnvVars = options.claudeEnvVars || {}; + options.claudeEnvVars[key] = value; + } else { + console.error(chalk.red(`Invalid --claude-env format: ${envArg}. Expected KEY=VALUE`)); + process.exit(1); + } + } else { + unknownArgs.push(arg); + // Check if this arg expects a value (simplified check for common patterns) + if (i + 1 < args.length && !args[i + 1].startsWith('-')) { + unknownArgs.push(args[++i]); + } + } + } + + if (unknownArgs.length > 0) { + options.claudeArgs = [...(options.claudeArgs || []), ...unknownArgs]; + } + + if (showHelp) { + console.log(` +${chalk.bold('happy')} - Claude Code On the Go + +${chalk.bold('Usage:')} +\t happy [options] Start Claude with mobile control +\t happy auth Manage authentication +\t happy codex Start Codex mode +\t happy opencode Start OpenCode mode (ACP) +\t happy gemini Start Gemini mode (ACP) + happy connect Connect AI vendor API keys + happy notify Send push notification + happy daemon Manage background service that allows + to spawn new sessions away from your computer + happy doctor System diagnostics & troubleshooting + +${chalk.bold('Examples:')} + happy Start session + happy --yolo Start with bypassing permissions + happy sugar for --dangerously-skip-permissions + happy --js-runtime bun Use bun instead of node to spawn Claude Code + happy --claude-env ANTHROPIC_BASE_URL=http://127.0.0.1:3456 + Use a custom API endpoint (e.g., claude-code-router) + happy auth login --force Authenticate + happy doctor Run diagnostics + +${chalk.bold('Happy supports ALL Claude options!')} + Use any claude flag with happy as you would with claude. Our favorite: + + happy --resume + +${chalk.gray('─'.repeat(60))} +${chalk.bold.cyan('Claude Code Options (from `claude --help`):')} +`); + + // Run claude --help and display its output + try { + const claudeHelp = execFileSync(claudeCliPath, ['--help'], { encoding: 'utf8' }); + console.log(claudeHelp); + } catch { + console.log(chalk.yellow('Could not retrieve claude help. Make sure claude is installed.')); + } + + process.exit(0); + } + + if (showVersion) { + console.log(`happy version: ${packageJson.version}`); + // Don't exit - continue to pass --version to Claude Code + } + + const { credentials } = await authAndSetupMachineIfNeeded(); + + // Always auto-start daemon for simplicity + logger.debug('Ensuring Happy background service is running & matches our version...'); + + if (!(await isDaemonRunningCurrentlyInstalledHappyVersion())) { + logger.debug('Starting Happy background service...'); + + // Use the built binary to spawn daemon + const daemonProcess = spawnHappyCLI(['daemon', 'start-sync'], { + detached: true, + stdio: 'ignore', + env: process.env, + }); + daemonProcess.unref(); + + // Give daemon a moment to write PID & port file + await new Promise((resolve) => setTimeout(resolve, 200)); + } + + try { + options.terminalRuntime = context.terminalRuntime; + await runClaude(credentials, options); + } catch (error) { + console.error(chalk.red('Error:'), error instanceof Error ? error.message : 'Unknown error'); + if (process.env.DEBUG) { + console.error(error); + } + process.exit(1); + } +} diff --git a/cli/src/backends/claude/cli/detect.ts b/cli/src/backends/claude/cli/detect.ts new file mode 100644 index 000000000..0c1a8f28e --- /dev/null +++ b/cli/src/backends/claude/cli/detect.ts @@ -0,0 +1,7 @@ +import type { CliDetectSpec } from '@/backends/types'; + +export const cliDetect = { + versionArgsToTry: [['--version'], ['version']], + loginStatusArgs: null, +} satisfies CliDetectSpec; + diff --git a/cli/src/commands/connect/authenticateClaude.ts b/cli/src/backends/claude/cloud/authenticate.ts similarity index 90% rename from cli/src/commands/connect/authenticateClaude.ts rename to cli/src/backends/claude/cloud/authenticate.ts index 19c760ea6..77c444713 100644 --- a/cli/src/commands/connect/authenticateClaude.ts +++ b/cli/src/backends/claude/cloud/authenticate.ts @@ -6,9 +6,15 @@ */ import { createServer, IncomingMessage, ServerResponse } from 'http'; -import { randomBytes, createHash } from 'crypto'; -import { openBrowser } from '@/utils/browser'; -import { ClaudeAuthTokens, PKCECodes } from './types'; +import { randomBytes } from 'crypto'; +import { openBrowser } from '@/ui/openBrowser'; +import { generatePkceCodes } from '@/cloud/pkce'; + +export interface ClaudeAuthTokens { + raw: any; + token: string; + expires: number; +} // Anthropic OAuth Configuration for Claude.ai const CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e'; @@ -17,26 +23,6 @@ const TOKEN_URL = 'https://console.anthropic.com/v1/oauth/token'; const DEFAULT_PORT = 54545; const SCOPE = 'user:inference'; -/** - * Generate PKCE codes for OAuth flow - */ -function generatePKCE(): PKCECodes { - // Generate code verifier (43-128 characters, base64url) - const verifier = randomBytes(32) - .toString('base64url') - .replace(/[^a-zA-Z0-9\-._~]/g, ''); - - // Generate code challenge (SHA256 of verifier, base64url encoded) - const challenge = createHash('sha256') - .update(verifier) - .digest('base64url') - .replace(/=/g, '') - .replace(/\+/g, '-') - .replace(/\//g, '_'); - - return { verifier, challenge }; -} - /** * Generate random state for OAuth security */ @@ -206,9 +192,9 @@ async function startCallbackServer( */ export async function authenticateClaude(): Promise { console.log('🚀 Starting Anthropic Claude authentication...'); - + // Generate PKCE codes and state - const { verifier, challenge } = generatePKCE(); + const { verifier, challenge } = generatePkceCodes(); const state = generateState(); // Try to use default port, or find an available one @@ -266,4 +252,4 @@ export async function authenticateClaude(): Promise { console.error('\n❌ Failed to authenticate with Anthropic'); throw error; } -} \ No newline at end of file +} diff --git a/cli/src/backends/claude/cloud/connect.ts b/cli/src/backends/claude/cloud/connect.ts new file mode 100644 index 000000000..989ec9365 --- /dev/null +++ b/cli/src/backends/claude/cloud/connect.ts @@ -0,0 +1,12 @@ +import type { CloudConnectTarget } from '@/cloud/connectTypes'; +import { AGENTS_CORE } from '@happy/agents'; +import { authenticateClaude } from './authenticate'; + +export const claudeCloudConnect: CloudConnectTarget = { + id: 'claude', + displayName: 'Claude', + vendorDisplayName: 'Anthropic Claude', + vendorKey: AGENTS_CORE.claude.cloudConnect!.vendorKey, + status: AGENTS_CORE.claude.cloudConnect!.status, + authenticate: authenticateClaude, +}; diff --git a/cli/src/backends/claude/daemon/spawnHooks.ts b/cli/src/backends/claude/daemon/spawnHooks.ts new file mode 100644 index 000000000..53eb4faeb --- /dev/null +++ b/cli/src/backends/claude/daemon/spawnHooks.ts @@ -0,0 +1,10 @@ +import type { DaemonSpawnHooks } from '@/daemon/spawnHooks'; + +export const claudeDaemonSpawnHooks: DaemonSpawnHooks = { + buildAuthEnv: async ({ token }) => ({ + env: { CLAUDE_CODE_OAUTH_TOKEN: token }, + cleanupOnFailure: null, + cleanupOnExit: null, + }), +}; + diff --git a/cli/src/backends/claude/index.ts b/cli/src/backends/claude/index.ts new file mode 100644 index 000000000..3bf47a292 --- /dev/null +++ b/cli/src/backends/claude/index.ts @@ -0,0 +1,17 @@ +import { AGENTS_CORE } from '@happy/agents'; + +import type { AgentCatalogEntry } from '../types'; + +export const agent = { + id: AGENTS_CORE.claude.id, + cliSubcommand: AGENTS_CORE.claude.cliSubcommand, + getCliCommandHandler: async () => (await import('@/backends/claude/cli/command')).handleClaudeCliCommand, + getCliCapabilityOverride: async () => (await import('@/backends/claude/cli/capability')).cliCapability, + getCliDetect: async () => (await import('@/backends/claude/cli/detect')).cliDetect, + getCloudConnectTarget: async () => (await import('@/backends/claude/cloud/connect')).claudeCloudConnect, + getDaemonSpawnHooks: async () => (await import('@/backends/claude/daemon/spawnHooks')).claudeDaemonSpawnHooks, + vendorResumeSupport: AGENTS_CORE.claude.resume.vendorResume, + getHeadlessTmuxArgvTransform: async () => + (await import('@/backends/claude/terminal/headlessTmuxTransform')).claudeHeadlessTmuxArgvTransform, +} satisfies AgentCatalogEntry; + diff --git a/cli/src/backends/claude/loop.test.ts b/cli/src/backends/claude/loop.test.ts new file mode 100644 index 000000000..e8bd39f2a --- /dev/null +++ b/cli/src/backends/claude/loop.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it, vi } from 'vitest' +import { MessageQueue2 } from '@/utils/MessageQueue2' + +const mockClaudeLocalLauncher = vi.fn() +vi.mock('./claudeLocalLauncher', () => ({ + claudeLocalLauncher: mockClaudeLocalLauncher, +})) + +const mockClaudeRemoteLauncher = vi.fn() +vi.mock('./claudeRemoteLauncher', () => ({ + claudeRemoteLauncher: mockClaudeRemoteLauncher, +})) + +vi.mock('@/ui/logger', () => ({ + logger: { + debug: vi.fn(), + debugLargeJson: vi.fn(), + warn: vi.fn(), + logFilePath: '/tmp/happy-cli-test.log', + }, +})) + +describe('loop', () => { + it('updates Session.mode so keepAlive reports correct mode', async () => { + mockClaudeLocalLauncher.mockResolvedValueOnce('switch') + mockClaudeRemoteLauncher.mockResolvedValueOnce('exit') + + const keepAlive = vi.fn() + const client = { + keepAlive, + updateMetadata: vi.fn(), + } as any + + const messageQueue = new MessageQueue2(() => 'mode') + + const { loop } = await import('./loop') + + let capturedSession: any = null + await loop({ + path: '/tmp', + onModeChange: () => {}, + mcpServers: {}, + session: client, + api: {} as any, + messageQueue, + hookSettingsPath: '/tmp/hooks.json', + onSessionReady: (s: any) => { + capturedSession = s + }, + } as any) + + expect(keepAlive.mock.calls.some((call) => call[1] === 'remote')).toBe(true) + capturedSession?.cleanup() + }) +}) diff --git a/cli/src/claude/loop.ts b/cli/src/backends/claude/loop.ts similarity index 87% rename from cli/src/claude/loop.ts rename to cli/src/backends/claude/loop.ts index 36bbaef4d..0807b5e97 100644 --- a/cli/src/claude/loop.ts +++ b/cli/src/backends/claude/loop.ts @@ -14,6 +14,11 @@ import type { PermissionMode } from "@/api/types" export interface EnhancedMode { permissionMode: PermissionMode; + /** + * Stable id for the originating user message (when provided by the app), + * used for discard markers and reconciliation. + */ + localId?: string | null; model?: string; fallbackModel?: string; customSystemPrompt?: string; @@ -62,6 +67,9 @@ export async function loop(opts: LoopOptions) { jsRuntime: opts.jsRuntime }); + // Publish initial permission mode so the app can reflect it even before any app-driven message exists. + session.setLastPermissionMode(opts.permissionMode ?? 'default'); + // Notify that session is ready if (opts.onSessionReady) { opts.onSessionReady(session); @@ -80,9 +88,7 @@ export async function loop(opts: LoopOptions) { // Non "exit" reason means we need to switch to remote mode mode = 'remote'; - if (opts.onModeChange) { - opts.onModeChange(mode); - } + session.onModeChange(mode); continue; } @@ -95,9 +101,7 @@ export async function loop(opts: LoopOptions) { // Non "exit" reason means we need to switch to local mode mode = 'local'; - if (opts.onModeChange) { - opts.onModeChange(mode); - } + session.onModeChange(mode); continue; } } diff --git a/cli/src/claude/runClaude.ts b/cli/src/backends/claude/runClaude.ts similarity index 68% rename from cli/src/claude/runClaude.ts rename to cli/src/backends/claude/runClaude.ts index bcdd74fd5..0168da800 100644 --- a/cli/src/claude/runClaude.ts +++ b/cli/src/backends/claude/runClaude.ts @@ -3,30 +3,35 @@ import { randomUUID } from 'node:crypto'; import { ApiClient } from '@/api/api'; import { logger } from '@/ui/logger'; -import { loop } from '@/claude/loop'; -import { AgentState, Metadata } from '@/api/types'; -import packageJson from '../../package.json'; +import { loop } from '@/backends/claude/loop'; +import { AgentState, Metadata, Session as ApiSession } from '@/api/types'; +import packageJson from '../../../package.json'; import { Credentials, readSettings } from '@/persistence'; import { EnhancedMode, PermissionMode } from './loop'; import { MessageQueue2 } from '@/utils/MessageQueue2'; import { hashObject } from '@/utils/deterministicJson'; -import { startCaffeinate, stopCaffeinate } from '@/utils/caffeinate'; -import { extractSDKMetadataAsync } from '@/claude/sdk/metadataExtractor'; -import { parseSpecialCommand } from '@/parsers/specialCommands'; +import { startCaffeinate, stopCaffeinate } from '@/integrations/caffeinate'; +import { extractSDKMetadataAsync } from '@/backends/claude/sdk/metadataExtractor'; +import { parseSpecialCommand } from '@/cli/parsers/specialCommands'; import { getEnvironmentInfo } from '@/ui/doctor'; import { configuration } from '@/configuration'; -import { notifyDaemonSessionStarted } from '@/daemon/controlClient'; import { initialMachineMetadata } from '@/daemon/run'; -import { startHappyServer } from '@/claude/utils/startHappyServer'; -import { startHookServer } from '@/claude/utils/startHookServer'; -import { generateHookSettingsFile, cleanupHookSettingsFile } from '@/claude/utils/generateHookSettings'; -import { registerKillSessionHandler } from './registerKillSessionHandler'; -import { projectPath } from '../projectPath'; +import { startHappyServer } from '@/mcp/startHappyServer'; +import { startHookServer } from '@/backends/claude/utils/startHookServer'; +import { generateHookSettingsFile, cleanupHookSettingsFile } from '@/backends/claude/utils/generateHookSettings'; +import { registerKillSessionHandler } from '@/rpc/handlers/killSession'; +import { projectPath } from '../../projectPath'; import { resolve } from 'node:path'; -import { startOfflineReconnection, connectionState } from '@/utils/serverConnectionErrors'; -import { claudeLocal } from '@/claude/claudeLocal'; -import { createSessionScanner } from '@/claude/utils/sessionScanner'; +import { startOfflineReconnection, connectionState } from '@/api/offline/serverConnectionErrors'; +import { claudeLocal } from '@/backends/claude/claudeLocal'; +import { createSessionScanner } from '@/backends/claude/utils/sessionScanner'; import { Session } from './session'; +import type { TerminalRuntimeFlags } from '@/terminal/terminalRuntimeFlags'; +import { buildTerminalMetadataFromRuntimeFlags } from '@/terminal/terminalMetadata'; +import { persistTerminalAttachmentInfoIfNeeded, reportSessionToDaemonIfRunning, sendTerminalFallbackMessageIfNeeded } from '@/agent/runtime/startupSideEffects'; +import { applyStartupMetadataUpdateToSession, buildPermissionModeOverride } from '@/agent/runtime/startupMetadataUpdate'; +import { createBaseSessionForAttach } from '@/agent/runtime/createBaseSessionForAttach'; +import { createSessionMetadata } from '@/agent/runtime/createSessionMetadata'; /** JavaScript runtime to use for spawning Claude Code */ export type JsRuntime = 'node' | 'bun' @@ -41,6 +46,46 @@ export interface StartOptions { startedBy?: 'daemon' | 'terminal' /** JavaScript runtime to use for spawning Claude Code (default: 'node') */ jsRuntime?: JsRuntime + /** Internal terminal runtime flags passed by the spawner (daemon/tmux wrapper). */ + terminalRuntime?: TerminalRuntimeFlags | null + /** + * Optional timestamp for permissionMode (ms). Used to order explicit UI selections across devices. + * When omitted, the runner falls back to local time when publishing a mode. + */ + permissionModeUpdatedAt?: number + /** + * Existing Happy session ID to reconnect to. + * When set, the CLI will connect to this session instead of creating a new one. + * Used for resuming inactive sessions. + */ + existingSessionId?: string +} + +function inferPermissionModeFromClaudeArgs(args?: string[]): PermissionMode | undefined { + const input = args ?? []; + let inferred: PermissionMode | undefined; + + for (let i = 0; i < input.length; i++) { + const arg = input[i]; + + if (arg === '--dangerously-skip-permissions') { + inferred = 'bypassPermissions'; + continue; + } + + if (arg === '--permission-mode') { + const next = i + 1 < input.length ? input[i + 1] : undefined; + if (next && !next.startsWith('-')) { + if (next === 'default' || next === 'acceptEdits' || next === 'bypassPermissions' || next === 'plan') { + inferred = next as PermissionMode; + } + i++; // consume value + } + continue; + } + } + + return inferred; } export async function runClaude(credentials: Credentials, options: StartOptions = {}): Promise { @@ -65,9 +110,6 @@ export async function runClaude(credentials: Credentials, options: StartOptions // Create session service const api = await ApiClient.create(credentials); - // Create a new session - let state: AgentState = {}; - // Get machine ID from settings (should already be set up) const settings = await readSettings(); let machineId = settings?.machineId @@ -83,91 +125,114 @@ export async function runClaude(credentials: Credentials, options: StartOptions metadata: initialMachineMetadata }); - let metadata: Metadata = { - path: workingDirectory, - host: os.hostname(), - version: packageJson.version, - os: os.platform(), - machineId: machineId, - homeDir: os.homedir(), - happyHomeDir: configuration.happyHomeDir, - happyLibDir: projectPath(), - happyToolsDir: resolve(projectPath(), 'tools', 'unpacked'), - startedFromDaemon: options.startedBy === 'daemon', - hostPid: process.pid, - startedBy: options.startedBy || 'terminal', - // Initialize lifecycle state - lifecycleState: 'running', - lifecycleStateSince: Date.now(), - flavor: 'claude' - }; - const response = await api.getOrCreateSession({ tag: sessionTag, metadata, state }); - - // Handle server unreachable case - run Claude locally with hot reconnection - // Note: connectionState.notifyOffline() was already called by api.ts with error details - if (!response) { - let offlineSessionId: string | null = null; - - const reconnection = startOfflineReconnection({ - serverUrl: configuration.serverUrl, - onReconnected: async () => { - const resp = await api.getOrCreateSession({ tag: randomUUID(), metadata, state }); - if (!resp) throw new Error('Server unavailable'); - const session = api.sessionSyncClient(resp); - const scanner = await createSessionScanner({ + const terminal = buildTerminalMetadataFromRuntimeFlags(options.terminalRuntime ?? null); + // Resolve initial permission mode for sessions that start in terminal local mode. + // This is important because there may be no app-sent user messages yet (no meta.permissionMode to infer from). + const explicitPermissionMode = options.permissionMode; + const explicitPermissionModeUpdatedAt = options.permissionModeUpdatedAt; + const initialPermissionMode = options.permissionMode ?? inferPermissionModeFromClaudeArgs(options.claudeArgs) ?? 'default'; + options.permissionMode = initialPermissionMode; + + const { state, metadata } = createSessionMetadata({ + flavor: 'claude', + machineId, + directory: workingDirectory, + startedBy: options.startedBy, + terminalRuntime: options.terminalRuntime ?? null, + permissionMode: initialPermissionMode, + permissionModeUpdatedAt: typeof explicitPermissionModeUpdatedAt === 'number' ? explicitPermissionModeUpdatedAt : Date.now(), + }); + + // Handle existing session (for inactive session resume) vs new session. + let baseSession: ApiSession; + if (options.existingSessionId) { + logger.debug(`[START] Resuming existing session: ${options.existingSessionId}`); + baseSession = await createBaseSessionForAttach({ + existingSessionId: options.existingSessionId, + metadata, + state, + }); + } else { + const response = await api.getOrCreateSession({ tag: sessionTag, metadata, state }); + + // Handle server unreachable case - run Claude locally with hot reconnection + // Note: connectionState.notifyOffline() was already called by api.ts with error details + if (!response) { + let offlineSessionId: string | null = null; + + const reconnection = startOfflineReconnection({ + serverUrl: configuration.serverUrl, + onReconnected: async () => { + const resp = await api.getOrCreateSession({ tag: randomUUID(), metadata, state }); + if (!resp) throw new Error('Server unavailable'); + const session = api.sessionSyncClient(resp); + const scanner = await createSessionScanner({ + sessionId: null, + workingDirectory, + onMessage: (msg) => session.sendClaudeSessionMessage(msg) + }); + if (offlineSessionId) scanner.onNewSession(offlineSessionId); + return { session, scanner }; + }, + onNotify: console.log, + onCleanup: () => { + // Scanner cleanup handled automatically when process exits + } + }); + + const abortController = new AbortController(); + const abortOnSignal = () => abortController.abort(); + process.once('SIGINT', abortOnSignal); + process.once('SIGTERM', abortOnSignal); + + try { + await claudeLocal({ + path: workingDirectory, sessionId: null, - workingDirectory, - onMessage: (msg) => session.sendClaudeSessionMessage(msg) + onSessionFound: (id) => { offlineSessionId = id; }, + onThinkingChange: () => {}, + abort: abortController.signal, + claudeEnvVars: options.claudeEnvVars, + claudeArgs: options.claudeArgs, + mcpServers: {}, + allowedTools: [] }); - if (offlineSessionId) scanner.onNewSession(offlineSessionId); - return { session, scanner }; - }, - onNotify: console.log, - onCleanup: () => { - // Scanner cleanup handled automatically when process exits + } finally { + process.removeListener('SIGINT', abortOnSignal); + process.removeListener('SIGTERM', abortOnSignal); + reconnection.cancel(); + stopCaffeinate(); } - }); - - try { - await claudeLocal({ - path: workingDirectory, - sessionId: null, - onSessionFound: (id) => { offlineSessionId = id; }, - onThinkingChange: () => {}, - abort: new AbortController().signal, - claudeEnvVars: options.claudeEnvVars, - claudeArgs: options.claudeArgs, - mcpServers: {}, - allowedTools: [] - }); - } finally { - reconnection.cancel(); - stopCaffeinate(); + process.exit(0); } - process.exit(0); + + baseSession = response; + logger.debug(`Session created: ${baseSession.id}`); } - logger.debug(`Session created: ${response.id}`); + // Create realtime session + const session = api.sessionSyncClient(baseSession); + // Mark the session as active and refresh metadata on startup. + applyStartupMetadataUpdateToSession({ + session, + next: metadata, + nowMs: Date.now(), + permissionModeOverride: buildPermissionModeOverride({ + permissionMode: explicitPermissionMode, + permissionModeUpdatedAt: explicitPermissionModeUpdatedAt, + }), + }); - // Always report to daemon if it exists - try { - logger.debug(`[START] Reporting session ${response.id} to daemon`); - const result = await notifyDaemonSessionStarted(response.id, metadata); - if (result.error) { - logger.debug(`[START] Failed to report to daemon (may not be running):`, result.error); - } else { - logger.debug(`[START] Reported session ${response.id} to daemon`); - } - } catch (error) { - logger.debug('[START] Failed to report to daemon (may not be running):', error); - } + await persistTerminalAttachmentInfoIfNeeded({ sessionId: baseSession.id, terminal }); + sendTerminalFallbackMessageIfNeeded({ session, terminal }); + await reportSessionToDaemonIfRunning({ sessionId: baseSession.id, metadata }); // Extract SDK metadata in background and update session when ready extractSDKMetadataAsync(async (sdkMetadata) => { logger.debug('[start] SDK metadata extracted, updating session:', sdkMetadata); try { // Update session metadata with tools and slash commands - api.sessionSyncClient(response).updateMetadata((currentMetadata) => ({ + session.updateMetadata((currentMetadata) => ({ ...currentMetadata, tools: sdkMetadata.tools, slashCommands: sdkMetadata.slashCommands @@ -178,9 +243,6 @@ export async function runClaude(credentials: Credentials, options: StartOptions } }); - // Create realtime session - const session = api.sessionSyncClient(response); - // Start Happy MCP server const happyServer = await startHappyServer(session); logger.debug(`[START] Happy MCP server started at ${happyServer.url}`); @@ -199,8 +261,8 @@ export async function runClaude(credentials: Credentials, options: StartOptions const previousSessionId = currentSession.sessionId; if (previousSessionId !== sessionId) { logger.debug(`[START] Claude session ID changed: ${previousSessionId} -> ${sessionId}`); - currentSession.onSessionFound(sessionId); } + currentSession.onSessionFound(sessionId, data); } } }); @@ -212,13 +274,17 @@ export async function runClaude(credentials: Credentials, options: StartOptions // Print log file path const logPath = logger.logFilePath; - logger.infoDeveloper(`Session: ${response.id}`); + logger.infoDeveloper(`Session: ${baseSession.id}`); logger.infoDeveloper(`Logs: ${logPath}`); // Set initial agent state session.updateAgentState((currentState) => ({ ...currentState, - controlledByUser: options.startingMode !== 'remote' + controlledByUser: options.startingMode !== 'remote', + capabilities: { + ...(currentState.capabilities && typeof currentState.capabilities === 'object' ? currentState.capabilities : {}), + askUserQuestionAnswersInPermission: true, + }, })); // Start caffeinate to prevent sleep on macOS @@ -240,7 +306,7 @@ export async function runClaude(credentials: Credentials, options: StartOptions // Forward messages to the queue // Permission modes: Use the unified 7-mode type, mapping happens at SDK boundary in claudeRemote.ts - let currentPermissionMode: PermissionMode | undefined = options.permissionMode; + let currentPermissionMode: PermissionMode = options.permissionMode ?? 'default'; let currentModel = options.model; // Track current model state let currentFallbackModel: string | undefined = undefined; // Track current fallback model let currentCustomSystemPrompt: string | undefined = undefined; // Track current custom system prompt @@ -326,6 +392,7 @@ export async function runClaude(credentials: Credentials, options: StartOptions logger.debug('[start] Detected /compact command'); const enhancedMode: EnhancedMode = { permissionMode: messagePermissionMode || 'default', + localId: message.localId ?? null, model: messageModel, fallbackModel: messageFallbackModel, customSystemPrompt: messageCustomSystemPrompt, @@ -342,6 +409,7 @@ export async function runClaude(credentials: Credentials, options: StartOptions logger.debug('[start] Detected /clear command'); const enhancedMode: EnhancedMode = { permissionMode: messagePermissionMode || 'default', + localId: message.localId ?? null, model: messageModel, fallbackModel: messageFallbackModel, customSystemPrompt: messageCustomSystemPrompt, @@ -350,13 +418,14 @@ export async function runClaude(credentials: Credentials, options: StartOptions disallowedTools: messageDisallowedTools }; messageQueue.pushIsolateAndClear(specialCommand.originalMessage || message.content.text, enhancedMode); - logger.debugLargeJson('[start] /compact command pushed to queue:', message); + logger.debugLargeJson('[start] /clear command pushed to queue:', message); return; } // Push with resolved permission mode, model, system prompts, and tools const enhancedMode: EnhancedMode = { permissionMode: messagePermissionMode || 'default', + localId: message.localId ?? null, model: messageModel, fallbackModel: messageFallbackModel, customSystemPrompt: messageCustomSystemPrompt, @@ -382,7 +451,6 @@ export async function runClaude(credentials: Credentials, options: StartOptions archivedBy: 'cli', archiveReason: 'User terminated' })); - // Cleanup session resources (intervals, callbacks) currentSession?.cleanup(); @@ -490,4 +558,4 @@ export async function runClaude(credentials: Credentials, options: StartOptions // Exit process.exit(0); -} \ No newline at end of file +} diff --git a/cli/src/claude/sdk/index.ts b/cli/src/backends/claude/sdk/index.ts similarity index 100% rename from cli/src/claude/sdk/index.ts rename to cli/src/backends/claude/sdk/index.ts diff --git a/cli/src/claude/sdk/metadataExtractor.ts b/cli/src/backends/claude/sdk/metadataExtractor.ts similarity index 100% rename from cli/src/claude/sdk/metadataExtractor.ts rename to cli/src/backends/claude/sdk/metadataExtractor.ts diff --git a/cli/src/claude/sdk/query.ts b/cli/src/backends/claude/sdk/query.ts similarity index 100% rename from cli/src/claude/sdk/query.ts rename to cli/src/backends/claude/sdk/query.ts diff --git a/cli/src/claude/sdk/stream.ts b/cli/src/backends/claude/sdk/stream.ts similarity index 100% rename from cli/src/claude/sdk/stream.ts rename to cli/src/backends/claude/sdk/stream.ts diff --git a/cli/src/claude/sdk/types.ts b/cli/src/backends/claude/sdk/types.ts similarity index 94% rename from cli/src/claude/sdk/types.ts rename to cli/src/backends/claude/sdk/types.ts index de4cba7c3..41b0d1c4a 100644 --- a/cli/src/claude/sdk/types.ts +++ b/cli/src/backends/claude/sdk/types.ts @@ -15,7 +15,7 @@ export interface SDKMessage { export interface SDKUserMessage extends SDKMessage { type: 'user' - parent_tool_use_id?: string + parent_tool_use_id?: string | null message: { role: 'user' content: string | Array<{ @@ -30,7 +30,7 @@ export interface SDKUserMessage extends SDKMessage { export interface SDKAssistantMessage extends SDKMessage { type: 'assistant' - parent_tool_use_id?: string + parent_tool_use_id?: string | null message: { role: 'assistant' content: Array<{ @@ -142,6 +142,11 @@ export type PermissionResult = { } | { behavior: 'deny' message: string + /** + * When true, interrupts the current execution after denying the tool call. + * This matches the Claude Agent SDK permission result schema. + */ + interrupt?: boolean } /** @@ -195,4 +200,4 @@ export class AbortError extends Error { super(message) this.name = 'AbortError' } -} \ No newline at end of file +} diff --git a/cli/src/claude/sdk/utils.ts b/cli/src/backends/claude/sdk/utils.ts similarity index 100% rename from cli/src/claude/sdk/utils.ts rename to cli/src/backends/claude/sdk/utils.ts diff --git a/cli/src/backends/claude/session.test.ts b/cli/src/backends/claude/session.test.ts new file mode 100644 index 000000000..507700eeb --- /dev/null +++ b/cli/src/backends/claude/session.test.ts @@ -0,0 +1,242 @@ +import { describe, expect, it, vi } from 'vitest'; +import { Session } from './session'; +import { MessageQueue2 } from '@/utils/MessageQueue2'; + +describe('Session', () => { + it('does not bump permissionModeUpdatedAt when permission mode does not change', () => { + const metadataUpdates: any[] = []; + const client = { + keepAlive: vi.fn(), + updateMetadata: vi.fn((updater: (current: any) => any) => { + metadataUpdates.push(updater({})); + }), + } as any; + + const session = new Session({ + api: {} as any, + client, + path: '/tmp', + logPath: '/tmp/log', + sessionId: null, + mcpServers: {}, + messageQueue: new MessageQueue2(() => 'mode'), + onModeChange: () => {}, + hookSettingsPath: '/tmp/hooks.json', + }); + + try { + session.setLastPermissionMode('default', 111); + session.setLastPermissionMode('default', 222); + session.setLastPermissionMode('plan', 333); + session.setLastPermissionMode('plan', 444); + + expect(metadataUpdates).toEqual([ + { permissionMode: 'plan', permissionModeUpdatedAt: 333 }, + ]); + } finally { + session.cleanup(); + } + }); + + it('notifies sessionFound callbacks with transcriptPath when provided', () => { + let metadata: any = {}; + + const client = { + keepAlive: vi.fn(), + updateMetadata: (updater: (current: any) => any) => { + metadata = updater(metadata); + } + } as any; + + const session = new Session({ + api: {} as any, + client, + path: '/tmp', + logPath: '/tmp/log', + sessionId: null, + mcpServers: {}, + messageQueue: new MessageQueue2(() => 'mode'), + onModeChange: () => {}, + hookSettingsPath: '/tmp/hooks.json', + }); + + try { + const events: any[] = []; + (session as any).addSessionFoundCallback((info: any) => events.push(info)); + + (session as any).onSessionFound('sess_1', { transcript_path: '/tmp/sess_1.jsonl' }); + + expect(metadata.claudeSessionId).toBe('sess_1'); + expect(events).toEqual([{ sessionId: 'sess_1', transcriptPath: '/tmp/sess_1.jsonl' }]); + } finally { + session.cleanup(); + } + }); + + it('does not carry over transcriptPath when sessionId changes and hook lacks transcriptPath', () => { + let metadata: any = {}; + + const client = { + keepAlive: vi.fn(), + updateMetadata: (updater: (current: any) => any) => { + metadata = updater(metadata); + } + } as any; + + const session = new Session({ + api: {} as any, + client, + path: '/tmp', + logPath: '/tmp/log', + sessionId: null, + mcpServers: {}, + messageQueue: new MessageQueue2(() => 'mode'), + onModeChange: () => {}, + hookSettingsPath: '/tmp/hooks.json', + }); + + try { + const events: any[] = []; + (session as any).addSessionFoundCallback((info: any) => events.push(info)); + + (session as any).onSessionFound('sess_1', { transcript_path: '/tmp/sess_1.jsonl' }); + (session as any).onSessionFound('sess_2'); + (session as any).onSessionFound('sess_2', { transcript_path: '/tmp/sess_2.jsonl' }); + + expect(metadata.claudeSessionId).toBe('sess_2'); + expect(events).toEqual([ + { sessionId: 'sess_1', transcriptPath: '/tmp/sess_1.jsonl' }, + { sessionId: 'sess_2', transcriptPath: null }, + { sessionId: 'sess_2', transcriptPath: '/tmp/sess_2.jsonl' }, + ]); + } finally { + session.cleanup(); + } + }); + + it('clearSessionId clears transcriptPath as well', () => { + const client = { + keepAlive: vi.fn(), + updateMetadata: vi.fn(), + } as any; + + const session = new Session({ + api: {} as any, + client, + path: '/tmp', + logPath: '/tmp/log', + sessionId: null, + mcpServers: {}, + messageQueue: new MessageQueue2(() => 'mode'), + onModeChange: () => { }, + hookSettingsPath: '/tmp/hooks.json', + }); + + try { + session.onSessionFound('sess_1', { transcript_path: '/tmp/sess_1.jsonl' } as any); + expect(session.sessionId).toBe('sess_1'); + expect(session.transcriptPath).toBe('/tmp/sess_1.jsonl'); + + session.clearSessionId(); + + expect(session.sessionId).toBeNull(); + expect(session.transcriptPath).toBeNull(); + } finally { + session.cleanup(); + } + }); + + it('consumeOneTimeFlags consumes short -c and -r flags', () => { + const client = { + keepAlive: vi.fn(), + updateMetadata: vi.fn(), + } as any; + + const session = new Session({ + api: {} as any, + client, + path: '/tmp', + logPath: '/tmp/log', + sessionId: null, + claudeArgs: ['-c', '-r', 'abc-123', '--foo', 'bar'], + mcpServers: {}, + messageQueue: new MessageQueue2(() => 'mode'), + onModeChange: () => { }, + hookSettingsPath: '/tmp/hooks.json', + }); + + try { + session.consumeOneTimeFlags(); + expect(session.claudeArgs).toEqual(['--foo', 'bar']); + } finally { + session.cleanup(); + } + }); + + it('emits ACP task lifecycle events when thinking toggles', () => { + const client = { + keepAlive: vi.fn(), + updateMetadata: vi.fn(), + sendAgentMessage: vi.fn(), + } as any; + + const session = new Session({ + api: {} as any, + client, + path: '/tmp', + logPath: '/tmp/log', + sessionId: null, + mcpServers: {}, + messageQueue: new MessageQueue2(() => 'mode'), + onModeChange: () => { }, + hookSettingsPath: '/tmp/hooks.json', + }); + + try { + session.onThinkingChange(true); + expect(client.sendAgentMessage).toHaveBeenCalledTimes(1); + const [provider1, payload1] = client.sendAgentMessage.mock.calls[0]; + expect(provider1).toBe('claude'); + expect(payload1?.type).toBe('task_started'); + expect(typeof payload1?.id).toBe('string'); + + session.onThinkingChange(true); + expect(client.sendAgentMessage).toHaveBeenCalledTimes(1); + + session.onThinkingChange(false); + expect(client.sendAgentMessage).toHaveBeenCalledTimes(2); + const [provider2, payload2] = client.sendAgentMessage.mock.calls[1]; + expect(provider2).toBe('claude'); + expect(payload2).toEqual({ type: 'task_complete', id: payload1.id }); + } finally { + session.cleanup(); + } + }); + + it('does not emit orphan ACP task_complete events', () => { + const client = { + keepAlive: vi.fn(), + updateMetadata: vi.fn(), + sendAgentMessage: vi.fn(), + } as any; + + const session = new Session({ + api: {} as any, + client, + path: '/tmp', + logPath: '/tmp/log', + sessionId: null, + mcpServers: {}, + messageQueue: new MessageQueue2(() => 'mode'), + onModeChange: () => { }, + hookSettingsPath: '/tmp/hooks.json', + }); + + try { + session.onThinkingChange(false); + expect(client.sendAgentMessage).not.toHaveBeenCalled(); + } finally { + session.cleanup(); + } + }); +}); diff --git a/cli/src/backends/claude/session.ts b/cli/src/backends/claude/session.ts new file mode 100644 index 000000000..708aafad2 --- /dev/null +++ b/cli/src/backends/claude/session.ts @@ -0,0 +1,313 @@ +import { ApiClient, ApiSessionClient } from "@/lib"; +import { MessageQueue2 } from "@/utils/MessageQueue2"; +import { EnhancedMode } from "./loop"; +import { logger } from "@/ui/logger"; +import type { JsRuntime } from "./runClaude"; +import type { SessionHookData } from "./utils/startHookServer"; +import type { PermissionMode } from "@/api/types"; +import { randomUUID } from "node:crypto"; + +export type SessionFoundInfo = { + sessionId: string; + transcriptPath: string | null; +}; + +export class Session { + readonly path: string; + readonly logPath: string; + readonly api: ApiClient; + readonly client: ApiSessionClient; + readonly queue: MessageQueue2; + readonly claudeEnvVars?: Record; + claudeArgs?: string[]; // Made mutable to allow filtering + readonly mcpServers: Record; + readonly allowedTools?: string[]; + readonly _onModeChange: (mode: 'local' | 'remote') => void; + /** Path to temporary settings file with SessionStart hook (required for session tracking) */ + readonly hookSettingsPath: string; + /** JavaScript runtime to use for spawning Claude Code (default: 'node') */ + readonly jsRuntime: JsRuntime; + + sessionId: string | null; + transcriptPath: string | null = null; + mode: 'local' | 'remote' = 'local'; + thinking: boolean = false; + private currentTaskId: string | null = null; + + /** + * Last known permission mode for this session, derived from message metadata / permission responses. + * Used to carry permission settings across remote ↔ local mode switches. + */ + lastPermissionMode: PermissionMode = 'default'; + lastPermissionModeUpdatedAt: number = 0; + + /** Callbacks to be notified when session ID is found/changed */ + private sessionFoundCallbacks: ((info: SessionFoundInfo) => void)[] = []; + + /** Keep alive interval reference for cleanup */ + private keepAliveInterval: NodeJS.Timeout; + + constructor(opts: { + api: ApiClient, + client: ApiSessionClient, + path: string, + logPath: string, + sessionId: string | null, + claudeEnvVars?: Record, + claudeArgs?: string[], + mcpServers: Record, + messageQueue: MessageQueue2, + onModeChange: (mode: 'local' | 'remote') => void, + allowedTools?: string[], + /** Path to temporary settings file with SessionStart hook (required for session tracking) */ + hookSettingsPath: string, + /** JavaScript runtime to use for spawning Claude Code (default: 'node') */ + jsRuntime?: JsRuntime, + }) { + this.path = opts.path; + this.api = opts.api; + this.client = opts.client; + this.logPath = opts.logPath; + this.sessionId = opts.sessionId; + this.queue = opts.messageQueue; + this.claudeEnvVars = opts.claudeEnvVars; + this.claudeArgs = opts.claudeArgs; + this.mcpServers = opts.mcpServers; + this.allowedTools = opts.allowedTools; + this._onModeChange = opts.onModeChange; + this.hookSettingsPath = opts.hookSettingsPath; + this.jsRuntime = opts.jsRuntime ?? 'node'; + + // Start keep alive + this.client.keepAlive(this.thinking, this.mode); + this.keepAliveInterval = setInterval(() => { + this.client.keepAlive(this.thinking, this.mode); + }, 2000); + } + + /** + * Cleanup resources (call when session is no longer needed) + */ + cleanup = (): void => { + clearInterval(this.keepAliveInterval); + this.sessionFoundCallbacks = []; + logger.debug('[Session] Cleaned up resources'); + } + + setLastPermissionMode = (mode: PermissionMode, updatedAt: number = Date.now()): void => { + if (mode === this.lastPermissionMode) { + return; + } + this.lastPermissionMode = mode; + this.lastPermissionModeUpdatedAt = updatedAt; + this.client.updateMetadata((metadata) => ({ + ...metadata, + permissionMode: mode, + permissionModeUpdatedAt: updatedAt + })); + } + + onThinkingChange = (thinking: boolean) => { + const wasThinking = this.thinking; + this.thinking = thinking; + this.client.keepAlive(thinking, this.mode); + + if (wasThinking === thinking) { + return; + } + + if (thinking) { + const id = randomUUID(); + this.currentTaskId = id; + this.client.sendAgentMessage('claude', { type: 'task_started', id }); + return; + } + + if (!this.currentTaskId) { + return; + } + + const id = this.currentTaskId; + this.currentTaskId = null; + this.client.sendAgentMessage('claude', { type: 'task_complete', id }); + } + + onModeChange = (mode: 'local' | 'remote') => { + this.mode = mode; + this.client.keepAlive(this.thinking, mode); + this._onModeChange(mode); + } + + /** + * Called when Claude session ID is discovered or changed. + * + * This is triggered by the SessionStart hook when: + * - Claude starts a new session (fresh start) + * - Claude resumes a session (--continue, --resume flags) + * - Claude forks a session (/compact, double-escape fork) + * + * Updates internal state, syncs to API metadata, and notifies + * all registered callbacks (e.g., SessionScanner) about the change. + */ + onSessionFound = (sessionId: string, hookData?: SessionHookData) => { + const nextTranscriptPathRaw = hookData?.transcript_path ?? hookData?.transcriptPath; + const nextTranscriptPath = typeof nextTranscriptPathRaw === 'string' ? nextTranscriptPathRaw : null; + + const prevSessionId = this.sessionId; + const prevTranscriptPath = this.transcriptPath; + + this.sessionId = sessionId; + if (prevSessionId !== sessionId) { + // Avoid carrying a transcript path across different Claude sessions. + // If the hook didn't provide a transcript path for this session, force fallback to heuristics. + this.transcriptPath = nextTranscriptPath; + } else if (nextTranscriptPath) { + // Same sessionId, but we learned/updated the exact transcript path. + this.transcriptPath = nextTranscriptPath; + } + + // Update metadata with Claude Code session ID + if (prevSessionId !== sessionId) { + this.client.updateMetadata((metadata) => ({ + ...metadata, + claudeSessionId: sessionId + })); + logger.debug(`[Session] Claude Code session ID ${sessionId} added to metadata`); + + } + + // Notify callbacks when either the sessionId changes or we learned a better transcript path. + const didTranscriptPathChange = Boolean(nextTranscriptPath) && nextTranscriptPath !== prevTranscriptPath; + if (prevSessionId === sessionId && !didTranscriptPathChange) { + return; + } + + const info: SessionFoundInfo = { + sessionId, + transcriptPath: this.transcriptPath + }; + + // Notify all registered callbacks + for (const callback of this.sessionFoundCallbacks) { + callback(info); + } + } + + /** + * Register a callback to be notified when session ID is found/changed + */ + addSessionFoundCallback = (callback: (info: SessionFoundInfo) => void): void => { + this.sessionFoundCallbacks.push(callback); + } + + /** + * Remove a session found callback + */ + removeSessionFoundCallback = (callback: (info: SessionFoundInfo) => void): void => { + const index = this.sessionFoundCallbacks.indexOf(callback); + if (index !== -1) { + this.sessionFoundCallbacks.splice(index, 1); + } + } + + /** + * Wait until we have a sessionId (and optionally a transcriptPath) from Claude hooks. + * Used to avoid switching modes before the session is actually initialized on disk. + */ + waitForSessionFound = async (opts: { timeoutMs?: number; requireTranscriptPath?: boolean } = {}): Promise => { + const timeoutMs = opts.timeoutMs ?? 2000; + const requireTranscriptPath = opts.requireTranscriptPath ?? false; + + const isReady = (): boolean => { + if (!this.sessionId) return false; + if (requireTranscriptPath && !this.transcriptPath) return false; + return true; + }; + + if (isReady()) { + return { sessionId: this.sessionId!, transcriptPath: this.transcriptPath }; + } + + return new Promise((resolve) => { + const onUpdate = () => { + if (!isReady()) return; + cleanup(); + resolve({ sessionId: this.sessionId!, transcriptPath: this.transcriptPath }); + }; + + const cleanup = () => { + clearTimeout(timeoutId); + this.removeSessionFoundCallback(onUpdate); + }; + + const timeoutId = setTimeout(() => { + cleanup(); + if (this.sessionId) { + resolve({ sessionId: this.sessionId, transcriptPath: this.transcriptPath }); + } else { + resolve(null); + } + }, timeoutMs); + + this.addSessionFoundCallback(onUpdate); + }); + } + + /** + * Clear the current session ID (used by /clear command) + */ + clearSessionId = (): void => { + this.sessionId = null; + this.transcriptPath = null; + logger.debug('[Session] Session ID cleared'); + } + + /** + * Consume one-time Claude flags from claudeArgs after Claude spawn + * Handles: --resume (with or without session ID), --continue + */ + consumeOneTimeFlags = (): void => { + if (!this.claudeArgs) return; + + const filteredArgs: string[] = []; + for (let i = 0; i < this.claudeArgs.length; i++) { + const arg = this.claudeArgs[i]; + + if (arg === '--continue' || arg === '-c') { + logger.debug('[Session] Consumed --continue flag'); + continue; + } + + if (arg === '--session-id') { + if (i + 1 < this.claudeArgs.length) { + const nextArg = this.claudeArgs[i + 1]; + if (!nextArg.startsWith('-')) { + i++; // Skip the value + logger.debug(`[Session] Consumed --session-id flag with value: ${nextArg}`); + } else { + logger.debug('[Session] Consumed --session-id flag (missing value)'); + } + } else { + logger.debug('[Session] Consumed --session-id flag (missing value)'); + } + continue; + } + + if (arg === '--resume' || arg === '-r') { + const nextArg = i + 1 < this.claudeArgs.length ? this.claudeArgs[i + 1] : undefined; + if (nextArg && !nextArg.startsWith('-')) { + i++; // Skip the value + logger.debug(`[Session] Consumed ${arg} flag with session ID: ${nextArg}`); + } else { + logger.debug(`[Session] Consumed ${arg} flag (no session ID)`); + } + continue; + } + + filteredArgs.push(arg); + } + + this.claudeArgs = filteredArgs.length > 0 ? filteredArgs : undefined; + logger.debug(`[Session] Consumed one-time flags, remaining args:`, this.claudeArgs); + } +} diff --git a/cli/src/backends/claude/terminal/headlessTmuxTransform.ts b/cli/src/backends/claude/terminal/headlessTmuxTransform.ts new file mode 100644 index 000000000..939ea4eab --- /dev/null +++ b/cli/src/backends/claude/terminal/headlessTmuxTransform.ts @@ -0,0 +1,7 @@ +import type { HeadlessTmuxArgvTransform } from '@/backends/types'; +import { ensureRemoteStartingModeArgs } from '@/terminal/headlessTmuxArgs'; + +export const claudeHeadlessTmuxArgvTransform: HeadlessTmuxArgvTransform = (argv) => { + return ensureRemoteStartingModeArgs(argv); +}; + diff --git a/cli/src/claude/types.ts b/cli/src/backends/claude/types.ts similarity index 80% rename from cli/src/claude/types.ts rename to cli/src/backends/claude/types.ts index a875bbc90..dd3d79dea 100644 --- a/cli/src/claude/types.ts +++ b/cli/src/backends/claude/types.ts @@ -5,14 +5,9 @@ import { z } from "zod"; -// Usage statistics for assistant messages - used in apiSession.ts -export const UsageSchema = z.object({ - input_tokens: z.number().int().nonnegative(), - cache_creation_input_tokens: z.number().int().nonnegative().optional(), - cache_read_input_tokens: z.number().int().nonnegative().optional(), - output_tokens: z.number().int().nonnegative(), - service_tier: z.string().optional(), -}).passthrough(); +import { UsageSchema } from "@/api/usage"; + +export { UsageSchema }; // Main schema with minimal validation for only the fields we use // NOTE: Schema is intentionally lenient to handle various Claude Code message formats diff --git a/cli/src/backends/claude/ui/RemoteModeDisplay.test.ts b/cli/src/backends/claude/ui/RemoteModeDisplay.test.ts new file mode 100644 index 000000000..3aac7ad86 --- /dev/null +++ b/cli/src/backends/claude/ui/RemoteModeDisplay.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; + +import { interpretRemoteModeKeypress } from './RemoteModeDisplay'; + +describe('RemoteModeDisplay input handling', () => { + it('switches immediately on Ctrl+T', () => { + const result = interpretRemoteModeKeypress({ confirmationMode: null, actionInProgress: null }, 't', { ctrl: true }); + expect(result.action).toBe('switch'); + }); + + it('requires double space to switch when using spacebar', () => { + const first = interpretRemoteModeKeypress({ confirmationMode: null, actionInProgress: null }, ' ', {}); + expect(first.action).toBe('confirm-switch'); + + const second = interpretRemoteModeKeypress({ confirmationMode: 'switch', actionInProgress: null }, ' ', {}); + expect(second.action).toBe('switch'); + }); +}); + diff --git a/cli/src/backends/claude/ui/RemoteModeDisplay.tsx b/cli/src/backends/claude/ui/RemoteModeDisplay.tsx new file mode 100644 index 000000000..1dfabc0d7 --- /dev/null +++ b/cli/src/backends/claude/ui/RemoteModeDisplay.tsx @@ -0,0 +1,259 @@ +/** + * RemoteModeDisplay + * + * Claude-specific terminal UI for “remote mode” sessions. + * Unlike Codex/Gemini/OpenCode read-only shells, this display supports switching back to local mode. + */ + +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { Box, Text, useInput, useStdout } from 'ink'; + +import { MessageBuffer, type BufferedMessage } from '@/ui/ink/messageBuffer'; + +export type RemoteModeConfirmation = 'exit' | 'switch' | null; +export type RemoteModeActionInProgress = 'exiting' | 'switching' | null; + +export type RemoteModeKeypressAction = + | 'none' + | 'reset' + | 'confirm-exit' + | 'confirm-switch' + | 'exit' + | 'switch'; + +export function interpretRemoteModeKeypress( + state: { confirmationMode: RemoteModeConfirmation; actionInProgress: RemoteModeActionInProgress }, + input: string, + key: { ctrl?: boolean; meta?: boolean; shift?: boolean } = {}, +): { action: RemoteModeKeypressAction } { + if (state.actionInProgress) return { action: 'none' }; + + if (key.ctrl && input === 'c') { + return { action: state.confirmationMode === 'exit' ? 'exit' : 'confirm-exit' }; + } + + // Ctrl-T: immediate switch to terminal (avoids “space spam” → buffered spaces) + if (key.ctrl && input === 't') { + return { action: 'switch' }; + } + + // Double-space confirmation for switching + if (input === ' ') { + return { action: state.confirmationMode === 'switch' ? 'switch' : 'confirm-switch' }; + } + + if (state.confirmationMode) { + return { action: 'reset' }; + } + + return { action: 'none' }; +} + +export type RemoteModeDisplayProps = { + messageBuffer: MessageBuffer; + logPath?: string; + onExit?: () => void; + onSwitchToLocal?: () => void; +}; + +export const RemoteModeDisplay: React.FC = ({ messageBuffer, logPath, onExit, onSwitchToLocal }) => { + const [messages, setMessages] = useState([]); + const [confirmationMode, setConfirmationMode] = useState(null); + const [actionInProgress, setActionInProgress] = useState(null); + const confirmationTimeoutRef = useRef(null); + const actionTimeoutRef = useRef(null); + const { stdout } = useStdout(); + const terminalWidth = stdout.columns || 80; + const terminalHeight = stdout.rows || 24; + + useEffect(() => { + setMessages(messageBuffer.getMessages()); + + const unsubscribe = messageBuffer.onUpdate((newMessages) => { + setMessages(newMessages); + }); + + return () => { + unsubscribe(); + if (confirmationTimeoutRef.current) { + clearTimeout(confirmationTimeoutRef.current); + } + if (actionTimeoutRef.current) { + clearTimeout(actionTimeoutRef.current); + } + }; + }, [messageBuffer]); + + const resetConfirmation = useCallback(() => { + setConfirmationMode(null); + if (confirmationTimeoutRef.current) { + clearTimeout(confirmationTimeoutRef.current); + confirmationTimeoutRef.current = null; + } + }, []); + + const setConfirmationWithTimeout = useCallback( + (mode: Exclude) => { + setConfirmationMode(mode); + if (confirmationTimeoutRef.current) { + clearTimeout(confirmationTimeoutRef.current); + } + confirmationTimeoutRef.current = setTimeout(() => resetConfirmation(), 15000); + }, + [resetConfirmation], + ); + + useInput( + useCallback( + (input, key) => { + const { action } = interpretRemoteModeKeypress({ confirmationMode, actionInProgress }, input, key as any); + if (action === 'none') return; + if (action === 'reset') { + resetConfirmation(); + return; + } + if (action === 'confirm-exit') { + setConfirmationWithTimeout('exit'); + return; + } + if (action === 'confirm-switch') { + setConfirmationWithTimeout('switch'); + return; + } + if (action === 'exit') { + resetConfirmation(); + setActionInProgress('exiting'); + if (actionTimeoutRef.current) { + clearTimeout(actionTimeoutRef.current); + } + actionTimeoutRef.current = setTimeout(() => onExit?.(), 100); + return; + } + if (action === 'switch') { + resetConfirmation(); + setActionInProgress('switching'); + if (actionTimeoutRef.current) { + clearTimeout(actionTimeoutRef.current); + } + actionTimeoutRef.current = setTimeout(() => onSwitchToLocal?.(), 100); + } + }, + [confirmationMode, actionInProgress, onExit, onSwitchToLocal, setConfirmationWithTimeout, resetConfirmation], + ), + ); + + const getMessageColor = (type: BufferedMessage['type']): string => { + switch (type) { + case 'user': + return 'magenta'; + case 'assistant': + return 'cyan'; + case 'system': + return 'blue'; + case 'tool': + return 'yellow'; + case 'result': + return 'green'; + case 'status': + return 'gray'; + default: + return 'white'; + } + }; + + const formatMessage = (msg: BufferedMessage): string => { + const lines = msg.content.split('\n'); + const maxLineLength = terminalWidth - 10; + return lines + .map((line) => { + if (line.length <= maxLineLength) return line; + const chunks: string[] = []; + for (let i = 0; i < line.length; i += maxLineLength) { + chunks.push(line.slice(i, i + maxLineLength)); + } + return chunks.join('\n'); + }) + .join('\n'); + }; + + return ( + + + + + 📡 Remote Mode - Claude Messages + + + {'─'.repeat(Math.min(terminalWidth - 4, 60))} + + + + + {messages.length === 0 ? ( + + Waiting for messages... + + ) : ( + messages.slice(-Math.max(1, terminalHeight - 10)).map((msg) => ( + + + {formatMessage(msg)} + + + )) + )} + + + + + + {actionInProgress === 'exiting' ? ( + + Exiting... + + ) : actionInProgress === 'switching' ? ( + + Switching to local mode... + + ) : confirmationMode === 'exit' ? ( + + ⚠️ Press Ctrl-C again to exit completely + + ) : confirmationMode === 'switch' ? ( + + ⏸️ Press space again (or Ctrl-T) to switch to local mode + + ) : ( + + 📱 Press space (or Ctrl-T) to switch to local mode • Ctrl-C to exit + + )} + {process.env.DEBUG && logPath && ( + + Debug logs: {logPath} + + )} + + + + ); +}; + diff --git a/cli/src/claude/utils/OutgoingMessageQueue.ts b/cli/src/backends/claude/utils/OutgoingMessageQueue.ts similarity index 100% rename from cli/src/claude/utils/OutgoingMessageQueue.ts rename to cli/src/backends/claude/utils/OutgoingMessageQueue.ts diff --git a/cli/src/claude/utils/__fixtures__/0-say-lol-session.jsonl b/cli/src/backends/claude/utils/__fixtures__/0-say-lol-session.jsonl similarity index 100% rename from cli/src/claude/utils/__fixtures__/0-say-lol-session.jsonl rename to cli/src/backends/claude/utils/__fixtures__/0-say-lol-session.jsonl diff --git a/cli/src/claude/utils/__fixtures__/1-continue-run-ls-tool.jsonl b/cli/src/backends/claude/utils/__fixtures__/1-continue-run-ls-tool.jsonl similarity index 100% rename from cli/src/claude/utils/__fixtures__/1-continue-run-ls-tool.jsonl rename to cli/src/backends/claude/utils/__fixtures__/1-continue-run-ls-tool.jsonl diff --git a/cli/src/claude/utils/__fixtures__/duplicate-assistant-response-2.jsonl b/cli/src/backends/claude/utils/__fixtures__/duplicate-assistant-response-2.jsonl similarity index 100% rename from cli/src/claude/utils/__fixtures__/duplicate-assistant-response-2.jsonl rename to cli/src/backends/claude/utils/__fixtures__/duplicate-assistant-response-2.jsonl diff --git a/cli/src/claude/utils/__fixtures__/duplicate-assistant-response.jsonl b/cli/src/backends/claude/utils/__fixtures__/duplicate-assistant-response.jsonl similarity index 100% rename from cli/src/claude/utils/__fixtures__/duplicate-assistant-response.jsonl rename to cli/src/backends/claude/utils/__fixtures__/duplicate-assistant-response.jsonl diff --git a/cli/src/claude/utils/__fixtures__/permission-prompt-aborted-with-interrupt.jsonl b/cli/src/backends/claude/utils/__fixtures__/permission-prompt-aborted-with-interrupt.jsonl similarity index 100% rename from cli/src/claude/utils/__fixtures__/permission-prompt-aborted-with-interrupt.jsonl rename to cli/src/backends/claude/utils/__fixtures__/permission-prompt-aborted-with-interrupt.jsonl diff --git a/cli/src/claude/utils/__fixtures__/task_non_sdk.jsonl b/cli/src/backends/claude/utils/__fixtures__/task_non_sdk.jsonl similarity index 100% rename from cli/src/claude/utils/__fixtures__/task_non_sdk.jsonl rename to cli/src/backends/claude/utils/__fixtures__/task_non_sdk.jsonl diff --git a/cli/src/claude/utils/__fixtures__/task_sdk.jsonl b/cli/src/backends/claude/utils/__fixtures__/task_sdk.jsonl similarity index 100% rename from cli/src/claude/utils/__fixtures__/task_sdk.jsonl rename to cli/src/backends/claude/utils/__fixtures__/task_sdk.jsonl diff --git a/cli/src/claude/utils/claudeCheckSession.test.ts b/cli/src/backends/claude/utils/claudeCheckSession.test.ts similarity index 91% rename from cli/src/claude/utils/claudeCheckSession.test.ts rename to cli/src/backends/claude/utils/claudeCheckSession.test.ts index a360354c3..482d5488e 100644 --- a/cli/src/claude/utils/claudeCheckSession.test.ts +++ b/cli/src/backends/claude/utils/claudeCheckSession.test.ts @@ -160,6 +160,19 @@ describe('claudeCheckSession', () => { expect(claudeCheckSession(sessionId, testDir)).toBe(true); }); + + it('should accept session when transcriptPath override points to valid file outside projectDir', () => { + const sessionId = '33333333-3333-3333-3333-333333333333'; + + const altDir = join(testDir, 'alt-project'); + mkdirSync(altDir, { recursive: true }); + + const transcriptPath = join(altDir, `${sessionId}.jsonl`); + writeFileSync(transcriptPath, JSON.stringify({ uuid: 'msg-override', type: 'user' }) + '\n'); + + // RED: current implementation ignores transcriptPath and checks only `${getProjectPath(path)}/${sessionId}.jsonl` + expect((claudeCheckSession as any)(sessionId, testDir, transcriptPath)).toBe(true); + }); }); describe('Mixed format sessions', () => { diff --git a/cli/src/claude/utils/claudeCheckSession.ts b/cli/src/backends/claude/utils/claudeCheckSession.ts similarity index 89% rename from cli/src/claude/utils/claudeCheckSession.ts rename to cli/src/backends/claude/utils/claudeCheckSession.ts index 384df15ec..f42aaf846 100644 --- a/cli/src/claude/utils/claudeCheckSession.ts +++ b/cli/src/backends/claude/utils/claudeCheckSession.ts @@ -3,11 +3,11 @@ import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { getProjectPath } from "./path"; -export function claudeCheckSession(sessionId: string, path: string) { +export function claudeCheckSession(sessionId: string, path: string, transcriptPath?: string | null) { const projectDir = getProjectPath(path); - // Check if session id is in the project dir - const sessionFile = join(projectDir, `${sessionId}.jsonl`); + // Prefer explicit transcript path (from Claude hook) over the project-dir heuristic. + const sessionFile = transcriptPath ?? join(projectDir, `${sessionId}.jsonl`); const sessionExists = existsSync(sessionFile); if (!sessionExists) { logger.debug(`[claudeCheckSession] Path ${sessionFile} does not exist`); @@ -38,4 +38,4 @@ export function claudeCheckSession(sessionId: string, path: string) { logger.debug(`[claudeCheckSession] Session ${sessionId}: ${hasGoodMessage ? 'valid' : 'invalid'}`); return hasGoodMessage; -} \ No newline at end of file +} diff --git a/cli/src/claude/utils/claudeFindLastSession.test.ts b/cli/src/backends/claude/utils/claudeFindLastSession.test.ts similarity index 100% rename from cli/src/claude/utils/claudeFindLastSession.test.ts rename to cli/src/backends/claude/utils/claudeFindLastSession.test.ts diff --git a/cli/src/claude/utils/claudeFindLastSession.ts b/cli/src/backends/claude/utils/claudeFindLastSession.ts similarity index 87% rename from cli/src/claude/utils/claudeFindLastSession.ts rename to cli/src/backends/claude/utils/claudeFindLastSession.ts index efcf5698c..e1818be04 100644 --- a/cli/src/claude/utils/claudeFindLastSession.ts +++ b/cli/src/backends/claude/utils/claudeFindLastSession.ts @@ -13,9 +13,9 @@ import { logger } from '@/ui/logger'; * Note: Agent sessions (agent-*) are excluded because --resume only accepts UUID format. * Returns the session ID (filename without .jsonl extension) or null if no valid sessions found. */ -export function claudeFindLastSession(workingDirectory: string): string | null { +export function claudeFindLastSession(workingDirectory: string, claudeConfigDirOverride?: string | null): string | null { try { - const projectDir = getProjectPath(workingDirectory); + const projectDir = getProjectPath(workingDirectory, claudeConfigDirOverride); // UUID format pattern (8-4-4-4-12 hex digits) const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; @@ -32,7 +32,8 @@ export function claudeFindLastSession(workingDirectory: string): string | null { } // Check if this is a valid session (has messages with uuid field) - if (claudeCheckSession(sessionId, workingDirectory)) { + const transcriptPath = join(projectDir, f); + if (claudeCheckSession(sessionId, workingDirectory, transcriptPath)) { return { name: f, sessionId: sessionId, @@ -49,4 +50,4 @@ export function claudeFindLastSession(workingDirectory: string): string | null { logger.debug('[claudeFindLastSession] Error finding sessions:', e); return null; } -} \ No newline at end of file +} diff --git a/cli/src/claude/utils/claudeSettings.test.ts b/cli/src/backends/claude/utils/claudeSettings.test.ts similarity index 100% rename from cli/src/claude/utils/claudeSettings.test.ts rename to cli/src/backends/claude/utils/claudeSettings.test.ts diff --git a/cli/src/claude/utils/claudeSettings.ts b/cli/src/backends/claude/utils/claudeSettings.ts similarity index 100% rename from cli/src/claude/utils/claudeSettings.ts rename to cli/src/backends/claude/utils/claudeSettings.ts diff --git a/cli/src/claude/utils/generateHookSettings.ts b/cli/src/backends/claude/utils/generateHookSettings.ts similarity index 100% rename from cli/src/claude/utils/generateHookSettings.ts rename to cli/src/backends/claude/utils/generateHookSettings.ts diff --git a/cli/src/claude/utils/getToolDescriptor.ts b/cli/src/backends/claude/utils/getToolDescriptor.ts similarity index 100% rename from cli/src/claude/utils/getToolDescriptor.ts rename to cli/src/backends/claude/utils/getToolDescriptor.ts diff --git a/cli/src/claude/utils/getToolName.ts b/cli/src/backends/claude/utils/getToolName.ts similarity index 100% rename from cli/src/claude/utils/getToolName.ts rename to cli/src/backends/claude/utils/getToolName.ts diff --git a/cli/src/claude/utils/path.test.ts b/cli/src/backends/claude/utils/path.test.ts similarity index 83% rename from cli/src/claude/utils/path.test.ts rename to cli/src/backends/claude/utils/path.test.ts index 6708be26a..2bd223b23 100644 --- a/cli/src/claude/utils/path.test.ts +++ b/cli/src/backends/claude/utils/path.test.ts @@ -54,6 +54,13 @@ describe('getProjectPath', () => { }); describe('CLAUDE_CONFIG_DIR support', () => { + it('should prefer explicit claudeConfigDir argument over process.env.CLAUDE_CONFIG_DIR', () => { + process.env.CLAUDE_CONFIG_DIR = '/env/claude/config'; + const workingDir = '/Users/steve/projects/my-app'; + const result = (getProjectPath as any)(workingDir, '/override/claude/config'); + expect(result).toBe(join('/override/claude/config', 'projects', '-Users-steve-projects-my-app')); + }); + it('should use default .claude directory when CLAUDE_CONFIG_DIR is not set', () => { // When CLAUDE_CONFIG_DIR is not set, it uses homedir()/.claude const workingDir = '/Users/steve/projects/my-app'; @@ -91,5 +98,12 @@ describe('getProjectPath', () => { const result = getProjectPath(workingDir); expect(result).toBe(join('/custom/claude/config/', 'projects', '-Users-steve-projects-my-app')); }); + + it('should trim whitespace in CLAUDE_CONFIG_DIR', () => { + process.env.CLAUDE_CONFIG_DIR = ' /custom/claude/config '; + const workingDir = '/Users/steve/projects/my-app'; + const result = getProjectPath(workingDir); + expect(result).toBe(join('/custom/claude/config', 'projects', '-Users-steve-projects-my-app')); + }); }); }); diff --git a/cli/src/backends/claude/utils/path.ts b/cli/src/backends/claude/utils/path.ts new file mode 100644 index 000000000..74354deac --- /dev/null +++ b/cli/src/backends/claude/utils/path.ts @@ -0,0 +1,10 @@ +import { homedir } from "node:os"; +import { join, resolve } from "node:path"; + +export function getProjectPath(workingDirectory: string, claudeConfigDirOverride?: string | null) { + const projectId = resolve(workingDirectory).replace(/[\\\/\.: _]/g, '-'); + const claudeConfigDirRaw = claudeConfigDirOverride ?? process.env.CLAUDE_CONFIG_DIR ?? ''; + const claudeConfigDirTrimmed = claudeConfigDirRaw.trim(); + const claudeConfigDir = claudeConfigDirTrimmed ? claudeConfigDirTrimmed : join(homedir(), '.claude'); + return join(claudeConfigDir, 'projects', projectId); +} diff --git a/cli/src/backends/claude/utils/permissionHandler.exitPlanMode.test.ts b/cli/src/backends/claude/utils/permissionHandler.exitPlanMode.test.ts new file mode 100644 index 000000000..0f8f949c3 --- /dev/null +++ b/cli/src/backends/claude/utils/permissionHandler.exitPlanMode.test.ts @@ -0,0 +1,121 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@/lib', () => ({ + logger: { + debug: vi.fn(), + debugLargeJson: vi.fn(), + }, +})); + +describe('PermissionHandler (ExitPlanMode)', () => { + beforeEach(() => { + vi.clearAllMocks(); + delete process.env.HAPPY_STACKS_TOOL_TRACE; + delete process.env.HAPPY_STACKS_TOOL_TRACE_FILE; + delete process.env.HAPPY_STACKS_TOOL_TRACE_DIR; + delete process.env.HAPPY_LOCAL_TOOL_TRACE; + delete process.env.HAPPY_LOCAL_TOOL_TRACE_FILE; + delete process.env.HAPPY_LOCAL_TOOL_TRACE_DIR; + delete process.env.HAPPY_TOOL_TRACE; + delete process.env.HAPPY_TOOL_TRACE_FILE; + delete process.env.HAPPY_TOOL_TRACE_DIR; + }); + + it('allows ExitPlanMode when approved', async () => { + const rpcHandlers = new Map any>(); + let agentState: any = { requests: {}, completedRequests: {} }; + + const client = { + sessionId: 's1', + rpcHandlerManager: { + registerHandler: (name: string, handler: any) => { + rpcHandlers.set(name, handler); + }, + }, + updateAgentState: vi.fn((updater: (current: any) => any) => { + agentState = updater(agentState); + }), + } as any; + + const session = { + client, + api: { + push: () => ({ sendToAllDevices: vi.fn() }), + }, + setLastPermissionMode: vi.fn(), + } as any; + + const { PermissionHandler } = await import('./permissionHandler'); + const handler = new PermissionHandler(session); + + handler.onMessage({ + type: 'assistant', + message: { + content: [{ type: 'tool_use', id: 'toolu_1', name: 'ExitPlanMode', input: { plan: 'p1' } }], + }, + } as any); + + const resultPromise = handler.handleToolCall( + 'ExitPlanMode', + { plan: 'p1' }, + { permissionMode: 'plan' } as any, + { signal: new AbortController().signal }, + ); + + const permissionRpc = rpcHandlers.get('permission'); + expect(permissionRpc).toBeDefined(); + + await permissionRpc!({ id: 'toolu_1', approved: true }); + await expect(resultPromise).resolves.toEqual({ behavior: 'allow', updatedInput: { plan: 'p1' } }); + }); + + it('denies ExitPlanMode with the provided reason, and does not abort the remote loop', async () => { + const rpcHandlers = new Map any>(); + let agentState: any = { requests: {}, completedRequests: {} }; + + const client = { + sessionId: 's1', + rpcHandlerManager: { + registerHandler: (name: string, handler: any) => { + rpcHandlers.set(name, handler); + }, + }, + updateAgentState: vi.fn((updater: (current: any) => any) => { + agentState = updater(agentState); + }), + } as any; + + const session = { + client, + api: { + push: () => ({ sendToAllDevices: vi.fn() }), + }, + setLastPermissionMode: vi.fn(), + } as any; + + const { PermissionHandler } = await import('./permissionHandler'); + const handler = new PermissionHandler(session); + + handler.onMessage({ + type: 'assistant', + message: { + content: [{ type: 'tool_use', id: 'toolu_1', name: 'ExitPlanMode', input: { plan: 'p1' } }], + }, + } as any); + + const resultPromise = handler.handleToolCall( + 'ExitPlanMode', + { plan: 'p1' }, + { permissionMode: 'plan' } as any, + { signal: new AbortController().signal }, + ); + + const permissionRpc = rpcHandlers.get('permission'); + expect(permissionRpc).toBeDefined(); + + await permissionRpc!({ id: 'toolu_1', approved: false, reason: 'Please change step 2' }); + await expect(resultPromise).resolves.toMatchObject({ behavior: 'deny', message: 'Please change step 2' }); + + expect(handler.isAborted('toolu_1')).toBe(false); + }); +}); diff --git a/cli/src/backends/claude/utils/permissionHandler.toolTrace.test.ts b/cli/src/backends/claude/utils/permissionHandler.toolTrace.test.ts new file mode 100644 index 000000000..d1fc9d346 --- /dev/null +++ b/cli/src/backends/claude/utils/permissionHandler.toolTrace.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { existsSync, mkdtempSync, readFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { __resetToolTraceForTests } from '@/agent/tools/trace/toolTrace'; +import { PermissionHandler } from './permissionHandler'; + +class FakeRpcHandlerManager { + handlers = new Map any>(); + registerHandler(_name: string, handler: any) { + this.handlers.set(_name, handler); + } +} + +class FakeClient { + sessionId = 'test-session-id'; + rpcHandlerManager = new FakeRpcHandlerManager(); + agentState: any = { requests: {}, completedRequests: {}, capabilities: {} }; + + updateAgentState(updater: any) { + this.agentState = updater(this.agentState); + return this.agentState; + } + + getAgentStateSnapshot() { + return this.agentState; + } +} + +function createFakeSession() { + const client = new FakeClient(); + return { + client, + api: { + push() { + return { sendToAllDevices() {} }; + }, + }, + } as any; +} + +describe('Claude PermissionHandler tool trace', () => { + afterEach(() => { + delete process.env.HAPPY_STACKS_TOOL_TRACE; + delete process.env.HAPPY_STACKS_TOOL_TRACE_FILE; + __resetToolTraceForTests(); + }); + + it('records permission-request and permission-response when tool tracing is enabled', async () => { + const dir = mkdtempSync(join(tmpdir(), 'happy-tool-trace-claude-permissions-')); + const filePath = join(dir, 'tool-trace.jsonl'); + process.env.HAPPY_STACKS_TOOL_TRACE = '1'; + process.env.HAPPY_STACKS_TOOL_TRACE_FILE = filePath; + + const session = createFakeSession(); + const handler = new PermissionHandler(session); + + const input = { file_path: '/etc/hosts' }; + handler.onMessage({ + type: 'assistant', + message: { content: [{ type: 'tool_use', id: 'toolu_1', name: 'Read', input }] }, + } as any); + + const controller = new AbortController(); + const permissionPromise = handler.handleToolCall('Read', input, { permissionMode: 'default' } as any, { + signal: controller.signal, + }); + + await new Promise((r) => setTimeout(r, 0)); + handler.approveToolCall('toolu_1'); + + await expect(permissionPromise).resolves.toMatchObject({ behavior: 'allow' }); + + expect(existsSync(filePath)).toBe(true); + const raw = readFileSync(filePath, 'utf8'); + const lines = raw.trim().split('\n').map((l) => JSON.parse(l)); + + expect(lines).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + direction: 'outbound', + sessionId: 'test-session-id', + protocol: 'claude', + provider: 'claude', + kind: 'permission-request', + payload: expect.objectContaining({ + type: 'permission-request', + permissionId: 'toolu_1', + toolName: 'Read', + }), + }), + expect.objectContaining({ + direction: 'inbound', + sessionId: 'test-session-id', + protocol: 'claude', + provider: 'claude', + kind: 'permission-response', + payload: expect.objectContaining({ + type: 'permission-response', + permissionId: 'toolu_1', + approved: true, + }), + }), + ]), + ); + }); +}); diff --git a/cli/src/claude/utils/permissionHandler.ts b/cli/src/backends/claude/utils/permissionHandler.ts similarity index 53% rename from cli/src/claude/utils/permissionHandler.ts rename to cli/src/backends/claude/utils/permissionHandler.ts index 1f8d7b8c1..44d53ccb2 100644 --- a/cli/src/claude/utils/permissionHandler.ts +++ b/cli/src/backends/claude/utils/permissionHandler.ts @@ -9,19 +9,26 @@ import { isDeepStrictEqual } from 'node:util'; import { logger } from "@/lib"; import { SDKAssistantMessage, SDKMessage, SDKUserMessage } from "../sdk"; import { PermissionResult } from "../sdk/types"; -import { PLAN_FAKE_REJECT, PLAN_FAKE_RESTART } from "../sdk/prompts"; import { Session } from "../session"; import { getToolName } from "./getToolName"; import { EnhancedMode, PermissionMode } from "../loop"; import { getToolDescriptor } from "./getToolDescriptor"; import { delay } from "@/utils/time"; +import { isShellCommandAllowed } from '@/agent/permissions/shellCommandAllowlist'; +import { recordToolTraceEvent } from '@/agent/tools/trace/toolTrace'; interface PermissionResponse { id: string; approved: boolean; reason?: string; mode?: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan'; - allowTools?: string[]; + allowedTools?: string[]; + allowTools?: string[]; // legacy alias + /** + * AskUserQuestion: structured answers keyed by question text. + * Claude Code may use this to complete the interaction without a TUI. + */ + answers?: Record; receivedAt?: number; } @@ -47,6 +54,159 @@ export class PermissionHandler { constructor(session: Session) { this.session = session; this.setupClientHandler(); + this.advertiseCapabilities(); + this.seedAllowlistFromAgentState(); + } + + private isToolTraceEnabled(): boolean { + const isTruthy = (value: string | undefined): boolean => + typeof value === 'string' && ['1', 'true', 'yes', 'on'].includes(value.toLowerCase()); + return ( + isTruthy(process.env.HAPPY_STACKS_TOOL_TRACE) || + isTruthy(process.env.HAPPY_LOCAL_TOOL_TRACE) || + isTruthy(process.env.HAPPY_TOOL_TRACE) + ); + } + + private redactToolTraceValue(value: unknown, key?: string): unknown { + const REDACT_KEYS = new Set(['content', 'text', 'old_string', 'new_string', 'oldText', 'newText', 'oldContent', 'newContent']); + + if (typeof value === 'string') { + if (key && REDACT_KEYS.has(key)) return `[redacted ${value.length} chars]`; + if (value.length <= 1_000) return value; + return `${value.slice(0, 1_000)}…(truncated ${value.length - 1_000} chars)`; + } + + if (typeof value !== 'object' || value === null) return value; + + if (Array.isArray(value)) { + const sliced = value.slice(0, 50).map((v) => this.redactToolTraceValue(v)); + if (value.length <= 50) return sliced; + return [...sliced, `…(truncated ${value.length - 50} items)`]; + } + + const entries = Object.entries(value as Record); + const out: Record = {}; + const sliced = entries.slice(0, 200); + for (const [k, v] of sliced) out[k] = this.redactToolTraceValue(v, k); + if (entries.length > 200) out._truncatedKeys = entries.length - 200; + return out; + } + + private seedAllowlistFromAgentState(): void { + try { + const snapshot = (this.session.client as any).getAgentStateSnapshot?.() ?? null; + const completed = snapshot?.completedRequests; + if (!completed) return; + + const isApprovedEntry = (value: unknown): value is { status: 'approved'; allowedTools?: unknown; allowTools?: unknown } => { + if (!value || typeof value !== 'object') return false; + return (value as any).status === 'approved'; + }; + + for (const entry of Object.values(completed as Record)) { + if (!isApprovedEntry(entry)) continue; + + const list = entry.allowedTools ?? entry.allowTools; + if (!Array.isArray(list)) continue; + for (const tool of list) { + if (typeof tool !== 'string' || tool.length === 0) continue; + if (tool.startsWith('Bash(') || tool === 'Bash') { + this.parseBashPermission(tool); + } else { + this.allowedTools.add(tool); + } + } + } + } catch (error) { + logger.debug('[Claude] Failed to seed allowlist from agentState', error); + } + } + + private advertiseCapabilities(): void { + // Capability negotiation for app ↔ agent compatibility. + // Older agents won't set this, so clients can safely fall back to legacy behavior. + this.session.client.updateAgentState((currentState) => { + const currentCaps = (currentState as any).capabilities; + if (currentCaps && currentCaps.askUserQuestionAnswersInPermission === true) { + return currentState; + } + return { + ...currentState, + capabilities: { + ...(currentCaps && typeof currentCaps === 'object' ? currentCaps : {}), + askUserQuestionAnswersInPermission: true, + }, + }; + }); + } + + approveToolCall(toolCallId: string, opts?: { answers?: Record }): void { + this.applyPermissionResponse({ id: toolCallId, approved: true, answers: opts?.answers }); + } + + private applyPermissionResponse(message: PermissionResponse): void { + logger.debug(`Permission response: ${JSON.stringify(message)}`); + + const id = message.id; + + if (this.isToolTraceEnabled()) { + recordToolTraceEvent({ + direction: 'inbound', + sessionId: this.session.client.sessionId, + protocol: 'claude', + provider: 'claude', + kind: 'permission-response', + payload: { + type: 'permission-response', + permissionId: id, + approved: message.approved, + reason: typeof message.reason === 'string' ? message.reason : undefined, + mode: message.mode, + allowedTools: this.redactToolTraceValue(message.allowedTools ?? message.allowTools, 'allowedTools'), + answers: this.redactToolTraceValue(message.answers, 'answers'), + }, + }); + } + + const pending = this.pendingRequests.get(id); + + if (!pending) { + logger.debug('Permission request not found or already resolved'); + return; + } + + // Store the response with timestamp + this.responses.set(id, { ...message, receivedAt: Date.now() }); + this.pendingRequests.delete(id); + + // Handle the permission response based on tool type + this.handlePermissionResponse(message, pending); + + // Move processed request to completedRequests + this.session.client.updateAgentState((currentState) => { + const request = currentState.requests?.[id]; + if (!request) return currentState; + let r = { ...currentState.requests }; + delete r[id]; + return { + ...currentState, + requests: r, + completedRequests: { + ...currentState.completedRequests, + [id]: { + ...request, + completedAt: Date.now(), + status: message.approved ? 'approved' : 'denied', + reason: message.reason, + mode: message.mode, + ...(Array.isArray(message.allowedTools ?? message.allowTools) + ? { allowedTools: (message.allowedTools ?? message.allowTools)! } + : null), + } + } + }; + }); } /** @@ -58,6 +218,7 @@ export class PermissionHandler { handleModeChange(mode: PermissionMode) { this.permissionMode = mode; + this.session.setLastPermissionMode(mode); } /** @@ -69,8 +230,9 @@ export class PermissionHandler { ): void { // Update allowed tools - if (response.allowTools && response.allowTools.length > 0) { - response.allowTools.forEach(tool => { + const allowedTools = response.allowedTools ?? response.allowTools; + if (allowedTools && allowedTools.length > 0) { + allowedTools.forEach(tool => { if (tool.startsWith('Bash(') || tool === 'Bash') { this.parseBashPermission(tool); } else { @@ -82,32 +244,38 @@ export class PermissionHandler { // Update permission mode if (response.mode) { this.permissionMode = response.mode; + this.session.setLastPermissionMode(response.mode); } - // Handle - if (pending.toolName === 'exit_plan_mode' || pending.toolName === 'ExitPlanMode') { - // Handle exit_plan_mode specially - logger.debug('Plan mode result received', response); - if (response.approved) { - logger.debug('Plan approved - injecting PLAN_FAKE_RESTART'); - // Inject the approval message at the beginning of the queue - if (response.mode && ['default', 'acceptEdits', 'bypassPermissions'].includes(response.mode)) { - this.session.queue.unshift(PLAN_FAKE_RESTART, { permissionMode: response.mode }); - } else { - this.session.queue.unshift(PLAN_FAKE_RESTART, { permissionMode: 'default' }); - } - pending.resolve({ behavior: 'deny', message: PLAN_FAKE_REJECT }); - } else { - pending.resolve({ behavior: 'deny', message: response.reason || 'Plan rejected' }); - } - } else { - // Handle default case for all other tools - const result: PermissionResult = response.approved - ? { behavior: 'allow', updatedInput: (pending.input as Record) || {} } - : { behavior: 'deny', message: response.reason || `The user doesn't want to proceed with this tool use. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). STOP what you are doing and wait for the user to tell you how to proceed.` }; - - pending.resolve(result); + // Handle default case for all tools + if (pending.toolName === 'AskUserQuestion' && response.approved && response.answers) { + const baseInput = + pending.input && typeof pending.input === 'object' && !Array.isArray(pending.input) + ? (pending.input as Record) + : {}; + logger.debug( + `[AskUserQuestion] Resolving canCallTool with ${Object.keys(response.answers).length} answer(s) via updatedInput`, + ); + pending.resolve({ + behavior: 'allow', + updatedInput: { + ...baseInput, + answers: response.answers, + }, + }); + return; } + + const result: PermissionResult = response.approved + ? { behavior: 'allow', updatedInput: (pending.input as Record) || {} } + : { + behavior: 'deny', + message: + response.reason || + `The user doesn't want to proceed with this tool use. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). STOP what you are doing and wait for the user to tell you how to proceed.`, + }; + + pending.resolve(result); } /** @@ -119,16 +287,13 @@ export class PermissionHandler { if (toolName === 'Bash') { const inputObj = input as { command?: string }; if (inputObj?.command) { - // Check literal matches - if (this.allowedBashLiterals.has(inputObj.command)) { + const patterns: Array<{ kind: 'exact'; value: string } | { kind: 'prefix'; value: string }> = []; + for (const literal of this.allowedBashLiterals) patterns.push({ kind: 'exact', value: literal }); + for (const prefix of this.allowedBashPrefixes) patterns.push({ kind: 'prefix', value: prefix }); + + if (patterns.length > 0 && isShellCommandAllowed(inputObj.command, patterns)) { return { behavior: 'allow', updatedInput: input as Record }; } - // Check prefix matches - for (const prefix of this.allowedBashPrefixes) { - if (inputObj.command.startsWith(prefix)) { - return { behavior: 'allow', updatedInput: input as Record }; - } - } } } else if (this.allowedTools.has(toolName)) { return { behavior: 'allow', updatedInput: input as Record }; @@ -215,6 +380,12 @@ export class PermissionHandler { // Update agent state this.session.client.updateAgentState((currentState) => ({ ...currentState, + capabilities: { + ...(currentState.capabilities && typeof currentState.capabilities === 'object' + ? currentState.capabilities + : {}), + askUserQuestionAnswersInPermission: true, + }, requests: { ...currentState.requests, [id]: { @@ -225,6 +396,22 @@ export class PermissionHandler { } })); + if (this.isToolTraceEnabled()) { + recordToolTraceEvent({ + direction: 'outbound', + sessionId: this.session.client.sessionId, + protocol: 'claude', + provider: 'claude', + kind: 'permission-request', + payload: { + type: 'permission-request', + permissionId: id, + toolName, + input: this.redactToolTraceValue(input), + }, + }); + } + logger.debug(`Permission request sent for tool call ${id}: ${toolName}`); }); } @@ -318,14 +505,15 @@ export class PermissionHandler { */ isAborted(toolCallId: string): boolean { - // If tool not approved, it's aborted - if (this.responses.get(toolCallId)?.approved === false) { - return true; - } - - // Always abort exit_plan_mode + // ExitPlanMode is used to negotiate a plan; even if the user rejects it (or requests changes), + // Claude should be allowed to continue the current turn to revise the plan. const toolCall = this.toolCalls.find(tc => tc.id === toolCallId); if (toolCall && (toolCall.name === 'exit_plan_mode' || toolCall.name === 'ExitPlanMode')) { + return false; + } + + // If tool not approved, it's aborted + if (this.responses.get(toolCallId)?.approved === false) { return true; } @@ -376,46 +564,9 @@ export class PermissionHandler { * Sets up the client handler for permission responses */ private setupClientHandler(): void { - this.session.client.rpcHandlerManager.registerHandler('permission', async (message) => { - logger.debug(`Permission response: ${JSON.stringify(message)}`); - - const id = message.id; - const pending = this.pendingRequests.get(id); - - if (!pending) { - logger.debug('Permission request not found or already resolved'); - return; - } - - // Store the response with timestamp - this.responses.set(id, { ...message, receivedAt: Date.now() }); - this.pendingRequests.delete(id); - - // Handle the permission response based on tool type - this.handlePermissionResponse(message, pending); - - // Move processed request to completedRequests - this.session.client.updateAgentState((currentState) => { - const request = currentState.requests?.[id]; - if (!request) return currentState; - let r = { ...currentState.requests }; - delete r[id]; - return { - ...currentState, - requests: r, - completedRequests: { - ...currentState.completedRequests, - [id]: { - ...request, - completedAt: Date.now(), - status: message.approved ? 'approved' : 'denied', - reason: message.reason, - mode: message.mode, - allowTools: message.allowTools - } - } - }; - }); + this.session.client.rpcHandlerManager.registerHandler('permission', async (message) => { + this.applyPermissionResponse(message); + return { ok: true } as const; }); } @@ -425,4 +576,4 @@ export class PermissionHandler { getResponses(): Map { return this.responses; } -} \ No newline at end of file +} diff --git a/cli/src/claude/utils/permissionMode.test.ts b/cli/src/backends/claude/utils/permissionMode.test.ts similarity index 100% rename from cli/src/claude/utils/permissionMode.test.ts rename to cli/src/backends/claude/utils/permissionMode.test.ts diff --git a/cli/src/claude/utils/permissionMode.ts b/cli/src/backends/claude/utils/permissionMode.ts similarity index 94% rename from cli/src/claude/utils/permissionMode.ts rename to cli/src/backends/claude/utils/permissionMode.ts index 204a70292..36f0d4b16 100644 --- a/cli/src/claude/utils/permissionMode.ts +++ b/cli/src/backends/claude/utils/permissionMode.ts @@ -1,4 +1,4 @@ -import type { QueryOptions } from '@/claude/sdk'; +import type { QueryOptions } from '@/backends/claude/sdk'; import type { PermissionMode } from '@/api/types'; /** Derived from SDK's QueryOptions - the modes Claude actually supports */ diff --git a/cli/src/claude/utils/sdkToLogConverter.test.ts b/cli/src/backends/claude/utils/sdkToLogConverter.test.ts similarity index 99% rename from cli/src/claude/utils/sdkToLogConverter.test.ts rename to cli/src/backends/claude/utils/sdkToLogConverter.test.ts index 975b177ca..1343b3054 100644 --- a/cli/src/claude/utils/sdkToLogConverter.test.ts +++ b/cli/src/backends/claude/utils/sdkToLogConverter.test.ts @@ -4,7 +4,7 @@ import { describe, it, expect, beforeEach } from 'vitest' import { SDKToLogConverter, convertSDKToLog } from './sdkToLogConverter' -import type { SDKMessage, SDKUserMessage, SDKAssistantMessage, SDKSystemMessage, SDKResultMessage } from '@/claude/sdk' +import type { SDKMessage, SDKUserMessage, SDKAssistantMessage, SDKSystemMessage, SDKResultMessage } from '@/backends/claude/sdk' describe('SDKToLogConverter', () => { let converter: SDKToLogConverter diff --git a/cli/src/claude/utils/sdkToLogConverter.ts b/cli/src/backends/claude/utils/sdkToLogConverter.ts similarity index 99% rename from cli/src/claude/utils/sdkToLogConverter.ts rename to cli/src/backends/claude/utils/sdkToLogConverter.ts index 5b68e0d83..b110ea501 100644 --- a/cli/src/claude/utils/sdkToLogConverter.ts +++ b/cli/src/backends/claude/utils/sdkToLogConverter.ts @@ -11,8 +11,8 @@ import type { SDKAssistantMessage, SDKSystemMessage, SDKResultMessage -} from '@/claude/sdk' -import type { RawJSONLines } from '@/claude/types' +} from '@/backends/claude/sdk' +import type { RawJSONLines } from '@/backends/claude/types' /** * Context for converting SDK messages to log format diff --git a/cli/src/backends/claude/utils/sessionScanner.onMessageErrors.test.ts b/cli/src/backends/claude/utils/sessionScanner.onMessageErrors.test.ts new file mode 100644 index 000000000..637603f83 --- /dev/null +++ b/cli/src/backends/claude/utils/sessionScanner.onMessageErrors.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { createSessionScanner } from './sessionScanner' +import { mkdir, writeFile, rm } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { existsSync } from 'node:fs' +import { getProjectPath } from './path' +import { logger } from '@/ui/logger' + +async function waitFor(predicate: () => boolean, timeoutMs: number = 2000, intervalMs: number = 25): Promise { + const start = Date.now() + while (Date.now() - start < timeoutMs) { + if (predicate()) return + await new Promise(resolve => setTimeout(resolve, intervalMs)) + } + throw new Error('Timed out waiting for condition') +} + +describe('sessionScanner onMessage errors', () => { + let testDir: string + let projectDir: string + let scanner: Awaited> | null = null + let originalClaudeConfigDir: string | undefined + let claudeConfigDir: string + + beforeEach(async () => { + testDir = join(tmpdir(), `scanner-test-${Date.now()}`) + await mkdir(testDir, { recursive: true }) + + originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR + claudeConfigDir = join(testDir, 'claude-config') + process.env.CLAUDE_CONFIG_DIR = claudeConfigDir + + projectDir = getProjectPath(testDir) + await mkdir(projectDir, { recursive: true }) + }) + + afterEach(async () => { + if (scanner) { + await scanner.cleanup() + scanner = null + } + + if (originalClaudeConfigDir === undefined) { + delete process.env.CLAUDE_CONFIG_DIR; + } else { + process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir; + } + + if (existsSync(testDir)) { + await rm(testDir, { recursive: true, force: true }) + } + }) + + it('logs and continues when onMessage callback throws', async () => { + const debugSpy = vi.spyOn(logger, 'debug') + + let didThrow = false + scanner = await createSessionScanner({ + sessionId: null, + workingDirectory: testDir, + transcriptMissingWarningMs: 0, + onMessage: () => { + didThrow = true + throw new Error('boom') + }, + }) + + const sessionId = '93a9705e-bc6a-406d-8dce-8acc014dedbd' + const sessionFile = join(projectDir, `${sessionId}.jsonl`) + await writeFile(sessionFile, JSON.stringify({ type: 'user', uuid: 'u1', message: { content: 'hi' } }) + '\n') + scanner.onNewSession(sessionId) + + await waitFor(() => didThrow) + await waitFor(() => debugSpy.mock.calls.some((c) => String(c[0]).includes('[SESSION_SCANNER] onMessage callback threw'))) + }) +}) + diff --git a/cli/src/claude/utils/sessionScanner.test.ts b/cli/src/backends/claude/utils/sessionScanner.test.ts similarity index 64% rename from cli/src/claude/utils/sessionScanner.test.ts rename to cli/src/backends/claude/utils/sessionScanner.test.ts index 56ec9a912..866117f7b 100644 --- a/cli/src/claude/utils/sessionScanner.test.ts +++ b/cli/src/backends/claude/utils/sessionScanner.test.ts @@ -7,17 +7,33 @@ import { tmpdir } from 'node:os' import { existsSync } from 'node:fs' import { getProjectPath } from './path' +async function waitFor(predicate: () => boolean, timeoutMs: number = 2000, intervalMs: number = 25): Promise { + const start = Date.now() + while (Date.now() - start < timeoutMs) { + if (predicate()) return + await new Promise(resolve => setTimeout(resolve, intervalMs)) + } + throw new Error('Timed out waiting for condition') +} + describe('sessionScanner', () => { let testDir: string let projectDir: string let collectedMessages: RawJSONLines[] let scanner: Awaited> | null = null + let originalClaudeConfigDir: string | undefined + let claudeConfigDir: string beforeEach(async () => { testDir = join(tmpdir(), `scanner-test-${Date.now()}`) await mkdir(testDir, { recursive: true }) + + // Ensure the scanner and this test agree on where session files live. + // (getProjectPath uses CLAUDE_CONFIG_DIR + a sanitized project id derived from workingDirectory.) + originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR + claudeConfigDir = join(testDir, 'claude-config') + process.env.CLAUDE_CONFIG_DIR = claudeConfigDir - // Use the same path calculation as the scanner to ensure paths match projectDir = getProjectPath(testDir) await mkdir(projectDir, { recursive: true }) @@ -31,12 +47,15 @@ describe('sessionScanner', () => { scanner = null } + if (originalClaudeConfigDir === undefined) { + delete process.env.CLAUDE_CONFIG_DIR; + } else { + process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir; + } + if (existsSync(testDir)) { await rm(testDir, { recursive: true, force: true }) } - if (existsSync(projectDir)) { - await rm(projectDir, { recursive: true, force: true }) - } }) it('should process initial session and resumed session correctly', async () => { @@ -64,7 +83,7 @@ describe('sessionScanner', () => { // Write first line await writeFile(sessionFile1, lines1[0] + '\n') scanner.onNewSession(sessionId1) - await new Promise(resolve => setTimeout(resolve, 100)) + await waitFor(() => collectedMessages.length >= 1) expect(collectedMessages).toHaveLength(1) expect(collectedMessages[0].type).toBe('user') @@ -77,7 +96,7 @@ describe('sessionScanner', () => { // Write second line with delay await new Promise(resolve => setTimeout(resolve, 50)) await appendFile(sessionFile1, lines1[1] + '\n') - await new Promise(resolve => setTimeout(resolve, 200)) + await waitFor(() => collectedMessages.length >= 2) expect(collectedMessages).toHaveLength(2) expect(collectedMessages[1].type).toBe('assistant') @@ -103,7 +122,7 @@ describe('sessionScanner', () => { await writeFile(sessionFile2, initialContent) scanner.onNewSession(sessionId2) - await new Promise(resolve => setTimeout(resolve, 100)) + await waitFor(() => collectedMessages.length >= phase1Count + 1) // Should have added only 1 new message (summary) // The historical user + assistant messages (lines 1-2) are deduplicated because they have same UUIDs @@ -113,7 +132,7 @@ describe('sessionScanner', () => { // Write new messages (user asks for ls tool) - this is line 3 await new Promise(resolve => setTimeout(resolve, 50)) await appendFile(sessionFile2, lines2[3] + '\n') - await new Promise(resolve => setTimeout(resolve, 200)) + await waitFor(() => collectedMessages.some(m => m.type === 'user' && m.message.content === 'run ls tool ')) // Find the user message we just added const userMessages = collectedMessages.filter(m => m.type === 'user') @@ -128,7 +147,12 @@ describe('sessionScanner', () => { await new Promise(resolve => setTimeout(resolve, 50)) await appendFile(sessionFile2, lines2[i] + '\n') } - await new Promise(resolve => setTimeout(resolve, 300)) + await waitFor(() => collectedMessages.some(m => + m.type === 'assistant' && + Array.isArray((m.message?.content as any)) && + typeof (m.message?.content as any)[0]?.text === 'string' && + (m.message?.content as any)[0].text.includes('0-say-lol-session.jsonl') + ), 5000) // Final count check const finalMessages = collectedMessages.slice(phase1Count) @@ -145,6 +169,78 @@ describe('sessionScanner', () => { expect(content).toContain('readme.md') } }) + + it('should read from transcriptPath when provided (even if projectDir differs)', async () => { + scanner = await createSessionScanner({ + sessionId: null, + workingDirectory: testDir, + onMessage: (msg) => collectedMessages.push(msg) + }) + + const altProjectDir = join(testDir, 'alt-project') + await mkdir(altProjectDir, { recursive: true }) + + const sessionId = '11111111-1111-1111-1111-111111111111' + const transcriptPath = join(altProjectDir, `${sessionId}.jsonl`) + await writeFile(transcriptPath, JSON.stringify({ + type: 'user', + uuid: 'm1', + message: { content: 'hello from alt dir' } + }) + '\n') + + if (!scanner) throw new Error('scanner is not initialized') + scanner.onNewSession({ sessionId, transcriptPath }) + + await waitFor(() => collectedMessages.length >= 1, 500) + + expect(collectedMessages).toHaveLength(1) + expect(collectedMessages[0].type).toBe('user') + if (collectedMessages[0].type === 'user') { + expect(collectedMessages[0].message.content).toBe('hello from alt dir') + } + }) + + it('should use initial transcriptPath to mark existing messages as processed', async () => { + const altProjectDir = join(testDir, 'alt-project') + await mkdir(altProjectDir, { recursive: true }) + + const sessionId = '22222222-2222-2222-2222-222222222222' + const transcriptPath = join(altProjectDir, `${sessionId}.jsonl`) + + // Existing message should be treated as already-processed (not emitted) + await writeFile( + transcriptPath, + JSON.stringify({ + type: 'user', + uuid: 'm_old', + message: { content: 'old message' }, + }) + '\n', + ) + + scanner = await createSessionScanner({ + sessionId, + transcriptPath, + workingDirectory: testDir, + onMessage: (msg: RawJSONLines) => collectedMessages.push(msg), + }) + + // Should not emit existing history on startup + expect(collectedMessages).toHaveLength(0) + + // Append new message and ensure it is emitted + await appendFile( + transcriptPath, + JSON.stringify({ + type: 'assistant', + uuid: 'm_new', + message: {}, + }) + '\n', + ) + + await waitFor(() => collectedMessages.length >= 1, 1000) + expect(collectedMessages).toHaveLength(1) + expect(collectedMessages[0].type).toBe('assistant') + }) it('should not process duplicate assistant messages with same message ID', async () => { // Currently broken unclear if we need this or not post migrating to sdk & removeing deduplication @@ -208,4 +304,24 @@ describe('sessionScanner', () => { // expect(lastAssistantMsg.message.id).toBe('msg_01KWeuP88pkzRtXmggJRnQmV') // } }) -}) \ No newline at end of file + it('should notify when transcript file is missing for too long', async () => { + const missing: { sessionId: string; filePath: string }[] = [] + + scanner = await createSessionScanner({ + sessionId: null, + workingDirectory: testDir, + onMessage: (msg) => collectedMessages.push(msg), + onTranscriptMissing: (info: { sessionId: string; filePath: string }) => missing.push(info), + transcriptMissingWarningMs: 50, + }) + + const sessionId = '11111111-1111-1111-1111-111111111111' + scanner.onNewSession(sessionId) + + await waitFor(() => missing.length >= 1) + + expect(missing).toEqual([ + { sessionId, filePath: join(projectDir, `${sessionId}.jsonl`) }, + ]) + }) +}) diff --git a/cli/src/backends/claude/utils/sessionScanner.ts b/cli/src/backends/claude/utils/sessionScanner.ts new file mode 100644 index 000000000..3e04f8e22 --- /dev/null +++ b/cli/src/backends/claude/utils/sessionScanner.ts @@ -0,0 +1,322 @@ +import { InvalidateSync } from "@/utils/sync"; +import { RawJSONLines, RawJSONLinesSchema } from "../types"; +import { dirname, join } from "node:path"; +import { readFile } from "node:fs/promises"; +import { logger } from "@/ui/logger"; +import { startFileWatcher } from "@/integrations/watcher/startFileWatcher"; +import { getProjectPath } from "./path"; + +/** + * Known internal Claude Code event types that should be silently skipped. + * These are written to session JSONL files by Claude Code but are not + * actual conversation messages - they're internal state/tracking events. + */ +const INTERNAL_CLAUDE_EVENT_TYPES = new Set([ + 'file-history-snapshot', + 'change', + 'queue-operation', +]); + +export type SessionScannerSessionInfo = { + sessionId: string; + transcriptPath?: string | null; +}; + +export async function createSessionScanner(opts: { + sessionId: string | null, + /** + * Optional absolute transcript file path for the initial sessionId (from Claude's SessionStart hook). + * When provided, it is used instead of the `getProjectPath()` heuristic. + */ + transcriptPath?: string | null, + /** + * Optional Claude config dir override (e.g., when the child process runs with CLAUDE_CONFIG_DIR set). + * Used only for the heuristic project-dir fallback when transcriptPath is not available. + */ + claudeConfigDir?: string | null, + workingDirectory: string + onMessage: (message: RawJSONLines) => void + onTranscriptMissing?: (info: { sessionId: string; filePath: string }) => void + /** How long to wait (ms) before warning that the transcript file is missing. Set <= 0 to disable. */ + transcriptMissingWarningMs?: number +}) { + + // Best-effort project directory resolution (fallback). + // When available, we prefer the Claude hook's transcriptPath-derived directory instead. + const initialProjectDir = getProjectPath(opts.workingDirectory, opts.claudeConfigDir ?? null); + let projectDirOverride: string | null = null; + const sessionFileOverrides = new Map(); + + const transcriptMissingWarningMs = opts.transcriptMissingWarningMs ?? 5000; + const warnedMissingTranscripts = new Set(); + const missingTranscriptTimers = new Map(); + + function effectiveProjectDir(): string { + return projectDirOverride ?? initialProjectDir; + } + + function getSessionFilePath(sessionId: string): string { + const override = sessionFileOverrides.get(sessionId); + return override ?? join(effectiveProjectDir(), `${sessionId}.jsonl`); + } + + function scheduleTranscriptMissingWarning(sessionId: string): void { + if (!opts.onTranscriptMissing) return; + if (!Number.isFinite(transcriptMissingWarningMs) || transcriptMissingWarningMs <= 0) return; + if (warnedMissingTranscripts.has(sessionId)) return; + if (missingTranscriptTimers.has(sessionId)) return; + + const timeoutId = setTimeout(async () => { + missingTranscriptTimers.delete(sessionId); + if (warnedMissingTranscripts.has(sessionId)) return; + + const filePath = getSessionFilePath(sessionId); + try { + await readFile(filePath, 'utf-8'); + return; + } catch { + // still missing (or unreadable) + } + + warnedMissingTranscripts.add(sessionId); + try { + opts.onTranscriptMissing?.({ sessionId, filePath }); + } catch (err) { + logger.debug('[SESSION_SCANNER] onTranscriptMissing callback threw:', err); + } + }, transcriptMissingWarningMs); + + missingTranscriptTimers.set(sessionId, timeoutId); + } + + // Finished, pending finishing and current session + let finishedSessions = new Set(); + let pendingSessions = new Set(); + let currentSessionId: string | null = null; + let watchers = new Map void }>(); + let processedMessageKeys = new Set(); + + // If the caller already knows the transcript path for the initial session, + // apply it before reading any existing messages so we mark the correct history as processed. + if (opts.sessionId && typeof opts.transcriptPath === 'string' && opts.transcriptPath.trim()) { + const transcriptPath = opts.transcriptPath.trim(); + sessionFileOverrides.set(opts.sessionId, transcriptPath); + projectDirOverride = dirname(transcriptPath); + } + + // Mark existing messages as processed and start watching the initial session + if (opts.sessionId) { + let messages = await readSessionLog(getSessionFilePath(opts.sessionId)); + logger.debug(`[SESSION_SCANNER] Marking ${messages.length} existing messages as processed from session ${opts.sessionId}`); + for (let m of messages) { + processedMessageKeys.add(messageKey(m)); + } + // IMPORTANT: Also start watching the initial session file because Claude Code + // may continue writing to it even after creating a new session with --resume + // (agent tasks and other updates can still write to the original session file) + currentSessionId = opts.sessionId; + scheduleTranscriptMissingWarning(opts.sessionId); + } + + // Main sync function + const sync = new InvalidateSync(async () => { + // logger.debug(`[SESSION_SCANNER] Syncing...`); + + // Collect session ids - include ALL sessions that have watchers + // This ensures we continue processing sessions that Claude Code may still write to + let sessions: string[] = []; + for (let p of pendingSessions) { + sessions.push(p); + } + if (currentSessionId && !pendingSessions.has(currentSessionId)) { + sessions.push(currentSessionId); + } + // Also process sessions that have active watchers (they may still receive updates) + for (let [sessionId] of watchers) { + if (!sessions.includes(sessionId)) { + sessions.push(sessionId); + } + } + + // Process sessions + for (let session of sessions) { + const sessionMessages = await readSessionLog(getSessionFilePath(session)); + let skipped = 0; + let sent = 0; + for (let file of sessionMessages) { + let key = messageKey(file); + if (processedMessageKeys.has(key)) { + skipped++; + continue; + } + processedMessageKeys.add(key); + logger.debug(`[SESSION_SCANNER] Sending new message: type=${file.type}, uuid=${file.type === 'summary' ? file.leafUuid : file.uuid}`); + try { + opts.onMessage(file); + sent++; + } catch (err) { + logger.debug('[SESSION_SCANNER] onMessage callback threw:', err); + } + } + if (sessionMessages.length > 0) { + logger.debug(`[SESSION_SCANNER] Session ${session}: found=${sessionMessages.length}, skipped=${skipped}, sent=${sent}`); + } + } + + // Move pending sessions to finished sessions (but keep processing them via watchers) + for (let p of sessions) { + if (pendingSessions.has(p)) { + pendingSessions.delete(p); + finishedSessions.add(p); + } + } + + // Update watchers for all sessions + for (let p of sessions) { + const desiredPath = getSessionFilePath(p); + const existing = watchers.get(p); + + if (!existing) { + logger.debug(`[SESSION_SCANNER] Starting watcher for session: ${p}`); + watchers.set(p, { filePath: desiredPath, stop: startFileWatcher(desiredPath, () => { sync.invalidate(); }) }); + continue; + } + + if (existing.filePath !== desiredPath) { + logger.debug(`[SESSION_SCANNER] Restarting watcher for session: ${p} (${existing.filePath} -> ${desiredPath})`); + existing.stop(); + watchers.set(p, { filePath: desiredPath, stop: startFileWatcher(desiredPath, () => { sync.invalidate(); }) }); + } + } + }); + await sync.invalidateAndAwait(); + + // Periodic sync + const intervalId = setInterval(() => { sync.invalidate(); }, 3000); + + // Public interface + return { + cleanup: async () => { + clearInterval(intervalId); + for (let w of watchers.values()) { + w.stop(); + } + watchers.clear(); + for (const timeoutId of missingTranscriptTimers.values()) { + clearTimeout(timeoutId); + } + missingTranscriptTimers.clear(); + await sync.invalidateAndAwait(); + sync.stop(); + }, + onNewSession: (arg: string | SessionScannerSessionInfo) => { + const sessionId = typeof arg === 'string' ? arg : arg.sessionId; + const transcriptPathRaw = typeof arg === 'string' ? null : arg.transcriptPath; + const transcriptPath = typeof transcriptPathRaw === 'string' && transcriptPathRaw.trim() ? transcriptPathRaw : null; + + let didUpdatePaths = false; + if (transcriptPath) { + const prevOverride = sessionFileOverrides.get(sessionId); + if (prevOverride !== transcriptPath) { + sessionFileOverrides.set(sessionId, transcriptPath); + didUpdatePaths = true; + } + const nextProjectDir = dirname(transcriptPath); + if (!projectDirOverride || projectDirOverride !== nextProjectDir) { + projectDirOverride = nextProjectDir; + didUpdatePaths = true; + } + } + + if (currentSessionId === sessionId) { + if (didUpdatePaths) { + sync.invalidate(); + } else { + logger.debug(`[SESSION_SCANNER] New session: ${sessionId} is the same as the current session, skipping`); + } + return; + } + if (finishedSessions.has(sessionId)) { + if (didUpdatePaths) sync.invalidate(); + else logger.debug(`[SESSION_SCANNER] New session: ${sessionId} is already finished, skipping`); + return; + } + if (pendingSessions.has(sessionId)) { + if (didUpdatePaths) sync.invalidate(); + else logger.debug(`[SESSION_SCANNER] New session: ${sessionId} is already pending, skipping`); + return; + } + if (currentSessionId) { + pendingSessions.add(currentSessionId); + } + logger.debug(`[SESSION_SCANNER] New session: ${sessionId}`) + currentSessionId = sessionId; + scheduleTranscriptMissingWarning(sessionId); + sync.invalidate(); + }, + } +} + +export type SessionScanner = ReturnType; + + +// +// Helpers +// + +function messageKey(message: RawJSONLines): string { + if (message.type === 'user') { + return message.uuid; + } else if (message.type === 'assistant') { + return message.uuid; + } else if (message.type === 'summary') { + return 'summary: ' + message.leafUuid + ': ' + message.summary; + } else if (message.type === 'system') { + return message.uuid; + } else { + throw Error() // Impossible + } +} + +/** + * Read and parse session log file + * Returns only valid conversation messages, silently skipping internal events + */ +async function readSessionLog(sessionFilePath: string): Promise { + logger.debug(`[SESSION_SCANNER] Reading session file: ${sessionFilePath}`); + let file: string; + try { + file = await readFile(sessionFilePath, 'utf-8'); + } catch (error) { + logger.debug(`[SESSION_SCANNER] Session file not found: ${sessionFilePath}`); + return []; + } + let lines = file.split('\n'); + let messages: RawJSONLines[] = []; + for (let l of lines) { + try { + if (l.trim() === '') { + continue; + } + let message = JSON.parse(l); + + // Silently skip known internal Claude Code events + // These are state/tracking events, not conversation messages + if (message.type && INTERNAL_CLAUDE_EVENT_TYPES.has(message.type)) { + continue; + } + + let parsed = RawJSONLinesSchema.safeParse(message); + if (!parsed.success) { + // Unknown message types are silently skipped + // They will be tracked by processedMessageKeys to avoid reprocessing + continue; + } + messages.push(parsed.data); + } catch (e) { + logger.debug(`[SESSION_SCANNER] Error processing message: ${e}`); + continue; + } + } + return messages; +} diff --git a/cli/src/claude/utils/startHookServer.ts b/cli/src/backends/claude/utils/startHookServer.ts similarity index 99% rename from cli/src/claude/utils/startHookServer.ts rename to cli/src/backends/claude/utils/startHookServer.ts index d7ce813f8..7f70e9759 100644 --- a/cli/src/claude/utils/startHookServer.ts +++ b/cli/src/backends/claude/utils/startHookServer.ts @@ -67,8 +67,10 @@ export interface SessionHookData { session_id?: string; sessionId?: string; transcript_path?: string; + transcriptPath?: string; cwd?: string; hook_event_name?: string; + hookEventName?: string; source?: string; [key: string]: unknown; } @@ -173,4 +175,3 @@ export async function startHookServer(options: HookServerOptions): Promise trimIdent(` - ALWAYS when you start a new chat - you must call a tool "mcp__happy__change_title" to set a chat title. When you think chat title is not relevant anymore - call the tool again to change it. When chat name is too generic and you have a change to make it more specific - call the tool again to change it. This title is needed to easily find the chat in the future. Help human. + Use the tool "mcp__happy__change_title" to set (or update) a short, descriptive chat title so the user can find this chat later. + + RELIABILITY RULES (IMPORTANT): + - Tool-use sequencing is strict. If you use "AskUserQuestion", do NOT include any other tool_use in the same assistant turn. Wait for the user's answer before calling other tools. `))(); /** @@ -35,4 +38,4 @@ export const systemPrompt = (() => { } else { return BASE_SYSTEM_PROMPT; } -})(); \ No newline at end of file +})(); diff --git a/cli/src/codex/__tests__/emitReadyIfIdle.test.ts b/cli/src/backends/codex/__tests__/emitReadyIfIdle.test.ts similarity index 100% rename from cli/src/codex/__tests__/emitReadyIfIdle.test.ts rename to cli/src/backends/codex/__tests__/emitReadyIfIdle.test.ts diff --git a/cli/src/backends/codex/__tests__/extractCodexToolErrorText.test.ts b/cli/src/backends/codex/__tests__/extractCodexToolErrorText.test.ts new file mode 100644 index 000000000..c306dd479 --- /dev/null +++ b/cli/src/backends/codex/__tests__/extractCodexToolErrorText.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import { extractCodexToolErrorText } from '../runCodex'; +import type { CodexToolResponse } from '../types'; + +describe('extractCodexToolErrorText', () => { + it('returns null when response is not an error', () => { + const response: CodexToolResponse = { + content: [{ type: 'text', text: 'ok' }], + isError: false, + }; + + expect(extractCodexToolErrorText(response)).toBeNull(); + }); + + it('returns concatenated text when response is an error', () => { + const response: CodexToolResponse = { + content: [ + { type: 'text', text: 'first' }, + { type: 'text', text: 'second' }, + ], + isError: true, + }; + + expect(extractCodexToolErrorText(response)).toBe('first\nsecond'); + }); + + it('returns a fallback message when response is an error but has no text', () => { + const response: CodexToolResponse = { + content: [{ type: 'image' }], + isError: true, + }; + + expect(extractCodexToolErrorText(response)).toBe('Codex error'); + }); +}); + diff --git a/cli/src/backends/codex/__tests__/extractMcpToolCallResultOutput.test.ts b/cli/src/backends/codex/__tests__/extractMcpToolCallResultOutput.test.ts new file mode 100644 index 000000000..49880b407 --- /dev/null +++ b/cli/src/backends/codex/__tests__/extractMcpToolCallResultOutput.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; +import { extractMcpToolCallResultOutput } from '../runCodex'; + +describe('extractMcpToolCallResultOutput', () => { + it('prefers Ok when present (including falsy values)', () => { + expect(extractMcpToolCallResultOutput({ Ok: false })).toBe(false); + expect(extractMcpToolCallResultOutput({ Ok: 0 })).toBe(0); + expect(extractMcpToolCallResultOutput({ Ok: '' })).toBe(''); + expect(extractMcpToolCallResultOutput({ Ok: null })).toBeNull(); + }); + + it('prefers Err when Ok is absent (including falsy values)', () => { + expect(extractMcpToolCallResultOutput({ Err: false })).toBe(false); + expect(extractMcpToolCallResultOutput({ Err: 0 })).toBe(0); + expect(extractMcpToolCallResultOutput({ Err: '' })).toBe(''); + expect(extractMcpToolCallResultOutput({ Err: null })).toBeNull(); + }); + + it('returns result as-is when it is not an Ok/Err object', () => { + expect(extractMcpToolCallResultOutput(false)).toBe(false); + expect(extractMcpToolCallResultOutput(0)).toBe(0); + expect(extractMcpToolCallResultOutput('')).toBe(''); + expect(extractMcpToolCallResultOutput(null)).toBeNull(); + expect(extractMcpToolCallResultOutput({ value: 1 })).toEqual({ value: 1 }); + }); +}); + diff --git a/cli/src/backends/codex/__tests__/resumeSessionIdConsumption.test.ts b/cli/src/backends/codex/__tests__/resumeSessionIdConsumption.test.ts new file mode 100644 index 000000000..1713ba0d2 --- /dev/null +++ b/cli/src/backends/codex/__tests__/resumeSessionIdConsumption.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; +import { nextStoredSessionIdForResumeAfterAttempt } from '../runCodex'; + +describe('nextStoredSessionIdForResumeAfterAttempt', () => { + it('keeps stored resume id when resume fails', () => { + expect(nextStoredSessionIdForResumeAfterAttempt('abc', { attempted: true, success: false })).toBe('abc'); + }); + + it('consumes stored resume id only when resume succeeds', () => { + expect(nextStoredSessionIdForResumeAfterAttempt('abc', { attempted: true, success: true })).toBe(null); + }); + + it('does not consume stored resume id when no resume attempt was made', () => { + expect(nextStoredSessionIdForResumeAfterAttempt('abc', { attempted: false, success: true })).toBe('abc'); + }); +}); + diff --git a/cli/src/backends/codex/acp/backend.ts b/cli/src/backends/codex/acp/backend.ts new file mode 100644 index 000000000..fadb53f60 --- /dev/null +++ b/cli/src/backends/codex/acp/backend.ts @@ -0,0 +1,36 @@ +/** + * Codex ACP Backend Factory + * + * Creates an ACP backend for Codex via the optional `codex-acp` capability install. + * Mirrors the Gemini ACP factory pattern (single place for command resolution). + */ + +import { AcpBackend, type AcpBackendOptions, type AcpPermissionHandler } from '@/agent/acp/AcpBackend'; +import type { AgentBackend, AgentFactoryOptions, McpServerConfig } from '@/agent/core'; +import { resolveCodexAcpCommand } from '@/backends/codex/acp/resolveCommand'; + +export interface CodexAcpBackendOptions extends AgentFactoryOptions { + mcpServers?: Record; + permissionHandler?: AcpPermissionHandler; +} + +export interface CodexAcpBackendResult { + backend: AgentBackend; + command: string; +} + +export function createCodexAcpBackend(options: CodexAcpBackendOptions): CodexAcpBackendResult { + const command = resolveCodexAcpCommand(); + + const backendOptions: AcpBackendOptions = { + agentName: 'codex', + cwd: options.cwd, + command, + args: [], + env: options.env, + mcpServers: options.mcpServers, + permissionHandler: options.permissionHandler, + }; + + return { backend: new AcpBackend(backendOptions), command }; +} diff --git a/cli/src/backends/codex/acp/resolveCommand.ts b/cli/src/backends/codex/acp/resolveCommand.ts new file mode 100644 index 000000000..d932ad437 --- /dev/null +++ b/cli/src/backends/codex/acp/resolveCommand.ts @@ -0,0 +1,31 @@ +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; + +import { configuration } from '@/configuration'; + +/** + * Resolve the Codex ACP binary. + * + * Codex ACP is provided by the optional `codex-acp` capability install. + */ +export function resolveCodexAcpCommand(): string { + const envOverride = typeof process.env.HAPPY_CODEX_ACP_BIN === 'string' + ? process.env.HAPPY_CODEX_ACP_BIN.trim() + : ''; + if (envOverride) { + if (!existsSync(envOverride)) { + throw new Error(`Codex ACP is enabled but HAPPY_CODEX_ACP_BIN does not exist: ${envOverride}`); + } + return envOverride; + } + + const binName = process.platform === 'win32' ? 'codex-acp.cmd' : 'codex-acp'; + const defaultPath = join(configuration.happyHomeDir, 'tools', 'codex-acp', 'node_modules', '.bin', binName); + if (existsSync(defaultPath)) { + return defaultPath; + } + + // Last-resort: rely on PATH (useful for local installs while developing). + return 'codex-acp'; +} + diff --git a/cli/src/backends/codex/acp/runtime.ts b/cli/src/backends/codex/acp/runtime.ts new file mode 100644 index 000000000..ecdc67ec4 --- /dev/null +++ b/cli/src/backends/codex/acp/runtime.ts @@ -0,0 +1,285 @@ +import { randomUUID } from 'node:crypto'; + +import { logger } from '@/ui/logger'; +import type { AgentBackend, AgentMessage, McpServerConfig } from '@/agent'; +import { createCatalogAcpBackend } from '@/agent/acp'; +import type { MessageBuffer } from '@/ui/ink/messageBuffer'; +import { maybeUpdateCodexSessionIdMetadata } from '@/backends/codex/utils/codexSessionIdMetadata'; +import type { CodexAcpBackendOptions, CodexAcpBackendResult } from '@/backends/codex/acp/backend'; +import { + handleAcpModelOutputDelta, + handleAcpStatusRunning, + forwardAcpPermissionRequest, + forwardAcpTerminalOutput, +} from '@/agent/acp/bridge/acpCommonHandlers'; +import type { ApiSessionClient } from '@/api/apiSession'; +import type { AcpPermissionHandler } from '@/agent/acp/AcpBackend'; +import { importAcpReplayHistoryV1 } from '@/agent/acp/history/importAcpReplayHistory'; +import { normalizeAvailableCommands, publishSlashCommandsToMetadata } from '@/agent/acp/commands/publishSlashCommands'; + +export function createCodexAcpRuntime(params: { + directory: string; + session: ApiSessionClient; + messageBuffer: MessageBuffer; + mcpServers: Record; + permissionHandler: AcpPermissionHandler; + onThinkingChange: (thinking: boolean) => void; +}) { + const lastCodexAcpThreadIdPublished: { value: string | null } = { value: null }; + + let backend: AgentBackend | null = null; + let sessionId: string | null = null; + + let accumulatedResponse = ''; + let isResponseInProgress = false; + let taskStartedSent = false; + let turnAborted = false; + let loadingSession = false; + + const publishThreadIdToMetadata = () => { + maybeUpdateCodexSessionIdMetadata({ + getCodexThreadId: () => sessionId, + updateHappySessionMetadata: (updater) => params.session.updateMetadata(updater), + lastPublished: lastCodexAcpThreadIdPublished, + }); + }; + + const resetTurnState = () => { + accumulatedResponse = ''; + isResponseInProgress = false; + taskStartedSent = false; + turnAborted = false; + loadingSession = false; + }; + + const attachMessageHandler = (b: AgentBackend) => { + b.onMessage((msg: AgentMessage) => { + if (loadingSession) { + if (msg.type === 'status' && msg.status === 'error') { + turnAborted = true; + params.session.sendAgentMessage('codex', { type: 'turn_aborted', id: randomUUID() }); + } + return; + } + + switch (msg.type) { + case 'model-output': { + handleAcpModelOutputDelta({ + delta: msg.textDelta ?? '', + messageBuffer: params.messageBuffer, + getIsResponseInProgress: () => isResponseInProgress, + setIsResponseInProgress: (value) => { isResponseInProgress = value; }, + appendToAccumulatedResponse: (delta) => { accumulatedResponse += delta; }, + }); + break; + } + + case 'status': { + if (msg.status === 'running') { + handleAcpStatusRunning({ + session: params.session, + agent: 'codex', + messageBuffer: params.messageBuffer, + onThinkingChange: params.onThinkingChange, + getTaskStartedSent: () => taskStartedSent, + setTaskStartedSent: (value) => { taskStartedSent = value; }, + makeId: () => randomUUID(), + }); + } + + if (msg.status === 'error') { + turnAborted = true; + params.session.sendAgentMessage('codex', { type: 'turn_aborted', id: randomUUID() }); + } + break; + } + + case 'tool-call': { + params.messageBuffer.addMessage(`Executing: ${msg.toolName}`, 'tool'); + params.session.sendAgentMessage('codex', { + type: 'tool-call', + callId: msg.callId, + name: msg.toolName, + input: msg.args, + id: randomUUID(), + }); + break; + } + + case 'tool-result': { + const maybeStream = + msg.result + && typeof msg.result === 'object' + && !Array.isArray(msg.result) + && (typeof (msg.result as any).stdoutChunk === 'string' || (msg.result as any)._stream === true); + if (!maybeStream) { + const outputText = msg.result == null + ? '(no output)' + : typeof msg.result === 'string' + ? msg.result + : JSON.stringify(msg.result).slice(0, 200); + params.messageBuffer.addMessage(`Result: ${outputText}`, 'result'); + } + params.session.sendAgentMessage('codex', { + type: 'tool-result', + callId: msg.callId, + output: msg.result, + id: randomUUID(), + }); + break; + } + + case 'fs-edit': { + params.messageBuffer.addMessage(`File edit: ${msg.description}`, 'tool'); + params.session.sendAgentMessage('codex', { + type: 'file-edit', + description: msg.description, + diff: msg.diff, + filePath: msg.path || 'unknown', + id: randomUUID(), + }); + break; + } + + case 'terminal-output': { + forwardAcpTerminalOutput({ + msg, + messageBuffer: params.messageBuffer, + session: params.session, + agent: 'codex', + getCallId: () => randomUUID(), + }); + break; + } + + case 'permission-request': { + forwardAcpPermissionRequest({ msg, session: params.session, agent: 'codex' }); + break; + } + + case 'event': { + if ((msg as any).name === 'available_commands_update') { + const payload = (msg as any).payload; + const details = normalizeAvailableCommands(payload?.availableCommands ?? payload); + publishSlashCommandsToMetadata({ session: params.session, details }); + } + if ((msg as any).name === 'thinking') { + const text = ((msg as any).payload?.text ?? '') as string; + if (text) { + params.session.sendAgentMessage('codex', { type: 'thinking', text }); + } + } + break; + } + } + }); + }; + + const ensureBackend = async (): Promise => { + if (backend) return backend; + const created = await createCatalogAcpBackend('codex', { + cwd: params.directory, + mcpServers: params.mcpServers, + permissionHandler: params.permissionHandler, + }); + backend = created.backend; + attachMessageHandler(backend); + logger.debug(`[CodexACP] Backend created (command=${created.command})`); + return backend; + }; + + return { + getSessionId: () => sessionId, + + beginTurn(): void { + turnAborted = false; + }, + + async reset(): Promise { + sessionId = null; + resetTurnState(); + + if (backend) { + try { + await backend.dispose(); + } catch (e) { + logger.debug('[CodexACP] Failed to dispose backend (non-fatal)', e); + } + backend = null; + } + }, + + async startOrLoad(opts: { resumeId?: string | null }): Promise { + const b = await ensureBackend(); + + if (opts.resumeId) { + const resumeId = opts.resumeId.trim(); + const loadWithReplay = b.loadSessionWithReplayCapture; + const loadSession = b.loadSession; + if (!loadSession && !loadWithReplay) { + throw new Error('Codex ACP backend does not support loading sessions'); + } + loadingSession = true; + let replay: any[] | null = null; + try { + if (loadWithReplay) { + const loaded = await loadWithReplay(resumeId); + sessionId = loaded.sessionId ?? resumeId; + replay = Array.isArray(loaded.replay) ? (loaded.replay as any[]) : null; + } else if (loadSession) { + const loaded = await loadSession(resumeId); + sessionId = loaded.sessionId ?? resumeId; + } else { + throw new Error('Codex ACP backend does not support loading sessions'); + } + } finally { + loadingSession = false; + } + + if (replay) { + importAcpReplayHistoryV1({ + session: params.session, + provider: 'codex', + remoteSessionId: resumeId, + replay, + permissionHandler: params.permissionHandler, + }).catch((e) => { + logger.debug('[CodexACP] Failed to import replay history (non-fatal)', e); + }); + } + } else { + const started = await b.startSession(); + sessionId = started.sessionId; + } + + publishThreadIdToMetadata(); + return sessionId; + }, + + async sendPrompt(prompt: string): Promise { + if (!sessionId) { + throw new Error('Codex ACP session was not started'); + } + const b = await ensureBackend(); + await b.sendPrompt(sessionId, prompt); + if (b.waitForResponseComplete) { + await b.waitForResponseComplete(120_000); + } + publishThreadIdToMetadata(); + }, + + flushTurn(): void { + if (accumulatedResponse.trim()) { + params.session.sendAgentMessage('codex', { type: 'message', message: accumulatedResponse }); + } + accumulatedResponse = ''; + isResponseInProgress = false; + + if (!turnAborted && taskStartedSent) { + params.session.sendAgentMessage('codex', { type: 'task_complete', id: randomUUID() }); + } + taskStartedSent = false; + turnAborted = false; + }, + }; +} diff --git a/cli/src/backends/codex/cli/capability.ts b/cli/src/backends/codex/cli/capability.ts new file mode 100644 index 000000000..8dce9ee87 --- /dev/null +++ b/cli/src/backends/codex/cli/capability.ts @@ -0,0 +1,47 @@ +import type { Capability } from '@/capabilities/service'; +import { buildCliCapabilityData } from '@/capabilities/probes/cliBase'; +import { probeAcpAgentCapabilities } from '@/capabilities/probes/acpProbe'; +import { DefaultTransport } from '@/agent/transport'; +import { resolveCodexAcpCommand } from '@/backends/codex/acp/resolveCommand'; +import { normalizeCapabilityProbeError } from '@/capabilities/utils/normalizeCapabilityProbeError'; +import { resolveAcpProbeTimeoutMs } from '@/capabilities/utils/acpProbeTimeout'; + +export const cliCapability: Capability = { + descriptor: { id: 'cli.codex', kind: 'cli', title: 'Codex CLI' }, + detect: async ({ request, context }) => { + const entry = context.cliSnapshot?.clis?.codex; + const base = buildCliCapabilityData({ request, entry }); + + const includeAcpCapabilities = Boolean((request.params ?? {}).includeAcpCapabilities); + if (!includeAcpCapabilities) { + return base; + } + + // Codex ACP is provided by the optional `codex-acp` binary (not the Codex CLI itself). + // Probe initialize to check for loadSession support so the UI can enable resume reliably. + const acp = await (async () => { + try { + const command = resolveCodexAcpCommand(); + const probe = await probeAcpAgentCapabilities({ + command, + args: [], + cwd: process.cwd(), + env: { + NODE_ENV: 'production', + DEBUG: '', + }, + transport: new DefaultTransport('codex'), + timeoutMs: resolveAcpProbeTimeoutMs('codex'), + }); + + return probe.ok + ? { ok: true as const, checkedAt: probe.checkedAt, loadSession: probe.agentCapabilities?.loadSession === true } + : { ok: false as const, checkedAt: probe.checkedAt, error: normalizeCapabilityProbeError(probe.error) }; + } catch (e) { + return { ok: false as const, checkedAt: Date.now(), error: normalizeCapabilityProbeError(e) }; + } + })(); + + return { ...base, acp }; + }, +}; diff --git a/cli/src/backends/codex/cli/checklists.ts b/cli/src/backends/codex/cli/checklists.ts new file mode 100644 index 000000000..ac1de151e --- /dev/null +++ b/cli/src/backends/codex/cli/checklists.ts @@ -0,0 +1,21 @@ +import { CODEX_MCP_RESUME_DIST_TAG } from '@/capabilities/deps/codexMcpResume'; +import type { AgentChecklistContributions } from '@/backends/types'; + +export const checklists = { + 'resume.codex': [ + // Codex can be resumed via either: + // - MCP resume (codex-mcp-resume), or + // - ACP resume (codex-acp + ACP `loadSession` support) + // + // The app uses this checklist for inactive-session resume UX, so include both: + // - `includeAcpCapabilities` so the UI can enable/disable resume correctly when `expCodexAcp` is enabled + // - dep statuses so we can block with a helpful install prompt + { id: 'cli.codex', params: { includeAcpCapabilities: true, includeLoginStatus: true } }, + { id: 'dep.codex-acp', params: { onlyIfInstalled: true, includeRegistry: true } }, + { + id: 'dep.codex-mcp-resume', + params: { includeRegistry: true, onlyIfInstalled: true, distTag: CODEX_MCP_RESUME_DIST_TAG }, + }, + ], +} satisfies AgentChecklistContributions; + diff --git a/cli/src/backends/codex/cli/command.ts b/cli/src/backends/codex/cli/command.ts new file mode 100644 index 000000000..f07a873b2 --- /dev/null +++ b/cli/src/backends/codex/cli/command.ts @@ -0,0 +1,52 @@ +import chalk from 'chalk'; + +import { CODEX_PERMISSION_MODES, isCodexPermissionMode } from '@/api/types'; +import { authAndSetupMachineIfNeeded } from '@/ui/auth'; +import { parseSessionStartArgs } from '@/cli/sessionStartArgs'; + +import type { CommandContext } from '@/cli/commandRegistry'; + +export async function handleCodexCliCommand(context: CommandContext): Promise { + try { + const { runCodex } = await import('@/backends/codex/runCodex'); + + const { startedBy, permissionMode, permissionModeUpdatedAt } = parseSessionStartArgs(context.args); + if (permissionMode && !isCodexPermissionMode(permissionMode)) { + console.error( + chalk.red( + `Invalid --permission-mode for codex: ${permissionMode}. Valid values: ${CODEX_PERMISSION_MODES.join(', ')}`, + ), + ); + console.error(chalk.gray('Tip: use --yolo for full bypass-like behavior.')); + process.exit(1); + } + + const readFlagValue = (flag: string): string | undefined => { + const idx = context.args.indexOf(flag); + if (idx === -1) return undefined; + const value = context.args[idx + 1]; + if (!value || value.startsWith('-')) return undefined; + return value; + }; + + const existingSessionId = readFlagValue('--existing-session'); + const resume = readFlagValue('--resume'); + + const { credentials } = await authAndSetupMachineIfNeeded(); + await runCodex({ + credentials, + startedBy, + terminalRuntime: context.terminalRuntime, + permissionMode, + permissionModeUpdatedAt, + existingSessionId, + resume, + }); + } catch (error) { + console.error(chalk.red('Error:'), error instanceof Error ? error.message : 'Unknown error'); + if (process.env.DEBUG) { + console.error(error); + } + process.exit(1); + } +} diff --git a/cli/src/backends/codex/cli/detect.ts b/cli/src/backends/codex/cli/detect.ts new file mode 100644 index 000000000..18c179cce --- /dev/null +++ b/cli/src/backends/codex/cli/detect.ts @@ -0,0 +1,7 @@ +import type { CliDetectSpec } from '@/backends/types'; + +export const cliDetect = { + versionArgsToTry: [['--version'], ['version'], ['-v']], + loginStatusArgs: ['login', 'status'], +} satisfies CliDetectSpec; + diff --git a/cli/src/backends/codex/cli/extraCapabilities.ts b/cli/src/backends/codex/cli/extraCapabilities.ts new file mode 100644 index 000000000..b932af7f1 --- /dev/null +++ b/cli/src/backends/codex/cli/extraCapabilities.ts @@ -0,0 +1,9 @@ +import { codexAcpDepCapability } from '@/capabilities/registry/depCodexAcp'; +import { codexMcpResumeDepCapability } from '@/capabilities/registry/depCodexMcpResume'; +import type { Capability } from '@/capabilities/service'; + +export const capabilities: ReadonlyArray = [ + codexMcpResumeDepCapability, + codexAcpDepCapability, +]; + diff --git a/cli/src/commands/connect/authenticateCodex.ts b/cli/src/backends/codex/cloud/authenticate.ts similarity index 91% rename from cli/src/commands/connect/authenticateCodex.ts rename to cli/src/backends/codex/cloud/authenticate.ts index 24b3172ef..79aa9f39a 100644 --- a/cli/src/commands/connect/authenticateCodex.ts +++ b/cli/src/backends/codex/cloud/authenticate.ts @@ -6,35 +6,22 @@ */ import { createServer, IncomingMessage, ServerResponse } from 'http'; -import { randomBytes, createHash } from 'crypto'; -import { CodexAuthTokens, PKCECodes } from './types'; -import { openBrowser } from '@/utils/browser'; +import { randomBytes } from 'crypto'; +import { openBrowser } from '@/ui/openBrowser'; +import { generatePkceCodes } from '@/cloud/pkce'; + +export interface CodexAuthTokens { + id_token: string; + access_token: string; + refresh_token: string; + account_id: string; +} // Configuration const CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann'; const AUTH_BASE_URL = 'https://auth.openai.com'; const DEFAULT_PORT = 1455; -/** - * Generate PKCE codes for OAuth flow - */ -function generatePKCE(): PKCECodes { - // Generate code verifier (43-128 characters, base64url) - const verifier = randomBytes(32) - .toString('base64url') - .replace(/[^a-zA-Z0-9\-._~]/g, ''); - - // Generate code challenge (SHA256 of verifier, base64url encoded) - const challenge = createHash('sha256') - .update(verifier) - .digest('base64url') - .replace(/=/g, '') - .replace(/\+/g, '-') - .replace(/\//g, '_'); - - return { verifier, challenge }; -} - /** * Generate random state for OAuth security */ @@ -222,7 +209,7 @@ export async function authenticateCodex(): Promise { // console.log('🚀 Starting Codex authentication...'); // Generate PKCE codes and state - const { verifier, challenge } = generatePKCE(); + const { verifier, challenge } = generatePkceCodes(); const state = generateState(); // Try to use default port, or find an available one @@ -278,4 +265,4 @@ export async function authenticateCodex(): Promise { // console.log(`Account ID: ${tokens.account_id || 'N/A'}`); return tokens; -} \ No newline at end of file +} diff --git a/cli/src/backends/codex/cloud/connect.ts b/cli/src/backends/codex/cloud/connect.ts new file mode 100644 index 000000000..10a6b52cc --- /dev/null +++ b/cli/src/backends/codex/cloud/connect.ts @@ -0,0 +1,12 @@ +import type { CloudConnectTarget } from '@/cloud/connectTypes'; +import { AGENTS_CORE } from '@happy/agents'; +import { authenticateCodex } from './authenticate'; + +export const codexCloudConnect: CloudConnectTarget = { + id: 'codex', + displayName: 'Codex', + vendorDisplayName: 'OpenAI Codex', + vendorKey: AGENTS_CORE.codex.cloudConnect!.vendorKey, + status: AGENTS_CORE.codex.cloudConnect!.status, + authenticate: authenticateCodex, +}; diff --git a/cli/src/backends/codex/codexMcpClient.test.ts b/cli/src/backends/codex/codexMcpClient.test.ts new file mode 100644 index 000000000..fae246648 --- /dev/null +++ b/cli/src/backends/codex/codexMcpClient.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect, vi } from 'vitest'; +import { getCodexElicitationToolCallId, getCodexEventToolCallId } from './codexMcpClient'; + +// NOTE: This test suite uses mocks because the real Codex CLI / MCP transport +// is not guaranteed to be available in CI or local test environments. +vi.mock('child_process', () => ({ + execFileSync: vi.fn(), +})); + +vi.mock('@modelcontextprotocol/sdk/types.js', async () => { + const { z } = await import('zod'); + return { + RequestSchema: z.object({}).passthrough(), + ElicitRequestParamsSchema: z.object({}).passthrough(), + ElicitRequestSchema: z.object({}).passthrough(), + }; +}); + +vi.mock('@modelcontextprotocol/sdk/client/stdio.js', () => { + const instances: any[] = []; + + class StdioClientTransport { + public command: string; + public args: string[]; + public env: Record; + + constructor(opts: { command: string; args: string[]; env: Record }) { + this.command = opts.command; + this.args = opts.args; + this.env = opts.env; + instances.push(this); + } + } + + return { StdioClientTransport, __transportInstances: instances }; +}); + +vi.mock('@modelcontextprotocol/sdk/client/index.js', () => { + class Client { + setNotificationHandler() { } + setRequestHandler() { } + async connect() { } + async close() { } + } + + return { Client }; +}); + +describe('CodexMcpClient elicitation ids', () => { + it('prefers codex_call_id over codex_mcp_tool_call_id', () => { + expect(getCodexElicitationToolCallId({ + codex_mcp_tool_call_id: 'mcp-1', + codex_call_id: 'call-1', + })).toBe('call-1'); + }); + + it('falls back to codex_mcp_tool_call_id when codex_call_id is missing', () => { + expect(getCodexElicitationToolCallId({ + codex_mcp_tool_call_id: 'mcp-1', + })).toBe('mcp-1'); + }); +}); + +describe('CodexMcpClient event ids', () => { + it('prefers call_id over mcp_tool_call_id', () => { + expect(getCodexEventToolCallId({ + mcp_tool_call_id: 'mcp-1', + call_id: 'call-1', + })).toBe('call-1'); + }); + + it('falls back to mcp_tool_call_id when call_id is missing', () => { + expect(getCodexEventToolCallId({ + mcp_tool_call_id: 'mcp-1', + })).toBe('mcp-1'); + }); +}); + +describe('CodexMcpClient command detection', () => { + it('does not treat "codex " output as "not installed"', async () => { + vi.resetModules(); + + const { execFileSync } = await import('child_process'); + (execFileSync as any).mockReturnValue('codex 0.43.0-alpha.5\n'); + + const stdioModule = (await import('@modelcontextprotocol/sdk/client/stdio.js')) as any; + const __transportInstances = stdioModule.__transportInstances as any[]; + __transportInstances.length = 0; + + const mod = await import('./codexMcpClient'); + + const client = new (mod as any).CodexMcpClient(); + await expect(client.connect()).resolves.toBeUndefined(); + + expect(__transportInstances.length).toBe(1); + expect(__transportInstances[0].command).toBe('codex'); + expect(__transportInstances[0].args).toEqual(['mcp-server']); + }); +}); diff --git a/cli/src/backends/codex/codexMcpClient.ts b/cli/src/backends/codex/codexMcpClient.ts new file mode 100644 index 000000000..8006be61d --- /dev/null +++ b/cli/src/backends/codex/codexMcpClient.ts @@ -0,0 +1,795 @@ +/** + * Codex MCP Client - Simple wrapper for Codex tools + */ + +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; +import { logger } from '@/ui/logger'; +import type { CodexSessionConfig, CodexToolResponse } from './types'; +import { z } from 'zod'; +import { ElicitRequestParamsSchema, RequestSchema } from '@modelcontextprotocol/sdk/types.js'; +import { CodexPermissionHandler } from './utils/permissionHandler'; +import { execFileSync } from 'child_process'; +import { randomUUID } from 'node:crypto'; + +const DEFAULT_TIMEOUT = 14 * 24 * 60 * 60 * 1000; // 14 days, which is the half of the maximum possible timeout (~28 days for int32 value in NodeJS) + +type CodexMcpClientSpawnMode = 'codex-cli' | 'mcp-server'; + +const ElicitRequestSchemaWithExtras = RequestSchema.extend({ + method: z.literal('elicitation/create'), + // Codex adds extra fields beyond the MCP SDK schema; accept any params payload + // and decode the codex_* fields at usage sites. + params: z.any() +}); + +// ============================================================================ +// Codex Elicitation Request Types (from Codex MCP server) +// Field names are stable since v0.9.0 - all use codex_* prefix +// ============================================================================ + +/** Common fields shared by all elicitation requests */ +interface CodexElicitationBase { + message: string; + codex_elicitation: 'exec-approval' | 'patch-approval'; + codex_mcp_tool_call_id: string; + codex_event_id: string; + codex_call_id: string; +} + +/** Exec approval request params (command execution) */ +interface ExecApprovalParams extends CodexElicitationBase { + codex_elicitation: 'exec-approval'; + codex_command: string[]; + codex_cwd: string; + codex_parsed_cmd?: Array<{ cmd: string; args?: string[] }>; // Added in ~v0.46 +} + +/** Patch approval request params (code changes) */ +interface PatchApprovalParams extends CodexElicitationBase { + codex_elicitation: 'patch-approval'; + codex_reason?: string; + codex_grant_root?: string; + codex_changes: Record; +} + +type CodexElicitationParams = ExecApprovalParams | PatchApprovalParams; + +// ============================================================================ +// Elicitation Response Types +// ============================================================================ + +type ElicitationAction = 'accept' | 'decline' | 'cancel'; + +/** + * Codex ReviewDecision::ApprovedExecpolicyAmendment variant + * + * Rust definition uses: + * - #[serde(rename_all = "snake_case")] on enum -> variant name is snake_case + * - #[serde(transparent)] on ExecPolicyAmendment -> serializes as array directly + * + * Result: { "approved_execpolicy_amendment": { "proposed_execpolicy_amendment": ["cmd", "arg1", ...] } } + */ +type ExecpolicyAmendmentDecision = { + approved_execpolicy_amendment: { + proposed_execpolicy_amendment: string[]; // transparent: directly an array, not { command: [...] } + }; +}; +/** + * Codex ReviewDecision enum - uses #[serde(rename_all = "snake_case")] + * See: codex-rs/protocol/src/protocol.rs + */ +type ReviewDecision = + | 'approved' + | 'approved_for_session' + | 'denied' + | 'abort' + | ExecpolicyAmendmentDecision; + +/** + * Response format changed in v0.77: + * - 'decision': v0.9 ~ v0.77 (ReviewDecision only) + * - 'both': v0.77+ (action + decision + content) + */ +type ElicitationResponseStyle = 'decision' | 'both'; + +export function getCodexElicitationToolCallId(params: Record): string | undefined { + const callId = params.codex_call_id; + if (typeof callId === 'string') { + return callId; + } + + const mcpToolCallId = params.codex_mcp_tool_call_id; + if (typeof mcpToolCallId === 'string') { + return mcpToolCallId; + } + + return undefined; +} + +export function getCodexEventToolCallId(msg: Record): string | undefined { + const callId = msg.call_id ?? msg.codex_call_id; + if (typeof callId === 'string') { + return callId; + } + + const mcpToolCallId = msg.mcp_tool_call_id ?? msg.codex_mcp_tool_call_id; + if (typeof mcpToolCallId === 'string') { + return mcpToolCallId; + } + + return undefined; +} + +// ============================================================================ +// Version Detection +// ============================================================================ + +interface CodexVersionInfo { + raw: string | null; + parsed: boolean; + major: number; + minor: number; + patch: number; + prereleaseTag?: string; + prereleaseNum?: number; +} + +type CodexVersionTarget = Pick< + CodexVersionInfo, + 'major' | 'minor' | 'patch' | 'prereleaseTag' | 'prereleaseNum' +>; + +const MCP_SERVER_MIN_VERSION = { + major: 0, + minor: 43, + patch: 0, + prereleaseTag: 'alpha', + prereleaseNum: 5 +}; + +// Codex CLI <= 0.77.0 still expects ReviewDecision in exec/patch approvals. +const ELICITATION_DECISION_MAX_VERSION: CodexVersionTarget = { + major: 0, + minor: 77, + patch: 0 +}; + +const cachedCodexVersionInfoByCommand = new Map(); + +function getCodexVersionInfo(codexCommand: string): CodexVersionInfo { + const cached = cachedCodexVersionInfoByCommand.get(codexCommand); + if (cached) return cached; + + try { + const raw = execFileSync(codexCommand, ['--version'], { encoding: 'utf8' }).trim(); + const match = raw.match(/(?:codex(?:-cli)?)\s+v?(\d+)\.(\d+)\.(\d+)(?:-([a-z]+)\.(\d+))?/i) + ?? raw.match(/\b(\d+)\.(\d+)\.(\d+)(?:-([a-z]+)\.(\d+))?\b/); + if (!match) { + const info: CodexVersionInfo = { + raw, + parsed: false, + major: 0, + minor: 0, + patch: 0 + }; + cachedCodexVersionInfoByCommand.set(codexCommand, info); + return info; + } + + const major = Number(match[1]); + const minor = Number(match[2]); + const patch = Number(match[3]); + const prereleaseTag = match[4]; + const prereleaseNum = match[5] ? Number(match[5]) : undefined; + + const info: CodexVersionInfo = { + raw, + parsed: true, + major, + minor, + patch, + prereleaseTag, + prereleaseNum + }; + cachedCodexVersionInfoByCommand.set(codexCommand, info); + return info; + } catch (error) { + logger.debug(`[CodexMCP] Error detecting codex version for ${codexCommand}:`, error); + const info: CodexVersionInfo = { + raw: null, + parsed: false, + major: 0, + minor: 0, + patch: 0 + }; + cachedCodexVersionInfoByCommand.set(codexCommand, info); + return info; + } +} + +function compareVersions(info: CodexVersionInfo, target: CodexVersionTarget): number { + if (info.major !== target.major) return info.major - target.major; + if (info.minor !== target.minor) return info.minor - target.minor; + if (info.patch !== target.patch) return info.patch - target.patch; + + const infoTag = info.prereleaseTag; + const targetTag = target.prereleaseTag; + if (!infoTag && !targetTag) return 0; + if (!infoTag && targetTag) return 1; + if (infoTag && !targetTag) return -1; + if (!infoTag || !targetTag) return 0; + if (infoTag !== targetTag) return infoTag.localeCompare(targetTag); + + const infoNum = info.prereleaseNum ?? 0; + const targetNum = target.prereleaseNum ?? 0; + return infoNum - targetNum; +} + +function isVersionAtLeast(info: CodexVersionInfo, target: CodexVersionTarget): boolean { + if (!info.parsed) return false; + return compareVersions(info, target) >= 0; +} + +function isVersionAtMost(info: CodexVersionInfo, target: CodexVersionTarget): boolean { + if (!info.parsed) return false; + return compareVersions(info, target) <= 0; +} + +function getElicitationResponseStyle(info: CodexVersionInfo): ElicitationResponseStyle { + const override = process.env.HAPPY_CODEX_ELICITATION_STYLE?.toLowerCase(); + if (override === 'decision' || override === 'both') { + return override; + } + + // Default to 'both' if version unknown (safer for newer versions) + if (!info.parsed) return 'both'; + // v0.77 and earlier expect ReviewDecision format + return isVersionAtMost(info, ELICITATION_DECISION_MAX_VERSION) ? 'decision' : 'both'; +} + +function buildElicitationResponse( + style: ElicitationResponseStyle, + action: ElicitationAction, + decision: ReviewDecision +): { action: ElicitationAction; decision?: ReviewDecision; content?: Record } { + if (style === 'decision') { + // v0.77 and earlier: ReviewDecision format + return { action, decision }; + } + // v0.77+: Full elicitation response with action + decision + content + return { action, decision, content: {} }; +} + +function isExecpolicyAmendmentDecision( + decision: ReviewDecision +): decision is ExecpolicyAmendmentDecision { + return typeof decision === 'object' + && decision !== null + && 'approved_execpolicy_amendment' in decision; +} + +/** + * Get the correct MCP subcommand based on installed codex version + * Versions >= 0.43.0-alpha.5 use 'mcp-server', older versions use 'mcp' + */ +function getCodexMcpCommand(codexCommand: string): string { + const info = getCodexVersionInfo(codexCommand); + if (!info.parsed) return 'mcp-server'; + + // Version >= 0.43.0-alpha.5 has mcp-server + return isVersionAtLeast(info, MCP_SERVER_MIN_VERSION) ? 'mcp-server' : 'mcp'; +} + +export class CodexMcpClient { + private client: Client; + private transport: StdioClientTransport | null = null; + private connected: boolean = false; + private threadId: string | null = null; + private conversationId: string | null = null; + private handler: ((event: any) => void) | null = null; + private permissionHandler: CodexPermissionHandler | null = null; + private codexCommand: string; + private mode: CodexMcpClientSpawnMode; + private mcpServerArgs: string[]; + /** Cached proposed_execpolicy_amendment from notifications, keyed by call_id */ + private pendingAmendments = new Map(); + + constructor(options?: { command?: string; mode?: CodexMcpClientSpawnMode; args?: string[] }) { + this.codexCommand = options?.command ?? 'codex'; + this.mode = options?.mode ?? 'codex-cli'; + this.mcpServerArgs = options?.args ?? []; + + this.client = new Client( + { name: 'happy-codex-client', version: '1.0.0' }, + { capabilities: { elicitation: {} } } + ); + + const CodexEventNotificationSchema = z.object({ + method: z.literal('codex/event'), + params: z.object({ + msg: z.any() + }) + }).passthrough() as any; + + this.client.setNotificationHandler(CodexEventNotificationSchema, (data: any) => { + const msg = data.params.msg as Record | null; + this.updateIdentifiersFromEvent(msg); + this.handler?.(msg); + + // Cache proposed_execpolicy_amendment for later use in elicitation request + if (msg && msg.type === 'exec_approval_request') { + const callId = getCodexEventToolCallId(msg); + const amendment = msg.proposed_execpolicy_amendment; + if (typeof callId === 'string' && Array.isArray(amendment)) { + this.pendingAmendments.set(callId, amendment.filter((p): p is string => typeof p === 'string')); + } + } + }); + } + + setHandler(handler: ((event: any) => void) | null): void { + this.handler = handler; + } + + /** + * Set the permission handler for tool approval + */ + setPermissionHandler(handler: CodexPermissionHandler): void { + this.permissionHandler = handler; + } + + async connect(): Promise { + if (this.connected) return; + + const transportArgs = (() => { + if (this.mode === 'mcp-server') { + logger.debug(`[CodexMCP] Connecting to MCP server using command: ${this.codexCommand} ${this.mcpServerArgs.join(' ')}`.trim()); + return this.mcpServerArgs; + } + + const versionInfo = getCodexVersionInfo(this.codexCommand); + logger.debug('[CodexMCP] Detected codex version', versionInfo); + + if (versionInfo.raw === null) { + throw new Error( + `Codex CLI not found or not executable: ${this.codexCommand}\n` + + '\n' + + 'To install codex:\n' + + ' npm install -g @openai/codex\n' + + '\n' + + 'Alternatively, use Claude:\n' + + ' happy claude' + ); + } + + const mcpCommand = getCodexMcpCommand(this.codexCommand); + logger.debug(`[CodexMCP] Connecting to Codex MCP server using command: ${this.codexCommand} ${mcpCommand}`); + return [mcpCommand]; + })(); + + this.transport = new StdioClientTransport({ + command: this.codexCommand, + args: transportArgs, + env: Object.keys(process.env).reduce((acc, key) => { + const value = process.env[key]; + if (typeof value === 'string') acc[key] = value; + return acc; + }, {} as Record) + }); + + // Register request handlers for Codex permission methods + this.registerPermissionHandlers(); + + await this.client.connect(this.transport); + this.connected = true; + + logger.debug('[CodexMCP] Connected to Codex'); + } + + private registerPermissionHandlers(): void { + const versionInfo = getCodexVersionInfo(this.codexCommand); + const responseStyle = getElicitationResponseStyle(versionInfo); + logger.debug('[CodexMCP] Elicitation response style', { + style: responseStyle, + version: versionInfo.raw + }); + + this.client.setRequestHandler( + ElicitRequestSchemaWithExtras, + async (request) => { + const params = (request.params ?? {}) as Record; + logger.debugLargeJson('[CodexMCP] Received elicitation request', params); + + // Extract fields using stable codex_* field names (since v0.9). + // Prefer codex_call_id/call_id for local correlation because codex_mcp_tool_call_id can repeat. + const toolCallId = getCodexElicitationToolCallId(params) ?? randomUUID(); + const elicitationType = this.extractString(params, 'codex_elicitation'); + const message = this.extractString(params, 'message') ?? ''; + + const isPatchApproval = elicitationType === 'patch-approval'; + const toolName = isPatchApproval ? 'CodexPatch' : 'CodexBash'; + + // Get and consume cached proposed_execpolicy_amendment from notification + const cachedAmendment = this.pendingAmendments.get(toolCallId); + this.pendingAmendments.delete(toolCallId); + + // Build tool input based on elicitation type + const toolInput = isPatchApproval + ? this.buildPatchToolInput(params, message) + : this.buildExecToolInput(params, cachedAmendment); + + logger.debug('[CodexMCP] Permission request', { + toolCallId, + toolName, + elicitationType + }); + + // Deny by default if no permission handler + if (!this.permissionHandler) { + logger.debug('[CodexMCP] No permission handler, denying'); + return buildElicitationResponse(responseStyle, 'decline', 'denied'); + } + + try { + const result = await this.permissionHandler.handleToolCall( + toolCallId, + toolName, + toolInput + ); + + const decision = this.mapResultToDecision(result); + const action = this.mapDecisionToAction(decision); + + logger.debug('[CodexMCP] Sending response', { + toolCallId, + decision, + action, + responseStyle + }); + return buildElicitationResponse(responseStyle, action, decision); + } catch (error) { + logger.debug('[CodexMCP] Error handling permission:', error); + return buildElicitationResponse(responseStyle, 'decline', 'denied'); + } + } + ); + + logger.debug('[CodexMCP] Permission handlers registered'); + } + + /** Extract string field from params */ + private extractString(params: Record, key: string): string | undefined { + const value = params[key]; + return typeof value === 'string' && value.length > 0 ? value : undefined; + } + + /** + * Build tool input for exec approval (command execution) + * @param params - Elicitation request params + * @param cachedAmendment - Cached proposed_execpolicy_amendment from notification + */ + private buildExecToolInput( + params: Record, + cachedAmendment?: string[] + ): { + command: string[]; + cwd?: string; + parsed_cmd?: unknown[]; + reason?: string; + proposedExecpolicyAmendment?: string[]; + } { + // codex_command is the full shell command (e.g., ["/bin/zsh", "-lc", "yarn dev"]) + const command = Array.isArray(params.codex_command) + ? params.codex_command.filter((p): p is string => typeof p === 'string') + : []; + const cwd = this.extractString(params, 'codex_cwd'); + const parsed_cmd = Array.isArray(params.codex_parsed_cmd) + ? params.codex_parsed_cmd + : undefined; + const reason = this.extractString(params, 'codex_reason'); + + // Use cached amendment from notification (e.g., ["yarn", "dev"]) + // This is the correct user-friendly command, not the full shell wrapper + const proposedExecpolicyAmendment = cachedAmendment; + + return { command, cwd, parsed_cmd, reason, proposedExecpolicyAmendment }; + } + + /** Build tool input for patch approval (code changes) */ + private buildPatchToolInput(params: Record, message: string): { + message: string; + reason?: string; + grantRoot?: string; + changes?: unknown; + } { + const reason = this.extractString(params, 'codex_reason'); + const grantRoot = this.extractString(params, 'codex_grant_root'); + const changes = typeof params.codex_changes === 'object' && params.codex_changes !== null + ? params.codex_changes + : undefined; + + return { message, reason, grantRoot, changes }; + } + + /** + * Map permission handler result to Codex ReviewDecision + * Both use snake_case (Codex uses #[serde(rename_all = "snake_case")]) + * ExecPolicyAmendment uses #[serde(transparent)] so it's just an array + */ + private mapResultToDecision(result: { + decision: 'approved' | 'approved_for_session' | 'approved_execpolicy_amendment' | 'denied' | 'abort'; + execPolicyAmendment?: { command: string[] }; + }): ReviewDecision { + switch (result.decision) { + case 'approved_execpolicy_amendment': + if (result.execPolicyAmendment?.command?.length) { + return { + approved_execpolicy_amendment: { + // transparent: directly the array, not { command: [...] } + proposed_execpolicy_amendment: result.execPolicyAmendment.command + } + }; + } + logger.debug('[CodexMCP] Missing execpolicy amendment, falling back to approved'); + return 'approved'; + case 'approved': + return 'approved'; + case 'approved_for_session': + return 'approved_for_session'; + case 'denied': + return 'denied'; + case 'abort': + return 'abort'; + } + } + + /** Map ReviewDecision to ElicitationAction */ + private mapDecisionToAction(decision: ReviewDecision): ElicitationAction { + if (decision === 'approved' || decision === 'approved_for_session' || isExecpolicyAmendmentDecision(decision)) { + return 'accept'; + } + if (decision === 'abort') { + return 'cancel'; + } + return 'decline'; + } + + async startSession(config: CodexSessionConfig, options?: { signal?: AbortSignal }): Promise { + if (!this.connected) await this.connect(); + + logger.debug('[CodexMCP] Starting Codex session:', config); + + const response = await this.client.callTool({ + name: 'codex', + arguments: config as any + }, undefined, { + signal: options?.signal, + timeout: DEFAULT_TIMEOUT, + // maxTotalTimeout: 10000000000 + }); + + logger.debug('[CodexMCP] startSession response:', response); + + // Extract session / conversation identifiers from response if present + this.extractIdentifiers(response); + + return response as CodexToolResponse; + } + + async continueSession(prompt: string, options?: { signal?: AbortSignal }): Promise { + if (!this.connected) await this.connect(); + + if (!this.threadId) { + throw new Error('No active session. Call startSession first.'); + } + + if (!this.conversationId) { + // Some Codex deployments reuse the thread id as the conversation identifier. + this.conversationId = this.threadId; + logger.debug('[CodexMCP] conversationId missing, defaulting to threadId:', this.conversationId); + } + + const args: Record = { threadId: this.threadId, prompt }; + if (this.conversationId) { + args.conversationId = this.conversationId; + } + logger.debug('[CodexMCP] Continuing Codex session:', args); + + const response = await this.client.callTool({ + name: 'codex-reply', + arguments: args + }, undefined, { + signal: options?.signal, + timeout: DEFAULT_TIMEOUT + }); + + logger.debug('[CodexMCP] continueSession response:', response); + this.extractIdentifiers(response); + + return response as CodexToolResponse; + } + + + private updateIdentifiersFromEvent(event: any): void { + if (!event || typeof event !== 'object') { + return; + } + + const candidates: any[] = [event]; + if (event.data && typeof event.data === 'object') { + candidates.push(event.data); + } + + for (const candidate of candidates) { + const threadId = + candidate.thread_id + ?? candidate.threadId + ?? candidate.session_id + ?? candidate.sessionId; + if (threadId) { + this.threadId = threadId; + logger.debug('[CodexMCP] Thread ID extracted from event:', this.threadId); + } + + const conversationId = candidate.conversation_id ?? candidate.conversationId; + if (conversationId) { + this.conversationId = conversationId; + logger.debug('[CodexMCP] Conversation ID extracted from event:', this.conversationId); + } + } + } + private extractIdentifiers(response: any): void { + const meta = response?.meta || {}; + const structured = + response?.structuredContent + ?? response?.structured_content + ?? response?.structured_output + ?? undefined; + + const threadId = + (structured && typeof structured === 'object' ? (structured as any).threadId ?? (structured as any).thread_id : undefined) + ?? meta.threadId + ?? meta.thread_id + ?? meta.sessionId + ?? meta.session_id + ?? response?.threadId + ?? response?.thread_id + ?? response?.sessionId + ?? response?.session_id; + if (threadId) { + this.threadId = threadId; + logger.debug('[CodexMCP] Thread ID extracted:', this.threadId); + } + + const conversationId = + (structured && typeof structured === 'object' ? (structured as any).conversationId ?? (structured as any).conversation_id : undefined) + ?? meta.conversationId + ?? meta.conversation_id + ?? response?.conversationId + ?? response?.conversation_id; + if (conversationId) { + this.conversationId = conversationId; + logger.debug('[CodexMCP] Conversation ID extracted:', this.conversationId); + } + + const content = response?.content; + if (Array.isArray(content)) { + for (const item of content) { + if (!this.threadId && item?.threadId) { + this.threadId = item.threadId; + logger.debug('[CodexMCP] Thread ID extracted from content:', this.threadId); + } + if (!this.threadId && item?.sessionId) { + // Some Codex events still surface the thread id under `sessionId`. + this.threadId = item.sessionId; + logger.debug('[CodexMCP] Thread ID extracted from content (sessionId):', this.threadId); + } + if (!this.conversationId && item && typeof item === 'object' && 'conversationId' in item && item.conversationId) { + this.conversationId = item.conversationId; + logger.debug('[CodexMCP] Conversation ID extracted from content:', this.conversationId); + } + } + } + } + + getThreadId(): string | null { + return this.threadId; + } + + getSessionId(): string | null { + // Backwards-compat: our callers historically used "sessionId", but Codex standardizes on "threadId". + return this.threadId; + } + + /** + * Fork-only: seed the MCP client with an existing Codex session id so we can resume + * with `codex-reply` without relying on transcript files. + */ + setThreadIdForResume(threadId: string): void { + this.threadId = threadId; + // conversationId will be defaulted to threadId on first reply if missing. + this.conversationId = null; + logger.debug('[CodexMCP] Session seeded for resume:', this.threadId); + } + + /** + * Backwards-compat alias. + * + * Prefer `setThreadIdForResume()` (Codex v0.81+). + */ + setSessionIdForResume(sessionId: string): void { + this.setThreadIdForResume(sessionId); + } + + hasActiveSession(): boolean { + return this.threadId !== null; + } + + clearSession(): void { + // Store the previous session ID before clearing for potential resume + const previousSessionId = this.threadId; + this.threadId = null; + this.conversationId = null; + logger.debug('[CodexMCP] Session cleared, previous sessionId:', previousSessionId); + } + + /** + * Store the current session ID without clearing it, useful for abort handling + */ + storeSessionForResume(): string | null { + logger.debug('[CodexMCP] Storing session for potential resume:', this.threadId); + return this.threadId; + } + + /** + * Force close the Codex MCP transport and clear all session identifiers. + * Use this for permanent shutdown (e.g. kill/exit). Prefer `disconnect()` for + * transient connection resets where you may want to keep the session id. + */ + async forceCloseSession(): Promise { + logger.debug('[CodexMCP] Force closing session'); + try { + await this.disconnect(); + } finally { + this.clearSession(); + } + logger.debug('[CodexMCP] Session force-closed'); + } + + async disconnect(): Promise { + if (!this.connected) return; + + // Capture pid in case we need to force-kill + const pid = this.transport?.pid ?? null; + logger.debug(`[CodexMCP] Disconnecting; child pid=${pid ?? 'none'}`); + + try { + // Ask client to close the transport + logger.debug('[CodexMCP] client.close begin'); + await this.client.close(); + logger.debug('[CodexMCP] client.close done'); + } catch (e) { + logger.debug('[CodexMCP] Error closing client, attempting transport close directly', e); + try { + logger.debug('[CodexMCP] transport.close begin'); + await this.transport?.close?.(); + logger.debug('[CodexMCP] transport.close done'); + } catch {} + } + + // As a last resort, if child still exists, send SIGKILL + if (pid) { + try { + process.kill(pid, 0); // check if alive + logger.debug('[CodexMCP] Child still alive, sending SIGKILL'); + try { process.kill(pid, 'SIGKILL'); } catch {} + } catch { /* not running */ } + } + + this.transport = null; + this.connected = false; + // Preserve session/conversation identifiers for potential reconnection / recovery flows. + logger.debug(`[CodexMCP] Disconnected; session ${this.threadId ?? 'none'} preserved`); + } +} diff --git a/cli/src/backends/codex/daemon/spawnHooks.ts b/cli/src/backends/codex/daemon/spawnHooks.ts new file mode 100644 index 000000000..263e4bf13 --- /dev/null +++ b/cli/src/backends/codex/daemon/spawnHooks.ts @@ -0,0 +1,75 @@ +import { existsSync } from 'node:fs'; +import fs from 'node:fs/promises'; +import { join } from 'node:path'; + +import tmp from 'tmp'; + +import { getCodexAcpDepStatus } from '@/capabilities/deps/codexAcp'; +import type { DaemonSpawnHooks } from '@/daemon/spawnHooks'; + +export const codexDaemonSpawnHooks: DaemonSpawnHooks = { + buildAuthEnv: async ({ token }) => { + const codexHomeDir = tmp.dirSync(); + + let cleaned = false; + const cleanup = () => { + if (cleaned) return; + cleaned = true; + try { + codexHomeDir.removeCallback(); + } catch { + // best-effort + } + }; + + try { + await fs.writeFile(join(codexHomeDir.name, 'auth.json'), token); + } catch (error) { + cleanup(); + throw error; + } + + return { + env: { CODEX_HOME: codexHomeDir.name }, + cleanupOnFailure: cleanup, + cleanupOnExit: cleanup, + }; + }, + + validateSpawn: async ({ experimentalCodexResume, experimentalCodexAcp }) => { + if (experimentalCodexAcp !== true) return { ok: true }; + + if (experimentalCodexResume === true) { + return { + ok: false, + errorMessage: 'Invalid spawn options: Codex ACP and Codex resume MCP cannot both be enabled.', + }; + } + + const envOverride = typeof process.env.HAPPY_CODEX_ACP_BIN === 'string' ? process.env.HAPPY_CODEX_ACP_BIN.trim() : ''; + if (envOverride) { + if (!existsSync(envOverride)) { + return { + ok: false, + errorMessage: `Codex ACP is enabled, but HAPPY_CODEX_ACP_BIN does not exist: ${envOverride}`, + }; + } + return { ok: true }; + } + + const status = await getCodexAcpDepStatus({ onlyIfInstalled: true }); + if (!status.installed || !status.binPath) { + return { + ok: false, + errorMessage: 'Codex ACP is enabled, but codex-acp is not installed. Install it from the Happy app (Machine details → Codex ACP) or disable the experiment.', + }; + } + + return { ok: true }; + }, + + buildExtraEnvForChild: ({ experimentalCodexResume, experimentalCodexAcp }) => ({ + ...(experimentalCodexResume === true ? { HAPPY_EXPERIMENTAL_CODEX_RESUME: '1' } : {}), + ...(experimentalCodexAcp === true ? { HAPPY_EXPERIMENTAL_CODEX_ACP: '1' } : {}), + }), +}; diff --git a/cli/src/backends/codex/experiments/codexExperiments.ts b/cli/src/backends/codex/experiments/codexExperiments.ts new file mode 100644 index 000000000..78c8dbc67 --- /dev/null +++ b/cli/src/backends/codex/experiments/codexExperiments.ts @@ -0,0 +1,10 @@ +export function isExperimentalCodexVendorResumeEnabled(): boolean { + const raw = process.env.HAPPY_EXPERIMENTAL_CODEX_RESUME; + return typeof raw === 'string' && ['true', '1', 'yes'].includes(raw.trim().toLowerCase()); +} + +export function isExperimentalCodexAcpEnabled(): boolean { + const raw = process.env.HAPPY_EXPERIMENTAL_CODEX_ACP; + return typeof raw === 'string' && ['true', '1', 'yes'].includes(raw.trim().toLowerCase()); +} + diff --git a/cli/src/backends/codex/experiments/index.ts b/cli/src/backends/codex/experiments/index.ts new file mode 100644 index 000000000..d0287db5e --- /dev/null +++ b/cli/src/backends/codex/experiments/index.ts @@ -0,0 +1,2 @@ +export { isExperimentalCodexAcpEnabled, isExperimentalCodexVendorResumeEnabled } from './codexExperiments'; + diff --git a/cli/src/codex/happyMcpStdioBridge.ts b/cli/src/backends/codex/happyMcpStdioBridge.ts similarity index 100% rename from cli/src/codex/happyMcpStdioBridge.ts rename to cli/src/backends/codex/happyMcpStdioBridge.ts diff --git a/cli/src/backends/codex/index.ts b/cli/src/backends/codex/index.ts new file mode 100644 index 000000000..604bba40a --- /dev/null +++ b/cli/src/backends/codex/index.ts @@ -0,0 +1,23 @@ +import { AGENTS_CORE } from '@happy/agents'; + +import { checklists } from './cli/checklists'; +import type { AgentCatalogEntry } from '../types'; + +export const agent = { + id: AGENTS_CORE.codex.id, + cliSubcommand: AGENTS_CORE.codex.cliSubcommand, + getCliCommandHandler: async () => (await import('@/backends/codex/cli/command')).handleCodexCliCommand, + getCliCapabilityOverride: async () => (await import('@/backends/codex/cli/capability')).cliCapability, + getCapabilities: async () => (await import('@/backends/codex/cli/extraCapabilities')).capabilities, + getCliDetect: async () => (await import('@/backends/codex/cli/detect')).cliDetect, + getCloudConnectTarget: async () => (await import('@/backends/codex/cloud/connect')).codexCloudConnect, + getDaemonSpawnHooks: async () => (await import('@/backends/codex/daemon/spawnHooks')).codexDaemonSpawnHooks, + vendorResumeSupport: AGENTS_CORE.codex.resume.vendorResume, + getVendorResumeSupport: async () => (await import('@/backends/codex/resume/vendorResumeSupport')).supportsCodexVendorResume, + getAcpBackendFactory: async () => { + const { createCodexAcpBackend } = await import('@/backends/codex/acp/backend'); + return (opts) => createCodexAcpBackend(opts as any); + }, + checklists, +} satisfies AgentCatalogEntry; + diff --git a/cli/src/backends/codex/resume/vendorResumeSupport.test.ts b/cli/src/backends/codex/resume/vendorResumeSupport.test.ts new file mode 100644 index 000000000..bda458614 --- /dev/null +++ b/cli/src/backends/codex/resume/vendorResumeSupport.test.ts @@ -0,0 +1,43 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { supportsCodexVendorResume } from './vendorResumeSupport'; + +describe('supportsCodexVendorResume', () => { + const prev = process.env.HAPPY_EXPERIMENTAL_CODEX_RESUME; + const prevAcp = process.env.HAPPY_EXPERIMENTAL_CODEX_ACP; + + beforeEach(() => { + delete process.env.HAPPY_EXPERIMENTAL_CODEX_RESUME; + delete process.env.HAPPY_EXPERIMENTAL_CODEX_ACP; + }); + + afterEach(() => { + if (typeof prev === 'string') process.env.HAPPY_EXPERIMENTAL_CODEX_RESUME = prev; + else delete process.env.HAPPY_EXPERIMENTAL_CODEX_RESUME; + if (typeof prevAcp === 'string') process.env.HAPPY_EXPERIMENTAL_CODEX_ACP = prevAcp; + else delete process.env.HAPPY_EXPERIMENTAL_CODEX_ACP; + }); + + it('rejects by default', () => { + expect(supportsCodexVendorResume({})).toBe(false); + }); + + it('allows when explicitly enabled for this spawn', () => { + expect(supportsCodexVendorResume({ experimentalCodexResume: true })).toBe(true); + }); + + it('allows when explicitly enabled via ACP for this spawn', () => { + expect(supportsCodexVendorResume({ experimentalCodexAcp: true })).toBe(true); + }); + + it('allows when HAPPY_EXPERIMENTAL_CODEX_RESUME is set', () => { + process.env.HAPPY_EXPERIMENTAL_CODEX_RESUME = '1'; + expect(supportsCodexVendorResume({})).toBe(true); + }); + + it('allows when HAPPY_EXPERIMENTAL_CODEX_ACP is set', () => { + process.env.HAPPY_EXPERIMENTAL_CODEX_ACP = '1'; + expect(supportsCodexVendorResume({})).toBe(true); + }); +}); + diff --git a/cli/src/backends/codex/resume/vendorResumeSupport.ts b/cli/src/backends/codex/resume/vendorResumeSupport.ts new file mode 100644 index 000000000..bd3278c68 --- /dev/null +++ b/cli/src/backends/codex/resume/vendorResumeSupport.ts @@ -0,0 +1,11 @@ +import type { VendorResumeSupportFn } from '@/backends/types'; + +import { isExperimentalCodexAcpEnabled, isExperimentalCodexVendorResumeEnabled } from '@/backends/codex/experiments'; + +export const supportsCodexVendorResume: VendorResumeSupportFn = (params) => { + return params.experimentalCodexResume === true + || params.experimentalCodexAcp === true + || isExperimentalCodexVendorResumeEnabled() + || isExperimentalCodexAcpEnabled(); +}; + diff --git a/cli/src/codex/runCodex.ts b/cli/src/backends/codex/runCodex.ts similarity index 52% rename from cli/src/codex/runCodex.ts rename to cli/src/backends/codex/runCodex.ts index fdaf9b29d..cc62821f3 100644 --- a/cli/src/codex/runCodex.ts +++ b/cli/src/backends/codex/runCodex.ts @@ -3,6 +3,8 @@ import React from "react"; import { ApiClient } from '@/api/api'; import { CodexMcpClient } from './codexMcpClient'; import { CodexPermissionHandler } from './utils/permissionHandler'; +import { formatCodexEventForUi } from './utils/formatCodexEventForUi'; +import { nextCodexLifecycleAcpMessages } from './utils/codexAcpLifecycle'; import { ReasoningProcessor } from './utils/reasoningProcessor'; import { DiffProcessor } from './utils/diffProcessor'; import { randomUUID } from 'node:crypto'; @@ -10,27 +12,37 @@ import { logger } from '@/ui/logger'; import { Credentials, readSettings } from '@/persistence'; import { initialMachineMetadata } from '@/daemon/run'; import { configuration } from '@/configuration'; -import packageJson from '../../package.json'; +import packageJson from '../../../package.json'; import os from 'node:os'; import { MessageQueue2 } from '@/utils/MessageQueue2'; import { hashObject } from '@/utils/deterministicJson'; import { projectPath } from '@/projectPath'; import { resolve, join } from 'node:path'; -import { createSessionMetadata } from '@/utils/createSessionMetadata'; -import fs from 'node:fs'; -import { startHappyServer } from '@/claude/utils/startHappyServer'; +import { existsSync } from 'node:fs'; +import { createSessionMetadata } from '@/agent/runtime/createSessionMetadata'; +import { startHappyServer } from '@/mcp/startHappyServer'; import { MessageBuffer } from "@/ui/ink/messageBuffer"; -import { CodexDisplay } from "@/ui/ink/CodexDisplay"; +import { CodexTerminalDisplay } from "@/backends/codex/ui/CodexTerminalDisplay"; import { trimIdent } from "@/utils/trimIdent"; -import type { CodexSessionConfig } from './types'; -import { CHANGE_TITLE_INSTRUCTION } from '@/gemini/constants'; -import { notifyDaemonSessionStarted } from "@/daemon/controlClient"; -import { registerKillSessionHandler } from "@/claude/registerKillSessionHandler"; +import type { CodexSessionConfig, CodexToolResponse } from './types'; +import { CHANGE_TITLE_INSTRUCTION } from '@/agent/runtime/changeTitleInstruction'; +import { registerKillSessionHandler } from '@/rpc/handlers/killSession'; import { delay } from "@/utils/time"; -import { stopCaffeinate } from "@/utils/caffeinate"; -import { connectionState } from '@/utils/serverConnectionErrors'; -import { setupOfflineReconnection } from '@/utils/setupOfflineReconnection'; +import { stopCaffeinate } from '@/integrations/caffeinate'; +import { formatErrorForUi } from '@/ui/formatErrorForUi'; +import { waitForMessagesOrPending } from '@/agent/runtime/waitForMessagesOrPending'; +import { connectionState } from '@/api/offline/serverConnectionErrors'; +import { setupOfflineReconnection } from '@/api/offline/setupOfflineReconnection'; import type { ApiSessionClient } from '@/api/apiSession'; +import { buildTerminalMetadataFromRuntimeFlags } from '@/terminal/terminalMetadata'; +import { isExperimentalCodexAcpEnabled, isExperimentalCodexVendorResumeEnabled } from '@/backends/codex/experiments'; +import { maybeUpdatePermissionModeMetadata } from '@/agent/runtime/permissionModeMetadata'; +import { parseSpecialCommand } from '@/cli/parsers/specialCommands'; +import { createBaseSessionForAttach } from '@/agent/runtime/createBaseSessionForAttach'; +import { maybeUpdateCodexSessionIdMetadata } from './utils/codexSessionIdMetadata'; +import { createCodexAcpRuntime } from './acp/runtime'; +import { applyStartupMetadataUpdateToSession, buildPermissionModeOverride } from '@/agent/runtime/startupMetadataUpdate'; +import { persistTerminalAttachmentInfoIfNeeded, primeAgentStateForUi, reportSessionToDaemonIfRunning, sendTerminalFallbackMessageIfNeeded } from '@/agent/runtime/startupSideEffects'; type ReadyEventOptions = { pending: unknown; @@ -60,12 +72,52 @@ export function emitReadyIfIdle({ pending, queueSize, shouldExit, sendReady, not return true; } +export function extractCodexToolErrorText(response: CodexToolResponse): string | null { + if (!response?.isError) { + return null; + } + const text = (response.content || []) + .map((c) => (c && typeof c.text === 'string' ? c.text : '')) + .filter(Boolean) + .join('\n') + .trim(); + return text || 'Codex error'; +} + +export function extractMcpToolCallResultOutput(result: unknown): unknown { + if (result && typeof result === 'object') { + const record = result as Record; + if (Object.prototype.hasOwnProperty.call(record, 'Ok')) { + return (record as any).Ok; + } + if (Object.prototype.hasOwnProperty.call(record, 'Err')) { + return (record as any).Err; + } + } + return result; +} + +export function nextStoredSessionIdForResumeAfterAttempt( + storedSessionIdForResume: string | null, + attempt: { attempted: boolean; success: boolean }, +): string | null { + if (!attempt.attempted) { + return storedSessionIdForResume; + } + return attempt.success ? null : storedSessionIdForResume; +} + /** * Main entry point for the codex command with ink UI */ export async function runCodex(opts: { credentials: Credentials; startedBy?: 'daemon' | 'terminal'; + terminalRuntime?: import('@/terminal/terminalRuntimeFlags').TerminalRuntimeFlags | null; + permissionMode?: import('@/api/types').PermissionMode; + permissionModeUpdatedAt?: number; + existingSessionId?: string; + resume?: string; }): Promise { // Use shared PermissionMode type for cross-agent compatibility type PermissionMode = import('@/api/types').PermissionMode; @@ -105,90 +157,127 @@ export async function runCodex(opts: { }); // - // Create session + // Attach to existing Happy session (inactive-session-resume) OR create a new one. // + const initialPermissionMode = opts.permissionMode ?? 'default'; const { state, metadata } = createSessionMetadata({ flavor: 'codex', machineId, - startedBy: opts.startedBy + startedBy: opts.startedBy, + terminalRuntime: opts.terminalRuntime ?? null, + permissionMode: initialPermissionMode, + permissionModeUpdatedAt: typeof opts.permissionModeUpdatedAt === 'number' ? opts.permissionModeUpdatedAt : Date.now(), }); - const response = await api.getOrCreateSession({ tag: sessionTag, metadata, state }); - - // Handle server unreachable case - create offline stub with hot reconnection + const terminal = buildTerminalMetadataFromRuntimeFlags(opts.terminalRuntime ?? null); let session: ApiSessionClient; // Permission handler declared here so it can be updated in onSessionSwap callback - // (assigned later at line ~385 after client setup) + // (assigned later after client setup) let permissionHandler: CodexPermissionHandler; - const { session: initialSession, reconnectionHandle } = setupOfflineReconnection({ - api, - sessionTag, - metadata, - state, - response, - onSessionSwap: (newSession) => { - session = newSession; - // Update permission handler with new session to avoid stale reference - if (permissionHandler) { - permissionHandler.updateSession(newSession); - } - } - }); - session = initialSession; + // Offline reconnection handle (only relevant when creating a new session and server is unreachable) + let reconnectionHandle: { cancel: () => void } | null = null; - // Always report to daemon if it exists (skip if offline) - if (response) { - try { - logger.debug(`[START] Reporting session ${response.id} to daemon`); - const result = await notifyDaemonSessionStarted(response.id, metadata); - if (result.error) { - logger.debug(`[START] Failed to report to daemon (may not be running):`, result.error); - } else { - logger.debug(`[START] Reported session ${response.id} to daemon`); + if (typeof opts.existingSessionId === 'string' && opts.existingSessionId.trim()) { + const existingId = opts.existingSessionId.trim(); + logger.debug(`[codex] Attaching to existing Happy session: ${existingId}`); + const baseSession = await createBaseSessionForAttach({ + existingSessionId: existingId, + metadata, + state, + }); + session = api.sessionSyncClient(baseSession); + // Refresh metadata on startup (mark session active and update runtime fields). + applyStartupMetadataUpdateToSession({ + session, + next: metadata, + nowMs: Date.now(), + permissionModeOverride: buildPermissionModeOverride({ + permissionMode: opts.permissionMode, + permissionModeUpdatedAt: opts.permissionModeUpdatedAt, + }), + }); + + primeAgentStateForUi(session, '[codex]'); + await persistTerminalAttachmentInfoIfNeeded({ sessionId: existingId, terminal }); + sendTerminalFallbackMessageIfNeeded({ session, terminal }); + await reportSessionToDaemonIfRunning({ sessionId: existingId, metadata }); + } else { + const response = await api.getOrCreateSession({ tag: sessionTag, metadata, state }); + + // Handle server unreachable case - create offline stub with hot reconnection + const offline = setupOfflineReconnection({ + api, + sessionTag, + metadata, + state, + response, + onSessionSwap: (newSession) => { + session = newSession; + // Update permission handler with new session to avoid stale reference + if (permissionHandler) { + permissionHandler.updateSession(newSession); + } } - } catch (error) { - logger.debug('[START] Failed to report to daemon (may not be running):', error); + }); + session = offline.session; + reconnectionHandle = offline.reconnectionHandle; + + primeAgentStateForUi(session, '[codex]'); + if (response) { + await persistTerminalAttachmentInfoIfNeeded({ sessionId: response.id, terminal }); + sendTerminalFallbackMessageIfNeeded({ session, terminal }); + await reportSessionToDaemonIfRunning({ sessionId: response.id, metadata }); } } const messageQueue = new MessageQueue2((mode) => hashObject({ permissionMode: mode.permissionMode, - model: mode.model, + // Intentionally ignore model in the mode hash: Codex cannot reliably switch models mid-session + // without losing in-memory context. })); // Track current overrides to apply per message // Use shared PermissionMode type from api/types for cross-agent compatibility - let currentPermissionMode: import('@/api/types').PermissionMode | undefined = undefined; - let currentModel: string | undefined = undefined; + let currentPermissionMode: import('@/api/types').PermissionMode | undefined = initialPermissionMode; session.onUserMessage((message) => { // Resolve permission mode (accept all modes, will be mapped in switch statement) let messagePermissionMode = currentPermissionMode; if (message.meta?.permissionMode) { - messagePermissionMode = message.meta.permissionMode as import('@/api/types').PermissionMode; - currentPermissionMode = messagePermissionMode; - logger.debug(`[Codex] Permission mode updated from user message to: ${currentPermissionMode}`); + const nextPermissionMode = message.meta.permissionMode as import('@/api/types').PermissionMode; + const res = maybeUpdatePermissionModeMetadata({ + currentPermissionMode, + nextPermissionMode, + updateMetadata: (updater) => session.updateMetadata(updater), + }); + currentPermissionMode = res.currentPermissionMode; + messagePermissionMode = currentPermissionMode; + if (res.didChange) { + logger.debug(`[Codex] Permission mode updated from user message to: ${currentPermissionMode}`); + } } else { logger.debug(`[Codex] User message received with no permission mode override, using current: ${currentPermissionMode ?? 'default (effective)'}`); } - // Resolve model; explicit null resets to default (undefined) - let messageModel = currentModel; - if (message.meta?.hasOwnProperty('model')) { - messageModel = message.meta.model || undefined; - currentModel = messageModel; - logger.debug(`[Codex] Model updated from user message: ${messageModel || 'reset to default'}`); - } else { - logger.debug(`[Codex] User message received with no model override, using current: ${currentModel || 'default'}`); - } + // Model overrides are intentionally ignored for Codex. + // Codex's model is selected at session creation time by the Codex engine / local config. + const messageModel: string | undefined = undefined; const enhancedMode: EnhancedMode = { permissionMode: messagePermissionMode || 'default', model: messageModel, }; - messageQueue.push(message.content.text, enhancedMode); + + const specialCommand = parseSpecialCommand(message.content.text); + if (specialCommand.type === 'clear') { + messageQueue.pushIsolateAndClear(message.content.text, enhancedMode); + } else { + messageQueue.push(message.content.text, enhancedMode); + } }); + let thinking = false; + let currentTaskId: string | null = null; session.keepAlive(thinking, 'remote'); // Periodic keep-alive; store handle so we can clear on exit const keepAliveInterval = setInterval(() => { @@ -236,6 +325,15 @@ export async function runCodex(opts: { let abortController = new AbortController(); let shouldExit = false; let storedSessionIdForResume: string | null = null; + if (typeof opts.resume === 'string' && opts.resume.trim()) { + storedSessionIdForResume = opts.resume.trim(); + logger.debug('[Codex] Resume requested via --resume:', storedSessionIdForResume); + } + + const useCodexAcp = isExperimentalCodexAcpEnabled(); + let happyServer: { url: string; stop: () => void } | null = null; + let client: CodexMcpClient | null = null; + let codexAcpRuntime: ReturnType | null = null; /** * Handles aborting the current task/inference without exiting the process. @@ -246,11 +344,18 @@ export async function runCodex(opts: { logger.debug('[Codex] Abort requested - stopping current task'); try { // Store the current session ID before aborting for potential resume - if (client.hasActiveSession()) { - storedSessionIdForResume = client.storeSessionForResume(); + const mcpClient = client; + if (mcpClient && mcpClient.hasActiveSession()) { + storedSessionIdForResume = mcpClient.storeSessionForResume(); logger.debug('[Codex] Stored session for resume:', storedSessionIdForResume); + } else if (useCodexAcp) { + const currentAcpSessionId = codexAcpRuntime?.getSessionId(); + if (currentAcpSessionId) { + storedSessionIdForResume = currentAcpSessionId; + logger.debug('[CodexACP] Stored session for resume:', storedSessionIdForResume); + } } - + abortController.abort(); reasoningProcessor.abort(); logger.debug('[Codex] Abort completed - session remains active'); @@ -282,7 +387,7 @@ export async function runCodex(opts: { archivedBy: 'cli', archiveReason: 'User terminated' })); - + // Send session death message session.sendSessionDeath(); await session.flush(); @@ -291,7 +396,12 @@ export async function runCodex(opts: { // Force close Codex transport (best-effort) so we don't leave stray processes try { - await client.forceCloseSession(); + if (client) { + await client.forceCloseSession(); + } else if (codexAcpRuntime) { + await codexAcpRuntime.reset(); + codexAcpRuntime = null; + } } catch (e) { logger.debug('[Codex] Error while force closing Codex session during termination', e); } @@ -300,7 +410,7 @@ export async function runCodex(opts: { stopCaffeinate(); // Stop Happy MCP server - happyServer.stop(); + happyServer?.stop(); logger.debug('[Codex] Session termination complete, exiting'); process.exit(0); @@ -325,7 +435,7 @@ export async function runCodex(opts: { if (hasTTY) { console.clear(); - inkInstance = render(React.createElement(CodexDisplay, { + inkInstance = render(React.createElement(CodexTerminalDisplay, { messageBuffer, logPath: process.env.DEBUG ? logger.getLogPath() : undefined, onExit: async () => { @@ -352,50 +462,62 @@ export async function runCodex(opts: { // Start Context // - const client = new CodexMcpClient(); + // Start Happy MCP server (HTTP) and prepare STDIO bridge config for Codex + happyServer = await startHappyServer(session); + const directory = process.cwd(); + const bridgeScript = join(projectPath(), 'bin', 'happy-mcp.mjs'); + // Use process.execPath (bun or node) as command to support both runtimes + const mcpServers = { + happy: { + command: process.execPath, + args: [bridgeScript, '--url', happyServer!.url] + } + }; - // Helper: find Codex session transcript for a given sessionId - function findCodexResumeFile(sessionId: string | null): string | null { - if (!sessionId) return null; - try { - const codexHomeDir = process.env.CODEX_HOME || join(os.homedir(), '.codex'); - const rootDir = join(codexHomeDir, 'sessions'); - - // Recursively collect all files under the sessions directory - function collectFilesRecursive(dir: string, acc: string[] = []): string[] { - let entries: fs.Dirent[]; - try { - entries = fs.readdirSync(dir, { withFileTypes: true }); - } catch { - return acc; - } - for (const entry of entries) { - const full = join(dir, entry.name); - if (entry.isDirectory()) { - collectFilesRecursive(full, acc); - } else if (entry.isFile()) { - acc.push(full); - } - } - return acc; - } + const isVendorResumeRequested = typeof opts.resume === 'string' && opts.resume.trim().length > 0; + const codexMcpServer = (() => { + if (useCodexAcp) { + // ACP mode bypasses Codex MCP server selection (resume/no-resume). + return { mode: 'codex-cli' as const, command: 'codex' }; + } + if (!isVendorResumeRequested) { + return { mode: 'codex-cli' as const, command: 'codex' }; + } - const candidates = collectFilesRecursive(rootDir) - .filter(full => full.endsWith(`-${sessionId}.jsonl`)) - .filter(full => { - try { return fs.statSync(full).isFile(); } catch { return false; } - }) - .sort((a, b) => { - const sa = fs.statSync(a).mtimeMs; - const sb = fs.statSync(b).mtimeMs; - return sb - sa; // newest first - }); - return candidates[0] || null; - } catch { - return null; + if (!isExperimentalCodexVendorResumeEnabled()) { + throw new Error('Codex resume is experimental and is disabled on this machine.'); } - } - permissionHandler = new CodexPermissionHandler(session); + + const envOverride = (() => { + const v = typeof process.env.HAPPY_CODEX_RESUME_MCP_SERVER_BIN === 'string' + ? process.env.HAPPY_CODEX_RESUME_MCP_SERVER_BIN.trim() + : (typeof process.env.HAPPY_CODEX_RESUME_BIN === 'string' ? process.env.HAPPY_CODEX_RESUME_BIN.trim() : ''); + return v; + })(); + if (envOverride && existsSync(envOverride)) { + return { mode: 'mcp-server' as const, command: envOverride }; + } + + const binName = process.platform === 'win32' ? 'codex-mcp-resume.cmd' : 'codex-mcp-resume'; + const defaultNew = join(configuration.happyHomeDir, 'tools', 'codex-mcp-resume', 'node_modules', '.bin', binName); + const defaultOld = join(configuration.happyHomeDir, 'tools', 'codex-resume', 'node_modules', '.bin', binName); + + const found = [defaultNew, defaultOld].find((p) => existsSync(p)); + if (found) { + return { mode: 'mcp-server' as const, command: found }; + } + + throw new Error( + `Codex resume MCP server is not installed.\n` + + `Install it from the Happy app (Machine details → Codex resume), or set HAPPY_CODEX_RESUME_MCP_SERVER_BIN.\n` + + `Expected: ${defaultNew}`, + ); + })(); + + client = useCodexAcp ? null : new CodexMcpClient({ mode: codexMcpServer.mode, command: codexMcpServer.command }); + + // NOTE: Codex resume support varies by build; forks may seed `codex-reply` with a stored session id. + permissionHandler = new CodexPermissionHandler(session, { onAbortRequested: handleAbort }); const reasoningProcessor = new ReasoningProcessor((message) => { // Callback to send messages directly from the processor session.sendCodexMessage(message); @@ -404,10 +526,59 @@ export async function runCodex(opts: { // Callback to send messages directly from the processor session.sendCodexMessage(message); }); - client.setPermissionHandler(permissionHandler); - client.setHandler((msg) => { + if (client) client.setPermissionHandler(permissionHandler); + + function forwardCodexStatusToUi(messageText: string): void { + messageBuffer.addMessage(messageText, 'status'); + session.sendSessionEvent({ type: 'message', message: messageText }); + } + + function forwardCodexErrorToUi(errorText: string): void { + const text = typeof errorText === 'string' ? errorText.trim() : ''; + if (!text || text === 'Codex error') { + forwardCodexStatusToUi('Codex error'); + return; + } + forwardCodexStatusToUi(`Codex error: ${text}`); + } + + const lastCodexThreadIdPublished: { value: string | null } = { value: null }; + + const publishCodexThreadIdToMetadata = () => { + maybeUpdateCodexSessionIdMetadata({ + getCodexThreadId: () => (client ? client.getSessionId() : (codexAcpRuntime?.getSessionId() ?? null)), + updateHappySessionMetadata: (updater) => session.updateMetadata(updater), + lastPublished: lastCodexThreadIdPublished, + }); + }; + + if (useCodexAcp) { + codexAcpRuntime = createCodexAcpRuntime({ + directory, + session, + messageBuffer, + mcpServers, + permissionHandler, + onThinkingChange: (value) => { thinking = value; }, + }); + } + + if (client) client.setHandler((msg) => { logger.debug(`[Codex] MCP message: ${JSON.stringify(msg)}`); + publishCodexThreadIdToMetadata(); + + const lifecycle = nextCodexLifecycleAcpMessages({ currentTaskId, msg }); + currentTaskId = lifecycle.currentTaskId; + for (const event of lifecycle.messages) { + session.sendAgentMessage('codex', event); + } + + const uiText = formatCodexEventForUi(msg); + if (uiText) { + forwardCodexStatusToUi(uiText); + } + // Add messages to the ink UI buffer based on message type if (msg.type === 'agent_message') { messageBuffer.addMessage(msg.message, 'assistant'); @@ -546,43 +717,63 @@ export async function runCodex(opts: { diffProcessor.processDiff(msg.unified_diff); } } + // Handle MCP tool calls (e.g., change_title from happy server) + if (msg.type === 'mcp_tool_call_begin') { + const { call_id, invocation } = msg; + // Use mcp__ prefix so frontend recognizes it as MCP tool (minimal display) + const toolName = `mcp__${invocation.server}__${invocation.tool}`; + session.sendCodexMessage({ + type: 'tool-call', + name: toolName, + callId: call_id, + input: invocation.arguments || {}, + id: randomUUID() + }); + } + if (msg.type === 'mcp_tool_call_end') { + const { call_id, result } = msg; + const output = extractMcpToolCallResultOutput(result); + session.sendCodexMessage({ + type: 'tool-call-result', + callId: call_id, + output: output, + id: randomUUID() + }); + } }); - // Start Happy MCP server (HTTP) and prepare STDIO bridge config for Codex - const happyServer = await startHappyServer(session); - const bridgeCommand = join(projectPath(), 'bin', 'happy-mcp.mjs'); - const mcpServers = { - happy: { - command: bridgeCommand, - args: ['--url', happyServer.url] - } - } as const; let first = true; try { - logger.debug('[codex]: client.connect begin'); - await client.connect(); - logger.debug('[codex]: client.connect done'); + if (client) { + logger.debug('[codex]: client.connect begin'); + await client.connect(); + logger.debug('[codex]: client.connect done'); + } let wasCreated = false; + let currentModeHash: string | null = null; let pending: { message: string; mode: EnhancedMode; isolate: boolean; hash: string } | null = null; - // If we restart (e.g., mode change), use this to carry a resume file - let nextExperimentalResume: string | null = null; while (!shouldExit) { logActiveHandles('loop-top'); // Get next batch; respect mode boundaries like Claude let message: { message: string; mode: EnhancedMode; isolate: boolean; hash: string } | null = pending; - pending = null; - if (!message) { - // Capture the current signal to distinguish idle-abort from queue close - const waitSignal = abortController.signal; - const batch = await messageQueue.waitForMessagesAndGetAsString(waitSignal); - if (!batch) { - // If wait was aborted (e.g., remote abort with no active inference), ignore and continue - if (waitSignal.aborted && !shouldExit) { - logger.debug('[codex]: Wait aborted while idle; ignoring and continuing'); - continue; + pending = null; + if (!message) { + // Capture the current signal to distinguish idle-abort from queue close + const waitSignal = abortController.signal; + const batch = await waitForMessagesOrPending({ + messageQueue, + abortSignal: waitSignal, + popPendingMessage: () => session.popPendingMessage(), + waitForMetadataUpdate: (signal) => session.waitForMetadataUpdate(signal), + }); + if (!batch) { + // If wait was aborted (e.g., remote abort with no active inference), ignore and continue + if (waitSignal.aborted && !shouldExit) { + logger.debug('[codex]: Wait aborted while idle; ignoring and continuing'); + continue; } logger.debug(`[codex]: batch=${!!batch}, shouldExit=${shouldExit}`); break; @@ -595,25 +786,17 @@ export async function runCodex(opts: { break; } - // If a session exists and mode changed, restart on next iteration + // If a session exists and permission mode changed, restart on next iteration. + // NOTE: This drops in-memory context (no resume attempt). if (wasCreated && currentModeHash && message.hash !== currentModeHash) { logger.debug('[Codex] Mode changed – restarting Codex session'); messageBuffer.addMessage('═'.repeat(40), 'status'); messageBuffer.addMessage('Starting new Codex session (mode changed)...', 'status'); - // Capture previous sessionId and try to find its transcript to resume - try { - const prevSessionId = client.getSessionId(); - nextExperimentalResume = findCodexResumeFile(prevSessionId); - if (nextExperimentalResume) { - logger.debug(`[Codex] Found resume file for session ${prevSessionId}: ${nextExperimentalResume}`); - messageBuffer.addMessage('Resuming previous context…', 'status'); - } else { - logger.debug('[Codex] No resume file found for previous session'); - } - } catch (e) { - logger.debug('[Codex] Error while searching resume file', e); + if (client) { + client.clearSession(); + } else { + await codexAcpRuntime?.reset(); } - client.clearSession(); wasCreated = false; currentModeHash = null; pending = message; @@ -630,9 +813,75 @@ export async function runCodex(opts: { messageBuffer.addMessage(message.message, 'user'); currentModeHash = message.hash; + const specialCommand = parseSpecialCommand(message.message); + if (specialCommand.type === 'clear') { + logger.debug('[Codex] Handling /clear command - resetting session'); + if (client) { + client.clearSession(); + } else { + await codexAcpRuntime?.reset(); + } + wasCreated = false; + currentModeHash = null; + + // Reset processors/permissions + permissionHandler.reset(); + reasoningProcessor.abort(); + diffProcessor.reset(); + thinking = false; + session.keepAlive(thinking, 'remote'); + + messageBuffer.addMessage('Session reset.', 'status'); + emitReadyIfIdle({ + pending, + queueSize: () => messageQueue.size(), + shouldExit, + sendReady, + }); + continue; + } + try { - // Map permission mode to approval policy and sandbox for startSession - const approvalPolicy = (() => { + if (useCodexAcp) { + const codexAcp = codexAcpRuntime; + if (!codexAcp) { + throw new Error('Codex ACP runtime was not initialized'); + } + codexAcp.beginTurn(); + + if (!wasCreated) { + const resumeId = storedSessionIdForResume?.trim(); + if (resumeId) { + messageBuffer.addMessage('Resuming previous context…', 'status'); + try { + await codexAcp.startOrLoad({ resumeId }); + storedSessionIdForResume = nextStoredSessionIdForResumeAfterAttempt(storedSessionIdForResume, { + attempted: true, + success: true, + }); + } catch (e) { + logger.debug('[Codex ACP] Resume failed; starting a new session instead', e); + messageBuffer.addMessage('Resume failed; starting a new session.', 'status'); + session.sendSessionEvent({ type: 'message', message: 'Resume failed; starting a new session.' }); + await codexAcp.startOrLoad({}); + storedSessionIdForResume = nextStoredSessionIdForResumeAfterAttempt(storedSessionIdForResume, { + attempted: true, + success: false, + }); + } + } else { + await codexAcp.startOrLoad({}); + } + wasCreated = true; + first = false; + } + + await codexAcp.sendPrompt(message.message); + } else { + const mcpClient = client!; + + // Map permission mode to approval policy and sandbox for startSession + const approvalPolicy = (() => { switch (message.mode.permissionMode) { // Codex native modes case 'default': return 'untrusted' as const; // Ask for non-trusted commands @@ -646,7 +895,7 @@ export async function runCodex(opts: { default: return 'untrusted' as const; // Safe fallback } })(); - const sandbox = (() => { + const sandbox = (() => { switch (message.mode.permissionMode) { // Codex native modes case 'default': return 'workspace-write' as const; // Can write in workspace @@ -661,59 +910,74 @@ export async function runCodex(opts: { } })(); - if (!wasCreated) { + if (!wasCreated) { const startConfig: CodexSessionConfig = { prompt: first ? message.message + '\n\n' + CHANGE_TITLE_INSTRUCTION : message.message, sandbox, 'approval-policy': approvalPolicy, config: { mcp_servers: mcpServers } }; - if (message.mode.model) { - startConfig.model = message.mode.model; - } - - // Check for resume file from multiple sources - let resumeFile: string | null = null; - - // Priority 1: Explicit resume file from mode change - if (nextExperimentalResume) { - resumeFile = nextExperimentalResume; - nextExperimentalResume = null; // consume once - logger.debug('[Codex] Using resume file from mode change:', resumeFile); - } - // Priority 2: Resume from stored abort session - else if (storedSessionIdForResume) { - const abortResumeFile = findCodexResumeFile(storedSessionIdForResume); - if (abortResumeFile) { - resumeFile = abortResumeFile; - logger.debug('[Codex] Using resume file from aborted session:', resumeFile); - messageBuffer.addMessage('Resuming from aborted session...', 'status'); + // NOTE: Model overrides are intentionally not supported for Codex. + // Codex's model selection is controlled by Codex itself (local config / default). + + // Resume-by-session-id path (fork): seed codex-reply with the previous session id. + if (storedSessionIdForResume) { + const resumeId = storedSessionIdForResume; + messageBuffer.addMessage('Resuming previous context…', 'status'); + mcpClient.setSessionIdForResume(resumeId); + const resumeResponse = await mcpClient.continueSession(message.message, { signal: abortController.signal }); + const resumeError = extractCodexToolErrorText(resumeResponse); + if (resumeError) { + forwardCodexErrorToUi(resumeError); + mcpClient.clearSession(); + wasCreated = false; + currentModeHash = null; + continue; } - storedSessionIdForResume = null; // consume once - } - - // Apply resume file if found - if (resumeFile) { - (startConfig.config as any).experimental_resume = resumeFile; + storedSessionIdForResume = nextStoredSessionIdForResumeAfterAttempt(storedSessionIdForResume, { + attempted: true, + success: true, + }); + publishCodexThreadIdToMetadata(); + } else { + const startResponse = await mcpClient.startSession( + startConfig, + { signal: abortController.signal } + ); + const startError = extractCodexToolErrorText(startResponse); + if (startError) { + forwardCodexErrorToUi(startError); + mcpClient.clearSession(); + wasCreated = false; + currentModeHash = null; + continue; + } + publishCodexThreadIdToMetadata(); } - - await client.startSession( - startConfig, - { signal: abortController.signal } - ); + wasCreated = true; first = false; } else { - const response = await client.continueSession( + const response = await mcpClient.continueSession( message.message, { signal: abortController.signal } ); logger.debug('[Codex] continueSession response:', response); + const continueError = extractCodexToolErrorText(response); + if (continueError) { + forwardCodexErrorToUi(continueError); + mcpClient.clearSession(); + wasCreated = false; + currentModeHash = null; + continue; + } + publishCodexThreadIdToMetadata(); + } } } catch (error) { logger.warn('Error in codex session:', error); const isAbortError = error instanceof Error && error.name === 'AbortError'; - + if (isAbortError) { messageBuffer.addMessage('Aborted by user', 'status'); session.sendSessionEvent({ type: 'message', message: 'Aborted by user' }); @@ -721,27 +985,37 @@ export async function runCodex(opts: { // Do not clear session state here; the next user message should continue on the // existing session if possible. } else { - messageBuffer.addMessage('Process exited unexpectedly', 'status'); - session.sendSessionEvent({ type: 'message', message: 'Process exited unexpectedly' }); + const details = formatErrorForUi(error); + const messageText = `Codex process error: ${details}`; + messageBuffer.addMessage(messageText, 'status'); + session.sendSessionEvent({ type: 'message', message: messageText }); // For unexpected exits, try to store session for potential recovery - if (client.hasActiveSession()) { - storedSessionIdForResume = client.storeSessionForResume(); + const mcpClient = client; + if (mcpClient && mcpClient.hasActiveSession()) { + storedSessionIdForResume = mcpClient.storeSessionForResume(); logger.debug('[Codex] Stored session after unexpected error:', storedSessionIdForResume); } } } finally { + if (useCodexAcp) { + codexAcpRuntime?.flushTurn(); + } + // Reset permission handler, reasoning processor, and diff processor permissionHandler.reset(); reasoningProcessor.abort(); // Use abort to properly finish any in-progress tool calls diffProcessor.reset(); thinking = false; session.keepAlive(thinking, 'remote'); - emitReadyIfIdle({ - pending, - queueSize: () => messageQueue.size(), - shouldExit, - sendReady, - }); + const popped = !shouldExit ? await session.popPendingMessage() : false; + if (!popped) { + emitReadyIfIdle({ + pending, + queueSize: () => messageQueue.size(), + shouldExit, + sendReady, + }); + } logActiveHandles('after-turn'); } } @@ -769,12 +1043,17 @@ export async function runCodex(opts: { } catch (e) { logger.debug('[codex]: Error while closing session', e); } - logger.debug('[codex]: client.forceCloseSession begin'); - await client.forceCloseSession(); - logger.debug('[codex]: client.forceCloseSession done'); + if (client) { + logger.debug('[codex]: client.forceCloseSession begin'); + await client.forceCloseSession(); + logger.debug('[codex]: client.forceCloseSession done'); + } else { + await codexAcpRuntime?.reset(); + codexAcpRuntime = null; + } // Stop Happy MCP server logger.debug('[codex]: happyServer.stop'); - happyServer.stop(); + happyServer?.stop(); // Clean up ink UI if (process.stdin.isTTY) { diff --git a/cli/src/codex/types.ts b/cli/src/backends/codex/types.ts similarity index 68% rename from cli/src/codex/types.ts rename to cli/src/backends/codex/types.ts index efaffb67a..07b4d86e8 100644 --- a/cli/src/codex/types.ts +++ b/cli/src/backends/codex/types.ts @@ -22,4 +22,9 @@ export interface CodexToolResponse { mimeType?: string; }>; isError?: boolean; + // MCP servers commonly return structured output here (e.g. structuredContent.threadId). + structuredContent?: Record; + // Some versions/tools may still include alternate naming. + structured_content?: Record; + meta?: Record; } diff --git a/cli/src/backends/codex/ui/CodexTerminalDisplay.tsx b/cli/src/backends/codex/ui/CodexTerminalDisplay.tsx new file mode 100644 index 000000000..859cef357 --- /dev/null +++ b/cli/src/backends/codex/ui/CodexTerminalDisplay.tsx @@ -0,0 +1,31 @@ +/** + * CodexTerminalDisplay + * + * Read-only terminal UI for Codex sessions started by Happy. + * This UI intentionally does not accept prompts from stdin; it displays logs and exit controls only. + */ + +import React from 'react'; + +import { AgentLogShell } from '@/ui/ink/AgentLogShell'; +import { MessageBuffer } from '@/ui/ink/messageBuffer'; +import { buildReadOnlyFooterLines } from '@/ui/ink/readOnlyFooterLines'; + +export type CodexTerminalDisplayProps = { + messageBuffer: MessageBuffer; + logPath?: string; + onExit?: () => void | Promise; +}; + +export const CodexTerminalDisplay: React.FC = ({ messageBuffer, logPath, onExit }) => { + return ( + + ); +}; diff --git a/cli/src/backends/codex/utils/codexAcpLifecycle.test.ts b/cli/src/backends/codex/utils/codexAcpLifecycle.test.ts new file mode 100644 index 000000000..ec92cbf3d --- /dev/null +++ b/cli/src/backends/codex/utils/codexAcpLifecycle.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; +import { nextCodexLifecycleAcpMessages } from './codexAcpLifecycle'; + +describe('nextCodexLifecycleAcpMessages', () => { + it('emits a task_started event and stores the task id', () => { + const result = nextCodexLifecycleAcpMessages({ + currentTaskId: null, + msg: { type: 'task_started' }, + }); + + expect(result.messages).toHaveLength(1); + expect(result.messages[0]).toMatchObject({ type: 'task_started', id: expect.any(String) }); + expect(result.messages[0].type).toBe('task_started'); + if (result.messages[0].type === 'task_started') { + expect(result.currentTaskId).toBe(result.messages[0].id); + } + }); + + it('reuses the current task id across task_started events', () => { + const result = nextCodexLifecycleAcpMessages({ + currentTaskId: 'task-1', + msg: { type: 'task_started' }, + }); + + expect(result.messages).toEqual([{ type: 'task_started', id: 'task-1' }]); + expect(result.currentTaskId).toBe('task-1'); + }); + + it('emits task_complete and clears the task id', () => { + const result = nextCodexLifecycleAcpMessages({ + currentTaskId: 'task-1', + msg: { type: 'task_complete' }, + }); + + expect(result.messages).toEqual([{ type: 'task_complete', id: 'task-1' }]); + expect(result.currentTaskId).toBeNull(); + }); + + it('emits turn_aborted and clears the task id', () => { + const result = nextCodexLifecycleAcpMessages({ + currentTaskId: 'task-1', + msg: { type: 'turn_aborted' }, + }); + + expect(result.messages).toEqual([{ type: 'turn_aborted', id: 'task-1' }]); + expect(result.currentTaskId).toBeNull(); + }); + + it('ignores unrelated events', () => { + const result = nextCodexLifecycleAcpMessages({ + currentTaskId: 'task-1', + msg: { type: 'agent_message', message: 'hello' }, + }); + + expect(result.messages).toEqual([]); + expect(result.currentTaskId).toBe('task-1'); + }); +}); diff --git a/cli/src/backends/codex/utils/codexAcpLifecycle.ts b/cli/src/backends/codex/utils/codexAcpLifecycle.ts new file mode 100644 index 000000000..12b2d7cf4 --- /dev/null +++ b/cli/src/backends/codex/utils/codexAcpLifecycle.ts @@ -0,0 +1,32 @@ +import { randomUUID } from 'node:crypto'; +import type { ACPMessageData } from '@/api/apiSession'; + +export function nextCodexLifecycleAcpMessages(params: { + currentTaskId: string | null; + msg: unknown; +}): { currentTaskId: string | null; messages: ACPMessageData[] } { + const { currentTaskId, msg } = params; + + if (!msg || typeof msg !== 'object') { + return { currentTaskId, messages: [] }; + } + + const type = (msg as any).type; + + if (type === 'task_started') { + const id = currentTaskId ?? randomUUID(); + return { currentTaskId: id, messages: [{ type: 'task_started', id }] }; + } + + if (type === 'task_complete') { + const id = currentTaskId ?? randomUUID(); + return { currentTaskId: null, messages: [{ type: 'task_complete', id }] }; + } + + if (type === 'turn_aborted') { + const id = currentTaskId ?? randomUUID(); + return { currentTaskId: null, messages: [{ type: 'turn_aborted', id }] }; + } + + return { currentTaskId, messages: [] }; +} diff --git a/cli/src/backends/codex/utils/codexSessionIdMetadata.test.ts b/cli/src/backends/codex/utils/codexSessionIdMetadata.test.ts new file mode 100644 index 000000000..e35be8c53 --- /dev/null +++ b/cli/src/backends/codex/utils/codexSessionIdMetadata.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; + +import type { Metadata } from '@/api/types'; +import { maybeUpdateCodexSessionIdMetadata } from './codexSessionIdMetadata'; + +describe('maybeUpdateCodexSessionIdMetadata', () => { + it('no-ops when thread id is missing', () => { + const lastPublished = { value: null as string | null }; + let called = 0; + + maybeUpdateCodexSessionIdMetadata({ + getCodexThreadId: () => null, + updateHappySessionMetadata: () => { + called++; + }, + lastPublished, + }); + + expect(called).toBe(0); + expect(lastPublished.value).toBeNull(); + }); + + it('publishes codexSessionId once per new thread id and preserves other metadata', () => { + const lastPublished = { value: null as string | null }; + const updates: Metadata[] = []; + + const apply = (updater: (m: Metadata) => Metadata) => { + const base = { path: '/tmp', flavor: 'codex' } as unknown as Metadata; + updates.push(updater(base)); + }; + + maybeUpdateCodexSessionIdMetadata({ + getCodexThreadId: () => ' thread-1 ', + updateHappySessionMetadata: apply, + lastPublished, + }); + + maybeUpdateCodexSessionIdMetadata({ + getCodexThreadId: () => 'thread-1', + updateHappySessionMetadata: apply, + lastPublished, + }); + + maybeUpdateCodexSessionIdMetadata({ + getCodexThreadId: () => 'thread-2', + updateHappySessionMetadata: apply, + lastPublished, + }); + + expect(updates).toEqual([ + { path: '/tmp', flavor: 'codex', codexSessionId: 'thread-1' } as unknown as Metadata, + { path: '/tmp', flavor: 'codex', codexSessionId: 'thread-2' } as unknown as Metadata, + ]); + }); +}); + diff --git a/cli/src/backends/codex/utils/codexSessionIdMetadata.ts b/cli/src/backends/codex/utils/codexSessionIdMetadata.ts new file mode 100644 index 000000000..3d7861c37 --- /dev/null +++ b/cli/src/backends/codex/utils/codexSessionIdMetadata.ts @@ -0,0 +1,21 @@ +import type { Metadata } from '@/api/types'; + +export function maybeUpdateCodexSessionIdMetadata(params: { + getCodexThreadId: () => string | null; + updateHappySessionMetadata: (updater: (metadata: Metadata) => Metadata) => void; + lastPublished: { value: string | null }; +}): void { + const raw = params.getCodexThreadId(); + const next = typeof raw === 'string' ? raw.trim() : ''; + if (!next) return; + + if (params.lastPublished.value === next) return; + params.lastPublished.value = next; + + params.updateHappySessionMetadata((metadata) => ({ + ...metadata, + // Happy metadata field name. Value is Codex threadId (Codex uses "threadId" as the stable resume id). + codexSessionId: next, + })); +} + diff --git a/cli/src/codex/utils/diffProcessor.ts b/cli/src/backends/codex/utils/diffProcessor.ts similarity index 100% rename from cli/src/codex/utils/diffProcessor.ts rename to cli/src/backends/codex/utils/diffProcessor.ts diff --git a/cli/src/backends/codex/utils/formatCodexEventForUi.test.ts b/cli/src/backends/codex/utils/formatCodexEventForUi.test.ts new file mode 100644 index 000000000..38feeb676 --- /dev/null +++ b/cli/src/backends/codex/utils/formatCodexEventForUi.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; +import { formatCodexEventForUi } from './formatCodexEventForUi'; + +describe('formatCodexEventForUi', () => { + it('formats generic error events', () => { + expect(formatCodexEventForUi({ type: 'error', message: 'bad' })).toBe('Codex error: bad'); + }); + + it('formats stream errors', () => { + expect(formatCodexEventForUi({ type: 'stream_error', message: 'oops' })).toBe('Codex stream error: oops'); + }); + + it('formats MCP startup failures', () => { + expect( + formatCodexEventForUi({ + type: 'mcp_startup_update', + server: 'happy', + status: { state: 'failed', error: 'nope' }, + }), + ).toBe('MCP server "happy" failed to start: nope'); + }); + + it('avoids redundant fallback text for MCP startup failures without an error string', () => { + expect( + formatCodexEventForUi({ + type: 'mcp_startup_update', + status: { state: 'failed' }, + }), + ).toBe('MCP server "unknown" failed to start: unknown error'); + }); + + it('returns null for events that should not be shown', () => { + expect(formatCodexEventForUi({ type: 'agent_message', message: 'hi' })).toBeNull(); + }); +}); diff --git a/cli/src/backends/codex/utils/formatCodexEventForUi.ts b/cli/src/backends/codex/utils/formatCodexEventForUi.ts new file mode 100644 index 000000000..ce4d346d0 --- /dev/null +++ b/cli/src/backends/codex/utils/formatCodexEventForUi.ts @@ -0,0 +1,26 @@ +export function formatCodexEventForUi(msg: unknown): string | null { + if (!msg || typeof msg !== 'object') { + return null; + } + + const m = msg as any; + const type = m.type; + + if (type === 'error') { + const raw = typeof m.message === 'string' ? m.message.trim() : ''; + return raw ? `Codex error: ${raw}` : 'Codex error'; + } + + if (type === 'stream_error') { + const raw = typeof m.message === 'string' ? m.message.trim() : ''; + return raw ? `Codex stream error: ${raw}` : 'Codex stream error'; + } + + if (type === 'mcp_startup_update' && m.status?.state === 'failed') { + const serverName = typeof m.server === 'string' && m.server.trim() ? m.server.trim() : 'unknown'; + const errorText = typeof m.status?.error === 'string' && m.status.error.trim() ? m.status.error.trim() : 'unknown error'; + return `MCP server "${serverName}" failed to start: ${errorText}`; + } + + return null; +} diff --git a/cli/src/codex/utils/permissionHandler.ts b/cli/src/backends/codex/utils/permissionHandler.ts similarity index 68% rename from cli/src/codex/utils/permissionHandler.ts rename to cli/src/backends/codex/utils/permissionHandler.ts index 0720211d4..70c525668 100644 --- a/cli/src/codex/utils/permissionHandler.ts +++ b/cli/src/backends/codex/utils/permissionHandler.ts @@ -11,7 +11,7 @@ import { BasePermissionHandler, PermissionResult, PendingRequest -} from '@/utils/BasePermissionHandler'; +} from '@/agent/permissions/BasePermissionHandler'; // Re-export types for backwards compatibility export type { PermissionResult, PendingRequest }; @@ -20,8 +20,14 @@ export type { PermissionResult, PendingRequest }; * Codex-specific permission handler. */ export class CodexPermissionHandler extends BasePermissionHandler { - constructor(session: ApiSessionClient) { - super(session); + constructor( + session: ApiSessionClient, + opts?: { onAbortRequested?: (() => void | Promise) | null }, + ) { + super(session, { + ...opts, + toolTrace: { protocol: 'codex', provider: 'codex' }, + }); } protected getLogPrefix(): string { @@ -40,6 +46,13 @@ export class CodexPermissionHandler extends BasePermissionHandler { toolName: string, input: unknown ): Promise { + // Respect user "don't ask again for session" choices captured via our permission UI. + if (this.isAllowedForSession(toolName, input)) { + logger.debug(`${this.getLogPrefix()} Auto-approving (allowed for session) tool ${toolName} (${toolCallId})`); + this.recordAutoDecision(toolCallId, toolName, input, 'approved_for_session'); + return { decision: 'approved_for_session' }; + } + return new Promise((resolve, reject) => { // Store the pending request this.pendingRequests.set(toolCallId, { @@ -55,4 +68,4 @@ export class CodexPermissionHandler extends BasePermissionHandler { logger.debug(`${this.getLogPrefix()} Permission request sent for tool: ${toolName} (${toolCallId})`); }); } -} \ No newline at end of file +} diff --git a/cli/src/codex/utils/reasoningProcessor.ts b/cli/src/backends/codex/utils/reasoningProcessor.ts similarity index 96% rename from cli/src/codex/utils/reasoningProcessor.ts rename to cli/src/backends/codex/utils/reasoningProcessor.ts index a655c0b06..6628061c1 100644 --- a/cli/src/codex/utils/reasoningProcessor.ts +++ b/cli/src/backends/codex/utils/reasoningProcessor.ts @@ -11,7 +11,7 @@ import { ReasoningToolResult, ReasoningMessage, ReasoningOutput -} from '@/utils/BaseReasoningProcessor'; +} from '@/agent/BaseReasoningProcessor'; // Re-export types for backwards compatibility export type { ReasoningToolCall, ReasoningToolResult, ReasoningMessage, ReasoningOutput }; diff --git a/cli/src/agent/factories/gemini.ts b/cli/src/backends/gemini/acp/backend.ts similarity index 91% rename from cli/src/agent/factories/gemini.ts rename to cli/src/backends/gemini/acp/backend.ts index 8a8b3d6d0..a250d6c15 100644 --- a/cli/src/agent/factories/gemini.ts +++ b/cli/src/backends/gemini/acp/backend.ts @@ -8,22 +8,21 @@ * the --experimental-acp flag for ACP mode. */ -import { AcpBackend, type AcpBackendOptions, type AcpPermissionHandler } from '../acp/AcpBackend'; -import type { AgentBackend, McpServerConfig, AgentFactoryOptions } from '../core'; -import { agentRegistry } from '../core'; -import { geminiTransport } from '../transport'; +import { AcpBackend, type AcpBackendOptions, type AcpPermissionHandler } from '@/agent/acp/AcpBackend'; +import type { AgentBackend, McpServerConfig, AgentFactoryOptions } from '@/agent/core'; +import { geminiTransport } from '@/backends/gemini/acp/transport'; import { logger } from '@/ui/logger'; import { GEMINI_API_KEY_ENV, GOOGLE_API_KEY_ENV, GEMINI_MODEL_ENV, DEFAULT_GEMINI_MODEL -} from '@/gemini/constants'; +} from '@/backends/gemini/constants'; import { readGeminiLocalConfig, determineGeminiModel, getGeminiModelSource -} from '@/gemini/utils/config'; +} from '@/backends/gemini/utils/config'; /** * Options for creating a Gemini ACP backend @@ -173,15 +172,3 @@ export function createGeminiBackend(options: GeminiBackendOptions): GeminiBacken modelSource, }; } - -/** - * Register Gemini backend with the global agent registry. - * - * This function should be called during application initialization - * to make the Gemini agent available for use. - */ -export function registerGeminiAgent(): void { - agentRegistry.register('gemini', (opts) => createGeminiBackend(opts).backend); - logger.debug('[Gemini] Registered with agent registry'); -} - diff --git a/cli/src/backends/gemini/acp/transport.test.ts b/cli/src/backends/gemini/acp/transport.test.ts new file mode 100644 index 000000000..262f3292b --- /dev/null +++ b/cli/src/backends/gemini/acp/transport.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; + +import { geminiTransport } from './transport'; + +describe('GeminiTransport determineToolName', () => { + it('detects write_file tool calls', () => { + expect( + geminiTransport.determineToolName('other', 'write_file-123', { filePath: '/tmp/a', content: 'x' }, { recentPromptHadChangeTitle: false, toolCallCountSincePrompt: 0 }) + ).toBe('write'); + }); + + it('detects run_shell_command tool calls', () => { + expect( + geminiTransport.determineToolName('other', 'run_shell_command-123', { command: 'pwd' }, { recentPromptHadChangeTitle: false, toolCallCountSincePrompt: 0 }) + ).toBe('execute'); + }); + + it('detects read_file tool calls', () => { + expect( + geminiTransport.determineToolName('other', 'read_file-123', { filePath: '/tmp/a' }, { recentPromptHadChangeTitle: false, toolCallCountSincePrompt: 0 }) + ).toBe('read'); + }); +}); + +describe('GeminiTransport extractToolNameFromId', () => { + it('prefers TodoWrite over write for write_todos toolCallIds', () => { + expect(geminiTransport.extractToolNameFromId('write_todos-123')).toBe('TodoWrite'); + }); + + it('detects replace as edit', () => { + expect(geminiTransport.extractToolNameFromId('replace-123')).toBe('edit'); + }); + + it('detects glob toolCallIds', () => { + expect(geminiTransport.extractToolNameFromId('glob-123')).toBe('glob'); + }); +}); diff --git a/cli/src/agent/transport/handlers/GeminiTransport.ts b/cli/src/backends/gemini/acp/transport.ts similarity index 75% rename from cli/src/agent/transport/handlers/GeminiTransport.ts rename to cli/src/backends/gemini/acp/transport.ts index a516b9467..d03c3de88 100644 --- a/cli/src/agent/transport/handlers/GeminiTransport.ts +++ b/cli/src/backends/gemini/acp/transport.ts @@ -18,9 +18,17 @@ import type { StderrContext, StderrResult, ToolNameContext, -} from '../TransportHandler'; -import type { AgentMessage } from '../../core'; +} from '@/agent/transport/TransportHandler'; +import type { AgentMessage } from '@/agent/core'; import { logger } from '@/ui/logger'; +import { filterJsonObjectOrArrayLine } from '@/agent/transport/utils/jsonStdoutFilter'; +import { + findEmptyInputDefaultToolName, + findToolNameFromId, + findToolNameFromInputFields, + isEmptyToolInput, + type ToolPatternWithInputFields, +} from '@/agent/transport/utils/toolPatternInference'; /** * Gemini-specific timeout values (in milliseconds) @@ -48,14 +56,7 @@ export const GEMINI_TIMEOUTS = { * - inputFields: optional fields that indicate this tool when present in input * - emptyInputDefault: if true, this tool is the default when input is empty */ -interface ExtendedToolPattern extends ToolPattern { - /** Fields in input that indicate this tool */ - inputFields?: string[]; - /** If true, this is the default tool when input is empty and toolName is "other" */ - emptyInputDefault?: boolean; -} - -const GEMINI_TOOL_PATTERNS: ExtendedToolPattern[] = [ +const GEMINI_TOOL_PATTERNS: ToolPatternWithInputFields[] = [ { name: 'change_title', patterns: ['change_title', 'change-title', 'happy__change_title', 'mcp__happy__change_title'], @@ -72,6 +73,37 @@ const GEMINI_TOOL_PATTERNS: ExtendedToolPattern[] = [ patterns: ['think'], inputFields: ['thought', 'thinking'], }, + // Gemini CLI filesystem / shell tool conventions + { + name: 'read', + patterns: ['read', 'read_file'], + inputFields: ['filePath', 'file_path', 'path', 'locations'], + }, + { + name: 'write', + patterns: ['write', 'write_file'], + inputFields: ['filePath', 'file_path', 'path', 'content'], + }, + { + name: 'edit', + patterns: ['edit', 'replace'], + inputFields: ['oldText', 'newText', 'old_string', 'new_string', 'oldString', 'newString'], + }, + { + name: 'execute', + patterns: ['run_shell_command', 'shell', 'exec', 'bash'], + inputFields: ['command', 'cmd'], + }, + { + name: 'glob', + patterns: ['glob'], + inputFields: ['pattern', 'glob'], + }, + { + name: 'TodoWrite', + patterns: ['write_todos', 'todo_write', 'todowrite'], + inputFields: ['todos', 'items'], + }, ]; /** @@ -108,30 +140,7 @@ export class GeminiTransport implements TransportHandler { * that breaks ACP JSON-RPC parsing. We only keep valid JSON lines. */ filterStdoutLine(line: string): string | null { - const trimmed = line.trim(); - - // Empty lines - skip - if (!trimmed) { - return null; - } - - // Must start with { or [ to be valid JSON-RPC - if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) { - return null; - } - - // Validate it's actually parseable JSON and is an object (not a primitive) - // JSON-RPC messages are always objects, but numbers like "105887304" parse as valid JSON - try { - const parsed = JSON.parse(trimmed); - // Must be an object or array (for batched requests), not a primitive - if (typeof parsed !== 'object' || parsed === null) { - return null; - } - return line; - } catch { - return null; - } + return filterJsonObjectOrArrayLine(line); } /** @@ -235,27 +244,7 @@ export class GeminiTransport implements TransportHandler { * Tool IDs often contain the tool name as a prefix (e.g., "change_title-1765385846663" -> "change_title") */ extractToolNameFromId(toolCallId: string): string | null { - const lowerId = toolCallId.toLowerCase(); - - for (const toolPattern of GEMINI_TOOL_PATTERNS) { - for (const pattern of toolPattern.patterns) { - if (lowerId.includes(pattern.toLowerCase())) { - return toolPattern.name; - } - } - } - - return null; - } - - /** - * Check if input is effectively empty - */ - private isEmptyInput(input: Record | undefined | null): boolean { - if (!input) return true; - if (Array.isArray(input)) return input.length === 0; - if (typeof input === 'object') return Object.keys(input).length === 0; - return false; + return findToolNameFromId(toolCallId, GEMINI_TOOL_PATTERNS, { preferLongestMatch: true }); } /** @@ -275,42 +264,27 @@ export class GeminiTransport implements TransportHandler { input: Record, _context: ToolNameContext ): string { - // If tool name is already known, return it - if (toolName !== 'other' && toolName !== 'Unknown tool') { - return toolName; - } - // 1. Check toolCallId for known tool names (most reliable) // Tool IDs often contain the tool name: "change_title-123456" -> "change_title" - const idToolName = this.extractToolNameFromId(toolCallId); + const idToolName = findToolNameFromId(toolCallId, GEMINI_TOOL_PATTERNS, { preferLongestMatch: true }); if (idToolName) { return idToolName; } - // 2. Check input fields for tool-specific signatures - if (input && typeof input === 'object' && !Array.isArray(input)) { - const inputKeys = Object.keys(input); - - for (const toolPattern of GEMINI_TOOL_PATTERNS) { - if (toolPattern.inputFields) { - // Check if any input field matches this tool's signature - const hasMatchingField = toolPattern.inputFields.some((field) => - inputKeys.some((key) => key.toLowerCase() === field.toLowerCase()) - ); - if (hasMatchingField) { - return toolPattern.name; - } - } - } + // If tool name is already known and not generic, keep it. + if (toolName !== 'other' && toolName !== 'Unknown tool') { + return toolName; } + // 2. Check input fields for tool-specific signatures + const inputFieldToolName = findToolNameFromInputFields(input, GEMINI_TOOL_PATTERNS); + if (inputFieldToolName) return inputFieldToolName; + // 3. For empty input, use the default tool (if configured) // This handles cases like change_title where the title is extracted from context - if (this.isEmptyInput(input) && toolName === 'other') { - const defaultTool = GEMINI_TOOL_PATTERNS.find((p) => p.emptyInputDefault); - if (defaultTool) { - return defaultTool.name; - } + if (toolName === 'other' && isEmptyToolInput(input)) { + const defaultToolName = findEmptyInputDefaultToolName(GEMINI_TOOL_PATTERNS); + if (defaultToolName) return defaultToolName; } // Return original tool name if we couldn't determine it diff --git a/cli/src/backends/gemini/cli/capability.ts b/cli/src/backends/gemini/cli/capability.ts new file mode 100644 index 000000000..582f6e80d --- /dev/null +++ b/cli/src/backends/gemini/cli/capability.ts @@ -0,0 +1,38 @@ +import type { Capability } from '@/capabilities/service'; +import { buildCliCapabilityData } from '@/capabilities/probes/cliBase'; +import { probeAcpAgentCapabilities } from '@/capabilities/probes/acpProbe'; +import { geminiTransport } from '@/backends/gemini/acp/transport'; +import { normalizeCapabilityProbeError } from '@/capabilities/utils/normalizeCapabilityProbeError'; +import { resolveAcpProbeTimeoutMs } from '@/capabilities/utils/acpProbeTimeout'; + +export const cliCapability: Capability = { + descriptor: { id: 'cli.gemini', kind: 'cli', title: 'Gemini CLI' }, + detect: async ({ request, context }) => { + const entry = context.cliSnapshot?.clis?.gemini; + const base = buildCliCapabilityData({ request, entry }); + + const includeAcpCapabilities = Boolean((request.params ?? {}).includeAcpCapabilities); + if (!includeAcpCapabilities || base.available !== true || !base.resolvedPath) { + return base; + } + + const probe = await probeAcpAgentCapabilities({ + command: base.resolvedPath, + args: ['--experimental-acp'], + cwd: process.cwd(), + env: { + // Keep output clean to avoid ACP stdout pollution. + NODE_ENV: 'production', + DEBUG: '', + }, + transport: geminiTransport, + timeoutMs: resolveAcpProbeTimeoutMs('gemini'), + }); + + const acp = probe.ok + ? { ok: true, checkedAt: probe.checkedAt, loadSession: probe.agentCapabilities?.loadSession === true } + : { ok: false, checkedAt: probe.checkedAt, error: normalizeCapabilityProbeError(probe.error) }; + + return { ...base, acp }; + }, +}; diff --git a/cli/src/backends/gemini/cli/checklists.ts b/cli/src/backends/gemini/cli/checklists.ts new file mode 100644 index 000000000..ec083e282 --- /dev/null +++ b/cli/src/backends/gemini/cli/checklists.ts @@ -0,0 +1,4 @@ +import type { AgentChecklistContributions } from '@/backends/types'; + +export const checklists = { +} satisfies AgentChecklistContributions; diff --git a/cli/src/backends/gemini/cli/command.ts b/cli/src/backends/gemini/cli/command.ts new file mode 100644 index 000000000..84b4fc35a --- /dev/null +++ b/cli/src/backends/gemini/cli/command.ts @@ -0,0 +1,199 @@ +import chalk from 'chalk'; + +import { CODEX_GEMINI_PERMISSION_MODES, isCodexGeminiPermissionMode } from '@/api/types'; +import { authAndSetupMachineIfNeeded } from '@/ui/auth'; +import { ApiClient } from '@/api/api'; +import { logger } from '@/ui/logger'; +import { isDaemonRunningCurrentlyInstalledHappyVersion } from '@/daemon/controlClient'; +import { spawnHappyCLI } from '@/utils/spawnHappyCLI'; +import { parseSessionStartArgs } from '@/cli/sessionStartArgs'; +import { DEFAULT_GEMINI_MODEL, GEMINI_MODEL_ENV } from '@/backends/gemini/constants'; + +import type { CommandContext } from '@/cli/commandRegistry'; + +export async function handleGeminiCliCommand(context: CommandContext): Promise { + const args = context.args; + const geminiSubcommand = args[1]; + + if (geminiSubcommand === 'model' && args[2] === 'set' && args[3]) { + const modelName = args[3]; + const validModels = ['gemini-2.5-pro', 'gemini-2.5-flash', 'gemini-2.5-flash-lite']; + + if (!validModels.includes(modelName)) { + console.error(`Invalid model: ${modelName}`); + console.error(`Available models: ${validModels.join(', ')}`); + process.exit(1); + } + + try { + const { saveGeminiModelToConfig } = await import('@/backends/gemini/utils/config'); + saveGeminiModelToConfig(modelName); + const { join } = await import('node:path'); + const { homedir } = await import('node:os'); + const configPath = join(homedir(), '.gemini', 'config.json'); + console.log(`✓ Model set to: ${modelName}`); + console.log(` Config saved to: ${configPath}`); + console.log(' This model will be used in future sessions.'); + process.exit(0); + } catch (error) { + console.error('Failed to save model configuration:', error); + process.exit(1); + } + } + + if (geminiSubcommand === 'model' && args[2] === 'get') { + try { + const { readGeminiLocalConfig } = await import('@/backends/gemini/utils/config'); + const local = readGeminiLocalConfig(); + if (local.model) { + console.log(`Current model: ${local.model}`); + } else if (process.env[GEMINI_MODEL_ENV]) { + console.log(`Current model: ${process.env[GEMINI_MODEL_ENV]} (from ${GEMINI_MODEL_ENV} env var)`); + } else { + console.log(`Current model: ${DEFAULT_GEMINI_MODEL} (default)`); + } + process.exit(0); + } catch (error) { + console.error('Failed to read model configuration:', error); + process.exit(1); + } + } + + if (geminiSubcommand === 'project' && args[2] === 'set' && args[3]) { + const projectId = args[3]; + + try { + const { saveGoogleCloudProjectToConfig } = await import('@/backends/gemini/utils/config'); + + let userEmail: string | undefined = undefined; + try { + const { readCredentials } = await import('@/persistence'); + const credentials = await readCredentials(); + if (credentials) { + const api = await ApiClient.create(credentials); + const vendorToken = await api.getVendorToken('gemini'); + if (vendorToken?.oauth?.id_token) { + const parts = vendorToken.oauth.id_token.split('.'); + if (parts.length === 3) { + const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')); + userEmail = payload.email; + } + } + } + } catch { + // If we can't get email, project will be saved globally + } + + saveGoogleCloudProjectToConfig(projectId, userEmail); + console.log(`✓ Google Cloud Project set to: ${projectId}`); + if (userEmail) { + console.log(` Linked to account: ${userEmail}`); + } + console.log(' This project will be used for Google Workspace accounts.'); + process.exit(0); + } catch (error) { + console.error('Failed to save project configuration:', error); + process.exit(1); + } + } + + if (geminiSubcommand === 'project' && args[2] === 'get') { + try { + const { readGeminiLocalConfig } = await import('@/backends/gemini/utils/config'); + const config = readGeminiLocalConfig(); + + if (config.googleCloudProject) { + console.log(`Current Google Cloud Project: ${config.googleCloudProject}`); + if (config.googleCloudProjectEmail) { + console.log(` Linked to account: ${config.googleCloudProjectEmail}`); + } else { + console.log(' Applies to: all accounts (global)'); + } + } else if (process.env.GOOGLE_CLOUD_PROJECT) { + console.log(`Current Google Cloud Project: ${process.env.GOOGLE_CLOUD_PROJECT} (from env var)`); + } else { + console.log('No Google Cloud Project configured.'); + console.log(''); + console.log('If you see "Authentication required" error, you may need to set a project:'); + console.log(' happy gemini project set '); + console.log(''); + console.log('This is required for Google Workspace accounts.'); + console.log('Guide: https://goo.gle/gemini-cli-auth-docs#workspace-gca'); + } + process.exit(0); + } catch (error) { + console.error('Failed to read project configuration:', error); + process.exit(1); + } + } + + if (geminiSubcommand === 'project' && !args[2]) { + console.log('Usage: happy gemini project '); + console.log(''); + console.log('Commands:'); + console.log(' set Set Google Cloud Project ID'); + console.log(' get Show current Google Cloud Project ID'); + console.log(''); + console.log('Google Workspace accounts require a Google Cloud Project.'); + console.log('If you see "Authentication required" error, set your project ID.'); + console.log(''); + console.log('Guide: https://goo.gle/gemini-cli-auth-docs#workspace-gca'); + process.exit(0); + } + + try { + const { runGemini } = await import('@/backends/gemini/runGemini'); + + const { startedBy, permissionMode, permissionModeUpdatedAt } = parseSessionStartArgs(args); + if (permissionMode && !isCodexGeminiPermissionMode(permissionMode)) { + console.error( + chalk.red( + `Invalid --permission-mode for gemini: ${permissionMode}. Valid values: ${CODEX_GEMINI_PERMISSION_MODES.join(', ')}`, + ), + ); + console.error(chalk.gray('Tip: use --yolo for full bypass-like behavior.')); + process.exit(1); + } + + const readFlagValue = (flag: string): string | undefined => { + const idx = args.indexOf(flag); + if (idx === -1) return undefined; + const value = args[idx + 1]; + if (!value || value.startsWith('-')) return undefined; + return value; + }; + + const existingSessionId = readFlagValue('--existing-session'); + const resume = readFlagValue('--resume'); + + const { credentials } = await authAndSetupMachineIfNeeded(); + + logger.debug('Ensuring Happy background service is running & matches our version...'); + if (!(await isDaemonRunningCurrentlyInstalledHappyVersion())) { + logger.debug('Starting Happy background service...'); + const daemonProcess = spawnHappyCLI(['daemon', 'start-sync'], { + detached: true, + stdio: 'ignore', + env: process.env, + }); + daemonProcess.unref(); + await new Promise((resolve) => setTimeout(resolve, 200)); + } + + await runGemini({ + credentials, + startedBy, + terminalRuntime: context.terminalRuntime, + permissionMode, + permissionModeUpdatedAt, + existingSessionId, + resume, + }); + } catch (error) { + console.error(chalk.red('Error:'), error instanceof Error ? error.message : 'Unknown error'); + if (process.env.DEBUG) { + console.error(error); + } + process.exit(1); + } +} diff --git a/cli/src/backends/gemini/cli/detect.ts b/cli/src/backends/gemini/cli/detect.ts new file mode 100644 index 000000000..4ef7ab7c2 --- /dev/null +++ b/cli/src/backends/gemini/cli/detect.ts @@ -0,0 +1,7 @@ +import type { CliDetectSpec } from '@/backends/types'; + +export const cliDetect = { + versionArgsToTry: [['--version'], ['version'], ['-v']], + loginStatusArgs: ['auth', 'status'], +} satisfies CliDetectSpec; + diff --git a/cli/src/commands/connect/authenticateGemini.ts b/cli/src/backends/gemini/cloud/authenticate.ts similarity index 91% rename from cli/src/commands/connect/authenticateGemini.ts rename to cli/src/backends/gemini/cloud/authenticate.ts index 588b65885..471faf88f 100644 --- a/cli/src/commands/connect/authenticateGemini.ts +++ b/cli/src/backends/gemini/cloud/authenticate.ts @@ -6,13 +6,22 @@ */ import { createServer, IncomingMessage, ServerResponse } from 'http'; -import { randomBytes, createHash } from 'crypto'; +import { randomBytes } from 'crypto'; import { exec } from 'child_process'; import { promisify } from 'util'; -import { GeminiAuthTokens, PKCECodes } from './types'; +import { generatePkceCodes } from '@/cloud/pkce'; const execAsync = promisify(exec); +export interface GeminiAuthTokens { + access_token: string; + refresh_token?: string; + token_type: string; + expires_in: number; + scope: string; + id_token?: string; +} + // Google OAuth Configuration for Gemini const CLIENT_ID = '681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com'; const CLIENT_SECRET = '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'; @@ -25,26 +34,6 @@ const SCOPES = [ 'https://www.googleapis.com/auth/userinfo.profile', ].join(' '); -/** - * Generate PKCE codes for OAuth flow - */ -function generatePKCE(): PKCECodes { - // Generate code verifier (43-128 characters, base64url) - const verifier = randomBytes(32) - .toString('base64url') - .replace(/[^a-zA-Z0-9\-._~]/g, ''); - - // Generate code challenge (SHA256 of verifier, base64url encoded) - const challenge = createHash('sha256') - .update(verifier) - .digest('base64url') - .replace(/=/g, '') - .replace(/\+/g, '-') - .replace(/\//g, '_'); - - return { verifier, challenge }; -} - /** * Generate random state for OAuth security */ @@ -205,7 +194,7 @@ export async function authenticateGemini(): Promise { console.log('🚀 Starting Google Gemini authentication...'); // Generate PKCE codes and state - const { verifier, challenge } = generatePKCE(); + const { verifier, challenge } = generatePkceCodes(); const state = generateState(); // Try to use default port, or find an available one @@ -271,4 +260,4 @@ export async function authenticateGemini(): Promise { console.error('\n❌ Failed to authenticate with Google'); throw error; } -} \ No newline at end of file +} diff --git a/cli/src/backends/gemini/cloud/connect.ts b/cli/src/backends/gemini/cloud/connect.ts new file mode 100644 index 000000000..adb28f861 --- /dev/null +++ b/cli/src/backends/gemini/cloud/connect.ts @@ -0,0 +1,14 @@ +import type { CloudConnectTarget } from '@/cloud/connectTypes'; +import { AGENTS_CORE } from '@happy/agents'; +import { authenticateGemini } from './authenticate'; +import { updateLocalGeminiCredentials } from './updateLocalCredentials'; + +export const geminiCloudConnect: CloudConnectTarget = { + id: 'gemini', + displayName: 'Gemini', + vendorDisplayName: 'Google Gemini', + vendorKey: AGENTS_CORE.gemini.cloudConnect!.vendorKey, + status: AGENTS_CORE.gemini.cloudConnect!.status, + authenticate: authenticateGemini, + postConnect: updateLocalGeminiCredentials, +}; diff --git a/cli/src/backends/gemini/cloud/updateLocalCredentials.ts b/cli/src/backends/gemini/cloud/updateLocalCredentials.ts new file mode 100644 index 000000000..6de1c6327 --- /dev/null +++ b/cli/src/backends/gemini/cloud/updateLocalCredentials.ts @@ -0,0 +1,49 @@ +import chalk from 'chalk'; +import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +type GeminiOAuthTokens = Readonly<{ + access_token: string; + refresh_token?: string; + id_token?: string; + expires_in?: number; + token_type?: string; + scope?: string; +}>; + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +export function updateLocalGeminiCredentials(oauth: unknown): void { + if (!isRecord(oauth) || typeof oauth.access_token !== 'string') { + return; + } + + const tokens = oauth as GeminiOAuthTokens; + + try { + const geminiDir = join(homedir(), '.gemini'); + const credentialsPath = join(geminiDir, 'oauth_creds.json'); + + if (!existsSync(geminiDir)) { + mkdirSync(geminiDir, { recursive: true }); + } + + const credentials = { + access_token: tokens.access_token, + token_type: tokens.token_type || 'Bearer', + scope: tokens.scope || 'https://www.googleapis.com/auth/cloud-platform', + ...(tokens.refresh_token && { refresh_token: tokens.refresh_token }), + ...(tokens.id_token && { id_token: tokens.id_token }), + ...(tokens.expires_in && { expires_in: tokens.expires_in }), + }; + + writeFileSync(credentialsPath, JSON.stringify(credentials, null, 2), 'utf-8'); + console.log(chalk.gray(` Updated local credentials: ${credentialsPath}`)); + } catch (error) { + console.log(chalk.yellow(` ⚠️ Could not update local credentials: ${error}`)); + } +} + diff --git a/cli/src/gemini/constants.ts b/cli/src/backends/gemini/constants.ts similarity index 55% rename from cli/src/gemini/constants.ts rename to cli/src/backends/gemini/constants.ts index 4903b2341..9476147ba 100644 --- a/cli/src/gemini/constants.ts +++ b/cli/src/backends/gemini/constants.ts @@ -5,7 +5,7 @@ * and default values. */ -import { trimIdent } from '@/utils/trimIdent'; +import { CHANGE_TITLE_INSTRUCTION } from '@/agent/runtime/changeTitleInstruction'; /** Environment variable name for Gemini API key */ export const GEMINI_API_KEY_ENV = 'GEMINI_API_KEY'; @@ -19,11 +19,5 @@ export const GEMINI_MODEL_ENV = 'GEMINI_MODEL'; /** Default Gemini model */ export const DEFAULT_GEMINI_MODEL = 'gemini-2.5-pro'; -/** - * Instruction for changing chat title - * Used in system prompts to instruct agents to call change_title function - */ -export const CHANGE_TITLE_INSTRUCTION = trimIdent( - `Based on this message, call functions.happy__change_title to change chat session title that would represent the current task. If chat idea would change dramatically - call this function again to update the title.` -); - +// Back-compat export (this constant is shared across agents, not Gemini-specific). +export { CHANGE_TITLE_INSTRUCTION }; diff --git a/cli/src/backends/gemini/daemon/spawnHooks.ts b/cli/src/backends/gemini/daemon/spawnHooks.ts new file mode 100644 index 000000000..7406e9ffd --- /dev/null +++ b/cli/src/backends/gemini/daemon/spawnHooks.ts @@ -0,0 +1,10 @@ +import type { DaemonSpawnHooks } from '@/daemon/spawnHooks'; + +export const geminiDaemonSpawnHooks: DaemonSpawnHooks = { + buildAuthEnv: async ({ token }) => ({ + env: { CLAUDE_CODE_OAUTH_TOKEN: token }, + cleanupOnFailure: null, + cleanupOnExit: null, + }), +}; + diff --git a/cli/src/backends/gemini/index.ts b/cli/src/backends/gemini/index.ts new file mode 100644 index 000000000..94541d066 --- /dev/null +++ b/cli/src/backends/gemini/index.ts @@ -0,0 +1,21 @@ +import { AGENTS_CORE } from '@happy/agents'; + +import { checklists } from './cli/checklists'; +import type { AgentCatalogEntry } from '../types'; + +export const agent = { + id: AGENTS_CORE.gemini.id, + cliSubcommand: AGENTS_CORE.gemini.cliSubcommand, + getCliCommandHandler: async () => (await import('@/backends/gemini/cli/command')).handleGeminiCliCommand, + getCliCapabilityOverride: async () => (await import('@/backends/gemini/cli/capability')).cliCapability, + getCliDetect: async () => (await import('@/backends/gemini/cli/detect')).cliDetect, + getCloudConnectTarget: async () => (await import('@/backends/gemini/cloud/connect')).geminiCloudConnect, + getDaemonSpawnHooks: async () => (await import('@/backends/gemini/daemon/spawnHooks')).geminiDaemonSpawnHooks, + vendorResumeSupport: AGENTS_CORE.gemini.resume.vendorResume, + getAcpBackendFactory: async () => { + const { createGeminiBackend } = await import('@/backends/gemini/acp/backend'); + return (opts) => createGeminiBackend(opts as any); + }, + checklists, +} satisfies AgentCatalogEntry; + diff --git a/cli/src/gemini/runGemini.ts b/cli/src/backends/gemini/runGemini.ts similarity index 77% rename from cli/src/gemini/runGemini.ts rename to cli/src/backends/gemini/runGemini.ts index 43bf6423e..91f5cbe7b 100644 --- a/cli/src/gemini/runGemini.ts +++ b/cli/src/backends/gemini/runGemini.ts @@ -15,42 +15,59 @@ import { join, resolve } from 'node:path'; import { ApiClient } from '@/api/api'; import { logger } from '@/ui/logger'; import { Credentials, readSettings } from '@/persistence'; -import { createSessionMetadata } from '@/utils/createSessionMetadata'; +import { createSessionMetadata } from '@/agent/runtime/createSessionMetadata'; import { initialMachineMetadata } from '@/daemon/run'; import { configuration } from '@/configuration'; -import packageJson from '../../package.json'; +import packageJson from '../../../package.json'; import { MessageQueue2 } from '@/utils/MessageQueue2'; import { hashObject } from '@/utils/deterministicJson'; import { projectPath } from '@/projectPath'; -import { startHappyServer } from '@/claude/utils/startHappyServer'; +import { startHappyServer } from '@/mcp/startHappyServer'; import { MessageBuffer } from '@/ui/ink/messageBuffer'; -import { notifyDaemonSessionStarted } from '@/daemon/controlClient'; -import { registerKillSessionHandler } from '@/claude/registerKillSessionHandler'; -import { stopCaffeinate } from '@/utils/caffeinate'; -import { connectionState } from '@/utils/serverConnectionErrors'; -import { setupOfflineReconnection } from '@/utils/setupOfflineReconnection'; +import { registerKillSessionHandler } from '@/rpc/handlers/killSession'; +import { stopCaffeinate } from '@/integrations/caffeinate'; +import { connectionState } from '@/api/offline/serverConnectionErrors'; +import { setupOfflineReconnection } from '@/api/offline/setupOfflineReconnection'; +import { waitForMessagesOrPending } from '@/agent/runtime/waitForMessagesOrPending'; import type { ApiSessionClient } from '@/api/apiSession'; - -import { createGeminiBackend } from '@/agent/factories/gemini'; +import { formatGeminiErrorForUi } from '@/backends/gemini/utils/formatGeminiErrorForUi'; +import { buildTerminalMetadataFromRuntimeFlags } from '@/terminal/terminalMetadata'; +import { maybeUpdatePermissionModeMetadata } from '@/agent/runtime/permissionModeMetadata'; +import { applyStartupMetadataUpdateToSession, buildPermissionModeOverride } from '@/agent/runtime/startupMetadataUpdate'; +import { createBaseSessionForAttach } from '@/agent/runtime/createBaseSessionForAttach'; +import { persistTerminalAttachmentInfoIfNeeded, primeAgentStateForUi, reportSessionToDaemonIfRunning, sendTerminalFallbackMessageIfNeeded } from '@/agent/runtime/startupSideEffects'; + +import { createCatalogAcpBackend } from '@/agent/acp'; +import type { GeminiBackendOptions, GeminiBackendResult } from '@/backends/gemini/acp/backend'; +import { importAcpReplayHistoryV1 } from '@/agent/acp/history/importAcpReplayHistory'; +import { normalizeAvailableCommands, publishSlashCommandsToMetadata } from '@/agent/acp/commands/publishSlashCommands'; import type { AgentBackend, AgentMessage } from '@/agent'; -import { GeminiDisplay } from '@/ui/ink/GeminiDisplay'; -import { GeminiPermissionHandler } from '@/gemini/utils/permissionHandler'; -import { GeminiReasoningProcessor } from '@/gemini/utils/reasoningProcessor'; -import { GeminiDiffProcessor } from '@/gemini/utils/diffProcessor'; -import type { GeminiMode, CodexMessagePayload } from '@/gemini/types'; -import type { PermissionMode } from '@/api/types'; -import { GEMINI_MODEL_ENV, DEFAULT_GEMINI_MODEL, CHANGE_TITLE_INSTRUCTION } from '@/gemini/constants'; +import { GeminiTerminalDisplay } from '@/backends/gemini/ui/GeminiTerminalDisplay'; +import { GeminiPermissionHandler } from '@/backends/gemini/utils/permissionHandler'; +import { GeminiReasoningProcessor } from '@/backends/gemini/utils/reasoningProcessor'; +import { GeminiDiffProcessor } from '@/backends/gemini/utils/diffProcessor'; +import type { GeminiMode, CodexMessagePayload } from '@/backends/gemini/types'; +import { CODEX_GEMINI_PERMISSION_MODES, isCodexGeminiPermissionMode, type CodexGeminiPermissionMode, type PermissionMode } from '@/api/types'; +import { GEMINI_MODEL_ENV, DEFAULT_GEMINI_MODEL } from '@/backends/gemini/constants'; +import { CHANGE_TITLE_INSTRUCTION } from '@/agent/runtime/changeTitleInstruction'; import { readGeminiLocalConfig, saveGeminiModelToConfig, getInitialGeminiModel -} from '@/gemini/utils/config'; +} from '@/backends/gemini/utils/config'; +import { maybeUpdateGeminiSessionIdMetadata } from '@/backends/gemini/utils/geminiSessionIdMetadata'; import { parseOptionsFromText, hasIncompleteOptions, formatOptionsXml, -} from '@/gemini/utils/optionsParser'; -import { ConversationHistory } from '@/gemini/utils/conversationHistory'; +} from '@/backends/gemini/utils/optionsParser'; +import { ConversationHistory } from '@/backends/gemini/utils/conversationHistory'; +import { + handleAcpModelOutputDelta, + handleAcpStatusRunning, + forwardAcpPermissionRequest, + forwardAcpTerminalOutput, +} from '@/agent/acp/bridge/acpCommonHandlers'; /** @@ -59,6 +76,11 @@ import { ConversationHistory } from '@/gemini/utils/conversationHistory'; export async function runGemini(opts: { credentials: Credentials; startedBy?: 'daemon' | 'terminal'; + terminalRuntime?: import('@/terminal/terminalRuntimeFlags').TerminalRuntimeFlags | null; + permissionMode?: PermissionMode; + permissionModeUpdatedAt?: number; + existingSessionId?: string; + resume?: string; }): Promise { // // Define session @@ -124,15 +146,24 @@ export async function runGemini(opts: { // Create session // + const initialPermissionMode: PermissionMode = + opts.permissionMode && isCodexGeminiPermissionMode(opts.permissionMode) + ? opts.permissionMode + : 'default'; + const { state, metadata } = createSessionMetadata({ flavor: 'gemini', machineId, - startedBy: opts.startedBy + startedBy: opts.startedBy, + terminalRuntime: opts.terminalRuntime ?? null, + permissionMode: initialPermissionMode, + permissionModeUpdatedAt: typeof opts.permissionModeUpdatedAt === 'number' ? opts.permissionModeUpdatedAt : Date.now(), }); - const response = await api.getOrCreateSession({ tag: sessionTag, metadata, state }); + const terminal = buildTerminalMetadataFromRuntimeFlags(opts.terminalRuntime ?? null); // Handle server unreachable case - create offline stub with hot reconnection let session: ApiSessionClient; + let reconnectionHandle: { cancel: () => void } | null = null; // Permission handler declared here so it can be updated in onSessionSwap callback // (assigned later after Happy server setup) let permissionHandler: GeminiPermissionHandler; @@ -157,42 +188,67 @@ export async function runGemini(opts: { } }; - const { session: initialSession, reconnectionHandle } = setupOfflineReconnection({ - api, - sessionTag, - metadata, - state, - response, - onSessionSwap: (newSession) => { - // If we're processing a message, queue the swap for later - // This prevents race conditions where session changes mid-processing - if (isProcessingMessage) { - logger.debug('[gemini] Session swap requested during message processing - queueing'); - pendingSessionSwap = newSession; - } else { - // Safe to swap immediately - session = newSession; - if (permissionHandler) { - permissionHandler.updateSession(newSession); - } - } - } + const normalizedExistingSessionId = typeof opts.existingSessionId === 'string' ? opts.existingSessionId.trim() : ''; + const permissionModeOverride = buildPermissionModeOverride({ + permissionMode: opts.permissionMode, + permissionModeUpdatedAt: opts.permissionModeUpdatedAt, }); - session = initialSession; - // Report to daemon (only if we have a real session) - if (response) { - try { - logger.debug(`[START] Reporting session ${response.id} to daemon`); - const result = await notifyDaemonSessionStarted(response.id, metadata); - if (result.error) { - logger.debug(`[START] Failed to report to daemon (may not be running):`, result.error); - } else { - logger.debug(`[START] Reported session ${response.id} to daemon`); + let reportedSessionId: string | null = null; + + if (normalizedExistingSessionId) { + logger.debug(`[gemini] Attaching to existing Happy session: ${normalizedExistingSessionId}`); + const baseSession = await createBaseSessionForAttach({ + existingSessionId: normalizedExistingSessionId, + metadata, + state, + }); + + session = api.sessionSyncClient(baseSession); + reportedSessionId = normalizedExistingSessionId; + + applyStartupMetadataUpdateToSession({ + session, + next: metadata, + nowMs: Date.now(), + permissionModeOverride, + }); + } else { + const response = await api.getOrCreateSession({ tag: sessionTag, metadata, state }); + + const offline = setupOfflineReconnection({ + api, + sessionTag, + metadata, + state, + response, + onSessionSwap: (newSession) => { + // If we're processing a message, queue the swap for later + // This prevents race conditions where session changes mid-processing + if (isProcessingMessage) { + logger.debug('[gemini] Session swap requested during message processing - queueing'); + pendingSessionSwap = newSession; + } else { + // Safe to swap immediately + session = newSession; + if (permissionHandler) { + permissionHandler.updateSession(newSession); + } + } } - } catch (error) { - logger.debug('[START] Failed to report to daemon (may not be running):', error); - } + }); + + session = offline.session; + reconnectionHandle = offline.reconnectionHandle; + reportedSessionId = response ? response.id : null; + } + + primeAgentStateForUi(session, '[gemini]'); + + if (reportedSessionId) { + await persistTerminalAttachmentInfoIfNeeded({ sessionId: reportedSessionId, terminal }); + sendTerminalFallbackMessageIfNeeded({ session, terminal }); + await reportSessionToDaemonIfRunning({ sessionId: reportedSessionId, metadata }); } const messageQueue = new MessageQueue2((mode) => hashObject({ @@ -204,32 +260,33 @@ export async function runGemini(opts: { const conversationHistory = new ConversationHistory({ maxMessages: 20, maxCharacters: 50000 }); // Track current overrides to apply per message - let currentPermissionMode: PermissionMode | undefined = undefined; + let currentPermissionMode: PermissionMode | undefined = initialPermissionMode; let currentModel: string | undefined = undefined; session.onUserMessage((message) => { // Resolve permission mode (validate) - same as Codex let messagePermissionMode = currentPermissionMode; if (message.meta?.permissionMode) { - const validModes: PermissionMode[] = ['default', 'read-only', 'safe-yolo', 'yolo']; - if (validModes.includes(message.meta.permissionMode as PermissionMode)) { - messagePermissionMode = message.meta.permissionMode as PermissionMode; - currentPermissionMode = messagePermissionMode; - // Update permission handler with new mode - updatePermissionMode(messagePermissionMode); - logger.debug(`[Gemini] Permission mode updated from user message to: ${currentPermissionMode}`); + if (CODEX_GEMINI_PERMISSION_MODES.includes(message.meta.permissionMode as CodexGeminiPermissionMode)) { + const nextPermissionMode = message.meta.permissionMode as PermissionMode; + const res = maybeUpdatePermissionModeMetadata({ + currentPermissionMode, + nextPermissionMode, + updateMetadata: (updater) => session.updateMetadata(updater), + }); + currentPermissionMode = res.currentPermissionMode; + messagePermissionMode = currentPermissionMode; + if (res.didChange) { + // Update permission handler with new mode + updatePermissionMode(messagePermissionMode); + logger.debug(`[Gemini] Permission mode updated from user message to: ${currentPermissionMode}`); + } } else { logger.debug(`[Gemini] Invalid permission mode received: ${message.meta.permissionMode}`); } } else { logger.debug(`[Gemini] User message received with no permission mode override, using current: ${currentPermissionMode ?? 'default (effective)'}`); } - - // Initialize permission mode if not set yet - if (currentPermissionMode === undefined) { - currentPermissionMode = 'default'; - updatePermissionMode('default'); - } // Resolve model; explicit null resets to default (undefined) let messageModel = currentModel; @@ -336,6 +393,12 @@ export async function runGemini(opts: { let geminiBackend: AgentBackend | null = null; let acpSessionId: string | null = null; let wasSessionCreated = false; + let storedResumeId: string | null = (() => { + const raw = typeof opts.resume === 'string' ? opts.resume.trim() : ''; + return raw ? raw : null; + })(); + + const lastGeminiSessionIdPublished: { value: string | null } = { value: null }; async function handleAbort() { logger.debug('[Gemini] Abort requested - stopping current task'); @@ -456,7 +519,7 @@ export async function runGemini(opts: { // Read displayedModel from closure - it will have latest value on each render const currentModelValue = displayedModel || 'gemini-2.5-pro'; // Don't log on every render to avoid spam - only log when model changes - return React.createElement(GeminiDisplay, { + return React.createElement(GeminiTerminalDisplay, { messageBuffer, logPath: process.env.DEBUG ? logger.getLogPath() : undefined, currentModel: currentModelValue, @@ -492,16 +555,17 @@ export async function runGemini(opts: { // const happyServer = await startHappyServer(session); - const bridgeCommand = join(projectPath(), 'bin', 'happy-mcp.mjs'); + const bridgeScript = join(projectPath(), 'bin', 'happy-mcp.mjs'); + // Use process.execPath (bun or node) as command to support both runtimes const mcpServers = { happy: { - command: bridgeCommand, - args: ['--url', happyServer.url] + command: process.execPath, + args: [bridgeScript, '--url', happyServer.url] } }; // Create permission handler for tool approval (variable declared earlier for onSessionSwap) - permissionHandler = new GeminiPermissionHandler(session); + permissionHandler = new GeminiPermissionHandler(session, { onAbortRequested: handleAbort }); // Create reasoning processor for handling thinking/reasoning chunks const reasoningProcessor = new GeminiReasoningProcessor((message) => { @@ -539,21 +603,20 @@ export async function runGemini(opts: { switch (msg.type) { case 'model-output': if (msg.textDelta) { - // If this is the first delta of a new response, create a new message - // Otherwise, update the existing message for this response - if (!isResponseInProgress) { - // Start of new response - create new assistant message - // Remove "Thinking..." message if it exists (it will be replaced by actual response) - messageBuffer.removeLastMessage('system'); // Remove "Thinking..." if present - messageBuffer.addMessage(msg.textDelta, 'assistant'); - isResponseInProgress = true; - logger.debug(`[gemini] Started new response, first chunk length: ${msg.textDelta.length}`); + const delta = msg.textDelta; + const wasInProgress = isResponseInProgress; + handleAcpModelOutputDelta({ + delta, + messageBuffer, + getIsResponseInProgress: () => isResponseInProgress, + setIsResponseInProgress: (value) => { isResponseInProgress = value; }, + appendToAccumulatedResponse: (d) => { accumulatedResponse += d; }, + }); + if (!wasInProgress) { + logger.debug(`[gemini] Started new response, first chunk length: ${delta.length}`); } else { - // Continue existing response - update last assistant message - messageBuffer.updateLastMessage(msg.textDelta, 'assistant'); - logger.debug(`[gemini] Updated response, chunk length: ${msg.textDelta.length}, total accumulated: ${accumulatedResponse.length + msg.textDelta.length}`); + logger.debug(`[gemini] Updated response, chunk length: ${delta.length}, total accumulated: ${accumulatedResponse.length}`); } - accumulatedResponse += msg.textDelta; } break; @@ -576,24 +639,15 @@ export async function runGemini(opts: { } if (msg.status === 'running') { - thinking = true; - session.keepAlive(thinking, 'remote'); - - // Send task_started event ONCE per turn (like Codex) when agent starts working - // Gemini may go running -> idle -> running multiple times during a turn - if (!taskStartedSent) { - session.sendAgentMessage('gemini', { - type: 'task_started', - id: randomUUID(), - }); - taskStartedSent = true; - } - - // Show thinking indicator in UI when agent starts working (like Codex) - // This will be updated with actual thinking text when agent_thought_chunk events arrive - // Always show thinking indicator when status becomes 'running' to give user feedback - // Even if response is in progress, we want to show thinking for new operations - messageBuffer.addMessage('Thinking...', 'system'); + handleAcpStatusRunning({ + session, + agent: 'gemini', + messageBuffer, + onThinkingChange: (value) => { thinking = value; }, + getTaskStartedSent: () => taskStartedSent, + setTaskStartedSent: (value) => { taskStartedSent = value; }, + makeId: () => randomUUID(), + }); // Don't reset accumulator here - tool calls can happen during a response // Accumulator will be reset when a new prompt is sent (in the main loop) @@ -678,27 +732,38 @@ export async function runGemini(opts: { changeTitleCompleted = true; logger.debug('[gemini] change_title completed'); } + + const isStreamingChunk = + !!msg.result + && typeof msg.result === 'object' + && (msg.result as any)._stream === true + && (typeof (msg.result as any).stdoutChunk === 'string' || typeof (msg.result as any).stderrChunk === 'string'); - // Show tool result in UI like Codex does - // Check if result contains error information - const isError = msg.result && typeof msg.result === 'object' && 'error' in msg.result; - const resultText = typeof msg.result === 'string' - ? msg.result.substring(0, 200) - : JSON.stringify(msg.result).substring(0, 200); - const truncatedResult = resultText + (typeof msg.result === 'string' && msg.result.length > 200 ? '...' : ''); - - const resultSize = typeof msg.result === 'string' - ? msg.result.length - : JSON.stringify(msg.result).length; + // Show tool result in UI like Codex does + // Check if result contains error information + const isError = msg.result && typeof msg.result === 'object' && 'error' in msg.result; + const resultText = msg.result == null + ? '(no output)' + : typeof msg.result === 'string' + ? msg.result.substring(0, 200) + : JSON.stringify(msg.result).substring(0, 200); + const truncatedResult = resultText + (typeof msg.result === 'string' && msg.result.length > 200 ? '...' : ''); + + const resultSize = typeof msg.result === 'string' + ? msg.result.length + : msg.result == null ? 0 : JSON.stringify(msg.result).length; logger.debug(`[gemini] ${isError ? '❌' : '✅'} Tool result received: ${msg.toolName} (${msg.callId}) - Size: ${resultSize} bytes${isError ? ' [ERROR]' : ''}`); // Process tool result through diff processor to check for diff information (like Codex) - if (!isError) { + if (!isError && !isStreamingChunk) { diffProcessor.processToolResult(msg.toolName, msg.result, msg.callId); } - if (isError) { + if (isStreamingChunk) { + // Avoid spamming the terminal UI for streamed tool result chunks; the mobile UI + // will append these to the active tool as incremental output. + } else if (isError) { const errorMsg = (msg.result as any).error || 'Tool call failed'; logger.debug(`[gemini] ❌ Tool call error: ${errorMsg.substring(0, 300)}`); messageBuffer.addMessage(`Error: ${errorMsg}`, 'status'); @@ -749,26 +814,17 @@ export async function runGemini(opts: { break; case 'terminal-output': - messageBuffer.addMessage(msg.data, 'result'); - session.sendAgentMessage('gemini', { - type: 'terminal-output', - data: msg.data, - callId: (msg as any).callId || randomUUID(), + forwardAcpTerminalOutput({ + msg, + messageBuffer, + session, + agent: 'gemini', + getCallId: (m) => (m as any).callId || randomUUID(), }); break; case 'permission-request': - // Forward permission request to mobile app - // Note: toolName is in msg.payload.toolName (from AcpBackend), - // msg.reason also contains the tool name - const payload = (msg as any).payload || {}; - session.sendAgentMessage('gemini', { - type: 'permission-request', - permissionId: msg.id, - toolName: payload.toolName || (msg as any).reason || 'unknown', - description: (msg as any).reason || payload.toolName || '', - options: payload, - }); + forwardAcpPermissionRequest({ msg, session, agent: 'gemini' }); break; case 'exec-approval-request': @@ -843,6 +899,11 @@ export async function runGemini(opts: { break; case 'event': + if (msg.name === 'available_commands_update') { + const payload = msg.payload as any; + const details = normalizeAvailableCommands(payload?.availableCommands ?? payload); + publishSlashCommandsToMetadata({ session, details }); + } // Handle thinking events - process through ReasoningProcessor like Codex if (msg.name === 'thinking') { const thinkingPayload = msg.payload as { text?: string } | undefined; @@ -895,7 +956,12 @@ export async function runGemini(opts: { if (!message) { logger.debug('[gemini] Main loop: waiting for messages from queue...'); const waitSignal = abortController.signal; - const batch = await messageQueue.waitForMessagesAndGetAsString(waitSignal); + const batch = await waitForMessagesOrPending({ + messageQueue, + abortSignal: waitSignal, + popPendingMessage: () => session.popPendingMessage(), + waitForMetadataUpdate: (signal) => session.waitForMetadataUpdate(signal), + }); if (!batch) { if (waitSignal.aborted && !shouldExit) { logger.debug('[gemini] Main loop: wait aborted, continuing...'); @@ -941,7 +1007,7 @@ export async function runGemini(opts: { // Create new backend with new model const modelToUse = message.mode?.model === undefined ? undefined : (message.mode.model || null); - const backendResult = createGeminiBackend({ + const backendResult = (await createCatalogAcpBackend('gemini', { cwd: process.cwd(), mcpServers, permissionHandler, @@ -950,7 +1016,7 @@ export async function runGemini(opts: { // Pass model from message - if undefined, will use local config/env/default // If explicitly null, will skip local config and use env/default model: modelToUse, - }); + })) as GeminiBackendResult; geminiBackend = backendResult.backend; // Set up message handler again @@ -967,6 +1033,11 @@ export async function runGemini(opts: { const { sessionId } = await geminiBackend.startSession(); acpSessionId = sessionId; logger.debug(`[gemini] New ACP session started: ${acpSessionId}`); + maybeUpdateGeminiSessionIdMetadata({ + getGeminiSessionId: () => acpSessionId, + updateHappySessionMetadata: (updater) => session.updateMetadata(updater), + lastPublished: lastGeminiSessionIdPublished, + }); // Update displayed model in UI (don't save to config - this is backend initialization) logger.debug(`[gemini] Calling updateDisplayedModel with: ${actualModel}`); @@ -994,7 +1065,7 @@ export async function runGemini(opts: { // First message or session not created yet - create backend and start session if (!geminiBackend) { const modelToUse = message.mode?.model === undefined ? undefined : (message.mode.model || null); - const backendResult = createGeminiBackend({ + const backendResult = (await createCatalogAcpBackend('gemini', { cwd: process.cwd(), mcpServers, permissionHandler, @@ -1003,7 +1074,7 @@ export async function runGemini(opts: { // Pass model from message - if undefined, will use local config/env/default // If explicitly null, will skip local config and use env/default model: modelToUse, - }); + })) as GeminiBackendResult; geminiBackend = backendResult.backend; // Set up message handler @@ -1024,9 +1095,48 @@ export async function runGemini(opts: { logger.debug('[gemini] Starting ACP session...'); // Update permission handler with current permission mode before starting session updatePermissionMode(message.mode.permissionMode); - const { sessionId } = await geminiBackend.startSession(); - acpSessionId = sessionId; - logger.debug(`[gemini] ACP session started: ${acpSessionId}`); + const resumeId = storedResumeId; + if (resumeId) { + if (!geminiBackend.loadSession) { + throw new Error('Gemini ACP backend does not support loading sessions'); + } + storedResumeId = null; // consume once + messageBuffer.addMessage('Resuming previous context…', 'status'); + const loadWithReplay = (geminiBackend as any).loadSessionWithReplayCapture as undefined | ((id: string) => Promise<{ sessionId: string; replay: any[] }>); + let replay: any[] | null = null; + if (loadWithReplay) { + const loaded = await loadWithReplay(resumeId); + replay = Array.isArray(loaded.replay) ? loaded.replay : null; + const loadedSessionId = + typeof loaded.sessionId === 'string' && loaded.sessionId.trim().length > 0 + ? loaded.sessionId.trim() + : resumeId; + acpSessionId = loadedSessionId; + } else { + await geminiBackend.loadSession(resumeId); + acpSessionId = resumeId; + } + logger.debug(`[gemini] ACP session loaded: ${acpSessionId}`); + + if (replay) { + void importAcpReplayHistoryV1({ + session, + provider: 'gemini', + remoteSessionId: acpSessionId, + replay, + permissionHandler, + }); + } + } else { + const { sessionId } = await geminiBackend.startSession(); + acpSessionId = sessionId; + logger.debug(`[gemini] ACP session started: ${acpSessionId}`); + } + maybeUpdateGeminiSessionIdMetadata({ + getGeminiSessionId: () => acpSessionId, + updateHappySessionMetadata: (updater) => session.updateMetadata(updater), + lastPublished: lastGeminiSessionIdPublished, + }); wasSessionCreated = true; currentModeHash = message.hash; @@ -1149,69 +1259,7 @@ export async function runGemini(opts: { messageBuffer.addMessage('Aborted by user', 'status'); session.sendSessionEvent({ type: 'message', message: 'Aborted by user' }); } else { - // Parse error message - let errorMsg = 'Process error occurred'; - - if (typeof error === 'object' && error !== null) { - const errObj = error as any; - - // Extract error information from various possible formats - const errorDetails = errObj.data?.details || errObj.details || ''; - const errorCode = errObj.code || errObj.status || (errObj.response?.status); - const errorMessage = errObj.message || errObj.error?.message || ''; - const errorString = String(error); - - // Check for 404 error (model not found) - if (errorCode === 404 || errorDetails.includes('notFound') || errorDetails.includes('404') || - errorMessage.includes('not found') || errorMessage.includes('404')) { - const currentModel = displayedModel || 'gemini-2.5-pro'; - errorMsg = `Model "${currentModel}" not found. Available models: gemini-2.5-pro, gemini-2.5-flash, gemini-2.5-flash-lite`; - } - // Check for empty response / internal error after retries exhausted - else if (errorCode === -32603 || - errorDetails.includes('empty response') || errorDetails.includes('Model stream ended')) { - errorMsg = 'Gemini API returned empty response after retries. This is a temporary issue - please try again.'; - } - // Check for rate limit error (429) - multiple possible formats - else if (errorCode === 429 || - errorDetails.includes('429') || errorMessage.includes('429') || errorString.includes('429') || - errorDetails.includes('rateLimitExceeded') || errorDetails.includes('RESOURCE_EXHAUSTED') || - errorMessage.includes('Rate limit exceeded') || errorMessage.includes('Resource exhausted') || - errorString.includes('rateLimitExceeded') || errorString.includes('RESOURCE_EXHAUSTED')) { - errorMsg = 'Gemini API rate limit exceeded. Please wait a moment and try again. The API will retry automatically.'; - } - // Check for quota/capacity exceeded error - else if (errorDetails.includes('quota') || errorMessage.includes('quota') || errorString.includes('quota') || - errorDetails.includes('exhausted') || errorDetails.includes('capacity')) { - // Extract reset time from error message like "Your quota will reset after 3h20m35s." - const resetTimeMatch = (errorDetails + errorMessage + errorString).match(/reset after (\d+h)?(\d+m)?(\d+s)?/i); - let resetTimeMsg = ''; - if (resetTimeMatch) { - const parts = resetTimeMatch.slice(1).filter(Boolean).join(''); - resetTimeMsg = ` Quota resets in ${parts}.`; - } - errorMsg = `Gemini quota exceeded.${resetTimeMsg} Try using a different model (gemini-2.5-flash-lite) or wait for quota reset.`; - } - // Check for authentication error (Google Workspace accounts need project ID) - else if (errorMessage.includes('Authentication required') || - errorDetails.includes('Authentication required') || - errorCode === -32000) { - errorMsg = `Authentication required. For Google Workspace accounts, you need to set a Google Cloud Project:\n` + - ` happy gemini project set \n` + - `Or use a different Google account: happy connect gemini\n` + - `Guide: https://goo.gle/gemini-cli-auth-docs#workspace-gca`; - } - // Check for empty error (command not found) - else if (Object.keys(error).length === 0) { - errorMsg = 'Failed to start Gemini. Is "gemini" CLI installed? Run: npm install -g @google/gemini-cli'; - } - // Use message from error object - else if (errObj.message || errorMessage) { - errorMsg = errorDetails || errorMessage || errObj.message; - } - } else if (error instanceof Error) { - errorMsg = error.message; - } + const errorMsg = formatGeminiErrorForUi(error, displayedModel); messageBuffer.addMessage(errorMsg, 'status'); // Use sendAgentMessage for consistency with ACP format @@ -1273,8 +1321,11 @@ export async function runGemini(opts: { thinking = false; session.keepAlive(thinking, 'remote'); - // Use same logic as Codex - emit ready if idle (no pending operations, no queue) - emitReadyIfIdle(); + const popped = !shouldExit ? await session.popPendingMessage() : false; + if (!popped) { + // Use same logic as Codex - emit ready if idle (no pending operations, no queue) + emitReadyIfIdle(); + } // Message processing complete - safe to apply any pending session swap isProcessingMessage = false; @@ -1324,4 +1375,3 @@ export async function runGemini(opts: { logger.debug('[gemini]: Final cleanup completed'); } } - diff --git a/cli/src/gemini/types.ts b/cli/src/backends/gemini/types.ts similarity index 100% rename from cli/src/gemini/types.ts rename to cli/src/backends/gemini/types.ts diff --git a/cli/src/backends/gemini/ui/GeminiTerminalDisplay.tsx b/cli/src/backends/gemini/ui/GeminiTerminalDisplay.tsx new file mode 100644 index 000000000..6f2932ce8 --- /dev/null +++ b/cli/src/backends/gemini/ui/GeminiTerminalDisplay.tsx @@ -0,0 +1,73 @@ +/** + * GeminiTerminalDisplay + * + * Read-only terminal UI for Gemini sessions started by Happy. + * This UI intentionally does not accept prompts from stdin; it displays logs and exit controls only. + */ + +import React, { useEffect, useState } from 'react'; + +import { AgentLogShell } from '@/ui/ink/AgentLogShell'; +import { MessageBuffer, type BufferedMessage } from '@/ui/ink/messageBuffer'; +import { buildReadOnlyFooterLines } from '@/ui/ink/readOnlyFooterLines'; + +export type GeminiTerminalDisplayProps = { + messageBuffer: MessageBuffer; + logPath?: string; + currentModel?: string; + onExit?: () => void | Promise; +}; + +export const GeminiTerminalDisplay: React.FC = ({ + messageBuffer, + logPath, + currentModel, + onExit, +}) => { + const [model, setModel] = useState(currentModel); + + useEffect(() => { + if (currentModel !== undefined && currentModel !== model) { + setModel(currentModel); + } + }, [currentModel]); + + useEffect(() => { + const unsubscribe = messageBuffer.onUpdate((newMessages) => { + const modelMessage = [...newMessages].reverse().find((msg) => msg.type === 'system' && msg.content.startsWith('[MODEL:')); + if (!modelMessage) return; + + const modelMatch = modelMessage.content.match(/\[MODEL:(.+?)\]/); + if (modelMatch && modelMatch[1]) { + const extractedModel = modelMatch[1]; + setModel((prevModel) => (extractedModel !== prevModel ? extractedModel : prevModel)); + } + }); + + return () => unsubscribe(); + }, [messageBuffer]); + + const filterMessage = (msg: BufferedMessage): boolean => { + if (msg.type === 'system' && !msg.content.trim()) return false; + if (msg.type === 'system' && msg.content.startsWith('[MODEL:')) return false; + if (msg.type === 'system' && msg.content.startsWith('Using model:')) return false; + return true; + }; + + const footerLines: string[] = [...buildReadOnlyFooterLines('Gemini')]; + if (model) { + footerLines.push(`Model: ${model}`); + } + + return ( + + ); +}; diff --git a/cli/src/gemini/utils/config.ts b/cli/src/backends/gemini/utils/config.ts similarity index 100% rename from cli/src/gemini/utils/config.ts rename to cli/src/backends/gemini/utils/config.ts diff --git a/cli/src/gemini/utils/conversationHistory.ts b/cli/src/backends/gemini/utils/conversationHistory.ts similarity index 100% rename from cli/src/gemini/utils/conversationHistory.ts rename to cli/src/backends/gemini/utils/conversationHistory.ts diff --git a/cli/src/gemini/utils/diffProcessor.ts b/cli/src/backends/gemini/utils/diffProcessor.ts similarity index 100% rename from cli/src/gemini/utils/diffProcessor.ts rename to cli/src/backends/gemini/utils/diffProcessor.ts diff --git a/cli/src/backends/gemini/utils/formatGeminiErrorForUi.test.ts b/cli/src/backends/gemini/utils/formatGeminiErrorForUi.test.ts new file mode 100644 index 000000000..197f824fa --- /dev/null +++ b/cli/src/backends/gemini/utils/formatGeminiErrorForUi.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { formatGeminiErrorForUi } from './formatGeminiErrorForUi'; + +describe('formatGeminiErrorForUi', () => { + it('formats Error instances using stack when available', () => { + const err = new Error('boom'); + err.stack = 'STACK'; + expect(formatGeminiErrorForUi(err, null)).toContain('STACK'); + }); + + it('formats model-not-found errors', () => { + expect(formatGeminiErrorForUi({ code: 404 }, 'gemini-x')).toContain('Model "gemini-x" not found'); + }); + + it('formats empty object errors as missing CLI install', () => { + expect(formatGeminiErrorForUi({}, null)).toContain('Is "gemini" CLI installed?'); + }); + + it('does not include empty quota reset time when no duration is captured', () => { + expect(formatGeminiErrorForUi({ message: 'quota reset after ' }, null)).not.toContain('Quota resets in .'); + }); +}); diff --git a/cli/src/backends/gemini/utils/formatGeminiErrorForUi.ts b/cli/src/backends/gemini/utils/formatGeminiErrorForUi.ts new file mode 100644 index 000000000..538a1d41e --- /dev/null +++ b/cli/src/backends/gemini/utils/formatGeminiErrorForUi.ts @@ -0,0 +1,102 @@ +import { formatErrorForUi } from '@/ui/formatErrorForUi'; + +export function formatGeminiErrorForUi(error: unknown, displayedModel?: string | null): string { + // Parse error message (keep existing UX-focused heuristics; avoid dumping stacks unless needed) + let errorMsg = 'Process error occurred'; + + // Handle Error instances specially to avoid misclassifying them as "empty object" errors. + const isErrorInstance = error instanceof Error; + + if (typeof error === 'object' && error !== null) { + const errObj = error as any; + + // Extract error information from various possible formats + const rawDetails = errObj.data?.details ?? errObj.details ?? ''; + const errorDetails = Array.isArray(rawDetails) + ? rawDetails.map((d) => (typeof d === 'string' ? d : JSON.stringify(d))).join('\n') + : String(rawDetails); + const errorCode = errObj.code || errObj.status || (errObj.response?.status); + const errorMessage = errObj.message || errObj.error?.message || ''; + const errorString = String(error); + + // Check for 404 error (model not found) + if ( + errorCode === 404 || + errorDetails.includes('notFound') || + errorDetails.includes('404') || + errorMessage.includes('not found') || + errorMessage.includes('404') + ) { + const currentModel = displayedModel || 'gemini-2.5-pro'; + errorMsg = `Model "${currentModel}" not found. Available models: gemini-2.5-pro, gemini-2.5-flash, gemini-2.5-flash-lite`; + } + // Check for empty response / internal error after retries exhausted + else if ( + errorCode === -32603 || + errorDetails.includes('empty response') || + errorDetails.includes('Model stream ended') + ) { + errorMsg = 'Gemini API returned empty response after retries. This is a temporary issue - please try again.'; + } + // Check for rate limit error (429) - multiple possible formats + else if ( + errorCode === 429 || + errorDetails.includes('429') || + errorMessage.includes('429') || + errorString.includes('429') || + errorDetails.includes('rateLimitExceeded') || + errorDetails.includes('RESOURCE_EXHAUSTED') || + errorMessage.includes('Rate limit exceeded') || + errorMessage.includes('Resource exhausted') || + errorString.includes('rateLimitExceeded') || + errorString.includes('RESOURCE_EXHAUSTED') + ) { + errorMsg = 'Gemini API rate limit exceeded. Please wait a moment and try again. The API will retry automatically.'; + } + // Check for quota/capacity exceeded error + else if ( + errorDetails.includes('quota') || + errorMessage.includes('quota') || + errorString.includes('quota') || + errorDetails.includes('exhausted') || + errorDetails.includes('capacity') + ) { + // Extract reset time from error message like "Your quota will reset after 3h20m35s." + const resetTimeMatch = (errorDetails + errorMessage + errorString).match(/reset after (\d+h)?(\d+m)?(\d+s)?/i); + let resetTimeMsg = ''; + if (resetTimeMatch) { + const parts = resetTimeMatch.slice(1).filter(Boolean).join(''); + if (parts) { + resetTimeMsg = ` Quota resets in ${parts}.`; + } + } + errorMsg = `Gemini quota exceeded.${resetTimeMsg} Try using a different model (gemini-2.5-flash-lite) or wait for quota reset.`; + } + // Check for authentication error (Google Workspace accounts need project ID) + else if ( + errorMessage.includes('Authentication required') || + errorDetails.includes('Authentication required') || + errorCode === -32000 + ) { + errorMsg = + `Authentication required. For Google Workspace accounts, you need to set a Google Cloud Project:\n` + + ` happy gemini project set \n` + + `Or use a different Google account: happy connect gemini\n` + + `Guide: https://goo.gle/gemini-cli-auth-docs#workspace-gca`; + } + // Check for empty error (command not found). Ignore Error instances here. + else if (!isErrorInstance && Object.keys(error).length === 0) { + errorMsg = 'Failed to start Gemini. Is "gemini" CLI installed? Run: npm install -g @google/gemini-cli'; + } + // Use message from error object (prefer details if present) + else if (errObj.message || errorMessage) { + if (isErrorInstance) { + errorMsg = errorDetails || formatErrorForUi(error); + } else { + errorMsg = errorDetails || errorMessage || errObj.message; + } + } + } + + return errorMsg; +} diff --git a/cli/src/backends/gemini/utils/geminiSessionIdMetadata.test.ts b/cli/src/backends/gemini/utils/geminiSessionIdMetadata.test.ts new file mode 100644 index 000000000..31e9820d1 --- /dev/null +++ b/cli/src/backends/gemini/utils/geminiSessionIdMetadata.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; + +import { maybeUpdateGeminiSessionIdMetadata } from './geminiSessionIdMetadata'; + +describe('maybeUpdateGeminiSessionIdMetadata', () => { + it('publishes geminiSessionId once per new session id and preserves other metadata', () => { + const published: any[] = []; + const last = { value: null as string | null }; + + maybeUpdateGeminiSessionIdMetadata({ + getGeminiSessionId: () => 'g1', + updateHappySessionMetadata: (updater) => published.push(updater({ keep: true } as any)), + lastPublished: last, + }); + + maybeUpdateGeminiSessionIdMetadata({ + getGeminiSessionId: () => 'g1', + updateHappySessionMetadata: (updater) => published.push(updater({ keep: true } as any)), + lastPublished: last, + }); + + maybeUpdateGeminiSessionIdMetadata({ + getGeminiSessionId: () => 'g2', + updateHappySessionMetadata: (updater) => published.push(updater({ keep: true } as any)), + lastPublished: last, + }); + + expect(published).toEqual([ + { keep: true, geminiSessionId: 'g1' }, + { keep: true, geminiSessionId: 'g2' }, + ]); + }); +}); + diff --git a/cli/src/backends/gemini/utils/geminiSessionIdMetadata.ts b/cli/src/backends/gemini/utils/geminiSessionIdMetadata.ts new file mode 100644 index 000000000..a16bf4dc1 --- /dev/null +++ b/cli/src/backends/gemini/utils/geminiSessionIdMetadata.ts @@ -0,0 +1,21 @@ +import type { Metadata } from '@/api/types'; + +export function maybeUpdateGeminiSessionIdMetadata(params: { + getGeminiSessionId: () => string | null; + updateHappySessionMetadata: (updater: (metadata: Metadata) => Metadata) => void; + lastPublished: { value: string | null }; +}): void { + const raw = params.getGeminiSessionId(); + const next = typeof raw === 'string' ? raw.trim() : ''; + if (!next) return; + + if (params.lastPublished.value === next) return; + params.lastPublished.value = next; + + params.updateHappySessionMetadata((metadata) => ({ + ...metadata, + // Happy metadata field name. Value is Gemini ACP sessionId (Gemini uses sessionId as the stable resume id). + geminiSessionId: next, + })); +} + diff --git a/cli/src/gemini/utils/optionsParser.ts b/cli/src/backends/gemini/utils/optionsParser.ts similarity index 100% rename from cli/src/gemini/utils/optionsParser.ts rename to cli/src/backends/gemini/utils/optionsParser.ts diff --git a/cli/src/gemini/utils/permissionHandler.ts b/cli/src/backends/gemini/utils/permissionHandler.ts similarity index 81% rename from cli/src/gemini/utils/permissionHandler.ts rename to cli/src/backends/gemini/utils/permissionHandler.ts index aa766bde0..49a71b0a4 100644 --- a/cli/src/gemini/utils/permissionHandler.ts +++ b/cli/src/backends/gemini/utils/permissionHandler.ts @@ -12,7 +12,7 @@ import { BasePermissionHandler, PermissionResult, PendingRequest -} from '@/utils/BasePermissionHandler'; +} from '@/agent/permissions/BasePermissionHandler'; // Re-export types for backwards compatibility export type { PermissionResult, PendingRequest }; @@ -23,8 +23,11 @@ export type { PermissionResult, PendingRequest }; export class GeminiPermissionHandler extends BasePermissionHandler { private currentPermissionMode: PermissionMode = 'default'; - constructor(session: ApiSessionClient) { - super(session); + constructor( + session: ApiSessionClient, + opts?: { onAbortRequested?: (() => void | Promise) | null }, + ) { + super(session, opts); } protected getLogPrefix(): string { @@ -51,6 +54,11 @@ export class GeminiPermissionHandler extends BasePermissionHandler { * Check if a tool should be auto-approved based on permission mode */ private shouldAutoApprove(toolName: string, toolCallId: string, input: unknown): boolean { + // Never auto-approve internal app prompts that must remain user-controlled. + if (toolName === 'AcpHistoryImport') { + return false; + } + // Always auto-approve these tools regardless of permission mode: // - change_title: Changing chat title is safe and should be automatic // - GeminiReasoning: Reasoning is just display of thinking process, not an action @@ -102,30 +110,21 @@ export class GeminiPermissionHandler extends BasePermissionHandler { toolName: string, input: unknown ): Promise { + // Respect user "don't ask again for session" choices captured via our permission UI. + if (this.isAllowedForSession(toolName, input)) { + logger.debug(`${this.getLogPrefix()} Auto-approving (allowed for session) tool ${toolName} (${toolCallId})`); + this.recordAutoDecision(toolCallId, toolName, input, 'approved_for_session'); + return { decision: 'approved_for_session' }; + } + // Check if we should auto-approve based on permission mode // Pass toolCallId to check by ID (e.g., change_title-* even if toolName is "other") if (this.shouldAutoApprove(toolName, toolCallId, input)) { logger.debug(`${this.getLogPrefix()} Auto-approving tool ${toolName} (${toolCallId}) in ${this.currentPermissionMode} mode`); - - // Update agent state with auto-approved request - this.session.updateAgentState((currentState) => ({ - ...currentState, - completedRequests: { - ...currentState.completedRequests, - [toolCallId]: { - tool: toolName, - arguments: input, - createdAt: Date.now(), - completedAt: Date.now(), - status: 'approved', - decision: this.currentPermissionMode === 'yolo' ? 'approved_for_session' : 'approved' - } - } - })); - - return { - decision: this.currentPermissionMode === 'yolo' ? 'approved_for_session' : 'approved' - }; + const decision: PermissionResult['decision'] = + this.currentPermissionMode === 'yolo' ? 'approved_for_session' : 'approved'; + this.recordAutoDecision(toolCallId, toolName, input, decision); + return { decision }; } // Otherwise, ask for permission @@ -145,4 +144,3 @@ export class GeminiPermissionHandler extends BasePermissionHandler { }); } } - diff --git a/cli/src/gemini/utils/promptUtils.ts b/cli/src/backends/gemini/utils/promptUtils.ts similarity index 100% rename from cli/src/gemini/utils/promptUtils.ts rename to cli/src/backends/gemini/utils/promptUtils.ts diff --git a/cli/src/gemini/utils/reasoningProcessor.ts b/cli/src/backends/gemini/utils/reasoningProcessor.ts similarity index 96% rename from cli/src/gemini/utils/reasoningProcessor.ts rename to cli/src/backends/gemini/utils/reasoningProcessor.ts index 0ec3706e2..b11bee772 100644 --- a/cli/src/gemini/utils/reasoningProcessor.ts +++ b/cli/src/backends/gemini/utils/reasoningProcessor.ts @@ -11,7 +11,7 @@ import { ReasoningToolResult, ReasoningMessage, ReasoningOutput -} from '@/utils/BaseReasoningProcessor'; +} from '@/agent/BaseReasoningProcessor'; // Re-export types for backwards compatibility export type { ReasoningToolCall, ReasoningToolResult, ReasoningMessage, ReasoningOutput }; diff --git a/cli/src/backends/headlessTmuxTransform.test.ts b/cli/src/backends/headlessTmuxTransform.test.ts new file mode 100644 index 000000000..78c4b25a4 --- /dev/null +++ b/cli/src/backends/headlessTmuxTransform.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest'; + +import { AGENTS } from './catalog'; + +describe('headless tmux argv transforms', () => { + it('forces remote starting mode for claude', async () => { + const transform = await AGENTS.claude.getHeadlessTmuxArgvTransform!(); + expect(transform(['--foo'])).toEqual(['--foo', '--happy-starting-mode', 'remote']); + }); + + it('does not rewrite argv for codex', async () => { + expect(AGENTS.codex.getHeadlessTmuxArgvTransform).toBeUndefined(); + }); +}); + diff --git a/cli/src/backends/opencode/acp/backend.ts b/cli/src/backends/opencode/acp/backend.ts new file mode 100644 index 000000000..ffc3d1535 --- /dev/null +++ b/cli/src/backends/opencode/acp/backend.ts @@ -0,0 +1,48 @@ +/** + * OpenCode ACP Backend - OpenCode agent via ACP + * + * This module provides a factory function for creating an OpenCode backend + * that communicates using the Agent Client Protocol (ACP). + * + * OpenCode must be installed and available in PATH. + * ACP mode: `opencode acp` + */ + +import { AcpBackend, type AcpBackendOptions, type AcpPermissionHandler } from '@/agent/acp/AcpBackend'; +import type { AgentBackend, McpServerConfig, AgentFactoryOptions } from '@/agent/core'; +import { openCodeTransport } from '@/backends/opencode/acp/transport'; +import { logger } from '@/ui/logger'; + +export interface OpenCodeBackendOptions extends AgentFactoryOptions { + /** MCP servers to make available to the agent */ + mcpServers?: Record; + /** Optional permission handler for tool approval */ + permissionHandler?: AcpPermissionHandler; +} + +export function createOpenCodeBackend(options: OpenCodeBackendOptions): AgentBackend { + const backendOptions: AcpBackendOptions = { + agentName: 'opencode', + cwd: options.cwd, + command: 'opencode', + args: ['acp'], + env: { + ...options.env, + // Keep output clean; ACP must own stdout. + NODE_ENV: 'production', + DEBUG: '', + }, + mcpServers: options.mcpServers, + permissionHandler: options.permissionHandler, + transportHandler: openCodeTransport, + }; + + logger.debug('[OpenCode] Creating ACP backend with options:', { + cwd: backendOptions.cwd, + command: backendOptions.command, + args: backendOptions.args, + mcpServerCount: options.mcpServers ? Object.keys(options.mcpServers).length : 0, + }); + + return new AcpBackend(backendOptions); +} diff --git a/cli/src/backends/opencode/acp/runtime.ts b/cli/src/backends/opencode/acp/runtime.ts new file mode 100644 index 000000000..d7feaf7ff --- /dev/null +++ b/cli/src/backends/opencode/acp/runtime.ts @@ -0,0 +1,273 @@ +import { randomUUID } from 'node:crypto'; + +import { logger } from '@/ui/logger'; +import type { AgentBackend, AgentMessage, McpServerConfig } from '@/agent'; +import { createCatalogAcpBackend } from '@/agent/acp'; +import type { MessageBuffer } from '@/ui/ink/messageBuffer'; +import { + handleAcpModelOutputDelta, + handleAcpStatusRunning, + forwardAcpPermissionRequest, + forwardAcpTerminalOutput, +} from '@/agent/acp/bridge/acpCommonHandlers'; +import type { ApiSessionClient } from '@/api/apiSession'; +import type { AcpPermissionHandler } from '@/agent/acp/AcpBackend'; +import { normalizeAvailableCommands, publishSlashCommandsToMetadata } from '@/agent/acp/commands/publishSlashCommands'; +import { importAcpReplayHistoryV1 } from '@/agent/acp/history/importAcpReplayHistory'; + +export function createOpenCodeAcpRuntime(params: { + directory: string; + session: ApiSessionClient; + messageBuffer: MessageBuffer; + mcpServers: Record; + permissionHandler: AcpPermissionHandler; + onThinkingChange: (thinking: boolean) => void; +}) { + let backend: AgentBackend | null = null; + let sessionId: string | null = null; + + let accumulatedResponse = ''; + let isResponseInProgress = false; + let taskStartedSent = false; + let turnAborted = false; + let loadingSession = false; + + const resetTurnState = () => { + accumulatedResponse = ''; + isResponseInProgress = false; + taskStartedSent = false; + turnAborted = false; + }; + + const attachMessageHandler = (b: AgentBackend) => { + b.onMessage((msg: AgentMessage) => { + if (loadingSession) { + if (msg.type === 'status' && msg.status === 'error') { + turnAborted = true; + params.session.sendAgentMessage('opencode', { type: 'turn_aborted', id: randomUUID() }); + } + return; + } + switch (msg.type) { + case 'model-output': { + handleAcpModelOutputDelta({ + delta: msg.textDelta ?? '', + messageBuffer: params.messageBuffer, + getIsResponseInProgress: () => isResponseInProgress, + setIsResponseInProgress: (value) => { isResponseInProgress = value; }, + appendToAccumulatedResponse: (delta) => { accumulatedResponse += delta; }, + }); + break; + } + + case 'status': { + if (msg.status === 'running') { + handleAcpStatusRunning({ + session: params.session, + agent: 'opencode', + messageBuffer: params.messageBuffer, + onThinkingChange: params.onThinkingChange, + getTaskStartedSent: () => taskStartedSent, + setTaskStartedSent: (value) => { taskStartedSent = value; }, + makeId: () => randomUUID(), + }); + } + + if (msg.status === 'error') { + turnAborted = true; + params.session.sendAgentMessage('opencode', { type: 'turn_aborted', id: randomUUID() }); + } + break; + } + + case 'tool-call': { + params.messageBuffer.addMessage(`Executing: ${msg.toolName}`, 'tool'); + params.session.sendAgentMessage('opencode', { + type: 'tool-call', + callId: msg.callId, + name: msg.toolName, + input: msg.args, + id: randomUUID(), + }); + break; + } + + case 'tool-result': { + const maybeStream = + msg.result + && typeof msg.result === 'object' + && !Array.isArray(msg.result) + && (typeof (msg.result as any).stdoutChunk === 'string' || (msg.result as any)._stream === true); + if (!maybeStream) { + const outputText = typeof msg.result === 'string' + ? msg.result + : JSON.stringify(msg.result ?? '').slice(0, 200); + params.messageBuffer.addMessage(`Result: ${outputText}`, 'result'); + } + params.session.sendAgentMessage('opencode', { + type: 'tool-result', + callId: msg.callId, + output: msg.result, + id: randomUUID(), + }); + break; + } + + case 'fs-edit': { + params.messageBuffer.addMessage(`File edit: ${msg.description}`, 'tool'); + params.session.sendAgentMessage('opencode', { + type: 'file-edit', + description: msg.description, + diff: msg.diff, + filePath: msg.path || 'unknown', + id: randomUUID(), + }); + break; + } + + case 'terminal-output': { + forwardAcpTerminalOutput({ + msg, + messageBuffer: params.messageBuffer, + session: params.session, + agent: 'opencode', + getCallId: () => randomUUID(), + }); + break; + } + + case 'permission-request': { + forwardAcpPermissionRequest({ msg, session: params.session, agent: 'opencode' }); + break; + } + + case 'event': { + const name = (msg as any).name as string | undefined; + if (name === 'available_commands_update') { + const payload = (msg as any).payload; + const details = normalizeAvailableCommands(payload?.availableCommands ?? payload); + publishSlashCommandsToMetadata({ session: params.session, details }); + } + if (name === 'thinking') { + const text = ((msg as any).payload?.text ?? '') as string; + if (text) { + params.session.sendAgentMessage('opencode', { type: 'thinking', text }); + } + } + break; + } + } + }); + }; + + const ensureBackend = async (): Promise => { + if (backend) return backend; + const created = await createCatalogAcpBackend('opencode', { + cwd: params.directory, + mcpServers: params.mcpServers, + permissionHandler: params.permissionHandler, + }); + backend = created.backend; + attachMessageHandler(backend); + logger.debug('[OpenCodeACP] Backend created'); + return backend; + }; + + return { + getSessionId: () => sessionId, + + beginTurn(): void { + turnAborted = false; + }, + + async cancel(): Promise { + if (!sessionId) return; + const b = await ensureBackend(); + await b.cancel(sessionId); + }, + + async reset(): Promise { + sessionId = null; + resetTurnState(); + loadingSession = false; + + if (backend) { + try { + await backend.dispose(); + } catch (e) { + logger.debug('[OpenCodeACP] Failed to dispose backend (non-fatal)', e); + } + backend = null; + } + }, + + async startOrLoad(opts: { resumeId?: string | null }): Promise { + const b = await ensureBackend(); + + const resumeId = typeof opts.resumeId === 'string' ? opts.resumeId.trim() : ''; + if (resumeId) { + const loadWithReplay = (b as any).loadSessionWithReplayCapture as ((id: string) => Promise<{ sessionId: string; replay?: unknown[] }>) | undefined; + const loadSession = (b as any).loadSession as ((id: string) => Promise<{ sessionId: string }>) | undefined; + if (!loadSession && !loadWithReplay) { + throw new Error('OpenCode ACP backend does not support loading sessions'); + } + + loadingSession = true; + let replay: unknown[] | null = null; + try { + if (loadWithReplay) { + const loaded = await loadWithReplay(resumeId); + sessionId = loaded.sessionId ?? resumeId; + replay = Array.isArray(loaded.replay) ? loaded.replay : null; + } else { + const loaded = await loadSession!(resumeId); + sessionId = loaded.sessionId ?? resumeId; + } + } finally { + loadingSession = false; + } + + if (replay) { + importAcpReplayHistoryV1({ + session: params.session, + provider: 'opencode', + remoteSessionId: resumeId, + replay: replay as any[], + permissionHandler: params.permissionHandler, + }).catch((e) => { + logger.debug('[OpenCodeACP] Failed to import replay history (non-fatal)', e); + }); + } + } else { + const started = await b.startSession(); + sessionId = started.sessionId; + } + + return sessionId!; + }, + + async sendPrompt(prompt: string): Promise { + if (!sessionId) { + throw new Error('OpenCode ACP session was not started'); + } + + const b = await ensureBackend(); + await b.sendPrompt(sessionId, prompt); + if (b.waitForResponseComplete) { + await b.waitForResponseComplete(120_000); + } + }, + + flushTurn(): void { + if (accumulatedResponse.trim()) { + params.session.sendAgentMessage('opencode', { type: 'message', message: accumulatedResponse }); + } + + if (!turnAborted) { + params.session.sendAgentMessage('opencode', { type: 'task_complete', id: randomUUID() }); + } + + resetTurnState(); + }, + }; +} diff --git a/cli/src/backends/opencode/acp/transport.test.ts b/cli/src/backends/opencode/acp/transport.test.ts new file mode 100644 index 000000000..33655af2c --- /dev/null +++ b/cli/src/backends/opencode/acp/transport.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest'; + +import { OpenCodeTransport } from './transport'; + +const ctx = { recentPromptHadChangeTitle: false, toolCallCountSincePrompt: 0 } as const; + +describe('OpenCodeTransport determineToolName', () => { + it('returns the original tool name when it is not "other"', () => { + const transport = new OpenCodeTransport(); + expect(transport.determineToolName('read', 'read-1', { path: '/tmp/x' }, ctx)).toBe('read'); + }); + + it('extracts a tool name from toolCallId patterns (case-insensitive)', () => { + const transport = new OpenCodeTransport(); + expect(transport.determineToolName('other', 'BASH-123', { command: 'ls' }, ctx)).toBe('bash'); + expect(transport.determineToolName('other', 'mcp__happy__change_title-1', {}, ctx)).toBe('change_title'); + }); + + it('infers a tool name from input field signatures when toolCallId is not helpful', () => { + const transport = new OpenCodeTransport(); + expect(transport.determineToolName('other', 'unknown-1', { filePath: '/tmp/x' }, ctx)).toBe('read'); + expect(transport.determineToolName('other', 'unknown-2', { oldString: 'a', newString: 'b' }, ctx)).toBe('edit'); + }); + + it('does not guess a tool name for empty input without an id match', () => { + const transport = new OpenCodeTransport(); + expect(transport.determineToolName('other', 'unknown-3', {}, ctx)).toBe('other'); + }); +}); + +describe('OpenCodeTransport handleStderr', () => { + it('suppresses empty stderr lines', () => { + const transport = new OpenCodeTransport(); + expect(transport.handleStderr(' ', { activeToolCalls: new Set(), hasActiveInvestigation: false })).toEqual({ + message: null, + suppress: true, + }); + }); + + it('emits actionable auth errors', () => { + const transport = new OpenCodeTransport(); + const res = transport.handleStderr('Unauthorized: missing API key', { activeToolCalls: new Set(), hasActiveInvestigation: false }); + expect(res.message?.type).toBe('status'); + expect((res.message as any)?.status).toBe('error'); + }); + + it('emits actionable model-not-found errors', () => { + const transport = new OpenCodeTransport(); + const res = transport.handleStderr('Model not found', { activeToolCalls: new Set(), hasActiveInvestigation: false }); + expect(res.message?.type).toBe('status'); + expect((res.message as any)?.status).toBe('error'); + }); +}); + +describe('OpenCodeTransport timeouts', () => { + it('treats task-like tool calls as investigation tools', () => { + const transport = new OpenCodeTransport(); + expect(transport.isInvestigationTool('task-123', undefined)).toBe(true); + expect(transport.isInvestigationTool('explore-123', undefined)).toBe(true); + expect(transport.isInvestigationTool('read-123', 'task')).toBe(true); + expect(transport.isInvestigationTool('read-123', 'read')).toBe(false); + }); +}); diff --git a/cli/src/backends/opencode/acp/transport.ts b/cli/src/backends/opencode/acp/transport.ts new file mode 100644 index 000000000..46af600da --- /dev/null +++ b/cli/src/backends/opencode/acp/transport.ts @@ -0,0 +1,219 @@ +/** + * OpenCode Transport Handler + * + * Minimal TransportHandler for OpenCode's ACP mode. + * + * OpenCode ACP is expected to speak JSON-RPC over ndJSON on stdout. + * This transport focuses on: + * - Conservative stdout filtering (JSON objects/arrays only) + * - Reasonable init/tool timeouts + * - Heuristics for mapping OpenCode "other" tool names to concrete tool names + * - Basic stderr classification (auth/model errors) + * + * Agent-specific stderr parsing can be added later if needed. + */ + +import type { + TransportHandler, + ToolPattern, + StderrContext, + StderrResult, + ToolNameContext, +} from '@/agent/transport/TransportHandler'; +import type { AgentMessage } from '@/agent/core'; +import { logger } from '@/ui/logger'; +import { filterJsonObjectOrArrayLine } from '@/agent/transport/utils/jsonStdoutFilter'; +import { + findToolNameFromId, + findToolNameFromInputFields, + type ToolPatternWithInputFields, +} from '@/agent/transport/utils/toolPatternInference'; + +export const OPENCODE_TIMEOUTS = { + /** + * OpenCode startup can be slow on first run (provider config, auth checks, etc.). + * Prefer a conservative init timeout to avoid false failures. + */ + init: 60_000, + toolCall: 120_000, + investigation: 300_000, + think: 30_000, + idle: 500, +} as const; + +const OPENCODE_TOOL_PATTERNS: readonly ToolPatternWithInputFields[] = [ + { + name: 'change_title', + patterns: ['change_title', 'change-title', 'happy__change_title', 'mcp__happy__change_title'], + inputFields: ['title'], + }, + { + name: 'save_memory', + patterns: ['save_memory', 'save-memory'], + inputFields: ['memory', 'content'], + }, + { + name: 'think', + patterns: ['think'], + inputFields: ['thought', 'thinking'], + }, + // OpenCode CLI tool conventions + { + name: 'read', + patterns: ['read', 'read_file'], + inputFields: ['filePath', 'path'], + }, + { + name: 'write', + patterns: ['write', 'write_file'], + inputFields: ['content', 'filePath'], + }, + { + name: 'edit', + patterns: ['edit'], + inputFields: ['oldString', 'newString'], + }, + { + name: 'bash', + patterns: ['bash', 'shell', 'exec'], + inputFields: ['command'], + }, + { + name: 'glob', + patterns: ['glob'], + inputFields: ['pattern'], + }, + { + name: 'grep', + patterns: ['grep'], + inputFields: ['pattern', 'include'], + }, + { + name: 'task', + patterns: ['task'], + inputFields: ['prompt', 'subagent_type'], + }, +] as const; + +export class OpenCodeTransport implements TransportHandler { + readonly agentName = 'opencode'; + + getInitTimeout(): number { + return OPENCODE_TIMEOUTS.init; + } + + filterStdoutLine(line: string): string | null { + return filterJsonObjectOrArrayLine(line); + } + + handleStderr(text: string, context: StderrContext): StderrResult { + const trimmed = text.trim(); + if (!trimmed) return { message: null, suppress: true }; + + // Rate limit errors - OpenCode (or its providers) may retry; keep logs for debugging. + if ( + trimmed.includes('429') || + trimmed.toLowerCase().includes('rate limit') || + trimmed.includes('RATE_LIMIT') + ) { + return { message: null, suppress: false }; + } + + // Authentication error - show actionable message. + if ( + trimmed.toLowerCase().includes('authentication') || + trimmed.toLowerCase().includes('unauthorized') || + trimmed.toLowerCase().includes('api key') + ) { + const errorMessage: AgentMessage = { + type: 'status', + status: 'error', + detail: 'Authentication error. Run `opencode auth login` to configure API keys.', + }; + return { message: errorMessage }; + } + + // Model not found - show actionable message. + if (trimmed.toLowerCase().includes('model not found')) { + const errorMessage: AgentMessage = { + type: 'status', + status: 'error', + detail: 'Model not found. Check available models with `opencode models`.', + }; + return { message: errorMessage }; + } + + // During long-running tools, keep stderr available for debugging but avoid noisy UI messages. + if (context.hasActiveInvestigation) { + const hasError = + trimmed.includes('timeout') || + trimmed.includes('Timeout') || + trimmed.includes('failed') || + trimmed.includes('Failed') || + trimmed.includes('error') || + trimmed.includes('Error'); + + if (hasError) return { message: null, suppress: false }; + } + + return { message: null }; + } + + getToolPatterns(): ToolPattern[] { + // TransportHandler expects a mutable array type; keep our source list readonly and + // return a shallow copy to satisfy the signature without risking accidental mutation. + return [...OPENCODE_TOOL_PATTERNS]; + } + + determineToolName( + toolName: string, + toolCallId: string, + input: Record, + _context: ToolNameContext + ): string { + if (toolName !== 'other' && toolName !== 'Unknown tool') return toolName; + + // 1) Prefer toolCallId pattern matching (most reliable). + const idToolName = findToolNameFromId(toolCallId, OPENCODE_TOOL_PATTERNS, { preferLongestMatch: true }); + if (idToolName) return idToolName; + + // 2) Fallback to input field signatures. + const inputToolName = findToolNameFromInputFields(input, OPENCODE_TOOL_PATTERNS); + if (inputToolName) return inputToolName; + + if (toolName === 'other' || toolName === 'Unknown tool') { + const inputKeys = input && typeof input === 'object' ? Object.keys(input) : []; + logger.debug( + `[OpenCodeTransport] Unknown tool pattern - toolCallId: "${toolCallId}", ` + + `toolName: "${toolName}", inputKeys: [${inputKeys.join(', ')}].` + ); + } + + return toolName; + } + + extractToolNameFromId(toolCallId: string): string | null { + return findToolNameFromId(toolCallId, OPENCODE_TOOL_PATTERNS, { preferLongestMatch: true }); + } + + isInvestigationTool(toolCallId: string, toolKind?: string): boolean { + const lowerId = toolCallId.toLowerCase(); + return ( + lowerId.includes('task') || + lowerId.includes('explore') || + (typeof toolKind === 'string' && toolKind.includes('task')) + ); + } + + getToolCallTimeout(toolCallId: string, toolKind?: string): number { + if (this.isInvestigationTool(toolCallId, toolKind)) return OPENCODE_TIMEOUTS.investigation; + if (toolKind === 'think') return OPENCODE_TIMEOUTS.think; + return OPENCODE_TIMEOUTS.toolCall; + } + + getIdleTimeout(): number { + return OPENCODE_TIMEOUTS.idle; + } +} + +export const openCodeTransport = new OpenCodeTransport(); diff --git a/cli/src/backends/opencode/cli/capability.ts b/cli/src/backends/opencode/cli/capability.ts new file mode 100644 index 000000000..7ad3e26c7 --- /dev/null +++ b/cli/src/backends/opencode/cli/capability.ts @@ -0,0 +1,38 @@ +import type { Capability } from '@/capabilities/service'; +import { buildCliCapabilityData } from '@/capabilities/probes/cliBase'; +import { probeAcpAgentCapabilities } from '@/capabilities/probes/acpProbe'; +import { openCodeTransport } from '@/backends/opencode/acp/transport'; +import { normalizeCapabilityProbeError } from '@/capabilities/utils/normalizeCapabilityProbeError'; +import { resolveAcpProbeTimeoutMs } from '@/capabilities/utils/acpProbeTimeout'; + +export const cliCapability: Capability = { + descriptor: { id: 'cli.opencode', kind: 'cli', title: 'OpenCode CLI' }, + detect: async ({ request, context }) => { + const entry = context.cliSnapshot?.clis?.opencode; + const base = buildCliCapabilityData({ request, entry }); + + const includeAcpCapabilities = Boolean((request.params ?? {}).includeAcpCapabilities); + if (!includeAcpCapabilities || base.available !== true || !base.resolvedPath) { + return base; + } + + const probe = await probeAcpAgentCapabilities({ + command: base.resolvedPath, + args: ['acp'], + cwd: process.cwd(), + env: { + // Keep output clean to avoid ACP stdout pollution. + NODE_ENV: 'production', + DEBUG: '', + }, + transport: openCodeTransport, + timeoutMs: resolveAcpProbeTimeoutMs('opencode'), + }); + + const acp = probe.ok + ? { ok: true, checkedAt: probe.checkedAt, loadSession: probe.agentCapabilities?.loadSession === true } + : { ok: false, checkedAt: probe.checkedAt, error: normalizeCapabilityProbeError(probe.error) }; + + return { ...base, acp }; + }, +}; diff --git a/cli/src/backends/opencode/cli/checklists.ts b/cli/src/backends/opencode/cli/checklists.ts new file mode 100644 index 000000000..ec083e282 --- /dev/null +++ b/cli/src/backends/opencode/cli/checklists.ts @@ -0,0 +1,4 @@ +import type { AgentChecklistContributions } from '@/backends/types'; + +export const checklists = { +} satisfies AgentChecklistContributions; diff --git a/cli/src/backends/opencode/cli/command.ts b/cli/src/backends/opencode/cli/command.ts new file mode 100644 index 000000000..4c8dc3bed --- /dev/null +++ b/cli/src/backends/opencode/cli/command.ts @@ -0,0 +1,55 @@ +import chalk from 'chalk'; + +import { CODEX_GEMINI_PERMISSION_MODES, isCodexGeminiPermissionMode } from '@/api/types'; +import { authAndSetupMachineIfNeeded } from '@/ui/auth'; +import { parseSessionStartArgs } from '@/cli/sessionStartArgs'; + +import type { CommandContext } from '@/cli/commandRegistry'; + +export async function handleOpenCodeCliCommand(context: CommandContext): Promise { + try { + const { runOpenCode } = await import('@/backends/opencode/runOpenCode'); + + const { startedBy, permissionMode, permissionModeUpdatedAt } = parseSessionStartArgs(context.args); + if (permissionMode && !isCodexGeminiPermissionMode(permissionMode)) { + console.error( + chalk.red( + `Invalid --permission-mode for opencode: ${permissionMode}. Valid values: ${CODEX_GEMINI_PERMISSION_MODES.join( + ', ', + )}`, + ), + ); + console.error(chalk.gray('Tip: use --yolo for full bypass-like behavior.')); + process.exit(1); + } + + const readFlagValue = (flag: string): string | undefined => { + const idx = context.args.indexOf(flag); + if (idx === -1) return undefined; + const value = context.args[idx + 1]; + if (!value || value.startsWith('-')) return undefined; + return value; + }; + + const existingSessionId = readFlagValue('--existing-session'); + const resume = readFlagValue('--resume'); + + const { credentials } = await authAndSetupMachineIfNeeded(); + await runOpenCode({ + credentials, + startedBy, + terminalRuntime: context.terminalRuntime, + permissionMode, + permissionModeUpdatedAt, + existingSessionId, + resume, + }); + } catch (error) { + console.error(chalk.red('Error:'), error instanceof Error ? error.message : 'Unknown error'); + if (process.env.DEBUG) { + console.error(error); + } + process.exit(1); + } +} + diff --git a/cli/src/backends/opencode/cli/detect.ts b/cli/src/backends/opencode/cli/detect.ts new file mode 100644 index 000000000..9a132c595 --- /dev/null +++ b/cli/src/backends/opencode/cli/detect.ts @@ -0,0 +1,7 @@ +import type { CliDetectSpec } from '@/backends/types'; + +export const cliDetect = { + versionArgsToTry: [['--version'], ['version'], ['-v']], + loginStatusArgs: ['auth', 'list'], +} satisfies CliDetectSpec; + diff --git a/cli/src/backends/opencode/daemon/spawnHooks.ts b/cli/src/backends/opencode/daemon/spawnHooks.ts new file mode 100644 index 000000000..fe75b7584 --- /dev/null +++ b/cli/src/backends/opencode/daemon/spawnHooks.ts @@ -0,0 +1,9 @@ +import type { DaemonSpawnHooks } from '@/daemon/spawnHooks'; + +export const opencodeDaemonSpawnHooks: DaemonSpawnHooks = { + buildAuthEnv: async ({ token }) => ({ + env: { CLAUDE_CODE_OAUTH_TOKEN: token }, + cleanupOnFailure: null, + cleanupOnExit: null, + }), +}; diff --git a/cli/src/backends/opencode/index.ts b/cli/src/backends/opencode/index.ts new file mode 100644 index 000000000..135c28dff --- /dev/null +++ b/cli/src/backends/opencode/index.ts @@ -0,0 +1,20 @@ +import { AGENTS_CORE } from '@happy/agents'; + +import { checklists } from './cli/checklists'; +import type { AgentCatalogEntry } from '../types'; + +export const agent = { + id: AGENTS_CORE.opencode.id, + cliSubcommand: AGENTS_CORE.opencode.cliSubcommand, + getCliCommandHandler: async () => (await import('@/backends/opencode/cli/command')).handleOpenCodeCliCommand, + getCliCapabilityOverride: async () => (await import('@/backends/opencode/cli/capability')).cliCapability, + getCliDetect: async () => (await import('@/backends/opencode/cli/detect')).cliDetect, + getDaemonSpawnHooks: async () => (await import('@/backends/opencode/daemon/spawnHooks')).opencodeDaemonSpawnHooks, + vendorResumeSupport: AGENTS_CORE.opencode.resume.vendorResume, + getAcpBackendFactory: async () => { + const { createOpenCodeBackend } = await import('@/backends/opencode/acp/backend'); + return (opts) => ({ backend: createOpenCodeBackend(opts as any) }); + }, + checklists, +} satisfies AgentCatalogEntry; + diff --git a/cli/src/backends/opencode/runOpenCode.ts b/cli/src/backends/opencode/runOpenCode.ts new file mode 100644 index 000000000..8124ea491 --- /dev/null +++ b/cli/src/backends/opencode/runOpenCode.ts @@ -0,0 +1,359 @@ +/** + * OpenCode CLI Entry Point + * + * Runs the OpenCode agent through Happy CLI using ACP. + */ + +import { render } from 'ink'; +import React from 'react'; +import { randomUUID } from 'node:crypto'; +import { join } from 'node:path'; + +import { ApiClient } from '@/api/api'; +import type { ApiSessionClient } from '@/api/apiSession'; +import type { PermissionMode } from '@/api/types'; +import { logger } from '@/ui/logger'; +import type { Credentials } from '@/persistence'; +import { readSettings } from '@/persistence'; +import { initialMachineMetadata } from '@/daemon/run'; +import { connectionState } from '@/api/offline/serverConnectionErrors'; +import { setupOfflineReconnection } from '@/api/offline/setupOfflineReconnection'; +import { projectPath } from '@/projectPath'; +import { startHappyServer } from '@/mcp/startHappyServer'; +import { createSessionMetadata } from '@/agent/runtime/createSessionMetadata'; +import { createBaseSessionForAttach } from '@/agent/runtime/createBaseSessionForAttach'; +import { + persistTerminalAttachmentInfoIfNeeded, + primeAgentStateForUi, + reportSessionToDaemonIfRunning, + sendTerminalFallbackMessageIfNeeded, +} from '@/agent/runtime/startupSideEffects'; +import { maybeUpdatePermissionModeMetadata } from '@/agent/runtime/permissionModeMetadata'; +import { applyStartupMetadataUpdateToSession, buildPermissionModeOverride } from '@/agent/runtime/startupMetadataUpdate'; +import { registerKillSessionHandler } from '@/rpc/handlers/killSession'; +import { stopCaffeinate } from '@/integrations/caffeinate'; +import { MessageQueue2 } from '@/utils/MessageQueue2'; +import { hashObject } from '@/utils/deterministicJson'; +import { parseSpecialCommand } from '@/cli/parsers/specialCommands'; +import { MessageBuffer } from '@/ui/ink/messageBuffer'; +import { OpenCodeTerminalDisplay } from '@/backends/opencode/ui/OpenCodeTerminalDisplay'; + +import type { McpServerConfig } from '@/agent'; +import { OpenCodePermissionHandler } from './utils/permissionHandler'; +import { maybeUpdateOpenCodeSessionIdMetadata } from './utils/opencodeSessionIdMetadata'; +import { createOpenCodeAcpRuntime } from './acp/runtime'; +import { waitForNextOpenCodeMessage } from './utils/waitForNextOpenCodeMessage'; + +export async function runOpenCode(opts: { + credentials: Credentials; + startedBy?: 'daemon' | 'terminal'; + terminalRuntime?: import('@/terminal/terminalRuntimeFlags').TerminalRuntimeFlags | null; + permissionMode?: PermissionMode; + permissionModeUpdatedAt?: number; + existingSessionId?: string; + resume?: string; +}): Promise { + const sessionTag = randomUUID(); + + connectionState.setBackend('OpenCode'); + + const api = await ApiClient.create(opts.credentials); + + const settings = await readSettings(); + const machineId = settings?.machineId; + if (!machineId) { + console.error(`[START] No machine ID found in settings. Please report this issue on https://github.com/slopus/happy-cli/issues`); + process.exit(1); + } + await api.getOrCreateMachine({ machineId, metadata: initialMachineMetadata }); + + const initialPermissionMode = opts.permissionMode ?? 'default'; + const { state, metadata } = createSessionMetadata({ + flavor: 'opencode', + machineId, + startedBy: opts.startedBy, + terminalRuntime: opts.terminalRuntime ?? null, + permissionMode: initialPermissionMode, + permissionModeUpdatedAt: typeof opts.permissionModeUpdatedAt === 'number' ? opts.permissionModeUpdatedAt : Date.now(), + }); + + const terminal = metadata.terminal; + let session: ApiSessionClient; + let permissionHandler: OpenCodePermissionHandler; + let reconnectionHandle: { cancel: () => void } | null = null; + + if (typeof opts.existingSessionId === 'string' && opts.existingSessionId.trim()) { + const existingId = opts.existingSessionId.trim(); + logger.debug(`[opencode] Attaching to existing Happy session: ${existingId}`); + const baseSession = await createBaseSessionForAttach({ existingSessionId: existingId, metadata, state }); + session = api.sessionSyncClient(baseSession); + + applyStartupMetadataUpdateToSession({ + session, + next: metadata, + nowMs: Date.now(), + permissionModeOverride: buildPermissionModeOverride({ + permissionMode: opts.permissionMode, + permissionModeUpdatedAt: opts.permissionModeUpdatedAt, + }), + }); + + primeAgentStateForUi(session, '[OpenCode]'); + await reportSessionToDaemonIfRunning({ sessionId: existingId, metadata }); + await persistTerminalAttachmentInfoIfNeeded({ sessionId: existingId, terminal }); + sendTerminalFallbackMessageIfNeeded({ session, terminal }); + } else { + const response = await api.getOrCreateSession({ tag: sessionTag, metadata, state }); + if (!response) { + throw new Error('Failed to create session'); + } + + const { session: initialSession, reconnectionHandle: rh } = setupOfflineReconnection({ + api, + sessionTag, + metadata, + state, + response, + onSessionSwap: (newSession) => { + session = newSession; + if (permissionHandler) { + permissionHandler.updateSession(newSession); + } + }, + }); + session = initialSession; + reconnectionHandle = rh; + + primeAgentStateForUi(session, '[OpenCode]'); + await reportSessionToDaemonIfRunning({ sessionId: response.id, metadata }); + await persistTerminalAttachmentInfoIfNeeded({ sessionId: response.id, terminal }); + sendTerminalFallbackMessageIfNeeded({ session, terminal }); + } + + // Start Happy MCP server for `change_title` tool exposure (bridged to ACP via happy-mcp.mjs). + const happyServer = await startHappyServer(session); + + const bridgeCommand = join(projectPath(), 'bin', 'happy-mcp.mjs'); + const mcpServers: Record = { + happy: { command: bridgeCommand, args: ['--url', happyServer.url] }, + }; + + let abortRequestedCallback: (() => void | Promise) | null = null; + permissionHandler = new OpenCodePermissionHandler(session, { + onAbortRequested: () => abortRequestedCallback?.(), + }); + permissionHandler.setPermissionMode(initialPermissionMode); + + // Message queue keyed by permission mode. OpenCode can apply permission decisions per tool call + // without restarting the session, so we do not include model in the hash. + const messageQueue = new MessageQueue2<{ permissionMode: PermissionMode }>((mode) => hashObject({ + permissionMode: mode.permissionMode, + })); + + let currentPermissionMode: PermissionMode | undefined = initialPermissionMode; + + session.onUserMessage((message) => { + let messagePermissionMode = currentPermissionMode; + if (message.meta?.permissionMode) { + const nextPermissionMode = message.meta.permissionMode as PermissionMode; + const res = maybeUpdatePermissionModeMetadata({ + currentPermissionMode, + nextPermissionMode, + updateMetadata: (updater) => session.updateMetadata(updater), + }); + currentPermissionMode = res.currentPermissionMode; + messagePermissionMode = currentPermissionMode; + } + + const mode = { permissionMode: messagePermissionMode || 'default' }; + const special = parseSpecialCommand(message.content.text); + if (special.type === 'clear') { + messageQueue.pushIsolateAndClear(message.content.text, mode); + } else { + messageQueue.push(message.content.text, mode); + } + }); + + const messageBuffer = new MessageBuffer(); + const hasTTY = process.stdout.isTTY && process.stdin.isTTY; + let inkInstance: ReturnType | null = null; + if (hasTTY) { + console.clear(); + inkInstance = render(React.createElement(OpenCodeTerminalDisplay, { + messageBuffer, + logPath: process.env.DEBUG ? logger.getLogPath() : undefined, + onExit: async () => { + shouldExit = true; + await handleAbort(); + }, + }), { exitOnCtrlC: false, patchConsole: false }); + } + + let thinking = false; + let shouldExit = false; + let abortController = new AbortController(); + session.keepAlive(thinking, 'remote'); + const keepAliveInterval = setInterval(() => session.keepAlive(thinking, 'remote'), 2000); + + const runtime = createOpenCodeAcpRuntime({ + directory: metadata.path, + session, + messageBuffer, + mcpServers, + permissionHandler, + onThinkingChange: (value) => { thinking = value; }, + }); + const lastPublishedOpenCodeSessionId = { value: null as string | null }; + + const handleAbort = async () => { + logger.debug('[OpenCode] Abort requested'); + session.sendAgentMessage('opencode', { type: 'turn_aborted', id: randomUUID() }); + permissionHandler.reset(); + messageQueue.reset(); + try { + abortController.abort(); + abortController = new AbortController(); + await runtime.cancel(); + } catch (e) { + logger.debug('[OpenCode] Failed to cancel current operation (non-fatal)', e); + } + }; + abortRequestedCallback = handleAbort; + + const handleKillSession = async () => { + logger.debug('[OpenCode] Kill session requested'); + shouldExit = true; + await handleAbort(); + try { + if (session) { + session.updateMetadata((currentMetadata) => ({ + ...currentMetadata, + lifecycleState: 'archived', + lifecycleStateSince: Date.now(), + archivedBy: 'cli', + archiveReason: 'User terminated', + })); + session.sendSessionDeath(); + await session.flush(); + await session.close(); + } + } finally { + clearInterval(keepAliveInterval); + reconnectionHandle?.cancel(); + stopCaffeinate(); + happyServer.stop(); + await runtime.reset(); + inkInstance?.unmount(); + process.exit(0); + } + }; + + session.rpcHandlerManager.registerHandler('abort', handleAbort); + registerKillSessionHandler(session.rpcHandlerManager, handleKillSession); + + const sendReady = () => { + session.sendSessionEvent({ type: 'ready' }); + try { + api.push().sendToAllDevices("It's ready!", 'OpenCode is waiting for your command', { sessionId: session.sessionId }); + } catch (pushError) { + logger.debug('[OpenCode] Failed to send ready push', pushError); + } + }; + + let wasStarted = false; + let storedSessionIdForResume: string | null = null; + if (typeof opts.resume === 'string' && opts.resume.trim()) { + storedSessionIdForResume = opts.resume.trim(); + } + + try { + let currentModeHash: string | null = null; + type QueuedMessage = { message: string; mode: { permissionMode: PermissionMode }; hash: string }; + let pending: QueuedMessage | null = null; + + while (!shouldExit) { + let message: QueuedMessage | null = pending; + pending = null; + + if (!message) { + const next = await waitForNextOpenCodeMessage({ + messageQueue, + abortSignal: abortController.signal, + session, + }); + if (!next) continue; + message = { message: next.message, mode: next.mode, hash: next.hash }; + } + if (!message) continue; + + // Apply permission mode immediately (no session restart required). + permissionHandler.setPermissionMode(message.mode.permissionMode); + + if (currentModeHash && message.hash !== currentModeHash) { + // Mode changes in OpenCode do not require restart; only update handler state. + currentModeHash = message.hash; + } else { + currentModeHash = message.hash; + } + + messageBuffer.addMessage(message.message, 'user'); + + const special = parseSpecialCommand(message.message); + if (special.type === 'clear') { + messageBuffer.addMessage('Resetting OpenCode session…', 'status'); + await runtime.reset(); + wasStarted = false; + lastPublishedOpenCodeSessionId.value = null; + permissionHandler.reset(); + thinking = false; + session.keepAlive(thinking, 'remote'); + messageBuffer.addMessage('Session reset.', 'status'); + sendReady(); + continue; + } + + try { + runtime.beginTurn(); + if (!wasStarted) { + const resumeId = storedSessionIdForResume?.trim(); + if (resumeId) { + storedSessionIdForResume = null; // consume once + messageBuffer.addMessage('Resuming previous context…', 'status'); + try { + await runtime.startOrLoad({ resumeId }); + } catch (e) { + logger.debug('[OpenCode] Resume failed; starting a new session instead', e); + messageBuffer.addMessage('Resume failed; starting a new session.', 'status'); + session.sendAgentMessage('opencode', { type: 'message', message: 'Resume failed; starting a new session.' }); + await runtime.startOrLoad({}); + } + } else { + await runtime.startOrLoad({}); + } + maybeUpdateOpenCodeSessionIdMetadata({ + getOpenCodeSessionId: () => runtime.getSessionId(), + updateHappySessionMetadata: (updater) => session.updateMetadata(updater), + lastPublished: lastPublishedOpenCodeSessionId, + }); + wasStarted = true; + } + await runtime.sendPrompt(message.message); + } catch (error) { + logger.debug('[OpenCode] Error during prompt:', error); + session.sendAgentMessage('opencode', { type: 'message', message: `Error: ${error instanceof Error ? error.message : String(error)}` }); + } finally { + runtime.flushTurn(); + thinking = false; + session.keepAlive(thinking, 'remote'); + sendReady(); + } + } + } finally { + clearInterval(keepAliveInterval); + reconnectionHandle?.cancel(); + stopCaffeinate(); + happyServer.stop(); + await runtime.reset(); + inkInstance?.unmount(); + } +} diff --git a/cli/src/backends/opencode/ui/OpenCodeTerminalDisplay.tsx b/cli/src/backends/opencode/ui/OpenCodeTerminalDisplay.tsx new file mode 100644 index 000000000..6c1214ab5 --- /dev/null +++ b/cli/src/backends/opencode/ui/OpenCodeTerminalDisplay.tsx @@ -0,0 +1,31 @@ +/** + * OpenCodeTerminalDisplay + * + * Read-only terminal UI for OpenCode sessions started by Happy. + * This UI intentionally does not accept prompts from stdin; it displays logs and exit controls only. + */ + +import React from 'react'; + +import { AgentLogShell } from '@/ui/ink/AgentLogShell'; +import { MessageBuffer } from '@/ui/ink/messageBuffer'; +import { buildReadOnlyFooterLines } from '@/ui/ink/readOnlyFooterLines'; + +export type OpenCodeTerminalDisplayProps = { + messageBuffer: MessageBuffer; + logPath?: string; + onExit?: () => void | Promise; +}; + +export const OpenCodeTerminalDisplay: React.FC = ({ messageBuffer, logPath, onExit }) => { + return ( + + ); +}; diff --git a/cli/src/backends/opencode/utils/opencodeSessionIdMetadata.test.ts b/cli/src/backends/opencode/utils/opencodeSessionIdMetadata.test.ts new file mode 100644 index 000000000..e5d501769 --- /dev/null +++ b/cli/src/backends/opencode/utils/opencodeSessionIdMetadata.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; + +import type { Metadata } from '@/api/types'; +import { maybeUpdateOpenCodeSessionIdMetadata } from './opencodeSessionIdMetadata'; + +describe('maybeUpdateOpenCodeSessionIdMetadata', () => { + it('no-ops when session id is missing', () => { + const lastPublished = { value: null as string | null }; + let called = 0; + + maybeUpdateOpenCodeSessionIdMetadata({ + getOpenCodeSessionId: () => null, + updateHappySessionMetadata: () => { + called++; + }, + lastPublished, + }); + + expect(called).toBe(0); + expect(lastPublished.value).toBeNull(); + }); + + it('publishes opencodeSessionId once per new session id and preserves other metadata', () => { + const lastPublished = { value: null as string | null }; + const updates: Metadata[] = []; + + const apply = (updater: (m: Metadata) => Metadata) => { + const base = { path: '/tmp', flavor: 'opencode' } as unknown as Metadata; + updates.push(updater(base)); + }; + + maybeUpdateOpenCodeSessionIdMetadata({ + getOpenCodeSessionId: () => ' session-1 ', + updateHappySessionMetadata: apply, + lastPublished, + }); + + maybeUpdateOpenCodeSessionIdMetadata({ + getOpenCodeSessionId: () => 'session-1', + updateHappySessionMetadata: apply, + lastPublished, + }); + + maybeUpdateOpenCodeSessionIdMetadata({ + getOpenCodeSessionId: () => 'session-2', + updateHappySessionMetadata: apply, + lastPublished, + }); + + expect(updates).toEqual([ + { path: '/tmp', flavor: 'opencode', opencodeSessionId: 'session-1' } as unknown as Metadata, + { path: '/tmp', flavor: 'opencode', opencodeSessionId: 'session-2' } as unknown as Metadata, + ]); + }); +}); + diff --git a/cli/src/backends/opencode/utils/opencodeSessionIdMetadata.ts b/cli/src/backends/opencode/utils/opencodeSessionIdMetadata.ts new file mode 100644 index 000000000..da091e17e --- /dev/null +++ b/cli/src/backends/opencode/utils/opencodeSessionIdMetadata.ts @@ -0,0 +1,21 @@ +import type { Metadata } from '@/api/types'; + +export function maybeUpdateOpenCodeSessionIdMetadata(params: { + getOpenCodeSessionId: () => string | null; + updateHappySessionMetadata: (updater: (metadata: Metadata) => Metadata) => void; + lastPublished: { value: string | null }; +}): void { + const raw = params.getOpenCodeSessionId(); + const next = typeof raw === 'string' ? raw.trim() : ''; + if (!next) return; + + if (params.lastPublished.value === next) return; + params.lastPublished.value = next; + + params.updateHappySessionMetadata((metadata) => ({ + ...metadata, + // Happy metadata field name. Value is OpenCode ACP sessionId (OpenCode uses sessionId as the stable resume id). + opencodeSessionId: next, + })); +} + diff --git a/cli/src/backends/opencode/utils/permissionHandler.test.ts b/cli/src/backends/opencode/utils/permissionHandler.test.ts new file mode 100644 index 000000000..0b10316d7 --- /dev/null +++ b/cli/src/backends/opencode/utils/permissionHandler.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; + +import { isOpenCodeWriteLikeToolName } from './permissionHandler'; + +describe('isOpenCodeWriteLikeToolName', () => { + it('treats unknown tool names as write-like for safety', () => { + expect(isOpenCodeWriteLikeToolName('other')).toBe(true); + expect(isOpenCodeWriteLikeToolName('Unknown tool')).toBe(true); + expect(isOpenCodeWriteLikeToolName('unknown')).toBe(true); + }); + + it('treats common write tools as write-like', () => { + expect(isOpenCodeWriteLikeToolName('write')).toBe(true); + expect(isOpenCodeWriteLikeToolName('edit_file')).toBe(true); + expect(isOpenCodeWriteLikeToolName('bash')).toBe(true); + }); + + it('treats common read tools as not write-like', () => { + expect(isOpenCodeWriteLikeToolName('read')).toBe(false); + expect(isOpenCodeWriteLikeToolName('glob')).toBe(false); + expect(isOpenCodeWriteLikeToolName('grep')).toBe(false); + }); +}); + diff --git a/cli/src/backends/opencode/utils/permissionHandler.ts b/cli/src/backends/opencode/utils/permissionHandler.ts new file mode 100644 index 000000000..7c3ff3f80 --- /dev/null +++ b/cli/src/backends/opencode/utils/permissionHandler.ts @@ -0,0 +1,115 @@ +/** + * OpenCode Permission Handler + * + * Handles tool permission requests and responses for OpenCode ACP sessions. + * Uses the same mobile permission RPC flow as Codex/Gemini. + */ + +import { logger } from '@/ui/logger'; +import type { ApiSessionClient } from '@/api/apiSession'; +import type { PermissionMode } from '@/api/types'; +import { + BasePermissionHandler, + type PermissionResult, + type PendingRequest, +} from '@/agent/permissions/BasePermissionHandler'; + +// Re-export types for backwards compatibility +export type { PermissionResult, PendingRequest }; + +export function isOpenCodeWriteLikeToolName(toolName: string): boolean { + const lower = toolName.toLowerCase(); + // Safety: when OpenCode reports an unknown tool name (often "other"), + // treat it as write-like so safe modes do not silently auto-approve it. + if (lower === 'other' || lower === 'unknown tool' || lower === 'unknown') return true; + + const writeish = [ + 'edit', + 'write', + 'patch', + 'delete', + 'remove', + 'create', + 'mkdir', + 'rename', + 'move', + 'copy', + 'exec', + 'bash', + 'shell', + 'run', + 'terminal', + ]; + return writeish.some((k) => lower === k || lower.includes(k)); +} + +export class OpenCodePermissionHandler extends BasePermissionHandler { + private currentPermissionMode: PermissionMode = 'default'; + + constructor( + session: ApiSessionClient, + opts?: { onAbortRequested?: (() => void | Promise) | null }, + ) { + super(session, opts); + } + + protected getLogPrefix(): string { + return '[OpenCode]'; + } + + updateSession(newSession: ApiSessionClient): void { + super.updateSession(newSession); + } + + setPermissionMode(mode: PermissionMode): void { + this.currentPermissionMode = mode; + logger.debug(`${this.getLogPrefix()} Permission mode set to: ${mode}`); + } + + private shouldAutoApprove(toolName: string, toolCallId: string): boolean { + // Always-auto-approve lightweight internal tools if any appear. + // (Conservative: keep this list minimal.) + const alwaysAutoApproveNames = ['change_title', 'save_memory', 'think']; + if (alwaysAutoApproveNames.some((n) => toolName.toLowerCase().includes(n))) return true; + + switch (this.currentPermissionMode) { + case 'yolo': + return true; + case 'safe-yolo': + return !isOpenCodeWriteLikeToolName(toolName); + case 'read-only': + return !isOpenCodeWriteLikeToolName(toolName); + case 'default': + case 'acceptEdits': + case 'bypassPermissions': + case 'plan': + default: + return false; + } + } + + async handleToolCall(toolCallId: string, toolName: string, input: unknown): Promise { + // Respect user "don't ask again for session" choices captured via our permission UI. + if (this.isAllowedForSession(toolName, input)) { + logger.debug(`${this.getLogPrefix()} Auto-approving (allowed for session) tool ${toolName} (${toolCallId})`); + this.recordAutoDecision(toolCallId, toolName, input, 'approved_for_session'); + return { decision: 'approved_for_session' }; + } + + if (this.shouldAutoApprove(toolName, toolCallId)) { + const decision: PermissionResult['decision'] = + this.currentPermissionMode === 'yolo' ? 'approved_for_session' : 'approved'; + + logger.debug(`${this.getLogPrefix()} Auto-approving tool ${toolName} (${toolCallId}) in ${this.currentPermissionMode} mode`); + this.recordAutoDecision(toolCallId, toolName, input, decision); + + return { decision }; + } + + return new Promise((resolve, reject) => { + this.pendingRequests.set(toolCallId, { resolve, reject, toolName, input }); + this.addPendingRequestToState(toolCallId, toolName, input); + logger.debug(`${this.getLogPrefix()} Permission request sent for tool: ${toolName} (${toolCallId}) in ${this.currentPermissionMode} mode`); + }); + } +} diff --git a/cli/src/backends/opencode/utils/waitForNextOpenCodeMessage.test.ts b/cli/src/backends/opencode/utils/waitForNextOpenCodeMessage.test.ts new file mode 100644 index 000000000..5ad9fc328 --- /dev/null +++ b/cli/src/backends/opencode/utils/waitForNextOpenCodeMessage.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; + +import type { PermissionMode } from '@/api/types'; +import { MessageQueue2 } from '@/utils/MessageQueue2'; + +import { waitForNextOpenCodeMessage } from './waitForNextOpenCodeMessage'; + +describe('waitForNextOpenCodeMessage', () => { + it('wakes on metadata update and then processes a pending-queue item', async () => { + const queue = new MessageQueue2<{ permissionMode: PermissionMode }>(() => 'hash'); + + let pendingText: string | null = null; + const session = { + popPendingMessage: async () => { + if (!pendingText) return false; + const text = pendingText; + pendingText = null; + queue.pushImmediate(text, { permissionMode: 'default' }); + return true; + }, + waitForMetadataUpdate: async (abortSignal?: AbortSignal) => { + if (abortSignal?.aborted) return false; + return await new Promise((resolve) => { + const timer = setTimeout(() => resolve(true), 0); + timer.unref?.(); + }); + }, + }; + + const abortController = new AbortController(); + pendingText = 'from-pending'; + + const result = await waitForNextOpenCodeMessage({ + messageQueue: queue, + abortSignal: abortController.signal, + session: session as any, + }); + + expect(result?.message).toBe('from-pending'); + }); +}); + diff --git a/cli/src/backends/opencode/utils/waitForNextOpenCodeMessage.ts b/cli/src/backends/opencode/utils/waitForNextOpenCodeMessage.ts new file mode 100644 index 000000000..995018fb9 --- /dev/null +++ b/cli/src/backends/opencode/utils/waitForNextOpenCodeMessage.ts @@ -0,0 +1,18 @@ +import type { ApiSessionClient } from '@/api/apiSession'; +import type { PermissionMode } from '@/api/types'; +import type { MessageBatch } from '@/agent/runtime/waitForMessagesOrPending'; +import { waitForMessagesOrPending } from '@/agent/runtime/waitForMessagesOrPending'; +import type { MessageQueue2 } from '@/utils/MessageQueue2'; + +export async function waitForNextOpenCodeMessage(opts: { + messageQueue: MessageQueue2<{ permissionMode: PermissionMode }>; + abortSignal: AbortSignal; + session: ApiSessionClient; +}): Promise | null> { + return await waitForMessagesOrPending({ + messageQueue: opts.messageQueue, + abortSignal: opts.abortSignal, + popPendingMessage: () => opts.session.popPendingMessage(), + waitForMetadataUpdate: (signal) => opts.session.waitForMetadataUpdate(signal), + }); +} diff --git a/cli/src/backends/types.ts b/cli/src/backends/types.ts new file mode 100644 index 000000000..de70b2621 --- /dev/null +++ b/cli/src/backends/types.ts @@ -0,0 +1,107 @@ +import type { AgentBackend } from '@/agent/core'; +import type { ChecklistId } from '@/capabilities/checklistIds'; +import type { Capability } from '@/capabilities/service'; +import type { CommandHandler } from '@/cli/commandRegistry'; +import type { CloudConnectTarget } from '@/cloud/connectTypes'; +import type { DaemonSpawnHooks } from '@/daemon/spawnHooks'; + +import { + AGENT_IDS as CATALOG_AGENT_IDS, + DEFAULT_AGENT_ID as DEFAULT_CATALOG_AGENT_ID, + type AgentId as CatalogAgentId, + type VendorResumeSupportLevel, +} from '@happy/agents'; + +export { CATALOG_AGENT_IDS, DEFAULT_CATALOG_AGENT_ID }; +export type { CatalogAgentId, VendorResumeSupportLevel }; + +export type CatalogAcpBackendCreateResult = Readonly<{ backend: AgentBackend }>; +export type CatalogAcpBackendFactory = (opts: unknown) => CatalogAcpBackendCreateResult; + +export type VendorResumeSupportParams = Readonly<{ + experimentalCodexResume?: boolean; + experimentalCodexAcp?: boolean; +}>; + +export type VendorResumeSupportFn = (params: VendorResumeSupportParams) => boolean; + +export type HeadlessTmuxArgvTransform = (argv: string[]) => string[]; + +export type AgentChecklistContributions = Partial< + Record }>>> +>; + +export type CliDetectSpec = Readonly<{ + /** + * Candidate argv lists to try for `--version` probing. + * The first matching semver is returned (best-effort). + */ + versionArgsToTry?: ReadonlyArray>; + /** + * Optional argv for best-effort "am I logged in?" probing. + * When omitted/undefined, the snapshot returns null (unknown/unsupported). + */ + loginStatusArgs?: ReadonlyArray | null; +}>; + +export type AgentCatalogEntry = Readonly<{ + id: CatalogAgentId; + cliSubcommand: CatalogAgentId; + /** + * Optional CLI subcommand handler for this agent. + */ + getCliCommandHandler?: () => Promise; + getCliCapabilityOverride?: () => Promise; + /** + * Optional extra capabilities contributed by this agent. + * + * Use this for agent-specific deps/tools/experiments, not the base `cli.` + * capability (handled by `getCliCapabilityOverride` / generic fallback). + */ + getCapabilities?: () => Promise>; + getCliDetect?: () => Promise; + /** + * Optional cloud connect target for this agent. + * + * When present, `happy connect ` will be available. + */ + getCloudConnectTarget?: () => Promise; + /** + * Optional daemon spawn hooks for this agent. + * + * These are evaluated by the daemon before spawning a child process. + */ + getDaemonSpawnHooks?: () => Promise; + /** + * Whether this agent supports vendor-level resume (NOT Happy session resume). + * + * Used by the daemon to decide whether it may pass `--resume `. + */ + vendorResumeSupport: VendorResumeSupportLevel; + /** + * Optional predicate used when vendor resume support is experimental. + * + * This intentionally stays catalog-driven and lazy-imported. + */ + getVendorResumeSupport?: () => Promise; + /** + * Optional argv rewrite when launching headless sessions in tmux. + * + * Used by the CLI `--tmux` launcher before it spawns a child `happy ...` process. + */ + getHeadlessTmuxArgvTransform?: () => Promise; + /** + * Optional ACP backend factory for this agent. + * + * This is intentionally "pull-based" (lazy import) to avoid side-effect + * registration and import-order dependence. + */ + getAcpBackendFactory?: () => Promise; + /** + * Optional capability checklist contributions for agent-specific UX. + * + * This is intentionally data-only (no self-registration) so the capabilities + * engine can stay deterministic and easy to inspect. + */ + checklists?: AgentChecklistContributions; +}>; diff --git a/cli/src/capabilities/checklistIds.ts b/cli/src/capabilities/checklistIds.ts new file mode 100644 index 000000000..e635714ce --- /dev/null +++ b/cli/src/capabilities/checklistIds.ts @@ -0,0 +1,2 @@ +export { CHECKLIST_IDS, resumeChecklistId } from '@happy/protocol/checklists'; +export type { ChecklistId } from '@happy/protocol/checklists'; diff --git a/cli/src/capabilities/checklists.ts b/cli/src/capabilities/checklists.ts new file mode 100644 index 000000000..432661109 --- /dev/null +++ b/cli/src/capabilities/checklists.ts @@ -0,0 +1,65 @@ +import type { AgentCatalogEntry } from '@/backends/catalog'; +import { AGENTS } from '@/backends/catalog'; +import { CATALOG_AGENT_IDS } from '@/backends/types'; +import type { CatalogAgentId } from '@/backends/types'; +import { AGENTS_CORE } from '@happy/agents'; + +import { CHECKLIST_IDS, resumeChecklistId, type ChecklistId } from './checklistIds'; +import type { CapabilityDetectRequest } from './types'; + +const cliAgentRequests: CapabilityDetectRequest[] = (Object.values(AGENTS) as AgentCatalogEntry[]).map((entry) => ({ + id: `cli.${entry.id}`, +})); + +function mergeChecklistContributions( + base: Record, +): Record { + const next: Record = { ...base }; + + for (const entry of Object.values(AGENTS) as AgentCatalogEntry[]) { + const contributions = entry.checklists; + if (!contributions) continue; + + for (const [checklistId, requests] of Object.entries(contributions) as Array< + [ChecklistId, ReadonlyArray<{ id: string; params?: Record }>] + >) { + const normalized: CapabilityDetectRequest[] = requests.map((r) => ({ + id: r.id as CapabilityDetectRequest['id'], + ...(r.params ? { params: r.params } : {}), + })); + next[checklistId] = [...(next[checklistId] ?? []), ...normalized]; + } + } + + return next; +} + +const resumeChecklistEntries = Object.fromEntries( + CATALOG_AGENT_IDS.map((id) => { + const runtimeGate = AGENTS_CORE[id].resume.runtimeGate; + const requests: CapabilityDetectRequest[] = []; + if (runtimeGate === 'acpLoadSession') { + requests.push({ + id: `cli.${id}`, + params: { includeAcpCapabilities: true, includeLoginStatus: true }, + }); + } + return [resumeChecklistId(id), requests] as const; + }), +) as Record<`resume.${CatalogAgentId}`, CapabilityDetectRequest[]>; + +const baseChecklists = { + [CHECKLIST_IDS.NEW_SESSION]: [ + ...cliAgentRequests, + { id: 'tool.tmux' }, + ], + [CHECKLIST_IDS.MACHINE_DETAILS]: [ + ...cliAgentRequests, + { id: 'tool.tmux' }, + { id: 'dep.codex-mcp-resume' }, + { id: 'dep.codex-acp' }, + ], + ...resumeChecklistEntries, +} satisfies Record; + +export const checklists: Record = mergeChecklistContributions(baseChecklists); diff --git a/cli/src/capabilities/context/buildDetectContext.ts b/cli/src/capabilities/context/buildDetectContext.ts new file mode 100644 index 000000000..406092f83 --- /dev/null +++ b/cli/src/capabilities/context/buildDetectContext.ts @@ -0,0 +1,14 @@ +import type { CapabilitiesDetectContext, CapabilitiesDetectContextBuilder } from '../service'; +import type { CapabilityDetectRequest } from '../types'; +import { detectCliSnapshotOnDaemonPath } from '../snapshots/cliSnapshot'; + +export const buildDetectContext: CapabilitiesDetectContextBuilder = async (requests: CapabilityDetectRequest[]): Promise => { + const wantsCliOrTmux = requests.some((r) => r.id.startsWith('cli.') || r.id === 'tool.tmux'); + const anyLogin = requests.some((r) => r.id.startsWith('cli.') && Boolean((r.params ?? {}).includeLoginStatus)); + const cliSnapshot = wantsCliOrTmux + ? await detectCliSnapshotOnDaemonPath({ ...(anyLogin ? { includeLoginStatus: true } : {}) }) + : null; + + return { cliSnapshot }; +}; + diff --git a/cli/src/capabilities/deps/codexAcp.ts b/cli/src/capabilities/deps/codexAcp.ts new file mode 100644 index 000000000..301101217 --- /dev/null +++ b/cli/src/capabilities/deps/codexAcp.ts @@ -0,0 +1,180 @@ +import { execFile } from 'child_process'; +import { constants as fsConstants } from 'fs'; +import { access, mkdir, readFile, writeFile } from 'fs/promises'; +import { dirname, join } from 'path'; +import { promisify } from 'util'; +import { configuration } from '@/configuration'; + +const execFileAsync = promisify(execFile); + +export const CODEX_ACP_NPM_PACKAGE = '@zed-industries/codex-acp'; +export const CODEX_ACP_DIST_TAG = 'latest'; +export const DEFAULT_CODEX_ACP_INSTALL_SPEC = `${CODEX_ACP_NPM_PACKAGE}@${CODEX_ACP_DIST_TAG}`; + +export const codexAcpInstallDir = () => join(configuration.happyHomeDir, 'tools', 'codex-acp'); + +export const codexAcpBinPath = () => { + const binName = process.platform === 'win32' ? 'codex-acp.cmd' : 'codex-acp'; + return join(codexAcpInstallDir(), 'node_modules', '.bin', binName); +}; + +const codexAcpStatePath = () => join(codexAcpInstallDir(), 'install-state.json'); + +async function readCodexAcpState(): Promise<{ lastInstallLogPath: string | null } | null> { + try { + const raw = await readFile(codexAcpStatePath(), 'utf8'); + const parsed = JSON.parse(raw); + const lastInstallLogPath = typeof parsed?.lastInstallLogPath === 'string' ? parsed.lastInstallLogPath : null; + return { lastInstallLogPath }; + } catch { + return null; + } +} + +async function writeCodexAcpState(next: { lastInstallLogPath: string | null }): Promise { + await mkdir(codexAcpInstallDir(), { recursive: true }); + await writeFile(codexAcpStatePath(), JSON.stringify(next, null, 2), 'utf8'); +} + +async function readInstalledNpmPackageVersion(opts: { installDir: string; packageName: string }): Promise { + try { + const pkgPath = join(opts.installDir, 'node_modules', opts.packageName, 'package.json'); + const raw = await readFile(pkgPath, 'utf8'); + const parsed = JSON.parse(raw); + const version = typeof parsed?.version === 'string' ? parsed.version : null; + return version; + } catch { + return null; + } +} + +async function readNpmDistTagVersion(opts: { packageName: string; distTag: string }): Promise { + try { + const { stdout } = await execFileAsync('npm', ['view', `${opts.packageName}@${opts.distTag}`, 'version'], { + timeout: 10_000, + windowsHide: true, + }); + const text = typeof stdout === 'string' ? stdout.trim() : ''; + return text || null; + } catch { + return null; + } +} + +async function installNpmDepToPrefix(opts: { + installDir: string; + installSpec: string; + logPath: string; +}): Promise<{ ok: true } | { ok: false; errorMessage: string }> { + try { + await mkdir(opts.installDir, { recursive: true }); + await mkdir(dirname(opts.logPath), { recursive: true }); + const { stdout, stderr } = await execFileAsync( + 'npm', + ['install', '--no-audit', '--no-fund', '--prefix', opts.installDir, opts.installSpec], + { timeout: 15 * 60_000, windowsHide: true, maxBuffer: 50 * 1024 * 1024 }, + ); + + await writeFile( + opts.logPath, + [`# installSpec: ${opts.installSpec}`, '', '## stdout', stdout ?? '', '', '## stderr', stderr ?? ''].join('\n'), + 'utf8', + ); + + return { ok: true }; + } catch (e) { + const message = e instanceof Error ? e.message : 'Install failed'; + try { + await mkdir(dirname(opts.logPath), { recursive: true }); + await writeFile(opts.logPath, `# installSpec: ${opts.installSpec}\n\n${message}\n`, 'utf8'); + } catch { } + return { ok: false, errorMessage: message }; + } +} + +export async function installCodexAcp(installSpecOverride?: string): Promise< + | { ok: true; logPath: string } + | { ok: false; errorMessage: string; logPath: string } +> { + const logPath = join(configuration.logsDir, `install-dep-codex-acp-${Date.now()}.log`); + + const installSpecRaw = typeof installSpecOverride === 'string' ? installSpecOverride.trim() : ''; + const installSpec = + installSpecRaw || + (typeof process.env.HAPPY_CODEX_ACP_INSTALL_SPEC === 'string' ? process.env.HAPPY_CODEX_ACP_INSTALL_SPEC.trim() : '') || + DEFAULT_CODEX_ACP_INSTALL_SPEC; + + const result = await installNpmDepToPrefix({ + installDir: codexAcpInstallDir(), + installSpec, + logPath, + }); + + try { + await writeCodexAcpState({ lastInstallLogPath: logPath }); + } catch { } + + if (!result.ok) { + return { ok: false, errorMessage: result.errorMessage, logPath }; + } + + return { ok: true, logPath }; +} + +export type CodexAcpDepData = { + installed: boolean; + installDir: string; + binPath: string | null; + installedVersion: string | null; + distTag: string; + lastInstallLogPath: string | null; + registry?: { ok: true; latestVersion: string | null } | { ok: false; errorMessage: string }; +}; + +export async function getCodexAcpDepStatus(opts?: { + includeRegistry?: boolean; + onlyIfInstalled?: boolean; + distTag?: string; +}): Promise { + const primaryBinPath = codexAcpBinPath(); + const state = await readCodexAcpState(); + const accessMode = process.platform === 'win32' ? fsConstants.F_OK : fsConstants.X_OK; + + const installed = await (async () => { + try { + await access(primaryBinPath, accessMode); + return true; + } catch { + return false; + } + })(); + + const binPath = installed ? primaryBinPath : null; + const installDir = codexAcpInstallDir(); + const installedVersion = await readInstalledNpmPackageVersion({ installDir, packageName: CODEX_ACP_NPM_PACKAGE }); + const includeRegistry = Boolean(opts?.includeRegistry); + const onlyIfInstalled = Boolean(opts?.onlyIfInstalled); + const distTag = typeof opts?.distTag === 'string' && opts.distTag.trim() ? opts.distTag.trim() : CODEX_ACP_DIST_TAG; + + const registry = includeRegistry && (!onlyIfInstalled || installed) + ? await (async () => { + try { + const latestVersion = await readNpmDistTagVersion({ packageName: CODEX_ACP_NPM_PACKAGE, distTag }); + return { ok: true as const, latestVersion }; + } catch (e) { + const msg = e instanceof Error ? e.message : 'Failed to read npm dist-tag'; + return { ok: false as const, errorMessage: msg }; + } + })() + : undefined; + + return { + installed, + binPath, + installDir, + installedVersion, + distTag, + lastInstallLogPath: state?.lastInstallLogPath ?? null, + ...(registry ? { registry } : {}), + }; +} diff --git a/cli/src/capabilities/deps/codexMcpResume.ts b/cli/src/capabilities/deps/codexMcpResume.ts new file mode 100644 index 000000000..5e4aec6c2 --- /dev/null +++ b/cli/src/capabilities/deps/codexMcpResume.ts @@ -0,0 +1,223 @@ +import { execFile } from 'child_process'; +import { constants as fsConstants } from 'fs'; +import { access, mkdir, readFile, writeFile } from 'fs/promises'; +import { dirname, join } from 'path'; +import { promisify } from 'util'; +import { configuration } from '@/configuration'; + +const execFileAsync = promisify(execFile); + +export const CODEX_MCP_RESUME_NPM_PACKAGE = '@leeroy/codex-mcp-resume'; +export const CODEX_MCP_RESUME_DIST_TAG = 'happy-codex-resume'; +export const DEFAULT_CODEX_MCP_RESUME_INSTALL_SPEC = `${CODEX_MCP_RESUME_NPM_PACKAGE}@${CODEX_MCP_RESUME_DIST_TAG}`; + +export const codexResumeInstallDir = () => join(configuration.happyHomeDir, 'tools', 'codex-mcp-resume'); +export const codexResumeLegacyInstallDir = () => join(configuration.happyHomeDir, 'tools', 'codex-resume'); + +const codexResumeBinPath = () => { + const binName = process.platform === 'win32' ? 'codex-mcp-resume.cmd' : 'codex-mcp-resume'; + return join(codexResumeInstallDir(), 'node_modules', '.bin', binName); +}; +const codexResumeLegacyBinPath = () => { + const binName = process.platform === 'win32' ? 'codex-mcp-resume.cmd' : 'codex-mcp-resume'; + return join(codexResumeLegacyInstallDir(), 'node_modules', '.bin', binName); +}; + +const codexResumeStatePath = () => join(codexResumeInstallDir(), 'install-state.json'); +const codexResumeLegacyStatePath = () => join(codexResumeLegacyInstallDir(), 'install-state.json'); + +async function readCodexResumeState(): Promise<{ lastInstallLogPath: string | null } | null> { + try { + const raw = await readFile(codexResumeStatePath(), 'utf8'); + const parsed = JSON.parse(raw); + const lastInstallLogPath = typeof parsed?.lastInstallLogPath === 'string' ? parsed.lastInstallLogPath : null; + return { lastInstallLogPath }; + } catch { + return null; + } +} + +async function readCodexResumeStateWithFallback(): Promise<{ lastInstallLogPath: string | null } | null> { + const primary = await readCodexResumeState(); + if (primary) return primary; + try { + const raw = await readFile(codexResumeLegacyStatePath(), 'utf8'); + const parsed = JSON.parse(raw); + const lastInstallLogPath = typeof parsed?.lastInstallLogPath === 'string' ? parsed.lastInstallLogPath : null; + return { lastInstallLogPath }; + } catch { + return null; + } +} + +async function writeCodexResumeState(next: { lastInstallLogPath: string | null }): Promise { + await mkdir(codexResumeInstallDir(), { recursive: true }); + await writeFile(codexResumeStatePath(), JSON.stringify(next, null, 2), 'utf8'); +} + +async function readInstalledNpmPackageVersion(opts: { installDir: string; packageName: string }): Promise { + try { + const pkgPath = join(opts.installDir, 'node_modules', opts.packageName, 'package.json'); + const raw = await readFile(pkgPath, 'utf8'); + const parsed = JSON.parse(raw); + const version = typeof parsed?.version === 'string' ? parsed.version : null; + return version; + } catch { + return null; + } +} + +async function readNpmDistTagVersion(opts: { packageName: string; distTag: string }): Promise { + try { + const { stdout } = await execFileAsync('npm', ['view', `${opts.packageName}@${opts.distTag}`, 'version'], { + timeout: 10_000, + windowsHide: true, + }); + const text = typeof stdout === 'string' ? stdout.trim() : ''; + return text || null; + } catch { + return null; + } +} + +async function installNpmDepToPrefix(opts: { + installDir: string; + installSpec: string; + logPath: string; +}): Promise<{ ok: true } | { ok: false; errorMessage: string }> { + try { + await mkdir(opts.installDir, { recursive: true }); + await mkdir(dirname(opts.logPath), { recursive: true }); + const { stdout, stderr } = await execFileAsync( + 'npm', + ['install', '--no-audit', '--no-fund', '--prefix', opts.installDir, opts.installSpec], + { timeout: 15 * 60_000, windowsHide: true, maxBuffer: 50 * 1024 * 1024 }, + ); + + await writeFile( + opts.logPath, + [`# installSpec: ${opts.installSpec}`, '', '## stdout', stdout ?? '', '', '## stderr', stderr ?? ''].join('\n'), + 'utf8', + ); + + return { ok: true }; + } catch (e) { + const message = e instanceof Error ? e.message : 'Install failed'; + try { + await mkdir(dirname(opts.logPath), { recursive: true }); + await writeFile(opts.logPath, `# installSpec: ${opts.installSpec}\n\n${message}\n`, 'utf8'); + } catch { } + return { ok: false, errorMessage: message }; + } +} + +export async function installCodexMcpResume(installSpecOverride?: string): Promise< + | { ok: true; logPath: string } + | { ok: false; errorMessage: string; logPath: string } +> { + const logPath = join(configuration.logsDir, `install-dep-codex-mcp-resume-${Date.now()}.log`); + + const installSpecRaw = typeof installSpecOverride === 'string' ? installSpecOverride.trim() : ''; + const installSpec = + installSpecRaw || + (typeof process.env.HAPPY_CODEX_MCP_RESUME_INSTALL_SPEC === 'string' ? process.env.HAPPY_CODEX_MCP_RESUME_INSTALL_SPEC.trim() : '') || + (typeof process.env.HAPPY_CODEX_RESUME_INSTALL_SPEC === 'string' ? process.env.HAPPY_CODEX_RESUME_INSTALL_SPEC.trim() : '') || + DEFAULT_CODEX_MCP_RESUME_INSTALL_SPEC; + + const result = await installNpmDepToPrefix({ + installDir: codexResumeInstallDir(), + installSpec, + logPath, + }); + + try { + await writeCodexResumeState({ lastInstallLogPath: logPath }); + } catch { } + + if (!result.ok) { + const extraHelp = (() => { + if (installSpec !== DEFAULT_CODEX_MCP_RESUME_INSTALL_SPEC) return ''; + const msg = result.errorMessage || ''; + if (!msg.includes('No matching version found')) return ''; + return `\n\nTip: the npm dist-tag "${CODEX_MCP_RESUME_DIST_TAG}" may not be set yet.\n` + + `Publish and then run your dist-tag workflow, or temporarily install "${CODEX_MCP_RESUME_NPM_PACKAGE}@latest".`; + })(); + return { ok: false, errorMessage: result.errorMessage + extraHelp, logPath }; + } + + return { ok: true, logPath }; +} + +export type CodexMcpResumeDepData = { + installed: boolean; + installDir: string; + binPath: string | null; + installedVersion: string | null; + distTag: string; + lastInstallLogPath: string | null; + registry?: { ok: true; latestVersion: string | null } | { ok: false; errorMessage: string }; +}; + +export async function getCodexMcpResumeDepStatus(opts?: { + includeRegistry?: boolean; + onlyIfInstalled?: boolean; + distTag?: string; +}): Promise { + const primaryBinPath = codexResumeBinPath(); + const legacyBinPath = codexResumeLegacyBinPath(); + const state = await readCodexResumeStateWithFallback(); + const accessMode = process.platform === 'win32' ? fsConstants.F_OK : fsConstants.X_OK; + + const installed = await (async () => { + try { + await access(primaryBinPath, accessMode); + return true; + } catch { + try { + await access(legacyBinPath, accessMode); + return true; + } catch { + return false; + } + } + })(); + + const binPath = installed + ? await (async () => { + try { + await access(primaryBinPath, accessMode); + return primaryBinPath; + } catch { + return legacyBinPath; + } + })() + : null; + + const installDir = binPath?.startsWith(codexResumeLegacyInstallDir()) ? codexResumeLegacyInstallDir() : codexResumeInstallDir(); + const installedVersion = await readInstalledNpmPackageVersion({ installDir, packageName: CODEX_MCP_RESUME_NPM_PACKAGE }); + const includeRegistry = Boolean(opts?.includeRegistry); + const onlyIfInstalled = Boolean(opts?.onlyIfInstalled); + const distTag = typeof opts?.distTag === 'string' && opts.distTag.trim() ? opts.distTag.trim() : CODEX_MCP_RESUME_DIST_TAG; + + const registry = includeRegistry && (!onlyIfInstalled || installed) + ? await (async () => { + try { + const latestVersion = await readNpmDistTagVersion({ packageName: CODEX_MCP_RESUME_NPM_PACKAGE, distTag }); + return { ok: true as const, latestVersion }; + } catch (e) { + const msg = e instanceof Error ? e.message : 'Failed to read npm dist-tag'; + return { ok: false as const, errorMessage: msg }; + } + })() + : undefined; + + return { + installed, + binPath, + installDir, + installedVersion, + distTag, + lastInstallLogPath: state?.lastInstallLogPath ?? null, + ...(registry ? { registry } : {}), + }; +} diff --git a/cli/src/capabilities/errors.ts b/cli/src/capabilities/errors.ts new file mode 100644 index 000000000..f3d566d44 --- /dev/null +++ b/cli/src/capabilities/errors.ts @@ -0,0 +1,10 @@ +export class CapabilityError extends Error { + public readonly code?: string; + + constructor(message: string, code?: string) { + super(message); + this.name = 'CapabilityError'; + this.code = code; + } +} + diff --git a/cli/src/capabilities/probes/acpProbe.ts b/cli/src/capabilities/probes/acpProbe.ts new file mode 100644 index 000000000..dc4456422 --- /dev/null +++ b/cli/src/capabilities/probes/acpProbe.ts @@ -0,0 +1,174 @@ +import { spawn, type ChildProcess } from 'node:child_process'; +import { + ClientSideConnection, + ndJsonStream, + PROTOCOL_VERSION, + type Agent, + type Client, + type InitializeRequest, + type InitializeResponse, + type RequestPermissionRequest, + type RequestPermissionResponse, + type SessionNotification, +} from '@agentclientprotocol/sdk'; + +import { logger } from '@/ui/logger'; +import type { TransportHandler } from '@/agent/transport'; +import { nodeToWebStreams } from '@/agent/acp/nodeToWebStreams'; + +type AcpProbeResult = + | { ok: true; checkedAt: number; agentCapabilities: InitializeResponse['agentCapabilities'] } + | { ok: false; checkedAt: number; error: { message: string } }; + +async function terminateProcess(child: ChildProcess): Promise { + if (child.killed) return; + + const waitForExit = new Promise((resolve) => { + child.once('exit', () => resolve()); + }); + + try { + child.kill('SIGTERM'); + } catch { + // ignore + } + + await Promise.race([ + waitForExit, + new Promise((resolve) => setTimeout(resolve, 250)), + ]); + + if (!child.killed) { + try { + child.kill('SIGKILL'); + } catch { + // ignore + } + } +} + +export async function probeAcpAgentCapabilities(params: { + command: string; + args: string[]; + cwd: string; + env: Record; + transport: TransportHandler; + timeoutMs?: number; +}): Promise { + const checkedAt = Date.now(); + const timeoutMs = typeof params.timeoutMs === 'number' ? params.timeoutMs : 2500; + + let child: ChildProcess | null = null; + try { + const isWindows = process.platform === 'win32'; + const env = { ...process.env, ...params.env }; + + if (isWindows) { + child = spawn(params.command, params.args, { + cwd: params.cwd, + env, + stdio: ['pipe', 'pipe', 'pipe'], + shell: true, + windowsHide: true, + }); + } else { + child = spawn(params.command, params.args, { + cwd: params.cwd, + env, + stdio: ['pipe', 'pipe', 'pipe'], + }); + } + + if (!child.stdin || !child.stdout || !child.stderr) { + throw new Error('Failed to create stdio pipes'); + } + + child.stderr.on('data', (data: Buffer) => { + const text = data.toString(); + if (text.trim()) { + logger.debug(`[acpProbe] stderr(${params.transport.agentName}): ${text.trim()}`); + } + }); + + const { writable, readable } = nodeToWebStreams(child.stdin, child.stdout); + + const filteredReadable = new ReadableStream({ + async start(controller) { + const reader = readable.getReader(); + const decoder = new TextDecoder(); + const encoder = new TextEncoder(); + let buffer = ''; + let filteredCount = 0; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) { + if (buffer.trim()) { + const filtered = params.transport.filterStdoutLine?.(buffer); + if (filtered === undefined) controller.enqueue(encoder.encode(buffer)); + else if (filtered !== null) controller.enqueue(encoder.encode(filtered)); + else filteredCount++; + } + if (filteredCount > 0) { + logger.debug(`[acpProbe] filtered ${filteredCount} lines from ${params.transport.agentName} stdout`); + } + controller.close(); + break; + } + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + for (const line of lines) { + if (!line.trim()) continue; + const filtered = params.transport.filterStdoutLine?.(line); + if (filtered === undefined) controller.enqueue(encoder.encode(`${line}\n`)); + else if (filtered !== null) controller.enqueue(encoder.encode(`${filtered}\n`)); + else filteredCount++; + } + } + } catch (error) { + controller.error(error); + } finally { + reader.releaseLock(); + } + }, + }); + + const stream = ndJsonStream(writable, filteredReadable); + + const client: Client = { + sessionUpdate: async (_params: SessionNotification) => {}, + requestPermission: async (_params: RequestPermissionRequest): Promise => { + // Probe should never ask for permissions; fail closed if it does. + return { outcome: { outcome: 'selected', optionId: 'cancel' } }; + }, + }; + + const connection = new ClientSideConnection((_agent: Agent) => client, stream); + + const initRequest: InitializeRequest = { + protocolVersion: PROTOCOL_VERSION, + clientCapabilities: { + fs: { readTextFile: false, writeTextFile: false }, + }, + clientInfo: { name: 'happy-cli-capabilities', version: '0' }, + }; + + const initResponse = await Promise.race([ + connection.initialize(initRequest), + new Promise((_, reject) => setTimeout(() => reject(new Error(`ACP initialize timeout after ${timeoutMs}ms`)), timeoutMs)), + ]); + + return { ok: true, checkedAt, agentCapabilities: initResponse.agentCapabilities }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { ok: false, checkedAt, error: { message } }; + } finally { + if (child) { + await terminateProcess(child); + } + } +} diff --git a/cli/src/capabilities/probes/cliBase.ts b/cli/src/capabilities/probes/cliBase.ts new file mode 100644 index 000000000..dd6806b37 --- /dev/null +++ b/cli/src/capabilities/probes/cliBase.ts @@ -0,0 +1,19 @@ +import type { CapabilityDetectRequest } from '../types'; +import type { DetectCliEntry } from '../snapshots/cliSnapshot'; + +export function buildCliCapabilityData(opts: { + request: CapabilityDetectRequest; + entry: DetectCliEntry | undefined; +}): DetectCliEntry { + const includeLoginStatus = Boolean((opts.request.params ?? {}).includeLoginStatus); + const entry = opts.entry ?? { available: false }; + + const out: DetectCliEntry = { + available: entry.available, + ...(entry.resolvedPath ? { resolvedPath: entry.resolvedPath } : {}), + ...(entry.version ? { version: entry.version } : {}), + ...(includeLoginStatus ? { isLoggedIn: entry.isLoggedIn ?? null } : {}), + }; + + return out; +} diff --git a/cli/src/capabilities/registry/depCodexAcp.ts b/cli/src/capabilities/registry/depCodexAcp.ts new file mode 100644 index 000000000..7b90beb9a --- /dev/null +++ b/cli/src/capabilities/registry/depCodexAcp.ts @@ -0,0 +1,36 @@ +import type { Capability } from '../service'; +import { CapabilityError } from '../errors'; +import { getCodexAcpDepStatus, installCodexAcp } from '../deps/codexAcp'; + +export const codexAcpDepCapability: Capability = { + descriptor: { + id: 'dep.codex-acp', + kind: 'dep', + title: 'Codex ACP', + methods: { + install: { title: 'Install' }, + upgrade: { title: 'Upgrade' }, + }, + }, + detect: async ({ request }) => { + const includeRegistry = Boolean((request.params ?? {}).includeRegistry); + const onlyIfInstalled = Boolean((request.params ?? {}).onlyIfInstalled); + const distTag = typeof (request.params ?? {}).distTag === 'string' ? String((request.params ?? {}).distTag) : undefined; + return await getCodexAcpDepStatus({ includeRegistry, onlyIfInstalled, distTag }); + }, + invoke: async ({ method, params }) => { + if (method !== 'install' && method !== 'upgrade') { + throw new CapabilityError(`Unsupported method: ${method}`, 'unsupported-method'); + } + + const installSpec = method === 'install' && typeof params?.installSpec === 'string' + ? String(params.installSpec) + : undefined; + + const result = await installCodexAcp(installSpec); + if (!result.ok) { + return { ok: false, error: { message: result.errorMessage, code: 'install-failed' }, logPath: result.logPath }; + } + return { ok: true, result: { logPath: result.logPath } }; + }, +}; diff --git a/cli/src/capabilities/registry/depCodexMcpResume.ts b/cli/src/capabilities/registry/depCodexMcpResume.ts new file mode 100644 index 000000000..fa63294aa --- /dev/null +++ b/cli/src/capabilities/registry/depCodexMcpResume.ts @@ -0,0 +1,36 @@ +import type { Capability } from '../service'; +import { CapabilityError } from '../errors'; +import { getCodexMcpResumeDepStatus, installCodexMcpResume } from '../deps/codexMcpResume'; + +export const codexMcpResumeDepCapability: Capability = { + descriptor: { + id: 'dep.codex-mcp-resume', + kind: 'dep', + title: 'Codex MCP resume', + methods: { + install: { title: 'Install' }, + upgrade: { title: 'Upgrade' }, + }, + }, + detect: async ({ request }) => { + const includeRegistry = Boolean((request.params ?? {}).includeRegistry); + const onlyIfInstalled = Boolean((request.params ?? {}).onlyIfInstalled); + const distTag = typeof (request.params ?? {}).distTag === 'string' ? String((request.params ?? {}).distTag) : undefined; + return await getCodexMcpResumeDepStatus({ includeRegistry, onlyIfInstalled, distTag }); + }, + invoke: async ({ method, params }) => { + if (method !== 'install' && method !== 'upgrade') { + throw new CapabilityError(`Unsupported method: ${method}`, 'unsupported-method'); + } + + const installSpec = method === 'install' && typeof params?.installSpec === 'string' + ? String(params.installSpec) + : undefined; + + const result = await installCodexMcpResume(installSpec); + if (!result.ok) { + return { ok: false, error: { message: result.errorMessage, code: 'install-failed' }, logPath: result.logPath }; + } + return { ok: true, result: { logPath: result.logPath } }; + }, +}; diff --git a/cli/src/capabilities/registry/toolTmux.ts b/cli/src/capabilities/registry/toolTmux.ts new file mode 100644 index 000000000..63a7e326e --- /dev/null +++ b/cli/src/capabilities/registry/toolTmux.ts @@ -0,0 +1,8 @@ +import type { Capability } from '../service'; + +export const tmuxCapability: Capability = { + descriptor: { id: 'tool.tmux', kind: 'tool', title: 'tmux' }, + detect: async ({ context }) => { + return context.cliSnapshot?.tmux ?? { available: false }; + }, +}; diff --git a/cli/src/capabilities/service.ts b/cli/src/capabilities/service.ts new file mode 100644 index 000000000..ef2e57623 --- /dev/null +++ b/cli/src/capabilities/service.ts @@ -0,0 +1,126 @@ +import type { + CapabilitiesDescribeResponse, + CapabilitiesDetectRequest, + CapabilitiesDetectResponse, + CapabilitiesInvokeRequest, + CapabilitiesInvokeResponse, + CapabilityDescriptor, + CapabilityDetectRequest, + CapabilityDetectResult, + CapabilityId, + ChecklistId, +} from './types'; +import { CapabilityError } from './errors'; +import type { DetectCliSnapshot } from './snapshots/cliSnapshot'; + +export type CapabilitiesDetectContext = { + cliSnapshot: DetectCliSnapshot | null; +}; + +export type CapabilitiesDetectContextBuilder = (requests: CapabilityDetectRequest[]) => Promise; + +export type Capability = { + descriptor: CapabilityDescriptor; + detect: (args: { request: CapabilityDetectRequest; context: CapabilitiesDetectContext }) => Promise; + invoke?: (args: { method: string; params?: Record }) => Promise; +}; + +export type CapabilitiesService = { + describe: () => CapabilitiesDescribeResponse; + detect: (data: CapabilitiesDetectRequest) => Promise; + invoke: (data: CapabilitiesInvokeRequest) => Promise; +}; + +function mergeOverrides( + rawRequests: CapabilityDetectRequest[], + overrides: CapabilitiesDetectRequest['overrides'] | undefined, +): CapabilityDetectRequest[] { + const safeOverrides = overrides ?? {}; + return rawRequests.map((r) => { + const overrideParams = safeOverrides[r.id]?.params; + if (!overrideParams) return r; + return { ...r, params: { ...(r.params ?? {}), ...overrideParams } }; + }); +} + +function selectRequestsFromChecklist(opts: { + checklistId: ChecklistId | undefined; + checklists: Record; + requests: CapabilityDetectRequest[] | undefined; +}): CapabilityDetectRequest[] { + if (opts.checklistId) return opts.checklists[opts.checklistId] ?? []; + return Array.isArray(opts.requests) ? opts.requests : []; +} + +export function createCapabilitiesService(opts: { + capabilities: Capability[]; + checklists: Record; + buildContext: CapabilitiesDetectContextBuilder; +}): CapabilitiesService { + const capabilityMap = new Map(); + for (const cap of opts.capabilities) { + capabilityMap.set(cap.descriptor.id, cap); + } + + const describe = (): CapabilitiesDescribeResponse => ({ + protocolVersion: 1, + capabilities: opts.capabilities.map((c) => c.descriptor), + checklists: opts.checklists, + }); + + const detect = async (data: CapabilitiesDetectRequest): Promise => { + const selectedChecklistId = data?.checklistId; + const rawRequests = selectRequestsFromChecklist({ + checklistId: selectedChecklistId, + checklists: opts.checklists, + requests: data?.requests, + }); + + const requests = mergeOverrides(rawRequests, data?.overrides); + const checkedAt = Date.now(); + const context = await opts.buildContext(requests); + + const results: Partial> = {}; + for (const req of requests) { + const cap = capabilityMap.get(req.id); + if (!cap) { + results[req.id] = { ok: false, checkedAt, error: { message: `Unknown capability: ${req.id}`, code: 'unknown-capability' } }; + continue; + } + + try { + const dataOut = await cap.detect({ request: req, context }); + results[req.id] = { ok: true, checkedAt, data: dataOut }; + } catch (e) { + const message = e instanceof Error ? e.message : 'Detect failed'; + const code = e instanceof CapabilityError ? e.code : 'detect-failed'; + results[req.id] = { ok: false, checkedAt, error: { message, code } }; + } + } + + return { protocolVersion: 1, results }; + }; + + const invoke = async (data: CapabilitiesInvokeRequest): Promise => { + const id = data?.id as CapabilityId | undefined; + const method = typeof data?.method === 'string' ? data.method.trim() : ''; + if (!id || !method) { + return { ok: false, error: { message: 'Invalid capabilities.invoke request', code: 'invalid-request' } }; + } + + const cap = capabilityMap.get(id); + if (!cap || !cap.invoke) { + return { ok: false, error: { message: `Unsupported capability: ${String(id)}`, code: 'unsupported-capability' } }; + } + + try { + return await cap.invoke({ method, params: data?.params }); + } catch (e) { + const message = e instanceof Error ? e.message : 'Invoke failed'; + const code = e instanceof CapabilityError ? e.code : 'invoke-failed'; + return { ok: false, error: { message, code } }; + } + }; + + return { describe, detect, invoke }; +} diff --git a/cli/src/capabilities/snapshots/cliSnapshot.ts b/cli/src/capabilities/snapshots/cliSnapshot.ts new file mode 100644 index 000000000..6ec60c526 --- /dev/null +++ b/cli/src/capabilities/snapshots/cliSnapshot.ts @@ -0,0 +1,322 @@ +import { execFile } from 'child_process'; +import type { ExecOptions } from 'child_process'; +import { constants as fsConstants } from 'fs'; +import { access } from 'fs/promises'; +import { join, delimiter as PATH_DELIMITER } from 'path'; +import { promisify } from 'util'; + +import { AGENTS, type CatalogAgentId, type CliDetectSpec } from '@/backends/catalog'; + +const execFileAsync = promisify(execFile); + +export type DetectCliName = CatalogAgentId; + +export interface DetectCliRequest { + /** + * When true, also probes whether each detected CLI appears to be authenticated. + * This is best-effort and may return null when unknown/unsupported. + */ + includeLoginStatus?: boolean; +} + +export interface DetectCliEntry { + available: boolean; + resolvedPath?: string; + version?: string; + isLoggedIn?: boolean | null; + /** + * Optional ACP agent capability probe results for CLIs that can run in ACP mode. + * This is only populated when a capabilities request explicitly asks for it. + */ + acp?: { + ok: boolean; + checkedAt: number; + loadSession?: boolean | null; + error?: { message: string }; + }; +} + +export interface DetectTmuxEntry { + available: boolean; + resolvedPath?: string; + version?: string; +} + +export interface DetectCliSnapshot { + path: string | null; + clis: Record; + tmux: DetectTmuxEntry; +} + +async function resolveCommandOnPath(command: string, pathEnv: string | null): Promise { + if (!pathEnv) return null; + + const segments = pathEnv + .split(PATH_DELIMITER) + .map((p) => p.trim()) + .filter(Boolean); + + const isWindows = process.platform === 'win32'; + const extensions = isWindows + ? (process.env.PATHEXT || '.EXE;.CMD;.BAT;.COM') + .split(';') + .map((e) => e.trim()) + .filter(Boolean) + : ['']; + + for (const dir of segments) { + for (const ext of extensions) { + const candidate = join(dir, isWindows ? `${command}${ext}` : command); + try { + await access(candidate, isWindows ? fsConstants.F_OK : fsConstants.X_OK); + return candidate; + } catch { + // continue + } + } + } + + return null; +} + +function getFirstLine(value: string): string | null { + const normalized = value.replaceAll('\r\n', '\n').replaceAll('\r', '\n').trim(); + if (!normalized) return null; + const [first] = normalized.split('\n'); + const trimmed = first.trim(); + if (!trimmed) return null; + return trimmed.length > 120 ? trimmed.slice(0, 120) : trimmed; +} + +function extractSemver(value: string | null): string | null { + if (!value) return null; + const match = value.match(/\b\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?\b/); + return match?.[0] ?? null; +} + +function extractTmuxVersion(value: string | null): string | null { + if (!value) return null; + const match = value.match(/\btmux\s+([0-9]+(?:\.[0-9]+)?[a-z]?)\b/i); + return match?.[1] ?? null; +} + +function defaultVersionArgsToTry(): Array { + return [['--version'], ['version'], ['-v']]; +} + +const cliDetectCache = new Map(); + +async function resolveCliDetectSpec(name: DetectCliName): Promise { + if (cliDetectCache.has(name)) { + return cliDetectCache.get(name) ?? null; + } + + const entry = AGENTS[name]; + if (!entry?.getCliDetect) { + cliDetectCache.set(name, null); + return null; + } + + const spec = await entry.getCliDetect(); + cliDetectCache.set(name, spec); + return spec; +} + +async function resolveCliVersionArgsToTry(name: DetectCliName): Promise> { + const spec = (await resolveCliDetectSpec(name))?.versionArgsToTry; + if (!spec || spec.length === 0) return defaultVersionArgsToTry(); + return spec.map((v) => [...v]); +} + +async function resolveCliLoginStatusArgs(name: DetectCliName): Promise { + const spec = (await resolveCliDetectSpec(name))?.loginStatusArgs; + if (spec === null) return null; + if (!spec) return null; + return [...spec]; +} + +async function detectCliVersion(params: { name: DetectCliName; resolvedPath: string }): Promise { + // Best-effort, must never throw. + try { + // Keep this short (runs in parallel for multiple CLIs), but give enough headroom for slower systems. + const timeoutMs = 1200; + const isWindows = process.platform === 'win32'; + const isCmdScript = isWindows && /\.(cmd|bat)$/i.test(params.resolvedPath); + + const asString = (value: unknown): string => { + if (typeof value === 'string') return value; + if (Buffer.isBuffer(value)) return value.toString('utf8'); + return ''; + }; + + const argsToTry: Array = await resolveCliVersionArgsToTry(params.name); + + const execFileBestEffort = async (file: string, args: string[], options: ExecOptions): Promise<{ stdout: string; stderr: string }> => { + try { + const { stdout, stderr } = await execFileAsync(file, args, options); + return { stdout: asString(stdout), stderr: asString(stderr) }; + } catch (error) { + // For non-zero exit codes, execFile still provides stdout/stderr on the error object. + const maybeStdout = asString((error as any)?.stdout); + const maybeStderr = asString((error as any)?.stderr); + return { stdout: maybeStdout, stderr: maybeStderr }; + } + }; + + if (isCmdScript) { + // .cmd/.bat require cmd.exe (best-effort, only --version is supported here) + const primary = argsToTry.find((args) => args.includes('--version')) ?? ['--version']; + const { stdout, stderr } = await execFileBestEffort('cmd.exe', [ + '/d', + '/s', + '/c', + `"${params.resolvedPath}" ${primary.join(' ')}`, + ], { timeout: timeoutMs, windowsHide: true }); + return extractSemver(getFirstLine(`${stdout}\n${stderr}`)); + } + + for (const args of argsToTry) { + const { stdout, stderr } = await execFileBestEffort(params.resolvedPath, args, { + timeout: timeoutMs, + windowsHide: true, + }); + const combined = `${stdout}\n${stderr}`; + const firstLine = getFirstLine(combined); + const semver = extractSemver(firstLine) ?? extractSemver(combined); + if (semver) return semver; + } + + return null; + } catch { + return null; + } +} + +async function detectTmuxVersion(params: { resolvedPath: string }): Promise { + // Best-effort, must never throw. + try { + const timeoutMs = 1500; + const isWindows = process.platform === 'win32'; + const isCmdScript = isWindows && /\.(cmd|bat)$/i.test(params.resolvedPath); + + const asString = (value: unknown): string => { + if (typeof value === 'string') return value; + if (Buffer.isBuffer(value)) return value.toString('utf8'); + return ''; + }; + + const execFileBestEffort = async (file: string, args: string[], options: ExecOptions): Promise<{ stdout: string; stderr: string }> => { + try { + const { stdout, stderr } = await execFileAsync(file, args, options); + return { stdout: asString(stdout), stderr: asString(stderr) }; + } catch (error) { + const maybeStdout = asString((error as any)?.stdout); + const maybeStderr = asString((error as any)?.stderr); + return { stdout: maybeStdout, stderr: maybeStderr }; + } + }; + + if (isCmdScript) { + const { stdout, stderr } = await execFileBestEffort( + 'cmd.exe', + ['/d', '/s', '/c', `"${params.resolvedPath}" -V`], + { timeout: timeoutMs, windowsHide: true }, + ); + return extractTmuxVersion(getFirstLine(`${stdout}\n${stderr}`)); + } + + const { stdout, stderr } = await execFileBestEffort(params.resolvedPath, ['-V'], { + timeout: timeoutMs, + windowsHide: true, + }); + return extractTmuxVersion(getFirstLine(`${stdout}\n${stderr}`)); + } catch { + return null; + } +} + +async function detectCliLoginStatus(params: { name: DetectCliName; resolvedPath: string }): Promise { + // Best-effort, must never throw. + try { + const timeoutMs = 800; + const loginArgs = await resolveCliLoginStatusArgs(params.name); + if (!loginArgs) return null; + + const isWindows = process.platform === 'win32'; + const isCmdScript = isWindows && /\.(cmd|bat)$/i.test(params.resolvedPath); + + const runStatus = async (file: string, args: string[]): Promise => { + try { + await execFileAsync(file, args, { timeout: timeoutMs, windowsHide: true }); + return true; + } catch (error) { + // execFileAsync throws on non-zero exit; check exit code via various properties. + const code = (error as any)?.status ?? (error as any)?.exitCode ?? (error as any)?.code; + // Non-zero exit codes are still a deterministic "not logged in" for our probes. + if (typeof code === 'number') { + return false; + } + return null; + } + }; + + if (isCmdScript) { + return await runStatus('cmd.exe', ['/d', '/s', '/c', `"${params.resolvedPath}" ${loginArgs.join(' ')}`]); + } + return await runStatus(params.resolvedPath, loginArgs); + } catch { + return null; + } +} + +/** + * CLI status snapshot - checks whether CLIs are resolvable on daemon PATH. + * + * This is more reliable than the `bash` RPC for "is CLI installed?" checks because it: + * - does not rely on a login shell (no ~/.zshrc, ~/.profile, etc) + * - matches how the daemon itself will resolve binaries when spawning + */ +export async function detectCliSnapshotOnDaemonPath(data: DetectCliRequest): Promise { + const pathEnv = typeof process.env.PATH === 'string' ? process.env.PATH : null; + const includeLoginStatus = Boolean(data?.includeLoginStatus); + const names = Object.keys(AGENTS) as DetectCliName[]; + + const pairs = await Promise.all( + names.map(async (name) => { + const resolvedPath = await resolveCommandOnPath(name, pathEnv); + if (!resolvedPath) { + const entry: DetectCliEntry = { available: false }; + return [name, entry] as const; + } + + const version = await detectCliVersion({ name, resolvedPath }); + const isLoggedIn = includeLoginStatus ? await detectCliLoginStatus({ name, resolvedPath }) : null; + const entry: DetectCliEntry = { + available: true, + resolvedPath, + ...(typeof version === 'string' ? { version } : {}), + ...(includeLoginStatus ? { isLoggedIn } : {}), + }; + return [name, entry] as const; + }), + ); + + const tmuxResolvedPath = await resolveCommandOnPath('tmux', pathEnv); + const tmux: DetectTmuxEntry = (() => { + if (!tmuxResolvedPath) return { available: false }; + return { available: true, resolvedPath: tmuxResolvedPath }; + })(); + + if (tmux.available && tmuxResolvedPath) { + const version = await detectTmuxVersion({ resolvedPath: tmuxResolvedPath }); + if (typeof version === 'string') { + tmux.version = version; + } + } + + return { + path: pathEnv, + clis: Object.fromEntries(pairs) as Record, + tmux, + }; +} diff --git a/cli/src/capabilities/types.ts b/cli/src/capabilities/types.ts new file mode 100644 index 000000000..f3f52affc --- /dev/null +++ b/cli/src/capabilities/types.ts @@ -0,0 +1,58 @@ +import type { CatalogAgentId } from '@/backends/catalog'; +import type { ChecklistId } from './checklistIds'; + +export type { ChecklistId } from './checklistIds'; + +export type CliCapabilityId = `cli.${CatalogAgentId}`; +export type ToolCapabilityId = `tool.${string}`; +export type DepCapabilityId = `dep.${string}`; + +export type CapabilityId = + | CliCapabilityId + | ToolCapabilityId + | DepCapabilityId; + +export type CapabilityKind = 'cli' | 'tool' | 'dep'; + +export type CapabilityDetectRequest = { + id: CapabilityId; + params?: Record; +}; + +export type CapabilityDescriptor = { + id: CapabilityId; + kind: CapabilityKind; + title?: string; + methods?: Record; +}; + +export type CapabilitiesDescribeResponse = { + protocolVersion: 1; + capabilities: CapabilityDescriptor[]; + checklists: Record; +}; + +export type CapabilityDetectResult = + | { ok: true; checkedAt: number; data: unknown } + | { ok: false; checkedAt: number; error: { message: string; code?: string } }; + +export type CapabilitiesDetectRequest = { + checklistId?: ChecklistId; + requests?: CapabilityDetectRequest[]; + overrides?: Partial }>>; +}; + +export type CapabilitiesDetectResponse = { + protocolVersion: 1; + results: Partial>; +}; + +export type CapabilitiesInvokeRequest = { + id: CapabilityId; + method: string; + params?: Record; +}; + +export type CapabilitiesInvokeResponse = + | { ok: true; result: unknown } + | { ok: false; error: { message: string; code?: string }; logPath?: string }; diff --git a/cli/src/capabilities/utils/acpProbeTimeout.ts b/cli/src/capabilities/utils/acpProbeTimeout.ts new file mode 100644 index 000000000..647d1a3a1 --- /dev/null +++ b/cli/src/capabilities/utils/acpProbeTimeout.ts @@ -0,0 +1,20 @@ +const DEFAULT_ACP_PROBE_TIMEOUT_MS = 8_000; + +import type { CatalogAgentId } from '@/backends/types'; + +function parseTimeoutMs(raw: string | undefined): number | null { + if (!raw) return null; + const n = Number.parseInt(raw, 10); + if (!Number.isFinite(n) || n <= 0) return null; + return n; +} + +export function resolveAcpProbeTimeoutMs(agentName: CatalogAgentId): number { + const perAgent = parseTimeoutMs(process.env[`HAPPY_ACP_PROBE_TIMEOUT_${agentName.toUpperCase()}_MS`]); + if (typeof perAgent === 'number') return perAgent; + + const global = parseTimeoutMs(process.env.HAPPY_ACP_PROBE_TIMEOUT_MS); + if (typeof global === 'number') return global; + + return DEFAULT_ACP_PROBE_TIMEOUT_MS; +} diff --git a/cli/src/capabilities/utils/normalizeCapabilityProbeError.test.ts b/cli/src/capabilities/utils/normalizeCapabilityProbeError.test.ts new file mode 100644 index 000000000..62be43159 --- /dev/null +++ b/cli/src/capabilities/utils/normalizeCapabilityProbeError.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; +import { normalizeCapabilityProbeError } from './normalizeCapabilityProbeError'; + +describe('normalizeCapabilityProbeError', () => { + it('normalizes Error-like objects', () => { + expect(normalizeCapabilityProbeError(new Error('boom'))).toEqual({ message: 'boom' }); + expect(normalizeCapabilityProbeError({ message: 'nope' })).toEqual({ message: 'nope' }); + }); + + it('normalizes strings', () => { + expect(normalizeCapabilityProbeError('fail')).toEqual({ message: 'fail' }); + }); + + it('stringifies unknown values', () => { + expect(normalizeCapabilityProbeError(null)).toEqual({ message: 'null' }); + }); +}); diff --git a/cli/src/capabilities/utils/normalizeCapabilityProbeError.ts b/cli/src/capabilities/utils/normalizeCapabilityProbeError.ts new file mode 100644 index 000000000..9176a2a41 --- /dev/null +++ b/cli/src/capabilities/utils/normalizeCapabilityProbeError.ts @@ -0,0 +1,12 @@ +export function normalizeCapabilityProbeError(error: unknown): { message: string } { + if (error && typeof error === 'object') { + const maybeMessage = (error as { message?: unknown }).message; + if (typeof maybeMessage === 'string' && maybeMessage.length > 0) { + return { message: maybeMessage }; + } + } + if (typeof error === 'string' && error.length > 0) { + return { message: error }; + } + return { message: String(error) }; +} diff --git a/cli/src/claude/claudeLocalLauncher.ts b/cli/src/claude/claudeLocalLauncher.ts deleted file mode 100644 index 0a7772ed8..000000000 --- a/cli/src/claude/claudeLocalLauncher.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { logger } from "@/ui/logger"; -import { claudeLocal } from "./claudeLocal"; -import { Session } from "./session"; -import { Future } from "@/utils/future"; -import { createSessionScanner } from "./utils/sessionScanner"; - -export async function claudeLocalLauncher(session: Session): Promise<'switch' | 'exit'> { - - // Create scanner - const scanner = await createSessionScanner({ - sessionId: session.sessionId, - workingDirectory: session.path, - onMessage: (message) => { - // Block SDK summary messages - we generate our own - if (message.type !== 'summary') { - session.client.sendClaudeSessionMessage(message) - } - } - }); - - // Register callback to notify scanner when session ID is found via hook - // This is important for --continue/--resume where session ID is not known upfront - const scannerSessionCallback = (sessionId: string) => { - scanner.onNewSession(sessionId); - }; - session.addSessionFoundCallback(scannerSessionCallback); - - - // Handle abort - let exitReason: 'switch' | 'exit' | null = null; - const processAbortController = new AbortController(); - let exutFuture = new Future(); - try { - async function abort() { - - // Send abort signal - if (!processAbortController.signal.aborted) { - processAbortController.abort(); - } - - // Await full exit - await exutFuture.promise; - } - - async function doAbort() { - logger.debug('[local]: doAbort'); - - // Switching to remote mode - if (!exitReason) { - exitReason = 'switch'; - } - - // Reset sent messages - session.queue.reset(); - - // Abort - await abort(); - } - - async function doSwitch() { - logger.debug('[local]: doSwitch'); - - // Switching to remote mode - if (!exitReason) { - exitReason = 'switch'; - } - - // Abort - await abort(); - } - - // When to abort - session.client.rpcHandlerManager.registerHandler('abort', doAbort); // Abort current process, clean queue and switch to remote mode - session.client.rpcHandlerManager.registerHandler('switch', doSwitch); // When user wants to switch to remote mode - session.queue.setOnMessage((message: string, mode) => { - // Switch to remote mode when message received - doSwitch(); - }); // When any message is received, abort current process, clean queue and switch to remote mode - - // Exit if there are messages in the queue - if (session.queue.size() > 0) { - return 'switch'; - } - - // Handle session start - const handleSessionStart = (sessionId: string) => { - session.onSessionFound(sessionId); - scanner.onNewSession(sessionId); - } - - // Run local mode - while (true) { - // If we already have an exit reason, return it - if (exitReason) { - return exitReason; - } - - // Launch - logger.debug('[local]: launch'); - try { - await claudeLocal({ - path: session.path, - sessionId: session.sessionId, - onSessionFound: handleSessionStart, - onThinkingChange: session.onThinkingChange, - abort: processAbortController.signal, - claudeEnvVars: session.claudeEnvVars, - claudeArgs: session.claudeArgs, - mcpServers: session.mcpServers, - allowedTools: session.allowedTools, - hookSettingsPath: session.hookSettingsPath, - }); - - // Consume one-time Claude flags after spawn - // For example we don't want to pass --resume flag after first spawn - session.consumeOneTimeFlags(); - - // Normal exit - if (!exitReason) { - exitReason = 'exit'; - break; - } - } catch (e) { - logger.debug('[local]: launch error', e); - if (!exitReason) { - session.client.sendSessionEvent({ type: 'message', message: 'Process exited unexpectedly' }); - continue; - } else { - break; - } - } - logger.debug('[local]: launch done'); - } - } finally { - - // Resolve future - exutFuture.resolve(undefined); - - // Set handlers to no-op - session.client.rpcHandlerManager.registerHandler('abort', async () => { }); - session.client.rpcHandlerManager.registerHandler('switch', async () => { }); - session.queue.setOnMessage(null); - - // Remove session found callback - session.removeSessionFoundCallback(scannerSessionCallback); - - // Cleanup - await scanner.cleanup(); - } - - // Return - return exitReason || 'exit'; -} \ No newline at end of file diff --git a/cli/src/claude/sdk/prompts.ts b/cli/src/claude/sdk/prompts.ts deleted file mode 100644 index a7e47cec9..000000000 --- a/cli/src/claude/sdk/prompts.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const PLAN_FAKE_REJECT = `User approved plan, but you need to be restarted. STOP IMMEDIATELY TO SWITCH FROM PLAN MODE. DO NOT REPLY TO THIS MESSAGE.` -export const PLAN_FAKE_RESTART = `PlEaZe Continue with plan.` \ No newline at end of file diff --git a/cli/src/claude/session.ts b/cli/src/claude/session.ts deleted file mode 100644 index 0dc45b9f8..000000000 --- a/cli/src/claude/session.ts +++ /dev/null @@ -1,185 +0,0 @@ -import { ApiClient, ApiSessionClient } from "@/lib"; -import { MessageQueue2 } from "@/utils/MessageQueue2"; -import { EnhancedMode } from "./loop"; -import { logger } from "@/ui/logger"; -import type { JsRuntime } from "./runClaude"; - -export class Session { - readonly path: string; - readonly logPath: string; - readonly api: ApiClient; - readonly client: ApiSessionClient; - readonly queue: MessageQueue2; - readonly claudeEnvVars?: Record; - claudeArgs?: string[]; // Made mutable to allow filtering - readonly mcpServers: Record; - readonly allowedTools?: string[]; - readonly _onModeChange: (mode: 'local' | 'remote') => void; - /** Path to temporary settings file with SessionStart hook (required for session tracking) */ - readonly hookSettingsPath: string; - /** JavaScript runtime to use for spawning Claude Code (default: 'node') */ - readonly jsRuntime: JsRuntime; - - sessionId: string | null; - mode: 'local' | 'remote' = 'local'; - thinking: boolean = false; - - /** Callbacks to be notified when session ID is found/changed */ - private sessionFoundCallbacks: ((sessionId: string) => void)[] = []; - - /** Keep alive interval reference for cleanup */ - private keepAliveInterval: NodeJS.Timeout; - - constructor(opts: { - api: ApiClient, - client: ApiSessionClient, - path: string, - logPath: string, - sessionId: string | null, - claudeEnvVars?: Record, - claudeArgs?: string[], - mcpServers: Record, - messageQueue: MessageQueue2, - onModeChange: (mode: 'local' | 'remote') => void, - allowedTools?: string[], - /** Path to temporary settings file with SessionStart hook (required for session tracking) */ - hookSettingsPath: string, - /** JavaScript runtime to use for spawning Claude Code (default: 'node') */ - jsRuntime?: JsRuntime, - }) { - this.path = opts.path; - this.api = opts.api; - this.client = opts.client; - this.logPath = opts.logPath; - this.sessionId = opts.sessionId; - this.queue = opts.messageQueue; - this.claudeEnvVars = opts.claudeEnvVars; - this.claudeArgs = opts.claudeArgs; - this.mcpServers = opts.mcpServers; - this.allowedTools = opts.allowedTools; - this._onModeChange = opts.onModeChange; - this.hookSettingsPath = opts.hookSettingsPath; - this.jsRuntime = opts.jsRuntime ?? 'node'; - - // Start keep alive - this.client.keepAlive(this.thinking, this.mode); - this.keepAliveInterval = setInterval(() => { - this.client.keepAlive(this.thinking, this.mode); - }, 2000); - } - - /** - * Cleanup resources (call when session is no longer needed) - */ - cleanup = (): void => { - clearInterval(this.keepAliveInterval); - this.sessionFoundCallbacks = []; - logger.debug('[Session] Cleaned up resources'); - } - - onThinkingChange = (thinking: boolean) => { - this.thinking = thinking; - this.client.keepAlive(thinking, this.mode); - } - - onModeChange = (mode: 'local' | 'remote') => { - this.mode = mode; - this.client.keepAlive(this.thinking, mode); - this._onModeChange(mode); - } - - /** - * Called when Claude session ID is discovered or changed. - * - * This is triggered by the SessionStart hook when: - * - Claude starts a new session (fresh start) - * - Claude resumes a session (--continue, --resume flags) - * - Claude forks a session (/compact, double-escape fork) - * - * Updates internal state, syncs to API metadata, and notifies - * all registered callbacks (e.g., SessionScanner) about the change. - */ - onSessionFound = (sessionId: string) => { - this.sessionId = sessionId; - - // Update metadata with Claude Code session ID - this.client.updateMetadata((metadata) => ({ - ...metadata, - claudeSessionId: sessionId - })); - logger.debug(`[Session] Claude Code session ID ${sessionId} added to metadata`); - - // Notify all registered callbacks - for (const callback of this.sessionFoundCallbacks) { - callback(sessionId); - } - } - - /** - * Register a callback to be notified when session ID is found/changed - */ - addSessionFoundCallback = (callback: (sessionId: string) => void): void => { - this.sessionFoundCallbacks.push(callback); - } - - /** - * Remove a session found callback - */ - removeSessionFoundCallback = (callback: (sessionId: string) => void): void => { - const index = this.sessionFoundCallbacks.indexOf(callback); - if (index !== -1) { - this.sessionFoundCallbacks.splice(index, 1); - } - } - - /** - * Clear the current session ID (used by /clear command) - */ - clearSessionId = (): void => { - this.sessionId = null; - logger.debug('[Session] Session ID cleared'); - } - - /** - * Consume one-time Claude flags from claudeArgs after Claude spawn - * Handles: --resume (with or without session ID), --continue - */ - consumeOneTimeFlags = (): void => { - if (!this.claudeArgs) return; - - const filteredArgs: string[] = []; - for (let i = 0; i < this.claudeArgs.length; i++) { - const arg = this.claudeArgs[i]; - - if (arg === '--continue') { - logger.debug('[Session] Consumed --continue flag'); - continue; - } - - if (arg === '--resume') { - // Check if next arg looks like a UUID (contains dashes and alphanumeric) - if (i + 1 < this.claudeArgs.length) { - const nextArg = this.claudeArgs[i + 1]; - // Simple UUID pattern check - contains dashes and is not another flag - if (!nextArg.startsWith('-') && nextArg.includes('-')) { - // Skip both --resume and the UUID - i++; // Skip the UUID - logger.debug(`[Session] Consumed --resume flag with session ID: ${nextArg}`); - } else { - // Just --resume without UUID - logger.debug('[Session] Consumed --resume flag (no session ID)'); - } - } else { - // --resume at the end of args - logger.debug('[Session] Consumed --resume flag (no session ID)'); - } - continue; - } - - filteredArgs.push(arg); - } - - this.claudeArgs = filteredArgs.length > 0 ? filteredArgs : undefined; - logger.debug(`[Session] Consumed one-time flags, remaining args:`, this.claudeArgs); - } -} \ No newline at end of file diff --git a/cli/src/claude/utils/path.ts b/cli/src/claude/utils/path.ts deleted file mode 100644 index 1b7b8f764..000000000 --- a/cli/src/claude/utils/path.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { homedir } from "node:os"; -import { join, resolve } from "node:path"; - -export function getProjectPath(workingDirectory: string) { - const projectId = resolve(workingDirectory).replace(/[\\\/\.: _]/g, '-'); - const claudeConfigDir = process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude'); - return join(claudeConfigDir, 'projects', projectId); -} \ No newline at end of file diff --git a/cli/src/claude/utils/sessionScanner.ts b/cli/src/claude/utils/sessionScanner.ts deleted file mode 100644 index cae2634c4..000000000 --- a/cli/src/claude/utils/sessionScanner.ts +++ /dev/null @@ -1,208 +0,0 @@ -import { InvalidateSync } from "@/utils/sync"; -import { RawJSONLines, RawJSONLinesSchema } from "../types"; -import { join } from "node:path"; -import { readFile } from "node:fs/promises"; -import { logger } from "@/ui/logger"; -import { startFileWatcher } from "@/modules/watcher/startFileWatcher"; -import { getProjectPath } from "./path"; - -/** - * Known internal Claude Code event types that should be silently skipped. - * These are written to session JSONL files by Claude Code but are not - * actual conversation messages - they're internal state/tracking events. - */ -const INTERNAL_CLAUDE_EVENT_TYPES = new Set([ - 'file-history-snapshot', - 'change', - 'queue-operation', -]); - -export async function createSessionScanner(opts: { - sessionId: string | null, - workingDirectory: string - onMessage: (message: RawJSONLines) => void -}) { - - // Resolve project directory - const projectDir = getProjectPath(opts.workingDirectory); - - // Finished, pending finishing and current session - let finishedSessions = new Set(); - let pendingSessions = new Set(); - let currentSessionId: string | null = null; - let watchers = new Map void)>(); - let processedMessageKeys = new Set(); - - // Mark existing messages as processed and start watching the initial session - if (opts.sessionId) { - let messages = await readSessionLog(projectDir, opts.sessionId); - logger.debug(`[SESSION_SCANNER] Marking ${messages.length} existing messages as processed from session ${opts.sessionId}`); - for (let m of messages) { - processedMessageKeys.add(messageKey(m)); - } - // IMPORTANT: Also start watching the initial session file because Claude Code - // may continue writing to it even after creating a new session with --resume - // (agent tasks and other updates can still write to the original session file) - currentSessionId = opts.sessionId; - } - - // Main sync function - const sync = new InvalidateSync(async () => { - // logger.debug(`[SESSION_SCANNER] Syncing...`); - - // Collect session ids - include ALL sessions that have watchers - // This ensures we continue processing sessions that Claude Code may still write to - let sessions: string[] = []; - for (let p of pendingSessions) { - sessions.push(p); - } - if (currentSessionId && !pendingSessions.has(currentSessionId)) { - sessions.push(currentSessionId); - } - // Also process sessions that have active watchers (they may still receive updates) - for (let [sessionId] of watchers) { - if (!sessions.includes(sessionId)) { - sessions.push(sessionId); - } - } - - // Process sessions - for (let session of sessions) { - const sessionMessages = await readSessionLog(projectDir, session); - let skipped = 0; - let sent = 0; - for (let file of sessionMessages) { - let key = messageKey(file); - if (processedMessageKeys.has(key)) { - skipped++; - continue; - } - processedMessageKeys.add(key); - logger.debug(`[SESSION_SCANNER] Sending new message: type=${file.type}, uuid=${file.type === 'summary' ? file.leafUuid : file.uuid}`); - opts.onMessage(file); - sent++; - } - if (sessionMessages.length > 0) { - logger.debug(`[SESSION_SCANNER] Session ${session}: found=${sessionMessages.length}, skipped=${skipped}, sent=${sent}`); - } - } - - // Move pending sessions to finished sessions (but keep processing them via watchers) - for (let p of sessions) { - if (pendingSessions.has(p)) { - pendingSessions.delete(p); - finishedSessions.add(p); - } - } - - // Update watchers for all sessions - for (let p of sessions) { - if (!watchers.has(p)) { - logger.debug(`[SESSION_SCANNER] Starting watcher for session: ${p}`); - watchers.set(p, startFileWatcher(join(projectDir, `${p}.jsonl`), () => { sync.invalidate(); })); - } - } - }); - await sync.invalidateAndAwait(); - - // Periodic sync - const intervalId = setInterval(() => { sync.invalidate(); }, 3000); - - // Public interface - return { - cleanup: async () => { - clearInterval(intervalId); - for (let w of watchers.values()) { - w(); - } - watchers.clear(); - await sync.invalidateAndAwait(); - sync.stop(); - }, - onNewSession: (sessionId: string) => { - if (currentSessionId === sessionId) { - logger.debug(`[SESSION_SCANNER] New session: ${sessionId} is the same as the current session, skipping`); - return; - } - if (finishedSessions.has(sessionId)) { - logger.debug(`[SESSION_SCANNER] New session: ${sessionId} is already finished, skipping`); - return; - } - if (pendingSessions.has(sessionId)) { - logger.debug(`[SESSION_SCANNER] New session: ${sessionId} is already pending, skipping`); - return; - } - if (currentSessionId) { - pendingSessions.add(currentSessionId); - } - logger.debug(`[SESSION_SCANNER] New session: ${sessionId}`) - currentSessionId = sessionId; - sync.invalidate(); - }, - } -} - -export type SessionScanner = ReturnType; - - -// -// Helpers -// - -function messageKey(message: RawJSONLines): string { - if (message.type === 'user') { - return message.uuid; - } else if (message.type === 'assistant') { - return message.uuid; - } else if (message.type === 'summary') { - return 'summary: ' + message.leafUuid + ': ' + message.summary; - } else if (message.type === 'system') { - return message.uuid; - } else { - throw Error() // Impossible - } -} - -/** - * Read and parse session log file - * Returns only valid conversation messages, silently skipping internal events - */ -async function readSessionLog(projectDir: string, sessionId: string): Promise { - const expectedSessionFile = join(projectDir, `${sessionId}.jsonl`); - logger.debug(`[SESSION_SCANNER] Reading session file: ${expectedSessionFile}`); - let file: string; - try { - file = await readFile(expectedSessionFile, 'utf-8'); - } catch (error) { - logger.debug(`[SESSION_SCANNER] Session file not found: ${expectedSessionFile}`); - return []; - } - let lines = file.split('\n'); - let messages: RawJSONLines[] = []; - for (let l of lines) { - try { - if (l.trim() === '') { - continue; - } - let message = JSON.parse(l); - - // Silently skip known internal Claude Code events - // These are state/tracking events, not conversation messages - if (message.type && INTERNAL_CLAUDE_EVENT_TYPES.has(message.type)) { - continue; - } - - let parsed = RawJSONLinesSchema.safeParse(message); - if (!parsed.success) { - // Unknown message types are silently skipped - // They will be tracked by processedMessageKeys to avoid reprocessing - continue; - } - messages.push(parsed.data); - } catch (e) { - logger.debug(`[SESSION_SCANNER] Error processing message: ${e}`); - continue; - } - } - return messages; -} diff --git a/cli/src/cli/commandRegistry.ts b/cli/src/cli/commandRegistry.ts new file mode 100644 index 000000000..9c82c36d9 --- /dev/null +++ b/cli/src/cli/commandRegistry.ts @@ -0,0 +1,44 @@ +import type { TerminalRuntimeFlags } from '@/terminal/terminalRuntimeFlags'; + +import { AGENTS, type AgentCatalogEntry } from '@/backends/catalog'; + +import { handleAttachCliCommand } from './commands/attach'; +import { handleAuthCliCommand } from './commands/auth'; +import { handleConnectCliCommand } from './commands/connect'; +import { handleDaemonCliCommand } from './commands/daemon'; +import { handleDoctorCliCommand } from './commands/doctor'; +import { handleLogoutCliCommand } from './commands/logout'; +import { handleNotifyCliCommand } from './commands/notify'; + +export type CommandContext = Readonly<{ + args: string[]; + rawArgv: string[]; + terminalRuntime: TerminalRuntimeFlags | null; +}>; + +export type CommandHandler = (context: CommandContext) => Promise; + +function buildAgentCommandRegistry(): Readonly> { + const registry: Record = {}; + + for (const entry of Object.values(AGENTS) as AgentCatalogEntry[]) { + if (!entry.getCliCommandHandler) continue; + registry[entry.cliSubcommand] = async (context) => { + const handler = await entry.getCliCommandHandler!(); + await handler(context); + }; + } + + return registry; +} + +export const commandRegistry: Readonly> = { + attach: handleAttachCliCommand, + auth: handleAuthCliCommand, + connect: handleConnectCliCommand, + daemon: handleDaemonCliCommand, + doctor: handleDoctorCliCommand, + logout: handleLogoutCliCommand, + notify: handleNotifyCliCommand, + ...buildAgentCommandRegistry(), +}; diff --git a/cli/src/cli/commands/attach.ts b/cli/src/cli/commands/attach.ts new file mode 100644 index 000000000..316d6dd59 --- /dev/null +++ b/cli/src/cli/commands/attach.ts @@ -0,0 +1,18 @@ +import chalk from 'chalk'; + +import { handleAttachCommand } from '@/commands/attach'; + +import type { CommandContext } from '@/cli/commandRegistry'; + +export async function handleAttachCliCommand(context: CommandContext): Promise { + try { + await handleAttachCommand(context.args.slice(1)); + } catch (error) { + console.error(chalk.red('Error:'), error instanceof Error ? error.message : 'Unknown error'); + if (process.env.DEBUG) { + console.error(error); + } + process.exit(1); + } +} + diff --git a/cli/src/cli/commands/auth.ts b/cli/src/cli/commands/auth.ts new file mode 100644 index 000000000..32d344b73 --- /dev/null +++ b/cli/src/cli/commands/auth.ts @@ -0,0 +1,18 @@ +import chalk from 'chalk'; + +import { handleAuthCommand } from '@/commands/auth'; + +import type { CommandContext } from '@/cli/commandRegistry'; + +export async function handleAuthCliCommand(context: CommandContext): Promise { + try { + await handleAuthCommand(context.args.slice(1)); + } catch (error) { + console.error(chalk.red('Error:'), error instanceof Error ? error.message : 'Unknown error'); + if (process.env.DEBUG) { + console.error(error); + } + process.exit(1); + } +} + diff --git a/cli/src/cli/commands/connect.ts b/cli/src/cli/commands/connect.ts new file mode 100644 index 000000000..862edb558 --- /dev/null +++ b/cli/src/cli/commands/connect.ts @@ -0,0 +1,18 @@ +import chalk from 'chalk'; + +import { handleConnectCommand } from '@/commands/connect'; + +import type { CommandContext } from '@/cli/commandRegistry'; + +export async function handleConnectCliCommand(context: CommandContext): Promise { + try { + await handleConnectCommand(context.args.slice(1)); + } catch (error) { + console.error(chalk.red('Error:'), error instanceof Error ? error.message : 'Unknown error'); + if (process.env.DEBUG) { + console.error(error); + } + process.exit(1); + } +} + diff --git a/cli/src/cli/commands/daemon.ts b/cli/src/cli/commands/daemon.ts new file mode 100644 index 000000000..2400e626e --- /dev/null +++ b/cli/src/cli/commands/daemon.ts @@ -0,0 +1,139 @@ +import chalk from 'chalk'; + +import { checkIfDaemonRunningAndCleanupStaleState, listDaemonSessions, stopDaemon, stopDaemonSession } from '@/daemon/controlClient'; +import { install } from '@/daemon/install'; +import { startDaemon } from '@/daemon/run'; +import { uninstall } from '@/daemon/uninstall'; +import { getLatestDaemonLog } from '@/ui/logger'; +import { runDoctorCommand } from '@/ui/doctor'; +import { spawnHappyCLI } from '@/utils/spawnHappyCLI'; + +import type { CommandContext } from '@/cli/commandRegistry'; + +export async function handleDaemonCliCommand(context: CommandContext): Promise { + const args = context.args; + const daemonSubcommand = args[1]; + + if (daemonSubcommand === 'list') { + try { + const sessions = await listDaemonSessions(); + + if (sessions.length === 0) { + console.log( + 'No active sessions this daemon is aware of (they might have been started by a previous version of the daemon)', + ); + } else { + console.log('Active sessions:'); + console.log(JSON.stringify(sessions, null, 2)); + } + } catch { + console.log('No daemon running'); + } + return; + } + + if (daemonSubcommand === 'stop-session') { + const sessionId = args[2]; + if (!sessionId) { + console.error('Session ID required'); + process.exit(1); + } + + try { + const success = await stopDaemonSession(sessionId); + console.log(success ? 'Session stopped' : 'Failed to stop session'); + } catch { + console.log('No daemon running'); + } + return; + } + + if (daemonSubcommand === 'start') { + const child = spawnHappyCLI(['daemon', 'start-sync'], { + detached: true, + stdio: 'ignore', + env: process.env, + }); + child.unref(); + + let started = false; + for (let i = 0; i < 50; i++) { + if (await checkIfDaemonRunningAndCleanupStaleState()) { + started = true; + break; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + + if (started) { + console.log('Daemon started successfully'); + } else { + console.error('Failed to start daemon'); + process.exit(1); + } + process.exit(0); + } + + if (daemonSubcommand === 'start-sync') { + await startDaemon(); + process.exit(0); + } + + if (daemonSubcommand === 'stop') { + await stopDaemon(); + process.exit(0); + } + + if (daemonSubcommand === 'status') { + await runDoctorCommand('daemon'); + process.exit(0); + } + + if (daemonSubcommand === 'logs') { + const latest = await getLatestDaemonLog(); + if (!latest) { + console.log('No daemon logs found'); + } else { + console.log(latest.path); + } + process.exit(0); + } + + if (daemonSubcommand === 'install') { + try { + await install(); + } catch (error) { + console.error(chalk.red('Error:'), error instanceof Error ? error.message : 'Unknown error'); + process.exit(1); + } + return; + } + + if (daemonSubcommand === 'uninstall') { + try { + await uninstall(); + } catch (error) { + console.error(chalk.red('Error:'), error instanceof Error ? error.message : 'Unknown error'); + process.exit(1); + } + return; + } + + console.log(` +${chalk.bold('happy daemon')} - Daemon management + +${chalk.bold('Usage:')} + happy daemon start Start the daemon (detached) + happy daemon stop Stop the daemon (sessions stay alive) + happy daemon status Show daemon status + happy daemon list List active sessions + + If you want to kill all happy related processes run + ${chalk.cyan('happy doctor clean')} + +${chalk.bold('Note:')} The daemon runs in the background and manages Claude sessions. + +${chalk.bold('To clean up runaway processes:')} Use ${chalk.cyan('happy doctor clean')} +`); +} + diff --git a/cli/src/cli/commands/doctor.ts b/cli/src/cli/commands/doctor.ts new file mode 100644 index 000000000..8bc9ef085 --- /dev/null +++ b/cli/src/cli/commands/doctor.ts @@ -0,0 +1,20 @@ +import { killRunawayHappyProcesses } from '@/daemon/doctor'; +import { runDoctorCommand } from '@/ui/doctor'; + +import type { CommandContext } from '@/cli/commandRegistry'; + +export async function handleDoctorCliCommand(context: CommandContext): Promise { + const args = context.args; + + if (args[1] === 'clean') { + const result = await killRunawayHappyProcesses(); + console.log(`Cleaned up ${result.killed} runaway processes`); + if (result.errors.length > 0) { + console.log('Errors:', result.errors); + } + process.exit(0); + } + + await runDoctorCommand(); +} + diff --git a/cli/src/cli/commands/logout.ts b/cli/src/cli/commands/logout.ts new file mode 100644 index 000000000..199e55c76 --- /dev/null +++ b/cli/src/cli/commands/logout.ts @@ -0,0 +1,19 @@ +import chalk from 'chalk'; + +import { handleAuthCommand } from '@/commands/auth'; + +import type { CommandContext } from '@/cli/commandRegistry'; + +export async function handleLogoutCliCommand(_context: CommandContext): Promise { + console.log(chalk.yellow('Note: "happy logout" is deprecated. Use "happy auth logout" instead.\n')); + try { + await handleAuthCommand(['logout']); + } catch (error) { + console.error(chalk.red('Error:'), error instanceof Error ? error.message : 'Unknown error'); + if (process.env.DEBUG) { + console.error(error); + } + process.exit(1); + } +} + diff --git a/cli/src/cli/commands/notify.ts b/cli/src/cli/commands/notify.ts new file mode 100644 index 000000000..c405fa6f2 --- /dev/null +++ b/cli/src/cli/commands/notify.ts @@ -0,0 +1,97 @@ +import chalk from 'chalk'; + +import { ApiClient } from '@/api/api'; +import { readCredentials } from '@/persistence'; + +import type { CommandContext } from '@/cli/commandRegistry'; + +export async function handleNotifyCliCommand(context: CommandContext): Promise { + try { + await handleNotifyCommand(context.args.slice(1)); + } catch (error) { + console.error(chalk.red('Error:'), error instanceof Error ? error.message : 'Unknown error'); + if (process.env.DEBUG) { + console.error(error); + } + process.exit(1); + } +} + +async function handleNotifyCommand(args: string[]): Promise { + let message = ''; + let title = ''; + let showHelp = false; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + + if (arg === '-p' && i + 1 < args.length) { + message = args[++i]; + } else if (arg === '-t' && i + 1 < args.length) { + title = args[++i]; + } else if (arg === '-h' || arg === '--help') { + showHelp = true; + } else { + console.error(chalk.red(`Unknown argument for notify command: ${arg}`)); + process.exit(1); + } + } + + if (showHelp) { + console.log(` +${chalk.bold('happy notify')} - Send notification + +${chalk.bold('Usage:')} + happy notify -p [-t ] Send notification with custom message and optional title + happy notify -h, --help Show this help + +${chalk.bold('Options:')} + -p <message> Notification message (required) + -t <title> Notification title (optional, defaults to "Happy") + +${chalk.bold('Examples:')} + happy notify -p "Deployment complete!" + happy notify -p "System update complete" -t "Server Status" + happy notify -t "Alert" -p "Database connection restored" +`); + return; + } + + if (!message) { + console.error( + chalk.red('Error: Message is required. Use -p "your message" to specify the notification text.'), + ); + console.log(chalk.gray('Run "happy notify --help" for usage information.')); + process.exit(1); + } + + let credentials = await readCredentials(); + if (!credentials) { + console.error(chalk.red('Error: Not authenticated. Please run "happy auth login" first.')); + process.exit(1); + } + + console.log(chalk.blue('📱 Sending push notification...')); + + try { + const api = await ApiClient.create(credentials); + + const notificationTitle = title || 'Happy'; + + api.push().sendToAllDevices(notificationTitle, message, { + source: 'cli', + timestamp: Date.now(), + }); + + console.log(chalk.green('✓ Push notification sent successfully!')); + console.log(chalk.gray(` Title: ${notificationTitle}`)); + console.log(chalk.gray(` Message: ${message}`)); + console.log(chalk.gray(' Check your mobile device for the notification.')); + + await new Promise((resolve) => setTimeout(resolve, 1000)); + } catch (error) { + console.error(chalk.red('✗ Failed to send push notification')); + throw error; + } +} + diff --git a/cli/src/cli/dispatch.ts b/cli/src/cli/dispatch.ts new file mode 100644 index 000000000..fd4a95930 --- /dev/null +++ b/cli/src/cli/dispatch.ts @@ -0,0 +1,61 @@ +import chalk from 'chalk'; +import { logger } from '@/ui/logger'; +import type { TerminalRuntimeFlags } from '@/terminal/terminalRuntimeFlags'; +import { commandRegistry } from '@/cli/commandRegistry'; +import { AGENTS } from '@/backends/catalog'; +import { DEFAULT_CATALOG_AGENT_ID } from '@/backends/types'; + +export async function dispatchCli(params: Readonly<{ + args: string[]; + terminalRuntime: TerminalRuntimeFlags | null; + rawArgv: string[]; +}>): Promise<void> { + const { args, terminalRuntime, rawArgv } = params; + + // If --version is passed - do not log, its likely daemon inquiring about our version + if (!args.includes('--version')) { + logger.debug('Starting happy CLI with args: ', rawArgv); + } + + // Check if first argument is a subcommand + const subcommand = args[0]; + + // Headless tmux launcher (CLI flow) + if (args.includes('--tmux')) { + // If user is asking for help/version, don't start a session. + if (args.includes('-h') || args.includes('--help') || args.includes('-v') || args.includes('--version')) { + const idx = args.indexOf('--tmux'); + if (idx !== -1) args.splice(idx, 1); + } else { + const disallowed = new Set(['doctor', 'auth', 'connect', 'notify', 'daemon', 'install', 'uninstall', 'logout', 'attach']); + if (subcommand && disallowed.has(subcommand)) { + console.error(chalk.red('Error:'), '--tmux can only be used when starting a session.'); + process.exit(1); + } + + try { + const { startHappyHeadlessInTmux } = await import('@/terminal/startHappyHeadlessInTmux'); + await startHappyHeadlessInTmux(args); + } catch (error) { + console.error(chalk.red('Error:'), error instanceof Error ? error.message : 'Unknown error') + if (process.env.DEBUG) { + console.error(error) + } + process.exit(1) + } + return; + } + } + const commandHandler = (subcommand ? commandRegistry[subcommand] : undefined); + if (commandHandler) { + await commandHandler({ args, rawArgv, terminalRuntime }); + return; + } + + const defaultEntry = AGENTS[DEFAULT_CATALOG_AGENT_ID]; + if (!defaultEntry.getCliCommandHandler) { + throw new Error(`Default agent '${DEFAULT_CATALOG_AGENT_ID}' has no CLI command handler registered`); + } + const defaultHandler = await defaultEntry.getCliCommandHandler(); + await defaultHandler({ args, rawArgv, terminalRuntime }); +} diff --git a/cli/src/cli/parseArgs.ts b/cli/src/cli/parseArgs.ts new file mode 100644 index 000000000..7fda8bf7b --- /dev/null +++ b/cli/src/cli/parseArgs.ts @@ -0,0 +1,10 @@ +import { parseAndStripTerminalRuntimeFlags, type TerminalRuntimeFlags } from '@/terminal/terminalRuntimeFlags'; + +export function parseCliArgs(argv: string[]): Readonly<{ + args: string[]; + terminalRuntime: TerminalRuntimeFlags | null; +}> { + const parsed = parseAndStripTerminalRuntimeFlags(argv); + return { args: parsed.argv, terminalRuntime: parsed.terminal }; +} + diff --git a/cli/src/parsers/specialCommands.test.ts b/cli/src/cli/parsers/specialCommands.test.ts similarity index 100% rename from cli/src/parsers/specialCommands.test.ts rename to cli/src/cli/parsers/specialCommands.test.ts diff --git a/cli/src/parsers/specialCommands.ts b/cli/src/cli/parsers/specialCommands.ts similarity index 98% rename from cli/src/parsers/specialCommands.ts rename to cli/src/cli/parsers/specialCommands.ts index 69226460d..8aaa4c0c2 100644 --- a/cli/src/parsers/specialCommands.ts +++ b/cli/src/cli/parsers/specialCommands.ts @@ -22,21 +22,21 @@ export interface SpecialCommandResult { */ export function parseCompact(message: string): CompactCommandResult { const trimmed = message.trim(); - + if (trimmed === '/compact') { return { isCompact: true, originalMessage: trimmed }; } - + if (trimmed.startsWith('/compact ')) { return { isCompact: true, originalMessage: trimmed }; } - + return { isCompact: false, originalMessage: message @@ -49,7 +49,7 @@ export function parseCompact(message: string): CompactCommandResult { */ export function parseClear(message: string): ClearCommandResult { const trimmed = message.trim(); - + return { isClear: trimmed === '/clear' }; @@ -67,14 +67,14 @@ export function parseSpecialCommand(message: string): SpecialCommandResult { originalMessage: compactResult.originalMessage }; } - + const clearResult = parseClear(message); if (clearResult.isClear) { return { type: 'clear' }; } - + return { type: null }; diff --git a/cli/src/cli/sessionStartArgs.ts b/cli/src/cli/sessionStartArgs.ts new file mode 100644 index 000000000..7a4e128ce --- /dev/null +++ b/cli/src/cli/sessionStartArgs.ts @@ -0,0 +1,55 @@ +import chalk from 'chalk'; +import { PERMISSION_MODES, isPermissionMode, type PermissionMode } from '@/api/types'; + +export function parseSessionStartArgs(args: string[]): { + startedBy: 'daemon' | 'terminal' | undefined; + permissionMode: PermissionMode | undefined; + permissionModeUpdatedAt: number | undefined; +} { + let startedBy: 'daemon' | 'terminal' | undefined = undefined; + let permissionMode: PermissionMode | undefined = undefined; + let permissionModeUpdatedAt: number | undefined = undefined; + + for (let i = 1; i < args.length; i++) { + const arg = args[i]; + if (arg === '--started-by') { + if (i + 1 >= args.length) { + console.error(chalk.red('Missing value for --started-by (expected: daemon|terminal)')); + process.exit(1); + } + const value = args[++i]; + if (value !== 'daemon' && value !== 'terminal') { + console.error(chalk.red(`Invalid --started-by value: ${value}. Expected: daemon|terminal`)); + process.exit(1); + } + startedBy = value; + } else if (arg === '--permission-mode') { + if (i + 1 >= args.length) { + console.error(chalk.red(`Missing value for --permission-mode. Valid values: ${PERMISSION_MODES.join(', ')}`)); + process.exit(1); + } + const value = args[++i]; + if (!isPermissionMode(value)) { + console.error(chalk.red(`Invalid --permission-mode value: ${value}. Valid values: ${PERMISSION_MODES.join(', ')}`)); + process.exit(1); + } + permissionMode = value; + } else if (arg === '--permission-mode-updated-at') { + if (i + 1 >= args.length) { + console.error(chalk.red('Missing value for --permission-mode-updated-at (expected: unix ms timestamp)')); + process.exit(1); + } + const raw = args[++i]; + const parsedAt = Number(raw); + if (!Number.isFinite(parsedAt) || parsedAt <= 0) { + console.error(chalk.red(`Invalid --permission-mode-updated-at value: ${raw}. Expected a positive number (unix ms)`)); + process.exit(1); + } + permissionModeUpdatedAt = Math.floor(parsedAt); + } else if (arg === '--yolo') { + permissionMode = 'yolo'; + } + } + + return { startedBy, permissionMode, permissionModeUpdatedAt }; +} diff --git a/cli/src/cloud/connectTypes.ts b/cli/src/cloud/connectTypes.ts new file mode 100644 index 000000000..1c6225ba3 --- /dev/null +++ b/cli/src/cloud/connectTypes.ts @@ -0,0 +1,27 @@ +import type { CatalogAgentId } from '@/backends/types'; +import type { CloudConnectTargetStatus, CloudVendorKey } from '@happy/agents'; + +export type { CloudConnectTargetStatus, CloudVendorKey }; + +export type ConnectTargetId = CatalogAgentId; + +export type CloudConnectResult = Readonly<{ + vendorKey: CloudVendorKey; + oauth: unknown; +}>; + +export type CloudConnectTarget = Readonly<{ + id: ConnectTargetId; + displayName: string; + vendorDisplayName: string; + vendorKey: CloudVendorKey; + /** + * Whether this connect target is actively consumed by Happy (CLI/app) today. + * + * - wired: connecting has an effect (token is fetched/used by the product) + * - experimental: token may be stored but not yet used everywhere + */ + status: CloudConnectTargetStatus; + authenticate: () => Promise<unknown>; + postConnect?: (oauth: unknown) => void; +}>; diff --git a/cli/src/commands/connect/utils.ts b/cli/src/cloud/decodeJwtPayload.ts similarity index 100% rename from cli/src/commands/connect/utils.ts rename to cli/src/cloud/decodeJwtPayload.ts diff --git a/cli/src/cloud/pkce.ts b/cli/src/cloud/pkce.ts new file mode 100644 index 000000000..03be4b924 --- /dev/null +++ b/cli/src/cloud/pkce.ts @@ -0,0 +1,28 @@ +import { createHash, randomBytes } from 'node:crypto'; + +export interface PkceCodes { + verifier: string; + challenge: string; +} + +/** + * Generate PKCE codes for OAuth flows. + * + * - verifier: 43-128 characters, base64url-ish + * - challenge: SHA256(verifier), base64url + */ +export function generatePkceCodes(bytes: number = 32): PkceCodes { + const verifier = randomBytes(bytes) + .toString('base64url') + .replace(/[^a-zA-Z0-9\-._~]/g, ''); + + const challenge = createHash('sha256') + .update(verifier) + .digest('base64url') + .replace(/=/g, '') + .replace(/\+/g, '-') + .replace(/\//g, '_'); + + return { verifier, challenge }; +} + diff --git a/cli/src/codex/codexMcpClient.ts b/cli/src/codex/codexMcpClient.ts deleted file mode 100644 index ed101394c..000000000 --- a/cli/src/codex/codexMcpClient.ts +++ /dev/null @@ -1,365 +0,0 @@ -/** - * Codex MCP Client - Simple wrapper for Codex tools - */ - -import { Client } from '@modelcontextprotocol/sdk/client/index.js'; -import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; -import { logger } from '@/ui/logger'; -import type { CodexSessionConfig, CodexToolResponse } from './types'; -import { z } from 'zod'; -import { ElicitRequestSchema } from '@modelcontextprotocol/sdk/types.js'; -import { CodexPermissionHandler } from './utils/permissionHandler'; -import { execSync } from 'child_process'; - -const DEFAULT_TIMEOUT = 14 * 24 * 60 * 60 * 1000; // 14 days, which is the half of the maximum possible timeout (~28 days for int32 value in NodeJS) - -/** - * Get the correct MCP subcommand based on installed codex version - * Versions >= 0.43.0-alpha.5 use 'mcp-server', older versions use 'mcp' - * Returns null if codex is not installed or version cannot be determined - */ -function getCodexMcpCommand(): string | null { - try { - const version = execSync('codex --version', { encoding: 'utf8' }).trim(); - const match = version.match(/codex-cli\s+(\d+\.\d+\.\d+(?:-alpha\.\d+)?)/); - if (!match) { - logger.debug('[CodexMCP] Could not parse codex version:', version); - return null; - } - - const versionStr = match[1]; - const [major, minor, patch] = versionStr.split(/[-.]/).map(Number); - - // Version >= 0.43.0-alpha.5 has mcp-server - if (major > 0 || minor > 43) return 'mcp-server'; - if (minor === 43 && patch === 0) { - // Check for alpha version - if (versionStr.includes('-alpha.')) { - const alphaNum = parseInt(versionStr.split('-alpha.')[1]); - return alphaNum >= 5 ? 'mcp-server' : 'mcp'; - } - return 'mcp-server'; // 0.43.0 stable has mcp-server - } - return 'mcp'; // Older versions use mcp - } catch (error) { - logger.debug('[CodexMCP] Codex CLI not found or not executable:', error); - return null; - } -} - -export class CodexMcpClient { - private client: Client; - private transport: StdioClientTransport | null = null; - private connected: boolean = false; - private sessionId: string | null = null; - private conversationId: string | null = null; - private handler: ((event: any) => void) | null = null; - private permissionHandler: CodexPermissionHandler | null = null; - - constructor() { - this.client = new Client( - { name: 'happy-codex-client', version: '1.0.0' }, - { capabilities: { elicitation: {} } } - ); - - this.client.setNotificationHandler(z.object({ - method: z.literal('codex/event'), - params: z.object({ - msg: z.any() - }) - }).passthrough(), (data) => { - const msg = data.params.msg; - this.updateIdentifiersFromEvent(msg); - this.handler?.(msg); - }); - } - - setHandler(handler: ((event: any) => void) | null): void { - this.handler = handler; - } - - /** - * Set the permission handler for tool approval - */ - setPermissionHandler(handler: CodexPermissionHandler): void { - this.permissionHandler = handler; - } - - async connect(): Promise<void> { - if (this.connected) return; - - const mcpCommand = getCodexMcpCommand(); - - if (mcpCommand === null) { - throw new Error( - 'Codex CLI not found or not executable.\n' + - '\n' + - 'To install codex:\n' + - ' npm install -g @openai/codex\n' + - '\n' + - 'Alternatively, use Claude:\n' + - ' happy claude' - ); - } - - logger.debug(`[CodexMCP] Connecting to Codex MCP server using command: codex ${mcpCommand}`); - - this.transport = new StdioClientTransport({ - command: 'codex', - args: [mcpCommand], - env: Object.keys(process.env).reduce((acc, key) => { - const value = process.env[key]; - if (typeof value === 'string') acc[key] = value; - return acc; - }, {} as Record<string, string>) - }); - - // Register request handlers for Codex permission methods - this.registerPermissionHandlers(); - - await this.client.connect(this.transport); - this.connected = true; - - logger.debug('[CodexMCP] Connected to Codex'); - } - - private registerPermissionHandlers(): void { - // Register handler for exec command approval requests - this.client.setRequestHandler( - ElicitRequestSchema, - async (request) => { - console.log('[CodexMCP] Received elicitation request:', request.params); - - // Load params - const params = request.params as unknown as { - message: string, - codex_elicitation: string, - codex_mcp_tool_call_id: string, - codex_event_id: string, - codex_call_id: string, - codex_command: string[], - codex_cwd: string - } - const toolName = 'CodexBash'; - - // If no permission handler set, deny by default - if (!this.permissionHandler) { - logger.debug('[CodexMCP] No permission handler set, denying by default'); - return { - decision: 'denied' as const, - }; - } - - try { - // Request permission through the handler - const result = await this.permissionHandler.handleToolCall( - params.codex_call_id, - toolName, - { - command: params.codex_command, - cwd: params.codex_cwd - } - ); - - logger.debug('[CodexMCP] Permission result:', result); - return { - decision: result.decision - } - } catch (error) { - logger.debug('[CodexMCP] Error handling permission request:', error); - return { - decision: 'denied' as const, - reason: error instanceof Error ? error.message : 'Permission request failed' - }; - } - } - ); - - logger.debug('[CodexMCP] Permission handlers registered'); - } - - async startSession(config: CodexSessionConfig, options?: { signal?: AbortSignal }): Promise<CodexToolResponse> { - if (!this.connected) await this.connect(); - - logger.debug('[CodexMCP] Starting Codex session:', config); - - const response = await this.client.callTool({ - name: 'codex', - arguments: config as any - }, undefined, { - signal: options?.signal, - timeout: DEFAULT_TIMEOUT, - // maxTotalTimeout: 10000000000 - }); - - logger.debug('[CodexMCP] startSession response:', response); - - // Extract session / conversation identifiers from response if present - this.extractIdentifiers(response); - - return response as CodexToolResponse; - } - - async continueSession(prompt: string, options?: { signal?: AbortSignal }): Promise<CodexToolResponse> { - if (!this.connected) await this.connect(); - - if (!this.sessionId) { - throw new Error('No active session. Call startSession first.'); - } - - if (!this.conversationId) { - // Some Codex deployments reuse the session ID as the conversation identifier - this.conversationId = this.sessionId; - logger.debug('[CodexMCP] conversationId missing, defaulting to sessionId:', this.conversationId); - } - - const args = { sessionId: this.sessionId, conversationId: this.conversationId, prompt }; - logger.debug('[CodexMCP] Continuing Codex session:', args); - - const response = await this.client.callTool({ - name: 'codex-reply', - arguments: args - }, undefined, { - signal: options?.signal, - timeout: DEFAULT_TIMEOUT - }); - - logger.debug('[CodexMCP] continueSession response:', response); - this.extractIdentifiers(response); - - return response as CodexToolResponse; - } - - - private updateIdentifiersFromEvent(event: any): void { - if (!event || typeof event !== 'object') { - return; - } - - const candidates: any[] = [event]; - if (event.data && typeof event.data === 'object') { - candidates.push(event.data); - } - - for (const candidate of candidates) { - const sessionId = candidate.session_id ?? candidate.sessionId; - if (sessionId) { - this.sessionId = sessionId; - logger.debug('[CodexMCP] Session ID extracted from event:', this.sessionId); - } - - const conversationId = candidate.conversation_id ?? candidate.conversationId; - if (conversationId) { - this.conversationId = conversationId; - logger.debug('[CodexMCP] Conversation ID extracted from event:', this.conversationId); - } - } - } - private extractIdentifiers(response: any): void { - const meta = response?.meta || {}; - if (meta.sessionId) { - this.sessionId = meta.sessionId; - logger.debug('[CodexMCP] Session ID extracted:', this.sessionId); - } else if (response?.sessionId) { - this.sessionId = response.sessionId; - logger.debug('[CodexMCP] Session ID extracted:', this.sessionId); - } - - if (meta.conversationId) { - this.conversationId = meta.conversationId; - logger.debug('[CodexMCP] Conversation ID extracted:', this.conversationId); - } else if (response?.conversationId) { - this.conversationId = response.conversationId; - logger.debug('[CodexMCP] Conversation ID extracted:', this.conversationId); - } - - const content = response?.content; - if (Array.isArray(content)) { - for (const item of content) { - if (!this.sessionId && item?.sessionId) { - this.sessionId = item.sessionId; - logger.debug('[CodexMCP] Session ID extracted from content:', this.sessionId); - } - if (!this.conversationId && item && typeof item === 'object' && 'conversationId' in item && item.conversationId) { - this.conversationId = item.conversationId; - logger.debug('[CodexMCP] Conversation ID extracted from content:', this.conversationId); - } - } - } - } - - getSessionId(): string | null { - return this.sessionId; - } - - hasActiveSession(): boolean { - return this.sessionId !== null; - } - - clearSession(): void { - // Store the previous session ID before clearing for potential resume - const previousSessionId = this.sessionId; - this.sessionId = null; - this.conversationId = null; - logger.debug('[CodexMCP] Session cleared, previous sessionId:', previousSessionId); - } - - /** - * Store the current session ID without clearing it, useful for abort handling - */ - storeSessionForResume(): string | null { - logger.debug('[CodexMCP] Storing session for potential resume:', this.sessionId); - return this.sessionId; - } - - /** - * Force close the Codex MCP transport and clear all session identifiers. - * Use this for permanent shutdown (e.g. kill/exit). Prefer `disconnect()` for - * transient connection resets where you may want to keep the session id. - */ - async forceCloseSession(): Promise<void> { - logger.debug('[CodexMCP] Force closing session'); - try { - await this.disconnect(); - } finally { - this.clearSession(); - } - logger.debug('[CodexMCP] Session force-closed'); - } - - async disconnect(): Promise<void> { - if (!this.connected) return; - - // Capture pid in case we need to force-kill - const pid = this.transport?.pid ?? null; - logger.debug(`[CodexMCP] Disconnecting; child pid=${pid ?? 'none'}`); - - try { - // Ask client to close the transport - logger.debug('[CodexMCP] client.close begin'); - await this.client.close(); - logger.debug('[CodexMCP] client.close done'); - } catch (e) { - logger.debug('[CodexMCP] Error closing client, attempting transport close directly', e); - try { - logger.debug('[CodexMCP] transport.close begin'); - await this.transport?.close?.(); - logger.debug('[CodexMCP] transport.close done'); - } catch {} - } - - // As a last resort, if child still exists, send SIGKILL - if (pid) { - try { - process.kill(pid, 0); // check if alive - logger.debug('[CodexMCP] Child still alive, sending SIGKILL'); - try { process.kill(pid, 'SIGKILL'); } catch {} - } catch { /* not running */ } - } - - this.transport = null; - this.connected = false; - // Preserve session/conversation identifiers for potential reconnection / recovery flows. - logger.debug(`[CodexMCP] Disconnected; session ${this.sessionId ?? 'none'} preserved`); - } -} diff --git a/cli/src/commands/attach.ts b/cli/src/commands/attach.ts new file mode 100644 index 000000000..39d560dda --- /dev/null +++ b/cli/src/commands/attach.ts @@ -0,0 +1,88 @@ +import chalk from 'chalk'; +import { spawn } from 'node:child_process'; + +import { configuration } from '@/configuration'; +import { readTerminalAttachmentInfo } from '@/terminal/terminalAttachmentInfo'; +import { createTerminalAttachPlan } from '@/terminal/terminalAttachPlan'; +import { isTmuxAvailable, normalizeExitCode } from '@/integrations/tmux'; + +function spawnTmux(params: { + args: string[]; + env: NodeJS.ProcessEnv; + stdio: 'inherit' | 'ignore'; +}): Promise<number> { + return new Promise((resolve) => { + const child = spawn('tmux', params.args, { + stdio: params.stdio, + env: params.env, + shell: false, + }); + + child.once('error', () => resolve(1)); + child.once('exit', (code) => resolve(normalizeExitCode(code))); + }); +} + +export async function handleAttachCommand(argv: string[]): Promise<void> { + const sessionId = argv[0]?.trim(); + if (!sessionId) { + console.error(chalk.red('Error:'), 'Missing session ID.'); + console.log(''); + console.log('Usage: happy attach <sessionId>'); + process.exit(1); + } + + if (!(await isTmuxAvailable())) { + console.error(chalk.red('Error:'), 'tmux is not available on this machine.'); + process.exit(1); + } + + const info = await readTerminalAttachmentInfo({ + happyHomeDir: configuration.happyHomeDir, + sessionId, + }); + + if (!info) { + console.error(chalk.red('Error:'), `No local attachment info found for session ${sessionId}.`); + console.error(chalk.gray('This usually means the session was not started with tmux, or it was started on another machine.')); + process.exit(1); + } + + const plan = createTerminalAttachPlan({ + terminal: info.terminal, + insideTmux: Boolean(process.env.TMUX), + }); + + if (plan.type === 'not-attachable') { + console.error(chalk.red('Error:'), plan.reason); + process.exit(1); + } + + const env: NodeJS.ProcessEnv = { ...process.env, ...plan.tmuxCommandEnv }; + if (plan.shouldUnsetTmuxEnv) { + delete env.TMUX; + delete env.TMUX_PANE; + } + + const selectExit = await spawnTmux({ + args: plan.selectWindowArgs, + env, + stdio: 'ignore', + }); + + if (selectExit !== 0) { + console.error(chalk.red('Error:'), `Failed to select tmux window (${plan.target}).`); + process.exit(selectExit); + } + + if (!plan.shouldAttach) { + return; + } + + const attachExit = await spawnTmux({ + args: plan.attachSessionArgs, + env, + stdio: 'inherit', + }); + process.exit(attachExit); +} diff --git a/cli/src/commands/auth.ts b/cli/src/commands/auth.ts index 804f163be..e6096d237 100644 --- a/cli/src/commands/auth.ts +++ b/cli/src/commands/auth.ts @@ -38,12 +38,13 @@ function showAuthHelp(): void { ${chalk.bold('happy auth')} - Authentication management ${chalk.bold('Usage:')} - happy auth login [--force] Authenticate with Happy + happy auth login [--no-open] [--force] Authenticate with Happy happy auth logout Remove authentication and machine data happy auth status Show authentication status happy auth help Show this help message ${chalk.bold('Options:')} + --no-open Do not attempt to open a browser (prints URL instead) --force Clear credentials, machine ID, and stop daemon before re-auth ${chalk.gray('PS: Your master secret never leaves your mobile/web device. Each CLI machine')} @@ -54,6 +55,12 @@ ${chalk.gray('cannot be displayed from the CLI.')} async function handleAuthLogin(args: string[]): Promise<void> { const forceAuth = args.includes('--force') || args.includes('-f'); + const noOpen = args.includes('--no-open') || args.includes('--no-browser') || args.includes('--no-browser-open'); + + if (noOpen) { + // Used by the auth UI layer to skip automatic browser open attempts. + process.env.HAPPY_NO_BROWSER_OPEN = '1'; + } if (forceAuth) { // As per user's request: "--force-auth will clear credentials, clear machine ID, stop daemon" diff --git a/cli/src/commands/connect.ts b/cli/src/commands/connect.ts index ac9311a4f..116753af1 100644 --- a/cli/src/commands/connect.ts +++ b/cli/src/commands/connect.ts @@ -1,13 +1,9 @@ import chalk from 'chalk'; -import { existsSync, mkdirSync, writeFileSync } from 'fs'; -import { homedir } from 'os'; -import { join } from 'path'; import { readCredentials } from '@/persistence'; import { ApiClient } from '@/api/api'; -import { authenticateCodex } from './connect/authenticateCodex'; -import { authenticateClaude } from './connect/authenticateClaude'; -import { authenticateGemini } from './connect/authenticateGemini'; -import { decodeJwtPayload } from './connect/utils'; +import { decodeJwtPayload } from '@/cloud/decodeJwtPayload'; +import type { CloudConnectTarget, CloudConnectTargetStatus } from '@/cloud/connectTypes'; +import { AGENTS } from '@/backends/catalog'; /** * Handle connect subcommand @@ -19,43 +15,70 @@ import { decodeJwtPayload } from './connect/utils'; * - connect help: Show help for connect command */ export async function handleConnectCommand(args: string[]): Promise<void> { - const subcommand = args[0]; + const { includeExperimental, subcommand } = parseConnectArgs(args); + + const allTargets = await loadConnectTargets({ includeExperimental: true }); + const visibleTargets = includeExperimental ? allTargets : allTargets.filter((t) => t.status === 'wired'); + + const targetById = new Map(allTargets.map((t) => [t.id, t] as const)); + const visibleTargetById = new Map(visibleTargets.map((t) => [t.id, t] as const)); if (!subcommand || subcommand === 'help' || subcommand === '--help' || subcommand === '-h') { - showConnectHelp(); + showConnectHelp(visibleTargets, { includeExperimental }); return; } - switch (subcommand.toLowerCase()) { - case 'codex': - await handleConnectVendor('codex', 'OpenAI'); - break; - case 'claude': - await handleConnectVendor('claude', 'Anthropic'); - break; - case 'gemini': - await handleConnectVendor('gemini', 'Gemini'); - break; - case 'status': - await handleConnectStatus(); - break; - default: - console.error(chalk.red(`Unknown connect target: ${subcommand}`)); - showConnectHelp(); - process.exit(1); + const normalized = subcommand.toLowerCase(); + if (normalized === 'status') { + await handleConnectStatus(visibleTargets); + return; + } + + const visibleTarget = visibleTargetById.get(normalized as any); + if (!visibleTarget) { + const hiddenTarget = targetById.get(normalized as any); + if (hiddenTarget && hiddenTarget.status === 'experimental' && !includeExperimental) { + console.error(chalk.yellow(`Connect target '${hiddenTarget.id}' is experimental and not enabled by default.`)); + console.error(chalk.gray(`Run: happy connect --all ${hiddenTarget.id}`)); + process.exit(1); + } + console.error(chalk.red(`Unknown connect target: ${subcommand}`)); + showConnectHelp(visibleTargets, { includeExperimental }); + process.exit(1); } + + await handleConnectVendor(visibleTarget); +} + +function parseConnectArgs(args: ReadonlyArray<string>): Readonly<{ includeExperimental: boolean; subcommand: string | null }> { + const includeExperimental = args.includes('--all') || args.includes('--experimental'); + const rest = args.filter((a) => a !== '--all' && a !== '--experimental'); + const subcommand = rest[0] ?? null; + return { includeExperimental, subcommand }; +} + +async function loadConnectTargets(params: Readonly<{ includeExperimental: boolean }>): Promise<CloudConnectTarget[]> { + const targets: CloudConnectTarget[] = []; + for (const entry of Object.values(AGENTS)) { + if (!entry.getCloudConnectTarget) continue; + targets.push(await entry.getCloudConnectTarget()); + } + targets.sort((a, b) => a.id.localeCompare(b.id)); + return params.includeExperimental ? targets : targets.filter((t) => t.status === 'wired'); } -function showConnectHelp(): void { +function showConnectHelp(targets: ReadonlyArray<CloudConnectTarget>, opts: Readonly<{ includeExperimental: boolean }>): void { + const targetLines = targets.length > 0 + ? targets.map((t) => formatTargetLine(t)).join('\n') + : ' (no connect targets registered)'; console.log(` ${chalk.bold('happy connect')} - Connect AI vendor API keys to Happy cloud ${chalk.bold('Usage:')} - happy connect codex Store your Codex API key in Happy cloud - happy connect claude Store your Anthropic API key in Happy cloud - happy connect gemini Store your Gemini API key in Happy cloud +${targetLines} happy connect status Show connection status for all vendors happy connect help Show this help message + happy connect --all ... Include experimental providers ${chalk.bold('Description:')} The connect command allows you to securely store your AI vendor API keys @@ -63,20 +86,24 @@ ${chalk.bold('Description:')} without exposing your API keys locally. ${chalk.bold('Examples:')} - happy connect codex - happy connect claude - happy connect gemini + happy connect ${targets[0]?.id ?? 'gemini'} happy connect status ${chalk.bold('Notes:')} • You must be authenticated with Happy first (run 'happy auth login') • API keys are encrypted and stored securely in Happy cloud • You can manage your stored keys at app.happy.engineering + ${opts.includeExperimental ? '' : '• Some providers are experimental; use --all to show them'} `); } -async function handleConnectVendor(vendor: 'codex' | 'claude' | 'gemini', displayName: string): Promise<void> { - console.log(chalk.bold(`\n🔌 Connecting ${displayName} to Happy cloud\n`)); +function formatTargetLine(target: CloudConnectTarget): string { + const statusSuffix = target.status === 'wired' ? '' : chalk.gray(' (experimental)'); + return ` happy connect ${target.id.padEnd(12)} ${target.vendorDisplayName}${statusSuffix}`; +} + +async function handleConnectVendor(target: CloudConnectTarget): Promise<void> { + console.log(chalk.bold(`\n🔌 Connecting ${target.vendorDisplayName} to Happy cloud\n`)); // Check if authenticated const credentials = await readCredentials(); @@ -89,38 +116,18 @@ async function handleConnectVendor(vendor: 'codex' | 'claude' | 'gemini', displa // Create API client const api = await ApiClient.create(credentials); - // Handle vendor authentication - if (vendor === 'codex') { - console.log('🚀 Registering Codex token with server'); - const codexAuthTokens = await authenticateCodex(); - await api.registerVendorToken('openai', { oauth: codexAuthTokens }); - console.log('✅ Codex token registered with server'); - process.exit(0); - } else if (vendor === 'claude') { - console.log('🚀 Registering Anthropic token with server'); - const anthropicAuthTokens = await authenticateClaude(); - await api.registerVendorToken('anthropic', { oauth: anthropicAuthTokens }); - console.log('✅ Anthropic token registered with server'); - process.exit(0); - } else if (vendor === 'gemini') { - console.log('🚀 Registering Gemini token with server'); - const geminiAuthTokens = await authenticateGemini(); - await api.registerVendorToken('gemini', { oauth: geminiAuthTokens }); - console.log('✅ Gemini token registered with server'); - - // Also update local Gemini config to keep tokens in sync - updateLocalGeminiCredentials(geminiAuthTokens); - - process.exit(0); - } else { - throw new Error(`Unsupported vendor: ${vendor}`); - } + console.log(`🚀 Registering ${target.displayName} token with server`); + const oauth = await target.authenticate(); + await api.registerVendorToken(target.vendorKey, { oauth }); + console.log(`✅ ${target.displayName} token registered with server`); + target.postConnect?.(oauth); + process.exit(0); } /** * Show connection status for all vendors */ -async function handleConnectStatus(): Promise<void> { +async function handleConnectStatus(targets: ReadonlyArray<CloudConnectTarget>): Promise<void> { console.log(chalk.bold('\n🔌 Connection Status\n')); // Check if authenticated @@ -134,23 +141,17 @@ async function handleConnectStatus(): Promise<void> { // Create API client const api = await ApiClient.create(credentials); - // Check each vendor - const vendors: Array<{ key: 'openai' | 'anthropic' | 'gemini'; name: string; display: string }> = [ - { key: 'gemini', name: 'Gemini', display: 'Google Gemini' }, - { key: 'openai', name: 'Codex', display: 'OpenAI Codex' }, - { key: 'anthropic', name: 'Claude', display: 'Anthropic Claude' }, - ]; - - for (const vendor of vendors) { + for (const target of targets) { try { - const token = await api.getVendorToken(vendor.key); + const token = await api.getVendorToken(target.vendorKey); if (token?.oauth) { // Try to extract user info from id_token (JWT) let userInfo = ''; - if (token.oauth.id_token) { - const payload = decodeJwtPayload(token.oauth.id_token); + const idToken = (token.oauth as any)?.id_token; + if (typeof idToken === 'string') { + const payload = decodeJwtPayload(idToken); if (payload?.email) { userInfo = chalk.gray(` (${payload.email})`); } @@ -161,15 +162,15 @@ async function handleConnectStatus(): Promise<void> { const isExpired = expiresAt && expiresAt < Date.now(); if (isExpired) { - console.log(` ${chalk.yellow('⚠️')} ${vendor.display}: ${chalk.yellow('expired')}${userInfo}`); + console.log(` ${chalk.yellow('⚠️')} ${target.vendorDisplayName}: ${chalk.yellow('expired')}${userInfo}`); } else { - console.log(` ${chalk.green('✓')} ${vendor.display}: ${chalk.green('connected')}${userInfo}`); + console.log(` ${chalk.green('✓')} ${target.vendorDisplayName}: ${chalk.green('connected')}${userInfo}`); } } else { - console.log(` ${chalk.gray('○')} ${vendor.display}: ${chalk.gray('not connected')}`); + console.log(` ${chalk.gray('○')} ${target.vendorDisplayName}: ${chalk.gray('not connected')}`); } } catch { - console.log(` ${chalk.gray('○')} ${vendor.display}: ${chalk.gray('not connected')}`); + console.log(` ${chalk.gray('○')} ${target.vendorDisplayName}: ${chalk.gray('not connected')}`); } } @@ -178,42 +179,3 @@ async function handleConnectStatus(): Promise<void> { console.log(chalk.gray('Example: happy connect gemini')); console.log(''); } - -/** - * Update local Gemini credentials file to keep in sync with Happy cloud - * This ensures the Gemini SDK uses the same account as Happy - */ -function updateLocalGeminiCredentials(tokens: { - access_token: string; - refresh_token?: string; - id_token?: string; - expires_in?: number; - token_type?: string; - scope?: string; -}): void { - try { - const geminiDir = join(homedir(), '.gemini'); - const credentialsPath = join(geminiDir, 'oauth_creds.json'); - - // Create directory if it doesn't exist - if (!existsSync(geminiDir)) { - mkdirSync(geminiDir, { recursive: true }); - } - - // Write credentials in the format Gemini CLI expects - const credentials = { - access_token: tokens.access_token, - token_type: tokens.token_type || 'Bearer', - scope: tokens.scope || 'https://www.googleapis.com/auth/cloud-platform', - ...(tokens.refresh_token && { refresh_token: tokens.refresh_token }), - ...(tokens.id_token && { id_token: tokens.id_token }), - ...(tokens.expires_in && { expires_in: tokens.expires_in }), - }; - - writeFileSync(credentialsPath, JSON.stringify(credentials, null, 2), 'utf-8'); - console.log(chalk.gray(` Updated local credentials: ${credentialsPath}`)); - } catch (error) { - // Non-critical error - server tokens will still work - console.log(chalk.yellow(` ⚠️ Could not update local credentials: ${error}`)); - } -} \ No newline at end of file diff --git a/cli/src/commands/connect/types.ts b/cli/src/commands/connect/types.ts deleted file mode 100644 index 2a24e5ea8..000000000 --- a/cli/src/commands/connect/types.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Type definitions for Codex authentication - */ - -export interface CodexAuthTokens { - id_token: string; - access_token: string; - refresh_token: string; - account_id: string; -} - -export interface GeminiAuthTokens { - access_token: string; - refresh_token?: string; - token_type: string; - expires_in: number; - scope: string; - id_token?: string; -} - -export interface PKCECodes { - verifier: string; - challenge: string; -} - -export interface ClaudeAuthTokens { - raw: any; - token: string; - expires: number; -} \ No newline at end of file diff --git a/cli/src/daemon/controlServer.ts b/cli/src/daemon/controlServer.ts index e0e05f9be..17b9a5c8a 100644 --- a/cli/src/daemon/controlServer.ts +++ b/cli/src/daemon/controlServer.ts @@ -9,7 +9,7 @@ import { serializerCompiler, validatorCompiler, ZodTypeProvider } from 'fastify- import { logger } from '@/ui/logger'; import { Metadata } from '@/api/types'; import { TrackedSession } from './types'; -import { SpawnSessionOptions, SpawnSessionResult } from '@/modules/common/registerCommonHandlers'; +import { SpawnSessionOptions, SpawnSessionResult } from '@/rpc/handlers/registerSessionHandlers'; export function startDaemonControlServer({ getChildren, @@ -19,7 +19,7 @@ export function startDaemonControlServer({ onHappySessionWebhook }: { getChildren: () => TrackedSession[]; - stopSession: (sessionId: string) => boolean; + stopSession: (sessionId: string) => Promise<boolean>; spawnSession: (options: SpawnSessionOptions) => Promise<SpawnSessionResult>; requestShutdown: () => void; onHappySessionWebhook: (sessionId: string, metadata: Metadata) => void; @@ -99,7 +99,7 @@ export function startDaemonControlServer({ const { sessionId } = request.body; logger.debug(`[CONTROL SERVER] Stop session request: ${sessionId}`); - const success = stopSession(sessionId); + const success = await stopSession(sessionId); return { success }; }); @@ -124,7 +124,8 @@ export function startDaemonControlServer({ }), 500: z.object({ success: z.boolean(), - error: z.string().optional() + error: z.string().optional(), + errorCode: z.string().optional(), }) } } @@ -163,7 +164,8 @@ export function startDaemonControlServer({ reply.code(500); return { success: false, - error: result.errorMessage + error: result.errorMessage, + errorCode: result.errorCode, }; } }); @@ -208,4 +210,4 @@ export function startDaemonControlServer({ }); }); }); -} \ No newline at end of file +} diff --git a/cli/src/daemon/daemon.integration.test.ts b/cli/src/daemon/daemon.integration.test.ts index 9b566c907..0991793e7 100644 --- a/cli/src/daemon/daemon.integration.test.ts +++ b/cli/src/daemon/daemon.integration.test.ts @@ -46,15 +46,33 @@ async function waitFor( throw new Error('Timeout waiting for condition'); } +function isProcessAlive(pid: number): boolean { + try { + // `process.kill(pid, 0)` can return true for zombies; prefer checking `ps` state. + const stat = execSync(`ps -o stat= -p ${pid}`, { stdio: ['ignore', 'pipe', 'ignore'] }) + .toString() + .trim(); + if (!stat) return false; + return !stat.includes('Z'); + } catch { + return false; + } +} + // Check if dev server is running and properly configured async function isServerHealthy(): Promise<boolean> { try { + const configuredServerUrl = process.env.HAPPY_SERVER_URL || 'http://localhost:3005'; + const healthUrl = new URL('/health', configuredServerUrl); + // Avoid IPv6/localhost resolution issues in some CI/container environments. + if (healthUrl.hostname === 'localhost') healthUrl.hostname = '127.0.0.1'; + // First check if server responds - const response = await fetch('http://localhost:3005/', { - signal: AbortSignal.timeout(1000) + const response = await fetch(healthUrl.toString(), { + signal: AbortSignal.timeout(3000) }); if (!response.ok) { - console.log('[TEST] Server health check failed: root endpoint not OK'); + console.log('[TEST] Server health check failed:', response.status, response.statusText); return false; } @@ -86,14 +104,14 @@ describe.skipIf(!await isServerHealthy())('Daemon Integration Tests', { timeout: stdio: 'ignore' }); - // Wait for daemon to write its state file (it needs to auth, setup, and start server) + // Wait for daemon to write its state file (it needs to auth, setup, and start HTTP control server) await waitFor(async () => { const state = await readDaemonState(); - return state !== null; + return !!(state && typeof state.pid === 'number' && typeof state.httpPort === 'number' && state.httpPort > 0); }, 10_000, 250); // Wait up to 10 seconds, checking every 250ms const daemonState = await readDaemonState(); - if (!daemonState) { + if (!daemonState?.pid || !daemonState?.httpPort) { throw new Error('Daemon failed to start within timeout'); } daemonPid = daemonState.pid; @@ -117,7 +135,7 @@ describe.skipIf(!await isServerHealthy())('Daemon Integration Tests', { timeout: path: '/test/path', host: 'test-host', homeDir: '/test/home', - happyHomeDir: '/test/happy-home', + happyHomeDir: configuration.happyHomeDir, happyLibDir: '/test/happy-lib', happyToolsDir: '/test/happy-tools', hostPid: 99999, @@ -128,8 +146,12 @@ describe.skipIf(!await isServerHealthy())('Daemon Integration Tests', { timeout: await notifyDaemonSessionStarted('test-session-123', mockMetadata); // Verify session is tracked + await waitFor(async () => { + const sessions = await listDaemonSessions(); + return sessions.length === 1; + }, 10_000, 250); + const sessions = await listDaemonSessions(); - expect(sessions).toHaveLength(1); const tracked = sessions[0]; expect(tracked.startedBy).toBe('happy directly - likely by user from terminal'); @@ -137,13 +159,18 @@ describe.skipIf(!await isServerHealthy())('Daemon Integration Tests', { timeout: expect(tracked.pid).toBe(99999); }); - it('should spawn & stop a session via HTTP (not testing RPC route, but similar enough)', async () => { + it('should spawn & stop a session via HTTP (not testing RPC route, but similar enough)', { timeout: 60_000 }, async () => { const response = await spawnDaemonSession('/tmp', 'spawned-test-456'); expect(response).toHaveProperty('success', true); expect(response).toHaveProperty('sessionId'); // Verify session is tracked + await waitFor(async () => { + const sessions = await listDaemonSessions(); + return sessions.some((s: any) => s.happySessionId === response.sessionId); + }, 30_000, 250); + const sessions = await listDaemonSessions(); const spawnedSession = sessions.find( (s: any) => s.happySessionId === response.sessionId @@ -157,7 +184,7 @@ describe.skipIf(!await isServerHealthy())('Daemon Integration Tests', { timeout: await stopDaemonSession(spawnedSession.happySessionId); }); - it('stress test: spawn / stop', { timeout: 60_000 }, async () => { + it('stress test: spawn / stop', { timeout: 120_000 }, async () => { const promises = []; const sessionCount = 20; for (let i = 0; i < sessionCount; i++) { @@ -166,18 +193,26 @@ describe.skipIf(!await isServerHealthy())('Daemon Integration Tests', { timeout: // Wait for all sessions to be spawned const results = await Promise.all(promises); + results.forEach((result) => { + expect(result.success).toBe(true); + expect(result.sessionId).toBeDefined(); + }); const sessionIds = results.map(r => r.sessionId); - const sessions = await listDaemonSessions(); - expect(sessions).toHaveLength(sessionCount); + await waitFor(async () => { + const sessions = await listDaemonSessions(); + return sessions.length === sessionCount; + }, 60_000, 500); // Stop all sessions const stopResults = await Promise.all(sessionIds.map(sessionId => stopDaemonSession(sessionId))); expect(stopResults.every(r => r), 'Not all sessions reported stopped').toBe(true); // Verify all sessions are stopped - const emptySessions = await listDaemonSessions(); - expect(emptySessions).toHaveLength(0); + await waitFor(async () => { + const emptySessions = await listDaemonSessions(); + return emptySessions.length === 0; + }, 60_000, 500); }); it('should handle daemon stop request gracefully', async () => { @@ -187,7 +222,7 @@ describe.skipIf(!await isServerHealthy())('Daemon Integration Tests', { timeout: await waitFor(async () => !existsSync(configuration.daemonStateFile), 1000); }); - it('should track both daemon-spawned and terminal sessions', async () => { + it('should track both daemon-spawned and terminal sessions', { timeout: 60_000 }, async () => { // Spawn a real happy process that looks like it was started from terminal const terminalHappyProcess = spawnHappyCLI([ '--happy-starting-mode', 'remote', @@ -201,19 +236,25 @@ describe.skipIf(!await isServerHealthy())('Daemon Integration Tests', { timeout: throw new Error('Failed to spawn terminal happy process'); } // Give time to start & report itself - await new Promise(resolve => setTimeout(resolve, 5_000)); + await waitFor(async () => { + const sessions = await listDaemonSessions(); + return sessions.some((s: any) => s.startedBy !== 'daemon'); + }, 30_000, 500); // Spawn a daemon session const spawnResponse = await spawnDaemonSession('/tmp', 'daemon-session-bbb'); // List all sessions + await waitFor(async () => { + const sessions = await listDaemonSessions(); + return sessions.length === 2; + }, 30_000, 500); const sessions = await listDaemonSessions(); - expect(sessions).toHaveLength(2); // Verify we have one of each type - const terminalSession = sessions.find( - (s: any) => s.pid === terminalHappyProcess.pid - ); + const terminalSession = + sessions.find((s: any) => s.pid === terminalHappyProcess.pid) + ?? sessions.find((s: any) => s.startedBy !== 'daemon'); const daemonSession = sessions.find( (s: any) => s.happySessionId === spawnResponse.sessionId ); @@ -225,7 +266,10 @@ describe.skipIf(!await isServerHealthy())('Daemon Integration Tests', { timeout: expect(daemonSession.startedBy).toBe('daemon'); // Clean up both sessions - await stopDaemonSession('terminal-session-aaa'); + expect(terminalSession?.happySessionId).toBeDefined(); + await stopDaemonSession(terminalSession.happySessionId); + + expect(daemonSession?.happySessionId).toBeDefined(); await stopDaemonSession(daemonSession.happySessionId); // Also kill the terminal process directly to be sure @@ -236,21 +280,26 @@ describe.skipIf(!await isServerHealthy())('Daemon Integration Tests', { timeout: } }); - it('should update session metadata when webhook is called', async () => { + it('should update session metadata when webhook is called', { timeout: 60_000 }, async () => { // Spawn a session const spawnResponse = await spawnDaemonSession('/tmp'); // Verify webhook was processed (session ID updated) - const sessions = await listDaemonSessions(); - const session = sessions.find((s: any) => s.happySessionId === spawnResponse.sessionId); - expect(session).toBeDefined(); + await waitFor(async () => { + const sessions = await listDaemonSessions(); + return sessions.some((s: any) => s.happySessionId === spawnResponse.sessionId); + }, 30_000, 250); // Clean up await stopDaemonSession(spawnResponse.sessionId); }); - it('should not allow starting a second daemon', async () => { + it('should not allow starting a second daemon', { timeout: 60_000 }, async () => { // Daemon is already running from beforeEach + const initialState = await readDaemonState(); + expect(initialState).toBeDefined(); + const initialPid = initialState!.pid; + // Try to start another daemon const secondChild = spawn('yarn', ['tsx', 'src/index.ts', 'daemon', 'start-sync'], { cwd: process.cwd(), @@ -267,15 +316,25 @@ describe.skipIf(!await isServerHealthy())('Daemon Integration Tests', { timeout: }); // Wait for the second daemon to exit + let exitCode: number | null = null; await new Promise<void>((resolve) => { - secondChild.on('exit', () => resolve()); + secondChild.on('exit', (code) => { + exitCode = code; + resolve(); + }); }); - // Should report that daemon is already running - expect(output).toContain('already running'); + // Should not have replaced the running daemon + expect(exitCode).toBe(0); + const finalState = await readDaemonState(); + expect(finalState).toBeDefined(); + expect(finalState!.pid).toBe(initialPid); + + // Optional: keep message flexible + expect(output.toLowerCase()).toMatch(/already running|lock|another daemon/i); }); - it('should handle concurrent session operations', async () => { + it('should handle concurrent session operations', { timeout: 60_000 }, async () => { // Spawn multiple sessions concurrently const promises = []; for (let i = 0; i < 3; i++) { @@ -295,15 +354,19 @@ describe.skipIf(!await isServerHealthy())('Daemon Integration Tests', { timeout: // Collect session IDs for tracking const spawnedSessionIds = results.map(r => r.sessionId); - // Give sessions time to report via webhook - await new Promise(resolve => setTimeout(resolve, 1000)); - // List should show all sessions + await waitFor(async () => { + const sessions = await listDaemonSessions(); + const daemonSessions = sessions.filter( + (s: any) => s.startedBy === 'daemon' && spawnedSessionIds.includes(s.happySessionId) + ); + return daemonSessions.length >= 3; + }, 30_000, 250); + const sessions = await listDaemonSessions(); const daemonSessions = sessions.filter( (s: any) => s.startedBy === 'daemon' && spawnedSessionIds.includes(s.happySessionId) ); - expect(daemonSessions.length).toBeGreaterThanOrEqual(3); // Stop all spawned sessions for (const session of daemonSessions) { @@ -324,16 +387,10 @@ describe.skipIf(!await isServerHealthy())('Daemon Integration Tests', { timeout: process.kill(daemonPid, 'SIGKILL'); // Wait for process to die - await new Promise(resolve => setTimeout(resolve, 500)); + await waitFor(async () => !isProcessAlive(daemonPid), 10_000, 250); // Check if process is dead - let isDead = false; - try { - process.kill(daemonPid, 0); - } catch { - isDead = true; - } - expect(isDead).toBe(true); + expect(isProcessAlive(daemonPid)).toBe(false); // Check that log file exists (it was created when daemon started) const finalLogs = readdirSync(logsDir).filter(f => f.endsWith('-daemon.log')); @@ -357,16 +414,10 @@ describe.skipIf(!await isServerHealthy())('Daemon Integration Tests', { timeout: process.kill(daemonPid, 'SIGTERM'); // Wait for graceful shutdown - await new Promise(resolve => setTimeout(resolve, 4_000)); + await waitFor(async () => !isProcessAlive(daemonPid), 15_000, 250); // Check if process is dead - let isDead = false; - try { - process.kill(daemonPid, 0); - } catch { - isDead = true; - } - expect(isDead).toBe(true); + expect(isProcessAlive(daemonPid)).toBe(false); // Read the log file to check for cleanup messages const logContent = readFileSync(logFile.path, 'utf8'); @@ -445,9 +496,12 @@ describe.skipIf(!await isServerHealthy())('Daemon Integration Tests', { timeout: console.log(`[TEST] Changed package.json version to ${testVersion}`); - // The daemon should automatically detect the version mismatch and restart itself - // We check once per minute, wait for a little longer than that - await new Promise(resolve => setTimeout(resolve, parseInt(process.env.HAPPY_DAEMON_HEARTBEAT_INTERVAL || '30000') + 10_000)); + // The daemon should automatically detect the version mismatch and restart itself. + const heartbeatMs = parseInt(process.env.HAPPY_DAEMON_HEARTBEAT_INTERVAL || '30000'); + await waitFor(async () => { + const finalState = await readDaemonState(); + return !!(finalState && finalState.startedWithCliVersion === testVersion && finalState.pid && finalState.pid !== initialPid); + }, Math.min(90_000, heartbeatMs + 70_000), 1000); // Check that the daemon is running with the new version const finalState = await readDaemonState(); @@ -470,4 +524,4 @@ describe.skipIf(!await isServerHealthy())('Daemon Integration Tests', { timeout: // TODO: Test npm uninstall scenario - daemon should gracefully handle when happy-coder is uninstalled // Current behavior: daemon tries to spawn new daemon on version mismatch but dist/index.mjs is gone // Expected: daemon should detect missing entrypoint and either exit cleanly or at minimum not respawn infinitely -}); \ No newline at end of file +}); diff --git a/cli/src/daemon/doctor.test.ts b/cli/src/daemon/doctor.test.ts new file mode 100644 index 000000000..01bd550e4 --- /dev/null +++ b/cli/src/daemon/doctor.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import { classifyHappyProcess } from './doctor'; + +describe('classifyHappyProcess', () => { + it('should ignore unrelated processes with "happy" in the name', () => { + const res = classifyHappyProcess({ pid: 123, name: 'happy-hour', cmd: 'happy-hour --serve' }); + expect(res).toBeNull(); + }); + + it('should detect a daemon process started from dist', () => { + const res = classifyHappyProcess({ + pid: 123, + name: 'node', + cmd: '/usr/bin/node /repo/dist/index.mjs daemon start-sync', + }); + expect(res).not.toBeNull(); + expect(res!.type).toBe('daemon'); + }); + + it('should detect a daemon-spawned session process', () => { + const res = classifyHappyProcess({ + pid: 123, + name: 'node', + cmd: '/usr/bin/node /repo/dist/index.mjs --started-by daemon', + }); + expect(res).not.toBeNull(); + expect(res!.type).toBe('daemon-spawned-session'); + }); + + it('should detect a dev daemon started from tsx', () => { + const res = classifyHappyProcess({ + pid: 123, + name: 'node', + cmd: '/usr/bin/node /repo/node_modules/.bin/tsx src/index.ts daemon start-sync --happy-cli', + }); + expect(res).not.toBeNull(); + expect(res!.type).toBe('dev-daemon'); + }); +}); + diff --git a/cli/src/daemon/doctor.ts b/cli/src/daemon/doctor.ts index 177db5a44..e9a794727 100644 --- a/cli/src/daemon/doctor.ts +++ b/cli/src/daemon/doctor.ts @@ -8,46 +8,59 @@ import psList from 'ps-list'; import spawn from 'cross-spawn'; +export type HappyProcessInfo = { pid: number; command: string; type: string }; + /** * Find all Happy CLI processes (including current process) */ -export async function findAllHappyProcesses(): Promise<Array<{ pid: number, command: string, type: string }>> { +export function classifyHappyProcess(proc: { pid: number; name?: string; cmd?: string }): HappyProcessInfo | null { + const cmd = proc.cmd || ''; + const name = proc.name || ''; + + // NOTE: Be intentionally strict here. This classification is used for PID reuse safety + // (reattach + stopSession). A false positive could cause us to adopt/kill a non-Happy process. + const isHappy = + (name === 'node' && + (cmd.includes('happy-cli') || + cmd.includes('dist/index.mjs') || + cmd.includes('bin/happy.mjs') || + (cmd.includes('tsx') && cmd.includes('src/index.ts') && cmd.includes('happy-cli')))) || + cmd.includes('happy.mjs') || + cmd.includes('happy-coder') || + name === 'happy'; + + if (!isHappy) return null; + + // Classify process type + let type = 'unknown'; + if (proc.pid === process.pid) { + type = 'current'; + } else if (cmd.includes('--version')) { + type = cmd.includes('tsx') ? 'dev-daemon-version-check' : 'daemon-version-check'; + } else if (cmd.includes('daemon start-sync') || cmd.includes('daemon start')) { + type = cmd.includes('tsx') ? 'dev-daemon' : 'daemon'; + } else if (cmd.includes('--started-by daemon')) { + type = cmd.includes('tsx') ? 'dev-daemon-spawned' : 'daemon-spawned-session'; + } else if (cmd.includes('doctor')) { + type = cmd.includes('tsx') ? 'dev-doctor' : 'doctor'; + } else if (cmd.includes('--yolo')) { + type = 'dev-session'; + } else { + type = cmd.includes('tsx') ? 'dev-related' : 'user-session'; + } + + return { pid: proc.pid, command: cmd || name, type }; +} + +export async function findAllHappyProcesses(): Promise<HappyProcessInfo[]> { try { const processes = await psList(); - const allProcesses: Array<{ pid: number, command: string, type: string }> = []; + const allProcesses: HappyProcessInfo[] = []; for (const proc of processes) { - const cmd = proc.cmd || ''; - const name = proc.name || ''; - - // Check if it's a Happy process - const isHappy = name.includes('happy') || - name === 'node' && (cmd.includes('happy-cli') || cmd.includes('dist/index.mjs')) || - cmd.includes('happy.mjs') || - cmd.includes('happy-coder') || - (cmd.includes('tsx') && cmd.includes('src/index.ts') && cmd.includes('happy-cli')); - - if (!isHappy) continue; - - // Classify process type - let type = 'unknown'; - if (proc.pid === process.pid) { - type = 'current'; - } else if (cmd.includes('--version')) { - type = cmd.includes('tsx') ? 'dev-daemon-version-check' : 'daemon-version-check'; - } else if (cmd.includes('daemon start-sync') || cmd.includes('daemon start')) { - type = cmd.includes('tsx') ? 'dev-daemon' : 'daemon'; - } else if (cmd.includes('--started-by daemon')) { - type = cmd.includes('tsx') ? 'dev-daemon-spawned' : 'daemon-spawned-session'; - } else if (cmd.includes('doctor')) { - type = cmd.includes('tsx') ? 'dev-doctor' : 'doctor'; - } else if (cmd.includes('--yolo')) { - type = 'dev-session'; - } else { - type = cmd.includes('tsx') ? 'dev-related' : 'user-session'; - } - - allProcesses.push({ pid: proc.pid, command: cmd || name, type }); + const classified = classifyHappyProcess(proc); + if (!classified) continue; + allProcesses.push(classified); } return allProcesses; @@ -56,6 +69,11 @@ export async function findAllHappyProcesses(): Promise<Array<{ pid: number, comm } } +export async function findHappyProcessByPid(pid: number): Promise<HappyProcessInfo | null> { + const all = await findAllHappyProcesses(); + return all.find((p) => p.pid === pid) ?? null; +} + /** * Find all runaway Happy CLI processes that should be killed */ diff --git a/cli/src/daemon/findRunningTrackedSessionById.test.ts b/cli/src/daemon/findRunningTrackedSessionById.test.ts new file mode 100644 index 000000000..1dd0b999d --- /dev/null +++ b/cli/src/daemon/findRunningTrackedSessionById.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; +import type { TrackedSession } from './types'; +import { findRunningTrackedSessionById } from './findRunningTrackedSessionById'; + +describe('findRunningTrackedSessionById', () => { + it('returns the matching tracked session when PID is alive and hash matches', async () => { + const sessions: TrackedSession[] = [ + { pid: 1, startedBy: 'daemon', happySessionId: 's1', processCommandHash: 'h1' }, + { pid: 2, startedBy: 'daemon', happySessionId: 's2', processCommandHash: 'h2' }, + ]; + + const found = await findRunningTrackedSessionById({ + sessions, + happySessionId: 's2', + isPidAlive: async (pid) => pid === 2, + getProcessCommandHash: async (pid) => (pid === 2 ? 'h2' : null), + }); + + expect(found?.pid).toBe(2); + expect(found?.happySessionId).toBe('s2'); + }); + + it('returns null when PID is not alive', async () => { + const sessions: TrackedSession[] = [ + { pid: 2, startedBy: 'daemon', happySessionId: 's2', processCommandHash: 'h2' }, + ]; + + const found = await findRunningTrackedSessionById({ + sessions, + happySessionId: 's2', + isPidAlive: async () => false, + getProcessCommandHash: async () => 'h2', + }); + + expect(found).toBeNull(); + }); + + it('returns null when command hash mismatches', async () => { + const sessions: TrackedSession[] = [ + { pid: 2, startedBy: 'daemon', happySessionId: 's2', processCommandHash: 'h2' }, + ]; + + const found = await findRunningTrackedSessionById({ + sessions, + happySessionId: 's2', + isPidAlive: async () => true, + getProcessCommandHash: async () => 'DIFFERENT', + }); + + expect(found).toBeNull(); + }); +}); diff --git a/cli/src/daemon/findRunningTrackedSessionById.ts b/cli/src/daemon/findRunningTrackedSessionById.ts new file mode 100644 index 000000000..2787a4994 --- /dev/null +++ b/cli/src/daemon/findRunningTrackedSessionById.ts @@ -0,0 +1,29 @@ +import type { TrackedSession } from './types'; + +export async function findRunningTrackedSessionById(opts: { + sessions: Iterable<TrackedSession>; + happySessionId: string; + isPidAlive: (pid: number) => Promise<boolean>; + getProcessCommandHash: (pid: number) => Promise<string | null>; +}): Promise<TrackedSession | null> { + const target = opts.happySessionId.trim(); + if (!target) return null; + + for (const s of opts.sessions) { + if (s.happySessionId !== target) continue; + + const alive = await opts.isPidAlive(s.pid); + if (!alive) continue; + + // If we have a hash, require it to match to avoid PID reuse false positives. + if (s.processCommandHash) { + const current = await opts.getProcessCommandHash(s.pid); + if (!current) continue; + if (current !== s.processCommandHash) continue; + } + + return s; + } + + return null; +} diff --git a/cli/src/daemon/lifecycle/heartbeat.ts b/cli/src/daemon/lifecycle/heartbeat.ts new file mode 100644 index 000000000..b55a67fec --- /dev/null +++ b/cli/src/daemon/lifecycle/heartbeat.ts @@ -0,0 +1,199 @@ +import { readFileSync } from 'fs'; +import { join } from 'path'; + +import type { ApiMachineClient } from '@/api/apiMachine'; +import type { DaemonLocallyPersistedState } from '@/persistence'; +import { readDaemonState, writeDaemonState } from '@/persistence'; +import { projectPath } from '@/projectPath'; +import { logger } from '@/ui/logger'; +import { spawnHappyCLI } from '@/utils/spawnHappyCLI'; +import { writeSessionExitReport } from '@/daemon/sessionExitReport'; + +import { reportDaemonObservedSessionExit } from '../sessionTermination'; +import type { TrackedSession } from '../types'; +import { removeSessionMarker } from '../sessionRegistry'; + +export function startDaemonHeartbeatLoop(params: Readonly<{ + pidToTrackedSession: Map<number, TrackedSession>; + spawnResourceCleanupByPid: Map<number, () => void>; + sessionAttachCleanupByPid: Map<number, () => Promise<void>>; + getApiMachineForSessions: () => ApiMachineClient | null; + controlPort: number; + fileState: DaemonLocallyPersistedState; + currentCliVersion: string; + requestShutdown: (source: 'happy-app' | 'happy-cli' | 'os-signal' | 'exception', errorMessage?: string) => void; +}>): NodeJS.Timeout { + const { + pidToTrackedSession, + spawnResourceCleanupByPid, + sessionAttachCleanupByPid, + getApiMachineForSessions, + controlPort, + fileState, + currentCliVersion, + requestShutdown, + } = params; + + // Every 60 seconds: + // 1. Prune stale sessions + // 2. Check if daemon needs update + // 3. If outdated, restart with latest version + // 4. Write heartbeat + const heartbeatIntervalMs = parseInt(process.env.HAPPY_DAEMON_HEARTBEAT_INTERVAL || '60000'); + let heartbeatRunning = false; + + const intervalHandle = setInterval(async () => { + if (heartbeatRunning) { + return; + } + heartbeatRunning = true; + + if (process.env.DEBUG) { + logger.debug(`[DAEMON RUN] Health check started at ${new Date().toLocaleString()}`); + } + + // Prune stale sessions + for (const [pid, _] of pidToTrackedSession.entries()) { + try { + // Check if process is still alive (signal 0 doesn't kill, just checks) + process.kill(pid, 0); + } catch (error) { + // Process is dead, remove from tracking + logger.debug(`[DAEMON RUN] Removing stale session with PID ${pid} (process no longer exists)`); + const tracked = pidToTrackedSession.get(pid); + if (tracked) { + const apiMachine = getApiMachineForSessions(); + if (apiMachine) { + reportDaemonObservedSessionExit({ + apiMachine, + trackedSession: tracked, + now: () => Date.now(), + exit: { reason: 'process-missing', code: null, signal: null }, + }); + } + void writeSessionExitReport({ + sessionId: tracked.happySessionId ?? null, + pid, + report: { + observedAt: Date.now(), + observedBy: 'daemon', + reason: 'process-missing', + code: null, + signal: null, + }, + }).catch((e) => logger.debug('[DAEMON RUN] Failed to write session exit report', e)); + } + const cleanup = spawnResourceCleanupByPid.get(pid); + if (cleanup) { + spawnResourceCleanupByPid.delete(pid); + try { + cleanup(); + } catch (cleanupError) { + logger.debug('[DAEMON RUN] Failed to cleanup spawn resources', cleanupError); + } + } + const attachCleanup = sessionAttachCleanupByPid.get(pid); + if (attachCleanup) { + sessionAttachCleanupByPid.delete(pid); + try { + await attachCleanup(); + } catch (cleanupError) { + logger.debug('[DAEMON RUN] Failed to cleanup session attach file', cleanupError); + } + } + pidToTrackedSession.delete(pid); + void removeSessionMarker(pid); + } + } + + // Cleanup any spawn resources for sessions no longer tracked (e.g. stopSession removed them). + for (const [pid, cleanup] of spawnResourceCleanupByPid.entries()) { + if (pidToTrackedSession.has(pid)) continue; + try { + process.kill(pid, 0); + } catch { + spawnResourceCleanupByPid.delete(pid); + try { + cleanup(); + } catch (cleanupError) { + logger.debug('[DAEMON RUN] Failed to cleanup spawn resources', cleanupError); + } + } + } + + for (const [pid, cleanup] of sessionAttachCleanupByPid.entries()) { + if (pidToTrackedSession.has(pid)) continue; + try { + process.kill(pid, 0); + } catch { + sessionAttachCleanupByPid.delete(pid); + try { + await cleanup(); + } catch (cleanupError) { + logger.debug('[DAEMON RUN] Failed to cleanup session attach file', cleanupError); + } + } + } + + // Check if daemon needs update + // If version on disk is different from the one in package.json - we need to restart + // BIG if - does this get updated from underneath us on npm upgrade? + const projectVersion = JSON.parse(readFileSync(join(projectPath(), 'package.json'), 'utf-8')).version; + if (projectVersion !== currentCliVersion) { + logger.debug('[DAEMON RUN] Daemon is outdated, triggering self-restart with latest version, clearing heartbeat interval'); + + clearInterval(intervalHandle); + + // Spawn new daemon through the CLI + // We do not need to clean ourselves up - we will be killed by + // the CLI start command. + // 1. It will first check if daemon is running (yes in this case) + // 2. If the version is stale (it will read daemon.state.json file and check startedWithCliVersion) & compare it to its own version + // 3. Next it will start a new daemon with the latest version with daemon-sync :D + // Done! + try { + spawnHappyCLI(['daemon', 'start'], { + detached: true, + stdio: 'ignore' + }); + } catch (error) { + logger.debug('[DAEMON RUN] Failed to spawn new daemon, this is quite likely to happen during integration tests as we are cleaning out dist/ directory', error); + } + + // So we can just hang forever + logger.debug('[DAEMON RUN] Hanging for a bit - waiting for CLI to kill us because we are running outdated version of the code'); + await new Promise(resolve => setTimeout(resolve, 10_000)); + process.exit(0); + } + + // Before wrecklessly overriting the daemon state file, we should check if we are the ones who own it + // Race condition is possible, but thats okay for the time being :D + const daemonState = await readDaemonState(); + if (daemonState && daemonState.pid !== process.pid) { + logger.debug('[DAEMON RUN] Somehow a different daemon was started without killing us. We should kill ourselves.') + requestShutdown('exception', 'A different daemon was started without killing us. We should kill ourselves.') + } + + // Heartbeat + try { + const updatedState: DaemonLocallyPersistedState = { + pid: process.pid, + httpPort: controlPort, + startTime: fileState.startTime, + startedWithCliVersion: fileState.startedWithCliVersion, + lastHeartbeat: new Date().toLocaleString(), + daemonLogPath: fileState.daemonLogPath + }; + writeDaemonState(updatedState); + if (process.env.DEBUG) { + logger.debug(`[DAEMON RUN] Health check completed at ${updatedState.lastHeartbeat}`); + } + } catch (error) { + logger.debug('[DAEMON RUN] Failed to write heartbeat', error); + } + + heartbeatRunning = false; + }, heartbeatIntervalMs); // Every 60 seconds in production + + return intervalHandle; +} diff --git a/cli/src/daemon/lifecycle/shutdown.ts b/cli/src/daemon/lifecycle/shutdown.ts new file mode 100644 index 000000000..0e5095b4f --- /dev/null +++ b/cli/src/daemon/lifecycle/shutdown.ts @@ -0,0 +1,61 @@ +import { logger } from '@/ui/logger'; + +export type DaemonShutdownSource = 'happy-app' | 'happy-cli' | 'os-signal' | 'exception'; + +export type DaemonShutdownRequest = { + source: DaemonShutdownSource; + errorMessage?: string; +}; + +export function createDaemonShutdownController(): { + requestShutdown: (source: DaemonShutdownSource, errorMessage?: string) => void; + resolvesWhenShutdownRequested: Promise<DaemonShutdownRequest>; +} { + // In case the setup malfunctions - our signal handlers will not properly + // shut down. We will force exit the process with code 1. + let requestShutdown: (source: DaemonShutdownSource, errorMessage?: string) => void; + const resolvesWhenShutdownRequested = new Promise<DaemonShutdownRequest>((resolve) => { + requestShutdown = (source, errorMessage) => { + logger.debug(`[DAEMON RUN] Requesting shutdown (source: ${source}, errorMessage: ${errorMessage})`); + + // Start graceful shutdown + resolve({ source, errorMessage }); + }; + }); + + // Setup signal handlers + process.on('SIGINT', () => { + logger.debug('[DAEMON RUN] Received SIGINT'); + requestShutdown('os-signal'); + }); + + process.on('SIGTERM', () => { + logger.debug('[DAEMON RUN] Received SIGTERM'); + requestShutdown('os-signal'); + }); + + process.on('uncaughtException', (error) => { + logger.debug('[DAEMON RUN] FATAL: Uncaught exception', error); + logger.debug(`[DAEMON RUN] Stack trace: ${error.stack}`); + requestShutdown('exception', error.message); + }); + + process.on('unhandledRejection', (reason, promise) => { + logger.debug('[DAEMON RUN] FATAL: Unhandled promise rejection', reason); + logger.debug(`[DAEMON RUN] Rejected promise:`, promise); + const error = reason instanceof Error ? reason : new Error(`Unhandled promise rejection: ${reason}`); + logger.debug(`[DAEMON RUN] Stack trace: ${error.stack}`); + requestShutdown('exception', error.message); + }); + + process.on('exit', (code) => { + logger.debug(`[DAEMON RUN] Process exiting with code: ${code}`); + }); + + process.on('beforeExit', (code) => { + logger.debug(`[DAEMON RUN] Process about to exit with code: ${code}`); + }); + + return { requestShutdown: requestShutdown!, resolvesWhenShutdownRequested }; +} + diff --git a/cli/src/daemon/machine/metadata.ts b/cli/src/daemon/machine/metadata.ts new file mode 100644 index 000000000..b3359b409 --- /dev/null +++ b/cli/src/daemon/machine/metadata.ts @@ -0,0 +1,43 @@ +import os from 'os'; +import { execFile } from 'child_process'; +import { promisify } from 'util'; + +import { configuration } from '@/configuration'; +import { projectPath } from '@/projectPath'; +import type { MachineMetadata } from '@/api/types'; +import packageJson from '../../../package.json'; + +const execFileAsync = promisify(execFile); + +export async function getPreferredHostName(): Promise<string> { + const fallback = os.hostname(); + if (process.platform !== 'darwin') { + return fallback; + } + + const tryScutil = async (key: 'HostName' | 'LocalHostName' | 'ComputerName'): Promise<string | null> => { + try { + const { stdout } = await execFileAsync('scutil', ['--get', key], { timeout: 400 }); + const value = typeof stdout === 'string' ? stdout.trim() : ''; + return value.length > 0 ? value : null; + } catch { + return null; + } + }; + + // Prefer HostName (can be FQDN) → LocalHostName → ComputerName → os.hostname() + return (await tryScutil('HostName')) + ?? (await tryScutil('LocalHostName')) + ?? (await tryScutil('ComputerName')) + ?? fallback; +} + +export const initialMachineMetadata: MachineMetadata = { + host: os.hostname(), + platform: os.platform(), + happyCliVersion: packageJson.version, + homeDir: os.homedir(), + happyHomeDir: configuration.happyHomeDir, + happyLibDir: projectPath(), +}; + diff --git a/cli/src/daemon/pidSafety.real.integration.test.ts b/cli/src/daemon/pidSafety.real.integration.test.ts new file mode 100644 index 000000000..0a208c330 --- /dev/null +++ b/cli/src/daemon/pidSafety.real.integration.test.ts @@ -0,0 +1,83 @@ +/** + * Opt-in daemon reattach integration tests. + * + * These tests spawn real processes and rely on `ps-list` classification. + * + * Enable with: `HAPPY_CLI_DAEMON_REATTACH_INTEGRATION=1` + */ + +import { afterEach, describe, expect, it } from 'vitest'; +import { spawn } from 'node:child_process'; +import { isPidSafeHappySessionProcess } from './pidSafety'; +import { findHappyProcessByPid } from './doctor'; +import { hashProcessCommand } from './sessionRegistry'; + +function shouldRunDaemonReattachIntegration(): boolean { + return process.env.HAPPY_CLI_DAEMON_REATTACH_INTEGRATION === '1'; +} + +function spawnHappyLookingProcess(): { pid: number; kill: () => void } { + // Important: We need `ps-list` to classify this as a Happy session process. + // `doctor.classifyHappyProcess` considers a process "happy" if cmd includes "happy-cli", + // and marks it as daemon-spawned-session if cmd includes "--started-by daemon". + const child = spawn( + process.execPath, + ['-e', 'setInterval(() => {}, 1_000_000)', 'happy-cli', '--started-by', 'daemon'], + { stdio: 'ignore' }, + ); + if (!child.pid) throw new Error('Failed to spawn test process'); + return { + pid: child.pid, + kill: () => { + try { + child.kill('SIGTERM'); + } catch { + // ignore + } + }, + }; +} + +async function waitForHappyProcess(pid: number, timeoutMs: number): Promise<Awaited<ReturnType<typeof findHappyProcessByPid>>> { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const proc = await findHappyProcessByPid(pid); + if (proc) return proc; + await new Promise((r) => setTimeout(r, 100)); + } + return null; +} + +describe.skipIf(!shouldRunDaemonReattachIntegration())('pidSafety (real) integration tests (opt-in)', { timeout: 20_000 }, () => { + const spawned: Array<() => void> = []; + + afterEach(() => { + for (const k of spawned.splice(0)) k(); + }); + + it('returns true when PID is a Happy session process and command hash matches', async () => { + const p = spawnHappyLookingProcess(); + spawned.push(p.kill); + + const proc = await waitForHappyProcess(p.pid, 5_000); + expect(proc).not.toBeNull(); + if (!proc) return; + + const expected = hashProcessCommand(proc.command); + await expect(isPidSafeHappySessionProcess({ pid: p.pid, expectedProcessCommandHash: expected })).resolves.toBe(true); + }); + + it('returns false when command hash mismatches (PID reuse safety)', async () => { + const p = spawnHappyLookingProcess(); + spawned.push(p.kill); + + const proc = await waitForHappyProcess(p.pid, 5_000); + expect(proc).not.toBeNull(); + if (!proc) return; + + const wrong = '0'.repeat(64); + expect(hashProcessCommand(proc.command)).not.toBe(wrong); + await expect(isPidSafeHappySessionProcess({ pid: p.pid, expectedProcessCommandHash: wrong })).resolves.toBe(false); + }); +}); + diff --git a/cli/src/daemon/pidSafety.ts b/cli/src/daemon/pidSafety.ts new file mode 100644 index 000000000..8a55a1d2b --- /dev/null +++ b/cli/src/daemon/pidSafety.ts @@ -0,0 +1,24 @@ +import { findHappyProcessByPid } from './doctor'; +import { hashProcessCommand } from './sessionRegistry'; + +// IMPORTANT: keep this strict. A false positive here could cause us to adopt/kill an unrelated process. +export const ALLOWED_HAPPY_SESSION_PROCESS_TYPES = new Set([ + 'daemon-spawned-session', + 'user-session', + 'dev-daemon-spawned', + 'dev-session', +]); + +export async function isPidSafeHappySessionProcess(params: { + pid: number; + expectedProcessCommandHash?: string; +}): Promise<boolean> { + const proc = await findHappyProcessByPid(params.pid); + if (!proc || !ALLOWED_HAPPY_SESSION_PROCESS_TYPES.has(proc.type)) return false; + + if (params.expectedProcessCommandHash) { + return hashProcessCommand(proc.command) === params.expectedProcessCommandHash; + } + + return true; +} diff --git a/cli/src/daemon/platform/tmux/spawnConfig.ts b/cli/src/daemon/platform/tmux/spawnConfig.ts new file mode 100644 index 000000000..10a9956c0 --- /dev/null +++ b/cli/src/daemon/platform/tmux/spawnConfig.ts @@ -0,0 +1,53 @@ +import { buildHappyCliSubprocessInvocation } from '@/utils/spawnHappyCLI'; +import type { CatalogAgentId } from '@/backends/types'; + +export function buildTmuxWindowEnv( + daemonEnv: NodeJS.ProcessEnv, + extraEnv: Record<string, string>, +): Record<string, string> { + const filteredDaemonEnv = Object.fromEntries( + Object.entries(daemonEnv).filter(([, value]) => typeof value === 'string'), + ) as Record<string, string>; + + return { ...filteredDaemonEnv, ...extraEnv }; +} + +export function buildTmuxSpawnConfig(params: { + agent: CatalogAgentId; + directory: string; + extraEnv: Record<string, string>; + tmuxCommandEnv?: Record<string, string>; + extraArgs?: string[]; +}): { + commandTokens: string[]; + tmuxEnv: Record<string, string>; + tmuxCommandEnv: Record<string, string>; + directory: string; +} { + const args = [ + params.agent, + '--happy-starting-mode', + 'remote', + '--started-by', + 'daemon', + ...(params.extraArgs ?? []), + ]; + + const { runtime, argv } = buildHappyCliSubprocessInvocation(args); + const commandTokens = [runtime, ...argv]; + + const tmuxEnv = buildTmuxWindowEnv(process.env, params.extraEnv); + + const tmuxCommandEnv: Record<string, string> = { ...(params.tmuxCommandEnv ?? {}) }; + const tmuxTmpDir = tmuxCommandEnv.TMUX_TMPDIR; + if (typeof tmuxTmpDir !== 'string' || tmuxTmpDir.length === 0) { + delete tmuxCommandEnv.TMUX_TMPDIR; + } + + return { + commandTokens, + tmuxEnv, + tmuxCommandEnv, + directory: params.directory, + }; +} diff --git a/cli/src/daemon/reattach.real.integration.test.ts b/cli/src/daemon/reattach.real.integration.test.ts new file mode 100644 index 000000000..2a760dd2e --- /dev/null +++ b/cli/src/daemon/reattach.real.integration.test.ts @@ -0,0 +1,155 @@ +/** + * Opt-in daemon reattach integration tests. + * + * These tests spawn real processes and rely on `ps-list` classification. + * + * Enable with: `HAPPY_CLI_DAEMON_REATTACH_INTEGRATION=1` + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { spawn } from 'node:child_process'; +import type { Metadata } from '@/api/types'; + +function shouldRunDaemonReattachIntegration(): boolean { + return process.env.HAPPY_CLI_DAEMON_REATTACH_INTEGRATION === '1'; +} + +function spawnHappyLookingProcess(): { pid: number; kill: () => void } { + const child = spawn( + process.execPath, + ['-e', 'setInterval(() => {}, 1_000_000)', 'happy-cli', '--started-by', 'daemon'], + { stdio: 'ignore' }, + ); + if (!child.pid) throw new Error('Failed to spawn test process'); + return { + pid: child.pid, + kill: () => { + try { + child.kill('SIGTERM'); + } catch { + // ignore + } + }, + }; +} + +describe.skipIf(!shouldRunDaemonReattachIntegration())( + 'reattach (real) integration tests (opt-in)', + { timeout: 20_000 }, + () => { + const originalHappyHomeDir = process.env.HAPPY_HOME_DIR; + const spawned: Array<() => void> = []; + const tempHomes: string[] = []; + + beforeEach(() => { + const home = mkdtempSync(join(tmpdir(), 'happy-cli-daemon-reattach-test-')); + tempHomes.push(home); + process.env.HAPPY_HOME_DIR = home; + vi.resetModules(); + }); + + afterEach(() => { + for (const k of spawned.splice(0)) k(); + for (const home of tempHomes.splice(0)) { + rmSync(home, { recursive: true, force: true }); + } + if (originalHappyHomeDir === undefined) { + delete process.env.HAPPY_HOME_DIR; + } else { + process.env.HAPPY_HOME_DIR = originalHappyHomeDir; + } + vi.resetModules(); + }); + + it('adopts a marker only when PID is alive and command hash matches', async () => { + const { adoptSessionsFromMarkers } = await import('./reattach'); + const { findAllHappyProcesses, findHappyProcessByPid } = await import('./doctor'); + const { hashProcessCommand, listSessionMarkers, writeSessionMarker } = await import('./sessionRegistry'); + + const p = spawnHappyLookingProcess(); + spawned.push(p.kill); + + // Wait for ps-list to see it (best-effort). + const start = Date.now(); + let proc = null as Awaited<ReturnType<typeof findHappyProcessByPid>>; + while (Date.now() - start < 5_000) { + proc = await findHappyProcessByPid(p.pid); + if (proc) break; + await new Promise((r) => setTimeout(r, 100)); + } + expect(proc).not.toBeNull(); + if (!proc) return; + + const metadata: Metadata = { + path: '/tmp', + host: 'test-host', + homeDir: '/tmp', + happyHomeDir: process.env.HAPPY_HOME_DIR!, + happyLibDir: '/tmp', + happyToolsDir: '/tmp', + hostPid: p.pid, + startedBy: 'terminal', + machineId: 'test-machine', + }; + + await writeSessionMarker({ + pid: p.pid, + happySessionId: 'sess-1', + startedBy: 'terminal', + cwd: '/tmp', + processCommandHash: hashProcessCommand(proc.command), + processCommand: proc.command, + metadata, + }); + + const markers = await listSessionMarkers(); + expect(markers).toHaveLength(1); + + const happyProcesses = await findAllHappyProcesses(); + const map = new Map<number, any>(); + const { adopted } = adoptSessionsFromMarkers({ markers, happyProcesses, pidToTrackedSession: map }); + expect(adopted).toBe(1); + expect(map.get(p.pid)?.reattachedFromDiskMarker).toBe(true); + expect(map.get(p.pid)?.processCommandHash).toBe(hashProcessCommand(proc.command)); + }); + + it('does not adopt when marker hash mismatches (fail-closed)', async () => { + const { adoptSessionsFromMarkers } = await import('./reattach'); + const { findAllHappyProcesses, findHappyProcessByPid } = await import('./doctor'); + const { listSessionMarkers, writeSessionMarker } = await import('./sessionRegistry'); + + const p = spawnHappyLookingProcess(); + spawned.push(p.kill); + + // Wait until ps-list sees the process (avoid flakiness). + const start = Date.now(); + let proc = null as Awaited<ReturnType<typeof findHappyProcessByPid>>; + while (Date.now() - start < 5_000) { + proc = await findHappyProcessByPid(p.pid); + if (proc) break; + await new Promise((r) => setTimeout(r, 100)); + } + expect(proc).not.toBeNull(); + if (!proc) return; + + await writeSessionMarker({ + pid: p.pid, + happySessionId: 'sess-2', + startedBy: 'terminal', + processCommandHash: '0'.repeat(64), + processCommand: proc.command, + }); + + const markers = await listSessionMarkers(); + const happyProcesses = await findAllHappyProcesses(); + const map = new Map<number, any>(); + const { adopted } = adoptSessionsFromMarkers({ markers, happyProcesses, pidToTrackedSession: map }); + expect(adopted).toBe(0); + expect(map.size).toBe(0); + }); + }, +); + diff --git a/cli/src/daemon/reattach.ts b/cli/src/daemon/reattach.ts new file mode 100644 index 000000000..5c3b63bb0 --- /dev/null +++ b/cli/src/daemon/reattach.ts @@ -0,0 +1,49 @@ +import { ALLOWED_HAPPY_SESSION_PROCESS_TYPES } from './pidSafety'; +import type { HappyProcessInfo } from './doctor'; +import type { DaemonSessionMarker } from './sessionRegistry'; +import { hashProcessCommand } from './sessionRegistry'; +import type { TrackedSession } from './types'; + +export function adoptSessionsFromMarkers(params: { + markers: DaemonSessionMarker[]; + happyProcesses: HappyProcessInfo[]; + pidToTrackedSession: Map<number, TrackedSession>; +}): { adopted: number; eligible: number } { + const happyPidToType = new Map(params.happyProcesses.map((p) => [p.pid, p.type] as const)); + const happyPidToCommandHash = new Map(params.happyProcesses.map((p) => [p.pid, hashProcessCommand(p.command)] as const)); + + let adopted = 0; + let eligible = 0; + + for (const marker of params.markers) { + // Safety: avoid PID reuse adopting an unrelated process. Only adopt if PID currently looks + // like a Happy session process (best-effort cross-platform via ps-list classification). + const procType = happyPidToType.get(marker.pid); + if (!procType || !ALLOWED_HAPPY_SESSION_PROCESS_TYPES.has(procType)) { + continue; + } + eligible++; + + // Stronger PID reuse safety: require the marker's observed command hash to match what is currently running. + if (!marker.processCommandHash) { + continue; + } + const currentHash = happyPidToCommandHash.get(marker.pid); + if (!currentHash || currentHash !== marker.processCommandHash) { + continue; + } + + if (params.pidToTrackedSession.has(marker.pid)) continue; + params.pidToTrackedSession.set(marker.pid, { + startedBy: marker.startedBy ?? 'reattached', + happySessionId: marker.happySessionId, + happySessionMetadataFromLocalWebhook: marker.metadata, + pid: marker.pid, + processCommandHash: marker.processCommandHash, + reattachedFromDiskMarker: true, + }); + adopted++; + } + + return { adopted, eligible }; +} diff --git a/cli/src/daemon/run.noninteractiveAuth.test.ts b/cli/src/daemon/run.noninteractiveAuth.test.ts new file mode 100644 index 000000000..34088b999 --- /dev/null +++ b/cli/src/daemon/run.noninteractiveAuth.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { spawn } from 'node:child_process'; +import { existsSync } from 'node:fs'; + +import { projectPath } from '@/projectPath'; + +function runNode(args: string[], env: NodeJS.ProcessEnv, timeoutMs: number) { + return new Promise<{ code: number; stdout: string; stderr: string }>((resolve, reject) => { + const child = spawn(process.execPath, args, { + env, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (d) => (stdout += String(d))); + child.stderr.on('data', (d) => (stderr += String(d))); + const t = setTimeout(() => { + try { + child.kill('SIGKILL'); + } catch { + // ignore + } + reject(new Error(`timed out after ${timeoutMs}ms\nstdout:\n${stdout}\nstderr:\n${stderr}`)); + }, timeoutMs); + child.on('error', (e) => { + clearTimeout(t); + reject(e); + }); + child.on('exit', (code) => { + clearTimeout(t); + resolve({ code: code ?? 0, stdout, stderr }); + }); + }); +} + +describe('daemon start-sync auth gating', () => { + it('fails fast without creating a lock when started non-interactively with no credentials', async () => { + const home = await mkdtemp(join(tmpdir(), 'happy-cli-home-')); + const entry = join(projectPath(), 'dist', 'index.mjs'); + + const env: NodeJS.ProcessEnv = { + ...process.env, + HAPPY_HOME_DIR: home, + // Ensure we do not accidentally hit real infra + HAPPY_SERVER_URL: 'http://127.0.0.1:9', + HAPPY_WEBAPP_URL: 'http://127.0.0.1:9', + DEBUG: '1', + }; + + try { + const res = await runNode([entry, 'daemon', 'start-sync'], env, 3000); + expect(res.code).not.toBe(0); + expect(existsSync(join(home, 'daemon.state.json.lock'))).toBe(false); + } finally { + await rm(home, { recursive: true, force: true }); + } + }); +}); + diff --git a/cli/src/daemon/run.tmuxEnv.test.ts b/cli/src/daemon/run.tmuxEnv.test.ts new file mode 100644 index 000000000..7f13a0c3f --- /dev/null +++ b/cli/src/daemon/run.tmuxEnv.test.ts @@ -0,0 +1,16 @@ +import { describe, it, expect } from 'vitest'; + +describe('daemon tmux env building', () => { + it('merges daemon process env and profile env for tmux windows', async () => { + const runModule = (await import('@/daemon/run')) as typeof import('@/daemon/run'); + const merged = runModule.buildTmuxWindowEnv( + { PATH: '/bin', HOME: '/home/user', UNDEFINED: undefined }, + { HOME: '/override', CUSTOM: 'x' } + ); + + expect(merged.PATH).toBe('/bin'); + expect(merged.HOME).toBe('/override'); + expect(merged.CUSTOM).toBe('x'); + expect('UNDEFINED' in merged).toBe(false); + }, 15000); +}); diff --git a/cli/src/daemon/run.tmuxSpawn.test.ts b/cli/src/daemon/run.tmuxSpawn.test.ts new file mode 100644 index 000000000..2fc667f82 --- /dev/null +++ b/cli/src/daemon/run.tmuxSpawn.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect } from 'vitest'; + +describe('daemon tmux spawn config', () => { + const originalRuntimeOverride = process.env.HAPPY_CLI_SUBPROCESS_RUNTIME; + const originalPath = process.env.PATH; + + it('uses merged env and bun runtime when configured', async () => { + process.env.HAPPY_CLI_SUBPROCESS_RUNTIME = 'bun'; + process.env.PATH = '/bin'; + + try { + const runModule = (await import('@/daemon/run')) as typeof import('@/daemon/run'); + const cfg = runModule.buildTmuxSpawnConfig({ + agent: 'claude', + directory: '/tmp', + extraEnv: { + FOO: 'bar', + }, + tmuxCommandEnv: { + TMUX_TMPDIR: '/custom/tmux', + }, + extraArgs: ['--happy-terminal-mode', 'tmux'], + }); + + expect(cfg.commandTokens[0]).toBe('bun'); + expect(cfg.tmuxEnv.PATH).toBe('/bin'); + expect(cfg.tmuxEnv.FOO).toBe('bar'); + expect(cfg.tmuxCommandEnv.TMUX_TMPDIR).toBe('/custom/tmux'); + expect(cfg.commandTokens).toEqual(expect.arrayContaining(['--happy-terminal-mode', 'tmux'])); + } finally { + if (originalRuntimeOverride === undefined) { + delete process.env.HAPPY_CLI_SUBPROCESS_RUNTIME; + } else { + process.env.HAPPY_CLI_SUBPROCESS_RUNTIME = originalRuntimeOverride; + } + if (originalPath === undefined) { + delete process.env.PATH; + } else { + process.env.PATH = originalPath; + } + } + }, 15000); +}); diff --git a/cli/src/daemon/run.ts b/cli/src/daemon/run.ts index 75889d14e..82d109396 100644 --- a/cli/src/daemon/run.ts +++ b/cli/src/daemon/run.ts @@ -1,69 +1,52 @@ import fs from 'fs/promises'; import os from 'os'; -import * as tmp from 'tmp'; import { ApiClient } from '@/api/api'; +import type { ApiMachineClient } from '@/api/apiMachine'; import { TrackedSession } from './types'; -import { MachineMetadata, DaemonState, Metadata } from '@/api/types'; -import { SpawnSessionOptions, SpawnSessionResult } from '@/modules/common/registerCommonHandlers'; +import { MachineMetadata, DaemonState } from '@/api/types'; +import { SpawnSessionOptions, SpawnSessionResult } from '@/rpc/handlers/registerSessionHandlers'; import { logger } from '@/ui/logger'; import { authAndSetupMachineIfNeeded } from '@/ui/auth'; import { configuration } from '@/configuration'; -import { startCaffeinate, stopCaffeinate } from '@/utils/caffeinate'; +import { startCaffeinate, stopCaffeinate } from '@/integrations/caffeinate'; import packageJson from '../../package.json'; import { getEnvironmentInfo } from '@/ui/doctor'; import { spawnHappyCLI } from '@/utils/spawnHappyCLI'; -import { writeDaemonState, DaemonLocallyPersistedState, readDaemonState, acquireDaemonLock, releaseDaemonLock, readSettings, getActiveProfile, getEnvironmentVariables, validateProfileForAgent, getProfileEnvironmentVariables } from '@/persistence'; +import { AGENTS, getVendorResumeSupport, resolveAgentCliSubcommand, resolveCatalogAgentId } from '@/backends/catalog'; +import { + writeDaemonState, + DaemonLocallyPersistedState, + acquireDaemonLock, + releaseDaemonLock, + readSettings, + readCredentials, +} from '@/persistence'; +import { createSessionAttachFile } from './sessionAttachFile'; +import { getDaemonShutdownExitCode, getDaemonShutdownWatchdogTimeoutMs } from './shutdownPolicy'; import { cleanupDaemonState, isDaemonRunningCurrentlyInstalledHappyVersion, stopDaemon } from './controlClient'; import { startDaemonControlServer } from './controlServer'; -import { readFileSync } from 'fs'; -import { join } from 'path'; +import { findHappyProcessByPid } from './doctor'; +import { hashProcessCommand } from './sessionRegistry'; +import { findRunningTrackedSessionById } from './findRunningTrackedSessionById'; +import { reattachTrackedSessionsFromMarkers } from './sessions/reattachFromMarkers'; +import { createOnHappySessionWebhook } from './sessions/onHappySessionWebhook'; +import { createOnChildExited } from './sessions/onChildExited'; +import { createStopSession } from './sessions/stopSession'; +import { startDaemonHeartbeatLoop } from './lifecycle/heartbeat'; import { projectPath } from '@/projectPath'; -import { getTmuxUtilities, isTmuxAvailable, parseTmuxSessionIdentifier, formatTmuxSessionIdentifier } from '@/utils/tmux'; +import { selectPreferredTmuxSessionName, TmuxUtilities, isTmuxAvailable } from '@/integrations/tmux'; import { expandEnvironmentVariables } from '@/utils/expandEnvVars'; - -// Prepare initial metadata -export const initialMachineMetadata: MachineMetadata = { - host: os.hostname(), - platform: os.platform(), - happyCliVersion: packageJson.version, - homeDir: os.homedir(), - happyHomeDir: configuration.happyHomeDir, - happyLibDir: projectPath() -}; - -// Get environment variables for a profile, filtered for agent compatibility -async function getProfileEnvironmentVariablesForAgent( - profileId: string, - agentType: 'claude' | 'codex' | 'gemini' -): Promise<Record<string, string>> { - try { - const settings = await readSettings(); - const profile = settings.profiles.find(p => p.id === profileId); - - if (!profile) { - logger.debug(`[DAEMON RUN] Profile ${profileId} not found`); - return {}; - } - - // Check if profile is compatible with the agent - if (!validateProfileForAgent(profile, agentType)) { - logger.debug(`[DAEMON RUN] Profile ${profileId} not compatible with agent ${agentType}`); - return {}; - } - - // Get environment variables from profile (new schema) - const envVars = getProfileEnvironmentVariables(profile); - - logger.debug(`[DAEMON RUN] Loaded ${Object.keys(envVars).length} environment variables from profile ${profileId} for agent ${agentType}`); - return envVars; - } catch (error) { - logger.debug('[DAEMON RUN] Failed to get profile environment variables:', error); - return {}; - } -} - +import { resolveTerminalRequestFromSpawnOptions } from '@/terminal/terminalConfig'; +import { validateEnvVarRecordStrict } from '@/terminal/envVarSanitization'; + +import { getPreferredHostName, initialMachineMetadata } from './machine/metadata'; +export { initialMachineMetadata } from './machine/metadata'; +import { createDaemonShutdownController } from './lifecycle/shutdown'; +import { buildTmuxSpawnConfig, buildTmuxWindowEnv } from './platform/tmux/spawnConfig'; +export { buildTmuxSpawnConfig, buildTmuxWindowEnv } from './platform/tmux/spawnConfig'; +import { SPAWN_SESSION_ERROR_CODES } from '@/rpc/handlers/registerSessionHandlers'; export async function startDaemon(): Promise<void> { // We don't have cleanup function at the time of server construction // Control flow is: @@ -72,64 +55,13 @@ export async function startDaemon(): Promise<void> { // 3. Once our setup is complete - if all goes well - we await this promise // 4. When it resolves we can cleanup and exit // - // In case the setup malfunctions - our signal handlers will not properly - // shut down. We will force exit the process with code 1. - let requestShutdown: (source: 'happy-app' | 'happy-cli' | 'os-signal' | 'exception', errorMessage?: string) => void; - let resolvesWhenShutdownRequested = new Promise<({ source: 'happy-app' | 'happy-cli' | 'os-signal' | 'exception', errorMessage?: string })>((resolve) => { - requestShutdown = (source, errorMessage) => { - logger.debug(`[DAEMON RUN] Requesting shutdown (source: ${source}, errorMessage: ${errorMessage})`); - - // Fallback - in case startup malfunctions - we will force exit the process with code 1 - setTimeout(async () => { - logger.debug('[DAEMON RUN] Startup malfunctioned, forcing exit with code 1'); - - // Give time for logs to be flushed - await new Promise(resolve => setTimeout(resolve, 100)) - - process.exit(1); - }, 1_000); - - // Start graceful shutdown - resolve({ source, errorMessage }); - }; - }); - - // Setup signal handlers - process.on('SIGINT', () => { - logger.debug('[DAEMON RUN] Received SIGINT'); - requestShutdown('os-signal'); - }); - - process.on('SIGTERM', () => { - logger.debug('[DAEMON RUN] Received SIGTERM'); - requestShutdown('os-signal'); - }); - - process.on('uncaughtException', (error) => { - logger.debug('[DAEMON RUN] FATAL: Uncaught exception', error); - logger.debug(`[DAEMON RUN] Stack trace: ${error.stack}`); - requestShutdown('exception', error.message); - }); - - process.on('unhandledRejection', (reason, promise) => { - logger.debug('[DAEMON RUN] FATAL: Unhandled promise rejection', reason); - logger.debug(`[DAEMON RUN] Rejected promise:`, promise); - const error = reason instanceof Error ? reason : new Error(`Unhandled promise rejection: ${reason}`); - logger.debug(`[DAEMON RUN] Stack trace: ${error.stack}`); - requestShutdown('exception', error.message); - }); - - process.on('exit', (code) => { - logger.debug(`[DAEMON RUN] Process exiting with code: ${code}`); - }); - - process.on('beforeExit', (code) => { - logger.debug(`[DAEMON RUN] Process about to exit with code: ${code}`); - }); + const { requestShutdown, resolvesWhenShutdownRequested } = createDaemonShutdownController(); logger.debug('[DAEMON RUN] Starting daemon process...'); logger.debugLargeJson('[DAEMON RUN] Environment', getEnvironmentInfo()); + const isInteractive = Boolean(process.stdin.isTTY && process.stdout.isTTY); + // Check if already running // Check if running daemon version matches current CLI version const runningDaemonVersionMatches = await isDaemonRunningCurrentlyInstalledHappyVersion(); @@ -142,89 +74,180 @@ export async function startDaemon(): Promise<void> { process.exit(0); } - // Acquire exclusive lock (proves daemon is running) - const daemonLockHandle = await acquireDaemonLock(5, 200); - if (!daemonLockHandle) { - logger.debug('[DAEMON RUN] Daemon lock file already held, another daemon is running'); - process.exit(0); + // If this daemon is started detached (no TTY) and credentials are missing, we cannot safely + // run the interactive auth selector UI. In that case, fail fast and let the parent/orchestrator + // run `happy auth login` in an interactive terminal. + if (!isInteractive) { + const credentials = await readCredentials(); + if (!credentials) { + logger.debug('[AUTH] No credentials found'); + logger.debug('[DAEMON RUN] Non-interactive mode: refusing to start auth UI. Run: happy auth login'); + process.exit(1); + } } - // At this point we should be safe to startup the daemon: - // 1. Not have a stale daemon state - // 2. Should not have another daemon process running + let daemonLockHandle: Awaited<ReturnType<typeof acquireDaemonLock>> = null; try { + // Ensure auth and machine registration BEFORE we take the daemon lock. + // This prevents stuck lock files when auth is interrupted or cannot proceed. + const { credentials, machineId } = await authAndSetupMachineIfNeeded(); + logger.debug('[DAEMON RUN] Auth and machine setup complete'); + + // Acquire exclusive lock (proves daemon is running) + daemonLockHandle = await acquireDaemonLock(5, 200); + if (!daemonLockHandle) { + logger.debug('[DAEMON RUN] Daemon lock file already held, another daemon is running'); + process.exit(0); + } + // Start caffeinate const caffeinateStarted = startCaffeinate(); if (caffeinateStarted) { logger.debug('[DAEMON RUN] Sleep prevention enabled'); } - // Ensure auth and machine registration BEFORE anything else - const { credentials, machineId } = await authAndSetupMachineIfNeeded(); - logger.debug('[DAEMON RUN] Auth and machine setup complete'); - - // Setup state - key by PID - const pidToTrackedSession = new Map<number, TrackedSession>(); + // Setup state - key by PID + const pidToTrackedSession = new Map<number, TrackedSession>(); + const spawnResourceCleanupByPid = new Map<number, () => void>(); + const sessionAttachCleanupByPid = new Map<number, () => Promise<void>>(); + let apiMachineForSessions: ApiMachineClient | null = null; // Session spawning awaiter system const pidToAwaiter = new Map<number, (session: TrackedSession) => void>(); + const pidToSpawnResultResolver = new Map<number, (result: SpawnSessionResult) => void>(); + const pidToSpawnWebhookTimeout = new Map<number, NodeJS.Timeout>(); // Helper functions const getCurrentChildren = () => Array.from(pidToTrackedSession.values()); - // Handle webhook from happy session reporting itself - const onHappySessionWebhook = (sessionId: string, sessionMetadata: Metadata) => { - logger.debugLargeJson(`[DAEMON RUN] Session reported`, sessionMetadata); - - const pid = sessionMetadata.hostPid; - if (!pid) { - logger.debug(`[DAEMON RUN] Session webhook missing hostPid for sessionId: ${sessionId}`); - return; - } - - logger.debug(`[DAEMON RUN] Session webhook: ${sessionId}, PID: ${pid}, started by: ${sessionMetadata.startedBy || 'unknown'}`); - logger.debug(`[DAEMON RUN] Current tracked sessions before webhook: ${Array.from(pidToTrackedSession.keys()).join(', ')}`); - - // Check if we already have this PID (daemon-spawned) - const existingSession = pidToTrackedSession.get(pid); - - if (existingSession && existingSession.startedBy === 'daemon') { - // Update daemon-spawned session with reported data - existingSession.happySessionId = sessionId; - existingSession.happySessionMetadataFromLocalWebhook = sessionMetadata; - logger.debug(`[DAEMON RUN] Updated daemon-spawned session ${sessionId} with metadata`); - - // Resolve any awaiter for this PID - const awaiter = pidToAwaiter.get(pid); - if (awaiter) { - pidToAwaiter.delete(pid); - awaiter(existingSession); - logger.debug(`[DAEMON RUN] Resolved session awaiter for PID ${pid}`); - } - } else if (!existingSession) { - // New session started externally - const trackedSession: TrackedSession = { - startedBy: 'happy directly - likely by user from terminal', - happySessionId: sessionId, - happySessionMetadataFromLocalWebhook: sessionMetadata, - pid - }; - pidToTrackedSession.set(pid, trackedSession); - logger.debug(`[DAEMON RUN] Registered externally-started session ${sessionId}`); - } - }; - - // Spawn a new session (sessionId reserved for future --resume functionality) - const spawnSession = async (options: SpawnSessionOptions): Promise<SpawnSessionResult> => { - logger.debugLargeJson('[DAEMON RUN] Spawning session', options); - - const { directory, sessionId, machineId, approvedNewDirectoryCreation = true } = options; - let directoryCreated = false; + await reattachTrackedSessionsFromMarkers({ pidToTrackedSession }); + + // Handle webhook from happy session reporting itself + const onHappySessionWebhook = createOnHappySessionWebhook({ pidToTrackedSession, pidToAwaiter }); + + // Spawn a new session (sessionId reserved for future Happy session resume; vendor resume uses options.resume). + const spawnSession = async (options: SpawnSessionOptions): Promise<SpawnSessionResult> => { + // Do NOT log raw options: it may include secrets (token / env vars). + const envKeysPreview = options.environmentVariables && typeof options.environmentVariables === 'object' + ? Object.keys(options.environmentVariables as Record<string, unknown>) + : []; + const environmentVariablesValidation = validateEnvVarRecordStrict(options.environmentVariables); + logger.debugLargeJson('[DAEMON RUN] Spawning session', { + directory: options.directory, + sessionId: options.sessionId, + machineId: options.machineId, + approvedNewDirectoryCreation: options.approvedNewDirectoryCreation, + agent: options.agent, + profileId: options.profileId, + hasToken: !!options.token, + hasResume: typeof options.resume === 'string' && options.resume.trim().length > 0, + environmentVariableCount: envKeysPreview.length, + environmentVariableKeys: envKeysPreview, + environmentVariablesValid: environmentVariablesValidation.ok, + environmentVariablesError: environmentVariablesValidation.ok ? null : environmentVariablesValidation.error, + }); + + if (!environmentVariablesValidation.ok) { + return { + type: 'error', + errorCode: SPAWN_SESSION_ERROR_CODES.INVALID_ENVIRONMENT_VARIABLES, + errorMessage: environmentVariablesValidation.error, + }; + } + + const { + directory, + sessionId, + machineId, + approvedNewDirectoryCreation = true, + resume, + existingSessionId, + sessionEncryptionKeyBase64, + sessionEncryptionVariant, + permissionMode, + permissionModeUpdatedAt, + experimentalCodexResume, + experimentalCodexAcp + } = options; + const normalizedResume = typeof resume === 'string' ? resume.trim() : ''; + const normalizedExistingSessionId = typeof existingSessionId === 'string' ? existingSessionId.trim() : ''; + + // Idempotency: a resume request should not spawn a duplicate process when the session is already running. + // This is especially important for pending-queue wake-ups, where the UI may attempt a best-effort wake + // even if a session is already attached. + if (normalizedExistingSessionId) { + const existingTracked = await findRunningTrackedSessionById({ + sessions: pidToTrackedSession.values(), + happySessionId: normalizedExistingSessionId, + isPidAlive: async (pid) => { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } + }, + getProcessCommandHash: async (pid) => { + const proc = await findHappyProcessByPid(pid); + return proc?.command ? hashProcessCommand(proc.command) : null; + }, + }); + if (existingTracked) { + logger.debug(`[DAEMON RUN] Resume requested for ${normalizedExistingSessionId}, but session is already running (pid=${existingTracked.pid})`); + return { type: 'success', sessionId: normalizedExistingSessionId }; + } + } + const effectiveResume = normalizedResume; + const catalogAgentId = resolveCatalogAgentId(options.agent ?? null); + + // Only gate vendor resume. Happy-session reconnect (existingSessionId) is supported for all agents. + if (effectiveResume) { + const vendorResumeSupport = await getVendorResumeSupport(options.agent ?? null); + const ok = vendorResumeSupport({ experimentalCodexResume, experimentalCodexAcp }); + if (!ok) { + const supportLevel = AGENTS[catalogAgentId].vendorResumeSupport; + const qualifier = supportLevel === 'experimental' ? ' (experimental and not enabled)' : ''; + return { + type: 'error', + errorCode: SPAWN_SESSION_ERROR_CODES.RESUME_NOT_SUPPORTED, + errorMessage: `Resume is not supported for agent '${catalogAgentId}'${qualifier}.`, + }; + } + } - try { - await fs.access(directory); - logger.debug(`[DAEMON RUN] Directory exists: ${directory}`); + const normalizedSessionEncryptionKeyBase64 = + typeof sessionEncryptionKeyBase64 === 'string' ? sessionEncryptionKeyBase64.trim() : ''; + if (normalizedExistingSessionId) { + if (!normalizedSessionEncryptionKeyBase64) { + return { + type: 'error', + errorCode: SPAWN_SESSION_ERROR_CODES.RESUME_MISSING_ENCRYPTION_KEY, + errorMessage: 'Missing session encryption key for resume', + }; + } + if (sessionEncryptionVariant !== 'dataKey') { + return { + type: 'error', + errorCode: SPAWN_SESSION_ERROR_CODES.RESUME_UNSUPPORTED_ENCRYPTION_VARIANT, + errorMessage: 'Unsupported session encryption variant for resume', + }; + } + } + let directoryCreated = false; + + const daemonSpawnHooks = AGENTS[catalogAgentId].getDaemonSpawnHooks + ? await AGENTS[catalogAgentId].getDaemonSpawnHooks!() + : null; + + let spawnResourceCleanupOnFailure: (() => void) | null = null; + let spawnResourceCleanupOnExit: (() => void) | null = null; + let spawnResourceCleanupArmed = false; + let sessionAttachCleanup: (() => Promise<void>) | null = null; + + try { + await fs.access(directory); + logger.debug(`[DAEMON RUN] Directory exists: ${directory}`); } catch (error) { logger.debug(`[DAEMON RUN] Directory doesn't exist, creating: ${directory}`); @@ -260,6 +283,7 @@ export async function startDaemon(): Promise<void> { logger.debug(`[DAEMON RUN] Directory creation failed: ${errorMessage}`); return { type: 'error', + errorCode: SPAWN_SESSION_ERROR_CODES.DIRECTORY_CREATE_FAILED, errorMessage }; } @@ -275,56 +299,39 @@ export async function startDaemon(): Promise<void> { // Layer 1: Resolve authentication token if provided const authEnv: Record<string, string> = {}; if (options.token) { - if (options.agent === 'codex') { - - // Create a temporary directory for Codex - const codexHomeDir = tmp.dirSync(); - - // Write the token to the temporary directory - fs.writeFile(join(codexHomeDir.name, 'auth.json'), options.token); - - // Set the environment variable for Codex - authEnv.CODEX_HOME = codexHomeDir.name; - } else { // Assuming claude + if (daemonSpawnHooks?.buildAuthEnv) { + const built = await daemonSpawnHooks.buildAuthEnv({ token: options.token }); + Object.assign(authEnv, built.env); + spawnResourceCleanupOnFailure = built.cleanupOnFailure ?? null; + spawnResourceCleanupOnExit = built.cleanupOnExit ?? null; + } else { authEnv.CLAUDE_CODE_OAUTH_TOKEN = options.token; } } // Layer 2: Profile environment variables - // Priority: GUI-provided profile > CLI local active profile > none + // IMPORTANT: only apply profile env when explicitly provided by the caller. + // We do NOT fall back to CLI-local active profile here, because sessions spawned via + // the daemon are typically requested by the GUI and must respect GUI opt-in gating. let profileEnv: Record<string, string> = {}; - if (options.environmentVariables && Object.keys(options.environmentVariables).length > 0) { + if (Object.keys(environmentVariablesValidation.env).length > 0) { // GUI provided profile environment variables - highest priority for profile settings - profileEnv = options.environmentVariables; + profileEnv = environmentVariablesValidation.env; logger.info(`[DAEMON RUN] Using GUI-provided profile environment variables (${Object.keys(profileEnv).length} vars)`); logger.debug(`[DAEMON RUN] GUI profile env var keys: ${Object.keys(profileEnv).join(', ')}`); } else { - // Fallback to CLI local active profile - try { - const settings = await readSettings(); - if (settings.activeProfileId) { - logger.debug(`[DAEMON RUN] No GUI profile provided, loading CLI local active profile: ${settings.activeProfileId}`); - - // Get profile environment variables filtered for agent compatibility - profileEnv = await getProfileEnvironmentVariablesForAgent( - settings.activeProfileId, - options.agent || 'claude' - ); - - logger.debug(`[DAEMON RUN] Loaded ${Object.keys(profileEnv).length} environment variables from CLI local profile for agent ${options.agent || 'claude'}`); - logger.debug(`[DAEMON RUN] CLI profile env var keys: ${Object.keys(profileEnv).join(', ')}`); - } else { - logger.debug('[DAEMON RUN] No CLI local active profile set'); - } - } catch (error) { - logger.debug('[DAEMON RUN] Failed to load CLI local profile environment variables:', error); - // Continue without profile env vars - this is not a fatal error - } + logger.debug('[DAEMON RUN] No profile environment variables provided by caller; skipping profile env injection'); + } + // Session identity (non-secret) for cross-device display/debugging + // Empty string means "no profile" and should still be preserved. + const sessionProfileEnv: Record<string, string> = {}; + if (options.profileId !== undefined) { + sessionProfileEnv.HAPPY_SESSION_PROFILE_ID = options.profileId; } - // Final merge: Profile vars first, then auth (auth takes precedence to protect authentication) - let extraEnv = { ...profileEnv, ...authEnv }; + // Final merge: profile vars + session identity, then auth (auth takes precedence to protect authentication) + let extraEnv = { ...profileEnv, ...sessionProfileEnv, ...authEnv }; logger.debug(`[DAEMON RUN] Final environment variable keys (before expansion) (${Object.keys(extraEnv).length}): ${Object.keys(extraEnv).join(', ')}`); // Expand ${VAR} references from daemon's process.env @@ -354,65 +361,163 @@ export async function startDaemon(): Promise<void> { const errorMessage = `Authentication will fail - environment variables not found in daemon: ${missingVarDetails.join('; ')}. ` + `Ensure these variables are set in the daemon's environment (not just your shell) before starting sessions.`; logger.warn(`[DAEMON RUN] ${errorMessage}`); + if (spawnResourceCleanupOnFailure && !spawnResourceCleanupArmed) { + spawnResourceCleanupOnFailure(); + spawnResourceCleanupOnFailure = null; + spawnResourceCleanupOnExit = null; + } return { type: 'error', + errorCode: SPAWN_SESSION_ERROR_CODES.AUTH_ENV_UNEXPANDED, errorMessage }; } - // Check if tmux is available and should be used - const tmuxAvailable = await isTmuxAvailable(); - let useTmux = tmuxAvailable; - - // Get tmux session name from environment variables (now set by profile system) - // Empty string means "use current/most recent session" (tmux default behavior) - let tmuxSessionName: string | undefined = extraEnv.TMUX_SESSION_NAME; + const cleanupSpawnResources = () => { + if (spawnResourceCleanupOnFailure && !spawnResourceCleanupArmed) { + spawnResourceCleanupOnFailure(); + spawnResourceCleanupOnFailure = null; + spawnResourceCleanupOnExit = null; + } + }; - // If tmux is not available or session name is explicitly undefined, fall back to regular spawning - // Note: Empty string is valid (means use current/most recent tmux session) - if (!tmuxAvailable || tmuxSessionName === undefined) { - useTmux = false; - if (tmuxSessionName !== undefined) { - logger.debug(`[DAEMON RUN] tmux session name specified but tmux not available, falling back to regular spawning`); + if (daemonSpawnHooks?.validateSpawn) { + const validation = await daemonSpawnHooks.validateSpawn({ experimentalCodexResume, experimentalCodexAcp }); + if (!validation.ok) { + cleanupSpawnResources(); + return { + type: 'error', + errorCode: SPAWN_SESSION_ERROR_CODES.SPAWN_VALIDATION_FAILED, + errorMessage: validation.errorMessage, + }; } } - if (useTmux && tmuxSessionName !== undefined) { - // Try to spawn in tmux session - const sessionDesc = tmuxSessionName || 'current/most recent session'; - logger.debug(`[DAEMON RUN] Attempting to spawn session in tmux: ${sessionDesc}`); - - const tmux = getTmuxUtilities(tmuxSessionName); - - // Construct command for the CLI - const cliPath = join(projectPath(), 'dist', 'index.mjs'); - // Determine agent command - support claude, codex, and gemini - const agent = options.agent === 'gemini' ? 'gemini' : (options.agent === 'codex' ? 'codex' : 'claude'); - const fullCommand = `node --no-warnings --no-deprecation ${cliPath} ${agent} --happy-starting-mode remote --started-by daemon`; - - // Spawn in tmux with environment variables - // IMPORTANT: Pass complete environment (process.env + extraEnv) because: - // 1. tmux sessions need daemon's expanded auth variables (e.g., ANTHROPIC_AUTH_TOKEN) - // 2. Regular spawn uses env: { ...process.env, ...extraEnv } - // 3. tmux needs explicit environment via -e flags to ensure all variables are available - const windowName = `happy-${Date.now()}-${agent}`; - const tmuxEnv: Record<string, string> = {}; - - // Add all daemon environment variables (filtering out undefined) - for (const [key, value] of Object.entries(process.env)) { - if (value !== undefined) { - tmuxEnv[key] = value; - } + const terminalRequest = resolveTerminalRequestFromSpawnOptions({ + happyHomeDir: configuration.happyHomeDir, + terminal: options.terminal, + environmentVariables: extraEnv, + }); + + // Remove tmux control env vars from the spawned agent process. + // TMUX_SESSION_NAME is Happy-specific; TMUX_TMPDIR is a daemon/runtime concern. + const extraEnvForChild = { ...extraEnv }; + delete extraEnvForChild.TMUX_SESSION_NAME; + delete extraEnvForChild.TMUX_TMPDIR; + if (daemonSpawnHooks?.buildExtraEnvForChild) { + Object.assign( + extraEnvForChild, + daemonSpawnHooks.buildExtraEnvForChild({ experimentalCodexResume, experimentalCodexAcp }), + ); } + let sessionAttachFilePath: string | null = null; + if (normalizedExistingSessionId) { + const attach = await createSessionAttachFile({ + happySessionId: normalizedExistingSessionId, + payload: { + encryptionKeyBase64: normalizedSessionEncryptionKeyBase64, + encryptionVariant: 'dataKey', + }, + }); + sessionAttachFilePath = attach.filePath; + sessionAttachCleanup = attach.cleanup; + } + + const extraEnvForChildWithMessage = sessionAttachFilePath + ? { ...extraEnvForChild, HAPPY_SESSION_ATTACH_FILE: sessionAttachFilePath } + : extraEnvForChild; + + // Check if tmux is available and should be used + const tmuxAvailable = await isTmuxAvailable(); + const tmuxRequested = terminalRequest.requested === 'tmux'; + let useTmux = tmuxAvailable && tmuxRequested; + + const tmuxSessionName = tmuxRequested ? terminalRequest.tmux.sessionName : undefined; + const tmuxTmpDir = tmuxRequested ? terminalRequest.tmux.tmpDir : null; + const tmuxCommandEnv: Record<string, string> = {}; + if (tmuxTmpDir) { + tmuxCommandEnv.TMUX_TMPDIR = tmuxTmpDir; + } + + let tmuxFallbackReason: string | null = null; + + if (!tmuxAvailable && tmuxRequested) { + tmuxFallbackReason = 'tmux is not available on this machine'; + logger.debug('[DAEMON RUN] tmux requested but tmux is not available; falling back to regular spawning'); + } + + if (useTmux && tmuxSessionName !== undefined) { + // Resolve empty-string session name (legacy "current/most recent") deterministically. + let resolvedTmuxSessionName = tmuxSessionName; + if (tmuxSessionName === '') { + try { + const tmuxForDiscovery = new TmuxUtilities(undefined, tmuxCommandEnv); + const listResult = await tmuxForDiscovery.executeTmuxCommand([ + 'list-sessions', + '-F', + '#{session_name}\t#{session_attached}\t#{session_last_attached}', + ]); + resolvedTmuxSessionName = + selectPreferredTmuxSessionName(listResult?.stdout ?? '') ?? TmuxUtilities.DEFAULT_SESSION_NAME; + } catch (error) { + logger.debug('[DAEMON RUN] Failed to resolve current/most-recent tmux session; defaulting to "happy"', error); + resolvedTmuxSessionName = TmuxUtilities.DEFAULT_SESSION_NAME; + } + } + + // Try to spawn in tmux session + const sessionDesc = resolvedTmuxSessionName || 'current/most recent session'; + logger.debug(`[DAEMON RUN] Attempting to spawn session in tmux: ${sessionDesc}`); + + const agentSubcommand = resolveAgentCliSubcommand(options.agent); + const windowName = `happy-${Date.now()}-${agentSubcommand}`; + const tmuxTarget = `${resolvedTmuxSessionName}:${windowName}`; + + const terminalRuntimeArgs = [ + '--happy-terminal-mode', + 'tmux', + '--happy-terminal-requested', + 'tmux', + '--happy-tmux-target', + tmuxTarget, + ...(tmuxTmpDir ? ['--happy-tmux-tmpdir', tmuxTmpDir] : []), + ]; + + const { commandTokens, tmuxEnv } = buildTmuxSpawnConfig({ + agent: agentSubcommand, + directory, + extraEnv: extraEnvForChildWithMessage, + tmuxCommandEnv, + extraArgs: [ + ...terminalRuntimeArgs, + ...(permissionMode ? ['--permission-mode', permissionMode] : []), + ...(typeof permissionModeUpdatedAt === 'number' + ? ['--permission-mode-updated-at', `${permissionModeUpdatedAt}`] + : []), + ...(effectiveResume ? ['--resume', effectiveResume] : []), + ...(normalizedExistingSessionId ? ['--existing-session', normalizedExistingSessionId] : []), + ], + }); + const tmux = new TmuxUtilities(resolvedTmuxSessionName, tmuxCommandEnv); - // Add extra environment variables (these should already be filtered) - Object.assign(tmuxEnv, extraEnv); - - const tmuxResult = await tmux.spawnInTmux([fullCommand], { - sessionName: tmuxSessionName, - windowName: windowName, - cwd: directory - }, tmuxEnv); // Pass complete environment for tmux session + // Spawn in tmux with environment variables + // IMPORTANT: `spawnInTmux` uses `-e KEY=VALUE` flags for the window. + // Use merged env so tmux mode matches regular process spawn behavior. + // Note: this may add many `-e` flags; if it becomes a problem we can optimize + // by diffing against `tmux show-environment` in a follow-up. + if (tmuxTmpDir) { + try { + await fs.mkdir(tmuxTmpDir, { recursive: true }); + } catch (error) { + logger.debug('[DAEMON RUN] Failed to ensure TMUX_TMPDIR exists; tmux may fail to start', error); + } + } + + const tmuxResult = await tmux.spawnInTmux(commandTokens, { + sessionName: resolvedTmuxSessionName, + windowName: windowName, + cwd: directory + }, tmuxEnv); // Pass complete environment for tmux session if (tmuxResult.success) { logger.debug(`[DAEMON RUN] Successfully spawned in tmux session: ${tmuxResult.sessionId}, PID: ${tmuxResult.pid}`); @@ -422,94 +527,119 @@ export async function startDaemon(): Promise<void> { throw new Error('Tmux window created but no PID returned'); } - // Create a tracked session for tmux windows - now we have the real PID! - const trackedSession: TrackedSession = { - startedBy: 'daemon', - pid: tmuxResult.pid, // Real PID from tmux -P flag - tmuxSessionId: tmuxResult.sessionId, - directoryCreated, - message: directoryCreated - ? `The path '${directory}' did not exist. We created a new folder and spawned a new session in tmux session '${tmuxSessionName}'. Use 'tmux attach -t ${tmuxSessionName}' to view the session.` - : `Spawned new session in tmux session '${tmuxSessionName}'. Use 'tmux attach -t ${tmuxSessionName}' to view the session.` - }; - - // Add to tracking map so webhook can find it later - pidToTrackedSession.set(tmuxResult.pid, trackedSession); + // Resolve the actual tmux session name used (important when sessionName was empty/undefined) + const tmuxSession = tmuxResult.sessionName ?? (resolvedTmuxSessionName || 'happy'); + + // Create a tracked session for tmux windows - now we have the real PID! + const trackedSession: TrackedSession = { + startedBy: 'daemon', + pid: tmuxResult.pid, // Real PID from tmux -P flag + tmuxSessionId: tmuxResult.sessionId, + vendorResumeId: effectiveResume || undefined, + directoryCreated, + message: directoryCreated + ? `The path '${directory}' did not exist. We created a new folder and spawned a new session in tmux session '${tmuxSession}'. Use 'tmux attach -t ${tmuxSession}' to view the session.` + : `Spawned new session in tmux session '${tmuxSession}'. Use 'tmux attach -t ${tmuxSession}' to view the session.` + }; + + // Add to tracking map so webhook can find it later + pidToTrackedSession.set(tmuxResult.pid, trackedSession); + if (spawnResourceCleanupOnExit) { + spawnResourceCleanupByPid.set(tmuxResult.pid, spawnResourceCleanupOnExit); + spawnResourceCleanupArmed = true; + } + if (sessionAttachCleanup) { + sessionAttachCleanupByPid.set(tmuxResult.pid, sessionAttachCleanup); + sessionAttachCleanup = null; + } // Wait for webhook to populate session with happySessionId (exact same as regular flow) logger.debug(`[DAEMON RUN] Waiting for session webhook for PID ${tmuxResult.pid} (tmux)`); - return new Promise((resolve) => { - // Set timeout for webhook (same as regular flow) - const timeout = setTimeout(() => { - pidToAwaiter.delete(tmuxResult.pid!); - logger.debug(`[DAEMON RUN] Session webhook timeout for PID ${tmuxResult.pid} (tmux)`); - resolve({ - type: 'error', - errorMessage: `Session webhook timeout for PID ${tmuxResult.pid} (tmux)` - }); - }, 15_000); // Same timeout as regular sessions - - // Register awaiter for tmux session (exact same as regular flow) - pidToAwaiter.set(tmuxResult.pid!, (completedSession) => { - clearTimeout(timeout); - logger.debug(`[DAEMON RUN] Session ${completedSession.happySessionId} fully spawned with webhook (tmux)`); - resolve({ - type: 'success', - sessionId: completedSession.happySessionId! - }); + return new Promise((resolve) => { + // Set timeout for webhook (same as regular flow) + const timeout = setTimeout(() => { + pidToAwaiter.delete(tmuxResult.pid!); + pidToSpawnResultResolver.delete(tmuxResult.pid!); + pidToSpawnWebhookTimeout.delete(tmuxResult.pid!); + logger.debug(`[DAEMON RUN] Session webhook timeout for PID ${tmuxResult.pid} (tmux)`); + resolve({ + type: 'error', + errorCode: SPAWN_SESSION_ERROR_CODES.SESSION_WEBHOOK_TIMEOUT, + errorMessage: `Session webhook timeout for PID ${tmuxResult.pid} (tmux)` }); - }); - } else { - logger.debug(`[DAEMON RUN] Failed to spawn in tmux: ${tmuxResult.error}, falling back to regular spawning`); - useTmux = false; - } - } + }, 15_000); // Same timeout as regular sessions + pidToSpawnWebhookTimeout.set(tmuxResult.pid!, timeout); - // Regular process spawning (fallback or if tmux not available) - if (!useTmux) { - logger.debug(`[DAEMON RUN] Using regular process spawning`); - - // Construct arguments for the CLI - support claude, codex, and gemini - let agentCommand: string; - switch (options.agent) { - case 'claude': - case undefined: - agentCommand = 'claude'; - break; - case 'codex': - agentCommand = 'codex'; - break; - case 'gemini': - agentCommand = 'gemini'; - break; - default: - return { - type: 'error', - errorMessage: `Unsupported agent type: '${options.agent}'. Please update your CLI to the latest version.` - }; - } - const args = [ - agentCommand, - '--happy-starting-mode', 'remote', - '--started-by', 'daemon' - ]; - - // TODO: In future, sessionId could be used with --resume to continue existing sessions - // For now, we ignore it - each spawn creates a new session - const happyProcess = spawnHappyCLI(args, { - cwd: directory, - detached: true, // Sessions stay alive when daemon stops - stdio: ['ignore', 'pipe', 'pipe'], // Capture stdout/stderr for debugging - env: { - ...process.env, - ...extraEnv - } + // Register awaiter for tmux session (exact same as regular flow) + pidToAwaiter.set(tmuxResult.pid!, (completedSession) => { + clearTimeout(timeout); + pidToSpawnWebhookTimeout.delete(tmuxResult.pid!); + pidToSpawnResultResolver.delete(tmuxResult.pid!); + logger.debug(`[DAEMON RUN] Session ${completedSession.happySessionId} fully spawned with webhook (tmux)`); + resolve({ + type: 'success', + sessionId: completedSession.happySessionId! + }); + }); }); - - // Log output for debugging - if (process.env.DEBUG) { - happyProcess.stdout?.on('data', (data) => { + } else { + tmuxFallbackReason = tmuxResult.error ?? 'tmux spawn failed'; + logger.debug(`[DAEMON RUN] Failed to spawn in tmux: ${tmuxResult.error}, falling back to regular spawning`); + useTmux = false; + } + } + + // Regular process spawning (fallback or if tmux not available) + if (!useTmux) { + logger.debug(`[DAEMON RUN] Using regular process spawning`); + + const agentCommand = resolveAgentCliSubcommand(options.agent); + const args = [ + agentCommand, + '--happy-starting-mode', 'remote', + '--started-by', 'daemon' + ]; + + if (tmuxRequested) { + const reason = tmuxFallbackReason ?? 'tmux was not used'; + args.push( + '--happy-terminal-mode', + 'plain', + '--happy-terminal-requested', + 'tmux', + '--happy-terminal-fallback-reason', + reason, + ); + } + + if (effectiveResume) { + args.push('--resume', effectiveResume); + } + if (normalizedExistingSessionId) { + args.push('--existing-session', normalizedExistingSessionId); + } + if (permissionMode) { + args.push('--permission-mode', permissionMode); + } + if (typeof permissionModeUpdatedAt === 'number') { + args.push('--permission-mode-updated-at', `${permissionModeUpdatedAt}`); + } + + // NOTE: sessionId is reserved for future Happy session resume; we currently ignore it. + const happyProcess = spawnHappyCLI(args, { + cwd: directory, + detached: true, // Sessions stay alive when daemon stops + stdio: ['ignore', 'pipe', 'pipe'], // Capture stdout/stderr for debugging + env: { + ...process.env, + ...extraEnvForChildWithMessage + } + }); + + // Log output for debugging + if (process.env.DEBUG) { + happyProcess.stdout?.on('data', (data) => { logger.debug(`[DAEMON RUN] Child stdout: ${data.toString()}`); }); happyProcess.stderr?.on('data', (data) => { @@ -517,37 +647,82 @@ export async function startDaemon(): Promise<void> { }); } - if (!happyProcess.pid) { - logger.debug('[DAEMON RUN] Failed to spawn process - no PID returned'); - return { - type: 'error', - errorMessage: 'Failed to spawn Happy process - no PID returned' - }; - } - - logger.debug(`[DAEMON RUN] Spawned process with PID ${happyProcess.pid}`); - - const trackedSession: TrackedSession = { - startedBy: 'daemon', - pid: happyProcess.pid, - childProcess: happyProcess, - directoryCreated, - message: directoryCreated ? `The path '${directory}' did not exist. We created a new folder and spawned a new session there.` : undefined - }; + if (!happyProcess.pid) { + logger.debug('[DAEMON RUN] Failed to spawn process - no PID returned'); + if (spawnResourceCleanupOnFailure && !spawnResourceCleanupArmed) { + spawnResourceCleanupOnFailure(); + spawnResourceCleanupOnFailure = null; + spawnResourceCleanupOnExit = null; + } + if (sessionAttachCleanup) { + await sessionAttachCleanup(); + sessionAttachCleanup = null; + } + return { + type: 'error', + errorCode: SPAWN_SESSION_ERROR_CODES.SPAWN_NO_PID, + errorMessage: 'Failed to spawn Happy process - no PID returned' + }; + } + + logger.debug(`[DAEMON RUN] Spawned process with PID ${happyProcess.pid}`); + if (sessionAttachCleanup) { + sessionAttachCleanupByPid.set(happyProcess.pid, sessionAttachCleanup); + sessionAttachCleanup = null; + } + + const trackedSession: TrackedSession = { + startedBy: 'daemon', + pid: happyProcess.pid, + childProcess: happyProcess, + vendorResumeId: effectiveResume || undefined, + directoryCreated, + message: directoryCreated ? `The path '${directory}' did not exist. We created a new folder and spawned a new session there.` : undefined + }; pidToTrackedSession.set(happyProcess.pid, trackedSession); + if (spawnResourceCleanupOnExit) { + spawnResourceCleanupByPid.set(happyProcess.pid, spawnResourceCleanupOnExit); + spawnResourceCleanupArmed = true; + } happyProcess.on('exit', (code, signal) => { logger.debug(`[DAEMON RUN] Child PID ${happyProcess.pid} exited with code ${code}, signal ${signal}`); if (happyProcess.pid) { - onChildExited(happyProcess.pid); + const resolveSpawn = pidToSpawnResultResolver.get(happyProcess.pid); + if (resolveSpawn) { + pidToSpawnResultResolver.delete(happyProcess.pid); + const timeout = pidToSpawnWebhookTimeout.get(happyProcess.pid); + if (timeout) clearTimeout(timeout); + pidToSpawnWebhookTimeout.delete(happyProcess.pid); + pidToAwaiter.delete(happyProcess.pid); + resolveSpawn({ + type: 'error', + errorCode: SPAWN_SESSION_ERROR_CODES.CHILD_EXITED_BEFORE_WEBHOOK, + errorMessage: `Child process exited before session webhook (pid=${happyProcess.pid}, code=${code ?? 'null'}, signal=${signal ?? 'null'})`, + }); + } + onChildExited(happyProcess.pid, { reason: 'process-exited', code, signal }); } }); happyProcess.on('error', (error) => { logger.debug(`[DAEMON RUN] Child process error:`, error); if (happyProcess.pid) { - onChildExited(happyProcess.pid); + const resolveSpawn = pidToSpawnResultResolver.get(happyProcess.pid); + if (resolveSpawn) { + pidToSpawnResultResolver.delete(happyProcess.pid); + const timeout = pidToSpawnWebhookTimeout.get(happyProcess.pid); + if (timeout) clearTimeout(timeout); + pidToSpawnWebhookTimeout.delete(happyProcess.pid); + pidToAwaiter.delete(happyProcess.pid); + resolveSpawn({ + type: 'error', + errorCode: SPAWN_SESSION_ERROR_CODES.CHILD_EXITED_BEFORE_WEBHOOK, + errorMessage: `Child process error before session webhook (pid=${happyProcess.pid})`, + }); + } + onChildExited(happyProcess.pid, { reason: 'process-error', code: null, signal: null }); } }); @@ -555,21 +730,28 @@ export async function startDaemon(): Promise<void> { logger.debug(`[DAEMON RUN] Waiting for session webhook for PID ${happyProcess.pid}`); return new Promise((resolve) => { + pidToSpawnResultResolver.set(happyProcess.pid!, resolve); // Set timeout for webhook const timeout = setTimeout(() => { pidToAwaiter.delete(happyProcess.pid!); + pidToSpawnResultResolver.delete(happyProcess.pid!); + pidToSpawnWebhookTimeout.delete(happyProcess.pid!); logger.debug(`[DAEMON RUN] Session webhook timeout for PID ${happyProcess.pid}`); resolve({ type: 'error', + errorCode: SPAWN_SESSION_ERROR_CODES.SESSION_WEBHOOK_TIMEOUT, errorMessage: `Session webhook timeout for PID ${happyProcess.pid}` }); // 15 second timeout - I have seen timeouts on 10 seconds // even though session was still created successfully in ~2 more seconds }, 15_000); + pidToSpawnWebhookTimeout.set(happyProcess.pid!, timeout); // Register awaiter pidToAwaiter.set(happyProcess.pid!, (completedSession) => { clearTimeout(timeout); + pidToSpawnWebhookTimeout.delete(happyProcess.pid!); + pidToSpawnResultResolver.delete(happyProcess.pid!); logger.debug(`[DAEMON RUN] Session ${completedSession.happySessionId} fully spawned with webhook`); resolve({ type: 'success', @@ -582,59 +764,38 @@ export async function startDaemon(): Promise<void> { // This should never be reached, but TypeScript requires a return statement return { type: 'error', + errorCode: SPAWN_SESSION_ERROR_CODES.UNEXPECTED, errorMessage: 'Unexpected error in session spawning' }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - logger.debug('[DAEMON RUN] Failed to spawn session:', error); - return { - type: 'error', - errorMessage: `Failed to spawn session: ${errorMessage}` + } catch (error) { + if (spawnResourceCleanupOnFailure && !spawnResourceCleanupArmed) { + spawnResourceCleanupOnFailure(); + spawnResourceCleanupOnFailure = null; + spawnResourceCleanupOnExit = null; + } + if (sessionAttachCleanup) { + await sessionAttachCleanup(); + sessionAttachCleanup = null; + } + const errorMessage = error instanceof Error ? error.message : String(error); + logger.debug('[DAEMON RUN] Failed to spawn session:', error); + return { + type: 'error', + errorCode: SPAWN_SESSION_ERROR_CODES.SPAWN_FAILED, + errorMessage: `Failed to spawn session: ${errorMessage}` }; } }; - // Stop a session by sessionId or PID fallback - const stopSession = (sessionId: string): boolean => { - logger.debug(`[DAEMON RUN] Attempting to stop session ${sessionId}`); - - // Try to find by sessionId first - for (const [pid, session] of pidToTrackedSession.entries()) { - if (session.happySessionId === sessionId || - (sessionId.startsWith('PID-') && pid === parseInt(sessionId.replace('PID-', '')))) { - - if (session.startedBy === 'daemon' && session.childProcess) { - try { - session.childProcess.kill('SIGTERM'); - logger.debug(`[DAEMON RUN] Sent SIGTERM to daemon-spawned session ${sessionId}`); - } catch (error) { - logger.debug(`[DAEMON RUN] Failed to kill session ${sessionId}:`, error); - } - } else { - // For externally started sessions, try to kill by PID - try { - process.kill(pid, 'SIGTERM'); - logger.debug(`[DAEMON RUN] Sent SIGTERM to external session PID ${pid}`); - } catch (error) { - logger.debug(`[DAEMON RUN] Failed to kill external session PID ${pid}:`, error); - } - } - - pidToTrackedSession.delete(pid); - logger.debug(`[DAEMON RUN] Removed session ${sessionId} from tracking`); - return true; - } - } + const stopSession = createStopSession({ pidToTrackedSession }); - logger.debug(`[DAEMON RUN] Session ${sessionId} not found`); - return false; - }; - - // Handle child process exit - const onChildExited = (pid: number) => { - logger.debug(`[DAEMON RUN] Removing exited process PID ${pid} from tracking`); - pidToTrackedSession.delete(pid); - }; + // Handle child process exit + const onChildExited = createOnChildExited({ + pidToTrackedSession, + spawnResourceCleanupByPid, + sessionAttachCleanupByPid, + getApiMachineForSessions: () => apiMachineForSessions, + }); // Start control server const { port: controlPort, stop: stopControlServer } = await startDaemonControlServer({ @@ -668,15 +829,18 @@ export async function startDaemon(): Promise<void> { const api = await ApiClient.create(credentials); // Get or create machine + const preferredHostForRegistration = await getPreferredHostName(); + const metadataForRegistration: MachineMetadata = { ...initialMachineMetadata, host: preferredHostForRegistration }; const machine = await api.getOrCreateMachine({ machineId, - metadata: initialMachineMetadata, + metadata: metadataForRegistration, daemonState: initialDaemonState }); logger.debug(`[DAEMON RUN] Machine registered: ${machine.id}`); // Create realtime machine session const apiMachine = api.machineSyncClient(machine); + apiMachineForSessions = apiMachine; // Set RPC handlers apiMachine.setRPCHandlers({ @@ -686,104 +850,77 @@ export async function startDaemon(): Promise<void> { }); // Connect to server - apiMachine.connect(); + const preferredHost = await getPreferredHostName(); + let didRefreshMachineMetadata = false; + apiMachine.connect({ + onConnect: async () => { + if (didRefreshMachineMetadata) return; + + // Keep machine metadata fresh without clobbering user-provided fields (e.g. displayName) that may exist. + await apiMachine.updateMachineMetadata((metadata) => { + const base = (metadata ?? (machine.metadata as any) ?? {}) as any; + const next: MachineMetadata = { + ...base, + host: preferredHost, + platform: os.platform(), + happyCliVersion: packageJson.version, + homeDir: os.homedir(), + happyHomeDir: configuration.happyHomeDir, + happyLibDir: projectPath(), + } as MachineMetadata; + + // If nothing changes, skip emitting an update entirely. + const current = base as Partial<MachineMetadata>; + const isSame = + current.host === next.host && + current.platform === next.platform && + current.happyCliVersion === next.happyCliVersion && + current.homeDir === next.homeDir && + current.happyHomeDir === next.happyHomeDir && + current.happyLibDir === next.happyLibDir; + + if (isSame) { + return base as MachineMetadata; + } + + return next; + }); + + didRefreshMachineMetadata = true; + }, + }); // Every 60 seconds: // 1. Prune stale sessions // 2. Check if daemon needs update // 3. If outdated, restart with latest version // 4. Write heartbeat - const heartbeatIntervalMs = parseInt(process.env.HAPPY_DAEMON_HEARTBEAT_INTERVAL || '60000'); - let heartbeatRunning = false - const restartOnStaleVersionAndHeartbeat = setInterval(async () => { - if (heartbeatRunning) { - return; - } - heartbeatRunning = true; - - if (process.env.DEBUG) { - logger.debug(`[DAEMON RUN] Health check started at ${new Date().toLocaleString()}`); - } - - // Prune stale sessions - for (const [pid, _] of pidToTrackedSession.entries()) { - try { - // Check if process is still alive (signal 0 doesn't kill, just checks) - process.kill(pid, 0); - } catch (error) { - // Process is dead, remove from tracking - logger.debug(`[DAEMON RUN] Removing stale session with PID ${pid} (process no longer exists)`); - pidToTrackedSession.delete(pid); - } - } - - // Check if daemon needs update - // If version on disk is different from the one in package.json - we need to restart - // BIG if - does this get updated from underneath us on npm upgrade? - const projectVersion = JSON.parse(readFileSync(join(projectPath(), 'package.json'), 'utf-8')).version; - if (projectVersion !== configuration.currentCliVersion) { - logger.debug('[DAEMON RUN] Daemon is outdated, triggering self-restart with latest version, clearing heartbeat interval'); - - clearInterval(restartOnStaleVersionAndHeartbeat); - - // Spawn new daemon through the CLI - // We do not need to clean ourselves up - we will be killed by - // the CLI start command. - // 1. It will first check if daemon is running (yes in this case) - // 2. If the version is stale (it will read daemon.state.json file and check startedWithCliVersion) & compare it to its own version - // 3. Next it will start a new daemon with the latest version with daemon-sync :D - // Done! - try { - spawnHappyCLI(['daemon', 'start'], { - detached: true, - stdio: 'ignore' - }); - } catch (error) { - logger.debug('[DAEMON RUN] Failed to spawn new daemon, this is quite likely to happen during integration tests as we are cleaning out dist/ directory', error); - } - - // So we can just hang forever - logger.debug('[DAEMON RUN] Hanging for a bit - waiting for CLI to kill us because we are running outdated version of the code'); - await new Promise(resolve => setTimeout(resolve, 10_000)); - process.exit(0); - } - - // Before wrecklessly overriting the daemon state file, we should check if we are the ones who own it - // Race condition is possible, but thats okay for the time being :D - const daemonState = await readDaemonState(); - if (daemonState && daemonState.pid !== process.pid) { - logger.debug('[DAEMON RUN] Somehow a different daemon was started without killing us. We should kill ourselves.') - requestShutdown('exception', 'A different daemon was started without killing us. We should kill ourselves.') - } - - // Heartbeat - try { - const updatedState: DaemonLocallyPersistedState = { - pid: process.pid, - httpPort: controlPort, - startTime: fileState.startTime, - startedWithCliVersion: packageJson.version, - lastHeartbeat: new Date().toLocaleString(), - daemonLogPath: fileState.daemonLogPath - }; - writeDaemonState(updatedState); - if (process.env.DEBUG) { - logger.debug(`[DAEMON RUN] Health check completed at ${updatedState.lastHeartbeat}`); - } - } catch (error) { - logger.debug('[DAEMON RUN] Failed to write heartbeat', error); - } - - heartbeatRunning = false; - }, heartbeatIntervalMs); // Every 60 seconds in production - - // Setup signal handlers - const cleanupAndShutdown = async (source: 'happy-app' | 'happy-cli' | 'os-signal' | 'exception', errorMessage?: string) => { - logger.debug(`[DAEMON RUN] Starting proper cleanup (source: ${source}, errorMessage: ${errorMessage})...`); + const restartOnStaleVersionAndHeartbeat = startDaemonHeartbeatLoop({ + pidToTrackedSession, + spawnResourceCleanupByPid, + sessionAttachCleanupByPid, + getApiMachineForSessions: () => apiMachineForSessions, + controlPort, + fileState, + currentCliVersion: configuration.currentCliVersion, + requestShutdown, + }); - // Clear health check interval - if (restartOnStaleVersionAndHeartbeat) { - clearInterval(restartOnStaleVersionAndHeartbeat); + // Setup signal handlers + const cleanupAndShutdown = async (source: 'happy-app' | 'happy-cli' | 'os-signal' | 'exception', errorMessage?: string) => { + const exitCode = getDaemonShutdownExitCode(source); + const shutdownWatchdog = setTimeout(async () => { + logger.debug(`[DAEMON RUN] Shutdown timed out, forcing exit with code ${exitCode}`); + await new Promise((resolve) => setTimeout(resolve, 100)); + process.exit(exitCode); + }, getDaemonShutdownWatchdogTimeoutMs()); + shutdownWatchdog.unref?.(); + + logger.debug(`[DAEMON RUN] Starting proper cleanup (source: ${source}, errorMessage: ${errorMessage})...`); + + // Clear health check interval + if (restartOnStaleVersionAndHeartbeat) { + clearInterval(restartOnStaleVersionAndHeartbeat); logger.debug('[DAEMON RUN] Health check interval cleared'); } @@ -800,13 +937,16 @@ export async function startDaemon(): Promise<void> { apiMachine.shutdown(); await stopControlServer(); - await cleanupDaemonState(); - await stopCaffeinate(); - await releaseDaemonLock(daemonLockHandle); + await cleanupDaemonState(); + await stopCaffeinate(); + if (daemonLockHandle) { + await releaseDaemonLock(daemonLockHandle); + } - logger.debug('[DAEMON RUN] Cleanup completed, exiting process'); - process.exit(0); - }; + logger.debug('[DAEMON RUN] Cleanup completed, exiting process'); + clearTimeout(shutdownWatchdog); + process.exit(exitCode); + }; logger.debug('[DAEMON RUN] Daemon started successfully, waiting for shutdown request'); @@ -814,6 +954,13 @@ export async function startDaemon(): Promise<void> { const shutdownRequest = await resolvesWhenShutdownRequested; await cleanupAndShutdown(shutdownRequest.source, shutdownRequest.errorMessage); } catch (error) { + try { + if (daemonLockHandle) { + await releaseDaemonLock(daemonLockHandle); + } + } catch { + // ignore + } logger.debug('[DAEMON RUN][FATAL] Failed somewhere unexpectedly - exiting with code 1', error); process.exit(1); } diff --git a/cli/src/daemon/sessionAttachFile.test.ts b/cli/src/daemon/sessionAttachFile.test.ts new file mode 100644 index 000000000..df7f49296 --- /dev/null +++ b/cli/src/daemon/sessionAttachFile.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, test, vi } from 'vitest'; + +describe('createSessionAttachFile', () => { + test('writes a 0600 attach file under HAPPY_HOME_DIR and cleanup deletes it', async () => { + const { mkdtemp, readFile, stat } = await import('node:fs/promises'); + const { tmpdir } = await import('node:os'); + const { join, resolve, sep } = await import('node:path'); + + const dir = await mkdtemp(join(tmpdir(), 'happy-home-')); + process.env.HAPPY_HOME_DIR = dir; + const baseDir = resolve(join(dir, 'tmp', 'session-attach')); + + vi.resetModules(); + + const { encodeBase64 } = await import('@/api/encryption'); + const { createSessionAttachFile } = await import('./sessionAttachFile'); + + const key = encodeBase64(new Uint8Array(32).fill(5), 'base64'); + const { filePath, cleanup } = await createSessionAttachFile({ + happySessionId: 'happy-session-1', + payload: { encryptionKeyBase64: key, encryptionVariant: 'dataKey' }, + }); + + expect(resolve(filePath).startsWith(baseDir + sep)).toBe(true); + + const raw = await readFile(filePath, 'utf-8'); + expect(JSON.parse(raw)).toEqual({ + encryptionKeyBase64: key, + encryptionVariant: 'dataKey', + }); + + if (process.platform !== 'win32') { + const s = await stat(filePath); + expect(s.mode & 0o077).toBe(0); + } + + await cleanup(); + await expect(stat(filePath)).rejects.toBeTruthy(); + }); + + test('prevents path traversal in happySessionId (always stays within base dir)', async () => { + const { mkdtemp, stat } = await import('node:fs/promises'); + const { tmpdir } = await import('node:os'); + const { basename, join, resolve, sep } = await import('node:path'); + + const dir = await mkdtemp(join(tmpdir(), 'happy-home-')); + process.env.HAPPY_HOME_DIR = dir; + const baseDir = resolve(join(dir, 'tmp', 'session-attach')); + + vi.resetModules(); + + const { encodeBase64 } = await import('@/api/encryption'); + const { createSessionAttachFile } = await import('./sessionAttachFile'); + + const key = encodeBase64(new Uint8Array(32).fill(5), 'base64'); + + const { filePath, cleanup } = await createSessionAttachFile({ + happySessionId: '../evil', + payload: { encryptionKeyBase64: key, encryptionVariant: 'dataKey' }, + }); + + expect(resolve(filePath).startsWith(baseDir + sep)).toBe(true); + expect(basename(filePath).startsWith('..')).toBe(false); + + await cleanup(); + await expect(stat(filePath)).rejects.toBeTruthy(); + + // Ensure the base directory still exists (we didn't clobber parent dirs). + await expect(stat(join(dir, 'tmp', 'session-attach'))).resolves.toBeTruthy(); + }); +}); diff --git a/cli/src/daemon/sessionAttachFile.ts b/cli/src/daemon/sessionAttachFile.ts new file mode 100644 index 000000000..23acc9b19 --- /dev/null +++ b/cli/src/daemon/sessionAttachFile.ts @@ -0,0 +1,55 @@ +import { configuration } from '@/configuration'; +import { logger } from '@/ui/logger'; +import { randomUUID } from 'node:crypto'; +import { mkdir, unlink, writeFile } from 'node:fs/promises'; +import { isAbsolute, join, relative, resolve, sep } from 'node:path'; + +export type SessionAttachFilePayload = { + encryptionKeyBase64: string; + encryptionVariant: 'dataKey'; +}; + +function sanitizeHappySessionIdForFilename(happySessionId: string): string { + const safe = happySessionId.replace(/[^A-Za-z0-9._-]+/g, '_'); + const trimmed = safe + .replace(/_+/g, '_') + .replace(/^[._-]+/, '') + .replace(/[_-]+$/, ''); + + const normalized = trimmed.length > 0 ? trimmed : 'session'; + return normalized.length > 96 ? normalized.slice(0, 96) : normalized; +} + +function assertPathWithinBaseDir(baseDir: string, filePath: string): void { + const rel = relative(baseDir, filePath); + if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) { + throw new Error('Invalid session attach file path'); + } +} + +export async function createSessionAttachFile(params: { + happySessionId: string; + payload: SessionAttachFilePayload; +}): Promise<{ filePath: string; cleanup: () => Promise<void> }> { + const baseDir = resolve(join(configuration.happyHomeDir, 'tmp', 'session-attach')); + await mkdir(baseDir, { recursive: true }); + + const safeSessionId = sanitizeHappySessionIdForFilename(params.happySessionId); + const filePath = resolve(join(baseDir, `${safeSessionId}-${randomUUID()}.json`)); + assertPathWithinBaseDir(baseDir, filePath); + + const payloadJson = JSON.stringify(params.payload); + await writeFile(filePath, payloadJson, { mode: 0o600 }); + + const cleanup = async () => { + try { + await unlink(filePath); + } catch { + // ignore + } + }; + + logger.debug('[daemon] Created session attach file', { filePath }); + + return { filePath, cleanup }; +} diff --git a/cli/src/daemon/sessionExitReport.test.ts b/cli/src/daemon/sessionExitReport.test.ts new file mode 100644 index 000000000..f38ec5b70 --- /dev/null +++ b/cli/src/daemon/sessionExitReport.test.ts @@ -0,0 +1,92 @@ +import { mkdtemp, readFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it, vi } from 'vitest'; + +describe('writeSessionExitReport', () => { + it('writes a JSON report to disk', async () => { + const { writeSessionExitReport } = await import('./sessionExitReport'); + const dir = await mkdtemp(join(tmpdir(), 'happy-exit-report-')); + + const outPath = await writeSessionExitReport({ + baseDir: dir, + sessionId: 'sess_1', + pid: 123, + report: { + observedAt: 1, + observedBy: 'daemon', + reason: 'process-missing', + }, + }); + + const raw = await readFile(outPath, 'utf8'); + const parsed = JSON.parse(raw); + expect(parsed).toMatchObject({ + sessionId: 'sess_1', + pid: 123, + observedAt: 1, + observedBy: 'daemon', + reason: 'process-missing', + }); + }); + + it('writes a JSON report to disk (sync)', async () => { + const { writeSessionExitReportSync } = await import('./sessionExitReport'); + const dir = await mkdtemp(join(tmpdir(), 'happy-exit-report-sync-')); + + const outPath = writeSessionExitReportSync({ + baseDir: dir, + sessionId: 'sess_2', + pid: 456, + report: { + observedAt: 2, + observedBy: 'session', + reason: 'uncaught-exception', + }, + }); + + const raw = await readFile(outPath, 'utf8'); + const parsed = JSON.parse(raw); + expect(parsed).toMatchObject({ + sessionId: 'sess_2', + pid: 456, + observedAt: 2, + observedBy: 'session', + reason: 'uncaught-exception', + }); + }); + + it('defaults to HAPPY_HOME_DIR/logs/session-exit', async () => { + const dir = await mkdtemp(join(tmpdir(), 'happy-home-dir-')); + vi.stubEnv('HAPPY_HOME_DIR', dir); + + try { + // Ensure Configuration picks up the test HAPPY_HOME_DIR. + vi.resetModules(); + const { writeSessionExitReportSync } = await import('./sessionExitReport'); + + const outPath = writeSessionExitReportSync({ + sessionId: 'sess_3', + pid: 789, + report: { + observedAt: 3, + observedBy: 'daemon', + reason: 'process-missing', + }, + }); + + expect(outPath.startsWith(join(dir, 'logs', 'session-exit'))).toBe(true); + const raw = await readFile(outPath, 'utf8'); + const parsed = JSON.parse(raw); + expect(parsed).toMatchObject({ + sessionId: 'sess_3', + pid: 789, + observedAt: 3, + observedBy: 'daemon', + reason: 'process-missing', + }); + } finally { + vi.unstubAllEnvs(); + } + }); +}); diff --git a/cli/src/daemon/sessionExitReport.ts b/cli/src/daemon/sessionExitReport.ts new file mode 100644 index 000000000..6c3389cbc --- /dev/null +++ b/cli/src/daemon/sessionExitReport.ts @@ -0,0 +1,65 @@ +import { mkdir, writeFile } from 'node:fs/promises'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { configuration } from '@/configuration'; + +export type SessionExitReport = { + observedAt: number; + observedBy: 'daemon' | 'session'; + reason: string; + code?: number | null; + signal?: string | null; + lastRpcMethod?: string | null; + lastRpcAt?: number | null; + error?: string | null; +}; + +/** + * Persist a small, structured "why did this session stop?" record to disk. + * + * This is intentionally local-only so we can keep richer diagnostics without + * expanding server schema or leaking sensitive details. + */ +export async function writeSessionExitReport(opts: { + baseDir?: string; + sessionId?: string | null; + pid: number; + report: SessionExitReport; +}): Promise<string> { + const baseDir = opts.baseDir ?? join(configuration.happyHomeDir, 'logs', 'session-exit'); + await mkdir(baseDir, { recursive: true }); + + const sessionPart = opts.sessionId ? `session-${opts.sessionId}` : 'session-unknown'; + const path = join(baseDir, `${sessionPart}-pid-${opts.pid}.json`); + + const payload = { + sessionId: opts.sessionId ?? null, + pid: opts.pid, + ...opts.report, + }; + + await writeFile(path, JSON.stringify(payload, null, 2), 'utf8'); + return path; +} + +export function writeSessionExitReportSync(opts: { + baseDir?: string; + sessionId?: string | null; + pid: number; + report: SessionExitReport; +}): string { + const baseDir = opts.baseDir ?? join(configuration.happyHomeDir, 'logs', 'session-exit'); + mkdirSync(baseDir, { recursive: true }); + + const sessionPart = opts.sessionId ? `session-${opts.sessionId}` : 'session-unknown'; + const path = join(baseDir, `${sessionPart}-pid-${opts.pid}.json`); + + const payload = { + sessionId: opts.sessionId ?? null, + pid: opts.pid, + ...opts.report, + }; + + writeFileSync(path, JSON.stringify(payload, null, 2), 'utf8'); + return path; +} diff --git a/cli/src/daemon/sessionRegistry.test.ts b/cli/src/daemon/sessionRegistry.test.ts new file mode 100644 index 000000000..a92b21a04 --- /dev/null +++ b/cli/src/daemon/sessionRegistry.test.ts @@ -0,0 +1,135 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +describe('sessionRegistry', () => { + const originalHappyHomeDir = process.env.HAPPY_HOME_DIR; + let happyHomeDir: string; + + beforeEach(() => { + happyHomeDir = join(tmpdir(), `happy-cli-session-registry-${Date.now()}-${Math.random().toString(36).slice(2)}`); + process.env.HAPPY_HOME_DIR = happyHomeDir; + vi.resetModules(); + }); + + afterEach(() => { + if (existsSync(happyHomeDir)) { + rmSync(happyHomeDir, { recursive: true, force: true }); + } + if (originalHappyHomeDir === undefined) { + delete process.env.HAPPY_HOME_DIR; + } else { + process.env.HAPPY_HOME_DIR = originalHappyHomeDir; + } + }); + + it('should write a marker and preserve createdAt across updates', async () => { + const { configuration } = await import('@/configuration'); + const { listSessionMarkers, writeSessionMarker } = await import('./sessionRegistry'); + + await writeSessionMarker({ + pid: 12345, + happySessionId: 'sess-1', + startedBy: 'terminal', + cwd: '/tmp', + }); + + const markers1 = await listSessionMarkers(); + expect(markers1).toHaveLength(1); + expect(markers1[0].pid).toBe(12345); + expect(markers1[0].happySessionId).toBe('sess-1'); + expect(markers1[0].happyHomeDir).toBe(configuration.happyHomeDir); + expect(typeof markers1[0].createdAt).toBe('number'); + expect(typeof markers1[0].updatedAt).toBe('number'); + + const createdAt1 = markers1[0].createdAt; + const updatedAt1 = markers1[0].updatedAt; + + // Ensure updatedAt changes even on fast machines. + await new Promise((r) => setTimeout(r, 2)); + + await writeSessionMarker({ + pid: 12345, + happySessionId: 'sess-2', + startedBy: 'terminal', + cwd: '/tmp', + }); + + const markers2 = await listSessionMarkers(); + expect(markers2).toHaveLength(1); + expect(markers2[0].createdAt).toBe(createdAt1); + expect(markers2[0].updatedAt).toBeGreaterThanOrEqual(updatedAt1); + expect(markers2[0].happySessionId).toBe('sess-2'); + }); + + it('should ignore markers with wrong happyHomeDir and tolerate invalid JSON', async () => { + const { configuration } = await import('@/configuration'); + const { listSessionMarkers } = await import('./sessionRegistry'); + + const dir = join(configuration.happyHomeDir, 'tmp', 'daemon-sessions'); + mkdirSync(dir, { recursive: true }); + // Write a marker with different happyHomeDir + writeFileSync( + join(dir, 'pid-111.json'), + JSON.stringify({ pid: 111, happySessionId: 'x', happyHomeDir: '/other', createdAt: 1, updatedAt: 1 }, null, 2), + 'utf-8' + ); + // Write invalid JSON + writeFileSync(join(dir, 'pid-222.json'), '{', 'utf-8'); + + const markers = await listSessionMarkers(); + expect(markers).toEqual([]); + }); + + it('removeSessionMarker should not throw if the marker does not exist', async () => { + const { removeSessionMarker } = await import('./sessionRegistry'); + await expect(removeSessionMarker(99999)).resolves.toBeUndefined(); + }); + + it('writes valid JSON payload shape to disk', async () => { + const { configuration } = await import('@/configuration'); + const { writeSessionMarker } = await import('./sessionRegistry'); + + // 64 hex chars (sha256) + const processCommandHash = 'a'.repeat(64); + + await writeSessionMarker({ + pid: 54321, + happySessionId: 'sess-xyz', + startedBy: 'daemon', + cwd: '/tmp', + processCommandHash, + processCommand: 'node dist/index.mjs --started-by daemon', + }); + + const filePath = join(configuration.happyHomeDir, 'tmp', 'daemon-sessions', 'pid-54321.json'); + const raw = readFileSync(filePath, 'utf-8'); + const parsed = JSON.parse(raw); + expect(parsed.pid).toBe(54321); + expect(parsed.happySessionId).toBe('sess-xyz'); + expect(parsed.happyHomeDir).toBe(configuration.happyHomeDir); + expect(parsed.startedBy).toBe('daemon'); + expect(parsed.processCommandHash).toBe(processCommandHash); + expect(parsed.processCommand).toBe('node dist/index.mjs --started-by daemon'); + expect(typeof parsed.createdAt).toBe('number'); + expect(typeof parsed.updatedAt).toBe('number'); + }); + + it('supports opencode flavor markers', async () => { + const { listSessionMarkers, writeSessionMarker } = await import('./sessionRegistry'); + + await writeSessionMarker({ + pid: 777, + happySessionId: 'sess-opencode', + startedBy: 'terminal', + flavor: 'opencode', + cwd: '/tmp', + }); + + const markers = await listSessionMarkers(); + expect(markers).toHaveLength(1); + expect(markers[0].pid).toBe(777); + expect(markers[0].flavor).toBe('opencode'); + }); +}); diff --git a/cli/src/daemon/sessionRegistry.ts b/cli/src/daemon/sessionRegistry.ts new file mode 100644 index 000000000..26dfcb5f7 --- /dev/null +++ b/cli/src/daemon/sessionRegistry.ts @@ -0,0 +1,134 @@ +import { configuration } from '@/configuration'; +import { logger } from '@/ui/logger'; +import { createHash } from 'node:crypto'; +import { mkdir, readdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import * as z from 'zod'; +import { CATALOG_AGENT_IDS } from '@/backends/types'; + +const DaemonSessionMarkerSchema = z.object({ + pid: z.number().int().positive(), + happySessionId: z.string(), + happyHomeDir: z.string(), + createdAt: z.number().int().positive(), + updatedAt: z.number().int().positive(), + flavor: z.enum(CATALOG_AGENT_IDS).optional(), + startedBy: z.enum(['daemon', 'terminal']).optional(), + cwd: z.string().optional(), + // Process identity safety (PID reuse mitigation). Hash of the observed process command line. + processCommandHash: z.string().regex(/^[a-f0-9]{64}$/).optional(), + // Optional debug-only sample of the observed command (best-effort; may be truncated by ps-list). + processCommand: z.string().optional(), + metadata: z.any().optional(), +}); + +export type DaemonSessionMarker = z.infer<typeof DaemonSessionMarkerSchema>; + +export function hashProcessCommand(command: string): string { + return createHash('sha256').update(command).digest('hex'); +} + +function daemonSessionsDir(): string { + return join(configuration.happyHomeDir, 'tmp', 'daemon-sessions'); +} + +async function ensureDir(dir: string): Promise<void> { + await mkdir(dir, { recursive: true }); +} + +async function writeJsonAtomic(filePath: string, value: unknown): Promise<void> { + const tmpPath = `${filePath}.tmp`; + try { + await writeFile(tmpPath, JSON.stringify(value, null, 2), 'utf-8'); + try { + await rename(tmpPath, filePath); + } catch (e) { + const err = e as NodeJS.ErrnoException; + // On Windows, rename may fail if destination exists. + if (err?.code === 'EEXIST' || err?.code === 'EPERM') { + try { + await unlink(filePath); + } catch { + // ignore unlink failure (e.g. ENOENT) + } + await rename(tmpPath, filePath); + return; + } + throw e; + } + } catch (e) { + // Best-effort cleanup to avoid leaving behind orphaned temp files on failure. + try { + await unlink(tmpPath); + } catch { + // ignore cleanup failure + } + throw e; + } +} + +export async function writeSessionMarker(marker: Omit<DaemonSessionMarker, 'createdAt' | 'updatedAt' | 'happyHomeDir'> & { createdAt?: number; updatedAt?: number }): Promise<void> { + await ensureDir(daemonSessionsDir()); + const now = Date.now(); + const filePath = join(daemonSessionsDir(), `pid-${marker.pid}.json`); + + let createdAtFromDisk: number | undefined; + try { + const raw = await readFile(filePath, 'utf-8'); + const existing = DaemonSessionMarkerSchema.safeParse(JSON.parse(raw)); + if (existing.success) { + createdAtFromDisk = existing.data.createdAt; + } + } catch (e) { + // ignore ENOENT (new marker); log other errors for diagnostics + const err = e as NodeJS.ErrnoException; + if (err?.code !== 'ENOENT') { + logger.debug(`[sessionRegistry] Could not read existing session marker pid-${marker.pid}.json to preserve createdAt`, e); + } + } + + const payload: DaemonSessionMarker = DaemonSessionMarkerSchema.parse({ + ...marker, + happyHomeDir: configuration.happyHomeDir, + createdAt: marker.createdAt ?? createdAtFromDisk ?? now, + updatedAt: now, + }); + await writeJsonAtomic(filePath, payload); +} + +export async function removeSessionMarker(pid: number): Promise<void> { + const filePath = join(daemonSessionsDir(), `pid-${pid}.json`); + try { + await unlink(filePath); + } catch (e) { + const err = e as NodeJS.ErrnoException; + if (err?.code !== 'ENOENT') { + logger.debug(`[sessionRegistry] Failed to remove session marker pid-${pid}.json`, e); + } + } +} + +export async function listSessionMarkers(): Promise<DaemonSessionMarker[]> { + await ensureDir(daemonSessionsDir()); + const entries = await readdir(daemonSessionsDir()); + const markers: DaemonSessionMarker[] = []; + for (const name of entries) { + if (!name.startsWith('pid-') || !name.endsWith('.json')) continue; + const full = join(daemonSessionsDir(), name); + try { + const raw = await readFile(full, 'utf-8'); + const parsed = DaemonSessionMarkerSchema.safeParse(JSON.parse(raw)); + if (!parsed.success) { + logger.debug(`[sessionRegistry] Failed to parse session marker ${name}`, parsed.error); + continue; + } + // Extra safety: only accept markers for our home dir. + if (parsed.data.happyHomeDir !== configuration.happyHomeDir) continue; + markers.push(parsed.data); + } catch (e) { + logger.debug(`[sessionRegistry] Failed to read or parse session marker ${name}`, e); + // ignore unreadable marker + } + } + return markers; +} diff --git a/cli/src/daemon/sessionTermination.test.ts b/cli/src/daemon/sessionTermination.test.ts new file mode 100644 index 000000000..cce7c9f2d --- /dev/null +++ b/cli/src/daemon/sessionTermination.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { TrackedSession } from './types'; + +describe('daemon session termination reporting', () => { + it('emits session-end when sessionId is known', async () => { + const apiMachine = { + emitSessionEnd: vi.fn(), + }; + + const { reportDaemonObservedSessionExit } = await import('./sessionTermination'); + + const tracked: TrackedSession = { + startedBy: 'daemon', + pid: 123, + happySessionId: 'sess_1', + }; + + const now = 1710000000000; + reportDaemonObservedSessionExit({ + apiMachine, + trackedSession: tracked, + now: () => now, + exit: { reason: 'process-missing' }, + }); + + expect(apiMachine.emitSessionEnd).toHaveBeenCalledWith({ + sid: 'sess_1', + time: now, + exit: expect.objectContaining({ + observedBy: 'daemon', + reason: 'process-missing', + pid: 123, + }), + }); + }); + + it('does not emit session-end when sessionId is unknown', async () => { + const apiMachine = { + emitSessionEnd: vi.fn(), + }; + + const { reportDaemonObservedSessionExit } = await import('./sessionTermination'); + + const tracked: TrackedSession = { + startedBy: 'daemon', + pid: 123, + }; + + reportDaemonObservedSessionExit({ + apiMachine, + trackedSession: tracked, + now: () => 1, + exit: { reason: 'process-missing' }, + }); + + expect(apiMachine.emitSessionEnd).not.toHaveBeenCalled(); + }); +}); diff --git a/cli/src/daemon/sessionTermination.ts b/cli/src/daemon/sessionTermination.ts new file mode 100644 index 000000000..541cd7c8f --- /dev/null +++ b/cli/src/daemon/sessionTermination.ts @@ -0,0 +1,32 @@ +import type { TrackedSession } from './types'; + +type DaemonObservedExit = { + reason: string; + code?: number | null; + signal?: string | null; +}; + +export function reportDaemonObservedSessionExit(opts: { + apiMachine: { emitSessionEnd: (payload: any) => void }; + trackedSession: TrackedSession; + now: () => number; + exit: DaemonObservedExit; +}) { + const { apiMachine, trackedSession, now, exit } = opts; + + if (!trackedSession.happySessionId) { + return; + } + + apiMachine.emitSessionEnd({ + sid: trackedSession.happySessionId, + time: now(), + exit: { + observedBy: 'daemon', + pid: trackedSession.pid, + reason: exit.reason, + code: exit.code ?? null, + signal: exit.signal ?? null, + }, + }); +} diff --git a/cli/src/daemon/sessions/onChildExited.ts b/cli/src/daemon/sessions/onChildExited.ts new file mode 100644 index 000000000..3d006cf11 --- /dev/null +++ b/cli/src/daemon/sessions/onChildExited.ts @@ -0,0 +1,63 @@ +import type { ApiMachineClient } from '@/api/apiMachine'; +import { logger } from '@/ui/logger'; +import { writeSessionExitReport } from '@/daemon/sessionExitReport'; + +import type { TrackedSession } from '../types'; +import { reportDaemonObservedSessionExit } from '../sessionTermination'; +import { removeSessionMarker } from '../sessionRegistry'; + +export type ChildExit = { reason: string; code: number | null; signal: string | null }; + +export function createOnChildExited(params: Readonly<{ + pidToTrackedSession: Map<number, TrackedSession>; + spawnResourceCleanupByPid: Map<number, () => void>; + sessionAttachCleanupByPid: Map<number, () => Promise<void>>; + getApiMachineForSessions: () => ApiMachineClient | null; +}>): (pid: number, exit: ChildExit) => void { + const { pidToTrackedSession, spawnResourceCleanupByPid, sessionAttachCleanupByPid, getApiMachineForSessions } = params; + + return (pid: number, exit: ChildExit) => { + logger.debug(`[DAEMON RUN] Removing exited process PID ${pid} from tracking`); + const tracked = pidToTrackedSession.get(pid); + if (tracked) { + const apiMachineForSessions = getApiMachineForSessions(); + if (apiMachineForSessions) { + reportDaemonObservedSessionExit({ + apiMachine: apiMachineForSessions, + trackedSession: tracked, + now: () => Date.now(), + exit, + }); + } + void writeSessionExitReport({ + sessionId: tracked.happySessionId ?? null, + pid, + report: { + observedAt: Date.now(), + observedBy: 'daemon', + reason: exit.reason, + code: exit.code, + signal: exit.signal, + }, + }).catch((e) => logger.debug('[DAEMON RUN] Failed to write session exit report', e)); + } + const cleanup = spawnResourceCleanupByPid.get(pid); + if (cleanup) { + spawnResourceCleanupByPid.delete(pid); + try { + cleanup(); + } catch (error) { + logger.debug('[DAEMON RUN] Failed to cleanup spawn resources', error); + } + } + const attachCleanup = sessionAttachCleanupByPid.get(pid); + if (attachCleanup) { + sessionAttachCleanupByPid.delete(pid); + void attachCleanup().catch((error) => { + logger.debug('[DAEMON RUN] Failed to cleanup session attach file', error); + }); + } + pidToTrackedSession.delete(pid); + void removeSessionMarker(pid); + }; +} diff --git a/cli/src/daemon/sessions/onHappySessionWebhook.ts b/cli/src/daemon/sessions/onHappySessionWebhook.ts new file mode 100644 index 000000000..70c4f13d7 --- /dev/null +++ b/cli/src/daemon/sessions/onHappySessionWebhook.ts @@ -0,0 +1,92 @@ +import type { Metadata } from '@/api/types'; +import { configuration } from '@/configuration'; +import { logger } from '@/ui/logger'; + +import { findHappyProcessByPid } from '../doctor'; +import type { TrackedSession } from '../types'; +import { hashProcessCommand, writeSessionMarker } from '../sessionRegistry'; + +export function createOnHappySessionWebhook(params: Readonly<{ + pidToTrackedSession: Map<number, TrackedSession>; + pidToAwaiter: Map<number, (session: TrackedSession) => void>; +}>): (sessionId: string, sessionMetadata: Metadata) => void { + const { pidToTrackedSession, pidToAwaiter } = params; + + return (sessionId: string, sessionMetadata: Metadata) => { + logger.debugLargeJson(`[DAEMON RUN] Session reported`, sessionMetadata); + + // Safety: ignore cross-daemon/cross-stack reports. + if (sessionMetadata?.happyHomeDir && sessionMetadata.happyHomeDir !== configuration.happyHomeDir) { + logger.debug(`[DAEMON RUN] Ignoring session report for different happyHomeDir: ${sessionMetadata.happyHomeDir}`); + return; + } + + const pid = sessionMetadata.hostPid; + if (!pid) { + logger.debug(`[DAEMON RUN] Session webhook missing hostPid for sessionId: ${sessionId}`); + return; + } + + logger.debug(`[DAEMON RUN] Session webhook: ${sessionId}, PID: ${pid}, started by: ${sessionMetadata.startedBy || 'unknown'}`); + logger.debug(`[DAEMON RUN] Current tracked sessions before webhook: ${Array.from(pidToTrackedSession.keys()).join(', ')}`); + + // Check if we already have this PID (daemon-spawned) + const existingSession = pidToTrackedSession.get(pid); + + if (existingSession && existingSession.startedBy === 'daemon') { + // Update daemon-spawned session with reported data + existingSession.happySessionId = sessionId; + existingSession.happySessionMetadataFromLocalWebhook = sessionMetadata; + logger.debug(`[DAEMON RUN] Updated daemon-spawned session ${sessionId} with metadata`); + + // Resolve any awaiter for this PID + const awaiter = pidToAwaiter.get(pid); + if (awaiter) { + pidToAwaiter.delete(pid); + awaiter(existingSession); + logger.debug(`[DAEMON RUN] Resolved session awaiter for PID ${pid}`); + } + } else if (!existingSession) { + // New session started externally + const trackedSession: TrackedSession = { + startedBy: 'happy directly - likely by user from terminal', + happySessionId: sessionId, + happySessionMetadataFromLocalWebhook: sessionMetadata, + pid + }; + pidToTrackedSession.set(pid, trackedSession); + logger.debug(`[DAEMON RUN] Registered externally-started session ${sessionId}`); + } else if (existingSession?.reattachedFromDiskMarker) { + // Reattached sessions remain kill-protected (PID reuse safety), but we still keep metadata up to date. + existingSession.startedBy = sessionMetadata.startedBy ?? existingSession.startedBy; + existingSession.happySessionId = sessionId; + existingSession.happySessionMetadataFromLocalWebhook = sessionMetadata; + } + + // Best-effort: write/update marker so future daemon restarts can reattach. + // Also capture a process command hash so reattach/stop can be PID-reuse-safe. + void (async () => { + const proc = await findHappyProcessByPid(pid); + const processCommandHash = proc?.command ? hashProcessCommand(proc.command) : undefined; + if (processCommandHash) { + // Store on the tracked session too so stopSession can require a match. + const s = pidToTrackedSession.get(pid); + if (s) s.processCommandHash = processCommandHash; + } else { + logger.debug(`[DAEMON RUN] Could not determine process command for PID ${pid}; marker will be weaker`); + } + + await writeSessionMarker({ + pid, + happySessionId: sessionId, + startedBy: sessionMetadata.startedBy ?? 'terminal', + cwd: sessionMetadata.path, + processCommandHash, + processCommand: proc?.command, + metadata: sessionMetadata, + }); + })().catch((e) => { + logger.debug('[DAEMON RUN] Failed to write session marker', e); + }); + }; +} diff --git a/cli/src/daemon/sessions/reattachFromMarkers.ts b/cli/src/daemon/sessions/reattachFromMarkers.ts new file mode 100644 index 000000000..350624025 --- /dev/null +++ b/cli/src/daemon/sessions/reattachFromMarkers.ts @@ -0,0 +1,32 @@ +import { logger } from '@/ui/logger'; + +import type { TrackedSession } from '../types'; +import { findAllHappyProcesses } from '../doctor'; +import { adoptSessionsFromMarkers } from '../reattach'; +import { listSessionMarkers, removeSessionMarker } from '../sessionRegistry'; + +export async function reattachTrackedSessionsFromMarkers(params: Readonly<{ + pidToTrackedSession: Map<number, TrackedSession>; +}>): Promise<void> { + const { pidToTrackedSession } = params; + + // On daemon restart, reattach to still-running sessions via disk markers (stack-scoped by HAPPY_HOME_DIR). + try { + const markers = await listSessionMarkers(); + const happyProcesses = await findAllHappyProcesses(); + const aliveMarkers = []; + for (const marker of markers) { + try { + process.kill(marker.pid, 0); + aliveMarkers.push(marker); + } catch { + await removeSessionMarker(marker.pid); + continue; + } + } + const { adopted } = adoptSessionsFromMarkers({ markers: aliveMarkers, happyProcesses, pidToTrackedSession }); + if (adopted > 0) logger.debug(`[DAEMON RUN] Reattached ${adopted} sessions from disk markers`); + } catch (e) { + logger.debug('[DAEMON RUN] Failed to reattach sessions from disk markers', e); + } +} diff --git a/cli/src/daemon/sessions/stopSession.ts b/cli/src/daemon/sessions/stopSession.ts new file mode 100644 index 000000000..be72cfcfc --- /dev/null +++ b/cli/src/daemon/sessions/stopSession.ts @@ -0,0 +1,52 @@ +import { logger } from '@/ui/logger'; + +import { isPidSafeHappySessionProcess } from '../pidSafety'; +import type { TrackedSession } from '../types'; + +export function createStopSession(params: Readonly<{ + pidToTrackedSession: Map<number, TrackedSession>; +}>): (sessionId: string) => Promise<boolean> { + const { pidToTrackedSession } = params; + + // Stop a session by sessionId or PID fallback + return async (sessionId: string): Promise<boolean> => { + logger.debug(`[DAEMON RUN] Attempting to stop session ${sessionId}`); + + // Try to find by sessionId first + for (const [pid, session] of pidToTrackedSession.entries()) { + if (session.happySessionId === sessionId || + (sessionId.startsWith('PID-') && pid === parseInt(sessionId.replace('PID-', '')))) { + + if (session.startedBy === 'daemon' && session.childProcess) { + try { + session.childProcess.kill('SIGTERM'); + logger.debug(`[DAEMON RUN] Sent SIGTERM to daemon-spawned session ${sessionId}`); + } catch (error) { + logger.debug(`[DAEMON RUN] Failed to kill session ${sessionId}:`, error); + } + } else { + // PID reuse safety: verify the PID still looks like a Happy session process (and matches hash if known). + const safe = await isPidSafeHappySessionProcess({ pid, expectedProcessCommandHash: session.processCommandHash }); + if (!safe) { + logger.warn(`[DAEMON RUN] Refusing to SIGTERM PID ${pid} for session ${sessionId} (PID reuse safety)`); + return false; + } + // For externally started sessions, try to kill by PID + try { + process.kill(pid, 'SIGTERM'); + logger.debug(`[DAEMON RUN] Sent SIGTERM to external session PID ${pid}`); + } catch (error) { + logger.debug(`[DAEMON RUN] Failed to kill external session PID ${pid}:`, error); + } + } + + pidToTrackedSession.delete(pid); + logger.debug(`[DAEMON RUN] Removed session ${sessionId} from tracking`); + return true; + } + } + + logger.debug(`[DAEMON RUN] Session ${sessionId} not found`); + return false; + }; +} diff --git a/cli/src/daemon/shutdownPolicy.test.ts b/cli/src/daemon/shutdownPolicy.test.ts new file mode 100644 index 000000000..028665b4a --- /dev/null +++ b/cli/src/daemon/shutdownPolicy.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; + +import { getDaemonShutdownExitCode, getDaemonShutdownWatchdogTimeoutMs } from './shutdownPolicy'; + +describe('daemon shutdown policy', () => { + it('exits 0 for non-exception shutdown sources', () => { + expect(getDaemonShutdownExitCode('happy-app')).toBe(0); + expect(getDaemonShutdownExitCode('happy-cli')).toBe(0); + expect(getDaemonShutdownExitCode('os-signal')).toBe(0); + }); + + it('exits 1 for exception shutdown source', () => { + expect(getDaemonShutdownExitCode('exception')).toBe(1); + }); + + it('uses a non-trivial watchdog timeout', () => { + expect(getDaemonShutdownWatchdogTimeoutMs()).toBeGreaterThanOrEqual(5_000); + }); +}); + diff --git a/cli/src/daemon/shutdownPolicy.ts b/cli/src/daemon/shutdownPolicy.ts new file mode 100644 index 000000000..68791d4bd --- /dev/null +++ b/cli/src/daemon/shutdownPolicy.ts @@ -0,0 +1,13 @@ +export type DaemonShutdownSource = 'happy-app' | 'happy-cli' | 'os-signal' | 'exception'; + +export function getDaemonShutdownExitCode(source: DaemonShutdownSource): 0 | 1 { + return source === 'exception' ? 1 : 0; +} + +// A watchdog is useful to avoid hanging forever on shutdown if some cleanup path stalls. +// This should be long enough to not fire during normal shutdown, so the daemon does not +// incorrectly exit with a failure code (which can trigger restart loops + extra log files). +export function getDaemonShutdownWatchdogTimeoutMs(): number { + return 15_000; +} + diff --git a/cli/src/daemon/spawnHooks.ts b/cli/src/daemon/spawnHooks.ts new file mode 100644 index 000000000..9dd9ac475 --- /dev/null +++ b/cli/src/daemon/spawnHooks.ts @@ -0,0 +1,22 @@ +export type DaemonSpawnValidationResult = + | Readonly<{ ok: true }> + | Readonly<{ ok: false; errorMessage: string }>; + +export type DaemonSpawnAuthEnvResult = Readonly<{ + env: Record<string, string>; + /** + * Cleanup to run when we fail BEFORE the child is successfully spawned. + */ + cleanupOnFailure?: (() => void) | null; + /** + * Cleanup to run when the spawned child exits (tracked by PID). + */ + cleanupOnExit?: (() => void) | null; +}>; + +export type DaemonSpawnHooks = Readonly<{ + buildAuthEnv?: (params: Readonly<{ token: string }>) => Promise<DaemonSpawnAuthEnvResult>; + validateSpawn?: (params: Readonly<{ experimentalCodexResume?: boolean; experimentalCodexAcp?: boolean }>) => Promise<DaemonSpawnValidationResult>; + buildExtraEnvForChild?: (params: Readonly<{ experimentalCodexResume?: boolean; experimentalCodexAcp?: boolean }>) => Record<string, string>; +}>; + diff --git a/cli/src/daemon/types.ts b/cli/src/daemon/types.ts index ed8f08aa4..c576ad2f5 100644 --- a/cli/src/daemon/types.ts +++ b/cli/src/daemon/types.ts @@ -12,11 +12,23 @@ export interface TrackedSession { startedBy: 'daemon' | string; happySessionId?: string; happySessionMetadataFromLocalWebhook?: Metadata; + /** Vendor resume id (e.g. Claude/Codex session id) supplied/derived at spawn time. */ + vendorResumeId?: string; pid: number; + /** + * Hash of the observed process command line for PID reuse safety. + * If present, we require this to match before sending SIGTERM by PID. + */ + processCommandHash?: string; childProcess?: ChildProcess; error?: string; directoryCreated?: boolean; message?: string; /** tmux session identifier (format: session:window) */ tmuxSessionId?: string; -} \ No newline at end of file + /** + * Sessions reattached from disk markers after daemon restart are potentially unsafe to kill by PID + * (avoids PID reuse killing unrelated processes). We keep them kill-protected. + */ + reattachedFromDiskMarker?: boolean; +} diff --git a/cli/src/index.ts b/cli/src/index.ts index c7ec6b157..a179b0703 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -2,708 +2,15 @@ /** * CLI entry point for happy command - * + * * Simple argument parsing without any CLI framework dependencies */ +import { dispatchCli } from '@/cli/dispatch'; +import { parseCliArgs } from '@/cli/parseArgs'; -import chalk from 'chalk' -import { runClaude, StartOptions } from '@/claude/runClaude' -import { logger } from './ui/logger' -import { readCredentials } from './persistence' -import { authAndSetupMachineIfNeeded } from './ui/auth' -import packageJson from '../package.json' -import { z } from 'zod' -import { startDaemon } from './daemon/run' -import { checkIfDaemonRunningAndCleanupStaleState, isDaemonRunningCurrentlyInstalledHappyVersion, stopDaemon } from './daemon/controlClient' -import { getLatestDaemonLog } from './ui/logger' -import { killRunawayHappyProcesses } from './daemon/doctor' -import { install } from './daemon/install' -import { uninstall } from './daemon/uninstall' -import { ApiClient } from './api/api' -import { runDoctorCommand } from './ui/doctor' -import { listDaemonSessions, stopDaemonSession } from './daemon/controlClient' -import { handleAuthCommand } from './commands/auth' -import { handleConnectCommand } from './commands/connect' -import { spawnHappyCLI } from './utils/spawnHappyCLI' -import { claudeCliPath } from './claude/claudeLocal' -import { execFileSync } from 'node:child_process' - - -(async () => { - const args = process.argv.slice(2) - - // If --version is passed - do not log, its likely daemon inquiring about our version - if (!args.includes('--version')) { - logger.debug('Starting happy CLI with args: ', process.argv) - } - - // Check if first argument is a subcommand - const subcommand = args[0] - - // Log which subcommand was detected (for debugging) - if (!args.includes('--version')) { - } - - if (subcommand === 'doctor') { - // Check for clean subcommand - if (args[1] === 'clean') { - const result = await killRunawayHappyProcesses() - console.log(`Cleaned up ${result.killed} runaway processes`) - if (result.errors.length > 0) { - console.log('Errors:', result.errors) - } - process.exit(0) - } - await runDoctorCommand(); - return; - } else if (subcommand === 'auth') { - // Handle auth subcommands - try { - await handleAuthCommand(args.slice(1)); - } catch (error) { - console.error(chalk.red('Error:'), error instanceof Error ? error.message : 'Unknown error') - if (process.env.DEBUG) { - console.error(error) - } - process.exit(1) - } - return; - } else if (subcommand === 'connect') { - // Handle connect subcommands - try { - await handleConnectCommand(args.slice(1)); - } catch (error) { - console.error(chalk.red('Error:'), error instanceof Error ? error.message : 'Unknown error') - if (process.env.DEBUG) { - console.error(error) - } - process.exit(1) - } - return; - } else if (subcommand === 'codex') { - // Handle codex command - try { - const { runCodex } = await import('@/codex/runCodex'); - - // Parse startedBy argument - let startedBy: 'daemon' | 'terminal' | undefined = undefined; - for (let i = 1; i < args.length; i++) { - if (args[i] === '--started-by') { - startedBy = args[++i] as 'daemon' | 'terminal'; - } - } - - const { - credentials - } = await authAndSetupMachineIfNeeded(); - await runCodex({credentials, startedBy}); - // Do not force exit here; allow instrumentation to show lingering handles - } catch (error) { - console.error(chalk.red('Error:'), error instanceof Error ? error.message : 'Unknown error') - if (process.env.DEBUG) { - console.error(error) - } - process.exit(1) - } - return; - } else if (subcommand === 'gemini') { - // Handle gemini subcommands - const geminiSubcommand = args[1]; - - // Handle "happy gemini model set <model>" command - if (geminiSubcommand === 'model' && args[2] === 'set' && args[3]) { - const modelName = args[3]; - const validModels = ['gemini-2.5-pro', 'gemini-2.5-flash', 'gemini-2.5-flash-lite']; - - if (!validModels.includes(modelName)) { - console.error(`Invalid model: ${modelName}`); - console.error(`Available models: ${validModels.join(', ')}`); - process.exit(1); - } - - try { - const { existsSync, readFileSync, writeFileSync, mkdirSync } = require('fs'); - const { join } = require('path'); - const { homedir } = require('os'); - - const configDir = join(homedir(), '.gemini'); - const configPath = join(configDir, 'config.json'); - - // Create directory if it doesn't exist - if (!existsSync(configDir)) { - mkdirSync(configDir, { recursive: true }); - } - - // Read existing config or create new one - let config: any = {}; - if (existsSync(configPath)) { - try { - config = JSON.parse(readFileSync(configPath, 'utf-8')); - } catch (error) { - // Ignore parse errors, start fresh - config = {}; - } - } - - // Update model in config - config.model = modelName; - - // Write config back - writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8'); - console.log(`✓ Model set to: ${modelName}`); - console.log(` Config saved to: ${configPath}`); - console.log(` This model will be used in future sessions.`); - process.exit(0); - } catch (error) { - console.error('Failed to save model configuration:', error); - process.exit(1); - } - } - - // Handle "happy gemini model get" command - if (geminiSubcommand === 'model' && args[2] === 'get') { - try { - const { existsSync, readFileSync } = require('fs'); - const { join } = require('path'); - const { homedir } = require('os'); - - const configPaths = [ - join(homedir(), '.gemini', 'config.json'), - join(homedir(), '.config', 'gemini', 'config.json'), - ]; - - let model: string | null = null; - for (const configPath of configPaths) { - if (existsSync(configPath)) { - try { - const config = JSON.parse(readFileSync(configPath, 'utf-8')); - model = config.model || config.GEMINI_MODEL || null; - if (model) break; - } catch (error) { - // Ignore parse errors - } - } - } - - if (model) { - console.log(`Current model: ${model}`); - } else if (process.env.GEMINI_MODEL) { - console.log(`Current model: ${process.env.GEMINI_MODEL} (from GEMINI_MODEL env var)`); - } else { - console.log('Current model: gemini-2.5-pro (default)'); - } - process.exit(0); - } catch (error) { - console.error('Failed to read model configuration:', error); - process.exit(1); - } - } - - // Handle "happy gemini project set <project-id>" command - if (geminiSubcommand === 'project' && args[2] === 'set' && args[3]) { - const projectId = args[3]; - - try { - const { saveGoogleCloudProjectToConfig } = await import('@/gemini/utils/config'); - const { readCredentials } = await import('@/persistence'); - const { ApiClient } = await import('@/api/api'); - - // Try to get current user email from Happy cloud token - let userEmail: string | undefined = undefined; - try { - const credentials = await readCredentials(); - if (credentials) { - const api = await ApiClient.create(credentials); - const vendorToken = await api.getVendorToken('gemini'); - if (vendorToken?.oauth?.id_token) { - const parts = vendorToken.oauth.id_token.split('.'); - if (parts.length === 3) { - const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')); - userEmail = payload.email; - } - } - } - } catch { - // If we can't get email, project will be saved globally - } - - saveGoogleCloudProjectToConfig(projectId, userEmail); - console.log(`✓ Google Cloud Project set to: ${projectId}`); - if (userEmail) { - console.log(` Linked to account: ${userEmail}`); - } - console.log(` This project will be used for Google Workspace accounts.`); - process.exit(0); - } catch (error) { - console.error('Failed to save project configuration:', error); - process.exit(1); - } - } - - // Handle "happy gemini project get" command - if (geminiSubcommand === 'project' && args[2] === 'get') { - try { - const { readGeminiLocalConfig } = await import('@/gemini/utils/config'); - const config = readGeminiLocalConfig(); - - if (config.googleCloudProject) { - console.log(`Current Google Cloud Project: ${config.googleCloudProject}`); - if (config.googleCloudProjectEmail) { - console.log(` Linked to account: ${config.googleCloudProjectEmail}`); - } else { - console.log(` Applies to: all accounts (global)`); - } - } else if (process.env.GOOGLE_CLOUD_PROJECT) { - console.log(`Current Google Cloud Project: ${process.env.GOOGLE_CLOUD_PROJECT} (from env var)`); - } else { - console.log('No Google Cloud Project configured.'); - console.log(''); - console.log('If you see "Authentication required" error, you may need to set a project:'); - console.log(' happy gemini project set <your-project-id>'); - console.log(''); - console.log('This is required for Google Workspace accounts.'); - console.log('Guide: https://goo.gle/gemini-cli-auth-docs#workspace-gca'); - } - process.exit(0); - } catch (error) { - console.error('Failed to read project configuration:', error); - process.exit(1); - } - } - - // Handle "happy gemini project" (no subcommand) - show help - if (geminiSubcommand === 'project' && !args[2]) { - console.log('Usage: happy gemini project <command>'); - console.log(''); - console.log('Commands:'); - console.log(' set <project-id> Set Google Cloud Project ID'); - console.log(' get Show current Google Cloud Project ID'); - console.log(''); - console.log('Google Workspace accounts require a Google Cloud Project.'); - console.log('If you see "Authentication required" error, set your project ID.'); - console.log(''); - console.log('Guide: https://goo.gle/gemini-cli-auth-docs#workspace-gca'); - process.exit(0); - } - - // Handle gemini command (ACP-based agent) - try { - const { runGemini } = await import('@/gemini/runGemini'); - - // Parse startedBy argument - let startedBy: 'daemon' | 'terminal' | undefined = undefined; - for (let i = 1; i < args.length; i++) { - if (args[i] === '--started-by') { - startedBy = args[++i] as 'daemon' | 'terminal'; - } - } - - const { - credentials - } = await authAndSetupMachineIfNeeded(); - - // Auto-start daemon for gemini (same as claude) - logger.debug('Ensuring Happy background service is running & matches our version...'); - if (!(await isDaemonRunningCurrentlyInstalledHappyVersion())) { - logger.debug('Starting Happy background service...'); - const daemonProcess = spawnHappyCLI(['daemon', 'start-sync'], { - detached: true, - stdio: 'ignore', - env: process.env - }); - daemonProcess.unref(); - await new Promise(resolve => setTimeout(resolve, 200)); - } - - await runGemini({credentials, startedBy}); - } catch (error) { - console.error(chalk.red('Error:'), error instanceof Error ? error.message : 'Unknown error') - if (process.env.DEBUG) { - console.error(error) - } - process.exit(1) - } - return; - } else if (subcommand === 'logout') { - // Keep for backward compatibility - redirect to auth logout - console.log(chalk.yellow('Note: "happy logout" is deprecated. Use "happy auth logout" instead.\n')); - try { - await handleAuthCommand(['logout']); - } catch (error) { - console.error(chalk.red('Error:'), error instanceof Error ? error.message : 'Unknown error') - if (process.env.DEBUG) { - console.error(error) - } - process.exit(1) - } - return; - } else if (subcommand === 'notify') { - // Handle notification command - try { - await handleNotifyCommand(args.slice(1)); - } catch (error) { - console.error(chalk.red('Error:'), error instanceof Error ? error.message : 'Unknown error') - if (process.env.DEBUG) { - console.error(error) - } - process.exit(1) - } - return; - } else if (subcommand === 'daemon') { - // Show daemon management help - const daemonSubcommand = args[1] - - if (daemonSubcommand === 'list') { - try { - const sessions = await listDaemonSessions() - - if (sessions.length === 0) { - console.log('No active sessions this daemon is aware of (they might have been started by a previous version of the daemon)') - } else { - console.log('Active sessions:') - console.log(JSON.stringify(sessions, null, 2)) - } - } catch (error) { - console.log('No daemon running') - } - return - - } else if (daemonSubcommand === 'stop-session') { - const sessionId = args[2] - if (!sessionId) { - console.error('Session ID required') - process.exit(1) - } - - try { - const success = await stopDaemonSession(sessionId) - console.log(success ? 'Session stopped' : 'Failed to stop session') - } catch (error) { - console.log('No daemon running') - } - return - - } else if (daemonSubcommand === 'start') { - // Spawn detached daemon process - const child = spawnHappyCLI(['daemon', 'start-sync'], { - detached: true, - stdio: 'ignore', - env: process.env - }); - child.unref(); - - // Wait for daemon to write state file (up to 5 seconds) - let started = false; - for (let i = 0; i < 50; i++) { - if (await checkIfDaemonRunningAndCleanupStaleState()) { - started = true; - break; - } - await new Promise(resolve => setTimeout(resolve, 100)); - } - - if (started) { - console.log('Daemon started successfully'); - } else { - console.error('Failed to start daemon'); - process.exit(1); - } - process.exit(0); - } else if (daemonSubcommand === 'start-sync') { - await startDaemon() - process.exit(0) - } else if (daemonSubcommand === 'stop') { - await stopDaemon() - process.exit(0) - } else if (daemonSubcommand === 'status') { - // Show daemon-specific doctor output - await runDoctorCommand('daemon') - process.exit(0) - } else if (daemonSubcommand === 'logs') { - // Simply print the path to the latest daemon log file - const latest = await getLatestDaemonLog() - if (!latest) { - console.log('No daemon logs found') - } else { - console.log(latest.path) - } - process.exit(0) - } else if (daemonSubcommand === 'install') { - try { - await install() - } catch (error) { - console.error(chalk.red('Error:'), error instanceof Error ? error.message : 'Unknown error') - process.exit(1) - } - } else if (daemonSubcommand === 'uninstall') { - try { - await uninstall() - } catch (error) { - console.error(chalk.red('Error:'), error instanceof Error ? error.message : 'Unknown error') - process.exit(1) - } - } else { - console.log(` -${chalk.bold('happy daemon')} - Daemon management - -${chalk.bold('Usage:')} - happy daemon start Start the daemon (detached) - happy daemon stop Stop the daemon (sessions stay alive) - happy daemon status Show daemon status - happy daemon list List active sessions - - If you want to kill all happy related processes run - ${chalk.cyan('happy doctor clean')} - -${chalk.bold('Note:')} The daemon runs in the background and manages Claude sessions. - -${chalk.bold('To clean up runaway processes:')} Use ${chalk.cyan('happy doctor clean')} -`) - } - return; - } else { - - // If the first argument is claude, remove it - if (args.length > 0 && args[0] === 'claude') { - args.shift() - } - - // Parse command line arguments for main command - const options: StartOptions = {} - let showHelp = false - let showVersion = false - const unknownArgs: string[] = [] // Collect unknown args to pass through to claude - - for (let i = 0; i < args.length; i++) { - const arg = args[i] - - if (arg === '-h' || arg === '--help') { - showHelp = true - // Also pass through to claude - unknownArgs.push(arg) - } else if (arg === '-v' || arg === '--version') { - showVersion = true - // Also pass through to claude (will show after our version) - unknownArgs.push(arg) - } else if (arg === '--happy-starting-mode') { - options.startingMode = z.enum(['local', 'remote']).parse(args[++i]) - } else if (arg === '--yolo') { - // Shortcut for --dangerously-skip-permissions - unknownArgs.push('--dangerously-skip-permissions') - } else if (arg === '--started-by') { - options.startedBy = args[++i] as 'daemon' | 'terminal' - } else if (arg === '--js-runtime') { - const runtime = args[++i] - if (runtime !== 'node' && runtime !== 'bun') { - console.error(chalk.red(`Invalid --js-runtime value: ${runtime}. Must be 'node' or 'bun'`)) - process.exit(1) - } - options.jsRuntime = runtime - } else if (arg === '--claude-env') { - // Parse KEY=VALUE environment variable to pass to Claude - const envArg = args[++i] - if (envArg && envArg.includes('=')) { - const eqIndex = envArg.indexOf('=') - const key = envArg.substring(0, eqIndex) - const value = envArg.substring(eqIndex + 1) - options.claudeEnvVars = options.claudeEnvVars || {} - options.claudeEnvVars[key] = value - } else { - console.error(chalk.red(`Invalid --claude-env format: ${envArg}. Expected KEY=VALUE`)) - process.exit(1) - } - } else { - // Pass unknown arguments through to claude - unknownArgs.push(arg) - // Check if this arg expects a value (simplified check for common patterns) - if (i + 1 < args.length && !args[i + 1].startsWith('-')) { - unknownArgs.push(args[++i]) - } - } - } - - // Add unknown args to claudeArgs - if (unknownArgs.length > 0) { - options.claudeArgs = [...(options.claudeArgs || []), ...unknownArgs] - } - - // Show help - if (showHelp) { - console.log(` -${chalk.bold('happy')} - Claude Code On the Go - -${chalk.bold('Usage:')} - happy [options] Start Claude with mobile control - happy auth Manage authentication - happy codex Start Codex mode - happy gemini Start Gemini mode (ACP) - happy connect Connect AI vendor API keys - happy notify Send push notification - happy daemon Manage background service that allows - to spawn new sessions away from your computer - happy doctor System diagnostics & troubleshooting - -${chalk.bold('Examples:')} - happy Start session - happy --yolo Start with bypassing permissions - happy sugar for --dangerously-skip-permissions - happy --js-runtime bun Use bun instead of node to spawn Claude Code - happy --claude-env ANTHROPIC_BASE_URL=http://127.0.0.1:3456 - Use a custom API endpoint (e.g., claude-code-router) - happy auth login --force Authenticate - happy doctor Run diagnostics - -${chalk.bold('Happy supports ALL Claude options!')} - Use any claude flag with happy as you would with claude. Our favorite: - - happy --resume - -${chalk.gray('─'.repeat(60))} -${chalk.bold.cyan('Claude Code Options (from `claude --help`):')} -`) - - // Run claude --help and display its output - // Use execFileSync directly with claude CLI for runtime-agnostic compatibility - try { - const claudeHelp = execFileSync(claudeCliPath, ['--help'], { encoding: 'utf8' }) - console.log(claudeHelp) - } catch (e) { - console.log(chalk.yellow('Could not retrieve claude help. Make sure claude is installed.')) - } - - process.exit(0) - } - - // Show version - if (showVersion) { - console.log(`happy version: ${packageJson.version}`) - // Don't exit - continue to pass --version to Claude Code - } - - // Normal flow - auth and machine setup - const { - credentials - } = await authAndSetupMachineIfNeeded(); - - // Always auto-start daemon for simplicity - logger.debug('Ensuring Happy background service is running & matches our version...'); - - if (!(await isDaemonRunningCurrentlyInstalledHappyVersion())) { - logger.debug('Starting Happy background service...'); - - // Use the built binary to spawn daemon - const daemonProcess = spawnHappyCLI(['daemon', 'start-sync'], { - detached: true, - stdio: 'ignore', - env: process.env - }) - daemonProcess.unref(); - - // Give daemon a moment to write PID & port file - await new Promise(resolve => setTimeout(resolve, 200)); - } - - // Start the CLI - try { - await runClaude(credentials, options); - } catch (error) { - console.error(chalk.red('Error:'), error instanceof Error ? error.message : 'Unknown error') - if (process.env.DEBUG) { - console.error(error) - } - process.exit(1) - } - } +void (async () => { + const { args, terminalRuntime } = parseCliArgs(process.argv.slice(2)); + await dispatchCli({ args, terminalRuntime, rawArgv: process.argv }); })(); - -/** - * Handle notification command - */ -async function handleNotifyCommand(args: string[]): Promise<void> { - let message = '' - let title = '' - let showHelp = false - - // Parse arguments - for (let i = 0; i < args.length; i++) { - const arg = args[i] - - if (arg === '-p' && i + 1 < args.length) { - message = args[++i] - } else if (arg === '-t' && i + 1 < args.length) { - title = args[++i] - } else if (arg === '-h' || arg === '--help') { - showHelp = true - } else { - console.error(chalk.red(`Unknown argument for notify command: ${arg}`)) - process.exit(1) - } - } - - if (showHelp) { - console.log(` -${chalk.bold('happy notify')} - Send notification - -${chalk.bold('Usage:')} - happy notify -p <message> [-t <title>] Send notification with custom message and optional title - happy notify -h, --help Show this help - -${chalk.bold('Options:')} - -p <message> Notification message (required) - -t <title> Notification title (optional, defaults to "Happy") - -${chalk.bold('Examples:')} - happy notify -p "Deployment complete!" - happy notify -p "System update complete" -t "Server Status" - happy notify -t "Alert" -p "Database connection restored" -`) - return - } - - if (!message) { - console.error(chalk.red('Error: Message is required. Use -p "your message" to specify the notification text.')) - console.log(chalk.gray('Run "happy notify --help" for usage information.')) - process.exit(1) - } - - // Load credentials - let credentials = await readCredentials() - if (!credentials) { - console.error(chalk.red('Error: Not authenticated. Please run "happy auth login" first.')) - process.exit(1) - } - - console.log(chalk.blue('📱 Sending push notification...')) - - try { - // Create API client and send push notification - const api = await ApiClient.create(credentials); - - // Use custom title or default to "Happy" - const notificationTitle = title || 'Happy' - - // Send the push notification - api.push().sendToAllDevices( - notificationTitle, - message, - { - source: 'cli', - timestamp: Date.now() - } - ) - - console.log(chalk.green('✓ Push notification sent successfully!')) - console.log(chalk.gray(` Title: ${notificationTitle}`)) - console.log(chalk.gray(` Message: ${message}`)) - console.log(chalk.gray(' Check your mobile device for the notification.')) - - // Give a moment for the async operation to start - await new Promise(resolve => setTimeout(resolve, 1000)) - - } catch (error) { - console.error(chalk.red('✗ Failed to send push notification')) - throw error - } -} diff --git a/cli/src/utils/caffeinate.ts b/cli/src/integrations/caffeinate.ts similarity index 100% rename from cli/src/utils/caffeinate.ts rename to cli/src/integrations/caffeinate.ts diff --git a/cli/src/modules/difftastic/index.test.ts b/cli/src/integrations/difftastic/index.test.ts similarity index 100% rename from cli/src/modules/difftastic/index.test.ts rename to cli/src/integrations/difftastic/index.test.ts diff --git a/cli/src/modules/difftastic/index.ts b/cli/src/integrations/difftastic/index.ts similarity index 100% rename from cli/src/modules/difftastic/index.ts rename to cli/src/integrations/difftastic/index.ts diff --git a/cli/src/modules/proxy/startHTTPDirectProxy.ts b/cli/src/integrations/proxy/startHTTPDirectProxy.ts similarity index 100% rename from cli/src/modules/proxy/startHTTPDirectProxy.ts rename to cli/src/integrations/proxy/startHTTPDirectProxy.ts diff --git a/cli/src/modules/ripgrep/index.test.ts b/cli/src/integrations/ripgrep/index.test.ts similarity index 85% rename from cli/src/modules/ripgrep/index.test.ts rename to cli/src/integrations/ripgrep/index.test.ts index be400bca2..9ba4f5765 100644 --- a/cli/src/modules/ripgrep/index.test.ts +++ b/cli/src/integrations/ripgrep/index.test.ts @@ -13,7 +13,7 @@ describe('ripgrep low-level wrapper', () => { }) it('should search for pattern', async () => { - const result = await run(['describe', 'src/modules/ripgrep/index.test.ts']) + const result = await run(['describe', 'src/integrations/ripgrep/index.test.ts']) expect(result.exitCode).toBe(0) expect(result.stdout).toContain('describe') }) @@ -25,7 +25,7 @@ describe('ripgrep low-level wrapper', () => { }) it('should handle JSON output', async () => { - const result = await run(['--json', 'describe', 'src/modules/ripgrep/index.test.ts']) + const result = await run(['--json', 'describe', 'src/integrations/ripgrep/index.test.ts']) expect(result.exitCode).toBe(0) // Parse first line to check it's valid JSON @@ -35,8 +35,8 @@ describe('ripgrep low-level wrapper', () => { }) it('should respect custom working directory', async () => { - const result = await run(['describe', 'index.test.ts'], { cwd: 'src/modules/ripgrep' }) + const result = await run(['describe', 'index.test.ts'], { cwd: 'src/integrations/ripgrep' }) expect(result.exitCode).toBe(0) expect(result.stdout).toContain('describe') }) -}) \ No newline at end of file +}) diff --git a/cli/src/modules/ripgrep/index.ts b/cli/src/integrations/ripgrep/index.ts similarity index 100% rename from cli/src/modules/ripgrep/index.ts rename to cli/src/integrations/ripgrep/index.ts diff --git a/cli/src/utils/tmux.ts b/cli/src/integrations/tmux/index.ts similarity index 73% rename from cli/src/utils/tmux.ts rename to cli/src/integrations/tmux/index.ts index f09583586..8357450fc 100644 --- a/cli/src/utils/tmux.ts +++ b/cli/src/integrations/tmux/index.ts @@ -23,6 +23,41 @@ import { spawn, SpawnOptions } from 'child_process'; import { promisify } from 'util'; import { logger } from '@/ui/logger'; +export type { TmuxSessionListRow } from './sessionSelector'; +export { parseTmuxSessionList, selectPreferredTmuxSessionName } from './sessionSelector'; + +function readNonNegativeIntegerEnv(name: string, fallback: number): number { + const raw = process.env[name]; + if (!raw) return fallback; + const parsed = Number.parseInt(raw, 10); + if (!Number.isFinite(parsed) || parsed < 0) return fallback; + return parsed; +} + +function readPositiveIntegerEnv(name: string, fallback: number): number { + const value = readNonNegativeIntegerEnv(name, fallback); + return value <= 0 ? fallback : value; +} + +function isTmuxWindowIndexConflict(stderr: string | undefined): boolean { + return /index\s+\d+\s+in\s+use/i.test(stderr ?? ''); +} + +export function normalizeExitCode(code: number | null): number { + // Node passes `code === null` when the process was terminated by a signal. + // Preserve failure semantics rather than treating it as success. + return code ?? 1; +} + +function quoteForPosixShell(arg: string): string { + // POSIX-safe single-quote escaping: ' -> '\'' . + return `'${arg.replace(/'/g, `'\\''`)}'`; +} + +function buildPosixShellCommand(args: string[]): string { + return args.map(quoteForPosixShell).join(' '); +} + export enum TmuxControlState { /** Normal text processing mode */ NORMAL = "normal", @@ -77,10 +112,12 @@ export type TmuxWindowOperation = | 'join-pane' | 'join' | 'break-pane' | 'break'; export interface TmuxEnvironment { - session: string; - window: string; + /** tmux server socket path (TMUX env var first component) */ + socket_path: string; + /** tmux server pid (TMUX env var second component) */ + server_pid: number; + /** tmux pane identifier/index (TMUX env var third component) */ pane: string; - socket_path?: string; } export interface TmuxCommandResult { @@ -98,8 +135,6 @@ export interface TmuxSessionInfo { socket_path?: string; tmux_active: boolean; current_session?: string; - env_session?: string; - env_window?: string; env_pane?: string; available_sessions: string[]; } @@ -135,17 +170,19 @@ export function parseTmuxSessionIdentifier(identifier: string): TmuxSessionIdent session: parts[0].trim() }; - // Validate session name (tmux has restrictions on session names) - if (!/^[a-zA-Z0-9._-]+$/.test(result.session)) { - throw new TmuxSessionIdentifierError(`Invalid session name: "${result.session}". Only alphanumeric characters, dots, hyphens, and underscores are allowed.`); + // Validate session name for our identifier format. + // Allow spaces, since tmux sessions can be user-named with spaces. + // Disallow characters that would make our identifier ambiguous (e.g. ':' separator). + if (!/^[a-zA-Z0-9._ -]+$/.test(result.session)) { + throw new TmuxSessionIdentifierError(`Invalid session name: "${result.session}". Only alphanumeric characters, spaces, dots, hyphens, and underscores are allowed.`); } if (parts.length > 1) { const windowAndPane = parts[1].split('.'); result.window = windowAndPane[0]?.trim(); - if (result.window && !/^[a-zA-Z0-9._-]+$/.test(result.window)) { - throw new TmuxSessionIdentifierError(`Invalid window name: "${result.window}". Only alphanumeric characters, dots, hyphens, and underscores are allowed.`); + if (result.window && !/^[a-zA-Z0-9._ -]+$/.test(result.window)) { + throw new TmuxSessionIdentifierError(`Invalid window name: "${result.window}". Only alphanumeric characters, spaces, dots, hyphens, and underscores are allowed.`); } if (windowAndPane.length > 1) { @@ -183,15 +220,25 @@ export function extractSessionAndWindow(tmuxOutput: string): { session: string; // Look for session:window patterns in tmux output const lines = tmuxOutput.split('\n'); + const nameRegex = /^[a-zA-Z0-9._ -]+$/; for (const line of lines) { - const match = line.match(/^([a-zA-Z0-9._-]+):([a-zA-Z0-9._-]+)(?:\.([0-9]+))?/); - if (match) { - return { - session: match[1], - window: match[2] - }; - } + const trimmed = line.trim(); + if (!trimmed) continue; + + // Allow spaces in names, but keep ':' as the session/window separator. + // This helper is intended for extracting the canonical identifier shapes that tmux can emit + // via format strings (e.g. '#S:#W' or '#S:#W.#P'), so we require end-of-line matches. + const match = trimmed.match(/^(.+?):(.+?)(?:\.([0-9]+))?$/); + if (!match) continue; + + const session = match[1]?.trim(); + const window = match[2]?.trim(); + + if (!session || !window) continue; + if (!nameRegex.test(session) || !nameRegex.test(window)) continue; + + return { session, window }; } return null; @@ -343,7 +390,8 @@ const COMMANDS_SUPPORTING_TARGET = new Set([ 'send-keys', 'capture-pane', 'new-window', 'kill-window', 'select-window', 'split-window', 'select-pane', 'kill-pane', 'select-layout', 'display-message', 'attach-session', 'detach-client', - 'new-session', 'kill-session', 'list-windows', 'list-panes' + // NOTE: `new-session -t` targets a *group name*, not a session/window target. + 'kill-session', 'list-windows', 'list-panes' ]); // Control sequences that must be separate arguments with proper typing @@ -359,9 +407,13 @@ export class TmuxUtilities { private controlState: TmuxControlState = TmuxControlState.NORMAL; public readonly sessionName: string; + private readonly tmuxCommandEnv?: Record<string, string>; + private readonly tmuxSocketPath?: string; - constructor(sessionName?: string) { + constructor(sessionName?: string, tmuxCommandEnv?: Record<string, string>, tmuxSocketPath?: string) { this.sessionName = sessionName || TmuxUtilities.DEFAULT_SESSION_NAME; + this.tmuxCommandEnv = tmuxCommandEnv; + this.tmuxSocketPath = tmuxSocketPath; } /** @@ -373,35 +425,25 @@ export class TmuxUtilities { return null; } - // Parse TMUX environment: /tmp/tmux-1000/default,4219,0 + // TMUX environment format: socket_path,server_pid,pane_id + // NOTE: session name / window are NOT encoded in TMUX. Query tmux formats for those. try { const parts = tmuxEnv.split(','); - if (parts.length >= 3) { - const socketPath = parts[0]; - // Extract last component from path (JavaScript doesn't support negative array indexing) - const pathParts = parts[1].split('/'); - const sessionAndWindow = pathParts[pathParts.length - 1] || parts[1]; - const pane = parts[2]; - - // Extract session name from session.window format - let session: string; - let window: string; - if (sessionAndWindow.includes('.')) { - const parts = sessionAndWindow.split('.', 2); - session = parts[0]; - window = parts[1] || "0"; - } else { - session = sessionAndWindow; - window = "0"; - } + if (parts.length < 3) return null; - return { - session, - window, - pane, - socket_path: socketPath - }; - } + const socketPath = parts[0]?.trim(); + const serverPidStr = parts[1]?.trim(); + // Prefer TMUX_PANE (pane id like %0). Fallback to TMUX env var third component (often pane index). + const pane = (process.env.TMUX_PANE ?? parts[2])?.trim(); + + if (!socketPath || !serverPidStr || !pane) return null; + if (!/^\d+$/.test(serverPidStr)) return null; + + return { + socket_path: socketPath, + server_pid: Number.parseInt(serverPidStr, 10), + pane, + }; } catch (error) { logger.debug('[TMUX] Failed to parse TMUX environment variable:', error); } @@ -425,8 +467,9 @@ export class TmuxUtilities { let baseCmd = ['tmux']; // Add socket specification if provided - if (socketPath) { - baseCmd = ['tmux', '-S', socketPath]; + const resolvedSocketPath = socketPath ?? this.tmuxSocketPath; + if (resolvedSocketPath) { + baseCmd = ['tmux', '-S', resolvedSocketPath]; } // Handle send-keys with proper target specification @@ -448,7 +491,8 @@ export class TmuxUtilities { const fullCmd = [...baseCmd, ...cmd]; // Add target specification for commands that support it - if (cmd.length > 0 && COMMANDS_SUPPORTING_TARGET.has(cmd[0])) { + const hasExplicitTarget = cmd.includes('-t'); + if (!hasExplicitTarget && cmd.length > 0 && COMMANDS_SUPPORTING_TARGET.has(cmd[0])) { let target = targetSession; if (window) target += `:${window}`; if (pane) target += `.${pane}`; @@ -482,11 +526,18 @@ export class TmuxUtilities { */ private runCommand(args: string[], options: SpawnOptions = {}): Promise<{ exitCode: number; stdout: string; stderr: string }> { return new Promise((resolve, reject) => { + const mergedEnv = { + ...process.env, + ...(this.tmuxCommandEnv ?? {}), + ...(options.env ?? {}), + }; + const child = spawn(args[0], args.slice(1), { stdio: ['ignore', 'pipe', 'pipe'], timeout: 5000, shell: false, - ...options + ...options, + env: mergedEnv, }); let stdout = ''; @@ -502,7 +553,7 @@ export class TmuxUtilities { child.on('close', (code) => { resolve({ - exitCode: code || 0, + exitCode: normalizeExitCode(code), stdout, stderr }); @@ -698,19 +749,12 @@ export class TmuxUtilities { pane: "unknown", socket_path: undefined, tmux_active: envInfo !== null, - current_session: envInfo?.session, + current_session: undefined, available_sessions: [] }; - // Update with environment info if it matches our target session - if (envInfo && envInfo.session === targetSession) { - info.window = envInfo.window; - info.pane = envInfo.pane; + if (envInfo) { info.socket_path = envInfo.socket_path; - } else if (envInfo) { - // Add environment info as separate fields - info.env_session = envInfo.session; - info.env_window = envInfo.window; info.env_pane = envInfo.pane; } @@ -731,21 +775,20 @@ export class TmuxUtilities { * Spawn process in tmux session with environment variables. * * IMPORTANT: Unlike Node.js spawn(), env is a separate parameter. - * This is intentional because: - * - Tmux windows inherit environment from the tmux server - * - Only NEW or DIFFERENT variables need to be set via -e flag - * - Passing all of process.env would create 50+ unnecessary -e flags + * This is intentional because tmux sets window-scoped environment via `new-window -e KEY=VALUE`. + * Callers may provide a fully merged environment (daemon env + profile overrides) so tmux and + * non-tmux spawns behave consistently. * * @param args - Command and arguments to execute (as array, will be joined) * @param options - Spawn options (tmux-specific, excludes env) - * @param env - Environment variables to set in window (only pass what's different!) + * @param env - Environment variables to set in window * @returns Result with success status and session identifier */ async spawnInTmux( args: string[], options: TmuxSpawnOptions = {}, env?: Record<string, string> - ): Promise<{ success: boolean; sessionId?: string; pid?: number; error?: string }> { + ): Promise<{ success: boolean; sessionId?: string; sessionName?: string; windowName?: string; pid?: number; error?: string }> { try { // Check if tmux is available const tmuxCheck = await this.executeTmuxCommand(['list-sessions']); @@ -754,26 +797,41 @@ export class TmuxUtilities { } // Handle session name resolution - // - undefined: Use first existing session or create "happy" - // - empty string: Use first existing session or create "happy" + // - undefined: Use this instance's default session ("happy") + // - empty string: Use current/most-recent session deterministically // - specific name: Use that session (create if doesn't exist) - let sessionName = options.sessionName !== undefined && options.sessionName !== '' - ? options.sessionName - : null; - - // If no specific session name, try to use first existing session - if (!sessionName) { - const listResult = await this.executeTmuxCommand(['list-sessions', '-F', '#{session_name}']); - if (listResult && listResult.returncode === 0 && listResult.stdout.trim()) { - // Use first session from list - const firstSession = listResult.stdout.trim().split('\n')[0]; - sessionName = firstSession; - logger.debug(`[TMUX] Using first existing session: ${sessionName}`); - } else { - // No sessions exist, create "happy" - sessionName = 'happy'; - logger.debug(`[TMUX] No existing sessions, using default: ${sessionName}`); - } + let sessionName = options.sessionName ?? this.sessionName; + + if (options.sessionName === '') { + const listResult = await this.executeTmuxCommand([ + 'list-sessions', + '-F', + '#{session_name}\t#{session_attached}\t#{session_last_attached}', + ]); + + const candidates = (listResult?.stdout ?? '') + .trim() + .split('\n') + .filter((line) => line.trim()) + .map((line) => { + const [name, attachedRaw, lastAttachedRaw] = line.split('\t'); + const attached = Number.parseInt(attachedRaw ?? '0', 10); + const lastAttached = Number.parseInt(lastAttachedRaw ?? '0', 10); + return { + name: (name ?? '').trim(), + attached: Number.isFinite(attached) ? attached : 0, + lastAttached: Number.isFinite(lastAttached) ? lastAttached : 0, + }; + }) + .filter((row) => row.name.length > 0); + + candidates.sort((a, b) => { + // Prefer attached sessions first, then most recently attached. + if (a.attached !== b.attached) return b.attached - a.attached; + return b.lastAttached - a.lastAttached; + }); + + sessionName = candidates[0]?.name ?? TmuxUtilities.DEFAULT_SESSION_NAME; } const windowName = options.windowName || `happy-${Date.now()}`; @@ -782,11 +840,11 @@ export class TmuxUtilities { await this.ensureSessionExists(sessionName); // Build command to execute in the new window - const fullCommand = args.join(' '); + const fullCommand = buildPosixShellCommand(args); // Create new window in session with command and environment variables // IMPORTANT: Don't manually add -t here - executeTmuxCommand handles it via parameters - const createWindowArgs = ['new-window', '-n', windowName]; + const createWindowArgs = ['new-window', '-P', '-F', '#{pane_pid}', '-n', windowName]; // Add working directory if specified if (options.cwd) { @@ -794,6 +852,9 @@ export class TmuxUtilities { createWindowArgs.push('-c', cwdPath); } + // Add target session explicitly so option ordering is correct. + createWindowArgs.push('-t', sessionName); + // Add environment variables using -e flag (sets them in the window's environment) // Note: tmux windows inherit environment from tmux server, but we need to ensure // the daemon's environment variables (especially expanded auth variables) are available @@ -811,15 +872,9 @@ export class TmuxUtilities { continue; } - // Escape value for shell safety - // Must escape: backslashes, double quotes, dollar signs, backticks - const escapedValue = value - .replace(/\\/g, '\\\\') // Backslash first! - .replace(/"/g, '\\"') // Double quotes - .replace(/\$/g, '\\$') // Dollar signs - .replace(/`/g, '\\`'); // Backticks - - createWindowArgs.push('-e', `${key}="${escapedValue}"`); + // `new-window -e` takes KEY=VALUE literally (no shell parsing). + // Do NOT quote or escape values intended for shell parsing. + createWindowArgs.push('-e', `${key}=${value}`); } logger.debug(`[TMUX] Setting ${Object.keys(env).length} environment variables in tmux window`); } @@ -827,12 +882,31 @@ export class TmuxUtilities { // Add the command to run in the window (runs immediately when window is created) createWindowArgs.push(fullCommand); - // Add -P flag to print the pane PID immediately - createWindowArgs.push('-P'); - createWindowArgs.push('-F', '#{pane_pid}'); - - // Create window with command and get PID immediately - const createResult = await this.executeTmuxCommand(createWindowArgs, sessionName); + // Create window with command and get PID immediately. + // + // Note: tmux can fail with `create window failed: index N in use` when multiple + // clients concurrently create windows in the same session (tmux does not always + // auto-retry the window index allocation). Retry a few times to make concurrent + // session starts robust. + const maxAttempts = readPositiveIntegerEnv('HAPPY_CLI_TMUX_CREATE_WINDOW_MAX_ATTEMPTS', 3); + const retryDelayMs = readNonNegativeIntegerEnv('HAPPY_CLI_TMUX_CREATE_WINDOW_RETRY_DELAY_MS', 25); + + let createResult: TmuxCommandResult | null = null; + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + createResult = await this.executeTmuxCommand(createWindowArgs); + if (createResult && createResult.returncode === 0) break; + + const stderr = createResult?.stderr; + const shouldRetry = attempt < maxAttempts && isTmuxWindowIndexConflict(stderr); + if (!shouldRetry) break; + + logger.debug( + `[TMUX] new-window failed with window index conflict; retrying (attempt ${attempt}/${maxAttempts})`, + ); + if (retryDelayMs > 0) { + await new Promise((resolve) => setTimeout(resolve, retryDelayMs)); + } + } if (!createResult || createResult.returncode !== 0) { throw new Error(`Failed to create tmux window: ${createResult?.stderr}`); @@ -855,6 +929,8 @@ export class TmuxUtilities { return { success: true, sessionId: formatTmuxSessionIdentifier(sessionIdentifier), + sessionName, + windowName, pid: panePid }; } catch (error) { @@ -894,8 +970,8 @@ export class TmuxUtilities { throw new TmuxSessionIdentifierError(`Window identifier required: ${sessionIdentifier}`); } - const result = await this.executeWinOp('kill-window', [parsed.window], parsed.session); - return result; + const result = await this.executeTmuxCommand(['kill-window'], parsed.session, parsed.window); + return result !== null && result.returncode === 0; } catch (error) { if (error instanceof TmuxSessionIdentifierError) { logger.debug(`[TMUX] Invalid window identifier: ${error.message}`); @@ -911,35 +987,51 @@ export class TmuxUtilities { */ async listWindows(sessionName?: string): Promise<string[]> { const targetSession = sessionName || this.sessionName; - const result = await this.executeTmuxCommand(['list-windows', '-t', targetSession]); + const result = await this.executeTmuxCommand(['list-windows', '-t', targetSession, '-F', '#W']); if (!result || result.returncode !== 0) { return []; } - // Parse window names from tmux output - const windows: string[] = []; - const lines = result.stdout.trim().split('\n'); - - for (const line of lines) { - const match = line.match(/^\d+:\s+(\w+)/); - if (match) { - windows.push(match[1]); - } - } - - return windows; + return result.stdout + .split('\n') + .map((line) => line.trim()) + .filter(Boolean); } } // Global instance for consistent usage -let _tmuxUtils: TmuxUtilities | null = null; +const _tmuxUtilsByKey = new Map<string, TmuxUtilities>(); + +function tmuxUtilitiesCacheKey( + sessionName?: string, + tmuxCommandEnv?: Record<string, string>, + tmuxSocketPath?: string +): string { + const resolvedSessionName = sessionName ?? TmuxUtilities.DEFAULT_SESSION_NAME; + const resolvedSocketPath = tmuxSocketPath ?? ''; + const envKey = tmuxCommandEnv + ? Object.entries(tmuxCommandEnv) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([k, v]) => `${k}=${v}`) + .join('\n') + : ''; + + return `${resolvedSessionName}\n${resolvedSocketPath}\n${envKey}`; +} -export function getTmuxUtilities(sessionName?: string): TmuxUtilities { - if (!_tmuxUtils || (sessionName && sessionName !== _tmuxUtils.sessionName)) { - _tmuxUtils = new TmuxUtilities(sessionName); - } - return _tmuxUtils; +export function getTmuxUtilities( + sessionName?: string, + tmuxCommandEnv?: Record<string, string>, + tmuxSocketPath?: string +): TmuxUtilities { + const key = tmuxUtilitiesCacheKey(sessionName, tmuxCommandEnv, tmuxSocketPath); + const existing = _tmuxUtilsByKey.get(key); + if (existing) return existing; + + const created = new TmuxUtilities(sessionName, tmuxCommandEnv, tmuxSocketPath); + _tmuxUtilsByKey.set(key, created); + return created; } export async function isTmuxAvailable(): Promise<boolean> { @@ -964,24 +1056,25 @@ export async function createTmuxSession( } ): Promise<{ success: boolean; sessionIdentifier?: string; error?: string }> { try { - if (!sessionName || !/^[a-zA-Z0-9._-]+$/.test(sessionName)) { + const trimmedSessionName = sessionName?.trim(); + if (!trimmedSessionName || !/^[a-zA-Z0-9._ -]+$/.test(trimmedSessionName)) { throw new TmuxSessionIdentifierError(`Invalid session name: "${sessionName}"`); } - const utils = new TmuxUtilities(sessionName); + const utils = new TmuxUtilities(trimmedSessionName); const windowName = options?.windowName || 'main'; const cmd = ['new-session']; if (options?.detached !== false) { cmd.push('-d'); } - cmd.push('-s', sessionName); + cmd.push('-s', trimmedSessionName); cmd.push('-n', windowName); const result = await utils.executeTmuxCommand(cmd); if (result && result.returncode === 0) { const sessionIdentifier: TmuxSessionIdentifier = { - session: sessionName, + session: trimmedSessionName, window: windowName }; return { @@ -1026,11 +1119,11 @@ export function buildTmuxSessionIdentifier(params: { pane?: string; }): { success: boolean; identifier?: string; error?: string } { try { - if (!params.session || !/^[a-zA-Z0-9._-]+$/.test(params.session)) { + if (!params.session || !/^[a-zA-Z0-9._ -]+$/.test(params.session)) { throw new TmuxSessionIdentifierError(`Invalid session name: "${params.session}"`); } - if (params.window && !/^[a-zA-Z0-9._-]+$/.test(params.window)) { + if (params.window && !/^[a-zA-Z0-9._ -]+$/.test(params.window)) { throw new TmuxSessionIdentifierError(`Invalid window name: "${params.window}"`); } @@ -1049,4 +1142,4 @@ export function buildTmuxSessionIdentifier(params: { error: error instanceof Error ? error.message : 'Unknown error' }; } -} \ No newline at end of file +} diff --git a/cli/src/integrations/tmux/sessionSelector.test.ts b/cli/src/integrations/tmux/sessionSelector.test.ts new file mode 100644 index 000000000..89c94e017 --- /dev/null +++ b/cli/src/integrations/tmux/sessionSelector.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; + +import { selectPreferredTmuxSessionName } from './sessionSelector'; + +describe('selectPreferredTmuxSessionName', () => { + it('prefers attached sessions over detached', () => { + const stdout = ['dev\t1\t100', 'other\t0\t200'].join('\n'); + expect(selectPreferredTmuxSessionName(stdout)).toBe('dev'); + }); + + it('prefers most recently attached among attached sessions', () => { + const stdout = ['a\t1\t100', 'b\t1\t200', 'c\t0\t999'].join('\n'); + expect(selectPreferredTmuxSessionName(stdout)).toBe('b'); + }); + + it('returns null when no valid sessions exist', () => { + expect(selectPreferredTmuxSessionName('')).toBeNull(); + expect(selectPreferredTmuxSessionName('\n\n')).toBeNull(); + expect(selectPreferredTmuxSessionName('bad-line')).toBeNull(); + }); +}); diff --git a/cli/src/integrations/tmux/sessionSelector.ts b/cli/src/integrations/tmux/sessionSelector.ts new file mode 100644 index 000000000..3b54e0ef3 --- /dev/null +++ b/cli/src/integrations/tmux/sessionSelector.ts @@ -0,0 +1,45 @@ +export type TmuxSessionListRow = { + name: string; + attached: number; + lastAttached: number; +}; + +function parseIntOrZero(value: string | undefined): number { + const parsed = Number.parseInt(value ?? '0', 10); + return Number.isFinite(parsed) ? parsed : 0; +} + +export function parseTmuxSessionList(stdout: string): TmuxSessionListRow[] { + if (typeof stdout !== 'string' || stdout.trim().length === 0) return []; + + return stdout + .trim() + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => { + const parts = line.split('\t'); + if (parts.length < 3) return null; + const [nameRaw, attachedRaw, lastAttachedRaw] = parts; + const name = (nameRaw ?? '').trim(); + if (name.length === 0) return null; + return { + name, + attached: parseIntOrZero(attachedRaw), + lastAttached: parseIntOrZero(lastAttachedRaw), + } satisfies TmuxSessionListRow; + }) + .filter((row): row is TmuxSessionListRow => row !== null); +} + +export function selectPreferredTmuxSessionName(stdout: string): string | null { + const rows = parseTmuxSessionList(stdout); + if (rows.length === 0) return null; + + rows.sort((a, b) => { + if (a.attached !== b.attached) return b.attached - a.attached; + return b.lastAttached - a.lastAttached; + }); + + return rows[0]?.name ?? null; +} diff --git a/cli/src/integrations/tmux/tmux.commandEnv.test.ts b/cli/src/integrations/tmux/tmux.commandEnv.test.ts new file mode 100644 index 000000000..ba732fe9b --- /dev/null +++ b/cli/src/integrations/tmux/tmux.commandEnv.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { EventEmitter } from 'node:events'; +import type { SpawnOptions, ChildProcessWithoutNullStreams } from 'node:child_process'; + +type SpawnCall = { + command: string; + args: string[]; + options: SpawnOptions; +}; + +const { spawnMock, getLastSpawnCall } = vi.hoisted(() => { + let lastSpawnCall: SpawnCall | null = null; + + const spawnMock = vi.fn((command: string, args: readonly string[], options: SpawnOptions) => { + lastSpawnCall = { command, args: [...args], options }; + + type MinimalChild = EventEmitter & { + stdout: EventEmitter; + stderr: EventEmitter; + }; + + const child = new EventEmitter() as MinimalChild; + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + + queueMicrotask(() => { + child.emit('close', 0); + }); + + return child as unknown as ChildProcessWithoutNullStreams; + }); + + return { + spawnMock, + getLastSpawnCall: () => lastSpawnCall, + }; +}); + +vi.mock('child_process', () => ({ + spawn: spawnMock, +})); + +describe('TmuxUtilities tmux subprocess environment', () => { + beforeEach(() => { + spawnMock.mockClear(); + }); + + it('passes TMUX_TMPDIR to tmux subprocess env when provided', async () => { + vi.resetModules(); + const { TmuxUtilities } = await import('@/integrations/tmux'); + + const utils = new TmuxUtilities('happy', { TMUX_TMPDIR: '/custom/tmux' }); + await utils.executeTmuxCommand(['list-sessions']); + + const call = getLastSpawnCall(); + expect(call).not.toBeNull(); + expect((call!.options.env as NodeJS.ProcessEnv | undefined)?.TMUX_TMPDIR).toBe('/custom/tmux'); + }); +}); diff --git a/cli/src/integrations/tmux/tmux.real.integration.test.ts b/cli/src/integrations/tmux/tmux.real.integration.test.ts new file mode 100644 index 000000000..f969b89de --- /dev/null +++ b/cli/src/integrations/tmux/tmux.real.integration.test.ts @@ -0,0 +1,322 @@ +/** + * Opt-in tmux integration tests. + * + * These tests start isolated tmux servers (via `-S` or `TMUX_TMPDIR`) and must + * never interact with a user's existing tmux sessions. + * + * Enable with: `HAPPY_CLI_TMUX_INTEGRATION=1` + */ + +import { describe, it, expect } from 'vitest'; +import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { TmuxUtilities } from '@/integrations/tmux'; + +function isTmuxInstalled(): boolean { + const result = spawnSync('tmux', ['-V'], { encoding: 'utf8' }); + return result.status === 0; +} + +function shouldRunTmuxIntegration(): boolean { + return process.env.HAPPY_CLI_TMUX_INTEGRATION === '1' && isTmuxInstalled(); +} + +function waitForFile(path: string, timeoutMs: number): Promise<void> { + const pollIntervalMs = 50; + const start = Date.now(); + return new Promise((resolve, reject) => { + const tick = () => { + if (existsSync(path)) return resolve(); + if (Date.now() - start > timeoutMs) { + return reject(new Error(`Timed out waiting for file: ${path}`)); + } + setTimeout(tick, pollIntervalMs); + }; + tick(); + }); +} + +function writeDumpScript(dir: string): string { + const scriptPath = join(dir, 'happy-cli-tmux-dump.cjs'); + writeFileSync( + scriptPath, + [ + "const fs = require('fs');", + "const outFile = process.argv[2];", + "const keepAliveMs = Number(process.argv[3] || '0');", + 'const payload = {', + ' argv: process.argv.slice(4),', + ' env: {', + ' FOO: process.env.FOO,', + ' BAR: process.env.BAR,', + ' TMUX: process.env.TMUX,', + ' TMUX_PANE: process.env.TMUX_PANE,', + ' TMUX_TMPDIR: process.env.TMUX_TMPDIR,', + ' },', + '};', + 'fs.writeFileSync(outFile, JSON.stringify(payload));', + 'if (keepAliveMs > 0) setTimeout(() => {}, keepAliveMs);', + '', + ].join('\n'), + 'utf8', + ); + return scriptPath; +} + +type DumpScriptPayload = { + argv: string[]; + env: { + FOO?: string; + BAR?: string; + TMUX?: string; + TMUX_PANE?: string; + TMUX_TMPDIR?: string; + }; +}; + +function readDumpPayload(outFile: string): DumpScriptPayload { + return JSON.parse(readFileSync(outFile, 'utf8')) as DumpScriptPayload; +} + +async function withCleanTmuxClientEnv<T>(fn: () => Promise<T>): Promise<T> { + const originalTmux = process.env.TMUX; + const originalTmuxPane = process.env.TMUX_PANE; + const originalTmuxTmpDir = process.env.TMUX_TMPDIR; + + delete process.env.TMUX; + delete process.env.TMUX_PANE; + delete process.env.TMUX_TMPDIR; + + try { + return await fn(); + } finally { + if (originalTmux === undefined) delete process.env.TMUX; + else process.env.TMUX = originalTmux; + + if (originalTmuxPane === undefined) delete process.env.TMUX_PANE; + else process.env.TMUX_PANE = originalTmuxPane; + + if (originalTmuxTmpDir === undefined) delete process.env.TMUX_TMPDIR; + else process.env.TMUX_TMPDIR = originalTmuxTmpDir; + } +} + +type TmuxRunResult = { + status: number | null; + stdout: string; + stderr: string; + error: Error | undefined; +}; + +function runTmux(args: string[], options?: { env?: Record<string, string | undefined> }): TmuxRunResult { + // Never inherit the user's existing tmux context (TMUX/TMUX_PANE) or TMUX_TMPDIR. + // These tests must only ever talk to isolated servers created by the test itself. + const env: NodeJS.ProcessEnv = { ...process.env }; + delete env.TMUX; + delete env.TMUX_PANE; + delete env.TMUX_TMPDIR; + + const result = spawnSync('tmux', args, { + encoding: 'utf8', + env: { + ...env, + ...(options?.env ?? {}), + } as NodeJS.ProcessEnv, + }); + return { + status: result.status, + stdout: result.stdout ?? '', + stderr: result.stderr ?? '', + error: result.error, + }; +} + +function killIsolatedTmuxServer(socketPath: string): void { + const result = runTmux(['-S', socketPath, 'kill-server']); + if (result.status !== 0 && process.env.DEBUG) { + // Cleanup should never fail the test run, but debug logging can help diagnose flakes. + console.error('[tmux-it] Failed to kill isolated tmux server', { + socketPath, + status: result.status, + stderr: result.stderr, + error: result.error?.message, + }); + } +} + +describe.skipIf(!shouldRunTmuxIntegration())('tmux (real) integration tests (opt-in)', { timeout: 20_000 }, () => { + it('spawnInTmux returns a real pane PID via -P/-F (regression: PR107 option ordering)', async () => { + const dir = mkdtempSync(join(tmpdir(), 'happy-cli-tmux-it-')); + const socketPath = join(dir, 'tmux.sock'); + const utils = new TmuxUtilities('happy', undefined, socketPath); + + try { + const scriptPath = writeDumpScript(dir); + const outFile = join(dir, 'out.json'); + + const sessionName = `happy-it-${process.pid}-${Date.now()}`; + const windowName = 'pid'; + + const result = await utils.spawnInTmux( + [process.execPath, scriptPath, outFile, '5000', 'pid-check'], + { sessionName, windowName, cwd: dir }, + {}, + ); + + expect(result.success).toBe(true); + expect(typeof result.pid).toBe('number'); + expect(result.pid).toBeGreaterThan(0); + + // Ground truth: query tmux directly for the pane pid. + const panes = runTmux(['-S', socketPath, 'list-panes', '-t', `${sessionName}:${windowName}`, '-F', '#{pane_pid}']); + expect(panes.status).toBe(0); + const listedPid = Number.parseInt(panes.stdout.trim(), 10); + expect(listedPid).toBe(result.pid); + + await waitForFile(outFile, 2_000); + const payload = readDumpPayload(outFile); + expect(payload.argv).toEqual(['pid-check']); + + // Validate the TMUX env format: socket_path,server_pid,pane (not session/window). + expect(typeof payload.env?.TMUX).toBe('string'); + const parts = String(payload.env.TMUX).split(','); + expect(parts.length).toBeGreaterThanOrEqual(3); + expect(parts[0]!.length).toBeGreaterThan(0); + expect(/^\d+$/.test(parts[1]!)).toBe(true); + } finally { + // Kill only the isolated server (never touch the user's default tmux server). + killIsolatedTmuxServer(socketPath); + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('spawnInTmux passes -e KEY=VALUE env values literally (regression: PR107 quoting/escaping)', async () => { + const dir = mkdtempSync(join(tmpdir(), 'happy-cli-tmux-it-')); + const socketPath = join(dir, 'tmux.sock'); + const utils = new TmuxUtilities('happy', undefined, socketPath); + + try { + const scriptPath = writeDumpScript(dir); + const outFile = join(dir, 'out.json'); + + const sessionName = `happy-it-${process.pid}-${Date.now()}`; + const windowName = 'env'; + + const env = { + FOO: 'a$b', + BAR: 'quote"back\\tick`', + }; + + const result = await utils.spawnInTmux( + [process.execPath, scriptPath, outFile, '5000', 'env-check'], + { sessionName, windowName, cwd: dir }, + env, + ); + + expect(result.success).toBe(true); + + await waitForFile(outFile, 2_000); + const payload = readDumpPayload(outFile); + + expect(payload.env?.FOO).toBe(env.FOO); + expect(payload.env?.BAR).toBe(env.BAR); + } finally { + killIsolatedTmuxServer(socketPath); + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('spawnInTmux quotes command tokens safely (regression: PR107 args.join(\" \") injection/splitting)', async () => { + const dir = mkdtempSync(join(tmpdir(), 'happy-cli-tmux-it-')); + const socketPath = join(dir, 'tmux.sock'); + const utils = new TmuxUtilities('happy', undefined, socketPath); + + try { + const scriptPath = writeDumpScript(dir); + const outFile = join(dir, 'out.json'); + const sentinelFile = join(dir, 'injection-sentinel'); + + const sessionName = `happy-it-${process.pid}-${Date.now()}`; + const windowName = 'quote'; + + const argWithSpaces = 'a b'; + const argWithSingleQuote = "c'd"; + const injectionArg = `$(touch ${sentinelFile})`; + + const result = await utils.spawnInTmux( + [process.execPath, scriptPath, outFile, '5000', argWithSpaces, argWithSingleQuote, injectionArg], + { sessionName, windowName, cwd: dir }, + {}, + ); + + expect(result.success).toBe(true); + + await waitForFile(outFile, 2_000); + const payload = readDumpPayload(outFile); + expect(payload.argv).toEqual([argWithSpaces, argWithSingleQuote, injectionArg]); + + // If quoting were broken, the shell would execute `touch <sentinel>` and create the file. + expect(existsSync(sentinelFile)).toBe(false); + } finally { + killIsolatedTmuxServer(socketPath); + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('TMUX_TMPDIR affects which tmux server commands talk to (regression: PR107 wrong-server assumptions)', async () => { + const dir = mkdtempSync(join(tmpdir(), 'happy-cli-tmux-it-')); + // IMPORTANT: keep the socket path short to avoid unix domain socket length limits (common on macOS). + // tmux will create tmux-<uid>/default within this directory. + const tmuxTmpDir = mkdtempSync(join(tmpdir(), 'happy-cli-tmux-tmpdir-it-')); + + const utils = new TmuxUtilities('happy', { TMUX_TMPDIR: tmuxTmpDir }); + + try { + const scriptPath = writeDumpScript(dir); + const outFile = join(dir, 'out.json'); + + const sessionName = `happy-it-${process.pid}-${Date.now()}`; + const windowName = 'tmpdir'; + + const result = await withCleanTmuxClientEnv(() => + utils.spawnInTmux( + [process.execPath, scriptPath, outFile, '5000', 'tmpdir-check'], + { sessionName, windowName, cwd: dir }, + {}, + ), + ); + + if (!result.success) { + throw new Error(`spawnInTmux failed: ${result.error ?? 'unknown error'}`); + } + + // Without TMUX_TMPDIR, a fresh tmux client should not see the isolated session. + const defaultList = runTmux(['list-sessions']); + expect(defaultList.stdout.includes(sessionName)).toBe(false); + + // With TMUX_TMPDIR, tmux should see our isolated session. + const isolatedList = runTmux(['list-sessions'], { env: { TMUX_TMPDIR: tmuxTmpDir } }); + expect(isolatedList.status).toBe(0); + expect(isolatedList.stdout.includes(sessionName)).toBe(true); + + await waitForFile(outFile, 2_000); + const payload = readDumpPayload(outFile); + expect(payload.argv).toEqual(['tmpdir-check']); + } finally { + // Kill only the isolated server identified by TMUX_TMPDIR. + const result = runTmux(['kill-server'], { env: { TMUX_TMPDIR: tmuxTmpDir } }); + if (result.status !== 0 && process.env.DEBUG) { + console.error('[tmux-it] Failed to kill isolated tmux server via TMUX_TMPDIR', { + tmuxTmpDir, + status: result.status, + stderr: result.stderr, + error: result.error?.message, + }); + } + rmSync(tmuxTmpDir, { recursive: true, force: true }); + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/cli/src/integrations/tmux/tmux.socketPath.test.ts b/cli/src/integrations/tmux/tmux.socketPath.test.ts new file mode 100644 index 000000000..a5cd5b2d6 --- /dev/null +++ b/cli/src/integrations/tmux/tmux.socketPath.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { EventEmitter } from 'node:events'; +import type { SpawnOptions, ChildProcessWithoutNullStreams } from 'node:child_process'; + +type SpawnCall = { + command: string; + args: string[]; + options: SpawnOptions; +}; + +const { spawnMock, getLastSpawnCall } = vi.hoisted(() => { + let lastSpawnCall: SpawnCall | null = null; + + const spawnMock = vi.fn((command: string, args: readonly string[], options: SpawnOptions) => { + lastSpawnCall = { command, args: [...args], options }; + + type MinimalChild = EventEmitter & { + stdout: EventEmitter; + stderr: EventEmitter; + }; + + const child = new EventEmitter() as MinimalChild; + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + + queueMicrotask(() => { + child.emit('close', 0); + }); + + return child as unknown as ChildProcessWithoutNullStreams; + }); + + return { + spawnMock, + getLastSpawnCall: () => lastSpawnCall, + }; +}); + +vi.mock('child_process', () => ({ + spawn: spawnMock, +})); + +describe('TmuxUtilities tmux socket path', () => { + beforeEach(() => { + spawnMock.mockClear(); + }); + + it('uses -S <socketPath> by default when configured', async () => { + vi.resetModules(); + const { TmuxUtilities } = await import('@/integrations/tmux'); + + const socketPath = '/tmp/happy-cli-tmux-test.sock'; + const utils = new TmuxUtilities('happy', undefined, socketPath); + await utils.executeTmuxCommand(['list-sessions']); + + const call = getLastSpawnCall(); + expect(call).not.toBeNull(); + expect(call!.command).toBe('tmux'); + expect(call!.args).toEqual(expect.arrayContaining(['-S', socketPath])); + }); +}); diff --git a/cli/src/utils/tmux.test.ts b/cli/src/integrations/tmux/tmux.test.ts similarity index 61% rename from cli/src/utils/tmux.test.ts rename to cli/src/integrations/tmux/tmux.test.ts index c5628e981..efab4b599 100644 --- a/cli/src/utils/tmux.test.ts +++ b/cli/src/integrations/tmux/tmux.test.ts @@ -5,16 +5,31 @@ * They do NOT require tmux to be installed on the system. * All tests mock environment variables and test string parsing only. */ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { + normalizeExitCode, parseTmuxSessionIdentifier, formatTmuxSessionIdentifier, validateTmuxSessionIdentifier, buildTmuxSessionIdentifier, + createTmuxSession, TmuxSessionIdentifierError, + extractSessionAndWindow, TmuxUtilities, type TmuxSessionIdentifier, -} from './tmux'; + type TmuxCommandResult, +} from './index'; + +describe('normalizeExitCode', () => { + it('treats signal termination (null) as non-zero', () => { + expect(normalizeExitCode(null)).toBe(1); + }); + + it('preserves normal exit codes', () => { + expect(normalizeExitCode(0)).toBe(0); + expect(normalizeExitCode(2)).toBe(2); + }); +}); describe('parseTmuxSessionIdentifier', () => { it('should parse session-only identifier', () => { @@ -66,9 +81,12 @@ describe('parseTmuxSessionIdentifier', () => { expect(() => parseTmuxSessionIdentifier(undefined as any)).toThrow(TmuxSessionIdentifierError); }); - it('should throw on invalid session name characters', () => { - expect(() => parseTmuxSessionIdentifier('invalid session')).toThrow(TmuxSessionIdentifierError); - expect(() => parseTmuxSessionIdentifier('invalid session')).toThrow('Only alphanumeric characters, dots, hyphens, and underscores are allowed'); + it('should allow session names with spaces', () => { + const result = parseTmuxSessionIdentifier('my session:window-1'); + expect(result).toEqual({ + session: 'my session', + window: 'window-1', + }); }); it('should throw on special characters in session name', () => { @@ -78,8 +96,8 @@ describe('parseTmuxSessionIdentifier', () => { }); it('should throw on invalid window name characters', () => { - expect(() => parseTmuxSessionIdentifier('session:invalid window')).toThrow(TmuxSessionIdentifierError); - expect(() => parseTmuxSessionIdentifier('session:invalid window')).toThrow('Only alphanumeric characters, dots, hyphens, and underscores are allowed'); + expect(() => parseTmuxSessionIdentifier('session:invalid@window')).toThrow(TmuxSessionIdentifierError); + expect(() => parseTmuxSessionIdentifier('session:invalid@window')).toThrow('Only alphanumeric characters'); }); it('should throw on non-numeric pane identifier', () => { @@ -172,13 +190,13 @@ describe('validateTmuxSessionIdentifier', () => { }); it('should return valid:false for invalid session characters', () => { - const result = validateTmuxSessionIdentifier('invalid session'); + const result = validateTmuxSessionIdentifier('invalid@session'); expect(result.valid).toBe(false); expect(result.error).toContain('Only alphanumeric characters'); }); it('should return valid:false for invalid window characters', () => { - const result = validateTmuxSessionIdentifier('session:invalid window'); + const result = validateTmuxSessionIdentifier('session:invalid@window'); expect(result.valid).toBe(false); expect(result.error).toContain('Only alphanumeric characters'); }); @@ -196,7 +214,7 @@ describe('validateTmuxSessionIdentifier', () => { it('should not throw exceptions', () => { expect(() => validateTmuxSessionIdentifier('')).not.toThrow(); - expect(() => validateTmuxSessionIdentifier('invalid session')).not.toThrow(); + expect(() => validateTmuxSessionIdentifier('invalid@session')).not.toThrow(); expect(() => validateTmuxSessionIdentifier(null as any)).not.toThrow(); }); }); @@ -240,7 +258,7 @@ describe('buildTmuxSessionIdentifier', () => { }); it('should return error for invalid session characters', () => { - const result = buildTmuxSessionIdentifier({ session: 'invalid session' }); + const result = buildTmuxSessionIdentifier({ session: 'invalid@session' }); expect(result.success).toBe(false); expect(result.error).toContain('Invalid session name'); }); @@ -248,7 +266,7 @@ describe('buildTmuxSessionIdentifier', () => { it('should return error for invalid window characters', () => { const result = buildTmuxSessionIdentifier({ session: 'session', - window: 'invalid window' + window: 'invalid@window' }); expect(result.success).toBe(false); expect(result.error).toContain('Invalid window name'); @@ -278,17 +296,23 @@ describe('buildTmuxSessionIdentifier', () => { it('should not throw exceptions for invalid inputs', () => { expect(() => buildTmuxSessionIdentifier({ session: '' })).not.toThrow(); - expect(() => buildTmuxSessionIdentifier({ session: 'invalid session' })).not.toThrow(); + expect(() => buildTmuxSessionIdentifier({ session: 'invalid@session' })).not.toThrow(); expect(() => buildTmuxSessionIdentifier({ session: null as any })).not.toThrow(); }); }); describe('TmuxUtilities.detectTmuxEnvironment', () => { const originalTmuxEnv = process.env.TMUX; + const originalTmuxPaneEnv = process.env.TMUX_PANE; // Helper to set and restore environment - const withTmuxEnv = (value: string | undefined, fn: () => void) => { + const withTmuxEnv = (value: string | undefined, fn: () => void, pane?: string | undefined) => { process.env.TMUX = value; + if (pane !== undefined) { + process.env.TMUX_PANE = pane; + } else { + delete process.env.TMUX_PANE; + } try { fn(); } finally { @@ -297,6 +321,11 @@ describe('TmuxUtilities.detectTmuxEnvironment', () => { } else { delete process.env.TMUX; } + if (originalTmuxPaneEnv !== undefined) { + process.env.TMUX_PANE = originalTmuxPaneEnv; + } else { + delete process.env.TMUX_PANE; + } } }; @@ -313,37 +342,26 @@ describe('TmuxUtilities.detectTmuxEnvironment', () => { const utils = new TmuxUtilities(); const result = utils.detectTmuxEnvironment(); expect(result).toEqual({ - session: '4219', - window: '0', + socket_path: '/tmp/tmux-1000/default', + server_pid: 4219, pane: '0', - socket_path: '/tmp/tmux-1000/default' }); }); }); - it('should parse TMUX env with session.window format', () => { + it('should return null for malformed TMUX env (non-numeric server pid)', () => { withTmuxEnv('/tmp/tmux-1000/default,mysession.mywindow,2', () => { const utils = new TmuxUtilities(); const result = utils.detectTmuxEnvironment(); - expect(result).toEqual({ - session: 'mysession', - window: 'mywindow', - pane: '2', - socket_path: '/tmp/tmux-1000/default' - }); + expect(result).toBeNull(); }); }); - it('should handle TMUX env without session.window format', () => { + it('should return null for malformed TMUX env (non-numeric server pid, no dot)', () => { withTmuxEnv('/tmp/tmux-1000/default,session123,1', () => { const utils = new TmuxUtilities(); const result = utils.detectTmuxEnvironment(); - expect(result).toEqual({ - session: 'session123', - window: '0', - pane: '1', - socket_path: '/tmp/tmux-1000/default' - }); + expect(result).toBeNull(); }); }); @@ -353,24 +371,21 @@ describe('TmuxUtilities.detectTmuxEnvironment', () => { const utils = new TmuxUtilities(); const result = utils.detectTmuxEnvironment(); expect(result).toEqual({ - session: '5678', - window: '0', + socket_path: '/tmp/tmux-1000/my-socket', + server_pid: 5678, pane: '3', - socket_path: '/tmp/tmux-1000/my-socket' }); }); }); it('should handle socket path with multiple slashes', () => { - // Test the array indexing fix - ensure we get the last component correctly - withTmuxEnv('/var/run/tmux/1000/default,session.window,0', () => { + withTmuxEnv('/var/run/tmux/1000/default,1234,0', () => { const utils = new TmuxUtilities(); const result = utils.detectTmuxEnvironment(); expect(result).toEqual({ - session: 'session', - window: 'window', + socket_path: '/var/run/tmux/1000/default', + server_pid: 1234, pane: '0', - socket_path: '/var/run/tmux/1000/default' }); }); }); @@ -397,10 +412,9 @@ describe('TmuxUtilities.detectTmuxEnvironment', () => { const result = utils.detectTmuxEnvironment(); // Should still parse the first 3 parts correctly expect(result).toEqual({ - session: '4219', - window: '0', + socket_path: '/tmp/tmux-1000/default', + server_pid: 4219, pane: '0', - socket_path: '/tmp/tmux-1000/default' }); }); }); @@ -409,14 +423,20 @@ describe('TmuxUtilities.detectTmuxEnvironment', () => { withTmuxEnv('/tmp/tmux-1000/default,my.session.name.5,2', () => { const utils = new TmuxUtilities(); const result = utils.detectTmuxEnvironment(); - // Split on dot, so my.session becomes session=my, window=session + expect(result).toBeNull(); + }); + }); + + it('should prefer TMUX_PANE (pane id) when present', () => { + withTmuxEnv('/tmp/tmux-1000/default,4219,0', () => { + const utils = new TmuxUtilities(); + const result = utils.detectTmuxEnvironment(); expect(result).toEqual({ - session: 'my', - window: 'session', - pane: '2', - socket_path: '/tmp/tmux-1000/default' + socket_path: '/tmp/tmux-1000/default', + server_pid: 4219, + pane: '%0', }); - }); + }, '%0'); }); }); @@ -454,3 +474,178 @@ describe('Round-trip consistency', () => { expect(parsed).toEqual(params); }); }); + +describe('extractSessionAndWindow', () => { + it('extracts session and window names containing spaces', () => { + const parsed = extractSessionAndWindow('my session:my window.2'); + expect(parsed).toEqual({ session: 'my session', window: 'my window' }); + }); +}); + +describe('createTmuxSession', () => { + it('returns a trimmed session identifier', async () => { + const spy = vi + .spyOn(TmuxUtilities.prototype, 'executeTmuxCommand') + .mockResolvedValue({ returncode: 0, stdout: '', stderr: '', command: [] }); + + try { + const result = await createTmuxSession(' my session ', { windowName: 'main' }); + expect(result.success).toBe(true); + expect(result.sessionIdentifier).toBe('my session:main'); + } finally { + spy.mockRestore(); + } + }); +}); + +describe('TmuxUtilities.spawnInTmux', () => { + class FakeTmuxUtilities extends TmuxUtilities { + public calls: Array<{ cmd: string[]; session?: string }> = []; + + async executeTmuxCommand(cmd: string[], session?: string): Promise<TmuxCommandResult | null> { + this.calls.push({ cmd, session }); + + if (cmd[0] === 'list-sessions') { + // tmux availability check + if (cmd.length === 1) { + return { returncode: 0, stdout: 'oldSess: 1 windows\nnewSess: 2 windows\n', stderr: '', command: cmd }; + } + + // Most-recent selection format + if (cmd[1] === '-F' && cmd[2]?.includes('session_last_attached')) { + return { + returncode: 0, + stdout: 'oldSess\t0\t100\nnewSess\t0\t200\n', + stderr: '', + command: cmd, + }; + } + + // Legacy name-only listing + if (cmd[1] === '-F') { + return { returncode: 0, stdout: 'oldSess\nnewSess\n', stderr: '', command: cmd }; + } + } + + if (cmd[0] === 'has-session') { + return { returncode: 0, stdout: '', stderr: '', command: cmd }; + } + + if (cmd[0] === 'new-session') { + return { returncode: 0, stdout: '', stderr: '', command: cmd }; + } + + if (cmd[0] === 'new-window') { + return { returncode: 0, stdout: '4242\n', stderr: '', command: cmd }; + } + + return { returncode: 0, stdout: '', stderr: '', command: cmd }; + } + } + + it('builds tmux new-window args without quoting env values', async () => { + const tmux = new FakeTmuxUtilities(); + + await tmux.spawnInTmux( + ['echo', 'hello'], + { sessionName: 'my-session', windowName: 'my-window', cwd: '/tmp' }, + { FOO: 'a$b', BAR: 'quote"back\\tick`' } + ); + + const newWindowCall = tmux.calls.find((call) => call.cmd[0] === 'new-window'); + expect(newWindowCall).toBeDefined(); + + const newWindowArgs = newWindowCall!.cmd; + + // -e takes literal KEY=VALUE, not shell-escaped values. + expect(newWindowArgs).toContain('FOO=a$b'); + expect(newWindowArgs).toContain('BAR=quote"back\\tick`'); + expect(newWindowArgs.some((arg) => arg.startsWith('FOO="'))).toBe(false); + expect(newWindowArgs.some((arg) => arg.startsWith('BAR="'))).toBe(false); + + // -P/-F options must appear before the shell command argument. + const commandIndex = newWindowArgs.indexOf("'echo' 'hello'"); + const pIndex = newWindowArgs.indexOf('-P'); + const fIndex = newWindowArgs.indexOf('-F'); + expect(pIndex).toBeGreaterThanOrEqual(0); + expect(fIndex).toBeGreaterThanOrEqual(0); + expect(commandIndex).toBeGreaterThanOrEqual(0); + expect(pIndex).toBeLessThan(commandIndex); + expect(fIndex).toBeLessThan(commandIndex); + + // When targeting a specific session, -t must be included explicitly. + const tIndex = newWindowArgs.indexOf('-t'); + expect(tIndex).toBeGreaterThanOrEqual(0); + expect(newWindowArgs[tIndex + 1]).toBe('my-session'); + expect(tIndex).toBeLessThan(commandIndex); + }); + + it('quotes command arguments for tmux shell command safely', async () => { + const tmux = new FakeTmuxUtilities(); + + await tmux.spawnInTmux( + ['echo', 'a b', "c'd", '$(rm -rf /)'], + { sessionName: 'my-session', windowName: 'my-window' }, + {} + ); + + const newWindowCall = tmux.calls.find((call) => call.cmd[0] === 'new-window'); + expect(newWindowCall).toBeDefined(); + + const newWindowArgs = newWindowCall!.cmd; + const commandArg = newWindowArgs[newWindowArgs.length - 1]; + expect(commandArg).toBe("'echo' 'a b' 'c'\\''d' '$(rm -rf /)'"); + }); + + it('treats empty sessionName as current/most-recent session (deterministic)', async () => { + const tmux = new FakeTmuxUtilities(); + + const result = await tmux.spawnInTmux( + ['echo', 'hello'], + { sessionName: '', windowName: 'my-window' }, + {} + ); + + expect(result.success).toBe(true); + expect(result.sessionId).toBe('newSess:my-window'); + + // Should request deterministic session selection metadata (not just "first session") + const usedLastAttachedFormat = tmux.calls.some( + (call) => + call.cmd[0] === 'list-sessions' && + call.cmd[1] === '-F' && + Boolean(call.cmd[2]?.includes('session_last_attached')) + ); + expect(usedLastAttachedFormat).toBe(true); + }); + + it('retries new-window when tmux reports a window index conflict', async () => { + class ConflictThenSuccessTmuxUtilities extends FakeTmuxUtilities { + private newWindowAttempts = 0; + + override async executeTmuxCommand(cmd: string[], session?: string): Promise<TmuxCommandResult | null> { + if (cmd[0] === 'new-window') { + this.newWindowAttempts += 1; + this.calls.push({ cmd, session }); + if (this.newWindowAttempts === 1) { + return { returncode: 1, stdout: '', stderr: 'create window failed: index 1 in use.', command: cmd }; + } + return { returncode: 0, stdout: '4242\n', stderr: '', command: cmd }; + } + return super.executeTmuxCommand(cmd, session); + } + } + + const tmux = new ConflictThenSuccessTmuxUtilities(); + + const result = await tmux.spawnInTmux( + ['echo', 'hello'], + { sessionName: 'my-session', windowName: 'my-window' }, + {}, + ); + + expect(result.success).toBe(true); + const newWindowCalls = tmux.calls.filter((call) => call.cmd[0] === 'new-window'); + expect(newWindowCalls.length).toBeGreaterThanOrEqual(2); + }); +}); diff --git a/cli/src/modules/watcher/awaitFileExist.ts b/cli/src/integrations/watcher/awaitFileExist.ts similarity index 100% rename from cli/src/modules/watcher/awaitFileExist.ts rename to cli/src/integrations/watcher/awaitFileExist.ts diff --git a/cli/src/modules/watcher/startFileWatcher.ts b/cli/src/integrations/watcher/startFileWatcher.ts similarity index 100% rename from cli/src/modules/watcher/startFileWatcher.ts rename to cli/src/integrations/watcher/startFileWatcher.ts diff --git a/cli/src/lib.ts b/cli/src/lib.ts index 561013203..a43bbc7ce 100644 --- a/cli/src/lib.ts +++ b/cli/src/lib.ts @@ -12,4 +12,4 @@ export { ApiSessionClient } from '@/api/apiSession' export { logger } from '@/ui/logger' export { configuration } from '@/configuration' -export { RawJSONLinesSchema, type RawJSONLines } from '@/claude/types' \ No newline at end of file +export { RawJSONLinesSchema, type RawJSONLines } from '@/backends/claude/types' \ No newline at end of file diff --git a/cli/src/claude/utils/startHappyServer.ts b/cli/src/mcp/startHappyServer.ts similarity index 89% rename from cli/src/claude/utils/startHappyServer.ts rename to cli/src/mcp/startHappyServer.ts index 9a1bb21b0..2168b613c 100644 --- a/cli/src/claude/utils/startHappyServer.ts +++ b/cli/src/mcp/startHappyServer.ts @@ -45,29 +45,30 @@ export async function startHappyServer(client: ApiSessionClient) { inputSchema: { title: z.string().describe('The new title for the chat session'), }, - }, async (args) => { - const response = await handler(args.title); + } as any, async (args: any, _extra: any) => { + const title = typeof args?.title === 'string' ? args.title : ''; + const response = await handler(title); logger.debug('[happyMCP] Response:', response); if (response.success) { return { content: [ { - type: 'text', - text: `Successfully changed chat title to: "${args.title}"`, + type: 'text' as const, + text: `Successfully changed chat title to: "${title}"`, }, ], - isError: false, + isError: false as const, }; } else { return { content: [ { - type: 'text', + type: 'text' as const, text: `Failed to change chat title: ${response.error || 'Unknown error'}`, }, ], - isError: true, + isError: true as const, }; } }); diff --git a/cli/src/persistence.daemonState.test.ts b/cli/src/persistence.daemonState.test.ts new file mode 100644 index 000000000..ec4ed01dd --- /dev/null +++ b/cli/src/persistence.daemonState.test.ts @@ -0,0 +1,45 @@ +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +describe('readDaemonState', () => { + const previousHomeDir = process.env.HAPPY_HOME_DIR; + + afterEach(() => { + process.env.HAPPY_HOME_DIR = previousHomeDir; + }); + + it('retries when the daemon state file appears shortly after the call starts', async () => { + const homeDir = mkdtempSync(join(tmpdir(), 'happy-cli-daemon-state-')); + + vi.resetModules(); + process.env.HAPPY_HOME_DIR = homeDir; + + const [{ configuration }, { readDaemonState }] = await Promise.all([ + import('./configuration'), + import('./persistence'), + ]); + + setTimeout(() => { + writeFileSync( + configuration.daemonStateFile, + JSON.stringify( + { + pid: 123, + httpPort: 5173, + startTime: new Date().toISOString(), + startedWithCliVersion: '0.0.0-test', + }, + null, + 2 + ), + 'utf-8' + ); + }, 5); + + const state = await readDaemonState(); + expect(state?.pid).toBe(123); + }); +}); + diff --git a/cli/src/persistence.ts b/cli/src/persistence.ts index 0270aaf5a..da09f42c4 100644 --- a/cli/src/persistence.ts +++ b/cli/src/persistence.ts @@ -6,193 +6,17 @@ import { FileHandle } from 'node:fs/promises' import { readFile, writeFile, mkdir, open, unlink, rename, stat } from 'node:fs/promises' -import { existsSync, writeFileSync, readFileSync, unlinkSync } from 'node:fs' +import { existsSync, mkdirSync, writeFileSync, readFileSync, unlinkSync, renameSync } from 'node:fs' import { constants } from 'node:fs' +import { dirname } from 'node:path' import { configuration } from '@/configuration' import * as z from 'zod'; import { encodeBase64 } from '@/api/encryption'; import { logger } from '@/ui/logger'; -// AI backend profile schema - MUST match happy app exactly -// Using same Zod schema as GUI for runtime validation consistency - -// Environment variable schemas for different AI providers (matching GUI exactly) -const AnthropicConfigSchema = z.object({ - baseUrl: z.string().url().optional(), - authToken: z.string().optional(), - model: z.string().optional(), -}); - -const OpenAIConfigSchema = z.object({ - apiKey: z.string().optional(), - baseUrl: z.string().url().optional(), - model: z.string().optional(), -}); - -const AzureOpenAIConfigSchema = z.object({ - apiKey: z.string().optional(), - endpoint: z.string().url().optional(), - apiVersion: z.string().optional(), - deploymentName: z.string().optional(), -}); - -const TogetherAIConfigSchema = z.object({ - apiKey: z.string().optional(), - model: z.string().optional(), -}); - -// Tmux configuration schema (matching GUI exactly) -const TmuxConfigSchema = z.object({ - sessionName: z.string().optional(), - tmpDir: z.string().optional(), - updateEnvironment: z.boolean().optional(), -}); - -// Environment variables schema with validation (matching GUI exactly) -const EnvironmentVariableSchema = z.object({ - name: z.string().regex(/^[A-Z_][A-Z0-9_]*$/, 'Invalid environment variable name'), - value: z.string(), -}); - -// Profile compatibility schema (matching GUI exactly) -const ProfileCompatibilitySchema = z.object({ - claude: z.boolean().default(true), - codex: z.boolean().default(true), - gemini: z.boolean().default(true), -}); - -// AIBackendProfile schema - EXACT MATCH with GUI schema -export const AIBackendProfileSchema = z.object({ - id: z.string().uuid(), - name: z.string().min(1).max(100), - description: z.string().max(500).optional(), - - // Agent-specific configurations - anthropicConfig: AnthropicConfigSchema.optional(), - openaiConfig: OpenAIConfigSchema.optional(), - azureOpenAIConfig: AzureOpenAIConfigSchema.optional(), - togetherAIConfig: TogetherAIConfigSchema.optional(), - - // Tmux configuration - tmuxConfig: TmuxConfigSchema.optional(), - - // Environment variables (validated) - environmentVariables: z.array(EnvironmentVariableSchema).default([]), - - // Default session type for this profile - defaultSessionType: z.enum(['simple', 'worktree']).optional(), - - // Default permission mode for this profile (supports both Claude and Codex modes) - defaultPermissionMode: z.enum([ - 'default', 'acceptEdits', 'bypassPermissions', 'plan', // Claude modes - 'read-only', 'safe-yolo', 'yolo' // Codex modes - ]).optional(), - - // Default model mode for this profile - defaultModelMode: z.string().optional(), - - // Compatibility metadata - compatibility: ProfileCompatibilitySchema.default({ claude: true, codex: true, gemini: true }), - - // Built-in profile indicator - isBuiltIn: z.boolean().default(false), - - // Metadata - createdAt: z.number().default(() => Date.now()), - updatedAt: z.number().default(() => Date.now()), - version: z.string().default('1.0.0'), -}); - -export type AIBackendProfile = z.infer<typeof AIBackendProfileSchema>; - -// Helper functions matching the happy app exactly -export function validateProfileForAgent(profile: AIBackendProfile, agent: 'claude' | 'codex' | 'gemini'): boolean { - return profile.compatibility[agent]; -} - -export function getProfileEnvironmentVariables(profile: AIBackendProfile): Record<string, string> { - const envVars: Record<string, string> = {}; - - // Add validated environment variables - profile.environmentVariables.forEach(envVar => { - envVars[envVar.name] = envVar.value; - }); - - // Add Anthropic config - if (profile.anthropicConfig) { - if (profile.anthropicConfig.baseUrl) envVars.ANTHROPIC_BASE_URL = profile.anthropicConfig.baseUrl; - if (profile.anthropicConfig.authToken) envVars.ANTHROPIC_AUTH_TOKEN = profile.anthropicConfig.authToken; - if (profile.anthropicConfig.model) envVars.ANTHROPIC_MODEL = profile.anthropicConfig.model; - } - - // Add OpenAI config - if (profile.openaiConfig) { - if (profile.openaiConfig.apiKey) envVars.OPENAI_API_KEY = profile.openaiConfig.apiKey; - if (profile.openaiConfig.baseUrl) envVars.OPENAI_BASE_URL = profile.openaiConfig.baseUrl; - if (profile.openaiConfig.model) envVars.OPENAI_MODEL = profile.openaiConfig.model; - } - - // Add Azure OpenAI config - if (profile.azureOpenAIConfig) { - if (profile.azureOpenAIConfig.apiKey) envVars.AZURE_OPENAI_API_KEY = profile.azureOpenAIConfig.apiKey; - if (profile.azureOpenAIConfig.endpoint) envVars.AZURE_OPENAI_ENDPOINT = profile.azureOpenAIConfig.endpoint; - if (profile.azureOpenAIConfig.apiVersion) envVars.AZURE_OPENAI_API_VERSION = profile.azureOpenAIConfig.apiVersion; - if (profile.azureOpenAIConfig.deploymentName) envVars.AZURE_OPENAI_DEPLOYMENT_NAME = profile.azureOpenAIConfig.deploymentName; - } - - // Add Together AI config - if (profile.togetherAIConfig) { - if (profile.togetherAIConfig.apiKey) envVars.TOGETHER_API_KEY = profile.togetherAIConfig.apiKey; - if (profile.togetherAIConfig.model) envVars.TOGETHER_MODEL = profile.togetherAIConfig.model; - } - - // Add Tmux config - if (profile.tmuxConfig) { - // Empty string means "use current/most recent session", so include it - if (profile.tmuxConfig.sessionName !== undefined) envVars.TMUX_SESSION_NAME = profile.tmuxConfig.sessionName; - if (profile.tmuxConfig.tmpDir) envVars.TMUX_TMPDIR = profile.tmuxConfig.tmpDir; - if (profile.tmuxConfig.updateEnvironment !== undefined) { - envVars.TMUX_UPDATE_ENVIRONMENT = profile.tmuxConfig.updateEnvironment.toString(); - } - } - - return envVars; -} - -// Profile validation function using Zod schema -export function validateProfile(profile: unknown): AIBackendProfile { - const result = AIBackendProfileSchema.safeParse(profile); - if (!result.success) { - throw new Error(`Invalid profile data: ${result.error.message}`); - } - return result.data; -} - - -// Profile versioning system -// Profile version: Semver string for individual profile data compatibility (e.g., "1.0.0") -// Used to version the AIBackendProfile schema itself (anthropicConfig, tmuxConfig, etc.) -export const CURRENT_PROFILE_VERSION = '1.0.0'; - -// Settings schema version: Integer for overall Settings structure compatibility -// Incremented when Settings structure changes (e.g., adding profiles array was v1→v2) -// Used for migration logic in readSettings() -export const SUPPORTED_SCHEMA_VERSION = 2; - -// Profile version validation -export function validateProfileVersion(profile: AIBackendProfile): boolean { - // Simple semver validation for now - const semverRegex = /^\d+\.\d+\.\d+$/; - return semverRegex.test(profile.version || ''); -} - -// Profile compatibility check for version upgrades -export function isProfileVersionCompatible(profileVersion: string, requiredVersion: string = CURRENT_PROFILE_VERSION): boolean { - // For now, all 1.x.x versions are compatible - const [major] = profileVersion.split('.'); - const [requiredMajor] = requiredVersion.split('.'); - return major === requiredMajor; -} +// Settings schema version: Integer for overall Settings structure compatibility. +// Incremented when Settings structure changes. +export const SUPPORTED_SCHEMA_VERSION = 4; interface Settings { // Schema version for backwards compatibility @@ -203,18 +27,11 @@ interface Settings { machineId?: string machineIdConfirmedByServer?: boolean daemonAutoStartWhenRunningHappy?: boolean - // Profile management settings (synced with happy app) - activeProfileId?: string - profiles: AIBackendProfile[] - // CLI-local environment variable cache (not synced) - localEnvironmentVariables: Record<string, Record<string, string>> // profileId -> env vars } const defaultSettings: Settings = { schemaVersion: SUPPORTED_SCHEMA_VERSION, onboardingCompleted: false, - profiles: [], - localEnvironmentVariables: {} } /** @@ -224,22 +41,23 @@ const defaultSettings: Settings = { function migrateSettings(raw: any, fromVersion: number): any { let migrated = { ...raw }; - // Migration from v1 to v2 (added profile support) - if (fromVersion < 2) { - // Ensure profiles array exists - if (!migrated.profiles) { - migrated.profiles = []; + // Migration from v2 to v3 (removed CLI-local env cache) + if (fromVersion < 3) { + if ('localEnvironmentVariables' in migrated) { + delete migrated.localEnvironmentVariables; } - // Ensure localEnvironmentVariables exists - if (!migrated.localEnvironmentVariables) { - migrated.localEnvironmentVariables = {}; - } - // Update schema version - migrated.schemaVersion = 2; + migrated.schemaVersion = 3; + } + + // Migration from v3 to v4 (removed CLI-local profile persistence) + if (fromVersion < 4) { + if ('profiles' in migrated) delete migrated.profiles; + if ('activeProfileId' in migrated) delete migrated.activeProfileId; + migrated.schemaVersion = 4; } // Future migrations go here: - // if (fromVersion < 3) { ... } + // if (fromVersion < 4) { ... } return migrated; } @@ -257,6 +75,15 @@ export interface DaemonLocallyPersistedState { daemonLogPath?: string; } +export const DaemonLocallyPersistedStateSchema = z.object({ + pid: z.number().int().positive(), + httpPort: z.number().int().positive(), + startTime: z.string(), + startedWithCliVersion: z.string(), + lastHeartbeat: z.string().optional(), + daemonLogPath: z.string().optional(), +}); + export async function readSettings(): Promise<Settings> { if (!existsSync(configuration.settingsFile)) { return { ...defaultSettings } @@ -281,24 +108,6 @@ export async function readSettings(): Promise<Settings> { // Migrate if needed const migrated = migrateSettings(raw, schemaVersion); - // Validate and clean profiles gracefully (don't crash on invalid profiles) - if (migrated.profiles && Array.isArray(migrated.profiles)) { - const validProfiles: AIBackendProfile[] = []; - for (const profile of migrated.profiles) { - try { - const validated = AIBackendProfileSchema.parse(profile); - validProfiles.push(validated); - } catch (error: any) { - logger.warn( - `⚠️ Invalid profile "${profile?.name || profile?.id || 'unknown'}" - skipping. ` + - `Error: ${error.message}` - ); - // Continue processing other profiles - } - } - migrated.profiles = validProfiles; - } - // Merge with defaults to ensure all required fields exist return { ...defaultSettings, ...migrated }; } catch (error: any) { @@ -483,24 +292,77 @@ export async function clearMachineId(): Promise<void> { * Read daemon state from local file */ export async function readDaemonState(): Promise<DaemonLocallyPersistedState | null> { - try { - if (!existsSync(configuration.daemonStateFile)) { - return null; + for (let attempt = 1; attempt <= 3; attempt++) { + try { + // Note: daemon state is written atomically via rename; retry helps if the reader races with filesystem. + const content = await readFile(configuration.daemonStateFile, 'utf-8'); + const parsed = DaemonLocallyPersistedStateSchema.safeParse(JSON.parse(content)); + if (!parsed.success) { + logger.warn(`[PERSISTENCE] Daemon state file is invalid: ${configuration.daemonStateFile}`, parsed.error); + // File is corrupt/unexpected structure; retry won't help. + return null; + } + return parsed.data; + } catch (error) { + // A SyntaxError from JSON.parse indicates the file is corrupt; retrying won't fix it. + if (error instanceof SyntaxError) { + logger.warn(`[PERSISTENCE] Daemon state file is corrupt and could not be parsed: ${configuration.daemonStateFile}`, error); + return null; + } + const err = error as NodeJS.ErrnoException; + if (err?.code === 'ENOENT') { + if (attempt === 3) return null; + await new Promise((resolve) => setTimeout(resolve, 15)); + continue; + } + if (attempt === 3) { + logger.warn(`[PERSISTENCE] Failed to read daemon state file after 3 attempts: ${configuration.daemonStateFile}`, error); + return null; + } + await new Promise((resolve) => setTimeout(resolve, 15)); } - const content = await readFile(configuration.daemonStateFile, 'utf-8'); - return JSON.parse(content) as DaemonLocallyPersistedState; - } catch (error) { - // State corrupted somehow :( - console.error(`[PERSISTENCE] Daemon state file corrupted: ${configuration.daemonStateFile}`, error); - return null; } + return null; } /** * Write daemon state to local file (synchronously for atomic operation) */ export function writeDaemonState(state: DaemonLocallyPersistedState): void { - writeFileSync(configuration.daemonStateFile, JSON.stringify(state, null, 2), 'utf-8'); + const dir = dirname(configuration.daemonStateFile); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + const tmpPath = `${configuration.daemonStateFile}.tmp`; + try { + writeFileSync(tmpPath, JSON.stringify(state, null, 2), 'utf-8'); + try { + renameSync(tmpPath, configuration.daemonStateFile); + } catch (e) { + const err = e as NodeJS.ErrnoException; + // On Windows, renameSync may fail if destination exists. + if (err?.code === 'EEXIST' || err?.code === 'EPERM') { + try { + unlinkSync(configuration.daemonStateFile); + } catch { + // ignore unlink failure (e.g. ENOENT) + } + renameSync(tmpPath, configuration.daemonStateFile); + } else { + throw e; + } + } + } catch (e) { + // Best-effort cleanup to avoid leaving behind orphan tmp files on failures like disk full. + try { + if (existsSync(tmpPath)) { + unlinkSync(tmpPath); + } + } catch { + // ignore cleanup failure + } + throw e; + } } /** @@ -510,6 +372,10 @@ export async function clearDaemonState(): Promise<void> { if (existsSync(configuration.daemonStateFile)) { await unlink(configuration.daemonStateFile); } + const tmpPath = `${configuration.daemonStateFile}.tmp`; + if (existsSync(tmpPath)) { + await unlink(tmpPath).catch(() => {}); + } // Also clean up lock file if it exists (for stale cleanup) if (existsSync(configuration.daemonLockFile)) { try { @@ -582,125 +448,3 @@ export async function releaseDaemonLock(lockHandle: FileHandle): Promise<void> { } } catch { } } - -// -// Profile Management -// - -/** - * Get all profiles from settings - */ -export async function getProfiles(): Promise<AIBackendProfile[]> { - const settings = await readSettings(); - return settings.profiles || []; -} - -/** - * Get a specific profile by ID - */ -export async function getProfile(profileId: string): Promise<AIBackendProfile | null> { - const settings = await readSettings(); - return settings.profiles.find(p => p.id === profileId) || null; -} - -/** - * Get the active profile - */ -export async function getActiveProfile(): Promise<AIBackendProfile | null> { - const settings = await readSettings(); - if (!settings.activeProfileId) return null; - return settings.profiles.find(p => p.id === settings.activeProfileId) || null; -} - -/** - * Set the active profile by ID - */ -export async function setActiveProfile(profileId: string): Promise<void> { - await updateSettings(settings => ({ - ...settings, - activeProfileId: profileId - })); -} - -/** - * Update profiles (synced from happy app) with validation - */ -export async function updateProfiles(profiles: unknown[]): Promise<void> { - // Validate all profiles using Zod schema - const validatedProfiles = profiles.map(profile => validateProfile(profile)); - - await updateSettings(settings => { - // Preserve active profile ID if it still exists - const activeProfileId = settings.activeProfileId; - const activeProfileStillExists = activeProfileId && validatedProfiles.some(p => p.id === activeProfileId); - - return { - ...settings, - profiles: validatedProfiles, - activeProfileId: activeProfileStillExists ? activeProfileId : undefined - }; - }); -} - -/** - * Get environment variables for a profile - * Combines profile custom env vars with CLI-local cached env vars - */ -export async function getEnvironmentVariables(profileId: string): Promise<Record<string, string>> { - const settings = await readSettings(); - const profile = settings.profiles.find(p => p.id === profileId); - if (!profile) return {}; - - // Start with profile's environment variables (new schema) - const envVars: Record<string, string> = {}; - if (profile.environmentVariables) { - profile.environmentVariables.forEach(envVar => { - envVars[envVar.name] = envVar.value; - }); - } - - // Override with CLI-local cached environment variables - const localEnvVars = settings.localEnvironmentVariables[profileId] || {}; - Object.assign(envVars, localEnvVars); - - return envVars; -} - -/** - * Set environment variables for a profile in CLI-local cache - */ -export async function setEnvironmentVariables(profileId: string, envVars: Record<string, string>): Promise<void> { - await updateSettings(settings => ({ - ...settings, - localEnvironmentVariables: { - ...settings.localEnvironmentVariables, - [profileId]: envVars - } - })); -} - -/** - * Get a specific environment variable for a profile - * Checks CLI-local cache first, then profile environment variables - */ -export async function getEnvironmentVariable(profileId: string, key: string): Promise<string | undefined> { - const settings = await readSettings(); - - // Check CLI-local cache first - const localEnvVars = settings.localEnvironmentVariables[profileId] || {}; - if (localEnvVars[key] !== undefined) { - return localEnvVars[key]; - } - - // Fall back to profile environment variables (new schema) - const profile = settings.profiles.find(p => p.id === profileId); - if (profile?.environmentVariables) { - const envVar = profile.environmentVariables.find(env => env.name === key); - if (envVar) { - return envVar.value; - } - } - - return undefined; -} - diff --git a/cli/src/rpc/handlers/bash.ts b/cli/src/rpc/handlers/bash.ts new file mode 100644 index 000000000..cc3f0f107 --- /dev/null +++ b/cli/src/rpc/handlers/bash.ts @@ -0,0 +1,108 @@ +import { logger } from '@/ui/logger'; +import { exec, ExecOptions } from 'child_process'; +import { promisify } from 'util'; +import { RpcHandlerManager } from '@/api/rpc/RpcHandlerManager'; +import { validatePath } from './pathSecurity'; +import { RPC_METHODS } from '@happy/protocol/rpc'; + +const execAsync = promisify(exec); + +interface BashRequest { + command: string; + cwd?: string; + timeout?: number; // timeout in milliseconds +} + +interface BashResponse { + success: boolean; + stdout?: string; + stderr?: string; + exitCode?: number; + error?: string; +} + +export function registerBashHandler(rpcHandlerManager: RpcHandlerManager, workingDirectory: string): void { + // Shell command handler - executes commands in the default shell + rpcHandlerManager.registerHandler<BashRequest, BashResponse>(RPC_METHODS.BASH, async (data) => { + logger.debug('Shell command request:', data.command); + + // Validate cwd if provided + // Special case: "/" means "use shell's default cwd" (used by CLI detection) + // Security: Still validate all other paths to prevent directory traversal + if (data.cwd && data.cwd !== '/') { + const validation = validatePath(data.cwd, workingDirectory); + if (!validation.valid) { + return { success: false, error: validation.error }; + } + } + + try { + // Build options with shell enabled by default + // Note: ExecOptions doesn't support boolean for shell, but exec() uses the default shell when shell is undefined + // If cwd is "/", use undefined to let shell use its default (respects user's PATH) + const options: ExecOptions = { + cwd: data.cwd === '/' ? undefined : data.cwd, + timeout: data.timeout || 30000, // Default 30 seconds timeout + }; + + logger.debug('Shell command executing...', { cwd: options.cwd, timeout: options.timeout }); + const { stdout, stderr } = await execAsync(data.command, options); + logger.debug('Shell command executed, processing result...'); + + const result = { + success: true, + stdout: stdout ? stdout.toString() : '', + stderr: stderr ? stderr.toString() : '', + exitCode: 0 + }; + logger.debug('Shell command result:', { + success: true, + exitCode: 0, + stdoutLen: result.stdout.length, + stderrLen: result.stderr.length + }); + return result; + } catch (error) { + const execError = error as NodeJS.ErrnoException & { + stdout?: string; + stderr?: string; + code?: number | string; + killed?: boolean; + }; + + // Check if the error was due to timeout + if (execError.code === 'ETIMEDOUT' || execError.killed) { + const result = { + success: false, + stdout: execError.stdout || '', + stderr: execError.stderr || '', + exitCode: typeof execError.code === 'number' ? execError.code : -1, + error: 'Command timed out' + }; + logger.debug('Shell command timed out:', { + success: false, + exitCode: result.exitCode, + error: 'Command timed out' + }); + return result; + } + + // If exec fails, it includes stdout/stderr in the error + const result = { + success: false, + stdout: execError.stdout ? execError.stdout.toString() : '', + stderr: execError.stderr ? execError.stderr.toString() : execError.message || 'Command failed', + exitCode: typeof execError.code === 'number' ? execError.code : 1, + error: execError.message || 'Command failed' + }; + logger.debug('Shell command failed:', { + success: false, + exitCode: result.exitCode, + error: result.error, + stdoutLen: result.stdout.length, + stderrLen: result.stderr.length + }); + return result; + } + }); +} diff --git a/cli/src/rpc/handlers/capabilities.ts b/cli/src/rpc/handlers/capabilities.ts new file mode 100644 index 000000000..00300b3ca --- /dev/null +++ b/cli/src/rpc/handlers/capabilities.ts @@ -0,0 +1,80 @@ +import type { RpcHandlerManager } from '@/api/rpc/RpcHandlerManager'; +import { AGENTS, type AgentCatalogEntry } from '@/backends/catalog'; +import { checklists } from '@/capabilities/checklists'; +import { buildDetectContext } from '@/capabilities/context/buildDetectContext'; +import { buildCliCapabilityData } from '@/capabilities/probes/cliBase'; +import { tmuxCapability } from '@/capabilities/registry/toolTmux'; +import { createCapabilitiesService } from '@/capabilities/service'; +import type { Capability } from '@/capabilities/service'; +import type { + CapabilitiesDescribeResponse, + CapabilitiesDetectRequest, + CapabilitiesDetectResponse, + CapabilitiesInvokeRequest, + CapabilitiesInvokeResponse, +} from '@/capabilities/types'; +import { RPC_METHODS } from '@happy/protocol/rpc'; + +function titleCase(value: string): string { + if (!value) return value; + return `${value[0].toUpperCase()}${value.slice(1)}`; +} + +function createGenericCliCapability(agentId: AgentCatalogEntry['id']): Capability { + return { + descriptor: { id: `cli.${agentId}`, kind: 'cli', title: `${titleCase(agentId)} CLI` }, + detect: async ({ request, context }) => { + const entry = context.cliSnapshot?.clis?.[agentId]; + return buildCliCapabilityData({ request, entry }); + }, + }; +} + +export function registerCapabilitiesHandlers(rpcHandlerManager: RpcHandlerManager): void { + let servicePromise: Promise<ReturnType<typeof createCapabilitiesService>> | null = null; + + const getService = (): Promise<ReturnType<typeof createCapabilitiesService>> => { + if (servicePromise) return servicePromise; + servicePromise = (async () => { + const cliCapabilities = await Promise.all( + (Object.values(AGENTS) as AgentCatalogEntry[]).map(async (entry) => { + if (entry.getCliCapabilityOverride) { + return await entry.getCliCapabilityOverride(); + } + return createGenericCliCapability(entry.id); + }), + ); + + const extraCapabilitiesNested = await Promise.all( + (Object.values(AGENTS) as AgentCatalogEntry[]).map(async (entry) => { + if (!entry.getCapabilities) return []; + return [...(await entry.getCapabilities())]; + }), + ); + const extraCapabilities: Capability[] = extraCapabilitiesNested.flat(); + + return createCapabilitiesService({ + capabilities: [ + ...cliCapabilities, + ...extraCapabilities, + tmuxCapability, + ], + checklists, + buildContext: buildDetectContext, + }); + })(); + return servicePromise; + }; + + rpcHandlerManager.registerHandler<{}, CapabilitiesDescribeResponse>(RPC_METHODS.CAPABILITIES_DESCRIBE, async () => { + return (await getService()).describe(); + }); + + rpcHandlerManager.registerHandler<CapabilitiesDetectRequest, CapabilitiesDetectResponse>(RPC_METHODS.CAPABILITIES_DETECT, async (data) => { + return await (await getService()).detect(data); + }); + + rpcHandlerManager.registerHandler<CapabilitiesInvokeRequest, CapabilitiesInvokeResponse>(RPC_METHODS.CAPABILITIES_INVOKE, async (data) => { + return await (await getService()).invoke(data); + }); +} diff --git a/cli/src/rpc/handlers/difftastic.ts b/cli/src/rpc/handlers/difftastic.ts new file mode 100644 index 000000000..a2130bac0 --- /dev/null +++ b/cli/src/rpc/handlers/difftastic.ts @@ -0,0 +1,49 @@ +import { logger } from '@/ui/logger'; +import { RpcHandlerManager } from '@/api/rpc/RpcHandlerManager'; +import { run as runDifftastic } from '@/integrations/difftastic/index'; +import { validatePath } from './pathSecurity'; +import { RPC_METHODS } from '@happy/protocol/rpc'; + +interface DifftasticRequest { + args: string[]; + cwd?: string; +} + +interface DifftasticResponse { + success: boolean; + exitCode?: number; + stdout?: string; + stderr?: string; + error?: string; +} + +export function registerDifftasticHandler(rpcHandlerManager: RpcHandlerManager, workingDirectory: string): void { + // Difftastic handler - raw interface to difftastic + rpcHandlerManager.registerHandler<DifftasticRequest, DifftasticResponse>(RPC_METHODS.DIFFTASTIC, async (data) => { + logger.debug('Difftastic request with args:', data.args, 'cwd:', data.cwd); + + // Validate cwd if provided + if (data.cwd) { + const validation = validatePath(data.cwd, workingDirectory); + if (!validation.valid) { + return { success: false, error: validation.error }; + } + } + + try { + const result = await runDifftastic(data.args, { cwd: data.cwd }); + return { + success: true, + exitCode: result.exitCode, + stdout: result.stdout.toString(), + stderr: result.stderr.toString() + }; + } catch (error) { + logger.debug('Failed to run difftastic:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to run difftastic' + }; + } + }); +} diff --git a/cli/src/modules/common/registerCommonHandlers.ts b/cli/src/rpc/handlers/fileSystem.ts similarity index 56% rename from cli/src/modules/common/registerCommonHandlers.ts rename to cli/src/rpc/handlers/fileSystem.ts index bd4e07a5e..bf52a84fd 100644 --- a/cli/src/modules/common/registerCommonHandlers.ts +++ b/cli/src/rpc/handlers/fileSystem.ts @@ -1,29 +1,10 @@ import { logger } from '@/ui/logger'; -import { exec, ExecOptions } from 'child_process'; -import { promisify } from 'util'; import { readFile, writeFile, readdir, stat } from 'fs/promises'; import { createHash } from 'crypto'; import { join } from 'path'; -import { run as runRipgrep } from '@/modules/ripgrep/index'; -import { run as runDifftastic } from '@/modules/difftastic/index'; -import { RpcHandlerManager } from '../../api/rpc/RpcHandlerManager'; +import { RpcHandlerManager } from '@/api/rpc/RpcHandlerManager'; import { validatePath } from './pathSecurity'; - -const execAsync = promisify(exec); - -interface BashRequest { - command: string; - cwd?: string; - timeout?: number; // timeout in milliseconds -} - -interface BashResponse { - success: boolean; - stdout?: string; - stderr?: string; - exitCode?: number; - error?: string; -} +import { RPC_METHODS } from '@happy/protocol/rpc'; interface ReadFileRequest { path: string; @@ -84,155 +65,9 @@ interface GetDirectoryTreeResponse { error?: string; } -interface RipgrepRequest { - args: string[]; - cwd?: string; -} - -interface RipgrepResponse { - success: boolean; - exitCode?: number; - stdout?: string; - stderr?: string; - error?: string; -} - -interface DifftasticRequest { - args: string[]; - cwd?: string; -} - -interface DifftasticResponse { - success: boolean; - exitCode?: number; - stdout?: string; - stderr?: string; - error?: string; -} - -/* - * Spawn Session Options and Result - * This rpc type is used by the daemon, all other RPCs here are for sessions -*/ - -export interface SpawnSessionOptions { - machineId?: string; - directory: string; - sessionId?: string; - approvedNewDirectoryCreation?: boolean; - agent?: 'claude' | 'codex' | 'gemini'; - token?: string; - environmentVariables?: { - // Anthropic Claude API configuration - ANTHROPIC_BASE_URL?: string; // Custom API endpoint (overrides default) - ANTHROPIC_AUTH_TOKEN?: string; // API authentication token - ANTHROPIC_MODEL?: string; // Model to use (e.g., claude-3-5-sonnet-20241022) - - // Tmux session management environment variables - // Based on tmux(1) manual and common tmux usage patterns - TMUX_SESSION_NAME?: string; // Name for tmux session (creates/attaches to named session) - TMUX_TMPDIR?: string; // Temporary directory for tmux server socket files - // Note: TMUX_TMPDIR is used by tmux to store socket files when default /tmp is not suitable - // Common use case: When /tmp has limited space or different permissions - }; -} - -export type SpawnSessionResult = - | { type: 'success'; sessionId: string } - | { type: 'requestToApproveDirectoryCreation'; directory: string } - | { type: 'error'; errorMessage: string }; - -/** - * Register all RPC handlers with the session - */ -export function registerCommonHandlers(rpcHandlerManager: RpcHandlerManager, workingDirectory: string) { - - // Shell command handler - executes commands in the default shell - rpcHandlerManager.registerHandler<BashRequest, BashResponse>('bash', async (data) => { - logger.debug('Shell command request:', data.command); - - // Validate cwd if provided - // Special case: "/" means "use shell's default cwd" (used by CLI detection) - // Security: Still validate all other paths to prevent directory traversal - if (data.cwd && data.cwd !== '/') { - const validation = validatePath(data.cwd, workingDirectory); - if (!validation.valid) { - return { success: false, error: validation.error }; - } - } - - try { - // Build options with shell enabled by default - // Note: ExecOptions doesn't support boolean for shell, but exec() uses the default shell when shell is undefined - // If cwd is "/", use undefined to let shell use its default (respects user's PATH) - const options: ExecOptions = { - cwd: data.cwd === '/' ? undefined : data.cwd, - timeout: data.timeout || 30000, // Default 30 seconds timeout - }; - - logger.debug('Shell command executing...', { cwd: options.cwd, timeout: options.timeout }); - const { stdout, stderr } = await execAsync(data.command, options); - logger.debug('Shell command executed, processing result...'); - - const result = { - success: true, - stdout: stdout ? stdout.toString() : '', - stderr: stderr ? stderr.toString() : '', - exitCode: 0 - }; - logger.debug('Shell command result:', { - success: true, - exitCode: 0, - stdoutLen: result.stdout.length, - stderrLen: result.stderr.length - }); - return result; - } catch (error) { - const execError = error as NodeJS.ErrnoException & { - stdout?: string; - stderr?: string; - code?: number | string; - killed?: boolean; - }; - - // Check if the error was due to timeout - if (execError.code === 'ETIMEDOUT' || execError.killed) { - const result = { - success: false, - stdout: execError.stdout || '', - stderr: execError.stderr || '', - exitCode: typeof execError.code === 'number' ? execError.code : -1, - error: 'Command timed out' - }; - logger.debug('Shell command timed out:', { - success: false, - exitCode: result.exitCode, - error: 'Command timed out' - }); - return result; - } - - // If exec fails, it includes stdout/stderr in the error - const result = { - success: false, - stdout: execError.stdout ? execError.stdout.toString() : '', - stderr: execError.stderr ? execError.stderr.toString() : execError.message || 'Command failed', - exitCode: typeof execError.code === 'number' ? execError.code : 1, - error: execError.message || 'Command failed' - }; - logger.debug('Shell command failed:', { - success: false, - exitCode: result.exitCode, - error: result.error, - stdoutLen: result.stdout.length, - stderrLen: result.stderr.length - }); - return result; - } - }); - +export function registerFileSystemHandlers(rpcHandlerManager: RpcHandlerManager, workingDirectory: string): void { // Read file handler - returns base64 encoded content - rpcHandlerManager.registerHandler<ReadFileRequest, ReadFileResponse>('readFile', async (data) => { + rpcHandlerManager.registerHandler<ReadFileRequest, ReadFileResponse>(RPC_METHODS.READ_FILE, async (data) => { logger.debug('Read file request:', data.path); // Validate path is within working directory @@ -252,7 +87,7 @@ export function registerCommonHandlers(rpcHandlerManager: RpcHandlerManager, wor }); // Write file handler - with hash verification - rpcHandlerManager.registerHandler<WriteFileRequest, WriteFileResponse>('writeFile', async (data) => { + rpcHandlerManager.registerHandler<WriteFileRequest, WriteFileResponse>(RPC_METHODS.WRITE_FILE, async (data) => { logger.debug('Write file request:', data.path); // Validate path is within working directory @@ -318,7 +153,7 @@ export function registerCommonHandlers(rpcHandlerManager: RpcHandlerManager, wor }); // List directory handler - rpcHandlerManager.registerHandler<ListDirectoryRequest, ListDirectoryResponse>('listDirectory', async (data) => { + rpcHandlerManager.registerHandler<ListDirectoryRequest, ListDirectoryResponse>(RPC_METHODS.LIST_DIRECTORY, async (data) => { logger.debug('List directory request:', data.path); // Validate path is within working directory @@ -376,7 +211,7 @@ export function registerCommonHandlers(rpcHandlerManager: RpcHandlerManager, wor }); // Get directory tree handler - recursive with depth control - rpcHandlerManager.registerHandler<GetDirectoryTreeRequest, GetDirectoryTreeResponse>('getDirectoryTree', async (data) => { + rpcHandlerManager.registerHandler<GetDirectoryTreeRequest, GetDirectoryTreeResponse>(RPC_METHODS.GET_DIRECTORY_TREE, async (data) => { logger.debug('Get directory tree request:', data.path, 'maxDepth:', data.maxDepth); // Validate path is within working directory @@ -461,62 +296,4 @@ export function registerCommonHandlers(rpcHandlerManager: RpcHandlerManager, wor return { success: false, error: error instanceof Error ? error.message : 'Failed to get directory tree' }; } }); - - // Ripgrep handler - raw interface to ripgrep - rpcHandlerManager.registerHandler<RipgrepRequest, RipgrepResponse>('ripgrep', async (data) => { - logger.debug('Ripgrep request with args:', data.args, 'cwd:', data.cwd); - - // Validate cwd if provided - if (data.cwd) { - const validation = validatePath(data.cwd, workingDirectory); - if (!validation.valid) { - return { success: false, error: validation.error }; - } - } - - try { - const result = await runRipgrep(data.args, { cwd: data.cwd }); - return { - success: true, - exitCode: result.exitCode, - stdout: result.stdout.toString(), - stderr: result.stderr.toString() - }; - } catch (error) { - logger.debug('Failed to run ripgrep:', error); - return { - success: false, - error: error instanceof Error ? error.message : 'Failed to run ripgrep' - }; - } - }); - - // Difftastic handler - raw interface to difftastic - rpcHandlerManager.registerHandler<DifftasticRequest, DifftasticResponse>('difftastic', async (data) => { - logger.debug('Difftastic request with args:', data.args, 'cwd:', data.cwd); - - // Validate cwd if provided - if (data.cwd) { - const validation = validatePath(data.cwd, workingDirectory); - if (!validation.valid) { - return { success: false, error: validation.error }; - } - } - - try { - const result = await runDifftastic(data.args, { cwd: data.cwd }); - return { - success: true, - exitCode: result.exitCode, - stdout: result.stdout.toString(), - stderr: result.stderr.toString() - }; - } catch (error) { - logger.debug('Failed to run difftastic:', error); - return { - success: false, - error: error instanceof Error ? error.message : 'Failed to run difftastic' - }; - } - }); -} \ No newline at end of file +} diff --git a/cli/src/claude/registerKillSessionHandler.ts b/cli/src/rpc/handlers/killSession.ts similarity index 88% rename from cli/src/claude/registerKillSessionHandler.ts rename to cli/src/rpc/handlers/killSession.ts index e62ba7a5e..d4f24a5b8 100644 --- a/cli/src/claude/registerKillSessionHandler.ts +++ b/cli/src/rpc/handlers/killSession.ts @@ -1,5 +1,6 @@ import { RpcHandlerManager } from "@/api/rpc/RpcHandlerManager"; import { logger } from "@/lib"; +import { RPC_METHODS } from '@happy/protocol/rpc'; interface KillSessionRequest { // No parameters needed @@ -15,7 +16,7 @@ export function registerKillSessionHandler( rpcHandlerManager: RpcHandlerManager, killThisHappy: () => Promise<void> ) { - rpcHandlerManager.registerHandler<KillSessionRequest, KillSessionResponse>('killSession', async () => { + rpcHandlerManager.registerHandler<KillSessionRequest, KillSessionResponse>(RPC_METHODS.KILL_SESSION, async () => { logger.debug('Kill session request received'); // This will start the cleanup process diff --git a/cli/src/modules/common/pathSecurity.test.ts b/cli/src/rpc/handlers/pathSecurity.test.ts similarity index 100% rename from cli/src/modules/common/pathSecurity.test.ts rename to cli/src/rpc/handlers/pathSecurity.test.ts diff --git a/cli/src/modules/common/pathSecurity.ts b/cli/src/rpc/handlers/pathSecurity.ts similarity index 100% rename from cli/src/modules/common/pathSecurity.ts rename to cli/src/rpc/handlers/pathSecurity.ts diff --git a/cli/src/rpc/handlers/previewEnv.ts b/cli/src/rpc/handlers/previewEnv.ts new file mode 100644 index 000000000..5046d06ce --- /dev/null +++ b/cli/src/rpc/handlers/previewEnv.ts @@ -0,0 +1,191 @@ +import type { RpcHandlerManager } from '@/api/rpc/RpcHandlerManager'; +import { expandEnvironmentVariables } from '@/utils/expandEnvVars'; +import { isValidEnvVarKey, sanitizeEnvVarRecord } from '@/terminal/envVarSanitization'; +import { RPC_METHODS } from '@happy/protocol/rpc'; + +type EnvPreviewSecretsPolicy = 'none' | 'redacted' | 'full'; + +interface PreviewEnvRequest { + keys: string[]; + extraEnv?: Record<string, string>; + /** + * Keys that should be treated as sensitive at minimum (UI/user/docs provided). + * The daemon may still treat additional keys as sensitive via its own heuristics. + */ + sensitiveKeys?: string[]; +} + +type PreviewEnvSensitivitySource = 'forced' | 'hinted' | 'none'; + +interface PreviewEnvValue { + value: string | null; + isSet: boolean; + isSensitive: boolean; + /** + * True when sensitivity is enforced by daemon heuristics (not overridable by UI). + */ + isForcedSensitive: boolean; + sensitivitySource: PreviewEnvSensitivitySource; + display: 'full' | 'redacted' | 'hidden' | 'unset'; +} + +interface PreviewEnvResponse { + policy: EnvPreviewSecretsPolicy; + values: Record<string, PreviewEnvValue>; +} + +function normalizeSecretsPolicy(raw: unknown): EnvPreviewSecretsPolicy { + if (typeof raw !== 'string') return 'none'; + const normalized = raw.trim().toLowerCase(); + if (normalized === 'none' || normalized === 'redacted' || normalized === 'full') return normalized; + return 'none'; +} + +function clampInt(value: number, min: number, max: number): number { + if (!Number.isFinite(value)) return min; + return Math.max(min, Math.min(max, Math.trunc(value))); +} + +function redactSecret(value: string): string { + const len = value.length; + if (len <= 0) return ''; + if (len <= 2) return '*'.repeat(len); + + // Hybrid: percentage with min/max caps (credit-card style). + const ratio = 0.2; + const startRaw = Math.ceil(len * ratio); + const endRaw = Math.ceil(len * ratio); + + let start = clampInt(startRaw, 1, 6); + let end = clampInt(endRaw, 1, 6); + + // Ensure we always have at least 1 masked character (when possible). + if (start + end >= len) { + // Keep start/end small enough to leave room for masking. + // Prefer preserving start, then reduce end. + end = Math.max(0, len - start - 1); + if (end < 1) { + start = Math.max(0, len - 2); + end = Math.max(0, len - start - 1); + } + } + + const maskedLen = Math.max(0, len - start - end); + const prefix = value.slice(0, start); + const suffix = end > 0 ? value.slice(len - end) : ''; + return `${prefix}${'*'.repeat(maskedLen)}${suffix}`; +} + +export function registerPreviewEnvHandler(rpcHandlerManager: RpcHandlerManager): void { + // Environment preview handler - returns daemon-effective env values with secret policy applied. + // + // This is the recommended way for the UI to preview what a spawned session will receive: + // - Uses daemon process.env as the base + // - Optionally applies profile-provided extraEnv with the same ${VAR} expansion semantics used for spawns + // - Applies daemon-controlled secret visibility policy (HAPPY_ENV_PREVIEW_SECRETS) + rpcHandlerManager.registerHandler<PreviewEnvRequest, PreviewEnvResponse>(RPC_METHODS.PREVIEW_ENV, async (data) => { + const keys = Array.isArray(data?.keys) ? data.keys : []; + const maxKeys = 200; + const trimmedKeys = keys.slice(0, maxKeys); + for (const key of trimmedKeys) { + if (typeof key !== 'string' || !isValidEnvVarKey(key)) { + throw new Error(`Invalid env var key: "${String(key)}"`); + } + } + + const policy = normalizeSecretsPolicy(process.env.HAPPY_ENV_PREVIEW_SECRETS); + const sensitiveKeys = Array.isArray(data?.sensitiveKeys) + ? data.sensitiveKeys.filter((k): k is string => typeof k === 'string' && isValidEnvVarKey(k)) + : []; + const sensitiveKeySet = new Set(sensitiveKeys); + + const extraEnv = sanitizeEnvVarRecord(data?.extraEnv); + + const expandedExtraEnv = Object.keys(extraEnv).length > 0 + ? expandEnvironmentVariables(extraEnv, process.env, { warnOnUndefined: false }) + : {}; + const effectiveEnv: NodeJS.ProcessEnv = { ...process.env, ...expandedExtraEnv }; + + const defaultSecretNameRegex = /TOKEN|KEY|SECRET|AUTH|PASS|PASSWORD|COOKIE/i; + const overrideRegexRaw = process.env.HAPPY_ENV_PREVIEW_SECRET_NAME_REGEX; + const secretNameRegex = (() => { + if (typeof overrideRegexRaw !== 'string') return defaultSecretNameRegex; + const trimmed = overrideRegexRaw.trim(); + if (!trimmed) return defaultSecretNameRegex; + try { + return new RegExp(trimmed, 'i'); + } catch { + return defaultSecretNameRegex; + } + })(); + + const values: Record<string, PreviewEnvValue> = {}; + for (const key of trimmedKeys) { + const rawValue = effectiveEnv[key]; + const isSet = typeof rawValue === 'string'; + const isForcedSensitive = secretNameRegex.test(key); + const hintedSensitive = sensitiveKeySet.has(key); + const isSensitive = isForcedSensitive || hintedSensitive; + const sensitivitySource: PreviewEnvSensitivitySource = isForcedSensitive + ? 'forced' + : hintedSensitive + ? 'hinted' + : 'none'; + + if (!isSet) { + values[key] = { + value: null, + isSet: false, + isSensitive, + isForcedSensitive, + sensitivitySource, + display: 'unset', + }; + continue; + } + + if (!isSensitive) { + values[key] = { + value: rawValue, + isSet: true, + isSensitive: false, + isForcedSensitive: false, + sensitivitySource: 'none', + display: 'full', + }; + continue; + } + + if (policy === 'none') { + values[key] = { + value: null, + isSet: true, + isSensitive: true, + isForcedSensitive, + sensitivitySource, + display: 'hidden', + }; + } else if (policy === 'redacted') { + values[key] = { + value: redactSecret(rawValue), + isSet: true, + isSensitive: true, + isForcedSensitive, + sensitivitySource, + display: 'redacted', + }; + } else { + values[key] = { + value: rawValue, + isSet: true, + isSensitive: true, + isForcedSensitive, + sensitivitySource, + display: 'full', + }; + } + } + + return { policy, values }; + }); +} diff --git a/cli/src/rpc/handlers/registerSessionHandlers.capabilities.test.ts b/cli/src/rpc/handlers/registerSessionHandlers.capabilities.test.ts new file mode 100644 index 000000000..42abc656f --- /dev/null +++ b/cli/src/rpc/handlers/registerSessionHandlers.capabilities.test.ts @@ -0,0 +1,230 @@ +/** + * Tests for the checklist-based capabilities RPCs: + * - capabilities.describe + * - capabilities.detect + * + * These replace legacy detect-cli / detect-capabilities / dep-status. + */ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { RpcHandlerManager } from '@/api/rpc/RpcHandlerManager'; +import type { RpcRequest } from '@/api/rpc/types'; +import { decodeBase64, decrypt, encodeBase64, encrypt } from '@/api/encryption'; +import { registerSessionHandlers } from './registerSessionHandlers'; +import { chmod, mkdtemp, rm, writeFile } from 'fs/promises'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { RPC_METHODS } from '@happy/protocol/rpc'; +import { CHECKLIST_IDS, resumeChecklistId } from '@happy/protocol/checklists'; + +function createTestRpcManager(params?: { scopePrefix?: string }) { + const encryptionKey = new Uint8Array(32).fill(7); + const encryptionVariant = 'legacy' as const; + const scopePrefix = params?.scopePrefix ?? 'machine-test'; + + const manager = new RpcHandlerManager({ + scopePrefix, + encryptionKey, + encryptionVariant, + logger: () => undefined, + }); + + registerSessionHandlers(manager, process.cwd()); + + async function call<TResponse, TRequest>(method: string, request: TRequest): Promise<TResponse> { + const encryptedParams = encodeBase64(encrypt(encryptionKey, encryptionVariant, request)); + const rpcRequest: RpcRequest = { + method: `${scopePrefix}:${method}`, + params: encryptedParams, + }; + const encryptedResponse = await manager.handleRequest(rpcRequest); + const decrypted = decrypt(encryptionKey, encryptionVariant, decodeBase64(encryptedResponse)); + return decrypted as TResponse; + } + + return { call }; +} + +describe('registerCommonHandlers capabilities', () => { + const originalPath = process.env.PATH; + const originalPathext = process.env.PATHEXT; + + beforeEach(() => { + if (originalPath === undefined) delete process.env.PATH; + else process.env.PATH = originalPath; + + if (originalPathext === undefined) delete process.env.PATHEXT; + else process.env.PATHEXT = originalPathext; + }); + + afterEach(() => { + if (originalPath === undefined) delete process.env.PATH; + else process.env.PATH = originalPath; + + if (originalPathext === undefined) delete process.env.PATHEXT; + else process.env.PATHEXT = originalPathext; + }); + + it('describes supported capabilities and checklists', async () => { + const { call } = createTestRpcManager(); + const result = await call<{ + protocolVersion: 1; + capabilities: Array<{ id: string; kind: string }>; + checklists: Record<string, Array<{ id: string; params?: any }>>; + }, {}>(RPC_METHODS.CAPABILITIES_DESCRIBE, {}); + + expect(result.protocolVersion).toBe(1); + expect(result.capabilities.map((c) => c.id)).toEqual( + expect.arrayContaining(['cli.codex', 'cli.claude', 'cli.gemini', 'cli.opencode', 'tool.tmux', 'dep.codex-mcp-resume']), + ); + expect(Object.keys(result.checklists)).toEqual( + expect.arrayContaining([ + CHECKLIST_IDS.NEW_SESSION, + CHECKLIST_IDS.MACHINE_DETAILS, + resumeChecklistId('claude'), + resumeChecklistId('codex'), + resumeChecklistId('gemini'), + resumeChecklistId('opencode'), + ]), + ); + expect(result.checklists[resumeChecklistId('codex')].map((r) => r.id)).toEqual( + expect.arrayContaining(['cli.codex', 'dep.codex-mcp-resume']), + ); + }); + + it('detects checklist new-session deterministically from PATH', async () => { + const dir = await mkdtemp(join(tmpdir(), 'happy-cli-capabilities-')); + try { + const isWindows = process.platform === 'win32'; + + const fakeCodex = join(dir, isWindows ? 'codex.cmd' : 'codex'); + const fakeClaude = join(dir, isWindows ? 'claude.cmd' : 'claude'); + const fakeGemini = join(dir, isWindows ? 'gemini.cmd' : 'gemini'); + const fakeOpenCode = join(dir, isWindows ? 'opencode.cmd' : 'opencode'); + const fakeTmux = join(dir, isWindows ? 'tmux.cmd' : 'tmux'); + + await writeFile( + fakeCodex, + isWindows + ? '@echo off\r\nif "%1"=="--version" (echo codex 1.2.3& exit /b 0)\r\necho ok\r\n' + : '#!/bin/sh\nif [ "$1" = "--version" ]; then echo "codex 1.2.3"; exit 0; fi\necho ok\n', + 'utf8', + ); + await writeFile( + fakeClaude, + isWindows + ? '@echo off\r\nif "%1"=="--version" (echo claude 0.1.0& exit /b 0)\r\necho ok\r\n' + : '#!/bin/sh\nif [ "$1" = "--version" ]; then echo "claude 0.1.0"; exit 0; fi\necho ok\n', + 'utf8', + ); + await writeFile( + fakeGemini, + isWindows + ? '@echo off\r\nif "%1"=="--version" (echo gemini 9.9.9& exit /b 0)\r\necho ok\r\n' + : '#!/bin/sh\nif [ "$1" = "--version" ]; then echo "gemini 9.9.9"; exit 0; fi\necho ok\n', + 'utf8', + ); + await writeFile( + fakeOpenCode, + isWindows + ? '@echo off\r\nif "%1"=="--version" (echo opencode 0.1.48& exit /b 0)\r\necho ok\r\n' + : '#!/bin/sh\nif [ "$1" = "--version" ]; then echo "opencode 0.1.48"; exit 0; fi\necho ok\n', + 'utf8', + ); + await writeFile( + fakeTmux, + isWindows + ? '@echo off\r\nif "%1"=="-V" (echo tmux 3.3a& exit /b 0)\r\necho ok\r\n' + : '#!/bin/sh\nif [ "$1" = "-V" ]; then echo "tmux 3.3a"; exit 0; fi\necho ok\n', + 'utf8', + ); + + if (!isWindows) { + await chmod(fakeCodex, 0o755); + await chmod(fakeClaude, 0o755); + await chmod(fakeGemini, 0o755); + await chmod(fakeOpenCode, 0o755); + await chmod(fakeTmux, 0o755); + } else { + process.env.PATHEXT = '.CMD'; + } + + process.env.PATH = `${dir}`; + + const { call } = createTestRpcManager(); + const result = await call<{ + protocolVersion: 1; + results: Record< + string, + { ok: boolean; data?: any; error?: any; checkedAt: number } + >; + }, { checklistId: string }>(RPC_METHODS.CAPABILITIES_DETECT, { checklistId: CHECKLIST_IDS.NEW_SESSION }); + + expect(result.protocolVersion).toBe(1); + expect(result.results['cli.codex'].ok).toBe(true); + expect(result.results['cli.codex'].data.available).toBe(true); + expect(result.results['cli.codex'].data.resolvedPath).toBe(fakeCodex); + expect(result.results['cli.codex'].data.version).toBe('1.2.3'); + + expect(result.results['cli.claude'].ok).toBe(true); + expect(result.results['cli.claude'].data.available).toBe(true); + expect(result.results['cli.claude'].data.version).toBe('0.1.0'); + + expect(result.results['cli.gemini'].ok).toBe(true); + expect(result.results['cli.gemini'].data.available).toBe(true); + expect(result.results['cli.gemini'].data.version).toBe('9.9.9'); + + expect(result.results['cli.opencode'].ok).toBe(true); + expect(result.results['cli.opencode'].data.available).toBe(true); + expect(result.results['cli.opencode'].data.resolvedPath).toBe(fakeOpenCode); + expect(result.results['cli.opencode'].data.version).toBe('0.1.48'); + + expect(result.results['tool.tmux'].ok).toBe(true); + expect(result.results['tool.tmux'].data.available).toBe(true); + expect(result.results['tool.tmux'].data.version).toBe('3.3a'); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + it('supports per-capability params (includeLoginStatus) and skips registry checks when onlyIfInstalled=true and not installed', async () => { + const dir = await mkdtemp(join(tmpdir(), 'happy-cli-capabilities-login-')); + try { + const isWindows = process.platform === 'win32'; + const fakeCodex = join(dir, isWindows ? 'codex.cmd' : 'codex'); + await writeFile( + fakeCodex, + isWindows + ? '@echo off\r\nif \"%1\"==\"login\" if \"%2\"==\"status\" (echo ok& exit /b 0)\r\nif \"%1\"==\"--version\" (echo codex 1.2.3& exit /b 0)\r\necho nope& exit /b 1\r\n' + : '#!/bin/sh\nif [ \"$1\" = \"login\" ] && [ \"$2\" = \"status\" ]; then echo ok; exit 0; fi\nif [ \"$1\" = \"--version\" ]; then echo \"codex 1.2.3\"; exit 0; fi\necho nope; exit 1;\n', + 'utf8', + ); + if (!isWindows) { + await chmod(fakeCodex, 0o755); + } else { + process.env.PATHEXT = '.CMD'; + } + process.env.PATH = `${dir}`; + + const { call } = createTestRpcManager(); + const result = await call<{ + results: Record<string, { ok: boolean; data?: any }>; + }, { + requests: Array<{ id: string; params?: any }>; + }>(RPC_METHODS.CAPABILITIES_DETECT, { + requests: [ + { id: 'cli.codex', params: { includeLoginStatus: true } }, + { id: 'dep.codex-mcp-resume', params: { includeRegistry: true, onlyIfInstalled: true } }, + ], + }); + + expect(result.results['cli.codex'].ok).toBe(true); + expect(result.results['cli.codex'].data.isLoggedIn).toBe(true); + + expect(result.results['dep.codex-mcp-resume'].ok).toBe(true); + expect(result.results['dep.codex-mcp-resume'].data.installed).toBe(false); + expect(result.results['dep.codex-mcp-resume'].data.registry).toBeUndefined(); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/cli/src/rpc/handlers/registerSessionHandlers.previewEnv.test.ts b/cli/src/rpc/handlers/registerSessionHandlers.previewEnv.test.ts new file mode 100644 index 000000000..a2ba3df91 --- /dev/null +++ b/cli/src/rpc/handlers/registerSessionHandlers.previewEnv.test.ts @@ -0,0 +1,216 @@ +/** + * Tests for the `preview-env` RPC handler. + * + * Ensures the daemon can safely preview effective environment variable values + * (including ${VAR} expansion) without exposing secrets by default. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { RpcHandlerManager } from '@/api/rpc/RpcHandlerManager'; +import type { RpcRequest } from '@/api/rpc/types'; +import { decodeBase64, decrypt, encodeBase64, encrypt } from '@/api/encryption'; +import { registerSessionHandlers } from './registerSessionHandlers'; +import { RPC_METHODS } from '@happy/protocol/rpc'; + +function createTestRpcManager(params?: { scopePrefix?: string }) { + const encryptionKey = new Uint8Array(32).fill(7); + const encryptionVariant = 'legacy' as const; + const scopePrefix = params?.scopePrefix ?? 'machine-test'; + + const manager = new RpcHandlerManager({ + scopePrefix, + encryptionKey, + encryptionVariant, + logger: () => undefined, + }); + + registerSessionHandlers(manager, process.cwd()); + + async function call<TResponse, TRequest>(method: string, request: TRequest): Promise<TResponse> { + const encryptedParams = encodeBase64(encrypt(encryptionKey, encryptionVariant, request)); + const rpcRequest: RpcRequest = { + method: `${scopePrefix}:${method}`, + params: encryptedParams, + }; + const encryptedResponse = await manager.handleRequest(rpcRequest); + const decrypted = decrypt(encryptionKey, encryptionVariant, decodeBase64(encryptedResponse)); + return decrypted as TResponse; + } + + return { call }; +} + +describe('registerCommonHandlers preview-env', () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + it('returns effective env values with embedded ${VAR} expansion', async () => { + process.env.PATH = '/usr/bin'; + process.env.HAPPY_ENV_PREVIEW_SECRETS = 'none'; + + const { call } = createTestRpcManager(); + + const result = await call<{ policy: string; values: Record<string, { display: string; value: string | null }> }, { + keys: string[]; + extraEnv?: Record<string, string>; + }>(RPC_METHODS.PREVIEW_ENV, { + keys: ['PATH'], + extraEnv: { + PATH: '/opt/bin:${PATH}', + }, + }); + + expect(result.policy).toBe('none'); + expect(result.values.PATH.display).toBe('full'); + expect(result.values.PATH.value).toBe('/opt/bin:/usr/bin'); + }); + + it('accepts lowercase env var keys', async () => { + process.env.npm_config_registry = 'https://example.test'; + process.env.HAPPY_ENV_PREVIEW_SECRETS = 'none'; + + const { call } = createTestRpcManager(); + + const result = await call<{ policy: string; values: Record<string, { display: string; value: string | null }> }, { + keys: string[]; + }>(RPC_METHODS.PREVIEW_ENV, { + keys: ['npm_config_registry'], + }); + + expect(result.policy).toBe('none'); + expect(result.values.npm_config_registry.display).toBe('full'); + expect(result.values.npm_config_registry.value).toBe('https://example.test'); + }); + + it('rejects dangerous prototype keys', async () => { + process.env.HAPPY_ENV_PREVIEW_SECRETS = 'none'; + + const { call } = createTestRpcManager(); + + const result = await call<{ error: string }, { keys: string[] }>(RPC_METHODS.PREVIEW_ENV, { + keys: ['__proto__'], + }); + + expect(result.error).toMatch(/Invalid env var key/); + }); + + it('hides sensitive values when HAPPY_ENV_PREVIEW_SECRETS=none', async () => { + process.env.SECRET_TOKEN = 'sk-1234567890'; + process.env.HAPPY_ENV_PREVIEW_SECRETS = 'none'; + + const { call } = createTestRpcManager(); + + const result = await call<{ policy: string; values: Record<string, { isSensitive: boolean; isForcedSensitive: boolean; sensitivitySource: string; display: string; value: string | null }> }, { + keys: string[]; + extraEnv?: Record<string, string>; + sensitiveKeys?: string[]; + }>(RPC_METHODS.PREVIEW_ENV, { + keys: ['ANTHROPIC_AUTH_TOKEN'], + extraEnv: { + ANTHROPIC_AUTH_TOKEN: '${SECRET_TOKEN}', + }, + sensitiveKeys: ['SECRET_TOKEN', 'ANTHROPIC_AUTH_TOKEN'], + }); + + expect(result.policy).toBe('none'); + expect(result.values.ANTHROPIC_AUTH_TOKEN.isSensitive).toBe(true); + expect(result.values.ANTHROPIC_AUTH_TOKEN.isForcedSensitive).toBe(true); + expect(result.values.ANTHROPIC_AUTH_TOKEN.sensitivitySource).toBe('forced'); + expect(result.values.ANTHROPIC_AUTH_TOKEN.display).toBe('hidden'); + expect(result.values.ANTHROPIC_AUTH_TOKEN.value).toBeNull(); + }); + + it('redacts sensitive values when HAPPY_ENV_PREVIEW_SECRETS=redacted', async () => { + process.env.SECRET_TOKEN = 'sk-1234567890'; + process.env.HAPPY_ENV_PREVIEW_SECRETS = 'redacted'; + + const { call } = createTestRpcManager(); + + const result = await call<{ policy: string; values: Record<string, { display: string; value: string | null }> }, { + keys: string[]; + extraEnv?: Record<string, string>; + sensitiveKeys?: string[]; + }>(RPC_METHODS.PREVIEW_ENV, { + keys: ['ANTHROPIC_AUTH_TOKEN'], + extraEnv: { + ANTHROPIC_AUTH_TOKEN: '${SECRET_TOKEN}', + }, + sensitiveKeys: ['SECRET_TOKEN', 'ANTHROPIC_AUTH_TOKEN'], + }); + + expect(result.policy).toBe('redacted'); + expect(result.values.ANTHROPIC_AUTH_TOKEN.display).toBe('redacted'); + expect(result.values.ANTHROPIC_AUTH_TOKEN.value).toBe('sk-*******890'); + }); + + it('returns full sensitive values when HAPPY_ENV_PREVIEW_SECRETS=full', async () => { + process.env.SECRET_TOKEN = 'sk-1234567890'; + process.env.HAPPY_ENV_PREVIEW_SECRETS = 'full'; + + const { call } = createTestRpcManager(); + + const result = await call<{ policy: string; values: Record<string, { display: string; value: string | null }> }, { + keys: string[]; + extraEnv?: Record<string, string>; + sensitiveKeys?: string[]; + }>(RPC_METHODS.PREVIEW_ENV, { + keys: ['ANTHROPIC_AUTH_TOKEN'], + extraEnv: { + ANTHROPIC_AUTH_TOKEN: '${SECRET_TOKEN}', + }, + sensitiveKeys: ['SECRET_TOKEN', 'ANTHROPIC_AUTH_TOKEN'], + }); + + expect(result.policy).toBe('full'); + expect(result.values.ANTHROPIC_AUTH_TOKEN.display).toBe('full'); + expect(result.values.ANTHROPIC_AUTH_TOKEN.value).toBe('sk-1234567890'); + }); + + it('supports overriding the secret name regex via HAPPY_ENV_PREVIEW_SECRET_NAME_REGEX', async () => { + process.env.HAPPY_ENV_PREVIEW_SECRETS = 'none'; + process.env.HAPPY_ENV_PREVIEW_SECRET_NAME_REGEX = '^FOO$'; + process.env.BAR_TOKEN = 'sk-1234567890'; + + const { call } = createTestRpcManager(); + + const result = await call<{ policy: string; values: Record<string, { isSensitive: boolean; isForcedSensitive: boolean; sensitivitySource: string; display: string; value: string | null }> }, { + keys: string[]; + }>(RPC_METHODS.PREVIEW_ENV, { + keys: ['BAR_TOKEN'], + }); + + expect(result.policy).toBe('none'); + expect(result.values.BAR_TOKEN.isSensitive).toBe(false); + expect(result.values.BAR_TOKEN.isForcedSensitive).toBe(false); + expect(result.values.BAR_TOKEN.sensitivitySource).toBe('none'); + expect(result.values.BAR_TOKEN.display).toBe('full'); + expect(result.values.BAR_TOKEN.value).toBe('sk-1234567890'); + }); + + it('falls back to default secret regex when HAPPY_ENV_PREVIEW_SECRET_NAME_REGEX is invalid', async () => { + process.env.HAPPY_ENV_PREVIEW_SECRETS = 'none'; + process.env.HAPPY_ENV_PREVIEW_SECRET_NAME_REGEX = '('; + process.env.BAR_TOKEN = 'sk-1234567890'; + + const { call } = createTestRpcManager(); + + const result = await call<{ policy: string; values: Record<string, { isSensitive: boolean; isForcedSensitive: boolean; sensitivitySource: string; display: string; value: string | null }> }, { + keys: string[]; + }>(RPC_METHODS.PREVIEW_ENV, { + keys: ['BAR_TOKEN'], + }); + + expect(result.policy).toBe('none'); + expect(result.values.BAR_TOKEN.isSensitive).toBe(true); + expect(result.values.BAR_TOKEN.isForcedSensitive).toBe(true); + expect(result.values.BAR_TOKEN.sensitivitySource).toBe('forced'); + expect(result.values.BAR_TOKEN.display).toBe('hidden'); + expect(result.values.BAR_TOKEN.value).toBeNull(); + }); +}); diff --git a/cli/src/rpc/handlers/registerSessionHandlers.ts b/cli/src/rpc/handlers/registerSessionHandlers.ts new file mode 100644 index 000000000..bfa6f3da6 --- /dev/null +++ b/cli/src/rpc/handlers/registerSessionHandlers.ts @@ -0,0 +1,111 @@ +import type { TerminalSpawnOptions } from '@/terminal/terminalConfig'; +import type { PermissionMode } from '@/api/types'; +import type { CatalogAgentId } from '@/backends/types'; +import { RpcHandlerManager } from '@/api/rpc/RpcHandlerManager'; +import type { SpawnSessionErrorCode } from '@happy/protocol'; +export { SPAWN_SESSION_ERROR_CODES } from '@happy/protocol'; +export type { SpawnSessionErrorCode } from '@happy/protocol'; +import { registerCapabilitiesHandlers } from './capabilities'; +import { registerPreviewEnvHandler } from './previewEnv'; +import { registerBashHandler } from './bash'; +import { registerFileSystemHandlers } from './fileSystem'; +import { registerRipgrepHandler } from './ripgrep'; +import { registerDifftasticHandler } from './difftastic'; + +/* + * Spawn Session Options and Result + * This rpc type is used by the daemon, all other RPCs here are for sessions + */ + +export interface SpawnSessionOptions { + machineId?: string; + directory: string; + sessionId?: string; + /** + * Resume an existing agent session by id (vendor resume). + * + * Upstream intent: Claude (`--resume <sessionId>`). + * If resume is requested for an unsupported agent, the daemon should return an error + * rather than silently spawning a fresh session. + */ + resume?: string; + /** + * Experimental: allow Codex vendor resume for this spawn. + * This is evaluated by the daemon BEFORE spawning the child process. + */ + experimentalCodexResume?: boolean; + /** + * Experimental: switch Codex sessions to use ACP (codex-acp) instead of MCP. + * This is evaluated by the daemon BEFORE spawning the child process. + */ + experimentalCodexAcp?: boolean; + /** + * Existing Happy session ID to reconnect to (for inactive session resume). + * When set, the CLI will connect to this session instead of creating a new one. + */ + existingSessionId?: string; + /** + * Session encryption key (dataKey mode only) encoded as base64. + * Required when existingSessionId is set. + */ + sessionEncryptionKeyBase64?: string; + /** + * Session encryption variant (resume only supports dataKey). + * Required when existingSessionId is set. + */ + sessionEncryptionVariant?: 'dataKey'; + /** + * Optional: explicit permission mode to publish at startup (seed or override). + * When omitted, the runner preserves existing metadata.permissionMode. + */ + permissionMode?: PermissionMode; + /** + * Optional timestamp for permissionMode (ms). Used to order explicit UI selections across devices. + */ + permissionModeUpdatedAt?: number; + approvedNewDirectoryCreation?: boolean; + agent?: CatalogAgentId; + token?: string; + /** + * Daemon/runtime terminal configuration for the spawned session (non-secret). + * Preferred over legacy TMUX_* env vars. + */ + terminal?: TerminalSpawnOptions; + /** + * Session-scoped profile identity for display/debugging across devices. + * This is NOT the profile content; actual runtime behavior is still driven + * by environmentVariables passed for this spawn. + * + * Empty string is allowed and means "no profile". + */ + profileId?: string; + /** + * Arbitrary environment variables for the spawned session. + * + * The GUI builds these from a profile (env var list + tmux settings) and may include + * provider-specific keys like: + * - ANTHROPIC_AUTH_TOKEN / ANTHROPIC_BASE_URL / ANTHROPIC_MODEL + * - OPENAI_API_KEY / OPENAI_BASE_URL / OPENAI_MODEL + * - AZURE_OPENAI_* / TOGETHER_* + * - TMUX_SESSION_NAME / TMUX_TMPDIR + */ + environmentVariables?: Record<string, string>; +} + +export type SpawnSessionResult = + | { type: 'success'; sessionId?: string } + | { type: 'requestToApproveDirectoryCreation'; directory: string } + | { type: 'error'; errorCode: SpawnSessionErrorCode; errorMessage: string }; + +/** + * Register all session RPC handlers with the daemon + */ +export function registerSessionHandlers(rpcHandlerManager: RpcHandlerManager, workingDirectory: string) { + registerBashHandler(rpcHandlerManager, workingDirectory); + // Checklist-based machine capability registry (replaces legacy detect-cli / detect-capabilities / dep-status). + registerCapabilitiesHandlers(rpcHandlerManager); + registerPreviewEnvHandler(rpcHandlerManager); + registerFileSystemHandlers(rpcHandlerManager, workingDirectory); + registerRipgrepHandler(rpcHandlerManager, workingDirectory); + registerDifftasticHandler(rpcHandlerManager, workingDirectory); +} diff --git a/cli/src/rpc/handlers/ripgrep.ts b/cli/src/rpc/handlers/ripgrep.ts new file mode 100644 index 000000000..1afbcbea4 --- /dev/null +++ b/cli/src/rpc/handlers/ripgrep.ts @@ -0,0 +1,49 @@ +import { logger } from '@/ui/logger'; +import { RpcHandlerManager } from '@/api/rpc/RpcHandlerManager'; +import { run as runRipgrep } from '@/integrations/ripgrep/index'; +import { validatePath } from './pathSecurity'; +import { RPC_METHODS } from '@happy/protocol/rpc'; + +interface RipgrepRequest { + args: string[]; + cwd?: string; +} + +interface RipgrepResponse { + success: boolean; + exitCode?: number; + stdout?: string; + stderr?: string; + error?: string; +} + +export function registerRipgrepHandler(rpcHandlerManager: RpcHandlerManager, workingDirectory: string): void { + // Ripgrep handler - raw interface to ripgrep + rpcHandlerManager.registerHandler<RipgrepRequest, RipgrepResponse>(RPC_METHODS.RIPGREP, async (data) => { + logger.debug('Ripgrep request with args:', data.args, 'cwd:', data.cwd); + + // Validate cwd if provided + if (data.cwd) { + const validation = validatePath(data.cwd, workingDirectory); + if (!validation.valid) { + return { success: false, error: validation.error }; + } + } + + try { + const result = await runRipgrep(data.args, { cwd: data.cwd }); + return { + success: true, + exitCode: result.exitCode, + stdout: result.stdout.toString(), + stderr: result.stderr.toString() + }; + } catch (error) { + logger.debug('Failed to run ripgrep:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to run ripgrep' + }; + } + }); +} diff --git a/cli/src/terminal/envVarSanitization.test.ts b/cli/src/terminal/envVarSanitization.test.ts new file mode 100644 index 000000000..7f97128b3 --- /dev/null +++ b/cli/src/terminal/envVarSanitization.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; +import { isValidEnvVarKey, sanitizeEnvVarRecord, validateEnvVarRecordStrict } from './envVarSanitization'; + +describe('envVarSanitization', () => { + it('rejects prototype-pollution keys', () => { + expect(isValidEnvVarKey('__proto__')).toBe(false); + expect(isValidEnvVarKey('constructor')).toBe(false); + expect(isValidEnvVarKey('prototype')).toBe(false); + }); + + it('sanitizes records by filtering invalid keys and non-string values', () => { + const out = sanitizeEnvVarRecord({ + GOOD: 'ok', + ['__proto__']: 'bad', + ALSO_OK: 123, + } as any); + expect(out).toEqual({ GOOD: 'ok' }); + }); + + it('strictly validates records for spawning', () => { + expect(validateEnvVarRecordStrict({ GOOD: 'ok' })).toEqual({ ok: true, env: { GOOD: 'ok' } }); + expect(validateEnvVarRecordStrict({ ['__proto__']: 'x' } as any)).toEqual({ ok: false, error: 'Invalid env var key: \"__proto__\"' }); + expect(validateEnvVarRecordStrict({ GOOD: 123 } as any)).toEqual({ ok: false, error: 'Invalid env var value for \"GOOD\": expected string' }); + }); +}); diff --git a/cli/src/terminal/envVarSanitization.ts b/cli/src/terminal/envVarSanitization.ts new file mode 100644 index 000000000..d8c264a23 --- /dev/null +++ b/cli/src/terminal/envVarSanitization.ts @@ -0,0 +1,38 @@ +const VALID_ENV_VAR_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/; +const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype']); + +export function isValidEnvVarKey(key: string): boolean { + return VALID_ENV_VAR_KEY.test(key) && !FORBIDDEN_KEYS.has(key); +} + +export function sanitizeEnvVarRecord(raw: unknown): Record<string, string> { + const out: Record<string, string> = Object.create(null); + if (!raw || typeof raw !== 'object') return out; + + for (const [k, v] of Object.entries(raw as Record<string, unknown>)) { + if (typeof k !== 'string' || !isValidEnvVarKey(k)) continue; + if (typeof v !== 'string') continue; + out[k] = v; + } + return out; +} + +export function validateEnvVarRecordStrict(raw: unknown): { ok: true; env: Record<string, string> } | { ok: false; error: string } { + if (!raw || typeof raw !== 'object') { + return { ok: true, env: Object.create(null) }; + } + + const env: Record<string, string> = Object.create(null); + for (const [k, v] of Object.entries(raw as Record<string, unknown>)) { + if (typeof k !== 'string' || !isValidEnvVarKey(k)) { + return { ok: false, error: `Invalid env var key: "${String(k)}"` }; + } + if (typeof v !== 'string') { + return { ok: false, error: `Invalid env var value for "${k}": expected string` }; + } + env[k] = v; + } + + return { ok: true, env }; +} + diff --git a/cli/src/terminal/headlessTmuxArgs.test.ts b/cli/src/terminal/headlessTmuxArgs.test.ts new file mode 100644 index 000000000..8a13daec8 --- /dev/null +++ b/cli/src/terminal/headlessTmuxArgs.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; + +import { ensureRemoteStartingModeArgs } from './headlessTmuxArgs'; + +describe('ensureRemoteStartingModeArgs', () => { + it('appends remote mode when not present', () => { + expect(ensureRemoteStartingModeArgs(['--foo'])).toEqual([ + '--foo', + '--happy-starting-mode', + 'remote', + ]); + }); + + it('keeps explicit remote mode', () => { + expect(ensureRemoteStartingModeArgs(['--happy-starting-mode', 'remote'])).toEqual([ + '--happy-starting-mode', + 'remote', + ]); + }); + + it('throws when local mode is requested', () => { + expect(() => ensureRemoteStartingModeArgs(['--happy-starting-mode', 'local'])).toThrow( + 'Headless tmux sessions require remote mode', + ); + }); + + it('throws a helpful error when --happy-starting-mode is missing a value', () => { + expect(() => ensureRemoteStartingModeArgs(['--happy-starting-mode'])).toThrow(/--happy-starting-mode/); + }); +}); diff --git a/cli/src/terminal/headlessTmuxArgs.ts b/cli/src/terminal/headlessTmuxArgs.ts new file mode 100644 index 000000000..e97a87fae --- /dev/null +++ b/cli/src/terminal/headlessTmuxArgs.ts @@ -0,0 +1,18 @@ +export function ensureRemoteStartingModeArgs(argv: string[]): string[] { + const idx = argv.indexOf('--happy-starting-mode'); + if (idx === -1) { + return [...argv, '--happy-starting-mode', 'remote']; + } + + const value = argv[idx + 1]; + if (!value || value.startsWith('--')) { + throw new Error('Missing value for --happy-starting-mode (expected "remote" or "local")'); + } + if (value === 'remote') return argv; + if (value === 'local') { + throw new Error('Headless tmux sessions require remote mode'); + } + + // Unknown value: preserve but keep behavior consistent by failing closed. + throw new Error('Headless tmux sessions require remote mode'); +} diff --git a/cli/src/terminal/startHappyHeadlessInTmux.test.ts b/cli/src/terminal/startHappyHeadlessInTmux.test.ts new file mode 100644 index 000000000..2c72e038e --- /dev/null +++ b/cli/src/terminal/startHappyHeadlessInTmux.test.ts @@ -0,0 +1,98 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('chalk', () => ({ + default: { + green: (s: string) => s, + red: (s: string) => s, + }, +})); + +const mockSpawnInTmux = vi.fn( + async (_args: string[], _options: any, _env?: Record<string, string>) => ({ success: true as const }), +); +const mockExecuteTmuxCommand = vi.fn(async () => ({ stdout: '' })); + +vi.mock('@/integrations/tmux', () => { + class TmuxUtilities { + static DEFAULT_SESSION_NAME = 'happy'; + constructor() {} + executeTmuxCommand = mockExecuteTmuxCommand; + spawnInTmux = mockSpawnInTmux; + } + + return { + isTmuxAvailable: vi.fn(async () => true), + selectPreferredTmuxSessionName: () => 'picked', + TmuxUtilities, + }; +}); + +vi.mock('@/utils/spawnHappyCLI', () => ({ + buildHappyCliSubprocessInvocation: () => ({ runtime: 'node', argv: ['happy'] }), +})); + +describe('startHappyHeadlessInTmux', () => { + const originalTmuxEnv = process.env.TMUX; + + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(Date, 'now').mockReturnValue(123); + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + (Date.now as any).mockRestore?.(); + (console.log as any).mockRestore?.(); + (console.error as any).mockRestore?.(); + if (originalTmuxEnv === undefined) { + delete process.env.TMUX; + } else { + process.env.TMUX = originalTmuxEnv; + } + }); + + it('prints only select-window when already inside tmux', async () => { + process.env.TMUX = '1'; + const { startHappyHeadlessInTmux } = await import('./startHappyHeadlessInTmux'); + + await startHappyHeadlessInTmux([]); + + const lines = (console.log as any).mock.calls.map((c: any[]) => String(c[0] ?? '')); + expect(lines.some((l: string) => l.includes('Started Happy in tmux'))).toBe(true); + expect(lines.some((l: string) => l.includes('tmux select-window -t') && l.includes('picked:happy-123-claude'))).toBe(true); + expect(lines.some((l: string) => l.includes('tmux attach -t'))).toBe(false); + }); + + it('prints attach then select-window when outside tmux', async () => { + delete process.env.TMUX; + const { startHappyHeadlessInTmux } = await import('./startHappyHeadlessInTmux'); + + await startHappyHeadlessInTmux([]); + + const lines = (console.log as any).mock.calls.map((c: any[]) => String(c[0] ?? '')); + const attachIdx = lines.findIndex((l: string) => l.includes('tmux attach -t') && l.includes('happy')); + const selectIdx = lines.findIndex((l: string) => l.includes('tmux select-window -t') && l.includes('happy:happy-123-claude')); + expect(attachIdx).toBeGreaterThanOrEqual(0); + expect(selectIdx).toBeGreaterThanOrEqual(0); + expect(attachIdx).toBeLessThan(selectIdx); + }); + + it('does not pass TMUX variables through to the tmux window environment', async () => { + process.env.TMUX = '1'; + process.env.TMUX_PANE = '%1'; + process.env.HAPPY_TEST_FOO = 'bar'; + const { startHappyHeadlessInTmux } = await import('./startHappyHeadlessInTmux'); + + await startHappyHeadlessInTmux([]); + + const env = mockSpawnInTmux.mock.calls[0]?.[2] as Record<string, string> | undefined; + expect(env).toBeDefined(); + expect(env?.TMUX).toBeUndefined(); + expect(env?.TMUX_PANE).toBeUndefined(); + expect(env?.HAPPY_TEST_FOO).toBe('bar'); + + delete process.env.TMUX_PANE; + delete process.env.HAPPY_TEST_FOO; + }); +}); diff --git a/cli/src/terminal/startHappyHeadlessInTmux.ts b/cli/src/terminal/startHappyHeadlessInTmux.ts new file mode 100644 index 000000000..45e7a3552 --- /dev/null +++ b/cli/src/terminal/startHappyHeadlessInTmux.ts @@ -0,0 +1,97 @@ +import chalk from 'chalk'; + +import { buildHappyCliSubprocessInvocation } from '@/utils/spawnHappyCLI'; +import { isTmuxAvailable, selectPreferredTmuxSessionName, TmuxUtilities } from '@/integrations/tmux'; +import { AGENTS } from '@/backends/catalog'; +import { DEFAULT_CATALOG_AGENT_ID } from '@/backends/types'; + +function removeFlag(argv: string[], flag: string): string[] { + return argv.filter((arg) => arg !== flag); +} + +function inferAgent(argv: string[]): keyof typeof AGENTS { + const first = argv[0] as keyof typeof AGENTS | undefined; + if (first && Object.prototype.hasOwnProperty.call(AGENTS, first)) return first; + return DEFAULT_CATALOG_AGENT_ID; +} + +function buildWindowEnv(): Record<string, string> { + const excludedKeys = new Set(['TMUX', 'TMUX_PANE']); + return Object.fromEntries( + Object.entries(process.env).filter( + ([key, value]) => typeof value === 'string' && !excludedKeys.has(key), + ), + ) as Record<string, string>; +} + +async function resolveTmuxSessionName(params: { + requestedSessionName: string; +}): Promise<string> { + if (params.requestedSessionName !== '') return params.requestedSessionName; + + const tmux = new TmuxUtilities(); + const listResult = await tmux.executeTmuxCommand([ + 'list-sessions', + '-F', + '#{session_name}\t#{session_attached}\t#{session_last_attached}', + ]); + + return selectPreferredTmuxSessionName(listResult?.stdout ?? '') ?? TmuxUtilities.DEFAULT_SESSION_NAME; +} + +export async function startHappyHeadlessInTmux(argv: string[]): Promise<void> { + const argsWithoutTmux = removeFlag(argv, '--tmux'); + const agent = inferAgent(argsWithoutTmux); + const entry = AGENTS[agent]; + const transform = entry.getHeadlessTmuxArgvTransform ? await entry.getHeadlessTmuxArgvTransform() : null; + const childArgs = transform ? transform(argsWithoutTmux) : argsWithoutTmux; + + if (!(await isTmuxAvailable())) { + console.error(chalk.red('Error:'), 'tmux is not available on this machine.'); + process.exit(1); + } + + const insideTmux = Boolean(process.env.TMUX); + const requestedSessionName = insideTmux ? '' : TmuxUtilities.DEFAULT_SESSION_NAME; + const resolvedSessionName = await resolveTmuxSessionName({ requestedSessionName }); + + const windowName = `happy-${Date.now()}-${agent}`; + const tmuxTarget = `${resolvedSessionName}:${windowName}`; + + const terminalRuntimeArgs = [ + '--happy-terminal-mode', + 'tmux', + '--happy-terminal-requested', + 'tmux', + '--happy-tmux-target', + tmuxTarget, + ]; + + const inv = buildHappyCliSubprocessInvocation([...childArgs, ...terminalRuntimeArgs]); + const commandTokens = [inv.runtime, ...inv.argv]; + + const tmux = new TmuxUtilities(resolvedSessionName); + const result = await tmux.spawnInTmux( + commandTokens, + { + sessionName: resolvedSessionName, + windowName, + cwd: process.cwd(), + }, + buildWindowEnv(), + ); + + if (!result.success) { + console.error(chalk.red('Error:'), `Failed to start in tmux: ${result.error ?? 'unknown error'}`); + process.exit(1); + } + + console.log(chalk.green('✓ Started Happy in tmux')); + console.log(` Target: ${tmuxTarget}`); + if (insideTmux) { + console.log(` Attach: tmux select-window -t ${tmuxTarget}`); + } else { + console.log(` Attach: tmux attach -t ${resolvedSessionName}`); + console.log(` tmux select-window -t ${tmuxTarget}`); + } +} diff --git a/cli/src/terminal/terminalAttachPlan.test.ts b/cli/src/terminal/terminalAttachPlan.test.ts new file mode 100644 index 000000000..e6589baf2 --- /dev/null +++ b/cli/src/terminal/terminalAttachPlan.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest'; + +import type { Metadata } from '@/api/types'; + +import { createTerminalAttachPlan } from './terminalAttachPlan'; + +describe('createTerminalAttachPlan', () => { + it('returns not-attachable when terminal mode is plain', () => { + const terminal: NonNullable<Metadata['terminal']> = { mode: 'plain' }; + const plan = createTerminalAttachPlan({ terminal, insideTmux: false }); + expect(plan.type).toBe('not-attachable'); + }); + + it('returns not-attachable when tmux mode has no target', () => { + const terminal: NonNullable<Metadata['terminal']> = { mode: 'tmux' }; + const plan = createTerminalAttachPlan({ terminal, insideTmux: false }); + expect(plan.type).toBe('not-attachable'); + }); + + it('returns not-attachable when tmux target is invalid', () => { + const terminal: NonNullable<Metadata['terminal']> = { + mode: 'tmux', + tmux: { target: 'bad*:window' }, + }; + + const plan = createTerminalAttachPlan({ terminal, insideTmux: false }); + expect(plan.type).toBe('not-attachable'); + }); + + it('plans select-window + attach when outside tmux', () => { + const terminal: NonNullable<Metadata['terminal']> = { + mode: 'tmux', + tmux: { target: 'happy:window-1' }, + }; + + const plan = createTerminalAttachPlan({ terminal, insideTmux: false }); + expect(plan).toEqual({ + type: 'tmux', + sessionName: 'happy', + target: 'happy:window-1', + shouldAttach: true, + shouldUnsetTmuxEnv: false, + tmuxCommandEnv: {}, + selectWindowArgs: ['select-window', '-t', 'happy:window-1'], + attachSessionArgs: ['attach-session', '-t', 'happy'], + }); + }); + + it('plans select-window only when already in tmux shared server', () => { + const terminal: NonNullable<Metadata['terminal']> = { + mode: 'tmux', + tmux: { target: 'happy:window-2' }, + }; + + const plan = createTerminalAttachPlan({ terminal, insideTmux: true }); + expect(plan.type).toBe('tmux'); + if (plan.type !== 'tmux') throw new Error('expected tmux plan'); + expect(plan.shouldAttach).toBe(false); + }); + + it('forces attach when tmux uses a custom tmpDir (isolated server)', () => { + const terminal: NonNullable<Metadata['terminal']> = { + mode: 'tmux', + tmux: { target: 'happy:window-3', tmpDir: '/custom/tmux' }, + }; + + const plan = createTerminalAttachPlan({ terminal, insideTmux: true }); + expect(plan.type).toBe('tmux'); + if (plan.type !== 'tmux') throw new Error('expected tmux plan'); + expect(plan.shouldUnsetTmuxEnv).toBe(true); + expect(plan.tmuxCommandEnv).toEqual({ TMUX_TMPDIR: '/custom/tmux' }); + expect(plan.shouldAttach).toBe(true); + }); +}); diff --git a/cli/src/terminal/terminalAttachPlan.ts b/cli/src/terminal/terminalAttachPlan.ts new file mode 100644 index 000000000..33f4df5c6 --- /dev/null +++ b/cli/src/terminal/terminalAttachPlan.ts @@ -0,0 +1,72 @@ +import type { Metadata } from '@/api/types'; +import { parseTmuxSessionIdentifier } from '@/integrations/tmux'; + +export type TerminalAttachPlan = + | { type: 'not-attachable'; reason: string } + | { + type: 'tmux'; + sessionName: string; + target: string; + selectWindowArgs: string[]; + attachSessionArgs: string[]; + tmuxCommandEnv: Record<string, string>; + /** + * True when we should clear TMUX/TMUX_PANE from the environment for tmux + * commands (e.g. isolated tmux server selected via TMUX_TMPDIR). + */ + shouldUnsetTmuxEnv: boolean; + /** + * True when we should run `tmux attach-session ...` after selecting the window. + * When already inside a shared tmux server, selecting the window is sufficient. + */ + shouldAttach: boolean; + }; + +export function createTerminalAttachPlan(params: { + terminal: NonNullable<Metadata['terminal']>; + insideTmux: boolean; +}): TerminalAttachPlan { + if (params.terminal.mode === 'plain') { + return { + type: 'not-attachable', + reason: 'Session was not started in tmux.', + }; + } + + const target = params.terminal.tmux?.target; + if (typeof target !== 'string' || target.trim().length === 0) { + return { + type: 'not-attachable', + reason: 'Session does not include a tmux target.', + }; + } + + let parsed: ReturnType<typeof parseTmuxSessionIdentifier>; + try { + parsed = parseTmuxSessionIdentifier(target); + } catch { + return { + type: 'not-attachable', + reason: 'Session includes an invalid tmux target.', + }; + } + + const tmpDir = params.terminal.tmux?.tmpDir; + const tmuxCommandEnv: Record<string, string> = + typeof tmpDir === 'string' && tmpDir.trim().length > 0 ? { TMUX_TMPDIR: tmpDir } : {}; + + const shouldUnsetTmuxEnv = Object.prototype.hasOwnProperty.call(tmuxCommandEnv, 'TMUX_TMPDIR'); + + const shouldAttach = !params.insideTmux || shouldUnsetTmuxEnv; + + return { + type: 'tmux', + sessionName: parsed.session, + target, + shouldAttach, + shouldUnsetTmuxEnv, + tmuxCommandEnv, + selectWindowArgs: ['select-window', '-t', target], + attachSessionArgs: ['attach-session', '-t', parsed.session], + }; +} diff --git a/cli/src/terminal/terminalAttachmentInfo.test.ts b/cli/src/terminal/terminalAttachmentInfo.test.ts new file mode 100644 index 000000000..7647b3180 --- /dev/null +++ b/cli/src/terminal/terminalAttachmentInfo.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from 'vitest'; +import * as tmp from 'tmp'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { readTerminalAttachmentInfo, writeTerminalAttachmentInfo } from './terminalAttachmentInfo'; + +describe('terminalAttachmentInfo', () => { + it('writes and reads per-session terminal attachment info', async () => { + const dir = tmp.dirSync({ unsafeCleanup: true }); + try { + await writeTerminalAttachmentInfo({ + happyHomeDir: dir.name, + sessionId: 'sess_123', + terminal: { + mode: 'tmux', + tmux: { target: 'happy:win-1', tmpDir: '/tmp/happy-tmux' }, + }, + }); + + const raw = await readFile(join(dir.name, 'terminal', 'sessions', 'sess_123.json'), 'utf8'); + const parsed = JSON.parse(raw); + expect(parsed.sessionId).toBe('sess_123'); + expect(parsed.terminal?.tmux?.target).toBe('happy:win-1'); + + const info = await readTerminalAttachmentInfo({ + happyHomeDir: dir.name, + sessionId: 'sess_123', + }); + expect(info?.terminal.mode).toBe('tmux'); + expect(info?.terminal.tmux?.tmpDir).toBe('/tmp/happy-tmux'); + } finally { + dir.removeCallback(); + } + }); + + it('stores sessionId using a filename-safe encoding to prevent path traversal', async () => { + const dir = tmp.dirSync({ unsafeCleanup: true }); + try { + const sessionId = '../evil/session'; + await writeTerminalAttachmentInfo({ + happyHomeDir: dir.name, + sessionId, + terminal: { + mode: 'plain', + plain: { command: 'echo hi', cwd: '/tmp' }, + } as any, + }); + + const encodedFileName = `${encodeURIComponent(sessionId)}.json`; + const raw = await readFile(join(dir.name, 'terminal', 'sessions', encodedFileName), 'utf8'); + const parsed = JSON.parse(raw); + expect(parsed.sessionId).toBe(sessionId); + + const info = await readTerminalAttachmentInfo({ happyHomeDir: dir.name, sessionId }); + expect(info?.sessionId).toBe(sessionId); + } finally { + dir.removeCallback(); + } + }); + + it('can still read legacy files created with the raw sessionId filename', async () => { + const dir = tmp.dirSync({ unsafeCleanup: true }); + try { + const sessionId = 'tmux:legacy'; + await mkdir(join(dir.name, 'terminal', 'sessions'), { recursive: true }); + const legacyPath = join(dir.name, 'terminal', 'sessions', `${sessionId}.json`); + await writeFile(legacyPath, JSON.stringify({ + version: 1, + sessionId, + terminal: { mode: 'tmux', tmux: { target: 'happy:win-1', tmpDir: '/tmp/happy-tmux' } }, + updatedAt: Date.now(), + }, null, 2), 'utf8'); + + const info = await readTerminalAttachmentInfo({ happyHomeDir: dir.name, sessionId }); + expect(info?.terminal.mode).toBe('tmux'); + expect(info?.terminal.tmux?.target).toBe('happy:win-1'); + } finally { + dir.removeCallback(); + } + }); + + it('does not read legacy files when sessionId contains path separators', async () => { + const dir = tmp.dirSync({ unsafeCleanup: true }); + try { + const sessionId = '../../pwned'; + await mkdir(join(dir.name, 'terminal', 'sessions'), { recursive: true }); + + // If the legacy path fallback were used for this sessionId, it would resolve outside the sessions dir. + // Ensure we don't read it even if such a file exists. + const traversedPath = join(dir.name, 'terminal', 'sessions', `${sessionId}.json`); + await writeFile(traversedPath, JSON.stringify({ + version: 1, + sessionId, + terminal: { mode: 'plain', plain: { command: 'echo hi', cwd: '/tmp' } }, + updatedAt: Date.now(), + }, null, 2), 'utf8'); + + const info = await readTerminalAttachmentInfo({ happyHomeDir: dir.name, sessionId }); + expect(info).toBeNull(); + } finally { + dir.removeCallback(); + } + }); +}); diff --git a/cli/src/terminal/terminalAttachmentInfo.ts b/cli/src/terminal/terminalAttachmentInfo.ts new file mode 100644 index 000000000..70eccef3d --- /dev/null +++ b/cli/src/terminal/terminalAttachmentInfo.ts @@ -0,0 +1,81 @@ +import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import type { Metadata } from '@/api/types'; + +export type TerminalAttachmentInfo = { + version: 1; + sessionId: string; + terminal: NonNullable<Metadata['terminal']>; + updatedAt: number; +}; + +function sessionsDir(happyHomeDir: string): string { + return join(happyHomeDir, 'terminal', 'sessions'); +} + +function sessionIdToFilename(sessionId: string): string { + return encodeURIComponent(sessionId); +} + +function sessionFilePath(happyHomeDir: string, sessionId: string): string { + return join(sessionsDir(happyHomeDir), `${sessionIdToFilename(sessionId)}.json`); +} + +function legacySessionFilePath(happyHomeDir: string, sessionId: string): string { + return join(sessionsDir(happyHomeDir), `${sessionId}.json`); +} + +export async function writeTerminalAttachmentInfo(params: { + happyHomeDir: string; + sessionId: string; + terminal: NonNullable<Metadata['terminal']>; +}): Promise<void> { + const dir = sessionsDir(params.happyHomeDir); + await mkdir(dir, { recursive: true }); + + const info: TerminalAttachmentInfo = { + version: 1, + sessionId: params.sessionId, + terminal: params.terminal, + updatedAt: Date.now(), + }; + + const path = sessionFilePath(params.happyHomeDir, params.sessionId); + const tmpPath = `${path}.tmp`; + + await writeFile(tmpPath, JSON.stringify(info, null, 2), 'utf8'); + await rename(tmpPath, path); +} + +export async function readTerminalAttachmentInfo(params: { + happyHomeDir: string; + sessionId: string; +}): Promise<TerminalAttachmentInfo | null> { + try { + const encodedPath = sessionFilePath(params.happyHomeDir, params.sessionId); + let raw: string; + try { + raw = await readFile(encodedPath, 'utf8'); + } catch (e) { + const err = e as NodeJS.ErrnoException; + if (err?.code !== 'ENOENT') throw e; + // Only allow legacy fallback for filename-safe session ids. The legacy filename + // used the raw sessionId, so path separators would allow traversal outside the + // intended sessions directory. + if (params.sessionId.includes('/') || params.sessionId.includes('\\')) throw e; + const legacyPath = legacySessionFilePath(params.happyHomeDir, params.sessionId); + if (legacyPath === encodedPath) throw e; + raw = await readFile(legacyPath, 'utf8'); + } + const parsed = JSON.parse(raw) as Partial<TerminalAttachmentInfo> | null; + if (!parsed || typeof parsed !== 'object') return null; + if (parsed.version !== 1) return null; + if (parsed.sessionId !== params.sessionId) return null; + if (!parsed.terminal || typeof parsed.terminal !== 'object') return null; + if (parsed.terminal.mode !== 'plain' && parsed.terminal.mode !== 'tmux') return null; + return parsed as TerminalAttachmentInfo; + } catch { + return null; + } +} diff --git a/cli/src/terminal/terminalConfig.test.ts b/cli/src/terminal/terminalConfig.test.ts new file mode 100644 index 000000000..9aeb8fec8 --- /dev/null +++ b/cli/src/terminal/terminalConfig.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest'; +import { resolveTerminalRequestFromSpawnOptions } from './terminalConfig'; + +describe('resolveTerminalRequestFromSpawnOptions', () => { + it('prefers typed terminal config over legacy TMUX_* env vars', () => { + const resolved = resolveTerminalRequestFromSpawnOptions({ + happyHomeDir: '/home/user/.happy', + terminal: { + mode: 'tmux', + tmux: { + sessionName: 'happy', + isolated: true, + }, + }, + environmentVariables: { + TMUX_SESSION_NAME: 'legacy-session', + TMUX_TMPDIR: '/tmp/legacy', + }, + }); + + expect(resolved).toEqual({ + requested: 'tmux', + tmux: { + sessionName: 'happy', + isolated: true, + tmpDir: '/home/user/.happy/tmux', + source: 'typed', + }, + }); + }); + + it('derives TMUX_TMPDIR from happyHomeDir when isolated and tmpDir not provided', () => { + const resolved = resolveTerminalRequestFromSpawnOptions({ + happyHomeDir: '/x/.happy', + terminal: { mode: 'tmux', tmux: { sessionName: 'happy', isolated: true } }, + environmentVariables: {}, + }); + + expect(resolved).toEqual({ + requested: 'tmux', + tmux: { + sessionName: 'happy', + isolated: true, + tmpDir: '/x/.happy/tmux', + source: 'typed', + }, + }); + }); + + it('falls back to legacy TMUX_* env vars when typed terminal config is absent', () => { + const resolved = resolveTerminalRequestFromSpawnOptions({ + happyHomeDir: '/home/user/.happy', + environmentVariables: { + TMUX_SESSION_NAME: '', + TMUX_TMPDIR: '/tmp/custom', + }, + }); + + expect(resolved).toEqual({ + requested: 'tmux', + tmux: { + sessionName: '', + isolated: false, + tmpDir: '/tmp/custom', + source: 'legacy', + }, + }); + }); + + it('returns requested=plain when terminal mode is plain', () => { + const resolved = resolveTerminalRequestFromSpawnOptions({ + happyHomeDir: '/home/user/.happy', + terminal: { mode: 'plain' }, + environmentVariables: { TMUX_SESSION_NAME: 'should-be-ignored' }, + }); + + expect(resolved).toEqual({ requested: 'plain' }); + }); +}); + diff --git a/cli/src/terminal/terminalConfig.ts b/cli/src/terminal/terminalConfig.ts new file mode 100644 index 000000000..307aff5b3 --- /dev/null +++ b/cli/src/terminal/terminalConfig.ts @@ -0,0 +1,95 @@ +import { posix as pathPosix } from 'node:path'; + +export type TerminalMode = 'plain' | 'tmux'; + +export type TerminalTmuxSpawnOptions = { + /** + * tmux session to create/select. + * + * Note: empty string is allowed for legacy behavior ("current/most recent session"), + * but should only be used for terminal-initiated flows where "current" is well-defined. + */ + sessionName?: string; + /** + * When true, prefer an isolated tmux server socket (via TMUX_TMPDIR) to avoid + * interfering with the user's global tmux server. + */ + isolated?: boolean; + /** + * Optional override for TMUX_TMPDIR. When null/undefined and isolated=true, we derive + * a deterministic directory under happyHomeDir. + */ + tmpDir?: string | null; +}; + +export type TerminalSpawnOptions = { + mode?: TerminalMode; + tmux?: TerminalTmuxSpawnOptions; +}; + +export type ResolvedTerminalRequest = + | { requested: 'plain' } + | { + requested: 'tmux'; + tmux: { + sessionName: string; + isolated: boolean; + tmpDir: string | null; + source: 'typed' | 'legacy'; + }; + } + | { requested: null }; + +function normalizeOptionalPath(value: string | null | undefined): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +export function resolveTerminalRequestFromSpawnOptions(params: { + happyHomeDir: string; + terminal?: TerminalSpawnOptions; + environmentVariables?: Record<string, string>; +}): ResolvedTerminalRequest { + const terminal = params.terminal; + if (terminal?.mode === 'plain') { + return { requested: 'plain' }; + } + + if (terminal?.mode === 'tmux') { + const sessionName = terminal.tmux?.sessionName ?? 'happy'; + const isolated = terminal.tmux?.isolated ?? true; + const tmpDirOverride = normalizeOptionalPath(terminal.tmux?.tmpDir ?? null); + const tmpDir = isolated + ? (tmpDirOverride ?? pathPosix.join(params.happyHomeDir, 'tmux')) + : tmpDirOverride; + + return { + requested: 'tmux', + tmux: { + sessionName, + isolated, + tmpDir, + source: 'typed', + }, + }; + } + + const env = params.environmentVariables ?? {}; + if (Object.prototype.hasOwnProperty.call(env, 'TMUX_SESSION_NAME')) { + const sessionName = env.TMUX_SESSION_NAME; + const tmpDir = normalizeOptionalPath(env.TMUX_TMPDIR ?? null); + return { + requested: 'tmux', + tmux: { + sessionName, + isolated: false, + tmpDir, + source: 'legacy', + }, + }; + } + + return { requested: null }; +} + diff --git a/cli/src/terminal/terminalFallbackMessage.test.ts b/cli/src/terminal/terminalFallbackMessage.test.ts new file mode 100644 index 000000000..268a9c2f7 --- /dev/null +++ b/cli/src/terminal/terminalFallbackMessage.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; + +import type { Metadata } from '@/api/types'; + +import { buildTerminalFallbackMessage } from './terminalFallbackMessage'; + +describe('buildTerminalFallbackMessage', () => { + it('returns null when tmux was not requested', () => { + const terminal: NonNullable<Metadata['terminal']> = { mode: 'plain' }; + expect(buildTerminalFallbackMessage(terminal)).toBeNull(); + }); + + it('returns a user-facing message when tmux was requested but we fell back to plain', () => { + const terminal: NonNullable<Metadata['terminal']> = { + mode: 'plain', + requested: 'tmux', + fallbackReason: 'tmux is not available on this machine', + }; + + expect(buildTerminalFallbackMessage(terminal)).toMatch('tmux'); + expect(buildTerminalFallbackMessage(terminal)).toMatch('tmux is not available on this machine'); + }); +}); + diff --git a/cli/src/terminal/terminalFallbackMessage.ts b/cli/src/terminal/terminalFallbackMessage.ts new file mode 100644 index 000000000..3aed80ab8 --- /dev/null +++ b/cli/src/terminal/terminalFallbackMessage.ts @@ -0,0 +1,16 @@ +import type { Metadata } from '@/api/types'; + +export function buildTerminalFallbackMessage( + terminal: NonNullable<Metadata['terminal']>, +): string | null { + if (terminal.mode !== 'plain') return null; + if (terminal.requested !== 'tmux') return null; + + const reason = + typeof terminal.fallbackReason === 'string' && terminal.fallbackReason.trim().length > 0 + ? ` Reason: ${terminal.fallbackReason.trim()}.` + : ''; + + return `This session couldn't be started in tmux, so "Attach from terminal" won't be available.${reason}`; +} + diff --git a/cli/src/terminal/terminalMetadata.ts b/cli/src/terminal/terminalMetadata.ts new file mode 100644 index 000000000..2aefecad3 --- /dev/null +++ b/cli/src/terminal/terminalMetadata.ts @@ -0,0 +1,34 @@ +import type { Metadata } from '@/api/types'; + +import type { TerminalRuntimeFlags } from './terminalRuntimeFlags'; + +export function buildTerminalMetadataFromRuntimeFlags( + flags: TerminalRuntimeFlags | null, +): Metadata['terminal'] | undefined { + if (!flags) return undefined; + + const mode = flags.mode; + if (mode !== 'plain' && mode !== 'tmux') return undefined; + + const terminal: NonNullable<Metadata['terminal']> = { + mode, + }; + + if (flags.requested === 'plain' || flags.requested === 'tmux') { + terminal.requested = flags.requested; + } + if (typeof flags.fallbackReason === 'string' && flags.fallbackReason.trim().length > 0) { + terminal.fallbackReason = flags.fallbackReason; + } + if (typeof flags.tmuxTarget === 'string' && flags.tmuxTarget.trim().length > 0) { + terminal.tmux = { + target: flags.tmuxTarget, + ...(typeof flags.tmuxTmpDir === 'string' && flags.tmuxTmpDir.trim().length > 0 + ? { tmpDir: flags.tmuxTmpDir } + : {}), + }; + } + + return terminal; +} + diff --git a/cli/src/terminal/terminalRuntimeFlags.test.ts b/cli/src/terminal/terminalRuntimeFlags.test.ts new file mode 100644 index 000000000..750279093 --- /dev/null +++ b/cli/src/terminal/terminalRuntimeFlags.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; +import { parseAndStripTerminalRuntimeFlags } from './terminalRuntimeFlags'; + +describe('parseAndStripTerminalRuntimeFlags', () => { + it('extracts tmux runtime info and strips internal flags from argv', () => { + const parsed = parseAndStripTerminalRuntimeFlags([ + 'claude', + '--happy-terminal-mode', + 'tmux', + '--happy-tmux-target', + 'happy:win-123', + '--happy-tmux-tmpdir', + '/tmp/happy-tmux', + '--model', + 'sonnet', + ]); + + expect(parsed).toEqual({ + terminal: { + mode: 'tmux', + tmuxTarget: 'happy:win-123', + tmuxTmpDir: '/tmp/happy-tmux', + }, + argv: ['claude', '--model', 'sonnet'], + }); + }); + + it('extracts fallback info when tmux was requested but plain mode was used', () => { + const parsed = parseAndStripTerminalRuntimeFlags([ + '--happy-terminal-mode', + 'plain', + '--happy-terminal-requested', + 'tmux', + '--happy-terminal-fallback-reason', + 'tmux not available', + '--foo', + 'bar', + ]); + + expect(parsed).toEqual({ + terminal: { + mode: 'plain', + requested: 'tmux', + fallbackReason: 'tmux not available', + }, + argv: ['--foo', 'bar'], + }); + }); +}); + diff --git a/cli/src/terminal/terminalRuntimeFlags.ts b/cli/src/terminal/terminalRuntimeFlags.ts new file mode 100644 index 000000000..c486c5d3a --- /dev/null +++ b/cli/src/terminal/terminalRuntimeFlags.ts @@ -0,0 +1,68 @@ +import type { TerminalMode } from './terminalConfig'; + +export type TerminalRuntimeFlags = { + mode?: TerminalMode; + requested?: TerminalMode; + fallbackReason?: string; + tmuxTarget?: string; + tmuxTmpDir?: string; +}; + +function parseTerminalMode(value: string | undefined): TerminalMode | undefined { + if (value === 'plain' || value === 'tmux') return value; + return undefined; +} + +export function parseAndStripTerminalRuntimeFlags(argv: string[]): { + terminal: TerminalRuntimeFlags | null; + argv: string[]; +} { + const terminal: TerminalRuntimeFlags = {}; + const remaining: string[] = []; + + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + + if (arg === '--happy-terminal-mode') { + terminal.mode = parseTerminalMode(argv[++i]); + continue; + } + if (arg === '--happy-terminal-requested') { + terminal.requested = parseTerminalMode(argv[++i]); + continue; + } + if (arg === '--happy-terminal-fallback-reason') { + const value = argv[++i]; + if (typeof value === 'string' && value.trim().length > 0) { + terminal.fallbackReason = value; + } + continue; + } + if (arg === '--happy-tmux-target') { + const value = argv[++i]; + if (typeof value === 'string' && value.trim().length > 0) { + terminal.tmuxTarget = value; + } + continue; + } + if (arg === '--happy-tmux-tmpdir') { + const value = argv[++i]; + if (typeof value === 'string' && value.trim().length > 0) { + terminal.tmuxTmpDir = value; + } + continue; + } + + remaining.push(arg); + } + + const hasAny = + terminal.mode !== undefined || + terminal.requested !== undefined || + terminal.fallbackReason !== undefined || + terminal.tmuxTarget !== undefined || + terminal.tmuxTmpDir !== undefined; + + return { terminal: hasAny ? terminal : null, argv: remaining }; +} + diff --git a/cli/src/test-setup.ts b/cli/src/test-setup.ts index 58a704af4..52626c70b 100644 --- a/cli/src/test-setup.ts +++ b/cli/src/test-setup.ts @@ -10,8 +10,16 @@ export function setup() { // Extend test timeout for integration tests process.env.VITEST_POOL_TIMEOUT = '60000' - // Make sure to build the project before running tests - // We rely on the dist files to spawn our CLI in integration tests + const skipBuild = (() => { + const raw = process.env.HAPPY_CLI_TEST_SKIP_BUILD + if (typeof raw !== 'string') return false + return ['1', 'true', 'yes'].includes(raw.trim().toLowerCase()) + })() + + // Make sure to build the project before running tests (opt-out). + // We rely on the dist files to spawn our CLI in some integration tests. + if (skipBuild) return + const buildResult = spawnSync('yarn', ['build'], { stdio: 'pipe' }) if (buildResult.stderr && buildResult.stderr.length > 0) { diff --git a/cli/src/ui/auth.ts b/cli/src/ui/auth.ts index 964f8ace9..e1e2ef7a1 100644 --- a/cli/src/ui/auth.ts +++ b/cli/src/ui/auth.ts @@ -7,7 +7,7 @@ import { displayQRCode } from "./qrcode"; import { delay } from "@/utils/time"; import { writeCredentialsLegacy, readCredentials, updateSettings, Credentials, writeCredentialsDataKey } from "@/persistence"; import { generateWebAuthUrl } from "@/api/webAuth"; -import { openBrowser } from "@/utils/browser"; +import { openBrowser } from '@/ui/openBrowser'; import { AuthSelector, AuthMethod } from "./ink/AuthSelector"; import { render } from 'ink'; import React from 'react'; @@ -113,15 +113,22 @@ async function doWebAuth(keypair: tweetnacl.BoxKeyPair): Promise<Credentials | n console.log('\nWeb Authentication\n'); const webUrl = generateWebAuthUrl(keypair.publicKey); - console.log('Opening your browser...'); - - const browserOpened = await openBrowser(webUrl); - - if (browserOpened) { - console.log('✓ Browser opened\n'); - console.log('Complete authentication in your browser window.'); + const noOpenRaw = (process.env.HAPPY_NO_BROWSER_OPEN ?? '').toString().trim(); + const noOpen = Boolean(noOpenRaw) && noOpenRaw !== '0' && noOpenRaw.toLowerCase() !== 'false'; + if (!noOpen) { + console.log('Opening your browser...'); + + const browserOpened = await openBrowser(webUrl); + + if (browserOpened) { + console.log('✓ Browser opened\n'); + console.log('Complete authentication in your browser window.'); + } else { + console.log('Could not open browser automatically.'); + } } else { - console.log('Could not open browser automatically.'); + console.log('Browser opening is disabled (HAPPY_NO_BROWSER_OPEN is set).'); + console.log('Open the URL below in the browser profile/account you want to authenticate.'); } // I changed this to always show the URL because we got a report from @@ -279,4 +286,4 @@ export async function authAndSetupMachineIfNeeded(): Promise<{ logger.debug(`[AUTH] Machine ID: ${settings.machineId}`); return { credentials, machineId: settings.machineId! }; -} \ No newline at end of file +} diff --git a/cli/src/ui/doctor.test.ts b/cli/src/ui/doctor.test.ts new file mode 100644 index 000000000..14f93d825 --- /dev/null +++ b/cli/src/ui/doctor.test.ts @@ -0,0 +1,16 @@ +import { describe, it, expect } from 'vitest'; +import { maskValue } from './doctor'; + +describe('doctor redaction', () => { + it('does not treat ${VAR:-default} templates as safe', () => { + expect(maskValue('${SAFE_TEMPLATE}')).toBe('${SAFE_TEMPLATE}'); + expect(maskValue('${LEAK:-sk-live-secret}')).toMatch(/^\$\{LEAK:-<\d+ chars>\}$/); + expect(maskValue('${LEAK:=sk-live-secret}')).toMatch(/^\$\{LEAK:=<\d+ chars>\}$/); + }); + + it('handles empty, undefined, and plain secret values', () => { + expect(maskValue('')).toBe('<empty>'); + expect(maskValue(undefined)).toBeUndefined(); + expect(maskValue('sk-live-secret')).toBe('<14 chars>'); + }); +}); diff --git a/cli/src/ui/doctor.ts b/cli/src/ui/doctor.ts index 084774989..0189d606d 100644 --- a/cli/src/ui/doctor.ts +++ b/cli/src/ui/doctor.ts @@ -17,6 +17,41 @@ import { join } from 'node:path' import { projectPath } from '@/projectPath' import packageJson from '../../package.json' +export function maskValue(value: string): string; +export function maskValue(value: string | undefined): string | undefined; +export function maskValue(value: string | undefined): string | undefined { + if (value === undefined) return undefined; + if (value.trim() === '') return '<empty>'; + + // Treat ${VAR} templates as safe to display (they do not contain secrets themselves). + if (/^\$\{[A-Z_][A-Z0-9_]*\}$/.test(value)) return value; + + // For templates with default values, preserve the template structure but mask the fallback. + // Example: ${OPENAI_API_KEY:-sk-...} -> ${OPENAI_API_KEY:-<N chars>} + const matchWithFallback = value.match(/^\$\{([A-Z_][A-Z0-9_]*)(:-|:=)(.*)\}$/); + if (matchWithFallback) { + const [, sourceVar, operator, fallback] = matchWithFallback; + if (fallback === '') return `\${${sourceVar}${operator}}`; + return `\${${sourceVar}${operator}${maskValue(fallback)}}`; + } + + return `<${value.length} chars>`; +} + +type SettingsForDisplay = Awaited<ReturnType<typeof readSettings>>; + +function redactSettingsForDisplay(settings: SettingsForDisplay): SettingsForDisplay { + const redacted = JSON.parse(JSON.stringify(settings ?? {})) as SettingsForDisplay; + const redactedRecord = redacted as unknown as Record<string, unknown>; + + // Remove any legacy CLI-local env cache; it may contain secrets. + if (Object.prototype.hasOwnProperty.call(redactedRecord, 'localEnvironmentVariables')) { + delete redactedRecord.localEnvironmentVariables; + } + + return redacted; +} + /** * Get relevant environment information for debugging */ @@ -120,7 +155,7 @@ export async function runDoctorCommand(filter?: 'all' | 'daemon'): Promise<void> try { const settings = await readSettings(); console.log(chalk.bold('\n📄 Settings (settings.json):')); - console.log(chalk.gray(JSON.stringify(settings, null, 2))); + console.log(chalk.gray(JSON.stringify(redactSettingsForDisplay(settings), null, 2))); } catch (error) { console.log(chalk.bold('\n📄 Settings:')); console.log(chalk.red('❌ Failed to read settings')); @@ -266,4 +301,4 @@ export async function runDoctorCommand(filter?: 'all' | 'daemon'): Promise<void> } console.log(chalk.green('\n✅ Doctor diagnosis complete!\n')); -} \ No newline at end of file +} diff --git a/cli/src/ui/formatErrorForUi.test.ts b/cli/src/ui/formatErrorForUi.test.ts new file mode 100644 index 000000000..d71017194 --- /dev/null +++ b/cli/src/ui/formatErrorForUi.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import { formatErrorForUi } from './formatErrorForUi'; + +describe('formatErrorForUi', () => { + it('formats Error instances using stack when available', () => { + const err = new Error('boom'); + err.stack = 'STACK'; + expect(formatErrorForUi(err)).toContain('STACK'); + }); + + it('formats non-Error values as strings', () => { + expect(formatErrorForUi('nope')).toBe('nope'); + expect(formatErrorForUi(123)).toBe('123'); + }); + + it('truncates long output with a suffix', () => { + const input = 'x'.repeat(1201); + const out = formatErrorForUi(input, { maxChars: 1000 }); + expect(out).toContain('…[truncated]'); + expect(out.startsWith('x'.repeat(1000))).toBe(true); + }); +}); + diff --git a/cli/src/ui/formatErrorForUi.ts b/cli/src/ui/formatErrorForUi.ts new file mode 100644 index 000000000..ee11128f3 --- /dev/null +++ b/cli/src/ui/formatErrorForUi.ts @@ -0,0 +1,14 @@ +/** + * Convert an unknown thrown value into a user-visible string. + * + * Intended for UI surfaces (TUI/mobile) where giant stacks can be noisy; we keep a generous cap. + */ +export function formatErrorForUi(error: unknown, opts?: { maxChars?: number }): string { + const maxChars = Math.max(1000, opts?.maxChars ?? 50_000); + const msg = error instanceof Error + ? (error.stack || error.message || String(error)) + : String(error); + + return msg.length > maxChars ? `${msg.slice(0, maxChars)}\n…[truncated]` : msg; +} + diff --git a/cli/src/ui/ink/AgentLogShell.tsx b/cli/src/ui/ink/AgentLogShell.tsx new file mode 100644 index 000000000..4e0612bbe --- /dev/null +++ b/cli/src/ui/ink/AgentLogShell.tsx @@ -0,0 +1,229 @@ +/** + * AgentLogShell + * + * Reusable Ink “agent display” shell for read-only terminal sessions. + * Renders a scrolling message log (from MessageBuffer) and a footer with exit controls. + * + * Provider-specific displays should live under their backend folders (e.g. src/backends/codex/ui) + * and use this component as a thin wrapper. + */ + +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { Box, Text, useInput, useStdout } from 'ink'; + +import { MessageBuffer, type BufferedMessage } from './messageBuffer'; + +type ExitConfirmationState = { + confirmationMode: boolean; + actionInProgress: boolean; +}; + +function getMessageColor(type: BufferedMessage['type']): string { + switch (type) { + case 'user': + return 'magenta'; + case 'assistant': + return 'cyan'; + case 'system': + return 'blue'; + case 'tool': + return 'yellow'; + case 'result': + return 'green'; + case 'status': + return 'gray'; + default: + return 'white'; + } +} + +function wrapToWidth(text: string, maxLineLength: number): string { + if (maxLineLength <= 0) return text; + const lines = text.split('\n'); + return lines + .map((line) => { + if (line.length <= maxLineLength) return line; + const chunks: string[] = []; + for (let i = 0; i < line.length; i += maxLineLength) { + chunks.push(line.slice(i, i + maxLineLength)); + } + return chunks.join('\n'); + }) + .join('\n'); +} + +export type AgentLogShellProps = { + messageBuffer: MessageBuffer; + title: string; + accentColor?: string; + logPath?: string; + footerLines?: string[]; + filterMessage?: (msg: BufferedMessage) => boolean; + onExit?: () => void | Promise<void>; +}; + +export const AgentLogShell: React.FC<AgentLogShellProps> = ({ + messageBuffer, + title, + accentColor, + logPath, + footerLines, + filterMessage, + onExit, +}) => { + const [messages, setMessages] = useState<BufferedMessage[]>([]); + const [exitState, setExitState] = useState<ExitConfirmationState>({ + confirmationMode: false, + actionInProgress: false, + }); + + const confirmationTimeoutRef = useRef<NodeJS.Timeout | null>(null); + const { stdout } = useStdout(); + const terminalWidth = stdout.columns || 80; + const terminalHeight = stdout.rows || 24; + + useEffect(() => { + setMessages(messageBuffer.getMessages()); + const unsubscribe = messageBuffer.onUpdate((newMessages) => setMessages(newMessages)); + return () => { + unsubscribe(); + if (confirmationTimeoutRef.current) { + clearTimeout(confirmationTimeoutRef.current); + } + }; + }, [messageBuffer]); + + const resetExitConfirmation = useCallback(() => { + setExitState((s) => ({ ...s, confirmationMode: false })); + if (confirmationTimeoutRef.current) { + clearTimeout(confirmationTimeoutRef.current); + confirmationTimeoutRef.current = null; + } + }, []); + + const setExitConfirmationWithTimeout = useCallback(() => { + setExitState((s) => ({ ...s, confirmationMode: true })); + if (confirmationTimeoutRef.current) { + clearTimeout(confirmationTimeoutRef.current); + } + confirmationTimeoutRef.current = setTimeout(() => resetExitConfirmation(), 15000); + }, [resetExitConfirmation]); + + useInput( + useCallback( + async (input, key) => { + if (exitState.actionInProgress) return; + + if (key.ctrl && input === 'c') { + if (exitState.confirmationMode) { + resetExitConfirmation(); + setExitState((s) => ({ ...s, actionInProgress: true })); + await new Promise((resolve) => setTimeout(resolve, 100)); + await onExit?.(); + } else { + setExitConfirmationWithTimeout(); + } + return; + } + + if (exitState.confirmationMode) { + resetExitConfirmation(); + } + }, + [exitState.actionInProgress, exitState.confirmationMode, onExit, resetExitConfirmation, setExitConfirmationWithTimeout], + ), + ); + + const displayed = typeof filterMessage === 'function' ? messages.filter(filterMessage) : messages; + const maxVisibleMessages = Math.max(1, terminalHeight - 10); + const visible = displayed.slice(-maxVisibleMessages); + + const formattedTitle = title.trim().length > 0 ? title.trim() : 'Agent'; + const headerColor = accentColor ?? 'gray'; + + const statusBorderColor = exitState.actionInProgress + ? 'gray' + : exitState.confirmationMode + ? 'red' + : (accentColor ?? 'green'); + + const contentMaxLineLength = terminalWidth - 10; + + return ( + <Box flexDirection="column" width={terminalWidth} height={terminalHeight}> + <Box + flexDirection="column" + width={terminalWidth} + borderStyle="round" + borderColor="gray" + paddingX={1} + overflow="hidden" + flexGrow={1} + > + <Box flexDirection="column" marginBottom={1}> + <Text color={headerColor} bold> + {formattedTitle} + </Text> + <Text color="gray" dimColor> + {'─'.repeat(Math.min(terminalWidth - 4, 60))} + </Text> + </Box> + + <Box flexDirection="column" flexGrow={1} overflow="hidden"> + {visible.length === 0 ? ( + <Text color="gray" dimColor> + Waiting for messages... + </Text> + ) : ( + visible.map((msg) => ( + <Box key={msg.id} flexDirection="column" marginBottom={1}> + <Text color={getMessageColor(msg.type)} dimColor> + {wrapToWidth(msg.content, contentMaxLineLength)} + </Text> + </Box> + )) + )} + </Box> + </Box> + + <Box + width={terminalWidth} + borderStyle="round" + borderColor={statusBorderColor} + paddingX={2} + justifyContent="center" + alignItems="center" + flexDirection="column" + minHeight={4} + > + <Box flexDirection="column" alignItems="center"> + {exitState.actionInProgress ? ( + <Text color="gray" bold> + Exiting... + </Text> + ) : exitState.confirmationMode ? ( + <Text color="red" bold> + ⚠️ Press Ctrl-C again to exit + </Text> + ) : ( + <> + <Text color={accentColor ?? 'green'} bold> + {formattedTitle} • Ctrl-C to exit + </Text> + {(footerLines ?? []).map((line, idx) => ( + <Text key={idx} color="gray" dimColor> + {line} + </Text> + ))} + </> + )} + {process.env.DEBUG && logPath && ( + <Text color="gray" dimColor> + Debug logs: {logPath} + </Text> + )} + </Box> + </Box> + </Box> + ); +}; diff --git a/cli/src/ui/ink/CodexDisplay.tsx b/cli/src/ui/ink/CodexDisplay.tsx deleted file mode 100644 index f05f4b662..000000000 --- a/cli/src/ui/ink/CodexDisplay.tsx +++ /dev/null @@ -1,176 +0,0 @@ -import React, { useState, useEffect, useRef, useCallback } from 'react' -import { Box, Text, useStdout, useInput } from 'ink' -import { MessageBuffer, type BufferedMessage } from './messageBuffer' - -interface CodexDisplayProps { - messageBuffer: MessageBuffer - logPath?: string - onExit?: () => void -} - -export const CodexDisplay: React.FC<CodexDisplayProps> = ({ messageBuffer, logPath, onExit }) => { - const [messages, setMessages] = useState<BufferedMessage[]>([]) - const [confirmationMode, setConfirmationMode] = useState<boolean>(false) - const [actionInProgress, setActionInProgress] = useState<boolean>(false) - const confirmationTimeoutRef = useRef<NodeJS.Timeout | null>(null) - const { stdout } = useStdout() - const terminalWidth = stdout.columns || 80 - const terminalHeight = stdout.rows || 24 - - useEffect(() => { - setMessages(messageBuffer.getMessages()) - - const unsubscribe = messageBuffer.onUpdate((newMessages) => { - setMessages(newMessages) - }) - - return () => { - unsubscribe() - if (confirmationTimeoutRef.current) { - clearTimeout(confirmationTimeoutRef.current) - } - } - }, [messageBuffer]) - - const resetConfirmation = useCallback(() => { - setConfirmationMode(false) - if (confirmationTimeoutRef.current) { - clearTimeout(confirmationTimeoutRef.current) - confirmationTimeoutRef.current = null - } - }, []) - - const setConfirmationWithTimeout = useCallback(() => { - setConfirmationMode(true) - if (confirmationTimeoutRef.current) { - clearTimeout(confirmationTimeoutRef.current) - } - confirmationTimeoutRef.current = setTimeout(() => { - resetConfirmation() - }, 15000) // 15 seconds timeout - }, [resetConfirmation]) - - useInput(useCallback(async (input, key) => { - // Don't process input if action is in progress - if (actionInProgress) return - - // Handle Ctrl-C - exits the agent directly instead of switching modes - if (key.ctrl && input === 'c') { - if (confirmationMode) { - // Second Ctrl-C, exit - resetConfirmation() - setActionInProgress(true) - // Small delay to show the status message - await new Promise(resolve => setTimeout(resolve, 100)) - onExit?.() - } else { - // First Ctrl-C, show confirmation - setConfirmationWithTimeout() - } - return - } - - // Any other key cancels confirmation - if (confirmationMode) { - resetConfirmation() - } - }, [confirmationMode, actionInProgress, onExit, setConfirmationWithTimeout, resetConfirmation])) - - const getMessageColor = (type: BufferedMessage['type']): string => { - switch (type) { - case 'user': return 'magenta' - case 'assistant': return 'cyan' - case 'system': return 'blue' - case 'tool': return 'yellow' - case 'result': return 'green' - case 'status': return 'gray' - default: return 'white' - } - } - - const formatMessage = (msg: BufferedMessage): string => { - const lines = msg.content.split('\n') - const maxLineLength = terminalWidth - 10 // Account for borders and padding - return lines.map(line => { - if (line.length <= maxLineLength) return line - const chunks: string[] = [] - for (let i = 0; i < line.length; i += maxLineLength) { - chunks.push(line.slice(i, i + maxLineLength)) - } - return chunks.join('\n') - }).join('\n') - } - - return ( - <Box flexDirection="column" width={terminalWidth} height={terminalHeight}> - {/* Main content area with logs */} - <Box - flexDirection="column" - width={terminalWidth} - height={terminalHeight - 4} - borderStyle="round" - borderColor="gray" - paddingX={1} - overflow="hidden" - > - <Box flexDirection="column" marginBottom={1}> - <Text color="gray" bold>🤖 Codex Agent Messages</Text> - <Text color="gray" dimColor>{'─'.repeat(Math.min(terminalWidth - 4, 60))}</Text> - </Box> - - <Box flexDirection="column" height={terminalHeight - 10} overflow="hidden"> - {messages.length === 0 ? ( - <Text color="gray" dimColor>Waiting for messages...</Text> - ) : ( - // Show only the last messages that fit in the available space - messages.slice(-Math.max(1, terminalHeight - 10)).map((msg) => ( - <Box key={msg.id} flexDirection="column" marginBottom={1}> - <Text color={getMessageColor(msg.type)} dimColor> - {formatMessage(msg)} - </Text> - </Box> - )) - )} - </Box> - </Box> - - {/* Modal overlay at the bottom */} - <Box - width={terminalWidth} - borderStyle="round" - borderColor={ - actionInProgress ? "gray" : - confirmationMode ? "red" : - "green" - } - paddingX={2} - justifyContent="center" - alignItems="center" - flexDirection="column" - > - <Box flexDirection="column" alignItems="center"> - {actionInProgress ? ( - <Text color="gray" bold> - Exiting agent... - </Text> - ) : confirmationMode ? ( - <Text color="red" bold> - ⚠️ Press Ctrl-C again to exit the agent - </Text> - ) : ( - <> - <Text color="green" bold> - 🤖 Codex Agent Running • Ctrl-C to exit - </Text> - </> - )} - {process.env.DEBUG && logPath && ( - <Text color="gray" dimColor> - Debug logs: {logPath} - </Text> - )} - </Box> - </Box> - </Box> - ) -} \ No newline at end of file diff --git a/cli/src/ui/ink/GeminiDisplay.tsx b/cli/src/ui/ink/GeminiDisplay.tsx deleted file mode 100644 index a54631fd6..000000000 --- a/cli/src/ui/ink/GeminiDisplay.tsx +++ /dev/null @@ -1,234 +0,0 @@ -/** - * GeminiDisplay - Ink UI component for Gemini agent - * - * This component provides a terminal UI for the Gemini agent, - * displaying messages, status, and handling user input. - */ - -import React, { useState, useEffect, useRef, useCallback } from 'react'; -import { Box, Text, useStdout, useInput } from 'ink'; -import { MessageBuffer, type BufferedMessage } from './messageBuffer'; - -interface GeminiDisplayProps { - messageBuffer: MessageBuffer; - logPath?: string; - currentModel?: string; - onExit?: () => void; -} - -export const GeminiDisplay: React.FC<GeminiDisplayProps> = ({ messageBuffer, logPath, currentModel, onExit }) => { - const [messages, setMessages] = useState<BufferedMessage[]>([]); - const [confirmationMode, setConfirmationMode] = useState<boolean>(false); - const [actionInProgress, setActionInProgress] = useState<boolean>(false); - const [model, setModel] = useState<string | undefined>(currentModel); - const confirmationTimeoutRef = useRef<NodeJS.Timeout | null>(null); - const { stdout } = useStdout(); - const terminalWidth = stdout.columns || 80; - const terminalHeight = stdout.rows || 24; - - // Update model when prop changes (only if different to avoid loops) - useEffect(() => { - if (currentModel !== undefined && currentModel !== model) { - setModel(currentModel); - } - }, [currentModel]); // Only depend on currentModel, not model, to avoid loops - - useEffect(() => { - setMessages(messageBuffer.getMessages()); - - const unsubscribe = messageBuffer.onUpdate((newMessages) => { - setMessages(newMessages); - - // Extract model from [MODEL:...] messages when messages update - // Use reverse + find to get the LATEST model message (in case model was changed) - const modelMessage = [...newMessages].reverse().find(msg => - msg.type === 'system' && msg.content.startsWith('[MODEL:') - ); - - if (modelMessage) { - const modelMatch = modelMessage.content.match(/\[MODEL:(.+?)\]/); - if (modelMatch && modelMatch[1]) { - const extractedModel = modelMatch[1]; - setModel(prevModel => { - // Only update if different to avoid unnecessary re-renders - if (extractedModel !== prevModel) { - return extractedModel; - } - return prevModel; - }); - } - } - }); - - return () => { - unsubscribe(); - if (confirmationTimeoutRef.current) { - clearTimeout(confirmationTimeoutRef.current); - } - }; - }, [messageBuffer]); - - const resetConfirmation = useCallback(() => { - setConfirmationMode(false); - if (confirmationTimeoutRef.current) { - clearTimeout(confirmationTimeoutRef.current); - confirmationTimeoutRef.current = null; - } - }, []); - - const setConfirmationWithTimeout = useCallback(() => { - setConfirmationMode(true); - if (confirmationTimeoutRef.current) { - clearTimeout(confirmationTimeoutRef.current); - } - confirmationTimeoutRef.current = setTimeout(() => { - resetConfirmation(); - }, 15000); // 15 seconds timeout - }, [resetConfirmation]); - - useInput(useCallback(async (input, key) => { - if (actionInProgress) return; - - // Handle Ctrl-C - if (key.ctrl && input === 'c') { - if (confirmationMode) { - // Second Ctrl-C, exit - resetConfirmation(); - setActionInProgress(true); - await new Promise(resolve => setTimeout(resolve, 100)); - onExit?.(); - } else { - // First Ctrl-C, show confirmation - setConfirmationWithTimeout(); - } - return; - } - - // Any other key cancels confirmation - if (confirmationMode) { - resetConfirmation(); - } - }, [confirmationMode, actionInProgress, onExit, setConfirmationWithTimeout, resetConfirmation])); - - const getMessageColor = (type: BufferedMessage['type']): string => { - switch (type) { - case 'user': return 'magenta'; - case 'assistant': return 'cyan'; - case 'system': return 'blue'; - case 'tool': return 'yellow'; - case 'result': return 'green'; - case 'status': return 'gray'; - default: return 'white'; - } - }; - - const formatMessage = (msg: BufferedMessage): string => { - const lines = msg.content.split('\n'); - const maxLineLength = terminalWidth - 10; - return lines.map(line => { - if (line.length <= maxLineLength) return line; - const chunks: string[] = []; - for (let i = 0; i < line.length; i += maxLineLength) { - chunks.push(line.slice(i, i + maxLineLength)); - } - return chunks.join('\n'); - }).join('\n'); - }; - - return ( - <Box flexDirection="column" width={terminalWidth} height={terminalHeight}> - {/* Main content area with logs */} - <Box - flexDirection="column" - width={terminalWidth} - height={terminalHeight - 4} - borderStyle="round" - borderColor="gray" - paddingX={1} - overflow="hidden" - > - <Box flexDirection="column" marginBottom={1}> - <Text color="cyan" bold>✨ Gemini Agent Messages</Text> - <Text color="gray" dimColor>{'─'.repeat(Math.min(terminalWidth - 4, 60))}</Text> - </Box> - - <Box flexDirection="column" height={terminalHeight - 10} overflow="hidden"> - {messages.length === 0 ? ( - <Text color="gray" dimColor>Waiting for messages...</Text> - ) : ( - messages - .filter(msg => { - // Filter out empty system messages (used for triggering re-renders) - if (msg.type === 'system' && !msg.content.trim()) { - return false; - } - // Filter out model update messages (model extraction happens in useEffect) - if (msg.type === 'system' && msg.content.startsWith('[MODEL:')) { - return false; // Don't show in UI - } - // Filter out status messages that are redundant (shown in status bar) - // But keep Thinking messages - they show agent's reasoning process (like Codex) - if (msg.type === 'system' && msg.content.startsWith('Using model:')) { - return false; // Don't show in UI - redundant with status bar - } - // Keep "Thinking..." and "[Thinking] ..." messages - they show agent's reasoning (like Codex) - return true; - }) - .slice(-Math.max(1, terminalHeight - 10)) - .map((msg, index, array) => ( - <Box key={msg.id} flexDirection="column" marginBottom={index < array.length - 1 ? 1 : 0}> - <Text color={getMessageColor(msg.type)} dimColor> - {formatMessage(msg)} - </Text> - </Box> - )) - )} - </Box> - </Box> - - {/* Status bar at the bottom */} - <Box - width={terminalWidth} - borderStyle="round" - borderColor={ - actionInProgress ? 'gray' : - confirmationMode ? 'red' : - 'cyan' - } - paddingX={2} - justifyContent="center" - alignItems="center" - flexDirection="column" - > - <Box flexDirection="column" alignItems="center"> - {actionInProgress ? ( - <Text color="gray" bold> - Exiting agent... - </Text> - ) : confirmationMode ? ( - <Text color="red" bold> - ⚠️ Press Ctrl-C again to exit the agent - </Text> - ) : ( - <> - <Text color="cyan" bold> - ✨ Gemini Agent Running • Ctrl-C to exit - </Text> - {model && ( - <Text color="gray" dimColor> - Model: {model} - </Text> - )} - </> - )} - {process.env.DEBUG && logPath && ( - <Text color="gray" dimColor> - Debug logs: {logPath} - </Text> - )} - </Box> - </Box> - </Box> - ); -}; - diff --git a/cli/src/ui/ink/RemoteModeDisplay.tsx b/cli/src/ui/ink/RemoteModeDisplay.tsx deleted file mode 100644 index 2a5691487..000000000 --- a/cli/src/ui/ink/RemoteModeDisplay.tsx +++ /dev/null @@ -1,202 +0,0 @@ -import React, { useState, useEffect, useRef, useCallback } from 'react' -import { Box, Text, useStdout, useInput } from 'ink' -import { MessageBuffer, type BufferedMessage } from './messageBuffer' - -interface RemoteModeDisplayProps { - messageBuffer: MessageBuffer - logPath?: string - onExit?: () => void - onSwitchToLocal?: () => void -} - -export const RemoteModeDisplay: React.FC<RemoteModeDisplayProps> = ({ messageBuffer, logPath, onExit, onSwitchToLocal }) => { - const [messages, setMessages] = useState<BufferedMessage[]>([]) - const [confirmationMode, setConfirmationMode] = useState<'exit' | 'switch' | null>(null) - const [actionInProgress, setActionInProgress] = useState<'exiting' | 'switching' | null>(null) - const confirmationTimeoutRef = useRef<NodeJS.Timeout | null>(null) - const { stdout } = useStdout() - const terminalWidth = stdout.columns || 80 - const terminalHeight = stdout.rows || 24 - - useEffect(() => { - setMessages(messageBuffer.getMessages()) - - const unsubscribe = messageBuffer.onUpdate((newMessages) => { - setMessages(newMessages) - }) - - return () => { - unsubscribe() - if (confirmationTimeoutRef.current) { - clearTimeout(confirmationTimeoutRef.current) - } - } - }, [messageBuffer]) - - const resetConfirmation = useCallback(() => { - setConfirmationMode(null) - if (confirmationTimeoutRef.current) { - clearTimeout(confirmationTimeoutRef.current) - confirmationTimeoutRef.current = null - } - }, []) - - const setConfirmationWithTimeout = useCallback((mode: 'exit' | 'switch') => { - setConfirmationMode(mode) - if (confirmationTimeoutRef.current) { - clearTimeout(confirmationTimeoutRef.current) - } - confirmationTimeoutRef.current = setTimeout(() => { - resetConfirmation() - }, 15000) // 15 seconds timeout - }, [resetConfirmation]) - - useInput(useCallback(async (input, key) => { - // Don't process input if action is in progress - if (actionInProgress) return - - // Handle Ctrl-C - if (key.ctrl && input === 'c') { - if (confirmationMode === 'exit') { - // Second Ctrl-C, exit - resetConfirmation() - setActionInProgress('exiting') - // Small delay to show the status message - await new Promise(resolve => setTimeout(resolve, 100)) - onExit?.() - } else { - // First Ctrl-C, show confirmation - setConfirmationWithTimeout('exit') - } - return - } - - // Handle double space - if (input === ' ') { - if (confirmationMode === 'switch') { - // Second space, switch to local - resetConfirmation() - setActionInProgress('switching') - // Small delay to show the status message - await new Promise(resolve => setTimeout(resolve, 100)) - onSwitchToLocal?.() - } else { - // First space, show confirmation - setConfirmationWithTimeout('switch') - } - return - } - - // Any other key cancels confirmation - if (confirmationMode) { - resetConfirmation() - } - }, [confirmationMode, actionInProgress, onExit, onSwitchToLocal, setConfirmationWithTimeout, resetConfirmation])) - - const getMessageColor = (type: BufferedMessage['type']): string => { - switch (type) { - case 'user': return 'magenta' - case 'assistant': return 'cyan' - case 'system': return 'blue' - case 'tool': return 'yellow' - case 'result': return 'green' - case 'status': return 'gray' - default: return 'white' - } - } - - const formatMessage = (msg: BufferedMessage): string => { - const lines = msg.content.split('\n') - const maxLineLength = terminalWidth - 10 // Account for borders and padding - return lines.map(line => { - if (line.length <= maxLineLength) return line - const chunks: string[] = [] - for (let i = 0; i < line.length; i += maxLineLength) { - chunks.push(line.slice(i, i + maxLineLength)) - } - return chunks.join('\n') - }).join('\n') - } - - return ( - <Box flexDirection="column" width={terminalWidth} height={terminalHeight}> - {/* Main content area with logs */} - <Box - flexDirection="column" - width={terminalWidth} - height={terminalHeight - 4} - borderStyle="round" - borderColor="gray" - paddingX={1} - overflow="hidden" - > - <Box flexDirection="column" marginBottom={1}> - <Text color="gray" bold>📡 Remote Mode - Claude Messages</Text> - <Text color="gray" dimColor>{'─'.repeat(Math.min(terminalWidth - 4, 60))}</Text> - </Box> - - <Box flexDirection="column" height={terminalHeight - 10} overflow="hidden"> - {messages.length === 0 ? ( - <Text color="gray" dimColor>Waiting for messages...</Text> - ) : ( - // Show only the last messages that fit in the available space - messages.slice(-Math.max(1, terminalHeight - 10)).map((msg) => ( - <Box key={msg.id} flexDirection="column" marginBottom={1}> - <Text color={getMessageColor(msg.type)} dimColor> - {formatMessage(msg)} - </Text> - </Box> - )) - )} - </Box> - </Box> - - {/* Modal overlay at the bottom */} - <Box - width={terminalWidth} - borderStyle="round" - borderColor={ - actionInProgress ? "gray" : - confirmationMode === 'exit' ? "red" : - confirmationMode === 'switch' ? "yellow" : - "green" - } - paddingX={2} - justifyContent="center" - alignItems="center" - flexDirection="column" - > - <Box flexDirection="column" alignItems="center"> - {actionInProgress === 'exiting' ? ( - <Text color="gray" bold> - Exiting... - </Text> - ) : actionInProgress === 'switching' ? ( - <Text color="gray" bold> - Switching to local mode... - </Text> - ) : confirmationMode === 'exit' ? ( - <Text color="red" bold> - ⚠️ Press Ctrl-C again to exit completely - </Text> - ) : confirmationMode === 'switch' ? ( - <Text color="yellow" bold> - ⏸️ Press space again to switch to local mode - </Text> - ) : ( - <> - <Text color="green" bold> - 📱 Press space to switch to local mode • Ctrl-C to exit - </Text> - </> - )} - {process.env.DEBUG && logPath && ( - <Text color="gray" dimColor> - Debug logs: {logPath} - </Text> - )} - </Box> - </Box> - </Box> - ) -} \ No newline at end of file diff --git a/cli/src/ui/ink/cleanupStdinAfterInk.test.ts b/cli/src/ui/ink/cleanupStdinAfterInk.test.ts new file mode 100644 index 000000000..e9ebf0c33 --- /dev/null +++ b/cli/src/ui/ink/cleanupStdinAfterInk.test.ts @@ -0,0 +1,63 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanupStdinAfterInk } from './cleanupStdinAfterInk'; + +function createFakeStdin() { + const listeners = new Map<string, Set<(...args: any[]) => void>>(); + const calls: Array<{ name: string; args: any[] }> = []; + + const api = { + isTTY: true, + on: (event: string, fn: (...args: any[]) => void) => { + calls.push({ name: 'on', args: [event] }); + const set = listeners.get(event) ?? new Set(); + set.add(fn); + listeners.set(event, set); + return api as any; + }, + off: (event: string, fn: (...args: any[]) => void) => { + calls.push({ name: 'off', args: [event] }); + listeners.get(event)?.delete(fn); + return api as any; + }, + resume: () => { + calls.push({ name: 'resume', args: [] }); + }, + pause: () => { + calls.push({ name: 'pause', args: [] }); + }, + setRawMode: (value: boolean) => { + calls.push({ name: 'setRawMode', args: [value] }); + }, + __calls: calls, + __listenerCount: (event: string) => listeners.get(event)?.size ?? 0, + }; + + return api; +} + +describe('cleanupStdinAfterInk', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('drains buffered input and pauses stdin', async () => { + vi.useFakeTimers(); + const stdin = createFakeStdin(); + + const promise = cleanupStdinAfterInk({ stdin: stdin as any, drainMs: 50 }); + await vi.advanceTimersByTimeAsync(60); + await promise; + + expect(stdin.__calls.some((c) => c.name === 'setRawMode' && c.args[0] === false)).toBe(true); + expect(stdin.__calls.some((c) => c.name === 'resume')).toBe(true); + expect(stdin.__calls.some((c) => c.name === 'pause')).toBe(true); + expect(stdin.__listenerCount('data')).toBe(0); + }); + + it('is a no-op when stdin is not a TTY', async () => { + const stdin = createFakeStdin(); + (stdin as any).isTTY = false; + await cleanupStdinAfterInk({ stdin: stdin as any, drainMs: 50 }); + expect(stdin.__calls.length).toBe(0); + }); +}); diff --git a/cli/src/ui/ink/cleanupStdinAfterInk.ts b/cli/src/ui/ink/cleanupStdinAfterInk.ts new file mode 100644 index 000000000..a1a40bbfa --- /dev/null +++ b/cli/src/ui/ink/cleanupStdinAfterInk.ts @@ -0,0 +1,57 @@ +export async function cleanupStdinAfterInk(opts: { + stdin: { + isTTY?: boolean; + on: (event: 'data', listener: (chunk: unknown) => void) => unknown; + off: (event: 'data', listener: (chunk: unknown) => void) => unknown; + resume: () => void; + pause: () => void; + setRawMode?: (value: boolean) => void; + }; + /** + * Drain buffered input for this many ms after the UI unmounts. + * This helps prevent users' “space spam” (used to switch modes) from being + * delivered to the next interactive child process. + */ + drainMs?: number; +}): Promise<void> { + const stdin = opts.stdin; + if (!stdin.isTTY) return; + + try { + stdin.setRawMode?.(false); + } catch { + // best-effort + } + + const drainMs = Math.max(0, opts.drainMs ?? 0); + if (drainMs === 0) { + try { + stdin.pause(); + } catch { + // best-effort + } + return; + } + + const drainListener = () => { + // Intentionally discard input. + }; + + try { + stdin.on('data', drainListener); + stdin.resume(); + await new Promise<void>((resolve) => setTimeout(resolve, drainMs)); + } finally { + try { + stdin.off('data', drainListener); + } catch { + // best-effort + } + try { + stdin.pause(); + } catch { + // best-effort + } + } +} + diff --git a/cli/src/ui/ink/readOnlyFooterLines.ts b/cli/src/ui/ink/readOnlyFooterLines.ts new file mode 100644 index 000000000..22f6af675 --- /dev/null +++ b/cli/src/ui/ink/readOnlyFooterLines.ts @@ -0,0 +1,16 @@ +/** + * Shared footer copy for read-only terminal displays. + * + * These displays intentionally do not accept prompts from stdin; users should + * interact via the Happy app/web until an interactive terminal mode exists for + * the provider. + */ + +export function buildReadOnlyFooterLines(providerName: string): string[] { + const name = providerName.trim().length > 0 ? providerName.trim() : 'this provider'; + return [ + "Logs only — you can’t send prompts from this terminal.", + `Use the Happy app/web (interactive terminal mode isn’t supported for ${name}).`, + ]; +} + diff --git a/cli/src/ui/logger.test.ts b/cli/src/ui/logger.test.ts new file mode 100644 index 000000000..8bfc11786 --- /dev/null +++ b/cli/src/ui/logger.test.ts @@ -0,0 +1,65 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { chmodSync, existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +describe('logger.debugLargeJson', () => { + const originalDebug = process.env.DEBUG; + const originalHappyHomeDir = process.env.HAPPY_HOME_DIR; + let tempDir: string; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'happy-cli-logger-test-')); + process.env.HAPPY_HOME_DIR = tempDir; + delete process.env.DEBUG; + vi.resetModules(); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + if (originalHappyHomeDir === undefined) delete process.env.HAPPY_HOME_DIR; + else process.env.HAPPY_HOME_DIR = originalHappyHomeDir; + + if (originalDebug === undefined) delete process.env.DEBUG; + else process.env.DEBUG = originalDebug; + }); + + it('does not write to log file when DEBUG is not set', async () => { + const { logger } = (await import('@/ui/logger')) as typeof import('@/ui/logger'); + + logger.debugLargeJson('[TEST] debugLargeJson', { secret: 'value' }); + + expect(existsSync(logger.getLogPath())).toBe(false); + }); + + it('writes to log file when DEBUG is set', async () => { + process.env.DEBUG = '1'; + + const { logger } = (await import('@/ui/logger')) as typeof import('@/ui/logger'); + + logger.debugLargeJson('[TEST] debugLargeJson', { secret: 'value' }); + + expect(existsSync(logger.getLogPath())).toBe(true); + const content = readFileSync(logger.getLogPath(), 'utf8'); + expect(content).toContain('[TEST] debugLargeJson'); + }); + + it('does not throw if log file cannot be written (even when DEBUG is set)', async () => { + // Make logs dir read-only so appendFileSync fails deterministically. + const logsDir = join(tempDir, 'logs'); + mkdirSync(logsDir, { recursive: true }); + chmodSync(logsDir, 0o555); + + process.env.DEBUG = '1'; + + const { logger } = (await import('@/ui/logger')) as typeof import('@/ui/logger'); + + try { + expect(() => { + logger.debugLargeJson('[TEST] debugLargeJson write should not throw', { secret: 'value' }); + }).not.toThrow(); + } finally { + chmodSync(logsDir, 0o755); + } + }); +}); diff --git a/cli/src/ui/logger.ts b/cli/src/ui/logger.ts index ecf739b61..b2646e370 100644 --- a/cli/src/ui/logger.ts +++ b/cli/src/ui/logger.ts @@ -47,6 +47,7 @@ function getSessionLogPath(): string { class Logger { private dangerouslyUnencryptedServerLoggingUrl: string | undefined + private hasLoggedFileWriteError: boolean = false constructor( public readonly logFilePath = getSessionLogPath() @@ -84,9 +85,7 @@ class Logger { maxStringLength: number = 100, maxArrayLength: number = 10, ): void { - if (!process.env.DEBUG) { - this.debug(`In production, skipping message inspection`) - } + if (!process.env.DEBUG) return; // Some of our messages are huge, but we still want to show them in the logs const truncateStrings = (obj: unknown): unknown => { @@ -222,11 +221,13 @@ class Logger { try { appendFileSync(this.logFilePath, logLine) } catch (appendError) { - if (process.env.DEBUG) { - console.error('[DEV MODE ONLY THROWING] Failed to append to log file:', appendError) - throw appendError + // Never throw from logging: log files are best-effort and should not break the CLI. + // When DEBUG is set, surface the first write failure for easier debugging. + if (process.env.DEBUG && !this.hasLoggedFileWriteError) { + console.error('[DEV MODE ONLY] Failed to append to log file:', appendError) + this.hasLoggedFileWriteError = true } - // In production, fail silently to avoid disturbing Claude session + // In production (and after the first DEBUG warning), fail silently to avoid disturbing the session. } } } diff --git a/cli/src/ui/messageFormatter.ts b/cli/src/ui/messageFormatter.ts index 2454cfb1a..903a7a96c 100644 --- a/cli/src/ui/messageFormatter.ts +++ b/cli/src/ui/messageFormatter.ts @@ -1,5 +1,5 @@ import chalk from 'chalk'; -import type { SDKMessage, SDKAssistantMessage, SDKResultMessage, SDKSystemMessage, SDKUserMessage } from '@/claude/sdk'; +import type { SDKMessage, SDKAssistantMessage, SDKResultMessage, SDKSystemMessage, SDKUserMessage } from '@/backends/claude/sdk'; import { logger } from './logger'; export type OnAssistantResultCallback = (result: SDKResultMessage) => void | Promise<void>; diff --git a/cli/src/ui/messageFormatterInk.ts b/cli/src/ui/messageFormatterInk.ts index 16e79bd86..6da12f18f 100644 --- a/cli/src/ui/messageFormatterInk.ts +++ b/cli/src/ui/messageFormatterInk.ts @@ -1,4 +1,4 @@ -import type { SDKMessage, SDKAssistantMessage, SDKResultMessage, SDKSystemMessage, SDKUserMessage } from '@/claude/sdk' +import type { SDKMessage, SDKAssistantMessage, SDKResultMessage, SDKSystemMessage, SDKUserMessage } from '@/backends/claude/sdk' import type { MessageBuffer } from './ink/messageBuffer' import { logger } from './logger' diff --git a/cli/src/ui/openBrowser.test.ts b/cli/src/ui/openBrowser.test.ts new file mode 100644 index 000000000..580ddd563 --- /dev/null +++ b/cli/src/ui/openBrowser.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; + +import { openBrowser } from './openBrowser'; + +function trySetStdoutIsTty(value: boolean): (() => void) | null { + const desc = Object.getOwnPropertyDescriptor(process.stdout, 'isTTY'); + try { + Object.defineProperty(process.stdout, 'isTTY', { value, configurable: true }); + return () => { + try { + if (desc) { + Object.defineProperty(process.stdout, 'isTTY', desc); + } + } catch { + // ignore restore failures + } + }; + } catch { + return null; + } +} + +describe('openBrowser', () => { + it('returns false when HAPPY_NO_BROWSER_OPEN is set', async () => { + const restoreTty = trySetStdoutIsTty(true); + const prev = process.env.HAPPY_NO_BROWSER_OPEN; + process.env.HAPPY_NO_BROWSER_OPEN = '1'; + + try { + const ok = await openBrowser('https://example.com'); + expect(ok).toBe(false); + } finally { + if (prev === undefined) delete process.env.HAPPY_NO_BROWSER_OPEN; + else process.env.HAPPY_NO_BROWSER_OPEN = prev; + restoreTty?.(); + } + }); +}); diff --git a/cli/src/utils/browser.ts b/cli/src/ui/openBrowser.ts similarity index 71% rename from cli/src/utils/browser.ts rename to cli/src/ui/openBrowser.ts index a843b9344..db416b758 100644 --- a/cli/src/utils/browser.ts +++ b/cli/src/ui/openBrowser.ts @@ -9,6 +9,12 @@ import { logger } from '@/ui/logger'; */ export async function openBrowser(url: string): Promise<boolean> { try { + const noOpenRaw = (process.env.HAPPY_NO_BROWSER_OPEN ?? '').toString().trim(); + const noOpen = Boolean(noOpenRaw) && noOpenRaw !== '0' && noOpenRaw.toLowerCase() !== 'false'; + if (noOpen) { + logger.debug('[browser] Browser opening disabled (HAPPY_NO_BROWSER_OPEN), skipping browser open'); + return false; + } // Check if we're in a headless environment if (!process.stdout.isTTY || process.env.CI || process.env.HEADLESS) { logger.debug('[browser] Headless environment detected, skipping browser open'); diff --git a/cli/src/utils/BasePermissionHandler.ts b/cli/src/utils/BasePermissionHandler.ts deleted file mode 100644 index 362a9ed5c..000000000 --- a/cli/src/utils/BasePermissionHandler.ts +++ /dev/null @@ -1,193 +0,0 @@ -/** - * Base Permission Handler - * - * Abstract base class for permission handlers that manage tool approval requests. - * Shared by Codex and Gemini permission handlers. - * - * @module BasePermissionHandler - */ - -import { logger } from "@/ui/logger"; -import { ApiSessionClient } from "@/api/apiSession"; -import { AgentState } from "@/api/types"; - -/** - * Permission response from the mobile app. - */ -export interface PermissionResponse { - id: string; - approved: boolean; - decision?: 'approved' | 'approved_for_session' | 'denied' | 'abort'; -} - -/** - * Pending permission request stored while awaiting user response. - */ -export interface PendingRequest { - resolve: (value: PermissionResult) => void; - reject: (error: Error) => void; - toolName: string; - input: unknown; -} - -/** - * Result of a permission request. - */ -export interface PermissionResult { - decision: 'approved' | 'approved_for_session' | 'denied' | 'abort'; -} - -/** - * Abstract base class for permission handlers. - * - * Subclasses must implement: - * - `getLogPrefix()` - returns the log prefix (e.g., '[Codex]') - */ -export abstract class BasePermissionHandler { - protected pendingRequests = new Map<string, PendingRequest>(); - protected session: ApiSessionClient; - private isResetting = false; - - /** - * Returns the log prefix for this handler. - */ - protected abstract getLogPrefix(): string; - - constructor(session: ApiSessionClient) { - this.session = session; - this.setupRpcHandler(); - } - - /** - * Update the session reference (used after offline reconnection swaps sessions). - * This is critical for avoiding stale session references after onSessionSwap. - */ - updateSession(newSession: ApiSessionClient): void { - logger.debug(`${this.getLogPrefix()} Session reference updated`); - this.session = newSession; - // Re-setup RPC handler with new session - this.setupRpcHandler(); - } - - /** - * Setup RPC handler for permission responses. - */ - protected setupRpcHandler(): void { - this.session.rpcHandlerManager.registerHandler<PermissionResponse, void>( - 'permission', - async (response) => { - const pending = this.pendingRequests.get(response.id); - if (!pending) { - logger.debug(`${this.getLogPrefix()} Permission request not found or already resolved`); - return; - } - - // Remove from pending - this.pendingRequests.delete(response.id); - - // Resolve the permission request - const result: PermissionResult = response.approved - ? { decision: response.decision === 'approved_for_session' ? 'approved_for_session' : 'approved' } - : { decision: response.decision === 'denied' ? 'denied' : 'abort' }; - - pending.resolve(result); - - // Move request to completed in agent state - this.session.updateAgentState((currentState) => { - const request = currentState.requests?.[response.id]; - if (!request) return currentState; - - const { [response.id]: _, ...remainingRequests } = currentState.requests || {}; - - let res = { - ...currentState, - requests: remainingRequests, - completedRequests: { - ...currentState.completedRequests, - [response.id]: { - ...request, - completedAt: Date.now(), - status: response.approved ? 'approved' : 'denied', - decision: result.decision - } - } - } satisfies AgentState; - return res; - }); - - logger.debug(`${this.getLogPrefix()} Permission ${response.approved ? 'approved' : 'denied'} for ${pending.toolName}`); - } - ); - } - - /** - * Add a pending request to the agent state. - */ - protected addPendingRequestToState(toolCallId: string, toolName: string, input: unknown): void { - this.session.updateAgentState((currentState) => ({ - ...currentState, - requests: { - ...currentState.requests, - [toolCallId]: { - tool: toolName, - arguments: input, - createdAt: Date.now() - } - } - })); - } - - /** - * Reset state for new sessions. - * This method is idempotent - safe to call multiple times. - */ - reset(): void { - // Guard against re-entrant/concurrent resets - if (this.isResetting) { - logger.debug(`${this.getLogPrefix()} Reset already in progress, skipping`); - return; - } - this.isResetting = true; - - try { - // Snapshot pending requests to avoid Map mutation during iteration - const pendingSnapshot = Array.from(this.pendingRequests.entries()); - this.pendingRequests.clear(); // Clear immediately to prevent new entries being processed - - // Reject all pending requests from snapshot - for (const [id, pending] of pendingSnapshot) { - try { - pending.reject(new Error('Session reset')); - } catch (err) { - logger.debug(`${this.getLogPrefix()} Error rejecting pending request ${id}:`, err); - } - } - - // Clear requests in agent state - this.session.updateAgentState((currentState) => { - const pendingRequests = currentState.requests || {}; - const completedRequests = { ...currentState.completedRequests }; - - // Move all pending to completed as canceled - for (const [id, request] of Object.entries(pendingRequests)) { - completedRequests[id] = { - ...request, - completedAt: Date.now(), - status: 'canceled', - reason: 'Session reset' - }; - } - - return { - ...currentState, - requests: {}, - completedRequests - }; - }); - - logger.debug(`${this.getLogPrefix()} Permission handler reset`); - } finally { - this.isResetting = false; - } - } -} diff --git a/cli/src/utils/MessageQueue.ts b/cli/src/utils/MessageQueue.ts index 6226c6c8d..988e33e62 100644 --- a/cli/src/utils/MessageQueue.ts +++ b/cli/src/utils/MessageQueue.ts @@ -1,4 +1,4 @@ -import { SDKMessage, SDKUserMessage } from "@/claude/sdk"; +import { SDKMessage, SDKUserMessage } from "@/backends/claude/sdk"; import { logger } from "@/ui/logger"; /** diff --git a/cli/src/utils/MessageQueue2.ts b/cli/src/utils/MessageQueue2.ts index 45f254ed9..9ad1119d0 100644 --- a/cli/src/utils/MessageQueue2.ts +++ b/cli/src/utils/MessageQueue2.ts @@ -17,6 +17,8 @@ export class MessageQueue2<T> { private closed = false; private onMessageHandler: ((message: string, mode: T) => void) | null = null; modeHasher: (mode: T) => string; + private lastWaitLogAt = 0; + private lastAbortLogAt = 0; constructor( modeHasher: (mode: T) => string, @@ -293,7 +295,14 @@ export class MessageQueue2<T> { // Set up abort handler if (abortSignal) { abortHandler = () => { - logger.debug('[MessageQueue2] Wait aborted'); + const reason = (abortSignal as any)?.reason; + if (reason !== 'waitForMessagesOrPending') { + const now = Date.now(); + if (now - this.lastAbortLogAt > 2000) { + this.lastAbortLogAt = now; + logger.debug('[MessageQueue2] Wait aborted'); + } + } // Clear waiter if it's still set if (this.waiter === waiterFunc) { this.waiter = null; @@ -330,7 +339,13 @@ export class MessageQueue2<T> { // Set the waiter this.waiter = waiterFunc; - logger.debug('[MessageQueue2] Waiting for messages...'); + { + const now = Date.now(); + if (now - this.lastWaitLogAt > 2000) { + this.lastWaitLogAt = now; + logger.debug('[MessageQueue2] Waiting for messages...'); + } + } }); } -} \ No newline at end of file +} diff --git a/cli/src/utils/expandEnvVars.test.ts b/cli/src/utils/expandEnvVars.test.ts index bf9c9a02c..c4f558ccc 100644 --- a/cli/src/utils/expandEnvVars.test.ts +++ b/cli/src/utils/expandEnvVars.test.ts @@ -134,6 +134,70 @@ describe('expandEnvironmentVariables', () => { }); }); + it('should use default for ${VAR:-default} when VAR is missing', () => { + const envVars = { + TARGET: '${MISSING_VAR:-default-value}', + }; + const sourceEnv = {}; + + const result = expandEnvironmentVariables(envVars, sourceEnv); + expect(result).toEqual({ + TARGET: 'default-value', + }); + }); + + it('should use default for ${VAR:=default} when VAR is missing', () => { + const envVars = { + TARGET: '${MISSING_VAR:=default-value}', + }; + const sourceEnv = {}; + + const result = expandEnvironmentVariables(envVars, sourceEnv); + expect(result).toEqual({ + TARGET: 'default-value', + }); + }); + + it('reuses ${VAR:=default} assignments for subsequent references in the same value', () => { + const envVars = { + TARGET: '${MISSING_VAR:=default-value}-${MISSING_VAR}', + }; + const sourceEnv = {}; + + const result = expandEnvironmentVariables(envVars, sourceEnv); + expect(result).toEqual({ + TARGET: 'default-value-default-value', + }); + }); + + it('treats empty string as missing for ${VAR:-default}', () => { + const envVars = { + TARGET: '${EMPTY_VAR:-default-value}', + }; + const sourceEnv = { + EMPTY_VAR: '', + }; + + const result = expandEnvironmentVariables(envVars, sourceEnv); + expect(result).toEqual({ + TARGET: 'default-value', + }); + }); + + it('treats empty string as missing for ${VAR:=default}', () => { + const envVars = { + TARGET: '${EMPTY_VAR:=default-value}', + }; + const sourceEnv = { + EMPTY_VAR: '', + }; + + const result = expandEnvironmentVariables(envVars, sourceEnv); + expect(result).toEqual({ + TARGET: 'default-value', + }); + }); + it('should handle multiple variables with same source', () => { const envVars = { VAR1: '${SHARED}', diff --git a/cli/src/utils/expandEnvVars.ts b/cli/src/utils/expandEnvVars.ts index f4e08f77f..1bd295321 100644 --- a/cli/src/utils/expandEnvVars.ts +++ b/cli/src/utils/expandEnvVars.ts @@ -8,8 +8,8 @@ import { logger } from '@/ui/logger'; * Example: { ANTHROPIC_AUTH_TOKEN: "${Z_AI_AUTH_TOKEN}" } * * When daemon spawns sessions: - * - Tmux mode: Shell automatically expands ${VAR} - * - Non-tmux mode: Node.js spawn does NOT expand ${VAR} + * - Tmux mode: tmux launches a shell, but shells do not expand ${VAR} placeholders embedded inside env values automatically + * - Non-tmux mode: Node.js spawn does NOT expand ${VAR} placeholders * * This utility ensures ${VAR} expansion works in both modes. * @@ -28,50 +28,70 @@ import { logger } from '@/ui/logger'; */ export function expandEnvironmentVariables( envVars: Record<string, string>, - sourceEnv: NodeJS.ProcessEnv = process.env + sourceEnv: NodeJS.ProcessEnv = process.env, + options?: { + warnOnUndefined?: boolean; + } ): Record<string, string> { const expanded: Record<string, string> = {}; const undefinedVars: string[] = []; + const assignedEnv: Record<string, string> = {}; + + function readEnv(varName: string): string | undefined { + if (Object.prototype.hasOwnProperty.call(assignedEnv, varName)) { + return assignedEnv[varName]; + } + return sourceEnv[varName]; + } for (const [key, value] of Object.entries(envVars)) { - // Replace all ${VAR} and ${VAR:-default} references with actual values from sourceEnv + // Replace all ${VAR}, ${VAR:-default}, and ${VAR:=default} references with actual values from sourceEnv const expandedValue = value.replace(/\$\{([^}]+)\}/g, (match, expr) => { - // Support bash parameter expansion: ${VAR:-default} + // Support bash parameter expansion: ${VAR:-default} and ${VAR:=default} // Example: ${Z_AI_BASE_URL:-https://api.z.ai/api/anthropic} const colonDashIndex = expr.indexOf(':-'); + const colonEqIndex = expr.indexOf(':='); let varName: string; let defaultValue: string | undefined; + let operator: ':-' | ':=' | null = null; - if (colonDashIndex !== -1) { - // Split ${VAR:-default} into varName and defaultValue - varName = expr.substring(0, colonDashIndex); - defaultValue = expr.substring(colonDashIndex + 2); + if (colonDashIndex !== -1 || colonEqIndex !== -1) { + // Split ${VAR:-default} or ${VAR:=default} into varName and defaultValue + const idx = colonDashIndex !== -1 && (colonEqIndex === -1 || colonDashIndex < colonEqIndex) + ? colonDashIndex + : colonEqIndex; + operator = idx === colonDashIndex ? ':-' : ':='; + varName = expr.substring(0, idx); + defaultValue = expr.substring(idx + 2); } else { // Simple ${VAR} reference varName = expr; } - const resolvedValue = sourceEnv[varName]; - if (resolvedValue !== undefined) { + const resolvedValue = readEnv(varName); + const shouldTreatEmptyAsMissing = defaultValue !== undefined; + const isMissing = resolvedValue === undefined || (shouldTreatEmptyAsMissing && resolvedValue === ''); + + if (!isMissing) { // Variable found in source environment - use its value - // Log for debugging (mask secret-looking values) - const isSensitive = varName.toLowerCase().includes('token') || - varName.toLowerCase().includes('key') || - varName.toLowerCase().includes('secret'); - const displayValue = isSensitive - ? (resolvedValue ? `<${resolvedValue.length} chars>` : '<empty>') - : resolvedValue; - logger.debug(`[EXPAND ENV] Expanded ${varName} from daemon env: ${displayValue}`); + if (process.env.DEBUG) { + logger.debug(`[EXPAND ENV] Expanded ${varName} from daemon env`); + } // Warn if empty string (common mistake) - if (resolvedValue === '') { + if (resolvedValue === '' && !Object.prototype.hasOwnProperty.call(assignedEnv, varName)) { logger.warn(`[EXPAND ENV] WARNING: ${varName} is set but EMPTY in daemon environment`); } return resolvedValue; } else if (defaultValue !== undefined) { // Variable not found but default value provided - use default - logger.debug(`[EXPAND ENV] Using default value for ${varName}: ${defaultValue}`); + if (process.env.DEBUG) { + logger.debug(`[EXPAND ENV] Using default value for ${varName}`); + } + if (operator === ':=') { + assignedEnv[varName] = defaultValue; + } return defaultValue; } else { // Variable not found and no default - keep placeholder and warn @@ -84,7 +104,8 @@ export function expandEnvironmentVariables( } // Log warning if any variables couldn't be resolved - if (undefinedVars.length > 0) { + const warnOnUndefined = options?.warnOnUndefined ?? true; + if (warnOnUndefined && undefinedVars.length > 0) { logger.warn(`[EXPAND ENV] Undefined variables referenced in profile environment: ${undefinedVars.join(', ')}`); logger.warn(`[EXPAND ENV] Session may fail to authenticate. Set these in daemon environment before launching:`); undefinedVars.forEach(varName => { diff --git a/cli/src/utils/offlineSessionStub.ts b/cli/src/utils/offlineSessionStub.ts deleted file mode 100644 index 50d67ab67..000000000 --- a/cli/src/utils/offlineSessionStub.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** - * Offline Session Stub Factory - * - * Creates a no-op session stub for offline mode that can be used across all backends - * (Claude, Codex, Gemini, etc.). All session methods become no-ops until reconnection. - * - * This follows DRY principles by providing a single implementation for all backends, - * satisfying REQ-8 from serverConnectionErrors.ts. - * - * @module offlineSessionStub - */ - -import type { ApiSessionClient } from '@/api/apiSession'; - -/** - * Creates a no-op session stub for offline mode. - * - * The stub implements the ApiSessionClient interface with no-op methods, - * allowing the application to continue running while offline. When reconnection - * succeeds, the real session replaces this stub. - * - * @param sessionTag - Unique session tag (used to create offline session ID) - * @returns A no-op ApiSessionClient stub - * - * @example - * ```typescript - * const offlineStub = createOfflineSessionStub(sessionTag); - * let session: ApiSessionClient = offlineStub; - * - * // When reconnected: - * session = api.sessionSyncClient(response); - * ``` - */ -export function createOfflineSessionStub(sessionTag: string): ApiSessionClient { - return { - sessionId: `offline-${sessionTag}`, - sendCodexMessage: () => {}, - sendAgentMessage: () => {}, - sendClaudeSessionMessage: () => {}, - keepAlive: () => {}, - sendSessionEvent: () => {}, - sendSessionDeath: () => {}, - updateLifecycleState: () => {}, - requestControlTransfer: async () => {}, - flush: async () => {}, - close: async () => {}, - updateMetadata: () => {}, - updateAgentState: () => {}, - onUserMessage: () => {}, - rpcHandlerManager: { - registerHandler: () => {} - } - } as unknown as ApiSessionClient; -} diff --git a/cli/src/utils/spawnHappyCLI.invocation.test.ts b/cli/src/utils/spawnHappyCLI.invocation.test.ts new file mode 100644 index 000000000..c184b8424 --- /dev/null +++ b/cli/src/utils/spawnHappyCLI.invocation.test.ts @@ -0,0 +1,46 @@ +/** + * Tests for building happy-cli subprocess invocations across runtimes (node/bun). + */ +import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest'; + +describe('happy-cli subprocess invocation', () => { + const originalRuntimeOverride = process.env.HAPPY_CLI_SUBPROCESS_RUNTIME; + + beforeEach(() => { + vi.resetModules(); + }); + + afterEach(() => { + if (originalRuntimeOverride === undefined) { + delete process.env.HAPPY_CLI_SUBPROCESS_RUNTIME; + } else { + process.env.HAPPY_CLI_SUBPROCESS_RUNTIME = originalRuntimeOverride; + } + }); + + it('builds a node invocation by default', async () => { + process.env.HAPPY_CLI_SUBPROCESS_RUNTIME = 'node'; + const mod = (await import('@/utils/spawnHappyCLI')) as typeof import('@/utils/spawnHappyCLI'); + + const inv = mod.buildHappyCliSubprocessInvocation(['--version']); + expect(inv.runtime).toBe('node'); + expect(inv.argv).toEqual( + expect.arrayContaining([ + '--no-warnings', + '--no-deprecation', + expect.stringMatching(/dist\/index\.mjs$/), + '--version', + ]), + ); + }); + + it('builds a bun invocation when HAPPY_CLI_SUBPROCESS_RUNTIME=bun', async () => { + process.env.HAPPY_CLI_SUBPROCESS_RUNTIME = 'bun'; + const mod = (await import('@/utils/spawnHappyCLI')) as typeof import('@/utils/spawnHappyCLI'); + const inv = mod.buildHappyCliSubprocessInvocation(['--version']); + expect(inv.runtime).toBe('bun'); + expect(inv.argv).toEqual(expect.arrayContaining([expect.stringMatching(/dist\/index\.mjs$/), '--version'])); + expect(inv.argv).not.toContain('--no-warnings'); + expect(inv.argv).not.toContain('--no-deprecation'); + }); +}); diff --git a/cli/src/utils/spawnHappyCLI.ts b/cli/src/utils/spawnHappyCLI.ts index 560633ffc..5596d8c51 100644 --- a/cli/src/utils/spawnHappyCLI.ts +++ b/cli/src/utils/spawnHappyCLI.ts @@ -56,6 +56,36 @@ import { logger } from '@/ui/logger'; import { existsSync } from 'node:fs'; import { isBun } from './runtime'; +function getSubprocessRuntime(): 'node' | 'bun' { + const override = process.env.HAPPY_CLI_SUBPROCESS_RUNTIME; + if (override === 'node' || override === 'bun') return override; + return isBun() ? 'bun' : 'node'; +} + +export function buildHappyCliSubprocessInvocation(args: string[]): { runtime: 'node' | 'bun'; argv: string[] } { + const projectRoot = projectPath(); + const entrypoint = join(projectRoot, 'dist', 'index.mjs'); + + // Use the same Node.js flags that the wrapper script uses + const nodeArgs = [ + '--no-warnings', + '--no-deprecation', + entrypoint, + ...args + ]; + + // Sanity check of the entrypoint path exists + if (!existsSync(entrypoint)) { + const errorMessage = `Entrypoint ${entrypoint} does not exist`; + logger.debug(`[SPAWN HAPPY CLI] ${errorMessage}`); + throw new Error(errorMessage); + } + + const runtime = getSubprocessRuntime(); + const argv = runtime === 'node' ? nodeArgs : [entrypoint, ...args]; + return { runtime, argv }; +} + /** * Spawn the Happy CLI with the given arguments in a cross-platform way. * @@ -68,9 +98,6 @@ import { isBun } from './runtime'; * @returns ChildProcess instance */ export function spawnHappyCLI(args: string[], options: SpawnOptions = {}): ChildProcess { - const projectRoot = projectPath(); - const entrypoint = join(projectRoot, 'dist', 'index.mjs'); - let directory: string | URL | undefined; if ('cwd' in options) { directory = options.cwd @@ -84,22 +111,7 @@ export function spawnHappyCLI(args: string[], options: SpawnOptions = {}): Child // details and flags we use to achieve the same result. const fullCommand = `happy ${args.join(' ')}`; logger.debug(`[SPAWN HAPPY CLI] Spawning: ${fullCommand} in ${directory}`); - - // Use the same Node.js flags that the wrapper script uses - const nodeArgs = [ - '--no-warnings', - '--no-deprecation', - entrypoint, - ...args - ]; - // Sanity check of the entrypoint path exists - if (!existsSync(entrypoint)) { - const errorMessage = `Entrypoint ${entrypoint} does not exist`; - logger.debug(`[SPAWN HAPPY CLI] ${errorMessage}`); - throw new Error(errorMessage); - } - - const runtime = isBun() ? 'bun' : 'node'; - return spawn(runtime, nodeArgs, options); + const { runtime, argv } = buildHappyCliSubprocessInvocation(args); + return spawn(runtime, argv, options); } diff --git a/cli/src/utils/sync.test.ts b/cli/src/utils/sync.test.ts new file mode 100644 index 000000000..c00347c16 --- /dev/null +++ b/cli/src/utils/sync.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect } from 'vitest'; +import { InvalidateSync } from './sync'; + +describe('InvalidateSync', () => { + it('resolves invalidateAndAwait even when command throws', async () => { + const errors: unknown[] = []; + const sync = new InvalidateSync(async () => { + throw new Error('boom'); + }, { + backoff: async <T>(cb: () => Promise<T>): Promise<T> => cb(), + onError: (e: unknown) => errors.push(e), + }); + + await sync.invalidateAndAwait(); + expect(errors.length).toBe(1); + }); + + it('runs again when invalidated during an in-flight sync', async () => { + let releaseFirstRun!: () => void; + const firstRunGate = new Promise<void>((resolve) => { + releaseFirstRun = resolve; + }); + const runs: number[] = []; + + const sync = new InvalidateSync(async () => { + const run = runs.length + 1; + runs.push(run); + if (run === 1) { + await firstRunGate; + } + }, { + backoff: async <T>(cb: () => Promise<T>): Promise<T> => cb(), + }); + + const first = sync.invalidateAndAwait(); + sync.invalidate(); // while run #1 is pending + + // Let run #1 finish, allowing queued run #2. + releaseFirstRun(); + + await first; + expect(runs).toEqual([1, 2]); + }); + + it('reports increasing failuresCount even when using a custom backoff', async () => { + const failuresCounts: number[] = []; + const errors: unknown[] = []; + + const backoff = async <T>(cb: () => Promise<T>): Promise<T> => { + let lastError: unknown; + for (let attempt = 0; attempt < 3; attempt++) { + try { + return await cb(); + } catch (e) { + lastError = e; + } + } + throw lastError; + }; + + const sync = new InvalidateSync(async () => { + throw new Error('boom'); + }, { + backoff, + onError: (e, failuresCount) => { + errors.push(e); + failuresCounts.push(failuresCount); + }, + }); + + await sync.invalidateAndAwait(); + + expect(errors).toHaveLength(3); + expect(failuresCounts).toEqual([1, 2, 3]); + }); +}); diff --git a/cli/src/utils/sync.ts b/cli/src/utils/sync.ts index ea1cdaa52..609155080 100644 --- a/cli/src/utils/sync.ts +++ b/cli/src/utils/sync.ts @@ -1,14 +1,46 @@ -import { backoff } from "@/utils/time"; +import { createBackoff, type BackoffFunc } from "@/utils/time"; +export type InvalidateSyncOptions = { + backoff?: BackoffFunc; + /** + * Called whenever an attempted sync fails. + * + * Notes: + * - `failuresCount` counts the number of failed attempts for the current sync run (1-based). + * - With the default backoff, this is called on each retry attempt, and once more when the run + * ultimately fails (because the final thrown error does not trigger the backoff's `onError`). + */ + onError?: (error: unknown, failuresCount: number) => void; +}; + +/** + * Coalescing invalidation runner. + * + * Behavior: + * - `invalidate()` schedules a run of `command()` if one isn't already in progress. + * - If `invalidate()` is called while a run is in-flight, it queues exactly one additional run + * after the current run completes (coalescing repeated invalidations). + * - `invalidateAndAwait()` resolves when the current (and any queued) run finishes. + * + * Failure semantics: + * - `command()` is executed via a bounded backoff (default: up to 8 attempts). + * - `invalidateAndAwait()` always resolves even if `command()` ultimately fails, so callers + * don't hang forever (e.g., startup/shutdown flows). + */ export class InvalidateSync { private _invalidated = false; private _invalidatedDouble = false; private _stopped = false; private _command: () => Promise<void>; + private _backoff: BackoffFunc; + private _onError?: (error: unknown, failuresCount: number) => void; + private _lastFailureCount = 0; private _pendings: (() => void)[] = []; - constructor(command: () => Promise<void>) { + constructor(command: () => Promise<void>, opts: InvalidateSyncOptions = {}) { this._command = command; + this._onError = opts.onError; + this._backoff = opts.backoff ?? createBackoff(); } invalidate() { @@ -18,7 +50,7 @@ export class InvalidateSync { if (!this._invalidated) { this._invalidated = true; this._invalidatedDouble = false; - this._doSync(); + void this._doSync(); } else { if (!this._invalidatedDouble) { this._invalidatedDouble = true; @@ -53,22 +85,38 @@ export class InvalidateSync { private _doSync = async () => { - await backoff(async () => { - if (this._stopped) { - return; + this._lastFailureCount = 0; + try { + await this._backoff(async () => { + if (this._stopped) { + return; + } + try { + await this._command(); + } catch (e) { + this._lastFailureCount++; + this._onError?.(e, this._lastFailureCount); + throw e; + } + }); + } catch (e) { + // Always resolve pending awaiters even on failure; otherwise invalidateAndAwait() can hang forever. + // Note: `_onError` is called on every failed attempt inside the callback above, even with custom backoffs. + // If the backoff throws before any attempt runs, report a single failure. + if (this._lastFailureCount === 0) { + this._onError?.(e, 1); } - await this._command(); - }); + } if (this._stopped) { this._notifyPendings(); return; } if (this._invalidatedDouble) { this._invalidatedDouble = false; - this._doSync(); + void this._doSync(); } else { this._invalidated = false; this._notifyPendings(); } } -} \ No newline at end of file +} diff --git a/cli/src/utils/time.ts b/cli/src/utils/time.ts index 9570d114d..4b8e67bb3 100644 --- a/cli/src/utils/time.ts +++ b/cli/src/utils/time.ts @@ -3,8 +3,11 @@ export async function delay(ms: number) { } export function exponentialBackoffDelay(currentFailureCount: number, minDelay: number, maxDelay: number, maxFailureCount: number) { - let maxDelayRet = minDelay + ((maxDelay - minDelay) / maxFailureCount) * Math.min(currentFailureCount, maxFailureCount); - return Math.round(Math.random() * maxDelayRet); + const safeMaxFailureCount = Number.isFinite(maxFailureCount) ? Math.max(maxFailureCount, 1) : 50; + const clampedFailureCount = Math.min(Math.max(currentFailureCount, 0), safeMaxFailureCount); + const maxDelayRet = minDelay + ((maxDelay - minDelay) / safeMaxFailureCount) * clampedFailureCount; + const jittered = Math.random() * maxDelayRet; + return Math.max(minDelay, Math.round(jittered)); } export type BackoffFunc = <T>(callback: () => Promise<T>) => Promise<T>; @@ -12,6 +15,7 @@ export type BackoffFunc = <T>(callback: () => Promise<T>) => Promise<T>; export function createBackoff( opts?: { onError?: (e: any, failuresCount: number) => void, + shouldRetry?: (e: any, failuresCount: number) => boolean, minDelay?: number, maxDelay?: number, maxFailureCount?: number @@ -20,13 +24,30 @@ export function createBackoff( let currentFailureCount = 0; const minDelay = opts && opts.minDelay !== undefined ? opts.minDelay : 250; const maxDelay = opts && opts.maxDelay !== undefined ? opts.maxDelay : 1000; - const maxFailureCount = opts && opts.maxFailureCount !== undefined ? opts.maxFailureCount : 50; + const maxFailureCount = opts && opts.maxFailureCount !== undefined ? opts.maxFailureCount : 8; + const shouldRetry = opts && opts.shouldRetry + ? opts.shouldRetry + : (e: any) => { + if (e && typeof e === 'object') { + if ((e as any).retryable === false) { + return false; + } + if (typeof (e as any).canTryAgain === 'boolean' && (e as any).canTryAgain === false) { + return false; + } + } + return true; + }; while (true) { try { return await callback(); } catch (e) { - if (currentFailureCount < maxFailureCount) { - currentFailureCount++; + currentFailureCount++; + if (!shouldRetry(e, currentFailureCount)) { + throw e; + } + if (currentFailureCount >= maxFailureCount) { + throw e; } if (opts && opts.onError) { opts.onError(e, currentFailureCount); @@ -38,4 +59,5 @@ export function createBackoff( }; } -export let backoff = createBackoff(); \ No newline at end of file +export let backoff = createBackoff(); +export let backoffForever = createBackoff({ maxFailureCount: Number.POSITIVE_INFINITY }); \ No newline at end of file diff --git a/cli/yarn.lock b/cli/yarn.lock index 8491498f1..9f68d80a0 100644 --- a/cli/yarn.lock +++ b/cli/yarn.lock @@ -424,6 +424,14 @@ "@fastify/forwarded" "^3.0.0" ipaddr.js "^2.1.0" +"@happy/agents@link:../packages/agents": + version "0.0.0" + uid "" + +"@happy/protocol@link:../packages/protocol": + version "0.0.0" + uid "" + "@humanfs/core@^0.19.1": version "0.19.1" resolved "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz" diff --git a/expo-app/AGENTS.md b/expo-app/AGENTS.md new file mode 120000 index 000000000..681311eb9 --- /dev/null +++ b/expo-app/AGENTS.md @@ -0,0 +1 @@ +CLAUDE.md \ No newline at end of file diff --git a/expo-app/CLAUDE.md b/expo-app/CLAUDE.md index 3954ea31d..edd33c541 100644 --- a/expo-app/CLAUDE.md +++ b/expo-app/CLAUDE.md @@ -20,7 +20,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ### Testing - `yarn test` - Run tests in watch mode (Vitest) -- No existing tests in the codebase yet +- Tests exist and should be kept green (Vitest) ### Production - `yarn ota` - Deploy over-the-air updates via EAS Update to production branch @@ -100,7 +100,7 @@ sources/ 1. **Authentication Flow**: QR code-based authentication using expo-camera with challenge-response mechanism 2. **Data Synchronization**: WebSocket-based real-time sync with automatic reconnection and state management 3. **Encryption**: End-to-end encryption using libsodium for all sensitive data -4. **State Management**: React Context for auth state, custom reducer for sync state +4. **State Management**: React Context for auth state; sync state is centralized in `sources/sync/storage.ts` (Zustand) with domain slices under `sources/sync/store/domains/*` 5. **Real-time Voice**: LiveKit integration for voice communication sessions 6. **Platform-Specific Code**: Separate implementations for web vs native when needed @@ -111,13 +111,137 @@ sources/ - Path alias `@/*` maps to `./sources/*` - TypeScript strict mode is enabled - ensure all code is properly typed - Follow existing component patterns when creating new UI components -- Real-time sync operations are handled through SyncSocket and SyncSession classes +- Real-time sync is orchestrated by the `Sync` singleton in `sources/sync/sync.ts`, with domain logic extracted into `sources/sync/engine/*` - Store all temporary scripts and any test outside of unit tests in sources/trash folder - When setting screen parameters ALWAYS set them in _layout.tsx if possible this avoids layout shifts - **Never use Alert module from React Native, always use @sources/modal/index.ts instead** - **Always apply layout width constraints** from `@/components/layout` to full-screen ScrollViews and content containers for responsive design across device sizes - Always run `yarn typecheck` after all changes to ensure type safety +## Folder Structure & Naming Conventions (2026-01) + +These conventions are **additive** to the guidelines above. The goal is to keep screens and sync logic easy to reason about. + +### Naming +- Buckets are lowercase (e.g. `components`, `hooks`, `sync`, `utils`). +- Feature folders are `camelCase` (e.g. `newSession`, `agentInput`, `profileEdit`). +- Avoid `_folders` except Expo Router special files (e.g. `_layout.tsx`) and `__tests__`. +- Allowed `_*.ts` markers (organization only) inside module-ish folders: `_types.ts`, `_shared.ts`, `_constants.ts`. + +### Screens and feature code +- Expo Router routes live in `sources/app/**`. +- Keep route files (Expo Router) as the screen entrypoints; extract non-trivial UI/logic into `sources/components/**`. + +### Components: domain map (2026-01) +When adding or refactoring components, prefer placing them under one of these domains: +- `sources/components/ui/` — reusable UI primitives (lists, popovers, dropdowns, forms) +- `sources/components/sessions/` — session-related UX (`agentInput`, `newSession`, etc.) +- `sources/components/profiles/` — profile management UI (edit, list, pickers) +- `sources/components/secrets/` — secrets + requirements UI +- `sources/components/machines/` — machine-related UI +- `sources/components/tools/` — tool rendering + permission UI + +Guidance (not a hard rule): +- Prefer reusing an existing domain over creating a new top-level folder under `sources/components/`. +- If a new domain is clearly warranted (distinct concept, multiple screens/features, long-term ownership), create it with a clear noun name and keep it cohesive. + +Bucket rule: +- Use `components/`, `hooks/`, `modules/`, `utils/` only when they contain multiple files; avoid creating a 1-file subfolder just for structure. + +### Sync organization +- `sources/sync/sync.ts` is the canonical sync orchestrator (public API + wiring) and remains the entrypoint. +- Extract cohesive logic into subdomains under `sources/sync/`: + - `sources/sync/engine/*` — runtime helpers used by `Sync` (prefer a few domain files like `sessions.ts`, `machines.ts`, `settings.ts`; avoid “one helper per file” sprawl) + - `sources/sync/store/domains/*` — Zustand domain slices + - `sources/sync/ops/*` — RPC operation helpers (sessions/machines/capabilities) + - `sources/sync/reducer/*` — message reducer pipeline (phases/helpers) + - `sources/sync/typesRaw/*` — raw message schemas + normalization +- Prefer splitting by *domain* (sessions/messages/machines/settings) rather than generic `utils/` buckets. + +Canonical entrypoints: +- `sources/sync/{storage.ts,ops.ts,typesRaw.ts,sync.ts}` are canonical entrypoints that define the public surface for sync. +- Extract internals under subfolders (`store/`, `ops/`, `typesRaw/`, `reducer/`, etc.) and have the entry files orchestrate them (import and compose). + +## Modals & dialogs (web + native) + +### Rules of thumb +- **Never call `Alert` / `Alert.prompt` directly**. Use `Modal` from `sources/modal` (`import { Modal } from '@/modal'`). +- **Avoid `react-native` `<Modal>`** for app-controlled overlays. Use the app modal system so stacking works consistently. +- If you need a new overlay: + - “OK / Confirm / Prompt” → `Modal.alert()` / `Modal.confirm()` / `Modal.prompt()` + - Custom UI → `Modal.show({ component, props })` + +### Web implementation (Radix) +On web, `BaseModal` renders a Radix `Dialog` (portal to `document.body`) so focus, scroll, and pointer events behave correctly when stacking modals (including when an Expo Router / Vaul drawer is already open). + +**Critical invariant:** Radix “singleton” stacks (DismissableLayer / FocusScope) must be shared across *all* dialogs. With Metro + package `exports`, mixing ESM and CJS entrypoints can load *two* Radix module instances and break focus/stacking. + +- Use the CJS entrypoints via `sources/utils/radixCjs.ts` (`requireRadixDialog()` / `requireRadixDismissableLayer()`) for any web dialog primitives. +- Wrap stacked dialog content with `DismissableLayer.Branch` so underlying Radix/Vaul layers don’t treat the top dialog as “outside” and dismiss. +- Only the top-most modal should render a backdrop; `ModalProvider` handles this via `showBackdrop`. + +### Native implementation (iOS/Android) +On native, stacking a React Navigation / Expo Router modal screen with an RN `<Modal>` can produce “invisible overlay blocks touches” and z-index ordering bugs. + +- `BaseModal` renders a “portal-style” overlay inside the current screen tree (absolute fill + high `zIndex`) so touches/focus stay within the same navigation presentation context. +- `Modal.alert()` / `Modal.confirm()` use the native system alert UI on iOS/Android (good accessibility + expected platform UX). +- `Modal.prompt()` uses the app prompt modal on all platforms for consistent behavior (since `Alert.prompt` is iOS-only). + +### Popovers (menus/tooltips) +Use the app `Popover` + `FloatingOverlay` for menus/tooltips/context menus. + +- Use `portal={{ web: { target: 'body' }, native: true }}` when the anchor is inside overflow-clipped containers (headers, lists, scrollviews). +- When the backdrop is enabled (default), `onRequestClose` is required (Popover is controlled). +- For context-menu style overlays, prefer `backdrop={{ effect: 'blur', anchorOverlay: ..., closeOnPan: true }}` so the trigger stays crisp above the blur without cutout seams. +- On web, portaled popovers are wrapped in Radix `DismissableLayer.Branch` (via `radixCjs.ts`) so Expo Router/Vaul/Radix layers don’t treat them as “outside”. + +## Settings persistence & sync (Account.settings + pending delta) — rules + +### Correct model +- **Effective settings** = server settings merged with `settingsDefaults` (+ migrations in `settingsParse()`). +- **Pending settings** = a **delta-only** object of user-intended changes not yet ACKed by the server (`pending-settings`). +- `/v1/account/settings` **POST replaces the blob** (not a patch), so accidental uploads can overwrite server state. + +### Hard rules (do NOT break these) +- **Never apply schema defaults when parsing pending deltas.** + - Do NOT do `SettingsSchema.partial().parse(...)` (or any parse path that synthesizes missing keys) if the schema contains `.default(...)`. + - Pending parsing must be “delta-only”: include a key only if it exists in the stored object and validates. +- **Treat settings as immutable.** + - Never mutate `settings` (or nested arrays/objects like `secrets`, `profiles`, `favorite*`, `dismissedCLIWarnings`) in place. + - Always update settings via `sync.applySettings({ field: nextValue })` / `useSettingMutable(...)` using immutable patterns (`map`, `filter`, `...spread`). +- **Avoid no-op writes on boot.** + - Do not call `sync.applySettings()` unconditionally in mount effects. + - Only persist when the value actually changed vs the current settings. +- **Never log secrets.** + - Do not log `secrets[].encryptedValue.value` or env-var secret values. If you add logs, log only counts/booleans (`hasValue`) and keys. + +### Defaults placement guidance +- It’s OK for `SettingsSchema` to have `.default(...)` for **effective settings parsing**, but you must ensure pending parsing does **not** trigger those defaults. +- If you need both behaviors, consider **separating schemas**: + - `SettingsSchema` (effective) may include defaults + - `PendingSettingsSchema` (delta-only) must not + +### Pending storage when empty +- Writing `"{}"` for “no pending” is acceptable **only if pending parsing is delta-only** (so `{}` stays `{}`). +- Deleting the `pending-settings` key when empty is a recommended optimization (less churn/ambiguity), but not required for correctness once delta-only parsing is in place. + +## Secret settings (encrypted-at-rest fields inside settings) + +Some settings values are secrets (e.g. API keys). Even though the outer `Account.settings` blob is encrypted for server transport, we also require **field-level encryption at rest** so secrets are not stored as plaintext in MMKV/JSON after the blob is decrypted. + +### Rules +- **Never persist plaintext secrets** in settings. Plaintext may be accepted as input, but must be sealed before persistence. +- **Decrypt just-in-time** (e.g. right before sending an encrypted machine RPC to spawn a session). +- **Never log secret values** (only counts/booleans like `hasValue`). + +### How to add a new secret setting field +- Use the standardized secret container schema: **`SecretStringSchema`** from `sources/sync/secretSettings.ts` + - Marker: **`_isSecretValue: true`** (required for automatic sealing) + - Plaintext input only: `.value` (must not be persisted) + - Ciphertext persisted: `.encryptedValue` (an `EncryptedStringSchema`) +- Sealing is automatic: `sync.applySettings(...)` runs `sealSecretsDeep(...)` (see `sources/sync/secretSettings.ts`). +- Decrypt just-in-time via `sync.decryptSecretValue(...)`. + ### Internationalization (i18n) Guidelines **CRITICAL: Always use the `t(...)` function for ALL user-visible strings** @@ -459,4 +583,4 @@ const MyComponent = () => { - Always put styles in the very end of the component or page file - Always wrap pages in memo - For hotkeys use "useGlobalKeyboard", do not change it, it works only on Web -- Use "AsyncLock" class for exclusive async locks \ No newline at end of file +- Use "AsyncLock" class for exclusive async locks diff --git a/expo-app/CONTRIBUTING.md b/expo-app/CONTRIBUTING.md index 5aa5635cc..a7ca4f9aa 100644 --- a/expo-app/CONTRIBUTING.md +++ b/expo-app/CONTRIBUTING.md @@ -23,42 +23,42 @@ This allows you to test production-like builds with real users before releasing ```bash # Development variant (default) -npm run ios:dev +yarn ios:dev # Preview variant -npm run ios:preview +yarn ios:preview # Production variant -npm run ios:production +yarn ios:production ``` ### Android Development ```bash # Development variant -npm run android:dev +yarn android:dev # Preview variant -npm run android:preview +yarn android:preview # Production variant -npm run android:production +yarn android:production ``` ### macOS Desktop (Tauri) ```bash # Development variant - run with hot reload -npm run tauri:dev +yarn tauri:dev # Build development variant -npm run tauri:build:dev +yarn tauri:build:dev # Build preview variant -npm run tauri:build:preview +yarn tauri:build:preview # Build production variant -npm run tauri:build:production +yarn tauri:build:production ``` **How Tauri Variants Work:** @@ -71,13 +71,13 @@ npm run tauri:build:production ```bash # Start dev server for development variant -npm run start:dev +yarn start:dev # Start dev server for preview variant -npm run start:preview +yarn start:preview # Start dev server for production variant -npm run start:production +yarn start:production ``` ## Visual Differences @@ -95,7 +95,7 @@ This makes it easy to distinguish which version you're testing! 1. **Build development variant:** ```bash - npm run ios:dev + yarn ios:dev ``` 2. **Make your changes** to the code @@ -104,19 +104,19 @@ This makes it easy to distinguish which version you're testing! 4. **Rebuild if needed** for native changes: ```bash - npm run ios:dev + yarn ios:dev ``` ### Testing Preview (Pre-Release) 1. **Build preview variant:** ```bash - npm run ios:preview + yarn ios:preview ``` 2. **Test OTA updates:** ```bash - npm run ota # Publishes to preview branch + yarn ota # Publishes to preview branch ``` 3. **Verify** the preview build works as expected @@ -125,17 +125,17 @@ This makes it easy to distinguish which version you're testing! 1. **Build production variant:** ```bash - npm run ios:production + yarn ios:production ``` 2. **Submit to App Store:** ```bash - npm run submit + yarn submit ``` 3. **Deploy OTA updates:** ```bash - npm run ota:production + yarn ota:production ``` ## All Variants Simultaneously @@ -144,9 +144,9 @@ You can install all three variants on the same device: ```bash # Build all three variants -npm run ios:dev -npm run ios:preview -npm run ios:production +yarn ios:dev +yarn ios:preview +yarn ios:production ``` All three apps appear on your device with different icons and names! @@ -195,12 +195,12 @@ You can connect different variants to different Happy CLI instances: ```bash # Development app → Dev CLI daemon -npm run android:dev -# Connect to CLI running: npm run dev:daemon:start +yarn android:dev +# Connect to CLI running: yarn dev:daemon:start # Production app → Stable CLI daemon -npm run android:production -# Connect to CLI running: npm run stable:daemon:start +yarn android:production +# Connect to CLI running: yarn stable:daemon:start ``` Each app maintains separate authentication and sessions! @@ -210,7 +210,7 @@ Each app maintains separate authentication and sessions! To test with a local Happy server: ```bash -npm run start:local-server +yarn start:local-server ``` This sets: @@ -227,8 +227,8 @@ This shouldn't happen - each variant has a unique bundle ID. If it does: 1. Check `app.config.js` - verify `bundleId` is set correctly for the variant 2. Clean build: ```bash - npm run prebuild - npm run ios:dev # or whichever variant + yarn prebuild + yarn ios:dev # or whichever variant ``` ### App not updating after changes @@ -236,12 +236,12 @@ This shouldn't happen - each variant has a unique bundle ID. If it does: 1. **For JS changes**: Hot reload should work automatically 2. **For native changes**: Rebuild the variant: ```bash - npm run ios:dev # Force rebuild + yarn ios:dev # Force rebuild ``` 3. **For config changes**: Clean and prebuild: ```bash - npm run prebuild - npm run ios:dev + yarn prebuild + yarn ios:dev ``` ### All three apps look the same @@ -258,7 +258,7 @@ If they're all the same name, the variant might not be set correctly. Verify: echo $APP_ENV # Or look at the build output -npm run ios:dev # Should show "Happy (dev)" as the name +yarn ios:dev # Should show "Happy (dev)" as the name ``` ### Connected device not found @@ -270,7 +270,7 @@ For iOS connected device testing: xcrun devicectl list devices # Run on specific connected device -npm run ios:connected-device +yarn ios:connected-device ``` ## Tips diff --git a/expo-app/app.config.js b/expo-app/app.config.js index 655f8542a..112c9e725 100644 --- a/expo-app/app.config.js +++ b/expo-app/app.config.js @@ -1,14 +1,29 @@ const variant = process.env.APP_ENV || 'development'; -const name = { + +// Allow opt-in overrides for local dev tooling without changing upstream defaults. +const nameOverride = (process.env.EXPO_APP_NAME || '').trim(); +const bundleIdOverride = (process.env.EXPO_APP_BUNDLE_ID || '').trim(); + +const namesByVariant = { development: "Happy (dev)", preview: "Happy (preview)", production: "Happy" -}[variant]; -const bundleId = { +}; +const bundleIdsByVariant = { development: "com.slopus.happy.dev", preview: "com.slopus.happy.preview", production: "com.ex3ndr.happy" -}[variant]; +}; + +// If APP_ENV is unknown, fall back to development-safe defaults to avoid generating +// an invalid Expo config with undefined name/bundle id. +const name = nameOverride || namesByVariant[variant] || namesByVariant.development; +const bundleId = bundleIdOverride || bundleIdsByVariant[variant] || bundleIdsByVariant.development; +// NOTE: +// The URL scheme is used for deep linking *and* by the Expo development client launcher flow. +// Keep the default stable for upstream users, but allow opt-in overrides for local dev variants +// (e.g. to avoid iOS scheme collisions between multiple installs). +const scheme = (process.env.EXPO_APP_SCHEME || '').trim() || "happy"; export default { expo: { @@ -18,7 +33,7 @@ export default { runtimeVersion: "18", orientation: "default", icon: "./sources/assets/images/icon.png", - scheme: "happy", + scheme, userInterfaceStyle: "automatic", newArchEnabled: true, notification: { @@ -174,4 +189,4 @@ export default { }, owner: "bulkacorp" } -}; \ No newline at end of file +}; diff --git a/expo-app/docs/agents-catalog.md b/expo-app/docs/agents-catalog.md new file mode 100644 index 000000000..904f3f9ca --- /dev/null +++ b/expo-app/docs/agents-catalog.md @@ -0,0 +1,205 @@ +# Expo agents/providers guide (Happy) + +This doc explains how to add a new “agent/provider” to the **Expo app** in a way that stays: +- catalogue-driven (no hardcoded `if (agentId === ...)` in screens), +- capability-driven (runtime checks come from capability results), +- test-friendly (Node-side tests can import `@/agents/catalog` without loading native assets). + +Last updated: 2026-01-27 + +--- + +## Mental model + +There are 3 layers in Expo: + +1) **Core registry** (`expo-app/sources/agents/registryCore.ts`) + - Defines the agent’s identity, CLI wiring (detectKey/spawnAgent), resume configuration, permission prompt protocol, i18n keys, etc. + - This is the source of truth for UI decision-making (e.g. “is agent experimental?”, “what resume mechanism is used?”). + +2) **UI registry** (`expo-app/sources/agents/registryUi.ts`) + - Expo-only visuals (icons, tints, glyphs, avatar sizing). + - Loaded lazily by `expo-app/sources/agents/catalog.ts` to keep Node-side tests working. + +3) **Behavior registry** (`expo-app/sources/agents/registryUiBehavior.ts`) + - Provider-specific hooks for: + - experimental resume switches, + - runtime resume gating/prefetch, + - preflight checks/prefetch, + - spawn/resume payload extras, + - new-session UI chips + new-session options, + - spawn environment variable transforms. + +Screens should import only from: +- `expo-app/sources/agents/catalog.ts` (single public surface) + +Provider code lives under: +- `expo-app/sources/agents/providers/<agentId>/...` + +--- + +## Files you typically add for a new agent + +Create a provider folder: +- `expo-app/sources/agents/providers/<agentId>/core.ts` +- `expo-app/sources/agents/providers/<agentId>/ui.ts` +- `expo-app/sources/agents/providers/<agentId>/uiBehavior.ts` (optional) + +Then wire them: +- Add `*_CORE` to `expo-app/sources/agents/registryCore.ts` +- Add `*_UI` to `expo-app/sources/agents/registryUi.ts` +- Add `*_UI_BEHAVIOR_OVERRIDE` to `expo-app/sources/agents/registryUiBehavior.ts` (only if you have overrides) + +--- + +## Agent IDs (shared vs Expo) + +The canonical IDs live in `@happy/agents` (workspace package). Expo imports `AGENT_IDS` and `AgentId` from there. + +When adding a brand-new agent ID, update **both**: +- `packages/agents` (for canonical ids/types) +- Expo provider folder + registries + +--- + +## Gating an agent behind experiments (agent selection) + +To hide an agent unless experiments are enabled: +- Set `availability.experimental: true` in your provider `core.ts`. + +This plugs into: +- `expo-app/sources/agents/enabled.ts` + - gated by `settings.experiments === true` and `settings.experimentalAgents[agentId] === true` + +The Settings screen uses `getAgentCore(agentId).availability.experimental` to list per-agent toggles. + +--- + +## Resume configuration (core) + +Resume is configured in `AgentCoreConfig.resume`: +- `supportsVendorResume: true | false` +- `experimental: true | false` (vendor resume requires opt-in) +- `runtimeGate: 'acpLoadSession' | null` (runtime-probed resume support when `supportsVendorResume === false`) +- `vendorResumeIdField` + `uiVendorResumeIdLabelKey` (for session-info “copy vendor id” UI) + +### Common patterns + +1) **Native vendor resume (stable)** +- `supportsVendorResume: true` +- `experimental: false` +- `runtimeGate: null` + +2) **Native vendor resume (experimental)** +- `supportsVendorResume: true` +- `experimental: true` +- Provide the experiment switches + gating in `uiBehavior.ts` (see below). + +3) **ACP runtime-gated resume (no vendor resume by default)** +- `supportsVendorResume: false` +- `runtimeGate: 'acpLoadSession'` + - Default behavior in `registryUiBehavior.ts` will: + - prefetch `cli.<detectKey>` with `includeAcpCapabilities`, + - gate resumability on `acp.loadSession === true`. + +--- + +## Provider behavior hooks (where “tricks” live) + +All hooks are typed on `AgentUiBehavior` in `expo-app/sources/agents/registryUiBehavior.ts`. + +### 1) Experimental resume switches (provider-owned) + +Use when `core.resume.experimental === true` or you have multiple experiment paths. + +Hooks: +- `resume.experimentSwitches` + - provider declares which `Settings` keys are relevant +- `resume.getAllowExperimentalVendorResume({ experiments })` + - provider decides whether experimental resume is enabled for *this agent* +- `resume.getExperimentalVendorResumeRequiresRuntime({ experiments })` + - provider can “fail closed” until runtime-gated support is confirmed (example: ACP-only experimental path) + +Important: generic code never references provider flag names; those live in the provider override. + +### 2) Runtime resume probing (ACP loadSession) + +Hook: +- `resume.getRuntimeResumePrefetchPlan({ experiments, results })` + +Default behavior (when `core.resume.runtimeGate === 'acpLoadSession'`) uses: +- `expo-app/sources/agents/acpRuntimeResume.ts` + +### 3) Resume/new-session preflight checks + +Hooks: +- `resume.getPreflightPrefetchPlan(...)` (optional) +- `resume.getPreflightIssues(...)` +- `newSession.getPreflightIssues(...)` + +Context includes `results` (capability results). If you need dependency/install checks: +- read them from `results` using `capabilities/*` helpers inside the provider folder +- do not pass provider-specific “dep installed” booleans through generic code + +### 4) Spawn/resume payload extras + +Hooks: +- `payload.buildSpawnSessionExtras(...)` +- `payload.buildResumeSessionExtras(...)` +- `payload.buildWakeResumeExtras(...)` + +These are for daemon payload fields that are *not* generic across agents. + +### 5) Spawn environment variable transforms (new session) + +Hook: +- `payload.buildSpawnEnvironmentVariables({ environmentVariables, newSessionOptions })` + +Use this for provider knobs expressed as env vars. + +### 6) New-session UI chips + options (no screen hardcoding) + +Hooks: +- `newSession.getAgentInputExtraActionChips({ agentOptionState, setAgentOptionState })` + - return chips to render only for this agent +- `newSession.buildNewSessionOptions({ agentOptionState })` + - convert local option state to a serializable `newSessionOptions` map for spawn-time hooks + +The New Session screen stores draft state generically as: +- `agentNewSessionOptionStateByAgentId[agentId]` + +Providers interpret keys within that map (example: `allowIndexing` for Auggie). + +--- + +## Node-safe imports (tests) + +Some tests import `@/agents/catalog` in a Node environment. Avoid importing native/icon modules from code that is executed during those imports. + +Patterns: +- `catalog.ts` lazy-loads `registryUi.ts` with `require(...)` to avoid image imports in Node. +- If a provider behavior needs a React Native component for chips, lazy-require it inside the hook. + +--- + +## Checklist when adding a new agent + +1) Add canonical ID to `packages/agents` (if new id). +2) Add `providers/<agentId>/core.ts` with: + - correct `cli.detectKey` and `cli.spawnAgent` + - correct `resume` fields (especially `runtimeGate`) + - correct `availability.experimental` gating +3) Add `providers/<agentId>/ui.ts` and wire into `registryUi.ts`. +4) Add `providers/<agentId>/uiBehavior.ts` if you need: + - experimental switches, + - preflight logic, + - spawn env vars, + - new-session chips/options, + - payload extras. +5) Add/adjust tests: + - `expo-app/sources/agents/enabled.test.ts` if availability changes + - `expo-app/sources/agents/registryUiBehavior.test.ts` for new behavior hooks +6) Run: + - `yarn --cwd expo-app typecheck` + - relevant `vitest` targets + diff --git a/expo-app/index.ts b/expo-app/index.ts index cf9ee28c1..8890b5e77 100644 --- a/expo-app/index.ts +++ b/expo-app/index.ts @@ -1,2 +1,2 @@ import './sources/unistyles'; -import 'expo-router/entry'; \ No newline at end of file +import 'expo-router/entry'; diff --git a/expo-app/package.json b/expo-app/package.json index 260f1a32b..449cf0e52 100644 --- a/expo-app/package.json +++ b/expo-app/package.json @@ -3,29 +3,29 @@ "main": "index.ts", "version": "1.0.0", "scripts": { - "start": "expo start", - "android": "expo run:android", - "ios": "expo run:ios", + "start": "cross-env EXPO_UNSTABLE_WEB_MODAL=1 expo start", + "android": "cross-env EXPO_UNSTABLE_WEB_MODAL=1 expo run:android", + "ios": "cross-env EXPO_UNSTABLE_WEB_MODAL=1 expo run:ios", "ios:connected-device": "yarn ios -d", "submit": "eas submit --platform ios", - "web": "expo start --web", + "web": "cross-env EXPO_UNSTABLE_WEB_MODAL=1 expo start --web", "test": "vitest", - "prebuild": "rm -rf android ios && expo prebuild", + "prebuild": "rm -rf android ios && cross-env EXPO_UNSTABLE_WEB_MODAL=1 expo prebuild", "ota": "APP_ENV=preview NODE_ENV=preview tsx sources/scripts/parseChangelog.ts && yarn typecheck && eas update --branch preview", "ota:production": "npx eas-cli@latest workflow:run ota.yaml", "typecheck": "tsc --noEmit", - "postinstall": "patch-package && npx setup-skia-web public", + "postinstall": "node ./tools/postinstall.mjs", "generate-theme": "tsx sources/theme.gen.ts", "// ==== Development/Preview/Production Variants ====": "", - "ios:dev": "cross-env APP_ENV=development expo run:ios", - "ios:preview": "cross-env APP_ENV=preview expo run:ios", - "ios:production": "cross-env APP_ENV=production expo run:ios", - "android:dev": "cross-env APP_ENV=development expo run:android", - "android:preview": "cross-env APP_ENV=preview expo run:android", - "android:production": "cross-env APP_ENV=production expo run:android", - "start:dev": "cross-env APP_ENV=development expo start", - "start:preview": "cross-env APP_ENV=preview expo start", - "start:production": "cross-env APP_ENV=production expo start", + "ios:dev": "cross-env EXPO_UNSTABLE_WEB_MODAL=1 APP_ENV=development expo run:ios", + "ios:preview": "cross-env EXPO_UNSTABLE_WEB_MODAL=1 APP_ENV=preview expo run:ios", + "ios:production": "cross-env EXPO_UNSTABLE_WEB_MODAL=1 APP_ENV=production expo run:ios", + "android:dev": "cross-env EXPO_UNSTABLE_WEB_MODAL=1 APP_ENV=development expo run:android", + "android:preview": "cross-env EXPO_UNSTABLE_WEB_MODAL=1 APP_ENV=preview expo run:android", + "android:production": "cross-env EXPO_UNSTABLE_WEB_MODAL=1 APP_ENV=production expo run:android", + "start:dev": "cross-env EXPO_UNSTABLE_WEB_MODAL=1 APP_ENV=development expo start", + "start:preview": "cross-env EXPO_UNSTABLE_WEB_MODAL=1 APP_ENV=preview expo start", + "start:production": "cross-env EXPO_UNSTABLE_WEB_MODAL=1 APP_ENV=production expo start", "// ==== macOS Desktop (Tauri) Variants ====": "", "tauri:dev": "tauri dev --config src-tauri/tauri.dev.conf.json", "tauri:build:dev": "tauri build --config src-tauri/tauri.dev.conf.json", @@ -36,6 +36,8 @@ "preset": "jest-expo" }, "dependencies": { + "@happy/agents": "link:../packages/agents", + "@happy/protocol": "link:../packages/protocol", "@config-plugins/react-native-webrtc": "^12.0.0", "@elevenlabs/react": "^0.12.3", "@elevenlabs/react-native": "^0.5.7", @@ -108,7 +110,7 @@ "expo-navigation-bar": "~5.0.8", "expo-notifications": "~0.32.11", "expo-print": "~15.0.7", - "expo-router": "~6.0.7", + "expo-router": "6.0.22", "expo-screen-capture": "~8.0.8", "expo-screen-orientation": "~9.0.7", "expo-secure-store": "~15.0.7", @@ -121,7 +123,7 @@ "expo-updates": "~29.0.11", "expo-web-browser": "~15.0.7", "fuse.js": "^7.1.0", - "libsodium-wrappers": "^0.7.15", + "libsodium-wrappers": "0.7.15", "livekit-client": "^2.15.4", "lottie-react-native": "~7.3.1", "mermaid": "^11.12.1", @@ -172,10 +174,11 @@ "@material/material-color-utilities": "^0.3.0", "@stablelib/hex": "^2.0.1", "@types/react": "~19.1.10", + "@types/react-test-renderer": "^19.1.0", "babel-plugin-transform-remove-console": "^6.9.4", "cross-env": "^10.1.0", "patch-package": "^8.0.0", - "react-test-renderer": "19.0.0", + "react-test-renderer": "19.1.0", "tsx": "^4.20.4", "typescript": "~5.9.2" }, diff --git a/expo-app/patches/expo-router+6.0.22.patch b/expo-app/patches/expo-router+6.0.22.patch new file mode 100644 index 000000000..3d06cf13a --- /dev/null +++ b/expo-app/patches/expo-router+6.0.22.patch @@ -0,0 +1,14 @@ +diff --git a/node_modules/expo-router/build/layouts/_web-modal.js b/node_modules/expo-router/build/layouts/_web-modal.js +--- a/node_modules/expo-router/build/layouts/_web-modal.js ++++ b/node_modules/expo-router/build/layouts/_web-modal.js +@@ -1,8 +1,8 @@ + "use strict"; + var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; + }; + Object.defineProperty(exports, "__esModule", { value: true }); +-const BaseStack_1 = __importDefault(require("./BaseStack")); +-exports.default = BaseStack_1.default; ++const ExperimentalModalStack_1 = __importDefault(require("./ExperimentalModalStack")); ++exports.default = ExperimentalModalStack_1.default; + //# sourceMappingURL=_web-modal.js.map diff --git a/expo-app/sources/-session/SessionView.tsx b/expo-app/sources/-session/SessionView.tsx index 457419294..2c162b558 100644 --- a/expo-app/sources/-session/SessionView.tsx +++ b/expo-app/sources/-session/SessionView.tsx @@ -1,5 +1,5 @@ import { AgentContentView } from '@/components/AgentContentView'; -import { AgentInput } from '@/components/AgentInput'; +import { AgentInput } from '@/components/sessions/agentInput'; import { getSuggestions } from '@/components/autocomplete/suggestions'; import { ChatHeaderView } from '@/components/ChatHeaderView'; import { ChatList } from '@/components/ChatList'; @@ -11,8 +11,11 @@ import { Modal } from '@/modal'; import { voiceHooks } from '@/realtime/hooks/voiceHooks'; import { startRealtimeSession, stopRealtimeSession } from '@/realtime/RealtimeSession'; import { gitStatusSync } from '@/sync/gitStatusSync'; -import { sessionAbort } from '@/sync/ops'; -import { storage, useIsDataReady, useLocalSetting, useRealtimeStatus, useSessionMessages, useSessionUsage, useSetting } from '@/sync/storage'; +import { sessionAbort, resumeSession } from '@/sync/ops'; +import { storage, useIsDataReady, useLocalSetting, useMachine, useRealtimeStatus, useSessionMessages, useSessionPendingMessages, useSessionUsage, useSetting, useSettings } from '@/sync/storage'; +import { canResumeSessionWithOptions, getAgentVendorResumeId } from '@/agents/resumeCapabilities'; +import { DEFAULT_AGENT_ID, getAgentCore, resolveAgentIdFromFlavor, buildResumeSessionExtrasFromUiState, getAgentResumeExperimentsFromSettings, getResumePreflightIssues, getResumePreflightPrefetchPlan } from '@/agents/catalog'; +import { useResumeCapabilityOptions } from '@/agents/useResumeCapabilityOptions'; import { useSession } from '@/sync/storage'; import { Session } from '@/sync/storageTypes'; import { sync } from '@/sync/sync'; @@ -22,7 +25,18 @@ import { isRunningOnMac } from '@/utils/platform'; import { useDeviceType, useHeaderHeight, useIsLandscape, useIsTablet } from '@/utils/responsive'; import { formatPathRelativeToHome, getSessionAvatarId, getSessionName, useSessionStatus } from '@/utils/sessionUtils'; import { isVersionSupported, MINIMUM_CLI_VERSION } from '@/utils/versionUtils'; +import { getMachineCapabilitiesSnapshot, prefetchMachineCapabilities, useMachineCapabilitiesCache } from '@/hooks/useMachineCapabilitiesCache'; +import { describeAcpLoadSessionSupport } from '@/agents/acpRuntimeResume'; +import type { ModelMode, PermissionMode } from '@/sync/permissionTypes'; +import { computePendingActivityAt } from '@/sync/unread'; +import { getPendingQueueWakeResumeOptions } from '@/sync/pendingQueueWake'; +import { getPermissionModeOverrideForSpawn } from '@/sync/permissionModeOverride'; +import { buildResumeSessionBaseOptionsFromSession } from '@/sync/resumeSessionBase'; +import { chooseSubmitMode } from '@/sync/submitMode'; +import { isMachineOnline } from '@/utils/machineUtils'; +import { getInactiveSessionUiState } from './sessionResumeUi'; import { Ionicons } from '@expo/vector-icons'; +import { useFocusEffect } from '@react-navigation/native'; import { useRouter } from 'expo-router'; import * as React from 'react'; import { useMemo } from 'react'; @@ -30,6 +44,31 @@ import { ActivityIndicator, Platform, Pressable, Text, View } from 'react-native import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useUnistyles } from 'react-native-unistyles'; +const CONFIGURABLE_MODEL_MODES = [ + 'default', + 'gemini-2.5-pro', + 'gemini-2.5-flash', + 'gemini-2.5-flash-lite', +] as const; +type ConfigurableModelMode = (typeof CONFIGURABLE_MODEL_MODES)[number]; + +const isConfigurableModelMode = (mode: ModelMode): mode is ConfigurableModelMode => { + return (CONFIGURABLE_MODEL_MODES as readonly string[]).includes(mode); +}; + +function formatResumeSupportDetailCode(code: 'cliNotDetected' | 'capabilityProbeFailed' | 'acpProbeFailed' | 'loadSessionFalse'): string { + switch (code) { + case 'cliNotDetected': + return t('session.resumeSupportDetails.cliNotDetected'); + case 'capabilityProbeFailed': + return t('session.resumeSupportDetails.capabilityProbeFailed'); + case 'acpProbeFailed': + return t('session.resumeSupportDetails.acpProbeFailed'); + case 'loadSessionFalse': + return t('session.resumeSupportDetails.loadSessionFalse'); + } +} + export const SessionView = React.memo((props: { id: string }) => { const sessionId = props.id; const router = useRouter(); @@ -168,17 +207,119 @@ function SessionViewLoaded({ sessionId, session }: { sessionId: string, session: const shouldShowCliWarning = isCliOutdated && !isAcknowledged; // Get permission mode from session object, default to 'default' const permissionMode = session.permissionMode || 'default'; - // Get model mode from session object - for Gemini sessions use explicit model, default to gemini-2.5-pro - const isGeminiSession = session.metadata?.flavor === 'gemini'; - const modelMode = session.modelMode || (isGeminiSession ? 'gemini-2.5-pro' : 'default'); + // Get model mode from session object - default is agent-specific (Gemini needs an explicit default) + const agentId = resolveAgentIdFromFlavor(session.metadata?.flavor) ?? DEFAULT_AGENT_ID; + const modelMode = session.modelMode || getAgentCore(agentId).model.defaultMode; const sessionStatus = useSessionStatus(session); const sessionUsage = useSessionUsage(sessionId); const alwaysShowContextSize = useSetting('alwaysShowContextSize'); - const experiments = useSetting('experiments'); + const { messages: pendingMessages } = useSessionPendingMessages(sessionId); + const expFileViewer = useSetting('expFileViewer'); + const settings = useSettings(); + + // Inactive session resume state + const isSessionActive = session.presence === 'online'; + const { resumeCapabilityOptions } = useResumeCapabilityOptions({ + agentId, + machineId: typeof machineId === 'string' ? machineId : null, + settings, + enabled: !isSessionActive, + }); + + const { state: machineCapabilitiesState } = useMachineCapabilitiesCache({ + machineId: typeof machineId === 'string' ? machineId : null, + enabled: false, + request: { requests: [] }, + }); + const machineCapabilitiesResults = React.useMemo(() => { + if (machineCapabilitiesState.status !== 'loaded' && machineCapabilitiesState.status !== 'loading') return undefined; + return machineCapabilitiesState.snapshot?.response.results as any; + }, [machineCapabilitiesState]); + + const vendorResumeId = React.useMemo(() => { + const field = getAgentCore(agentId).resume.vendorResumeIdField; + if (!field) return ''; + const raw = (session.metadata as any)?.[field]; + return typeof raw === 'string' ? raw.trim() : ''; + }, [agentId, session.metadata]); + + const acpLoadSessionSupport = React.useMemo(() => { + if (!vendorResumeId) return null; + if (getAgentCore(agentId).resume.runtimeGate !== 'acpLoadSession') return null; + return describeAcpLoadSessionSupport(agentId, machineCapabilitiesResults); + }, [agentId, machineCapabilitiesResults, vendorResumeId]); + + const isResumable = canResumeSessionWithOptions(session.metadata, resumeCapabilityOptions); + const [isResuming, setIsResuming] = React.useState(false); + + const machine = useMachine(typeof machineId === 'string' ? machineId : ''); + const isMachineReachable = Boolean(machine) && isMachineOnline(machine!); + + const inactiveUi = React.useMemo(() => { + return getInactiveSessionUiState({ + isSessionActive, + isResumable, + isMachineOnline: isMachineReachable, + }); + }, [isMachineReachable, isResumable, isSessionActive]); // Use draft hook for auto-saving message drafts const { clearDraft } = useDraft(sessionId, message, setMessage); + const pendingActivityAt = computePendingActivityAt(session.metadata); + const isFocusedRef = React.useRef(false); + const markViewedTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null); + const lastMarkedRef = React.useRef<{ sessionSeq: number; pendingActivityAt: number } | null>(null); + + const markSessionViewed = React.useCallback(() => { + void sync.markSessionViewed(sessionId).catch(() => { }); + }, [sessionId]); + + useFocusEffect(React.useCallback(() => { + isFocusedRef.current = true; + { + const current = storage.getState().sessions[sessionId]; + lastMarkedRef.current = { + sessionSeq: current?.seq ?? 0, + pendingActivityAt: computePendingActivityAt(current?.metadata), + }; + } + markSessionViewed(); + return () => { + isFocusedRef.current = false; + if (markViewedTimeoutRef.current) { + clearTimeout(markViewedTimeoutRef.current); + markViewedTimeoutRef.current = null; + } + markSessionViewed(); + }; + }, [markSessionViewed, sessionId])); + + React.useEffect(() => { + if (!isFocusedRef.current) return; + + const sessionSeq = session.seq ?? 0; + const last = lastMarkedRef.current; + if (last && last.sessionSeq >= sessionSeq && last.pendingActivityAt >= pendingActivityAt) return; + + lastMarkedRef.current = { sessionSeq, pendingActivityAt }; + if (markViewedTimeoutRef.current) clearTimeout(markViewedTimeoutRef.current); + markViewedTimeoutRef.current = setTimeout(() => { + markViewedTimeoutRef.current = null; + markSessionViewed(); + }, 250); + return () => { + if (markViewedTimeoutRef.current) { + clearTimeout(markViewedTimeoutRef.current); + markViewedTimeoutRef.current = null; + } + }; + }, [markSessionViewed, pendingActivityAt, session.seq]); + + React.useEffect(() => { + void sync.fetchPendingMessages(sessionId).catch(() => { }); + }, [sessionId, session.metadataVersion]); + // Handle dismissing CLI version warning const handleDismissCliWarning = React.useCallback(() => { if (machineId && cliVersion) { @@ -192,14 +333,121 @@ function SessionViewLoaded({ sessionId, session }: { sessionId: string, session: }, [machineId, cliVersion, acknowledgedCliVersions]); // Function to update permission mode - const updatePermissionMode = React.useCallback((mode: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' | 'read-only' | 'safe-yolo' | 'yolo') => { + const updatePermissionMode = React.useCallback((mode: PermissionMode) => { storage.getState().updateSessionPermissionMode(sessionId, mode); }, [sessionId]); - // Function to update model mode (for Gemini sessions) - const updateModelMode = React.useCallback((mode: 'default' | 'gemini-2.5-pro' | 'gemini-2.5-flash' | 'gemini-2.5-flash-lite') => { + // Function to update model mode (only for agents that expose model selection in the UI) + const updateModelMode = React.useCallback((mode: ModelMode) => { + const core = getAgentCore(agentId); + if (core.model.supportsSelection !== true) return; + if (!core.model.allowedModes.includes(mode)) return; storage.getState().updateSessionModelMode(sessionId, mode); - }, [sessionId]); + }, [agentId, sessionId]); + + // Handle resuming an inactive session + const handleResumeSession = React.useCallback(async () => { + if (!session.metadata?.machineId || !session.metadata?.path || !session.metadata?.flavor) { + Modal.alert(t('common.error'), t('session.resumeFailed')); + return; + } + if (!canResumeSessionWithOptions(session.metadata, resumeCapabilityOptions)) { + if (acpLoadSessionSupport?.kind === 'error' || acpLoadSessionSupport?.kind === 'unknown') { + const detailLines: string[] = []; + if (acpLoadSessionSupport?.code) { + detailLines.push(formatResumeSupportDetailCode(acpLoadSessionSupport.code)); + } + if (acpLoadSessionSupport?.rawMessage) { + detailLines.push(acpLoadSessionSupport.rawMessage); + } + const detail = detailLines.length > 0 ? `\n\n${t('common.details')}: ${detailLines.join('\n')}` : ''; + Modal.alert(t('common.error'), `${t('session.resumeFailed')}${detail}`); + } else { + Modal.alert(t('common.error'), t('session.resumeFailed')); + } + return; + } + if (!isMachineReachable) { + Modal.alert(t('common.error'), t('session.machineOfflineCannotResume')); + return; + } + + const sessionEncryptionKeyBase64 = sync.getSessionEncryptionKeyBase64ForResume(sessionId); + if (!sessionEncryptionKeyBase64) { + Modal.alert(t('common.error'), t('session.resumeFailed')); + return; + } + + setIsResuming(true); + try { + const permissionOverride = getPermissionModeOverrideForSpawn(session); + const base = buildResumeSessionBaseOptionsFromSession({ + sessionId, + session, + resumeCapabilityOptions, + permissionOverride, + }); + if (!base) { + Modal.alert(t('common.error'), t('session.resumeFailed')); + return; + } + + const snapshotBefore = getMachineCapabilitiesSnapshot(base.machineId); + const resultsBefore = snapshotBefore?.response.results as any; + const preflightPlan = getResumePreflightPrefetchPlan({ agentId, settings, results: resultsBefore }); + if (preflightPlan) { + try { + await prefetchMachineCapabilities({ + machineId: base.machineId, + request: preflightPlan.request, + timeoutMs: preflightPlan.timeoutMs, + }); + } catch { + // Non-blocking; fall back to attempting resume (pending queue preserves user message). + } + } + + const snapshot = getMachineCapabilitiesSnapshot(base.machineId); + const results = snapshot?.response.results as any; + const issues = getResumePreflightIssues({ + agentId, + experiments: getAgentResumeExperimentsFromSettings(agentId, settings), + results, + }); + + const blockingIssue = issues[0] ?? null; + if (blockingIssue) { + const openMachine = await Modal.confirm( + t(blockingIssue.titleKey), + t(blockingIssue.messageKey), + { confirmText: t(blockingIssue.confirmTextKey) } + ); + if (openMachine && blockingIssue.action === 'openMachine') { + router.push(`/machine/${base.machineId}` as any); + } + return; + } + + const result = await resumeSession({ + ...base, + sessionEncryptionKeyBase64, + sessionEncryptionVariant: 'dataKey', + ...buildResumeSessionExtrasFromUiState({ + agentId, + settings, + }), + }); + + if (result.type === 'error') { + Modal.alert(t('common.error'), result.errorMessage); + } + // On success, the session will become active and UI will update automatically + } catch (error) { + Modal.alert(t('common.error'), t('session.resumeFailed')); + } finally { + setIsResuming(false); + } + }, [agentId, resumeCapabilityOptions, router, session, sessionId, settings]); // Memoize header-dependent styles to prevent re-renders const headerDependentStyles = React.useMemo(() => ({ @@ -253,16 +501,57 @@ function SessionViewLoaded({ sessionId, session }: { sessionId: string, session: gitStatusSync.getSync(sessionId); }, [sessionId, realtimeStatus]); + const showInactiveNotResumableNotice = inactiveUi.noticeKind === 'not-resumable'; + const showMachineOfflineNotice = inactiveUi.noticeKind === 'machine-offline'; + const providerName = getAgentCore(agentId).connectedService?.name ?? t('status.unknown'); + const machineName = machine?.metadata?.displayName ?? machine?.metadata?.host ?? t('status.unknown'); + + const bottomNotice = React.useMemo(() => { + if (showInactiveNotResumableNotice) { + const extra = (() => { + if (!acpLoadSessionSupport) return ''; + if (acpLoadSessionSupport.kind === 'supported') return ''; + const note = acpLoadSessionSupport.kind === 'unknown' + ? `\n\n${t('session.resumeSupportNoteChecking')}` + : `\n\n${t('session.resumeSupportNoteUnverified')}`; + + const detailLines: string[] = []; + if (acpLoadSessionSupport.code) { + detailLines.push(formatResumeSupportDetailCode(acpLoadSessionSupport.code)); + } + if (acpLoadSessionSupport.rawMessage) { + detailLines.push(acpLoadSessionSupport.rawMessage); + } + const detail = detailLines.length > 0 ? `\n\n${t('common.details')}: ${detailLines.join('\n')}` : ''; + return `${note}${detail}`; + })(); + return { + title: t('session.inactiveNotResumableNoticeTitle'), + body: `${t('session.inactiveNotResumableNoticeBody', { provider: providerName })}${extra}`, + }; + } + if (showMachineOfflineNotice) { + return { + title: t('session.machineOfflineNoticeTitle'), + body: t('session.machineOfflineNoticeBody', { machine: machineName }), + }; + } + return null; + }, [acpLoadSessionSupport, machineName, providerName, showInactiveNotResumableNotice, showMachineOfflineNotice]); + let content = ( <> <Deferred> - {messages.length > 0 && ( - <ChatList session={session} /> + {(messages.length > 0 || pendingMessages.length > 0) && ( + <ChatList + session={session} + bottomNotice={bottomNotice} + /> )} </Deferred> </> ); - const placeholder = messages.length === 0 ? ( + const placeholder = (messages.length === 0 && pendingMessages.length === 0) ? ( <> {isLoaded ? ( <EmptyMessages session={session} /> @@ -272,55 +561,149 @@ function SessionViewLoaded({ sessionId, session }: { sessionId: string, session: </> ) : null; - const input = ( - <AgentInput - placeholder={t('session.inputPlaceholder')} - value={message} - onChangeText={setMessage} - sessionId={sessionId} - permissionMode={permissionMode} - onPermissionModeChange={updatePermissionMode} - modelMode={modelMode as any} - onModelModeChange={updateModelMode as any} - metadata={session.metadata} - connectionStatus={{ - text: sessionStatus.statusText, - color: sessionStatus.statusColor, - dotColor: sessionStatus.statusDotColor, - isPulsing: sessionStatus.isPulsing - }} - onSend={() => { - if (message.trim()) { + // Determine the status text to show for inactive sessions + const inactiveStatusText = inactiveUi.inactiveStatusTextKey ? t(inactiveUi.inactiveStatusTextKey) : null; + + const shouldShowInput = inactiveUi.shouldShowInput; + const hasWriteAccess = !session.accessLevel || session.accessLevel === 'edit' || session.accessLevel === 'admin'; + const isReadOnly = session.accessLevel === 'view'; + + const input = shouldShowInput ? ( + <View> + <AgentInput + placeholder={isReadOnly ? t('session.sharing.viewOnlyMode') : t('session.inputPlaceholder')} + value={message} + onChangeText={setMessage} + sessionId={sessionId} + permissionMode={permissionMode} + onPermissionModeChange={updatePermissionMode} + modelMode={modelMode} + onModelModeChange={updateModelMode} + metadata={session.metadata} + profileId={session.metadata?.profileId ?? undefined} + onProfileClick={session.metadata?.profileId !== undefined ? () => { + const profileId = session.metadata?.profileId; + const profileInfo = (profileId === null || (typeof profileId === 'string' && profileId.trim() === '')) + ? t('profiles.noProfile') + : (typeof profileId === 'string' ? profileId : t('status.unknown')); + Modal.alert( + t('profiles.title'), + `${t('profiles.sessionUses', { profile: profileInfo })}\n\n${t('profiles.profilesFixedPerSession')}`, + ); + } : undefined} + connectionStatus={{ + text: isResuming ? t('session.resuming') : (inactiveStatusText || sessionStatus.statusText), + color: sessionStatus.statusColor, + dotColor: sessionStatus.statusDotColor, + isPulsing: isResuming || sessionStatus.isPulsing + }} + onSend={() => { + if (!hasWriteAccess) { + Modal.alert(t('common.error'), t('session.sharing.noEditPermission')); + return; + } + const text = message.trim(); + if (!text) return; setMessage(''); clearDraft(); - sync.sendMessage(sessionId, message); trackMessageSent(); - } - }} - onMicPress={micButtonState.onMicPress} - isMicActive={micButtonState.isMicActive} - onAbort={() => sessionAbort(sessionId)} - showAbortButton={sessionStatus.state === 'thinking' || sessionStatus.state === 'waiting'} - onFileViewerPress={experiments ? () => router.push(`/session/${sessionId}/files`) : undefined} - // Autocomplete configuration - autocompletePrefixes={['@', '/']} - autocompleteSuggestions={(query) => getSuggestions(sessionId, query)} - usageData={sessionUsage ? { - inputTokens: sessionUsage.inputTokens, - outputTokens: sessionUsage.outputTokens, - cacheCreation: sessionUsage.cacheCreation, - cacheRead: sessionUsage.cacheRead, - contextSize: sessionUsage.contextSize - } : session.latestUsage ? { - inputTokens: session.latestUsage.inputTokens, - outputTokens: session.latestUsage.outputTokens, - cacheCreation: session.latestUsage.cacheCreation, - cacheRead: session.latestUsage.cacheRead, - contextSize: session.latestUsage.contextSize - } : undefined} - alwaysShowContextSize={alwaysShowContextSize} - /> - ); + + const configuredMode = storage.getState().settings.sessionMessageSendMode; + const submitMode = chooseSubmitMode({ configuredMode, session }); + + if (submitMode === 'server_pending') { + void (async () => { + try { + await sync.enqueuePendingMessage(sessionId, text); + } catch (e) { + setMessage(text); + Modal.alert(t('common.error'), e instanceof Error ? e.message : t('errors.failedToSendMessage')); + return; + } + + const wakeOpts = getPendingQueueWakeResumeOptions({ + sessionId, + session, + resumeCapabilityOptions, + permissionOverride: getPermissionModeOverrideForSpawn(session), + }); + if (!wakeOpts) return; + + try { + const sessionEncryptionKeyBase64 = sync.getSessionEncryptionKeyBase64ForResume(sessionId); + if (!sessionEncryptionKeyBase64) { + Modal.alert(t('common.error'), t('session.resumeFailed')); + return; + } + + const result = await resumeSession({ + ...wakeOpts, + sessionEncryptionKeyBase64, + sessionEncryptionVariant: 'dataKey', + }); + if (result.type === 'error') { + Modal.alert(t('common.error'), result.errorMessage); + } + } catch { + Modal.alert(t('common.error'), t('session.resumeFailed')); + } + })(); + return; + } + + // If session is inactive but resumable, resume it and send the message through the agent. + if (!isSessionActive && isResumable) { + void (async () => { + try { + // Always enqueue as a server-side pending message first so: + // - the user message is preserved even if spawn fails + // - the agent can pull it when it is ready (metadata-backed messageQueueV1) + await sync.enqueuePendingMessage(sessionId, text); + await handleResumeSession(); + } catch (e) { + setMessage(text); + Modal.alert(t('common.error'), e instanceof Error ? e.message : t('errors.failedToResumeSession')); + } + })(); + return; + } + + void (async () => { + try { + await sync.submitMessage(sessionId, text); + } catch (e) { + setMessage(text); + Modal.alert(t('common.error'), e instanceof Error ? e.message : t('errors.failedToSendMessage')); + } + })(); + }} + isSendDisabled={!shouldShowInput || isResuming || isReadOnly} + onMicPress={micButtonState.onMicPress} + isMicActive={micButtonState.isMicActive} + onAbort={() => sessionAbort(sessionId)} + showAbortButton={sessionStatus.state === 'thinking' || sessionStatus.state === 'waiting'} + onFileViewerPress={(settings.experiments === true && expFileViewer === true) ? () => router.push(`/session/${sessionId}/files`) : undefined} + // Autocomplete configuration + autocompletePrefixes={['@', '/']} + autocompleteSuggestions={(query) => getSuggestions(sessionId, query)} + disabled={isReadOnly} + usageData={sessionUsage ? { + inputTokens: sessionUsage.inputTokens, + outputTokens: sessionUsage.outputTokens, + cacheCreation: sessionUsage.cacheCreation, + cacheRead: sessionUsage.cacheRead, + contextSize: sessionUsage.contextSize + } : session.latestUsage ? { + inputTokens: session.latestUsage.inputTokens, + outputTokens: session.latestUsage.outputTokens, + cacheCreation: session.latestUsage.cacheCreation, + cacheRead: session.latestUsage.cacheRead, + contextSize: session.latestUsage.contextSize + } : undefined} + alwaysShowContextSize={alwaysShowContextSize} + /> + </View> + ) : null; return ( diff --git a/expo-app/sources/-session/sessionResumeUi.test.ts b/expo-app/sources/-session/sessionResumeUi.test.ts new file mode 100644 index 000000000..6052f518c --- /dev/null +++ b/expo-app/sources/-session/sessionResumeUi.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; + +import { getInactiveSessionUiState } from './sessionResumeUi'; + +describe('getInactiveSessionUiState', () => { + it('shows input for active sessions', () => { + expect(getInactiveSessionUiState({ + isSessionActive: true, + isResumable: false, + isMachineOnline: false, + })).toEqual({ + shouldShowInput: true, + inactiveStatusTextKey: null, + noticeKind: 'none', + }); + }); + + it('hides input and shows a not-resumable notice when vendor resume is not available', () => { + expect(getInactiveSessionUiState({ + isSessionActive: false, + isResumable: false, + isMachineOnline: true, + })).toEqual({ + shouldShowInput: false, + inactiveStatusTextKey: 'session.inactiveNotResumable', + noticeKind: 'not-resumable', + }); + }); + + it('hides input and shows a machine-offline notice when the machine is offline', () => { + expect(getInactiveSessionUiState({ + isSessionActive: false, + isResumable: true, + isMachineOnline: false, + })).toEqual({ + shouldShowInput: false, + inactiveStatusTextKey: 'session.inactiveMachineOffline', + noticeKind: 'machine-offline', + }); + }); + + it('shows input for inactive resumable sessions when machine is online', () => { + expect(getInactiveSessionUiState({ + isSessionActive: false, + isResumable: true, + isMachineOnline: true, + })).toEqual({ + shouldShowInput: true, + inactiveStatusTextKey: 'session.inactiveResumable', + noticeKind: 'none', + }); + }); +}); + diff --git a/expo-app/sources/-session/sessionResumeUi.ts b/expo-app/sources/-session/sessionResumeUi.ts new file mode 100644 index 000000000..780435b24 --- /dev/null +++ b/expo-app/sources/-session/sessionResumeUi.ts @@ -0,0 +1,40 @@ +export type InactiveSessionNoticeKind = 'none' | 'not-resumable' | 'machine-offline'; + +export type InactiveSessionUiState = Readonly<{ + shouldShowInput: boolean; + inactiveStatusTextKey: 'session.inactiveResumable' | 'session.inactiveMachineOffline' | 'session.inactiveNotResumable' | null; + noticeKind: InactiveSessionNoticeKind; +}>; + +export function getInactiveSessionUiState(opts: { + isSessionActive: boolean; + isResumable: boolean; + isMachineOnline: boolean; +}): InactiveSessionUiState { + if (opts.isSessionActive) { + return { shouldShowInput: true, inactiveStatusTextKey: null, noticeKind: 'none' }; + } + + if (!opts.isResumable) { + return { + shouldShowInput: false, + inactiveStatusTextKey: 'session.inactiveNotResumable', + noticeKind: 'not-resumable', + }; + } + + if (!opts.isMachineOnline) { + return { + shouldShowInput: false, + inactiveStatusTextKey: 'session.inactiveMachineOffline', + noticeKind: 'machine-offline', + }; + } + + return { + shouldShowInput: true, + inactiveStatusTextKey: 'session.inactiveResumable', + noticeKind: 'none', + }; +} + diff --git a/expo-app/sources/-zen/ZenAdd.tsx b/expo-app/sources/-zen/ZenAdd.tsx index 14e4da50c..4c9ab8654 100644 --- a/expo-app/sources/-zen/ZenAdd.tsx +++ b/expo-app/sources/-zen/ZenAdd.tsx @@ -6,6 +6,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { Typography } from '@/constants/Typography'; import { addTodo } from '@/-zen/model/ops'; import { useAuth } from '@/auth/AuthContext'; +import { t } from '@/text'; export const ZenAdd = React.memo(() => { const router = useRouter(); @@ -38,7 +39,7 @@ export const ZenAdd = React.memo(() => { borderBottomColor: theme.colors.divider, } ]} - placeholder="What needs to be done?" + placeholder={t('zen.add.placeholder')} placeholderTextColor={theme.colors.textSecondary} value={text} onChangeText={setText} @@ -71,4 +72,4 @@ const styles = StyleSheet.create((theme) => ({ paddingHorizontal: 4, ...Typography.default(), }, -})); \ No newline at end of file +})); diff --git a/expo-app/sources/-zen/ZenHome.tsx b/expo-app/sources/-zen/ZenHome.tsx index fdfd6e924..f9a46f901 100644 --- a/expo-app/sources/-zen/ZenHome.tsx +++ b/expo-app/sources/-zen/ZenHome.tsx @@ -11,6 +11,7 @@ import { toggleTodo as toggleTodoSync, reorderTodos as reorderTodosSync } from ' import { useAuth } from '@/auth/AuthContext'; import { useShallow } from 'zustand/react/shallow'; import { VoiceAssistantStatusBar } from '@/components/VoiceAssistantStatusBar'; +import { t } from '@/text'; export const ZenHome = () => { const insets = useSafeAreaInsets(); @@ -31,12 +32,12 @@ export const ZenHome = () => { const undone = todoState.undoneOrder .map(id => todoState.todos[id]) .filter(Boolean) - .map(t => ({ id: t.id, title: t.title, done: t.done })); + .map(todo => ({ id: todo.id, title: todo.title, done: todo.done })); const done = todoState.doneOrder .map(id => todoState.todos[id]) .filter(Boolean) - .map(t => ({ id: t.id, title: t.title, done: t.done })); + .map(todo => ({ id: todo.id, title: todo.title, done: todo.done })); return { undoneTodos: undone, doneTodos: done }; }, [todoState]); @@ -103,7 +104,7 @@ export const ZenHome = () => { {undoneTodos.length === 0 ? ( <View style={{ padding: 20, alignItems: 'center' }}> <Text style={{ color: theme.colors.textSecondary, fontSize: 16 }}> - No tasks yet. Tap + to add one. + {t('zen.home.noTasksYet')} </Text> </View> ) : ( @@ -114,4 +115,4 @@ export const ZenHome = () => { </ScrollView> </> ); -}; \ No newline at end of file +}; diff --git a/expo-app/sources/-zen/ZenView.tsx b/expo-app/sources/-zen/ZenView.tsx index d04e51d11..755e5f0d9 100644 --- a/expo-app/sources/-zen/ZenView.tsx +++ b/expo-app/sources/-zen/ZenView.tsx @@ -14,6 +14,8 @@ import { clarifyPrompt } from '@/-zen/model/prompts'; import { storeTempData, type NewSessionData } from '@/utils/tempDataStore'; import { toCamelCase } from '@/utils/stringUtils'; import { removeTaskLinks, getSessionsForTask } from '@/-zen/model/taskSessionLink'; +import { t } from '@/text'; +import { DEFAULT_AGENT_ID } from '@/agents/catalog'; export const ZenView = React.memo(() => { const router = useRouter(); @@ -111,7 +113,7 @@ export const ZenView = React.memo(() => { // Store the prompt data in temporary store const sessionData: NewSessionData = { prompt: promptText, - agentType: 'claude', // Default to Claude for clarification tasks + agentType: DEFAULT_AGENT_ID, // Default agent for clarification tasks taskId: todoId, taskTitle: editedText }; @@ -131,7 +133,7 @@ export const ZenView = React.memo(() => { // Store the prompt data in temporary store const sessionData: NewSessionData = { prompt: promptText, - agentType: 'claude', // Default to Claude + agentType: DEFAULT_AGENT_ID, // Default agent taskId: todoId, taskTitle: editedText }; @@ -217,7 +219,7 @@ export const ZenView = React.memo(() => { style={[styles.actionButton, { backgroundColor: theme.colors.button.primary.background }]} > <Ionicons name="hammer-outline" size={20} color="#FFFFFF" /> - <Text style={styles.actionButtonText}>Work on task</Text> + <Text style={styles.actionButtonText}>{t('zen.view.workOnTask')}</Text> </Pressable> <Pressable @@ -225,7 +227,7 @@ export const ZenView = React.memo(() => { style={[styles.actionButton, { backgroundColor: theme.colors.surfaceHighest }]} > <Ionicons name="sparkles" size={20} color={theme.colors.text} /> - <Text style={[styles.actionButtonText, { color: theme.colors.text }]}>Clarify</Text> + <Text style={[styles.actionButtonText, { color: theme.colors.text }]}>{t('zen.view.clarify')}</Text> </Pressable> <Pressable @@ -233,7 +235,7 @@ export const ZenView = React.memo(() => { style={[styles.actionButton, { backgroundColor: theme.colors.textDestructive }]} > <Ionicons name="trash-outline" size={20} color="#FFFFFF" /> - <Text style={styles.actionButtonText}>Delete</Text> + <Text style={styles.actionButtonText}>{t('zen.view.delete')}</Text> </Pressable> </View> @@ -241,7 +243,7 @@ export const ZenView = React.memo(() => { {linkedSessions.length > 0 && ( <View style={styles.linkedSessionsSection}> <Text style={[styles.sectionTitle, { color: theme.colors.text }]}> - Linked Sessions + {t('zen.view.linkedSessions')} </Text> {linkedSessions.map((link, index) => ( <Pressable @@ -262,7 +264,7 @@ export const ZenView = React.memo(() => { {/* Helper Text */} <View style={styles.helperSection}> <Text style={[styles.helperText, { color: theme.colors.textSecondary }]}> - Tap the task text to edit + {t('zen.view.tapTaskTextToEdit')} </Text> </View> </View> @@ -365,4 +367,4 @@ const styles = StyleSheet.create((theme) => ({ fontSize: 14, ...Typography.default(), }, -})); \ No newline at end of file +})); diff --git a/expo-app/sources/-zen/components/ZenHeader.tsx b/expo-app/sources/-zen/components/ZenHeader.tsx index 75620da4f..75baf657c 100644 --- a/expo-app/sources/-zen/components/ZenHeader.tsx +++ b/expo-app/sources/-zen/components/ZenHeader.tsx @@ -33,7 +33,7 @@ function HeaderTitleTablet() { fontWeight: '600', ...Typography.default('semiBold'), }}> - Zen + {t('zen.title')} </Text> ); } @@ -93,7 +93,7 @@ function HeaderTitle() { fontWeight: '600', ...Typography.default('semiBold'), }}> - Zen + {t('zen.title')} </Text> {connectionStatus.text && ( <View style={{ @@ -158,4 +158,4 @@ function HeaderRight() { <Ionicons name="add-outline" size={28} color={theme.colors.header.tint} /> </Pressable> ); -} \ No newline at end of file +} diff --git a/expo-app/sources/-zen/model/ops.ts b/expo-app/sources/-zen/model/ops.ts index 85c32b4b2..79e1327eb 100644 --- a/expo-app/sources/-zen/model/ops.ts +++ b/expo-app/sources/-zen/model/ops.ts @@ -11,7 +11,7 @@ import { KvItem, KvMutation } from '../../sync/apiKv'; -import { randomUUID } from 'expo-crypto'; +import { randomUUID } from '@/platform/randomUUID'; import { AsyncLock } from '@/utils/lock'; // diff --git a/expo-app/sources/__tests__/app/_layout.test.ts b/expo-app/sources/__tests__/app/_layout.test.ts new file mode 100644 index 000000000..d539e6128 --- /dev/null +++ b/expo-app/sources/__tests__/app/_layout.test.ts @@ -0,0 +1,120 @@ +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import renderer, { act } from 'react-test-renderer'; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +let isAuthenticated = true; +let segments: string[] = ['(app)']; + +vi.mock('react-native-reanimated', () => ({})); + +vi.mock('@expo/vector-icons', () => { + const React = require('react'); + return { + Ionicons: (props: any) => React.createElement('Ionicons', props, props.children), + }; +}); + +vi.mock('@/components/navigation/Header', () => { + return { createHeader: () => null }; +}); + +vi.mock('@/constants/Typography', () => { + return { Typography: { default: () => ({}) } }; +}); + +vi.mock('@/text', () => { + return { t: (key: string) => key }; +}); + +vi.mock('react-native', () => { + const React = require('react'); + return { + Platform: { OS: 'web', select: (o: any) => o.web ?? o.default }, + TouchableOpacity: (props: any) => React.createElement('TouchableOpacity', props, props.children), + Text: (props: any) => React.createElement('Text', props, props.children), + }; +}); + +vi.mock('expo-router', () => { + const React = require('react'); + const Stack: any = (props: any) => React.createElement('Stack', props, props.children); + Stack.Screen = (props: any) => React.createElement('StackScreen', props, props.children); + return { + Stack, + router: { replace: vi.fn() }, + useSegments: () => { + React.useMemo(() => 0, [segments.join('|')]); + return segments; + }, + }; +}); + +vi.mock('@/auth/AuthContext', () => { + const React = require('react'); + return { + useAuth: () => { + React.useMemo(() => 0, [isAuthenticated]); + return { isAuthenticated }; + }, + }; +}); + +vi.mock('@/auth/authRouting', () => { + return { + isPublicRouteForUnauthenticated: () => false, + }; +}); + +vi.mock('react-native-unistyles', () => { + const React = require('react'); + return { + useUnistyles: () => { + React.useMemo(() => 0, []); + return { + theme: { + colors: { + surface: '#fff', + header: { background: '#fff', tint: '#000' }, + }, + }, + }; + }, + }; +}); + +vi.mock('@/utils/platform', () => { + return { isRunningOnMac: () => false }; +}); + +describe('RootLayout hooks order', () => { + it('does not throw when redirecting after a non-redirect render', async () => { + const { default: RootLayout } = await import('@/app/(app)/_layout'); + + isAuthenticated = true; + segments = ['(app)']; + + let tree: renderer.ReactTestRenderer | undefined; + try { + act(() => { + tree = renderer.create(React.createElement(RootLayout)); + }); + + isAuthenticated = false; + segments = ['(app)', 'settings']; + + expect(() => { + act(() => { + tree!.update(React.createElement(RootLayout)); + }); + }).not.toThrow(); + } finally { + if (tree) { + act(() => { + tree!.unmount(); + }); + } + } + }); +}); diff --git a/expo-app/sources/__tests__/app/machine/machineDetails.capabilitiesRequestStability.test.ts b/expo-app/sources/__tests__/app/machine/machineDetails.capabilitiesRequestStability.test.ts new file mode 100644 index 000000000..3c2d61313 --- /dev/null +++ b/expo-app/sources/__tests__/app/machine/machineDetails.capabilitiesRequestStability.test.ts @@ -0,0 +1,214 @@ +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import renderer, { act } from 'react-test-renderer'; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; +(globalThis as any).expo = { EventEmitter: class {} }; + +const requests: unknown[] = []; + +vi.mock('react-native-reanimated', () => ({})); + +vi.mock('react-native', () => { + return { + Platform: { OS: 'web', select: (o: any) => o.web ?? o.default }, + TurboModuleRegistry: { getEnforcing: () => ({}) }, + View: 'View', + Text: 'Text', + ScrollView: 'ScrollView', + ActivityIndicator: 'ActivityIndicator', + RefreshControl: 'RefreshControl', + Pressable: 'Pressable', + TextInput: 'TextInput', + }; +}); + +vi.mock('@expo/vector-icons', () => { + return { + Ionicons: 'Ionicons', + Octicons: 'Octicons', + }; +}); + +vi.mock('expo-router', () => { + const Stack: any = {}; + Stack.Screen = () => null; + return { + Stack, + useLocalSearchParams: () => ({ id: 'machine-1' }), + useRouter: () => ({ back: vi.fn(), push: vi.fn(), replace: vi.fn() }), + }; +}); + +vi.mock('react-native-unistyles', () => { + const React = require('react'); + return { + useUnistyles: () => { + React.useMemo(() => 0, []); + return { + theme: { + colors: { + header: { tint: '#000' }, + input: { background: '#fff', text: '#000' }, + groupped: { background: '#fff', sectionTitle: '#000' }, + divider: '#ddd', + button: { primary: { background: '#000', tint: '#fff' } }, + text: '#000', + textSecondary: '#666', + surface: '#fff', + surfaceHigh: '#fff', + shadow: { color: '#000', opacity: 0.1 }, + status: { error: '#f00', connected: '#0f0', connecting: '#ff0', disconnected: '#999', default: '#999' }, + permissionButton: { inactive: { background: '#ccc' } }, + }, + }, + }; + }, + StyleSheet: { + create: (fn: any) => fn({ + colors: { + header: { tint: '#000' }, + input: { background: '#fff', text: '#000' }, + groupped: { background: '#fff', sectionTitle: '#000' }, + divider: '#ddd', + button: { primary: { background: '#000', tint: '#fff' } }, + text: '#000', + textSecondary: '#666', + surface: '#fff', + surfaceHigh: '#fff', + shadow: { color: '#000', opacity: 0.1 }, + status: { error: '#f00' }, + permissionButton: { inactive: { background: '#ccc' } }, + }, + }), + }, + }; +}); + +vi.mock('@/constants/Typography', () => { + return { Typography: { default: () => ({}) } }; +}); + +vi.mock('@/text', () => { + return { t: (key: string) => key }; +}); + +vi.mock('@/components/ui/lists/Item', () => ({ + Item: () => null, +})); + +vi.mock('@/components/ui/lists/ItemGroup', () => ({ + ItemGroup: ({ children }: any) => React.createElement(React.Fragment, null, children), +})); + +vi.mock('@/components/ui/lists/ItemList', () => ({ + ItemList: ({ children }: any) => React.createElement(React.Fragment, null, children), +})); + +vi.mock('@/components/MultiTextInput', () => ({ + MultiTextInput: () => null, +})); + +vi.mock('@/components/machines/DetectedClisList', () => ({ + DetectedClisList: () => null, +})); + +vi.mock('@/components/Switch', () => ({ + Switch: () => null, +})); + +vi.mock('@/modal', () => { + return { Modal: { alert: vi.fn(), confirm: vi.fn(), prompt: vi.fn(), show: vi.fn() } }; +}); + +vi.mock('@/sync/storage', () => { + const React = require('react'); + return { + storage: { getState: () => ({ applyFriends: vi.fn() }) }, + useSessions: () => [], + useAllMachines: () => [], + useMachine: () => null, + useSettings: () => { + React.useMemo(() => 0, []); + return { + experiments: true, + expCodexResume: true, + expCodexAcp: false, + }; + }, + useSetting: (name: string) => { + React.useMemo(() => 0, [name]); + if (name === 'experiments') return true; + if (name === 'expCodexResume') return true; + return false; + }, + useSettingMutable: (name: string) => { + React.useMemo(() => 0, [name]); + return [name === 'codexResumeInstallSpec' ? '' : null, vi.fn()]; + }, + }; +}); + +vi.mock('@/hooks/useNavigateToSession', () => { + return { useNavigateToSession: () => () => {} }; +}); + +vi.mock('@/hooks/useMachineCapabilitiesCache', () => { + return { + useMachineCapabilitiesCache: (params: any) => { + requests.push(params.request); + return { state: { status: 'idle' }, refresh: vi.fn() }; + }, + }; +}); + +vi.mock('@/sync/ops', () => { + return { + machineCapabilitiesInvoke: vi.fn(), + machineSpawnNewSession: vi.fn(), + machineStopDaemon: vi.fn(), + machineUpdateMetadata: vi.fn(), + }; +}); + +vi.mock('@/sync/sync', () => { + return { sync: { refreshMachines: vi.fn(), retryNow: vi.fn() } }; +}); + +vi.mock('@/utils/machineUtils', () => { + return { isMachineOnline: () => true }; +}); + +vi.mock('@/utils/sessionUtils', () => { + return { + formatPathRelativeToHome: () => '', + getSessionName: () => '', + getSessionSubtitle: () => '', + }; +}); + +vi.mock('@/utils/pathUtils', () => { + return { resolveAbsolutePath: () => '' }; +}); + +vi.mock('@/sync/terminalSettings', () => { + return { resolveTerminalSpawnOptions: () => ({}) }; +}); + +describe('MachineDetailScreen capabilities request', () => { + it('passes a stable request object to useMachineCapabilitiesCache', async () => { + const { default: MachineDetailScreen } = await import('@/app/(app)/machine/[id]'); + + let tree: renderer.ReactTestRenderer | undefined; + act(() => { + tree = renderer.create(React.createElement(MachineDetailScreen)); + }); + + act(() => { + tree!.update(React.createElement(MachineDetailScreen)); + }); + + expect(requests.length).toBeGreaterThanOrEqual(2); + expect(requests[0]).toBe(requests[1]); + }); +}); diff --git a/expo-app/sources/__tests__/app/new/pick/machine.presentation.test.ts b/expo-app/sources/__tests__/app/new/pick/machine.presentation.test.ts new file mode 100644 index 000000000..3996d8068 --- /dev/null +++ b/expo-app/sources/__tests__/app/new/pick/machine.presentation.test.ts @@ -0,0 +1,88 @@ +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import renderer, { act } from 'react-test-renderer'; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +vi.mock('react-native', () => { + const React = require('react'); + return { + Platform: { OS: 'ios' }, + ActivityIndicator: (props: any) => React.createElement('ActivityIndicator', props), + Pressable: (props: any) => React.createElement('Pressable', props, props.children), + Text: (props: any) => React.createElement('Text', props, props.children), + View: (props: any) => React.createElement('View', props, props.children), + }; +}); + +vi.mock('@expo/vector-icons', () => ({ + Ionicons: 'Ionicons', +})); + +vi.mock('@/text', () => ({ + t: (key: string) => key, +})); + +vi.mock('react-native-unistyles', () => ({ + useUnistyles: () => ({ theme: { colors: { textSecondary: '#666', header: { tint: '#000' }, surface: '#fff' } } }), + StyleSheet: { create: () => ({ container: {}, emptyContainer: {}, emptyText: {} }) }, +})); + +vi.mock('expo-router', () => ({ + Stack: { Screen: (props: any) => React.createElement('StackScreen', props) }, + useRouter: () => ({ back: vi.fn() }), + useNavigation: () => ({ getState: () => ({ index: 1, routes: [{ key: 'a' }, { key: 'b' }] }), dispatch: vi.fn() }), + useLocalSearchParams: () => ({ selectedId: 'm1' }), +})); + +vi.mock('@react-navigation/native', () => ({ + CommonActions: { + setParams: (params: any) => ({ type: 'SET_PARAMS', payload: { params } }), + }, +})); + +vi.mock('@/sync/storage', () => ({ + useAllMachines: () => [], + useSessions: () => [], + useSetting: () => false, + useSettingMutable: () => [[], vi.fn()], +})); + +vi.mock('@/components/ui/lists/ItemList', () => ({ + ItemList: ({ children }: any) => React.createElement(React.Fragment, null, children), +})); + +vi.mock('@/components/sessions/new/components/MachineSelector', () => ({ + MachineSelector: () => null, +})); + +vi.mock('@/utils/sessions/recentMachines', () => ({ + getRecentMachinesFromSessions: () => [], +})); + +vi.mock('@/sync/sync', () => ({ + sync: { refreshMachinesThrottled: vi.fn() }, +})); + +vi.mock('@/hooks/useMachineCapabilitiesCache', () => ({ + prefetchMachineCapabilities: vi.fn(), +})); + +vi.mock('@/hooks/useMachineEnvPresence', () => ({ + invalidateMachineEnvPresence: vi.fn(), +})); + +describe('MachinePickerScreen (iOS presentation)', () => { + it('presents as containedModal on iOS and provides an explicit header back button', async () => { + const MachinePickerScreen = (await import('@/app/(app)/new/pick/machine')).default; + + let tree: renderer.ReactTestRenderer | undefined; + await act(async () => { + tree = renderer.create(React.createElement(MachinePickerScreen)); + }); + + const stackScreen = tree?.root.findByType('StackScreen' as any); + expect(stackScreen?.props?.options?.presentation).toBe('containedModal'); + expect(typeof stackScreen?.props?.options?.headerLeft).toBe('function'); + }); +}); diff --git a/expo-app/sources/__tests__/app/new/pick/path.presentation.test.ts b/expo-app/sources/__tests__/app/new/pick/path.presentation.test.ts new file mode 100644 index 000000000..93bec16e3 --- /dev/null +++ b/expo-app/sources/__tests__/app/new/pick/path.presentation.test.ts @@ -0,0 +1,84 @@ +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import renderer, { act } from 'react-test-renderer'; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +vi.mock('@/text', () => ({ + t: (key: string) => key, +})); + +vi.mock('react-native', () => ({ + View: 'View', + Text: 'Text', + Pressable: 'Pressable', + Platform: { OS: 'ios', select: (options: any) => options.ios ?? options.default }, + TurboModuleRegistry: { getEnforcing: () => ({}) }, +})); + +let lastStackScreenOptions: any = null; +vi.mock('expo-router', () => ({ + Stack: { + Screen: ({ options }: any) => { + lastStackScreenOptions = options; + return null; + }, + }, + useRouter: () => ({ back: vi.fn() }), + useNavigation: () => ({ getState: () => ({ index: 1, routes: [{ key: 'a' }, { key: 'b' }] }) }), + useLocalSearchParams: () => ({ machineId: 'm1', selectedPath: '/tmp' }), +})); + +vi.mock('react-native-unistyles', () => ({ + useUnistyles: () => ({ theme: { colors: { header: { tint: '#000' }, textSecondary: '#666', input: { background: '#fff', placeholder: '#aaa', text: '#000' }, divider: '#ddd' } } }), + StyleSheet: { create: (fn: any) => fn({ colors: { header: { tint: '#000' }, textSecondary: '#666', input: { background: '#fff', placeholder: '#aaa', text: '#000' }, divider: '#ddd' } }) }, +})); + +vi.mock('@expo/vector-icons', () => ({ + Ionicons: 'Ionicons', +})); + +vi.mock('@/components/ui/lists/ItemList', () => ({ + ItemList: ({ children }: any) => React.createElement(React.Fragment, null, children), +})); + +vi.mock('@/components/layout', () => ({ + layout: { maxWidth: 900 }, +})); + +vi.mock('@/components/ui/forms/SearchHeader', () => ({ + SearchHeader: () => null, +})); + +vi.mock('@/components/sessions/new/components/PathSelector', () => ({ + PathSelector: () => null, +})); + +vi.mock('@/utils/sessions/recentPaths', () => ({ + getRecentPathsForMachine: () => [], +})); + +vi.mock('@/sync/storage', () => ({ + useAllMachines: () => [{ id: 'm1', metadata: { homeDir: '/home' } }], + useSessions: () => [], + useSetting: (key: string) => { + if (key === 'recentMachinePaths') return []; + if (key === 'usePathPickerSearch') return false; + return null; + }, + useSettingMutable: () => [[], vi.fn()], +})); + +describe('PathPickerScreen (iOS presentation)', () => { + it('presents as containedModal on iOS and provides an explicit header back button', async () => { + const PathPickerScreen = (await import('@/app/(app)/new/pick/path')).default; + lastStackScreenOptions = null; + + await act(async () => { + renderer.create(React.createElement(PathPickerScreen)); + }); + + expect(lastStackScreenOptions?.presentation).toBe('containedModal'); + expect(typeof lastStackScreenOptions?.headerLeft).toBe('function'); + }); +}); diff --git a/expo-app/sources/__tests__/app/new/pick/path.stackOptionsStability.test.ts b/expo-app/sources/__tests__/app/new/pick/path.stackOptionsStability.test.ts new file mode 100644 index 000000000..82d7d646b --- /dev/null +++ b/expo-app/sources/__tests__/app/new/pick/path.stackOptionsStability.test.ts @@ -0,0 +1,103 @@ +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import renderer, { act } from 'react-test-renderer'; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +const stableMachines = [{ id: 'm1', metadata: { homeDir: '/home' } }] as const; +const stableSessions: any[] = []; +const stableRecentMachinePaths: any[] = []; +const stableFavoriteDirectories: any[] = []; + +vi.mock('@/text', () => ({ + t: (key: string) => key, +})); + +vi.mock('@/constants/Typography', () => ({ + Typography: { default: () => ({}) }, +})); + +vi.mock('@/components/ui/lists/ItemList', () => ({ + ItemList: ({ children }: any) => React.createElement('ItemList', null, children), +})); + +vi.mock('@/components/layout', () => ({ + layout: { maxWidth: 720 }, +})); + +vi.mock('@/components/sessions/new/components/PathSelector', () => ({ + PathSelector: (props: any) => { + const didTriggerRef = React.useRef(false); + React.useEffect(() => { + if (didTriggerRef.current) return; + didTriggerRef.current = true; + // Trigger a state update that should NOT require updating Stack.Screen options. + props.onChangeSearchQuery?.('abc'); + }, [props]); + return null; + }, +})); + +vi.mock('@/components/ui/forms/SearchHeader', () => ({ + SearchHeader: () => null, +})); + +vi.mock('@/utils/sessions/recentPaths', () => ({ + getRecentPathsForMachine: () => [], +})); + +vi.mock('react-native', () => ({ + Platform: { OS: 'ios' }, + View: 'View', + Text: 'Text', + Pressable: 'Pressable', +})); + +vi.mock('@expo/vector-icons', () => ({ + Ionicons: 'Ionicons', +})); + +vi.mock('react-native-unistyles', () => ({ + useUnistyles: () => ({ theme: { colors: { header: { tint: '#000' } } } }), + StyleSheet: { create: () => ({}) }, +})); + +vi.mock('@/sync/storage', () => ({ + useAllMachines: () => stableMachines, + useSessions: () => stableSessions, + useSetting: (key: string) => { + if (key === 'usePathPickerSearch') return false; + if (key === 'recentMachinePaths') return stableRecentMachinePaths; + return null; + }, + useSettingMutable: () => [stableFavoriteDirectories, vi.fn()], +})); + +describe('PathPickerScreen (Stack.Screen options stability)', () => { + it('keeps Stack.Screen options referentially stable across parent re-renders', async () => { + const routerApi = { back: vi.fn(), setParams: vi.fn() }; + const navigationApi = { goBack: vi.fn() }; + const setOptions = vi.fn(); + + vi.doMock('expo-router', () => ({ + Stack: { + Screen: ({ options }: any) => { + React.useEffect(() => { + setOptions(options); + }, [options]); + return null; + }, + }, + useRouter: () => routerApi, + useNavigation: () => navigationApi, + useLocalSearchParams: () => ({ machineId: 'm1', selectedPath: '' }), + })); + + const PathPickerScreen = (await import('@/app/(app)/new/pick/path')).default; + await act(async () => { + renderer.create(React.createElement(PathPickerScreen)); + }); + + expect(setOptions).toHaveBeenCalledTimes(1); + }); +}); diff --git a/expo-app/sources/__tests__/app/new/pick/path.test.ts b/expo-app/sources/__tests__/app/new/pick/path.test.ts new file mode 100644 index 000000000..a848ad3b5 --- /dev/null +++ b/expo-app/sources/__tests__/app/new/pick/path.test.ts @@ -0,0 +1,88 @@ +import React from 'react'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import renderer, { act } from 'react-test-renderer'; + +let lastPathSelectorProps: any = null; + +vi.mock('@/text', () => ({ + t: (key: string) => key, +})); + +vi.mock('react-native', () => ({ + View: 'View', + Text: 'Text', + Pressable: 'Pressable', + Platform: { + OS: 'web', + select: (options: { web?: unknown; ios?: unknown; default?: unknown }) => options.web ?? options.ios ?? options.default, + }, + TurboModuleRegistry: { + getEnforcing: () => ({}), + }, +})); + +vi.mock('expo-router', () => ({ + Stack: { Screen: () => null }, + useRouter: () => ({ back: vi.fn() }), + useNavigation: () => ({ getState: () => ({ index: 1, routes: [{ key: 'a' }, { key: 'b' }] }) }), + useLocalSearchParams: () => ({ machineId: 'm1', selectedPath: '/tmp' }), +})); + +vi.mock('react-native-unistyles', () => ({ + useUnistyles: () => ({ theme: { colors: { header: { tint: '#000' }, textSecondary: '#666', input: { background: '#fff', placeholder: '#aaa', text: '#000' }, divider: '#ddd' } } }), + StyleSheet: { create: (fn: any) => fn({ colors: { textSecondary: '#666', input: { background: '#fff', placeholder: '#aaa', text: '#000' }, divider: '#ddd' } }) }, +})); + +vi.mock('@expo/vector-icons', () => ({ + Ionicons: 'Ionicons', +})); + +vi.mock('@/components/ui/lists/ItemList', () => ({ + ItemList: ({ children }: any) => React.createElement(React.Fragment, null, children), +})); + +vi.mock('@/components/layout', () => ({ + layout: { maxWidth: 900 }, +})); + +vi.mock('@/components/ui/forms/SearchHeader', () => ({ + SearchHeader: () => null, +})); + +vi.mock('@/components/sessions/new/components/PathSelector', () => ({ + PathSelector: (props: any) => { + lastPathSelectorProps = props; + return null; + }, +})); + +vi.mock('@/sync/storage', () => ({ + useAllMachines: () => [{ id: 'm1', metadata: { homeDir: '/home' } }], + useSessions: () => [], + useSetting: (key: string) => { + if (key === 'recentMachinePaths') return []; + if (key === 'usePathPickerSearch') return false; + return null; + }, + useSettingMutable: (key: string) => { + if (key === 'favoriteDirectories') return [undefined, vi.fn()]; + return [null, vi.fn()]; + }, +})); + +describe('PathPickerScreen', () => { + beforeEach(() => { + lastPathSelectorProps = null; + }); + + it('defaults favoriteDirectories to an empty array when setting is undefined', async () => { + const PathPickerScreen = (await import('@/app/(app)/new/pick/path')).default; + act(() => { + renderer.create(React.createElement(PathPickerScreen)); + }); + + expect(lastPathSelectorProps).toBeTruthy(); + expect(lastPathSelectorProps.favoriteDirectories).toEqual([]); + expect(typeof lastPathSelectorProps.onChangeFavoriteDirectories).toBe('function'); + }); +}); diff --git a/expo-app/sources/__tests__/app/new/pick/profile-edit.headerButtons.test.ts b/expo-app/sources/__tests__/app/new/pick/profile-edit.headerButtons.test.ts new file mode 100644 index 000000000..81d5e3de4 --- /dev/null +++ b/expo-app/sources/__tests__/app/new/pick/profile-edit.headerButtons.test.ts @@ -0,0 +1,141 @@ +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import renderer, { act } from 'react-test-renderer'; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +vi.mock('react-native', () => { + const React = require('react'); + return { + Platform: { OS: 'ios' }, + KeyboardAvoidingView: (props: any) => React.createElement('KeyboardAvoidingView', props, props.children), + View: (props: any) => React.createElement('View', props, props.children), + Pressable: (props: any) => React.createElement('Pressable', props, props.children), + useWindowDimensions: () => ({ width: 390, height: 844 }), + }; +}); + +vi.mock('@expo/vector-icons', () => { + const React = require('react'); + return { + Ionicons: (props: any) => React.createElement('Ionicons', props, props.children), + }; +}); + +vi.mock('expo-constants', () => ({ + default: { statusBarHeight: 0 }, +})); + +vi.mock('@react-navigation/elements', () => ({ + useHeaderHeight: () => 0, +})); + +const routerMock = { + back: vi.fn(), + push: vi.fn(), + replace: vi.fn(), + setParams: vi.fn(), +}; + +const navigationMock = { + setOptions: vi.fn(), + addListener: vi.fn(() => ({ remove: vi.fn() })), + getState: vi.fn(() => ({ index: 1, routes: [{ key: 'prev' }, { key: 'current' }] })), + dispatch: vi.fn(), +}; + +vi.mock('expo-router', () => { + const React = require('react'); + return { + Stack: { + Screen: (props: any) => React.createElement('StackScreen', props), + }, + useRouter: () => routerMock, + useLocalSearchParams: () => ({ + profileData: JSON.stringify({ + id: 'p1', + name: 'Test profile', + isBuiltIn: false, + compatibility: { claude: true, codex: true, gemini: true }, + }), + }), + useNavigation: () => navigationMock, + }; +}); + +vi.mock('react-native-unistyles', () => ({ + useUnistyles: () => ({ + theme: { colors: { header: { tint: '#000000' }, groupped: { background: '#ffffff' } } }, + rt: { insets: { bottom: 0 } }, + }), + StyleSheet: { + create: (fn: any) => fn({ colors: { groupped: { background: '#ffffff' } } }, { insets: { bottom: 0 } }), + }, +})); + +vi.mock('@/text', () => ({ + t: (key: string) => key, +})); + +vi.mock('@/components/profiles/edit', () => ({ + ProfileEditForm: () => React.createElement('ProfileEditForm'), +})); + +vi.mock('@/components/layout', () => ({ + layout: { maxWidth: 1024 }, +})); + +vi.mock('@/sync/storage', () => ({ + useSettingMutable: () => [[], vi.fn()], +})); + +vi.mock('@/sync/profileUtils', () => ({ + DEFAULT_PROFILES: [], + getBuiltInProfile: () => null, + getBuiltInProfileNameKey: () => null, + resolveProfileById: () => null, +})); + +vi.mock('@/sync/profileMutations', () => ({ + convertBuiltInProfileToCustom: (p: any) => p, + createEmptyCustomProfile: () => ({ id: 'new', name: '', isBuiltIn: false, compatibility: { claude: true, codex: true, gemini: true } }), + duplicateProfileForEdit: (p: any) => p, +})); + +vi.mock('@/modal', () => ({ + Modal: { alert: vi.fn(), show: vi.fn() }, +})); + +vi.mock('@/utils/ui/promptUnsavedChangesAlert', () => ({ + promptUnsavedChangesAlert: vi.fn(async () => 'keep'), +})); + +describe('ProfileEditScreen (header buttons)', () => { + it('renders a header close button even when the form is pristine', async () => { + const ProfileEditScreen = (await import('@/app/(app)/new/pick/profile-edit')).default; + + let tree: renderer.ReactTestRenderer | undefined; + await act(async () => { + tree = renderer.create(React.createElement(ProfileEditScreen)); + }); + + const stackScreen = tree?.root.findByType('StackScreen' as any); + expect(typeof stackScreen?.props?.options?.headerLeft).toBe('function'); + }); + + it('renders a disabled header save button when the form is pristine', async () => { + const ProfileEditScreen = (await import('@/app/(app)/new/pick/profile-edit')).default; + + let tree: renderer.ReactTestRenderer | undefined; + await act(async () => { + tree = renderer.create(React.createElement(ProfileEditScreen)); + }); + + const stackScreen = tree?.root.findByType('StackScreen' as any); + expect(typeof stackScreen?.props?.options?.headerRight).toBe('function'); + + const headerRight = stackScreen?.props?.options?.headerRight; + const saveButton = headerRight?.(); + expect(saveButton?.props?.disabled).toBe(true); + }); +}); diff --git a/expo-app/sources/__tests__/app/new/pick/profile.presentation.test.ts b/expo-app/sources/__tests__/app/new/pick/profile.presentation.test.ts new file mode 100644 index 000000000..93fc4f506 --- /dev/null +++ b/expo-app/sources/__tests__/app/new/pick/profile.presentation.test.ts @@ -0,0 +1,103 @@ +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import renderer, { act } from 'react-test-renderer'; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +vi.mock('@/text', () => ({ + t: (key: string) => key, +})); + +vi.mock('react-native', () => ({ + Platform: { OS: 'ios' }, + Pressable: 'Pressable', + View: 'View', +})); + +let lastStackScreenOptions: any = null; +vi.mock('expo-router', () => ({ + Stack: { + Screen: ({ options }: any) => { + lastStackScreenOptions = options; + return null; + }, + }, + useRouter: () => ({ back: vi.fn(), push: vi.fn(), setParams: vi.fn() }), + useNavigation: () => ({ getState: () => ({ index: 1, routes: [{ key: 'a' }, { key: 'b' }] }), dispatch: vi.fn() }), + useLocalSearchParams: () => ({ selectedId: '', machineId: 'm1' }), +})); + +vi.mock('react-native-unistyles', () => ({ + useUnistyles: () => ({ theme: { colors: { header: { tint: '#000' }, textSecondary: '#666', status: { connected: '#0f0', disconnected: '#f00' } } } }), + StyleSheet: { create: () => ({}) }, +})); + +vi.mock('@expo/vector-icons', () => ({ + Ionicons: 'Ionicons', +})); + +vi.mock('@/modal', () => ({ + Modal: { alert: vi.fn(), show: vi.fn() }, +})); + +vi.mock('@/sync/storage', () => ({ + useSetting: (key: string) => (key === 'useProfiles' ? false : false), + useSettingMutable: () => [[], vi.fn()], +})); + +vi.mock('@/components/ui/lists/ItemGroup', () => ({ + ItemGroup: ({ children }: any) => React.createElement(React.Fragment, null, children), +})); + +vi.mock('@/components/ui/lists/Item', () => ({ + Item: () => null, +})); + +vi.mock('@/components/profiles/ProfilesList', () => ({ + ProfilesList: () => null, +})); + +vi.mock('@/components/secrets/requirements', () => ({ + SecretRequirementModal: () => null, +})); + +vi.mock('@/utils/secrets/secretSatisfaction', () => ({ + getSecretSatisfaction: () => ({ isSatisfied: true, items: [] }), +})); + +vi.mock('@/sync/profileSecrets', () => ({ + getRequiredSecretEnvVarNames: () => [], +})); + +vi.mock('@/hooks/useMachineEnvPresence', () => ({ + useMachineEnvPresence: () => ({ refresh: vi.fn(), machineEnvReadyByName: {} }), +})); + +vi.mock('@/sync/ops', () => ({ + machinePreviewEnv: vi.fn(async () => ({ supported: false })), +})); + +vi.mock('@/sync/settings', () => ({ + getProfileEnvironmentVariables: () => ({}), +})); + +vi.mock('@/utils/tempDataStore', () => ({ + storeTempData: () => 'temp', + getTempData: () => null, +})); + +describe('ProfilePickerScreen (iOS presentation)', () => { + it('presents as containedModal on iOS and provides an explicit header back button', async () => { + vi.resetModules(); + const ProfilePickerScreen = (await import('@/app/(app)/new/pick/profile')).default; + lastStackScreenOptions = null; + + await act(async () => { + renderer.create(React.createElement(ProfilePickerScreen)); + }); + + const resolvedOptions = typeof lastStackScreenOptions === 'function' ? lastStackScreenOptions() : lastStackScreenOptions; + expect(resolvedOptions?.presentation).toBe('containedModal'); + expect(typeof resolvedOptions?.headerLeft).toBe('function'); + }); +}); diff --git a/expo-app/sources/__tests__/app/new/pick/profile.secretRequirementNavigation.test.ts b/expo-app/sources/__tests__/app/new/pick/profile.secretRequirementNavigation.test.ts new file mode 100644 index 000000000..c8aa0cf98 --- /dev/null +++ b/expo-app/sources/__tests__/app/new/pick/profile.secretRequirementNavigation.test.ts @@ -0,0 +1,141 @@ +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import renderer, { act } from 'react-test-renderer'; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +vi.mock('@/text', () => ({ + t: (key: string) => key, +})); + +vi.mock('react-native', () => ({ + Platform: { OS: 'ios' }, + Pressable: 'Pressable', + View: 'View', +})); + +vi.mock('@expo/vector-icons', () => ({ + Ionicons: 'Ionicons', +})); + +const routerMock = { + push: vi.fn(), + back: vi.fn(), +}; + +vi.mock('expo-router', () => ({ + Stack: { Screen: () => null }, + useRouter: () => routerMock, + useNavigation: () => ({ getState: () => ({ index: 1, routes: [{ key: 'a' }, { key: 'b' }] }), dispatch: vi.fn(), setParams: vi.fn() }), + useLocalSearchParams: () => ({ selectedId: '', machineId: 'm1' }), +})); + +vi.mock('react-native-unistyles', () => ({ + useUnistyles: () => ({ theme: { colors: { header: { tint: '#000' }, textSecondary: '#666' } } }), + StyleSheet: { create: () => ({}) }, +})); + +const modalShowMock = vi.fn(); +vi.mock('@/modal', () => ({ + Modal: { alert: vi.fn(), show: (...args: any[]) => modalShowMock(...args) }, +})); + +vi.mock('@/sync/storage', () => ({ + useSetting: (key: string) => { + if (key === 'useProfiles') return true; + if (key === 'experiments') return false; + return false; + }, + useSettingMutable: (key: string) => { + if (key === 'secrets') return [[], vi.fn()]; + if (key === 'secretBindingsByProfileId') return [{}, vi.fn()]; + if (key === 'profiles') return [[], vi.fn()]; + if (key === 'favoriteProfiles') return [[], vi.fn()]; + return [[], vi.fn()]; + }, +})); + +vi.mock('@/components/ui/lists/ItemGroup', () => ({ + ItemGroup: ({ children }: any) => React.createElement(React.Fragment, null, children), +})); + +vi.mock('@/components/ui/lists/Item', () => ({ + Item: () => null, +})); + +let capturedProfilesListProps: any = null; +vi.mock('@/components/profiles/ProfilesList', () => ({ + ProfilesList: (props: any) => { + capturedProfilesListProps = props; + return null; + }, +})); + +vi.mock('@/sync/profileSecrets', () => ({ + getRequiredSecretEnvVarNames: () => ['DEESEEK_AUTH_TOKEN'], +})); + +vi.mock('@/sync/ops', () => ({ + machinePreviewEnv: vi.fn(async () => ({ supported: false })), +})); + +vi.mock('@/sync/settings', () => ({ + getProfileEnvironmentVariables: () => ({}), +})); + +vi.mock('@/utils/secrets/secretSatisfaction', () => ({ + getSecretSatisfaction: () => ({ + isSatisfied: false, + items: [{ envVarName: 'DEESEEK_AUTH_TOKEN', required: true, isSatisfied: false }], + }), +})); + +vi.mock('@/hooks/useMachineEnvPresence', () => ({ + useMachineEnvPresence: () => ({ isLoading: false, isPreviewEnvSupported: false, meta: {} }), +})); + +vi.mock('@/utils/tempDataStore', () => ({ + storeTempData: () => 'temp', + getTempData: () => null, +})); + +vi.mock('@/components/secrets/requirements', () => ({ + SecretRequirementModal: () => null, +})); + +describe('ProfilePickerScreen (native secret requirement)', () => { + it('navigates to the secret requirement screen when required secrets are missing', async () => { + const ProfilePickerScreen = (await import('@/app/(app)/new/pick/profile')).default; + capturedProfilesListProps = null; + routerMock.push.mockClear(); + modalShowMock.mockClear(); + + await act(async () => { + renderer.create(React.createElement(ProfilePickerScreen)); + }); + + expect(typeof capturedProfilesListProps?.onPressProfile).toBe('function'); + + await act(async () => { + await capturedProfilesListProps.onPressProfile({ + id: 'deepseek', + name: 'DeepSeek', + isBuiltIn: true, + compatibility: { claude: true, codex: true, gemini: true }, + }); + }); + + expect(modalShowMock).not.toHaveBeenCalled(); + expect(routerMock.push).toHaveBeenCalledTimes(1); + expect(routerMock.push).toHaveBeenCalledWith({ + pathname: '/new/pick/secret-requirement', + params: expect.objectContaining({ + profileId: 'deepseek', + machineId: 'm1', + secretEnvVarName: 'DEESEEK_AUTH_TOKEN', + secretEnvVarNames: 'DEESEEK_AUTH_TOKEN', + revertOnCancel: '0', + }), + }); + }); +}); diff --git a/expo-app/sources/__tests__/app/new/pick/profile.setOptionsLoop.test.ts b/expo-app/sources/__tests__/app/new/pick/profile.setOptionsLoop.test.ts new file mode 100644 index 000000000..d4b384741 --- /dev/null +++ b/expo-app/sources/__tests__/app/new/pick/profile.setOptionsLoop.test.ts @@ -0,0 +1,124 @@ +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import renderer, { act } from 'react-test-renderer'; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +vi.mock('@/text', () => ({ + t: (key: string) => key, +})); + +vi.mock('react-native', () => ({ + Platform: { OS: 'ios' }, + Pressable: 'Pressable', + View: 'View', +})); + +vi.mock('@expo/vector-icons', () => ({ + Ionicons: 'Ionicons', +})); + +vi.mock('@/modal', () => ({ + Modal: { alert: vi.fn(), show: vi.fn() }, +})); + +vi.mock('@/sync/storage', () => ({ + useSetting: (key: string) => (key === 'useProfiles' ? false : false), + useSettingMutable: () => [[], vi.fn()], +})); + +vi.mock('@/components/ui/lists/ItemGroup', () => ({ + ItemGroup: ({ children }: any) => React.createElement(React.Fragment, null, children), +})); + +vi.mock('@/components/ui/lists/Item', () => ({ + Item: () => null, +})); + +vi.mock('@/components/profiles/ProfilesList', () => ({ + ProfilesList: () => null, +})); + +vi.mock('@/components/secrets/requirements', () => ({ + SecretRequirementModal: () => null, +})); + +vi.mock('@/utils/secrets/secretSatisfaction', () => ({ + getSecretSatisfaction: () => ({ isSatisfied: true, items: [] }), +})); + +vi.mock('@/sync/profileSecrets', () => ({ + getRequiredSecretEnvVarNames: () => [], +})); + +vi.mock('@/hooks/useMachineEnvPresence', () => ({ + useMachineEnvPresence: () => ({ isLoading: false, isPreviewEnvSupported: false, meta: {} }), +})); + +vi.mock('@/sync/ops', () => ({ + machinePreviewEnv: vi.fn(async () => ({ supported: false })), +})); + +vi.mock('@/sync/settings', () => ({ + getProfileEnvironmentVariables: () => ({}), +})); + +vi.mock('@/utils/tempDataStore', () => ({ + storeTempData: () => 'temp', + getTempData: () => null, +})); + +vi.mock('react-native-unistyles', () => ({ + useUnistyles: () => ({ theme: { colors: { header: { tint: '#000' } } } }), + StyleSheet: { create: () => ({}) }, +})); + +describe('ProfilePickerScreen (Stack.Screen options stability)', () => { + it('does not trigger an infinite setOptions update loop', async () => { + const listeners = new Set<() => void>(); + let setOptionsCalls = 0; + let didLoop = false; + + const navigationApi = { + getState: () => ({ index: 1, routes: [{ key: 'a' }, { key: 'b' }] }), + dispatch: vi.fn(), + setOptions: (_options: unknown) => { + setOptionsCalls += 1; + if (setOptionsCalls > 20) { + didLoop = true; + return; + } + listeners.forEach((notify) => notify()); + }, + }; + + vi.doMock('expo-router', () => ({ + Stack: { + Screen: ({ options }: any) => { + React.useEffect(() => { + navigationApi.setOptions(typeof options === 'function' ? options() : options); + }, [options]); + return null; + }, + }, + useRouter: () => ({ back: vi.fn(), push: vi.fn(), setParams: vi.fn() }), + useNavigation: () => { + const [, force] = React.useReducer((x) => x + 1, 0); + React.useLayoutEffect(() => { + listeners.add(force); + return () => void listeners.delete(force); + }, [force]); + return navigationApi as any; + }, + useLocalSearchParams: () => ({ selectedId: '', machineId: 'm1' }), + })); + + const ProfilePickerScreen = (await import('@/app/(app)/new/pick/profile')).default; + + await act(async () => { + renderer.create(React.createElement(ProfilePickerScreen)); + }); + + expect(didLoop).toBe(false); + }); +}); diff --git a/expo-app/sources/__tests__/app/new/pick/secret.presentation.test.ts b/expo-app/sources/__tests__/app/new/pick/secret.presentation.test.ts new file mode 100644 index 000000000..badd6e09d --- /dev/null +++ b/expo-app/sources/__tests__/app/new/pick/secret.presentation.test.ts @@ -0,0 +1,57 @@ +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import renderer, { act } from 'react-test-renderer'; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +vi.mock('@/text', () => ({ + t: (key: string) => key, +})); + +vi.mock('react-native', () => ({ + Platform: { OS: 'ios' }, + Pressable: 'Pressable', +})); + +vi.mock('@expo/vector-icons', () => ({ + Ionicons: 'Ionicons', +})); + +vi.mock('react-native-unistyles', () => ({ + useUnistyles: () => ({ theme: { colors: { header: { tint: '#000' } } } }), +})); + +let lastStackScreenOptions: any = null; +vi.mock('expo-router', () => ({ + Stack: { + Screen: ({ options }: any) => { + lastStackScreenOptions = options; + return null; + }, + }, + useRouter: () => ({ back: vi.fn(), setParams: vi.fn() }), + useNavigation: () => ({ goBack: vi.fn() }), + useLocalSearchParams: () => ({ selectedId: '' }), +})); + +vi.mock('@/sync/storage', () => ({ + useSettingMutable: () => [[], vi.fn()], +})); + +vi.mock('@/components/secrets/SecretsList', () => ({ + SecretsList: () => null, +})); + +describe('SecretPickerScreen (iOS presentation)', () => { + it('presents as containedModal on iOS and provides an explicit header back button', async () => { + const SecretPickerScreen = (await import('@/app/(app)/new/pick/secret')).default; + lastStackScreenOptions = null; + + await act(async () => { + renderer.create(React.createElement(SecretPickerScreen)); + }); + + expect(lastStackScreenOptions?.presentation).toBe('containedModal'); + expect(typeof lastStackScreenOptions?.headerLeft).toBe('function'); + }); +}); diff --git a/expo-app/sources/__tests__/app/new/pick/secret.stackOptionsStability.test.ts b/expo-app/sources/__tests__/app/new/pick/secret.stackOptionsStability.test.ts new file mode 100644 index 000000000..fca1ee27f --- /dev/null +++ b/expo-app/sources/__tests__/app/new/pick/secret.stackOptionsStability.test.ts @@ -0,0 +1,68 @@ +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import renderer, { act } from 'react-test-renderer'; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +vi.mock('@/text', () => ({ + t: (key: string) => key, +})); + +vi.mock('react-native', () => ({ + Platform: { OS: 'ios' }, + Pressable: 'Pressable', +})); + +vi.mock('@expo/vector-icons', () => ({ + Ionicons: 'Ionicons', +})); + +vi.mock('@/sync/storage', () => ({ + useSettingMutable: () => React.useState<any[]>([]), +})); + +vi.mock('@/components/secrets/SecretsList', () => ({ + SecretsList: ({ onChangeSecrets }: any) => { + const didTriggerRef = React.useRef(false); + React.useEffect(() => { + if (didTriggerRef.current) return; + didTriggerRef.current = true; + onChangeSecrets?.([]); + }, [onChangeSecrets]); + return null; + }, +})); + +vi.mock('react-native-unistyles', () => ({ + useUnistyles: () => ({ theme: { colors: { header: { tint: '#000' } } } }), +})); + +describe('SecretPickerScreen (Stack.Screen options stability)', () => { + it('keeps Stack.Screen options referentially stable across parent re-renders', async () => { + const routerApi = { back: vi.fn(), setParams: vi.fn() }; + const navigationApi = { goBack: vi.fn() }; + const setOptions = vi.fn(); + + vi.doMock('expo-router', () => ({ + Stack: { + Screen: ({ options }: any) => { + React.useEffect(() => { + setOptions(options); + }, [options]); + return null; + }, + }, + useRouter: () => routerApi, + useNavigation: () => navigationApi, + useLocalSearchParams: () => ({ selectedId: '' }), + })); + + const SecretPickerScreen = (await import('@/app/(app)/new/pick/secret')).default; + + await act(async () => { + renderer.create(React.createElement(SecretPickerScreen)); + }); + + expect(setOptions).toHaveBeenCalledTimes(1); + }); +}); diff --git a/expo-app/sources/__tests__/app/settings/profiles.nativeNavigation.test.ts b/expo-app/sources/__tests__/app/settings/profiles.nativeNavigation.test.ts new file mode 100644 index 000000000..0effaa101 --- /dev/null +++ b/expo-app/sources/__tests__/app/settings/profiles.nativeNavigation.test.ts @@ -0,0 +1,135 @@ +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import renderer, { act } from 'react-test-renderer'; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +vi.mock('react-native', () => { + const React = require('react'); + return { + Platform: { OS: 'ios' }, + View: (props: any) => React.createElement('View', props, props.children), + Pressable: (props: any) => React.createElement('Pressable', props, props.children), + }; +}); + +vi.mock('@expo/vector-icons', () => { + const React = require('react'); + return { + Ionicons: (props: any) => React.createElement('Ionicons', props, props.children), + }; +}); + +const routerMock = { + push: vi.fn(), + back: vi.fn(), +}; + +vi.mock('expo-router', () => ({ + useRouter: () => routerMock, + useNavigation: () => ({ setOptions: vi.fn() }), +})); + +vi.mock('react-native-unistyles', () => ({ + useUnistyles: () => ({ + theme: { colors: { groupped: { background: '#ffffff' }, surface: '#ffffff', divider: '#dddddd' } }, + rt: { insets: { bottom: 0 } }, + }), + StyleSheet: { create: (fn: any) => fn({ colors: { groupped: { background: '#ffffff' }, divider: '#dddddd' } }) }, +})); + +vi.mock('@/text', () => ({ + t: (key: string) => key, +})); + +vi.mock('@/sync/storage', () => ({ + useSetting: () => false, + useSettingMutable: () => [[], vi.fn()], +})); + +vi.mock('@/modal', () => ({ + Modal: { alert: vi.fn(), show: vi.fn() }, +})); + +vi.mock('@/utils/ui/promptUnsavedChangesAlert', () => ({ + promptUnsavedChangesAlert: vi.fn(async () => 'keep'), +})); + +vi.mock('@/components/profiles/edit', () => ({ + ProfileEditForm: () => React.createElement('ProfileEditForm'), +})); + +let capturedProfilesListProps: any = null; +vi.mock('@/components/profiles/ProfilesList', () => ({ + ProfilesList: (props: any) => { + capturedProfilesListProps = props; + return React.createElement('ProfilesList'); + }, +})); + +vi.mock('@/sync/profileUtils', () => ({ + DEFAULT_PROFILES: [], + getBuiltInProfileNameKey: () => null, + resolveProfileById: () => null, +})); + +vi.mock('@/sync/profileMutations', () => ({ + convertBuiltInProfileToCustom: (p: any) => p, + createEmptyCustomProfile: () => ({ id: 'new', name: '', isBuiltIn: false, compatibility: { claude: true, codex: true, gemini: true } }), + duplicateProfileForEdit: (p: any) => p, +})); + +vi.mock('@/components/ui/lists/ItemList', () => ({ + ItemList: (props: any) => React.createElement('ItemList', props, props.children), +})); +vi.mock('@/components/ui/lists/ItemGroup', () => ({ + ItemGroup: (props: any) => React.createElement('ItemGroup', props, props.children), +})); +vi.mock('@/components/ui/lists/Item', () => ({ + Item: (props: any) => React.createElement('Item', props, props.children), +})); +vi.mock('@/components/Switch', () => ({ + Switch: (props: any) => React.createElement('Switch', props, props.children), +})); + +vi.mock('@/components/secrets/requirements', () => ({ + SecretRequirementModal: () => React.createElement('SecretRequirementModal'), +})); + +vi.mock('@/utils/secrets/secretSatisfaction', () => ({ + getSecretSatisfaction: () => ({ isSatisfied: true, items: [] }), +})); + +vi.mock('@/sync/profileSecrets', () => ({ + getRequiredSecretEnvVarNames: () => [], +})); + +describe('ProfileManager (native)', () => { + it('navigates to the profile edit screen instead of using the inline modal editor', async () => { + const ProfileManager = (await import('@/app/(app)/settings/profiles')).default; + + capturedProfilesListProps = null; + routerMock.push.mockClear(); + + await act(async () => { + renderer.create(React.createElement(ProfileManager)); + }); + + expect(typeof capturedProfilesListProps?.onEditProfile).toBe('function'); + + await act(async () => { + capturedProfilesListProps.onEditProfile({ + id: 'p1', + name: 'Test profile', + isBuiltIn: false, + compatibility: { claude: true, codex: true, gemini: true }, + }); + }); + + expect(routerMock.push).toHaveBeenCalledTimes(1); + expect(routerMock.push).toHaveBeenCalledWith({ + pathname: '/new/pick/profile-edit', + params: { profileId: 'p1' }, + }); + }); +}); diff --git a/expo-app/sources/agents/acpRuntimeResume.ts b/expo-app/sources/agents/acpRuntimeResume.ts new file mode 100644 index 000000000..5f230c36f --- /dev/null +++ b/expo-app/sources/agents/acpRuntimeResume.ts @@ -0,0 +1,78 @@ +import type { CapabilitiesDetectRequest, CapabilityId, CapabilityDetectResult } from '@/sync/capabilitiesProtocol'; + +import type { AgentId } from './registryCore'; +import { getAgentCore } from './registryCore'; + +type CapabilityResults = Partial<Record<CapabilityId, CapabilityDetectResult>>; + +export function readAcpLoadSessionSupport(agentId: AgentId, results: CapabilityResults | undefined): boolean { + const capId = `cli.${getAgentCore(agentId).cli.detectKey}` as CapabilityId; + const result = results?.[capId]; + if (!result || !result.ok) return false; + const data = result.data as any; + return data?.acp?.ok === true && data?.acp?.loadSession === true; +} + +export type AcpLoadSessionSupport = Readonly<{ + kind: 'supported' | 'unsupported' | 'error' | 'unknown'; + code?: 'cliNotDetected' | 'capabilityProbeFailed' | 'acpProbeFailed' | 'loadSessionFalse'; + rawMessage?: string; +}>; + +export function describeAcpLoadSessionSupport(agentId: AgentId, results: CapabilityResults | undefined): AcpLoadSessionSupport { + const capId = `cli.${getAgentCore(agentId).cli.detectKey}` as CapabilityId; + const result = results?.[capId]; + if (!result) return { kind: 'unknown' }; + if (!result.ok) return { kind: 'error', code: 'capabilityProbeFailed', rawMessage: result.error?.message }; + + const data = result.data as any; + if (data?.available !== true) return { kind: 'unsupported', code: 'cliNotDetected' }; + + const acp = data?.acp; + if (!(acp && typeof acp === 'object')) return { kind: 'unknown' }; + if (acp.ok === false) return { kind: 'error', code: 'acpProbeFailed', rawMessage: acp.error?.message }; + + const loadSession = acp.ok === true && acp.loadSession === true; + return loadSession + ? { kind: 'supported' } + : { kind: 'unsupported', code: 'loadSessionFalse' }; +} + +export function buildAcpLoadSessionPrefetchRequest(agentId: AgentId): CapabilitiesDetectRequest { + const capId = `cli.${getAgentCore(agentId).cli.detectKey}` as CapabilityId; + return { + requests: [ + { + id: capId, + params: { includeAcpCapabilities: true, includeLoginStatus: true }, + }, + ], + }; +} + +export function shouldPrefetchAcpCapabilities(agentId: AgentId, results: CapabilityResults | undefined): boolean { + const capId = `cli.${getAgentCore(agentId).cli.detectKey}` as CapabilityId; + const result = results?.[capId]; + const data = result && result.ok ? (result.data as any) : null; + const acp = data?.acp; + + // If the CLI itself isn't available, ACP probing can't succeed and we should not spin. + if (data && data.available !== true) return false; + + // If ACP was never requested, it should be missing entirely. + if (!(acp && typeof acp === 'object')) return true; + + // If the probe succeeded, don't re-probe. + if (acp.ok === true) return false; + + // Probe can fail transiently (timeouts, temporary stdout pollution, agent cold starts). + // Retry after a short delay instead of caching a failure for 24h. + const retryAfterMs = 30_000; + const checkedAt = typeof acp.checkedAt === 'number' + ? acp.checkedAt + : typeof result?.checkedAt === 'number' + ? result.checkedAt + : 0; + if (!checkedAt) return true; + return (Date.now() - checkedAt) >= retryAfterMs; +} diff --git a/expo-app/sources/agents/agentPickerOptions.test.ts b/expo-app/sources/agents/agentPickerOptions.test.ts new file mode 100644 index 000000000..982f78b5c --- /dev/null +++ b/expo-app/sources/agents/agentPickerOptions.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest'; + +import { getAgentPickerOptions } from './agentPickerOptions'; + +describe('agents/agentPickerOptions', () => { + it('returns display metadata for enabled agents', () => { + const options = getAgentPickerOptions(['claude', 'codex', 'gemini']); + expect(options.map((o) => o.agentId)).toEqual(['claude', 'codex', 'gemini']); + expect(options[0]?.titleKey).toBe('agentInput.agent.claude'); + expect(options[1]?.titleKey).toBe('agentInput.agent.codex'); + expect(options[2]?.titleKey).toBe('agentInput.agent.gemini'); + expect(typeof options[0]?.iconName).toBe('string'); + }); +}); + diff --git a/expo-app/sources/agents/agentPickerOptions.ts b/expo-app/sources/agents/agentPickerOptions.ts new file mode 100644 index 000000000..aef7421ce --- /dev/null +++ b/expo-app/sources/agents/agentPickerOptions.ts @@ -0,0 +1,22 @@ +import type { TranslationKey } from '@/text'; +import type { AgentId } from './registryCore'; +import { getAgentCore } from './registryCore'; + +export type AgentPickerOption = Readonly<{ + agentId: AgentId; + titleKey: TranslationKey; + subtitleKey: TranslationKey; + iconName: string; +}>; + +export function getAgentPickerOptions(agentIds: readonly AgentId[]): readonly AgentPickerOption[] { + return agentIds.map((agentId) => { + const core = getAgentCore(agentId); + return { + agentId, + titleKey: core.displayNameKey, + subtitleKey: core.subtitleKey, + iconName: core.ui.agentPickerIconName, + }; + }); +} diff --git a/expo-app/sources/agents/catalog.test.ts b/expo-app/sources/agents/catalog.test.ts new file mode 100644 index 000000000..d5defcea2 --- /dev/null +++ b/expo-app/sources/agents/catalog.test.ts @@ -0,0 +1,20 @@ +import { describe, it, expect } from 'vitest'; + +import { AGENT_IDS as SHARED_AGENT_IDS } from '@happy/agents'; + +import { AGENT_IDS, DEFAULT_AGENT_ID, getAgentCore } from './catalog'; + +describe('agents/catalog', () => { + it('re-exports the canonical shared agent id list', () => { + // Reference equality ensures we’re not accidentally redefining the list in Expo. + expect(AGENT_IDS).toBe(SHARED_AGENT_IDS); + expect(DEFAULT_AGENT_ID).toBe('claude'); + }); + + it('composes core + ui + behavior for known agents', () => { + for (const id of AGENT_IDS) { + const core = getAgentCore(id); + expect(core.id).toBe(id); + } + }); +}); diff --git a/expo-app/sources/agents/catalog.ts b/expo-app/sources/agents/catalog.ts new file mode 100644 index 000000000..31a4407e9 --- /dev/null +++ b/expo-app/sources/agents/catalog.ts @@ -0,0 +1,117 @@ +import { AGENT_IDS, DEFAULT_AGENT_ID, type AgentId } from '@happy/agents'; + +import type { AgentCoreConfig, MachineLoginKey } from './registryCore'; +import { + getAgentCore as getExpoAgentCore, + isAgentId, + resolveAgentIdFromCliDetectKey, + resolveAgentIdFromConnectedServiceId, + resolveAgentIdFromFlavor, +} from './registryCore'; + +import type { AgentUiConfig } from './registryUi'; +type RegistryUiModule = typeof import('./registryUi'); +type AgentIconTintTheme = Parameters<RegistryUiModule['getAgentIconTintColor']>[1]; + +import type { AgentUiBehavior } from './registryUiBehavior'; +import { + AGENTS_UI_BEHAVIOR, + buildResumeCapabilityOptionsFromMaps, + buildResumeCapabilityOptionsFromUiState, + buildNewSessionOptionsFromUiState, + getNewSessionAgentInputExtraActionChips, + buildSpawnEnvironmentVariablesFromUiState, + buildResumeSessionExtrasFromUiState, + buildSpawnSessionExtrasFromUiState, + buildWakeResumeExtras, + getAgentResumeExperimentsFromSettings, + getAllowExperimentalResumeByAgentIdFromUiState, + getAllowRuntimeResumeByAgentIdFromResults, + getNewSessionPreflightIssues, + getNewSessionRelevantInstallableDepKeys, + getResumePreflightIssues, + getResumePreflightPrefetchPlan, + getResumeRuntimeSupportPrefetchPlan, +} from './registryUiBehavior'; + +export { AGENT_IDS, DEFAULT_AGENT_ID }; +export type { AgentId, MachineLoginKey }; + +export type AgentCatalogEntry = Readonly<{ + id: AgentId; + core: AgentCoreConfig; + ui: AgentUiConfig; + behavior: AgentUiBehavior; +}>; + +function registryUi() { + // Lazily load UI assets so Node-side tests can import `@/agents/catalog` + // without requiring image files. + return require('./registryUi') as typeof import('./registryUi'); +} + +export function getAgentCore(id: AgentId): AgentCoreConfig { + return getExpoAgentCore(id); +} + +export function getAgentUi(id: AgentId): AgentUiConfig { + return registryUi().AGENTS_UI[id]; +} + +export function getAgentIconSource(agentId: AgentId): ReturnType<RegistryUiModule['getAgentIconSource']> { + return registryUi().getAgentIconSource(agentId); +} + +export function getAgentIconTintColor( + agentId: AgentId, + theme: AgentIconTintTheme, +): ReturnType<RegistryUiModule['getAgentIconTintColor']> { + return registryUi().getAgentIconTintColor(agentId, theme); +} + +export function getAgentAvatarOverlaySizes( + agentId: AgentId, + size: number, +): ReturnType<RegistryUiModule['getAgentAvatarOverlaySizes']> { + return registryUi().getAgentAvatarOverlaySizes(agentId, size); +} + +export function getAgentCliGlyph(agentId: AgentId): ReturnType<RegistryUiModule['getAgentCliGlyph']> { + return registryUi().getAgentCliGlyph(agentId); +} + +export function getAgentBehavior(id: AgentId): AgentUiBehavior { + return AGENTS_UI_BEHAVIOR[id]; +} + +export function getAgent(id: AgentId): AgentCatalogEntry { + return { + id, + core: getAgentCore(id), + ui: getAgentUi(id), + behavior: getAgentBehavior(id), + }; +} + +export { + isAgentId, + resolveAgentIdFromFlavor, + resolveAgentIdFromCliDetectKey, + resolveAgentIdFromConnectedServiceId, + getAgentResumeExperimentsFromSettings, + getAllowExperimentalResumeByAgentIdFromUiState, + getAllowRuntimeResumeByAgentIdFromResults, + buildResumeCapabilityOptionsFromUiState, + buildResumeCapabilityOptionsFromMaps, + getResumeRuntimeSupportPrefetchPlan, + getResumePreflightPrefetchPlan, + getNewSessionPreflightIssues, + getResumePreflightIssues, + buildNewSessionOptionsFromUiState, + getNewSessionAgentInputExtraActionChips, + getNewSessionRelevantInstallableDepKeys, + buildSpawnEnvironmentVariablesFromUiState, + buildSpawnSessionExtrasFromUiState, + buildResumeSessionExtrasFromUiState, + buildWakeResumeExtras, +}; diff --git a/expo-app/sources/agents/cliWarnings.test.ts b/expo-app/sources/agents/cliWarnings.test.ts new file mode 100644 index 000000000..70b13fb88 --- /dev/null +++ b/expo-app/sources/agents/cliWarnings.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; + +import { applyCliWarningDismissal, isCliWarningDismissed } from './cliWarnings'; + +describe('agents/cliWarnings', () => { + it('marks a warning as dismissed globally', () => { + const current = { perMachine: {}, global: {} }; + const next = applyCliWarningDismissal({ + dismissed: current, + machineId: 'm1', + warningKey: 'codex', + scope: 'global', + }); + + expect(next.global.codex).toBe(true); + expect(next.perMachine).toEqual({}); + expect(isCliWarningDismissed({ dismissed: next, machineId: 'm1', warningKey: 'codex' })).toBe(true); + expect(isCliWarningDismissed({ dismissed: next, machineId: 'm2', warningKey: 'codex' })).toBe(true); + }); + + it('marks a warning as dismissed for a specific machine', () => { + const current = { perMachine: {}, global: {} }; + const next = applyCliWarningDismissal({ + dismissed: current, + machineId: 'm1', + warningKey: 'codex', + scope: 'machine', + }); + + expect(next.global).toEqual({}); + expect(next.perMachine.m1?.codex).toBe(true); + expect(isCliWarningDismissed({ dismissed: next, machineId: 'm1', warningKey: 'codex' })).toBe(true); + expect(isCliWarningDismissed({ dismissed: next, machineId: 'm2', warningKey: 'codex' })).toBe(false); + }); +}); + diff --git a/expo-app/sources/agents/cliWarnings.ts b/expo-app/sources/agents/cliWarnings.ts new file mode 100644 index 000000000..807e85104 --- /dev/null +++ b/expo-app/sources/agents/cliWarnings.ts @@ -0,0 +1,54 @@ +export type DismissedCliWarnings = Readonly<{ + perMachine: Readonly<Record<string, Readonly<Record<string, boolean>>>>; + global: Readonly<Record<string, boolean>>; +}>; + +export type CliWarningDismissScope = 'machine' | 'global'; + +export function isCliWarningDismissed(params: { + dismissed: DismissedCliWarnings | null | undefined; + machineId: string | null | undefined; + warningKey: string; +}): boolean { + const dismissed = params.dismissed; + if (!dismissed) return false; + if (dismissed.global?.[params.warningKey] === true) return true; + if (!params.machineId) return false; + return dismissed.perMachine?.[params.machineId]?.[params.warningKey] === true; +} + +export function applyCliWarningDismissal(params: { + dismissed: DismissedCliWarnings | null | undefined; + machineId: string | null | undefined; + warningKey: string; + scope: CliWarningDismissScope; +}): DismissedCliWarnings { + const base: DismissedCliWarnings = params.dismissed ?? { perMachine: {}, global: {} }; + + if (params.scope === 'global') { + return { + ...base, + global: { + ...(base.global ?? {}), + [params.warningKey]: true, + }, + }; + } + + if (!params.machineId) { + return base; + } + + const existing = base.perMachine?.[params.machineId] ?? {}; + return { + ...base, + perMachine: { + ...(base.perMachine ?? {}), + [params.machineId]: { + ...existing, + [params.warningKey]: true, + }, + }, + }; +} + diff --git a/expo-app/sources/agents/enabled.test.ts b/expo-app/sources/agents/enabled.test.ts new file mode 100644 index 000000000..c58444db9 --- /dev/null +++ b/expo-app/sources/agents/enabled.test.ts @@ -0,0 +1,26 @@ +import { describe, it, expect } from 'vitest'; + +import { getEnabledAgentIds, isAgentEnabled } from './enabled'; + +describe('agents/enabled', () => { + it('enables stable agents regardless of experiments', () => { + expect(isAgentEnabled({ agentId: 'claude', experiments: false, experimentalAgents: {} })).toBe(true); + expect(isAgentEnabled({ agentId: 'codex', experiments: false, experimentalAgents: {} })).toBe(true); + expect(isAgentEnabled({ agentId: 'opencode', experiments: false, experimentalAgents: {} })).toBe(true); + }); + + it('gates experimental agents behind experiments + per-agent toggle', () => { + expect(isAgentEnabled({ agentId: 'gemini', experiments: false, experimentalAgents: { gemini: true } })).toBe(false); + expect(isAgentEnabled({ agentId: 'gemini', experiments: true, experimentalAgents: { gemini: false } })).toBe(false); + expect(isAgentEnabled({ agentId: 'gemini', experiments: true, experimentalAgents: { gemini: true } })).toBe(true); + + expect(isAgentEnabled({ agentId: 'auggie', experiments: false, experimentalAgents: { auggie: true } })).toBe(false); + expect(isAgentEnabled({ agentId: 'auggie', experiments: true, experimentalAgents: { auggie: false } })).toBe(false); + expect(isAgentEnabled({ agentId: 'auggie', experiments: true, experimentalAgents: { auggie: true } })).toBe(true); + }); + + it('returns enabled agent ids in display order', () => { + expect(getEnabledAgentIds({ experiments: false, experimentalAgents: { gemini: true, auggie: true } })).toEqual(['claude', 'codex', 'opencode']); + expect(getEnabledAgentIds({ experiments: true, experimentalAgents: { gemini: true, auggie: true } })).toEqual(['claude', 'codex', 'opencode', 'gemini', 'auggie']); + }); +}); diff --git a/expo-app/sources/agents/enabled.ts b/expo-app/sources/agents/enabled.ts new file mode 100644 index 000000000..0d1ca1dc5 --- /dev/null +++ b/expo-app/sources/agents/enabled.ts @@ -0,0 +1,22 @@ +import type { AgentId } from './registryCore'; +import { AGENT_IDS, getAgentCore } from './registryCore'; + +export function isAgentEnabled(params: { + agentId: AgentId; + experiments: boolean; + experimentalAgents: Record<string, boolean> | null | undefined; +}): boolean { + const cfg = getAgentCore(params.agentId); + if (!cfg.availability.experimental) return true; + if (params.experiments !== true) return false; + return params.experimentalAgents?.[params.agentId] === true; +} + +export function getEnabledAgentIds(params: { + experiments: boolean; + experimentalAgents: Record<string, boolean> | null | undefined; +}): AgentId[] { + return AGENT_IDS.filter((agentId) => + isAgentEnabled({ agentId, experiments: params.experiments, experimentalAgents: params.experimentalAgents }), + ); +} diff --git a/expo-app/sources/agents/permissionUiCopy.ts b/expo-app/sources/agents/permissionUiCopy.ts new file mode 100644 index 000000000..c09575fb7 --- /dev/null +++ b/expo-app/sources/agents/permissionUiCopy.ts @@ -0,0 +1,44 @@ +import type { TranslationKey } from '@/text'; +import { getAgentCore, type AgentId } from './registryCore'; + +export type PermissionFooterCopy = + | Readonly<{ + protocol: 'codexDecision'; + yesAlwaysAllowCommandKey: TranslationKey; + yesForSessionKey: TranslationKey; + stopAndExplainKey: TranslationKey; + }> + | Readonly<{ + protocol: 'claude'; + yesAllowAllEditsKey: TranslationKey; + yesForToolKey: TranslationKey; + noTellAgentKey: TranslationKey; + }>; + +export function getPermissionFooterCopy(agentId: AgentId): PermissionFooterCopy { + const protocol = getAgentCore(agentId).permissions.promptProtocol; + if (protocol === 'codexDecision') { + return { + protocol, + yesAlwaysAllowCommandKey: 'codex.permissions.yesAlwaysAllowCommand', + yesForSessionKey: 'codex.permissions.yesForSession', + stopAndExplainKey: 'codex.permissions.stopAndExplain', + }; + } + + if (protocol === 'claude') { + return { + protocol: 'claude', + yesAllowAllEditsKey: 'claude.permissions.yesAllowAllEdits', + yesForToolKey: 'claude.permissions.yesForTool', + noTellAgentKey: 'claude.permissions.noTellClaude', + }; + } + + return { + protocol: 'claude', + yesAllowAllEditsKey: 'claude.permissions.yesAllowAllEdits', + yesForToolKey: 'claude.permissions.yesForTool', + noTellAgentKey: 'claude.permissions.noTellClaude', + }; +} diff --git a/expo-app/sources/agents/providers/auggie/AuggieIndexingChip.tsx b/expo-app/sources/agents/providers/auggie/AuggieIndexingChip.tsx new file mode 100644 index 000000000..18b237eb2 --- /dev/null +++ b/expo-app/sources/agents/providers/auggie/AuggieIndexingChip.tsx @@ -0,0 +1,34 @@ +import { Octicons } from '@expo/vector-icons'; +import * as React from 'react'; +import { Pressable, Text } from 'react-native'; + +import { hapticsLight } from '@/components/haptics'; +import type { AgentInputExtraActionChip } from '@/components/sessions/agentInput'; +import { t } from '@/text'; + +export function createAuggieAllowIndexingChip(opts: Readonly<{ + allowIndexing: boolean; + setAllowIndexing: (next: boolean) => void; +}>): AgentInputExtraActionChip { + return { + key: 'auggie-allow-indexing', + render: ({ chipStyle, showLabel, iconColor, textStyle }) => ( + <Pressable + onPress={() => { + hapticsLight(); + opts.setAllowIndexing(!opts.allowIndexing); + }} + hitSlop={{ top: 5, bottom: 10, left: 0, right: 0 }} + style={(p) => chipStyle(p.pressed)} + > + <Octicons name="search" size={16} color={iconColor} /> + {showLabel ? ( + <Text style={textStyle}> + {t(opts.allowIndexing ? 'agentInput.auggieIndexingChip.on' : 'agentInput.auggieIndexingChip.off')} + </Text> + ) : null} + </Pressable> + ), + }; +} + diff --git a/expo-app/sources/agents/providers/auggie/core.ts b/expo-app/sources/agents/providers/auggie/core.ts new file mode 100644 index 000000000..761fba8c2 --- /dev/null +++ b/expo-app/sources/agents/providers/auggie/core.ts @@ -0,0 +1,48 @@ +import type { AgentCoreConfig } from '@/agents/registryCore'; + +export const AUGGIE_CORE: AgentCoreConfig = { + id: 'auggie', + displayNameKey: 'agentInput.agent.auggie', + subtitleKey: 'profiles.aiBackend.auggieSubtitle', + permissionModeI18nPrefix: 'agentInput.codexPermissionMode', + availability: { experimental: true }, + connectedService: { + id: null, + name: 'Auggie', + connectRoute: null, + }, + flavorAliases: ['auggie'], + cli: { + detectKey: 'auggie', + machineLoginKey: 'auggie', + installBanner: { + installKind: 'ifAvailable', + }, + spawnAgent: 'auggie', + }, + permissions: { + modeGroup: 'codexLike', + promptProtocol: 'codexDecision', + }, + model: { + supportsSelection: false, + defaultMode: 'default', + allowedModes: ['default'], + }, + resume: { + vendorResumeIdField: 'auggieSessionId', + uiVendorResumeIdLabelKey: 'sessionInfo.auggieSessionId', + uiVendorResumeIdCopiedKey: 'sessionInfo.auggieSessionIdCopied', + supportsVendorResume: false, + runtimeGate: 'acpLoadSession', + experimental: false, + }, + toolRendering: { + hideUnknownToolsByDefault: false, + }, + ui: { + agentPickerIconName: 'sparkles', + cliGlyphScale: 1.0, + profileCompatibilityGlyphScale: 1.0, + }, +}; diff --git a/expo-app/sources/agents/providers/auggie/indexing.ts b/expo-app/sources/agents/providers/auggie/indexing.ts new file mode 100644 index 000000000..5f4bc8724 --- /dev/null +++ b/expo-app/sources/agents/providers/auggie/indexing.ts @@ -0,0 +1,19 @@ +export const HAPPY_AUGGIE_ALLOW_INDEXING_ENV_VAR = 'HAPPY_AUGGIE_ALLOW_INDEXING' as const; + +export const AUGGIE_ALLOW_INDEXING_METADATA_KEY = 'auggieAllowIndexing' as const; + +export const AUGGIE_NEW_SESSION_OPTION_ALLOW_INDEXING = 'allowIndexing' as const; + +export function applyAuggieAllowIndexingEnv( + env: Record<string, string> | undefined, + allowIndexing: boolean, +): Record<string, string> | undefined { + if (allowIndexing !== true) return env; + return { ...(env ?? {}), [HAPPY_AUGGIE_ALLOW_INDEXING_ENV_VAR]: '1' }; +} + +export function readAuggieAllowIndexingFromMetadata(metadata: unknown): boolean | null { + if (!metadata || typeof metadata !== 'object') return null; + const v = (metadata as any)[AUGGIE_ALLOW_INDEXING_METADATA_KEY]; + return typeof v === 'boolean' ? v : null; +} diff --git a/expo-app/sources/agents/providers/auggie/ui.ts b/expo-app/sources/agents/providers/auggie/ui.ts new file mode 100644 index 000000000..f140b6962 --- /dev/null +++ b/expo-app/sources/agents/providers/auggie/ui.ts @@ -0,0 +1,15 @@ +import type { UnistylesThemes } from 'react-native-unistyles'; + +import type { AgentUiConfig } from '@/agents/registryUi'; + +export const AUGGIE_UI: AgentUiConfig = { + id: 'auggie', + icon: require('@/assets/images/icon-monochrome.png'), + tintColor: (theme: UnistylesThemes[keyof UnistylesThemes]) => theme.colors.text, + avatarOverlay: { + circleScale: 0.35, + iconScale: ({ size }: { size: number }) => Math.round(size * 0.25), + }, + cliGlyph: 'A', +}; + diff --git a/expo-app/sources/agents/providers/auggie/uiBehavior.ts b/expo-app/sources/agents/providers/auggie/uiBehavior.ts new file mode 100644 index 000000000..7d1271443 --- /dev/null +++ b/expo-app/sources/agents/providers/auggie/uiBehavior.ts @@ -0,0 +1,33 @@ +import type { AgentUiBehavior } from '@/agents/registryUiBehavior'; + +import { applyAuggieAllowIndexingEnv, AUGGIE_NEW_SESSION_OPTION_ALLOW_INDEXING } from './indexing'; + +function getChipFactory(): typeof import('@/agents/providers/auggie/AuggieIndexingChip').createAuggieAllowIndexingChip { + // Lazy require so Node-side tests can import `@/agents/catalog` without resolving native icon deps. + return require('@/agents/providers/auggie/AuggieIndexingChip').createAuggieAllowIndexingChip; +} + +export const AUGGIE_UI_BEHAVIOR_OVERRIDE: AgentUiBehavior = { + newSession: { + buildNewSessionOptions: ({ agentOptionState }) => { + const allowIndexing = agentOptionState?.[AUGGIE_NEW_SESSION_OPTION_ALLOW_INDEXING] === true; + return { [AUGGIE_NEW_SESSION_OPTION_ALLOW_INDEXING]: allowIndexing }; + }, + getAgentInputExtraActionChips: ({ agentOptionState, setAgentOptionState }) => { + const allowIndexing = agentOptionState?.[AUGGIE_NEW_SESSION_OPTION_ALLOW_INDEXING] === true; + const createAuggieAllowIndexingChip = getChipFactory(); + return [ + createAuggieAllowIndexingChip({ + allowIndexing, + setAllowIndexing: (next) => setAgentOptionState(AUGGIE_NEW_SESSION_OPTION_ALLOW_INDEXING, next), + }), + ]; + }, + }, + payload: { + buildSpawnEnvironmentVariables: ({ environmentVariables, newSessionOptions }) => { + const allowIndexing = newSessionOptions?.[AUGGIE_NEW_SESSION_OPTION_ALLOW_INDEXING] === true; + return applyAuggieAllowIndexingEnv(environmentVariables, allowIndexing) ?? environmentVariables; + }, + }, +}; diff --git a/expo-app/sources/agents/providers/claude/core.ts b/expo-app/sources/agents/providers/claude/core.ts new file mode 100644 index 000000000..8d5114cb8 --- /dev/null +++ b/expo-app/sources/agents/providers/claude/core.ts @@ -0,0 +1,51 @@ +import type { AgentCoreConfig } from '@/agents/registryCore'; + +export const CLAUDE_CORE: AgentCoreConfig = { + id: 'claude', + displayNameKey: 'agentInput.agent.claude', + subtitleKey: 'profiles.aiBackend.claudeSubtitle', + permissionModeI18nPrefix: 'agentInput.permissionMode', + availability: { experimental: false }, + connectedService: { + id: 'anthropic', + name: 'Claude Code', + connectRoute: '/(app)/settings/connect/claude', + }, + flavorAliases: ['claude'], + cli: { + detectKey: 'claude', + machineLoginKey: 'claude-code', + installBanner: { + installKind: 'command', + installCommand: 'npm install -g @anthropic-ai/claude-code', + guideUrl: 'https://docs.anthropic.com/en/docs/claude-code/installation', + }, + spawnAgent: 'claude', + }, + permissions: { + modeGroup: 'claude', + promptProtocol: 'claude', + }, + model: { + supportsSelection: false, + defaultMode: 'default', + allowedModes: ['default'], + }, + resume: { + vendorResumeIdField: 'claudeSessionId', + uiVendorResumeIdLabelKey: 'sessionInfo.claudeCodeSessionId', + uiVendorResumeIdCopiedKey: 'sessionInfo.claudeCodeSessionIdCopied', + supportsVendorResume: true, + runtimeGate: null, + experimental: false, + }, + toolRendering: { + hideUnknownToolsByDefault: false, + }, + ui: { + agentPickerIconName: 'sparkles-outline', + cliGlyphScale: 1.0, + profileCompatibilityGlyphScale: 1.14, + }, +}; + diff --git a/expo-app/sources/agents/providers/claude/ui.ts b/expo-app/sources/agents/providers/claude/ui.ts new file mode 100644 index 000000000..ce7c5d5a0 --- /dev/null +++ b/expo-app/sources/agents/providers/claude/ui.ts @@ -0,0 +1,14 @@ +import type { AgentUiConfig } from '@/agents/registryUi'; + +export const CLAUDE_UI: AgentUiConfig = { + id: 'claude', + icon: require('@/assets/images/icon-claude.png'), + tintColor: null, + avatarOverlay: { + circleScale: 0.35, + iconScale: ({ size }: { size: number }) => Math.round(size * 0.28), + }, + // iOS can render dingbat glyphs as emoji; force text presentation (U+FE0E). + cliGlyph: '\u2733\uFE0E', +}; + diff --git a/expo-app/sources/agents/providers/codex/core.ts b/expo-app/sources/agents/providers/codex/core.ts new file mode 100644 index 000000000..3651c3760 --- /dev/null +++ b/expo-app/sources/agents/providers/codex/core.ts @@ -0,0 +1,52 @@ +import type { AgentCoreConfig } from '@/agents/registryCore'; + +export const CODEX_CORE: AgentCoreConfig = { + id: 'codex', + displayNameKey: 'agentInput.agent.codex', + subtitleKey: 'profiles.aiBackend.codexSubtitle', + permissionModeI18nPrefix: 'agentInput.codexPermissionMode', + availability: { experimental: false }, + connectedService: { + id: 'openai', + name: 'OpenAI Codex', + connectRoute: null, + }, + // Persisted metadata has used a few aliases over time. + flavorAliases: ['codex', 'openai', 'gpt'], + cli: { + detectKey: 'codex', + machineLoginKey: 'codex', + installBanner: { + installKind: 'command', + installCommand: 'npm install -g codex-cli', + guideUrl: 'https://github.com/openai/openai-codex', + }, + spawnAgent: 'codex', + }, + permissions: { + modeGroup: 'codexLike', + promptProtocol: 'codexDecision', + }, + model: { + supportsSelection: false, + defaultMode: 'default', + allowedModes: ['default'], + }, + resume: { + vendorResumeIdField: 'codexSessionId', + uiVendorResumeIdLabelKey: 'sessionInfo.codexSessionId', + uiVendorResumeIdCopiedKey: 'sessionInfo.codexSessionIdCopied', + supportsVendorResume: true, + runtimeGate: null, + experimental: true, + }, + toolRendering: { + hideUnknownToolsByDefault: false, + }, + ui: { + agentPickerIconName: 'terminal-outline', + cliGlyphScale: 0.92, + profileCompatibilityGlyphScale: 0.82, + }, +}; + diff --git a/expo-app/sources/agents/providers/codex/ui.ts b/expo-app/sources/agents/providers/codex/ui.ts new file mode 100644 index 000000000..035cb4982 --- /dev/null +++ b/expo-app/sources/agents/providers/codex/ui.ts @@ -0,0 +1,15 @@ +import type { UnistylesThemes } from 'react-native-unistyles'; + +import type { AgentUiConfig } from '@/agents/registryUi'; + +export const CODEX_UI: AgentUiConfig = { + id: 'codex', + icon: require('@/assets/images/icon-gpt.png'), + tintColor: (theme: UnistylesThemes[keyof UnistylesThemes]) => theme.colors.text, + avatarOverlay: { + circleScale: 0.35, + iconScale: ({ size }: { size: number }) => Math.round(size * 0.25), + }, + cliGlyph: '꩜', +}; + diff --git a/expo-app/sources/agents/providers/codex/uiBehavior.ts b/expo-app/sources/agents/providers/codex/uiBehavior.ts new file mode 100644 index 000000000..9db5399ac --- /dev/null +++ b/expo-app/sources/agents/providers/codex/uiBehavior.ts @@ -0,0 +1,208 @@ +import { buildAcpLoadSessionPrefetchRequest, readAcpLoadSessionSupport, shouldPrefetchAcpCapabilities } from '@/agents/acpRuntimeResume'; +import type { ResumeCapabilityOptions } from '@/agents/resumeCapabilities'; +import { getCodexAcpDepData } from '@/capabilities/codexAcpDep'; +import { getCodexMcpResumeDepData } from '@/capabilities/codexMcpResume'; +import { resumeChecklistId } from '@happy/protocol/checklists'; +import type { CapabilitiesDetectRequest } from '@/sync/capabilitiesProtocol'; + +import type { + AgentResumeExperiments, + AgentUiBehavior, + NewSessionPreflightContext, + NewSessionPreflightIssue, + NewSessionRelevantInstallableDepsContext, + ResumePreflightContext, +} from '@/agents/registryUiBehavior'; + +const CODEX_SWITCH_RESUME_MCP = 'resumeMcp'; +const CODEX_SWITCH_RESUME_ACP = 'resumeAcp'; + +function getSwitch(experiments: AgentResumeExperiments, id: string): boolean { + return experiments.switches[id] === true; +} + +export type CodexSpawnSessionExtras = Readonly<{ + experimentalCodexResume: boolean; + experimentalCodexAcp: boolean; +}>; + +export type CodexResumeSessionExtras = Readonly<{ + experimentalCodexResume: boolean; + experimentalCodexAcp: boolean; +}>; + +export function computeCodexSpawnSessionExtras(opts: { + agentId: string; + experiments: AgentResumeExperiments; + resumeSessionId: string; +}): CodexSpawnSessionExtras | null { + if (opts.agentId !== 'codex') return null; + if (opts.experiments.enabled !== true) return null; + return { + experimentalCodexResume: getSwitch(opts.experiments, CODEX_SWITCH_RESUME_MCP) === true && opts.resumeSessionId.trim().length > 0, + experimentalCodexAcp: getSwitch(opts.experiments, CODEX_SWITCH_RESUME_ACP) === true, + }; +} + +export function computeCodexResumeSessionExtras(opts: { + agentId: string; + experiments: AgentResumeExperiments; +}): CodexResumeSessionExtras | null { + if (opts.agentId !== 'codex') return null; + if (opts.experiments.enabled !== true) return null; + return { + experimentalCodexResume: getSwitch(opts.experiments, CODEX_SWITCH_RESUME_MCP) === true, + experimentalCodexAcp: getSwitch(opts.experiments, CODEX_SWITCH_RESUME_ACP) === true, + }; +} + +export function getCodexNewSessionPreflightIssues(ctx: NewSessionPreflightContext): readonly NewSessionPreflightIssue[] { + if (ctx.agentId !== 'codex') return []; + const extras = computeCodexSpawnSessionExtras({ + agentId: 'codex', + experiments: ctx.experiments, + resumeSessionId: ctx.resumeSessionId, + }); + + const codexAcpDep = getCodexAcpDepData(ctx.results); + const codexMcpResumeDep = getCodexMcpResumeDepData(ctx.results); + const deps = { + codexAcpInstalled: typeof codexAcpDep?.installed === 'boolean' ? codexAcpDep.installed : null, + codexMcpResumeInstalled: typeof codexMcpResumeDep?.installed === 'boolean' ? codexMcpResumeDep.installed : null, + }; + + const issues: NewSessionPreflightIssue[] = []; + if (extras?.experimentalCodexAcp === true && deps.codexAcpInstalled === false) { + issues.push({ + id: 'codex-acp-not-installed', + titleKey: 'errors.codexAcpNotInstalledTitle', + messageKey: 'errors.codexAcpNotInstalledMessage', + confirmTextKey: 'connect.openMachine', + action: 'openMachine', + }); + } + if (extras?.experimentalCodexResume === true && deps.codexMcpResumeInstalled === false) { + issues.push({ + id: 'codex-mcp-resume-not-installed', + titleKey: 'errors.codexResumeNotInstalledTitle', + messageKey: 'errors.codexResumeNotInstalledMessage', + confirmTextKey: 'connect.openMachine', + action: 'openMachine', + }); + } + return issues; +} + +export function getCodexNewSessionRelevantInstallableDepKeys(ctx: NewSessionRelevantInstallableDepsContext): readonly string[] { + if (ctx.agentId !== 'codex') return []; + if (ctx.experiments.enabled !== true) return []; + + const extras = computeCodexSpawnSessionExtras({ + agentId: 'codex', + experiments: ctx.experiments, + resumeSessionId: ctx.resumeSessionId, + }); + + const keys: string[] = []; + if (extras?.experimentalCodexResume === true) keys.push('codex-mcp-resume'); + if (extras?.experimentalCodexAcp === true) keys.push('codex-acp'); + return keys; +} + +export function getCodexResumePreflightIssues(ctx: ResumePreflightContext): readonly NewSessionPreflightIssue[] { + const extras = computeCodexResumeSessionExtras({ + agentId: 'codex', + experiments: ctx.experiments, + }); + if (!extras) return []; + + const codexAcpDep = getCodexAcpDepData(ctx.results); + const codexMcpResumeDep = getCodexMcpResumeDepData(ctx.results); + const deps = { + codexAcpInstalled: typeof codexAcpDep?.installed === 'boolean' ? codexAcpDep.installed : null, + codexMcpResumeInstalled: typeof codexMcpResumeDep?.installed === 'boolean' ? codexMcpResumeDep.installed : null, + }; + + const issues: NewSessionPreflightIssue[] = []; + if (extras.experimentalCodexAcp === true && deps.codexAcpInstalled === false) { + issues.push({ + id: 'codex-acp-not-installed', + titleKey: 'errors.codexAcpNotInstalledTitle', + messageKey: 'errors.codexAcpNotInstalledMessage', + confirmTextKey: 'connect.openMachine', + action: 'openMachine', + }); + } + if (extras.experimentalCodexResume === true && deps.codexMcpResumeInstalled === false) { + issues.push({ + id: 'codex-mcp-resume-not-installed', + titleKey: 'errors.codexResumeNotInstalledTitle', + messageKey: 'errors.codexResumeNotInstalledMessage', + confirmTextKey: 'connect.openMachine', + action: 'openMachine', + }); + } + return issues; +} + +export const CODEX_UI_BEHAVIOR_OVERRIDE: AgentUiBehavior = { + resume: { + experimentSwitches: [ + { id: CODEX_SWITCH_RESUME_MCP, settingKey: 'expCodexResume' }, + { id: CODEX_SWITCH_RESUME_ACP, settingKey: 'expCodexAcp' }, + ], + getAllowExperimentalVendorResume: ({ experiments }) => { + return experiments.enabled === true && (getSwitch(experiments, CODEX_SWITCH_RESUME_MCP) || getSwitch(experiments, CODEX_SWITCH_RESUME_ACP)); + }, + getExperimentalVendorResumeRequiresRuntime: ({ experiments }) => { + if (experiments.enabled !== true) return false; + // ACP-only mode must fail closed until ACP loadSession support is confirmed. + return getSwitch(experiments, CODEX_SWITCH_RESUME_ACP) === true && getSwitch(experiments, CODEX_SWITCH_RESUME_MCP) !== true; + }, + // Codex ACP mode can support vendor-resume via ACP `loadSession`. + // We probe this dynamically (same as Gemini/OpenCode) and only enforce it when `expCodexAcp` is enabled. + getAllowRuntimeResume: ({ experiments, results }) => { + if (experiments.enabled !== true) return false; + if (getSwitch(experiments, CODEX_SWITCH_RESUME_ACP) !== true) return false; + return readAcpLoadSessionSupport('codex', results); + }, + getRuntimeResumePrefetchPlan: ({ experiments, results }) => { + if (experiments.enabled !== true) return null; + if (getSwitch(experiments, CODEX_SWITCH_RESUME_ACP) !== true) return null; + if (!shouldPrefetchAcpCapabilities('codex', results)) return null; + return { request: buildAcpLoadSessionPrefetchRequest('codex'), timeoutMs: 8_000 }; + }, + getPreflightPrefetchPlan: ({ experiments }) => { + if (experiments.enabled !== true) return null; + if (!(getSwitch(experiments, CODEX_SWITCH_RESUME_MCP) || getSwitch(experiments, CODEX_SWITCH_RESUME_ACP))) return null; + const request: CapabilitiesDetectRequest = { checklistId: resumeChecklistId('codex') }; + return { request, timeoutMs: 12_000 }; + }, + getPreflightIssues: getCodexResumePreflightIssues, + }, + newSession: { + getPreflightIssues: getCodexNewSessionPreflightIssues, + getRelevantInstallableDepKeys: getCodexNewSessionRelevantInstallableDepKeys, + }, + payload: { + buildSpawnSessionExtras: ({ agentId, experiments, resumeSessionId }) => { + const extras = computeCodexSpawnSessionExtras({ + agentId, + experiments, + resumeSessionId, + }); + return extras ?? {}; + }, + buildResumeSessionExtras: ({ agentId, experiments }) => { + const extras = computeCodexResumeSessionExtras({ + agentId, + experiments, + }); + return extras ?? {}; + }, + buildWakeResumeExtras: ({ resumeCapabilityOptions }: { resumeCapabilityOptions: ResumeCapabilityOptions }) => { + const allowCodexResume = resumeCapabilityOptions.allowExperimentalResumeByAgentId?.codex === true; + return allowCodexResume ? { experimentalCodexResume: true } : {}; + }, + }, +}; diff --git a/expo-app/sources/agents/providers/gemini/core.ts b/expo-app/sources/agents/providers/gemini/core.ts new file mode 100644 index 000000000..afdf9a865 --- /dev/null +++ b/expo-app/sources/agents/providers/gemini/core.ts @@ -0,0 +1,51 @@ +import type { AgentCoreConfig } from '@/agents/registryCore'; + +export const GEMINI_CORE: AgentCoreConfig = { + id: 'gemini', + displayNameKey: 'agentInput.agent.gemini', + subtitleKey: 'profiles.aiBackend.geminiSubtitleExperimental', + permissionModeI18nPrefix: 'agentInput.geminiPermissionMode', + availability: { experimental: true }, + connectedService: { + id: 'gemini', + name: 'Google Gemini', + connectRoute: null, + }, + flavorAliases: ['gemini'], + cli: { + detectKey: 'gemini', + machineLoginKey: 'gemini-cli', + installBanner: { + installKind: 'ifAvailable', + guideUrl: 'https://ai.google.dev/gemini-api/docs/get-started', + }, + spawnAgent: 'gemini', + }, + permissions: { + modeGroup: 'codexLike', + promptProtocol: 'codexDecision', + }, + model: { + supportsSelection: true, + defaultMode: 'gemini-2.5-pro', + allowedModes: ['gemini-2.5-pro', 'gemini-2.5-flash', 'gemini-2.5-flash-lite'], + }, + resume: { + // Runtime-gated via ACP capability probing (loadSession). + vendorResumeIdField: 'geminiSessionId', + uiVendorResumeIdLabelKey: 'sessionInfo.geminiSessionId', + uiVendorResumeIdCopiedKey: 'sessionInfo.geminiSessionIdCopied', + supportsVendorResume: false, + runtimeGate: 'acpLoadSession', + experimental: false, + }, + toolRendering: { + hideUnknownToolsByDefault: true, + }, + ui: { + agentPickerIconName: 'planet-outline', + cliGlyphScale: 1.0, + profileCompatibilityGlyphScale: 0.88, + }, +}; + diff --git a/expo-app/sources/agents/providers/gemini/ui.ts b/expo-app/sources/agents/providers/gemini/ui.ts new file mode 100644 index 000000000..3a3ff8c2b --- /dev/null +++ b/expo-app/sources/agents/providers/gemini/ui.ts @@ -0,0 +1,13 @@ +import type { AgentUiConfig } from '@/agents/registryUi'; + +export const GEMINI_UI: AgentUiConfig = { + id: 'gemini', + icon: require('@/assets/images/icon-gemini.png'), + tintColor: null, + avatarOverlay: { + circleScale: 0.35, + iconScale: ({ size }: { size: number }) => Math.round(size * 0.35), + }, + cliGlyph: '\u2726\uFE0E', +}; + diff --git a/expo-app/sources/agents/providers/opencode/core.ts b/expo-app/sources/agents/providers/opencode/core.ts new file mode 100644 index 000000000..d164d2046 --- /dev/null +++ b/expo-app/sources/agents/providers/opencode/core.ts @@ -0,0 +1,51 @@ +import type { AgentCoreConfig } from '@/agents/registryCore'; + +export const OPENCODE_CORE: AgentCoreConfig = { + id: 'opencode', + displayNameKey: 'agentInput.agent.opencode', + subtitleKey: 'profiles.aiBackend.opencodeSubtitle', + permissionModeI18nPrefix: 'agentInput.codexPermissionMode', + availability: { experimental: false }, + connectedService: { + id: null, + name: 'OpenCode', + connectRoute: null, + }, + flavorAliases: ['opencode', 'open-code'], + cli: { + detectKey: 'opencode', + machineLoginKey: 'opencode', + installBanner: { + installKind: 'command', + installCommand: 'curl -fsSL https://opencode.ai/install | bash', + guideUrl: 'https://opencode.ai/docs', + }, + spawnAgent: 'opencode', + }, + permissions: { + modeGroup: 'codexLike', + promptProtocol: 'codexDecision', + }, + model: { + supportsSelection: false, + defaultMode: 'default', + allowedModes: ['default'], + }, + resume: { + vendorResumeIdField: 'opencodeSessionId', + uiVendorResumeIdLabelKey: 'sessionInfo.opencodeSessionId', + uiVendorResumeIdCopiedKey: 'sessionInfo.opencodeSessionIdCopied', + supportsVendorResume: false, + runtimeGate: 'acpLoadSession', + experimental: false, + }, + toolRendering: { + hideUnknownToolsByDefault: false, + }, + ui: { + agentPickerIconName: 'code-slash-outline', + cliGlyphScale: 1.0, + profileCompatibilityGlyphScale: 1.0, + }, +}; + diff --git a/expo-app/sources/agents/providers/opencode/ui.ts b/expo-app/sources/agents/providers/opencode/ui.ts new file mode 100644 index 000000000..29a7f83a3 --- /dev/null +++ b/expo-app/sources/agents/providers/opencode/ui.ts @@ -0,0 +1,15 @@ +import type { UnistylesThemes } from 'react-native-unistyles'; + +import type { AgentUiConfig } from '@/agents/registryUi'; + +export const OPENCODE_UI: AgentUiConfig = { + id: 'opencode', + icon: require('@/assets/images/icon-monochrome.png'), + tintColor: (theme: UnistylesThemes[keyof UnistylesThemes]) => theme.colors.text, + avatarOverlay: { + circleScale: 0.35, + iconScale: ({ size }: { size: number }) => Math.round(size * 0.25), + }, + cliGlyph: '</>', +}; + diff --git a/expo-app/sources/agents/registryCore.test.ts b/expo-app/sources/agents/registryCore.test.ts new file mode 100644 index 000000000..e9cec2be3 --- /dev/null +++ b/expo-app/sources/agents/registryCore.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect } from 'vitest'; + +import { resolveAgentIdFromFlavor, getAgentCore, AGENT_IDS } from './registryCore'; + +describe('agents/registryCore', () => { + it('exposes a stable list of agent ids', () => { + expect(Array.isArray(AGENT_IDS)).toBe(true); + expect(AGENT_IDS.length).toBeGreaterThan(0); + }); + + it('resolves known flavors and aliases to canonical agent ids', () => { + expect(resolveAgentIdFromFlavor('claude')).toBe('claude'); + expect(resolveAgentIdFromFlavor('codex')).toBe('codex'); + expect(resolveAgentIdFromFlavor('opencode')).toBe('opencode'); + expect(resolveAgentIdFromFlavor('gemini')).toBe('gemini'); + + // Common Codex aliases found in persisted session metadata. + expect(resolveAgentIdFromFlavor('openai')).toBe('codex'); + expect(resolveAgentIdFromFlavor('gpt')).toBe('codex'); + }); + + it('returns null for unknown flavor strings', () => { + expect(resolveAgentIdFromFlavor('unknown')).toBeNull(); + expect(resolveAgentIdFromFlavor('')).toBeNull(); + expect(resolveAgentIdFromFlavor(null)).toBeNull(); + expect(resolveAgentIdFromFlavor(undefined)).toBeNull(); + }); + + it('provides core config for known agents', () => { + const claude = getAgentCore('claude'); + expect(claude.id).toBe('claude'); + expect(claude.cli.detectKey).toBeTruthy(); + }); +}); diff --git a/expo-app/sources/agents/registryCore.ts b/expo-app/sources/agents/registryCore.ts new file mode 100644 index 000000000..1fd7f0ff0 --- /dev/null +++ b/expo-app/sources/agents/registryCore.ts @@ -0,0 +1,201 @@ +import type { ModelMode } from '@/sync/permissionTypes'; +import type { TranslationKey } from '@/text'; +import type { Href } from 'expo-router'; + +import { AGENT_IDS, DEFAULT_AGENT_ID, type AgentId } from '@happy/agents'; + +import { CLAUDE_CORE } from './providers/claude/core'; +import { CODEX_CORE } from './providers/codex/core'; +import { OPENCODE_CORE } from './providers/opencode/core'; +import { GEMINI_CORE } from './providers/gemini/core'; +import { AUGGIE_CORE } from './providers/auggie/core'; + +export { AGENT_IDS, DEFAULT_AGENT_ID }; +export type { AgentId }; + +export type PermissionModeGroupId = 'claude' | 'codexLike'; +export type PermissionPromptProtocol = 'claude' | 'codexDecision'; + +export type VendorResumeIdField = string; +export type MachineLoginKey = string; + +export type ResumeRuntimeGate = 'acpLoadSession' | null; + +export type AgentCoreConfig = Readonly<{ + id: AgentId; + /** + * Translation key for the agent display name in UI. + * (Resolved via `t(...)` in UI modules.) + */ + displayNameKey: TranslationKey; + /** + * Translation key for the agent subtitle in profile/session pickers. + */ + subtitleKey: TranslationKey; + /** + * Translation key prefix for permission mode labels/badges. + * Examples: + * - Claude: `agentInput.permissionMode.*` + * - Codex: `agentInput.codexPermissionMode.*` + * - Gemini: `agentInput.geminiPermissionMode.*` + */ + permissionModeI18nPrefix: string; + availability: Readonly<{ + /** + * When true, this agent is gated behind `settings.experiments` + `settings.experimentalAgents[id]`. + */ + experimental: boolean; + }>; + connectedService: Readonly<{ + /** + * Server-side connected service id (e.g. `anthropic`, `openai`). + * When null, the agent has no account-level OAuth connection surface in the UI. + */ + id: string | null; + /** + * Human-friendly name shown in account settings. + * (This is intentionally not i18n'd yet; can be moved to translations later.) + */ + name: string; + /** + * Optional app route used to connect the service. + */ + connectRoute?: Href | null; + }>; + flavorAliases: readonly string[]; + cli: Readonly<{ + /** + * The shell command name used for CLI detection (and for UX copy). + * Example: `command -v <detectKey>`. + */ + detectKey: string; + /** + * Profile-level machine-login identifier used when `profile.authMode=machineLogin`. + * Stored in `profile.requiresMachineLogin`. + */ + machineLoginKey: MachineLoginKey; + /** + * Optional UX metadata for "CLI not detected" banners. + */ + installBanner: Readonly<{ + /** + * When "command", show `newSession.cliBanners.installCommand` with `installCommand`. + * When "ifAvailable", show `newSession.cliBanners.installCliIfAvailable` with the CLI name. + */ + installKind: 'command' | 'ifAvailable'; + installCommand?: string; + guideUrl?: string; + }>; + /** + * Canonical agent id passed to daemon RPCs (spawn/resume). + * Keep this stable; do not use aliases here. + */ + spawnAgent: AgentId; + }>; + permissions: Readonly<{ + modeGroup: PermissionModeGroupId; + promptProtocol: PermissionPromptProtocol; + }>; + model: Readonly<{ + supportsSelection: boolean; + defaultMode: ModelMode; + allowedModes: readonly ModelMode[]; + }>; + resume: Readonly<{ + /** + * Field in session metadata containing the vendor resume id, if supported. + */ + vendorResumeIdField: VendorResumeIdField | null; + /** + * Translation keys for showing/copying the vendor resume id in the session info UI. + * When null, the UI should not render a resume id row for this agent. + */ + uiVendorResumeIdLabelKey: TranslationKey | null; + uiVendorResumeIdCopiedKey: TranslationKey | null; + /** + * Whether this agent can be resumed from UI in principle. + * (May still be gated by experiments in higher-level helpers.) + */ + supportsVendorResume: boolean; + /** + * Runtime-gated resume support mechanism (when `supportsVendorResume=false`). + * When set, the UI/CLI can detect resume support dynamically per machine. + */ + runtimeGate: ResumeRuntimeGate; + /** + * When true, vendor-resume support is considered experimental and must be enabled explicitly + * by callers (e.g. via feature flags / experiments). + */ + experimental: boolean; + }>; + toolRendering: Readonly<{ + /** + * When true, unknown tools should be hidden/minimal to avoid noisy internal tools. + */ + hideUnknownToolsByDefault: boolean; + }>; + ui: Readonly<{ + /** + * Icon used in agent picker UIs (Ionicons name). + * Kept here as a string so it remains Node-safe (tests can import it). + */ + agentPickerIconName: string; + /** + * Optional font size scale used for CLI glyph renderers (dingbat-based). + */ + cliGlyphScale: number; + /** + * Optional font size scale used for profile compatibility glyph renderers. + */ + profileCompatibilityGlyphScale: number; + }>; +}>; + +export const AGENTS_CORE: Readonly<Record<AgentId, AgentCoreConfig>> = Object.freeze({ + claude: CLAUDE_CORE, + codex: CODEX_CORE, + opencode: OPENCODE_CORE, + gemini: GEMINI_CORE, + auggie: AUGGIE_CORE, +}); + +export function isAgentId(value: unknown): value is AgentId { + return typeof value === 'string' && (AGENT_IDS as readonly string[]).includes(value); +} + +export function getAgentCore(id: AgentId): AgentCoreConfig { + return AGENTS_CORE[id]; +} + +export function resolveAgentIdFromFlavor(flavor: string | null | undefined): AgentId | null { + if (typeof flavor !== 'string') return null; + const normalized = flavor.trim().toLowerCase(); + if (!normalized) return null; + + for (const id of AGENT_IDS) { + const cfg = AGENTS_CORE[id]; + if (cfg.flavorAliases.includes(normalized)) return id; + } + return null; +} + +export function resolveAgentIdFromCliDetectKey(detectKey: string | null | undefined): AgentId | null { + if (typeof detectKey !== 'string') return null; + const normalized = detectKey.trim().toLowerCase(); + if (!normalized) return null; + for (const id of AGENT_IDS) { + if (AGENTS_CORE[id].cli.detectKey === normalized) return id; + } + return null; +} + +export function resolveAgentIdFromConnectedServiceId(serviceId: string | null | undefined): AgentId | null { + if (typeof serviceId !== 'string') return null; + const normalized = serviceId.trim().toLowerCase(); + if (!normalized) return null; + for (const id of AGENT_IDS) { + const svc = AGENTS_CORE[id].connectedService?.id; + if (typeof svc === 'string' && svc.toLowerCase() === normalized) return id; + } + return null; +} diff --git a/expo-app/sources/agents/registryUi.ts b/expo-app/sources/agents/registryUi.ts new file mode 100644 index 000000000..866fc4bf3 --- /dev/null +++ b/expo-app/sources/agents/registryUi.ts @@ -0,0 +1,59 @@ +import type { ImageSourcePropType } from 'react-native'; +import type { UnistylesThemes } from 'react-native-unistyles'; + +import type { AgentId } from './registryCore'; + +import { CLAUDE_UI } from './providers/claude/ui'; +import { CODEX_UI } from './providers/codex/ui'; +import { OPENCODE_UI } from './providers/opencode/ui'; +import { GEMINI_UI } from './providers/gemini/ui'; +import { AUGGIE_UI } from './providers/auggie/ui'; + +export type AgentUiConfig = Readonly<{ + id: AgentId; + icon: ImageSourcePropType; + /** + * Optional tint for the icon (Codex icon is monochrome and should match text color). + */ + tintColor: ((theme: UnistylesThemes[keyof UnistylesThemes]) => string) | null; + /** + * Avatar overlay sizing tweaks. + */ + avatarOverlay: Readonly<{ + circleScale: number; // relative to avatar size + iconScale: (params: { size: number }) => number; // absolute px derived from avatar size + }>; + /** + * Text glyph used in compact CLI/profile compatibility indicators. + */ + cliGlyph: string; +}>; + +export const AGENTS_UI: Readonly<Record<AgentId, AgentUiConfig>> = Object.freeze({ + claude: CLAUDE_UI, + codex: CODEX_UI, + opencode: OPENCODE_UI, + gemini: GEMINI_UI, + auggie: AUGGIE_UI, +}); + +export function getAgentIconSource(agentId: AgentId): ImageSourcePropType { + return AGENTS_UI[agentId].icon; +} + +export function getAgentIconTintColor(agentId: AgentId, theme: UnistylesThemes[keyof UnistylesThemes]): string | undefined { + const tint = AGENTS_UI[agentId].tintColor; + if (!tint) return undefined; + return tint(theme); +} + +export function getAgentAvatarOverlaySizes(agentId: AgentId, size: number): { circleSize: number; iconSize: number } { + const cfg = AGENTS_UI[agentId]; + const circleSize = Math.round(size * cfg.avatarOverlay.circleScale); + const iconSize = cfg.avatarOverlay.iconScale({ size }); + return { circleSize, iconSize }; +} + +export function getAgentCliGlyph(agentId: AgentId): string { + return AGENTS_UI[agentId].cliGlyph; +} diff --git a/expo-app/sources/agents/registryUiBehavior.test.ts b/expo-app/sources/agents/registryUiBehavior.test.ts new file mode 100644 index 000000000..70d9e0d7c --- /dev/null +++ b/expo-app/sources/agents/registryUiBehavior.test.ts @@ -0,0 +1,288 @@ +import { describe, expect, it } from 'vitest'; + +import { settingsDefaults } from '@/sync/settings'; + +import { + buildResumeCapabilityOptionsFromUiState, + buildResumeSessionExtrasFromUiState, + buildSpawnSessionExtrasFromUiState, + buildWakeResumeExtras, + getAgentResumeExperimentsFromSettings, + getNewSessionPreflightIssues, + getNewSessionRelevantInstallableDepKeys, + getResumePreflightIssues, + getResumePreflightPrefetchPlan, + getResumeRuntimeSupportPrefetchPlan, +} from './registryUiBehavior'; + +function makeSettings(overrides: Partial<typeof settingsDefaults> = {}) { + return { ...settingsDefaults, ...overrides }; +} + +describe('buildSpawnSessionExtrasFromUiState', () => { + it('enables codex resume only when spawning codex with a non-empty resume id', () => { + expect(buildSpawnSessionExtrasFromUiState({ + agentId: 'codex', + settings: makeSettings({ experiments: true, expCodexResume: true, expCodexAcp: false }), + resumeSessionId: 'x1', + })).toEqual({ + experimentalCodexResume: true, + experimentalCodexAcp: false, + }); + + expect(buildSpawnSessionExtrasFromUiState({ + agentId: 'codex', + settings: makeSettings({ experiments: true, expCodexResume: true, expCodexAcp: false }), + resumeSessionId: ' ', + })).toEqual({ + experimentalCodexResume: false, + experimentalCodexAcp: false, + }); + }); + + it('enables codex acp only when spawning codex and the flag is enabled', () => { + expect(buildSpawnSessionExtrasFromUiState({ + agentId: 'codex', + settings: makeSettings({ experiments: true, expCodexResume: false, expCodexAcp: true }), + resumeSessionId: '', + })).toEqual({ + experimentalCodexResume: false, + experimentalCodexAcp: true, + }); + }); + + it('returns an empty object for non-codex agents', () => { + expect(buildSpawnSessionExtrasFromUiState({ + agentId: 'claude', + settings: makeSettings({ experiments: true, expCodexResume: true, expCodexAcp: true }), + resumeSessionId: 'x1', + })).toEqual({}); + }); +}); + +describe('buildResumeSessionExtrasFromUiState', () => { + it('passes codex experiment flags through when experiments are enabled', () => { + expect(buildResumeSessionExtrasFromUiState({ + agentId: 'codex', + settings: makeSettings({ experiments: true, expCodexResume: true, expCodexAcp: false }), + })).toEqual({ + experimentalCodexResume: true, + experimentalCodexAcp: false, + }); + }); + + it('returns false flags when experiments are disabled', () => { + expect(buildResumeSessionExtrasFromUiState({ + agentId: 'codex', + settings: makeSettings({ experiments: false, expCodexResume: true, expCodexAcp: true }), + })).toEqual({}); + }); + + it('returns an empty object for non-codex agents', () => { + expect(buildResumeSessionExtrasFromUiState({ + agentId: 'claude', + settings: makeSettings({ experiments: true, expCodexResume: true, expCodexAcp: true }), + })).toEqual({}); + }); +}); + +describe('getResumePreflightIssues', () => { + it('returns a blocking issue when codex resume is requested but the resume dep is not installed', () => { + const settings = makeSettings({ experiments: true, expCodexResume: true, expCodexAcp: false }); + expect(getResumePreflightIssues({ + agentId: 'codex', + experiments: getAgentResumeExperimentsFromSettings('codex', settings), + results: { + 'dep.codex-mcp-resume': { ok: true, checkedAt: 1, data: { installed: false } }, + }, + })).toEqual([ + expect.objectContaining({ + id: 'codex-mcp-resume-not-installed', + action: 'openMachine', + }), + ]); + }); + + it('returns a blocking issue when codex acp is requested but the acp dep is not installed', () => { + const settings = makeSettings({ experiments: true, expCodexResume: false, expCodexAcp: true }); + expect(getResumePreflightIssues({ + agentId: 'codex', + experiments: getAgentResumeExperimentsFromSettings('codex', settings), + results: { + 'dep.codex-acp': { ok: true, checkedAt: 1, data: { installed: false } }, + }, + })).toEqual([ + expect.objectContaining({ + id: 'codex-acp-not-installed', + action: 'openMachine', + }), + ]); + }); + + it('returns empty when experiments are disabled or dep status is unknown', () => { + const disabled = makeSettings({ experiments: false, expCodexResume: true, expCodexAcp: true }); + expect(getResumePreflightIssues({ + agentId: 'codex', + experiments: getAgentResumeExperimentsFromSettings('codex', disabled), + results: { + 'dep.codex-acp': { ok: true, checkedAt: 1, data: { installed: false } }, + 'dep.codex-mcp-resume': { ok: true, checkedAt: 1, data: { installed: false } }, + } as any, + })).toEqual([]); + + const unknown = makeSettings({ experiments: true, expCodexResume: true, expCodexAcp: true }); + expect(getResumePreflightIssues({ + agentId: 'codex', + experiments: getAgentResumeExperimentsFromSettings('codex', unknown), + results: {} as any, + })).toEqual([]); + }); + + it('returns empty for non-codex agents', () => { + const settings = makeSettings({ experiments: true, expCodexResume: true, expCodexAcp: true }); + expect(getResumePreflightIssues({ + agentId: 'claude', + experiments: getAgentResumeExperimentsFromSettings('claude', settings), + results: {} as any, + })).toEqual([]); + }); +}); + +describe('buildWakeResumeExtras', () => { + it('adds experimentalCodexResume for codex wake payloads only', () => { + expect(buildWakeResumeExtras({ + agentId: 'claude', + resumeCapabilityOptions: { allowExperimentalResumeByAgentId: { codex: true } }, + })).toEqual({}); + expect(buildWakeResumeExtras({ + agentId: 'codex', + resumeCapabilityOptions: { allowExperimentalResumeByAgentId: { codex: true } }, + })).toEqual({ experimentalCodexResume: true }); + expect(buildWakeResumeExtras({ + agentId: 'codex', + resumeCapabilityOptions: {}, + })).toEqual({}); + }); +}); + +describe('buildResumeCapabilityOptionsFromUiState', () => { + it('includes codex experimental resume and runtime resume support when detected', () => { + const settings = makeSettings({ experiments: true, expCodexResume: true, expCodexAcp: false }); + expect(buildResumeCapabilityOptionsFromUiState({ + settings, + results: { + 'cli.gemini': { ok: true, checkedAt: 1, data: { available: true, acp: { ok: true, loadSession: true } } }, + } as any, + })).toEqual({ + allowExperimentalResumeByAgentId: { codex: true }, + allowRuntimeResumeByAgentId: { gemini: true }, + }); + }); + + it('includes OpenCode runtime resume support when detected', () => { + const settings = makeSettings({ experiments: false, expCodexResume: false, expCodexAcp: false }); + expect(buildResumeCapabilityOptionsFromUiState({ + settings, + results: { + 'cli.opencode': { ok: true, checkedAt: 1, data: { available: true, acp: { ok: true, loadSession: true } } }, + } as any, + })).toEqual({ + allowRuntimeResumeByAgentId: { opencode: true }, + }); + }); +}); + +describe('getResumeRuntimeSupportPrefetchPlan', () => { + it('prefetches gemini resume support when the ACP data is missing', () => { + expect(getResumeRuntimeSupportPrefetchPlan({ agentId: 'gemini', settings: makeSettings(), results: undefined })).toEqual({ + request: { + requests: [ + { + id: 'cli.gemini', + params: { includeAcpCapabilities: true, includeLoginStatus: true }, + }, + ], + }, + timeoutMs: 8_000, + }); + }); + + it('prefetches opencode resume support when the ACP data is missing', () => { + expect(getResumeRuntimeSupportPrefetchPlan({ agentId: 'opencode', settings: makeSettings(), results: undefined })).toEqual({ + request: { + requests: [ + { + id: 'cli.opencode', + params: { includeAcpCapabilities: true, includeLoginStatus: true }, + }, + ], + }, + timeoutMs: 8_000, + }); + }); +}); + +describe('getResumePreflightPrefetchPlan', () => { + it('prefetches codex resume checklist only when codex experiments are enabled', () => { + const disabled = makeSettings({ experiments: false, expCodexResume: true, expCodexAcp: true }); + expect(getResumePreflightPrefetchPlan({ agentId: 'codex', settings: disabled, results: undefined })).toEqual(null); + + const enabled = makeSettings({ experiments: true, expCodexResume: true, expCodexAcp: false }); + expect(getResumePreflightPrefetchPlan({ agentId: 'codex', settings: enabled, results: undefined })).toEqual( + expect.objectContaining({ + request: expect.objectContaining({ checklistId: expect.stringContaining('resume.codex') }), + }), + ); + }); +}); + +describe('getNewSessionRelevantInstallableDepKeys', () => { + it('returns codex deps based on current spawn extras', () => { + const settings = makeSettings({ experiments: true, expCodexResume: true, expCodexAcp: true }); + expect(getNewSessionRelevantInstallableDepKeys({ + agentId: 'codex', + experiments: getAgentResumeExperimentsFromSettings('codex', settings), + resumeSessionId: 'x1', + })).toEqual(['codex-mcp-resume', 'codex-acp']); + + expect(getNewSessionRelevantInstallableDepKeys({ + agentId: 'codex', + experiments: getAgentResumeExperimentsFromSettings('codex', settings), + resumeSessionId: '', + })).toEqual(['codex-acp']); + }); + + it('returns empty for non-codex agents and when experiments are disabled', () => { + const settings = makeSettings({ experiments: true, expCodexResume: true, expCodexAcp: true }); + expect(getNewSessionRelevantInstallableDepKeys({ + agentId: 'claude', + experiments: getAgentResumeExperimentsFromSettings('claude', settings), + resumeSessionId: 'x1', + })).toEqual([]); + + const disabled = makeSettings({ experiments: false, expCodexResume: true, expCodexAcp: true }); + expect(getNewSessionRelevantInstallableDepKeys({ + agentId: 'codex', + experiments: getAgentResumeExperimentsFromSettings('codex', disabled), + resumeSessionId: 'x1', + })).toEqual([]); + }); +}); + +describe('getNewSessionPreflightIssues', () => { + it('returns codex preflight issues based on machine results (deps missing)', () => { + const settings = makeSettings({ experiments: true, expCodexResume: true, expCodexAcp: true }); + const issues = getNewSessionPreflightIssues({ + agentId: 'codex', + experiments: getAgentResumeExperimentsFromSettings('codex', settings), + resumeSessionId: 'x1', + results: { + 'dep.codex-mcp-resume': { ok: true, checkedAt: 1, data: { installed: false } }, + 'dep.codex-acp': { ok: true, checkedAt: 1, data: { installed: false } }, + } as any, + }); + expect(issues.length).toBeGreaterThan(0); + expect(issues[0]).toEqual(expect.objectContaining({ id: 'codex-acp-not-installed' })); + expect(issues).toEqual(expect.arrayContaining([expect.objectContaining({ id: 'codex-mcp-resume-not-installed' })])); + }); +}); diff --git a/expo-app/sources/agents/registryUiBehavior.ts b/expo-app/sources/agents/registryUiBehavior.ts new file mode 100644 index 000000000..fb1698976 --- /dev/null +++ b/expo-app/sources/agents/registryUiBehavior.ts @@ -0,0 +1,315 @@ +import type { AgentId } from './registryCore'; +import { AGENT_IDS, getAgentCore } from './registryCore'; +import type { CapabilitiesDetectRequest, CapabilityDetectResult, CapabilityId } from '@/sync/capabilitiesProtocol'; +import type { ResumeCapabilityOptions } from '@/agents/resumeCapabilities'; +import type { TranslationKey } from '@/text'; +import type { Settings } from '@/sync/settings'; +import { buildAcpLoadSessionPrefetchRequest, readAcpLoadSessionSupport, shouldPrefetchAcpCapabilities } from './acpRuntimeResume'; +import { CODEX_UI_BEHAVIOR_OVERRIDE } from './providers/codex/uiBehavior'; +import { AUGGIE_UI_BEHAVIOR_OVERRIDE } from './providers/auggie/uiBehavior'; +import type { AgentInputExtraActionChip } from '@/components/sessions/agentInput'; + +type CapabilityResults = Partial<Record<CapabilityId, CapabilityDetectResult>>; + +export type AgentExperimentSwitches = Readonly<Record<string, boolean>>; + +export type AgentResumeExperiments = Readonly<{ + enabled: boolean; + switches: AgentExperimentSwitches; +}>; + +export type AgentExperimentSwitchDef = Readonly<{ + id: string; + settingKey: keyof Settings; +}>; + +export type ResumeRuntimeSupportPrefetchPlan = Readonly<{ + request: CapabilitiesDetectRequest; + timeoutMs: number; +}>; + +export type AgentUiBehavior = Readonly<{ + resume?: Readonly<{ + experimentSwitches?: readonly AgentExperimentSwitchDef[]; + getAllowExperimentalVendorResume?: (opts: { experiments: AgentResumeExperiments }) => boolean; + getExperimentalVendorResumeRequiresRuntime?: (opts: { experiments: AgentResumeExperiments }) => boolean; + getAllowRuntimeResume?: (opts: { experiments: AgentResumeExperiments; results: CapabilityResults | undefined }) => boolean; + getRuntimeResumePrefetchPlan?: (opts: { + experiments: AgentResumeExperiments; + results: CapabilityResults | undefined; + }) => ResumeRuntimeSupportPrefetchPlan | null; + getPreflightPrefetchPlan?: (opts: { + experiments: AgentResumeExperiments; + results: CapabilityResults | undefined; + }) => ResumeRuntimeSupportPrefetchPlan | null; + getPreflightIssues?: (ctx: ResumePreflightContext) => readonly NewSessionPreflightIssue[]; + }>; + newSession?: Readonly<{ + buildNewSessionOptions?: (ctx: { + agentId: AgentId; + agentOptionState?: Record<string, unknown> | null; + }) => Record<string, unknown> | null; + getAgentInputExtraActionChips?: (ctx: { + agentId: AgentId; + agentOptionState?: Record<string, unknown> | null; + setAgentOptionState: (key: string, value: unknown) => void; + }) => ReadonlyArray<AgentInputExtraActionChip> | undefined; + getPreflightIssues?: (ctx: NewSessionPreflightContext) => readonly NewSessionPreflightIssue[]; + getRelevantInstallableDepKeys?: (ctx: NewSessionRelevantInstallableDepsContext) => readonly string[]; + }>; + payload?: Readonly<{ + buildSpawnEnvironmentVariables?: (opts: { + agentId: AgentId; + environmentVariables: Record<string, string> | undefined; + newSessionOptions?: Record<string, unknown> | null; + }) => Record<string, string> | undefined; + buildSpawnSessionExtras?: (opts: { + agentId: AgentId; + experiments: AgentResumeExperiments; + resumeSessionId: string; + }) => Record<string, unknown>; + buildResumeSessionExtras?: (opts: { + agentId: AgentId; + experiments: AgentResumeExperiments; + }) => Record<string, unknown>; + buildWakeResumeExtras?: (opts: { agentId: AgentId; resumeCapabilityOptions: ResumeCapabilityOptions }) => Record<string, unknown>; + }>; +}>; + +export type NewSessionPreflightContext = Readonly<{ + agentId: AgentId; + experiments: AgentResumeExperiments; + resumeSessionId: string; + results: CapabilityResults | undefined; +}>; + +export type NewSessionRelevantInstallableDepsContext = Readonly<{ + agentId: AgentId; + experiments: AgentResumeExperiments; + resumeSessionId: string; +}>; + +export type NewSessionPreflightIssue = Readonly<{ + id: string; + titleKey: TranslationKey; + messageKey: TranslationKey; + confirmTextKey: TranslationKey; + action: 'openMachine'; +}>; + +export type ResumePreflightContext = Readonly<{ + agentId: AgentId; + experiments: AgentResumeExperiments; + results: CapabilityResults | undefined; +}>; + +function mergeAgentUiBehavior(a: AgentUiBehavior, b: AgentUiBehavior): AgentUiBehavior { + return { + ...(a.resume || b.resume ? { resume: { ...(a.resume ?? {}), ...(b.resume ?? {}) } } : {}), + ...(a.newSession || b.newSession ? { newSession: { ...(a.newSession ?? {}), ...(b.newSession ?? {}) } } : {}), + ...(a.payload || b.payload ? { payload: { ...(a.payload ?? {}), ...(b.payload ?? {}) } } : {}), + }; +} + +function buildDefaultAgentUiBehavior(agentId: AgentId): AgentUiBehavior { + const core = getAgentCore(agentId); + const runtimeGate = core.resume.runtimeGate; + if (runtimeGate === 'acpLoadSession') { + return { + resume: { + getAllowRuntimeResume: ({ results }) => readAcpLoadSessionSupport(agentId, results), + getRuntimeResumePrefetchPlan: ({ results }) => { + if (!shouldPrefetchAcpCapabilities(agentId, results)) return null; + return { request: buildAcpLoadSessionPrefetchRequest(agentId), timeoutMs: 8_000 }; + }, + }, + }; + } + return {}; +} + +const AGENTS_UI_BEHAVIOR_OVERRIDES: Readonly<Partial<Record<AgentId, AgentUiBehavior>>> = Object.freeze({ + codex: CODEX_UI_BEHAVIOR_OVERRIDE, + auggie: AUGGIE_UI_BEHAVIOR_OVERRIDE, +}); + +export const AGENTS_UI_BEHAVIOR: Readonly<Record<AgentId, AgentUiBehavior>> = Object.freeze( + Object.fromEntries( + AGENT_IDS.map((id) => { + const base = buildDefaultAgentUiBehavior(id); + const override = AGENTS_UI_BEHAVIOR_OVERRIDES[id] ?? {}; + return [id, mergeAgentUiBehavior(base, override)] as const; + }), + ) as Record<AgentId, AgentUiBehavior>, +); + +export function getAgentResumeExperimentsFromSettings(agentId: AgentId, settings: Settings): AgentResumeExperiments { + const enabled = settings.experiments === true; + const defs = AGENTS_UI_BEHAVIOR[agentId].resume?.experimentSwitches ?? []; + if (defs.length === 0) return { enabled, switches: {} }; + const switches: Record<string, boolean> = {}; + for (const def of defs) { + switches[def.id] = settings[def.settingKey] === true; + } + return { enabled, switches }; +} + +export function getAllowExperimentalResumeByAgentIdFromUiState(settings: Settings): Partial<Record<AgentId, boolean>> { + const out: Partial<Record<AgentId, boolean>> = {}; + for (const id of AGENT_IDS) { + const fn = AGENTS_UI_BEHAVIOR[id].resume?.getAllowExperimentalVendorResume; + if (!fn) continue; + const experiments = getAgentResumeExperimentsFromSettings(id, settings); + if (fn({ experiments }) === true) out[id] = true; + } + return out; +} + +export function getAllowRuntimeResumeByAgentIdFromResults(opts: { + settings: Settings; + results: CapabilityResults | undefined; +}): Partial<Record<AgentId, boolean>> { + const out: Partial<Record<AgentId, boolean>> = {}; + for (const id of AGENT_IDS) { + const fn = AGENTS_UI_BEHAVIOR[id].resume?.getAllowRuntimeResume; + if (!fn) continue; + const experiments = getAgentResumeExperimentsFromSettings(id, opts.settings); + if (fn({ experiments, results: opts.results }) === true) out[id] = true; + } + return out; +} + +export function buildResumeCapabilityOptionsFromUiState(opts: { + settings: Settings; + results: CapabilityResults | undefined; +}): ResumeCapabilityOptions { + const allowExperimental = getAllowExperimentalResumeByAgentIdFromUiState(opts.settings); + const allowRuntime = getAllowRuntimeResumeByAgentIdFromResults({ settings: opts.settings, results: opts.results }); + + // Generic rule: some agents may expose an experimental resume path that still requires runtime gating + // (e.g. ACP loadSession probing). Fail closed until runtime support is confirmed. + for (const id of AGENT_IDS) { + if (allowExperimental[id] !== true) continue; + const fn = AGENTS_UI_BEHAVIOR[id].resume?.getExperimentalVendorResumeRequiresRuntime; + if (!fn) continue; + const experiments = getAgentResumeExperimentsFromSettings(id, opts.settings); + if (fn({ experiments }) === true && allowRuntime[id] !== true) { + delete allowExperimental[id]; + } + } + + return buildResumeCapabilityOptionsFromMaps({ + allowExperimentalResumeByAgentId: allowExperimental, + allowRuntimeResumeByAgentId: allowRuntime, + }); +} + +export function buildResumeCapabilityOptionsFromMaps(opts: { + allowExperimentalResumeByAgentId?: Partial<Record<AgentId, boolean>>; + allowRuntimeResumeByAgentId?: Partial<Record<AgentId, boolean>>; +}): ResumeCapabilityOptions { + const allowExperimental = opts.allowExperimentalResumeByAgentId ?? {}; + const allowRuntime = opts.allowRuntimeResumeByAgentId ?? {}; + return { + ...(Object.keys(allowExperimental).length > 0 ? { allowExperimentalResumeByAgentId: allowExperimental } : {}), + ...(Object.keys(allowRuntime).length > 0 ? { allowRuntimeResumeByAgentId: allowRuntime } : {}), + }; +} + +export function getResumeRuntimeSupportPrefetchPlan( + opts: { + agentId: AgentId; + settings: Settings; + results: CapabilityResults | undefined; + }, +): ResumeRuntimeSupportPrefetchPlan | null { + const fn = AGENTS_UI_BEHAVIOR[opts.agentId].resume?.getRuntimeResumePrefetchPlan; + if (!fn) return null; + const experiments = getAgentResumeExperimentsFromSettings(opts.agentId, opts.settings); + return fn({ experiments, results: opts.results }); +} + +export function getResumePreflightPrefetchPlan( + opts: { + agentId: AgentId; + settings: Settings; + results: CapabilityResults | undefined; + }, +): ResumeRuntimeSupportPrefetchPlan | null { + const fn = AGENTS_UI_BEHAVIOR[opts.agentId].resume?.getPreflightPrefetchPlan; + if (!fn) return null; + const experiments = getAgentResumeExperimentsFromSettings(opts.agentId, opts.settings); + return fn({ experiments, results: opts.results }); +} + +export function getNewSessionPreflightIssues(ctx: NewSessionPreflightContext): readonly NewSessionPreflightIssue[] { + const fn = AGENTS_UI_BEHAVIOR[ctx.agentId].newSession?.getPreflightIssues; + return fn ? fn(ctx) : []; +} + +export function getResumePreflightIssues(ctx: ResumePreflightContext): readonly NewSessionPreflightIssue[] { + const fn = AGENTS_UI_BEHAVIOR[ctx.agentId].resume?.getPreflightIssues; + return fn ? fn(ctx) : []; +} + +export function buildNewSessionOptionsFromUiState(opts: { + agentId: AgentId; + agentOptionState?: Record<string, unknown> | null; +}): Record<string, unknown> | null { + const fn = AGENTS_UI_BEHAVIOR[opts.agentId].newSession?.buildNewSessionOptions; + return fn ? fn(opts) : null; +} + +export function getNewSessionAgentInputExtraActionChips(opts: { + agentId: AgentId; + agentOptionState?: Record<string, unknown> | null; + setAgentOptionState: (key: string, value: unknown) => void; +}): ReadonlyArray<AgentInputExtraActionChip> | undefined { + const fn = AGENTS_UI_BEHAVIOR[opts.agentId].newSession?.getAgentInputExtraActionChips; + return fn ? fn(opts) : undefined; +} + +export function getNewSessionRelevantInstallableDepKeys( + ctx: NewSessionRelevantInstallableDepsContext, +): readonly string[] { + const fn = AGENTS_UI_BEHAVIOR[ctx.agentId].newSession?.getRelevantInstallableDepKeys; + return fn ? fn(ctx) : []; +} + +export function buildSpawnSessionExtrasFromUiState(opts: { + agentId: AgentId; + settings: Settings; + resumeSessionId: string; +}): Record<string, unknown> { + const fn = AGENTS_UI_BEHAVIOR[opts.agentId].payload?.buildSpawnSessionExtras; + if (!fn) return {}; + const experiments = getAgentResumeExperimentsFromSettings(opts.agentId, opts.settings); + return fn({ agentId: opts.agentId, experiments, resumeSessionId: opts.resumeSessionId }); +} + +export function buildSpawnEnvironmentVariablesFromUiState(opts: { + agentId: AgentId; + environmentVariables: Record<string, string> | undefined; + newSessionOptions?: Record<string, unknown> | null; +}): Record<string, string> | undefined { + const fn = AGENTS_UI_BEHAVIOR[opts.agentId].payload?.buildSpawnEnvironmentVariables; + return fn ? fn(opts) : opts.environmentVariables; +} + +export function buildResumeSessionExtrasFromUiState(opts: { + agentId: AgentId; + settings: Settings; +}): Record<string, unknown> { + const fn = AGENTS_UI_BEHAVIOR[opts.agentId].payload?.buildResumeSessionExtras; + if (!fn) return {}; + const experiments = getAgentResumeExperimentsFromSettings(opts.agentId, opts.settings); + return fn({ agentId: opts.agentId, experiments }); +} + +export function buildWakeResumeExtras(opts: { + agentId: AgentId; + resumeCapabilityOptions: ResumeCapabilityOptions; +}): Record<string, unknown> { + const fn = AGENTS_UI_BEHAVIOR[opts.agentId]?.payload?.buildWakeResumeExtras; + return fn ? fn(opts) : {}; +} diff --git a/expo-app/sources/agents/resolve.test.ts b/expo-app/sources/agents/resolve.test.ts new file mode 100644 index 000000000..39306e673 --- /dev/null +++ b/expo-app/sources/agents/resolve.test.ts @@ -0,0 +1,17 @@ +import { describe, it, expect } from 'vitest'; + +import { resolveAgentIdOrDefault, resolveAgentIdForPermissionUi } from './resolve'; + +describe('agents/resolve', () => { + it('falls back to a default agent id for unknown flavors', () => { + expect(resolveAgentIdOrDefault('unknown', 'claude')).toBe('claude'); + expect(resolveAgentIdOrDefault(null, 'claude')).toBe('claude'); + }); + + it('prefers Codex tool prefix hints for permission UI', () => { + // When metadata flavor is present, prefer it (tool names can be provider-prefixed inconsistently). + expect(resolveAgentIdForPermissionUi({ flavor: 'claude', toolName: 'CodexBash' })).toBe('claude'); + expect(resolveAgentIdForPermissionUi({ flavor: 'gemini', toolName: 'CodexBash' })).toBe('gemini'); + expect(resolveAgentIdForPermissionUi({ flavor: null, toolName: 'CodexBash' })).toBe('codex'); + }); +}); diff --git a/expo-app/sources/agents/resolve.ts b/expo-app/sources/agents/resolve.ts new file mode 100644 index 000000000..969e73ebe --- /dev/null +++ b/expo-app/sources/agents/resolve.ts @@ -0,0 +1,27 @@ +import type { AgentId } from './registryCore'; +import { DEFAULT_AGENT_ID, resolveAgentIdFromFlavor } from './registryCore'; + +export function resolveAgentIdOrDefault( + flavor: string | null | undefined, + fallback: AgentId, +): AgentId { + return resolveAgentIdFromFlavor(flavor) ?? fallback; +} + +/** + * Permission prompts can arrive without reliable `metadata.flavor`, especially when + * older daemons/agents emit tool names that encode the agent (e.g. `CodexBash`). + * + * This helper centralizes those heuristics. + */ +export function resolveAgentIdForPermissionUi(params: { + flavor: string | null | undefined; + toolName: string; +}): AgentId { + const byFlavor = resolveAgentIdFromFlavor(params.flavor); + if (byFlavor) return byFlavor; + + const byTool = typeof params.toolName === 'string' ? params.toolName.trim() : ''; + if (byTool.startsWith('Codex')) return 'codex'; + return DEFAULT_AGENT_ID; +} diff --git a/expo-app/sources/agents/resumeCapabilities.test.ts b/expo-app/sources/agents/resumeCapabilities.test.ts new file mode 100644 index 000000000..b8df7d59a --- /dev/null +++ b/expo-app/sources/agents/resumeCapabilities.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from 'vitest'; + +import { getAgentVendorResumeId } from './resumeCapabilities'; + +describe('getAgentVendorResumeId', () => { + test('returns null when metadata missing', () => { + expect(getAgentVendorResumeId(null, 'claude')).toBeNull(); + }); + + test('returns null when agent is not resumable', () => { + expect(getAgentVendorResumeId({ claudeSessionId: 'c1' }, 'gemini')).toBeNull(); + }); + + test('returns Claude session id when agent is claude', () => { + expect(getAgentVendorResumeId({ claudeSessionId: 'c1' }, 'claude')).toBe('c1'); + }); + + test('returns null for experimental resume agents when not enabled', () => { + expect(getAgentVendorResumeId({ codexSessionId: 'x1' }, 'codex')).toBeNull(); + }); + + test('returns Codex session id when experimental resume is enabled for Codex', () => { + expect(getAgentVendorResumeId({ codexSessionId: 'x1' }, 'codex', { allowExperimentalResumeByAgentId: { codex: true } })).toBe('x1'); + }); + + test('treats persisted Codex flavor aliases as Codex for resume', () => { + expect(getAgentVendorResumeId({ codexSessionId: 'x1' }, 'openai', { allowExperimentalResumeByAgentId: { codex: true } })).toBe('x1'); + expect(getAgentVendorResumeId({ codexSessionId: 'x1' }, 'gpt', { allowExperimentalResumeByAgentId: { codex: true } })).toBe('x1'); + }); + + test('returns null for runtime resume agents when not enabled', () => { + expect(getAgentVendorResumeId({ opencodeSessionId: 'o1' }, 'opencode')).toBeNull(); + }); + + test('returns OpenCode session id when runtime resume is enabled for OpenCode', () => { + expect(getAgentVendorResumeId({ opencodeSessionId: 'o1' }, 'opencode', { allowRuntimeResumeByAgentId: { opencode: true } })).toBe('o1'); + }); +}); diff --git a/expo-app/sources/agents/resumeCapabilities.ts b/expo-app/sources/agents/resumeCapabilities.ts new file mode 100644 index 000000000..523a502ea --- /dev/null +++ b/expo-app/sources/agents/resumeCapabilities.ts @@ -0,0 +1,96 @@ +/** + * Agent capability configuration. + * + * Resume behavior is agent-specific and may be: + * - always available (vendor-native), + * - runtime-gated per machine (capability probing), or + * - experimental (requires explicit opt-in). + */ + +import type { AgentId } from '@/agents/catalog'; +import { getAgentCore, resolveAgentIdFromFlavor } from '@/agents/catalog'; + +export type ResumeCapabilityOptions = { + /** + * Experimental: enable vendor-resume for agents that require explicit opt-in. + */ + allowExperimentalResumeByAgentId?: Partial<Record<AgentId, boolean>>; + /** + * Runtime: enable vendor resume for agents that can be detected dynamically per machine. + * (Example: Gemini ACP loadSession support.) + */ + allowRuntimeResumeByAgentId?: Partial<Record<AgentId, boolean>>; +}; + +export function canAgentResume(agent: string | null | undefined, options?: ResumeCapabilityOptions): boolean { + if (typeof agent !== 'string') return false; + const agentId = resolveAgentIdFromFlavor(agent); + if (!agentId) return false; + const core = getAgentCore(agentId); + if (core.resume.supportsVendorResume !== true) { + return options?.allowRuntimeResumeByAgentId?.[agentId] === true; + } + if (core.resume.experimental !== true) return true; + return options?.allowExperimentalResumeByAgentId?.[agentId] === true; +} + +/** + * Minimal metadata shape used by resume capability checks. + * + * Note: `metadata.flavor` comes from persisted session metadata and may be `null` or an unknown string. + */ +export interface SessionMetadata { + flavor?: string | null; + // Vendor resume id fields vary by agent; store them as plain string properties on metadata. + [key: string]: unknown; +} + +export function getAgentSessionIdField(agent: string | null | undefined): string | null { + const agentId = resolveAgentIdFromFlavor(agent); + if (!agentId) return null; + return getAgentCore(agentId).resume.vendorResumeIdField; +} + +export function canResumeSession(metadata: SessionMetadata | null | undefined): boolean { + if (!metadata) return false; + + const agent = metadata.flavor; + if (!canAgentResume(agent)) return false; + + const field = getAgentSessionIdField(agent); + if (!field) return false; + + const agentSessionId = metadata[field]; + return typeof agentSessionId === 'string' && agentSessionId.length > 0; +} + +export function canResumeSessionWithOptions(metadata: SessionMetadata | null | undefined, options?: ResumeCapabilityOptions): boolean { + if (!metadata) return false; + const agent = metadata.flavor; + if (!canAgentResume(agent, options)) return false; + const field = getAgentSessionIdField(agent); + if (!field) return false; + const agentSessionId = metadata[field]; + return typeof agentSessionId === 'string' && agentSessionId.length > 0; +} + +export function getAgentSessionId(metadata: SessionMetadata | null | undefined): string | null { + if (!metadata) return null; + const field = getAgentSessionIdField(metadata.flavor); + if (!field) return null; + const agentSessionId = metadata[field]; + return typeof agentSessionId === 'string' && agentSessionId.length > 0 ? agentSessionId : null; +} + +export function getAgentVendorResumeId( + metadata: SessionMetadata | null | undefined, + agent: string | null | undefined, + options?: ResumeCapabilityOptions, +): string | null { + if (!metadata) return null; + if (!canAgentResume(agent, options)) return null; + const field = getAgentSessionIdField(agent); + if (!field) return null; + const agentSessionId = metadata[field]; + return typeof agentSessionId === 'string' && agentSessionId.length > 0 ? agentSessionId : null; +} diff --git a/expo-app/sources/agents/useEnabledAgentIds.ts b/expo-app/sources/agents/useEnabledAgentIds.ts new file mode 100644 index 000000000..6dc0031f1 --- /dev/null +++ b/expo-app/sources/agents/useEnabledAgentIds.ts @@ -0,0 +1,16 @@ +import * as React from 'react'; + +import { useSetting } from '@/sync/storage'; + +import { getEnabledAgentIds } from './enabled'; +import type { AgentId } from './registryCore'; + +export function useEnabledAgentIds(): AgentId[] { + const experiments = useSetting('experiments'); + const experimentalAgents = useSetting('experimentalAgents'); + + return React.useMemo(() => { + return getEnabledAgentIds({ experiments, experimentalAgents }); + }, [experiments, experimentalAgents]); +} + diff --git a/expo-app/sources/agents/useResumeCapabilityOptions.ts b/expo-app/sources/agents/useResumeCapabilityOptions.ts new file mode 100644 index 000000000..4a66edd9c --- /dev/null +++ b/expo-app/sources/agents/useResumeCapabilityOptions.ts @@ -0,0 +1,71 @@ +import * as React from 'react'; + +import type { AgentId } from './registryCore'; +import { buildResumeCapabilityOptionsFromUiState, getResumeRuntimeSupportPrefetchPlan } from './registryUiBehavior'; +import { useMachineCapabilitiesCache } from '@/hooks/useMachineCapabilitiesCache'; +import type { ResumeCapabilityOptions } from '@/agents/resumeCapabilities'; +import type { CapabilitiesDetectRequest } from '@/sync/capabilitiesProtocol'; +import type { Settings } from '@/sync/settings'; + +const NOOP_REQUEST: CapabilitiesDetectRequest = { requests: [] }; + +export function useResumeCapabilityOptions(opts: { + agentId: AgentId; + machineId: string | null | undefined; + settings: Settings; + enabled?: boolean; +}): { + resumeCapabilityOptions: ResumeCapabilityOptions; +} { + const enabled = opts.enabled !== false; + const machineId = typeof opts.machineId === 'string' ? opts.machineId : null; + + // Subscribe to the capabilities cache for this machine, but do not rely on staleMs for resume. + // Resume gating needs to fetch additional per-agent data (e.g. ACP probe) even when the base + // machine snapshot is fresh but missing those fields. + const { state, refresh } = useMachineCapabilitiesCache({ + machineId, + enabled: enabled && machineId !== null, + request: NOOP_REQUEST, + timeoutMs: undefined, + staleMs: 24 * 60 * 60 * 1000, + }); + + const results = React.useMemo(() => { + if (state.status !== 'loaded' && state.status !== 'loading') return undefined; + return state.snapshot?.response.results as any; + }, [state]); + + const plan = React.useMemo(() => { + return getResumeRuntimeSupportPrefetchPlan({ agentId: opts.agentId, settings: opts.settings, results }); + }, [opts.agentId, opts.settings, results]); + + const lastPrefetchRef = React.useRef<{ key: string; at: number } | null>(null); + + React.useEffect(() => { + if (!enabled) return; + if (!machineId) return; + if (!plan) return; + if (state.status === 'loading') return; + + const key = JSON.stringify(plan.request); + const now = Date.now(); + const last = lastPrefetchRef.current; + if (last && last.key === key && (now - last.at) < 5_000) { + return; + } + lastPrefetchRef.current = { key, at: now }; + + // Fetch missing runtime resume support data immediately (even if the cache is fresh). + refresh({ request: plan.request, timeoutMs: plan.timeoutMs }); + }, [enabled, machineId, plan, refresh, state.status]); + + const resumeCapabilityOptions = React.useMemo(() => { + return buildResumeCapabilityOptionsFromUiState({ + settings: opts.settings, + results, + }); + }, [opts.settings, results]); + + return { resumeCapabilityOptions }; +} diff --git a/expo-app/sources/app/(app)/_layout.tsx b/expo-app/sources/app/(app)/_layout.tsx index 408d7ad24..3e461ec6c 100644 --- a/expo-app/sources/app/(app)/_layout.tsx +++ b/expo-app/sources/app/(app)/_layout.tsx @@ -1,21 +1,38 @@ -import { Stack } from 'expo-router'; +import { Stack, router, useSegments } from 'expo-router'; import 'react-native-reanimated'; import * as React from 'react'; import { Typography } from '@/constants/Typography'; import { createHeader } from '@/components/navigation/Header'; import { Platform, TouchableOpacity, Text } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; import { isRunningOnMac } from '@/utils/platform'; import { useUnistyles } from 'react-native-unistyles'; import { t } from '@/text'; +import { useAuth } from '@/auth/AuthContext'; +import { isPublicRouteForUnauthenticated } from '@/auth/authRouting'; export const unstable_settings = { initialRouteName: 'index', }; export default function RootLayout() { + const auth = useAuth(); + const segments = useSegments(); + const { theme } = useUnistyles(); + + const shouldRedirect = !auth.isAuthenticated && !isPublicRouteForUnauthenticated(segments); + React.useEffect(() => { + if (!shouldRedirect) return; + router.replace('/'); + }, [shouldRedirect]); + + // Avoid rendering protected screens for a frame during redirect. + if (shouldRedirect) { + return null; + } + // Use custom header on Android and Mac Catalyst, native header on iOS (non-Catalyst) const shouldUseCustomHeader = Platform.OS === 'android' || isRunningOnMac() || Platform.OS === 'web'; - const { theme } = useUnistyles(); return ( <Stack @@ -117,6 +134,12 @@ export default function RootLayout() { headerTitle: t('settings.features'), }} /> + <Stack.Screen + name="settings/profiles" + options={{ + headerTitle: t('settingsFeatures.profiles'), + }} + /> <Stack.Screen name="terminal/connect" options={{ @@ -311,6 +334,13 @@ export default function RootLayout() { headerBackTitle: t('common.back'), }} /> + <Stack.Screen + name="new/pick/profile" + options={{ + headerTitle: '', + headerBackTitle: t('common.back'), + }} + /> <Stack.Screen name="new/pick/profile-edit" options={{ @@ -318,11 +348,38 @@ export default function RootLayout() { headerBackTitle: t('common.back'), }} /> + <Stack.Screen + name="new/pick/secret-requirement" + options={{ + headerShown: false, + // /new is presented modally on iOS. Ensure this overlay screen is too, + // otherwise it can end up pushed "behind" the modal (invisible but on the back stack). + presentation: Platform.OS === 'ios' ? 'containedModal' : 'modal', + }} + /> <Stack.Screen name="new/index" options={{ headerTitle: t('newSession.title'), - headerBackTitle: t('common.back'), + headerShown: true, + headerBackTitle: t('common.cancel'), + presentation: 'modal', + gestureEnabled: true, + fullScreenGestureEnabled: true, + // Swipe-to-dismiss is not consistently available across platforms; always provide a close button. + headerBackVisible: false, + headerLeft: () => null, + headerRight: () => ( + <TouchableOpacity + onPress={() => router.back()} + hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }} + style={{ paddingHorizontal: 12, paddingVertical: 6 }} + accessibilityRole="button" + accessibilityLabel={t('common.cancel')} + > + <Ionicons name="close" size={22} color={theme.colors.header.tint} /> + </TouchableOpacity> + ), }} /> <Stack.Screen diff --git a/expo-app/sources/app/(app)/artifacts/[id].tsx b/expo-app/sources/app/(app)/artifacts/[id].tsx index 93d6e2b9f..8a8a68398 100644 --- a/expo-app/sources/app/(app)/artifacts/[id].tsx +++ b/expo-app/sources/app/(app)/artifacts/[id].tsx @@ -153,7 +153,7 @@ export default function ArtifactDetailScreen() { console.error('Failed to delete artifact:', err); Modal.alert( t('common.error'), - 'Failed to delete artifact' + t('artifacts.deleteError') ); } finally { setIsDeleting(false); @@ -172,14 +172,63 @@ export default function ArtifactDetailScreen() { }); }, [artifact]); + const loadingTitle = t('artifacts.loading'); + const errorTitle = t('common.error'); + const untitledTitle = t('artifacts.untitled'); + const artifactTitle = artifact?.title || untitledTitle; + + const loadingScreenOptions = React.useMemo(() => { + return { + headerShown: true, + headerTitle: loadingTitle, + } as const; + }, [loadingTitle]); + + const errorScreenOptions = React.useMemo(() => { + return { + headerShown: true, + headerTitle: errorTitle, + } as const; + }, [errorTitle]); + + const headerRight = React.useCallback(() => { + return ( + <View style={{ flexDirection: 'row' }}> + <Pressable + onPress={handleEdit} + style={{ padding: 8, marginRight: 8 }} + disabled={isDeleting} + > + <Ionicons name="create-outline" size={22} color={styles.title.color} /> + </Pressable> + <Pressable + onPress={handleDelete} + style={{ padding: 8 }} + disabled={isDeleting} + > + <Ionicons + name="trash-outline" + size={22} + color={isDeleting ? styles.meta.color : styles.errorIcon.color} + /> + </Pressable> + </View> + ); + }, [handleDelete, handleEdit, isDeleting, styles.errorIcon.color, styles.meta.color, styles.title.color]); + + const screenOptions = React.useMemo(() => { + return { + headerShown: true, + headerTitle: artifactTitle, + headerRight, + } as const; + }, [artifactTitle, headerRight]); + if (isLoading) { return ( <View style={styles.container}> <Stack.Screen - options={{ - headerShown: true, - headerTitle: t('artifacts.loading'), - }} + options={loadingScreenOptions} /> <View style={styles.loadingContainer}> <ActivityIndicator size="large" /> @@ -192,10 +241,7 @@ export default function ArtifactDetailScreen() { return ( <View style={styles.container}> <Stack.Screen - options={{ - headerShown: true, - headerTitle: t('common.error'), - }} + options={errorScreenOptions} /> <View style={styles.errorContainer}> <Ionicons @@ -214,32 +260,7 @@ export default function ArtifactDetailScreen() { return ( <> <Stack.Screen - options={{ - headerShown: true, - headerTitle: artifact.title || 'Untitled', - headerRight: () => ( - <View style={{ flexDirection: 'row' }}> - <Pressable - onPress={handleEdit} - style={{ padding: 8, marginRight: 8 }} - disabled={isDeleting} - > - <Ionicons name="create-outline" size={22} color={styles.title.color} /> - </Pressable> - <Pressable - onPress={handleDelete} - style={{ padding: 8 }} - disabled={isDeleting} - > - <Ionicons - name="trash-outline" - size={22} - color={isDeleting ? styles.meta.color : styles.errorIcon.color} - /> - </Pressable> - </View> - ), - }} + options={screenOptions} /> <View style={styles.container}> <ScrollView @@ -256,7 +277,7 @@ export default function ArtifactDetailScreen() { !artifact.title && styles.untitledTitle ]} > - {artifact.title || 'Untitled'} + {artifactTitle} </Text> <Text style={styles.meta}> {formattedDate} @@ -268,7 +289,7 @@ export default function ArtifactDetailScreen() { <MarkdownView markdown={artifact.body} /> ) : ( <Text style={styles.emptyBody}> - No content + {t('artifacts.noContent')} </Text> )} </View> @@ -276,4 +297,4 @@ export default function ArtifactDetailScreen() { </View> </> ); -} \ No newline at end of file +} diff --git a/expo-app/sources/app/(app)/artifacts/edit/[id].tsx b/expo-app/sources/app/(app)/artifacts/edit/[id].tsx index 7d25ed6f0..d51cf2af5 100644 --- a/expo-app/sources/app/(app)/artifacts/edit/[id].tsx +++ b/expo-app/sources/app/(app)/artifacts/edit/[id].tsx @@ -205,15 +205,38 @@ export default function EditArtifactScreen() { }, default: {}, }); + + const loadingTitle = t('artifacts.loading'); + const errorTitle = t('common.error'); + const headerTitle = t('artifacts.edit'); + + const loadingScreenOptions = React.useMemo(() => { + return { + headerShown: true, + headerTitle: loadingTitle, + } as const; + }, [loadingTitle]); + + const errorScreenOptions = React.useMemo(() => { + return { + headerShown: true, + headerTitle: errorTitle, + } as const; + }, [errorTitle]); + + const screenOptions = React.useMemo(() => { + return { + headerShown: true, + headerTitle, + headerRight: HeaderRight, + } as const; + }, [HeaderRight, headerTitle]); if (isLoading) { return ( <View style={styles.container}> <Stack.Screen - options={{ - headerShown: true, - headerTitle: t('artifacts.loading'), - }} + options={loadingScreenOptions} /> <View style={styles.loadingContainer}> <ActivityIndicator size="large" /> @@ -226,10 +249,7 @@ export default function EditArtifactScreen() { return ( <View style={styles.container}> <Stack.Screen - options={{ - headerShown: true, - headerTitle: t('common.error'), - }} + options={errorScreenOptions} /> <View style={styles.errorContainer}> <Text style={styles.errorText}> @@ -243,11 +263,7 @@ export default function EditArtifactScreen() { return ( <> <Stack.Screen - options={{ - headerShown: true, - headerTitle: t('artifacts.edit'), - headerRight: HeaderRight, - }} + options={screenOptions} /> <View style={styles.container}> <KeyboardWrapper {...keyboardProps}> @@ -315,4 +331,4 @@ export default function EditArtifactScreen() { </View> </> ); -} \ No newline at end of file +} diff --git a/expo-app/sources/app/(app)/artifacts/new.tsx b/expo-app/sources/app/(app)/artifacts/new.tsx index 7e6610011..dcfd1b34b 100644 --- a/expo-app/sources/app/(app)/artifacts/new.tsx +++ b/expo-app/sources/app/(app)/artifacts/new.tsx @@ -140,15 +140,20 @@ export default function NewArtifactScreen() { }, default: {}, }); + + const headerTitle = t('artifacts.new'); + const screenOptions = React.useMemo(() => { + return { + headerShown: true, + headerTitle, + headerRight: HeaderRight, + } as const; + }, [HeaderRight, headerTitle]); return ( <> <Stack.Screen - options={{ - headerShown: true, - headerTitle: t('artifacts.new'), - headerRight: HeaderRight, - }} + options={screenOptions} /> <View style={styles.container}> <KeyboardWrapper {...keyboardProps}> @@ -216,4 +221,4 @@ export default function NewArtifactScreen() { </View> </> ); -} \ No newline at end of file +} diff --git a/expo-app/sources/app/(app)/dev/colors.tsx b/expo-app/sources/app/(app)/dev/colors.tsx index 83691cfcb..9936cb76b 100644 --- a/expo-app/sources/app/(app)/dev/colors.tsx +++ b/expo-app/sources/app/(app)/dev/colors.tsx @@ -1,5 +1,6 @@ import * as React from 'react'; -import { ScrollView, View, Text, StyleSheet } from 'react-native'; +import { ScrollView, View, Text } from 'react-native'; +import { StyleSheet } from 'react-native-unistyles'; import { Typography } from '@/constants/Typography'; const ColorSwatch = ({ name, color, textColor = '#000' }: { name: string; color: string; textColor?: string }) => ( @@ -194,4 +195,4 @@ const styles = StyleSheet.create({ colorItemTextDark: { color: '#111827', }, -}); \ No newline at end of file +}); diff --git a/expo-app/sources/app/(app)/dev/device-info.tsx b/expo-app/sources/app/(app)/dev/device-info.tsx index 3ea828c76..2f23a9b97 100644 --- a/expo-app/sources/app/(app)/dev/device-info.tsx +++ b/expo-app/sources/app/(app)/dev/device-info.tsx @@ -3,9 +3,9 @@ import { View, Text, ScrollView, Dimensions, Platform, PixelRatio } from 'react- import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { Stack } from 'expo-router'; import { Typography } from '@/constants/Typography'; -import { ItemGroup } from '@/components/ItemGroup'; -import { Item } from '@/components/Item'; -import { ItemList } from '@/components/ItemList'; +import { ItemGroup } from '@/components/ui/lists/ItemGroup'; +import { Item } from '@/components/ui/lists/Item'; +import { ItemList } from '@/components/ui/lists/ItemList'; import Constants from 'expo-constants'; import { useIsTablet, getDeviceType, calculateDeviceDimensions, useHeaderHeight } from '@/utils/responsive'; import { layout } from '@/components/layout'; @@ -29,14 +29,18 @@ export default function DeviceInfo() { }); const { widthInches, heightInches, diagonalInches } = dimensions; + + const screenOptions = React.useMemo(() => { + return { + title: 'Device Info', + headerLargeTitle: false, + } as const; + }, []); return ( <> <Stack.Screen - options={{ - title: 'Device Info', - headerLargeTitle: false, - }} + options={screenOptions} /> <ItemList> <ItemGroup title="Safe Area Insets"> @@ -180,4 +184,4 @@ export default function DeviceInfo() { </ItemList> </> ); -} \ No newline at end of file +} diff --git a/expo-app/sources/app/(app)/dev/expo-constants.tsx b/expo-app/sources/app/(app)/dev/expo-constants.tsx index c1c68bcfa..e949411ac 100644 --- a/expo-app/sources/app/(app)/dev/expo-constants.tsx +++ b/expo-app/sources/app/(app)/dev/expo-constants.tsx @@ -4,9 +4,9 @@ import { Stack } from 'expo-router'; import Constants from 'expo-constants'; import * as Updates from 'expo-updates'; import { Ionicons } from '@expo/vector-icons'; -import { Item } from '@/components/Item'; -import { ItemGroup } from '@/components/ItemGroup'; -import { ItemList } from '@/components/ItemList'; +import { Item } from '@/components/ui/lists/Item'; +import { ItemGroup } from '@/components/ui/lists/ItemGroup'; +import { ItemList } from '@/components/ui/lists/ItemList'; import { Typography } from '@/constants/Typography'; import * as Clipboard from 'expo-clipboard'; import { Modal } from '@/modal'; @@ -180,14 +180,18 @@ export default function ExpoConstantsScreen() { // Check if running embedded update const isEmbedded = ExpoUpdates?.isEmbeddedLaunch; + + const screenOptions = React.useMemo(() => { + return { + title: 'Expo Constants', + headerLargeTitle: false, + } as const; + }, []); return ( <> <Stack.Screen - options={{ - title: 'Expo Constants', - headerLargeTitle: false, - }} + options={screenOptions} /> <ItemList> {/* Main Configuration */} diff --git a/expo-app/sources/app/(app)/dev/index.tsx b/expo-app/sources/app/(app)/dev/index.tsx index 2f3283ea3..0a47810b8 100644 --- a/expo-app/sources/app/(app)/dev/index.tsx +++ b/expo-app/sources/app/(app)/dev/index.tsx @@ -1,9 +1,9 @@ import * as React from 'react'; import { ActivityIndicator } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; -import { Item } from '@/components/Item'; -import { ItemGroup } from '@/components/ItemGroup'; -import { ItemList } from '@/components/ItemList'; +import { Item } from '@/components/ui/lists/Item'; +import { ItemGroup } from '@/components/ui/lists/ItemGroup'; +import { ItemList } from '@/components/ui/lists/ItemList'; import { useRouter } from 'expo-router'; import Constants from 'expo-constants'; import * as Application from 'expo-application'; diff --git a/expo-app/sources/app/(app)/dev/inverted-list.tsx b/expo-app/sources/app/(app)/dev/inverted-list.tsx index 4d08f39cd..d5291c706 100644 --- a/expo-app/sources/app/(app)/dev/inverted-list.tsx +++ b/expo-app/sources/app/(app)/dev/inverted-list.tsx @@ -1,5 +1,6 @@ import React, { useState } from 'react'; -import { View, Text, FlatList, TextInput, KeyboardAvoidingView, Platform, TouchableOpacity, ScrollView, StyleSheet } from 'react-native'; +import { View, Text, FlatList, TextInput, KeyboardAvoidingView, Platform, TouchableOpacity, ScrollView } from 'react-native'; +import { StyleSheet } from 'react-native-unistyles'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { Stack } from 'expo-router'; import { useKeyboardHandler, useKeyboardState, useReanimatedKeyboardAnimation } from 'react-native-keyboard-controller'; @@ -53,12 +54,16 @@ export default function InvertedListTest() { </View> ); + const screenOptions = React.useMemo(() => { + return { + headerTitle: 'Inverted List Test', + } as const; + }, []); + return ( <> <Stack.Screen - options={{ - headerTitle: 'Inverted List Test', - }} + options={screenOptions} /> <Animated.View style={[styles.container, { transform: [{ translateY: height }] }]}> @@ -292,4 +297,4 @@ const styles = StyleSheet.create({ color: 'white', fontWeight: '600', }, -}); \ No newline at end of file +}); diff --git a/expo-app/sources/app/(app)/dev/list-demo.tsx b/expo-app/sources/app/(app)/dev/list-demo.tsx index ccfa3aac5..654d67447 100644 --- a/expo-app/sources/app/(app)/dev/list-demo.tsx +++ b/expo-app/sources/app/(app)/dev/list-demo.tsx @@ -1,8 +1,8 @@ import * as React from 'react'; import { Ionicons } from '@expo/vector-icons'; -import { Item } from '@/components/Item'; -import { ItemGroup } from '@/components/ItemGroup'; -import { ItemList } from '@/components/ItemList'; +import { Item } from '@/components/ui/lists/Item'; +import { ItemGroup } from '@/components/ui/lists/ItemGroup'; +import { ItemList } from '@/components/ui/lists/ItemList'; import { Switch } from '@/components/Switch'; export default function ListDemoScreen() { diff --git a/expo-app/sources/app/(app)/dev/logs.tsx b/expo-app/sources/app/(app)/dev/logs.tsx index 3e12c7f29..f7cb43534 100644 --- a/expo-app/sources/app/(app)/dev/logs.tsx +++ b/expo-app/sources/app/(app)/dev/logs.tsx @@ -2,9 +2,9 @@ import * as React from 'react'; import { View, Text, FlatList, Pressable } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { log } from '@/log'; -import { ItemGroup } from '@/components/ItemGroup'; -import { ItemList } from '@/components/ItemList'; -import { Item } from '@/components/Item'; +import { ItemGroup } from '@/components/ui/lists/ItemGroup'; +import { ItemList } from '@/components/ui/lists/ItemList'; +import { Item } from '@/components/ui/lists/Item'; import * as Clipboard from 'expo-clipboard'; import { Modal } from '@/modal'; diff --git a/expo-app/sources/app/(app)/dev/messages-demo-data.ts b/expo-app/sources/app/(app)/dev/messages-demo-data.ts index 940ce99cb..1ff03e523 100644 --- a/expo-app/sources/app/(app)/dev/messages-demo-data.ts +++ b/expo-app/sources/app/(app)/dev/messages-demo-data.ts @@ -458,4 +458,4 @@ export const NewComponent: React.FC<NewComponentProps> = ({ title, description } } ] } -]; \ No newline at end of file +]; diff --git a/expo-app/sources/app/(app)/dev/modal-demo.tsx b/expo-app/sources/app/(app)/dev/modal-demo.tsx index 24734d641..d1c941fb6 100644 --- a/expo-app/sources/app/(app)/dev/modal-demo.tsx +++ b/expo-app/sources/app/(app)/dev/modal-demo.tsx @@ -1,8 +1,9 @@ import React from 'react'; -import { View, Text, StyleSheet, ScrollView, Platform } from 'react-native'; -import { Item } from '@/components/Item'; -import { ItemGroup } from '@/components/ItemGroup'; -import { ItemList } from '@/components/ItemList'; +import { View, Text, ScrollView, Platform } from 'react-native'; +import { StyleSheet } from 'react-native-unistyles'; +import { Item } from '@/components/ui/lists/Item'; +import { ItemGroup } from '@/components/ui/lists/ItemGroup'; +import { ItemList } from '@/components/ui/lists/ItemList'; import { Modal } from '@/modal'; import { Typography } from '@/constants/Typography'; import { RoundButton } from '@/components/RoundButton'; @@ -208,4 +209,4 @@ const styles = StyleSheet.create({ customModalButtons: { width: '100%' } -}); \ No newline at end of file +}); diff --git a/expo-app/sources/app/(app)/dev/purchases.tsx b/expo-app/sources/app/(app)/dev/purchases.tsx index 02a9aee2c..b21b295fc 100644 --- a/expo-app/sources/app/(app)/dev/purchases.tsx +++ b/expo-app/sources/app/(app)/dev/purchases.tsx @@ -1,9 +1,9 @@ import * as React from 'react'; import { View, Text, TextInput, ActivityIndicator } from 'react-native'; import { Stack } from 'expo-router'; -import { Item } from '@/components/Item'; -import { ItemGroup } from '@/components/ItemGroup'; -import { ItemList } from '@/components/ItemList'; +import { Item } from '@/components/ui/lists/Item'; +import { ItemGroup } from '@/components/ui/lists/ItemGroup'; +import { ItemList } from '@/components/ui/lists/ItemList'; import { storage } from '@/sync/storage'; import { sync } from '@/sync/sync'; import { Typography } from '@/constants/Typography'; @@ -80,13 +80,17 @@ export default function PurchasesDevScreen() { } }; + const screenOptions = React.useMemo(() => { + return { + title: 'Purchases', + headerShown: true, + } as const; + }, []); + return ( <> <Stack.Screen - options={{ - title: 'Purchases', - headerShown: true - }} + options={screenOptions} /> <ItemList> diff --git a/expo-app/sources/app/(app)/dev/shimmer-demo.tsx b/expo-app/sources/app/(app)/dev/shimmer-demo.tsx index 1ae5620da..d028ac643 100644 --- a/expo-app/sources/app/(app)/dev/shimmer-demo.tsx +++ b/expo-app/sources/app/(app)/dev/shimmer-demo.tsx @@ -1,17 +1,22 @@ import React from 'react'; -import { View, Text, ScrollView, StyleSheet } from 'react-native'; +import { View, Text, ScrollView } from 'react-native'; +import { StyleSheet } from 'react-native-unistyles'; import { Stack } from 'expo-router'; import { ShimmerView } from '@/components/ShimmerView'; -import { ItemGroup } from '@/components/ItemGroup'; +import { ItemGroup } from '@/components/ui/lists/ItemGroup'; import { Ionicons } from '@expo/vector-icons'; export default function ShimmerDemoScreen() { + const screenOptions = React.useMemo(() => { + return { + headerTitle: 'Shimmer View Demo', + } as const; + }, []); + return ( <> <Stack.Screen - options={{ - headerTitle: 'Shimmer View Demo', - }} + options={screenOptions} /> <ScrollView style={styles.container}> @@ -272,4 +277,4 @@ const styles = StyleSheet.create({ fontWeight: 'bold', color: '#333', }, -}); \ No newline at end of file +}); diff --git a/expo-app/sources/app/(app)/dev/tests.tsx b/expo-app/sources/app/(app)/dev/tests.tsx index e3fb8dc0a..4eddb45eb 100644 --- a/expo-app/sources/app/(app)/dev/tests.tsx +++ b/expo-app/sources/app/(app)/dev/tests.tsx @@ -1,8 +1,8 @@ import * as React from 'react'; import { View, ScrollView, Text, ActivityIndicator } from 'react-native'; -import { Item } from '@/components/Item'; -import { ItemGroup } from '@/components/ItemGroup'; -import { ItemList } from '@/components/ItemList'; +import { Item } from '@/components/ui/lists/Item'; +import { ItemGroup } from '@/components/ui/lists/ItemGroup'; +import { ItemList } from '@/components/ui/lists/ItemList'; import { testRunner, TestSuite, TestResult } from '@/dev/testRunner'; import { Ionicons } from '@expo/vector-icons'; import { Typography } from '@/constants/Typography'; diff --git a/expo-app/sources/app/(app)/dev/todo-demo.tsx b/expo-app/sources/app/(app)/dev/todo-demo.tsx index 716286683..7cf6683f7 100644 --- a/expo-app/sources/app/(app)/dev/todo-demo.tsx +++ b/expo-app/sources/app/(app)/dev/todo-demo.tsx @@ -1,9 +1,9 @@ import * as React from 'react'; import { TodoView } from "@/-zen/components/TodoView"; import { Button, ScrollView, TextInput, View } from "react-native"; -import { randomUUID } from 'expo-crypto'; -import { ItemList } from '@/components/ItemList'; -import { ItemGroup } from '@/components/ItemGroup'; +import { randomUUID } from '@/platform/randomUUID'; +import { ItemList } from '@/components/ui/lists/ItemList'; +import { ItemGroup } from '@/components/ui/lists/ItemGroup'; import { layout } from '@/components/layout'; import { TodoList } from '@/-zen/components/TodoList'; diff --git a/expo-app/sources/app/(app)/dev/tools2.tsx b/expo-app/sources/app/(app)/dev/tools2.tsx index 25e9e3678..96296d16c 100644 --- a/expo-app/sources/app/(app)/dev/tools2.tsx +++ b/expo-app/sources/app/(app)/dev/tools2.tsx @@ -1,9 +1,10 @@ import React, { useState } from 'react'; -import { View, Text, ScrollView, StyleSheet } from 'react-native'; +import { View, Text, ScrollView } from 'react-native'; import { Stack } from 'expo-router'; import { ToolView } from '@/components/tools/ToolView'; -import { ItemGroup } from '@/components/ItemGroup'; -import { Item } from '@/components/Item'; +import { ItemGroup } from '@/components/ui/lists/ItemGroup'; +import { Item } from '@/components/ui/lists/Item'; +import { StyleSheet } from 'react-native-unistyles'; export default function Tools2Screen() { const [selectedExample, setSelectedExample] = useState<string>('all'); @@ -375,12 +376,16 @@ export function formatTime(date: Date): string { ); }; + const screenOptions = React.useMemo(() => { + return { + headerTitle: 'Tool Views Demo', + } as const; + }, []); + return ( <> <Stack.Screen - options={{ - headerTitle: 'Tool Views Demo', - }} + options={screenOptions} /> <ScrollView style={styles.container}> @@ -553,4 +558,4 @@ const styles = StyleSheet.create({ marginBottom: 16, lineHeight: 20, }, -}); \ No newline at end of file +}); diff --git a/expo-app/sources/app/(app)/dev/typography.tsx b/expo-app/sources/app/(app)/dev/typography.tsx index 0699bb371..49f009533 100644 --- a/expo-app/sources/app/(app)/dev/typography.tsx +++ b/expo-app/sources/app/(app)/dev/typography.tsx @@ -1,8 +1,9 @@ import * as React from 'react'; -import { ScrollView, View, Text, StyleSheet } from 'react-native'; +import { ScrollView, View, Text } from 'react-native'; +import { StyleSheet } from 'react-native-unistyles'; import { Typography } from '@/constants/Typography'; -import { Item } from '@/components/Item'; -import { ItemGroup } from '@/components/ItemGroup'; +import { Item } from '@/components/ui/lists/Item'; +import { ItemGroup } from '@/components/ui/lists/ItemGroup'; const TextSample = ({ title, style, text = "The quick brown fox jumps over the lazy dog" }: { title: string; style: any; text?: string }) => ( <View style={styles.sampleContainer}> @@ -174,4 +175,4 @@ const styles = StyleSheet.create({ padding: 16, borderRadius: 8, }, -}); \ No newline at end of file +}); diff --git a/expo-app/sources/app/(app)/friends/index.tsx b/expo-app/sources/app/(app)/friends/index.tsx index 719597f91..3fe7181b5 100644 --- a/expo-app/sources/app/(app)/friends/index.tsx +++ b/expo-app/sources/app/(app)/friends/index.tsx @@ -8,12 +8,14 @@ import { useAuth } from '@/auth/AuthContext'; import { storage } from '@/sync/storage'; import { Modal } from '@/modal'; import { t } from '@/text'; -import { ItemList } from '@/components/ItemList'; -import { ItemGroup } from '@/components/ItemGroup'; +import { ItemList } from '@/components/ui/lists/ItemList'; +import { ItemGroup } from '@/components/ui/lists/ItemGroup'; import { useHappyAction } from '@/hooks/useHappyAction'; import { useRouter } from 'expo-router'; +import { useRequireInboxFriendsEnabled } from '@/hooks/useRequireInboxFriendsEnabled'; export default function FriendsScreen() { + const enabled = useRequireInboxFriendsEnabled(); const { credentials } = useAuth(); const router = useRouter(); const friends = useAcceptedFriends(); @@ -92,6 +94,8 @@ export default function FriendsScreen() { const isProcessing = (id: string) => processingId === id && (acceptLoading || rejectLoading || removeLoading); + if (!enabled) return null; + return ( <ItemList style={{ paddingTop: 0 }}> {/* Friend Requests Section */} @@ -164,4 +168,4 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.textSecondary, textAlign: 'center', }, -})); \ No newline at end of file +})); diff --git a/expo-app/sources/app/(app)/friends/search.tsx b/expo-app/sources/app/(app)/friends/search.tsx index 7f419a606..ee26aec04 100644 --- a/expo-app/sources/app/(app)/friends/search.tsx +++ b/expo-app/sources/app/(app)/friends/search.tsx @@ -8,17 +8,21 @@ import { UserProfile } from '@/sync/friendTypes'; import { Modal } from '@/modal'; import { t } from '@/text'; import { trackFriendsConnect } from '@/track'; -import { ItemList } from '@/components/ItemList'; -import { ItemGroup } from '@/components/ItemGroup'; +import { ItemList } from '@/components/ui/lists/ItemList'; +import { ItemGroup } from '@/components/ui/lists/ItemGroup'; import { useSearch } from '@/hooks/useSearch'; +import { useRequireInboxFriendsEnabled } from '@/hooks/useRequireInboxFriendsEnabled'; export default function SearchFriendsScreen() { + const enabled = useRequireInboxFriendsEnabled(); const { credentials } = useAuth(); const [searchQuery, setSearchQuery] = useState(''); const [processingUserId, setProcessingUserId] = useState<string | null>(null); + if (!enabled) return null; + // Use the new search hook - const { results: searchResults, isSearching } = useSearch( + const { results: searchResults, isSearching, error: searchError } = useSearch( searchQuery, useCallback((query: string) => { if (!credentials) { @@ -66,6 +70,8 @@ export default function SearchFriendsScreen() { ); const hasSearched = searchQuery.trim().length > 0; + const searchErrorText = + searchError === 'searchFailed' ? t('errors.searchFailed') : null; return ( <KeyboardAvoidingView @@ -99,6 +105,9 @@ export default function SearchFriendsScreen() { </View> )} </View> + {searchErrorText ? ( + <Text style={styles.errorText}>{searchErrorText}</Text> + ) : null} </ItemGroup> <ItemGroup @@ -175,6 +184,12 @@ const styles = StyleSheet.create((theme) => ({ bottom: 0, justifyContent: 'center', }, + errorText: { + paddingHorizontal: 16, + paddingTop: 6, + fontSize: 13, + color: theme.colors.status.error, + }, resultsGroup: { marginBottom: 16, }, @@ -229,4 +244,4 @@ const styles = StyleSheet.create((theme) => ({ textAlign: 'center', lineHeight: 22, }, -})); \ No newline at end of file +})); diff --git a/expo-app/sources/app/(app)/inbox/index.tsx b/expo-app/sources/app/(app)/inbox/index.tsx index ba5e1f82b..b2113a207 100644 --- a/expo-app/sources/app/(app)/inbox/index.tsx +++ b/expo-app/sources/app/(app)/inbox/index.tsx @@ -9,6 +9,7 @@ import { useRouter } from 'expo-router'; import { Ionicons } from '@expo/vector-icons'; import { Typography } from '@/constants/Typography'; import { t } from '@/text'; +import { useRequireInboxFriendsEnabled } from '@/hooks/useRequireInboxFriendsEnabled'; const styles = StyleSheet.create((theme) => ({ container: { @@ -73,12 +74,15 @@ const styles = StyleSheet.create((theme) => ({ })); export default function InboxPage() { + const enabled = useRequireInboxFriendsEnabled(); const { theme } = useUnistyles(); const insets = useSafeAreaInsets(); const isTablet = useIsTablet(); const router = useRouter(); const headerHeight = useHeaderHeight(); + if (!enabled) return null; + // Calculate gradient height: safe area + some extra for the fade effect const gradientHeight = insets.top + 40; @@ -121,4 +125,4 @@ export default function InboxPage() { return ( <InboxView /> ); -} \ No newline at end of file +} diff --git a/expo-app/sources/app/(app)/index.tsx b/expo-app/sources/app/(app)/index.tsx index 8f11dfa55..98382a107 100644 --- a/expo-app/sources/app/(app)/index.tsx +++ b/expo-app/sources/app/(app)/index.tsx @@ -7,7 +7,7 @@ import { encodeBase64 } from "@/encryption/base64"; import { authGetToken } from "@/auth/authGetToken"; import { router, useRouter } from "expo-router"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; -import { getRandomBytesAsync } from "expo-crypto"; +import { getRandomBytesAsync } from "@/platform/cryptoRandom"; import { useIsLandscape } from "@/utils/responsive"; import { Typography } from "@/constants/Typography"; import { trackAccountCreated, trackAccountRestored } from '@/track'; diff --git a/expo-app/sources/app/(app)/machine/[id].tsx b/expo-app/sources/app/(app)/machine/[id].tsx index bb014796a..8927a6849 100644 --- a/expo-app/sources/app/(app)/machine/[id].tsx +++ b/expo-app/sources/app/(app)/machine/[id].tsx @@ -1,14 +1,19 @@ import React, { useState, useMemo, useCallback, useRef } from 'react'; import { View, Text, ScrollView, ActivityIndicator, RefreshControl, Platform, Pressable, TextInput } from 'react-native'; import { useLocalSearchParams, useRouter, Stack } from 'expo-router'; -import { Item } from '@/components/Item'; -import { ItemGroup } from '@/components/ItemGroup'; -import { ItemList } from '@/components/ItemList'; +import { Item } from '@/components/ui/lists/Item'; +import { ItemGroup } from '@/components/ui/lists/ItemGroup'; +import { ItemGroupTitleWithAction } from '@/components/ui/lists/ItemGroupTitleWithAction'; +import { ItemList } from '@/components/ui/lists/ItemList'; import { Typography } from '@/constants/Typography'; -import { useSessions, useAllMachines, useMachine } from '@/sync/storage'; +import { useSessions, useAllMachines, useMachine, storage, useSetting, useSettingMutable, useSettings } from '@/sync/storage'; import { Ionicons, Octicons } from '@expo/vector-icons'; import type { Session } from '@/sync/storageTypes'; -import { machineStopDaemon, machineUpdateMetadata } from '@/sync/ops'; +import { + machineSpawnNewSession, + machineStopDaemon, + machineUpdateMetadata, +} from '@/sync/ops'; import { Modal } from '@/modal'; import { formatPathRelativeToHome, getSessionName, getSessionSubtitle } from '@/utils/sessionUtils'; import { isMachineOnline } from '@/utils/machineUtils'; @@ -16,9 +21,15 @@ import { sync } from '@/sync/sync'; import { useUnistyles, StyleSheet } from 'react-native-unistyles'; import { t } from '@/text'; import { useNavigateToSession } from '@/hooks/useNavigateToSession'; -import { machineSpawnNewSession } from '@/sync/ops'; import { resolveAbsolutePath } from '@/utils/pathUtils'; import { MultiTextInput, type MultiTextInputHandle } from '@/components/MultiTextInput'; +import { DetectedClisList } from '@/components/machines/DetectedClisList'; +import { useMachineCapabilitiesCache } from '@/hooks/useMachineCapabilitiesCache'; +import { resolveTerminalSpawnOptions } from '@/sync/terminalSettings'; +import { Switch } from '@/components/Switch'; +import { CAPABILITIES_REQUEST_MACHINE_DETAILS } from '@/capabilities/requests'; +import { InstallableDepInstaller } from '@/components/machines/InstallableDepInstaller'; +import { getInstallableDepRegistryEntries } from '@/capabilities/installableDepsRegistry'; const styles = StyleSheet.create((theme) => ({ pathInputContainer: { @@ -60,6 +71,39 @@ const styles = StyleSheet.create((theme) => ({ default: theme.colors.permissionButton?.inactive?.background ?? theme.colors.surfaceHigh, }) as any, }, + tmuxInputContainer: { + paddingHorizontal: 16, + paddingVertical: 12, + }, + tmuxFieldLabel: { + ...Typography.default('semiBold'), + fontSize: 13, + color: theme.colors.groupped.sectionTitle, + marginBottom: 4, + }, + tmuxTextInput: { + ...Typography.default('regular'), + backgroundColor: theme.colors.input.background, + borderRadius: 10, + paddingHorizontal: 12, + paddingVertical: Platform.select({ ios: 10, default: 12 }), + fontSize: Platform.select({ ios: 17, default: 16 }), + lineHeight: Platform.select({ ios: 22, default: 24 }), + letterSpacing: Platform.select({ ios: -0.41, default: 0.15 }), + color: theme.colors.input.text, + ...(Platform.select({ + web: { + outline: 'none', + outlineStyle: 'none', + outlineWidth: 0, + outlineColor: 'transparent', + boxShadow: 'none', + WebkitBoxShadow: 'none', + WebkitAppearance: 'none', + }, + default: {}, + }) as object), + }, })); export default function MachineDetailScreen() { @@ -76,7 +120,87 @@ export default function MachineDetailScreen() { const [isSpawning, setIsSpawning] = useState(false); const inputRef = useRef<MultiTextInputHandle>(null); const [showAllPaths, setShowAllPaths] = useState(false); - // Variant D only + const isOnline = !!machine && isMachineOnline(machine); + const metadata = machine?.metadata; + + const terminalUseTmux = useSetting('sessionUseTmux'); + const terminalTmuxSessionName = useSetting('sessionTmuxSessionName'); + const terminalTmuxIsolated = useSetting('sessionTmuxIsolated'); + const terminalTmuxTmpDir = useSetting('sessionTmuxTmpDir'); + const [terminalTmuxByMachineId, setTerminalTmuxByMachineId] = useSettingMutable('sessionTmuxByMachineId'); + const settings = useSettings(); + const experimentsEnabled = settings.experiments === true; + + const { state: detectedCapabilities, refresh: refreshDetectedCapabilities } = useMachineCapabilitiesCache({ + machineId: machineId ?? null, + enabled: Boolean(machineId && isOnline), + request: CAPABILITIES_REQUEST_MACHINE_DETAILS, + }); + + const tmuxOverride = machineId ? terminalTmuxByMachineId?.[machineId] : undefined; + const tmuxOverrideEnabled = Boolean(tmuxOverride); + + const tmuxAvailable = React.useMemo(() => { + const snapshot = + detectedCapabilities.status === 'loaded' + ? detectedCapabilities.snapshot + : detectedCapabilities.status === 'loading' + ? detectedCapabilities.snapshot + : detectedCapabilities.status === 'error' + ? detectedCapabilities.snapshot + : undefined; + const result = snapshot?.response.results['tool.tmux']; + if (!result || !result.ok) return null; + const data = result.data as any; + return typeof data?.available === 'boolean' ? data.available : null; + }, [detectedCapabilities]); + + const setTmuxOverrideEnabled = useCallback((enabled: boolean) => { + if (!machineId) return; + if (enabled) { + setTerminalTmuxByMachineId({ + ...terminalTmuxByMachineId, + [machineId]: { + useTmux: terminalUseTmux, + sessionName: terminalTmuxSessionName, + isolated: terminalTmuxIsolated, + tmpDir: terminalTmuxTmpDir, + }, + }); + return; + } + + const next = { ...terminalTmuxByMachineId }; + delete next[machineId]; + setTerminalTmuxByMachineId(next); + }, [ + machineId, + setTerminalTmuxByMachineId, + terminalTmuxByMachineId, + terminalUseTmux, + terminalTmuxIsolated, + terminalTmuxSessionName, + terminalTmuxTmpDir, + ]); + + const updateTmuxOverride = useCallback((patch: Partial<NonNullable<typeof tmuxOverride>>) => { + if (!machineId || !tmuxOverride) return; + setTerminalTmuxByMachineId({ + ...terminalTmuxByMachineId, + [machineId]: { + ...tmuxOverride, + ...patch, + }, + }); + }, [machineId, setTerminalTmuxByMachineId, terminalTmuxByMachineId, tmuxOverride]); + + const setTmuxOverrideUseTmux = useCallback((next: boolean) => { + if (next && tmuxAvailable === false) { + Modal.alert(t('common.error'), t('machine.tmux.notDetectedMessage')); + return; + } + updateTmuxOverride({ useTmux: next }); + }, [tmuxAvailable, updateTmuxOverride]); const machineSessions = useMemo(() => { if (!sessions || !machineId) return []; @@ -113,9 +237,7 @@ export default function MachineDetailScreen() { const daemonStatus = useMemo(() => { if (!machine) return 'unknown'; - // Check metadata for daemon status - const metadata = machine.metadata as any; - if (metadata?.daemonLastKnownStatus === 'shutting-down') { + if (machine.metadata?.daemonLastKnownStatus === 'shutting-down') { return 'stopped'; } @@ -126,25 +248,25 @@ export default function MachineDetailScreen() { const handleStopDaemon = async () => { // Show confirmation modal using alert with buttons Modal.alert( - 'Stop Daemon?', - 'You will not be able to spawn new sessions on this machine until you restart the daemon on your computer again. Your current sessions will stay alive.', + t('machine.stopDaemonConfirmTitle'), + t('machine.stopDaemonConfirmBody'), [ { - text: 'Cancel', + text: t('common.cancel'), style: 'cancel' }, { - text: 'Stop Daemon', + text: t('machine.stopDaemon'), style: 'destructive', onPress: async () => { setIsStoppingDaemon(true); try { const result = await machineStopDaemon(machineId!); - Modal.alert('Daemon Stopped', result.message); + Modal.alert(t('machine.daemonStoppedTitle'), result.message); // Refresh to get updated metadata await sync.refreshMachines(); } catch (error) { - Modal.alert(t('common.error'), 'Failed to stop daemon. It may not be running.'); + Modal.alert(t('common.error'), t('machine.stopDaemonFailed')); } finally { setIsStoppingDaemon(false); } @@ -158,19 +280,116 @@ export default function MachineDetailScreen() { const handleRefresh = async () => { setIsRefreshing(true); - await sync.refreshMachines(); - setIsRefreshing(false); + try { + await sync.refreshMachines(); + refreshDetectedCapabilities(); + } finally { + setIsRefreshing(false); + } }; + const refreshCapabilities = useCallback(async () => { + if (!machineId) return; + // On direct loads/refreshes, machine encryption/socket may not be ready yet. + // Refreshing machines first makes this much more reliable and avoids misclassifying + // transient failures as “not supported / update CLI”. + await sync.refreshMachines(); + refreshDetectedCapabilities(); + }, [machineId, refreshDetectedCapabilities]); + + const capabilitiesSnapshot = useMemo(() => { + const snapshot = + detectedCapabilities.status === 'loaded' + ? detectedCapabilities.snapshot + : detectedCapabilities.status === 'loading' + ? detectedCapabilities.snapshot + : detectedCapabilities.status === 'error' + ? detectedCapabilities.snapshot + : undefined; + return snapshot ?? null; + }, [detectedCapabilities]); + + const installableDepEntries = useMemo(() => { + const entries = getInstallableDepRegistryEntries(); + const results = capabilitiesSnapshot?.response.results; + return entries.map((entry) => { + const enabledFlag = (settings as any)[entry.enabledSettingKey] === true; + const enabled = Boolean(machineId && experimentsEnabled && enabledFlag); + const depStatus = entry.getDepStatus(results); + const detectResult = entry.getDetectResult(results); + return { entry, enabled, depStatus, detectResult }; + }); + }, [capabilitiesSnapshot, experimentsEnabled, machineId, settings]); + + React.useEffect(() => { + if (!machineId) return; + if (!isOnline) return; + if (!experimentsEnabled) return; + + const results = capabilitiesSnapshot?.response.results; + if (!results) return; + + const requests = installableDepEntries + .filter((d) => d.enabled) + .filter((d) => d.entry.shouldPrefetchRegistry({ requireExistingResult: true, result: d.detectResult, data: d.depStatus })) + .flatMap((d) => d.entry.buildRegistryDetectRequest().requests ?? []); + + if (requests.length === 0) return; + + refreshDetectedCapabilities({ + request: { requests }, + timeoutMs: 12_000, + }); + }, [capabilitiesSnapshot, experimentsEnabled, installableDepEntries, isOnline, machineId, refreshDetectedCapabilities]); + + const detectedClisTitle = useMemo(() => { + const headerTextStyle = [ + Typography.default('regular'), + { + color: theme.colors.groupped.sectionTitle, + fontSize: Platform.select({ ios: 13, default: 14 }), + lineHeight: Platform.select({ ios: 18, default: 20 }), + letterSpacing: Platform.select({ ios: -0.08, default: 0.1 }), + textTransform: 'uppercase' as const, + fontWeight: Platform.select({ ios: 'normal', default: '500' }) as any, + }, + ]; + + const canRefresh = isOnline && detectedCapabilities.status !== 'loading'; + + return ( + <ItemGroupTitleWithAction + title={t('machine.detectedClis')} + titleStyle={headerTextStyle as any} + action={{ + accessibilityLabel: t('common.refresh'), + iconName: 'refresh', + iconColor: isOnline ? theme.colors.textSecondary : theme.colors.divider, + disabled: !canRefresh, + loading: detectedCapabilities.status === 'loading', + onPress: () => void refreshCapabilities(), + }} + /> + ); + }, [ + detectedCapabilities.status, + isOnline, + machine, + refreshCapabilities, + theme.colors.divider, + theme.colors.groupped.sectionTitle, + theme.colors.textSecondary, + ]); + const handleRenameMachine = async () => { if (!machine || !machineId) return; const newDisplayName = await Modal.prompt( - 'Rename Machine', - 'Give this machine a custom name. Leave empty to use the default hostname.', + t('machine.renameTitle'), + t('machine.renameDescription'), { defaultValue: machine.metadata?.displayName || '', - placeholder: machine.metadata?.host || 'Enter machine name', + placeholder: machine.metadata?.host || t('machine.renamePlaceholder'), cancelText: t('common.cancel'), confirmText: t('common.rename') } @@ -190,11 +409,11 @@ export default function MachineDetailScreen() { machine.metadataVersion ); - Modal.alert(t('common.success'), 'Machine renamed successfully'); + Modal.alert(t('common.success'), t('machine.renamedSuccess')); } catch (error) { Modal.alert( - 'Error', - error instanceof Error ? error.message : 'Failed to rename machine' + t('common.error'), + error instanceof Error ? error.message : t('machine.renameFailed') ); // Refresh to get latest state await sync.refreshMachines(); @@ -211,20 +430,33 @@ export default function MachineDetailScreen() { if (!isMachineOnline(machine)) return; setIsSpawning(true); const absolutePath = resolveAbsolutePath(pathToUse, machine?.metadata?.homeDir); + const terminal = resolveTerminalSpawnOptions({ + settings: storage.getState().settings, + machineId, + }); const result = await machineSpawnNewSession({ machineId: machineId!, directory: absolutePath, - approvedNewDirectoryCreation + approvedNewDirectoryCreation, + terminal, }); switch (result.type) { case 'success': // Dismiss machine picker & machine detail screen router.back(); router.back(); - navigateToSession(result.sessionId); + if (result.sessionId) { + navigateToSession(result.sessionId); + } else { + Modal.alert(t('common.error'), t('newSession.failedToStart')); + } break; case 'requestToApproveDirectoryCreation': { - const approved = await Modal.confirm('Create Directory?', `The directory '${result.directory}' does not exist. Would you like to create it?`, { cancelText: t('common.cancel'), confirmText: t('common.create') }); + const approved = await Modal.confirm( + t('newSession.directoryDoesNotExist'), + t('newSession.createDirectoryConfirm', { directory: result.directory }), + { cancelText: t('common.cancel'), confirmText: t('common.create') } + ); if (approved) { await handleStartSession(true); } @@ -235,7 +467,7 @@ export default function MachineDetailScreen() { break; } } catch (error) { - let errorMessage = 'Failed to start session. Make sure the daemon is running on the target machine.'; + let errorMessage = t('newSession.failedToStart'); if (error instanceof Error && !error.message.includes('Failed to spawn session')) { errorMessage = error.message; } @@ -246,87 +478,110 @@ export default function MachineDetailScreen() { }; const pastUsedRelativePath = useCallback((session: Session) => { - if (!session.metadata) return 'unknown path'; + if (!session.metadata) return t('machine.unknownPath'); return formatPathRelativeToHome(session.metadata.path, session.metadata.homeDir); }, []); + const headerBackTitle = t('machine.back'); + + const notFoundScreenOptions = React.useMemo(() => { + return { + headerShown: true, + headerTitle: '', + headerBackTitle, + } as const; + }, [headerBackTitle]); + + const machineName = + machine?.metadata?.displayName || + machine?.metadata?.host || + t('machine.unknownMachine'); + const machineIsOnline = machine ? isMachineOnline(machine) : false; + + const headerTitle = React.useCallback(() => { + if (!machine) return null; + return ( + <View> + <View style={{ flexDirection: 'row', alignItems: 'center' }}> + <Ionicons + name="desktop-outline" + size={18} + color={theme.colors.header.tint} + style={{ marginRight: 6 }} + /> + <Text style={[Typography.default('semiBold'), { fontSize: 17, color: theme.colors.header.tint }]}> + {machineName} + </Text> + </View> + <View style={{ flexDirection: 'row', alignItems: 'center', marginTop: 2 }}> + <View style={{ + width: 6, + height: 6, + borderRadius: 3, + backgroundColor: machineIsOnline ? '#34C759' : '#999', + marginRight: 4 + }} /> + <Text style={[Typography.default(), { + fontSize: 12, + color: machineIsOnline ? '#34C759' : '#999' + }]}> + {machineIsOnline ? t('status.online') : t('status.offline')} + </Text> + </View> + </View> + ); + }, [machineIsOnline, machine, machineName, theme.colors.header.tint]); + + const headerRight = React.useCallback(() => { + if (!machine) return null; + return ( + <Pressable + onPress={handleRenameMachine} + hitSlop={10} + style={{ + opacity: isRenamingMachine ? 0.5 : 1 + }} + disabled={isRenamingMachine} + > + <Octicons + name="pencil" + size={20} + color={theme.colors.text} + /> + </Pressable> + ); + }, [handleRenameMachine, isRenamingMachine, machine, theme.colors.text]); + + const screenOptions = React.useMemo(() => { + return { + headerShown: true, + headerTitle, + headerRight, + headerBackTitle, + } as const; + }, [headerBackTitle, headerRight, headerTitle]); + if (!machine) { return ( <> <Stack.Screen - options={{ - headerShown: true, - headerTitle: '', - headerBackTitle: t('machine.back') - }} + options={notFoundScreenOptions} /> <View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}> <Text style={[Typography.default(), { fontSize: 16, color: '#666' }]}> - Machine not found + {t('machine.notFound')} </Text> </View> </> ); } - const metadata = machine.metadata; - const machineName = metadata?.displayName || metadata?.host || 'unknown machine'; - const spawnButtonDisabled = !customPath.trim() || isSpawning || !isMachineOnline(machine!); return ( <> <Stack.Screen - options={{ - headerShown: true, - headerTitle: () => ( - <View style={{ alignItems: 'center' }}> - <View style={{ flexDirection: 'row', alignItems: 'center' }}> - <Ionicons - name="desktop-outline" - size={18} - color={theme.colors.header.tint} - style={{ marginRight: 6 }} - /> - <Text style={[Typography.default('semiBold'), { fontSize: 17, color: theme.colors.header.tint }]}> - {machineName} - </Text> - </View> - <View style={{ flexDirection: 'row', alignItems: 'center', marginTop: 2 }}> - <View style={{ - width: 6, - height: 6, - borderRadius: 3, - backgroundColor: isMachineOnline(machine) ? '#34C759' : '#999', - marginRight: 4 - }} /> - <Text style={[Typography.default(), { - fontSize: 12, - color: isMachineOnline(machine) ? '#34C759' : '#999' - }]}> - {isMachineOnline(machine) ? t('status.online') : t('status.offline')} - </Text> - </View> - </View> - ), - headerRight: () => ( - <Pressable - onPress={handleRenameMachine} - hitSlop={10} - style={{ - opacity: isRenamingMachine ? 0.5 : 1 - }} - disabled={isRenamingMachine} - > - <Octicons - name="pencil" - size={24} - color={theme.colors.text} - /> - </Pressable> - ), - headerBackTitle: t('machine.back') - }} + options={screenOptions} /> <ItemList refreshControl={ @@ -399,7 +654,6 @@ export default function MachineDetailScreen() { disabled={!isMachineOnline(machine)} selected={isSelected} showChevron={false} - pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} showDivider={!hideDivider} /> ); @@ -421,6 +675,121 @@ export default function MachineDetailScreen() { </> )} + {/* Machine-specific tmux override */} + {!!machineId && ( + <ItemGroup title={t('profiles.tmux.title')}> + <Item + title={t('machine.tmux.overrideTitle')} + subtitle={tmuxOverrideEnabled ? t('machine.tmux.overrideEnabledSubtitle') : t('machine.tmux.overrideDisabledSubtitle')} + rightElement={<Switch value={tmuxOverrideEnabled} onValueChange={setTmuxOverrideEnabled} />} + showChevron={false} + onPress={() => setTmuxOverrideEnabled(!tmuxOverrideEnabled)} + /> + + {tmuxOverrideEnabled && tmuxOverride && ( + <> + <Item + title={t('profiles.tmux.spawnSessionsTitle')} + subtitle={ + tmuxAvailable === false + ? t('machine.tmux.notDetectedSubtitle') + : (tmuxOverride.useTmux ? t('profiles.tmux.spawnSessionsEnabledSubtitle') : t('profiles.tmux.spawnSessionsDisabledSubtitle')) + } + rightElement={ + <Switch + value={tmuxOverride.useTmux} + onValueChange={setTmuxOverrideUseTmux} + disabled={tmuxAvailable === false && !tmuxOverride.useTmux} + /> + } + showChevron={false} + onPress={() => setTmuxOverrideUseTmux(!tmuxOverride.useTmux)} + /> + + {tmuxOverride.useTmux && ( + <> + <View style={[styles.tmuxInputContainer, { paddingTop: 0 }]}> + <Text style={styles.tmuxFieldLabel}> + {t('profiles.tmuxSession')} ({t('common.optional')}) + </Text> + <TextInput + style={styles.tmuxTextInput} + placeholder={t('profiles.tmux.sessionNamePlaceholder')} + placeholderTextColor={theme.colors.input.placeholder} + value={tmuxOverride.sessionName} + onChangeText={(value) => updateTmuxOverride({ sessionName: value })} + /> + </View> + + <Item + title={t('profiles.tmux.isolatedServerTitle')} + subtitle={tmuxOverride.isolated ? t('profiles.tmux.isolatedServerEnabledSubtitle') : t('profiles.tmux.isolatedServerDisabledSubtitle')} + rightElement={<Switch value={tmuxOverride.isolated} onValueChange={(next) => updateTmuxOverride({ isolated: next })} />} + showChevron={false} + onPress={() => updateTmuxOverride({ isolated: !tmuxOverride.isolated })} + /> + + {tmuxOverride.isolated && ( + <View style={[styles.tmuxInputContainer, { paddingTop: 0, paddingBottom: 16 }]}> + <Text style={styles.tmuxFieldLabel}> + {t('profiles.tmuxTempDir')} ({t('common.optional')}) + </Text> + <TextInput + style={styles.tmuxTextInput} + placeholder={t('profiles.tmux.tempDirPlaceholder')} + placeholderTextColor={theme.colors.input.placeholder} + value={tmuxOverride.tmpDir ?? ''} + onChangeText={(value) => updateTmuxOverride({ tmpDir: value.trim().length > 0 ? value : null })} + autoCapitalize="none" + autoCorrect={false} + /> + </View> + )} + </> + )} + </> + )} + </ItemGroup> + )} + + {/* Detected CLIs */} + <ItemGroup title={detectedClisTitle}> + <DetectedClisList state={detectedCapabilities} /> + </ItemGroup> + + {installableDepEntries.map(({ entry, enabled, depStatus }) => ( + <InstallableDepInstaller + key={entry.key} + machineId={machineId ?? ''} + enabled={enabled} + groupTitle={`${t(entry.groupTitleKey)}${entry.experimental ? ' (experimental)' : ''}`} + depId={entry.depId} + depTitle={entry.depTitle} + depIconName={entry.depIconName as any} + depStatus={depStatus} + capabilitiesStatus={detectedCapabilities.status} + installSpecSettingKey={entry.installSpecSettingKey} + installSpecTitle={entry.installSpecTitle} + installSpecDescription={entry.installSpecDescription} + installLabels={{ + install: t(entry.installLabels.installKey), + update: t(entry.installLabels.updateKey), + reinstall: t(entry.installLabels.reinstallKey), + }} + installModal={{ + installTitle: t(entry.installModal.installTitleKey), + updateTitle: t(entry.installModal.updateTitleKey), + reinstallTitle: t(entry.installModal.reinstallTitleKey), + description: t(entry.installModal.descriptionKey), + }} + refreshStatus={() => void refreshCapabilities()} + refreshRegistry={() => { + if (!machineId) return; + refreshDetectedCapabilities({ request: entry.buildRegistryDetectRequest(), timeoutMs: 12_000 }); + }} + /> + ))} + {/* Daemon */} <ItemGroup title={t('machine.daemon')}> <Item diff --git a/expo-app/sources/app/(app)/new/index.tsx b/expo-app/sources/app/(app)/new/index.tsx index 783dc2a19..10bb852c3 100644 --- a/expo-app/sources/app/(app)/new/index.tsx +++ b/expo-app/sources/app/(app)/new/index.tsx @@ -1,1953 +1,35 @@ import React from 'react'; -import { View, Text, Platform, Pressable, useWindowDimensions, ScrollView, TextInput } from 'react-native'; -import Constants from 'expo-constants'; -import { Typography } from '@/constants/Typography'; -import { useAllMachines, storage, useSetting, useSettingMutable, useSessions } from '@/sync/storage'; -import { Ionicons, Octicons } from '@expo/vector-icons'; -import { ItemGroup } from '@/components/ItemGroup'; -import { Item } from '@/components/Item'; -import { useRouter, useLocalSearchParams } from 'expo-router'; -import { useUnistyles } from 'react-native-unistyles'; -import { layout } from '@/components/layout'; -import { t } from '@/text'; -import { KeyboardAvoidingView } from 'react-native-keyboard-controller'; -import { useHeaderHeight } from '@/utils/responsive'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { machineSpawnNewSession } from '@/sync/ops'; -import { Modal } from '@/modal'; -import { sync } from '@/sync/sync'; -import { SessionTypeSelector } from '@/components/SessionTypeSelector'; -import { createWorktree } from '@/utils/createWorktree'; -import { getTempData, type NewSessionData } from '@/utils/tempDataStore'; -import { linkTaskToSession } from '@/-zen/model/taskSessionLink'; -import { PermissionMode, ModelMode, PermissionModeSelector } from '@/components/PermissionModeSelector'; -import { AIBackendProfile, getProfileEnvironmentVariables, validateProfileForAgent } from '@/sync/settings'; -import { getBuiltInProfile, DEFAULT_PROFILES } from '@/sync/profileUtils'; -import { AgentInput } from '@/components/AgentInput'; -import { StyleSheet } from 'react-native-unistyles'; -import { randomUUID } from 'expo-crypto'; -import { useCLIDetection } from '@/hooks/useCLIDetection'; -import { useEnvironmentVariables, resolveEnvVarSubstitution, extractEnvVarReferences } from '@/hooks/useEnvironmentVariables'; -import { formatPathRelativeToHome } from '@/utils/sessionUtils'; -import { resolveAbsolutePath } from '@/utils/pathUtils'; -import { MultiTextInput } from '@/components/MultiTextInput'; -import { isMachineOnline } from '@/utils/machineUtils'; -import { StatusDot } from '@/components/StatusDot'; -import { SearchableListSelector, SelectorConfig } from '@/components/SearchableListSelector'; -import { clearNewSessionDraft, loadNewSessionDraft, saveNewSessionDraft } from '@/sync/persistence'; +import { View } from 'react-native'; -// Simple temporary state for passing selections back from picker screens -let onMachineSelected: (machineId: string) => void = () => { }; -let onProfileSaved: (profile: AIBackendProfile) => void = () => { }; +import { PopoverBoundaryProvider, PopoverPortalTargetProvider } from '@/components/ui/popover'; +import { NewSessionSimplePanel } from '@/components/sessions/new/components/NewSessionSimplePanel'; +import { NewSessionWizard } from '@/components/sessions/new/components/NewSessionWizard'; +import { useNewSessionScreenModel } from '@/components/sessions/new/hooks/useNewSessionScreenModel'; -export const callbacks = { - onMachineSelected: (machineId: string) => { - onMachineSelected(machineId); - }, - onProfileSaved: (profile: AIBackendProfile) => { - onProfileSaved(profile); - } -} - -// Optimized profile lookup utility -const useProfileMap = (profiles: AIBackendProfile[]) => { - return React.useMemo(() => - new Map(profiles.map(p => [p.id, p])), - [profiles] - ); -}; - -// Environment variable transformation helper -// Returns ALL profile environment variables - daemon will use them as-is -const transformProfileToEnvironmentVars = (profile: AIBackendProfile, agentType: 'claude' | 'codex' | 'gemini' = 'claude') => { - // getProfileEnvironmentVariables already returns ALL env vars from profile - // including custom environmentVariables array and provider-specific configs - return getProfileEnvironmentVariables(profile); -}; - -// Helper function to get the most recent path for a machine -// Returns the path from the most recently CREATED session for this machine -const getRecentPathForMachine = (machineId: string | null, recentPaths: Array<{ machineId: string; path: string }>): string => { - if (!machineId) return ''; - - const machine = storage.getState().machines[machineId]; - const defaultPath = machine?.metadata?.homeDir || ''; - - // Get all sessions for this machine, sorted by creation time (most recent first) - const sessions = Object.values(storage.getState().sessions); - const pathsWithTimestamps: Array<{ path: string; timestamp: number }> = []; - - sessions.forEach(session => { - if (session.metadata?.machineId === machineId && session.metadata?.path) { - pathsWithTimestamps.push({ - path: session.metadata.path, - timestamp: session.createdAt // Use createdAt, not updatedAt - }); - } - }); - - // Sort by creation time (most recently created first) - pathsWithTimestamps.sort((a, b) => b.timestamp - a.timestamp); - - // Return the most recently created session's path, or default - return pathsWithTimestamps[0]?.path || defaultPath; -}; - -// Configuration constants -const RECENT_PATHS_DEFAULT_VISIBLE = 5; -const STATUS_ITEM_GAP = 11; // Spacing between status items (machine, CLI) - ~2 character spaces at 11px font - -const styles = StyleSheet.create((theme, rt) => ({ - container: { - flex: 1, - justifyContent: Platform.OS === 'web' ? 'center' : 'flex-end', - paddingTop: Platform.OS === 'web' ? 0 : 40, - }, - scrollContainer: { - flex: 1, - }, - contentContainer: { - width: '100%', - alignSelf: 'center', - paddingTop: rt.insets.top, - paddingBottom: 16, - }, - wizardContainer: { - backgroundColor: theme.colors.surface, - borderRadius: 16, - marginHorizontal: 16, - padding: 16, - marginBottom: 16, - }, - sectionHeader: { - fontSize: 14, - fontWeight: '600', - color: theme.colors.text, - marginBottom: 8, - marginTop: 12, - ...Typography.default('semiBold') - }, - sectionDescription: { - fontSize: 12, - color: theme.colors.textSecondary, - marginBottom: 12, - lineHeight: 18, - ...Typography.default() - }, - profileListItem: { - backgroundColor: theme.colors.input.background, - borderRadius: 12, - padding: 8, - marginBottom: 8, - flexDirection: 'row', - alignItems: 'center', - borderWidth: 2, - borderColor: 'transparent', - }, - profileListItemSelected: { - borderWidth: 2, - borderColor: theme.colors.text, - }, - profileIcon: { - width: 20, - height: 20, - borderRadius: 10, - backgroundColor: theme.colors.button.primary.background, - justifyContent: 'center', - alignItems: 'center', - marginRight: 10, - }, - profileListName: { - fontSize: 13, - fontWeight: '600', - color: theme.colors.text, - ...Typography.default('semiBold') - }, - profileListDetails: { - fontSize: 12, - color: theme.colors.textSecondary, - marginTop: 2, - ...Typography.default() - }, - addProfileButton: { - backgroundColor: theme.colors.surface, - borderRadius: 12, - padding: 12, - marginBottom: 12, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - }, - addProfileButtonText: { - fontSize: 13, - fontWeight: '600', - color: theme.colors.button.secondary.tint, - marginLeft: 8, - ...Typography.default('semiBold') - }, - selectorButton: { - backgroundColor: theme.colors.input.background, - borderRadius: 8, - padding: 10, - marginBottom: 12, - borderWidth: 1, - borderColor: theme.colors.divider, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - }, - selectorButtonText: { - color: theme.colors.text, - fontSize: 13, - flex: 1, - ...Typography.default() - }, - advancedHeader: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - paddingVertical: 12, - }, - advancedHeaderText: { - fontSize: 13, - fontWeight: '500', - color: theme.colors.textSecondary, - ...Typography.default(), - }, - permissionGrid: { - flexDirection: 'row', - flexWrap: 'wrap', - justifyContent: 'space-between', - marginBottom: 16, - }, - permissionButton: { - width: '48%', - backgroundColor: theme.colors.input.background, - borderRadius: 12, - padding: 16, - marginBottom: 12, - alignItems: 'center', - borderWidth: 2, - borderColor: 'transparent', - }, - permissionButtonSelected: { - borderColor: theme.colors.button.primary.background, - backgroundColor: theme.colors.button.primary.background + '10', - }, - permissionButtonText: { - fontSize: 14, - fontWeight: '600', - color: theme.colors.text, - marginTop: 8, - textAlign: 'center', - ...Typography.default('semiBold') - }, - permissionButtonTextSelected: { - color: theme.colors.button.primary.background, - }, - permissionButtonDesc: { - fontSize: 11, - color: theme.colors.textSecondary, - marginTop: 4, - textAlign: 'center', - ...Typography.default() - }, -})); - -function NewSessionWizard() { - const { theme, rt } = useUnistyles(); - const router = useRouter(); - const safeArea = useSafeAreaInsets(); - const { prompt, dataId, machineId: machineIdParam, path: pathParam } = useLocalSearchParams<{ - prompt?: string; - dataId?: string; - machineId?: string; - path?: string; - }>(); - - // Try to get data from temporary store first - const tempSessionData = React.useMemo(() => { - if (dataId) { - return getTempData<NewSessionData>(dataId); - } - return null; - }, [dataId]); - - // Load persisted draft state (survives remounts/screen navigation) - const persistedDraft = React.useRef(loadNewSessionDraft()).current; - - // Settings and state - const recentMachinePaths = useSetting('recentMachinePaths'); - const lastUsedAgent = useSetting('lastUsedAgent'); - - // A/B Test Flag - determines which wizard UI to show - // Control A (false): Simpler AgentInput-driven layout - // Variant B (true): Enhanced profile-first wizard with sections - const useEnhancedSessionWizard = useSetting('useEnhancedSessionWizard'); - const lastUsedPermissionMode = useSetting('lastUsedPermissionMode'); - const lastUsedModelMode = useSetting('lastUsedModelMode'); - const experimentsEnabled = useSetting('experiments'); - const [profiles, setProfiles] = useSettingMutable('profiles'); - const lastUsedProfile = useSetting('lastUsedProfile'); - const [favoriteDirectories, setFavoriteDirectories] = useSettingMutable('favoriteDirectories'); - const [favoriteMachines, setFavoriteMachines] = useSettingMutable('favoriteMachines'); - const [dismissedCLIWarnings, setDismissedCLIWarnings] = useSettingMutable('dismissedCLIWarnings'); - - // Combined profiles (built-in + custom) - const allProfiles = React.useMemo(() => { - const builtInProfiles = DEFAULT_PROFILES.map(bp => getBuiltInProfile(bp.id)!); - return [...builtInProfiles, ...profiles]; - }, [profiles]); - - const profileMap = useProfileMap(allProfiles); - const machines = useAllMachines(); - const sessions = useSessions(); - - // Wizard state - const [selectedProfileId, setSelectedProfileId] = React.useState<string | null>(() => { - if (lastUsedProfile && profileMap.has(lastUsedProfile)) { - return lastUsedProfile; - } - return 'anthropic'; // Default to Anthropic - }); - const [agentType, setAgentType] = React.useState<'claude' | 'codex' | 'gemini'>(() => { - // Check if agent type was provided in temp data - if (tempSessionData?.agentType) { - // Only allow gemini if experiments are enabled - if (tempSessionData.agentType === 'gemini' && !experimentsEnabled) { - return 'claude'; - } - return tempSessionData.agentType; - } - if (lastUsedAgent === 'claude' || lastUsedAgent === 'codex') { - return lastUsedAgent; - } - // Only allow gemini if experiments are enabled - if (lastUsedAgent === 'gemini' && experimentsEnabled) { - return lastUsedAgent; - } - return 'claude'; - }); - - // Agent cycling handler (for cycling through claude -> codex -> gemini) - // Note: Does NOT persist immediately - persistence is handled by useEffect below - const handleAgentClick = React.useCallback(() => { - setAgentType(prev => { - // Cycle: claude -> codex -> gemini (if experiments) -> claude - if (prev === 'claude') return 'codex'; - if (prev === 'codex') return experimentsEnabled ? 'gemini' : 'claude'; - return 'claude'; - }); - }, [experimentsEnabled]); - - // Persist agent selection changes (separate from setState to avoid race condition) - // This runs after agentType state is updated, ensuring the value is stable - React.useEffect(() => { - sync.applySettings({ lastUsedAgent: agentType }); - }, [agentType]); - - const [sessionType, setSessionType] = React.useState<'simple' | 'worktree'>('simple'); - const [permissionMode, setPermissionMode] = React.useState<PermissionMode>(() => { - // Initialize with last used permission mode if valid, otherwise default to 'default' - const validClaudeModes: PermissionMode[] = ['default', 'acceptEdits', 'plan', 'bypassPermissions']; - const validCodexGeminiModes: PermissionMode[] = ['default', 'read-only', 'safe-yolo', 'yolo']; - - if (lastUsedPermissionMode) { - if ((agentType === 'codex' || agentType === 'gemini') && validCodexGeminiModes.includes(lastUsedPermissionMode as PermissionMode)) { - return lastUsedPermissionMode as PermissionMode; - } else if (agentType === 'claude' && validClaudeModes.includes(lastUsedPermissionMode as PermissionMode)) { - return lastUsedPermissionMode as PermissionMode; - } - } - return 'default'; - }); - - // NOTE: Permission mode reset on agentType change is handled by the validation useEffect below (lines ~670-681) - // which intelligently resets only when the current mode is invalid for the new agent type. - // A duplicate unconditional reset here was removed to prevent race conditions. - - const [modelMode, setModelMode] = React.useState<ModelMode>(() => { - const validClaudeModes: ModelMode[] = ['default', 'adaptiveUsage', 'sonnet', 'opus']; - const validCodexModes: ModelMode[] = ['gpt-5-codex-high', 'gpt-5-codex-medium', 'gpt-5-codex-low', 'gpt-5-minimal', 'gpt-5-low', 'gpt-5-medium', 'gpt-5-high']; - // Note: 'default' is NOT valid for Gemini - we want explicit model selection - const validGeminiModes: ModelMode[] = ['gemini-2.5-pro', 'gemini-2.5-flash', 'gemini-2.5-flash-lite']; - - if (lastUsedModelMode) { - if (agentType === 'codex' && validCodexModes.includes(lastUsedModelMode as ModelMode)) { - return lastUsedModelMode as ModelMode; - } else if (agentType === 'claude' && validClaudeModes.includes(lastUsedModelMode as ModelMode)) { - return lastUsedModelMode as ModelMode; - } else if (agentType === 'gemini' && validGeminiModes.includes(lastUsedModelMode as ModelMode)) { - return lastUsedModelMode as ModelMode; - } - } - return agentType === 'codex' ? 'gpt-5-codex-high' : agentType === 'gemini' ? 'gemini-2.5-pro' : 'default'; - }); - - // Session details state - const [selectedMachineId, setSelectedMachineId] = React.useState<string | null>(() => { - if (machines.length > 0) { - if (recentMachinePaths.length > 0) { - for (const recent of recentMachinePaths) { - if (machines.find(m => m.id === recent.machineId)) { - return recent.machineId; - } - } - } - return machines[0].id; - } - return null; - }); - - const handlePermissionModeChange = React.useCallback((mode: PermissionMode) => { - setPermissionMode(mode); - // Save the new selection immediately - sync.applySettings({ lastUsedPermissionMode: mode }); - }, []); - - // - // Path selection - // - - const [selectedPath, setSelectedPath] = React.useState<string>(() => { - return getRecentPathForMachine(selectedMachineId, recentMachinePaths); - }); - const [sessionPrompt, setSessionPrompt] = React.useState(() => { - return tempSessionData?.prompt || prompt || persistedDraft?.input || ''; - }); - const [isCreating, setIsCreating] = React.useState(false); - const [showAdvanced, setShowAdvanced] = React.useState(false); - - // Handle machineId route param from picker screens (main's navigation pattern) - React.useEffect(() => { - if (typeof machineIdParam !== 'string' || machines.length === 0) { - return; - } - if (!machines.some(m => m.id === machineIdParam)) { - return; - } - if (machineIdParam !== selectedMachineId) { - setSelectedMachineId(machineIdParam); - const bestPath = getRecentPathForMachine(machineIdParam, recentMachinePaths); - setSelectedPath(bestPath); - } - }, [machineIdParam, machines, recentMachinePaths, selectedMachineId]); - - // Handle path route param from picker screens (main's navigation pattern) - React.useEffect(() => { - if (typeof pathParam !== 'string') { - return; - } - const trimmedPath = pathParam.trim(); - if (trimmedPath && trimmedPath !== selectedPath) { - setSelectedPath(trimmedPath); - } - }, [pathParam, selectedPath]); - - // Path selection state - initialize with formatted selected path - - // Refs for scrolling to sections - const scrollViewRef = React.useRef<ScrollView>(null); - const profileSectionRef = React.useRef<View>(null); - const machineSectionRef = React.useRef<View>(null); - const pathSectionRef = React.useRef<View>(null); - const permissionSectionRef = React.useRef<View>(null); - - // CLI Detection - automatic, non-blocking detection of installed CLIs on selected machine - const cliAvailability = useCLIDetection(selectedMachineId); - - // Auto-correct invalid agent selection after CLI detection completes - // This handles the case where lastUsedAgent was 'codex' but codex is not installed - React.useEffect(() => { - // Only act when detection has completed (timestamp > 0) - if (cliAvailability.timestamp === 0) return; - - // Check if currently selected agent is available - const agentAvailable = cliAvailability[agentType]; - - if (agentAvailable === false) { - // Current agent not available - find first available - const availableAgent: 'claude' | 'codex' | 'gemini' = - cliAvailability.claude === true ? 'claude' : - cliAvailability.codex === true ? 'codex' : - (cliAvailability.gemini === true && experimentsEnabled) ? 'gemini' : - 'claude'; // Fallback to claude (will fail at spawn with clear error) - - console.warn(`[AgentSelection] ${agentType} not available, switching to ${availableAgent}`); - setAgentType(availableAgent); - } - }, [cliAvailability.timestamp, cliAvailability.claude, cliAvailability.codex, cliAvailability.gemini, agentType, experimentsEnabled]); - - // Extract all ${VAR} references from profiles to query daemon environment - const envVarRefs = React.useMemo(() => { - const refs = new Set<string>(); - allProfiles.forEach(profile => { - extractEnvVarReferences(profile.environmentVariables || []) - .forEach(ref => refs.add(ref)); - }); - return Array.from(refs); - }, [allProfiles]); - - // Query daemon environment for ${VAR} resolution - const { variables: daemonEnv } = useEnvironmentVariables(selectedMachineId, envVarRefs); - - // Temporary banner dismissal (X button) - resets when component unmounts or machine changes - const [hiddenBanners, setHiddenBanners] = React.useState<{ claude: boolean; codex: boolean; gemini: boolean }>({ claude: false, codex: false, gemini: false }); - - // Helper to check if CLI warning has been dismissed (checks both global and per-machine) - const isWarningDismissed = React.useCallback((cli: 'claude' | 'codex' | 'gemini'): boolean => { - // Check global dismissal first - if (dismissedCLIWarnings.global?.[cli] === true) return true; - // Check per-machine dismissal - if (!selectedMachineId) return false; - return dismissedCLIWarnings.perMachine?.[selectedMachineId]?.[cli] === true; - }, [selectedMachineId, dismissedCLIWarnings]); - - // Unified dismiss handler for all three button types (easy to use correctly, hard to use incorrectly) - const handleCLIBannerDismiss = React.useCallback((cli: 'claude' | 'codex' | 'gemini', type: 'temporary' | 'machine' | 'global') => { - if (type === 'temporary') { - // X button: Hide for current session only (not persisted) - setHiddenBanners(prev => ({ ...prev, [cli]: true })); - } else if (type === 'global') { - // [any machine] button: Permanent dismissal across all machines - setDismissedCLIWarnings({ - ...dismissedCLIWarnings, - global: { - ...dismissedCLIWarnings.global, - [cli]: true, - }, - }); - } else { - // [this machine] button: Permanent dismissal for current machine only - if (!selectedMachineId) return; - const machineWarnings = dismissedCLIWarnings.perMachine?.[selectedMachineId] || {}; - setDismissedCLIWarnings({ - ...dismissedCLIWarnings, - perMachine: { - ...dismissedCLIWarnings.perMachine, - [selectedMachineId]: { - ...machineWarnings, - [cli]: true, - }, - }, - }); - } - }, [selectedMachineId, dismissedCLIWarnings, setDismissedCLIWarnings]); - - // Helper to check if profile is available (compatible + CLI detected) - const isProfileAvailable = React.useCallback((profile: AIBackendProfile): { available: boolean; reason?: string } => { - // Check profile compatibility with selected agent type - if (!validateProfileForAgent(profile, agentType)) { - // Build list of agents this profile supports (excluding current) - // Uses Object.entries to iterate over compatibility flags - scales automatically with new agents - const supportedAgents = (Object.entries(profile.compatibility) as [string, boolean][]) - .filter(([agent, supported]) => supported && agent !== agentType) - .map(([agent]) => agent.charAt(0).toUpperCase() + agent.slice(1)); // 'claude' -> 'Claude' - const required = supportedAgents.join(' or ') || 'another agent'; - return { - available: false, - reason: `requires-agent:${required}`, - }; - } - - // Check if required CLI is detected on machine (only if detection completed) - // Determine required CLI: if profile supports exactly one CLI, that CLI is required - // Uses Object.entries to iterate - scales automatically when new agents are added - const supportedCLIs = (Object.entries(profile.compatibility) as [string, boolean][]) - .filter(([, supported]) => supported) - .map(([agent]) => agent); - const requiredCLI = supportedCLIs.length === 1 ? supportedCLIs[0] as 'claude' | 'codex' | 'gemini' : null; - - if (requiredCLI && cliAvailability[requiredCLI] === false) { - return { - available: false, - reason: `cli-not-detected:${requiredCLI}`, - }; - } - - // Optimistic: If detection hasn't completed (null) or profile supports both, assume available - return { available: true }; - }, [agentType, cliAvailability]); - - // Computed values - const compatibleProfiles = React.useMemo(() => { - return allProfiles.filter(profile => validateProfileForAgent(profile, agentType)); - }, [allProfiles, agentType]); - - const selectedProfile = React.useMemo(() => { - if (!selectedProfileId) { - return null; - } - // Check custom profiles first - if (profileMap.has(selectedProfileId)) { - return profileMap.get(selectedProfileId)!; - } - // Check built-in profiles - return getBuiltInProfile(selectedProfileId); - }, [selectedProfileId, profileMap]); - - const selectedMachine = React.useMemo(() => { - if (!selectedMachineId) return null; - return machines.find(m => m.id === selectedMachineId); - }, [selectedMachineId, machines]); - - // Get recent paths for the selected machine - // Recent machines computed from sessions (for inline machine selection) - const recentMachines = React.useMemo(() => { - const machineIds = new Set<string>(); - const machinesWithTimestamp: Array<{ machine: typeof machines[0]; timestamp: number }> = []; - - sessions?.forEach(item => { - if (typeof item === 'string') return; // Skip section headers - const session = item as any; - if (session.metadata?.machineId && !machineIds.has(session.metadata.machineId)) { - const machine = machines.find(m => m.id === session.metadata.machineId); - if (machine) { - machineIds.add(machine.id); - machinesWithTimestamp.push({ - machine, - timestamp: session.updatedAt || session.createdAt - }); - } - } - }); - - return machinesWithTimestamp - .sort((a, b) => b.timestamp - a.timestamp) - .map(item => item.machine); - }, [sessions, machines]); - - const recentPaths = React.useMemo(() => { - if (!selectedMachineId) return []; - - const paths: string[] = []; - const pathSet = new Set<string>(); - - // First, add paths from recentMachinePaths (these are the most recent) - recentMachinePaths.forEach(entry => { - if (entry.machineId === selectedMachineId && !pathSet.has(entry.path)) { - paths.push(entry.path); - pathSet.add(entry.path); - } - }); - - // Then add paths from sessions if we need more - if (sessions) { - const pathsWithTimestamps: Array<{ path: string; timestamp: number }> = []; - - sessions.forEach(item => { - if (typeof item === 'string') return; // Skip section headers - - const session = item as any; - if (session.metadata?.machineId === selectedMachineId && session.metadata?.path) { - const path = session.metadata.path; - if (!pathSet.has(path)) { - pathSet.add(path); - pathsWithTimestamps.push({ - path, - timestamp: session.updatedAt || session.createdAt - }); - } - } - }); - - // Sort session paths by most recent first and add them - pathsWithTimestamps - .sort((a, b) => b.timestamp - a.timestamp) - .forEach(item => paths.push(item.path)); - } - - return paths; - }, [sessions, selectedMachineId, recentMachinePaths]); - - // Validation - const canCreate = React.useMemo(() => { - return ( - selectedProfileId !== null && - selectedMachineId !== null && - selectedPath.trim() !== '' - ); - }, [selectedProfileId, selectedMachineId, selectedPath]); - - const selectProfile = React.useCallback((profileId: string) => { - setSelectedProfileId(profileId); - // Check both custom profiles and built-in profiles - const profile = profileMap.get(profileId) || getBuiltInProfile(profileId); - if (profile) { - // Auto-select agent based on profile's EXCLUSIVE compatibility - // Only switch if profile supports exactly one CLI - scales automatically with new agents - const supportedCLIs = (Object.entries(profile.compatibility) as [string, boolean][]) - .filter(([, supported]) => supported) - .map(([agent]) => agent); - - if (supportedCLIs.length === 1) { - const requiredAgent = supportedCLIs[0] as 'claude' | 'codex' | 'gemini'; - // Check if this agent is available and allowed - const isAvailable = cliAvailability[requiredAgent] !== false; - const isAllowed = requiredAgent !== 'gemini' || experimentsEnabled; - - if (isAvailable && isAllowed) { - setAgentType(requiredAgent); - } - // If the required CLI is unavailable or not allowed, keep current agent (profile will show as unavailable) - } - // If supportedCLIs.length > 1, profile supports multiple CLIs - don't force agent switch - - // Set session type from profile's default - if (profile.defaultSessionType) { - setSessionType(profile.defaultSessionType); - } - // Set permission mode from profile's default - if (profile.defaultPermissionMode) { - setPermissionMode(profile.defaultPermissionMode as PermissionMode); - } - } - }, [profileMap, cliAvailability.claude, cliAvailability.codex, cliAvailability.gemini, experimentsEnabled]); - - // Reset permission mode to 'default' when agent type changes and current mode is invalid for new agent - React.useEffect(() => { - const validClaudeModes: PermissionMode[] = ['default', 'acceptEdits', 'plan', 'bypassPermissions']; - const validCodexGeminiModes: PermissionMode[] = ['default', 'read-only', 'safe-yolo', 'yolo']; - - const isValidForCurrentAgent = (agentType === 'codex' || agentType === 'gemini') - ? validCodexGeminiModes.includes(permissionMode) - : validClaudeModes.includes(permissionMode); - - if (!isValidForCurrentAgent) { - setPermissionMode('default'); - } - }, [agentType, permissionMode]); - - // Reset model mode when agent type changes to appropriate default - React.useEffect(() => { - const validClaudeModes: ModelMode[] = ['default', 'adaptiveUsage', 'sonnet', 'opus']; - const validCodexModes: ModelMode[] = ['gpt-5-codex-high', 'gpt-5-codex-medium', 'gpt-5-codex-low', 'gpt-5-minimal', 'gpt-5-low', 'gpt-5-medium', 'gpt-5-high']; - // Note: 'default' is NOT valid for Gemini - we want explicit model selection - const validGeminiModes: ModelMode[] = ['gemini-2.5-pro', 'gemini-2.5-flash', 'gemini-2.5-flash-lite']; +function NewSessionScreen() { + const model = useNewSessionScreenModel(); - let isValidForCurrentAgent = false; - if (agentType === 'codex') { - isValidForCurrentAgent = validCodexModes.includes(modelMode); - } else if (agentType === 'gemini') { - isValidForCurrentAgent = validGeminiModes.includes(modelMode); - } else { - isValidForCurrentAgent = validClaudeModes.includes(modelMode); - } - - if (!isValidForCurrentAgent) { - // Set appropriate default for each agent type - if (agentType === 'codex') { - setModelMode('gpt-5-codex-high'); - } else if (agentType === 'gemini') { - setModelMode('gemini-2.5-pro'); - } else { - setModelMode('default'); - } - } - }, [agentType, modelMode]); - - // Scroll to section helpers - for AgentInput button clicks - const scrollToSection = React.useCallback((ref: React.RefObject<View | Text | null>) => { - if (!ref.current || !scrollViewRef.current) return; - - // Use requestAnimationFrame to ensure layout is painted before measuring - requestAnimationFrame(() => { - if (ref.current && scrollViewRef.current) { - ref.current.measureLayout( - scrollViewRef.current as any, - (x, y) => { - scrollViewRef.current?.scrollTo({ y: y - 20, animated: true }); - }, - () => { - console.warn('measureLayout failed'); - } - ); - } - }); - }, []); - - const handleAgentInputProfileClick = React.useCallback(() => { - scrollToSection(profileSectionRef); - }, [scrollToSection]); - - const handleAgentInputMachineClick = React.useCallback(() => { - scrollToSection(machineSectionRef); - }, [scrollToSection]); - - const handleAgentInputPathClick = React.useCallback(() => { - scrollToSection(pathSectionRef); - }, [scrollToSection]); - - const handleAgentInputPermissionChange = React.useCallback((mode: PermissionMode) => { - setPermissionMode(mode); - scrollToSection(permissionSectionRef); - }, [scrollToSection]); - - const handleAgentInputAgentClick = React.useCallback(() => { - scrollToSection(profileSectionRef); // Agent tied to profile section - }, [scrollToSection]); - - const handleAddProfile = React.useCallback(() => { - const newProfile: AIBackendProfile = { - id: randomUUID(), - name: '', - anthropicConfig: {}, - environmentVariables: [], - compatibility: { claude: true, codex: true, gemini: true }, - isBuiltIn: false, - createdAt: Date.now(), - updatedAt: Date.now(), - version: '1.0.0', - }; - const profileData = encodeURIComponent(JSON.stringify(newProfile)); - router.push(`/new/pick/profile-edit?profileData=${profileData}`); - }, [router]); - - const handleEditProfile = React.useCallback((profile: AIBackendProfile) => { - const profileData = encodeURIComponent(JSON.stringify(profile)); - const machineId = selectedMachineId || ''; - router.push(`/new/pick/profile-edit?profileData=${profileData}&machineId=${machineId}`); - }, [router, selectedMachineId]); - - const handleDuplicateProfile = React.useCallback((profile: AIBackendProfile) => { - const duplicatedProfile: AIBackendProfile = { - ...profile, - id: randomUUID(), - name: `${profile.name} (Copy)`, - isBuiltIn: false, - createdAt: Date.now(), - updatedAt: Date.now(), - }; - const profileData = encodeURIComponent(JSON.stringify(duplicatedProfile)); - router.push(`/new/pick/profile-edit?profileData=${profileData}`); - }, [router]); - - // Helper to get meaningful subtitle text for profiles - const getProfileSubtitle = React.useCallback((profile: AIBackendProfile): string => { - const parts: string[] = []; - const availability = isProfileAvailable(profile); - - // Add "Built-in" indicator first for built-in profiles - if (profile.isBuiltIn) { - parts.push('Built-in'); - } - - // Add CLI type second (before warnings/availability) - if (profile.compatibility.claude && profile.compatibility.codex) { - parts.push('Claude & Codex CLI'); - } else if (profile.compatibility.claude) { - parts.push('Claude CLI'); - } else if (profile.compatibility.codex) { - parts.push('Codex CLI'); - } - - // Add availability warning if unavailable - if (!availability.available && availability.reason) { - if (availability.reason.startsWith('requires-agent:')) { - const required = availability.reason.split(':')[1]; - parts.push(`⚠️ This profile uses ${required} CLI only`); - } else if (availability.reason.startsWith('cli-not-detected:')) { - const cli = availability.reason.split(':')[1]; - const cliName = cli === 'claude' ? 'Claude' : 'Codex'; - parts.push(`⚠️ ${cliName} CLI not detected (this profile needs it)`); - } - } - - // Get model name - check both anthropicConfig and environmentVariables - let modelName: string | undefined; - if (profile.anthropicConfig?.model) { - // User set in GUI - literal value, no evaluation needed - modelName = profile.anthropicConfig.model; - } else if (profile.openaiConfig?.model) { - modelName = profile.openaiConfig.model; - } else { - // Check environmentVariables - may need ${VAR} evaluation - const modelEnvVar = profile.environmentVariables?.find(ev => ev.name === 'ANTHROPIC_MODEL'); - if (modelEnvVar) { - const resolved = resolveEnvVarSubstitution(modelEnvVar.value, daemonEnv); - if (resolved) { - // Show as "VARIABLE: value" when evaluated from ${VAR} - const varName = modelEnvVar.value.match(/^\$\{(.+)\}$/)?.[1]; - modelName = varName ? `${varName}: ${resolved}` : resolved; - } else { - // Show raw ${VAR} if not resolved (machine not selected or var not set) - modelName = modelEnvVar.value; - } - } - } - - if (modelName) { - parts.push(modelName); - } - - // Add base URL if exists in environmentVariables - const baseUrlEnvVar = profile.environmentVariables?.find(ev => ev.name === 'ANTHROPIC_BASE_URL'); - if (baseUrlEnvVar) { - const resolved = resolveEnvVarSubstitution(baseUrlEnvVar.value, daemonEnv); - if (resolved) { - // Extract hostname and show with variable name - const varName = baseUrlEnvVar.value.match(/^\$\{([A-Z_][A-Z0-9_]*)/)?.[1]; - try { - const url = new URL(resolved); - const display = varName ? `${varName}: ${url.hostname}` : url.hostname; - parts.push(display); - } catch { - // Not a valid URL, show as-is with variable name - parts.push(varName ? `${varName}: ${resolved}` : resolved); - } - } else { - // Show raw ${VAR} if not resolved (machine not selected or var not set) - parts.push(baseUrlEnvVar.value); - } - } - - return parts.join(', '); - }, [agentType, isProfileAvailable, daemonEnv]); - - const handleDeleteProfile = React.useCallback((profile: AIBackendProfile) => { - Modal.alert( - t('profiles.delete.title'), - t('profiles.delete.message', { name: profile.name }), - [ - { text: t('profiles.delete.cancel'), style: 'cancel' }, - { - text: t('profiles.delete.confirm'), - style: 'destructive', - onPress: () => { - const updatedProfiles = profiles.filter(p => p.id !== profile.id); - setProfiles(updatedProfiles); // Use mutable setter for persistence - if (selectedProfileId === profile.id) { - setSelectedProfileId('anthropic'); // Default to Anthropic - } - } - } - ] - ); - }, [profiles, selectedProfileId, setProfiles]); - - // Handle machine and path selection callbacks - React.useEffect(() => { - let handler = (machineId: string) => { - let machine = storage.getState().machines[machineId]; - if (machine) { - setSelectedMachineId(machineId); - const bestPath = getRecentPathForMachine(machineId, recentMachinePaths); - setSelectedPath(bestPath); - } - }; - onMachineSelected = handler; - return () => { - onMachineSelected = () => { }; - }; - }, [recentMachinePaths]); - - React.useEffect(() => { - let handler = (savedProfile: AIBackendProfile) => { - // Handle saved profile from profile-edit screen - - // Check if this is a built-in profile being edited - const isBuiltIn = DEFAULT_PROFILES.some(bp => bp.id === savedProfile.id); - let profileToSave = savedProfile; - - // For built-in profiles, create a new custom profile instead of modifying the built-in - if (isBuiltIn) { - profileToSave = { - ...savedProfile, - id: randomUUID(), // Generate new UUID for custom profile - isBuiltIn: false, - }; - } - - const existingIndex = profiles.findIndex(p => p.id === profileToSave.id); - let updatedProfiles: AIBackendProfile[]; - - if (existingIndex >= 0) { - // Update existing profile - updatedProfiles = [...profiles]; - updatedProfiles[existingIndex] = profileToSave; - } else { - // Add new profile - updatedProfiles = [...profiles, profileToSave]; - } - - setProfiles(updatedProfiles); // Use mutable setter for persistence - setSelectedProfileId(profileToSave.id); - }; - onProfileSaved = handler; - return () => { - onProfileSaved = () => { }; - }; - }, [profiles, setProfiles]); - - const handleMachineClick = React.useCallback(() => { - router.push('/new/pick/machine'); - }, [router]); - - const handlePathClick = React.useCallback(() => { - if (selectedMachineId) { - router.push({ - pathname: '/new/pick/path', - params: { - machineId: selectedMachineId, - selectedPath, - }, - }); - } - }, [selectedMachineId, selectedPath, router]); - - // Session creation - const handleCreateSession = React.useCallback(async () => { - if (!selectedMachineId) { - Modal.alert(t('common.error'), t('newSession.noMachineSelected')); - return; - } - if (!selectedPath) { - Modal.alert(t('common.error'), t('newSession.noPathSelected')); - return; - } - - setIsCreating(true); - - try { - let actualPath = selectedPath; - - // Handle worktree creation - if (sessionType === 'worktree' && experimentsEnabled) { - const worktreeResult = await createWorktree(selectedMachineId, selectedPath); - - if (!worktreeResult.success) { - if (worktreeResult.error === 'Not a Git repository') { - Modal.alert(t('common.error'), t('newSession.worktree.notGitRepo')); - } else { - Modal.alert(t('common.error'), t('newSession.worktree.failed', { error: worktreeResult.error || 'Unknown error' })); - } - setIsCreating(false); - return; - } - - actualPath = worktreeResult.worktreePath; - } - - // Save settings - const updatedPaths = [{ machineId: selectedMachineId, path: selectedPath }, ...recentMachinePaths.filter(rp => rp.machineId !== selectedMachineId)].slice(0, 10); - sync.applySettings({ - recentMachinePaths: updatedPaths, - lastUsedAgent: agentType, - lastUsedProfile: selectedProfileId, - lastUsedPermissionMode: permissionMode, - lastUsedModelMode: modelMode, - }); - - // Get environment variables from selected profile - let environmentVariables = undefined; - if (selectedProfileId) { - const selectedProfile = profileMap.get(selectedProfileId); - if (selectedProfile) { - environmentVariables = transformProfileToEnvironmentVars(selectedProfile, agentType); - } - } - - const result = await machineSpawnNewSession({ - machineId: selectedMachineId, - directory: actualPath, - approvedNewDirectoryCreation: true, - agent: agentType, - environmentVariables - }); - - if ('sessionId' in result && result.sessionId) { - // Clear draft state on successful session creation - clearNewSessionDraft(); - - await sync.refreshSessions(); - - // Set permission mode and model mode on the session - storage.getState().updateSessionPermissionMode(result.sessionId, permissionMode); - if (agentType === 'gemini' && modelMode && modelMode !== 'default') { - storage.getState().updateSessionModelMode(result.sessionId, modelMode as 'gemini-2.5-pro' | 'gemini-2.5-flash' | 'gemini-2.5-flash-lite'); - } - - // Send initial message if provided - if (sessionPrompt.trim()) { - await sync.sendMessage(result.sessionId, sessionPrompt); - } - - router.replace(`/session/${result.sessionId}`, { - dangerouslySingular() { - return 'session' - }, - }); - } else { - throw new Error('Session spawning failed - no session ID returned.'); - } - } catch (error) { - console.error('Failed to start session', error); - let errorMessage = 'Failed to start session. Make sure the daemon is running on the target machine.'; - if (error instanceof Error) { - if (error.message.includes('timeout')) { - errorMessage = 'Session startup timed out. The machine may be slow or the daemon may not be responding.'; - } else if (error.message.includes('Socket not connected')) { - errorMessage = 'Not connected to server. Check your internet connection.'; - } - } - Modal.alert(t('common.error'), errorMessage); - setIsCreating(false); - } - }, [selectedMachineId, selectedPath, sessionPrompt, sessionType, experimentsEnabled, agentType, selectedProfileId, permissionMode, modelMode, recentMachinePaths, profileMap, router]); - - const screenWidth = useWindowDimensions().width; - - // Machine online status for AgentInput (DRY - reused in info box too) - const connectionStatus = React.useMemo(() => { - if (!selectedMachine) return undefined; - const isOnline = isMachineOnline(selectedMachine); - - // Include CLI status only when in wizard AND detection completed - const includeCLI = selectedMachineId && cliAvailability.timestamp > 0; - - return { - text: isOnline ? 'online' : 'offline', - color: isOnline ? theme.colors.success : theme.colors.textDestructive, - dotColor: isOnline ? theme.colors.success : theme.colors.textDestructive, - isPulsing: isOnline, - cliStatus: includeCLI ? { - claude: cliAvailability.claude, - codex: cliAvailability.codex, - ...(experimentsEnabled && { gemini: cliAvailability.gemini }), - } : undefined, - }; - }, [selectedMachine, selectedMachineId, cliAvailability, experimentsEnabled, theme]); - - // Persist the current wizard state so it survives remounts and screen navigation - // Uses debouncing to avoid excessive writes - const draftSaveTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null); - React.useEffect(() => { - if (draftSaveTimerRef.current) { - clearTimeout(draftSaveTimerRef.current); - } - draftSaveTimerRef.current = setTimeout(() => { - saveNewSessionDraft({ - input: sessionPrompt, - selectedMachineId, - selectedPath, - agentType, - permissionMode, - sessionType, - updatedAt: Date.now(), - }); - }, 250); - return () => { - if (draftSaveTimerRef.current) { - clearTimeout(draftSaveTimerRef.current); - } - }; - }, [sessionPrompt, selectedMachineId, selectedPath, agentType, permissionMode, sessionType]); - - // ======================================================================== - // CONTROL A: Simpler AgentInput-driven layout (flag OFF) - // Shows machine/path selection via chips that navigate to picker screens - // ======================================================================== - if (!useEnhancedSessionWizard) { - return ( - <KeyboardAvoidingView - behavior={Platform.OS === 'ios' ? 'padding' : 'height'} - keyboardVerticalOffset={Platform.OS === 'ios' ? Constants.statusBarHeight + useHeaderHeight() : 0} - style={styles.container} - > - <View style={{ flex: 1, justifyContent: 'flex-end' }}> - {/* Session type selector only if experiments enabled */} - {experimentsEnabled && ( - <View style={{ paddingHorizontal: screenWidth > 700 ? 16 : 8, marginBottom: 16 }}> - <View style={{ maxWidth: layout.maxWidth, width: '100%', alignSelf: 'center' }}> - <SessionTypeSelector - value={sessionType} - onChange={setSessionType} - /> - </View> - </View> - )} - - {/* AgentInput with inline chips - sticky at bottom */} - <View style={{ paddingHorizontal: screenWidth > 700 ? 16 : 8, paddingBottom: Math.max(16, safeArea.bottom) }}> - <View style={{ maxWidth: layout.maxWidth, width: '100%', alignSelf: 'center' }}> - <AgentInput - value={sessionPrompt} - onChangeText={setSessionPrompt} - onSend={handleCreateSession} - isSendDisabled={!canCreate} - isSending={isCreating} - placeholder="What would you like to work on?" - autocompletePrefixes={[]} - autocompleteSuggestions={async () => []} - agentType={agentType} - onAgentClick={handleAgentClick} - permissionMode={permissionMode} - onPermissionModeChange={handlePermissionModeChange} - modelMode={modelMode} - onModelModeChange={setModelMode} - connectionStatus={connectionStatus} - machineName={selectedMachine?.metadata?.displayName || selectedMachine?.metadata?.host} - onMachineClick={handleMachineClick} - currentPath={selectedPath} - onPathClick={handlePathClick} - /> - </View> - </View> - </View> - </KeyboardAvoidingView> - ); + if (model.variant === 'simple') { + return <NewSessionSimplePanel {...model.simpleProps} />; } - // ======================================================================== - // VARIANT B: Enhanced profile-first wizard (flag ON) - // Full wizard with numbered sections, profile management, CLI detection - // ======================================================================== - return ( - <KeyboardAvoidingView - behavior={Platform.OS === 'ios' ? 'padding' : 'height'} - keyboardVerticalOffset={Platform.OS === 'ios' ? Constants.statusBarHeight + useHeaderHeight() : 0} - style={styles.container} - > - <View style={{ flex: 1 }}> - <ScrollView - ref={scrollViewRef} - style={styles.scrollContainer} - contentContainerStyle={styles.contentContainer} - keyboardShouldPersistTaps="handled" - > - <View style={[ - { paddingHorizontal: screenWidth > 700 ? 16 : 8 } - ]}> - <View style={[ - { maxWidth: layout.maxWidth, flex: 1, width: '100%', alignSelf: 'center' } - ]}> - <View ref={profileSectionRef} style={styles.wizardContainer}> - {/* CLI Detection Status Banner - shows after detection completes */} - {selectedMachineId && cliAvailability.timestamp > 0 && selectedMachine && connectionStatus && ( - <View style={{ - backgroundColor: theme.colors.surfacePressed, - borderRadius: 10, - padding: 10, - paddingRight: 18, - marginBottom: 12, - flexDirection: 'row', - alignItems: 'center', - gap: STATUS_ITEM_GAP, - }}> - <Ionicons name="desktop-outline" size={16} color={theme.colors.textSecondary} /> - <View style={{ flexDirection: 'row', alignItems: 'center', gap: STATUS_ITEM_GAP, flexWrap: 'wrap' }}> - <Text style={{ fontSize: 11, color: theme.colors.textSecondary, ...Typography.default() }}> - {selectedMachine.metadata?.displayName || selectedMachine.metadata?.host || 'Machine'}: - </Text> - <View style={{ flexDirection: 'row', alignItems: 'center', gap: 4 }}> - <StatusDot - color={connectionStatus.dotColor} - isPulsing={connectionStatus.isPulsing} - size={6} - /> - <Text style={{ fontSize: 11, color: connectionStatus.color, ...Typography.default() }}> - {connectionStatus.text} - </Text> - </View> - <View style={{ flexDirection: 'row', alignItems: 'center', gap: 4 }}> - <Text style={{ fontSize: 11, color: cliAvailability.claude ? theme.colors.success : theme.colors.textDestructive, ...Typography.default() }}> - {cliAvailability.claude ? '✓' : '✗'} - </Text> - <Text style={{ fontSize: 11, color: cliAvailability.claude ? theme.colors.success : theme.colors.textDestructive, ...Typography.default() }}> - claude - </Text> - </View> - <View style={{ flexDirection: 'row', alignItems: 'center', gap: 4 }}> - <Text style={{ fontSize: 11, color: cliAvailability.codex ? theme.colors.success : theme.colors.textDestructive, ...Typography.default() }}> - {cliAvailability.codex ? '✓' : '✗'} - </Text> - <Text style={{ fontSize: 11, color: cliAvailability.codex ? theme.colors.success : theme.colors.textDestructive, ...Typography.default() }}> - codex - </Text> - </View> - {experimentsEnabled && ( - <View style={{ flexDirection: 'row', alignItems: 'center', gap: 4 }}> - <Text style={{ fontSize: 11, color: cliAvailability.gemini ? theme.colors.success : theme.colors.textDestructive, ...Typography.default() }}> - {cliAvailability.gemini ? '✓' : '✗'} - </Text> - <Text style={{ fontSize: 11, color: cliAvailability.gemini ? theme.colors.success : theme.colors.textDestructive, ...Typography.default() }}> - gemini - </Text> - </View> - )} - </View> - </View> - )} - - {/* Section 1: Profile Management */} - <View style={{ flexDirection: 'row', alignItems: 'center', gap: 8, marginBottom: 8, marginTop: 12 }}> - <Text style={[styles.sectionHeader, { marginBottom: 0, marginTop: 0 }]}>1.</Text> - <Ionicons name="person-outline" size={18} color={theme.colors.text} /> - <Text style={[styles.sectionHeader, { marginBottom: 0, marginTop: 0 }]}>Choose AI Profile</Text> - </View> - <Text style={styles.sectionDescription}> - Choose which AI backend runs your session (Claude or Codex). Create custom profiles for alternative APIs. - </Text> - - {/* Missing CLI Installation Banners */} - {selectedMachineId && cliAvailability.claude === false && !isWarningDismissed('claude') && !hiddenBanners.claude && ( - <View style={{ - backgroundColor: theme.colors.box.warning.background, - borderRadius: 10, - padding: 12, - marginBottom: 12, - borderWidth: 1, - borderColor: theme.colors.box.warning.border, - }}> - <View style={{ flexDirection: 'row', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 6 }}> - <View style={{ flex: 1, flexDirection: 'row', alignItems: 'center', flexWrap: 'wrap', gap: 6, marginRight: 16 }}> - <Ionicons name="warning" size={16} color={theme.colors.warning} /> - <Text style={{ fontSize: 13, fontWeight: '600', color: theme.colors.text, ...Typography.default('semiBold') }}> - Claude CLI Not Detected - </Text> - <View style={{ flex: 1, minWidth: 20 }} /> - <Text style={{ fontSize: 10, color: theme.colors.textSecondary, ...Typography.default() }}> - Don't show this popup for - </Text> - <Pressable - onPress={() => handleCLIBannerDismiss('claude', 'machine')} - style={{ - borderRadius: 4, - borderWidth: 1, - borderColor: theme.colors.textSecondary, - paddingHorizontal: 8, - paddingVertical: 3, - }} - > - <Text style={{ fontSize: 10, color: theme.colors.textSecondary, ...Typography.default() }}> - this machine - </Text> - </Pressable> - <Pressable - onPress={() => handleCLIBannerDismiss('claude', 'global')} - style={{ - borderRadius: 4, - borderWidth: 1, - borderColor: theme.colors.textSecondary, - paddingHorizontal: 8, - paddingVertical: 3, - }} - > - <Text style={{ fontSize: 10, color: theme.colors.textSecondary, ...Typography.default() }}> - any machine - </Text> - </Pressable> - </View> - <Pressable - onPress={() => handleCLIBannerDismiss('claude', 'temporary')} - hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} - > - <Ionicons name="close" size={18} color={theme.colors.textSecondary} /> - </Pressable> - </View> - <View style={{ flexDirection: 'row', alignItems: 'center', flexWrap: 'wrap', gap: 4 }}> - <Text style={{ fontSize: 11, color: theme.colors.textSecondary, ...Typography.default() }}> - Install: npm install -g @anthropic-ai/claude-code • - </Text> - <Pressable onPress={() => { - if (Platform.OS === 'web') { - window.open('https://docs.anthropic.com/en/docs/claude-code/installation', '_blank'); - } - }}> - <Text style={{ fontSize: 11, color: theme.colors.textLink, ...Typography.default() }}> - View Installation Guide → - </Text> - </Pressable> - </View> - </View> - )} - - {selectedMachineId && cliAvailability.codex === false && !isWarningDismissed('codex') && !hiddenBanners.codex && ( - <View style={{ - backgroundColor: theme.colors.box.warning.background, - borderRadius: 10, - padding: 12, - marginBottom: 12, - borderWidth: 1, - borderColor: theme.colors.box.warning.border, - }}> - <View style={{ flexDirection: 'row', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 6 }}> - <View style={{ flex: 1, flexDirection: 'row', alignItems: 'center', flexWrap: 'wrap', gap: 6, marginRight: 16 }}> - <Ionicons name="warning" size={16} color={theme.colors.warning} /> - <Text style={{ fontSize: 13, fontWeight: '600', color: theme.colors.text, ...Typography.default('semiBold') }}> - Codex CLI Not Detected - </Text> - <View style={{ flex: 1, minWidth: 20 }} /> - <Text style={{ fontSize: 10, color: theme.colors.textSecondary, ...Typography.default() }}> - Don't show this popup for - </Text> - <Pressable - onPress={() => handleCLIBannerDismiss('codex', 'machine')} - style={{ - borderRadius: 4, - borderWidth: 1, - borderColor: theme.colors.textSecondary, - paddingHorizontal: 8, - paddingVertical: 3, - }} - > - <Text style={{ fontSize: 10, color: theme.colors.textSecondary, ...Typography.default() }}> - this machine - </Text> - </Pressable> - <Pressable - onPress={() => handleCLIBannerDismiss('codex', 'global')} - style={{ - borderRadius: 4, - borderWidth: 1, - borderColor: theme.colors.textSecondary, - paddingHorizontal: 8, - paddingVertical: 3, - }} - > - <Text style={{ fontSize: 10, color: theme.colors.textSecondary, ...Typography.default() }}> - any machine - </Text> - </Pressable> - </View> - <Pressable - onPress={() => handleCLIBannerDismiss('codex', 'temporary')} - hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} - > - <Ionicons name="close" size={18} color={theme.colors.textSecondary} /> - </Pressable> - </View> - <View style={{ flexDirection: 'row', alignItems: 'center', flexWrap: 'wrap', gap: 4 }}> - <Text style={{ fontSize: 11, color: theme.colors.textSecondary, ...Typography.default() }}> - Install: npm install -g codex-cli • - </Text> - <Pressable onPress={() => { - if (Platform.OS === 'web') { - window.open('https://github.com/openai/openai-codex', '_blank'); - } - }}> - <Text style={{ fontSize: 11, color: theme.colors.textLink, ...Typography.default() }}> - View Installation Guide → - </Text> - </Pressable> - </View> - </View> - )} - - {selectedMachineId && cliAvailability.gemini === false && experimentsEnabled && !isWarningDismissed('gemini') && !hiddenBanners.gemini && ( - <View style={{ - backgroundColor: theme.colors.box.warning.background, - borderRadius: 10, - padding: 12, - marginBottom: 12, - borderWidth: 1, - borderColor: theme.colors.box.warning.border, - }}> - <View style={{ flexDirection: 'row', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 6 }}> - <View style={{ flex: 1, flexDirection: 'row', alignItems: 'center', flexWrap: 'wrap', gap: 6, marginRight: 16 }}> - <Ionicons name="warning" size={16} color={theme.colors.warning} /> - <Text style={{ fontSize: 13, fontWeight: '600', color: theme.colors.text, ...Typography.default('semiBold') }}> - Gemini CLI Not Detected - </Text> - <View style={{ flex: 1, minWidth: 20 }} /> - <Text style={{ fontSize: 10, color: theme.colors.textSecondary, ...Typography.default() }}> - Don't show this popup for - </Text> - <Pressable - onPress={() => handleCLIBannerDismiss('gemini', 'machine')} - style={{ - borderRadius: 4, - borderWidth: 1, - borderColor: theme.colors.textSecondary, - paddingHorizontal: 8, - paddingVertical: 3, - }} - > - <Text style={{ fontSize: 10, color: theme.colors.textSecondary, ...Typography.default() }}> - this machine - </Text> - </Pressable> - <Pressable - onPress={() => handleCLIBannerDismiss('gemini', 'global')} - style={{ - borderRadius: 4, - borderWidth: 1, - borderColor: theme.colors.textSecondary, - paddingHorizontal: 8, - paddingVertical: 3, - }} - > - <Text style={{ fontSize: 10, color: theme.colors.textSecondary, ...Typography.default() }}> - any machine - </Text> - </Pressable> - </View> - <Pressable - onPress={() => handleCLIBannerDismiss('gemini', 'temporary')} - hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} - > - <Ionicons name="close" size={18} color={theme.colors.textSecondary} /> - </Pressable> - </View> - <View style={{ flexDirection: 'row', alignItems: 'center', flexWrap: 'wrap', gap: 4 }}> - <Text style={{ fontSize: 11, color: theme.colors.textSecondary, ...Typography.default() }}> - Install gemini CLI if available • - </Text> - <Pressable onPress={() => { - if (Platform.OS === 'web') { - window.open('https://ai.google.dev/gemini-api/docs/get-started', '_blank'); - } - }}> - <Text style={{ fontSize: 11, color: theme.colors.textLink, ...Typography.default() }}> - View Gemini Docs → - </Text> - </Pressable> - </View> - </View> - )} + const { layout, profiles, agent, machine, footer } = model.wizardProps; - {/* Custom profiles - show first */} - {profiles.map((profile) => { - const availability = isProfileAvailable(profile); - - return ( - <Pressable - key={profile.id} - style={[ - styles.profileListItem, - selectedProfileId === profile.id && styles.profileListItemSelected, - !availability.available && { opacity: 0.5 } - ]} - onPress={() => availability.available && selectProfile(profile.id)} - disabled={!availability.available} - > - <View style={[styles.profileIcon, { backgroundColor: theme.colors.button.secondary.tint }]}> - <Text style={{ fontSize: 16, color: theme.colors.button.primary.tint, ...Typography.default() }}> - {profile.compatibility.claude && profile.compatibility.codex ? '✳꩜' : - profile.compatibility.claude ? '✳' : '꩜'} - </Text> - </View> - <View style={{ flex: 1 }}> - <Text style={styles.profileListName}>{profile.name}</Text> - <Text style={styles.profileListDetails}> - {getProfileSubtitle(profile)} - </Text> - </View> - <View style={{ flexDirection: 'row', alignItems: 'center', gap: 12 }}> - {selectedProfileId === profile.id && ( - <Ionicons name="checkmark-circle" size={20} color={theme.colors.text} /> - )} - <Pressable - hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} - onPress={(e) => { - e.stopPropagation(); - handleDeleteProfile(profile); - }} - > - <Ionicons name="trash-outline" size={20} color={theme.colors.deleteAction} /> - </Pressable> - <Pressable - hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} - onPress={(e) => { - e.stopPropagation(); - handleDuplicateProfile(profile); - }} - > - <Ionicons name="copy-outline" size={20} color={theme.colors.button.secondary.tint} /> - </Pressable> - <Pressable - hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} - onPress={(e) => { - e.stopPropagation(); - handleEditProfile(profile); - }} - > - <Ionicons name="create-outline" size={20} color={theme.colors.button.secondary.tint} /> - </Pressable> - </View> - </Pressable> - ); - })} - - {/* Built-in profiles - show after custom */} - {DEFAULT_PROFILES.map((profileDisplay) => { - const profile = getBuiltInProfile(profileDisplay.id); - if (!profile) return null; - - const availability = isProfileAvailable(profile); - - return ( - <Pressable - key={profile.id} - style={[ - styles.profileListItem, - selectedProfileId === profile.id && styles.profileListItemSelected, - !availability.available && { opacity: 0.5 } - ]} - onPress={() => availability.available && selectProfile(profile.id)} - disabled={!availability.available} - > - <View style={styles.profileIcon}> - <Text style={{ fontSize: 16, color: theme.colors.button.primary.tint, ...Typography.default() }}> - {profile.compatibility.claude && profile.compatibility.codex ? '✳꩜' : - profile.compatibility.claude ? '✳' : '꩜'} - </Text> - </View> - <View style={{ flex: 1 }}> - <Text style={styles.profileListName}>{profile.name}</Text> - <Text style={styles.profileListDetails}> - {getProfileSubtitle(profile)} - </Text> - </View> - <View style={{ flexDirection: 'row', alignItems: 'center', gap: 12 }}> - {selectedProfileId === profile.id && ( - <Ionicons name="checkmark-circle" size={20} color={theme.colors.text} /> - )} - <Pressable - hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} - onPress={(e) => { - e.stopPropagation(); - handleEditProfile(profile); - }} - > - <Ionicons name="create-outline" size={20} color={theme.colors.button.secondary.tint} /> - </Pressable> - </View> - </Pressable> - ); - })} - - {/* Profile Action Buttons */} - <View style={{ flexDirection: 'row', gap: 8, marginBottom: 12 }}> - <Pressable - style={[styles.addProfileButton, { flex: 1 }]} - onPress={handleAddProfile} - > - <Ionicons name="add-circle-outline" size={20} color={theme.colors.button.secondary.tint} /> - <Text style={styles.addProfileButtonText}> - Add - </Text> - </Pressable> - <Pressable - style={[ - styles.addProfileButton, - { flex: 1 }, - !selectedProfile && { opacity: 0.4 } - ]} - onPress={() => selectedProfile && handleDuplicateProfile(selectedProfile)} - disabled={!selectedProfile} - > - <Ionicons name="copy-outline" size={20} color={theme.colors.button.secondary.tint} /> - <Text style={styles.addProfileButtonText}> - Duplicate - </Text> - </Pressable> - <Pressable - style={[ - styles.addProfileButton, - { flex: 1 }, - (!selectedProfile || selectedProfile.isBuiltIn) && { opacity: 0.4 } - ]} - onPress={() => selectedProfile && !selectedProfile.isBuiltIn && handleDeleteProfile(selectedProfile)} - disabled={!selectedProfile || selectedProfile.isBuiltIn} - > - <Ionicons name="trash-outline" size={20} color={theme.colors.deleteAction} /> - <Text style={[styles.addProfileButtonText, { color: theme.colors.deleteAction }]}> - Delete - </Text> - </Pressable> - </View> - - {/* Section 2: Machine Selection */} - <View ref={machineSectionRef}> - <View style={{ flexDirection: 'row', alignItems: 'center', gap: 8, marginBottom: 8, marginTop: 12 }}> - <Text style={[styles.sectionHeader, { marginBottom: 0, marginTop: 0 }]}>2.</Text> - <Ionicons name="desktop-outline" size={18} color={theme.colors.text} /> - <Text style={[styles.sectionHeader, { marginBottom: 0, marginTop: 0 }]}>Select Machine</Text> - </View> - </View> - - <View style={{ marginBottom: 24 }}> - <SearchableListSelector<typeof machines[0]> - config={{ - getItemId: (machine) => machine.id, - getItemTitle: (machine) => machine.metadata?.displayName || machine.metadata?.host || machine.id, - getItemSubtitle: undefined, - getItemIcon: (machine) => ( - <Ionicons - name="desktop-outline" - size={24} - color={theme.colors.textSecondary} - /> - ), - getRecentItemIcon: (machine) => ( - <Ionicons - name="time-outline" - size={24} - color={theme.colors.textSecondary} - /> - ), - getItemStatus: (machine) => { - const offline = !isMachineOnline(machine); - return { - text: offline ? 'offline' : 'online', - color: offline ? theme.colors.status.disconnected : theme.colors.status.connected, - dotColor: offline ? theme.colors.status.disconnected : theme.colors.status.connected, - isPulsing: !offline, - }; - }, - formatForDisplay: (machine) => machine.metadata?.displayName || machine.metadata?.host || machine.id, - parseFromDisplay: (text) => { - return machines.find(m => - m.metadata?.displayName === text || m.metadata?.host === text || m.id === text - ) || null; - }, - filterItem: (machine, searchText) => { - const displayName = (machine.metadata?.displayName || '').toLowerCase(); - const host = (machine.metadata?.host || '').toLowerCase(); - const search = searchText.toLowerCase(); - return displayName.includes(search) || host.includes(search); - }, - searchPlaceholder: "Type to filter machines...", - recentSectionTitle: "Recent Machines", - favoritesSectionTitle: "Favorite Machines", - noItemsMessage: "No machines available", - showFavorites: true, - showRecent: true, - showSearch: true, - allowCustomInput: false, - compactItems: true, - }} - items={machines} - recentItems={recentMachines} - favoriteItems={machines.filter(m => favoriteMachines.includes(m.id))} - selectedItem={selectedMachine || null} - onSelect={(machine) => { - setSelectedMachineId(machine.id); - const bestPath = getRecentPathForMachine(machine.id, recentMachinePaths); - setSelectedPath(bestPath); - }} - onToggleFavorite={(machine) => { - const isInFavorites = favoriteMachines.includes(machine.id); - if (isInFavorites) { - setFavoriteMachines(favoriteMachines.filter(id => id !== machine.id)); - } else { - setFavoriteMachines([...favoriteMachines, machine.id]); - } - }} - /> - </View> - - {/* Section 3: Working Directory */} - <View ref={pathSectionRef}> - <View style={{ flexDirection: 'row', alignItems: 'center', gap: 8, marginBottom: 8, marginTop: 12 }}> - <Text style={[styles.sectionHeader, { marginBottom: 0, marginTop: 0 }]}>3.</Text> - <Ionicons name="folder-outline" size={18} color={theme.colors.text} /> - <Text style={[styles.sectionHeader, { marginBottom: 0, marginTop: 0 }]}>Select Working Directory</Text> - </View> - </View> - - <View style={{ marginBottom: 24 }}> - <SearchableListSelector<string> - config={{ - getItemId: (path) => path, - getItemTitle: (path) => formatPathRelativeToHome(path, selectedMachine?.metadata?.homeDir), - getItemSubtitle: undefined, - getItemIcon: (path) => ( - <Ionicons - name="folder-outline" - size={24} - color={theme.colors.textSecondary} - /> - ), - getRecentItemIcon: (path) => ( - <Ionicons - name="time-outline" - size={24} - color={theme.colors.textSecondary} - /> - ), - getFavoriteItemIcon: (path) => ( - <Ionicons - name={path === selectedMachine?.metadata?.homeDir ? "home-outline" : "star-outline"} - size={24} - color={theme.colors.textSecondary} - /> - ), - canRemoveFavorite: (path) => path !== selectedMachine?.metadata?.homeDir, - formatForDisplay: (path) => formatPathRelativeToHome(path, selectedMachine?.metadata?.homeDir), - parseFromDisplay: (text) => { - if (selectedMachine?.metadata?.homeDir) { - return resolveAbsolutePath(text, selectedMachine.metadata.homeDir); - } - return null; - }, - filterItem: (path, searchText) => { - const displayPath = formatPathRelativeToHome(path, selectedMachine?.metadata?.homeDir); - return displayPath.toLowerCase().includes(searchText.toLowerCase()); - }, - searchPlaceholder: "Type to filter or enter custom directory...", - recentSectionTitle: "Recent Directories", - favoritesSectionTitle: "Favorite Directories", - noItemsMessage: "No recent directories", - showFavorites: true, - showRecent: true, - showSearch: true, - allowCustomInput: true, - compactItems: true, - }} - items={recentPaths} - recentItems={recentPaths} - favoriteItems={(() => { - if (!selectedMachine?.metadata?.homeDir) return []; - const homeDir = selectedMachine.metadata.homeDir; - // Include home directory plus user favorites - return [homeDir, ...favoriteDirectories.map(fav => resolveAbsolutePath(fav, homeDir))]; - })()} - selectedItem={selectedPath} - onSelect={(path) => { - setSelectedPath(path); - }} - onToggleFavorite={(path) => { - const homeDir = selectedMachine?.metadata?.homeDir; - if (!homeDir) return; - - // Don't allow removing home directory (handled by canRemoveFavorite) - if (path === homeDir) return; - - // Convert to relative format for storage - const relativePath = formatPathRelativeToHome(path, homeDir); - - // Check if already in favorites - const isInFavorites = favoriteDirectories.some(fav => - resolveAbsolutePath(fav, homeDir) === path - ); - - if (isInFavorites) { - // Remove from favorites - setFavoriteDirectories(favoriteDirectories.filter(fav => - resolveAbsolutePath(fav, homeDir) !== path - )); - } else { - // Add to favorites - setFavoriteDirectories([...favoriteDirectories, relativePath]); - } - }} - context={{ homeDir: selectedMachine?.metadata?.homeDir }} - /> - </View> - - {/* Section 4: Permission Mode */} - <View ref={permissionSectionRef}> - <Text style={styles.sectionHeader}>4. Permission Mode</Text> - </View> - <ItemGroup title=""> - {(agentType === 'codex' - ? [ - { value: 'default' as PermissionMode, label: 'Default', description: 'Ask for permissions', icon: 'shield-outline' }, - { value: 'read-only' as PermissionMode, label: 'Read Only', description: 'Read-only mode', icon: 'eye-outline' }, - { value: 'safe-yolo' as PermissionMode, label: 'Safe YOLO', description: 'Workspace write with approval', icon: 'shield-checkmark-outline' }, - { value: 'yolo' as PermissionMode, label: 'YOLO', description: 'Full access, skip permissions', icon: 'flash-outline' }, - ] - : [ - { value: 'default' as PermissionMode, label: 'Default', description: 'Ask for permissions', icon: 'shield-outline' }, - { value: 'acceptEdits' as PermissionMode, label: 'Accept Edits', description: 'Auto-approve edits', icon: 'checkmark-outline' }, - { value: 'plan' as PermissionMode, label: 'Plan', description: 'Plan before executing', icon: 'list-outline' }, - { value: 'bypassPermissions' as PermissionMode, label: 'Bypass Permissions', description: 'Skip all permissions', icon: 'flash-outline' }, - ] - ).map((option, index, array) => ( - <Item - key={option.value} - title={option.label} - subtitle={option.description} - leftElement={ - <Ionicons - name={option.icon as any} - size={24} - color={permissionMode === option.value ? theme.colors.button.primary.tint : theme.colors.textSecondary} - /> - } - rightElement={permissionMode === option.value ? ( - <Ionicons - name="checkmark-circle" - size={20} - color={theme.colors.button.primary.tint} - /> - ) : null} - onPress={() => setPermissionMode(option.value)} - showChevron={false} - selected={permissionMode === option.value} - showDivider={index < array.length - 1} - style={permissionMode === option.value ? { - borderWidth: 2, - borderColor: theme.colors.button.primary.tint, - borderRadius: Platform.select({ ios: 10, default: 16 }), - } : undefined} - /> - ))} - </ItemGroup> - - {/* Section 5: Advanced Options (Collapsible) */} - {experimentsEnabled && ( - <> - <Pressable - style={styles.advancedHeader} - onPress={() => setShowAdvanced(!showAdvanced)} - > - <Text style={styles.advancedHeaderText}>Advanced Options</Text> - <Ionicons - name={showAdvanced ? "chevron-up" : "chevron-down"} - size={20} - color={theme.colors.text} - /> - </Pressable> - - {showAdvanced && ( - <View style={{ marginBottom: 12 }}> - <SessionTypeSelector - value={sessionType} - onChange={setSessionType} - /> - </View> - )} - </> - )} - </View> - </View> - </View> - </ScrollView> - - {/* Section 5: AgentInput - Sticky at bottom */} - <View style={{ paddingHorizontal: screenWidth > 700 ? 16 : 8, paddingBottom: Math.max(16, safeArea.bottom) }}> - <View style={{ maxWidth: layout.maxWidth, width: '100%', alignSelf: 'center' }}> - <AgentInput - value={sessionPrompt} - onChangeText={setSessionPrompt} - onSend={handleCreateSession} - isSendDisabled={!canCreate} - isSending={isCreating} - placeholder="What would you like to work on?" - autocompletePrefixes={[]} - autocompleteSuggestions={async () => []} - agentType={agentType} - onAgentClick={handleAgentInputAgentClick} - permissionMode={permissionMode} - onPermissionModeChange={handleAgentInputPermissionChange} - modelMode={modelMode} - onModelModeChange={setModelMode} - connectionStatus={connectionStatus} - machineName={selectedMachine?.metadata?.displayName || selectedMachine?.metadata?.host} - onMachineClick={handleAgentInputMachineClick} - currentPath={selectedPath} - onPathClick={handleAgentInputPathClick} - profileId={selectedProfileId} - onProfileClick={handleAgentInputProfileClick} - /> - </View> - </View> - </View> - </KeyboardAvoidingView> + return ( + <View ref={model.popoverBoundaryRef} style={{ flex: 1, width: '100%' }}> + <PopoverPortalTargetProvider> + <PopoverBoundaryProvider boundaryRef={model.popoverBoundaryRef}> + <NewSessionWizard + layout={layout} + profiles={profiles} + agent={agent} + machine={machine} + footer={footer} + /> + </PopoverBoundaryProvider> + </PopoverPortalTargetProvider> + </View> ); } -export default React.memo(NewSessionWizard); +export default React.memo(NewSessionScreen); diff --git a/expo-app/sources/app/(app)/new/pick/machine.tsx b/expo-app/sources/app/(app)/new/pick/machine.tsx index c02580e8d..06dc185ea 100644 --- a/expo-app/sources/app/(app)/new/pick/machine.tsx +++ b/expo-app/sources/app/(app)/new/pick/machine.tsx @@ -1,36 +1,66 @@ import React from 'react'; -import { View, Text } from 'react-native'; -import { Stack, useRouter, useLocalSearchParams } from 'expo-router'; -import { CommonActions, useNavigation } from '@react-navigation/native'; +import { Pressable, Text, View, Platform } from 'react-native'; +import { Stack, useRouter, useLocalSearchParams, useNavigation } from 'expo-router'; +import { CommonActions } from '@react-navigation/native'; import { Typography } from '@/constants/Typography'; -import { useAllMachines, useSessions } from '@/sync/storage'; -import { Ionicons } from '@expo/vector-icons'; -import { isMachineOnline } from '@/utils/machineUtils'; +import { useAllMachines, useSessions, useSetting, useSettingMutable } from '@/sync/storage'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { t } from '@/text'; -import { ItemList } from '@/components/ItemList'; -import { SearchableListSelector } from '@/components/SearchableListSelector'; +import { ItemList } from '@/components/ui/lists/ItemList'; +import { MachineSelector } from '@/components/sessions/new/components/MachineSelector'; +import { getRecentMachinesFromSessions } from '@/utils/sessions/recentMachines'; +import { Ionicons } from '@expo/vector-icons'; +import { sync } from '@/sync/sync'; +import { prefetchMachineCapabilities } from '@/hooks/useMachineCapabilitiesCache'; +import { invalidateMachineEnvPresence } from '@/hooks/useMachineEnvPresence'; +import { CAPABILITIES_REQUEST_NEW_SESSION } from '@/capabilities/requests'; +import { HeaderTitleWithAction } from '@/components/navigation/HeaderTitleWithAction'; -const stylesheet = StyleSheet.create((theme) => ({ - container: { - flex: 1, - backgroundColor: theme.colors.groupped.background, - }, - emptyContainer: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - padding: 20, - }, - emptyText: { - fontSize: 16, - color: theme.colors.textSecondary, - textAlign: 'center', - ...Typography.default(), - }, -})); +function useMachinePickerScreenOptions(params: { + title: string; + onBack: () => void; + onRefresh: () => void; + isRefreshing: boolean; + theme: { colors: { header: { tint: string }; textSecondary: string } }; +}) { + const headerLeft = React.useCallback(() => ( + <Pressable + onPress={params.onBack} + hitSlop={10} + style={({ pressed }) => ({ padding: 2, opacity: pressed ? 0.7 : 1 })} + accessibilityRole="button" + accessibilityLabel={t('common.back')} + > + <Ionicons name="chevron-back" size={22} color={params.theme.colors.header.tint} /> + </Pressable> + ), [params.onBack, params.theme.colors.header.tint]); + + const headerTitle = React.useCallback(({ tintColor }: { children: string; tintColor?: string }) => ( + <HeaderTitleWithAction + title={params.title} + tintColor={tintColor ?? params.theme.colors.header.tint} + actionLabel={t('common.refresh')} + actionIconName="refresh-outline" + actionColor={params.theme.colors.textSecondary} + actionDisabled={params.isRefreshing} + actionLoading={params.isRefreshing} + onActionPress={params.onRefresh} + /> + ), [params.isRefreshing, params.onRefresh, params.theme.colors.header.tint, params.theme.colors.textSecondary, params.title]); -export default function MachinePickerScreen() { + return React.useMemo(() => ({ + headerShown: true, + title: params.title, + headerTitle, + headerBackTitle: t('common.back'), + // /new is presented as `containedModal` on iOS. Ensure picker screens are too, + // otherwise they can be pushed "behind" the modal (invisible but on the back stack). + presentation: Platform.OS === 'ios' ? ('containedModal' as const) : undefined, + headerLeft, + }), [headerLeft, headerTitle]); +} + +export default React.memo(function MachinePickerScreen() { const { theme } = useUnistyles(); const styles = stylesheet; const router = useRouter(); @@ -38,9 +68,41 @@ export default function MachinePickerScreen() { const params = useLocalSearchParams<{ selectedId?: string }>(); const machines = useAllMachines(); const sessions = useSessions(); + const useMachinePickerSearch = useSetting('useMachinePickerSearch'); + const [favoriteMachines, setFavoriteMachines] = useSettingMutable('favoriteMachines'); const selectedMachine = machines.find(m => m.id === params.selectedId) || null; + const [isRefreshing, setIsRefreshing] = React.useState(false); + const selectedMachineId = typeof params.selectedId === 'string' ? params.selectedId : null; + + const handleRefresh = React.useCallback(async () => { + if (isRefreshing) return; + setIsRefreshing(true); + try { + // Always refresh the machine list (new machines / metadata updates). + await sync.refreshMachinesThrottled({ staleMs: 0, force: true }); + + // Refresh machine-scoped caches only for the currently-selected machine (if any). + if (selectedMachineId) { + invalidateMachineEnvPresence({ machineId: selectedMachineId }); + await Promise.all([ + prefetchMachineCapabilities({ machineId: selectedMachineId, request: CAPABILITIES_REQUEST_NEW_SESSION }), + ]); + } + } finally { + setIsRefreshing(false); + } + }, [isRefreshing, selectedMachineId]); + + const screenOptions = useMachinePickerScreenOptions({ + title: t('newSession.selectMachineTitle'), + onBack: () => router.back(), + onRefresh: () => { void handleRefresh(); }, + isRefreshing, + theme, + }); + const handleSelectMachine = (machine: typeof machines[0]) => { // Support both callback pattern (feature branch wizard) and navigation params (main) const machineId = machine.id; @@ -52,7 +114,7 @@ export default function MachinePickerScreen() { navigation.dispatch({ ...CommonActions.setParams({ machineId }), source: previousRoute.key, - } as never); + }); } router.back(); @@ -60,43 +122,17 @@ export default function MachinePickerScreen() { // Compute recent machines from sessions const recentMachines = React.useMemo(() => { - const machineIds = new Set<string>(); - const machinesWithTimestamp: Array<{ machine: typeof machines[0]; timestamp: number }> = []; - - sessions?.forEach(item => { - if (typeof item === 'string') return; // Skip section headers - const session = item as any; - if (session.metadata?.machineId && !machineIds.has(session.metadata.machineId)) { - const machine = machines.find(m => m.id === session.metadata.machineId); - if (machine) { - machineIds.add(machine.id); - machinesWithTimestamp.push({ - machine, - timestamp: session.updatedAt || session.createdAt - }); - } - } - }); - - return machinesWithTimestamp - .sort((a, b) => b.timestamp - a.timestamp) - .map(item => item.machine); + return getRecentMachinesFromSessions({ machines, sessions }); }, [sessions, machines]); if (machines.length === 0) { return ( <> - <Stack.Screen - options={{ - headerShown: true, - headerTitle: 'Select Machine', - headerBackTitle: t('common.back') - }} - /> + <Stack.Screen options={screenOptions} /> <View style={styles.container}> <View style={styles.emptyContainer}> <Text style={styles.emptyText}> - No machines available + {t('newSession.noMachinesFound')} </Text> </View> </View> @@ -106,71 +142,44 @@ export default function MachinePickerScreen() { return ( <> - <Stack.Screen - options={{ - headerShown: true, - headerTitle: 'Select Machine', - headerBackTitle: t('common.back') - }} - /> + <Stack.Screen options={screenOptions} /> <ItemList> - <SearchableListSelector<typeof machines[0]> - config={{ - getItemId: (machine) => machine.id, - getItemTitle: (machine) => machine.metadata?.displayName || machine.metadata?.host || machine.id, - getItemSubtitle: undefined, - getItemIcon: (machine) => ( - <Ionicons - name="desktop-outline" - size={24} - color={theme.colors.textSecondary} - /> - ), - getRecentItemIcon: (machine) => ( - <Ionicons - name="time-outline" - size={24} - color={theme.colors.textSecondary} - /> - ), - getItemStatus: (machine) => { - const offline = !isMachineOnline(machine); - return { - text: offline ? 'offline' : 'online', - color: offline ? theme.colors.status.disconnected : theme.colors.status.connected, - dotColor: offline ? theme.colors.status.disconnected : theme.colors.status.connected, - isPulsing: !offline, - }; - }, - formatForDisplay: (machine) => machine.metadata?.displayName || machine.metadata?.host || machine.id, - parseFromDisplay: (text) => { - return machines.find(m => - m.metadata?.displayName === text || m.metadata?.host === text || m.id === text - ) || null; - }, - filterItem: (machine, searchText) => { - const displayName = (machine.metadata?.displayName || '').toLowerCase(); - const host = (machine.metadata?.host || '').toLowerCase(); - const search = searchText.toLowerCase(); - return displayName.includes(search) || host.includes(search); - }, - searchPlaceholder: "Type to filter machines...", - recentSectionTitle: "Recent Machines", - favoritesSectionTitle: "Favorite Machines", - noItemsMessage: "No machines available", - showFavorites: false, // Simpler modal experience - no favorites in modal - showRecent: true, - showSearch: true, - allowCustomInput: false, - compactItems: true, - }} - items={machines} - recentItems={recentMachines} - favoriteItems={[]} - selectedItem={selectedMachine} + <MachineSelector + machines={machines} + selectedMachine={selectedMachine} + recentMachines={recentMachines} + favoriteMachines={machines.filter(m => favoriteMachines.includes(m.id))} onSelect={handleSelectMachine} + showFavorites={true} + showSearch={useMachinePickerSearch} + onToggleFavorite={(machine) => { + const isInFavorites = favoriteMachines.includes(machine.id); + setFavoriteMachines(isInFavorites + ? favoriteMachines.filter(id => id !== machine.id) + : [...favoriteMachines, machine.id] + ); + }} /> </ItemList> </> ); -} \ No newline at end of file +}); + +const stylesheet = StyleSheet.create((theme) => ({ + container: { + flex: 1, + backgroundColor: theme.colors.groupped.background, + }, + emptyContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 20, + }, + emptyText: { + fontSize: 16, + color: theme.colors.textSecondary, + textAlign: 'center', + ...Typography.default(), + }, +})); diff --git a/expo-app/sources/app/(app)/new/pick/path.tsx b/expo-app/sources/app/(app)/new/pick/path.tsx index b0214d6c6..8050cf18c 100644 --- a/expo-app/sources/app/(app)/new/pick/path.tsx +++ b/expo-app/sources/app/(app)/new/pick/path.tsx @@ -1,64 +1,18 @@ -import React, { useState, useMemo, useRef } from 'react'; -import { View, Text, ScrollView, Pressable } from 'react-native'; -import { Stack, useRouter, useLocalSearchParams } from 'expo-router'; -import { CommonActions, useNavigation } from '@react-navigation/native'; -import { ItemGroup } from '@/components/ItemGroup'; -import { Item } from '@/components/Item'; +import React, { useState, useMemo } from 'react'; +import { View, Text, Pressable, Platform } from 'react-native'; +import { Stack, useRouter, useLocalSearchParams, useNavigation } from 'expo-router'; import { Typography } from '@/constants/Typography'; -import { useAllMachines, useSessions, useSetting } from '@/sync/storage'; +import { useAllMachines, useSessions, useSetting, useSettingMutable } from '@/sync/storage'; import { Ionicons } from '@expo/vector-icons'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; -import { layout } from '@/components/layout'; import { t } from '@/text'; -import { MultiTextInput, MultiTextInputHandle } from '@/components/MultiTextInput'; - -const stylesheet = StyleSheet.create((theme) => ({ - container: { - flex: 1, - backgroundColor: theme.colors.groupped.background, - }, - scrollContainer: { - flex: 1, - }, - scrollContent: { - alignItems: 'center', - }, - contentWrapper: { - width: '100%', - maxWidth: layout.maxWidth, - }, - emptyContainer: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - padding: 20, - }, - emptyText: { - fontSize: 16, - color: theme.colors.textSecondary, - textAlign: 'center', - ...Typography.default(), - }, - pathInputContainer: { - flexDirection: 'row', - alignItems: 'center', - gap: 8, - paddingHorizontal: 16, - paddingVertical: 16, - }, - pathInput: { - flex: 1, - backgroundColor: theme.colors.input.background, - borderRadius: 10, - paddingHorizontal: 12, - minHeight: 36, - position: 'relative', - borderWidth: 0.5, - borderColor: theme.colors.divider, - }, -})); +import { ItemList } from '@/components/ui/lists/ItemList'; +import { layout } from '@/components/layout'; +import { PathSelector } from '@/components/sessions/new/components/PathSelector'; +import { SearchHeader } from '@/components/ui/forms/SearchHeader'; +import { getRecentPathsForMachine } from '@/utils/sessions/recentPaths'; -export default function PathPickerScreen() { +export default React.memo(function PathPickerScreen() { const { theme } = useUnistyles(); const styles = stylesheet; const router = useRouter(); @@ -66,10 +20,13 @@ export default function PathPickerScreen() { const params = useLocalSearchParams<{ machineId?: string; selectedPath?: string }>(); const machines = useAllMachines(); const sessions = useSessions(); - const inputRef = useRef<MultiTextInputHandle>(null); const recentMachinePaths = useSetting('recentMachinePaths'); + const usePathPickerSearch = useSetting('usePathPickerSearch'); + const [favoriteDirectoriesRaw, setFavoriteDirectories] = useSettingMutable('favoriteDirectories'); + const favoriteDirectories = favoriteDirectoriesRaw ?? []; const [customPath, setCustomPath] = useState(params.selectedPath || ''); + const [pathSearchQuery, setPathSearchQuery] = useState(''); // Get the selected machine const machine = useMemo(() => { @@ -79,96 +36,88 @@ export default function PathPickerScreen() { // Get recent paths for this machine - prioritize from settings, then fall back to sessions const recentPaths = useMemo(() => { if (!params.machineId) return []; - - const paths: string[] = []; - const pathSet = new Set<string>(); - - // First, add paths from recentMachinePaths (these are the most recent) - recentMachinePaths.forEach(entry => { - if (entry.machineId === params.machineId && !pathSet.has(entry.path)) { - paths.push(entry.path); - pathSet.add(entry.path); - } + return getRecentPathsForMachine({ + machineId: params.machineId, + recentMachinePaths, + sessions, }); + }, [params.machineId, recentMachinePaths, sessions]); - // Then add paths from sessions if we need more - if (sessions) { - const pathsWithTimestamps: Array<{ path: string; timestamp: number }> = []; - sessions.forEach(item => { - if (typeof item === 'string') return; // Skip section headers + const handleSelectPath = React.useCallback((pathOverride?: string) => { + const rawPath = typeof pathOverride === 'string' ? pathOverride : customPath; + const pathToUse = rawPath.trim() || machine?.metadata?.homeDir || '/home'; + router.setParams({ path: pathToUse }); + navigation.goBack(); + }, [customPath, machine, navigation, router]); - const session = item as any; - if (session.metadata?.machineId === params.machineId && session.metadata?.path) { - const path = session.metadata.path; - if (!pathSet.has(path)) { - pathSet.add(path); - pathsWithTimestamps.push({ - path, - timestamp: session.updatedAt || session.createdAt - }); - } - } - }); + const handleBackPress = React.useCallback(() => { + navigation.goBack(); + }, [navigation]); - // Sort session paths by most recent first and add them - pathsWithTimestamps - .sort((a, b) => b.timestamp - a.timestamp) - .forEach(item => paths.push(item.path)); - } + const headerTitle = t('newSession.selectPathTitle'); + const headerBackTitle = t('common.back'); - return paths; - }, [sessions, params.machineId, recentMachinePaths]); + const headerLeft = React.useCallback(() => { + return ( + <Pressable + onPress={handleBackPress} + hitSlop={10} + style={({ pressed }) => ({ + marginLeft: 10, + opacity: pressed ? 0.7 : 1, + padding: 4, + })} + > + <Ionicons name="chevron-back" size={22} color={theme.colors.header.tint} /> + </Pressable> + ); + }, [handleBackPress, theme.colors.header.tint]); + const canConfirmCustomPath = customPath.trim().length > 0; - const handleSelectPath = React.useCallback(() => { - const pathToUse = customPath.trim() || machine?.metadata?.homeDir || '/home'; - // Pass path back via navigation params (main's pattern, received by new/index.tsx) - const state = navigation.getState(); - const previousRoute = state?.routes?.[state.index - 1]; - if (state && state.index > 0 && previousRoute) { - navigation.dispatch({ - ...CommonActions.setParams({ path: pathToUse }), - source: previousRoute.key, - } as never); - } - router.back(); - }, [customPath, router, machine, navigation]); + const headerRight = React.useCallback(() => { + return ( + <Pressable + onPress={() => handleSelectPath()} + disabled={!canConfirmCustomPath} + style={({ pressed }) => ({ + opacity: pressed ? 0.7 : 1, + padding: 4, + })} + > + <Ionicons + name="checkmark" + size={24} + color={theme.colors.header.tint} + /> + </Pressable> + ); + }, [canConfirmCustomPath, handleSelectPath, theme.colors.header.tint]); + + const screenOptions = React.useMemo(() => { + return { + headerShown: true, + title: headerTitle, + headerTitle, + headerBackTitle, + presentation: Platform.OS === 'ios' ? 'containedModal' : undefined, + headerLeft, + headerRight, + } as const; + }, [headerBackTitle, headerLeft, headerRight, headerTitle]); if (!machine) { return ( <> <Stack.Screen - options={{ - headerShown: true, - headerTitle: 'Select Path', - headerBackTitle: t('common.back'), - headerRight: () => ( - <Pressable - onPress={handleSelectPath} - disabled={!customPath.trim()} - style={({ pressed }) => ({ - marginRight: 16, - opacity: pressed ? 0.7 : 1, - padding: 4, - })} - > - <Ionicons - name="checkmark" - size={24} - color={theme.colors.header.tint} - /> - </Pressable> - ) - }} + options={screenOptions} /> - <View style={styles.container}> + <ItemList> <View style={styles.emptyContainer}> - <Text style={styles.emptyText}> - No machine selected - </Text> + <Text style={styles.emptyText}>{t('newSession.noMachineSelected')}</Text> </View> - </View> + </ItemList> </> ); } @@ -176,126 +125,70 @@ export default function PathPickerScreen() { return ( <> <Stack.Screen - options={{ - headerShown: true, - headerTitle: 'Select Path', - headerBackTitle: t('common.back'), - headerRight: () => ( - <Pressable - onPress={handleSelectPath} - disabled={!customPath.trim()} - style={({ pressed }) => ({ - opacity: pressed ? 0.7 : 1, - padding: 4, - })} - > - <Ionicons - name="checkmark" - size={24} - color={theme.colors.header.tint} - /> - </Pressable> - ) - }} + options={screenOptions} /> - <View style={styles.container}> - <ScrollView - style={styles.scrollContainer} - contentContainerStyle={styles.scrollContent} - keyboardShouldPersistTaps="handled" - > - <View style={styles.contentWrapper}> - <ItemGroup title="Enter Path"> - <View style={styles.pathInputContainer}> - <View style={[styles.pathInput, { paddingVertical: 8 }]}> - <MultiTextInput - ref={inputRef} - value={customPath} - onChangeText={setCustomPath} - placeholder="Enter path (e.g. /home/user/projects)" - maxHeight={76} - paddingTop={8} - paddingBottom={8} - // onSubmitEditing={handleSelectPath} - // blurOnSubmit={true} - // returnKeyType="done" - /> - </View> - </View> - </ItemGroup> - - {recentPaths.length > 0 && ( - <ItemGroup title="Recent Paths"> - {recentPaths.map((path, index) => { - const isSelected = customPath.trim() === path; - const isLast = index === recentPaths.length - 1; - - return ( - <Item - key={path} - title={path} - leftElement={ - <Ionicons - name="folder-outline" - size={18} - color={theme.colors.textSecondary} - /> - } - onPress={() => { - setCustomPath(path); - setTimeout(() => inputRef.current?.focus(), 50); - }} - selected={isSelected} - showChevron={false} - pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} - showDivider={!isLast} - /> - ); - })} - </ItemGroup> - )} - - {recentPaths.length === 0 && ( - <ItemGroup title="Suggested Paths"> - {(() => { - const homeDir = machine.metadata?.homeDir || '/home'; - const suggestedPaths = [ - homeDir, - `${homeDir}/projects`, - `${homeDir}/Documents`, - `${homeDir}/Desktop` - ]; - return suggestedPaths.map((path, index) => { - const isSelected = customPath.trim() === path; - - return ( - <Item - key={path} - title={path} - leftElement={ - <Ionicons - name="folder-outline" - size={18} - color={theme.colors.textSecondary} - /> - } - onPress={() => { - setCustomPath(path); - setTimeout(() => inputRef.current?.focus(), 50); - }} - selected={isSelected} - showChevron={false} - pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} - showDivider={index < 3} - /> - ); - }); - })()} - </ItemGroup> - )} - </View> - </ScrollView> - </View> + <ItemList style={{ paddingTop: 0 }} keyboardShouldPersistTaps="handled"> + {usePathPickerSearch && ( + <SearchHeader + value={pathSearchQuery} + onChangeText={setPathSearchQuery} + placeholder={t('newSession.searchPathsPlaceholder')} + /> + )} + <View style={styles.contentWrapper}> + <PathSelector + machineHomeDir={machine.metadata?.homeDir || '/home'} + selectedPath={customPath} + onChangeSelectedPath={setCustomPath} + submitBehavior="confirm" + onSubmitSelectedPath={handleSelectPath} + recentPaths={recentPaths} + usePickerSearch={usePathPickerSearch} + searchVariant="none" + searchQuery={pathSearchQuery} + onChangeSearchQuery={setPathSearchQuery} + favoriteDirectories={favoriteDirectories} + onChangeFavoriteDirectories={setFavoriteDirectories} + /> + </View> + </ItemList> </> ); -} \ No newline at end of file +}); + +const stylesheet = StyleSheet.create((theme) => ({ + emptyContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 20, + }, + emptyText: { + fontSize: 16, + color: theme.colors.textSecondary, + textAlign: 'center', + ...Typography.default(), + }, + contentWrapper: { + width: '100%', + maxWidth: layout.maxWidth, + alignSelf: 'center', + }, + pathInputContainer: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + paddingHorizontal: 16, + paddingVertical: 16, + }, + pathInput: { + flex: 1, + backgroundColor: theme.colors.input.background, + borderRadius: 10, + paddingHorizontal: 12, + minHeight: 36, + position: 'relative', + borderWidth: 0.5, + borderColor: theme.colors.divider, + }, +})); diff --git a/expo-app/sources/app/(app)/new/pick/preview-machine.tsx b/expo-app/sources/app/(app)/new/pick/preview-machine.tsx new file mode 100644 index 000000000..4b3b718cf --- /dev/null +++ b/expo-app/sources/app/(app)/new/pick/preview-machine.tsx @@ -0,0 +1,93 @@ +import React from 'react'; +import { Platform, Pressable } from 'react-native'; +import { Stack, useLocalSearchParams, useNavigation, useRouter } from 'expo-router'; +import { Ionicons } from '@expo/vector-icons'; + +import { ItemList } from '@/components/ui/lists/ItemList'; +import { MachineSelector } from '@/components/sessions/new/components/MachineSelector'; +import { useAllMachines, useSettingMutable } from '@/sync/storage'; +import { t } from '@/text'; +import { useUnistyles } from 'react-native-unistyles'; + +export default React.memo(function PreviewMachinePickerScreen() { + const { theme } = useUnistyles(); + const router = useRouter(); + const navigation = useNavigation(); + const params = useLocalSearchParams<{ selectedId?: string }>(); + const machines = useAllMachines(); + const [favoriteMachines, setFavoriteMachines] = useSettingMutable('favoriteMachines'); + + const selectedMachineId = typeof params.selectedId === 'string' ? params.selectedId : null; + const selectedMachine = machines.find((m) => m.id === selectedMachineId) ?? null; + + const headerLeft = React.useCallback(() => ( + <Pressable + onPress={() => router.back()} + hitSlop={10} + style={({ pressed }) => ({ padding: 2, opacity: pressed ? 0.7 : 1 })} + accessibilityRole="button" + accessibilityLabel={t('common.back')} + > + <Ionicons name="chevron-back" size={22} color={theme.colors.header.tint} /> + </Pressable> + ), [router, theme.colors.header.tint]); + + const screenOptions = React.useCallback(() => { + return { + headerShown: true, + title: t('profiles.previewMachine.title'), + headerBackTitle: t('common.back'), + presentation: Platform.OS === 'ios' ? ('containedModal' as const) : undefined, + headerLeft, + } as const; + }, [headerLeft]); + + const favoriteMachineList = React.useMemo(() => { + const byId = new Map(machines.map((m) => [m.id, m] as const)); + return favoriteMachines.map((id) => byId.get(id)).filter(Boolean) as typeof machines; + }, [favoriteMachines, machines]); + + const toggleFavorite = React.useCallback((machineId: string) => { + if (favoriteMachines.includes(machineId)) { + setFavoriteMachines(favoriteMachines.filter((id) => id !== machineId)); + return; + } + setFavoriteMachines([...favoriteMachines, machineId]); + }, [favoriteMachines, setFavoriteMachines]); + + const setPreviewMachineIdOnPreviousRoute = React.useCallback((previewMachineId: string) => { + const state = (navigation as any)?.getState?.(); + const previousRoute = state?.routes?.[state.index - 1]; + if (!state || typeof state.index !== 'number' || state.index <= 0 || !previousRoute?.key) { + return false; + } + (navigation as any).dispatch({ + type: 'SET_PARAMS', + payload: { params: { previewMachineId } }, + source: previousRoute.key, + }); + return true; + }, [navigation]); + + return ( + <> + <Stack.Screen options={screenOptions} /> + <ItemList> + <MachineSelector + machines={machines} + selectedMachine={selectedMachine} + favoriteMachines={favoriteMachineList} + showRecent={false} + showFavorites={favoriteMachineList.length > 0} + showSearch + searchPlacement={favoriteMachineList.length > 0 ? 'favorites' : 'all'} + onSelect={(machine) => { + setPreviewMachineIdOnPreviousRoute(machine.id); + router.back(); + }} + onToggleFavorite={(machine) => toggleFavorite(machine.id)} + /> + </ItemList> + </> + ); +}); diff --git a/expo-app/sources/app/(app)/new/pick/profile-edit.tsx b/expo-app/sources/app/(app)/new/pick/profile-edit.tsx index 9bf311c82..502a5e2fb 100644 --- a/expo-app/sources/app/(app)/new/pick/profile-edit.tsx +++ b/expo-app/sources/app/(app)/new/pick/profile-edit.tsx @@ -1,91 +1,334 @@ import React from 'react'; -import { View, KeyboardAvoidingView, Platform, useWindowDimensions } from 'react-native'; -import { Stack, useRouter, useLocalSearchParams } from 'expo-router'; +import { View, KeyboardAvoidingView, Platform, useWindowDimensions, Pressable } from 'react-native'; +import { Stack, useRouter, useLocalSearchParams, useNavigation } from 'expo-router'; import { StyleSheet } from 'react-native-unistyles'; import { useUnistyles } from 'react-native-unistyles'; import { useHeaderHeight } from '@react-navigation/elements'; import Constants from 'expo-constants'; import { t } from '@/text'; -import { ProfileEditForm } from '@/components/ProfileEditForm'; +import { ProfileEditForm } from '@/components/profiles/edit'; import { AIBackendProfile } from '@/sync/settings'; import { layout } from '@/components/layout'; -import { callbacks } from '../index'; +import { useSettingMutable } from '@/sync/storage'; +import { DEFAULT_PROFILES, getBuiltInProfile, getBuiltInProfileNameKey, resolveProfileById } from '@/sync/profileUtils'; +import { convertBuiltInProfileToCustom, createEmptyCustomProfile, duplicateProfileForEdit } from '@/sync/profileMutations'; +import { Modal } from '@/modal'; +import { promptUnsavedChangesAlert } from '@/utils/ui/promptUnsavedChangesAlert'; +import { Ionicons } from '@expo/vector-icons'; +import { PopoverPortalTargetProvider } from '@/components/ui/popover'; -export default function ProfileEditScreen() { +export default React.memo(function ProfileEditScreen() { const { theme } = useUnistyles(); const router = useRouter(); - const params = useLocalSearchParams<{ profileData?: string; machineId?: string }>(); + const navigation = useNavigation(); + const params = useLocalSearchParams<{ + profileId?: string | string[]; + cloneFromProfileId?: string | string[]; + profileData?: string | string[]; + machineId?: string | string[]; + }>(); + const profileIdParam = Array.isArray(params.profileId) ? params.profileId[0] : params.profileId; + const cloneFromProfileIdParam = Array.isArray(params.cloneFromProfileId) ? params.cloneFromProfileId[0] : params.cloneFromProfileId; + const profileDataParam = Array.isArray(params.profileData) ? params.profileData[0] : params.profileData; + const machineIdParam = Array.isArray(params.machineId) ? params.machineId[0] : params.machineId; const screenWidth = useWindowDimensions().width; const headerHeight = useHeaderHeight(); + const [profiles, setProfiles] = useSettingMutable('profiles'); + const [, setLastUsedProfile] = useSettingMutable('lastUsedProfile'); + const [isDirty, setIsDirty] = React.useState(false); + const isDirtyRef = React.useRef(false); + const saveRef = React.useRef<(() => boolean) | null>(null); + + React.useEffect(() => { + isDirtyRef.current = isDirty; + }, [isDirty]); + + React.useEffect(() => { + // On iOS native-stack modals, swipe-down dismissal can bypass `beforeRemove` in practice. + // The only reliable way to ensure unsaved edits aren't lost is to disable the gesture + // while the form is dirty, and rely on the header back/cancel flow (which we guard). + const setOptions = (navigation as any)?.setOptions; + if (typeof setOptions !== 'function') return; + setOptions({ gestureEnabled: !isDirty }); + }, [isDirty, navigation]); + + React.useEffect(() => { + const setOptions = (navigation as any)?.setOptions; + if (typeof setOptions !== 'function') return; + return () => { + // Always re-enable the gesture when leaving this screen. + setOptions({ gestureEnabled: true }); + }; + }, [navigation]); // Deserialize profile from URL params const profile: AIBackendProfile = React.useMemo(() => { - if (params.profileData) { + if (profileDataParam) { try { - return JSON.parse(decodeURIComponent(params.profileData)); + // Params may arrive already decoded (native) or URL-encoded (web / manual encodeURIComponent). + // Try raw JSON first, then fall back to decodeURIComponent. + try { + return JSON.parse(profileDataParam); + } catch { + return JSON.parse(decodeURIComponent(profileDataParam)); + } } catch (error) { console.error('Failed to parse profile data:', error); } } + const resolveById = (id: string) => resolveProfileById(id, profiles); + + if (cloneFromProfileIdParam) { + const base = resolveById(cloneFromProfileIdParam); + if (base) { + return duplicateProfileForEdit(base, { copySuffix: t('profiles.copySuffix') }); + } + } + + if (profileIdParam) { + const existing = resolveById(profileIdParam); + if (existing) { + return existing; + } + } + // Return empty profile for new profile creation - return { - id: '', - name: '', - anthropicConfig: {}, - environmentVariables: [], - compatibility: { claude: true, codex: true }, - isBuiltIn: false, - createdAt: Date.now(), - updatedAt: Date.now(), - version: '1.0.0', - }; - }, [params.profileData]); + return createEmptyCustomProfile(); + }, [cloneFromProfileIdParam, profileDataParam, profileIdParam, profiles]); - const handleSave = (savedProfile: AIBackendProfile) => { - // Call the callback to notify wizard of saved profile - callbacks.onProfileSaved(savedProfile); - router.back(); - }; + const confirmDiscard = React.useCallback(async () => { + const saveText = profile.isBuiltIn ? t('common.saveAs') : t('common.save'); + const message = profile.isBuiltIn + ? `${t('common.unsavedChangesWarning')}\n\n${t('profiles.builtInSaveAsHint')}` + : t('common.unsavedChangesWarning'); + return promptUnsavedChangesAlert( + (title, message, buttons) => Modal.alert(title, message, buttons), + { + title: t('common.discardChanges'), + message, + discardText: t('common.discard'), + saveText, + keepEditingText: t('common.keepEditing'), + }, + ); + }, [profile.isBuiltIn]); + + React.useEffect(() => { + const addListener = (navigation as any)?.addListener; + if (typeof addListener !== 'function') { + return; + } + + const subscription = addListener.call(navigation, 'beforeRemove', (e: any) => { + if (!isDirtyRef.current) return; + + e.preventDefault(); + + void (async () => { + const decision = await confirmDiscard(); + if (decision === 'discard') { + isDirtyRef.current = false; + (navigation as any).dispatch(e.data.action); + } else if (decision === 'save') { + saveRef.current?.(); + } + })(); + }); + + return () => subscription?.remove?.(); + }, [confirmDiscard, navigation]); + + const handleSave = (savedProfile: AIBackendProfile): boolean => { + if (!savedProfile.name || savedProfile.name.trim() === '') { + Modal.alert(t('common.error'), t('profiles.nameRequired')); + return false; + } - const handleCancel = () => { + const isBuiltIn = + savedProfile.isBuiltIn === true || + DEFAULT_PROFILES.some((bp) => bp.id === savedProfile.id) || + getBuiltInProfileNameKey(savedProfile.id) !== null; + + let profileToSave = savedProfile; + if (isBuiltIn) { + profileToSave = convertBuiltInProfileToCustom(savedProfile); + } + + const builtInNames = DEFAULT_PROFILES + .map((bp) => { + const key = getBuiltInProfileNameKey(bp.id); + return key ? t(key).trim() : null; + }) + .filter((name): name is string => Boolean(name)); + const hasBuiltInNameConflict = builtInNames.includes(profileToSave.name.trim()); + + // Duplicate name guard (same behavior as settings/profiles) + const isDuplicateName = profiles.some((p) => { + if (isBuiltIn) { + return p.name.trim() === profileToSave.name.trim(); + } + return p.id !== profileToSave.id && p.name.trim() === profileToSave.name.trim(); + }); + if (isDuplicateName || hasBuiltInNameConflict) { + Modal.alert(t('common.error'), t('profiles.duplicateName')); + return false; + } + + const existingIndex = profiles.findIndex((p) => p.id === profileToSave.id); + const isNewProfile = existingIndex < 0; + const updatedProfiles = existingIndex >= 0 + ? profiles.map((p, idx) => idx === existingIndex ? { ...profileToSave, updatedAt: Date.now() } : p) + : [...profiles, profileToSave]; + + setProfiles(updatedProfiles); + + // Update last used profile for convenience in other screens. + if (isNewProfile) { + setLastUsedProfile(profileToSave.id); + // For newly created profiles (including "Save As" from a built-in profile), prefer passing the id + // back to the previous picker route (if present). The picker already knows how to forward the + // selection to /new and close itself. This avoids stacking /new on top of /new (wizard case). + isDirtyRef.current = false; + setIsDirty(false); + const state = (navigation as any).getState?.(); + const previousRoute = state?.routes?.[state.index - 1]; + if (state && state.index > 0 && previousRoute) { + (navigation as any).dispatch({ + type: 'SET_PARAMS', + payload: { params: { profileId: profileToSave.id } }, + source: previousRoute.key, + } as never); + router.back(); + return true; + } + + // Fallback: if we can't find a previous route to set params on, go to /new directly. + router.replace({ + pathname: '/new', + params: { profileId: profileToSave.id }, + } as any); + return true; + } + + // Pass selection back to the /new screen via navigation params (unmount-safe). + const state = (navigation as any).getState?.(); + const previousRoute = state?.routes?.[state.index - 1]; + if (state && state.index > 0 && previousRoute) { + (navigation as any).dispatch({ + type: 'SET_PARAMS', + payload: { params: { profileId: profileToSave.id } }, + source: previousRoute.key, + } as never); + } + // Prevent the unsaved-changes guard from triggering on successful save. + isDirtyRef.current = false; + setIsDirty(false); router.back(); + return true; }; + const handleCancel = React.useCallback(() => { + void (async () => { + if (!isDirtyRef.current) { + router.back(); + return; + } + const decision = await confirmDiscard(); + if (decision === 'discard') { + isDirtyRef.current = false; + router.back(); + } else if (decision === 'save') { + saveRef.current?.(); + } + })(); + }, [confirmDiscard, router]); + + const headerTitle = profile.name ? t('profiles.editProfile') : t('profiles.addProfile'); + const headerBackTitle = t('common.back'); + + const headerLeft = React.useCallback(() => { + return ( + <Pressable + onPress={handleCancel} + accessibilityRole="button" + accessibilityLabel={t('common.cancel')} + hitSlop={12} + style={({ pressed }) => ({ + opacity: pressed ? 0.7 : 1, + padding: 4, + })} + > + <Ionicons name="close" size={24} color={theme.colors.header.tint} /> + </Pressable> + ); + }, [handleCancel, theme.colors.header.tint]); + + const handleSavePress = React.useCallback(() => { + saveRef.current?.(); + }, []); + + const headerRight = React.useCallback(() => { + return ( + <Pressable + onPress={handleSavePress} + disabled={!isDirty} + accessibilityRole="button" + accessibilityLabel={t('common.save')} + hitSlop={12} + style={({ pressed }) => ({ + opacity: !isDirty ? 0.35 : pressed ? 0.7 : 1, + padding: 4, + })} + > + <Ionicons name="checkmark" size={24} color={theme.colors.header.tint} /> + </Pressable> + ); + }, [handleSavePress, isDirty, theme.colors.header.tint]); + + const screenOptions = React.useMemo(() => { + return { + headerTitle, + headerBackTitle, + headerLeft, + headerRight, + } as const; + }, [headerBackTitle, headerLeft, headerRight, headerTitle]); + return ( - <KeyboardAvoidingView - behavior={Platform.OS === 'ios' ? 'padding' : 'height'} - keyboardVerticalOffset={Platform.OS === 'ios' ? Constants.statusBarHeight + headerHeight : 0} - style={profileEditScreenStyles.container} - > - <Stack.Screen - options={{ - headerTitle: profile.name ? t('profiles.editProfile') : t('profiles.addProfile'), - headerBackTitle: t('common.back'), - }} - /> - <View style={[ - { flex: 1, paddingHorizontal: screenWidth > 700 ? 16 : 8 } - ]}> + <PopoverPortalTargetProvider> + <KeyboardAvoidingView + behavior={Platform.OS === 'ios' ? 'padding' : 'height'} + keyboardVerticalOffset={Platform.OS === 'ios' ? Constants.statusBarHeight + headerHeight : 0} + style={profileEditScreenStyles.container} + > + <Stack.Screen + options={screenOptions} + /> <View style={[ - { maxWidth: layout.maxWidth, flex: 1, width: '100%', alignSelf: 'center' } + { flex: 1, paddingHorizontal: screenWidth > 700 ? 16 : 8 } ]}> - <ProfileEditForm - profile={profile} - machineId={params.machineId || null} - onSave={handleSave} - onCancel={handleCancel} - /> + <View style={[ + { maxWidth: layout.maxWidth, flex: 1, width: '100%', alignSelf: 'center' } + ]}> + <ProfileEditForm + profile={profile} + machineId={machineIdParam || null} + onSave={handleSave} + onCancel={handleCancel} + onDirtyChange={setIsDirty} + saveRef={saveRef} + /> + </View> </View> - </View> - </KeyboardAvoidingView> + </KeyboardAvoidingView> + </PopoverPortalTargetProvider> ); -} +}); const profileEditScreenStyles = StyleSheet.create((theme, rt) => ({ container: { flex: 1, - backgroundColor: theme.colors.surface, - paddingTop: rt.insets.top, + backgroundColor: theme.colors.groupped.background, paddingBottom: rt.insets.bottom, }, })); diff --git a/expo-app/sources/app/(app)/new/pick/profile.tsx b/expo-app/sources/app/(app)/new/pick/profile.tsx new file mode 100644 index 000000000..2ede02ef8 --- /dev/null +++ b/expo-app/sources/app/(app)/new/pick/profile.tsx @@ -0,0 +1,422 @@ +import React from 'react'; +import { Stack, useRouter, useLocalSearchParams, useNavigation } from 'expo-router'; +import { Ionicons } from '@expo/vector-icons'; +import { Platform, Pressable } from 'react-native'; +import { Item } from '@/components/ui/lists/Item'; +import { ItemGroup } from '@/components/ui/lists/ItemGroup'; +import { useSetting, useSettingMutable } from '@/sync/storage'; +import { t } from '@/text'; +import { StyleSheet, useUnistyles } from 'react-native-unistyles'; +import { AIBackendProfile } from '@/sync/settings'; +import { Modal } from '@/modal'; +import type { ItemAction } from '@/components/ui/lists/itemActions'; +import { machinePreviewEnv } from '@/sync/ops'; +import { getProfileEnvironmentVariables } from '@/sync/settings'; +import { getRequiredSecretEnvVarNames } from '@/sync/profileSecrets'; +import { getTempData, storeTempData } from '@/utils/tempDataStore'; +import { ProfilesList } from '@/components/profiles/ProfilesList'; +import { SecretRequirementModal, type SecretRequirementModalResult } from '@/components/secrets/requirements'; +import { getSecretSatisfaction } from '@/utils/secrets/secretSatisfaction'; +import { useMachineEnvPresence } from '@/hooks/useMachineEnvPresence'; +import { PopoverPortalTargetProvider } from '@/components/ui/popover'; + +export default React.memo(function ProfilePickerScreen() { + const { theme } = useUnistyles(); + const router = useRouter(); + const navigation = useNavigation(); + const params = useLocalSearchParams<{ selectedId?: string; machineId?: string; profileId?: string | string[]; secretRequirementResultId?: string }>(); + const useProfiles = useSetting('useProfiles'); + const experimentsEnabled = useSetting('experiments'); + const [secrets, setSecrets] = useSettingMutable('secrets'); + const [secretBindingsByProfileId, setSecretBindingsByProfileId] = useSettingMutable('secretBindingsByProfileId'); + const [profiles, setProfiles] = useSettingMutable('profiles'); + const [favoriteProfileIds, setFavoriteProfileIds] = useSettingMutable('favoriteProfiles'); + + const selectedId = typeof params.selectedId === 'string' ? params.selectedId : ''; + const machineId = typeof params.machineId === 'string' ? params.machineId : undefined; + const profileId = Array.isArray(params.profileId) ? params.profileId[0] : params.profileId; + const secretRequirementResultId = typeof params.secretRequirementResultId === 'string' ? params.secretRequirementResultId : ''; + const setParamsOnPreviousAndClose = React.useCallback((next: { profileId: string; secretId?: string; secretSessionOnlyId?: string }) => { + const state = navigation.getState(); + const previousRoute = state?.routes?.[state.index - 1]; + if (state && state.index > 0 && previousRoute) { + navigation.dispatch({ + type: 'SET_PARAMS', + payload: { params: next }, + source: previousRoute.key, + } as never); + } + router.back(); + }, [navigation, router]); + + // When the secret requirement screen is used (native), it returns a temp id via params. + // We handle it here and then return to the previous route with the correct selection. + React.useEffect(() => { + if (typeof secretRequirementResultId !== 'string' || secretRequirementResultId.length === 0) { + return; + } + + const entry = getTempData<{ + profileId: string; + revertOnCancel: boolean; + result: SecretRequirementModalResult; + }>(secretRequirementResultId); + + const clearParam = () => { + const setParams = (navigation as any)?.setParams; + if (typeof setParams === 'function') { + setParams({ secretRequirementResultId: undefined }); + } else { + navigation.dispatch({ + type: 'SET_PARAMS', + payload: { params: { secretRequirementResultId: undefined } }, + } as never); + } + }; + + if (!entry || !entry?.result) { + clearParam(); + return; + } + + const result = entry.result; + if (result.action === 'cancel') { + clearParam(); + return; + } + + const resolvedProfileId = entry.profileId; + if (result.action === 'useMachine') { + setParamsOnPreviousAndClose({ profileId: resolvedProfileId, secretId: '' }); + return; + } + + if (result.action === 'enterOnce') { + const tempId = storeTempData({ secret: result.value }); + setParamsOnPreviousAndClose({ profileId: resolvedProfileId, secretSessionOnlyId: tempId }); + return; + } + + if (result.action === 'selectSaved') { + const envVarName = result.envVarName.trim().toUpperCase(); + if (result.setDefault && envVarName.length > 0) { + setSecretBindingsByProfileId({ + ...secretBindingsByProfileId, + [resolvedProfileId]: { + ...(secretBindingsByProfileId[resolvedProfileId] ?? {}), + [envVarName]: result.secretId, + }, + }); + } + setParamsOnPreviousAndClose({ profileId: resolvedProfileId, secretId: result.secretId }); + return; + } + + clearParam(); + }, [navigation, secretBindingsByProfileId, secretRequirementResultId, setParamsOnPreviousAndClose, setSecretBindingsByProfileId]); + + const openSecretModal = React.useCallback((profile: AIBackendProfile, envVarName: string) => { + const requiredSecretName = envVarName.trim().toUpperCase(); + if (!requiredSecretName) return; + + const requiredSecretNames = getRequiredSecretEnvVarNames(profile); + + if (Platform.OS !== 'web') { + const selectedSecretIdByEnvVarName = secretBindingsByProfileId[profile.id] ?? null; + router.push({ + pathname: '/new/pick/secret-requirement', + params: { + profileId: profile.id, + machineId: machineId ?? undefined, + secretEnvVarName: requiredSecretName, + secretEnvVarNames: requiredSecretNames.join(','), + revertOnCancel: '0', + selectedSecretIdByEnvVarName: selectedSecretIdByEnvVarName + ? encodeURIComponent(JSON.stringify(selectedSecretIdByEnvVarName)) + : undefined, + }, + } as any); + return; + } + + const handleResolve = (result: SecretRequirementModalResult) => { + if (result.action === 'cancel') return; + + if (result.action === 'useMachine') { + // Explicit choice: prefer machine key (do not auto-apply defaults in parent). + setParamsOnPreviousAndClose({ profileId: profile.id, secretId: '' }); + return; + } + + if (result.action === 'enterOnce') { + const tempId = storeTempData({ secret: result.value }); + setParamsOnPreviousAndClose({ profileId: profile.id, secretSessionOnlyId: tempId }); + return; + } + + if (result.action === 'selectSaved') { + if (result.setDefault) { + setSecretBindingsByProfileId({ + ...secretBindingsByProfileId, + [profile.id]: { + ...(secretBindingsByProfileId[profile.id] ?? {}), + [requiredSecretName]: result.secretId, + }, + }); + } + setParamsOnPreviousAndClose({ profileId: profile.id, secretId: result.secretId }); + } + }; + + Modal.show({ + component: SecretRequirementModal, + props: { + profile, + secretEnvVarName: requiredSecretName, + secretEnvVarNames: requiredSecretNames, + machineId: machineId ?? null, + secrets, + defaultSecretId: secretBindingsByProfileId[profile.id]?.[requiredSecretName] ?? null, + defaultSecretIdByEnvVarName: secretBindingsByProfileId[profile.id] ?? null, + onChangeSecrets: setSecrets, + allowSessionOnly: true, + onResolve: handleResolve, + onRequestClose: () => handleResolve({ action: 'cancel' }), + }, + closeOnBackdrop: true, + }); + }, [machineId, router, secretBindingsByProfileId, secrets, setParamsOnPreviousAndClose, setSecretBindingsByProfileId, setSecrets]); + + const handleProfilePress = React.useCallback(async (profile: AIBackendProfile) => { + const profileId = profile.id; + const requiredSecretNames = getRequiredSecretEnvVarNames(profile); + const machineEnvReadyByName: Record<string, boolean> = {}; + + if (machineId && requiredSecretNames.length > 0) { + // Best-effort: ask daemon for presence of all required secrets. + const preview = await machinePreviewEnv(machineId, { + keys: requiredSecretNames, + extraEnv: getProfileEnvironmentVariables(profile), + sensitiveKeys: requiredSecretNames, + }); + if (preview.supported) { + for (const name of requiredSecretNames) { + machineEnvReadyByName[name] = Boolean(preview.response.values[name]?.isSet); + } + } else { + for (const name of requiredSecretNames) { + machineEnvReadyByName[name] = false; + } + } + } + + const satisfaction = getSecretSatisfaction({ + profile, + secrets, + defaultBindings: secretBindingsByProfileId[profileId] ?? null, + machineEnvReadyByName: machineId ? machineEnvReadyByName : null, + }); + + // If all required secrets are satisfied solely by a default saved secret AND this is the primary secret, + // we can still support the single-secret return param for legacy callers. + if (requiredSecretNames.length === 1) { + const only = requiredSecretNames[0]!; + const item = satisfaction.items.find((i) => i.envVarName === only) ?? null; + if (item?.satisfiedBy === 'defaultSaved' && item.savedSecretId) { + setParamsOnPreviousAndClose({ profileId, secretId: item.savedSecretId }); + return; + } + } + + if (!satisfaction.isSatisfied) { + const missing = satisfaction.items.find((i) => i.required && !i.isSatisfied)?.envVarName ?? null; + if (missing) { + openSecretModal(profile, missing); + return; + } + } + + setParamsOnPreviousAndClose({ profileId }); + }, [machineId, openSecretModal, secretBindingsByProfileId, secrets, setParamsOnPreviousAndClose]); + + const allRequiredSecretNames = React.useMemo(() => { + const names = new Set<string>(); + for (const p of profiles) { + for (const req of getRequiredSecretEnvVarNames(p)) { + names.add(req); + } + } + return Array.from(names); + }, [profiles]); + + const machineEnvPresence = useMachineEnvPresence(machineId ?? null, allRequiredSecretNames, { ttlMs: 5 * 60_000 }); + + const getSecretMachineEnvOverride = React.useCallback((profile: AIBackendProfile) => { + const required = getRequiredSecretEnvVarNames(profile); + if (required.length === 0) return null; + if (!machineId) return null; + if (!machineEnvPresence.isPreviewEnvSupported) return null; + return { + isReady: required.every((name) => Boolean(machineEnvPresence.meta[name]?.isSet)), + isLoading: machineEnvPresence.isLoading, + }; + }, [machineEnvPresence.isLoading, machineEnvPresence.isPreviewEnvSupported, machineEnvPresence.meta, machineId]); + + const handleDefaultEnvironmentPress = React.useCallback(() => { + setParamsOnPreviousAndClose({ profileId: '' }); + }, [setParamsOnPreviousAndClose]); + + React.useEffect(() => { + if (typeof profileId === 'string' && profileId.length > 0) { + setParamsOnPreviousAndClose({ profileId }); + } + }, [profileId, setParamsOnPreviousAndClose]); + + const openProfileCreate = React.useCallback(() => { + router.push({ + pathname: '/new/pick/profile-edit', + params: machineId ? { machineId } : {}, + }); + }, [machineId, router]); + + const openProfileEdit = React.useCallback((profileId: string) => { + router.push({ + pathname: '/new/pick/profile-edit', + params: machineId ? { profileId, machineId } : { profileId }, + }); + }, [machineId, router]); + + const openProfileDuplicate = React.useCallback((cloneFromProfileId: string) => { + router.push({ + pathname: '/new/pick/profile-edit', + params: machineId ? { cloneFromProfileId, machineId } : { cloneFromProfileId }, + }); + }, [machineId, router]); + + const handleAddProfile = React.useCallback(() => { + openProfileCreate(); + }, [openProfileCreate]); + + const handleDeleteProfile = React.useCallback((profile: AIBackendProfile) => { + Modal.alert( + t('profiles.delete.title'), + t('profiles.delete.message', { name: profile.name }), + [ + { text: t('profiles.delete.cancel'), style: 'cancel' }, + { + text: t('profiles.delete.confirm'), + style: 'destructive', + onPress: () => { + // Only custom profiles live in `profiles` setting. + const updatedProfiles = profiles.filter(p => p.id !== profile.id); + setProfiles(updatedProfiles); + if (selectedId === profile.id) setParamsOnPreviousAndClose({ profileId: '' }); + }, + }, + ], + ); + }, [profiles, selectedId, setParamsOnPreviousAndClose, setProfiles]); + + const handleBackPress = React.useCallback(() => { + navigation.goBack(); + }, [navigation]); + + const headerLeft = React.useCallback(() => { + return ( + <Pressable + onPress={handleBackPress} + hitSlop={10} + style={({ pressed }) => ({ marginLeft: 10, padding: 4, opacity: pressed ? 0.7 : 1 })} + accessibilityRole="button" + accessibilityLabel={t('common.back')} + > + <Ionicons name="chevron-back" size={22} color={theme.colors.header.tint} /> + </Pressable> + ); + }, [handleBackPress, theme.colors.header.tint]); + + const screenOptions = React.useCallback(() => { + return { + headerShown: true, + title: t('profiles.title'), + headerTitle: t('profiles.title'), + headerBackTitle: t('common.back'), + // /new is presented as `containedModal` on iOS. Ensure picker screens are too, + // otherwise they can be pushed "behind" the modal (invisible but on the back stack). + presentation: Platform.OS === 'ios' ? 'containedModal' : undefined, + headerLeft, + } as const; + }, [headerLeft]); + + return ( + <PopoverPortalTargetProvider> + <> + <Stack.Screen + options={screenOptions} + /> + + {!useProfiles ? ( + <ItemGroup footer={t('settingsFeatures.profilesDisabled')}> + <Item + title={t('settingsFeatures.profiles')} + subtitle={t('settingsFeatures.profilesDisabled')} + icon={<Ionicons name="person-outline" size={29} color={theme.colors.textSecondary} />} + showChevron={false} + /> + <Item + title={t('settings.featuresTitle')} + subtitle={t('settings.featuresSubtitle')} + icon={<Ionicons name="flask-outline" size={29} color={theme.colors.textSecondary} />} + onPress={() => router.push('/settings/features')} + /> + </ItemGroup> + ) : ( + <ProfilesList + customProfiles={profiles} + favoriteProfileIds={favoriteProfileIds} + onFavoriteProfileIdsChange={setFavoriteProfileIds} + experimentsEnabled={experimentsEnabled} + selectedProfileId={selectedId || null} + onPressProfile={handleProfilePress} + includeDefaultEnvironmentRow + onPressDefaultEnvironment={handleDefaultEnvironmentPress} + includeAddProfileRow + onAddProfilePress={handleAddProfile} + machineId={machineId ?? null} + getSecretOverrideReady={(profile) => { + const requiredSecretNames = getRequiredSecretEnvVarNames(profile); + if (requiredSecretNames.length === 0) return false; + const satisfaction = getSecretSatisfaction({ + profile, + secrets, + defaultBindings: secretBindingsByProfileId[profile.id] ?? null, + machineEnvReadyByName: null, + }); + if (!satisfaction.isSatisfied) return false; + const required = satisfaction.items.filter((i) => i.required); + if (required.length == 0) return false; + return required.some((i) => i.satisfiedBy !== 'machineEnv'); + }} + getSecretMachineEnvOverride={getSecretMachineEnvOverride} + onEditProfile={(p) => openProfileEdit(p.id)} + onDuplicateProfile={(p) => openProfileDuplicate(p.id)} + onDeleteProfile={handleDeleteProfile} + onSecretBadgePress={(profile) => { + const missing = getSecretSatisfaction({ + profile, + secrets, + defaultBindings: secretBindingsByProfileId[profile.id] ?? null, + machineEnvReadyByName: machineEnvPresence.meta + ? Object.fromEntries(Object.entries(machineEnvPresence.meta).map(([k, v]) => [k, Boolean(v?.isSet)])) + : null, + }).items.find((i) => i.required && !i.isSatisfied)?.envVarName ?? null; + openSecretModal(profile, missing ?? (getRequiredSecretEnvVarNames(profile)[0] ?? '')); + }} + /> + )} + </> + </PopoverPortalTargetProvider> + ); +}); + +const stylesheet = StyleSheet.create(() => ({})); diff --git a/expo-app/sources/app/(app)/new/pick/resume.tsx b/expo-app/sources/app/(app)/new/pick/resume.tsx new file mode 100644 index 000000000..0a725d573 --- /dev/null +++ b/expo-app/sources/app/(app)/new/pick/resume.tsx @@ -0,0 +1,301 @@ +import React from 'react'; +import { View, Text, Pressable, InteractionManager } from 'react-native'; +import { Stack, useLocalSearchParams, useRouter } from 'expo-router'; +import { CommonActions, useFocusEffect, useNavigation } from '@react-navigation/native'; +import { Ionicons } from '@expo/vector-icons'; +import { StyleSheet, useUnistyles } from 'react-native-unistyles'; +import { Typography } from '@/constants/Typography'; +import { layout } from '@/components/layout'; +import { t } from '@/text'; +import { ItemList } from '@/components/ui/lists/ItemList'; +import { ItemGroup } from '@/components/ui/lists/ItemGroup'; +import { MultiTextInput, type MultiTextInputHandle } from '@/components/MultiTextInput'; +import type { AgentId } from '@/agents/catalog'; +import { DEFAULT_AGENT_ID, getAgentCore, isAgentId } from '@/agents/catalog'; +import { getClipboardStringTrimmedSafe } from '@/utils/ui/clipboard'; + +const stylesheet = StyleSheet.create((theme) => ({ + container: { + flex: 1, + backgroundColor: theme.colors.groupped.background, + }, + inputSection: { + padding: 16, + alignSelf: 'center', + width: '100%', + maxWidth: layout.maxWidth, + }, + inputLabel: { + fontSize: 14, + color: theme.colors.textSecondary, + marginBottom: 8, + ...Typography.default('semiBold'), + }, + inputContainer: { + backgroundColor: theme.colors.surface, + borderRadius: 12, + paddingHorizontal: 12, + paddingVertical: 10, + borderWidth: 0.5, + borderColor: theme.colors.divider, + }, + buttonRow: { + flexDirection: 'row', + gap: 12, + marginTop: 16, + }, + button: { + flex: 1, + paddingVertical: 12, + borderRadius: 10, + alignItems: 'center', + justifyContent: 'center', + }, + buttonPrimary: { + backgroundColor: theme.colors.button.primary.background, + }, + buttonSecondary: { + backgroundColor: theme.colors.surface, + borderWidth: 0.5, + borderColor: theme.colors.divider, + }, + buttonText: { + fontSize: 15, + ...Typography.default('semiBold'), + }, + buttonTextPrimary: { + color: theme.colors.button.primary.tint, + }, + buttonTextSecondary: { + color: theme.colors.text, + }, + clearButton: { + marginTop: 12, + paddingVertical: 12, + alignItems: 'center', + }, + clearButtonText: { + fontSize: 15, + color: theme.colors.textDestructive, + ...Typography.default('semiBold'), + }, + helpText: { + fontSize: 13, + color: theme.colors.textSecondary, + marginTop: 12, + lineHeight: 20, + ...Typography.default(), + }, +})); + +export default function ResumePickerScreen() { + const { theme } = useUnistyles(); + const styles = stylesheet; + const router = useRouter(); + const navigation = useNavigation(); + const inputRef = React.useRef<MultiTextInputHandle>(null); + const params = useLocalSearchParams<{ + currentResumeId?: string; + agentType?: AgentId; + }>(); + + const [inputValue, setInputValue] = React.useState(params.currentResumeId || ''); + const agentType: AgentId = isAgentId(params.agentType) ? params.agentType : DEFAULT_AGENT_ID; + const agentLabel = t(getAgentCore(agentType).displayNameKey); + + const handleSave = () => { + const trimmed = inputValue.trim(); + const state = navigation.getState(); + if (!state) { + router.back(); + return; + } + const previousRoute = state.routes[state.index - 1]; + if (previousRoute) { + navigation.dispatch({ + ...CommonActions.setParams({ resumeSessionId: trimmed }), + source: previousRoute.key, + } as never); + } + router.back(); + }; + + const handleClear = () => { + const state = navigation.getState(); + if (!state) { + router.back(); + return; + } + const previousRoute = state.routes[state.index - 1]; + if (previousRoute) { + navigation.dispatch({ + ...CommonActions.setParams({ resumeSessionId: '' }), + source: previousRoute.key, + } as never); + } + router.back(); + }; + + const handlePaste = async () => { + const text = await getClipboardStringTrimmedSafe(); + if (text) { + setInputValue(text); + } + }; + + const focusInputWithRetries = React.useCallback(() => { + let cancelled = false; + const focus = () => { + if (cancelled) return; + inputRef.current?.focus(); + }; + + // Try immediately (best chance to succeed on web because it happens soon after navigation). + focus(); + + // Also retry across a few frames to catch cases where the input isn't mounted yet. + let rafAttempts = 0; + const raf = + typeof (globalThis as any).requestAnimationFrame === 'function' + ? ((globalThis as any).requestAnimationFrame as (cb: (ts: number) => void) => any).bind(globalThis) + : (cb: (ts: number) => void) => setTimeout(() => cb(Date.now()), 16); + const caf = + typeof (globalThis as any).cancelAnimationFrame === 'function' + ? ((globalThis as any).cancelAnimationFrame as (id: any) => void).bind(globalThis) + : (id: any) => clearTimeout(id); + let rafId: any = null; + const rafLoop = () => { + rafAttempts += 1; + focus(); + if (rafAttempts < 8) { + rafId = raf(rafLoop); + } + }; + rafId = raf(rafLoop); + + // And a time-based fallback for native modal transitions / slower mounts. + const timer = setTimeout(focus, 300); + + return () => { + cancelled = true; + clearTimeout(timer); + if (rafId !== null) caf(rafId); + }; + }, []); + + React.useEffect(() => { + const cleanup = focusInputWithRetries(); + return cleanup; + }, [focusInputWithRetries]); + + // Auto-focus the input when the screen becomes active. Relying on `autoFocus` alone can fail + // with native modal transitions / nested navigators. + useFocusEffect(React.useCallback(() => { + const cleanup = focusInputWithRetries(); + + // Prefer `InteractionManager` to wait for modal/navigation animations to settle. + let interactionCleanup: (() => void) | undefined; + const task = InteractionManager.runAfterInteractions(() => { + interactionCleanup = focusInputWithRetries(); + }); + + return () => { + task.cancel?.(); + interactionCleanup?.(); + cleanup(); + }; + }, [focusInputWithRetries])); + + const headerTitle = t('newSession.resume.pickerTitle'); + const headerBackTitle = t('common.cancel'); + const screenOptions = React.useMemo(() => { + return { + headerShown: true, + title: headerTitle, + headerTitle, + headerBackTitle, + } as const; + }, [headerBackTitle, headerTitle]); + + return ( + <> + <Stack.Screen + options={screenOptions} + /> + <View style={styles.container}> + <ItemList> + <ItemGroup> + <View style={styles.inputSection}> + <Text style={styles.inputLabel}> + {t('newSession.resume.subtitle', { agent: agentLabel })} + </Text> + + <View style={styles.inputContainer}> + <MultiTextInput + ref={inputRef} + value={inputValue} + onChangeText={setInputValue} + placeholder={ + t('newSession.resume.placeholder', { agent: agentLabel }) + } + autoFocus={true} + maxHeight={80} + paddingTop={0} + paddingBottom={0} + /> + </View> + + <View style={styles.buttonRow}> + <Pressable + onPress={handlePaste} + style={({ pressed }) => [ + styles.button, + styles.buttonSecondary, + { opacity: pressed ? 0.7 : 1 }, + ]} + > + <View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}> + <Ionicons name="clipboard-outline" size={18} color={theme.colors.text} /> + <Text style={[styles.buttonText, styles.buttonTextSecondary]}> + {t('newSession.resume.paste')} + </Text> + </View> + </Pressable> + <Pressable + onPress={handleSave} + style={({ pressed }) => [ + styles.button, + styles.buttonPrimary, + { opacity: pressed ? 0.7 : 1 }, + ]} + > + <Text style={[styles.buttonText, styles.buttonTextPrimary]}> + {t('newSession.resume.save')} + </Text> + </Pressable> + </View> + + {inputValue.trim() && ( + <Pressable + onPress={handleClear} + style={({ pressed }) => [ + styles.clearButton, + { opacity: pressed ? 0.7 : 1 }, + ]} + > + <Text style={styles.clearButtonText}> + {t('newSession.resume.clearAndRemove')} + </Text> + </Pressable> + )} + + <Text style={styles.helpText}> + {t('newSession.resume.helpText')} + </Text> + </View> + </ItemGroup> + </ItemList> + </View> + </> + ); +} diff --git a/expo-app/sources/app/(app)/new/pick/secret-requirement.tsx b/expo-app/sources/app/(app)/new/pick/secret-requirement.tsx new file mode 100644 index 000000000..ac8c4f96c --- /dev/null +++ b/expo-app/sources/app/(app)/new/pick/secret-requirement.tsx @@ -0,0 +1,179 @@ +import React from 'react'; +import { Platform } from 'react-native'; +import { Stack, useLocalSearchParams, useNavigation, useRouter } from 'expo-router'; + +import { useSetting, useSettingMutable } from '@/sync/storage'; +import { getBuiltInProfile } from '@/sync/profileUtils'; +import { SecretRequirementScreen, type SecretRequirementModalResult } from '@/components/secrets/requirements'; +import { storeTempData } from '@/utils/tempDataStore'; +import { PopoverPortalTargetProvider } from '@/components/ui/popover'; + +type SecretRequirementRoutePayload = Readonly<{ + profileId: string; + revertOnCancel: boolean; + result: SecretRequirementModalResult; +}>; + +function parseUpperEnvVarList(raw: unknown): string[] { + if (typeof raw !== 'string') return []; + return raw + .split(',') + .map((s) => s.trim().toUpperCase()) + .filter(Boolean); +} + +function parseJsonRecord(raw: unknown): Record<string, string | null | undefined> { + if (typeof raw !== 'string' || raw.length === 0) return {}; + try { + const decoded = decodeURIComponent(raw); + const parsed = JSON.parse(decoded); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}; + return parsed as Record<string, string | null | undefined>; + } catch { + return {}; + } +} + +function dispatchSetParamsToPreviousRoute(navigation: any, params: Record<string, any>): boolean { + const state = navigation?.getState?.(); + const previousRoute = state?.routes?.[state.index - 1]; + if (!state || typeof state.index !== 'number' || state.index <= 0 || !previousRoute?.key) { + return false; + } + + navigation.dispatch({ + type: 'SET_PARAMS', + payload: { params }, + source: previousRoute.key, + }); + return true; +} + +export default React.memo(function SecretRequirementPickerScreen() { + const router = useRouter(); + const navigation = useNavigation(); + const params = useLocalSearchParams<{ + profileId?: string; + secretEnvVarName?: string; + secretEnvVarNames?: string; + machineId?: string; + revertOnCancel?: string; + selectedSecretIdByEnvVarName?: string; + }>(); + + const profileId = typeof params.profileId === 'string' ? params.profileId : ''; + const machineId = typeof params.machineId === 'string' ? params.machineId : null; + const revertOnCancel = params.revertOnCancel === '1'; + + const profiles = useSetting('profiles'); + const [secrets, setSecrets] = useSettingMutable('secrets'); + const [secretBindingsByProfileId, setSecretBindingsByProfileId] = useSettingMutable('secretBindingsByProfileId'); + + const profile = + profiles.find((p) => p.id === profileId) ?? + (profileId ? getBuiltInProfile(profileId) : null); + + const secretEnvVarName = typeof params.secretEnvVarName === 'string' + ? params.secretEnvVarName.trim().toUpperCase() + : ''; + const secretEnvVarNames = parseUpperEnvVarList(params.secretEnvVarNames); + + const selectedSecretIdByEnvVarName = React.useMemo(() => { + return parseJsonRecord(params.selectedSecretIdByEnvVarName); + }, [params.selectedSecretIdByEnvVarName]); + + const screenOptions = React.useMemo(() => { + return { + headerShown: false, + presentation: Platform.OS === 'ios' ? 'containedTransparentModal' : undefined, + } as const; + }, []); + + const didSendResultRef = React.useRef(false); + + const sendResultToNewSession = React.useCallback((result: SecretRequirementModalResult) => { + if (!profileId) return; + if (didSendResultRef.current) return; + didSendResultRef.current = true; + + const payload: SecretRequirementRoutePayload = { + profileId, + revertOnCancel, + result, + }; + const id = storeTempData(payload); + + const didSet = dispatchSetParamsToPreviousRoute(navigation as any, { secretRequirementResultId: id }); + if (!didSet) { + router.replace({ pathname: '/new', params: { secretRequirementResultId: id } } as any); + return; + } + router.back(); + }, [navigation, profileId, revertOnCancel, router]); + + const handleCancel = React.useCallback(() => { + sendResultToNewSession({ action: 'cancel' }); + }, [sendResultToNewSession]); + + React.useEffect(() => { + const sub = (navigation as any)?.addListener?.('beforeRemove', () => { + if (didSendResultRef.current) return; + sendResultToNewSession({ action: 'cancel' }); + }); + return () => sub?.(); + }, [navigation, sendResultToNewSession]); + + if (!profile || !secretEnvVarName) { + return ( + <> + <Stack.Screen + options={screenOptions} + /> + </> + ); + } + + const defaultBindingsForProfile = secretBindingsByProfileId?.[profile.id] ?? null; + + return ( + <PopoverPortalTargetProvider> + <> + <Stack.Screen + options={screenOptions} + /> + + <SecretRequirementScreen + profile={profile} + secretEnvVarName={secretEnvVarName} + secretEnvVarNames={secretEnvVarNames.length > 0 ? secretEnvVarNames : undefined} + machineId={machineId} + secrets={secrets} + defaultSecretId={defaultBindingsForProfile?.[secretEnvVarName] ?? null} + selectedSavedSecretId={ + typeof selectedSecretIdByEnvVarName?.[secretEnvVarName] === 'string' && + String(selectedSecretIdByEnvVarName?.[secretEnvVarName]).trim().length > 0 + ? (selectedSecretIdByEnvVarName?.[secretEnvVarName] as string) + : null + } + selectedSecretIdByEnvVarName={selectedSecretIdByEnvVarName} + defaultSecretIdByEnvVarName={defaultBindingsForProfile} + onSetDefaultSecretId={(id) => { + if (!id) return; + setSecretBindingsByProfileId({ + ...secretBindingsByProfileId, + [profile.id]: { + ...(secretBindingsByProfileId?.[profile.id] ?? {}), + [secretEnvVarName]: id, + }, + }); + }} + onChangeSecrets={setSecrets} + allowSessionOnly={true} + onResolve={sendResultToNewSession} + onRequestClose={handleCancel} + onClose={handleCancel} + /> + </> + </PopoverPortalTargetProvider> + ); +}); diff --git a/expo-app/sources/app/(app)/new/pick/secret.tsx b/expo-app/sources/app/(app)/new/pick/secret.tsx new file mode 100644 index 000000000..3757eceb3 --- /dev/null +++ b/expo-app/sources/app/(app)/new/pick/secret.tsx @@ -0,0 +1,77 @@ +import React from 'react'; +import { Stack, useLocalSearchParams, useNavigation, useRouter } from 'expo-router'; +import { Platform, Pressable } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; + +import { useSettingMutable } from '@/sync/storage'; +import { t } from '@/text'; +import { SecretsList } from '@/components/secrets/SecretsList'; +import { useUnistyles } from 'react-native-unistyles'; + +export default React.memo(function SecretPickerScreen() { + const { theme } = useUnistyles(); + const router = useRouter(); + const navigation = useNavigation(); + const params = useLocalSearchParams<{ selectedId?: string }>(); + const selectedId = typeof params.selectedId === 'string' ? params.selectedId : ''; + + const [secrets, setSecrets] = useSettingMutable('secrets'); + + const setSecretParamAndClose = React.useCallback((secretId: string) => { + router.setParams({ secretId }); + navigation.goBack(); + }, [navigation, router]); + + const handleBackPress = React.useCallback(() => { + navigation.goBack(); + }, [navigation]); + + const headerTitle = t('settings.secrets'); + const headerBackTitle = t('common.back'); + + const headerLeft = React.useCallback(() => { + return ( + <Pressable + onPress={handleBackPress} + hitSlop={10} + style={({ pressed }) => ({ marginLeft: 10, padding: 4, opacity: pressed ? 0.7 : 1 })} + accessibilityRole="button" + accessibilityLabel={t('common.back')} + > + <Ionicons name="chevron-back" size={22} color={theme.colors.header.tint} /> + </Pressable> + ); + }, [handleBackPress, theme.colors.header.tint]); + + const screenOptions = React.useMemo(() => { + return { + headerShown: true, + title: headerTitle, + headerTitle, + headerBackTitle, + // /new is presented as `containedModal` on iOS. Ensure picker screens are too, + // otherwise they can be pushed "behind" the modal (invisible but on the back stack). + presentation: Platform.OS === 'ios' ? 'containedModal' : undefined, + headerLeft, + } as const; + }, [headerBackTitle, headerLeft, headerTitle]); + + return ( + <> + <Stack.Screen + options={screenOptions} + /> + + <SecretsList + secrets={secrets} + onChangeSecrets={setSecrets} + selectedId={selectedId} + onSelectId={setSecretParamAndClose} + includeNoneRow + allowAdd + allowEdit + onAfterAddSelectId={setSecretParamAndClose} + /> + </> + ); +}); diff --git a/expo-app/sources/app/(app)/restore/index.tsx b/expo-app/sources/app/(app)/restore/index.tsx index a0ae06f39..554925762 100644 --- a/expo-app/sources/app/(app)/restore/index.tsx +++ b/expo-app/sources/app/(app)/restore/index.tsx @@ -137,10 +137,7 @@ export default function Restore() { <View style={{justifyContent: 'flex-end' }}> <Text style={styles.secondInstructionText}> - 1. Open Happy on your mobile device{'\n'} - 2. Go to Settings → Account{'\n'} - 3. Tap "Link New Device"{'\n'} - 4. Scan this QR code + {t('connect.restoreQrInstructions')} </Text> </View> {!authReady && ( @@ -157,7 +154,7 @@ export default function Restore() { /> )} <View style={{ flexGrow: 4, paddingTop: 30 }}> - <RoundButton title="Restore with Secret Key Instead" display='inverted' onPress={() => { + <RoundButton title={t('connect.restoreWithSecretKeyInstead')} display='inverted' onPress={() => { router.push('/restore/manual'); }} /> </View> diff --git a/expo-app/sources/app/(app)/restore/manual.tsx b/expo-app/sources/app/(app)/restore/manual.tsx index 2c36ed29f..8df9ac7c6 100644 --- a/expo-app/sources/app/(app)/restore/manual.tsx +++ b/expo-app/sources/app/(app)/restore/manual.tsx @@ -112,12 +112,12 @@ export default function Restore() { <View style={styles.container}> <View style={styles.contentWrapper}> <Text style={styles.instructionText}> - Enter your secret key to restore access to your account. + {t('connect.restoreWithSecretKeyDescription')} </Text> <TextInput style={styles.textInput} - placeholder="XXXXX-XXXXX-XXXXX..." + placeholder={t('connect.secretKeyPlaceholder')} placeholderTextColor={theme.colors.input.placeholder} value={restoreKey} onChangeText={setRestoreKey} diff --git a/expo-app/sources/app/(app)/server.tsx b/expo-app/sources/app/(app)/server.tsx index d892888c3..3c6b9109f 100644 --- a/expo-app/sources/app/(app)/server.tsx +++ b/expo-app/sources/app/(app)/server.tsx @@ -3,8 +3,8 @@ import { View, TextInput, KeyboardAvoidingView, Platform } from 'react-native'; import { Stack, useRouter } from 'expo-router'; import { Text } from '@/components/StyledText'; import { Typography } from '@/constants/Typography'; -import { ItemGroup } from '@/components/ItemGroup'; -import { ItemList } from '@/components/ItemList'; +import { ItemGroup } from '@/components/ui/lists/ItemGroup'; +import { ItemList } from '@/components/ui/lists/ItemList'; import { RoundButton } from '@/components/RoundButton'; import { Modal } from '@/modal'; import { layout } from '@/components/layout'; @@ -158,14 +158,21 @@ export default function ServerConfigScreen() { } }; + const headerTitle = t('server.serverConfiguration'); + const headerBackTitle = t('common.back'); + + const screenOptions = React.useMemo(() => { + return { + headerShown: true, + headerTitle, + headerBackTitle, + } as const; + }, [headerBackTitle, headerTitle]); + return ( <> <Stack.Screen - options={{ - headerShown: true, - headerTitle: t('server.serverConfiguration'), - headerBackTitle: t('common.back'), - }} + options={screenOptions} /> <KeyboardAvoidingView diff --git a/expo-app/sources/app/(app)/session/[id]/files.tsx b/expo-app/sources/app/(app)/session/[id]/files.tsx index ace531422..15aae7b4e 100644 --- a/expo-app/sources/app/(app)/session/[id]/files.tsx +++ b/expo-app/sources/app/(app)/session/[id]/files.tsx @@ -6,8 +6,8 @@ import { useRouter } from 'expo-router'; import { useFocusEffect } from '@react-navigation/native'; import { Octicons } from '@expo/vector-icons'; import { Text } from '@/components/StyledText'; -import { Item } from '@/components/Item'; -import { ItemList } from '@/components/ItemList'; +import { Item } from '@/components/ui/lists/Item'; +import { ItemList } from '@/components/ui/lists/ItemList'; import { Typography } from '@/constants/Typography'; import { getGitStatusFiles, GitFileStatus, GitStatusFiles } from '@/sync/gitStatusFiles'; import { searchFiles, FileItem } from '@/sync/suggestionFile'; diff --git a/expo-app/sources/app/(app)/session/[id]/info.tsx b/expo-app/sources/app/(app)/session/[id]/info.tsx index 631df7f39..95b42879d 100644 --- a/expo-app/sources/app/(app)/session/[id]/info.tsx +++ b/expo-app/sources/app/(app)/session/[id]/info.tsx @@ -3,23 +3,27 @@ import { View, Text, Animated } from 'react-native'; import { useRouter, useLocalSearchParams } from 'expo-router'; import { Ionicons } from '@expo/vector-icons'; import { Typography } from '@/constants/Typography'; -import { Item } from '@/components/Item'; -import { ItemGroup } from '@/components/ItemGroup'; -import { ItemList } from '@/components/ItemList'; +import { Item } from '@/components/ui/lists/Item'; +import { ItemGroup } from '@/components/ui/lists/ItemGroup'; +import { ItemList } from '@/components/ui/lists/ItemList'; import { Avatar } from '@/components/Avatar'; -import { useSession, useIsDataReady } from '@/sync/storage'; +import { useSession, useIsDataReady, useSetting } from '@/sync/storage'; import { getSessionName, useSessionStatus, formatOSPlatform, formatPathRelativeToHome, getSessionAvatarId } from '@/utils/sessionUtils'; import * as Clipboard from 'expo-clipboard'; import { Modal } from '@/modal'; -import { sessionKill, sessionDelete } from '@/sync/ops'; +import { sessionArchive, sessionDelete, sessionRename } from '@/sync/ops'; import { useUnistyles } from 'react-native-unistyles'; import { layout } from '@/components/layout'; import { t } from '@/text'; import { isVersionSupported, MINIMUM_CLI_VERSION } from '@/utils/versionUtils'; +import { getAttachCommandForSession, getTmuxFallbackReason, getTmuxTargetForSession } from '@/utils/sessions/terminalSessionDetails'; import { CodeView } from '@/components/CodeView'; import { Session } from '@/sync/storageTypes'; import { useHappyAction } from '@/hooks/useHappyAction'; import { HappyError } from '@/utils/errors'; +import { resolveProfileById } from '@/sync/profileUtils'; +import { getProfileDisplayName } from '@/components/profiles/profileDisplay'; +import { DEFAULT_AGENT_ID, getAgentCore, resolveAgentIdFromFlavor } from '@/agents/catalog'; // Animated status dot component function StatusDot({ color, isPulsing, size = 8 }: { color: string; isPulsing?: boolean; size?: number }) { @@ -66,9 +70,47 @@ function SessionInfoContent({ session }: { session: Session }) { const devModeEnabled = __DEV__; const sessionName = getSessionName(session); const sessionStatus = useSessionStatus(session); - + const useProfiles = useSetting('useProfiles'); + const profiles = useSetting('profiles'); + const experimentsEnabled = useSetting('experiments'); // Check if CLI version is outdated const isCliOutdated = session.metadata?.version && !isVersionSupported(session.metadata.version, MINIMUM_CLI_VERSION); + const canManageSharing = !session.accessLevel || session.accessLevel === 'admin'; + const agentId = resolveAgentIdFromFlavor(session.metadata?.flavor) ?? DEFAULT_AGENT_ID; + const core = getAgentCore(agentId); + + const vendorResumeLabelKey = core.resume.uiVendorResumeIdLabelKey; + const vendorResumeCopiedKey = core.resume.uiVendorResumeIdCopiedKey; + const vendorResumeId = React.useMemo(() => { + const field = core.resume.vendorResumeIdField; + if (!field) return null; + const raw = (session.metadata as any)?.[field]; + const id = typeof raw === 'string' ? raw.trim() : ''; + return id.length > 0 ? id : null; + }, [core.resume.vendorResumeIdField, session.metadata]); + + const profileLabel = React.useMemo(() => { + const profileId = session.metadata?.profileId; + if (profileId === null || profileId === '') return t('profiles.noProfile'); + if (typeof profileId !== 'string') return t('status.unknown'); + const resolved = resolveProfileById(profileId, profiles); + if (resolved) { + return getProfileDisplayName(resolved); + } + return t('status.unknown'); + }, [profiles, session.metadata?.profileId]); + + const attachCommand = React.useMemo(() => { + return getAttachCommandForSession({ sessionId: session.id, terminal: session.metadata?.terminal }); + }, [session.id, session.metadata?.terminal]); + + const tmuxTarget = React.useMemo(() => { + return getTmuxTargetForSession(session.metadata?.terminal); + }, [session.metadata?.terminal]); + + const tmuxFallbackReason = React.useMemo(() => { + return getTmuxFallbackReason(session.metadata?.terminal); + }, [session.metadata?.terminal]); const handleCopySessionId = useCallback(async () => { if (!session) return; @@ -80,6 +122,16 @@ function SessionInfoContent({ session }: { session: Session }) { } }, [session]); + const handleCopyAttachCommand = useCallback(async () => { + if (!attachCommand) return; + try { + await Clipboard.setStringAsync(attachCommand); + Modal.alert(t('common.copied'), t('items.copiedToClipboard', { label: t('sessionInfo.attachFromTerminal') })); + } catch (error) { + Modal.alert(t('common.error'), t('sessionInfo.failedToCopyMetadata')); + } + }, [attachCommand]); + const handleCopyMetadata = useCallback(async () => { if (!session?.metadata) return; try { @@ -92,7 +144,7 @@ function SessionInfoContent({ session }: { session: Session }) { // Use HappyAction for archiving - it handles errors automatically const [archivingSession, performArchive] = useHappyAction(async () => { - const result = await sessionKill(session.id); + const result = await sessionArchive(session.id); if (!result.success) { throw new HappyError(result.message || t('sessionInfo.failedToArchiveSession'), false); } @@ -140,20 +192,44 @@ function SessionInfoContent({ session }: { session: Session }) { ); }, [performDelete]); + const handleRenameSession = useCallback(async () => { + const newName = await Modal.prompt( + t('sessionInfo.renameSession'), + t('sessionInfo.renameSessionSubtitle'), + { + defaultValue: sessionName, + placeholder: t('sessionInfo.renameSessionPlaceholder'), + confirmText: t('common.save'), + cancelText: t('common.cancel') + } + ); + + if (newName?.trim()) { + const result = await sessionRename(session.id, newName.trim()); + if (!result.success) { + Modal.alert(t('common.error'), result.message || t('sessionInfo.failedToRenameSession')); + } + } + }, [sessionName, session.id]); + const formatDate = useCallback((timestamp: number) => { return new Date(timestamp).toLocaleString(); }, []); - const handleCopyUpdateCommand = useCallback(async () => { - const updateCommand = 'npm install -g happy-coder@latest'; + const handleCopyCommand = useCallback(async (command: string) => { try { - await Clipboard.setStringAsync(updateCommand); - Modal.alert(t('common.success'), updateCommand); + await Clipboard.setStringAsync(command); + Modal.alert(t('common.success'), command); } catch (error) { Modal.alert(t('common.error'), t('common.error')); } }, []); + const handleCopyUpdateCommand = useCallback(async () => { + const updateCommand = 'npm install -g happy-coder@latest'; + await handleCopyCommand(updateCommand); + }, [handleCopyCommand]); + return ( <> <ItemList> @@ -198,25 +274,25 @@ function SessionInfoContent({ session }: { session: Session }) { </ItemGroup> )} - {/* Session Details */} - <ItemGroup> - <Item - title={t('sessionInfo.happySessionId')} + {/* Session Details */} + <ItemGroup> + <Item + title={t('sessionInfo.happySessionId')} subtitle={`${session.id.substring(0, 8)}...${session.id.substring(session.id.length - 8)}`} icon={<Ionicons name="finger-print-outline" size={29} color="#007AFF" />} onPress={handleCopySessionId} /> - {session.metadata?.claudeSessionId && ( + {vendorResumeId && vendorResumeLabelKey && vendorResumeCopiedKey && ( <Item - title={t('sessionInfo.claudeCodeSessionId')} - subtitle={`${session.metadata.claudeSessionId.substring(0, 8)}...${session.metadata.claudeSessionId.substring(session.metadata.claudeSessionId.length - 8)}`} - icon={<Ionicons name="code-outline" size={29} color="#9C27B0" />} + title={t(vendorResumeLabelKey)} + subtitle={`${vendorResumeId.substring(0, 8)}...${vendorResumeId.substring(vendorResumeId.length - 8)}`} + icon={<Ionicons name={core.ui.agentPickerIconName as any} size={29} color="#007AFF" />} onPress={async () => { try { - await Clipboard.setStringAsync(session.metadata!.claudeSessionId!); - Modal.alert(t('common.success'), t('sessionInfo.claudeCodeSessionIdCopied')); + await Clipboard.setStringAsync(vendorResumeId); + Modal.alert(t('common.success'), t(vendorResumeCopiedKey)); } catch (error) { - Modal.alert(t('common.error'), t('sessionInfo.failedToCopyClaudeCodeSessionId')); + Modal.alert(t('common.error'), t('sessionInfo.failedToCopyMetadata')); } }} /> @@ -249,6 +325,21 @@ function SessionInfoContent({ session }: { session: Session }) { {/* Quick Actions */} <ItemGroup title={t('sessionInfo.quickActions')}> + <Item + title={t('sessionInfo.renameSession')} + subtitle={t('sessionInfo.renameSessionSubtitle')} + icon={<Ionicons name="pencil-outline" size={29} color="#007AFF" />} + onPress={handleRenameSession} + /> + {!session.active && Boolean(vendorResumeId) && ( + <Item + title={t('sessionInfo.copyResumeCommand')} + subtitle={`happy resume ${session.id}`} + icon={<Ionicons name="terminal-outline" size={29} color="#9C27B0" />} + showChevron={false} + onPress={() => handleCopyCommand(`happy resume ${session.id}`)} + /> + )} {session.metadata?.machineId && ( <Item title={t('sessionInfo.viewMachine')} @@ -257,6 +348,14 @@ function SessionInfoContent({ session }: { session: Session }) { onPress={() => router.push(`/machine/${session.metadata?.machineId}`)} /> )} + {canManageSharing && ( + <Item + title={t('sessionInfo.manageSharing')} + subtitle={t('sessionInfo.manageSharingSubtitle')} + icon={<Ionicons name="share-outline" size={29} color="#007AFF" />} + onPress={() => router.push(`/session/${session.id}/sharing`)} + /> + )} {sessionStatus.isConnected && ( <Item title={t('sessionInfo.archiveSession')} @@ -307,22 +406,31 @@ function SessionInfoContent({ session }: { session: Session }) { showChevron={false} /> )} - <Item - title={t('sessionInfo.aiProvider')} - subtitle={(() => { - const flavor = session.metadata.flavor || 'claude'; - if (flavor === 'claude') return 'Claude'; - if (flavor === 'gpt' || flavor === 'openai') return 'Codex'; - if (flavor === 'gemini') return 'Gemini'; - return flavor; - })()} - icon={<Ionicons name="sparkles-outline" size={29} color="#5856D6" />} - showChevron={false} - /> - {session.metadata.hostPid && ( - <Item - title={t('sessionInfo.processId')} - subtitle={session.metadata.hostPid.toString()} + <Item + title={t('sessionInfo.aiProvider')} + subtitle={(() => { + const flavor = session.metadata.flavor; + const agentId = resolveAgentIdFromFlavor(flavor); + if (agentId) return t(getAgentCore(agentId).displayNameKey); + return typeof flavor === 'string' && flavor.length > 0 + ? flavor + : t(getAgentCore(DEFAULT_AGENT_ID).displayNameKey); + })()} + icon={<Ionicons name="sparkles-outline" size={29} color="#5856D6" />} + showChevron={false} + /> + {useProfiles && session.metadata?.profileId !== undefined && ( + <Item + title={t('sessionInfo.aiProfile')} + detail={profileLabel} + icon={<Ionicons name="person-circle-outline" size={29} color="#5856D6" />} + showChevron={false} + /> + )} + {session.metadata.hostPid && ( + <Item + title={t('sessionInfo.processId')} + subtitle={session.metadata.hostPid.toString()} icon={<Ionicons name="terminal-outline" size={29} color="#5856D6" />} showChevron={false} /> @@ -335,6 +443,31 @@ function SessionInfoContent({ session }: { session: Session }) { showChevron={false} /> )} + {!!attachCommand && ( + <Item + title={t('sessionInfo.attachFromTerminal')} + subtitle={attachCommand} + icon={<Ionicons name="terminal-outline" size={29} color="#5856D6" />} + onPress={handleCopyAttachCommand} + showChevron={false} + /> + )} + {!!tmuxTarget && ( + <Item + title={t('sessionInfo.tmuxTarget')} + subtitle={tmuxTarget} + icon={<Ionicons name="albums-outline" size={29} color="#5856D6" />} + showChevron={false} + /> + )} + {!!tmuxFallbackReason && ( + <Item + title={t('sessionInfo.tmuxFallback')} + subtitle={tmuxFallbackReason} + icon={<Ionicons name="alert-circle-outline" size={29} color="#FF9500" />} + showChevron={false} + /> + )} <Item title={t('sessionInfo.copyMetadata')} icon={<Ionicons name="copy-outline" size={29} color="#007AFF" />} @@ -383,11 +516,11 @@ function SessionInfoContent({ session }: { session: Session }) { {/* Raw JSON (Dev Mode Only) */} {devModeEnabled && ( - <ItemGroup title="Raw JSON (Dev Mode)"> + <ItemGroup title={t('sessionInfo.rawJsonDevMode')}> {session.agentState && ( <> <Item - title="Agent State" + title={t('sessionInfo.agentState')} icon={<Ionicons name="code-working-outline" size={29} color="#FF9500" />} showChevron={false} /> @@ -402,7 +535,7 @@ function SessionInfoContent({ session }: { session: Session }) { {session.metadata && ( <> <Item - title="Metadata" + title={t('sessionInfo.metadata')} icon={<Ionicons name="information-circle-outline" size={29} color="#5856D6" />} showChevron={false} /> @@ -417,7 +550,7 @@ function SessionInfoContent({ session }: { session: Session }) { {sessionStatus && ( <> <Item - title="Session Status" + title={t('sessionInfo.sessionStatus')} icon={<Ionicons name="analytics-outline" size={29} color="#007AFF" />} showChevron={false} /> @@ -437,7 +570,7 @@ function SessionInfoContent({ session }: { session: Session }) { )} {/* Full Session Object */} <Item - title="Full Session Object" + title={t('sessionInfo.fullSessionObject')} icon={<Ionicons name="document-text-outline" size={29} color="#34C759" />} showChevron={false} /> diff --git a/expo-app/sources/app/(app)/session/[id]/message/[messageId].tsx b/expo-app/sources/app/(app)/session/[id]/message/[messageId].tsx index c233f1845..71078629a 100644 --- a/expo-app/sources/app/(app)/session/[id]/message/[messageId].tsx +++ b/expo-app/sources/app/(app)/session/[id]/message/[messageId].tsx @@ -77,35 +77,47 @@ export default React.memo(() => { </View> ); } + + const tool = message.kind === 'tool-call' ? message.tool : null; + const toolHeaderTitle = React.useCallback(() => { + return tool ? <ToolHeader tool={tool} /> : null; + }, [tool]); + const toolHeaderRight = React.useCallback(() => { + return tool ? <ToolStatusIndicator tool={tool} /> : null; + }, [tool]); + + const toolScreenOptions = React.useMemo(() => { + return { + headerTitle: toolHeaderTitle, + headerRight: toolHeaderRight, + headerStyle: { + backgroundColor: theme.colors.header.background, + }, + headerTintColor: theme.colors.header.tint, + headerShadowVisible: false, + } as const; + }, [theme.colors.header.background, theme.colors.header.tint, toolHeaderRight, toolHeaderTitle]); return ( <> - {message && message.kind === 'tool-call' && message.tool && ( + {tool && ( <Stack.Screen - options={{ - headerTitle: () => <ToolHeader tool={message.tool} />, - headerRight: () => <ToolStatusIndicator tool={message.tool} />, - headerStyle: { - backgroundColor: theme.colors.header.background, - }, - headerTintColor: theme.colors.header.tint, - headerShadowVisible: false, - }} + options={toolScreenOptions} /> )} <Deferred> - <FullView message={message} /> + <FullView message={message} sessionId={sessionId!} metadata={(session as any)?.metadata ?? null} /> </Deferred> </> ); }); -function FullView(props: { message: Message }) { +function FullView(props: { message: Message; sessionId: string; metadata: any }) { const { theme } = useUnistyles(); const styles = stylesheet; if (props.message.kind === 'tool-call') { - return <ToolFullView tool={props.message.tool} messages={props.message.children} /> + return <ToolFullView tool={props.message.tool} messages={props.message.children} sessionId={props.sessionId} metadata={props.metadata} /> } if (props.message.kind === 'agent-text') { return ( @@ -122,4 +134,4 @@ function FullView(props: { message: Message }) { ) } return null; -} \ No newline at end of file +} diff --git a/expo-app/sources/app/(app)/session/[id]/sharing.tsx b/expo-app/sources/app/(app)/session/[id]/sharing.tsx new file mode 100644 index 000000000..15c99ed71 --- /dev/null +++ b/expo-app/sources/app/(app)/session/[id]/sharing.tsx @@ -0,0 +1,334 @@ +import React, { memo, useState, useCallback, useEffect } from 'react'; +import { View, Text } from 'react-native'; +import { useLocalSearchParams, useRouter } from 'expo-router'; +import { Ionicons } from '@expo/vector-icons'; +import { Item } from '@/components/Item'; +import { ItemGroup } from '@/components/ItemGroup'; +import { ItemList } from '@/components/ItemList'; +import { useSession, useIsDataReady } from '@/sync/storage'; +import { useUnistyles } from 'react-native-unistyles'; +import { t } from '@/text'; +import { Typography } from '@/constants/Typography'; +import { FriendSelector, PublicLinkDialog, SessionShareDialog } from '@/components/sessionSharing'; +import { SessionShare, PublicSessionShare, ShareAccessLevel } from '@/sync/sharingTypes'; +import { + getSessionShares, + createSessionShare, + updateSessionShare, + deleteSessionShare, + getPublicShare, + createPublicShare, + deletePublicShare +} from '@/sync/apiSharing'; +import { sync } from '@/sync/sync'; +import { useHappyAction } from '@/hooks/useHappyAction'; +import { HappyError } from '@/utils/errors'; +import { getFriendsList } from '@/sync/apiFriends'; +import { UserProfile } from '@/sync/friendTypes'; +import { encryptDataKeyForPublicShare } from '@/sync/encryption/publicShareEncryption'; +import { getRandomBytes } from 'expo-crypto'; +import { encryptDataKeyForRecipientV0, verifyRecipientContentPublicKeyBinding } from '@/sync/directShareEncryption'; + +function SharingManagementContent({ sessionId }: { sessionId: string }) { + const { theme } = useUnistyles(); + const router = useRouter(); + const session = useSession(sessionId); + + const [shares, setShares] = useState<SessionShare[]>([]); + const [publicShare, setPublicShare] = useState<PublicSessionShare | null>(null); + const [friends, setFriends] = useState<UserProfile[]>([]); + + const [showShareDialog, setShowShareDialog] = useState(false); + const [showFriendSelector, setShowFriendSelector] = useState(false); + const [showPublicLinkDialog, setShowPublicLinkDialog] = useState(false); + + // Load sharing data + const loadSharingData = useCallback(async () => { + try { + const credentials = sync.getCredentials(); + + // Load shares + const sharesData = await getSessionShares(credentials, sessionId); + setShares(sharesData); + + // Load public share + try { + const publicShareData = await getPublicShare(credentials, sessionId); + setPublicShare((prev) => { + if (!publicShareData) return null; + if (prev?.token && !publicShareData.token) { + return { ...publicShareData, token: prev.token }; + } + return publicShareData; + }); + } catch (e) { + // No public share exists + setPublicShare(null); + } + + // Load friends list + const friendsData = await getFriendsList(credentials); + setFriends(friendsData); + } catch (error) { + console.error('Failed to load sharing data:', error); + } + }, [sessionId]); + + useEffect(() => { + loadSharingData(); + }, [loadSharingData]); + + // Handle adding a new share + const handleAddShare = useCallback(async (userId: string, accessLevel: ShareAccessLevel) => { + try { + const credentials = sync.getCredentials(); + + const friend = friends.find(f => f.id === userId); + if (!friend) { + throw new HappyError(t('errors.operationFailed'), false); + } + if (!friend.contentPublicKey || !friend.contentPublicKeySig) { + throw new HappyError(t('session.sharing.recipientMissingKeys'), false); + } + const isValidBinding = verifyRecipientContentPublicKeyBinding({ + signingPublicKeyHex: friend.publicKey, + contentPublicKeyB64: friend.contentPublicKey, + contentPublicKeySigB64: friend.contentPublicKeySig, + }); + if (!isValidBinding) { + throw new HappyError(t('errors.operationFailed'), false); + } + + // Get plaintext session DEK from the sync layer (owner/admin only) + const dataKey = sync.getSessionDataKey(sessionId); + if (!dataKey) { + throw new HappyError(t('errors.sessionNotFound'), false); + } + const encryptedDataKey = encryptDataKeyForRecipientV0(dataKey, friend.contentPublicKey); + + await createSessionShare(credentials, sessionId, { + userId, + accessLevel, + encryptedDataKey, + }); + + await loadSharingData(); + setShowFriendSelector(false); + } catch (error) { + throw new HappyError(t('errors.operationFailed'), false); + } + }, [friends, sessionId, loadSharingData]); + + // Handle updating share access level + const handleUpdateShare = useCallback(async (shareId: string, accessLevel: ShareAccessLevel) => { + try { + const credentials = sync.getCredentials(); + await updateSessionShare(credentials, sessionId, shareId, accessLevel); + await loadSharingData(); + } catch (error) { + throw new HappyError(t('errors.operationFailed'), false); + } + }, [sessionId, loadSharingData]); + + // Handle removing a share + const handleRemoveShare = useCallback(async (shareId: string) => { + try { + const credentials = sync.getCredentials(); + await deleteSessionShare(credentials, sessionId, shareId); + await loadSharingData(); + } catch (error) { + throw new HappyError(t('errors.operationFailed'), false); + } + }, [sessionId, loadSharingData]); + + // Handle creating public share + const handleCreatePublicShare = useCallback(async (options: { + expiresInDays?: number; + maxUses?: number; + isConsentRequired: boolean; + }) => { + try { + const credentials = sync.getCredentials(); + + // Generate random token (12 bytes = 24 hex chars) + const tokenBytes = getRandomBytes(12); + const token = Array.from(tokenBytes) + .map(b => b.toString(16).padStart(2, '0')) + .join(''); + + // Get session data encryption key + const dataKey = sync.getSessionDataKey(sessionId); + if (!dataKey) { + throw new HappyError(t('errors.sessionNotFound'), false); + } + + // Encrypt data key with the token + const encryptedDataKey = await encryptDataKeyForPublicShare(dataKey, token); + + const expiresAt = options.expiresInDays + ? Date.now() + options.expiresInDays * 24 * 60 * 60 * 1000 + : undefined; + + const created = await createPublicShare(credentials, sessionId, { + token, + encryptedDataKey, + expiresAt, + maxUses: options.maxUses, + isConsentRequired: options.isConsentRequired, + }); + + setPublicShare(created); + await loadSharingData(); + } catch (error) { + throw new HappyError(t('errors.operationFailed'), false); + } + }, [sessionId, loadSharingData]); + + // Handle deleting public share + const handleDeletePublicShare = useCallback(async () => { + try { + const credentials = sync.getCredentials(); + await deletePublicShare(credentials, sessionId); + await loadSharingData(); + setShowPublicLinkDialog(false); + } catch (error) { + throw new HappyError(t('errors.operationFailed'), false); + } + }, [sessionId, loadSharingData]); + + if (!session) { + return ( + <View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}> + <Ionicons name="trash-outline" size={48} color={theme.colors.textSecondary} /> + <Text style={{ + color: theme.colors.text, + fontSize: 20, + marginTop: 16, + ...Typography.default('semiBold') + }}> + {t('errors.sessionDeleted')} + </Text> + </View> + ); + } + + const excludedUserIds = shares.map(share => share.sharedWithUser.id); + const canManage = !session.accessLevel || session.accessLevel === 'admin'; + + return ( + <> + <ItemList> + {/* Current Shares */} + <ItemGroup title={t('session.sharing.directSharing')}> + {shares.length > 0 ? ( + shares.map(share => ( + <Item + key={share.id} + title={share.sharedWithUser.username || [share.sharedWithUser.firstName, share.sharedWithUser.lastName].filter(Boolean).join(' ')} + subtitle={`@${share.sharedWithUser.username} • ${t(`session.sharing.${share.accessLevel === 'view' ? 'viewOnly' : share.accessLevel === 'edit' ? 'canEdit' : 'canManage'}`)}`} + icon={<Ionicons name="person-outline" size={29} color="#007AFF" />} + onPress={() => setShowShareDialog(true)} + /> + )) + ) : ( + <Item + title={t('session.sharing.noShares')} + icon={<Ionicons name="people-outline" size={29} color="#8E8E93" />} + showChevron={false} + /> + )} + {canManage && ( + <Item + title={t('session.sharing.addShare')} + icon={<Ionicons name="person-add-outline" size={29} color="#34C759" />} + onPress={() => setShowFriendSelector(true)} + /> + )} + </ItemGroup> + + {/* Public Link */} + <ItemGroup title={t('session.sharing.publicLink')}> + {publicShare ? ( + <Item + title={t('session.sharing.publicLinkActive')} + subtitle={publicShare.expiresAt + ? t('session.sharing.expiresOn') + ': ' + new Date(publicShare.expiresAt).toLocaleDateString() + : t('session.sharing.never') + } + icon={<Ionicons name="link-outline" size={29} color="#34C759" />} + onPress={() => setShowPublicLinkDialog(true)} + /> + ) : ( + <Item + title={t('session.sharing.createPublicLink')} + subtitle={t('session.sharing.publicLinkDescription')} + icon={<Ionicons name="link-outline" size={29} color="#007AFF" />} + onPress={() => setShowPublicLinkDialog(true)} + /> + )} + </ItemGroup> + </ItemList> + + {/* Dialogs */} + {showShareDialog && ( + <SessionShareDialog + sessionId={sessionId} + shares={shares} + canManage={canManage} + onAddShare={() => { + setShowShareDialog(false); + setShowFriendSelector(true); + }} + onUpdateShare={handleUpdateShare} + onRemoveShare={handleRemoveShare} + onManagePublicLink={() => { + setShowShareDialog(false); + setShowPublicLinkDialog(true); + }} + onClose={() => setShowShareDialog(false)} + /> + )} + + {showFriendSelector && ( + <FriendSelector + friends={friends} + excludedUserIds={excludedUserIds} + onSelect={handleAddShare} + /> + )} + + {showPublicLinkDialog && ( + <PublicLinkDialog + publicShare={publicShare} + onCreate={handleCreatePublicShare} + onDelete={handleDeletePublicShare} + onCancel={() => setShowPublicLinkDialog(false)} + /> + )} + </> + ); +} + +export default memo(() => { + const { theme } = useUnistyles(); + const { id } = useLocalSearchParams<{ id: string }>(); + const isDataReady = useIsDataReady(); + + if (!isDataReady) { + return ( + <View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}> + <Ionicons name="hourglass-outline" size={48} color={theme.colors.textSecondary} /> + <Text style={{ + color: theme.colors.textSecondary, + fontSize: 17, + marginTop: 16, + ...Typography.default('semiBold') + }}> + {t('common.loading')} + </Text> + </View> + ); + } + + return <SharingManagementContent sessionId={id} />; +}); diff --git a/expo-app/sources/app/(app)/settings/account.tsx b/expo-app/sources/app/(app)/settings/account.tsx index e6a1332d6..08946a814 100644 --- a/expo-app/sources/app/(app)/settings/account.tsx +++ b/expo-app/sources/app/(app)/settings/account.tsx @@ -6,9 +6,9 @@ import { Ionicons } from '@expo/vector-icons'; import * as Clipboard from 'expo-clipboard'; import { Typography } from '@/constants/Typography'; import { formatSecretKeyForBackup } from '@/auth/secretKeyBackup'; -import { Item } from '@/components/Item'; -import { ItemGroup } from '@/components/ItemGroup'; -import { ItemList } from '@/components/ItemList'; +import { Item } from '@/components/ui/lists/Item'; +import { ItemGroup } from '@/components/ui/lists/ItemGroup'; +import { ItemList } from '@/components/ui/lists/ItemList'; import { Modal } from '@/modal'; import { t } from '@/text'; import { layout } from '@/components/layout'; @@ -22,6 +22,7 @@ import { Image } from 'expo-image'; import { useHappyAction } from '@/hooks/useHappyAction'; import { disconnectGitHub } from '@/sync/apiGithub'; import { disconnectService } from '@/sync/apiServices'; +import { getAgentCore, resolveAgentIdFromConnectedServiceId, getAgentIconSource, getAgentIconTintColor } from '@/agents/catalog'; export default React.memo(() => { const { theme } = useUnistyles(); @@ -172,40 +173,42 @@ export default React.memo(() => { {/* Connected Services Section */} {profile.connectedServices && profile.connectedServices.length > 0 && (() => { - // Map of service IDs to display names and icons - const knownServices = { - anthropic: { name: 'Claude Code', icon: require('@/assets/images/icon-claude.png'), tintColor: null }, - gemini: { name: 'Google Gemini', icon: require('@/assets/images/icon-gemini.png'), tintColor: null }, - openai: { name: 'OpenAI Codex', icon: require('@/assets/images/icon-gpt.png'), tintColor: theme.colors.text } - }; - - // Filter to only known services - const displayServices = profile.connectedServices.filter( - service => service in knownServices - ); - + const displayServices = profile.connectedServices + .map((serviceId) => { + const agentId = resolveAgentIdFromConnectedServiceId(serviceId); + if (!agentId) return null; + const core = getAgentCore(agentId); + if (!core.connectedService?.id) return null; + return { + serviceId, + name: core.connectedService.name, + icon: getAgentIconSource(agentId), + tintColor: getAgentIconTintColor(agentId, theme) ?? null, + }; + }) + .filter((x): x is NonNullable<typeof x> => Boolean(x)); + if (displayServices.length === 0) return null; return ( <ItemGroup title={t('settings.connectedAccounts')}> {displayServices.map(service => { - const serviceInfo = knownServices[service as keyof typeof knownServices]; - const isDisconnecting = disconnectingService === service; + const isDisconnecting = disconnectingService === service.serviceId; return ( <Item - key={service} - title={serviceInfo.name} + key={service.serviceId} + title={service.name} detail={t('settingsAccount.statusActive')} subtitle={t('settingsAccount.tapToDisconnect')} - onPress={() => handleDisconnectService(service, serviceInfo.name)} + onPress={() => handleDisconnectService(service.serviceId, service.name)} loading={isDisconnecting} disabled={isDisconnecting} showChevron={false} icon={ <Image - source={serviceInfo.icon} + source={service.icon} style={{ width: 29, height: 29 }} - tintColor={serviceInfo.tintColor} + tintColor={service.tintColor} contentFit="contain" /> } diff --git a/expo-app/sources/app/(app)/settings/appearance.tsx b/expo-app/sources/app/(app)/settings/appearance.tsx index fb6bb4505..4b709e4d7 100644 --- a/expo-app/sources/app/(app)/settings/appearance.tsx +++ b/expo-app/sources/app/(app)/settings/appearance.tsx @@ -1,7 +1,8 @@ +import React from 'react'; import { Ionicons } from '@expo/vector-icons'; -import { Item } from '@/components/Item'; -import { ItemGroup } from '@/components/ItemGroup'; -import { ItemList } from '@/components/ItemList'; +import { Item } from '@/components/ui/lists/Item'; +import { ItemGroup } from '@/components/ui/lists/ItemGroup'; +import { ItemList } from '@/components/ui/lists/ItemList'; import { useSettingMutable, useLocalSettingMutable } from '@/sync/storage'; import { useRouter } from 'expo-router'; import * as Localization from 'expo-localization'; @@ -19,7 +20,7 @@ const isKnownAvatarStyle = (style: string): style is KnownAvatarStyle => { return style === 'pixelated' || style === 'gradient' || style === 'brutalist'; }; -export default function AppearanceSettingsScreen() { +export default React.memo(function AppearanceSettingsScreen() { const { theme } = useUnistyles(); const router = useRouter(); const [viewInline, setViewInline] = useSettingMutable('viewInline'); @@ -28,6 +29,8 @@ export default function AppearanceSettingsScreen() { const [showLineNumbersInToolViews, setShowLineNumbersInToolViews] = useSettingMutable('showLineNumbersInToolViews'); const [wrapLinesInDiffs, setWrapLinesInDiffs] = useSettingMutable('wrapLinesInDiffs'); const [alwaysShowContextSize, setAlwaysShowContextSize] = useSettingMutable('alwaysShowContextSize'); + const [agentInputActionBarLayout, setAgentInputActionBarLayout] = useSettingMutable('agentInputActionBarLayout'); + const [agentInputChipDensity, setAgentInputChipDensity] = useSettingMutable('agentInputChipDensity'); const [avatarStyle, setAvatarStyle] = useSettingMutable('avatarStyle'); const [showFlavorIcons, setShowFlavorIcons] = useSettingMutable('showFlavorIcons'); const [compactSessionView, setCompactSessionView] = useSettingMutable('compactSessionView'); @@ -198,6 +201,44 @@ export default function AppearanceSettingsScreen() { /> } /> + <Item + title={t('settingsAppearance.agentInputActionBarLayout')} + subtitle={t('settingsAppearance.agentInputActionBarLayoutDescription')} + icon={<Ionicons name="menu-outline" size={29} color="#5856D6" />} + detail={ + agentInputActionBarLayout === 'auto' + ? t('settingsAppearance.agentInputActionBarLayoutOptions.auto') + : agentInputActionBarLayout === 'wrap' + ? t('settingsAppearance.agentInputActionBarLayoutOptions.wrap') + : agentInputActionBarLayout === 'scroll' + ? t('settingsAppearance.agentInputActionBarLayoutOptions.scroll') + : t('settingsAppearance.agentInputActionBarLayoutOptions.collapsed') + } + onPress={() => { + const order: Array<typeof agentInputActionBarLayout> = ['auto', 'wrap', 'scroll', 'collapsed']; + const idx = Math.max(0, order.indexOf(agentInputActionBarLayout)); + const next = order[(idx + 1) % order.length]!; + setAgentInputActionBarLayout(next); + }} + /> + <Item + title={t('settingsAppearance.agentInputChipDensity')} + subtitle={t('settingsAppearance.agentInputChipDensityDescription')} + icon={<Ionicons name="text-outline" size={29} color="#5856D6" />} + detail={ + agentInputChipDensity === 'auto' + ? t('settingsAppearance.agentInputChipDensityOptions.auto') + : agentInputChipDensity === 'labels' + ? t('settingsAppearance.agentInputChipDensityOptions.labels') + : t('settingsAppearance.agentInputChipDensityOptions.icons') + } + onPress={() => { + const order: Array<typeof agentInputChipDensity> = ['auto', 'labels', 'icons']; + const idx = Math.max(0, order.indexOf(agentInputChipDensity)); + const next = order[(idx + 1) % order.length]!; + setAgentInputChipDensity(next); + }} + /> <Item title={t('settingsAppearance.avatarStyle')} subtitle={t('settingsAppearance.avatarStyleDescription')} @@ -260,4 +301,4 @@ export default function AppearanceSettingsScreen() { </ItemGroup> */} </ItemList> ); -} \ No newline at end of file +}); diff --git a/expo-app/sources/app/(app)/settings/connect/claude.tsx b/expo-app/sources/app/(app)/settings/connect/claude.tsx index 0693dd796..8008dca48 100644 --- a/expo-app/sources/app/(app)/settings/connect/claude.tsx +++ b/expo-app/sources/app/(app)/settings/connect/claude.tsx @@ -72,9 +72,9 @@ const OAuthViewUnsupported = React.memo((props: { return ( <View style={styles.unsupportedContainer}> - <Text style={styles.unsupportedTitle}>Connect {props.name}</Text> + <Text style={styles.unsupportedTitle}>{t('connect.unsupported.connectTitle', { name: props.name })}</Text> <Text style={styles.unsupportedText}> - Run the following command in your terminal: + {t('connect.unsupported.runCommandInTerminal')} </Text> <View style={styles.terminalContainer}> <Text style={styles.terminalCommand}> diff --git a/expo-app/sources/app/(app)/settings/features.tsx b/expo-app/sources/app/(app)/settings/features.tsx index ac7261455..1bfc5d3a0 100644 --- a/expo-app/sources/app/(app)/settings/features.tsx +++ b/expo-app/sources/app/(app)/settings/features.tsx @@ -1,61 +1,91 @@ +import React from 'react'; import { Platform } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; -import { Item } from '@/components/Item'; -import { ItemGroup } from '@/components/ItemGroup'; -import { ItemList } from '@/components/ItemList'; +import { Item } from '@/components/ui/lists/Item'; +import { ItemGroup } from '@/components/ui/lists/ItemGroup'; +import { ItemList } from '@/components/ui/lists/ItemList'; import { useSettingMutable, useLocalSettingMutable } from '@/sync/storage'; import { Switch } from '@/components/Switch'; import { t } from '@/text'; +import { AGENT_IDS, getAgentCore, type AgentId } from '@/agents/catalog'; -export default function FeaturesSettingsScreen() { +export default React.memo(function FeaturesSettingsScreen() { const [experiments, setExperiments] = useSettingMutable('experiments'); + const [experimentalAgents, setExperimentalAgents] = useSettingMutable('experimentalAgents'); + const [expUsageReporting, setExpUsageReporting] = useSettingMutable('expUsageReporting'); + const [expFileViewer, setExpFileViewer] = useSettingMutable('expFileViewer'); + const [expShowThinkingMessages, setExpShowThinkingMessages] = useSettingMutable('expShowThinkingMessages'); + const [expSessionType, setExpSessionType] = useSettingMutable('expSessionType'); + const [expZen, setExpZen] = useSettingMutable('expZen'); + const [expVoiceAuthFlow, setExpVoiceAuthFlow] = useSettingMutable('expVoiceAuthFlow'); + const [expInboxFriends, setExpInboxFriends] = useSettingMutable('expInboxFriends'); + const [expCodexResume, setExpCodexResume] = useSettingMutable('expCodexResume'); + const [expCodexAcp, setExpCodexAcp] = useSettingMutable('expCodexAcp'); + const [useProfiles, setUseProfiles] = useSettingMutable('useProfiles'); const [agentInputEnterToSend, setAgentInputEnterToSend] = useSettingMutable('agentInputEnterToSend'); const [commandPaletteEnabled, setCommandPaletteEnabled] = useLocalSettingMutable('commandPaletteEnabled'); const [markdownCopyV2, setMarkdownCopyV2] = useLocalSettingMutable('markdownCopyV2'); const [hideInactiveSessions, setHideInactiveSessions] = useSettingMutable('hideInactiveSessions'); + const [groupInactiveSessionsByProject, setGroupInactiveSessionsByProject] = useSettingMutable('groupInactiveSessionsByProject'); const [useEnhancedSessionWizard, setUseEnhancedSessionWizard] = useSettingMutable('useEnhancedSessionWizard'); + const [useMachinePickerSearch, setUseMachinePickerSearch] = useSettingMutable('useMachinePickerSearch'); + const [usePathPickerSearch, setUsePathPickerSearch] = useSettingMutable('usePathPickerSearch'); + + const setAllExperimentToggles = React.useCallback((enabled: boolean) => { + const nextExperimentalAgents: Record<string, boolean> = { ...(experimentalAgents ?? {}) }; + for (const id of AGENT_IDS) { + if (getAgentCore(id).availability.experimental) { + nextExperimentalAgents[id] = enabled; + } + } + setExperimentalAgents(nextExperimentalAgents as any); + setExpUsageReporting(enabled); + setExpFileViewer(enabled); + setExpShowThinkingMessages(enabled); + setExpSessionType(enabled); + setExpZen(enabled); + setExpVoiceAuthFlow(enabled); + setExpInboxFriends(enabled); + // Intentionally NOT auto-enabled: these require additional local installs and have extra surface area. + setExpCodexResume(false); + setExpCodexAcp(false); + }, [ + setExpCodexAcp, + setExpCodexResume, + setExpFileViewer, + setExpInboxFriends, + setExpSessionType, + setExpShowThinkingMessages, + setExpUsageReporting, + setExpVoiceAuthFlow, + setExpZen, + experimentalAgents, + setExperimentalAgents, + ]); return ( <ItemList style={{ paddingTop: 0 }}> - {/* Experimental Features */} - <ItemGroup - title={t('settingsFeatures.experiments')} - footer={t('settingsFeatures.experimentsDescription')} - > - <Item - title={t('settingsFeatures.experimentalFeatures')} - subtitle={experiments ? t('settingsFeatures.experimentalFeaturesEnabled') : t('settingsFeatures.experimentalFeaturesDisabled')} - icon={<Ionicons name="flask-outline" size={29} color="#5856D6" />} - rightElement={ - <Switch - value={experiments} - onValueChange={setExperiments} - /> - } - showChevron={false} - /> + {/* Standard feature toggles first */} + <ItemGroup> <Item title={t('settingsFeatures.markdownCopyV2')} subtitle={t('settingsFeatures.markdownCopyV2Subtitle')} icon={<Ionicons name="text-outline" size={29} color="#34C759" />} - rightElement={ - <Switch - value={markdownCopyV2} - onValueChange={setMarkdownCopyV2} - /> - } + rightElement={<Switch value={markdownCopyV2} onValueChange={setMarkdownCopyV2} />} showChevron={false} /> <Item title={t('settingsFeatures.hideInactiveSessions')} subtitle={t('settingsFeatures.hideInactiveSessionsSubtitle')} icon={<Ionicons name="eye-off-outline" size={29} color="#FF9500" />} - rightElement={ - <Switch - value={hideInactiveSessions} - onValueChange={setHideInactiveSessions} - /> - } + rightElement={<Switch value={hideInactiveSessions} onValueChange={setHideInactiveSessions} />} + showChevron={false} + /> + <Item + title={t('settingsFeatures.groupInactiveSessionsByProject')} + subtitle={t('settingsFeatures.groupInactiveSessionsByProjectSubtitle')} + icon={<Ionicons name="folder-outline" size={29} color="#007AFF" />} + rightElement={<Switch value={groupInactiveSessionsByProject} onValueChange={setGroupInactiveSessionsByProject} />} showChevron={false} /> <Item @@ -64,19 +94,37 @@ export default function FeaturesSettingsScreen() { ? t('settingsFeatures.enhancedSessionWizardEnabled') : t('settingsFeatures.enhancedSessionWizardDisabled')} icon={<Ionicons name="sparkles-outline" size={29} color="#AF52DE" />} - rightElement={ - <Switch - value={useEnhancedSessionWizard} - onValueChange={setUseEnhancedSessionWizard} - /> - } + rightElement={<Switch value={useEnhancedSessionWizard} onValueChange={setUseEnhancedSessionWizard} />} + showChevron={false} + /> + <Item + title={t('settingsFeatures.machinePickerSearch')} + subtitle={t('settingsFeatures.machinePickerSearchSubtitle')} + icon={<Ionicons name="search-outline" size={29} color="#007AFF" />} + rightElement={<Switch value={useMachinePickerSearch} onValueChange={setUseMachinePickerSearch} />} + showChevron={false} + /> + <Item + title={t('settingsFeatures.pathPickerSearch')} + subtitle={t('settingsFeatures.pathPickerSearchSubtitle')} + icon={<Ionicons name="folder-outline" size={29} color="#007AFF" />} + rightElement={<Switch value={usePathPickerSearch} onValueChange={setUsePathPickerSearch} />} + showChevron={false} + /> + <Item + title={t('settingsFeatures.profiles')} + subtitle={useProfiles + ? t('settingsFeatures.profilesEnabled') + : t('settingsFeatures.profilesDisabled')} + icon={<Ionicons name="person-outline" size={29} color="#AF52DE" />} + rightElement={<Switch value={useProfiles} onValueChange={setUseProfiles} />} showChevron={false} /> </ItemGroup> {/* Web-only Features */} {Platform.OS === 'web' && ( - <ItemGroup + <ItemGroup title={t('settingsFeatures.webFeatures')} footer={t('settingsFeatures.webFeaturesDescription')} > @@ -84,28 +132,154 @@ export default function FeaturesSettingsScreen() { title={t('settingsFeatures.enterToSend')} subtitle={agentInputEnterToSend ? t('settingsFeatures.enterToSendEnabled') : t('settingsFeatures.enterToSendDisabled')} icon={<Ionicons name="return-down-forward-outline" size={29} color="#007AFF" />} - rightElement={ - <Switch - value={agentInputEnterToSend} - onValueChange={setAgentInputEnterToSend} - /> - } + rightElement={<Switch value={agentInputEnterToSend} onValueChange={setAgentInputEnterToSend} />} showChevron={false} /> <Item title={t('settingsFeatures.commandPalette')} subtitle={commandPaletteEnabled ? t('settingsFeatures.commandPaletteEnabled') : t('settingsFeatures.commandPaletteDisabled')} icon={<Ionicons name="keypad-outline" size={29} color="#007AFF" />} - rightElement={ - <Switch - value={commandPaletteEnabled} - onValueChange={setCommandPaletteEnabled} + rightElement={<Switch value={commandPaletteEnabled} onValueChange={setCommandPaletteEnabled} />} + showChevron={false} + /> + </ItemGroup> + )} + + {/* Experiments last */} + <ItemGroup + title={t('settingsFeatures.experiments')} + footer={t('settingsFeatures.experimentsDescription')} + > + <Item + title={t('settingsFeatures.experimentalFeatures')} + subtitle={experiments ? t('settingsFeatures.experimentalFeaturesEnabled') : t('settingsFeatures.experimentalFeaturesDisabled')} + icon={<Ionicons name="flask-outline" size={29} color="#5856D6" />} + rightElement={ + <Switch + value={experiments} + onValueChange={(next) => { + setExperiments(next); + // Requirement: toggling the master switch enables/disables all experiments by default. + setAllExperimentToggles(next); + }} + /> + } + showChevron={false} + /> + </ItemGroup> + + {experiments && ( + <ItemGroup + title={t('settingsFeatures.experimentalOptions')} + footer={t('settingsFeatures.experimentalOptionsDescription')} + > + {AGENT_IDS.filter((id) => getAgentCore(id).availability.experimental).map((agentId) => { + const enabled = experimentalAgents?.[agentId] === true; + const icon = getAgentCore(agentId).ui.agentPickerIconName as React.ComponentProps<typeof Ionicons>['name']; + return ( + <Item + key={agentId} + title={t(getAgentCore(agentId).displayNameKey)} + subtitle={t(getAgentCore(agentId).subtitleKey)} + icon={<Ionicons name={icon} size={29} color="#007AFF" />} + rightElement={ + <Switch + value={enabled} + onValueChange={(next) => { + setExperimentalAgents({ + ...(experimentalAgents ?? {}), + [agentId]: next, + } as any); + }} + /> + } + showChevron={false} /> - } + ); + })} + <Item + title={t('settingsFeatures.expUsageReporting')} + subtitle={t('settingsFeatures.expUsageReportingSubtitle')} + icon={<Ionicons name="analytics-outline" size={29} color="#007AFF" />} + rightElement={<Switch value={expUsageReporting} onValueChange={setExpUsageReporting} />} + showChevron={false} + /> + <Item + title={t('settingsFeatures.expFileViewer')} + subtitle={t('settingsFeatures.expFileViewerSubtitle')} + icon={<Ionicons name="folder-open-outline" size={29} color="#FF9500" />} + rightElement={<Switch value={expFileViewer} onValueChange={setExpFileViewer} />} + showChevron={false} + /> + <Item + title={t('settingsFeatures.expShowThinkingMessages')} + subtitle={t('settingsFeatures.expShowThinkingMessagesSubtitle')} + icon={<Ionicons name="chatbubbles-outline" size={29} color="#34C759" />} + rightElement={<Switch value={expShowThinkingMessages} onValueChange={setExpShowThinkingMessages} />} + showChevron={false} + /> + <Item + title={t('settingsFeatures.expSessionType')} + subtitle={t('settingsFeatures.expSessionTypeSubtitle')} + icon={<Ionicons name="layers-outline" size={29} color="#AF52DE" />} + rightElement={<Switch value={expSessionType} onValueChange={setExpSessionType} />} + showChevron={false} + /> + <Item + title={t('settingsFeatures.expZen')} + subtitle={t('settingsFeatures.expZenSubtitle')} + icon={<Ionicons name="leaf-outline" size={29} color="#34C759" />} + rightElement={<Switch value={expZen} onValueChange={setExpZen} />} + showChevron={false} + /> + <Item + title={t('settingsFeatures.expVoiceAuthFlow')} + subtitle={t('settingsFeatures.expVoiceAuthFlowSubtitle')} + icon={<Ionicons name="mic-outline" size={29} color="#FF3B30" />} + rightElement={<Switch value={expVoiceAuthFlow} onValueChange={setExpVoiceAuthFlow} />} + showChevron={false} + /> + <Item + title={t('settingsFeatures.expInboxFriends')} + subtitle={t('settingsFeatures.expInboxFriendsSubtitle')} + icon={<Ionicons name="people-outline" size={29} color="#007AFF" />} + rightElement={<Switch value={expInboxFriends} onValueChange={setExpInboxFriends} />} + showChevron={false} + /> + <Item + title={t('settingsFeatures.expCodexResume')} + subtitle={t('settingsFeatures.expCodexResumeSubtitle')} + icon={<Ionicons name="sparkles-outline" size={29} color="#007AFF" />} + rightElement={<Switch + value={expCodexResume} + onValueChange={(next) => { + setExpCodexResume(next); + if (next) { + // Mutually exclusive: ACP makes the vendor-resume MCP fork unnecessary. + setExpCodexAcp(false); + } + }} + />} + showChevron={false} + /> + <Item + title={t('settingsFeatures.expCodexAcp')} + subtitle={t('settingsFeatures.expCodexAcpSubtitle')} + icon={<Ionicons name="sparkles-outline" size={29} color="#007AFF" />} + rightElement={<Switch + value={expCodexAcp} + onValueChange={(next) => { + setExpCodexAcp(next); + if (next) { + // Mutually exclusive: ACP replaces the resume-specific MCP fork. + setExpCodexResume(false); + } + }} + />} showChevron={false} /> </ItemGroup> )} </ItemList> ); -} +}); diff --git a/expo-app/sources/app/(app)/settings/language.tsx b/expo-app/sources/app/(app)/settings/language.tsx index 39150e8a7..89bd88ab1 100644 --- a/expo-app/sources/app/(app)/settings/language.tsx +++ b/expo-app/sources/app/(app)/settings/language.tsx @@ -1,8 +1,8 @@ import React from 'react'; import { Ionicons } from '@expo/vector-icons'; -import { Item } from '@/components/Item'; -import { ItemGroup } from '@/components/ItemGroup'; -import { ItemList } from '@/components/ItemList'; +import { Item } from '@/components/ui/lists/Item'; +import { ItemGroup } from '@/components/ui/lists/ItemGroup'; +import { ItemList } from '@/components/ui/lists/ItemList'; import { useSettingMutable } from '@/sync/storage'; import { useUnistyles } from 'react-native-unistyles'; import { t, getLanguageNativeName, SUPPORTED_LANGUAGES, SUPPORTED_LANGUAGE_CODES, type SupportedLanguage } from '@/text'; diff --git a/expo-app/sources/app/(app)/settings/profiles.tsx b/expo-app/sources/app/(app)/settings/profiles.tsx index fa4522023..21a6a6d94 100644 --- a/expo-app/sources/app/(app)/settings/profiles.tsx +++ b/expo-app/sources/app/(app)/settings/profiles.tsx @@ -1,25 +1,26 @@ import React from 'react'; -import { View, Text, Pressable, ScrollView, Alert } from 'react-native'; +import { View, Pressable, Platform } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; +import { useNavigation, useRouter } from 'expo-router'; import { useSettingMutable } from '@/sync/storage'; import { StyleSheet } from 'react-native-unistyles'; import { useUnistyles } from 'react-native-unistyles'; -import { Typography } from '@/constants/Typography'; import { t } from '@/text'; -import { Modal as HappyModal } from '@/modal/ModalManager'; -import { layout } from '@/components/layout'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { useWindowDimensions } from 'react-native'; +import { Modal } from '@/modal'; +import { promptUnsavedChangesAlert } from '@/utils/ui/promptUnsavedChangesAlert'; import { AIBackendProfile } from '@/sync/settings'; -import { getBuiltInProfile, DEFAULT_PROFILES } from '@/sync/profileUtils'; -import { ProfileEditForm } from '@/components/ProfileEditForm'; -import { randomUUID } from 'expo-crypto'; - -interface ProfileDisplay { - id: string; - name: string; - isBuiltIn: boolean; -} +import { DEFAULT_PROFILES, getBuiltInProfileNameKey, resolveProfileById } from '@/sync/profileUtils'; +import { ProfileEditForm } from '@/components/profiles/edit'; +import { ItemList } from '@/components/ui/lists/ItemList'; +import { ItemGroup } from '@/components/ui/lists/ItemGroup'; +import { Item } from '@/components/ui/lists/Item'; +import { Switch } from '@/components/Switch'; +import { convertBuiltInProfileToCustom, createEmptyCustomProfile, duplicateProfileForEdit } from '@/sync/profileMutations'; +import { useSetting } from '@/sync/storage'; +import { ProfilesList } from '@/components/profiles/ProfilesList'; +import { SecretRequirementModal, type SecretRequirementModalResult } from '@/components/secrets/requirements'; +import { getSecretSatisfaction } from '@/utils/secrets/secretSatisfaction'; +import { getRequiredSecretEnvVarNames } from '@/sync/profileSecrets'; interface ProfileManagerProps { onProfileSelect?: (profile: AIBackendProfile | null) => void; @@ -27,81 +28,201 @@ interface ProfileManagerProps { } // Profile utilities now imported from @/sync/profileUtils - -function ProfileManager({ onProfileSelect, selectedProfileId }: ProfileManagerProps) { +const ProfileManager = React.memo(function ProfileManager({ onProfileSelect, selectedProfileId }: ProfileManagerProps) { const { theme } = useUnistyles(); + const router = useRouter(); + const navigation = useNavigation(); + const [useProfiles, setUseProfiles] = useSettingMutable('useProfiles'); const [profiles, setProfiles] = useSettingMutable('profiles'); const [lastUsedProfile, setLastUsedProfile] = useSettingMutable('lastUsedProfile'); + const [favoriteProfileIds, setFavoriteProfileIds] = useSettingMutable('favoriteProfiles'); const [editingProfile, setEditingProfile] = React.useState<AIBackendProfile | null>(null); const [showAddForm, setShowAddForm] = React.useState(false); - const safeArea = useSafeAreaInsets(); - const screenWidth = useWindowDimensions().width; + const [isEditingDirty, setIsEditingDirty] = React.useState(false); + const isEditingDirtyRef = React.useRef(false); + const saveRef = React.useRef<(() => boolean) | null>(null); + const experimentsEnabled = useSetting('experiments'); + const [secrets, setSecrets] = useSettingMutable('secrets'); + const [secretBindingsByProfileId, setSecretBindingsByProfileId] = useSettingMutable('secretBindingsByProfileId'); - const handleAddProfile = () => { - setEditingProfile({ - id: randomUUID(), - name: '', - anthropicConfig: {}, - environmentVariables: [], - compatibility: { claude: true, codex: true, gemini: true }, - isBuiltIn: false, - createdAt: Date.now(), - updatedAt: Date.now(), - version: '1.0.0', + const openSecretModal = React.useCallback((profile: AIBackendProfile, envVarName?: string) => { + const requiredSecretNames = getRequiredSecretEnvVarNames(profile); + const requiredSecretName = (envVarName ?? requiredSecretNames[0] ?? '').trim().toUpperCase(); + if (!requiredSecretName) return; + + const handleResolve = (result: SecretRequirementModalResult) => { + if (result.action !== 'selectSaved') return; + setSecretBindingsByProfileId({ + ...secretBindingsByProfileId, + [profile.id]: { + ...(secretBindingsByProfileId[profile.id] ?? {}), + [requiredSecretName]: result.secretId, + }, + }); + }; + + Modal.show({ + component: SecretRequirementModal, + props: { + profile, + secretEnvVarName: requiredSecretName, + secretEnvVarNames: requiredSecretNames, + machineId: null, + secrets, + defaultSecretId: secretBindingsByProfileId[profile.id]?.[requiredSecretName] ?? null, + defaultSecretIdByEnvVarName: secretBindingsByProfileId[profile.id] ?? null, + onChangeSecrets: setSecrets, + allowSessionOnly: false, + onResolve: handleResolve, + onRequestClose: () => handleResolve({ action: 'cancel' } as SecretRequirementModalResult), + }, + closeOnBackdrop: true, }); + }, [secrets, secretBindingsByProfileId, setSecretBindingsByProfileId]); + + React.useEffect(() => { + isEditingDirtyRef.current = isEditingDirty; + }, [isEditingDirty]); + + const handleAddProfile = () => { + if (Platform.OS !== 'web') { + router.push({ pathname: '/new/pick/profile-edit', params: {} } as any); + return; + } + setEditingProfile(createEmptyCustomProfile()); setShowAddForm(true); }; const handleEditProfile = (profile: AIBackendProfile) => { + if (Platform.OS !== 'web') { + router.push({ pathname: '/new/pick/profile-edit', params: { profileId: profile.id } } as any); + return; + } setEditingProfile({ ...profile }); setShowAddForm(true); }; - const handleDeleteProfile = (profile: AIBackendProfile) => { - // Show confirmation dialog before deleting - Alert.alert( - t('profiles.delete.title'), - t('profiles.delete.message', { name: profile.name }), - [ + const handleDuplicateProfile = (profile: AIBackendProfile) => { + if (Platform.OS !== 'web') { + router.push({ pathname: '/new/pick/profile-edit', params: { cloneFromProfileId: profile.id } } as any); + return; + } + setEditingProfile(duplicateProfileForEdit(profile, { copySuffix: t('profiles.copySuffix') })); + setShowAddForm(true); + }; + + const closeEditor = React.useCallback(() => { + setShowAddForm(false); + setEditingProfile(null); + setIsEditingDirty(false); + }, []); + + const requestCloseEditor = React.useCallback(() => { + void (async () => { + if (!isEditingDirtyRef.current) { + closeEditor(); + return; + } + const isBuiltIn = !!editingProfile && DEFAULT_PROFILES.some((bp) => bp.id === editingProfile.id); + const saveText = isBuiltIn ? t('common.saveAs') : t('common.save'); + const message = isBuiltIn + ? `${t('common.unsavedChangesWarning')}\n\n${t('profiles.builtInSaveAsHint')}` + : t('common.unsavedChangesWarning'); + const decision = await promptUnsavedChangesAlert( + (title, message, buttons) => Modal.alert(title, message, buttons), { - text: t('profiles.delete.cancel'), - style: 'cancel', + title: t('common.discardChanges'), + message, + discardText: t('common.discard'), + saveText, + keepEditingText: t('common.keepEditing'), }, - { - text: t('profiles.delete.confirm'), - style: 'destructive', - onPress: () => { - const updatedProfiles = profiles.filter(p => p.id !== profile.id); - setProfiles(updatedProfiles); - - // Clear last used profile if it was deleted - if (lastUsedProfile === profile.id) { - setLastUsedProfile(null); - } + ); - // Notify parent if this was the selected profile - if (selectedProfileId === profile.id && onProfileSelect) { - onProfileSelect(null); - } + if (decision === 'discard') { + isEditingDirtyRef.current = false; + closeEditor(); + } else if (decision === 'save') { + // Save the form state (not the initial profile snapshot). + saveRef.current?.(); + } + })(); + }, [closeEditor, editingProfile]); + + React.useEffect(() => { + const addListener = (navigation as any)?.addListener; + if (typeof addListener !== 'function') { + return; + } + + const subscription = addListener.call(navigation, 'beforeRemove', (e: any) => { + if (!showAddForm || !isEditingDirtyRef.current) return; + + e.preventDefault(); + + void (async () => { + const isBuiltIn = !!editingProfile && DEFAULT_PROFILES.some((bp) => bp.id === editingProfile.id); + const saveText = isBuiltIn ? t('common.saveAs') : t('common.save'); + const message = isBuiltIn + ? `${t('common.unsavedChangesWarning')}\n\n${t('profiles.builtInSaveAsHint')}` + : t('common.unsavedChangesWarning'); + + const decision = await promptUnsavedChangesAlert( + (title, message, buttons) => Modal.alert(title, message, buttons), + { + title: t('common.discardChanges'), + message, + discardText: t('common.discard'), + saveText, + keepEditingText: t('common.keepEditing'), }, - }, - ], - { cancelable: true } + ); + + if (decision === 'discard') { + isEditingDirtyRef.current = false; + closeEditor(); + (navigation as any).dispatch(e.data.action); + } else if (decision === 'save') { + // Save form state; only continue navigation if save succeeded. + const didSave = saveRef.current?.() ?? false; + if (didSave) { + isEditingDirtyRef.current = false; + (navigation as any).dispatch(e.data.action); + } + } + })(); + }); + + return () => subscription?.remove?.(); + }, [closeEditor, editingProfile, navigation, showAddForm]); + + const handleDeleteProfile = async (profile: AIBackendProfile) => { + const confirmed = await Modal.confirm( + t('profiles.delete.title'), + t('profiles.delete.message', { name: profile.name }), + { cancelText: t('profiles.delete.cancel'), confirmText: t('profiles.delete.confirm'), destructive: true } ); + if (!confirmed) return; + + const updatedProfiles = profiles.filter(p => p.id !== profile.id); + setProfiles(updatedProfiles); + + // Clear last used profile if it was deleted + if (lastUsedProfile === profile.id) { + setLastUsedProfile(null); + } + + // Notify parent if this was the selected profile + if (selectedProfileId === profile.id && onProfileSelect) { + onProfileSelect(null); + } }; const handleSelectProfile = (profileId: string | null) => { let profile: AIBackendProfile | null = null; if (profileId) { - // Check if it's a built-in profile - const builtInProfile = getBuiltInProfile(profileId); - if (builtInProfile) { - profile = builtInProfile; - } else { - // Check if it's a custom profile - profile = profiles.find(p => p.id === profileId) || null; - } + profile = resolveProfileById(profileId, profiles); } if (onProfileSelect) { @@ -110,28 +231,34 @@ function ProfileManager({ onProfileSelect, selectedProfileId }: ProfileManagerPr setLastUsedProfile(profileId); }; - const handleSaveProfile = (profile: AIBackendProfile) => { + function handleSaveProfile(profile: AIBackendProfile): boolean { // Profile validation - ensure name is not empty if (!profile.name || profile.name.trim() === '') { - return; + Modal.alert(t('common.error'), t('profiles.nameRequired')); + return false; } // Check if this is a built-in profile being edited const isBuiltIn = DEFAULT_PROFILES.some(bp => bp.id === profile.id); + const builtInNames = DEFAULT_PROFILES + .map((bp) => { + const key = getBuiltInProfileNameKey(bp.id); + return key ? t(key).trim() : null; + }) + .filter((name): name is string => Boolean(name)); // For built-in profiles, create a new custom profile instead of modifying the built-in if (isBuiltIn) { - const newProfile: AIBackendProfile = { - ...profile, - id: randomUUID(), // Generate new UUID for custom profile - }; + const newProfile = convertBuiltInProfileToCustom(profile); + const hasBuiltInNameConflict = builtInNames.includes(newProfile.name.trim()); // Check for duplicate names (excluding the new profile) const isDuplicate = profiles.some(p => p.name.trim() === newProfile.name.trim() ); - if (isDuplicate) { - return; + if (isDuplicate || hasBuiltInNameConflict) { + Modal.alert(t('common.error'), t('profiles.duplicateName')); + return false; } setProfiles([...profiles, newProfile]); @@ -141,8 +268,10 @@ function ProfileManager({ onProfileSelect, selectedProfileId }: ProfileManagerPr const isDuplicate = profiles.some(p => p.id !== profile.id && p.name.trim() === profile.name.trim() ); - if (isDuplicate) { - return; + const hasBuiltInNameConflict = builtInNames.includes(profile.name.trim()); + if (isDuplicate || hasBuiltInNameConflict) { + Modal.alert(t('common.error'), t('profiles.duplicateName')); + return false; } const existingIndex = profiles.findIndex(p => p.id === profile.id); @@ -151,7 +280,10 @@ function ProfileManager({ onProfileSelect, selectedProfileId }: ProfileManagerPr if (existingIndex >= 0) { // Update existing profile updatedProfiles = [...profiles]; - updatedProfiles[existingIndex] = profile; + updatedProfiles[existingIndex] = { + ...profile, + updatedAt: Date.now(), + }; } else { // Add new profile updatedProfiles = [...profiles, profile]; @@ -160,259 +292,104 @@ function ProfileManager({ onProfileSelect, selectedProfileId }: ProfileManagerPr setProfiles(updatedProfiles); } - setShowAddForm(false); - setEditingProfile(null); - }; + closeEditor(); + return true; + } + + if (!useProfiles) { + return ( + <ItemList style={{ paddingTop: 0 }}> + <ItemGroup + title={t('settingsFeatures.profiles')} + footer={t('settingsFeatures.profilesDisabled')} + > + <Item + title={t('settingsFeatures.profiles')} + subtitle={t('settingsFeatures.profilesDisabled')} + icon={<Ionicons name="person-outline" size={29} color="#AF52DE" />} + rightElement={ + <Switch + value={useProfiles} + onValueChange={setUseProfiles} + /> + } + showChevron={false} + /> + </ItemGroup> + </ItemList> + ); + } return ( - <View style={{ flex: 1, backgroundColor: theme.colors.surface }}> - <ScrollView - style={{ flex: 1 }} - contentContainerStyle={{ - paddingHorizontal: screenWidth > 700 ? 16 : 8, - paddingBottom: safeArea.bottom + 100, + <View style={{ flex: 1 }}> + <ProfilesList + customProfiles={profiles} + favoriteProfileIds={favoriteProfileIds} + onFavoriteProfileIdsChange={setFavoriteProfileIds} + experimentsEnabled={experimentsEnabled} + selectedProfileId={selectedProfileId ?? null} + onPressProfile={(profile) => handleEditProfile(profile)} + machineId={null} + includeAddProfileRow + onAddProfilePress={handleAddProfile} + onEditProfile={(profile) => handleEditProfile(profile)} + onDuplicateProfile={(profile) => handleDuplicateProfile(profile)} + onDeleteProfile={(profile) => { void handleDeleteProfile(profile); }} + onSecretBadgePress={(profile) => { + const required = getRequiredSecretEnvVarNames(profile); + if (required.length <= 1) { + openSecretModal(profile, required[0]); + return; + } + // When multiple required secrets exist, prompt for which env var to configure. + Modal.alert( + t('secrets.defineDefaultForProfileTitle'), + required.join('\n'), + [ + { text: t('common.cancel'), style: 'cancel' }, + ...required.map((env) => ({ + text: env, + onPress: () => openSecretModal(profile, env), + })), + ], + ); }} - > - <View style={[{ maxWidth: layout.maxWidth, alignSelf: 'center', width: '100%' }]}> - <Text style={{ - fontSize: 24, - fontWeight: 'bold', - color: theme.colors.text, - marginVertical: 16, - ...Typography.default('semiBold') - }}> - {t('profiles.title')} - </Text> - - {/* None option - no profile */} - <Pressable - style={{ - backgroundColor: theme.colors.input.background, - borderRadius: 12, - padding: 16, - marginBottom: 12, - flexDirection: 'row', - alignItems: 'center', - borderWidth: selectedProfileId === null ? 2 : 0, - borderColor: theme.colors.text, - }} - onPress={() => handleSelectProfile(null)} - > - <View style={{ - width: 24, - height: 24, - borderRadius: 12, - backgroundColor: theme.colors.button.secondary.tint, - justifyContent: 'center', - alignItems: 'center', - marginRight: 12, - }}> - <Ionicons name="remove" size={16} color="white" /> - </View> - <View style={{ flex: 1 }}> - <Text style={{ - fontSize: 16, - fontWeight: '600', - color: theme.colors.text, - ...Typography.default('semiBold') - }}> - {t('profiles.noProfile')} - </Text> - <Text style={{ - fontSize: 14, - color: theme.colors.textSecondary, - marginTop: 2, - ...Typography.default() - }}> - {t('profiles.noProfileDescription')} - </Text> - </View> - {selectedProfileId === null && ( - <Ionicons name="checkmark-circle" size={20} color={theme.colors.text} /> - )} - </Pressable> - - {/* Built-in profiles */} - {DEFAULT_PROFILES.map((profileDisplay) => { - const profile = getBuiltInProfile(profileDisplay.id); - if (!profile) return null; - - return ( - <Pressable - key={profile.id} - style={{ - backgroundColor: theme.colors.input.background, - borderRadius: 12, - padding: 16, - marginBottom: 12, - flexDirection: 'row', - alignItems: 'center', - borderWidth: selectedProfileId === profile.id ? 2 : 0, - borderColor: theme.colors.text, - }} - onPress={() => handleSelectProfile(profile.id)} - > - <View style={{ - width: 24, - height: 24, - borderRadius: 12, - backgroundColor: theme.colors.button.primary.background, - justifyContent: 'center', - alignItems: 'center', - marginRight: 12, - }}> - <Ionicons name="star" size={16} color="white" /> - </View> - <View style={{ flex: 1 }}> - <Text style={{ - fontSize: 16, - fontWeight: '600', - color: theme.colors.text, - ...Typography.default('semiBold') - }}> - {profile.name} - </Text> - <Text style={{ - fontSize: 14, - color: theme.colors.textSecondary, - marginTop: 2, - ...Typography.default() - }}> - {profile.anthropicConfig?.model || 'Default model'} - {profile.anthropicConfig?.baseUrl && ` • ${profile.anthropicConfig.baseUrl}`} - </Text> - </View> - <View style={{ flexDirection: 'row', alignItems: 'center' }}> - {selectedProfileId === profile.id && ( - <Ionicons name="checkmark-circle" size={20} color={theme.colors.text} style={{ marginRight: 12 }} /> - )} - <Pressable - hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} - onPress={() => handleEditProfile(profile)} - > - <Ionicons name="create-outline" size={20} color={theme.colors.button.secondary.tint} /> - </Pressable> - </View> - </Pressable> - ); - })} - - {/* Custom profiles */} - {profiles.map((profile) => ( - <Pressable - key={profile.id} - style={{ - backgroundColor: theme.colors.input.background, - borderRadius: 12, - padding: 16, - marginBottom: 12, - flexDirection: 'row', - alignItems: 'center', - borderWidth: selectedProfileId === profile.id ? 2 : 0, - borderColor: theme.colors.text, - }} - onPress={() => handleSelectProfile(profile.id)} - > - <View style={{ - width: 24, - height: 24, - borderRadius: 12, - backgroundColor: theme.colors.button.secondary.tint, - justifyContent: 'center', - alignItems: 'center', - marginRight: 12, - }}> - <Ionicons name="person" size={16} color="white" /> - </View> - <View style={{ flex: 1 }}> - <Text style={{ - fontSize: 16, - fontWeight: '600', - color: theme.colors.text, - ...Typography.default('semiBold') - }}> - {profile.name} - </Text> - <Text style={{ - fontSize: 14, - color: theme.colors.textSecondary, - marginTop: 2, - ...Typography.default() - }}> - {profile.anthropicConfig?.model || t('profiles.defaultModel')} - {profile.tmuxConfig?.sessionName && ` • tmux: ${profile.tmuxConfig.sessionName}`} - {profile.tmuxConfig?.tmpDir && ` • dir: ${profile.tmuxConfig.tmpDir}`} - </Text> - </View> - <View style={{ flexDirection: 'row', alignItems: 'center' }}> - {selectedProfileId === profile.id && ( - <Ionicons name="checkmark-circle" size={20} color={theme.colors.text} style={{ marginRight: 12 }} /> - )} - <Pressable - hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} - onPress={() => handleEditProfile(profile)} - > - <Ionicons name="create-outline" size={20} color={theme.colors.button.secondary.tint} /> - </Pressable> - <Pressable - hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} - onPress={() => handleDeleteProfile(profile)} - style={{ marginLeft: 16 }} - > - <Ionicons name="trash-outline" size={20} color={theme.colors.deleteAction} /> - </Pressable> - </View> - </Pressable> - ))} - - {/* Add profile button */} - <Pressable - style={{ - backgroundColor: theme.colors.surface, - borderRadius: 12, - padding: 16, - marginBottom: 12, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - }} - onPress={handleAddProfile} - > - <Ionicons name="add-circle-outline" size={20} color={theme.colors.button.secondary.tint} /> - <Text style={{ - fontSize: 16, - fontWeight: '600', - color: theme.colors.button.secondary.tint, - marginLeft: 8, - ...Typography.default('semiBold') - }}> - {t('profiles.addProfile')} - </Text> - </Pressable> - </View> - </ScrollView> + getSecretOverrideReady={(profile) => { + const satisfaction = getSecretSatisfaction({ + profile, + secrets, + defaultBindings: secretBindingsByProfileId[profile.id] ?? null, + // No machine selected on this screen; explicitly treat machine env as unavailable. + machineEnvReadyByName: null, + }); + return satisfaction.isSatisfied && satisfaction.items.some((i) => i.required && i.satisfiedBy !== 'machineEnv'); + }} + // No machine selected on this screen, so machine-env preflight is intentionally omitted. + /> {/* Profile Add/Edit Modal */} {showAddForm && editingProfile && ( - <View style={profileManagerStyles.modalOverlay}> - <View style={profileManagerStyles.modalContent}> + <Pressable + style={profileManagerStyles.modalOverlay} + onPress={requestCloseEditor} + > + <Pressable style={profileManagerStyles.modalContent} onPress={() => { }}> <ProfileEditForm profile={editingProfile} machineId={null} onSave={handleSaveProfile} - onCancel={() => { - setShowAddForm(false); - setEditingProfile(null); - }} + onCancel={requestCloseEditor} + onDirtyChange={setIsEditingDirty} + saveRef={saveRef} /> - </View> - </View> + </Pressable> + </Pressable> )} </View> ); -} +}); -// ProfileEditForm now imported from @/components/ProfileEditForm +// ProfileEditForm now imported from @/components/profiles/edit const profileManagerStyles = StyleSheet.create((theme) => ({ modalOverlay: { @@ -428,9 +405,14 @@ const profileManagerStyles = StyleSheet.create((theme) => ({ }, modalContent: { width: '100%', - maxWidth: Math.min(layout.maxWidth, 600), + maxWidth: 600, maxHeight: '90%', + flex: 1, + minHeight: 0, + borderRadius: 16, + overflow: 'hidden', + backgroundColor: theme.colors.groupped.background, }, })); -export default ProfileManager; \ No newline at end of file +export default ProfileManager; diff --git a/expo-app/sources/app/(app)/settings/secrets.tsx b/expo-app/sources/app/(app)/settings/secrets.tsx new file mode 100644 index 000000000..4cb90b3fc --- /dev/null +++ b/expo-app/sources/app/(app)/settings/secrets.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { Stack } from 'expo-router'; + +import { useSettingMutable } from '@/sync/storage'; +import { t } from '@/text'; +import { SecretsList } from '@/components/secrets/SecretsList'; + +export default React.memo(function SecretsSettingsScreen() { + const [secrets, setSecrets] = useSettingMutable('secrets'); + + const headerTitle = t('settings.secrets'); + const headerBackTitle = t('common.back'); + + const screenOptions = React.useMemo(() => { + return { + headerShown: true, + headerTitle, + headerBackTitle, + } as const; + }, [headerBackTitle, headerTitle]); + + return ( + <> + <Stack.Screen + options={screenOptions} + /> + + <SecretsList + secrets={secrets} + onChangeSecrets={setSecrets} + allowAdd + allowEdit + /> + </> + ); +}); diff --git a/expo-app/sources/app/(app)/settings/session.tsx b/expo-app/sources/app/(app)/settings/session.tsx new file mode 100644 index 000000000..6363279c5 --- /dev/null +++ b/expo-app/sources/app/(app)/settings/session.tsx @@ -0,0 +1,222 @@ +import React from 'react'; +import { Ionicons } from '@expo/vector-icons'; +import { View, TextInput, Platform } from 'react-native'; +import { useUnistyles, StyleSheet } from 'react-native-unistyles'; + +import { Item } from '@/components/ui/lists/Item'; +import { ItemGroup } from '@/components/ui/lists/ItemGroup'; +import { ItemList } from '@/components/ui/lists/ItemList'; +import { Switch } from '@/components/Switch'; +import { DropdownMenu } from '@/components/ui/forms/dropdown/DropdownMenu'; +import { Text } from '@/components/StyledText'; +import { Typography } from '@/constants/Typography'; +import { t } from '@/text'; +import { useSettingMutable } from '@/sync/storage'; +import type { MessageSendMode } from '@/sync/submitMode'; +import { getPermissionModeLabelForAgentType, getPermissionModeOptionsForAgentType } from '@/sync/permissionModeOptions'; +import type { PermissionMode } from '@/sync/permissionTypes'; +import { useEnabledAgentIds } from '@/agents/useEnabledAgentIds'; +import { getAgentCore, type AgentId } from '@/agents/catalog'; + +export default React.memo(function SessionSettingsScreen() { + const { theme } = useUnistyles(); + const popoverBoundaryRef = React.useRef<any>(null); + + const [useTmux, setUseTmux] = useSettingMutable('sessionUseTmux'); + const [tmuxSessionName, setTmuxSessionName] = useSettingMutable('sessionTmuxSessionName'); + const [tmuxIsolated, setTmuxIsolated] = useSettingMutable('sessionTmuxIsolated'); + const [tmuxTmpDir, setTmuxTmpDir] = useSettingMutable('sessionTmuxTmpDir'); + + const [messageSendMode, setMessageSendMode] = useSettingMutable('sessionMessageSendMode'); + + const enabledAgentIds = useEnabledAgentIds(); + + const [defaultPermissionByAgent, setDefaultPermissionByAgent] = useSettingMutable('sessionDefaultPermissionModeByAgent'); + const getDefaultPermission = React.useCallback((agent: AgentId): PermissionMode => { + const raw = (defaultPermissionByAgent as any)?.[agent] as PermissionMode | undefined; + return (raw ?? 'default') as PermissionMode; + }, [defaultPermissionByAgent]); + const setDefaultPermission = React.useCallback((agent: AgentId, mode: PermissionMode) => { + setDefaultPermissionByAgent({ + ...(defaultPermissionByAgent ?? {}), + [agent]: mode, + } as any); + }, [defaultPermissionByAgent, setDefaultPermissionByAgent]); + + const [openProvider, setOpenProvider] = React.useState<null | AgentId>(null); + + const options: Array<{ key: MessageSendMode; title: string; subtitle: string }> = [ + { + key: 'agent_queue', + title: 'Queue in agent (current)', + subtitle: 'Write to transcript immediately; agent processes when ready.', + }, + { + key: 'interrupt', + title: 'Interrupt & send', + subtitle: 'Abort current turn, then send immediately.', + }, + { + key: 'server_pending', + title: 'Pending until ready', + subtitle: 'Keep messages in a pending queue; agent pulls when ready.', + }, + ]; + + return ( + <ItemList ref={popoverBoundaryRef} style={{ paddingTop: 0 }}> + <ItemGroup title="Message sending" footer="Controls what happens when you send a message while the agent is running."> + {options.map((option) => ( + <Item + key={option.key} + title={option.title} + subtitle={option.subtitle} + icon={<Ionicons name="send-outline" size={29} color="#007AFF" />} + rightElement={messageSendMode === option.key ? <Ionicons name="checkmark" size={20} color="#007AFF" /> : null} + onPress={() => setMessageSendMode(option.key)} + showChevron={false} + /> + ))} + </ItemGroup> + + <ItemGroup title="Default permissions" footer="Applies when starting a new session. Profiles can optionally override this."> + {enabledAgentIds.map((agentId, index) => { + const core = getAgentCore(agentId); + const mode = getDefaultPermission(agentId); + const showDivider = index < enabledAgentIds.length - 1; + return ( + <DropdownMenu + key={agentId} + open={openProvider === agentId} + onOpenChange={(next) => setOpenProvider(next ? agentId : null)} + variant="selectable" + search={false} + selectedId={mode as any} + showCategoryTitles={false} + matchTriggerWidth={true} + connectToTrigger={true} + rowKind="item" + popoverBoundaryRef={popoverBoundaryRef} + trigger={({ open, toggle }) => ( + <Item + title={t(core.displayNameKey)} + subtitle={getPermissionModeLabelForAgentType(agentId as any, mode)} + icon={<Ionicons name={core.ui.agentPickerIconName as any} size={29} color={theme.colors.textSecondary} />} + rightElement={<Ionicons name={open ? 'chevron-up' : 'chevron-down'} size={20} color={theme.colors.textSecondary} />} + onPress={toggle} + showChevron={false} + showDivider={showDivider} + selected={false} + /> + )} + items={getPermissionModeOptionsForAgentType(agentId as any).map((opt) => ({ + id: opt.value, + title: opt.label, + subtitle: opt.description, + icon: ( + <View style={{ width: 32, height: 32, alignItems: 'center', justifyContent: 'center' }}> + <Ionicons name={opt.icon as any} size={22} color={theme.colors.textSecondary} /> + </View> + ), + }))} + onSelect={(id) => { + setDefaultPermission(agentId, id as any); + setOpenProvider(null); + }} + /> + ); + })} + </ItemGroup> + + <ItemGroup title={t('profiles.tmux.title')}> + <Item + title={t('profiles.tmux.spawnSessionsTitle')} + subtitle={useTmux ? t('profiles.tmux.spawnSessionsEnabledSubtitle') : t('profiles.tmux.spawnSessionsDisabledSubtitle')} + icon={<Ionicons name="terminal-outline" size={29} color="#5856D6" />} + rightElement={<Switch value={useTmux} onValueChange={setUseTmux} />} + showChevron={false} + onPress={() => setUseTmux(!useTmux)} + /> + + {useTmux && ( + <> + <View style={[styles.inputContainer, { paddingTop: 0 }]}> + <Text style={styles.fieldLabel}> + {t('profiles.tmuxSession')} ({t('common.optional')}) + </Text> + <TextInput + style={styles.textInput} + placeholder={t('profiles.tmux.sessionNamePlaceholder')} + placeholderTextColor={theme.colors.input.placeholder} + value={tmuxSessionName ?? ''} + onChangeText={setTmuxSessionName} + /> + </View> + + <Item + title={t('profiles.tmux.isolatedServerTitle')} + subtitle={tmuxIsolated ? t('profiles.tmux.isolatedServerEnabledSubtitle') : t('profiles.tmux.isolatedServerDisabledSubtitle')} + icon={<Ionicons name="albums-outline" size={29} color="#5856D6" />} + rightElement={<Switch value={tmuxIsolated} onValueChange={setTmuxIsolated} />} + showChevron={false} + onPress={() => setTmuxIsolated(!tmuxIsolated)} + /> + + {tmuxIsolated && ( + <View style={[styles.inputContainer, { paddingTop: 0, paddingBottom: 16 }]}> + <Text style={styles.fieldLabel}> + {t('profiles.tmuxTempDir')} ({t('common.optional')}) + </Text> + <TextInput + style={styles.textInput} + placeholder={t('profiles.tmux.tempDirPlaceholder')} + placeholderTextColor={theme.colors.input.placeholder} + value={tmuxTmpDir ?? ''} + onChangeText={(value) => setTmuxTmpDir(value.trim().length > 0 ? value : null)} + autoCapitalize="none" + autoCorrect={false} + /> + </View> + )} + </> + )} + </ItemGroup> + </ItemList> + ); +}); + +const styles = StyleSheet.create((theme) => ({ + inputContainer: { + paddingHorizontal: 16, + paddingVertical: 12, + }, + fieldLabel: { + ...Typography.default('semiBold'), + fontSize: 13, + color: theme.colors.groupped.sectionTitle, + marginBottom: 4, + }, + textInput: { + ...Typography.default('regular'), + backgroundColor: theme.colors.input.background, + borderRadius: 10, + paddingHorizontal: 12, + paddingVertical: Platform.select({ ios: 10, default: 12 }), + fontSize: Platform.select({ ios: 17, default: 16 }), + lineHeight: Platform.select({ ios: 22, default: 24 }), + letterSpacing: Platform.select({ ios: -0.41, default: 0.15 }), + color: theme.colors.input.text, + ...(Platform.select({ + web: { + outline: 'none', + outlineStyle: 'none', + outlineWidth: 0, + outlineColor: 'transparent', + boxShadow: 'none', + WebkitBoxShadow: 'none', + WebkitAppearance: 'none', + }, + default: {}, + }) as object), + }, +})); diff --git a/expo-app/sources/app/(app)/settings/usage.tsx b/expo-app/sources/app/(app)/settings/usage.tsx index 35eb8f300..c74b4f348 100644 --- a/expo-app/sources/app/(app)/settings/usage.tsx +++ b/expo-app/sources/app/(app)/settings/usage.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { UsagePanel } from '@/components/usage/UsagePanel'; -import { ItemList } from '@/components/ItemList'; +import { ItemList } from '@/components/ui/lists/ItemList'; export default function UsageSettingsScreen() { return ( diff --git a/expo-app/sources/app/(app)/settings/voice.tsx b/expo-app/sources/app/(app)/settings/voice.tsx index 6b34376c0..735936d43 100644 --- a/expo-app/sources/app/(app)/settings/voice.tsx +++ b/expo-app/sources/app/(app)/settings/voice.tsx @@ -1,8 +1,8 @@ import { Ionicons } from '@expo/vector-icons'; import { useRouter } from 'expo-router'; -import { Item } from '@/components/Item'; -import { ItemGroup } from '@/components/ItemGroup'; -import { ItemList } from '@/components/ItemList'; +import { Item } from '@/components/ui/lists/Item'; +import { ItemGroup } from '@/components/ui/lists/ItemGroup'; +import { ItemList } from '@/components/ui/lists/ItemList'; import { useSettingMutable } from '@/sync/storage'; import { useUnistyles } from 'react-native-unistyles'; import { findLanguageByCode, getLanguageDisplayName, LANGUAGES } from '@/constants/Languages'; diff --git a/expo-app/sources/app/(app)/settings/voice/language.tsx b/expo-app/sources/app/(app)/settings/voice/language.tsx index 74799de38..34eb655c7 100644 --- a/expo-app/sources/app/(app)/settings/voice/language.tsx +++ b/expo-app/sources/app/(app)/settings/voice/language.tsx @@ -1,17 +1,16 @@ import React, { useState, useMemo } from 'react'; -import { View, TextInput, FlatList } from 'react-native'; +import { FlatList } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { useRouter } from 'expo-router'; -import { Item } from '@/components/Item'; -import { ItemGroup } from '@/components/ItemGroup'; -import { ItemList } from '@/components/ItemList'; +import { Item } from '@/components/ui/lists/Item'; +import { ItemGroup } from '@/components/ui/lists/ItemGroup'; +import { ItemList } from '@/components/ui/lists/ItemList'; +import { SearchHeader } from '@/components/ui/forms/SearchHeader'; import { useSettingMutable } from '@/sync/storage'; -import { useUnistyles } from 'react-native-unistyles'; import { LANGUAGES, getLanguageDisplayName, type Language } from '@/constants/Languages'; import { t } from '@/text'; -export default function LanguageSelectionScreen() { - const { theme } = useUnistyles(); +export default React.memo(function LanguageSelectionScreen() { const router = useRouter(); const [voiceAssistantLanguage, setVoiceAssistantLanguage] = useSettingMutable('voiceAssistantLanguage'); const [searchQuery, setSearchQuery] = useState(''); @@ -37,52 +36,11 @@ export default function LanguageSelectionScreen() { return ( <ItemList style={{ paddingTop: 0 }}> - {/* Search Header */} - <View style={{ - backgroundColor: theme.colors.surface, - paddingHorizontal: 16, - paddingVertical: 12, - borderBottomWidth: 1, - borderBottomColor: theme.colors.divider - }}> - <View style={{ - flexDirection: 'row', - alignItems: 'center', - backgroundColor: theme.colors.input.background, - borderRadius: 10, - paddingHorizontal: 12, - paddingVertical: 8, - }}> - <Ionicons - name="search-outline" - size={20} - color={theme.colors.textSecondary} - style={{ marginRight: 8 }} - /> - <TextInput - style={{ - flex: 1, - fontSize: 16, - color: theme.colors.input.text, - }} - placeholder={t('settingsVoice.language.searchPlaceholder')} - placeholderTextColor={theme.colors.input.placeholder} - value={searchQuery} - onChangeText={setSearchQuery} - autoCapitalize="none" - autoCorrect={false} - /> - {searchQuery.length > 0 && ( - <Ionicons - name="close-circle" - size={20} - color={theme.colors.textSecondary} - onPress={() => setSearchQuery('')} - style={{ marginLeft: 8 }} - /> - )} - </View> - </View> + <SearchHeader + value={searchQuery} + onChangeText={setSearchQuery} + placeholder={t('settingsVoice.language.searchPlaceholder')} + /> {/* Language List */} <ItemGroup @@ -111,4 +69,4 @@ export default function LanguageSelectionScreen() { </ItemGroup> </ItemList> ); -} +}); diff --git a/expo-app/sources/app/(app)/share/[token].tsx b/expo-app/sources/app/(app)/share/[token].tsx new file mode 100644 index 000000000..2d3dcb2a4 --- /dev/null +++ b/expo-app/sources/app/(app)/share/[token].tsx @@ -0,0 +1,307 @@ +import React, { memo, useCallback, useEffect, useMemo, useState } from 'react'; +import { ActivityIndicator, View } from 'react-native'; +import { useLocalSearchParams, useRouter } from 'expo-router'; +import { Ionicons } from '@expo/vector-icons'; +import { StyleSheet, useUnistyles } from 'react-native-unistyles'; +import { t } from '@/text'; +import { ItemList } from '@/components/ItemList'; +import { ItemGroup } from '@/components/ItemGroup'; +import { Item } from '@/components/Item'; +import { Avatar } from '@/components/Avatar'; +import { getServerUrl } from '@/sync/serverConfig'; +import { decryptDataKeyFromPublicShare } from '@/sync/encryption/publicShareEncryption'; +import { AES256Encryption } from '@/sync/encryption/encryptor'; +import { EncryptionCache } from '@/sync/encryption/encryptionCache'; +import { SessionEncryption } from '@/sync/encryption/sessionEncryption'; +import type { ApiMessage } from '@/sync/apiTypes'; +import { normalizeRawMessage, type NormalizedMessage } from '@/sync/typesRaw'; +import { useAuth } from '@/auth/AuthContext'; + +type ShareOwner = { + id: string; + username: string | null; + firstName: string | null; + lastName: string | null; + avatar: string | null; +}; + +type PublicShareResponse = { + session: { + id: string; + seq: number; + createdAt: number; + updatedAt: number; + active: boolean; + activeAt: number; + metadata: string; + metadataVersion: number; + agentState: string | null; + agentStateVersion: number; + }; + owner: ShareOwner; + accessLevel: 'view'; + encryptedDataKey: string; + isConsentRequired: boolean; +}; + +type PublicShareConsentResponse = { + error: string; + requiresConsent: true; + sessionId: string; + owner: ShareOwner | null; +}; + +type PublicShareMessagesResponse = { + messages: ApiMessage[]; +}; + +function getOwnerDisplayName(owner: ShareOwner | null): string { + if (!owner) return t('status.unknown'); + if (owner.username) return `@${owner.username}`; + const fullName = [owner.firstName, owner.lastName].filter(Boolean).join(' '); + return fullName || t('status.unknown'); +} + +function summarizeMessage(message: NormalizedMessage): string { + if (message.role === 'user') { + return message.content.text; + } + if (message.role === 'agent') { + for (const block of message.content) { + if (block.type === 'text' && block.text) { + return block.text; + } + if (block.type === 'tool-call') { + return block.name; + } + if (block.type === 'tool-result') { + return t('common.details'); + } + } + return t('common.details'); + } + return t('common.details'); +} + +export default memo(function PublicShareViewerScreen() { + const { token } = useLocalSearchParams<{ token: string }>(); + const { credentials } = useAuth(); + const router = useRouter(); + const { theme } = useUnistyles(); + + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState<string | null>(null); + const [consentInfo, setConsentInfo] = useState<PublicShareConsentResponse | null>(null); + const [share, setShare] = useState<PublicShareResponse | null>(null); + const [decryptedMetadata, setDecryptedMetadata] = useState<any | null>(null); + const [messages, setMessages] = useState<NormalizedMessage[]>([]); + + const authHeader = useMemo(() => { + if (!credentials?.token) return null; + return `Bearer ${credentials.token}`; + }, [credentials?.token]); + + const load = useCallback(async (withConsent: boolean) => { + if (!token) { + setError(t('errors.invalidShareLink')); + setIsLoading(false); + return; + } + + setIsLoading(true); + setError(null); + setConsentInfo(null); + setShare(null); + setDecryptedMetadata(null); + setMessages([]); + + try { + const serverUrl = getServerUrl(); + const url = withConsent + ? `${serverUrl}/v1/public-share/${token}?consent=true` + : `${serverUrl}/v1/public-share/${token}`; + + const headers: Record<string, string> = {}; + if (authHeader) { + headers['Authorization'] = authHeader; + } + + const response = await fetch(url, { method: 'GET', headers }); + if (!response.ok) { + if (response.status === 403) { + const data = await response.json(); + if (data?.requiresConsent) { + setConsentInfo(data as PublicShareConsentResponse); + setIsLoading(false); + return; + } + } + setError(t('session.sharing.shareNotFound')); + setIsLoading(false); + return; + } + + const data = (await response.json()) as PublicShareResponse; + const decryptedKey = await decryptDataKeyFromPublicShare(data.encryptedDataKey, token); + if (!decryptedKey) { + setError(t('session.sharing.failedToDecrypt')); + setIsLoading(false); + return; + } + + const sessionEncryptor = new AES256Encryption(decryptedKey); + const cache = new EncryptionCache(); + const sessionEncryption = new SessionEncryption(data.session.id, sessionEncryptor, cache); + + const decryptedMetadata = await sessionEncryption.decryptMetadata( + data.session.metadataVersion, + data.session.metadata + ); + + const messagesUrl = withConsent + ? `${serverUrl}/v1/public-share/${token}/messages?consent=true` + : `${serverUrl}/v1/public-share/${token}/messages`; + const messagesResponse = await fetch(messagesUrl, { method: 'GET', headers }); + if (!messagesResponse.ok) { + setError(t('errors.operationFailed')); + setIsLoading(false); + return; + } + const messagesData = (await messagesResponse.json()) as PublicShareMessagesResponse; + const decryptedMessages = await sessionEncryption.decryptMessages(messagesData.messages ?? []); + const normalized: NormalizedMessage[] = []; + for (const m of decryptedMessages) { + if (!m || !m.content) continue; + const normalizedMessage = normalizeRawMessage(m.id, m.localId, m.createdAt, m.content); + if (normalizedMessage) { + normalized.push(normalizedMessage); + } + } + normalized.sort((a, b) => a.createdAt - b.createdAt); + + setShare(data); + setDecryptedMetadata(decryptedMetadata); + setMessages(normalized.slice(-60)); + setIsLoading(false); + } catch { + setError(t('errors.operationFailed')); + setIsLoading(false); + } + }, [authHeader, token]); + + useEffect(() => { + void load(false); + }, [load]); + + if (isLoading) { + return ( + <View style={[styles.center, { backgroundColor: theme.colors.groupped.background }]}> + <ActivityIndicator size="large" color={theme.colors.textLink} /> + </View> + ); + } + + if (error) { + return ( + <View style={[styles.center, { backgroundColor: theme.colors.groupped.background }]}> + <Ionicons name="alert-circle-outline" size={64} color={theme.colors.textDestructive} /> + <ItemList> + <ItemGroup> + <Item title={t('common.error')} subtitle={error} showChevron={false} /> + </ItemGroup> + </ItemList> + </View> + ); + } + + if (consentInfo?.requiresConsent) { + const ownerName = getOwnerDisplayName(consentInfo.owner); + return ( + <ItemList style={{ paddingTop: 0 }}> + <ItemGroup title={t('session.sharing.consentRequired')}> + <Item + title={t('session.sharing.sharedBy', { name: ownerName })} + icon={<Ionicons name="person-outline" size={29} color="#007AFF" />} + showChevron={false} + /> + <Item + title={t('session.sharing.consentDescription')} + showChevron={false} + /> + </ItemGroup> + <ItemGroup> + <Item + title={t('session.sharing.acceptAndView')} + icon={<Ionicons name="checkmark-circle-outline" size={29} color="#34C759" />} + onPress={() => load(true)} + /> + <Item + title={t('common.cancel')} + icon={<Ionicons name="close-circle-outline" size={29} color="#FF3B30" />} + onPress={() => router.back()} + /> + </ItemGroup> + </ItemList> + ); + } + + if (!share) { + return null; + } + + const ownerName = getOwnerDisplayName(share.owner); + const ownerAvatarUrl = share.owner?.avatar ?? null; + const sessionName = decryptedMetadata?.name || decryptedMetadata?.path || t('session.sharing.session'); + + return ( + <ItemList style={{ paddingTop: 0 }}> + <ItemGroup title={t('session.sharing.publicLink')}> + <Item + title={sessionName} + subtitle={t('session.sharing.viewOnly')} + icon={<Ionicons name="lock-closed-outline" size={29} color="#007AFF" />} + showChevron={false} + /> + <Item + title={ownerName} + icon={ + <Avatar + id={share.owner.id} + size={32} + imageUrl={ownerAvatarUrl} + /> + } + showChevron={false} + /> + </ItemGroup> + + <ItemGroup title={t('common.message')}> + {messages.length > 0 ? ( + messages.map((m) => ( + <Item + key={m.id} + title={t('common.message')} + subtitle={summarizeMessage(m)} + subtitleLines={2} + showChevron={false} + /> + )) + ) : ( + <Item + title={t('session.sharing.noMessages')} + showChevron={false} + /> + )} + </ItemGroup> + </ItemList> + ); +}); + +const styles = StyleSheet.create(() => ({ + center: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: 24, + }, +})); diff --git a/expo-app/sources/app/(app)/terminal/connect.tsx b/expo-app/sources/app/(app)/terminal/connect.tsx index eefd9c877..36568997d 100644 --- a/expo-app/sources/app/(app)/terminal/connect.tsx +++ b/expo-app/sources/app/(app)/terminal/connect.tsx @@ -6,9 +6,9 @@ import { Typography } from '@/constants/Typography'; import { RoundButton } from '@/components/RoundButton'; import { useConnectTerminal } from '@/hooks/useConnectTerminal'; import { Ionicons } from '@expo/vector-icons'; -import { ItemList } from '@/components/ItemList'; -import { ItemGroup } from '@/components/ItemGroup'; -import { Item } from '@/components/Item'; +import { ItemList } from '@/components/ui/lists/ItemList'; +import { ItemGroup } from '@/components/ui/lists/ItemGroup'; +import { Item } from '@/components/ui/lists/Item'; import { t } from '@/text'; export default function TerminalConnectScreen() { diff --git a/expo-app/sources/app/(app)/terminal/index.tsx b/expo-app/sources/app/(app)/terminal/index.tsx index c3c65ade2..d30323387 100644 --- a/expo-app/sources/app/(app)/terminal/index.tsx +++ b/expo-app/sources/app/(app)/terminal/index.tsx @@ -6,9 +6,9 @@ import { Typography } from '@/constants/Typography'; import { RoundButton } from '@/components/RoundButton'; import { useConnectTerminal } from '@/hooks/useConnectTerminal'; import { Ionicons } from '@expo/vector-icons'; -import { ItemList } from '@/components/ItemList'; -import { ItemGroup } from '@/components/ItemGroup'; -import { Item } from '@/components/Item'; +import { ItemList } from '@/components/ui/lists/ItemList'; +import { ItemGroup } from '@/components/ui/lists/ItemGroup'; +import { Item } from '@/components/ui/lists/Item'; import { useUnistyles } from 'react-native-unistyles'; import { t } from '@/text'; diff --git a/expo-app/sources/app/(app)/user/[id].tsx b/expo-app/sources/app/(app)/user/[id].tsx index c0f14a3b7..998898131 100644 --- a/expo-app/sources/app/(app)/user/[id].tsx +++ b/expo-app/sources/app/(app)/user/[id].tsx @@ -6,9 +6,9 @@ import { useAuth } from '@/auth/AuthContext'; import { getUserProfile, sendFriendRequest, removeFriend } from '@/sync/apiFriends'; import { UserProfile, getDisplayName } from '@/sync/friendTypes'; import { Avatar } from '@/components/Avatar'; -import { ItemList } from '@/components/ItemList'; -import { ItemGroup } from '@/components/ItemGroup'; -import { Item } from '@/components/Item'; +import { ItemList } from '@/components/ui/lists/ItemList'; +import { ItemGroup } from '@/components/ui/lists/ItemGroup'; +import { Item } from '@/components/ui/lists/Item'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { layout } from '@/components/layout'; import { useHappyAction } from '@/hooks/useHappyAction'; @@ -16,12 +16,14 @@ import { Modal } from '@/modal'; import { t } from '@/text'; import { trackFriendsConnect } from '@/track'; import { Ionicons } from '@expo/vector-icons'; +import { useAllSessions } from '@/sync/storage'; export default function UserProfileScreen() { const { id } = useLocalSearchParams<{ id: string }>(); const { credentials } = useAuth(); const router = useRouter(); const { theme } = useUnistyles(); + const sessions = useAllSessions(); const [userProfile, setUserProfile] = useState<UserProfile | null>(null); const [isLoading, setIsLoading] = useState(true); @@ -159,6 +161,9 @@ export default function UserProfileScreen() { }; const friendActions = getFriendActions(); + const sharedSessions = userProfile.status === 'friend' + ? sessions.filter(session => session.owner === userProfile.id) + : []; return ( <ItemList style={{ paddingTop: 0 }}> @@ -207,6 +212,29 @@ export default function UserProfileScreen() { ))} </ItemGroup> + {/* Sessions shared by this friend */} + {userProfile.status === 'friend' && ( + <ItemGroup title={t('friends.sharedSessions')}> + {sharedSessions.length > 0 ? ( + sharedSessions.map((session) => ( + <Item + key={session.id} + title={session.metadata?.name || session.metadata?.path || t('sessionHistory.title')} + subtitle={t('session.sharing.viewOnly')} + icon={<Ionicons name="chatbubble-ellipses-outline" size={29} color="#007AFF" />} + onPress={() => router.push(`/session/${session.id}`)} + /> + )) + ) : ( + <Item + title={t('friends.noSharedSessions')} + icon={<Ionicons name="chatbubble-outline" size={29} color={theme.colors.textSecondary} />} + showChevron={false} + /> + )} + </ItemGroup> + )} + {/* GitHub Link */} <ItemGroup> @@ -316,4 +344,4 @@ const styles = StyleSheet.create((theme) => ({ marginLeft: 4, fontWeight: '500', }, -})); \ No newline at end of file +})); diff --git a/expo-app/sources/auth/authChallenge.ts b/expo-app/sources/auth/authChallenge.ts index 432954d3e..90cbaca8e 100644 --- a/expo-app/sources/auth/authChallenge.ts +++ b/expo-app/sources/auth/authChallenge.ts @@ -1,4 +1,4 @@ -import { getRandomBytes } from 'expo-crypto'; +import { getRandomBytes } from '@/platform/cryptoRandom'; import sodium from '@/encryption/libsodium.lib'; export function authChallenge(secret: Uint8Array) { diff --git a/expo-app/sources/auth/authGetToken.ts b/expo-app/sources/auth/authGetToken.ts index b463cffdc..67fb52a0d 100644 --- a/expo-app/sources/auth/authGetToken.ts +++ b/expo-app/sources/auth/authGetToken.ts @@ -2,11 +2,31 @@ import { authChallenge } from "./authChallenge"; import axios from 'axios'; import { encodeBase64 } from "../encryption/base64"; import { getServerUrl } from "@/sync/serverConfig"; +import { Encryption } from "@/sync/encryption/encryption"; +import sodium from '@/encryption/libsodium.lib'; + +const CONTENT_KEY_BINDING_PREFIX = new TextEncoder().encode('Happy content key v1\u0000'); export async function authGetToken(secret: Uint8Array) { const API_ENDPOINT = getServerUrl(); const { challenge, signature, publicKey } = authChallenge(secret); - const response = await axios.post(`${API_ENDPOINT}/v1/auth`, { challenge: encodeBase64(challenge), signature: encodeBase64(signature), publicKey: encodeBase64(publicKey) }); + + const encryption = await Encryption.create(secret); + const contentPublicKey = encryption.contentDataKey; + + const signingKeyPair = sodium.crypto_sign_seed_keypair(secret); + const binding = new Uint8Array(CONTENT_KEY_BINDING_PREFIX.length + contentPublicKey.length); + binding.set(CONTENT_KEY_BINDING_PREFIX, 0); + binding.set(contentPublicKey, CONTENT_KEY_BINDING_PREFIX.length); + const contentPublicKeySig = sodium.crypto_sign_detached(binding, signingKeyPair.privateKey); + + const response = await axios.post(`${API_ENDPOINT}/v1/auth`, { + challenge: encodeBase64(challenge), + signature: encodeBase64(signature), + publicKey: encodeBase64(publicKey), + contentPublicKey: encodeBase64(contentPublicKey), + contentPublicKeySig: encodeBase64(contentPublicKeySig), + }); const data = response.data; return data.token; -} \ No newline at end of file +} diff --git a/expo-app/sources/auth/authQRStart.ts b/expo-app/sources/auth/authQRStart.ts index ab9a7b6e4..d2df5e4fd 100644 --- a/expo-app/sources/auth/authQRStart.ts +++ b/expo-app/sources/auth/authQRStart.ts @@ -1,4 +1,4 @@ -import { getRandomBytes } from 'expo-crypto'; +import { getRandomBytes } from '@/platform/cryptoRandom'; import sodium from '@/encryption/libsodium.lib'; import axios from 'axios'; import { encodeBase64 } from '../encryption/base64'; diff --git a/expo-app/sources/auth/authRouting.test.ts b/expo-app/sources/auth/authRouting.test.ts new file mode 100644 index 000000000..e57381bc0 --- /dev/null +++ b/expo-app/sources/auth/authRouting.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; +import { isPublicRouteForUnauthenticated } from './authRouting'; + +describe('auth routing', () => { + it('allows root index when unauthenticated', () => { + expect(isPublicRouteForUnauthenticated(['(app)'])).toBe(true); + expect(isPublicRouteForUnauthenticated(['(app)', 'index'])).toBe(true); + }); + + it('allows restore routes when unauthenticated', () => { + expect(isPublicRouteForUnauthenticated(['(app)', 'restore'])).toBe(true); + expect(isPublicRouteForUnauthenticated(['(app)', 'restore', 'manual'])).toBe(true); + }); + + it('blocks app routes like new-session when unauthenticated', () => { + expect(isPublicRouteForUnauthenticated(['(app)', 'new'])).toBe(false); + expect(isPublicRouteForUnauthenticated(['(app)', 'session', 'abc'])).toBe(false); + expect(isPublicRouteForUnauthenticated(['(app)', 'settings'])).toBe(false); + }); +}); + diff --git a/expo-app/sources/auth/authRouting.ts b/expo-app/sources/auth/authRouting.ts new file mode 100644 index 000000000..572fd4bfc --- /dev/null +++ b/expo-app/sources/auth/authRouting.ts @@ -0,0 +1,18 @@ +export function isPublicRouteForUnauthenticated(segments: string[]): boolean { + // expo-router includes route groups like "(app)" in segments. + const normalized = segments.filter((s) => !(s.startsWith('(') && s.endsWith(')'))); + + if (normalized.length === 0) return true; + const first = normalized[0]; + + // Home (welcome / login / create account) + if (first === 'index') return true; + + // Restore / link account flows must work unauthenticated. + if (first === 'restore') return true; + + // Public share links must work unauthenticated. + if (first === 'share') return true; + + return false; +} diff --git a/expo-app/sources/auth/tokenStorage.test.ts b/expo-app/sources/auth/tokenStorage.test.ts new file mode 100644 index 000000000..5a2ef25d2 --- /dev/null +++ b/expo-app/sources/auth/tokenStorage.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; + +vi.mock('react-native', () => ({ + Platform: { OS: 'web' }, +})); + +vi.mock('expo-secure-store', () => ({})); + +function installLocalStorage() { + const previousDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'localStorage'); + const store = new Map<string, string>(); + const getItem = vi.fn((key: string) => store.get(key) ?? null); + const setItem = vi.fn((key: string, value: string) => { + store.set(key, value); + }); + const removeItem = vi.fn((key: string) => { + store.delete(key); + }); + + Object.defineProperty(globalThis, 'localStorage', { + value: { getItem, setItem, removeItem }, + configurable: true, + }); + + const restore = () => { + if (previousDescriptor) { + Object.defineProperty(globalThis, 'localStorage', previousDescriptor); + return; + } + // @ts-expect-error localStorage may not exist in this runtime. + delete globalThis.localStorage; + }; + + return { store, getItem, setItem, removeItem, restore }; +} + +describe('TokenStorage (web)', () => { + let restoreLocalStorage: (() => void) | null = null; + + beforeEach(() => { + vi.resetModules(); + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + restoreLocalStorage?.(); + restoreLocalStorage = null; + }); + + it('returns null when localStorage JSON is invalid', async () => { + const { setItem, restore } = installLocalStorage(); + restoreLocalStorage = restore; + setItem('auth_credentials', '{not valid json'); + + const { TokenStorage } = await import('./tokenStorage'); + await expect(TokenStorage.getCredentials()).resolves.toBeNull(); + }); + + it('returns false when localStorage.setItem throws', async () => { + const { restore } = installLocalStorage(); + restoreLocalStorage = restore; + (globalThis.localStorage.setItem as any).mockImplementation(() => { + throw new Error('QuotaExceededError'); + }); + + const { TokenStorage } = await import('./tokenStorage'); + await expect(TokenStorage.setCredentials({ token: 't', secret: 's' })).resolves.toBe(false); + }); + + it('returns false when localStorage.removeItem throws', async () => { + const { restore } = installLocalStorage(); + restoreLocalStorage = restore; + (globalThis.localStorage.removeItem as any).mockImplementation(() => { + throw new Error('SecurityError'); + }); + + const { TokenStorage } = await import('./tokenStorage'); + await expect(TokenStorage.removeCredentials()).resolves.toBe(false); + }); + + it('calls localStorage.getItem at most once per getCredentials call', async () => { + const { getItem, setItem, restore } = installLocalStorage(); + restoreLocalStorage = restore; + setItem('auth_credentials', JSON.stringify({ token: 't', secret: 's' })); + + const { TokenStorage } = await import('./tokenStorage'); + await TokenStorage.getCredentials(); + expect(getItem).toHaveBeenCalledTimes(1); + }); +}); diff --git a/expo-app/sources/auth/tokenStorage.ts b/expo-app/sources/auth/tokenStorage.ts index b69060ef9..a557a43aa 100644 --- a/expo-app/sources/auth/tokenStorage.ts +++ b/expo-app/sources/auth/tokenStorage.ts @@ -1,10 +1,17 @@ import * as SecureStore from 'expo-secure-store'; import { Platform } from 'react-native'; +import { readStorageScopeFromEnv, scopedStorageId } from '@/utils/storageScope'; const AUTH_KEY = 'auth_credentials'; +function getAuthKey(): string { + const scope = Platform.OS === 'web' ? null : readStorageScopeFromEnv(); + return scopedStorageId(AUTH_KEY, scope); +} + // Cache for synchronous access let credentialsCache: string | null = null; +let credentialsCacheKey: string | null = null; export interface AuthCredentials { token: string; @@ -13,13 +20,29 @@ export interface AuthCredentials { export const TokenStorage = { async getCredentials(): Promise<AuthCredentials | null> { + const key = getAuthKey(); if (Platform.OS === 'web') { - return localStorage.getItem(AUTH_KEY) ? JSON.parse(localStorage.getItem(AUTH_KEY)!) as AuthCredentials : null; + try { + const raw = localStorage.getItem(key); + if (!raw) return null; + return JSON.parse(raw) as AuthCredentials; + } catch (error) { + console.error('Error getting credentials:', error); + return null; + } + } + if (credentialsCache && credentialsCacheKey === key) { + try { + return JSON.parse(credentialsCache) as AuthCredentials; + } catch { + // Ignore cache parse errors, fall through to secure store read. + } } try { - const stored = await SecureStore.getItemAsync(AUTH_KEY); + const stored = await SecureStore.getItemAsync(key); if (!stored) return null; credentialsCache = stored; // Update cache + credentialsCacheKey = key; return JSON.parse(stored) as AuthCredentials; } catch (error) { console.error('Error getting credentials:', error); @@ -28,14 +51,21 @@ export const TokenStorage = { }, async setCredentials(credentials: AuthCredentials): Promise<boolean> { + const key = getAuthKey(); if (Platform.OS === 'web') { - localStorage.setItem(AUTH_KEY, JSON.stringify(credentials)); - return true; + try { + localStorage.setItem(key, JSON.stringify(credentials)); + return true; + } catch (error) { + console.error('Error setting credentials:', error); + return false; + } } try { const json = JSON.stringify(credentials); - await SecureStore.setItemAsync(AUTH_KEY, json); + await SecureStore.setItemAsync(key, json); credentialsCache = json; // Update cache + credentialsCacheKey = key; return true; } catch (error) { console.error('Error setting credentials:', error); @@ -44,17 +74,24 @@ export const TokenStorage = { }, async removeCredentials(): Promise<boolean> { + const key = getAuthKey(); if (Platform.OS === 'web') { - localStorage.removeItem(AUTH_KEY); - return true; + try { + localStorage.removeItem(key); + return true; + } catch (error) { + console.error('Error removing credentials:', error); + return false; + } } try { - await SecureStore.deleteItemAsync(AUTH_KEY); + await SecureStore.deleteItemAsync(key); credentialsCache = null; // Clear cache + credentialsCacheKey = null; return true; } catch (error) { console.error('Error removing credentials:', error); return false; } }, -}; \ No newline at end of file +}; diff --git a/expo-app/sources/capabilities/codexAcpDep.test.ts b/expo-app/sources/capabilities/codexAcpDep.test.ts new file mode 100644 index 000000000..6e59a1ec7 --- /dev/null +++ b/expo-app/sources/capabilities/codexAcpDep.test.ts @@ -0,0 +1,193 @@ +import { describe, expect, it } from 'vitest'; + +import type { CapabilityDetectResult, CapabilityId } from '@/sync/capabilitiesProtocol'; +import { + CODEX_ACP_DEP_ID, + getCodexAcpDepData, + getCodexAcpDetectResult, + getCodexAcpLatestVersion, + getCodexAcpRegistryError, + isCodexAcpUpdateAvailable, + shouldPrefetchCodexAcpRegistry, +} from './codexAcpDep'; + +describe('codexAcpDep', () => { + it('extracts detect result and dep data', () => { + const detectResult: CapabilityDetectResult = { + ok: true, + checkedAt: 123, + data: { + installed: true, + installDir: '/tmp', + binPath: '/tmp/bin', + installedVersion: '1.0.0', + distTag: 'latest', + lastInstallLogPath: null, + registry: { ok: true, latestVersion: '1.0.1' }, + }, + }; + + const results: Partial<Record<CapabilityId, CapabilityDetectResult>> = { + [CODEX_ACP_DEP_ID]: detectResult, + }; + + expect(getCodexAcpDetectResult(results)).toEqual(detectResult); + expect(getCodexAcpDepData(results)?.installedVersion).toBe('1.0.0'); + }); + + it('returns null when detect result is missing or not ok', () => { + expect(getCodexAcpDetectResult(undefined)).toBeNull(); + expect(getCodexAcpDepData(undefined)).toBeNull(); + + const results: Partial<Record<CapabilityId, CapabilityDetectResult>> = { + [CODEX_ACP_DEP_ID]: { ok: false, checkedAt: 1, error: { message: 'no' } }, + }; + expect(getCodexAcpDetectResult(results)?.ok).toBe(false); + expect(getCodexAcpDepData(results)).toBeNull(); + }); + + it('computes latest version, update availability, and registry error', () => { + const results: Partial<Record<CapabilityId, CapabilityDetectResult>> = { + [CODEX_ACP_DEP_ID]: { + ok: true, + checkedAt: 123, + data: { + installed: true, + installDir: '/tmp', + binPath: '/tmp/bin', + installedVersion: '1.0.0', + distTag: 'latest', + lastInstallLogPath: null, + registry: { ok: true, latestVersion: '1.0.1' }, + }, + }, + }; + + const data = getCodexAcpDepData(results); + expect(getCodexAcpLatestVersion(data)).toBe('1.0.1'); + expect(isCodexAcpUpdateAvailable(data)).toBe(true); + expect(getCodexAcpRegistryError(data)).toBeNull(); + + const resultsInstalledNewer: Partial<Record<CapabilityId, CapabilityDetectResult>> = { + [CODEX_ACP_DEP_ID]: { + ok: true, + checkedAt: 123, + data: { + installed: true, + installDir: '/tmp', + binPath: '/tmp/bin', + installedVersion: '1.0.2', + distTag: 'latest', + lastInstallLogPath: null, + registry: { ok: true, latestVersion: '1.0.1' }, + }, + }, + }; + const dataInstalledNewer = getCodexAcpDepData(resultsInstalledNewer); + expect(getCodexAcpLatestVersion(dataInstalledNewer)).toBe('1.0.1'); + expect(isCodexAcpUpdateAvailable(dataInstalledNewer)).toBe(false); + + const resultsNonSemver: Partial<Record<CapabilityId, CapabilityDetectResult>> = { + [CODEX_ACP_DEP_ID]: { + ok: true, + checkedAt: 123, + data: { + installed: true, + installDir: '/tmp', + binPath: '/tmp/bin', + installedVersion: 'main', + distTag: 'latest', + lastInstallLogPath: null, + registry: { ok: true, latestVersion: '1.0.1' }, + }, + }, + }; + const dataNonSemver = getCodexAcpDepData(resultsNonSemver); + expect(getCodexAcpLatestVersion(dataNonSemver)).toBe('1.0.1'); + expect(isCodexAcpUpdateAvailable(dataNonSemver)).toBe(false); + + const resultsErr: Partial<Record<CapabilityId, CapabilityDetectResult>> = { + [CODEX_ACP_DEP_ID]: { + ok: true, + checkedAt: 123, + data: { + installed: true, + installDir: '/tmp', + binPath: '/tmp/bin', + installedVersion: '1.0.0', + distTag: 'latest', + lastInstallLogPath: null, + registry: { ok: false, errorMessage: 'boom' }, + }, + }, + }; + const dataErr = getCodexAcpDepData(resultsErr); + expect(getCodexAcpLatestVersion(dataErr)).toBeNull(); + expect(isCodexAcpUpdateAvailable(dataErr)).toBe(false); + expect(getCodexAcpRegistryError(dataErr)).toBe('boom'); + }); + + it('prefetches registry when missing or stale', () => { + expect(shouldPrefetchCodexAcpRegistry({ requireExistingResult: false, result: null, data: null })).toBe(true); + expect(shouldPrefetchCodexAcpRegistry({ requireExistingResult: true, result: null, data: null })).toBe(false); + + // Installed but no registry payload => fetch. + expect(shouldPrefetchCodexAcpRegistry({ + requireExistingResult: true, + result: { ok: true, checkedAt: 123, data: {} }, + data: { + installed: true, + installDir: '/tmp', + binPath: null, + installedVersion: '1.0.0', + distTag: 'latest', + lastInstallLogPath: null, + }, + })).toBe(true); + + // Fresh ok registry should not fetch when timestamp is recent. + expect(shouldPrefetchCodexAcpRegistry({ + requireExistingResult: true, + result: { ok: true, checkedAt: Date.now(), data: {} }, + data: { + installed: true, + installDir: '/tmp', + binPath: null, + installedVersion: '1.0.0', + distTag: 'latest', + lastInstallLogPath: null, + registry: { ok: true, latestVersion: '1.0.1' }, + }, + })).toBe(false); + + // Successful registry checks should re-check after a reasonable time window. + const dayMs = 24 * 60 * 60 * 1000; + const now = Date.now(); + expect(shouldPrefetchCodexAcpRegistry({ + requireExistingResult: true, + result: { ok: true, checkedAt: now - (2 * dayMs), data: {} }, + data: { + installed: true, + installDir: '/tmp', + binPath: null, + installedVersion: '1.0.0', + distTag: 'latest', + lastInstallLogPath: null, + registry: { ok: true, latestVersion: '1.0.1' }, + }, + })).toBe(true); + expect(shouldPrefetchCodexAcpRegistry({ + requireExistingResult: true, + result: { ok: true, checkedAt: now - (1 * 60 * 60 * 1000), data: {} }, + data: { + installed: true, + installDir: '/tmp', + binPath: null, + installedVersion: '1.0.0', + distTag: 'latest', + lastInstallLogPath: null, + registry: { ok: true, latestVersion: '1.0.1' }, + }, + })).toBe(false); + }); +}); diff --git a/expo-app/sources/capabilities/codexAcpDep.ts b/expo-app/sources/capabilities/codexAcpDep.ts new file mode 100644 index 000000000..f6ba84599 --- /dev/null +++ b/expo-app/sources/capabilities/codexAcpDep.ts @@ -0,0 +1,92 @@ +import type { CapabilitiesDetectRequest, CapabilityDetectResult, CapabilityId, CodexAcpDepData } from '@/sync/capabilitiesProtocol'; +import { compareVersions, parseVersion } from '@/utils/versionUtils'; + +export const CODEX_ACP_DEP_ID = 'dep.codex-acp' as const satisfies CapabilityId; +export const CODEX_ACP_DIST_TAG = 'latest' as const; + +export function getCodexAcpDetectResult( + results: Partial<Record<CapabilityId, CapabilityDetectResult>> | null | undefined, +): CapabilityDetectResult | null { + const res = results?.[CODEX_ACP_DEP_ID]; + return res ? res : null; +} + +export function getCodexAcpDepData( + results: Partial<Record<CapabilityId, CapabilityDetectResult>> | null | undefined, +): CodexAcpDepData | null { + const result = getCodexAcpDetectResult(results); + if (!result || result.ok !== true) return null; + const data = result.data as any; + return data && typeof data === 'object' ? (data as CodexAcpDepData) : null; +} + +export function getCodexAcpLatestVersion(data: CodexAcpDepData | null | undefined): string | null { + const registry = data?.registry; + if (!registry || typeof registry !== 'object') return null; + if ((registry as any).ok !== true) return null; + const latest = (registry as any).latestVersion; + return typeof latest === 'string' ? latest : null; +} + +export function getCodexAcpRegistryError(data: CodexAcpDepData | null | undefined): string | null { + const registry = data?.registry; + if (!registry || typeof registry !== 'object') return null; + if ((registry as any).ok !== false) return null; + const msg = (registry as any).errorMessage; + return typeof msg === 'string' ? msg : null; +} + +export function isCodexAcpUpdateAvailable(data: CodexAcpDepData | null | undefined): boolean { + if (data?.installed !== true) return false; + const installed = typeof data.installedVersion === 'string' ? data.installedVersion : null; + const latest = getCodexAcpLatestVersion(data); + if (!installed || !latest) return false; + const installedParsed = parseVersion(installed); + const latestParsed = parseVersion(latest); + if (!installedParsed || !latestParsed) return false; + return compareVersions(installed, latest) < 0; +} + +export function shouldPrefetchCodexAcpRegistry(params: { + result?: CapabilityDetectResult | null; + data?: CodexAcpDepData | null; + requireExistingResult?: boolean; +}): boolean { + const OK_STALE_MS = 24 * 60 * 60 * 1000; // 24 hours + const ERROR_RETRY_MS = 30 * 60 * 1000; // 30 minutes + + const now = Date.now(); + const requireExistingResult = params.requireExistingResult === true; + const result = params.result ?? null; + const data = params.data ?? null; + + if (!result || result.ok !== true) { + return requireExistingResult ? false : true; + } + + if (!data || data.installed !== true) { + return requireExistingResult ? false : true; + } + + const checkedAt = typeof result.checkedAt === 'number' ? result.checkedAt : 0; + const hasRegistry = Boolean((data as any).registry); + + if (!hasRegistry) return true; + if (checkedAt <= 0) return true; + + const ok = (data as any).registry?.ok === true; + const ageMs = now - checkedAt; + const threshold = ok ? OK_STALE_MS : ERROR_RETRY_MS; + return ageMs > threshold; +} + +export function buildCodexAcpRegistryDetectRequest(): CapabilitiesDetectRequest { + return { + requests: [ + { + id: CODEX_ACP_DEP_ID, + params: { includeRegistry: true, onlyIfInstalled: true, distTag: CODEX_ACP_DIST_TAG }, + }, + ], + }; +} diff --git a/expo-app/sources/capabilities/codexMcpResume.test.ts b/expo-app/sources/capabilities/codexMcpResume.test.ts new file mode 100644 index 000000000..a06ce2b30 --- /dev/null +++ b/expo-app/sources/capabilities/codexMcpResume.test.ts @@ -0,0 +1,193 @@ +import { describe, expect, it } from 'vitest'; + +import type { CapabilityDetectResult, CapabilityId } from '@/sync/capabilitiesProtocol'; +import { + CODEX_MCP_RESUME_DEP_ID, + getCodexMcpResumeDepData, + getCodexMcpResumeDetectResult, + getCodexMcpResumeLatestVersion, + getCodexMcpResumeRegistryError, + isCodexMcpResumeUpdateAvailable, + shouldPrefetchCodexMcpResumeRegistry, +} from './codexMcpResume'; + +describe('codexMcpResume', () => { + it('extracts detect result and dep data', () => { + const detectResult: CapabilityDetectResult = { + ok: true, + checkedAt: 123, + data: { + installed: true, + installDir: '/tmp', + binPath: '/tmp/bin', + installedVersion: '1.0.0', + distTag: 'happy-codex-resume', + lastInstallLogPath: null, + registry: { ok: true, latestVersion: '1.0.1' }, + }, + }; + + const results: Partial<Record<CapabilityId, CapabilityDetectResult>> = { + [CODEX_MCP_RESUME_DEP_ID]: detectResult, + }; + + expect(getCodexMcpResumeDetectResult(results)).toEqual(detectResult); + expect(getCodexMcpResumeDepData(results)?.installedVersion).toBe('1.0.0'); + }); + + it('returns null when detect result is missing or not ok', () => { + expect(getCodexMcpResumeDetectResult(undefined)).toBeNull(); + expect(getCodexMcpResumeDepData(undefined)).toBeNull(); + + const results: Partial<Record<CapabilityId, CapabilityDetectResult>> = { + [CODEX_MCP_RESUME_DEP_ID]: { ok: false, checkedAt: 1, error: { message: 'no' } }, + }; + expect(getCodexMcpResumeDetectResult(results)?.ok).toBe(false); + expect(getCodexMcpResumeDepData(results)).toBeNull(); + }); + + it('computes latest version, update availability, and registry error', () => { + const results: Partial<Record<CapabilityId, CapabilityDetectResult>> = { + [CODEX_MCP_RESUME_DEP_ID]: { + ok: true, + checkedAt: 123, + data: { + installed: true, + installDir: '/tmp', + binPath: '/tmp/bin', + installedVersion: '1.0.0', + distTag: 'happy-codex-resume', + lastInstallLogPath: null, + registry: { ok: true, latestVersion: '1.0.1' }, + }, + }, + }; + + const data = getCodexMcpResumeDepData(results); + expect(getCodexMcpResumeLatestVersion(data)).toBe('1.0.1'); + expect(isCodexMcpResumeUpdateAvailable(data)).toBe(true); + expect(getCodexMcpResumeRegistryError(data)).toBeNull(); + + const resultsInstalledNewer: Partial<Record<CapabilityId, CapabilityDetectResult>> = { + [CODEX_MCP_RESUME_DEP_ID]: { + ok: true, + checkedAt: 123, + data: { + installed: true, + installDir: '/tmp', + binPath: '/tmp/bin', + installedVersion: '1.0.2', + distTag: 'happy-codex-resume', + lastInstallLogPath: null, + registry: { ok: true, latestVersion: '1.0.1' }, + }, + }, + }; + const dataInstalledNewer = getCodexMcpResumeDepData(resultsInstalledNewer); + expect(getCodexMcpResumeLatestVersion(dataInstalledNewer)).toBe('1.0.1'); + expect(isCodexMcpResumeUpdateAvailable(dataInstalledNewer)).toBe(false); + + const resultsNonSemver: Partial<Record<CapabilityId, CapabilityDetectResult>> = { + [CODEX_MCP_RESUME_DEP_ID]: { + ok: true, + checkedAt: 123, + data: { + installed: true, + installDir: '/tmp', + binPath: '/tmp/bin', + installedVersion: 'main', + distTag: 'happy-codex-resume', + lastInstallLogPath: null, + registry: { ok: true, latestVersion: '1.0.1' }, + }, + }, + }; + const dataNonSemver = getCodexMcpResumeDepData(resultsNonSemver); + expect(getCodexMcpResumeLatestVersion(dataNonSemver)).toBe('1.0.1'); + expect(isCodexMcpResumeUpdateAvailable(dataNonSemver)).toBe(false); + + const resultsErr: Partial<Record<CapabilityId, CapabilityDetectResult>> = { + [CODEX_MCP_RESUME_DEP_ID]: { + ok: true, + checkedAt: 123, + data: { + installed: true, + installDir: '/tmp', + binPath: '/tmp/bin', + installedVersion: '1.0.0', + distTag: 'happy-codex-resume', + lastInstallLogPath: null, + registry: { ok: false, errorMessage: 'boom' }, + }, + }, + }; + const dataErr = getCodexMcpResumeDepData(resultsErr); + expect(getCodexMcpResumeLatestVersion(dataErr)).toBeNull(); + expect(isCodexMcpResumeUpdateAvailable(dataErr)).toBe(false); + expect(getCodexMcpResumeRegistryError(dataErr)).toBe('boom'); + }); + + it('prefetches registry when missing or stale', () => { + expect(shouldPrefetchCodexMcpResumeRegistry({ requireExistingResult: false, result: null, data: null })).toBe(true); + expect(shouldPrefetchCodexMcpResumeRegistry({ requireExistingResult: true, result: null, data: null })).toBe(false); + + // Installed but no registry payload => fetch. + expect(shouldPrefetchCodexMcpResumeRegistry({ + requireExistingResult: true, + result: { ok: true, checkedAt: 123, data: {} }, + data: { + installed: true, + installDir: '/tmp', + binPath: null, + installedVersion: '1.0.0', + distTag: 'happy-codex-resume', + lastInstallLogPath: null, + }, + })).toBe(true); + + // Fresh ok registry should not fetch when timestamp is recent. + expect(shouldPrefetchCodexMcpResumeRegistry({ + requireExistingResult: true, + result: { ok: true, checkedAt: Date.now(), data: {} }, + data: { + installed: true, + installDir: '/tmp', + binPath: null, + installedVersion: '1.0.0', + distTag: 'happy-codex-resume', + lastInstallLogPath: null, + registry: { ok: true, latestVersion: '1.0.1' }, + }, + })).toBe(false); + + // Successful registry checks should re-check after a reasonable time window. + const dayMs = 24 * 60 * 60 * 1000; + const now = Date.now(); + expect(shouldPrefetchCodexMcpResumeRegistry({ + requireExistingResult: true, + result: { ok: true, checkedAt: now - (2 * dayMs), data: {} }, + data: { + installed: true, + installDir: '/tmp', + binPath: null, + installedVersion: '1.0.0', + distTag: 'happy-codex-resume', + lastInstallLogPath: null, + registry: { ok: true, latestVersion: '1.0.1' }, + }, + })).toBe(true); + expect(shouldPrefetchCodexMcpResumeRegistry({ + requireExistingResult: true, + result: { ok: true, checkedAt: now - (1 * 60 * 60 * 1000), data: {} }, + data: { + installed: true, + installDir: '/tmp', + binPath: null, + installedVersion: '1.0.0', + distTag: 'happy-codex-resume', + lastInstallLogPath: null, + registry: { ok: true, latestVersion: '1.0.1' }, + }, + })).toBe(false); + }); +}); diff --git a/expo-app/sources/capabilities/codexMcpResume.ts b/expo-app/sources/capabilities/codexMcpResume.ts new file mode 100644 index 000000000..5ed384c35 --- /dev/null +++ b/expo-app/sources/capabilities/codexMcpResume.ts @@ -0,0 +1,92 @@ +import type { CapabilitiesDetectRequest, CapabilityDetectResult, CapabilityId, CodexMcpResumeDepData } from '@/sync/capabilitiesProtocol'; +import { compareVersions, parseVersion } from '@/utils/versionUtils'; + +export const CODEX_MCP_RESUME_DEP_ID = 'dep.codex-mcp-resume' as const satisfies CapabilityId; +export const CODEX_MCP_RESUME_DIST_TAG = 'happy-codex-resume' as const; + +export function getCodexMcpResumeDetectResult( + results: Partial<Record<CapabilityId, CapabilityDetectResult>> | null | undefined, +): CapabilityDetectResult | null { + const res = results?.[CODEX_MCP_RESUME_DEP_ID]; + return res ? res : null; +} + +export function getCodexMcpResumeDepData( + results: Partial<Record<CapabilityId, CapabilityDetectResult>> | null | undefined, +): CodexMcpResumeDepData | null { + const result = getCodexMcpResumeDetectResult(results); + if (!result || result.ok !== true) return null; + const data = result.data as any; + return data && typeof data === 'object' ? (data as CodexMcpResumeDepData) : null; +} + +export function getCodexMcpResumeLatestVersion(data: CodexMcpResumeDepData | null | undefined): string | null { + const registry = data?.registry; + if (!registry || typeof registry !== 'object') return null; + if ((registry as any).ok !== true) return null; + const latest = (registry as any).latestVersion; + return typeof latest === 'string' ? latest : null; +} + +export function getCodexMcpResumeRegistryError(data: CodexMcpResumeDepData | null | undefined): string | null { + const registry = data?.registry; + if (!registry || typeof registry !== 'object') return null; + if ((registry as any).ok !== false) return null; + const msg = (registry as any).errorMessage; + return typeof msg === 'string' ? msg : null; +} + +export function isCodexMcpResumeUpdateAvailable(data: CodexMcpResumeDepData | null | undefined): boolean { + if (data?.installed !== true) return false; + const installed = typeof data.installedVersion === 'string' ? data.installedVersion : null; + const latest = getCodexMcpResumeLatestVersion(data); + if (!installed || !latest) return false; + const installedParsed = parseVersion(installed); + const latestParsed = parseVersion(latest); + if (!installedParsed || !latestParsed) return false; + return compareVersions(installed, latest) < 0; +} + +export function shouldPrefetchCodexMcpResumeRegistry(params: { + result?: CapabilityDetectResult | null; + data?: CodexMcpResumeDepData | null; + requireExistingResult?: boolean; +}): boolean { + const OK_STALE_MS = 24 * 60 * 60 * 1000; // 24 hours + const ERROR_RETRY_MS = 30 * 60 * 1000; // 30 minutes + + const now = Date.now(); + const requireExistingResult = params.requireExistingResult === true; + const result = params.result ?? null; + const data = params.data ?? null; + + if (!result || result.ok !== true) { + return requireExistingResult ? false : true; + } + + if (!data || data.installed !== true) { + return requireExistingResult ? false : true; + } + + const checkedAt = typeof result.checkedAt === 'number' ? result.checkedAt : 0; + const hasRegistry = Boolean((data as any).registry); + + if (!hasRegistry) return true; + if (checkedAt <= 0) return true; + + const ok = (data as any).registry?.ok === true; + const ageMs = now - checkedAt; + const threshold = ok ? OK_STALE_MS : ERROR_RETRY_MS; + return ageMs > threshold; +} + +export function buildCodexMcpResumeRegistryDetectRequest(): CapabilitiesDetectRequest { + return { + requests: [ + { + id: CODEX_MCP_RESUME_DEP_ID, + params: { includeRegistry: true, onlyIfInstalled: true, distTag: CODEX_MCP_RESUME_DIST_TAG }, + }, + ], + }; +} diff --git a/expo-app/sources/capabilities/installableDepsRegistry.test.ts b/expo-app/sources/capabilities/installableDepsRegistry.test.ts new file mode 100644 index 000000000..36c3e8271 --- /dev/null +++ b/expo-app/sources/capabilities/installableDepsRegistry.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest'; + +import { getInstallableDepRegistryEntries } from './installableDepsRegistry'; +import { CODEX_MCP_RESUME_DEP_ID } from './codexMcpResume'; +import { CODEX_ACP_DEP_ID } from './codexAcpDep'; + +describe('getInstallableDepRegistryEntries', () => { + it('returns the expected built-in installable deps', () => { + const entries = getInstallableDepRegistryEntries(); + expect(entries.map((e) => e.depId)).toEqual([CODEX_MCP_RESUME_DEP_ID, CODEX_ACP_DEP_ID]); + expect(entries.map((e) => e.installSpecSettingKey)).toEqual(['codexResumeInstallSpec', 'codexAcpInstallSpec']); + }); +}); + diff --git a/expo-app/sources/capabilities/installableDepsRegistry.ts b/expo-app/sources/capabilities/installableDepsRegistry.ts new file mode 100644 index 000000000..89a478be8 --- /dev/null +++ b/expo-app/sources/capabilities/installableDepsRegistry.ts @@ -0,0 +1,131 @@ +import type { CapabilitiesDetectRequest, CapabilityDetectResult, CapabilityId } from '@/sync/capabilitiesProtocol'; +import type { Settings } from '@/sync/settings'; +import type { TranslationKey } from '@/text'; +import type { CodexAcpDepData } from '@/sync/capabilitiesProtocol'; +import type { CodexMcpResumeDepData } from '@/sync/capabilitiesProtocol'; +import { t } from '@/text'; + +import { + buildCodexMcpResumeRegistryDetectRequest, + CODEX_MCP_RESUME_DEP_ID, + getCodexMcpResumeDepData, + getCodexMcpResumeDetectResult, + shouldPrefetchCodexMcpResumeRegistry, +} from './codexMcpResume'; +import { + buildCodexAcpRegistryDetectRequest, + CODEX_ACP_DEP_ID, + getCodexAcpDepData, + getCodexAcpDetectResult, + shouldPrefetchCodexAcpRegistry, +} from './codexAcpDep'; + +export type InstallSpecSettingKey = { + [K in keyof Settings]: Settings[K] extends string | null ? K : never; +}[keyof Settings] & string; + +export type InstallableDepDataLike = { + installed: boolean; + installedVersion: string | null; + distTag: string; + lastInstallLogPath: string | null; + registry?: { ok: true; latestVersion: string | null } | { ok: false; errorMessage: string }; +}; + +export type InstallableDepRegistryEntry = Readonly<{ + key: string; + experimental: boolean; + enabledSettingKey: Extract<keyof Settings, string>; + depId: Extract<CapabilityId, `dep.${string}`>; + depTitle: string; + depIconName: string; + groupTitleKey: TranslationKey; + installSpecSettingKey: InstallSpecSettingKey; + installSpecTitle: string; + installSpecDescription: string; + installLabels: { installKey: TranslationKey; updateKey: TranslationKey; reinstallKey: TranslationKey }; + installModal: { + installTitleKey: TranslationKey; + updateTitleKey: TranslationKey; + reinstallTitleKey: TranslationKey; + descriptionKey: TranslationKey; + }; + getDepStatus: (results: Partial<Record<CapabilityId, CapabilityDetectResult>> | null | undefined) => InstallableDepDataLike | null; + getDetectResult: (results: Partial<Record<CapabilityId, CapabilityDetectResult>> | null | undefined) => CapabilityDetectResult | null; + shouldPrefetchRegistry: (params: { + requireExistingResult?: boolean; + result?: CapabilityDetectResult | null; + data?: InstallableDepDataLike | null; + }) => boolean; + buildRegistryDetectRequest: () => CapabilitiesDetectRequest; +}>; + +export function getInstallableDepRegistryEntries(): readonly InstallableDepRegistryEntry[] { + const codexResume: InstallableDepRegistryEntry = { + key: 'codex-mcp-resume', + experimental: true, + enabledSettingKey: 'expCodexResume', + depId: CODEX_MCP_RESUME_DEP_ID, + depTitle: t('deps.installable.codexResume.title'), + depIconName: 'refresh-circle-outline', + groupTitleKey: 'newSession.codexResumeBanner.title', + installSpecSettingKey: 'codexResumeInstallSpec', + installSpecTitle: t('deps.installable.codexResume.installSpecTitle'), + installSpecDescription: t('deps.installable.installSpecDescription'), + installLabels: { + installKey: 'newSession.codexResumeBanner.install', + updateKey: 'newSession.codexResumeBanner.update', + reinstallKey: 'newSession.codexResumeBanner.reinstall', + }, + installModal: { + installTitleKey: 'newSession.codexResumeInstallModal.installTitle', + updateTitleKey: 'newSession.codexResumeInstallModal.updateTitle', + reinstallTitleKey: 'newSession.codexResumeInstallModal.reinstallTitle', + descriptionKey: 'newSession.codexResumeInstallModal.description', + }, + getDepStatus: (results) => getCodexMcpResumeDepData(results) as unknown as CodexMcpResumeDepData | null, + getDetectResult: (results) => getCodexMcpResumeDetectResult(results), + shouldPrefetchRegistry: ({ requireExistingResult, result, data }) => + shouldPrefetchCodexMcpResumeRegistry({ + requireExistingResult, + result, + data: data as any, + }), + buildRegistryDetectRequest: buildCodexMcpResumeRegistryDetectRequest, + }; + + const codexAcp: InstallableDepRegistryEntry = { + key: 'codex-acp', + experimental: true, + enabledSettingKey: 'expCodexAcp', + depId: CODEX_ACP_DEP_ID, + depTitle: t('deps.installable.codexAcp.title'), + depIconName: 'swap-horizontal-outline', + groupTitleKey: 'newSession.codexAcpBanner.title', + installSpecSettingKey: 'codexAcpInstallSpec', + installSpecTitle: t('deps.installable.codexAcp.installSpecTitle'), + installSpecDescription: t('deps.installable.installSpecDescription'), + installLabels: { + installKey: 'newSession.codexAcpBanner.install', + updateKey: 'newSession.codexAcpBanner.update', + reinstallKey: 'newSession.codexAcpBanner.reinstall', + }, + installModal: { + installTitleKey: 'newSession.codexAcpInstallModal.installTitle', + updateTitleKey: 'newSession.codexAcpInstallModal.updateTitle', + reinstallTitleKey: 'newSession.codexAcpInstallModal.reinstallTitle', + descriptionKey: 'newSession.codexAcpInstallModal.description', + }, + getDepStatus: (results) => getCodexAcpDepData(results) as unknown as CodexAcpDepData | null, + getDetectResult: (results) => getCodexAcpDetectResult(results), + shouldPrefetchRegistry: ({ requireExistingResult, result, data }) => + shouldPrefetchCodexAcpRegistry({ + requireExistingResult, + result, + data: data as any, + }), + buildRegistryDetectRequest: buildCodexAcpRegistryDetectRequest, + }; + + return [codexResume, codexAcp]; +} diff --git a/expo-app/sources/capabilities/requests.ts b/expo-app/sources/capabilities/requests.ts new file mode 100644 index 000000000..7036c4fa8 --- /dev/null +++ b/expo-app/sources/capabilities/requests.ts @@ -0,0 +1,20 @@ +import type { CapabilitiesDetectRequest } from '@/sync/capabilitiesProtocol'; +import { AGENT_IDS, getAgentCore } from '@/agents/catalog'; +import { CHECKLIST_IDS } from '@happy/protocol/checklists'; + +function buildCliLoginStatusOverrides(): Record<string, { params: { includeLoginStatus: true } }> { + const overrides: Record<string, { params: { includeLoginStatus: true } }> = {}; + for (const agentId of AGENT_IDS) { + overrides[`cli.${getAgentCore(agentId).cli.detectKey}`] = { params: { includeLoginStatus: true } }; + } + return overrides; +} + +export const CAPABILITIES_REQUEST_NEW_SESSION: CapabilitiesDetectRequest = { + checklistId: CHECKLIST_IDS.NEW_SESSION, +}; + +export const CAPABILITIES_REQUEST_MACHINE_DETAILS: CapabilitiesDetectRequest = { + checklistId: CHECKLIST_IDS.MACHINE_DETAILS, + overrides: buildCliLoginStatusOverrides() as any, +}; diff --git a/expo-app/sources/components/ActiveSessionsGroup.tsx b/expo-app/sources/components/ActiveSessionsGroup.tsx index d567b9fb9..654792ff0 100644 --- a/expo-app/sources/components/ActiveSessionsGroup.tsx +++ b/expo-app/sources/components/ActiveSessionsGroup.tsx @@ -9,10 +9,10 @@ import { getSessionName, useSessionStatus, getSessionAvatarId, formatPathRelativ import { Avatar } from './Avatar'; import { Typography } from '@/constants/Typography'; import { StatusDot } from './StatusDot'; -import { useAllMachines, useSetting } from '@/sync/storage'; +import { useAllMachines, useHasUnreadMessages, useSetting } from '@/sync/storage'; import { StyleSheet } from 'react-native-unistyles'; import { isMachineOnline } from '@/utils/machineUtils'; -import { machineSpawnNewSession, sessionKill } from '@/sync/ops'; +import { machineSpawnNewSession, sessionArchive } from '@/sync/ops'; import { storage } from '@/sync/storage'; import { Modal } from '@/modal'; import { CompactGitStatus } from './CompactGitStatus'; @@ -40,6 +40,22 @@ const stylesheet = StyleSheet.create((theme, runtime) => ({ shadowRadius: 0, elevation: 1, }, + sharedBadge: { + flexDirection: 'row', + alignItems: 'center', + marginLeft: 6, + paddingHorizontal: 6, + paddingVertical: 2, + borderRadius: 4, + backgroundColor: theme.colors.surfaceHighest, + }, + sharedBadgeText: { + fontSize: 11, + fontWeight: '500', + color: theme.colors.textSecondary, + marginLeft: 4, + ...Typography.default(), + }, sectionHeader: { paddingTop: 12, paddingBottom: Platform.select({ ios: 6, default: 8 }), @@ -95,11 +111,13 @@ const stylesheet = StyleSheet.create((theme, runtime) => ({ flexDirection: 'row', alignItems: 'center', marginBottom: 4, + gap: 4, }, sessionTitle: { fontSize: 15, fontWeight: '500', ...Typography.default('semiBold'), + flexShrink: 1, }, sessionTitleConnected: { color: theme.colors.text, @@ -344,8 +362,14 @@ const CompactSessionRow = React.memo(({ session, selected, showBorder }: { sessi const swipeableRef = React.useRef<Swipeable | null>(null); const swipeEnabled = Platform.OS !== 'web'; + // Check if this is a shared session + const isSharedSession = !!session.owner; + const ownerName = session.ownerProfile + ? (session.ownerProfile.username || session.ownerProfile.firstName) + : null; + const [archivingSession, performArchive] = useHappyAction(async () => { - const result = await sessionKill(session.id); + const result = await sessionArchive(session.id); if (!result.success) { throw new HappyError(result.message || t('sessionInfo.failedToArchiveSession'), false); } @@ -370,6 +394,7 @@ const CompactSessionRow = React.memo(({ session, selected, showBorder }: { sessi const avatarId = React.useMemo(() => { return getSessionAvatarId(session); }, [session]); + const hasUnreadMessages = useHasUnreadMessages(session.id); const itemContent = ( <Pressable @@ -390,7 +415,13 @@ const CompactSessionRow = React.memo(({ session, selected, showBorder }: { sessi }} > <View style={styles.avatarContainer}> - <Avatar id={avatarId} size={48} monochrome={!sessionStatus.isConnected} flavor={session.metadata?.flavor} /> + <Avatar + id={avatarId} + size={48} + monochrome={!sessionStatus.isConnected} + flavor={session.metadata?.flavor} + hasUnreadMessages={hasUnreadMessages} + /> </View> <View style={styles.sessionContent}> {/* Title line */} @@ -404,6 +435,14 @@ const CompactSessionRow = React.memo(({ session, selected, showBorder }: { sessi > {sessionName} </Text> + {isSharedSession && ownerName && ( + <View style={styles.sharedBadge}> + <Ionicons name="people-outline" size={12} color={styles.sharedBadgeText.color} /> + <Text style={styles.sharedBadgeText} numberOfLines={1}> + {ownerName} + </Text> + </View> + )} </View> {/* Status line with dot */} diff --git a/expo-app/sources/components/ActiveSessionsGroupCompact.tsx b/expo-app/sources/components/ActiveSessionsGroupCompact.tsx index 6e606a145..1803e5780 100644 --- a/expo-app/sources/components/ActiveSessionsGroupCompact.tsx +++ b/expo-app/sources/components/ActiveSessionsGroupCompact.tsx @@ -9,10 +9,10 @@ import { getSessionName, useSessionStatus, getSessionAvatarId, formatPathRelativ import { Avatar } from './Avatar'; import { Typography } from '@/constants/Typography'; import { StatusDot } from './StatusDot'; -import { useAllMachines, useSetting } from '@/sync/storage'; +import { useAllMachines, useHasUnreadMessages, useSetting } from '@/sync/storage'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { isMachineOnline } from '@/utils/machineUtils'; -import { machineSpawnNewSession, sessionKill } from '@/sync/ops'; +import { machineSpawnNewSession, sessionArchive } from '@/sync/ops'; import { resolveAbsolutePath } from '@/utils/pathUtils'; import { storage } from '@/sync/storage'; import { Modal } from '@/modal'; @@ -249,7 +249,13 @@ export function ActiveSessionsGroupCompact({ sessions, selectedSessionId }: Acti <View style={styles.sectionHeaderLeft}> {avatarId && ( <View style={styles.sectionHeaderAvatar}> - <Avatar id={avatarId} size={24} flavor={firstSession?.metadata?.flavor} /> + {firstSession && ( + <ProjectHeaderAvatar + avatarId={avatarId} + flavor={firstSession.metadata?.flavor} + sessionId={firstSession.id} + /> + )} </View> )} <Text style={styles.sectionHeaderPath}> @@ -288,6 +294,11 @@ export function ActiveSessionsGroupCompact({ sessions, selectedSessionId }: Acti ); } +const ProjectHeaderAvatar = React.memo(({ avatarId, flavor, sessionId }: { avatarId: string; flavor?: string | null; sessionId: string }) => { + const hasUnreadMessages = useHasUnreadMessages(sessionId); + return <Avatar id={avatarId} size={24} flavor={flavor} hasUnreadMessages={hasUnreadMessages} />; +}); + // Compact session row component with status line const CompactSessionRow = React.memo(({ session, selected, showBorder }: { session: Session; selected?: boolean; showBorder?: boolean }) => { const styles = stylesheet; @@ -300,7 +311,7 @@ const CompactSessionRow = React.memo(({ session, selected, showBorder }: { sessi const swipeEnabled = Platform.OS !== 'web'; const [archivingSession, performArchive] = useHappyAction(async () => { - const result = await sessionKill(session.id); + const result = await sessionArchive(session.id); if (!result.success) { throw new HappyError(result.message || t('sessionInfo.failedToArchiveSession'), false); } diff --git a/expo-app/sources/components/AgentInput.tsx b/expo-app/sources/components/AgentInput.tsx deleted file mode 100644 index a2481e38a..000000000 --- a/expo-app/sources/components/AgentInput.tsx +++ /dev/null @@ -1,1203 +0,0 @@ -import { Ionicons, Octicons } from '@expo/vector-icons'; -import * as React from 'react'; -import { View, Platform, useWindowDimensions, ViewStyle, Text, ActivityIndicator, TouchableWithoutFeedback, Image as RNImage, Pressable } from 'react-native'; -import { Image } from 'expo-image'; -import { layout } from './layout'; -import { MultiTextInput, KeyPressEvent } from './MultiTextInput'; -import { Typography } from '@/constants/Typography'; -import { PermissionMode, ModelMode } from './PermissionModeSelector'; -import { hapticsLight, hapticsError } from './haptics'; -import { Shaker, ShakeInstance } from './Shaker'; -import { StatusDot } from './StatusDot'; -import { useActiveWord } from './autocomplete/useActiveWord'; -import { useActiveSuggestions } from './autocomplete/useActiveSuggestions'; -import { AgentInputAutocomplete } from './AgentInputAutocomplete'; -import { FloatingOverlay } from './FloatingOverlay'; -import { TextInputState, MultiTextInputHandle } from './MultiTextInput'; -import { applySuggestion } from './autocomplete/applySuggestion'; -import { GitStatusBadge, useHasMeaningfulGitStatus } from './GitStatusBadge'; -import { StyleSheet, useUnistyles } from 'react-native-unistyles'; -import { useSetting } from '@/sync/storage'; -import { Theme } from '@/theme'; -import { t } from '@/text'; -import { Metadata } from '@/sync/storageTypes'; -import { AIBackendProfile, getProfileEnvironmentVariables, validateProfileForAgent } from '@/sync/settings'; -import { getBuiltInProfile } from '@/sync/profileUtils'; - -interface AgentInputProps { - value: string; - placeholder: string; - onChangeText: (text: string) => void; - sessionId?: string; - onSend: () => void; - sendIcon?: React.ReactNode; - onMicPress?: () => void; - isMicActive?: boolean; - permissionMode?: PermissionMode; - onPermissionModeChange?: (mode: PermissionMode) => void; - modelMode?: ModelMode; - onModelModeChange?: (mode: ModelMode) => void; - metadata?: Metadata | null; - onAbort?: () => void | Promise<void>; - showAbortButton?: boolean; - connectionStatus?: { - text: string; - color: string; - dotColor: string; - isPulsing?: boolean; - cliStatus?: { - claude: boolean | null; - codex: boolean | null; - gemini?: boolean | null; - }; - }; - autocompletePrefixes: string[]; - autocompleteSuggestions: (query: string) => Promise<{ key: string, text: string, component: React.ElementType }[]>; - usageData?: { - inputTokens: number; - outputTokens: number; - cacheCreation: number; - cacheRead: number; - contextSize: number; - }; - alwaysShowContextSize?: boolean; - onFileViewerPress?: () => void; - agentType?: 'claude' | 'codex' | 'gemini'; - onAgentClick?: () => void; - machineName?: string | null; - onMachineClick?: () => void; - currentPath?: string | null; - onPathClick?: () => void; - isSendDisabled?: boolean; - isSending?: boolean; - minHeight?: number; - profileId?: string | null; - onProfileClick?: () => void; -} - -const MAX_CONTEXT_SIZE = 190000; - -const stylesheet = StyleSheet.create((theme, runtime) => ({ - container: { - alignItems: 'center', - paddingBottom: 8, - paddingTop: 8, - }, - innerContainer: { - width: '100%', - position: 'relative', - }, - unifiedPanel: { - backgroundColor: theme.colors.input.background, - borderRadius: Platform.select({ default: 16, android: 20 }), - overflow: 'hidden', - paddingVertical: 2, - paddingBottom: 8, - paddingHorizontal: 8, - }, - inputContainer: { - flexDirection: 'row', - alignItems: 'center', - borderWidth: 0, - paddingLeft: 8, - paddingRight: 8, - paddingVertical: 4, - minHeight: 40, - }, - - // Overlay styles - autocompleteOverlay: { - position: 'absolute', - bottom: '100%', - left: 0, - right: 0, - marginBottom: 8, - zIndex: 1000, - }, - settingsOverlay: { - position: 'absolute', - bottom: '100%', - left: 0, - right: 0, - marginBottom: 8, - zIndex: 1000, - }, - overlayBackdrop: { - position: 'absolute', - top: -1000, - left: -1000, - right: -1000, - bottom: -1000, - zIndex: 999, - }, - overlaySection: { - paddingVertical: 8, - }, - overlaySectionTitle: { - fontSize: 12, - fontWeight: '600', - color: theme.colors.textSecondary, - paddingHorizontal: 16, - paddingBottom: 4, - ...Typography.default('semiBold'), - }, - overlayDivider: { - height: 1, - backgroundColor: theme.colors.divider, - marginHorizontal: 16, - }, - - // Selection styles - selectionItem: { - flexDirection: 'row', - alignItems: 'center', - paddingHorizontal: 16, - paddingVertical: 8, - backgroundColor: 'transparent', - }, - selectionItemPressed: { - backgroundColor: theme.colors.surfacePressed, - }, - radioButton: { - width: 16, - height: 16, - borderRadius: 8, - borderWidth: 2, - alignItems: 'center', - justifyContent: 'center', - marginRight: 12, - }, - radioButtonActive: { - borderColor: theme.colors.radio.active, - }, - radioButtonInactive: { - borderColor: theme.colors.radio.inactive, - }, - radioButtonDot: { - width: 6, - height: 6, - borderRadius: 3, - backgroundColor: theme.colors.radio.dot, - }, - selectionLabel: { - fontSize: 14, - ...Typography.default(), - }, - selectionLabelActive: { - color: theme.colors.radio.active, - }, - selectionLabelInactive: { - color: theme.colors.text, - }, - - // Status styles - statusContainer: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - paddingHorizontal: 16, - paddingBottom: 4, - }, - statusRow: { - flexDirection: 'row', - alignItems: 'center', - }, - statusText: { - fontSize: 11, - ...Typography.default(), - }, - permissionModeContainer: { - flexDirection: 'column', - alignItems: 'flex-end', - }, - permissionModeText: { - fontSize: 11, - ...Typography.default(), - }, - contextWarningText: { - fontSize: 11, - marginLeft: 8, - ...Typography.default(), - }, - - // Button styles - actionButtonsContainer: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - paddingHorizontal: 0, - }, - actionButtonsLeft: { - flexDirection: 'row', - gap: 8, - flex: 1, - overflow: 'hidden', - }, - actionButton: { - flexDirection: 'row', - alignItems: 'center', - borderRadius: Platform.select({ default: 16, android: 20 }), - paddingHorizontal: 8, - paddingVertical: 6, - justifyContent: 'center', - height: 32, - }, - actionButtonPressed: { - opacity: 0.7, - }, - actionButtonIcon: { - color: theme.colors.button.secondary.tint, - }, - sendButton: { - width: 32, - height: 32, - borderRadius: 16, - justifyContent: 'center', - alignItems: 'center', - flexShrink: 0, - marginLeft: 8, - }, - sendButtonActive: { - backgroundColor: theme.colors.button.primary.background, - }, - sendButtonInactive: { - backgroundColor: theme.colors.button.primary.disabled, - }, - sendButtonInner: { - width: '100%', - height: '100%', - alignItems: 'center', - justifyContent: 'center', - }, - sendButtonInnerPressed: { - opacity: 0.7, - }, - sendButtonIcon: { - color: theme.colors.button.primary.tint, - }, -})); - -const getContextWarning = (contextSize: number, alwaysShow: boolean = false, theme: Theme) => { - const percentageUsed = (contextSize / MAX_CONTEXT_SIZE) * 100; - const percentageRemaining = Math.max(0, Math.min(100, 100 - percentageUsed)); - - if (percentageRemaining <= 5) { - return { text: t('agentInput.context.remaining', { percent: Math.round(percentageRemaining) }), color: theme.colors.warningCritical }; - } else if (percentageRemaining <= 10) { - return { text: t('agentInput.context.remaining', { percent: Math.round(percentageRemaining) }), color: theme.colors.warning }; - } else if (alwaysShow) { - // Show context remaining in neutral color when not near limit - return { text: t('agentInput.context.remaining', { percent: Math.round(percentageRemaining) }), color: theme.colors.warning }; - } - return null; // No display needed -}; - -export const AgentInput = React.memo(React.forwardRef<MultiTextInputHandle, AgentInputProps>((props, ref) => { - const styles = stylesheet; - const { theme } = useUnistyles(); - const screenWidth = useWindowDimensions().width; - - const hasText = props.value.trim().length > 0; - - // Check if this is a Codex or Gemini session - // Use metadata.flavor for existing sessions, agentType prop for new sessions - const isCodex = props.metadata?.flavor === 'codex' || props.agentType === 'codex'; - const isGemini = props.metadata?.flavor === 'gemini' || props.agentType === 'gemini'; - - // Profile data - const profiles = useSetting('profiles'); - const currentProfile = React.useMemo(() => { - if (!props.profileId) return null; - // Check custom profiles first - const customProfile = profiles.find(p => p.id === props.profileId); - if (customProfile) return customProfile; - // Check built-in profiles - return getBuiltInProfile(props.profileId); - }, [profiles, props.profileId]); - - // Calculate context warning - const contextWarning = props.usageData?.contextSize - ? getContextWarning(props.usageData.contextSize, props.alwaysShowContextSize ?? false, theme) - : null; - - const agentInputEnterToSend = useSetting('agentInputEnterToSend'); - - - // Abort button state - const [isAborting, setIsAborting] = React.useState(false); - const shakerRef = React.useRef<ShakeInstance>(null); - const inputRef = React.useRef<MultiTextInputHandle>(null); - - // Forward ref to the MultiTextInput - React.useImperativeHandle(ref, () => inputRef.current!, []); - - // Autocomplete state - track text and selection together - const [inputState, setInputState] = React.useState<TextInputState>({ - text: props.value, - selection: { start: 0, end: 0 } - }); - - // Handle combined text and selection state changes - const handleInputStateChange = React.useCallback((newState: TextInputState) => { - // console.log('📝 Input state changed:', JSON.stringify(newState)); - setInputState(newState); - }, []); - - // Use the tracked selection from inputState - const activeWord = useActiveWord(inputState.text, inputState.selection, props.autocompletePrefixes); - // Using default options: clampSelection=true, autoSelectFirst=true, wrapAround=true - // To customize: useActiveSuggestions(activeWord, props.autocompleteSuggestions, { clampSelection: false, wrapAround: false }) - const [suggestions, selected, moveUp, moveDown] = useActiveSuggestions(activeWord, props.autocompleteSuggestions, { clampSelection: true, wrapAround: true }); - - // Debug logging - // React.useEffect(() => { - // console.log('🔍 Autocomplete Debug:', JSON.stringify({ - // value: props.value, - // inputState, - // activeWord, - // suggestionsCount: suggestions.length, - // selected, - // prefixes: props.autocompletePrefixes - // }, null, 2)); - // }, [props.value, inputState, activeWord, suggestions.length, selected]); - - // Handle suggestion selection - const handleSuggestionSelect = React.useCallback((index: number) => { - if (!suggestions[index] || !inputRef.current) return; - - const suggestion = suggestions[index]; - - // Apply the suggestion - const result = applySuggestion( - inputState.text, - inputState.selection, - suggestion.text, - props.autocompletePrefixes, - true // add space after - ); - - // Use imperative API to set text and selection - inputRef.current.setTextAndSelection(result.text, { - start: result.cursorPosition, - end: result.cursorPosition - }); - - // console.log('Selected suggestion:', suggestion.text); - - // Small haptic feedback - hapticsLight(); - }, [suggestions, inputState, props.autocompletePrefixes]); - - // Settings modal state - const [showSettings, setShowSettings] = React.useState(false); - - // Handle settings button press - const handleSettingsPress = React.useCallback(() => { - hapticsLight(); - setShowSettings(prev => !prev); - }, []); - - // Handle settings selection - const handleSettingsSelect = React.useCallback((mode: PermissionMode) => { - hapticsLight(); - props.onPermissionModeChange?.(mode); - // Don't close the settings overlay - let users see the change and potentially switch again - }, [props.onPermissionModeChange]); - - // Handle abort button press - const handleAbortPress = React.useCallback(async () => { - if (!props.onAbort) return; - - hapticsError(); - setIsAborting(true); - const startTime = Date.now(); - - try { - await props.onAbort?.(); - - // Ensure minimum 300ms loading time - const elapsed = Date.now() - startTime; - if (elapsed < 300) { - await new Promise(resolve => setTimeout(resolve, 300 - elapsed)); - } - } catch (error) { - // Shake on error - shakerRef.current?.shake(); - console.error('Abort RPC call failed:', error); - } finally { - setIsAborting(false); - } - }, [props.onAbort]); - - // Handle keyboard navigation - const handleKeyPress = React.useCallback((event: KeyPressEvent): boolean => { - // Handle autocomplete navigation first - if (suggestions.length > 0) { - if (event.key === 'ArrowUp') { - moveUp(); - return true; - } else if (event.key === 'ArrowDown') { - moveDown(); - return true; - } else if ((event.key === 'Enter' || (event.key === 'Tab' && !event.shiftKey))) { - // Both Enter and Tab select the current suggestion - // If none selected (selected === -1), select the first one - const indexToSelect = selected >= 0 ? selected : 0; - handleSuggestionSelect(indexToSelect); - return true; - } else if (event.key === 'Escape') { - // Clear suggestions by collapsing selection (triggers activeWord to clear) - if (inputRef.current) { - const cursorPos = inputState.selection.start; - inputRef.current.setTextAndSelection(inputState.text, { - start: cursorPos, - end: cursorPos - }); - } - return true; - } - } - - // Handle Escape for abort when no suggestions are visible - if (event.key === 'Escape' && props.showAbortButton && props.onAbort && !isAborting) { - handleAbortPress(); - return true; - } - - // Original key handling - if (Platform.OS === 'web') { - if (agentInputEnterToSend && event.key === 'Enter' && !event.shiftKey) { - if (props.value.trim()) { - props.onSend(); - return true; // Key was handled - } - } - // Handle Shift+Tab for permission mode switching - if (event.key === 'Tab' && event.shiftKey && props.onPermissionModeChange) { - const modeOrder: PermissionMode[] = isCodex - ? ['default', 'read-only', 'safe-yolo', 'yolo'] - : ['default', 'acceptEdits', 'plan', 'bypassPermissions']; // Claude and Gemini share same modes - const currentIndex = modeOrder.indexOf(props.permissionMode || 'default'); - const nextIndex = (currentIndex + 1) % modeOrder.length; - props.onPermissionModeChange(modeOrder[nextIndex]); - hapticsLight(); - return true; // Key was handled, prevent default tab behavior - } - - } - return false; // Key was not handled - }, [suggestions, moveUp, moveDown, selected, handleSuggestionSelect, props.showAbortButton, props.onAbort, isAborting, handleAbortPress, agentInputEnterToSend, props.value, props.onSend, props.permissionMode, props.onPermissionModeChange]); - - - - - return ( - <View style={[ - styles.container, - { paddingHorizontal: screenWidth > 700 ? 16 : 8 } - ]}> - <View style={[ - styles.innerContainer, - { maxWidth: layout.maxWidth } - ]}> - {/* Autocomplete suggestions overlay */} - {suggestions.length > 0 && ( - <View style={[ - styles.autocompleteOverlay, - { paddingHorizontal: screenWidth > 700 ? 0 : 8 } - ]}> - <AgentInputAutocomplete - suggestions={suggestions.map(s => { - const Component = s.component; - return <Component key={s.key} />; - })} - selectedIndex={selected} - onSelect={handleSuggestionSelect} - itemHeight={48} - /> - </View> - )} - - {/* Settings overlay */} - {showSettings && ( - <> - <TouchableWithoutFeedback onPress={() => setShowSettings(false)}> - <View style={styles.overlayBackdrop} /> - </TouchableWithoutFeedback> - <View style={[ - styles.settingsOverlay, - { paddingHorizontal: screenWidth > 700 ? 0 : 8 } - ]}> - <FloatingOverlay maxHeight={400} keyboardShouldPersistTaps="always"> - {/* Permission Mode Section */} - <View style={styles.overlaySection}> - <Text style={styles.overlaySectionTitle}> - {isCodex ? t('agentInput.codexPermissionMode.title') : isGemini ? t('agentInput.geminiPermissionMode.title') : t('agentInput.permissionMode.title')} - </Text> - {((isCodex || isGemini) - ? (['default', 'read-only', 'safe-yolo', 'yolo'] as const) - : (['default', 'acceptEdits', 'plan', 'bypassPermissions'] as const) - ).map((mode) => { - const modeConfig = isCodex ? { - 'default': { label: t('agentInput.codexPermissionMode.default') }, - 'read-only': { label: t('agentInput.codexPermissionMode.readOnly') }, - 'safe-yolo': { label: t('agentInput.codexPermissionMode.safeYolo') }, - 'yolo': { label: t('agentInput.codexPermissionMode.yolo') }, - } : isGemini ? { - 'default': { label: t('agentInput.geminiPermissionMode.default') }, - 'read-only': { label: t('agentInput.geminiPermissionMode.readOnly') }, - 'safe-yolo': { label: t('agentInput.geminiPermissionMode.safeYolo') }, - 'yolo': { label: t('agentInput.geminiPermissionMode.yolo') }, - } : { - default: { label: t('agentInput.permissionMode.default') }, - acceptEdits: { label: t('agentInput.permissionMode.acceptEdits') }, - plan: { label: t('agentInput.permissionMode.plan') }, - bypassPermissions: { label: t('agentInput.permissionMode.bypassPermissions') }, - }; - const config = modeConfig[mode as keyof typeof modeConfig]; - if (!config) return null; - const isSelected = props.permissionMode === mode; - - return ( - <Pressable - key={mode} - onPress={() => handleSettingsSelect(mode)} - style={({ pressed }) => ({ - flexDirection: 'row', - alignItems: 'center', - paddingHorizontal: 16, - paddingVertical: 8, - backgroundColor: pressed ? theme.colors.surfacePressed : 'transparent' - })} - > - <View style={{ - width: 16, - height: 16, - borderRadius: 8, - borderWidth: 2, - borderColor: isSelected ? theme.colors.radio.active : theme.colors.radio.inactive, - alignItems: 'center', - justifyContent: 'center', - marginRight: 12 - }}> - {isSelected && ( - <View style={{ - width: 6, - height: 6, - borderRadius: 3, - backgroundColor: theme.colors.radio.dot - }} /> - )} - </View> - <Text style={{ - fontSize: 14, - color: isSelected ? theme.colors.radio.active : theme.colors.text, - ...Typography.default() - }}> - {config.label} - </Text> - </Pressable> - ); - })} - </View> - - {/* Divider */} - <View style={{ - height: 1, - backgroundColor: theme.colors.divider, - marginHorizontal: 16 - }} /> - - {/* Model Section */} - <View style={{ paddingVertical: 8 }}> - <Text style={{ - fontSize: 12, - fontWeight: '600', - color: theme.colors.textSecondary, - paddingHorizontal: 16, - paddingBottom: 4, - ...Typography.default('semiBold') - }}> - {t('agentInput.model.title')} - </Text> - {isGemini ? ( - // Gemini model selector - (['gemini-2.5-pro', 'gemini-2.5-flash', 'gemini-2.5-flash-lite'] as const).map((model) => { - const modelConfig = { - 'gemini-2.5-pro': { label: 'Gemini 2.5 Pro', description: 'Most capable' }, - 'gemini-2.5-flash': { label: 'Gemini 2.5 Flash', description: 'Fast & efficient' }, - 'gemini-2.5-flash-lite': { label: 'Gemini 2.5 Flash Lite', description: 'Fastest' }, - }; - const config = modelConfig[model]; - const isSelected = props.modelMode === model; - - return ( - <Pressable - key={model} - onPress={() => { - hapticsLight(); - props.onModelModeChange?.(model); - }} - style={({ pressed }) => ({ - flexDirection: 'row', - alignItems: 'center', - paddingHorizontal: 16, - paddingVertical: 8, - backgroundColor: pressed ? theme.colors.surfacePressed : 'transparent' - })} - > - <View style={{ - width: 16, - height: 16, - borderRadius: 8, - borderWidth: 2, - borderColor: isSelected ? theme.colors.radio.active : theme.colors.radio.inactive, - alignItems: 'center', - justifyContent: 'center', - marginRight: 12 - }}> - {isSelected && ( - <View style={{ - width: 6, - height: 6, - borderRadius: 3, - backgroundColor: theme.colors.radio.dot - }} /> - )} - </View> - <View> - <Text style={{ - fontSize: 14, - color: isSelected ? theme.colors.radio.active : theme.colors.text, - ...Typography.default() - }}> - {config.label} - </Text> - <Text style={{ - fontSize: 11, - color: theme.colors.textSecondary, - ...Typography.default() - }}> - {config.description} - </Text> - </View> - </Pressable> - ); - }) - ) : ( - <Text style={{ - fontSize: 13, - color: theme.colors.textSecondary, - paddingHorizontal: 16, - paddingVertical: 8, - ...Typography.default() - }}> - {t('agentInput.model.configureInCli')} - </Text> - )} - </View> - </FloatingOverlay> - </View> - </> - )} - - {/* Connection status, context warning, and permission mode */} - {(props.connectionStatus || contextWarning || props.permissionMode) && ( - <View style={{ - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - paddingHorizontal: 16, - paddingBottom: 4, - minHeight: 20, // Fixed minimum height to prevent jumping - }}> - <View style={{ flexDirection: 'row', alignItems: 'center', flex: 1, gap: 11 }}> - {props.connectionStatus && ( - <> - <View style={{ flexDirection: 'row', alignItems: 'center', gap: 4 }}> - <StatusDot - color={props.connectionStatus.dotColor} - isPulsing={props.connectionStatus.isPulsing} - size={6} - /> - <Text style={{ - fontSize: 11, - color: props.connectionStatus.color, - ...Typography.default() - }}> - {props.connectionStatus.text} - </Text> - </View> - {/* CLI Status - only shown when provided (wizard only) */} - {props.connectionStatus.cliStatus && ( - <> - <View style={{ flexDirection: 'row', alignItems: 'center', gap: 4 }}> - <Text style={{ - fontSize: 11, - color: props.connectionStatus.cliStatus.claude - ? theme.colors.success - : theme.colors.textDestructive, - ...Typography.default() - }}> - {props.connectionStatus.cliStatus.claude ? '✓' : '✗'} - </Text> - <Text style={{ - fontSize: 11, - color: props.connectionStatus.cliStatus.claude - ? theme.colors.success - : theme.colors.textDestructive, - ...Typography.default() - }}> - claude - </Text> - </View> - <View style={{ flexDirection: 'row', alignItems: 'center', gap: 4 }}> - <Text style={{ - fontSize: 11, - color: props.connectionStatus.cliStatus.codex - ? theme.colors.success - : theme.colors.textDestructive, - ...Typography.default() - }}> - {props.connectionStatus.cliStatus.codex ? '✓' : '✗'} - </Text> - <Text style={{ - fontSize: 11, - color: props.connectionStatus.cliStatus.codex - ? theme.colors.success - : theme.colors.textDestructive, - ...Typography.default() - }}> - codex - </Text> - </View> - {props.connectionStatus.cliStatus.gemini !== undefined && ( - <View style={{ flexDirection: 'row', alignItems: 'center', gap: 4 }}> - <Text style={{ - fontSize: 11, - color: props.connectionStatus.cliStatus.gemini - ? theme.colors.success - : theme.colors.textDestructive, - ...Typography.default() - }}> - {props.connectionStatus.cliStatus.gemini ? '✓' : '✗'} - </Text> - <Text style={{ - fontSize: 11, - color: props.connectionStatus.cliStatus.gemini - ? theme.colors.success - : theme.colors.textDestructive, - ...Typography.default() - }}> - gemini - </Text> - </View> - )} - </> - )} - </> - )} - {contextWarning && ( - <Text style={{ - fontSize: 11, - color: contextWarning.color, - marginLeft: props.connectionStatus ? 8 : 0, - ...Typography.default() - }}> - {props.connectionStatus ? '• ' : ''}{contextWarning.text} - </Text> - )} - </View> - <View style={{ - flexDirection: 'column', - alignItems: 'flex-end', - minWidth: 150, // Fixed minimum width to prevent layout shift - }}> - {props.permissionMode && ( - <Text style={{ - fontSize: 11, - color: props.permissionMode === 'acceptEdits' ? theme.colors.permission.acceptEdits : - props.permissionMode === 'bypassPermissions' ? theme.colors.permission.bypass : - props.permissionMode === 'plan' ? theme.colors.permission.plan : - props.permissionMode === 'read-only' ? theme.colors.permission.readOnly : - props.permissionMode === 'safe-yolo' ? theme.colors.permission.safeYolo : - props.permissionMode === 'yolo' ? theme.colors.permission.yolo : - theme.colors.textSecondary, // Use secondary text color for default - ...Typography.default() - }}> - {isCodex ? ( - props.permissionMode === 'default' ? t('agentInput.codexPermissionMode.default') : - props.permissionMode === 'read-only' ? t('agentInput.codexPermissionMode.badgeReadOnly') : - props.permissionMode === 'safe-yolo' ? t('agentInput.codexPermissionMode.badgeSafeYolo') : - props.permissionMode === 'yolo' ? t('agentInput.codexPermissionMode.badgeYolo') : '' - ) : isGemini ? ( - props.permissionMode === 'default' ? t('agentInput.geminiPermissionMode.default') : - props.permissionMode === 'read-only' ? t('agentInput.geminiPermissionMode.badgeReadOnly') : - props.permissionMode === 'safe-yolo' ? t('agentInput.geminiPermissionMode.badgeSafeYolo') : - props.permissionMode === 'yolo' ? t('agentInput.geminiPermissionMode.badgeYolo') : '' - ) : ( - props.permissionMode === 'default' ? t('agentInput.permissionMode.default') : - props.permissionMode === 'acceptEdits' ? t('agentInput.permissionMode.badgeAcceptAllEdits') : - props.permissionMode === 'bypassPermissions' ? t('agentInput.permissionMode.badgeBypassAllPermissions') : - props.permissionMode === 'plan' ? t('agentInput.permissionMode.badgePlanMode') : '' - )} - </Text> - )} - </View> - </View> - )} - - {/* Box 1: Context Information (Machine + Path) - Only show if either exists */} - {(props.machineName !== undefined || props.currentPath) && ( - <View style={{ - backgroundColor: theme.colors.surfacePressed, - borderRadius: 12, - padding: 8, - marginBottom: 8, - gap: 4, - }}> - {/* Machine chip */} - {props.machineName !== undefined && props.onMachineClick && ( - <Pressable - onPress={() => { - hapticsLight(); - props.onMachineClick?.(); - }} - hitSlop={{ top: 5, bottom: 10, left: 0, right: 0 }} - style={(p) => ({ - flexDirection: 'row', - alignItems: 'center', - borderRadius: Platform.select({ default: 16, android: 20 }), - paddingHorizontal: 10, - paddingVertical: 6, - height: 32, - opacity: p.pressed ? 0.7 : 1, - gap: 6, - })} - > - <Ionicons - name="desktop-outline" - size={14} - color={theme.colors.textSecondary} - /> - <Text style={{ - fontSize: 13, - color: theme.colors.text, - fontWeight: '600', - ...Typography.default('semiBold'), - }}> - {props.machineName === null ? t('agentInput.noMachinesAvailable') : props.machineName} - </Text> - </Pressable> - )} - - {/* Path chip */} - {props.currentPath && props.onPathClick && ( - <Pressable - onPress={() => { - hapticsLight(); - props.onPathClick?.(); - }} - hitSlop={{ top: 5, bottom: 10, left: 0, right: 0 }} - style={(p) => ({ - flexDirection: 'row', - alignItems: 'center', - borderRadius: Platform.select({ default: 16, android: 20 }), - paddingHorizontal: 10, - paddingVertical: 6, - height: 32, - opacity: p.pressed ? 0.7 : 1, - gap: 6, - })} - > - <Ionicons - name="folder-outline" - size={14} - color={theme.colors.textSecondary} - /> - <Text style={{ - fontSize: 13, - color: theme.colors.text, - fontWeight: '600', - ...Typography.default('semiBold'), - }}> - {props.currentPath} - </Text> - </Pressable> - )} - </View> - )} - - {/* Box 2: Action Area (Input + Send) */} - <View style={styles.unifiedPanel}> - {/* Input field */} - <View style={[styles.inputContainer, props.minHeight ? { minHeight: props.minHeight } : undefined]}> - <MultiTextInput - ref={inputRef} - value={props.value} - paddingTop={Platform.OS === 'web' ? 10 : 8} - paddingBottom={Platform.OS === 'web' ? 10 : 8} - onChangeText={props.onChangeText} - placeholder={props.placeholder} - onKeyPress={handleKeyPress} - onStateChange={handleInputStateChange} - maxHeight={120} - /> - </View> - - {/* Action buttons below input */} - <View style={styles.actionButtonsContainer}> - <View style={{ flexDirection: 'column', flex: 1, gap: 2 }}> - {/* Row 1: Settings, Profile (FIRST), Agent, Abort, Git Status */} - <View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}> - <View style={styles.actionButtonsLeft}> - - {/* Settings button */} - {props.onPermissionModeChange && ( - <Pressable - onPress={handleSettingsPress} - hitSlop={{ top: 5, bottom: 10, left: 0, right: 0 }} - style={(p) => ({ - flexDirection: 'row', - alignItems: 'center', - borderRadius: Platform.select({ default: 16, android: 20 }), - paddingHorizontal: 8, - paddingVertical: 6, - justifyContent: 'center', - height: 32, - opacity: p.pressed ? 0.7 : 1, - })} - > - <Octicons - name={'gear'} - size={16} - color={theme.colors.button.secondary.tint} - /> - </Pressable> - )} - - {/* Profile selector button - FIRST */} - {props.profileId && props.onProfileClick && ( - <Pressable - onPress={() => { - hapticsLight(); - props.onProfileClick?.(); - }} - hitSlop={{ top: 5, bottom: 10, left: 0, right: 0 }} - style={(p) => ({ - flexDirection: 'row', - alignItems: 'center', - borderRadius: Platform.select({ default: 16, android: 20 }), - paddingHorizontal: 10, - paddingVertical: 6, - justifyContent: 'center', - height: 32, - opacity: p.pressed ? 0.7 : 1, - gap: 6, - })} - > - <Ionicons - name="person-outline" - size={14} - color={theme.colors.button.secondary.tint} - /> - <Text style={{ - fontSize: 13, - color: theme.colors.button.secondary.tint, - fontWeight: '600', - ...Typography.default('semiBold'), - }}> - {currentProfile?.name || 'Select Profile'} - </Text> - </Pressable> - )} - - {/* Agent selector button */} - {props.agentType && props.onAgentClick && ( - <Pressable - onPress={() => { - hapticsLight(); - props.onAgentClick?.(); - }} - hitSlop={{ top: 5, bottom: 10, left: 0, right: 0 }} - style={(p) => ({ - flexDirection: 'row', - alignItems: 'center', - borderRadius: Platform.select({ default: 16, android: 20 }), - paddingHorizontal: 10, - paddingVertical: 6, - justifyContent: 'center', - height: 32, - opacity: p.pressed ? 0.7 : 1, - gap: 6, - })} - > - <Octicons - name="cpu" - size={14} - color={theme.colors.button.secondary.tint} - /> - <Text style={{ - fontSize: 13, - color: theme.colors.button.secondary.tint, - fontWeight: '600', - ...Typography.default('semiBold'), - }}> - {props.agentType === 'claude' ? t('agentInput.agent.claude') : props.agentType === 'codex' ? t('agentInput.agent.codex') : t('agentInput.agent.gemini')} - </Text> - </Pressable> - )} - - {/* Abort button */} - {props.onAbort && ( - <Shaker ref={shakerRef}> - <Pressable - style={(p) => ({ - flexDirection: 'row', - alignItems: 'center', - borderRadius: Platform.select({ default: 16, android: 20 }), - paddingHorizontal: 8, - paddingVertical: 6, - justifyContent: 'center', - height: 32, - opacity: p.pressed ? 0.7 : 1, - })} - hitSlop={{ top: 5, bottom: 10, left: 0, right: 0 }} - onPress={handleAbortPress} - disabled={isAborting} - > - {isAborting ? ( - <ActivityIndicator - size="small" - color={theme.colors.button.secondary.tint} - /> - ) : ( - <Octicons - name={"stop"} - size={16} - color={theme.colors.button.secondary.tint} - /> - )} - </Pressable> - </Shaker> - )} - - {/* Git Status Badge */} - <GitStatusButton sessionId={props.sessionId} onPress={props.onFileViewerPress} /> - </View> - - {/* Send/Voice button - aligned with first row */} - <View - style={[ - styles.sendButton, - (hasText || props.isSending || (props.onMicPress && !props.isMicActive)) - ? styles.sendButtonActive - : styles.sendButtonInactive - ]} - > - <Pressable - style={(p) => ({ - width: '100%', - height: '100%', - alignItems: 'center', - justifyContent: 'center', - opacity: p.pressed ? 0.7 : 1, - })} - hitSlop={{ top: 5, bottom: 10, left: 0, right: 0 }} - onPress={() => { - hapticsLight(); - if (hasText) { - props.onSend(); - } else { - props.onMicPress?.(); - } - }} - disabled={props.isSendDisabled || props.isSending || (!hasText && !props.onMicPress)} - > - {props.isSending ? ( - <ActivityIndicator - size="small" - color={theme.colors.button.primary.tint} - /> - ) : hasText ? ( - <Octicons - name="arrow-up" - size={16} - color={theme.colors.button.primary.tint} - style={[ - styles.sendButtonIcon, - { marginTop: Platform.OS === 'web' ? 2 : 0 } - ]} - /> - ) : props.onMicPress && !props.isMicActive ? ( - <Image - source={require('@/assets/images/icon-voice-white.png')} - style={{ - width: 24, - height: 24, - }} - tintColor={theme.colors.button.primary.tint} - /> - ) : ( - <Octicons - name="arrow-up" - size={16} - color={theme.colors.button.primary.tint} - style={[ - styles.sendButtonIcon, - { marginTop: Platform.OS === 'web' ? 2 : 0 } - ]} - /> - )} - </Pressable> - </View> - </View> - </View> - </View> - </View> - </View> - </View> - ); -})); - -// Git Status Button Component -function GitStatusButton({ sessionId, onPress }: { sessionId?: string, onPress?: () => void }) { - const hasMeaningfulGitStatus = useHasMeaningfulGitStatus(sessionId || ''); - const styles = stylesheet; - const { theme } = useUnistyles(); - - if (!sessionId || !onPress) { - return null; - } - - return ( - <Pressable - style={(p) => ({ - flexDirection: 'row', - alignItems: 'center', - borderRadius: Platform.select({ default: 16, android: 20 }), - paddingHorizontal: 8, - paddingVertical: 6, - height: 32, - opacity: p.pressed ? 0.7 : 1, - flex: 1, - overflow: 'hidden', - })} - hitSlop={{ top: 5, bottom: 10, left: 0, right: 0 }} - onPress={() => { - hapticsLight(); - onPress?.(); - }} - > - {hasMeaningfulGitStatus ? ( - <GitStatusBadge sessionId={sessionId} /> - ) : ( - <Octicons - name="git-branch" - size={16} - color={theme.colors.button.secondary.tint} - /> - )} - </Pressable> - ); -} diff --git a/expo-app/sources/components/Avatar.tsx b/expo-app/sources/components/Avatar.tsx index fe78d57ca..c6e2e9860 100644 --- a/expo-app/sources/components/Avatar.tsx +++ b/expo-app/sources/components/Avatar.tsx @@ -6,6 +6,13 @@ import { AvatarGradient } from "./AvatarGradient"; import { AvatarBrutalist } from "./AvatarBrutalist"; import { useSetting } from '@/sync/storage'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; +import { + DEFAULT_AGENT_ID, + resolveAgentIdFromFlavor, + getAgentAvatarOverlaySizes, + getAgentIconSource, + getAgentIconTintColor, +} from '@/agents/catalog'; interface AvatarProps { id: string; @@ -16,14 +23,9 @@ interface AvatarProps { flavor?: string | null; imageUrl?: string | null; thumbhash?: string | null; + hasUnreadMessages?: boolean; } -const flavorIcons = { - claude: require('@/assets/images/icon-claude.png'), - codex: require('@/assets/images/icon-gpt.png'), - gemini: require('@/assets/images/icon-gemini.png'), -}; - const styles = StyleSheet.create((theme) => ({ container: { position: 'relative', @@ -41,14 +43,30 @@ const styles = StyleSheet.create((theme) => ({ shadowRadius: 2, elevation: 3, }, + unreadBadge: { + position: 'absolute', + top: -2, + right: -2, + backgroundColor: theme.colors.textLink, + borderRadius: 100, + borderWidth: 1.5, + borderColor: theme.colors.surface, + }, })); export const Avatar = React.memo((props: AvatarProps) => { - const { flavor, size = 48, imageUrl, thumbhash, ...avatarProps } = props; + const { flavor, size = 48, imageUrl, thumbhash, hasUnreadMessages, ...avatarProps } = props; const avatarStyle = useSetting('avatarStyle'); const showFlavorIcons = useSetting('showFlavorIcons'); const { theme } = useUnistyles(); + const agentId = resolveAgentIdFromFlavor(flavor); + + const unreadBadgeSize = Math.round(size * 0.22); + const unreadBadgeElement = hasUnreadMessages ? ( + <View style={[styles.unreadBadge, { width: unreadBadgeSize, height: unreadBadgeSize }]} /> + ) : null; + // Render custom image if provided if (imageUrl) { const imageElement = ( @@ -64,33 +82,32 @@ export const Avatar = React.memo((props: AvatarProps) => { /> ); - // Add flavor icon overlay if enabled - if (showFlavorIcons && flavor) { - const effectiveFlavor = flavor || 'claude'; - const flavorIcon = flavorIcons[effectiveFlavor as keyof typeof flavorIcons] || flavorIcons.claude; - const circleSize = Math.round(size * 0.35); - const iconSize = effectiveFlavor === 'codex' - ? Math.round(size * 0.25) - : effectiveFlavor === 'claude' - ? Math.round(size * 0.28) - : Math.round(size * 0.35); + const showFlavorOverlay = Boolean(showFlavorIcons && agentId); + if (showFlavorOverlay || hasUnreadMessages) { + const iconAgentId = agentId ?? DEFAULT_AGENT_ID; + const flavorIcon = getAgentIconSource(iconAgentId); + const tintColor = getAgentIconTintColor(iconAgentId, theme); + const { circleSize, iconSize } = getAgentAvatarOverlaySizes(iconAgentId, size); return ( <View style={[styles.container, { width: size, height: size }]}> {imageElement} - <View style={[styles.flavorIcon, { - width: circleSize, - height: circleSize, - alignItems: 'center', - justifyContent: 'center' - }]}> - <Image - source={flavorIcon} - style={{ width: iconSize, height: iconSize }} - contentFit="contain" - tintColor={effectiveFlavor === 'codex' ? theme.colors.text : undefined} - /> - </View> + {showFlavorOverlay && ( + <View style={[styles.flavorIcon, { + width: circleSize, + height: circleSize, + alignItems: 'center', + justifyContent: 'center' + }]}> + <Image + source={flavorIcon} + style={{ width: iconSize, height: iconSize }} + contentFit="contain" + tintColor={tintColor} + /> + </View> + )} + {unreadBadgeElement} </View> ); } @@ -109,40 +126,35 @@ export const Avatar = React.memo((props: AvatarProps) => { AvatarComponent = AvatarGradient; } - // Determine flavor icon for generated avatars - const effectiveFlavor = flavor || 'claude'; - const flavorIcon = flavorIcons[effectiveFlavor as keyof typeof flavorIcons] || flavorIcons.claude; - // Make icons smaller while keeping same circle size - // Claude slightly bigger than codex - const circleSize = Math.round(size * 0.35); - const iconSize = effectiveFlavor === 'codex' - ? Math.round(size * 0.25) - : effectiveFlavor === 'claude' - ? Math.round(size * 0.28) - : Math.round(size * 0.35); + const iconAgentId = agentId ?? DEFAULT_AGENT_ID; + const flavorIcon = getAgentIconSource(iconAgentId); + const tintColor = getAgentIconTintColor(iconAgentId, theme); + const { circleSize, iconSize } = getAgentAvatarOverlaySizes(iconAgentId, size); - // Only wrap in container if showing flavor icons - if (showFlavorIcons) { + if (showFlavorIcons || hasUnreadMessages) { return ( <View style={[styles.container, { width: size, height: size }]}> <AvatarComponent {...avatarProps} size={size} /> - <View style={[styles.flavorIcon, { - width: circleSize, - height: circleSize, - alignItems: 'center', - justifyContent: 'center' - }]}> - <Image - source={flavorIcon} - style={{ width: iconSize, height: iconSize }} - contentFit="contain" - tintColor={effectiveFlavor === 'codex' ? theme.colors.text : undefined} - /> - </View> + {showFlavorIcons && ( + <View style={[styles.flavorIcon, { + width: circleSize, + height: circleSize, + alignItems: 'center', + justifyContent: 'center' + }]}> + <Image + source={flavorIcon} + style={{ width: iconSize, height: iconSize }} + contentFit="contain" + tintColor={tintColor} + /> + </View> + )} + {unreadBadgeElement} </View> ); } // Return avatar without wrapper when not showing flavor icons return <AvatarComponent {...avatarProps} size={size} />; -}); \ No newline at end of file +}); diff --git a/expo-app/sources/components/ChatFooter.tsx b/expo-app/sources/components/ChatFooter.tsx index 111c42bca..1bf7418b4 100644 --- a/expo-app/sources/components/ChatFooter.tsx +++ b/expo-app/sources/components/ChatFooter.tsx @@ -3,9 +3,13 @@ import { View, Text, ViewStyle, TextStyle } from 'react-native'; import { Typography } from '@/constants/Typography'; import { Ionicons } from '@expo/vector-icons'; import { useUnistyles } from 'react-native-unistyles'; +import { t } from '@/text'; +import { SessionNoticeBanner, type SessionNoticeBannerProps } from '@/components/sessions/SessionNoticeBanner'; +import { layout } from '@/components/layout'; interface ChatFooterProps { controlledByUser?: boolean; + notice?: Pick<SessionNoticeBannerProps, 'title' | 'body'> | null; } export const ChatFooter = React.memo((props: ChatFooterProps) => { @@ -35,16 +39,27 @@ export const ChatFooter = React.memo((props: ChatFooterProps) => { <View style={containerStyle}> {props.controlledByUser && ( <View style={warningContainerStyle}> - <Ionicons - name="information-circle" - size={16} + <Ionicons + name="information-circle" + size={16} color={theme.colors.box.warning.text} /> <Text style={warningTextStyle}> - Permissions shown in terminal only. Reset or send a message to control from app. + {t('chatFooter.permissionsTerminalOnly')} </Text> </View> )} + {props.notice && ( + <View style={{ width: '100%', flexDirection: 'row', justifyContent: 'center' }}> + <View style={{ width: '100%', flexGrow: 1, flexBasis: 0, maxWidth: layout.maxWidth }}> + <SessionNoticeBanner + title={props.notice.title} + body={props.notice.body} + style={{ marginTop: 10, marginHorizontal: 8 }} + /> + </View> + </View> + )} </View> ); -}); \ No newline at end of file +}); diff --git a/expo-app/sources/components/ChatHeaderView.tsx b/expo-app/sources/components/ChatHeaderView.tsx index af74b924f..713c2d02b 100644 --- a/expo-app/sources/components/ChatHeaderView.tsx +++ b/expo-app/sources/components/ChatHeaderView.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { View, Text, StyleSheet, Platform, Pressable } from 'react-native'; +import { View, Text, Platform, Pressable } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { Ionicons } from '@expo/vector-icons'; import { useNavigation } from '@react-navigation/native'; @@ -7,7 +7,7 @@ import { Avatar } from '@/components/Avatar'; import { Typography } from '@/constants/Typography'; import { useHeaderHeight } from '@/utils/responsive'; import { layout } from '@/components/layout'; -import { useUnistyles } from 'react-native-unistyles'; +import { StyleSheet, useUnistyles } from 'react-native-unistyles'; interface ChatHeaderViewProps { title: string; @@ -153,4 +153,4 @@ const styles = StyleSheet.create({ justifyContent: 'center', marginRight: Platform.select({ ios: -8, default: -8 }), }, -}); \ No newline at end of file +}); diff --git a/expo-app/sources/components/ChatList.tsx b/expo-app/sources/components/ChatList.tsx index 72ca553cc..7b864c0e8 100644 --- a/expo-app/sources/components/ChatList.tsx +++ b/expo-app/sources/components/ChatList.tsx @@ -1,21 +1,30 @@ import * as React from 'react'; -import { useSession, useSessionMessages } from "@/sync/storage"; -import { ActivityIndicator, FlatList, Platform, View } from 'react-native'; +import { useSession, useSessionMessages, useSessionPendingMessages } from "@/sync/storage"; +import { FlatList, Platform, View } from 'react-native'; import { useCallback } from 'react'; import { useHeaderHeight } from '@/utils/responsive'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { MessageView } from './MessageView'; import { Metadata, Session } from '@/sync/storageTypes'; import { ChatFooter } from './ChatFooter'; -import { Message } from '@/sync/typesMessage'; +import { buildChatListItems, type ChatListItem } from '@/components/sessions/chatListItems'; +import { PendingUserTextMessageView } from '@/components/sessions/pending/PendingUserTextMessageView'; -export const ChatList = React.memo((props: { session: Session }) => { +export type ChatListBottomNotice = { + title: string; + body: string; +}; + +export const ChatList = React.memo((props: { session: Session; bottomNotice?: ChatListBottomNotice | null }) => { const { messages } = useSessionMessages(props.session.id); + const { messages: pendingMessages } = useSessionPendingMessages(props.session.id); + const items = React.useMemo(() => buildChatListItems({ messages, pendingMessages }), [messages, pendingMessages]); return ( <ChatListInternal metadata={props.session.metadata} sessionId={props.session.id} - messages={messages} + items={items} + bottomNotice={props.bottomNotice} /> ) }); @@ -26,25 +35,38 @@ const ListHeader = React.memo(() => { return <View style={{ flexDirection: 'row', alignItems: 'center', height: headerHeight + safeArea.top + 32 }} />; }); -const ListFooter = React.memo((props: { sessionId: string }) => { +const ListFooter = React.memo((props: { sessionId: string; bottomNotice?: ChatListBottomNotice | null }) => { const session = useSession(props.sessionId)!; return ( - <ChatFooter controlledByUser={session.agentState?.controlledByUser || false} /> + <ChatFooter + controlledByUser={session.agentState?.controlledByUser || false} + notice={props.bottomNotice ?? null} + /> ) }); const ChatListInternal = React.memo((props: { metadata: Metadata | null, sessionId: string, - messages: Message[], + items: ChatListItem[], + bottomNotice?: ChatListBottomNotice | null, }) => { - const keyExtractor = useCallback((item: any) => item.id, []); - const renderItem = useCallback(({ item }: { item: any }) => ( - <MessageView message={item} metadata={props.metadata} sessionId={props.sessionId} /> - ), [props.metadata, props.sessionId]); + const keyExtractor = useCallback((item: ChatListItem) => item.id, []); + const renderItem = useCallback(({ item }: { item: ChatListItem }) => { + if (item.kind === 'pending-user-text') { + return ( + <PendingUserTextMessageView + sessionId={props.sessionId} + message={item.pending} + otherPendingCount={item.otherPendingCount} + /> + ); + } + return <MessageView message={item.message} metadata={props.metadata} sessionId={props.sessionId} />; + }, [props.metadata, props.sessionId]); return ( <FlatList - data={props.messages} + data={props.items} inverted={true} keyExtractor={keyExtractor} maintainVisibleContentPosition={{ @@ -54,8 +76,8 @@ const ChatListInternal = React.memo((props: { keyboardShouldPersistTaps="handled" keyboardDismissMode={Platform.OS === 'ios' ? 'interactive' : 'none'} renderItem={renderItem} - ListHeaderComponent={<ListFooter sessionId={props.sessionId} />} + ListHeaderComponent={<ListFooter sessionId={props.sessionId} bottomNotice={props.bottomNotice} />} ListFooterComponent={<ListHeader />} /> ) -}); \ No newline at end of file +}); diff --git a/expo-app/sources/components/CommandPalette/CommandPalette.tsx b/expo-app/sources/components/CommandPalette/CommandPalette.tsx index c2701d894..c8dc2556f 100644 --- a/expo-app/sources/components/CommandPalette/CommandPalette.tsx +++ b/expo-app/sources/components/CommandPalette/CommandPalette.tsx @@ -1,5 +1,6 @@ import React from 'react'; -import { View, StyleSheet, Platform } from 'react-native'; +import { View, Platform } from 'react-native'; +import { StyleSheet } from 'react-native-unistyles'; import { CommandPaletteInput } from './CommandPaletteInput'; import { CommandPaletteResults } from './CommandPaletteResults'; import { useCommandPalette } from './useCommandPalette'; @@ -34,6 +35,7 @@ export function CommandPalette({ commands, onClose }: CommandPaletteProps) { onChangeText={handleSearchChange} onKeyPress={handleKeyPress} inputRef={inputRef} + autoFocus={true} /> <CommandPaletteResults categories={filteredCategories} @@ -69,4 +71,4 @@ const styles = StyleSheet.create({ borderWidth: 1, borderColor: 'rgba(0, 0, 0, 0.08)', }, -}); \ No newline at end of file +}); diff --git a/expo-app/sources/components/CommandPalette/CommandPaletteInput.tsx b/expo-app/sources/components/CommandPalette/CommandPaletteInput.tsx index 2d587260d..c86351b44 100644 --- a/expo-app/sources/components/CommandPalette/CommandPaletteInput.tsx +++ b/expo-app/sources/components/CommandPalette/CommandPaletteInput.tsx @@ -1,16 +1,21 @@ import React from 'react'; -import { View, TextInput, StyleSheet, Platform } from 'react-native'; +import { View, TextInput, Platform } from 'react-native'; import { Typography } from '@/constants/Typography'; import { t } from '@/text'; +import { StyleSheet } from 'react-native-unistyles'; interface CommandPaletteInputProps { value: string; onChangeText: (text: string) => void; onKeyPress?: (key: string) => void; inputRef?: React.RefObject<TextInput | null>; + placeholder?: string; + autoFocus?: boolean; } -export function CommandPaletteInput({ value, onChangeText, onKeyPress, inputRef }: CommandPaletteInputProps) { +export function CommandPaletteInput({ value, onChangeText, onKeyPress, inputRef, placeholder, autoFocus = true }: CommandPaletteInputProps) { + const styles = stylesheet; + const handleKeyDown = React.useCallback((e: any) => { if (Platform.OS === 'web' && onKeyPress) { const key = e.nativeEvent.key; @@ -31,9 +36,9 @@ export function CommandPaletteInput({ value, onChangeText, onKeyPress, inputRef style={[styles.input, Typography.default()]} value={value} onChangeText={onChangeText} - placeholder={t('commandPalette.placeholder')} + placeholder={placeholder ?? t('commandPalette.placeholder')} placeholderTextColor="#999" - autoFocus + autoFocus={autoFocus} autoCorrect={false} autoCapitalize="none" returnKeyType="go" @@ -44,7 +49,7 @@ export function CommandPaletteInput({ value, onChangeText, onKeyPress, inputRef ); } -const styles = StyleSheet.create({ +const stylesheet = StyleSheet.create(() => ({ container: { borderBottomWidth: 1, borderBottomColor: 'rgba(0, 0, 0, 0.06)', @@ -62,4 +67,4 @@ const styles = StyleSheet.create({ outlineWidth: 0, } as any : {}), }, -}); +})); diff --git a/expo-app/sources/components/CommandPalette/CommandPaletteItem.tsx b/expo-app/sources/components/CommandPalette/CommandPaletteItem.tsx index c52fb09d4..e85719e2e 100644 --- a/expo-app/sources/components/CommandPalette/CommandPaletteItem.tsx +++ b/expo-app/sources/components/CommandPalette/CommandPaletteItem.tsx @@ -1,8 +1,10 @@ import React from 'react'; -import { View, Text, Pressable, StyleSheet, Platform } from 'react-native'; +import { View, Text } from 'react-native'; import { Command } from './types'; -import { Typography } from '@/constants/Typography'; import { Ionicons } from '@expo/vector-icons'; +import { useUnistyles } from 'react-native-unistyles'; +import { SelectableRow } from '@/components/ui/lists/SelectableRow'; +import { Typography } from '@/constants/Typography'; interface CommandPaletteItemProps { command: Command; @@ -12,130 +14,32 @@ interface CommandPaletteItemProps { } export function CommandPaletteItem({ command, isSelected, onPress, onHover }: CommandPaletteItemProps) { - const [isHovered, setIsHovered] = React.useState(false); - - const handleMouseEnter = React.useCallback(() => { - if (Platform.OS === 'web') { - setIsHovered(true); - onHover?.(); - } - }, [onHover]); - - const handleMouseLeave = React.useCallback(() => { - if (Platform.OS === 'web') { - setIsHovered(false); - } - }, []); - - const pressableProps: any = { - style: ({ pressed }: any) => [ - styles.container, - isSelected && styles.selected, - isHovered && !isSelected && styles.hovered, - pressed && Platform.OS === 'web' && styles.pressed - ], - onPress, - }; - - // Add mouse events only on web - if (Platform.OS === 'web') { - pressableProps.onMouseEnter = handleMouseEnter; - pressableProps.onMouseLeave = handleMouseLeave; - } - + const { theme } = useUnistyles(); + return ( - <Pressable {...pressableProps}> - <View style={styles.content}> - {command.icon && ( - <View style={styles.iconContainer}> - <Ionicons - name={command.icon as any} - size={20} - color={isSelected ? '#007AFF' : '#666'} - /> - </View> - )} - <View style={styles.textContainer}> - <Text style={[styles.title, Typography.default()]}> - {command.title} + <SelectableRow + variant="selectable" + selected={isSelected} + onPress={onPress} + onHover={onHover} + left={command.icon ? ( + <View style={{ width: 32, height: 32, borderRadius: 8, backgroundColor: 'rgba(0, 0, 0, 0.04)', alignItems: 'center', justifyContent: 'center' }}> + <Ionicons + name={command.icon as any} + size={20} + color={isSelected ? '#007AFF' : '#666'} + /> + </View> + ) : null} + title={command.title} + subtitle={command.subtitle ?? undefined} + right={command.shortcut ? ( + <View style={{ paddingHorizontal: 10, paddingVertical: 5, backgroundColor: 'rgba(0, 0, 0, 0.04)', borderRadius: 6 }}> + <Text style={{ ...Typography.mono(), fontSize: 12, color: '#666', fontWeight: '500' }}> + {command.shortcut} </Text> - {command.subtitle && ( - <Text style={[styles.subtitle, Typography.default()]}> - {command.subtitle} - </Text> - )} </View> - {command.shortcut && ( - <View style={styles.shortcutContainer}> - <Text style={[styles.shortcut, Typography.mono()]}> - {command.shortcut} - </Text> - </View> - )} - </View> - </Pressable> + ) : null} + /> ); } - -const styles = StyleSheet.create({ - container: { - paddingHorizontal: 24, - paddingVertical: 12, - backgroundColor: 'transparent', - marginHorizontal: 8, - marginVertical: 2, - borderRadius: 8, - borderWidth: 2, - borderColor: 'transparent', - }, - selected: { - backgroundColor: '#F0F7FF', - borderColor: '#007AFF20', - }, - pressed: { - backgroundColor: '#F5F5F5', - }, - hovered: { - backgroundColor: '#F8F8F8', - }, - content: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - }, - iconContainer: { - width: 32, - height: 32, - borderRadius: 8, - backgroundColor: 'rgba(0, 0, 0, 0.04)', - alignItems: 'center', - justifyContent: 'center', - marginRight: 12, - }, - textContainer: { - flex: 1, - marginRight: 12, - }, - title: { - fontSize: 15, - color: '#000', - marginBottom: 2, - letterSpacing: -0.2, - }, - subtitle: { - fontSize: 13, - color: '#666', - letterSpacing: -0.1, - }, - shortcutContainer: { - paddingHorizontal: 10, - paddingVertical: 5, - backgroundColor: 'rgba(0, 0, 0, 0.04)', - borderRadius: 6, - }, - shortcut: { - fontSize: 12, - color: '#666', - fontWeight: '500', - }, -}); \ No newline at end of file diff --git a/expo-app/sources/components/CommandPalette/CommandPaletteModal.tsx b/expo-app/sources/components/CommandPalette/CommandPaletteModal.tsx deleted file mode 100644 index 5ef4bb368..000000000 --- a/expo-app/sources/components/CommandPalette/CommandPaletteModal.tsx +++ /dev/null @@ -1,148 +0,0 @@ -import React, { useEffect, useRef } from 'react'; -import { - View, - Modal, - TouchableWithoutFeedback, - Animated, - StyleSheet, - KeyboardAvoidingView, - Platform -} from 'react-native'; - -interface CommandPaletteModalProps { - visible: boolean; - onClose?: () => void; - children: React.ReactNode; -} - -export function CommandPaletteModal({ - visible, - onClose, - children -}: CommandPaletteModalProps) { - const fadeAnim = useRef(new Animated.Value(0)).current; - const scaleAnim = useRef(new Animated.Value(0.95)).current; - const [isModalVisible, setIsModalVisible] = React.useState(true); - - useEffect(() => { - if (visible) { - // Opening animation - Animated.parallel([ - Animated.timing(fadeAnim, { - toValue: 1, - duration: 200, - useNativeDriver: true - }), - Animated.spring(scaleAnim, { - toValue: 1, - friction: 10, - tension: 60, - useNativeDriver: true - }) - ]).start(); - } - }, [visible, fadeAnim, scaleAnim]); - - const handleClose = React.useCallback(() => { - // Closing animation - Animated.parallel([ - Animated.timing(fadeAnim, { - toValue: 0, - duration: 150, - useNativeDriver: true - }), - Animated.timing(scaleAnim, { - toValue: 0.95, - duration: 150, - useNativeDriver: true - }) - ]).start(() => { - setIsModalVisible(false); - // Small delay to ensure modal is hidden before calling onClose - setTimeout(() => { - if (onClose) { - onClose(); - } - }, 50); - }); - }, [fadeAnim, scaleAnim, onClose]); - - const handleBackdropPress = () => { - handleClose(); - }; - - if (!isModalVisible) { - return null; - } - - return ( - <Modal - visible={isModalVisible} - transparent={true} - animationType="none" - onRequestClose={handleClose} - > - <KeyboardAvoidingView - style={styles.container} - behavior={Platform.OS === 'ios' ? 'padding' : 'height'} - > - <TouchableWithoutFeedback onPress={handleBackdropPress}> - <Animated.View - style={[ - styles.backdrop, - { - opacity: fadeAnim.interpolate({ - inputRange: [0, 1], - outputRange: [0, 0.7] - }) - } - ]} - /> - </TouchableWithoutFeedback> - - <Animated.View - style={[ - styles.content, - { - opacity: fadeAnim, - transform: [{ scale: scaleAnim }] - } - ]} - > - {children} - </Animated.View> - </KeyboardAvoidingView> - </Modal> - ); -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - justifyContent: 'flex-start', - alignItems: 'center', - // Position at 30% from top of viewport - ...(Platform.OS === 'web' ? { - paddingTop: '30vh', - } as any : { - paddingTop: 200, // Fallback for native - }) - }, - backdrop: { - ...StyleSheet.absoluteFillObject, - backgroundColor: 'rgba(15, 15, 15, 0.75)', - // Remove blur for better performance - use darker overlay instead - // Blur can be re-enabled if needed but with optimizations - ...(Platform.OS === 'web' ? { - // backdropFilter: 'blur(2px)', - // WebkitBackdropFilter: 'blur(2px)', - // willChange: 'backdrop-filter', - // transform: 'translateZ(0)', // Force GPU acceleration - } as any : {}) - }, - content: { - zIndex: 1, - width: '90%', - maxWidth: 800, // Increased from 640 - } -}); \ No newline at end of file diff --git a/expo-app/sources/components/CommandPalette/CommandPaletteProvider.tsx b/expo-app/sources/components/CommandPalette/CommandPaletteProvider.tsx index 558241472..748250c28 100644 --- a/expo-app/sources/components/CommandPalette/CommandPaletteProvider.tsx +++ b/expo-app/sources/components/CommandPalette/CommandPaletteProvider.tsx @@ -121,7 +121,7 @@ export function CommandPaletteProvider({ children }: { children: React.ReactNode } return cmds; - }, [router, logout, sessions]); + }, [router, logout, sessions, navigateToSession]); const showCommandPalette = useCallback(() => { if (Platform.OS !== 'web' || !commandPaletteEnabled) return; @@ -131,11 +131,11 @@ export function CommandPaletteProvider({ children }: { children: React.ReactNode props: { commands, } - } as any); + }); }, [commands, commandPaletteEnabled]); // Set up global keyboard handler only if feature is enabled useGlobalKeyboard(commandPaletteEnabled ? showCommandPalette : () => {}); return <>{children}</>; -} \ No newline at end of file +} diff --git a/expo-app/sources/components/CommandPalette/CommandPaletteResults.tsx b/expo-app/sources/components/CommandPalette/CommandPaletteResults.tsx index ab7ec1bc0..1cee6310e 100644 --- a/expo-app/sources/components/CommandPalette/CommandPaletteResults.tsx +++ b/expo-app/sources/components/CommandPalette/CommandPaletteResults.tsx @@ -1,8 +1,10 @@ import React, { useRef, useEffect } from 'react'; -import { View, ScrollView, Text, StyleSheet, Platform } from 'react-native'; +import { View, ScrollView, Text, Platform } from 'react-native'; +import { StyleSheet } from 'react-native-unistyles'; import { Command, CommandCategory } from './types'; import { CommandPaletteItem } from './CommandPaletteItem'; import { Typography } from '@/constants/Typography'; +import { t } from '@/text'; interface CommandPaletteResultsProps { categories: CommandCategory[]; @@ -43,7 +45,7 @@ export function CommandPaletteResults({ return ( <View style={styles.emptyContainer}> <Text style={[styles.emptyText, Typography.default()]}> - No commands found + {t('commandPalette.noCommandsFound')} </Text> </View> ); @@ -126,4 +128,4 @@ const styles = StyleSheet.create({ letterSpacing: 0.8, fontWeight: '600', }, -}); \ No newline at end of file +}); diff --git a/expo-app/sources/components/CommandView.tsx b/expo-app/sources/components/CommandView.tsx index 5bbd22a16..081f98c6b 100644 --- a/expo-app/sources/components/CommandView.tsx +++ b/expo-app/sources/components/CommandView.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; -import { Text, View, StyleSheet, Platform } from 'react-native'; -import { useUnistyles } from 'react-native-unistyles'; +import { Text, View, Platform } from 'react-native'; +import { StyleSheet, useUnistyles } from 'react-native-unistyles'; +import { t } from '@/text'; interface CommandViewProps { command: string; @@ -120,7 +121,7 @@ export const CommandView = React.memo<CommandViewProps>(({ {/* Empty output indicator */} {!stdout && !stderr && !error && !hideEmptyOutput && ( - <Text style={styles.emptyOutput}>[Command completed with no output]</Text> + <Text style={styles.emptyOutput}>{t('commandView.completedWithNoOutput')}</Text> )} </> ) : ( @@ -132,4 +133,3 @@ export const CommandView = React.memo<CommandViewProps>(({ </View> ); }); - diff --git a/expo-app/sources/components/ConnectButton.tsx b/expo-app/sources/components/ConnectButton.tsx index 9b313a3f7..e686d98aa 100644 --- a/expo-app/sources/components/ConnectButton.tsx +++ b/expo-app/sources/components/ConnectButton.tsx @@ -88,7 +88,7 @@ export const ConnectButton = React.memo(() => { }} value={manualUrl} onChangeText={setManualUrl} - placeholder="happy://terminal?..." + placeholder={t('connect.terminalUrlPlaceholder')} placeholderTextColor="#999" autoCapitalize="none" autoCorrect={false} diff --git a/expo-app/sources/components/EmptyMainScreen.tsx b/expo-app/sources/components/EmptyMainScreen.tsx index 5ca26942c..d63e9a3c8 100644 --- a/expo-app/sources/components/EmptyMainScreen.tsx +++ b/expo-app/sources/components/EmptyMainScreen.tsx @@ -96,10 +96,10 @@ export function EmptyMainScreen() { <Text style={styles.title}>{t('components.emptyMainScreen.readyToCode')}</Text> <View style={styles.terminalBlock}> <Text style={[styles.terminalText, styles.terminalTextFirst]}> - $ npm i -g happy-coder + {t('components.emptyMainScreen.installCommand')} </Text> <Text style={styles.terminalText}> - $ happy + {t('components.emptyMainScreen.runCommand')} </Text> </View> @@ -151,7 +151,7 @@ export function EmptyMainScreen() { t('modals.authenticateTerminal'), t('modals.pasteUrlFromTerminal'), { - placeholder: 'happy://terminal?...', + placeholder: t('connect.terminalUrlPlaceholder'), cancelText: t('common.cancel'), confirmText: t('common.authenticate') } diff --git a/expo-app/sources/components/EmptyMessages.tsx b/expo-app/sources/components/EmptyMessages.tsx index 26ad3928a..41200ace4 100644 --- a/expo-app/sources/components/EmptyMessages.tsx +++ b/expo-app/sources/components/EmptyMessages.tsx @@ -112,12 +112,12 @@ export function EmptyMessages({ session }: EmptyMessagesProps) { )} <Text style={styles.noMessagesText}> - No messages yet + {t('components.emptyMessages.noMessagesYet')} </Text> <Text style={styles.createdText}> - Created {startedTime} + {t('components.emptyMessages.created', { time: startedTime })} </Text> </View> ); -} \ No newline at end of file +} diff --git a/expo-app/sources/components/EmptySessionsTablet.tsx b/expo-app/sources/components/EmptySessionsTablet.tsx index e9812c6ca..9e7b9a049 100644 --- a/expo-app/sources/components/EmptySessionsTablet.tsx +++ b/expo-app/sources/components/EmptySessionsTablet.tsx @@ -6,6 +6,7 @@ import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { useAllMachines } from '@/sync/storage'; import { isMachineOnline } from '@/utils/machineUtils'; import { useRouter } from 'expo-router'; +import { t } from '@/text'; const stylesheet = StyleSheet.create((theme) => ({ container: { @@ -78,13 +79,13 @@ export function EmptySessionsTablet() { /> <Text style={styles.titleText}> - No active sessions + {t('components.emptySessionsTablet.noActiveSessions')} </Text> {hasOnlineMachines ? ( <> <Text style={styles.descriptionText}> - Start a new session on any of your connected machines. + {t('components.emptySessionsTablet.startNewSessionDescription')} </Text> <Pressable style={styles.button} @@ -97,15 +98,15 @@ export function EmptySessionsTablet() { style={styles.buttonIcon} /> <Text style={styles.buttonText}> - Start New Session + {t('components.emptySessionsTablet.startNewSessionButton')} </Text> </Pressable> </> ) : ( <Text style={styles.descriptionText}> - Open a new terminal on your computer to start session. + {t('components.emptySessionsTablet.openTerminalToStart')} </Text> )} </View> ); -} \ No newline at end of file +} diff --git a/expo-app/sources/components/EnvironmentVariableCard.tsx b/expo-app/sources/components/EnvironmentVariableCard.tsx deleted file mode 100644 index 2185e0b21..000000000 --- a/expo-app/sources/components/EnvironmentVariableCard.tsx +++ /dev/null @@ -1,336 +0,0 @@ -import React from 'react'; -import { View, Text, TextInput, Pressable } from 'react-native'; -import { Ionicons } from '@expo/vector-icons'; -import { useUnistyles } from 'react-native-unistyles'; -import { Typography } from '@/constants/Typography'; -import { useEnvironmentVariables } from '@/hooks/useEnvironmentVariables'; - -export interface EnvironmentVariableCardProps { - variable: { name: string; value: string }; - machineId: string | null; - expectedValue?: string; // From profile documentation - description?: string; // Variable description - isSecret?: boolean; // Whether this is a secret (never query remote) - onUpdate: (newValue: string) => void; - onDelete: () => void; - onDuplicate: () => void; -} - -/** - * Parse environment variable value to determine configuration - */ -function parseVariableValue(value: string): { - useRemoteVariable: boolean; - remoteVariableName: string; - defaultValue: string; -} { - // Match: ${VARIABLE_NAME:-default_value} - const matchWithFallback = value.match(/^\$\{([A-Z_][A-Z0-9_]*):-(.*)\}$/); - if (matchWithFallback) { - return { - useRemoteVariable: true, - remoteVariableName: matchWithFallback[1], - defaultValue: matchWithFallback[2] - }; - } - - // Match: ${VARIABLE_NAME} (no fallback) - const matchNoFallback = value.match(/^\$\{([A-Z_][A-Z0-9_]*)\}$/); - if (matchNoFallback) { - return { - useRemoteVariable: true, - remoteVariableName: matchNoFallback[1], - defaultValue: '' - }; - } - - // Literal value (no template) - return { - useRemoteVariable: false, - remoteVariableName: '', - defaultValue: value - }; -} - -/** - * Single environment variable card component - * Matches profile list pattern from index.tsx:1163-1217 - */ -export function EnvironmentVariableCard({ - variable, - machineId, - expectedValue, - description, - isSecret = false, - onUpdate, - onDelete, - onDuplicate, -}: EnvironmentVariableCardProps) { - const { theme } = useUnistyles(); - - // Parse current value - const parsed = parseVariableValue(variable.value); - const [useRemoteVariable, setUseRemoteVariable] = React.useState(parsed.useRemoteVariable); - const [remoteVariableName, setRemoteVariableName] = React.useState(parsed.remoteVariableName); - const [defaultValue, setDefaultValue] = React.useState(parsed.defaultValue); - - // Query remote machine for variable value (only if checkbox enabled and not secret) - const shouldQueryRemote = useRemoteVariable && !isSecret && remoteVariableName.trim() !== ''; - const { variables: remoteValues } = useEnvironmentVariables( - machineId, - shouldQueryRemote ? [remoteVariableName] : [] - ); - - const remoteValue = remoteValues[remoteVariableName]; - - // Update parent when local state changes - React.useEffect(() => { - const newValue = useRemoteVariable && remoteVariableName.trim() !== '' - ? `\${${remoteVariableName}${defaultValue ? `:-${defaultValue}` : ''}}` - : defaultValue; - - if (newValue !== variable.value) { - onUpdate(newValue); - } - }, [useRemoteVariable, remoteVariableName, defaultValue, variable.value, onUpdate]); - - // Determine status - const showRemoteDiffersWarning = remoteValue !== null && expectedValue && remoteValue !== expectedValue; - const showDefaultOverrideWarning = expectedValue && defaultValue !== expectedValue; - - return ( - <View style={{ - backgroundColor: theme.colors.input.background, - borderRadius: theme.borderRadius.xl, - padding: theme.margins.lg, - marginBottom: theme.margins.md - }}> - {/* Header row with variable name and action buttons */} - <View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}> - <Text style={{ - fontSize: 12, - fontWeight: '600', - color: theme.colors.text, - ...Typography.default('semiBold') - }}> - {variable.name} - {isSecret && ( - <Ionicons name="lock-closed" size={theme.iconSize.small} color={theme.colors.textDestructive} style={{ marginLeft: 4 }} /> - )} - </Text> - - <View style={{ flexDirection: 'row', alignItems: 'center', gap: theme.margins.md }}> - <Pressable - hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} - onPress={onDelete} - > - <Ionicons name="trash-outline" size={theme.iconSize.large} color={theme.colors.deleteAction} /> - </Pressable> - <Pressable - hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} - onPress={onDuplicate} - > - <Ionicons name="copy-outline" size={theme.iconSize.large} color={theme.colors.button.secondary.tint} /> - </Pressable> - </View> - </View> - - {/* Description */} - {description && ( - <Text style={{ - fontSize: 11, - color: theme.colors.textSecondary, - marginBottom: 8, - ...Typography.default() - }}> - {description} - </Text> - )} - - {/* Checkbox: First try copying variable from remote machine */} - <Pressable - style={{ - flexDirection: 'row', - alignItems: 'center', - marginBottom: 8, - }} - onPress={() => setUseRemoteVariable(!useRemoteVariable)} - > - <View style={{ - width: 20, - height: 20, - borderRadius: theme.borderRadius.sm, - borderWidth: 2, - borderColor: useRemoteVariable ? theme.colors.button.primary.background : theme.colors.textSecondary, - backgroundColor: useRemoteVariable ? theme.colors.button.primary.background : 'transparent', - justifyContent: 'center', - alignItems: 'center', - marginRight: theme.margins.sm, - }}> - {useRemoteVariable && ( - <Ionicons name="checkmark" size={theme.iconSize.small} color={theme.colors.button.primary.tint} /> - )} - </View> - <Text style={{ - fontSize: 11, - color: theme.colors.textSecondary, - ...Typography.default() - }}> - First try copying variable from remote machine: - </Text> - </Pressable> - - {/* Remote variable name input */} - <TextInput - style={{ - backgroundColor: theme.colors.surface, - borderRadius: theme.borderRadius.lg, - padding: theme.margins.sm, - fontSize: 14, - color: theme.colors.text, - marginBottom: 4, - borderWidth: 1, - borderColor: theme.colors.textSecondary, - opacity: useRemoteVariable ? 1 : 0.5, - }} - placeholder="Variable name (e.g., Z_AI_MODEL)" - placeholderTextColor={theme.colors.input.placeholder} - value={remoteVariableName} - onChangeText={setRemoteVariableName} - editable={useRemoteVariable} - autoCapitalize="none" - autoCorrect={false} - /> - - {/* Remote variable status */} - {useRemoteVariable && !isSecret && machineId && remoteVariableName.trim() !== '' && ( - <View style={{ marginBottom: 8 }}> - {remoteValue === undefined ? ( - <Text style={{ - fontSize: 11, - color: theme.colors.textSecondary, - fontStyle: 'italic', - ...Typography.default() - }}> - ⏳ Checking remote machine... - </Text> - ) : remoteValue === null ? ( - <Text style={{ - fontSize: 11, - color: theme.colors.warning, - ...Typography.default() - }}> - ✗ Value not found - </Text> - ) : ( - <> - <Text style={{ - fontSize: 11, - color: theme.colors.success, - ...Typography.default() - }}> - ✓ Value found: {remoteValue} - </Text> - {showRemoteDiffersWarning && ( - <Text style={{ - fontSize: 11, - color: theme.colors.textSecondary, - marginTop: 2, - ...Typography.default() - }}> - ⚠️ Differs from documented value: {expectedValue} - </Text> - )} - </> - )} - </View> - )} - - {useRemoteVariable && !isSecret && !machineId && ( - <Text style={{ - fontSize: 11, - color: theme.colors.textSecondary, - marginBottom: 8, - fontStyle: 'italic', - ...Typography.default() - }}> - ℹ️ Select a machine to check if variable exists - </Text> - )} - - {/* Security message for secrets */} - {isSecret && ( - <Text style={{ - fontSize: 11, - color: theme.colors.textSecondary, - marginBottom: 8, - fontStyle: 'italic', - ...Typography.default() - }}> - 🔒 Secret value - not retrieved for security - </Text> - )} - - {/* Default value label */} - <Text style={{ - fontSize: 11, - color: theme.colors.textSecondary, - marginBottom: 4, - ...Typography.default() - }}> - Default value: - </Text> - - {/* Default value input */} - <TextInput - style={{ - backgroundColor: theme.colors.surface, - borderRadius: theme.borderRadius.lg, - padding: theme.margins.sm, - fontSize: 14, - color: theme.colors.text, - marginBottom: 4, - borderWidth: 1, - borderColor: theme.colors.textSecondary, - }} - placeholder={expectedValue || "Value"} - placeholderTextColor={theme.colors.input.placeholder} - value={defaultValue} - onChangeText={setDefaultValue} - autoCapitalize="none" - autoCorrect={false} - secureTextEntry={isSecret} - /> - - {/* Default override warning */} - {showDefaultOverrideWarning && !isSecret && ( - <Text style={{ - fontSize: 11, - color: theme.colors.textSecondary, - marginBottom: 8, - ...Typography.default() - }}> - ⚠️ Overriding documented default: {expectedValue} - </Text> - )} - - {/* Session preview */} - <Text style={{ - fontSize: 11, - color: theme.colors.textSecondary, - marginTop: 4, - ...Typography.default() - }}> - Session will receive: {variable.name} = { - isSecret - ? (useRemoteVariable && remoteVariableName - ? `\${${remoteVariableName}${defaultValue ? `:-***` : ''}} - hidden for security` - : (defaultValue ? '***hidden***' : '(empty)')) - : (useRemoteVariable && remoteValue !== undefined && remoteValue !== null - ? remoteValue - : defaultValue || '(empty)') - } - </Text> - </View> - ); -} diff --git a/expo-app/sources/components/EnvironmentVariablesList.tsx b/expo-app/sources/components/EnvironmentVariablesList.tsx deleted file mode 100644 index e42e61415..000000000 --- a/expo-app/sources/components/EnvironmentVariablesList.tsx +++ /dev/null @@ -1,258 +0,0 @@ -import React from 'react'; -import { View, Text, Pressable, TextInput } from 'react-native'; -import { Ionicons } from '@expo/vector-icons'; -import { useUnistyles } from 'react-native-unistyles'; -import { Typography } from '@/constants/Typography'; -import { EnvironmentVariableCard } from './EnvironmentVariableCard'; -import type { ProfileDocumentation } from '@/sync/profileUtils'; - -export interface EnvironmentVariablesListProps { - environmentVariables: Array<{ name: string; value: string }>; - machineId: string | null; - profileDocs?: ProfileDocumentation | null; - onChange: (newVariables: Array<{ name: string; value: string }>) => void; -} - -/** - * Complete environment variables section with title, add button, and editable cards - * Matches profile list pattern from index.tsx:1159-1308 - */ -export function EnvironmentVariablesList({ - environmentVariables, - machineId, - profileDocs, - onChange, -}: EnvironmentVariablesListProps) { - const { theme } = useUnistyles(); - - // Add variable inline form state - const [showAddForm, setShowAddForm] = React.useState(false); - const [newVarName, setNewVarName] = React.useState(''); - const [newVarValue, setNewVarValue] = React.useState(''); - - // Helper to get expected value and description from documentation - const getDocumentation = React.useCallback((varName: string) => { - if (!profileDocs) return { expectedValue: undefined, description: undefined, isSecret: false }; - - const doc = profileDocs.environmentVariables.find(ev => ev.name === varName); - return { - expectedValue: doc?.expectedValue, - description: doc?.description, - isSecret: doc?.isSecret || false - }; - }, [profileDocs]); - - // Extract variable name from value (for matching documentation) - const extractVarNameFromValue = React.useCallback((value: string): string | null => { - const match = value.match(/^\$\{([A-Z_][A-Z0-9_]*)/); - return match ? match[1] : null; - }, []); - - const handleUpdateVariable = React.useCallback((index: number, newValue: string) => { - const updated = [...environmentVariables]; - updated[index] = { ...updated[index], value: newValue }; - onChange(updated); - }, [environmentVariables, onChange]); - - const handleDeleteVariable = React.useCallback((index: number) => { - onChange(environmentVariables.filter((_, i) => i !== index)); - }, [environmentVariables, onChange]); - - const handleDuplicateVariable = React.useCallback((index: number) => { - const envVar = environmentVariables[index]; - const baseName = envVar.name.replace(/_COPY\d*$/, ''); - - // Find next available copy number - let copyNum = 1; - while (environmentVariables.some(v => v.name === `${baseName}_COPY${copyNum}`)) { - copyNum++; - } - - const duplicated = { - name: `${baseName}_COPY${copyNum}`, - value: envVar.value - }; - onChange([...environmentVariables, duplicated]); - }, [environmentVariables, onChange]); - - const handleAddVariable = React.useCallback(() => { - if (!newVarName.trim()) return; - - // Validate variable name format - if (!/^[A-Z_][A-Z0-9_]*$/.test(newVarName.trim())) { - return; - } - - // Check for duplicates - if (environmentVariables.some(v => v.name === newVarName.trim())) { - return; - } - - onChange([...environmentVariables, { - name: newVarName.trim(), - value: newVarValue.trim() || '' - }]); - - // Reset form - setNewVarName(''); - setNewVarValue(''); - setShowAddForm(false); - }, [newVarName, newVarValue, environmentVariables, onChange]); - - return ( - <View style={{ marginBottom: 16 }}> - {/* Section header */} - <Text style={{ - fontSize: 14, - fontWeight: '600', - color: theme.colors.text, - marginBottom: 12, - ...Typography.default('semiBold') - }}> - Environment Variables - </Text> - - {/* Add Variable Button */} - <Pressable - style={{ - flexDirection: 'row', - alignItems: 'center', - backgroundColor: theme.colors.button.primary.background, - borderRadius: theme.borderRadius.md, - paddingHorizontal: theme.margins.md, - paddingVertical: 6, - gap: 6, - marginBottom: theme.margins.md - }} - onPress={() => setShowAddForm(true)} - > - <Ionicons name="add" size={theme.iconSize.medium} color={theme.colors.button.primary.tint} /> - <Text style={{ - fontSize: 13, - fontWeight: '600', - color: theme.colors.button.primary.tint, - ...Typography.default('semiBold') - }}> - Add Variable - </Text> - </Pressable> - - {/* Add variable inline form */} - {showAddForm && ( - <View style={{ - backgroundColor: theme.colors.input.background, - borderRadius: theme.borderRadius.lg, - padding: theme.margins.md, - marginBottom: theme.margins.md, - borderWidth: 2, - borderColor: theme.colors.button.primary.background, - }}> - <TextInput - style={{ - backgroundColor: theme.colors.surface, - borderRadius: theme.borderRadius.lg, - padding: theme.margins.sm, - fontSize: 14, - color: theme.colors.text, - marginBottom: theme.margins.sm, - borderWidth: 1, - borderColor: theme.colors.textSecondary, - }} - placeholder="Variable name (e.g., MY_CUSTOM_VAR)" - placeholderTextColor={theme.colors.input.placeholder} - value={newVarName} - onChangeText={setNewVarName} - autoCapitalize="characters" - autoCorrect={false} - /> - <TextInput - style={{ - backgroundColor: theme.colors.surface, - borderRadius: theme.borderRadius.lg, - padding: theme.margins.sm, - fontSize: 14, - color: theme.colors.text, - marginBottom: theme.margins.md, - borderWidth: 1, - borderColor: theme.colors.textSecondary, - }} - placeholder="Value (e.g., my-value or ${MY_VAR})" - placeholderTextColor={theme.colors.input.placeholder} - value={newVarValue} - onChangeText={setNewVarValue} - autoCapitalize="none" - autoCorrect={false} - /> - <View style={{ flexDirection: 'row', gap: 8 }}> - <Pressable - style={{ - flex: 1, - backgroundColor: theme.colors.surface, - borderRadius: 6, - padding: theme.margins.sm, - alignItems: 'center', - borderWidth: 1, - borderColor: theme.colors.textSecondary, - }} - onPress={() => { - setShowAddForm(false); - setNewVarName(''); - setNewVarValue(''); - }} - > - <Text style={{ - fontSize: 14, - color: theme.colors.textSecondary, - ...Typography.default() - }}> - Cancel - </Text> - </Pressable> - <Pressable - style={{ - flex: 1, - backgroundColor: theme.colors.button.primary.background, - borderRadius: 6, - padding: theme.margins.sm, - alignItems: 'center', - }} - onPress={handleAddVariable} - > - <Text style={{ - fontSize: 14, - fontWeight: '600', - color: theme.colors.button.primary.tint, - ...Typography.default('semiBold') - }}> - Add - </Text> - </Pressable> - </View> - </View> - )} - - {/* Variable cards */} - {environmentVariables.map((envVar, index) => { - const varNameFromValue = extractVarNameFromValue(envVar.value); - const docs = getDocumentation(varNameFromValue || envVar.name); - - // Auto-detect secrets if not explicitly documented - const isSecret = docs.isSecret || /TOKEN|KEY|SECRET|AUTH/i.test(envVar.name) || /TOKEN|KEY|SECRET|AUTH/i.test(varNameFromValue || ''); - - return ( - <EnvironmentVariableCard - key={index} - variable={envVar} - machineId={machineId} - expectedValue={docs.expectedValue} - description={docs.description} - isSecret={isSecret} - onUpdate={(newValue) => handleUpdateVariable(index, newValue)} - onDelete={() => handleDeleteVariable(index)} - onDuplicate={() => handleDuplicateVariable(index)} - /> - ); - })} - </View> - ); -} diff --git a/expo-app/sources/components/FeedItemCard.tsx b/expo-app/sources/components/FeedItemCard.tsx index 06558f718..8395383b5 100644 --- a/expo-app/sources/components/FeedItemCard.tsx +++ b/expo-app/sources/components/FeedItemCard.tsx @@ -5,7 +5,7 @@ import { t } from '@/text'; import { useRouter } from 'expo-router'; import { useUser } from '@/sync/storage'; import { Avatar } from './Avatar'; -import { Item } from './Item'; +import { Item } from '@/components/ui/lists/Item'; import { useUnistyles } from 'react-native-unistyles'; interface FeedItemCardProps { @@ -95,4 +95,4 @@ export const FeedItemCard = React.memo(({ item }: FeedItemCardProps) => { default: return null; } -}); \ No newline at end of file +}); diff --git a/expo-app/sources/components/FloatingOverlay.arrow.test.ts b/expo-app/sources/components/FloatingOverlay.arrow.test.ts new file mode 100644 index 000000000..8e15f3991 --- /dev/null +++ b/expo-app/sources/components/FloatingOverlay.arrow.test.ts @@ -0,0 +1,129 @@ +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import renderer, { act } from 'react-test-renderer'; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +function flattenStyle(style: any): Record<string, any> { + if (!style) return {}; + if (Array.isArray(style)) { + return style.reduce((acc, item) => ({ ...acc, ...flattenStyle(item) }), {}); + } + return style; +} + +vi.mock('react-native', () => { + const React = require('react'); + return { + Platform: { OS: 'web' }, + ScrollView: (props: any) => React.createElement('ScrollView', props, props.children), + }; +}); + +vi.mock('react-native-unistyles', () => ({ + useUnistyles: () => ({ + theme: { + colors: { + surface: '#fff', + modal: { border: 'rgba(0,0,0,0.1)' }, + shadow: { color: 'rgba(0,0,0,0.2)', opacity: 0.2 }, + textSecondary: '#666', + }, + }, + }), + StyleSheet: { + create: (factory: any) => { + // FloatingOverlay's stylesheet factory is called with (theme, runtime) + return factory( + { + colors: { + surface: '#fff', + modal: { border: 'rgba(0,0,0,0.1)' }, + shadow: { color: 'rgba(0,0,0,0.2)', opacity: 0.2 }, + textSecondary: '#666', + }, + }, + {}, + ); + }, + }, +})); + +vi.mock('react-native-reanimated', () => { + const React = require('react'); + const AnimatedView = (props: any) => React.createElement('AnimatedView', props, props.children); + const AnimatedScrollView = (props: any) => React.createElement('AnimatedScrollView', props, props.children); + return { + __esModule: true, + default: { + View: AnimatedView, + ScrollView: AnimatedScrollView, + }, + }; +}); + +vi.mock('@/components/ui/scroll/ScrollEdgeFades', () => { + const React = require('react'); + return { ScrollEdgeFades: () => React.createElement('ScrollEdgeFades') }; +}); + +vi.mock('@/components/ui/scroll/ScrollEdgeIndicators', () => { + const React = require('react'); + return { ScrollEdgeIndicators: () => React.createElement('ScrollEdgeIndicators') }; +}); + +vi.mock('@/components/ui/scroll/useScrollEdgeFades', () => ({ + useScrollEdgeFades: () => ({ + visibility: { top: false, bottom: false, left: false, right: false }, + onViewportLayout: () => {}, + onContentSizeChange: () => {}, + onScroll: () => {}, + }), +})); + +describe('FloatingOverlay', () => { + it('renders an arrow when configured', async () => { + const { FloatingOverlay } = await import('./FloatingOverlay'); + + let tree: ReturnType<typeof renderer.create> | undefined; + act(() => { + tree = renderer.create( + React.createElement( + FloatingOverlay, + { + maxHeight: 200, + arrow: { placement: 'bottom' }, + } as any, + React.createElement('Child'), + ), + ); + }); + + const arrows = tree?.root.findAllByProps({ testID: 'floating-overlay-arrow' } as any) ?? []; + // Our Animated shim is a wrapper component returning a host element; filter to host nodes. + const hostArrows = arrows.filter((node: any) => typeof node.type === 'string'); + expect(hostArrows.length).toBe(1); + }); + + it('renders edge indicators when enabled without edge fades', async () => { + const { FloatingOverlay } = await import('./FloatingOverlay'); + + let tree: ReturnType<typeof renderer.create> | undefined; + act(() => { + tree = renderer.create( + React.createElement( + FloatingOverlay, + { + maxHeight: 200, + edgeIndicators: true, + edgeFades: false, + } as any, + React.createElement('Child'), + ), + ); + }); + + const indicators = tree?.root.findAll((node) => (node as any).type === 'ScrollEdgeIndicators') ?? []; + expect(indicators.length).toBe(1); + }); +}); diff --git a/expo-app/sources/components/FloatingOverlay.tsx b/expo-app/sources/components/FloatingOverlay.tsx index f2fb67390..052a11080 100644 --- a/expo-app/sources/components/FloatingOverlay.tsx +++ b/expo-app/sources/components/FloatingOverlay.tsx @@ -1,7 +1,10 @@ import * as React from 'react'; -import { Platform } from 'react-native'; +import { Platform, type StyleProp, type ViewStyle } from 'react-native'; import Animated from 'react-native-reanimated'; -import { StyleSheet } from 'react-native-unistyles'; +import { StyleSheet, useUnistyles } from 'react-native-unistyles'; +import { ScrollEdgeFades } from '@/components/ui/scroll/ScrollEdgeFades'; +import { useScrollEdgeFades } from '@/components/ui/scroll/useScrollEdgeFades'; +import { ScrollEdgeIndicators } from '@/components/ui/scroll/ScrollEdgeIndicators'; const stylesheet = StyleSheet.create((theme, runtime) => ({ container: { @@ -18,31 +21,197 @@ const stylesheet = StyleSheet.create((theme, runtime) => ({ }, })); +export type FloatingOverlayEdgeFades = + | boolean + | Readonly<{ + top?: boolean; + bottom?: boolean; + left?: boolean; + right?: boolean; + /** Gradient size in px (default 18). */ + size?: number; + }>; + +export type FloatingOverlayArrow = + | boolean + | Readonly<{ + /** + * The popover placement relative to its anchor. The arrow is rendered on the opposite + * edge (closest to the anchor), so `placement="bottom"` results in a top arrow. + */ + placement: 'top' | 'bottom' | 'left' | 'right'; + /** Square size in px (default 12). */ + size?: number; + }>; + interface FloatingOverlayProps { children: React.ReactNode; maxHeight?: number; showScrollIndicator?: boolean; keyboardShouldPersistTaps?: boolean | 'always' | 'never' | 'handled'; + edgeFades?: FloatingOverlayEdgeFades; + containerStyle?: StyleProp<ViewStyle>; + scrollViewStyle?: StyleProp<ViewStyle>; + /** + * Optional subtle chevrons (up/down/left/right) that show when more content + * exists beyond the current scroll position. Defaults to false. + */ + edgeIndicators?: boolean | Readonly<{ size?: number; opacity?: number }>; + /** Optional arrow that points back to the anchor (useful for context menus). */ + arrow?: FloatingOverlayArrow; } export const FloatingOverlay = React.memo((props: FloatingOverlayProps) => { const styles = stylesheet; + const { theme } = useUnistyles(); const { children, maxHeight = 240, showScrollIndicator = false, - keyboardShouldPersistTaps = 'handled' + keyboardShouldPersistTaps = 'handled', + edgeFades = false, + edgeIndicators = false, + arrow = false, + containerStyle, + scrollViewStyle, } = props; - return ( - <Animated.View style={[styles.container, { maxHeight }]}> + const fadeCfg = React.useMemo(() => { + if (!edgeFades) return null; + if (edgeFades === true) return { top: true, bottom: true, size: 18 } as const; + return { + top: edgeFades.top ?? false, + bottom: edgeFades.bottom ?? false, + left: edgeFades.left ?? false, + right: edgeFades.right ?? false, + size: typeof edgeFades.size === 'number' ? edgeFades.size : 18, + }; + }, [edgeFades]); + + const indicatorCfg = React.useMemo(() => { + if (!edgeIndicators) return null; + if (edgeIndicators === true) return { size: 14, opacity: 0.35 } as const; + return { + size: typeof edgeIndicators.size === 'number' ? edgeIndicators.size : 14, + opacity: typeof edgeIndicators.opacity === 'number' ? edgeIndicators.opacity : 0.35, + }; + }, [edgeIndicators]); + + const fades = useScrollEdgeFades({ + enabledEdges: { + top: Boolean(fadeCfg?.top) || Boolean(indicatorCfg), + bottom: Boolean(fadeCfg?.bottom) || Boolean(indicatorCfg), + left: Boolean(fadeCfg?.left), + right: Boolean(fadeCfg?.right), + }, + overflowThreshold: 1, + edgeThreshold: 1, + }); + + const arrowCfg = React.useMemo(() => { + if (!arrow) return null; + if (arrow === true) return { placement: 'bottom' as const, size: 12 } as const; + return { + placement: arrow.placement, + size: typeof arrow.size === 'number' ? arrow.size : 12, + }; + }, [arrow]); + + const arrowSide = React.useMemo(() => { + const placement = arrowCfg?.placement; + if (!placement) return null; + switch (placement) { + case 'top': + return 'bottom'; + case 'bottom': + return 'top'; + case 'left': + return 'right'; + case 'right': + return 'left'; + } + }, [arrowCfg?.placement]); + + const overlay = ( + <Animated.View style={[styles.container, { maxHeight }, containerStyle]}> <Animated.ScrollView - style={{ maxHeight }} + style={[{ maxHeight }, scrollViewStyle]} keyboardShouldPersistTaps={keyboardShouldPersistTaps} showsVerticalScrollIndicator={showScrollIndicator} + scrollEventThrottle={32} + onLayout={fadeCfg || indicatorCfg ? fades.onViewportLayout : undefined} + onContentSizeChange={fadeCfg || indicatorCfg ? fades.onContentSizeChange : undefined} + onScroll={fadeCfg || indicatorCfg ? fades.onScroll : undefined} > {children} </Animated.ScrollView> + {fadeCfg ? ( + <ScrollEdgeFades + color={theme.colors.surface} + size={fadeCfg.size} + edges={fades.visibility} + /> + ) : null} + + {indicatorCfg ? ( + <ScrollEdgeIndicators + edges={fades.visibility} + color={theme.colors.textSecondary} + size={indicatorCfg.size} + opacity={indicatorCfg.opacity} + /> + ) : null} + </Animated.View> + ); + + if (!arrowCfg || !arrowSide) return overlay; + + const arrowSize = arrowCfg.size; + const protrusion = arrowSize / 2; + + const arrowStyle = (() => { + const base = { + position: 'absolute' as const, + width: arrowSize, + height: arrowSize, + backgroundColor: theme.colors.surface, + borderWidth: Platform.OS === 'web' ? 0 : 0.5, + borderColor: theme.colors.modal.border, + ...(Platform.OS === 'web' + ? ({ + // RN-web can be inconsistent with shadow props on transformed views. + // Use CSS box-shadow to ensure the arrow is visible, even on light backdrops. + boxShadow: theme.dark + ? '0 4px 14px rgba(0, 0, 0, 0.55)' + : '0 4px 14px rgba(0, 0, 0, 0.24)', + } as any) + : { + shadowColor: theme.colors.shadow.color, + shadowOffset: { width: 0, height: 2 }, + shadowRadius: 3.84, + shadowOpacity: theme.colors.shadow.opacity, + elevation: 5, + }), + transform: [{ rotate: '45deg' as const }], + pointerEvents: 'none' as const, + }; + + switch (arrowSide) { + case 'top': + return [base, { top: -protrusion, left: '50%', marginLeft: -protrusion }] as const; + case 'bottom': + return [base, { bottom: -protrusion, left: '50%', marginLeft: -protrusion }] as const; + case 'left': + return [base, { left: -protrusion, top: '50%', marginTop: -protrusion }] as const; + case 'right': + return [base, { right: -protrusion, top: '50%', marginTop: -protrusion }] as const; + } + })(); + + return ( + <Animated.View style={{ position: 'relative' }}> + <Animated.View testID="floating-overlay-arrow" style={arrowStyle as any} /> + {overlay} </Animated.View> ); -}); \ No newline at end of file +}); diff --git a/expo-app/sources/components/InboxView.tsx b/expo-app/sources/components/InboxView.tsx index ef452593f..b82dcceaf 100644 --- a/expo-app/sources/components/InboxView.tsx +++ b/expo-app/sources/components/InboxView.tsx @@ -5,7 +5,7 @@ import { useAcceptedFriends, useFriendRequests, useRequestedFriends, useFeedItem import { UserCard } from '@/components/UserCard'; import { t } from '@/text'; import { trackFriendsSearch, trackFriendsProfileView } from '@/track'; -import { ItemGroup } from '@/components/ItemGroup'; +import { ItemGroup } from '@/components/ui/lists/ItemGroup'; import { UpdateBanner } from './UpdateBanner'; import { Typography } from '@/constants/Typography'; import { useRouter } from 'expo-router'; diff --git a/expo-app/sources/components/Item.tsx b/expo-app/sources/components/Item.tsx index 379a815d4..5b43bd9b3 100644 --- a/expo-app/sources/components/Item.tsx +++ b/expo-app/sources/components/Item.tsx @@ -1,315 +1 @@ -import * as React from 'react'; -import { - View, - Text, - Pressable, - StyleProp, - ViewStyle, - TextStyle, - Platform, - ActivityIndicator -} from 'react-native'; -import { Ionicons } from '@expo/vector-icons'; -import { Typography } from '@/constants/Typography'; -import * as Clipboard from 'expo-clipboard'; -import { Modal } from '@/modal'; -import { t } from '@/text'; -import { StyleSheet, useUnistyles } from 'react-native-unistyles'; - -export interface ItemProps { - title: string; - subtitle?: string; - subtitleLines?: number; // set 0 or undefined for auto/multiline - detail?: string; - icon?: React.ReactNode; - leftElement?: React.ReactNode; - rightElement?: React.ReactNode; - onPress?: () => void; - onLongPress?: () => void; - disabled?: boolean; - loading?: boolean; - selected?: boolean; - destructive?: boolean; - style?: StyleProp<ViewStyle>; - titleStyle?: StyleProp<TextStyle>; - subtitleStyle?: StyleProp<TextStyle>; - detailStyle?: StyleProp<TextStyle>; - showChevron?: boolean; - showDivider?: boolean; - dividerInset?: number; - pressableStyle?: StyleProp<ViewStyle>; - copy?: boolean | string; -} - -const stylesheet = StyleSheet.create((theme, runtime) => ({ - container: { - flexDirection: 'row', - alignItems: 'center', - paddingHorizontal: 16, - minHeight: Platform.select({ ios: 44, default: 56 }), - }, - containerWithSubtitle: { - paddingVertical: Platform.select({ ios: 11, default: 16 }), - }, - containerWithoutSubtitle: { - paddingVertical: Platform.select({ ios: 12, default: 16 }), - }, - iconContainer: { - marginRight: 12, - width: Platform.select({ ios: 29, default: 32 }), - height: Platform.select({ ios: 29, default: 32 }), - alignItems: 'center', - justifyContent: 'center', - }, - centerContent: { - flex: 1, - justifyContent: 'center', - }, - title: { - ...Typography.default('regular'), - fontSize: Platform.select({ ios: 17, default: 16 }), - lineHeight: Platform.select({ ios: 22, default: 24 }), - letterSpacing: Platform.select({ ios: -0.41, default: 0.15 }), - }, - titleNormal: { - color: theme.colors.text, - }, - titleSelected: { - color: theme.colors.text, - }, - titleDestructive: { - color: theme.colors.textDestructive, - }, - subtitle: { - ...Typography.default('regular'), - color: theme.colors.textSecondary, - fontSize: Platform.select({ ios: 15, default: 14 }), - lineHeight: 20, - letterSpacing: Platform.select({ ios: -0.24, default: 0.1 }), - marginTop: Platform.select({ ios: 2, default: 0 }), - }, - rightSection: { - flexDirection: 'row', - alignItems: 'center', - marginLeft: 8, - }, - detail: { - ...Typography.default('regular'), - color: theme.colors.textSecondary, - fontSize: 17, - letterSpacing: -0.41, - }, - divider: { - height: Platform.select({ ios: 0.33, default: 0 }), - backgroundColor: theme.colors.divider, - }, - pressablePressed: { - backgroundColor: theme.colors.surfacePressedOverlay, - }, -})); - -export const Item = React.memo<ItemProps>((props) => { - const { theme } = useUnistyles(); - const styles = stylesheet; - - // Platform-specific measurements - const isIOS = Platform.OS === 'ios'; - const isAndroid = Platform.OS === 'android'; - const isWeb = Platform.OS === 'web'; - - // Timer ref for long press copy functionality - const longPressTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null); - - const { - title, - subtitle, - subtitleLines, - detail, - icon, - leftElement, - rightElement, - onPress, - onLongPress, - disabled, - loading, - selected, - destructive, - style, - titleStyle, - subtitleStyle, - detailStyle, - showChevron = true, - showDivider = true, - dividerInset = isIOS ? 15 : 16, - pressableStyle, - copy - } = props; - - // Handle copy functionality - const handleCopy = React.useCallback(async () => { - if (!copy || isWeb) return; - - let textToCopy: string; - - if (typeof copy === 'string') { - // If copy is a string, use it directly - textToCopy = copy; - } else { - // If copy is true, try to figure out what to copy - // Priority: detail > subtitle > title - textToCopy = detail || subtitle || title; - } - - try { - await Clipboard.setStringAsync(textToCopy); - Modal.alert(t('common.copied'), t('items.copiedToClipboard', { label: title })); - } catch (error) { - console.error('Failed to copy:', error); - } - }, [copy, isWeb, title, subtitle, detail]); - - // Handle long press for copy functionality - const handlePressIn = React.useCallback(() => { - if (copy && !isWeb && !onPress) { - longPressTimer.current = setTimeout(() => { - handleCopy(); - }, 500); // 500ms delay for long press - } - }, [copy, isWeb, onPress, handleCopy]); - - const handlePressOut = React.useCallback(() => { - if (longPressTimer.current) { - clearTimeout(longPressTimer.current); - longPressTimer.current = null; - } - }, []); - - // Clean up timer on unmount - React.useEffect(() => { - return () => { - if (longPressTimer.current) { - clearTimeout(longPressTimer.current); - } - }; - }, []); - - // If copy is enabled and no onPress is provided, don't set a regular press handler - // The copy will be handled by long press instead - const handlePress = onPress; - - const isInteractive = handlePress || onLongPress || (copy && !isWeb); - const showAccessory = isInteractive && showChevron && !rightElement; - const chevronSize = (isIOS && !isWeb) ? 17 : 24; - - const titleColor = destructive ? styles.titleDestructive : (selected ? styles.titleSelected : styles.titleNormal); - const containerPadding = subtitle ? styles.containerWithSubtitle : styles.containerWithoutSubtitle; - - const content = ( - <> - <View style={[styles.container, containerPadding, style]}> - {/* Left Section */} - {(icon || leftElement) && ( - <View style={styles.iconContainer}> - {leftElement || icon} - </View> - )} - - {/* Center Section */} - <View style={styles.centerContent}> - <Text - style={[styles.title, titleColor, titleStyle]} - numberOfLines={subtitle ? 1 : 2} - > - {title} - </Text> - {subtitle && (() => { - // Allow multiline when requested or when content contains line breaks - const effectiveLines = subtitleLines !== undefined - ? (subtitleLines <= 0 ? undefined : subtitleLines) - : (typeof subtitle === 'string' && subtitle.indexOf('\n') !== -1 ? undefined : 1); - return ( - <Text - style={[styles.subtitle, subtitleStyle]} - numberOfLines={effectiveLines} - > - {subtitle} - </Text> - ); - })()} - </View> - - {/* Right Section */} - <View style={styles.rightSection}> - {detail && !rightElement && ( - <Text - style={[ - styles.detail, - { marginRight: showAccessory ? 6 : 0 }, - detailStyle - ]} - numberOfLines={1} - > - {detail} - </Text> - )} - {loading && ( - <ActivityIndicator - size="small" - color={theme.colors.textSecondary} - style={{ marginRight: showAccessory ? 6 : 0 }} - /> - )} - {rightElement} - {showAccessory && ( - <Ionicons - name="chevron-forward" - size={chevronSize} - color={theme.colors.groupped.chevron} - style={{ marginLeft: 4 }} - /> - )} - </View> - </View> - - {/* Divider */} - {showDivider && ( - <View - style={[ - styles.divider, - { - marginLeft: (isAndroid || isWeb) ? 0 : (dividerInset + (icon || leftElement ? (16 + ((isIOS && !isWeb) ? 29 : 32) + 15) : 16)) - } - ]} - /> - )} - </> - ); - - if (isInteractive) { - return ( - <Pressable - onPress={handlePress} - onLongPress={onLongPress} - onPressIn={handlePressIn} - onPressOut={handlePressOut} - disabled={disabled || loading} - style={({ pressed }) => [ - { - backgroundColor: pressed && isIOS && !isWeb ? theme.colors.surfacePressedOverlay : 'transparent', - opacity: disabled ? 0.5 : 1 - }, - pressableStyle - ]} - android_ripple={(isAndroid || isWeb) ? { - color: theme.colors.surfaceRipple, - borderless: false, - foreground: true - } : undefined} - > - {content} - </Pressable> - ); - } - - return <View style={[{ opacity: disabled ? 0.5 : 1 }, pressableStyle]}>{content}</View>; -}); +export * from '@/components/ui/lists/Item'; diff --git a/expo-app/sources/components/ItemGroup.tsx b/expo-app/sources/components/ItemGroup.tsx index 0e046fb86..364ef418a 100644 --- a/expo-app/sources/components/ItemGroup.tsx +++ b/expo-app/sources/components/ItemGroup.tsx @@ -1,147 +1 @@ -import * as React from 'react'; -import { - View, - Text, - StyleProp, - ViewStyle, - TextStyle, - Platform -} from 'react-native'; -import { Typography } from '@/constants/Typography'; -import { layout } from './layout'; -import { StyleSheet, useUnistyles } from 'react-native-unistyles'; - -interface ItemChildProps { - showDivider?: boolean; - [key: string]: any; -} - -export interface ItemGroupProps { - title?: string | React.ReactNode; - footer?: string; - children: React.ReactNode; - style?: StyleProp<ViewStyle>; - headerStyle?: StyleProp<ViewStyle>; - footerStyle?: StyleProp<ViewStyle>; - titleStyle?: StyleProp<TextStyle>; - footerTextStyle?: StyleProp<TextStyle>; - containerStyle?: StyleProp<ViewStyle>; -} - -const stylesheet = StyleSheet.create((theme, runtime) => ({ - wrapper: { - alignItems: 'center', - }, - container: { - width: '100%', - maxWidth: layout.maxWidth, - paddingHorizontal: Platform.select({ ios: 0, default: 4 }), - }, - header: { - paddingTop: Platform.select({ ios: 35, default: 16 }), - paddingBottom: Platform.select({ ios: 6, default: 8 }), - paddingHorizontal: Platform.select({ ios: 32, default: 24 }), - }, - headerNoTitle: { - paddingTop: Platform.select({ ios: 20, default: 16 }), - }, - headerText: { - ...Typography.default('regular'), - color: theme.colors.groupped.sectionTitle, - fontSize: Platform.select({ ios: 13, default: 14 }), - lineHeight: Platform.select({ ios: 18, default: 20 }), - letterSpacing: Platform.select({ ios: -0.08, default: 0.1 }), - textTransform: 'uppercase', - fontWeight: Platform.select({ ios: 'normal', default: '500' }), - }, - contentContainer: { - backgroundColor: theme.colors.surface, - marginHorizontal: Platform.select({ ios: 16, default: 12 }), - borderRadius: Platform.select({ ios: 10, default: 16 }), - overflow: 'hidden', - shadowColor: theme.colors.shadow.color, - shadowOffset: { width: 0, height: 0.33 }, - shadowOpacity: theme.colors.shadow.opacity, - shadowRadius: 0, - elevation: 1 - }, - footer: { - paddingTop: Platform.select({ ios: 6, default: 8 }), - paddingBottom: Platform.select({ ios: 8, default: 16 }), - paddingHorizontal: Platform.select({ ios: 32, default: 24 }), - }, - footerText: { - ...Typography.default('regular'), - color: theme.colors.groupped.sectionTitle, - fontSize: Platform.select({ ios: 13, default: 14 }), - lineHeight: Platform.select({ ios: 18, default: 20 }), - letterSpacing: Platform.select({ ios: -0.08, default: 0 }), - }, -})); - -export const ItemGroup = React.memo<ItemGroupProps>((props) => { - const { theme } = useUnistyles(); - const styles = stylesheet; - - const { - title, - footer, - children, - style, - headerStyle, - footerStyle, - titleStyle, - footerTextStyle, - containerStyle - } = props; - - return ( - <View style={[styles.wrapper, style]}> - <View style={styles.container}> - {/* Header */} - {title ? ( - <View style={[styles.header, headerStyle]}> - {typeof title === 'string' ? ( - <Text style={[styles.headerText, titleStyle]}> - {title} - </Text> - ) : ( - title - )} - </View> - ) : ( - // Add top margin when there's no title - <View style={styles.headerNoTitle} /> - )} - - {/* Content Container */} - <View style={[styles.contentContainer, containerStyle]}> - {React.Children.map(children, (child, index) => { - if (React.isValidElement<ItemChildProps>(child)) { - // Don't add props to React.Fragment - if (child.type === React.Fragment) { - return child; - } - const isLast = index === React.Children.count(children) - 1; - const childProps = child.props as ItemChildProps; - return React.cloneElement(child, { - ...childProps, - showDivider: !isLast && childProps.showDivider !== false - }); - } - return child; - })} - </View> - - {/* Footer */} - {footer && ( - <View style={[styles.footer, footerStyle]}> - <Text style={[styles.footerText, footerTextStyle]}> - {footer} - </Text> - </View> - )} - </View> - </View> - ); -}); \ No newline at end of file +export * from '@/components/ui/lists/ItemGroup'; diff --git a/expo-app/sources/components/ItemList.tsx b/expo-app/sources/components/ItemList.tsx index fc41b98c3..977e20537 100644 --- a/expo-app/sources/components/ItemList.tsx +++ b/expo-app/sources/components/ItemList.tsx @@ -1,102 +1 @@ -import * as React from 'react'; -import { - ScrollView, - View, - StyleProp, - ViewStyle, - Platform, - ScrollViewProps -} from 'react-native'; -import { StyleSheet, useUnistyles } from 'react-native-unistyles'; - -export interface ItemListProps extends ScrollViewProps { - children: React.ReactNode; - style?: StyleProp<ViewStyle>; - containerStyle?: StyleProp<ViewStyle>; - insetGrouped?: boolean; -} - -const stylesheet = StyleSheet.create((theme, runtime) => ({ - container: { - flex: 1, - backgroundColor: theme.colors.groupped.background, - }, - contentContainer: { - paddingBottom: Platform.select({ ios: 34, default: 16 }), - paddingTop: 0, - }, -})); - -export const ItemList = React.memo<ItemListProps>((props) => { - const { theme } = useUnistyles(); - const styles = stylesheet; - - const { - children, - style, - containerStyle, - insetGrouped = true, - ...scrollViewProps - } = props; - - const isIOS = Platform.OS === 'ios'; - const isWeb = Platform.OS === 'web'; - - // Override background for non-inset grouped lists on iOS - const backgroundColor = (isIOS && !insetGrouped) ? '#FFFFFF' : theme.colors.groupped.background; - - return ( - <ScrollView - style={[ - styles.container, - { backgroundColor }, - style - ]} - contentContainerStyle={[ - styles.contentContainer, - containerStyle - ]} - showsVerticalScrollIndicator={scrollViewProps.showsVerticalScrollIndicator !== undefined - ? scrollViewProps.showsVerticalScrollIndicator - : true} - contentInsetAdjustmentBehavior={(isIOS && !isWeb) ? 'automatic' : undefined} - {...scrollViewProps} - > - {children} - </ScrollView> - ); -}); - -export const ItemListStatic = React.memo<Omit<ItemListProps, keyof ScrollViewProps> & { - children: React.ReactNode; - style?: StyleProp<ViewStyle>; - containerStyle?: StyleProp<ViewStyle>; - insetGrouped?: boolean; -}>((props) => { - const { theme } = useUnistyles(); - - const { - children, - style, - containerStyle, - insetGrouped = true - } = props; - - const isIOS = Platform.OS === 'ios'; - - // Override background for non-inset grouped lists on iOS - const backgroundColor = (isIOS && !insetGrouped) ? '#FFFFFF' : theme.colors.groupped.background; - - return ( - <View - style={[ - { backgroundColor }, - style - ]} - > - <View style={containerStyle}> - {children} - </View> - </View> - ); -}); \ No newline at end of file +export * from '@/components/ui/lists/ItemList'; diff --git a/expo-app/sources/components/MainView.tsx b/expo-app/sources/components/MainView.tsx index bc66dd4f2..6d8b456b4 100644 --- a/expo-app/sources/components/MainView.tsx +++ b/expo-app/sources/components/MainView.tsx @@ -21,6 +21,8 @@ import { Typography } from '@/constants/Typography'; import { t } from '@/text'; import { isUsingCustomServer } from '@/sync/serverConfig'; import { trackFriendsSearch } from '@/track'; +import { ConnectionStatusControl } from '@/components/navigation/ConnectionStatusControl'; +import { useInboxFriendsEnabled } from '@/hooks/useInboxFriendsEnabled'; interface MainViewProps { variant: 'phone' | 'sidebar'; @@ -111,62 +113,13 @@ type ActiveTabType = 'sessions' | 'inbox' | 'settings'; // Header title component with connection status const HeaderTitle = React.memo(({ activeTab }: { activeTab: ActiveTabType }) => { const { theme } = useUnistyles(); - const socketStatus = useSocketStatus(); - - const connectionStatus = React.useMemo(() => { - const { status } = socketStatus; - switch (status) { - case 'connected': - return { - color: theme.colors.status.connected, - isPulsing: false, - text: t('status.connected'), - }; - case 'connecting': - return { - color: theme.colors.status.connecting, - isPulsing: true, - text: t('status.connecting'), - }; - case 'disconnected': - return { - color: theme.colors.status.disconnected, - isPulsing: false, - text: t('status.disconnected'), - }; - case 'error': - return { - color: theme.colors.status.error, - isPulsing: false, - text: t('status.error'), - }; - default: - return { - color: theme.colors.status.default, - isPulsing: false, - text: '', - }; - } - }, [socketStatus, theme]); return ( <View style={styles.titleContainer}> <Text style={styles.titleText}> {t(TAB_TITLES[activeTab])} </Text> - {connectionStatus.text && ( - <View style={styles.statusContainer}> - <StatusDot - color={connectionStatus.color} - isPulsing={connectionStatus.isPulsing} - size={6} - style={{ marginRight: 4 }} - /> - <Text style={[styles.statusText, { color: connectionStatus.color }]}> - {connectionStatus.text} - </Text> - </View> - )} + <ConnectionStatusControl variant="header" /> </View> ); }); @@ -230,11 +183,26 @@ export const MainView = React.memo(({ variant }: MainViewProps) => { const router = useRouter(); const friendRequests = useFriendRequests(); const realtimeStatus = useRealtimeStatus(); + const inboxFriendsEnabled = useInboxFriendsEnabled(); // Tab state management // NOTE: Zen tab removed - the feature never got to a useful state const [activeTab, setActiveTab] = React.useState<TabType>('sessions'); + React.useEffect(() => { + if (inboxFriendsEnabled) return; + if (activeTab !== 'inbox') return; + setActiveTab('sessions'); + }, [activeTab, inboxFriendsEnabled]); + + const headerTab: ActiveTabType = React.useMemo(() => { + const normalized = (activeTab === 'inbox' || activeTab === 'sessions' || activeTab === 'settings') + ? activeTab + : 'sessions'; + if (!inboxFriendsEnabled && normalized === 'inbox') return 'sessions'; + return normalized; + }, [activeTab, inboxFriendsEnabled]); + const handleNewSession = React.useCallback(() => { router.push('/new'); }, [router]); @@ -247,14 +215,14 @@ export const MainView = React.memo(({ variant }: MainViewProps) => { const renderTabContent = React.useCallback(() => { switch (activeTab) { case 'inbox': - return <InboxView />; + return inboxFriendsEnabled ? <InboxView /> : <SessionsListWrapper />; case 'settings': return <SettingsViewWrapper />; case 'sessions': default: return <SessionsListWrapper />; } - }, [activeTab]); + }, [activeTab, inboxFriendsEnabled]); // Sidebar variant if (variant === 'sidebar') { @@ -302,8 +270,8 @@ export const MainView = React.memo(({ variant }: MainViewProps) => { <View style={styles.phoneContainer}> <View style={{ backgroundColor: theme.colors.groupped.background }}> <Header - title={<HeaderTitle activeTab={activeTab as ActiveTabType} />} - headerRight={() => <HeaderRight activeTab={activeTab as ActiveTabType} />} + title={<HeaderTitle activeTab={headerTab} />} + headerRight={() => <HeaderRight activeTab={headerTab} />} headerLeft={() => <HeaderLogo />} headerShadowVisible={false} headerTransparent={true} diff --git a/expo-app/sources/components/MessageView.tsx b/expo-app/sources/components/MessageView.tsx index 9ddabe01c..2a30a576a 100644 --- a/expo-app/sources/components/MessageView.tsx +++ b/expo-app/sources/components/MessageView.tsx @@ -1,16 +1,20 @@ import * as React from "react"; -import { View, Text } from "react-native"; -import { StyleSheet } from 'react-native-unistyles'; +import { View, Text, Pressable } from "react-native"; +import { Ionicons } from '@expo/vector-icons'; +import * as Clipboard from 'expo-clipboard'; +import { Modal } from '@/modal'; +import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { MarkdownView } from "./markdown/MarkdownView"; import { t } from '@/text'; import { Message, UserTextMessage, AgentTextMessage, ToolCallMessage } from "@/sync/typesMessage"; import { Metadata } from "@/sync/storageTypes"; -import { layout } from "./layout"; +import { layout } from "@/components/layout"; import { ToolView } from "./tools/ToolView"; import { AgentEvent } from "@/sync/typesRaw"; import { sync } from '@/sync/sync'; import { Option } from './markdown/MarkdownView'; import { useSetting } from "@/sync/storage"; +import { isCommittedMessageDiscarded } from "@/utils/sessions/discardedCommittedMessages"; export const MessageView = (props: { message: Message; @@ -41,7 +45,7 @@ function RenderBlock(props: { }): React.ReactElement { switch (props.message.kind) { case 'user-text': - return <UserTextBlock message={props.message} sessionId={props.sessionId} />; + return <UserTextBlock message={props.message} metadata={props.metadata} sessionId={props.sessionId} />; case 'agent-text': return <AgentTextBlock message={props.message} sessionId={props.sessionId} />; @@ -67,16 +71,30 @@ function RenderBlock(props: { function UserTextBlock(props: { message: UserTextMessage; + metadata: Metadata | null; sessionId: string; }) { + const isDiscarded = isCommittedMessageDiscarded(props.metadata, props.message.localId); const handleOptionPress = React.useCallback((option: Option) => { - sync.sendMessage(props.sessionId, option.title); + void (async () => { + try { + await sync.submitMessage(props.sessionId, option.title); + } catch (e) { + Modal.alert(t('common.error'), e instanceof Error ? e.message : 'Failed to send message'); + } + })(); }, [props.sessionId]); return ( <View style={styles.userMessageContainer}> - <View style={styles.userMessageBubble}> + <View style={[styles.userMessageBubble, isDiscarded && styles.userMessageBubbleDiscarded]}> <MarkdownView markdown={props.message.displayText || props.message.text} onOptionPress={handleOptionPress} /> + {isDiscarded && ( + <Text style={styles.discardedCommittedMessageLabel}>{t('message.discarded')}</Text> + )} + <View style={styles.messageActionsRow}> + <CopyMessageButton markdown={props.message.displayText || props.message.text} /> + </View> {/* {__DEV__ && ( <Text style={styles.debugText}>{JSON.stringify(props.message.meta)}</Text> )} */} @@ -90,22 +108,92 @@ function AgentTextBlock(props: { sessionId: string; }) { const experiments = useSetting('experiments'); + const expShowThinkingMessages = useSetting('expShowThinkingMessages'); + const showThinkingMessages = experiments && expShowThinkingMessages; const handleOptionPress = React.useCallback((option: Option) => { - sync.sendMessage(props.sessionId, option.title); + void (async () => { + try { + await sync.submitMessage(props.sessionId, option.title); + } catch (e) { + Modal.alert(t('common.error'), e instanceof Error ? e.message : 'Failed to send message'); + } + })(); }, [props.sessionId]); // Hide thinking messages unless experiments is enabled - if (props.message.isThinking && !experiments) { + if (props.message.isThinking && !showThinkingMessages) { return null; } return ( <View style={styles.agentMessageContainer}> <MarkdownView markdown={props.message.text} onOptionPress={handleOptionPress} /> + <View style={styles.messageActionsRow}> + <CopyMessageButton markdown={props.message.text} /> + </View> </View> ); } +function CopyMessageButton(props: { markdown: string }) { + const { theme } = useUnistyles(); + const [copied, setCopied] = React.useState(false); + const resetTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null); + + const markdown = props.markdown || ''; + const isCopyable = markdown.trim().length > 0; + + const handlePress = React.useCallback(async () => { + if (!isCopyable) return; + + try { + await Clipboard.setStringAsync(markdown); + setCopied(true); + + if (resetTimer.current) { + clearTimeout(resetTimer.current); + } + resetTimer.current = setTimeout(() => { + setCopied(false); + }, 1200); + } catch (error) { + console.error('Failed to copy message:', error); + Modal.alert(t('common.error'), t('textSelection.failedToCopy')); + } + }, [isCopyable, markdown]); + + React.useEffect(() => { + return () => { + if (resetTimer.current) { + clearTimeout(resetTimer.current); + } + }; + }, []); + + if (!isCopyable) { + return null; + } + + return ( + <Pressable + onPress={handlePress} + hitSlop={8} + accessibilityRole="button" + accessibilityLabel={t('common.copy')} + style={({ pressed }) => [ + styles.copyMessageButton, + pressed && styles.copyMessageButtonPressed, + ]} + > + <Ionicons + name={copied ? "checkmark-outline" : "copy-outline"} + size={14} + color={copied ? theme.colors.success : theme.colors.textSecondary} + /> + </Pressable> + ); +} + function AgentEventBlock(props: { event: AgentEvent; metadata: Metadata | null; @@ -197,6 +285,14 @@ const styles = StyleSheet.create((theme) => ({ marginBottom: 12, maxWidth: '100%', }, + userMessageBubbleDiscarded: { + opacity: 0.65, + }, + discardedCommittedMessageLabel: { + marginTop: 6, + fontSize: 12, + color: theme.colors.agentEventText, + }, agentMessageContainer: { marginHorizontal: 16, marginBottom: 12, @@ -215,6 +311,20 @@ const styles = StyleSheet.create((theme) => ({ toolContainer: { marginHorizontal: 8, }, + messageActionsRow: { + flexDirection: 'row', + justifyContent: 'flex-end', + marginTop: 6, + }, + copyMessageButton: { + padding: 4, + borderRadius: 8, + opacity: 0.6, + cursor: 'pointer', + }, + copyMessageButtonPressed: { + opacity: 1, + }, debugText: { color: theme.colors.agentEventText, fontSize: 12, diff --git a/expo-app/sources/components/MultiTextInput.tsx b/expo-app/sources/components/MultiTextInput.tsx index f7213fb1e..00355a867 100644 --- a/expo-app/sources/components/MultiTextInput.tsx +++ b/expo-app/sources/components/MultiTextInput.tsx @@ -31,6 +31,8 @@ interface MultiTextInputProps { onChangeText: (text: string) => void; placeholder?: string; maxHeight?: number; + autoFocus?: boolean; + editable?: boolean; paddingTop?: number; paddingBottom?: number; paddingLeft?: number; @@ -205,6 +207,8 @@ export const MultiTextInput = React.forwardRef<MultiTextInputHandle, MultiTextIn keyboardType="default" returnKeyType="default" autoComplete="off" + autoFocus={props.autoFocus} + editable={props.editable} textContentType="none" submitBehavior="newline" /> @@ -212,4 +216,4 @@ export const MultiTextInput = React.forwardRef<MultiTextInputHandle, MultiTextIn ); }); -MultiTextInput.displayName = 'MultiTextInput'; \ No newline at end of file +MultiTextInput.displayName = 'MultiTextInput'; diff --git a/expo-app/sources/components/MultiTextInput.web.tsx b/expo-app/sources/components/MultiTextInput.web.tsx index 0cec9ac15..4bf8b2bb3 100644 --- a/expo-app/sources/components/MultiTextInput.web.tsx +++ b/expo-app/sources/components/MultiTextInput.web.tsx @@ -32,6 +32,7 @@ interface MultiTextInputProps { onChangeText: (text: string) => void; placeholder?: string; maxHeight?: number; + editable?: boolean; paddingTop?: number; paddingBottom?: number; paddingLeft?: number; @@ -196,6 +197,7 @@ export const MultiTextInput = React.forwardRef<MultiTextInputHandle, MultiTextIn onChange={handleChange} onSelect={handleSelect} onKeyDown={handleKeyDown} + readOnly={props.editable === false} maxRows={maxRows} autoCapitalize="sentences" autoCorrect="on" @@ -205,4 +207,4 @@ export const MultiTextInput = React.forwardRef<MultiTextInputHandle, MultiTextIn ); }); -MultiTextInput.displayName = 'MultiTextInput'; \ No newline at end of file +MultiTextInput.displayName = 'MultiTextInput'; diff --git a/expo-app/sources/components/NewSessionWizard.tsx b/expo-app/sources/components/NewSessionWizard.tsx deleted file mode 100644 index ea556c99f..000000000 --- a/expo-app/sources/components/NewSessionWizard.tsx +++ /dev/null @@ -1,1917 +0,0 @@ -import React, { useState, useMemo } from 'react'; -import { View, Text, Pressable, ScrollView, TextInput } from 'react-native'; -import { StyleSheet, useUnistyles } from 'react-native-unistyles'; -import { Typography } from '@/constants/Typography'; -import { t } from '@/text'; -import { Ionicons } from '@expo/vector-icons'; -import { SessionTypeSelector } from '@/components/SessionTypeSelector'; -import { PermissionModeSelector, PermissionMode, ModelMode } from '@/components/PermissionModeSelector'; -import { ItemGroup } from '@/components/ItemGroup'; -import { Item } from '@/components/Item'; -import { useAllMachines, useSessions, useSetting, storage } from '@/sync/storage'; -import { useRouter } from 'expo-router'; -import { AIBackendProfile, validateProfileForAgent, getProfileEnvironmentVariables } from '@/sync/settings'; -import { Modal } from '@/modal'; -import { sync } from '@/sync/sync'; -import { profileSyncService } from '@/sync/profileSync'; - -const stylesheet = StyleSheet.create((theme) => ({ - container: { - flex: 1, - }, - header: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - paddingHorizontal: 24, - paddingVertical: 16, - borderBottomWidth: 1, - borderBottomColor: theme.colors.divider, - }, - headerTitle: { - fontSize: 18, - fontWeight: '600', - color: theme.colors.text, - ...Typography.default('semiBold'), - }, - stepIndicator: { - flexDirection: 'row', - alignItems: 'center', - paddingHorizontal: 24, - paddingVertical: 16, - borderBottomWidth: 1, - borderBottomColor: theme.colors.divider, - }, - stepDot: { - width: 8, - height: 8, - borderRadius: 4, - marginHorizontal: 4, - }, - stepDotActive: { - backgroundColor: theme.colors.button.primary.background, - }, - stepDotInactive: { - backgroundColor: theme.colors.divider, - }, - stepContent: { - flex: 1, - paddingHorizontal: 24, - paddingTop: 24, - paddingBottom: 0, // No bottom padding since footer is separate - }, - stepTitle: { - fontSize: 20, - fontWeight: '600', - color: theme.colors.text, - marginBottom: 8, - ...Typography.default('semiBold'), - }, - stepDescription: { - fontSize: 16, - color: theme.colors.textSecondary, - marginBottom: 24, - ...Typography.default(), - }, - footer: { - flexDirection: 'row', - justifyContent: 'space-between', - paddingHorizontal: 24, - paddingVertical: 16, - borderTopWidth: 1, - borderTopColor: theme.colors.divider, - backgroundColor: theme.colors.surface, // Ensure footer has solid background - }, - button: { - paddingHorizontal: 16, - paddingVertical: 12, - borderRadius: 8, - minWidth: 100, - alignItems: 'center', - justifyContent: 'center', - }, - buttonPrimary: { - backgroundColor: theme.colors.button.primary.background, - }, - buttonSecondary: { - backgroundColor: 'transparent', - borderWidth: 1, - borderColor: theme.colors.divider, - }, - buttonText: { - fontSize: 16, - fontWeight: '600', - ...Typography.default('semiBold'), - }, - buttonTextPrimary: { - color: '#FFFFFF', - }, - buttonTextSecondary: { - color: theme.colors.text, - }, - textInput: { - backgroundColor: theme.colors.input.background, - borderRadius: 8, - paddingHorizontal: 12, - paddingVertical: 10, - fontSize: 16, - color: theme.colors.text, - borderWidth: 1, - borderColor: theme.colors.divider, - ...Typography.default(), - }, - agentOption: { - flexDirection: 'row', - alignItems: 'center', - padding: 16, - borderRadius: 12, - borderWidth: 2, - marginBottom: 12, - }, - agentOptionSelected: { - borderColor: theme.colors.button.primary.background, - backgroundColor: theme.colors.input.background, - }, - agentOptionUnselected: { - borderColor: theme.colors.divider, - backgroundColor: theme.colors.input.background, - }, - agentIcon: { - width: 40, - height: 40, - borderRadius: 20, - backgroundColor: theme.colors.button.primary.background, - alignItems: 'center', - justifyContent: 'center', - marginRight: 16, - }, - agentInfo: { - flex: 1, - }, - agentName: { - fontSize: 16, - fontWeight: '600', - color: theme.colors.text, - ...Typography.default('semiBold'), - }, - agentDescription: { - fontSize: 14, - color: theme.colors.textSecondary, - marginTop: 4, - ...Typography.default(), - }, -})); - -type WizardStep = 'profile' | 'profileConfig' | 'sessionType' | 'agent' | 'options' | 'machine' | 'path' | 'prompt'; - -// Profile selection item component with management actions -interface ProfileSelectionItemProps { - profile: AIBackendProfile; - isSelected: boolean; - onSelect: () => void; - onUseAsIs: () => void; - onEdit: () => void; - onDuplicate?: () => void; - onDelete?: () => void; - showManagementActions?: boolean; -} - -function ProfileSelectionItem({ profile, isSelected, onSelect, onUseAsIs, onEdit, onDuplicate, onDelete, showManagementActions = false }: ProfileSelectionItemProps) { - const { theme } = useUnistyles(); - const styles = stylesheet; - - return ( - <View style={{ - backgroundColor: isSelected ? theme.colors.input.background : 'transparent', - borderRadius: 12, - borderWidth: isSelected ? 2 : 1, - borderColor: isSelected ? theme.colors.button.primary.background : theme.colors.divider, - marginBottom: 12, - padding: 4, - }}> - {/* Profile Header */} - <Pressable onPress={onSelect} style={{ padding: 12 }}> - <View style={{ flexDirection: 'row', alignItems: 'center' }}> - <View style={{ - width: 40, - height: 40, - borderRadius: 20, - backgroundColor: theme.colors.button.primary.background, - alignItems: 'center', - justifyContent: 'center', - marginRight: 12, - }}> - <Ionicons - name="person-outline" - size={20} - color="white" - /> - </View> - <View style={{ flex: 1 }}> - <Text style={{ - fontSize: 16, - fontWeight: '600', - color: theme.colors.text, - marginBottom: 4, - ...Typography.default('semiBold'), - }}> - {profile.name} - </Text> - <Text style={{ - fontSize: 14, - color: theme.colors.textSecondary, - ...Typography.default(), - }}> - {profile.description} - </Text> - {profile.isBuiltIn && ( - <Text style={{ - fontSize: 12, - color: theme.colors.textSecondary, - marginTop: 2, - }}> - Built-in profile - </Text> - )} - </View> - {isSelected && ( - <Ionicons - name="checkmark-circle" - size={20} - color={theme.colors.button.primary.background} - /> - )} - </View> - </Pressable> - - {/* Action Buttons - Only show when selected */} - {isSelected && ( - <View style={{ - flexDirection: 'column', - paddingHorizontal: 12, - paddingBottom: 12, - gap: 8, - }}> - {/* Primary Actions */} - <View style={{ - flexDirection: 'row', - gap: 8, - }}> - <Pressable - style={{ - flex: 1, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - paddingVertical: 8, - paddingHorizontal: 12, - borderRadius: 8, - backgroundColor: theme.colors.button.primary.background, - }} - onPress={onUseAsIs} - > - <Ionicons name="checkmark" size={16} color="white" /> - <Text style={{ - color: 'white', - fontSize: 14, - fontWeight: '600', - marginLeft: 6, - ...Typography.default('semiBold'), - }}> - Use As-Is - </Text> - </Pressable> - - <Pressable - style={{ - flex: 1, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - paddingVertical: 8, - paddingHorizontal: 12, - borderRadius: 8, - backgroundColor: 'transparent', - borderWidth: 1, - borderColor: theme.colors.divider, - }} - onPress={onEdit} - > - <Ionicons name="create-outline" size={16} color={theme.colors.text} /> - <Text style={{ - color: theme.colors.text, - fontSize: 14, - fontWeight: '600', - marginLeft: 6, - ...Typography.default('semiBold'), - }}> - Edit - </Text> - </Pressable> - </View> - - {/* Management Actions - Only show for custom profiles */} - {showManagementActions && !profile.isBuiltIn && ( - <View style={{ - flexDirection: 'row', - gap: 8, - }}> - <Pressable - style={{ - flex: 1, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - paddingVertical: 6, - paddingHorizontal: 8, - borderRadius: 6, - backgroundColor: 'transparent', - borderWidth: 1, - borderColor: theme.colors.divider, - }} - onPress={onDuplicate} - > - <Ionicons name="copy-outline" size={14} color={theme.colors.textSecondary} /> - <Text style={{ - color: theme.colors.textSecondary, - fontSize: 12, - fontWeight: '600', - marginLeft: 4, - ...Typography.default('semiBold'), - }}> - Duplicate - </Text> - </Pressable> - - <Pressable - style={{ - flex: 1, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - paddingVertical: 6, - paddingHorizontal: 8, - borderRadius: 6, - backgroundColor: 'transparent', - borderWidth: 1, - borderColor: theme.colors.textDestructive, - }} - onPress={onDelete} - > - <Ionicons name="trash-outline" size={14} color={theme.colors.textDestructive} /> - <Text style={{ - color: theme.colors.textDestructive, - fontSize: 12, - fontWeight: '600', - marginLeft: 4, - ...Typography.default('semiBold'), - }}> - Delete - </Text> - </Pressable> - </View> - )} - </View> - )} - </View> - ); -} - -// Manual configuration item component -interface ManualConfigurationItemProps { - isSelected: boolean; - onSelect: () => void; - onUseCliVars: () => void; - onConfigureManually: () => void; -} - -function ManualConfigurationItem({ isSelected, onSelect, onUseCliVars, onConfigureManually }: ManualConfigurationItemProps) { - const { theme } = useUnistyles(); - const styles = stylesheet; - - return ( - <View style={{ - backgroundColor: isSelected ? theme.colors.input.background : 'transparent', - borderRadius: 12, - borderWidth: isSelected ? 2 : 1, - borderColor: isSelected ? theme.colors.button.primary.background : theme.colors.divider, - marginBottom: 12, - padding: 4, - }}> - {/* Profile Header */} - <Pressable onPress={onSelect} style={{ padding: 12 }}> - <View style={{ flexDirection: 'row', alignItems: 'center' }}> - <View style={{ - width: 40, - height: 40, - borderRadius: 20, - backgroundColor: theme.colors.textSecondary, - alignItems: 'center', - justifyContent: 'center', - marginRight: 12, - }}> - <Ionicons - name="settings" - size={20} - color="white" - /> - </View> - <View style={{ flex: 1 }}> - <Text style={{ - fontSize: 16, - fontWeight: '600', - color: theme.colors.text, - marginBottom: 4, - ...Typography.default('semiBold'), - }}> - Manual Configuration - </Text> - <Text style={{ - fontSize: 14, - color: theme.colors.textSecondary, - ...Typography.default(), - }}> - Use CLI environment variables or configure manually - </Text> - </View> - {isSelected && ( - <Ionicons - name="checkmark-circle" - size={20} - color={theme.colors.button.primary.background} - /> - )} - </View> - </Pressable> - - {/* Action Buttons - Only show when selected */} - {isSelected && ( - <View style={{ - flexDirection: 'row', - paddingHorizontal: 12, - paddingBottom: 12, - gap: 8, - }}> - <Pressable - style={{ - flex: 1, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - paddingVertical: 8, - paddingHorizontal: 12, - borderRadius: 8, - backgroundColor: theme.colors.button.primary.background, - }} - onPress={onUseCliVars} - > - <Ionicons name="terminal-outline" size={16} color="white" /> - <Text style={{ - color: 'white', - fontSize: 14, - fontWeight: '600', - marginLeft: 6, - ...Typography.default('semiBold'), - }}> - Use CLI Vars - </Text> - </Pressable> - - <Pressable - style={{ - flex: 1, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - paddingVertical: 8, - paddingHorizontal: 12, - borderRadius: 8, - backgroundColor: 'transparent', - borderWidth: 1, - borderColor: theme.colors.divider, - }} - onPress={onConfigureManually} - > - <Ionicons name="create-outline" size={16} color={theme.colors.text} /> - <Text style={{ - color: theme.colors.text, - fontSize: 14, - fontWeight: '600', - marginLeft: 6, - ...Typography.default('semiBold'), - }}> - Configure - </Text> - </Pressable> - </View> - )} - </View> - ); -} - -interface NewSessionWizardProps { - onComplete: (config: { - sessionType: 'simple' | 'worktree'; - profileId: string | null; - agentType: 'claude' | 'codex'; - permissionMode: PermissionMode; - modelMode: ModelMode; - machineId: string; - path: string; - prompt: string; - environmentVariables?: Record<string, string>; - }) => void; - onCancel: () => void; - initialPrompt?: string; -} - -export function NewSessionWizard({ onComplete, onCancel, initialPrompt = '' }: NewSessionWizardProps) { - const { theme } = useUnistyles(); - const styles = stylesheet; - const router = useRouter(); - const machines = useAllMachines(); - const sessions = useSessions(); - const experimentsEnabled = useSetting('experiments'); - const recentMachinePaths = useSetting('recentMachinePaths'); - const lastUsedAgent = useSetting('lastUsedAgent'); - const lastUsedPermissionMode = useSetting('lastUsedPermissionMode'); - const lastUsedModelMode = useSetting('lastUsedModelMode'); - const profiles = useSetting('profiles'); - const lastUsedProfile = useSetting('lastUsedProfile'); - - // Wizard state - const [currentStep, setCurrentStep] = useState<WizardStep>('profile'); - const [sessionType, setSessionType] = useState<'simple' | 'worktree'>('simple'); - const [agentType, setAgentType] = useState<'claude' | 'codex'>(() => { - if (lastUsedAgent === 'claude' || lastUsedAgent === 'codex') { - return lastUsedAgent; - } - return 'claude'; - }); - const [permissionMode, setPermissionMode] = useState<PermissionMode>('default'); - const [modelMode, setModelMode] = useState<ModelMode>('default'); - const [selectedProfileId, setSelectedProfileId] = useState<string | null>(() => { - return lastUsedProfile; - }); - - // Built-in profiles - const builtInProfiles: AIBackendProfile[] = useMemo(() => [ - { - id: 'anthropic', - name: 'Anthropic (Default)', - description: 'Default Claude configuration', - anthropicConfig: {}, - environmentVariables: [], - compatibility: { claude: true, codex: false, gemini: false }, - isBuiltIn: true, - createdAt: Date.now(), - updatedAt: Date.now(), - version: '1.0.0', - }, - { - id: 'deepseek', - name: 'DeepSeek (Reasoner)', - description: 'DeepSeek reasoning model with proxy to Anthropic API', - anthropicConfig: { - baseUrl: 'https://api.deepseek.com/anthropic', - model: 'deepseek-reasoner', - }, - environmentVariables: [ - { name: 'API_TIMEOUT_MS', value: '600000' }, - { name: 'ANTHROPIC_SMALL_FAST_MODEL', value: 'deepseek-chat' }, - { name: 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC', value: '1' }, - ], - compatibility: { claude: true, codex: false, gemini: false }, - isBuiltIn: true, - createdAt: Date.now(), - updatedAt: Date.now(), - version: '1.0.0', - }, - { - id: 'openai', - name: 'OpenAI (GPT-4/Codex)', - description: 'OpenAI GPT-4 and Codex models', - openaiConfig: { - baseUrl: 'https://api.openai.com/v1', - model: 'gpt-4-turbo', - }, - environmentVariables: [], - compatibility: { claude: false, codex: true, gemini: false }, - isBuiltIn: true, - createdAt: Date.now(), - updatedAt: Date.now(), - version: '1.0.0', - }, - { - id: 'azure-openai-codex', - name: 'Azure OpenAI (Codex)', - description: 'Microsoft Azure OpenAI for Codex agents', - azureOpenAIConfig: { - endpoint: 'https://your-resource.openai.azure.com/', - apiVersion: '2024-02-15-preview', - deploymentName: 'gpt-4-turbo', - }, - environmentVariables: [], - compatibility: { claude: false, codex: true, gemini: false }, - isBuiltIn: true, - createdAt: Date.now(), - updatedAt: Date.now(), - version: '1.0.0', - }, - { - id: 'azure-openai', - name: 'Azure OpenAI', - description: 'Microsoft Azure OpenAI configuration', - azureOpenAIConfig: { - apiVersion: '2024-02-15-preview', - }, - environmentVariables: [ - { name: 'AZURE_OPENAI_API_VERSION', value: '2024-02-15-preview' }, - ], - compatibility: { claude: false, codex: true, gemini: false }, - isBuiltIn: true, - createdAt: Date.now(), - updatedAt: Date.now(), - version: '1.0.0', - }, - { - id: 'zai', - name: 'Z.ai (GLM-4.6)', - description: 'Z.ai GLM-4.6 model with proxy to Anthropic API', - anthropicConfig: { - baseUrl: 'https://api.z.ai/api/anthropic', - model: 'glm-4.6', - }, - environmentVariables: [], - compatibility: { claude: true, codex: false, gemini: false }, - isBuiltIn: true, - createdAt: Date.now(), - updatedAt: Date.now(), - version: '1.0.0', - }, - { - id: 'microsoft', - name: 'Microsoft Azure', - description: 'Microsoft Azure AI services', - openaiConfig: { - baseUrl: 'https://api.openai.azure.com', - model: 'gpt-4-turbo', - }, - environmentVariables: [], - compatibility: { claude: false, codex: true, gemini: false }, - isBuiltIn: true, - createdAt: Date.now(), - updatedAt: Date.now(), - version: '1.0.0', - }, - ], []); - - // Combined profiles - const allProfiles = useMemo(() => { - return [...builtInProfiles, ...profiles]; - }, [profiles, builtInProfiles]); - - const [selectedMachineId, setSelectedMachineId] = useState<string>(() => { - if (machines.length > 0) { - // Check if we have a recently used machine that's currently available - if (recentMachinePaths.length > 0) { - for (const recent of recentMachinePaths) { - if (machines.find(m => m.id === recent.machineId)) { - return recent.machineId; - } - } - } - return machines[0].id; - } - return ''; - }); - const [selectedPath, setSelectedPath] = useState<string>(() => { - if (machines.length > 0 && selectedMachineId) { - const machine = machines.find(m => m.id === selectedMachineId); - return machine?.metadata?.homeDir || '/home'; - } - return '/home'; - }); - const [prompt, setPrompt] = useState<string>(initialPrompt); - const [customPath, setCustomPath] = useState<string>(''); - const [showCustomPathInput, setShowCustomPathInput] = useState<boolean>(false); - - // Profile configuration state - const [profileApiKeys, setProfileApiKeys] = useState<Record<string, Record<string, string>>>({}); - const [profileConfigs, setProfileConfigs] = useState<Record<string, Record<string, string>>>({}); - - // Dynamic steps based on whether profile needs configuration - const steps: WizardStep[] = React.useMemo(() => { - const baseSteps: WizardStep[] = experimentsEnabled - ? ['profile', 'sessionType', 'agent', 'options', 'machine', 'path', 'prompt'] - : ['profile', 'agent', 'options', 'machine', 'path', 'prompt']; - - // Insert profileConfig step after profile if needed - if (profileNeedsConfiguration(selectedProfileId)) { - const profileIndex = baseSteps.indexOf('profile'); - const beforeProfile = baseSteps.slice(0, profileIndex + 1) as WizardStep[]; - const afterProfile = baseSteps.slice(profileIndex + 1) as WizardStep[]; - return [ - ...beforeProfile, - 'profileConfig', - ...afterProfile - ] as WizardStep[]; - } - - return baseSteps; - }, [experimentsEnabled, selectedProfileId]); - - // Helper function to check if profile needs API keys - const profileNeedsConfiguration = (profileId: string | null): boolean => { - if (!profileId) return false; // Manual configuration doesn't need API keys - const profile = allProfiles.find(p => p.id === profileId); - if (!profile) return false; - - // Check if profile is one that requires API keys - const profilesNeedingKeys = ['openai', 'azure-openai', 'azure-openai-codex', 'zai', 'microsoft', 'deepseek']; - return profilesNeedingKeys.includes(profile.id); - }; - - // Get required fields for profile configuration - const getProfileRequiredFields = (profileId: string | null): Array<{key: string, label: string, placeholder: string, isPassword?: boolean}> => { - if (!profileId) return []; - const profile = allProfiles.find(p => p.id === profileId); - if (!profile) return []; - - switch (profile.id) { - case 'deepseek': - return [ - { key: 'ANTHROPIC_AUTH_TOKEN', label: 'DeepSeek API Key', placeholder: 'DEEPSEEK_API_KEY', isPassword: true } - ]; - case 'openai': - return [ - { key: 'OPENAI_API_KEY', label: 'OpenAI API Key', placeholder: 'sk-...', isPassword: true } - ]; - case 'azure-openai': - return [ - { key: 'AZURE_OPENAI_API_KEY', label: 'Azure OpenAI API Key', placeholder: 'Enter your Azure OpenAI API key', isPassword: true }, - { key: 'AZURE_OPENAI_ENDPOINT', label: 'Azure Endpoint', placeholder: 'https://your-resource.openai.azure.com/' }, - { key: 'AZURE_OPENAI_DEPLOYMENT_NAME', label: 'Deployment Name', placeholder: 'gpt-4-turbo' } - ]; - case 'zai': - return [ - { key: 'ANTHROPIC_AUTH_TOKEN', label: 'Z.ai API Key', placeholder: 'Z_AI_API_KEY', isPassword: true } - ]; - case 'microsoft': - return [ - { key: 'AZURE_OPENAI_API_KEY', label: 'Azure API Key', placeholder: 'Enter your Azure API key', isPassword: true }, - { key: 'AZURE_OPENAI_ENDPOINT', label: 'Azure Endpoint', placeholder: 'https://your-resource.openai.azure.com/' }, - { key: 'AZURE_OPENAI_DEPLOYMENT_NAME', label: 'Deployment Name', placeholder: 'gpt-4-turbo' } - ]; - case 'azure-openai-codex': - return [ - { key: 'AZURE_OPENAI_API_KEY', label: 'Azure OpenAI API Key', placeholder: 'Enter your Azure OpenAI API key', isPassword: true }, - { key: 'AZURE_OPENAI_ENDPOINT', label: 'Azure Endpoint', placeholder: 'https://your-resource.openai.azure.com/' }, - { key: 'AZURE_OPENAI_DEPLOYMENT_NAME', label: 'Deployment Name', placeholder: 'gpt-4-turbo' } - ]; - default: - return []; - } - }; - - // Auto-load profile settings and sync with CLI - React.useEffect(() => { - if (selectedProfileId) { - const selectedProfile = allProfiles.find(p => p.id === selectedProfileId); - if (selectedProfile) { - // Auto-select agent type based on profile compatibility - if (selectedProfile.compatibility.claude && !selectedProfile.compatibility.codex) { - setAgentType('claude'); - } else if (selectedProfile.compatibility.codex && !selectedProfile.compatibility.claude) { - setAgentType('codex'); - } - - // Sync active profile to CLI - profileSyncService.setActiveProfile(selectedProfileId).catch(error => { - console.error('[Wizard] Failed to sync active profile to CLI:', error); - }); - } - } - }, [selectedProfileId, allProfiles]); - - // Sync profiles with CLI on component mount and when profiles change - React.useEffect(() => { - const syncProfiles = async () => { - try { - await profileSyncService.bidirectionalSync(allProfiles); - } catch (error) { - console.error('[Wizard] Failed to sync profiles with CLI:', error); - // Continue without sync - profiles work locally - } - }; - - // Sync on mount - syncProfiles(); - - // Set up sync listener for profile changes - const handleSyncEvent = (event: any) => { - if (event.status === 'error') { - console.warn('[Wizard] Profile sync error:', event.error); - } - }; - - profileSyncService.addEventListener(handleSyncEvent); - - return () => { - profileSyncService.removeEventListener(handleSyncEvent); - }; - }, [allProfiles]); - - // Get recent paths for the selected machine - const recentPaths = useMemo(() => { - if (!selectedMachineId) return []; - - const paths: string[] = []; - const pathSet = new Set<string>(); - - // First, add paths from recentMachinePaths (these are the most recent) - recentMachinePaths.forEach(entry => { - if (entry.machineId === selectedMachineId && !pathSet.has(entry.path)) { - paths.push(entry.path); - pathSet.add(entry.path); - } - }); - - // Then add paths from sessions if we need more - if (sessions) { - const pathsWithTimestamps: Array<{ path: string; timestamp: number }> = []; - - sessions.forEach(item => { - if (typeof item === 'string') return; // Skip section headers - - const session = item as any; - if (session.metadata?.machineId === selectedMachineId && session.metadata?.path) { - const path = session.metadata.path; - if (!pathSet.has(path)) { - pathSet.add(path); - pathsWithTimestamps.push({ - path, - timestamp: session.updatedAt || session.createdAt - }); - } - } - }); - - // Sort session paths by most recent first and add them - pathsWithTimestamps - .sort((a, b) => b.timestamp - a.timestamp) - .forEach(item => paths.push(item.path)); - } - - return paths; - }, [sessions, selectedMachineId, recentMachinePaths]); - - const currentStepIndex = steps.indexOf(currentStep); - const isFirstStep = currentStepIndex === 0; - const isLastStep = currentStepIndex === steps.length - 1; - - // Handler for "Use Profile As-Is" - quick session creation - const handleUseProfileAsIs = (profile: AIBackendProfile) => { - setSelectedProfileId(profile.id); - - // Auto-select agent type based on profile compatibility - if (profile.compatibility.claude && !profile.compatibility.codex) { - setAgentType('claude'); - } else if (profile.compatibility.codex && !profile.compatibility.claude) { - setAgentType('codex'); - } - - // Get environment variables from profile (no user configuration) - const environmentVariables = getProfileEnvironmentVariables(profile); - - // Complete wizard immediately with profile settings - onComplete({ - sessionType, - profileId: profile.id, - agentType: agentType || (profile.compatibility.claude ? 'claude' : 'codex'), - permissionMode, - modelMode, - machineId: selectedMachineId, - path: showCustomPathInput && customPath.trim() ? customPath.trim() : selectedPath, - prompt, - environmentVariables, - }); - }; - - // Handler for "Edit Profile" - load profile and go to configuration step - const handleEditProfile = (profile: AIBackendProfile) => { - setSelectedProfileId(profile.id); - - // Auto-select agent type based on profile compatibility - if (profile.compatibility.claude && !profile.compatibility.codex) { - setAgentType('claude'); - } else if (profile.compatibility.codex && !profile.compatibility.claude) { - setAgentType('codex'); - } - - // If profile needs configuration, go to profileConfig step - if (profileNeedsConfiguration(profile.id)) { - setCurrentStep('profileConfig'); - } else { - // If no configuration needed, proceed to next step in the normal flow - const profileIndex = steps.indexOf('profile'); - setCurrentStep(steps[profileIndex + 1]); - } - }; - - // Handler for "Create New Profile" - const handleCreateProfile = () => { - Modal.prompt( - 'Create New Profile', - 'Enter a name for your new profile:', - { - defaultValue: 'My Custom Profile', - confirmText: 'Create', - cancelText: 'Cancel' - } - ).then((profileName) => { - if (profileName && profileName.trim()) { - const newProfile: AIBackendProfile = { - id: crypto.randomUUID(), - name: profileName.trim(), - description: 'Custom AI profile', - anthropicConfig: {}, - environmentVariables: [], - compatibility: { claude: true, codex: true, gemini: true }, - isBuiltIn: false, - createdAt: Date.now(), - updatedAt: Date.now(), - version: '1.0.0', - }; - - // Get current profiles from settings - const currentProfiles = storage.getState().settings.profiles || []; - const updatedProfiles = [...currentProfiles, newProfile]; - - // Persist through settings system - sync.applySettings({ profiles: updatedProfiles }); - - // Sync with CLI - profileSyncService.syncGuiToCli(updatedProfiles).catch(error => { - console.error('[Wizard] Failed to sync new profile with CLI:', error); - }); - - // Auto-select the newly created profile - setSelectedProfileId(newProfile.id); - } - }); - }; - - // Handler for "Duplicate Profile" - const handleDuplicateProfile = (profile: AIBackendProfile) => { - Modal.prompt( - 'Duplicate Profile', - `Enter a name for the duplicate of "${profile.name}":`, - { - defaultValue: `${profile.name} (Copy)`, - confirmText: 'Duplicate', - cancelText: 'Cancel' - } - ).then((newName) => { - if (newName && newName.trim()) { - const duplicatedProfile: AIBackendProfile = { - ...profile, - id: crypto.randomUUID(), - name: newName.trim(), - description: profile.description ? `Copy of ${profile.description}` : 'Custom AI profile', - isBuiltIn: false, - createdAt: Date.now(), - updatedAt: Date.now(), - }; - - // Get current profiles from settings - const currentProfiles = storage.getState().settings.profiles || []; - const updatedProfiles = [...currentProfiles, duplicatedProfile]; - - // Persist through settings system - sync.applySettings({ profiles: updatedProfiles }); - - // Sync with CLI - profileSyncService.syncGuiToCli(updatedProfiles).catch(error => { - console.error('[Wizard] Failed to sync duplicated profile with CLI:', error); - }); - } - }); - }; - - // Handler for "Delete Profile" - const handleDeleteProfile = (profile: AIBackendProfile) => { - Modal.confirm( - 'Delete Profile', - `Are you sure you want to delete "${profile.name}"? This action cannot be undone.`, - { - confirmText: 'Delete', - destructive: true - } - ).then((confirmed) => { - if (confirmed) { - // Get current profiles from settings - const currentProfiles = storage.getState().settings.profiles || []; - const updatedProfiles = currentProfiles.filter(p => p.id !== profile.id); - - // Persist through settings system - sync.applySettings({ profiles: updatedProfiles }); - - // Sync with CLI - profileSyncService.syncGuiToCli(updatedProfiles).catch(error => { - console.error('[Wizard] Failed to sync profile deletion with CLI:', error); - }); - - // Clear selection if deleted profile was selected - if (selectedProfileId === profile.id) { - setSelectedProfileId(null); - } - } - }); - }; - - // Handler for "Use CLI Environment Variables" - quick session creation with CLI vars - const handleUseCliEnvironmentVariables = () => { - setSelectedProfileId(null); - - // Complete wizard immediately with no profile (rely on CLI environment variables) - onComplete({ - sessionType, - profileId: null, - agentType, - permissionMode, - modelMode, - machineId: selectedMachineId, - path: showCustomPathInput && customPath.trim() ? customPath.trim() : selectedPath, - prompt, - environmentVariables: undefined, // Let CLI handle environment variables - }); - }; - - // Handler for "Manual Configuration" - go through normal wizard flow - const handleManualConfiguration = () => { - setSelectedProfileId(null); - - // Proceed to next step in normal wizard flow - const profileIndex = steps.indexOf('profile'); - setCurrentStep(steps[profileIndex + 1]); - }; - - const handleNext = () => { - // Special handling for profileConfig step - skip if profile doesn't need configuration - if (currentStep === 'profileConfig' && (!selectedProfileId || !profileNeedsConfiguration(selectedProfileId))) { - setCurrentStep(steps[currentStepIndex + 1]); - return; - } - - if (isLastStep) { - // Get environment variables from selected profile with proper precedence handling - let environmentVariables: Record<string, string> | undefined; - if (selectedProfileId) { - const selectedProfile = allProfiles.find(p => p.id === selectedProfileId); - if (selectedProfile) { - // Start with profile environment variables (base configuration) - environmentVariables = getProfileEnvironmentVariables(selectedProfile); - - // Only add user-provided API keys if they're non-empty - // This preserves CLI environment variable precedence when wizard fields are empty - const userApiKeys = profileApiKeys[selectedProfileId]; - if (userApiKeys) { - Object.entries(userApiKeys).forEach(([key, value]) => { - // Only override if user provided a non-empty value - if (value && value.trim().length > 0) { - environmentVariables![key] = value; - } - }); - } - - // Only add user configurations if they're non-empty - const userConfigs = profileConfigs[selectedProfileId]; - if (userConfigs) { - Object.entries(userConfigs).forEach(([key, value]) => { - // Only override if user provided a non-empty value - if (value && value.trim().length > 0) { - environmentVariables![key] = value; - } - }); - } - } - } - - onComplete({ - sessionType, - profileId: selectedProfileId, - agentType, - permissionMode, - modelMode, - machineId: selectedMachineId, - path: showCustomPathInput && customPath.trim() ? customPath.trim() : selectedPath, - prompt, - environmentVariables, - }); - } else { - setCurrentStep(steps[currentStepIndex + 1]); - } - }; - - const handleBack = () => { - if (isFirstStep) { - onCancel(); - } else { - setCurrentStep(steps[currentStepIndex - 1]); - } - }; - - const canProceed = useMemo(() => { - switch (currentStep) { - case 'profile': - return true; // Always valid (profile can be null for manual config) - case 'profileConfig': - if (!selectedProfileId) return false; - const requiredFields = getProfileRequiredFields(selectedProfileId); - // Profile configuration step is always shown when needed - // Users can leave fields empty to preserve CLI environment variables - return true; - case 'sessionType': - return true; // Always valid - case 'agent': - return true; // Always valid - case 'options': - return true; // Always valid - case 'machine': - return selectedMachineId.length > 0; - case 'path': - return (selectedPath.trim().length > 0) || (showCustomPathInput && customPath.trim().length > 0); - case 'prompt': - return prompt.trim().length > 0; - default: - return false; - } - }, [currentStep, selectedMachineId, selectedPath, prompt, showCustomPathInput, customPath, selectedProfileId, profileApiKeys, profileConfigs, getProfileRequiredFields]); - - const renderStepContent = () => { - switch (currentStep) { - case 'profile': - return ( - <View> - <Text style={styles.stepTitle}>Choose AI Profile</Text> - <Text style={styles.stepDescription}> - Select a pre-configured AI profile or set up manually - </Text> - - <ItemGroup title="Built-in Profiles"> - {builtInProfiles.map((profile) => ( - <ProfileSelectionItem - key={profile.id} - profile={profile} - isSelected={selectedProfileId === profile.id} - onSelect={() => setSelectedProfileId(profile.id)} - onUseAsIs={() => handleUseProfileAsIs(profile)} - onEdit={() => handleEditProfile(profile)} - /> - ))} - </ItemGroup> - - {profiles.length > 0 && ( - <ItemGroup title="Custom Profiles"> - {profiles.map((profile) => ( - <ProfileSelectionItem - key={profile.id} - profile={profile} - isSelected={selectedProfileId === profile.id} - onSelect={() => setSelectedProfileId(profile.id)} - onUseAsIs={() => handleUseProfileAsIs(profile)} - onEdit={() => handleEditProfile(profile)} - onDuplicate={() => handleDuplicateProfile(profile)} - onDelete={() => handleDeleteProfile(profile)} - showManagementActions={true} - /> - ))} - </ItemGroup> - )} - - {/* Create New Profile Button */} - <Pressable - style={{ - backgroundColor: theme.colors.input.background, - borderRadius: 12, - borderWidth: 2, - borderColor: theme.colors.button.primary.background, - borderStyle: 'dashed', - padding: 16, - marginBottom: 12, - }} - onPress={handleCreateProfile} - > - <View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'center' }}> - <View style={{ - width: 40, - height: 40, - borderRadius: 20, - backgroundColor: theme.colors.button.primary.background, - alignItems: 'center', - justifyContent: 'center', - marginRight: 12, - }}> - <Ionicons name="add" size={20} color="white" /> - </View> - <View style={{ flex: 1 }}> - <Text style={{ - fontSize: 16, - fontWeight: '600', - color: theme.colors.text, - marginBottom: 4, - ...Typography.default('semiBold'), - }}> - Create New Profile - </Text> - <Text style={{ - fontSize: 14, - color: theme.colors.textSecondary, - ...Typography.default(), - }}> - Set up a custom AI backend configuration - </Text> - </View> - </View> - </Pressable> - - <ItemGroup title="Manual Configuration"> - <ManualConfigurationItem - isSelected={selectedProfileId === null} - onSelect={() => setSelectedProfileId(null)} - onUseCliVars={() => handleUseCliEnvironmentVariables()} - onConfigureManually={() => handleManualConfiguration()} - /> - </ItemGroup> - - <View style={{ - backgroundColor: theme.colors.input.background, - padding: 12, - borderRadius: 8, - borderWidth: 1, - borderColor: theme.colors.divider, - marginTop: 16, - }}> - <Text style={{ - fontSize: 14, - color: theme.colors.textSecondary, - marginBottom: 4, - }}> - 💡 **Profile Selection Options:** - </Text> - <Text style={{ - fontSize: 12, - color: theme.colors.textSecondary, - marginTop: 4, - }}> - • **Use As-Is**: Quick session creation with current profile settings - </Text> - <Text style={{ - fontSize: 12, - color: theme.colors.textSecondary, - marginTop: 4, - }}> - • **Edit**: Configure API keys and settings before session creation - </Text> - <Text style={{ - fontSize: 12, - color: theme.colors.textSecondary, - marginTop: 4, - }}> - • **Manual**: Use CLI environment variables without profile configuration - </Text> - </View> - </View> - ); - - case 'profileConfig': - if (!selectedProfileId || !profileNeedsConfiguration(selectedProfileId)) { - // Skip configuration if no profile selected or profile doesn't need configuration - setCurrentStep(steps[currentStepIndex + 1]); - return null; - } - - return ( - <View> - <Text style={styles.stepTitle}>Configure {allProfiles.find(p => p.id === selectedProfileId)?.name || 'Profile'}</Text> - <Text style={styles.stepDescription}> - Enter your API keys and configuration details - </Text> - - <ItemGroup title="Required Configuration"> - {getProfileRequiredFields(selectedProfileId).map((field) => ( - <View key={field.key} style={{ marginBottom: 16 }}> - <Text style={{ - fontSize: 16, - fontWeight: '600', - color: theme.colors.text, - marginBottom: 8, - ...Typography.default('semiBold'), - }}> - {field.label} - </Text> - <TextInput - style={[ - styles.textInput, - { fontFamily: 'monospace' } // Monospace font for API keys - ]} - placeholder={field.placeholder} - placeholderTextColor={theme.colors.textSecondary} - value={(profileApiKeys[selectedProfileId!] as any)?.[field.key] || (profileConfigs[selectedProfileId!] as any)?.[field.key] || ''} - onChangeText={(text) => { - if (field.isPassword) { - // API key - setProfileApiKeys(prev => ({ - ...prev, - [selectedProfileId!]: { - ...(prev[selectedProfileId!] as Record<string, string> || {}), - [field.key]: text - } - })); - } else { - // Configuration field - setProfileConfigs(prev => ({ - ...prev, - [selectedProfileId!]: { - ...(prev[selectedProfileId!] as Record<string, string> || {}), - [field.key]: text - } - })); - } - }} - secureTextEntry={field.isPassword} - autoCapitalize="none" - autoCorrect={false} - returnKeyType="next" - /> - </View> - ))} - </ItemGroup> - - <View style={{ - backgroundColor: theme.colors.input.background, - padding: 12, - borderRadius: 8, - borderWidth: 1, - borderColor: theme.colors.divider, - marginTop: 16, - }}> - <Text style={{ - fontSize: 14, - color: theme.colors.textSecondary, - marginBottom: 4, - }}> - 💡 Tip: Your API keys are only used for this session and are not stored permanently - </Text> - <Text style={{ - fontSize: 12, - color: theme.colors.textSecondary, - marginTop: 4, - }}> - 📝 Note: Leave fields empty to use CLI environment variables if they're already set - </Text> - </View> - </View> - ); - - case 'sessionType': - return ( - <View> - <Text style={styles.stepTitle}>Choose AI Backend & Session Type</Text> - <Text style={styles.stepDescription}> - Select your AI provider and how you want to work with your code - </Text> - - <ItemGroup title="AI Backend"> - {[ - { - id: 'anthropic', - name: 'Anthropic Claude', - description: 'Advanced reasoning and coding assistant', - icon: 'cube-outline', - agentType: 'claude' as const - }, - { - id: 'openai', - name: 'OpenAI GPT-5', - description: 'Specialized coding assistant', - icon: 'code-outline', - agentType: 'codex' as const - }, - { - id: 'deepseek', - name: 'DeepSeek Reasoner', - description: 'Advanced reasoning model', - icon: 'analytics-outline', - agentType: 'claude' as const - }, - { - id: 'zai', - name: 'Z.ai', - description: 'AI assistant for development', - icon: 'flash-outline', - agentType: 'claude' as const - }, - { - id: 'microsoft', - name: 'Microsoft Azure', - description: 'Enterprise AI services', - icon: 'cloud-outline', - agentType: 'codex' as const - }, - ].map((backend) => ( - <Item - key={backend.id} - title={backend.name} - subtitle={backend.description} - leftElement={ - <Ionicons - name={backend.icon as any} - size={24} - color={theme.colors.textSecondary} - /> - } - rightElement={agentType === backend.agentType ? ( - <Ionicons - name="checkmark-circle" - size={20} - color={theme.colors.button.primary.background} - /> - ) : null} - onPress={() => setAgentType(backend.agentType)} - showChevron={false} - selected={agentType === backend.agentType} - showDivider={true} - /> - ))} - </ItemGroup> - - <SessionTypeSelector - value={sessionType} - onChange={setSessionType} - /> - </View> - ); - - case 'agent': - return ( - <View> - <Text style={styles.stepTitle}>Choose AI Agent</Text> - <Text style={styles.stepDescription}> - Select which AI assistant you want to use - </Text> - - {selectedProfileId && ( - <View style={{ - backgroundColor: theme.colors.input.background, - padding: 12, - borderRadius: 8, - marginBottom: 16, - borderWidth: 1, - borderColor: theme.colors.divider - }}> - <Text style={{ - fontSize: 14, - color: theme.colors.textSecondary, - marginBottom: 4 - }}> - Profile: {allProfiles.find(p => p.id === selectedProfileId)?.name || 'Unknown'} - </Text> - <Text style={{ - fontSize: 12, - color: theme.colors.textSecondary - }}> - {allProfiles.find(p => p.id === selectedProfileId)?.description} - </Text> - </View> - )} - - <Pressable - style={[ - styles.agentOption, - agentType === 'claude' ? styles.agentOptionSelected : styles.agentOptionUnselected, - selectedProfileId && !allProfiles.find(p => p.id === selectedProfileId)?.compatibility.claude && { - opacity: 0.5, - backgroundColor: theme.colors.surface - } - ]} - onPress={() => { - if (!selectedProfileId || allProfiles.find(p => p.id === selectedProfileId)?.compatibility.claude) { - setAgentType('claude'); - } - }} - disabled={!!(selectedProfileId && !allProfiles.find(p => p.id === selectedProfileId)?.compatibility.claude)} - > - <View style={styles.agentIcon}> - <Text style={{ color: 'white', fontSize: 16, fontWeight: 'bold' }}>C</Text> - </View> - <View style={styles.agentInfo}> - <Text style={styles.agentName}>Claude</Text> - <Text style={styles.agentDescription}> - Anthropic's AI assistant, great for coding and analysis - </Text> - {selectedProfileId && !allProfiles.find(p => p.id === selectedProfileId)?.compatibility.claude && ( - <Text style={{ fontSize: 12, color: theme.colors.textDestructive, marginTop: 4 }}> - Not compatible with selected profile - </Text> - )} - </View> - {agentType === 'claude' && ( - <Ionicons name="checkmark-circle" size={24} color={theme.colors.button.primary.background} /> - )} - </Pressable> - - <Pressable - style={[ - styles.agentOption, - agentType === 'codex' ? styles.agentOptionSelected : styles.agentOptionUnselected, - selectedProfileId && !allProfiles.find(p => p.id === selectedProfileId)?.compatibility.codex && { - opacity: 0.5, - backgroundColor: theme.colors.surface - } - ]} - onPress={() => { - if (!selectedProfileId || allProfiles.find(p => p.id === selectedProfileId)?.compatibility.codex) { - setAgentType('codex'); - } - }} - disabled={!!(selectedProfileId && !allProfiles.find(p => p.id === selectedProfileId)?.compatibility.codex)} - > - <View style={styles.agentIcon}> - <Text style={{ color: 'white', fontSize: 16, fontWeight: 'bold' }}>X</Text> - </View> - <View style={styles.agentInfo}> - <Text style={styles.agentName}>Codex</Text> - <Text style={styles.agentDescription}> - OpenAI's specialized coding assistant - </Text> - {selectedProfileId && !allProfiles.find(p => p.id === selectedProfileId)?.compatibility.codex && ( - <Text style={{ fontSize: 12, color: theme.colors.textDestructive, marginTop: 4 }}> - Not compatible with selected profile - </Text> - )} - </View> - {agentType === 'codex' && ( - <Ionicons name="checkmark-circle" size={24} color={theme.colors.button.primary.background} /> - )} - </Pressable> - </View> - ); - - case 'options': - return ( - <View> - <Text style={styles.stepTitle}>Agent Options</Text> - <Text style={styles.stepDescription}> - Configure how the AI agent should behave - </Text> - - {selectedProfileId && ( - <View style={{ - backgroundColor: theme.colors.input.background, - padding: 12, - borderRadius: 8, - marginBottom: 16, - borderWidth: 1, - borderColor: theme.colors.divider - }}> - <Text style={{ - fontSize: 14, - color: theme.colors.textSecondary, - marginBottom: 4 - }}> - Using profile: {allProfiles.find(p => p.id === selectedProfileId)?.name || 'Unknown'} - </Text> - <Text style={{ - fontSize: 12, - color: theme.colors.textSecondary - }}> - Environment variables will be applied automatically - </Text> - </View> - )} - <ItemGroup title="Permission Mode"> - {([ - { value: 'default', label: 'Default', description: 'Ask for permissions', icon: 'shield-outline' }, - { value: 'acceptEdits', label: 'Accept Edits', description: 'Auto-approve edits', icon: 'checkmark-outline' }, - { value: 'plan', label: 'Plan', description: 'Plan before executing', icon: 'list-outline' }, - { value: 'bypassPermissions', label: 'Bypass Permissions', description: 'Skip all permissions', icon: 'flash-outline' }, - ] as const).map((option, index, array) => ( - <Item - key={option.value} - title={option.label} - subtitle={option.description} - leftElement={ - <Ionicons - name={option.icon} - size={24} - color={theme.colors.textSecondary} - /> - } - rightElement={permissionMode === option.value ? ( - <Ionicons - name="checkmark-circle" - size={20} - color={theme.colors.button.primary.background} - /> - ) : null} - onPress={() => setPermissionMode(option.value as PermissionMode)} - showChevron={false} - selected={permissionMode === option.value} - showDivider={index < array.length - 1} - /> - ))} - </ItemGroup> - - <ItemGroup title="Model Mode"> - {(agentType === 'claude' ? [ - { value: 'default', label: 'Default', description: 'Balanced performance', icon: 'cube-outline' }, - { value: 'adaptiveUsage', label: 'Adaptive Usage', description: 'Automatically choose model', icon: 'analytics-outline' }, - { value: 'sonnet', label: 'Sonnet', description: 'Fast and efficient', icon: 'speedometer-outline' }, - { value: 'opus', label: 'Opus', description: 'Most capable model', icon: 'diamond-outline' }, - ] as const : [ - { value: 'gpt-5-codex-high', label: 'GPT-5 Codex High', description: 'Best for complex coding', icon: 'diamond-outline' }, - { value: 'gpt-5-codex-medium', label: 'GPT-5 Codex Medium', description: 'Balanced coding assistance', icon: 'cube-outline' }, - { value: 'gpt-5-codex-low', label: 'GPT-5 Codex Low', description: 'Fast coding help', icon: 'speedometer-outline' }, - ] as const).map((option, index, array) => ( - <Item - key={option.value} - title={option.label} - subtitle={option.description} - leftElement={ - <Ionicons - name={option.icon} - size={24} - color={theme.colors.textSecondary} - /> - } - rightElement={modelMode === option.value ? ( - <Ionicons - name="checkmark-circle" - size={20} - color={theme.colors.button.primary.background} - /> - ) : null} - onPress={() => setModelMode(option.value as ModelMode)} - showChevron={false} - selected={modelMode === option.value} - showDivider={index < array.length - 1} - /> - ))} - </ItemGroup> - </View> - ); - - case 'machine': - return ( - <View> - <Text style={styles.stepTitle}>Select Machine</Text> - <Text style={styles.stepDescription}> - Choose which machine to run your session on - </Text> - - <ItemGroup title="Available Machines"> - {machines.map((machine, index) => ( - <Item - key={machine.id} - title={machine.metadata?.displayName || machine.metadata?.host || machine.id} - subtitle={machine.metadata?.host || ''} - leftElement={ - <Ionicons - name="laptop-outline" - size={24} - color={theme.colors.textSecondary} - /> - } - rightElement={selectedMachineId === machine.id ? ( - <Ionicons - name="checkmark-circle" - size={20} - color={theme.colors.button.primary.background} - /> - ) : null} - onPress={() => { - setSelectedMachineId(machine.id); - // Update path when machine changes - const homeDir = machine.metadata?.homeDir || '/home'; - setSelectedPath(homeDir); - }} - showChevron={false} - selected={selectedMachineId === machine.id} - showDivider={index < machines.length - 1} - /> - ))} - </ItemGroup> - </View> - ); - - case 'path': - return ( - <View> - <Text style={styles.stepTitle}>Working Directory</Text> - <Text style={styles.stepDescription}> - Choose the directory to work in - </Text> - - {/* Recent Paths */} - {recentPaths.length > 0 && ( - <ItemGroup title="Recent Paths"> - {recentPaths.map((path, index) => ( - <Item - key={path} - title={path} - subtitle="Recently used" - leftElement={ - <Ionicons - name="time-outline" - size={24} - color={theme.colors.textSecondary} - /> - } - rightElement={selectedPath === path && !showCustomPathInput ? ( - <Ionicons - name="checkmark-circle" - size={20} - color={theme.colors.button.primary.background} - /> - ) : null} - onPress={() => { - setSelectedPath(path); - setShowCustomPathInput(false); - }} - showChevron={false} - selected={selectedPath === path && !showCustomPathInput} - showDivider={index < recentPaths.length - 1} - /> - ))} - </ItemGroup> - )} - - {/* Common Directories */} - <ItemGroup title="Common Directories"> - {(() => { - const machine = machines.find(m => m.id === selectedMachineId); - const homeDir = machine?.metadata?.homeDir || '/home'; - const pathOptions = [ - { value: homeDir, label: homeDir, description: 'Home directory' }, - { value: `${homeDir}/projects`, label: `${homeDir}/projects`, description: 'Projects folder' }, - { value: `${homeDir}/Documents`, label: `${homeDir}/Documents`, description: 'Documents folder' }, - { value: `${homeDir}/Desktop`, label: `${homeDir}/Desktop`, description: 'Desktop folder' }, - ]; - return pathOptions.map((option, index) => ( - <Item - key={option.value} - title={option.label} - subtitle={option.description} - leftElement={ - <Ionicons - name="folder-outline" - size={24} - color={theme.colors.textSecondary} - /> - } - rightElement={selectedPath === option.value && !showCustomPathInput ? ( - <Ionicons - name="checkmark-circle" - size={20} - color={theme.colors.button.primary.background} - /> - ) : null} - onPress={() => { - setSelectedPath(option.value); - setShowCustomPathInput(false); - }} - showChevron={false} - selected={selectedPath === option.value && !showCustomPathInput} - showDivider={index < pathOptions.length - 1} - /> - )); - })()} - </ItemGroup> - - {/* Custom Path Option */} - <ItemGroup title="Custom Directory"> - <Item - title="Enter custom path" - subtitle={showCustomPathInput && customPath ? customPath : "Specify a custom directory path"} - leftElement={ - <Ionicons - name="create-outline" - size={24} - color={theme.colors.textSecondary} - /> - } - rightElement={showCustomPathInput ? ( - <Ionicons - name="checkmark-circle" - size={20} - color={theme.colors.button.primary.background} - /> - ) : null} - onPress={() => setShowCustomPathInput(true)} - showChevron={false} - selected={showCustomPathInput} - showDivider={false} - /> - {showCustomPathInput && ( - <View style={{ paddingHorizontal: 16, paddingVertical: 12 }}> - <TextInput - style={styles.textInput} - placeholder="Enter directory path (e.g. /home/user/my-project)" - placeholderTextColor={theme.colors.textSecondary} - value={customPath} - onChangeText={setCustomPath} - autoCapitalize="none" - autoCorrect={false} - returnKeyType="done" - /> - </View> - )} - </ItemGroup> - </View> - ); - - case 'prompt': - return ( - <View> - <Text style={styles.stepTitle}>Initial Message</Text> - <Text style={styles.stepDescription}> - Write your first message to the AI agent - </Text> - - <TextInput - style={[styles.textInput, { height: 120, textAlignVertical: 'top' }]} - placeholder={t('session.inputPlaceholder')} - placeholderTextColor={theme.colors.textSecondary} - value={prompt} - onChangeText={setPrompt} - multiline={true} - autoCapitalize="sentences" - autoCorrect={true} - returnKeyType="default" - /> - </View> - ); - - default: - return null; - } - }; - - return ( - <View style={styles.container}> - <View style={styles.header}> - <Text style={styles.headerTitle}>New Session</Text> - <Pressable onPress={onCancel}> - <Ionicons name="close" size={24} color={theme.colors.textSecondary} /> - </Pressable> - </View> - - <View style={styles.stepIndicator}> - {steps.map((step, index) => ( - <View - key={step} - style={[ - styles.stepDot, - index <= currentStepIndex ? styles.stepDotActive : styles.stepDotInactive - ]} - /> - ))} - </View> - - <ScrollView - style={styles.stepContent} - contentContainerStyle={{ paddingBottom: 24 }} - showsVerticalScrollIndicator={true} - > - {renderStepContent()} - </ScrollView> - - <View style={styles.footer}> - <Pressable - style={[styles.button, styles.buttonSecondary]} - onPress={handleBack} - > - <Text style={[styles.buttonText, styles.buttonTextSecondary]}> - {isFirstStep ? 'Cancel' : 'Back'} - </Text> - </Pressable> - - <Pressable - style={[ - styles.button, - styles.buttonPrimary, - !canProceed && { opacity: 0.5 } - ]} - onPress={handleNext} - disabled={!canProceed} - > - <Text style={[styles.buttonText, styles.buttonTextPrimary]}> - {isLastStep ? 'Create Session' : 'Next'} - </Text> - </Pressable> - </View> - </View> - ); -} \ No newline at end of file diff --git a/expo-app/sources/components/OAuthView.tsx b/expo-app/sources/components/OAuthView.tsx index 5a28793d7..d8201f29f 100644 --- a/expo-app/sources/components/OAuthView.tsx +++ b/expo-app/sources/components/OAuthView.tsx @@ -359,9 +359,9 @@ export const OAuthViewUnsupported = React.memo((props: { return ( <View style={styles.unsupportedContainer}> - <Text style={styles.unsupportedTitle}>Connect {props.name}</Text> + <Text style={styles.unsupportedTitle}>{t('connect.unsupported.connectTitle', { name: props.name })}</Text> <Text style={styles.unsupportedText}> - Run the following command in your terminal: + {t('connect.unsupported.runCommandInTerminal')} </Text> <View style={styles.terminalContainer}> <Text style={styles.terminalCommand}> @@ -371,4 +371,4 @@ export const OAuthViewUnsupported = React.memo((props: { </View> </View> ); -}); \ No newline at end of file +}); diff --git a/expo-app/sources/components/PermissionModeSelector.tsx b/expo-app/sources/components/PermissionModeSelector.tsx deleted file mode 100644 index 5c9f0850e..000000000 --- a/expo-app/sources/components/PermissionModeSelector.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import React from 'react'; -import { Text, Pressable, Platform } from 'react-native'; -import { Ionicons } from '@expo/vector-icons'; -import { Typography } from '@/constants/Typography'; -import { hapticsLight } from './haptics'; - -export type PermissionMode = 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' | 'read-only' | 'safe-yolo' | 'yolo'; - -export type ModelMode = 'default' | 'adaptiveUsage' | 'sonnet' | 'opus' | 'gpt-5-codex-high' | 'gpt-5-codex-medium' | 'gpt-5-codex-low' | 'gpt-5-minimal' | 'gpt-5-low' | 'gpt-5-medium' | 'gpt-5-high' | 'gemini-2.5-pro' | 'gemini-2.5-flash' | 'gemini-2.5-flash-lite'; - -interface PermissionModeSelectorProps { - mode: PermissionMode; - onModeChange: (mode: PermissionMode) => void; - disabled?: boolean; -} - -const modeConfig = { - default: { - label: 'Default', - icon: 'shield-checkmark' as const, - description: 'Ask for permissions' - }, - acceptEdits: { - label: 'Accept Edits', - icon: 'create' as const, - description: 'Auto-approve edits' - }, - plan: { - label: 'Plan', - icon: 'list' as const, - description: 'Plan before executing' - }, - bypassPermissions: { - label: 'Yolo', - icon: 'flash' as const, - description: 'Skip all permissions' - }, - // Codex modes (not displayed in this component, but needed for type compatibility) - 'read-only': { - label: 'Read-only', - icon: 'eye' as const, - description: 'Read-only mode' - }, - 'safe-yolo': { - label: 'Safe YOLO', - icon: 'shield' as const, - description: 'Safe YOLO mode' - }, - 'yolo': { - label: 'YOLO', - icon: 'rocket' as const, - description: 'YOLO mode' - }, -}; - -const modeOrder: PermissionMode[] = ['default', 'acceptEdits', 'plan', 'bypassPermissions']; - -export const PermissionModeSelector: React.FC<PermissionModeSelectorProps> = ({ - mode, - onModeChange, - disabled = false -}) => { - const currentConfig = modeConfig[mode]; - - const handleTap = () => { - hapticsLight(); - const currentIndex = modeOrder.indexOf(mode); - const nextIndex = (currentIndex + 1) % modeOrder.length; - onModeChange(modeOrder[nextIndex]); - }; - - return ( - <Pressable - onPress={handleTap} - disabled={disabled} - hitSlop={{ top: 5, bottom: 10, left: 0, right: 0 }} - style={{ - flexDirection: 'row', - alignItems: 'center', - // backgroundColor: Platform.select({ - // ios: '#F2F2F7', - // android: '#E0E0E0', - // default: '#F2F2F7' - // }), - borderRadius: Platform.select({ default: 16, android: 20 }), - paddingHorizontal: 12, - paddingVertical: 6, - width: 120, - justifyContent: 'center', - height: 32, - opacity: disabled ? 0.5 : 1, - }} - > - <Ionicons - name={'hammer-outline'} - size={16} - color={'black'} - style={{ marginRight: 4 }} - /> - {/* <Text style={{ - fontSize: 13, - color: '#000', - fontWeight: '600', - ...Typography.default('semiBold') - }}> - {currentConfig.label} - </Text> */} - </Pressable> - ); -}; \ No newline at end of file diff --git a/expo-app/sources/components/ProfileEditForm.tsx b/expo-app/sources/components/ProfileEditForm.tsx deleted file mode 100644 index 8a3864d44..000000000 --- a/expo-app/sources/components/ProfileEditForm.tsx +++ /dev/null @@ -1,580 +0,0 @@ -import React from 'react'; -import { View, Text, Pressable, ScrollView, TextInput, ViewStyle, Linking, Platform } from 'react-native'; -import { Ionicons } from '@expo/vector-icons'; -import { StyleSheet } from 'react-native-unistyles'; -import { useUnistyles } from 'react-native-unistyles'; -import { Typography } from '@/constants/Typography'; -import { t } from '@/text'; -import { AIBackendProfile } from '@/sync/settings'; -import { PermissionMode, ModelMode } from '@/components/PermissionModeSelector'; -import { SessionTypeSelector } from '@/components/SessionTypeSelector'; -import { ItemGroup } from '@/components/ItemGroup'; -import { Item } from '@/components/Item'; -import { getBuiltInProfileDocumentation } from '@/sync/profileUtils'; -import { useEnvironmentVariables, extractEnvVarReferences } from '@/hooks/useEnvironmentVariables'; -import { EnvironmentVariablesList } from '@/components/EnvironmentVariablesList'; - -export interface ProfileEditFormProps { - profile: AIBackendProfile; - machineId: string | null; - onSave: (profile: AIBackendProfile) => void; - onCancel: () => void; - containerStyle?: ViewStyle; -} - -export function ProfileEditForm({ - profile, - machineId, - onSave, - onCancel, - containerStyle -}: ProfileEditFormProps) { - const { theme } = useUnistyles(); - - // Get documentation for built-in profiles - const profileDocs = React.useMemo(() => { - if (!profile.isBuiltIn) return null; - return getBuiltInProfileDocumentation(profile.id); - }, [profile.isBuiltIn, profile.id]); - - // Local state for environment variables (unified for all config) - const [environmentVariables, setEnvironmentVariables] = React.useState<Array<{ name: string; value: string }>>( - profile.environmentVariables || [] - ); - - // Extract ${VAR} references from environmentVariables for querying daemon - const envVarNames = React.useMemo(() => { - return extractEnvVarReferences(environmentVariables); - }, [environmentVariables]); - - // Query daemon environment using hook - const { variables: actualEnvVars } = useEnvironmentVariables(machineId, envVarNames); - - const [name, setName] = React.useState(profile.name || ''); - const [useTmux, setUseTmux] = React.useState(profile.tmuxConfig?.sessionName !== undefined); - const [tmuxSession, setTmuxSession] = React.useState(profile.tmuxConfig?.sessionName || ''); - const [tmuxTmpDir, setTmuxTmpDir] = React.useState(profile.tmuxConfig?.tmpDir || ''); - const [useStartupScript, setUseStartupScript] = React.useState(!!profile.startupBashScript); - const [startupScript, setStartupScript] = React.useState(profile.startupBashScript || ''); - const [defaultSessionType, setDefaultSessionType] = React.useState<'simple' | 'worktree'>(profile.defaultSessionType || 'simple'); - const [defaultPermissionMode, setDefaultPermissionMode] = React.useState<PermissionMode>((profile.defaultPermissionMode as PermissionMode) || 'default'); - const [agentType, setAgentType] = React.useState<'claude' | 'codex'>(() => { - if (profile.compatibility.claude && !profile.compatibility.codex) return 'claude'; - if (profile.compatibility.codex && !profile.compatibility.claude) return 'codex'; - return 'claude'; // Default to Claude if both or neither - }); - - const handleSave = () => { - if (!name.trim()) { - // Profile name validation - prevent saving empty profiles - return; - } - - onSave({ - ...profile, - name: name.trim(), - // Clear all config objects - ALL configuration now in environmentVariables - anthropicConfig: {}, - openaiConfig: {}, - azureOpenAIConfig: {}, - // Use environment variables from state (managed by EnvironmentVariablesList) - environmentVariables, - // Keep non-env-var configuration - tmuxConfig: useTmux ? { - sessionName: tmuxSession.trim() || '', // Empty string = use current/most recent tmux session - tmpDir: tmuxTmpDir.trim() || undefined, - updateEnvironment: undefined, // Preserve schema compatibility, not used by daemon - } : { - sessionName: undefined, - tmpDir: undefined, - updateEnvironment: undefined, - }, - startupBashScript: useStartupScript ? (startupScript.trim() || undefined) : undefined, - defaultSessionType: defaultSessionType, - defaultPermissionMode: defaultPermissionMode, - updatedAt: Date.now(), - }); - }; - - return ( - <ScrollView - style={[profileEditFormStyles.scrollView, containerStyle]} - contentContainerStyle={profileEditFormStyles.scrollContent} - keyboardShouldPersistTaps="handled" - > - <View style={profileEditFormStyles.formContainer}> - {/* Profile Name */} - <Text style={{ - fontSize: 14, - fontWeight: '600', - color: theme.colors.text, - marginBottom: 8, - ...Typography.default('semiBold') - }}> - {t('profiles.profileName')} - </Text> - <TextInput - style={{ - backgroundColor: theme.colors.input.background, - borderRadius: 10, // Matches new session panel input fields - padding: 12, - fontSize: 16, - color: theme.colors.text, - marginBottom: 16, - borderWidth: 1, - borderColor: theme.colors.textSecondary, - }} - placeholder={t('profiles.enterName')} - value={name} - onChangeText={setName} - /> - - {/* Built-in Profile Documentation - Setup Instructions */} - {profile.isBuiltIn && profileDocs && ( - <View style={{ - backgroundColor: theme.colors.surface, - borderRadius: 12, - padding: 16, - marginBottom: 20, - borderWidth: 1, - borderColor: theme.colors.button.primary.background, - }}> - <View style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 12 }}> - <Ionicons name="information-circle" size={20} color={theme.colors.button.primary.tint} style={{ marginRight: 8 }} /> - <Text style={{ - fontSize: 15, - fontWeight: '600', - color: theme.colors.text, - ...Typography.default('semiBold') - }}> - Setup Instructions - </Text> - </View> - - <Text style={{ - fontSize: 13, - color: theme.colors.text, - marginBottom: 12, - lineHeight: 18, - ...Typography.default() - }}> - {profileDocs.description} - </Text> - - {profileDocs.setupGuideUrl && ( - <Pressable - onPress={async () => { - try { - const url = profileDocs.setupGuideUrl!; - // On web/Tauri desktop, use window.open - if (Platform.OS === 'web') { - window.open(url, '_blank'); - } else { - // On native (iOS/Android), use Linking API - await Linking.openURL(url); - } - } catch (error) { - console.error('Failed to open URL:', error); - } - }} - style={{ - flexDirection: 'row', - alignItems: 'center', - backgroundColor: theme.colors.button.primary.background, - borderRadius: 8, - padding: 12, - marginBottom: 16, - }} - > - <Ionicons name="book-outline" size={16} color={theme.colors.button.primary.tint} style={{ marginRight: 8 }} /> - <Text style={{ - fontSize: 13, - color: theme.colors.button.primary.tint, - fontWeight: '600', - flex: 1, - ...Typography.default('semiBold') - }}> - View Official Setup Guide - </Text> - <Ionicons name="open-outline" size={14} color={theme.colors.button.primary.tint} /> - </Pressable> - )} - </View> - )} - - {/* Session Type */} - <Text style={{ - fontSize: 14, - fontWeight: '600', - color: theme.colors.text, - marginBottom: 12, - ...Typography.default('semiBold') - }}> - Default Session Type - </Text> - <View style={{ marginBottom: 16 }}> - <SessionTypeSelector - value={defaultSessionType} - onChange={setDefaultSessionType} - /> - </View> - - {/* Permission Mode */} - <Text style={{ - fontSize: 14, - fontWeight: '600', - color: theme.colors.text, - marginBottom: 12, - ...Typography.default('semiBold') - }}> - Default Permission Mode - </Text> - <ItemGroup title=""> - {[ - { value: 'default' as PermissionMode, label: 'Default', description: 'Ask for permissions', icon: 'shield-outline' }, - { value: 'acceptEdits' as PermissionMode, label: 'Accept Edits', description: 'Auto-approve edits', icon: 'checkmark-outline' }, - { value: 'plan' as PermissionMode, label: 'Plan', description: 'Plan before executing', icon: 'list-outline' }, - { value: 'bypassPermissions' as PermissionMode, label: 'Bypass Permissions', description: 'Skip all permissions', icon: 'flash-outline' }, - ].map((option, index, array) => ( - <Item - key={option.value} - title={option.label} - subtitle={option.description} - leftElement={ - <Ionicons - name={option.icon as any} - size={24} - color={defaultPermissionMode === option.value ? theme.colors.button.primary.tint : theme.colors.textSecondary} - /> - } - rightElement={defaultPermissionMode === option.value ? ( - <Ionicons - name="checkmark-circle" - size={20} - color={theme.colors.button.primary.tint} - /> - ) : null} - onPress={() => setDefaultPermissionMode(option.value)} - showChevron={false} - selected={defaultPermissionMode === option.value} - showDivider={index < array.length - 1} - style={defaultPermissionMode === option.value ? { - borderWidth: 2, - borderColor: theme.colors.button.primary.tint, - borderRadius: 8, - } : undefined} - /> - ))} - </ItemGroup> - <View style={{ marginBottom: 16 }} /> - - {/* Tmux Enable/Disable */} - <View style={{ - flexDirection: 'row', - alignItems: 'center', - marginBottom: 8, - }}> - <Pressable - style={{ - flexDirection: 'row', - alignItems: 'center', - marginRight: 8, - }} - onPress={() => setUseTmux(!useTmux)} - > - <View style={{ - width: 20, - height: 20, - borderRadius: 4, - borderWidth: 2, - borderColor: useTmux ? theme.colors.button.primary.background : theme.colors.textSecondary, - backgroundColor: useTmux ? theme.colors.button.primary.background : 'transparent', - justifyContent: 'center', - alignItems: 'center', - marginRight: 8, - }}> - {useTmux && ( - <Ionicons name="checkmark" size={12} color={theme.colors.button.primary.tint} /> - )} - </View> - </Pressable> - <Text style={{ - fontSize: 14, - fontWeight: '600', - color: theme.colors.text, - ...Typography.default('semiBold') - }}> - Spawn Sessions in Tmux - </Text> - </View> - <Text style={{ - fontSize: 12, - color: theme.colors.textSecondary, - marginBottom: 12, - ...Typography.default() - }}> - {useTmux ? 'Sessions spawn in new tmux windows. Configure session name and temp directory below.' : 'Sessions spawn in regular shell (no tmux integration)'} - </Text> - - {/* Tmux Session Name */} - <Text style={{ - fontSize: 14, - fontWeight: '600', - color: theme.colors.text, - marginBottom: 8, - ...Typography.default('semiBold') - }}> - Tmux Session Name ({t('common.optional')}) - </Text> - <Text style={{ - fontSize: 12, - color: theme.colors.textSecondary, - marginBottom: 8, - ...Typography.default() - }}> - Leave empty to use first existing tmux session (or create "happy" if none exist). Specify name (e.g., "my-work") for specific session. - </Text> - <TextInput - style={{ - backgroundColor: theme.colors.input.background, - borderRadius: 10, // Matches new session panel input fields - padding: 12, - fontSize: 16, - color: useTmux ? theme.colors.text : theme.colors.textSecondary, - marginBottom: 16, - borderWidth: 1, - borderColor: theme.colors.textSecondary, - opacity: useTmux ? 1 : 0.5, - }} - placeholder={useTmux ? 'Empty = first existing session' : "Disabled - tmux not enabled"} - value={tmuxSession} - onChangeText={setTmuxSession} - editable={useTmux} - /> - - {/* Tmux Temp Directory */} - <Text style={{ - fontSize: 14, - fontWeight: '600', - color: theme.colors.text, - marginBottom: 8, - ...Typography.default('semiBold') - }}> - Tmux Temp Directory ({t('common.optional')}) - </Text> - <Text style={{ - fontSize: 12, - color: theme.colors.textSecondary, - marginBottom: 8, - ...Typography.default() - }}> - Temporary directory for tmux session files. Leave empty for system default. - </Text> - <TextInput - style={{ - backgroundColor: theme.colors.input.background, - borderRadius: 10, // Matches new session panel input fields - padding: 12, - fontSize: 16, - color: useTmux ? theme.colors.text : theme.colors.textSecondary, - marginBottom: 16, - borderWidth: 1, - borderColor: theme.colors.textSecondary, - opacity: useTmux ? 1 : 0.5, - }} - placeholder={useTmux ? "/tmp (optional)" : "Disabled - tmux not enabled"} - placeholderTextColor={theme.colors.input.placeholder} - value={tmuxTmpDir} - onChangeText={setTmuxTmpDir} - editable={useTmux} - /> - - {/* Startup Bash Script */} - <View style={{ marginBottom: 24 }}> - <View style={{ - flexDirection: 'row', - alignItems: 'center', - marginBottom: 8, - }}> - <Pressable - style={{ - flexDirection: 'row', - alignItems: 'center', - marginRight: 8, - }} - onPress={() => setUseStartupScript(!useStartupScript)} - > - <View style={{ - width: 20, - height: 20, - borderRadius: 4, - borderWidth: 2, - borderColor: useStartupScript ? theme.colors.button.primary.background : theme.colors.textSecondary, - backgroundColor: useStartupScript ? theme.colors.button.primary.background : 'transparent', - justifyContent: 'center', - alignItems: 'center', - marginRight: 8, - }}> - {useStartupScript && ( - <Ionicons name="checkmark" size={12} color={theme.colors.button.primary.tint} /> - )} - </View> - </Pressable> - <Text style={{ - fontSize: 16, - fontWeight: '600', - color: theme.colors.text, - ...Typography.default('semiBold') - }}> - Startup Bash Script - </Text> - </View> - <Text style={{ - fontSize: 12, - color: theme.colors.textSecondary, - marginBottom: 12, - ...Typography.default() - }}> - {useStartupScript - ? 'Executed before spawning each session. Use for dynamic setup, environment checks, or custom initialization.' - : 'No startup script - sessions spawn directly'} - </Text> - <View style={{ - flexDirection: 'row', - alignItems: 'flex-start', - gap: 8, - opacity: useStartupScript ? 1 : 0.5, - }}> - <TextInput - style={{ - flex: 1, - backgroundColor: useStartupScript ? theme.colors.input.background : theme.colors.surface, - borderRadius: 10, // Matches new session panel input fields - padding: 12, - fontSize: 14, - color: useStartupScript ? theme.colors.text : theme.colors.textSecondary, - borderWidth: 1, - borderColor: theme.colors.textSecondary, - fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace', - minHeight: 100, - }} - placeholder={useStartupScript ? "#!/bin/bash\necho 'Initializing...'\n# Your script here" : "Disabled"} - value={startupScript} - onChangeText={setStartupScript} - editable={useStartupScript} - multiline - textAlignVertical="top" - /> - {useStartupScript && startupScript.trim() && ( - <Pressable - style={{ - backgroundColor: theme.colors.button.primary.background, - borderRadius: 6, - padding: 10, - justifyContent: 'center', - alignItems: 'center', - }} - onPress={() => { - if (Platform.OS === 'web') { - navigator.clipboard.writeText(startupScript); - } - }} - > - <Ionicons name="copy-outline" size={18} color={theme.colors.button.primary.tint} /> - </Pressable> - )} - </View> - </View> - - {/* Environment Variables Section - Unified configuration */} - <EnvironmentVariablesList - environmentVariables={environmentVariables} - machineId={machineId} - profileDocs={profileDocs} - onChange={setEnvironmentVariables} - /> - - {/* Action buttons */} - <View style={{ flexDirection: 'row', gap: 12 }}> - <Pressable - style={{ - flex: 1, - backgroundColor: theme.colors.surface, - borderRadius: 8, - padding: 12, - alignItems: 'center', - }} - onPress={onCancel} - > - <Text style={{ - fontSize: 16, - fontWeight: '600', - color: theme.colors.button.secondary.tint, - ...Typography.default('semiBold') - }}> - {t('common.cancel')} - </Text> - </Pressable> - {profile.isBuiltIn ? ( - // For built-in profiles, show "Save As" button (creates custom copy) - <Pressable - style={{ - flex: 1, - backgroundColor: theme.colors.button.primary.background, - borderRadius: 8, - padding: 12, - alignItems: 'center', - }} - onPress={handleSave} - > - <Text style={{ - fontSize: 16, - fontWeight: '600', - color: theme.colors.button.primary.tint, - ...Typography.default('semiBold') - }}> - {t('common.saveAs')} - </Text> - </Pressable> - ) : ( - // For custom profiles, show regular "Save" button - <Pressable - style={{ - flex: 1, - backgroundColor: theme.colors.button.primary.background, - borderRadius: 8, - padding: 12, - alignItems: 'center', - }} - onPress={handleSave} - > - <Text style={{ - fontSize: 16, - fontWeight: '600', - color: theme.colors.button.primary.tint, - ...Typography.default('semiBold') - }}> - {t('common.save')} - </Text> - </Pressable> - )} - </View> - </View> - </ScrollView> - ); -} - -const profileEditFormStyles = StyleSheet.create((theme, rt) => ({ - scrollView: { - flex: 1, - }, - scrollContent: { - padding: 20, - }, - formContainer: { - backgroundColor: theme.colors.surface, - borderRadius: 16, // Matches new session panel main container - padding: 20, - width: '100%', - }, -})); diff --git a/expo-app/sources/components/SearchableListSelector.tsx b/expo-app/sources/components/SearchableListSelector.tsx index c81ba79e2..26ea32c59 100644 --- a/expo-app/sources/components/SearchableListSelector.tsx +++ b/expo-app/sources/components/SearchableListSelector.tsx @@ -3,12 +3,11 @@ import { View, Text, Pressable, Platform } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { Typography } from '@/constants/Typography'; -import { ItemGroup } from '@/components/ItemGroup'; -import { Item } from '@/components/Item'; -import { MultiTextInput } from '@/components/MultiTextInput'; -import { Modal } from '@/modal'; +import { ItemGroup } from '@/components/ui/lists/ItemGroup'; +import { Item } from '@/components/ui/lists/Item'; import { t } from '@/text'; import { StatusDot } from '@/components/StatusDot'; +import { SearchHeader } from '@/components/ui/forms/SearchHeader'; /** * Configuration object for customizing the SearchableListSelector component. @@ -29,6 +28,12 @@ export interface SelectorConfig<T> { isPulsing?: boolean; } | null; + /** + * Optional extra element rendered next to the status (e.g. small CLI glyphs). + * Kept separate from status.text so it can be interactive (tap/hover). + */ + getItemStatusExtra?: (item: T) => React.ReactNode; + // Display formatting (e.g., formatPathRelativeToHome for paths, displayName for machines) formatForDisplay: (item: T, context?: any) => string; parseFromDisplay: (text: string, context?: any) => T | null; @@ -40,12 +45,14 @@ export interface SelectorConfig<T> { searchPlaceholder: string; recentSectionTitle: string; favoritesSectionTitle: string; + allSectionTitle?: string; noItemsMessage: string; // Optional features showFavorites?: boolean; showRecent?: boolean; showSearch?: boolean; + showAll?: boolean; allowCustomInput?: boolean; // Item subtitle override (for recent items, e.g., "Recently used") @@ -59,9 +66,6 @@ export interface SelectorConfig<T> { // Check if a favorite item can be removed (e.g., home directory can't be removed) canRemoveFavorite?: (item: T) => boolean; - - // Visual customization - compactItems?: boolean; // Use reduced padding for more compact lists (default: false) } /** @@ -75,142 +79,28 @@ export interface SearchableListSelectorProps<T> { selectedItem: T | null; onSelect: (item: T) => void; onToggleFavorite?: (item: T) => void; - context?: any; // Additional context (e.g., homeDir for paths) + context?: any; // Additional context (e.g., homeDir for paths) // Optional overrides showFavorites?: boolean; showRecent?: boolean; showSearch?: boolean; - - // Controlled collapse states (optional - defaults to uncontrolled internal state) - collapsedSections?: { - recent?: boolean; - favorites?: boolean; - all?: boolean; - }; - onCollapsedSectionsChange?: (collapsed: { recent?: boolean; favorites?: boolean; all?: boolean }) => void; + searchPlacement?: 'header' | 'recent' | 'favorites' | 'all'; } const RECENT_ITEMS_DEFAULT_VISIBLE = 5; -// Spacing constants (match existing codebase patterns) -const STATUS_DOT_TEXT_GAP = 4; // Gap between StatusDot and text (used throughout app for status indicators) -const ITEM_SPACING_GAP = 4; // Gap between elements and spacing between items (compact) -const COMPACT_ITEM_PADDING = 4; // Vertical padding for compact lists -// Border radius constants (consistent rounding) -const INPUT_BORDER_RADIUS = 10; // Input field and containers -const BUTTON_BORDER_RADIUS = 8; // Buttons and actionable elements -// ITEM_BORDER_RADIUS must match ItemGroup's contentContainer borderRadius to prevent clipping -// ItemGroup uses Platform.select({ ios: 10, default: 16 }) -const ITEM_BORDER_RADIUS = Platform.select({ ios: 10, default: 16 }); // Match ItemGroup container radius +const STATUS_DOT_TEXT_GAP = 4; +const ITEM_SPACING_GAP = 16; const stylesheet = StyleSheet.create((theme) => ({ - inputContainer: { - flexDirection: 'row', - alignItems: 'center', - gap: 8, - paddingHorizontal: 16, - paddingBottom: 8, - }, - inputWrapper: { - flex: 1, - backgroundColor: theme.colors.input.background, - borderRadius: INPUT_BORDER_RADIUS, - borderWidth: 0.5, - borderColor: theme.colors.divider, - }, - inputInner: { - flexDirection: 'row', - alignItems: 'center', - paddingHorizontal: 12, - }, - inputField: { - flex: 1, - }, - clearButton: { - width: 20, - height: 20, - borderRadius: INPUT_BORDER_RADIUS, - backgroundColor: theme.colors.textSecondary, - justifyContent: 'center', - alignItems: 'center', - marginLeft: 8, - }, - favoriteButton: { - borderRadius: BUTTON_BORDER_RADIUS, - padding: 8, - }, - sectionHeader: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - paddingHorizontal: 16, - paddingVertical: 10, - }, - sectionHeaderText: { - fontSize: 13, - fontWeight: '500', - color: theme.colors.text, - ...Typography.default(), - }, - selectedItemStyle: { - borderWidth: 2, - borderColor: theme.colors.button.primary.tint, - borderRadius: ITEM_BORDER_RADIUS, - }, - compactItemStyle: { - paddingVertical: COMPACT_ITEM_PADDING, - minHeight: 0, // Override Item's default minHeight (44-56px) for compact mode - }, - itemBackground: { - backgroundColor: theme.colors.input.background, - borderRadius: ITEM_BORDER_RADIUS, - marginBottom: ITEM_SPACING_GAP, - }, showMoreTitle: { textAlign: 'center', - color: theme.colors.button.primary.tint, + color: theme.colors.textLink, }, })); -/** - * Generic searchable list selector component with recent items, favorites, and filtering. - * - * Pattern extracted from Working Directory section in new session wizard. - * Supports any data type through TypeScript generics and configuration object. - * - * Features: - * - Search/filter with smart skip (doesn't filter when input matches selection) - * - Recent items with "Show More" toggle - * - Favorites with add/remove - * - Collapsible sections - * - Custom input support (optional) - * - * @example - * // For machines: - * <SearchableListSelector<Machine> - * config={machineConfig} - * items={machines} - * recentItems={recentMachines} - * favoriteItems={favoriteMachines} - * selectedItem={selectedMachine} - * onSelect={(machine) => setSelectedMachine(machine)} - * onToggleFavorite={(machine) => toggleFavorite(machine.id)} - * /> - * - * // For paths: - * <SearchableListSelector<string> - * config={pathConfig} - * items={allPaths} - * recentItems={recentPaths} - * favoriteItems={favoritePaths} - * selectedItem={selectedPath} - * onSelect={(path) => setSelectedPath(path)} - * onToggleFavorite={(path) => toggleFavorite(path)} - * context={{ homeDir }} - * /> - */ export function SearchableListSelector<T>(props: SearchableListSelectorProps<T>) { - const { theme } = useUnistyles(); + const { theme, rt } = useUnistyles(); const styles = stylesheet; const { config, @@ -224,167 +114,51 @@ export function SearchableListSelector<T>(props: SearchableListSelectorProps<T>) showFavorites = config.showFavorites !== false, showRecent = config.showRecent !== false, showSearch = config.showSearch !== false, - collapsedSections, - onCollapsedSectionsChange, + searchPlacement = 'header', } = props; + const showAll = config.showAll !== false; - // Use controlled state if provided, otherwise use internal state - const isControlled = collapsedSections !== undefined && onCollapsedSectionsChange !== undefined; - - // State management (matches Working Directory pattern) - const [inputText, setInputText] = React.useState(() => { - if (selectedItem) { - return config.formatForDisplay(selectedItem, context); - } - return ''; - }); + // Search query is intentionally decoupled from the selected value so pickers don't start pre-filtered. + const [inputText, setInputText] = React.useState(''); const [showAllRecent, setShowAllRecent] = React.useState(false); - // Internal uncontrolled state (used when not controlled from parent) - const [internalShowRecentSection, setInternalShowRecentSection] = React.useState(false); - const [internalShowFavoritesSection, setInternalShowFavoritesSection] = React.useState(false); - const [internalShowAllItemsSection, setInternalShowAllItemsSection] = React.useState(true); - - // Use controlled or uncontrolled state - const showRecentSection = isControlled ? !collapsedSections?.recent : internalShowRecentSection; - const showFavoritesSection = isControlled ? !collapsedSections?.favorites : internalShowFavoritesSection; - const showAllItemsSection = isControlled ? !collapsedSections?.all : internalShowAllItemsSection; - - // Toggle handlers that work for both controlled and uncontrolled - const toggleRecentSection = () => { - if (isControlled) { - onCollapsedSectionsChange?.({ ...collapsedSections, recent: !collapsedSections?.recent }); - } else { - setInternalShowRecentSection(!internalShowRecentSection); - } - }; - - const toggleFavoritesSection = () => { - if (isControlled) { - onCollapsedSectionsChange?.({ ...collapsedSections, favorites: !collapsedSections?.favorites }); - } else { - setInternalShowFavoritesSection(!internalShowFavoritesSection); - } - }; - - const toggleAllItemsSection = () => { - if (isControlled) { - onCollapsedSectionsChange?.({ ...collapsedSections, all: !collapsedSections?.all }); - } else { - setInternalShowAllItemsSection(!internalShowAllItemsSection); - } - }; - - // Track if user is actively typing (vs clicking from list) to control expansion behavior - const isUserTyping = React.useRef(false); + const favoriteIds = React.useMemo(() => { + return new Set(favoriteItems.map((item) => config.getItemId(item))); + }, [favoriteItems, config]); - // Update input text when selected item changes externally - React.useEffect(() => { - if (selectedItem && !isUserTyping.current) { - setInputText(config.formatForDisplay(selectedItem, context)); - } - }, [selectedItem, config, context]); - - // Filtering logic with smart skip (matches Working Directory pattern) - const filteredRecentItems = React.useMemo(() => { - if (!inputText.trim()) return recentItems; - - // Don't filter if text matches the currently selected item (user clicked from list) - const selectedDisplayText = selectedItem ? config.formatForDisplay(selectedItem, context) : null; - if (selectedDisplayText && inputText === selectedDisplayText) { - return recentItems; // Show all items, don't filter - } + const baseRecentItems = React.useMemo(() => { + return recentItems.filter((item) => !favoriteIds.has(config.getItemId(item))); + }, [recentItems, favoriteIds, config]); - // User is typing - filter the list - return recentItems.filter(item => config.filterItem(item, inputText, context)); - }, [recentItems, inputText, selectedItem, config, context]); + const baseAllItems = React.useMemo(() => { + const recentIds = new Set(baseRecentItems.map((item) => config.getItemId(item))); + return items.filter((item) => !favoriteIds.has(config.getItemId(item)) && !recentIds.has(config.getItemId(item))); + }, [items, baseRecentItems, favoriteIds, config]); const filteredFavoriteItems = React.useMemo(() => { if (!inputText.trim()) return favoriteItems; + return favoriteItems.filter((item) => config.filterItem(item, inputText, context)); + }, [favoriteItems, inputText, config, context]); - const selectedDisplayText = selectedItem ? config.formatForDisplay(selectedItem, context) : null; - if (selectedDisplayText && inputText === selectedDisplayText) { - return favoriteItems; // Show all favorites, don't filter - } - - // Don't filter if text matches a favorite (user clicked from list) - if (favoriteItems.some(item => config.formatForDisplay(item, context) === inputText)) { - return favoriteItems; // Show all favorites, don't filter - } - - return favoriteItems.filter(item => config.filterItem(item, inputText, context)); - }, [favoriteItems, inputText, selectedItem, config, context]); - - // Check if current input can be added to favorites - const canAddToFavorites = React.useMemo(() => { - if (!onToggleFavorite || !inputText.trim()) return false; - - // Parse input to see if it's a valid item - const parsedItem = config.parseFromDisplay(inputText.trim(), context); - if (!parsedItem) return false; + const filteredRecentItems = React.useMemo(() => { + if (!inputText.trim()) return baseRecentItems; + return baseRecentItems.filter((item) => config.filterItem(item, inputText, context)); + }, [baseRecentItems, inputText, config, context]); - // Check if already in favorites - const parsedId = config.getItemId(parsedItem); - return !favoriteItems.some(fav => config.getItemId(fav) === parsedId); - }, [inputText, favoriteItems, config, context, onToggleFavorite]); + const filteredItems = React.useMemo(() => { + if (!inputText.trim()) return baseAllItems; + return baseAllItems.filter((item) => config.filterItem(item, inputText, context)); + }, [baseAllItems, inputText, config, context]); - // Handle input text change const handleInputChange = (text: string) => { - isUserTyping.current = true; // User is actively typing setInputText(text); - // If allowCustomInput, try to parse and select if (config.allowCustomInput && text.trim()) { const parsedItem = config.parseFromDisplay(text.trim(), context); - if (parsedItem) { - onSelect(parsedItem); - } - } - }; - - // Handle item selection from list - const handleSelectItem = (item: T) => { - isUserTyping.current = false; // User clicked from list - setInputText(config.formatForDisplay(item, context)); - onSelect(item); - }; - - // Handle clear button - const handleClear = () => { - isUserTyping.current = false; - setInputText(''); - // Don't clear selection - just clear input - }; - - // Handle add to favorites - const handleAddToFavorites = () => { - if (!canAddToFavorites || !onToggleFavorite) return; - - const parsedItem = config.parseFromDisplay(inputText.trim(), context); - if (parsedItem) { - onToggleFavorite(parsedItem); + if (parsedItem) onSelect(parsedItem); } }; - // Handle remove from favorites - const handleRemoveFavorite = (item: T) => { - if (!onToggleFavorite) return; - - Modal.alert( - 'Remove Favorite', - `Remove "${config.getItemTitle(item)}" from ${config.favoritesSectionTitle.toLowerCase()}?`, - [ - { text: t('common.cancel'), style: 'cancel' }, - { - text: 'Remove', - style: 'destructive', - onPress: () => onToggleFavorite(item) - } - ] - ); - }; - - // Render status with StatusDot (DRY helper - matches Item.tsx detail style) const renderStatus = (status: { text: string; color: string; dotColor: string; isPulsing?: boolean } | null | undefined) => { if (!status) return null; return ( @@ -394,22 +168,50 @@ export function SearchableListSelector<T>(props: SearchableListSelectorProps<T>) isPulsing={status.isPulsing} size={6} /> - <Text style={[ - Typography.default('regular'), - { - fontSize: Platform.select({ ios: 17, default: 16 }), - letterSpacing: Platform.select({ ios: -0.41, default: 0.15 }), - color: status.color - } - ]}> + <Text + style={[ + Typography.default('regular'), + { + fontSize: Platform.select({ ios: 17, default: 16 }), + letterSpacing: Platform.select({ ios: -0.41, default: 0.15 }), + color: status.color, + }, + ]} + > {status.text} </Text> </View> ); }; - // Render individual item (for recent items) - const renderItem = (item: T, isSelected: boolean, isLast: boolean, showDividerOverride?: boolean, forRecent = false) => { + const renderFavoriteToggle = (item: T, isFavorite: boolean) => { + if (!showFavorites || !onToggleFavorite) return null; + + const canRemove = config.canRemoveFavorite?.(item) ?? true; + const disabled = isFavorite && !canRemove; + const selectedColor = rt.themeName === 'dark' ? theme.colors.text : theme.colors.button.primary.background; + const color = isFavorite ? selectedColor : theme.colors.textSecondary; + + return ( + <Pressable + hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} + disabled={disabled} + onPress={(e) => { + e.stopPropagation(); + if (disabled) return; + onToggleFavorite(item); + }} + > + <Ionicons + name={isFavorite ? 'star' : 'star-outline'} + size={24} + color={disabled ? theme.colors.textSecondary : color} + /> + </Pressable> + ); + }; + + const renderItem = (item: T, isSelected: boolean, isLast: boolean, showDividerOverride?: boolean, forRecent = false, forFavorite = false) => { const itemId = config.getItemId(item); const title = config.getItemTitle(item); const subtitle = forRecent && config.getRecentItemSubtitle @@ -417,8 +219,13 @@ export function SearchableListSelector<T>(props: SearchableListSelectorProps<T>) : config.getItemSubtitle?.(item); const icon = forRecent && config.getRecentItemIcon ? config.getRecentItemIcon(item) - : config.getItemIcon(item); + : forFavorite && config.getFavoriteItemIcon + ? config.getFavoriteItemIcon(item) + : config.getItemIcon(item); const status = config.getItemStatus?.(item, theme); + const statusExtra = config.getItemStatusExtra?.(item); + const isFavorite = favoriteIds.has(itemId) || forFavorite; + const selectedColor = rt.themeName === 'dark' ? theme.colors.text : theme.colors.button.primary.background; return ( <Item @@ -427,248 +234,162 @@ export function SearchableListSelector<T>(props: SearchableListSelectorProps<T>) subtitle={subtitle} subtitleLines={0} leftElement={icon} - rightElement={ + rightElement={( <View style={{ flexDirection: 'row', alignItems: 'center', gap: ITEM_SPACING_GAP }}> - {renderStatus(status)} - {isSelected && ( + <View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}> + {renderStatus(status)} + {statusExtra} + </View> + <View style={{ width: 24, alignItems: 'center', justifyContent: 'center' }}> <Ionicons name="checkmark-circle" - size={20} - color={theme.colors.button.primary.tint} + size={24} + color={selectedColor} + style={{ opacity: isSelected ? 1 : 0 }} /> - )} + </View> + {renderFavoriteToggle(item, isFavorite)} </View> - } - onPress={() => handleSelectItem(item)} + )} + onPress={() => onSelect(item)} showChevron={false} selected={isSelected} showDivider={showDividerOverride !== undefined ? showDividerOverride : !isLast} - style={[ - styles.itemBackground, - config.compactItems ? styles.compactItemStyle : undefined, - isSelected ? styles.selectedItemStyle : undefined - ]} /> ); }; - // "Show More" logic (matches Working Directory pattern) - const itemsToShow = (inputText.trim() && isUserTyping.current) || showAllRecent + const showAllRecentItems = showAllRecent || inputText.trim().length > 0; + const recentItemsToShow = showAllRecentItems ? filteredRecentItems : filteredRecentItems.slice(0, RECENT_ITEMS_DEFAULT_VISIBLE); + const hasRecentGroupBase = showRecent && baseRecentItems.length > 0; + const hasFavoritesGroupBase = showFavorites && favoriteItems.length > 0; + const hasAllGroupBase = showAll && baseAllItems.length > 0; + + const effectiveSearchPlacement = React.useMemo(() => { + if (!showSearch) return 'header' as const; + if (searchPlacement === 'header') return 'header' as const; + + if (searchPlacement === 'favorites' && hasFavoritesGroupBase) return 'favorites' as const; + if (searchPlacement === 'recent' && hasRecentGroupBase) return 'recent' as const; + if (searchPlacement === 'all' && hasAllGroupBase) return 'all' as const; + + // Fall back to the first visible group so the search never disappears. + if (hasFavoritesGroupBase) return 'favorites' as const; + if (hasRecentGroupBase) return 'recent' as const; + if (hasAllGroupBase) return 'all' as const; + return 'header' as const; + }, [hasAllGroupBase, hasFavoritesGroupBase, hasRecentGroupBase, searchPlacement, showSearch]); + + const showNoMatches = inputText.trim().length > 0; + const shouldRenderRecentGroup = showRecent && (filteredRecentItems.length > 0 || (effectiveSearchPlacement === 'recent' && showSearch && hasRecentGroupBase)); + const shouldRenderFavoritesGroup = showFavorites && (filteredFavoriteItems.length > 0 || (effectiveSearchPlacement === 'favorites' && showSearch && hasFavoritesGroupBase)); + const shouldRenderAllGroup = showAll && (filteredItems.length > 0 || (effectiveSearchPlacement === 'all' && showSearch && hasAllGroupBase)); + + const searchNodeHeader = showSearch ? ( + <SearchHeader + value={inputText} + onChangeText={handleInputChange} + placeholder={config.searchPlaceholder} + /> + ) : null; + + const searchNodeEmbedded = showSearch ? ( + <SearchHeader + value={inputText} + onChangeText={handleInputChange} + placeholder={config.searchPlaceholder} + containerStyle={{ + backgroundColor: 'transparent', + borderBottomWidth: 0, + }} + /> + ) : null; + + const renderEmptyRow = (title: string) => ( + <Item + title={title} + showChevron={false} + showDivider={false} + disabled={true} + /> + ); + return ( <> - {/* Search Input */} - {showSearch && ( - <View style={styles.inputContainer}> - <View style={styles.inputWrapper}> - <View style={styles.inputInner}> - <View style={styles.inputField}> - <MultiTextInput - value={inputText} - onChangeText={handleInputChange} - placeholder={config.searchPlaceholder} - maxHeight={40} - paddingTop={8} - paddingBottom={8} - /> - </View> - {inputText.trim() && ( - <Pressable - onPress={handleClear} - hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} - style={({ pressed }) => ([ - styles.clearButton, - { opacity: pressed ? 0.6 : 0.8 } - ])} - > - <Ionicons name="close" size={14} color={theme.colors.input.background} /> - </Pressable> - )} - </View> - </View> - {showFavorites && onToggleFavorite && ( - <Pressable - onPress={handleAddToFavorites} - disabled={!canAddToFavorites} - style={({ pressed }) => ([ - styles.favoriteButton, - { - backgroundColor: canAddToFavorites - ? theme.colors.button.primary.background - : theme.colors.divider, - opacity: pressed ? 0.7 : 1, - } - ])} - > - <Ionicons - name="star" - size={20} - color={canAddToFavorites ? theme.colors.button.primary.tint : theme.colors.textSecondary} - /> - </Pressable> + {effectiveSearchPlacement === 'header' && searchNodeHeader} + + {shouldRenderRecentGroup && ( + <ItemGroup title={config.recentSectionTitle}> + {effectiveSearchPlacement === 'recent' && searchNodeEmbedded} + {recentItemsToShow.length === 0 + ? renderEmptyRow(showNoMatches ? t('common.noMatches') : config.noItemsMessage) + : recentItemsToShow.map((item, index, arr) => { + const itemId = config.getItemId(item); + const selectedId = selectedItem ? config.getItemId(selectedItem) : null; + const isSelected = itemId === selectedId; + const isLast = index === arr.length - 1; + + const showDivider = !isLast || + (!inputText.trim() && + !showAllRecent && + filteredRecentItems.length > RECENT_ITEMS_DEFAULT_VISIBLE); + + return renderItem(item, isSelected, isLast, showDivider, true, false); + })} + + {!inputText.trim() && filteredRecentItems.length > RECENT_ITEMS_DEFAULT_VISIBLE && recentItemsToShow.length > 0 && ( + <Item + title={showAllRecent + ? t('machineLauncher.showLess') + : t('machineLauncher.showAll', { count: filteredRecentItems.length }) + } + onPress={() => setShowAllRecent(!showAllRecent)} + showChevron={false} + showDivider={false} + titleStyle={styles.showMoreTitle} + /> )} - </View> + </ItemGroup> )} - {/* Recent Items Section */} - {showRecent && filteredRecentItems.length > 0 && ( - <> - <Pressable - style={styles.sectionHeader} - onPress={toggleRecentSection} - > - <Text style={styles.sectionHeaderText}>{config.recentSectionTitle}</Text> - <Ionicons - name={showRecentSection ? "chevron-up" : "chevron-down"} - size={20} - color={theme.colors.text} - /> - </Pressable> - - {showRecentSection && ( - <ItemGroup title=""> - {itemsToShow.map((item, index, arr) => { - const itemId = config.getItemId(item); - const selectedId = selectedItem ? config.getItemId(selectedItem) : null; - const isSelected = itemId === selectedId; - const isLast = index === arr.length - 1; - - // Override divider logic for "Show More" button - const showDivider = !isLast || - (!(inputText.trim() && isUserTyping.current) && - !showAllRecent && - filteredRecentItems.length > RECENT_ITEMS_DEFAULT_VISIBLE); - - return renderItem(item, isSelected, isLast, showDivider, true); - })} - - {/* Show More Button */} - {!(inputText.trim() && isUserTyping.current) && - filteredRecentItems.length > RECENT_ITEMS_DEFAULT_VISIBLE && ( - <Item - title={showAllRecent - ? t('machineLauncher.showLess') - : t('machineLauncher.showAll', { count: filteredRecentItems.length }) - } - onPress={() => setShowAllRecent(!showAllRecent)} - showChevron={false} - showDivider={false} - titleStyle={styles.showMoreTitle} - /> - )} - </ItemGroup> - )} - </> + {shouldRenderFavoritesGroup && ( + <ItemGroup title={config.favoritesSectionTitle}> + {effectiveSearchPlacement === 'favorites' && searchNodeEmbedded} + {filteredFavoriteItems.length === 0 + ? renderEmptyRow(showNoMatches ? t('common.noMatches') : config.noItemsMessage) + : filteredFavoriteItems.map((item, index) => { + const itemId = config.getItemId(item); + const selectedId = selectedItem ? config.getItemId(selectedItem) : null; + const isSelected = itemId === selectedId; + const isLast = index === filteredFavoriteItems.length - 1; + return renderItem(item, isSelected, isLast, !isLast, false, true); + })} + </ItemGroup> )} - {/* Favorites Section */} - {showFavorites && filteredFavoriteItems.length > 0 && ( - <> - <Pressable - style={styles.sectionHeader} - onPress={toggleFavoritesSection} - > - <Text style={styles.sectionHeaderText}>{config.favoritesSectionTitle}</Text> - <Ionicons - name={showFavoritesSection ? "chevron-up" : "chevron-down"} - size={20} - color={theme.colors.text} - /> - </Pressable> - - {showFavoritesSection && ( - <ItemGroup title=""> - {filteredFavoriteItems.map((item, index) => { - const itemId = config.getItemId(item); - const selectedId = selectedItem ? config.getItemId(selectedItem) : null; - const isSelected = itemId === selectedId; - const isLast = index === filteredFavoriteItems.length - 1; - - const title = config.getItemTitle(item); - const subtitle = config.getItemSubtitle?.(item); - const icon = config.getFavoriteItemIcon?.(item) || config.getItemIcon(item); - const status = config.getItemStatus?.(item, theme); - const canRemove = config.canRemoveFavorite?.(item) ?? true; - - return ( - <Item - key={itemId} - title={title} - subtitle={subtitle} - subtitleLines={0} - leftElement={icon} - rightElement={ - <View style={{ flexDirection: 'row', alignItems: 'center', gap: ITEM_SPACING_GAP }}> - {renderStatus(status)} - {isSelected && ( - <Ionicons - name="checkmark-circle" - size={20} - color={theme.colors.button.primary.tint} - /> - )} - {onToggleFavorite && canRemove && ( - <Pressable - hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} - onPress={(e) => { - e.stopPropagation(); - handleRemoveFavorite(item); - }} - > - <Ionicons name="trash-outline" size={20} color="#FF6B6B" /> - </Pressable> - )} - </View> - } - onPress={() => handleSelectItem(item)} - showChevron={false} - selected={isSelected} - showDivider={!isLast} - style={[ - styles.itemBackground, - config.compactItems ? styles.compactItemStyle : undefined, - isSelected ? styles.selectedItemStyle : undefined - ]} - /> - ); - })} - </ItemGroup> - )} - </> + {shouldRenderAllGroup && ( + <ItemGroup title={config.allSectionTitle ?? t('common.all')}> + {effectiveSearchPlacement === 'all' && searchNodeEmbedded} + {filteredItems.length === 0 + ? renderEmptyRow(showNoMatches ? t('common.noMatches') : config.noItemsMessage) + : filteredItems.map((item, index) => { + const itemId = config.getItemId(item); + const selectedId = selectedItem ? config.getItemId(selectedItem) : null; + const isSelected = itemId === selectedId; + const isLast = index === filteredItems.length - 1; + return renderItem(item, isSelected, isLast, !isLast, false, false); + })} + </ItemGroup> )} - {/* All Items Section - always shown when items provided */} - {items.length > 0 && ( - <> - <Pressable - style={styles.sectionHeader} - onPress={toggleAllItemsSection} - > - <Text style={styles.sectionHeaderText}> - {config.recentSectionTitle.replace('Recent ', 'All ')} - </Text> - <Ionicons - name={showAllItemsSection ? "chevron-up" : "chevron-down"} - size={20} - color={theme.colors.text} - /> - </Pressable> - - {showAllItemsSection && ( - <ItemGroup title=""> - {items.map((item, index) => { - const itemId = config.getItemId(item); - const selectedId = selectedItem ? config.getItemId(selectedItem) : null; - const isSelected = itemId === selectedId; - const isLast = index === items.length - 1; - - return renderItem(item, isSelected, isLast, !isLast, false); - })} - </ItemGroup> - )} - </> + {!shouldRenderRecentGroup && !shouldRenderFavoritesGroup && !shouldRenderAllGroup && ( + <ItemGroup> + {effectiveSearchPlacement !== 'header' && searchNodeEmbedded} + {renderEmptyRow(showNoMatches ? t('common.noMatches') : config.noItemsMessage)} + </ItemGroup> )} </> ); diff --git a/expo-app/sources/components/SessionTypeSelector.tsx b/expo-app/sources/components/SessionTypeSelector.tsx index 33aefd357..3e72fad72 100644 --- a/expo-app/sources/components/SessionTypeSelector.tsx +++ b/expo-app/sources/components/SessionTypeSelector.tsx @@ -1,142 +1,81 @@ import React from 'react'; -import { View, Text, Pressable, Platform } from 'react-native'; +import { View } from 'react-native'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; -import { Typography } from '@/constants/Typography'; +import { ItemGroup } from '@/components/ui/lists/ItemGroup'; +import { Item } from '@/components/ui/lists/Item'; import { t } from '@/text'; -interface SessionTypeSelectorProps { +export interface SessionTypeSelectorProps { value: 'simple' | 'worktree'; onChange: (value: 'simple' | 'worktree') => void; + title?: string | null; } const stylesheet = StyleSheet.create((theme) => ({ - container: { - backgroundColor: theme.colors.surface, - borderRadius: Platform.select({ default: 12, android: 16 }), - marginBottom: 12, - overflow: 'hidden', - }, - title: { - fontSize: 13, - color: theme.colors.textSecondary, - marginBottom: 8, - marginLeft: 16, - marginTop: 12, - ...Typography.default('semiBold'), - }, - optionContainer: { - flexDirection: 'row', - alignItems: 'center', - paddingHorizontal: 16, - paddingVertical: 12, - minHeight: 44, - }, - optionPressed: { - backgroundColor: theme.colors.surfacePressed, - }, - radioButton: { + radioOuter: { width: 20, height: 20, borderRadius: 10, borderWidth: 2, alignItems: 'center', justifyContent: 'center', - marginRight: 12, }, - radioButtonActive: { + radioActive: { borderColor: theme.colors.radio.active, }, - radioButtonInactive: { + radioInactive: { borderColor: theme.colors.radio.inactive, }, - radioButtonDot: { + radioDot: { width: 8, height: 8, borderRadius: 4, backgroundColor: theme.colors.radio.dot, }, - optionContent: { - flex: 1, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - }, - optionLabel: { - fontSize: 16, - ...Typography.default('regular'), - }, - optionLabelActive: { - color: theme.colors.text, - }, - optionLabelInactive: { - color: theme.colors.text, - }, - divider: { - height: Platform.select({ ios: 0.33, default: 0.5 }), - backgroundColor: theme.colors.divider, - marginLeft: 48, - }, })); -export const SessionTypeSelector: React.FC<SessionTypeSelectorProps> = ({ value, onChange }) => { - const { theme } = useUnistyles(); +export function SessionTypeSelectorRows({ value, onChange }: Pick<SessionTypeSelectorProps, 'value' | 'onChange'>) { const styles = stylesheet; - const handlePress = (type: 'simple' | 'worktree') => { - onChange(type); - }; - return ( - <View style={styles.container}> - <Text style={styles.title}>{t('newSession.sessionType.title')}</Text> - - <Pressable - onPress={() => handlePress('simple')} - style={({ pressed }) => [ - styles.optionContainer, - pressed && styles.optionPressed, - ]} - > - <View style={[ - styles.radioButton, - value === 'simple' ? styles.radioButtonActive : styles.radioButtonInactive, - ]}> - {value === 'simple' && <View style={styles.radioButtonDot} />} - </View> - <View style={styles.optionContent}> - <Text style={[ - styles.optionLabel, - value === 'simple' ? styles.optionLabelActive : styles.optionLabelInactive, - ]}> - {t('newSession.sessionType.simple')} - </Text> - </View> - </Pressable> + <> + <Item + title={t('newSession.sessionType.simple')} + leftElement={( + <View style={[styles.radioOuter, value === 'simple' ? styles.radioActive : styles.radioInactive]}> + {value === 'simple' && <View style={styles.radioDot} />} + </View> + )} + selected={value === 'simple'} + onPress={() => onChange('simple')} + showChevron={false} + showDivider={true} + /> + + <Item + title={t('newSession.sessionType.worktree')} + leftElement={( + <View style={[styles.radioOuter, value === 'worktree' ? styles.radioActive : styles.radioInactive]}> + {value === 'worktree' && <View style={styles.radioDot} />} + </View> + )} + selected={value === 'worktree'} + onPress={() => onChange('worktree')} + showChevron={false} + showDivider={false} + /> + </> + ); +} - <View style={styles.divider} /> +export function SessionTypeSelector({ value, onChange, title = t('newSession.sessionType.title') }: SessionTypeSelectorProps) { + if (title === null) { + return <SessionTypeSelectorRows value={value} onChange={onChange} />; + } - <Pressable - onPress={() => handlePress('worktree')} - style={({ pressed }) => [ - styles.optionContainer, - pressed && styles.optionPressed, - ]} - > - <View style={[ - styles.radioButton, - value === 'worktree' ? styles.radioButtonActive : styles.radioButtonInactive, - ]}> - {value === 'worktree' && <View style={styles.radioButtonDot} />} - </View> - <View style={styles.optionContent}> - <Text style={[ - styles.optionLabel, - value === 'worktree' ? styles.optionLabelActive : styles.optionLabelInactive, - ]}> - {t('newSession.sessionType.worktree')} - </Text> - </View> - </Pressable> - </View> + return ( + <ItemGroup title={title}> + <SessionTypeSelectorRows value={value} onChange={onChange} /> + </ItemGroup> ); -}; \ No newline at end of file +} diff --git a/expo-app/sources/components/SessionsList.tsx b/expo-app/sources/components/SessionsList.tsx index a3999ed91..a3403ed3e 100644 --- a/expo-app/sources/components/SessionsList.tsx +++ b/expo-app/sources/components/SessionsList.tsx @@ -3,7 +3,7 @@ import { View, Pressable, FlatList, Platform } from 'react-native'; import { Swipeable } from 'react-native-gesture-handler'; import { Text } from '@/components/StyledText'; import { usePathname } from 'expo-router'; -import { SessionListViewItem } from '@/sync/storage'; +import { SessionListViewItem, useHasUnreadMessages } from '@/sync/storage'; import { Ionicons } from '@expo/vector-icons'; import { getSessionName, useSessionStatus, getSessionSubtitle, getSessionAvatarId } from '@/utils/sessionUtils'; import { Avatar } from './Avatar'; @@ -19,12 +19,12 @@ import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { useIsTablet } from '@/utils/responsive'; import { requestReview } from '@/utils/requestReview'; import { UpdateBanner } from './UpdateBanner'; -import { layout } from './layout'; +import { layout } from '@/components/layout'; import { useNavigateToSession } from '@/hooks/useNavigateToSession'; import { t } from '@/text'; import { useRouter } from 'expo-router'; -import { Item } from './Item'; -import { ItemGroup } from './ItemGroup'; +import { Item } from '@/components/ui/lists/Item'; +import { ItemGroup } from '@/components/ui/lists/ItemGroup'; import { useHappyAction } from '@/hooks/useHappyAction'; import { sessionDelete } from '@/sync/ops'; import { HappyError } from '@/utils/errors'; @@ -204,7 +204,6 @@ export function SessionsList() { const compactSessionView = useSetting('compactSessionView'); const router = useRouter(); const selectable = isTablet; - const experiments = useSetting('experiments'); const dataWithSelected = selectable ? React.useMemo(() => { return data?.map(item => ({ ...item, @@ -279,8 +278,8 @@ export function SessionsList() { const prevItem = index > 0 && dataWithSelected ? dataWithSelected[index - 1] : null; const nextItem = index < (dataWithSelected?.length || 0) - 1 && dataWithSelected ? dataWithSelected[index + 1] : null; - const isFirst = prevItem?.type === 'header'; - const isLast = nextItem?.type === 'header' || nextItem == null || nextItem?.type === 'active-sessions'; + const isFirst = prevItem?.type === 'header' || prevItem?.type === 'project-group'; + const isLast = nextItem?.type === 'header' || nextItem?.type === 'project-group' || nextItem == null || nextItem?.type === 'active-sessions'; const isSingle = isFirst && isLast; return ( @@ -290,6 +289,7 @@ export function SessionsList() { isFirst={isFirst} isLast={isLast} isSingle={isSingle} + variant={item.variant} /> ); } @@ -323,12 +323,13 @@ export function SessionsList() { } // Sub-component that handles session message logic -const SessionItem = React.memo(({ session, selected, isFirst, isLast, isSingle }: { +const SessionItem = React.memo(({ session, selected, isFirst, isLast, isSingle, variant }: { session: Session; selected?: boolean; isFirst?: boolean; isLast?: boolean; isSingle?: boolean; + variant?: 'default' | 'no-path'; }) => { const styles = stylesheet; const sessionStatus = useSessionStatus(session); @@ -365,6 +366,7 @@ const SessionItem = React.memo(({ session, selected, isFirst, isLast, isSingle } const avatarId = React.useMemo(() => { return getSessionAvatarId(session); }, [session]); + const hasUnreadMessages = useHasUnreadMessages(session.id); const itemContent = ( <Pressable @@ -387,7 +389,13 @@ const SessionItem = React.memo(({ session, selected, isFirst, isLast, isSingle } }} > <View style={styles.avatarContainer}> - <Avatar id={avatarId} size={48} monochrome={!sessionStatus.isConnected} flavor={session.metadata?.flavor} /> + <Avatar + id={avatarId} + size={48} + monochrome={!sessionStatus.isConnected} + flavor={session.metadata?.flavor} + hasUnreadMessages={hasUnreadMessages} + /> {session.draft && ( <View style={styles.draftIconContainer}> <Ionicons @@ -410,9 +418,11 @@ const SessionItem = React.memo(({ session, selected, isFirst, isLast, isSingle } </View> {/* Subtitle line */} - <Text style={styles.sessionSubtitle} numberOfLines={1}> - {sessionSubtitle} - </Text> + {variant !== 'no-path' && ( + <Text style={styles.sessionSubtitle} numberOfLines={1}> + {sessionSubtitle} + </Text> + )} {/* Status line with dot */} <View style={styles.statusRow}> diff --git a/expo-app/sources/components/SettingsView.tsx b/expo-app/sources/components/SettingsView.tsx index 249345e97..2496f5be9 100644 --- a/expo-app/sources/components/SettingsView.tsx +++ b/expo-app/sources/components/SettingsView.tsx @@ -1,15 +1,16 @@ -import { View, ScrollView, Pressable, Platform, Linking } from 'react-native'; +import { View, ScrollView, Pressable, Platform, Linking, Text as RNText, ActivityIndicator } from 'react-native'; import { Image } from 'expo-image'; import * as React from 'react'; import { Text } from '@/components/StyledText'; import { useRouter } from 'expo-router'; import { Ionicons } from '@expo/vector-icons'; +import { useFocusEffect } from '@react-navigation/native'; import Constants from 'expo-constants'; import { useAuth } from '@/auth/AuthContext'; import { Typography } from "@/constants/Typography"; -import { Item } from '@/components/Item'; -import { ItemGroup } from '@/components/ItemGroup'; -import { ItemList } from '@/components/ItemList'; +import { Item } from '@/components/ui/lists/Item'; +import { ItemGroup } from '@/components/ui/lists/ItemGroup'; +import { ItemList } from '@/components/ui/lists/ItemList'; import { useConnectTerminal } from '@/hooks/useConnectTerminal'; import { useEntitlement, useLocalSettingMutable, useSetting } from '@/sync/storage'; import { sync } from '@/sync/sync'; @@ -28,6 +29,9 @@ import { useProfile } from '@/sync/storage'; import { getDisplayName, getAvatarUrl, getBio } from '@/sync/profile'; import { Avatar } from '@/components/Avatar'; import { t } from '@/text'; +import { MachineCliGlyphs } from '@/components/sessions/new/components/MachineCliGlyphs'; +import { HappyError } from '@/utils/errors'; +import { DEFAULT_AGENT_ID, getAgentCore, getAgentIconSource, getAgentIconTintColor, resolveAgentIdFromConnectedServiceId } from '@/agents/catalog'; export const SettingsView = React.memo(function SettingsView() { const { theme } = useUnistyles(); @@ -37,14 +41,62 @@ export const SettingsView = React.memo(function SettingsView() { const [devModeEnabled, setDevModeEnabled] = useLocalSettingMutable('devModeEnabled'); const isPro = __DEV__ || useEntitlement('pro'); const experiments = useSetting('experiments'); + const expUsageReporting = useSetting('expUsageReporting'); + const useProfiles = useSetting('useProfiles'); + const terminalUseTmux = useSetting('sessionUseTmux'); const isCustomServer = isUsingCustomServer(); const allMachines = useAllMachines(); const profile = useProfile(); const displayName = getDisplayName(profile); const avatarUrl = getAvatarUrl(profile); const bio = getBio(profile); + const [githubUnavailableReason, setGithubUnavailableReason] = React.useState<string | null>(null); + + const anthropicAgentId = resolveAgentIdFromConnectedServiceId('anthropic') ?? DEFAULT_AGENT_ID; + const anthropicAgentCore = getAgentCore(anthropicAgentId); const { connectTerminal, connectWithUrl, isLoading } = useConnectTerminal(); + const [refreshingMachines, refreshMachines] = useHappyAction(async () => { + await sync.refreshMachinesThrottled({ force: true }); + }); + + useFocusEffect( + React.useCallback(() => { + void sync.refreshMachinesThrottled({ staleMs: 30_000 }); + }, []) + ); + + const machinesTitle = React.useMemo(() => { + const headerTextStyle = [ + Typography.default('regular'), + { + color: theme.colors.groupped.sectionTitle, + fontSize: Platform.select({ ios: 13, default: 14 }), + lineHeight: Platform.select({ ios: 18, default: 20 }), + letterSpacing: Platform.select({ ios: -0.08, default: 0.1 }), + textTransform: 'uppercase' as const, + fontWeight: Platform.select({ ios: 'normal', default: '500' }) as any, + }, + ]; + + return ( + <View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}> + <RNText style={headerTextStyle as any}>{t('settings.machines')}</RNText> + <Pressable + onPress={refreshMachines} + hitSlop={10} + style={{ padding: 2 }} + accessibilityRole="button" + accessibilityLabel="Refresh" + disabled={refreshingMachines} + > + {refreshingMachines + ? <ActivityIndicator size="small" color={theme.colors.textSecondary} /> + : <Ionicons name="refresh" size={18} color={theme.colors.textSecondary} />} + </Pressable> + </View> + ); + }, [refreshMachines, refreshingMachines, theme.colors.groupped.sectionTitle, theme.colors.textSecondary]); const handleGitHub = async () => { const url = 'https://github.com/slopus/happy'; @@ -92,8 +144,16 @@ export const SettingsView = React.memo(function SettingsView() { // GitHub connection const [connectingGitHub, connectGitHub] = useHappyAction(async () => { - const params = await getGitHubOAuthParams(auth.credentials!); - await Linking.openURL(params.url); + setGithubUnavailableReason(null); + try { + const params = await getGitHubOAuthParams(auth.credentials!); + await Linking.openURL(params.url); + } catch (e) { + if (e instanceof HappyError && e.canTryAgain === false) { + setGithubUnavailableReason(e.message); + } + throw e; + } }); // GitHub disconnection @@ -110,14 +170,18 @@ export const SettingsView = React.memo(function SettingsView() { // Anthropic connection const [connectingAnthropic, connectAnthropic] = useHappyAction(async () => { - router.push('/settings/connect/claude'); + const route = anthropicAgentCore.connectedService.connectRoute; + if (route) { + router.push(route); + } }); // Anthropic disconnection const [disconnectingAnthropic, handleDisconnectAnthropic] = useHappyAction(async () => { + const serviceName = anthropicAgentCore.connectedService.name; const confirmed = await Modal.confirm( - t('modals.disconnectService', { service: 'Claude' }), - t('modals.disconnectServiceConfirm', { service: 'Claude' }), + t('modals.disconnectService', { service: serviceName }), + t('modals.disconnectServiceConfirm', { service: serviceName }), { confirmText: t('modals.disconnect'), destructive: true } ); if (confirmed) { @@ -210,15 +274,16 @@ export const SettingsView = React.memo(function SettingsView() { <ItemGroup title={t('settings.connectedAccounts')}> <Item - title="Claude Code" + title={anthropicAgentCore.connectedService.name} subtitle={isAnthropicConnected ? t('settingsAccount.statusActive') : t('settings.connectAccount') } icon={ <Image - source={require('@/assets/images/icon-claude.png')} + source={getAgentIconSource(anthropicAgentId)} style={{ width: 29, height: 29 }} + tintColor={getAgentIconTintColor(anthropicAgentId, theme)} contentFit="contain" /> } @@ -230,7 +295,7 @@ export const SettingsView = React.memo(function SettingsView() { title={t('settings.github')} subtitle={isGitHubConnected ? t('settings.githubConnected', { login: profile.github?.login! }) - : t('settings.connectGithubAccount') + : (githubUnavailableReason ?? t('settings.connectGithubAccount')) } icon={ <Ionicons @@ -239,7 +304,10 @@ export const SettingsView = React.memo(function SettingsView() { color={isGitHubConnected ? theme.colors.status.connected : theme.colors.textSecondary} /> } - onPress={isGitHubConnected ? handleDisconnectGitHub : connectGitHub} + onPress={isGitHubConnected + ? handleDisconnectGitHub + : (githubUnavailableReason ? undefined : connectGitHub) + } loading={connectingGitHub || disconnectingGitHub} showChevron={false} /> @@ -257,7 +325,7 @@ export const SettingsView = React.memo(function SettingsView() { {/* Machines (sorted: online first, then last seen desc) */} {allMachines.length > 0 && ( - <ItemGroup title={t('settings.machines')}> + <ItemGroup title={machinesTitle}> {[...allMachines].map((machine) => { const isOnline = isMachineOnline(machine); const host = machine.metadata?.host || 'Unknown'; @@ -268,14 +336,37 @@ export const SettingsView = React.memo(function SettingsView() { const title = displayName || host; // Build subtitle: show hostname if different from title, plus platform and status - let subtitle = ''; + let subtitleTop = ''; if (displayName && displayName !== host) { - subtitle = host; + subtitleTop = host; } - if (platform) { - subtitle = subtitle ? `${subtitle} • ${platform}` : platform; - } - subtitle = subtitle ? `${subtitle} • ${isOnline ? t('status.online') : t('status.offline')}` : (isOnline ? t('status.online') : t('status.offline')); + const statusText = isOnline ? t('status.online') : t('status.offline'); + const statusLineText = platform ? `${platform} • ${statusText}` : statusText; + + const subtitle = ( + <View style={{ gap: 2 }}> + {subtitleTop ? ( + <RNText style={[Typography.default(), { fontSize: 14, color: theme.colors.textSecondary, lineHeight: 20 }]}> + {subtitleTop} + </RNText> + ) : null} + <View style={{ flexDirection: 'row', alignItems: 'center' }}> + <RNText + style={[ + Typography.default(), + { fontSize: 14, color: theme.colors.textSecondary, lineHeight: 20, flexShrink: 1 } + ]} + numberOfLines={1} + > + {statusLineText} + </RNText> + <RNText style={[Typography.default(), { fontSize: 14, color: theme.colors.textSecondary, lineHeight: 20, opacity: 0.8 }]}> + {' • '} + </RNText> + <MachineCliGlyphs machineId={machine.id} isOnline={isOnline} /> + </View> + </View> + ); return ( <Item @@ -302,38 +393,54 @@ export const SettingsView = React.memo(function SettingsView() { title={t('settings.account')} subtitle={t('settings.accountSubtitle')} icon={<Ionicons name="person-circle-outline" size={29} color="#007AFF" />} - onPress={() => router.push('/settings/account')} + onPress={() => router.push('/(app)/settings/account')} /> <Item title={t('settings.appearance')} subtitle={t('settings.appearanceSubtitle')} icon={<Ionicons name="color-palette-outline" size={29} color="#5856D6" />} - onPress={() => router.push('/settings/appearance')} + onPress={() => router.push('/(app)/settings/appearance')} /> <Item title={t('settings.voiceAssistant')} subtitle={t('settings.voiceAssistantSubtitle')} icon={<Ionicons name="mic-outline" size={29} color="#34C759" />} - onPress={() => router.push('/settings/voice')} + onPress={() => router.push('/(app)/settings/voice')} /> <Item title={t('settings.featuresTitle')} subtitle={t('settings.featuresSubtitle')} icon={<Ionicons name="flask-outline" size={29} color="#FF9500" />} - onPress={() => router.push('/settings/features')} + onPress={() => router.push('/(app)/settings/features')} /> <Item - title={t('settings.profiles')} - subtitle={t('settings.profilesSubtitle')} - icon={<Ionicons name="person-outline" size={29} color="#AF52DE" />} - onPress={() => router.push('/settings/profiles')} + title={t('settings.session')} + subtitle={terminalUseTmux ? t('settings.sessionSubtitleTmuxEnabled') : t('settings.sessionSubtitleMessageSendingAndTmux')} + icon={<Ionicons name="terminal-outline" size={29} color="#5856D6" />} + onPress={() => router.push('/(app)/settings/session')} /> - {experiments && ( + {useProfiles && ( + <Item + title={t('settings.profiles')} + subtitle={t('settings.profilesSubtitle')} + icon={<Ionicons name="person-outline" size={29} color="#AF52DE" />} + onPress={() => router.push('/(app)/settings/profiles')} + /> + )} + {useProfiles && ( + <Item + title={t('settings.secrets')} + subtitle={t('settings.secretsSubtitle')} + icon={<Ionicons name="key-outline" size={29} color="#AF52DE" />} + onPress={() => router.push('/(app)/settings/secrets')} + /> + )} + {experiments && expUsageReporting && ( <Item title={t('settings.usage')} subtitle={t('settings.usageSubtitle')} icon={<Ionicons name="analytics-outline" size={29} color="#007AFF" />} - onPress={() => router.push('/settings/usage')} + onPress={() => router.push('/(app)/settings/usage')} /> )} </ItemGroup> @@ -344,7 +451,7 @@ export const SettingsView = React.memo(function SettingsView() { <Item title={t('settings.developerTools')} icon={<Ionicons name="construct-outline" size={29} color="#5856D6" />} - onPress={() => router.push('/dev')} + onPress={() => router.push('/(app)/dev')} /> </ItemGroup> )} @@ -357,7 +464,7 @@ export const SettingsView = React.memo(function SettingsView() { icon={<Ionicons name="sparkles-outline" size={29} color="#FF9500" />} onPress={() => { trackWhatsNewClicked(); - router.push('/changelog'); + router.push('/(app)/changelog'); }} /> <Item diff --git a/expo-app/sources/components/ShimmerView.tsx b/expo-app/sources/components/ShimmerView.tsx index e813d0aaf..2f58083d0 100644 --- a/expo-app/sources/components/ShimmerView.tsx +++ b/expo-app/sources/components/ShimmerView.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { View, StyleSheet, ViewStyle } from 'react-native'; +import { View, type ViewStyle } from 'react-native'; import Animated, { useSharedValue, useAnimatedStyle, @@ -12,6 +12,7 @@ import Animated, { } from 'react-native-reanimated'; import { LinearGradient } from 'expo-linear-gradient'; import MaskedView from '@react-native-masked-view/masked-view'; +import { StyleSheet } from 'react-native-unistyles'; const AnimatedLinearGradient = Animated.createAnimatedComponent(LinearGradient); @@ -103,4 +104,4 @@ const styles = StyleSheet.create({ hiddenChildren: { opacity: 0, }, -}); \ No newline at end of file +}); diff --git a/expo-app/sources/components/SidebarView.tsx b/expo-app/sources/components/SidebarView.tsx index 6da485880..68c383f3f 100644 --- a/expo-app/sources/components/SidebarView.tsx +++ b/expo-app/sources/components/SidebarView.tsx @@ -1,6 +1,6 @@ -import { useSocketStatus, useFriendRequests, useSettings } from '@/sync/storage'; +import { useSocketStatus, useFriendRequests, useSetting, useSyncError } from '@/sync/storage'; import * as React from 'react'; -import { Text, View, Pressable, useWindowDimensions } from 'react-native'; +import { Platform, Text, View, Pressable, useWindowDimensions } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useRouter } from 'expo-router'; import { useHeaderHeight } from '@/utils/responsive'; @@ -15,6 +15,10 @@ import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { t } from '@/text'; import { useInboxHasContent } from '@/hooks/useInboxHasContent'; import { Ionicons } from '@expo/vector-icons'; +import { sync } from '@/sync/sync'; +import { PopoverBoundaryProvider } from '@/components/ui/popover'; +import { ConnectionStatusControl } from '@/components/navigation/ConnectionStatusControl'; +import { useInboxFriendsEnabled } from '@/hooks/useInboxFriendsEnabled'; const stylesheet = StyleSheet.create((theme, runtime) => ({ container: { @@ -23,6 +27,7 @@ const stylesheet = StyleSheet.create((theme, runtime) => ({ backgroundColor: theme.colors.groupped.background, borderWidth: StyleSheet.hairlineWidth, borderColor: theme.colors.divider, + overflow: 'visible', }, header: { flexDirection: 'row', @@ -30,6 +35,8 @@ const stylesheet = StyleSheet.create((theme, runtime) => ({ paddingHorizontal: 16, backgroundColor: theme.colors.groupped.background, position: 'relative', + zIndex: 100, + overflow: 'visible', }, logoContainer: { width: 32, @@ -44,7 +51,7 @@ const stylesheet = StyleSheet.create((theme, runtime) => ({ right: 0, flexDirection: 'column', alignItems: 'center', - pointerEvents: 'none', + overflow: 'visible', }, titleContainerLeft: { flex: 1, @@ -52,6 +59,7 @@ const stylesheet = StyleSheet.create((theme, runtime) => ({ alignItems: 'flex-start', marginLeft: 8, justifyContent: 'center', + overflow: 'visible', }, titleText: { fontSize: 17, @@ -127,6 +135,39 @@ const stylesheet = StyleSheet.create((theme, runtime) => ({ borderRadius: 3, backgroundColor: theme.colors.text, }, + banner: { + marginHorizontal: 12, + marginBottom: 8, + marginTop: 6, + paddingHorizontal: 12, + paddingVertical: 10, + borderRadius: 12, + backgroundColor: theme.colors.surface, + borderWidth: StyleSheet.hairlineWidth, + borderColor: theme.colors.divider, + flexDirection: 'row', + alignItems: 'center', + gap: 10, + }, + bannerText: { + flex: 1, + fontSize: 12, + color: theme.colors.textSecondary, + ...Typography.default(), + }, + bannerButton: { + paddingHorizontal: 10, + paddingVertical: 6, + borderRadius: 10, + backgroundColor: theme.colors.groupped.background, + borderWidth: StyleSheet.hairlineWidth, + borderColor: theme.colors.divider, + }, + bannerButtonText: { + fontSize: 12, + color: theme.colors.text, + ...Typography.default('semiBold'), + }, })); export const SidebarView = React.memo(() => { @@ -137,9 +178,13 @@ export const SidebarView = React.memo(() => { const headerHeight = useHeaderHeight(); const socketStatus = useSocketStatus(); const realtimeStatus = useRealtimeStatus(); + const syncError = useSyncError(); + const popoverBoundaryRef = React.useRef<any>(null); const friendRequests = useFriendRequests(); const inboxHasContent = useInboxHasContent(); - const settings = useSettings(); + const experimentsEnabled = useSetting('experiments'); + const expZen = useSetting('expZen'); + const inboxFriendsEnabled = useInboxFriendsEnabled(); // Compute connection status once per render (theme-reactive, no stale memoization) const connectionStatus = (() => { @@ -187,9 +232,10 @@ export const SidebarView = React.memo(() => { // Uses same formula as SidebarNavigator.tsx:18 for consistency const { width: windowWidth } = useWindowDimensions(); const sidebarWidth = Math.min(Math.max(Math.floor(windowWidth * 0.3), 250), 360); - // With experiments: 4 icons (148px total), threshold 408px > max 360px → always left-justify - // Without experiments: 3 icons (108px total), threshold 328px → left-justify below ~340px - const shouldLeftJustify = settings.experiments || sidebarWidth < 340; + const showZen = experimentsEnabled && expZen; + // With Zen enabled: 4 icons (148px total), threshold 408px > max 360px → always left-justify + // Without Zen: 3 icons (108px total), threshold 328px → left-justify below ~340px + const shouldLeftJustify = showZen || sidebarWidth < 340; const handleNewSession = React.useCallback(() => { router.push('/new'); @@ -199,25 +245,21 @@ export const SidebarView = React.memo(() => { const titleContent = ( <> <Text style={styles.titleText}>{t('sidebar.sessionsTitle')}</Text> - {connectionStatus.text && ( - <View style={styles.statusContainer}> - <StatusDot - color={connectionStatus.color} - isPulsing={connectionStatus.isPulsing} - size={6} - style={styles.statusDot} + {connectionStatus.text ? ( + <View style={Platform.OS === 'web' ? ({ pointerEvents: 'auto' } as any) : undefined}> + <ConnectionStatusControl + variant="sidebar" + alignSelf={shouldLeftJustify ? 'flex-start' : 'center'} /> - <Text style={[styles.statusText, { color: connectionStatus.textColor }]}> - {connectionStatus.text} - </Text> </View> - )} + ) : null} </> ); return ( <> - <View style={[styles.container, { paddingTop: safeArea.top }]}> + <View ref={popoverBoundaryRef} style={[styles.container, { paddingTop: safeArea.top }]}> + <PopoverBoundaryProvider boundaryRef={popoverBoundaryRef}> <View style={[styles.header, { height: headerHeight }]}> {/* Logo - always first */} <View style={styles.logoContainer}> @@ -237,7 +279,7 @@ export const SidebarView = React.memo(() => { {/* Navigation icons */} <View style={styles.rightContainer}> - {settings.experiments && ( + {showZen && ( <Pressable onPress={() => router.push('/(app)/zen')} hitSlop={15} @@ -250,28 +292,30 @@ export const SidebarView = React.memo(() => { /> </Pressable> )} - <Pressable - onPress={() => router.push('/(app)/inbox')} - hitSlop={15} - style={styles.notificationButton} - > - <Image - source={require('@/assets/images/brutalist/Brutalism 27.png')} - contentFit="contain" - style={[{ width: 32, height: 32 }]} - tintColor={theme.colors.header.tint} - /> - {friendRequests.length > 0 && ( - <View style={styles.badge}> - <Text style={styles.badgeText}> - {friendRequests.length > 99 ? '99+' : friendRequests.length} - </Text> - </View> - )} - {inboxHasContent && friendRequests.length === 0 && ( - <View style={styles.indicatorDot} /> - )} - </Pressable> + {inboxFriendsEnabled && ( + <Pressable + onPress={() => router.push('/(app)/inbox')} + hitSlop={15} + style={styles.notificationButton} + > + <Image + source={require('@/assets/images/brutalist/Brutalism 27.png')} + contentFit="contain" + style={[{ width: 32, height: 32 }]} + tintColor={theme.colors.header.tint} + /> + {friendRequests.length > 0 && ( + <View style={styles.badge}> + <Text style={styles.badgeText}> + {friendRequests.length > 99 ? '99+' : friendRequests.length} + </Text> + </View> + )} + {inboxHasContent && friendRequests.length === 0 && ( + <View style={styles.indicatorDot} /> + )} + </Pressable> + )} <Pressable onPress={() => router.push('/settings')} hitSlop={15} @@ -293,15 +337,47 @@ export const SidebarView = React.memo(() => { {/* Centered title - absolute positioned over full header */} {!shouldLeftJustify && ( - <View style={styles.titleContainer}> + <View + // On native, this overlay must be `box-none` so it doesn't block the header buttons. + // On web, use CSS-compatible pointer-events values (RN `box-none` isn't valid CSS). + pointerEvents={Platform.OS === 'web' ? undefined : 'box-none'} + style={[styles.titleContainer, Platform.OS === 'web' ? ({ pointerEvents: 'none' } as any) : null]} + > {titleContent} </View> )} </View> + {(syncError || socketStatus.status === 'error' || socketStatus.status === 'disconnected') && ( + <View style={styles.banner}> + <Text style={styles.bannerText} numberOfLines={2}> + {syncError?.message + ?? socketStatus.lastError + ?? (socketStatus.status === 'disconnected' ? t('status.disconnected') : t('status.error'))} + </Text> + {syncError?.kind === 'auth' ? ( + <Pressable + onPress={() => router.push('/restore')} + style={styles.bannerButton} + accessibilityRole="button" + > + <Text style={styles.bannerButtonText}>{t('connect.restoreAccount')}</Text> + </Pressable> + ) : syncError?.retryable !== false ? ( + <Pressable + onPress={() => sync.retryNow()} + style={styles.bannerButton} + accessibilityRole="button" + > + <Text style={styles.bannerButtonText}>{t('common.retry')}</Text> + </Pressable> + ) : null} + </View> + )} {realtimeStatus !== 'disconnected' && ( <VoiceAssistantStatusBar variant="sidebar" /> )} <MainView variant="sidebar" /> + </PopoverBoundaryProvider> </View> <FABWide onPress={handleNewSession} /> </> diff --git a/expo-app/sources/components/Switch.web.tsx b/expo-app/sources/components/Switch.web.tsx new file mode 100644 index 000000000..150d37d5d --- /dev/null +++ b/expo-app/sources/components/Switch.web.tsx @@ -0,0 +1,64 @@ +import * as React from 'react'; +import type { SwitchProps } from 'react-native'; +import { Pressable, View } from 'react-native'; +import { StyleSheet, useUnistyles } from 'react-native-unistyles'; + +const TRACK_WIDTH = 44; +const TRACK_HEIGHT = 24; +const THUMB_SIZE = 20; +const PADDING = 2; + +const stylesheet = StyleSheet.create(() => ({ + track: { + width: TRACK_WIDTH, + height: TRACK_HEIGHT, + borderRadius: TRACK_HEIGHT / 2, + padding: PADDING, + justifyContent: 'center', + }, + thumb: { + width: THUMB_SIZE, + height: THUMB_SIZE, + borderRadius: THUMB_SIZE / 2, + }, +})); + +export const Switch = ({ value, disabled, onValueChange, style, ...rest }: SwitchProps) => { + const { theme } = useUnistyles(); + const styles = stylesheet; + + const translateX = value ? TRACK_WIDTH - THUMB_SIZE - PADDING * 2 : 0; + + return ( + <Pressable + {...rest} + accessibilityRole="switch" + accessibilityState={{ checked: !!value, disabled: !!disabled }} + disabled={disabled} + onPress={() => onValueChange?.(!value)} + style={({ pressed }) => [ + style as any, + { opacity: disabled ? 0.6 : pressed ? 0.85 : 1 }, + ]} + > + <View + style={[ + styles.track, + { + backgroundColor: value ? theme.colors.switch.track.active : theme.colors.switch.track.inactive, + }, + ]} + > + <View + style={[ + styles.thumb, + { + backgroundColor: theme.colors.switch.thumb.active, + transform: [{ translateX }], + }, + ]} + /> + </View> + </Pressable> + ); +}; diff --git a/expo-app/sources/components/TabBar.tsx b/expo-app/sources/components/TabBar.tsx index 73c70f6d0..dc1198654 100644 --- a/expo-app/sources/components/TabBar.tsx +++ b/expo-app/sources/components/TabBar.tsx @@ -7,6 +7,7 @@ import { t } from '@/text'; import { Typography } from '@/constants/Typography'; import { layout } from '@/components/layout'; import { useInboxHasContent } from '@/hooks/useInboxHasContent'; +import { useInboxFriendsEnabled } from '@/hooks/useInboxFriendsEnabled'; export type TabType = 'zen' | 'inbox' | 'sessions' | 'settings'; @@ -83,16 +84,20 @@ const styles = StyleSheet.create((theme) => ({ export const TabBar = React.memo(({ activeTab, onTabPress, inboxBadgeCount = 0 }: TabBarProps) => { const { theme } = useUnistyles(); const insets = useSafeAreaInsets(); + const inboxFriendsEnabled = useInboxFriendsEnabled(); const inboxHasContent = useInboxHasContent(); const tabs: { key: TabType; icon: any; label: string }[] = React.useMemo(() => { // NOTE: Zen tab removed - the feature never got to a useful state - return [ - { key: 'inbox', icon: require('@/assets/images/brutalist/Brutalism 27.png'), label: t('tabs.inbox') }, + const base: { key: TabType; icon: any; label: string }[] = [ { key: 'sessions', icon: require('@/assets/images/brutalist/Brutalism 15.png'), label: t('tabs.sessions') }, { key: 'settings', icon: require('@/assets/images/brutalist/Brutalism 9.png'), label: t('tabs.settings') }, ]; - }, []); + if (inboxFriendsEnabled) { + base.unshift({ key: 'inbox', icon: require('@/assets/images/brutalist/Brutalism 27.png'), label: t('tabs.inbox') }); + } + return base; + }, [inboxFriendsEnabled]); return ( <View style={[styles.outerContainer, { paddingBottom: insets.bottom }]}> @@ -137,4 +142,4 @@ export const TabBar = React.memo(({ activeTab, onTabPress, inboxBadgeCount = 0 } </View> </View> ); -}); \ No newline at end of file +}); diff --git a/expo-app/sources/components/UpdateBanner.tsx b/expo-app/sources/components/UpdateBanner.tsx index eca83d1e6..eb4b39d84 100644 --- a/expo-app/sources/components/UpdateBanner.tsx +++ b/expo-app/sources/components/UpdateBanner.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { Ionicons } from '@expo/vector-icons'; -import { Item } from './Item'; -import { ItemGroup } from './ItemGroup'; +import { Item } from '@/components/ui/lists/Item'; +import { ItemGroup } from '@/components/ui/lists/ItemGroup'; import { useUnistyles } from 'react-native-unistyles'; import { useUpdates } from '@/hooks/useUpdates'; import { useChangelog } from '@/hooks/useChangelog'; diff --git a/expo-app/sources/components/UserCard.tsx b/expo-app/sources/components/UserCard.tsx index 7c2dfd920..40c390fb3 100644 --- a/expo-app/sources/components/UserCard.tsx +++ b/expo-app/sources/components/UserCard.tsx @@ -1,16 +1,20 @@ import React from 'react'; import { UserProfile, getDisplayName } from '@/sync/friendTypes'; -import { Item } from '@/components/Item'; +import { Item } from '@/components/ui/lists/Item'; import { Avatar } from '@/components/Avatar'; interface UserCardProps { user: UserProfile; onPress?: () => void; + disabled?: boolean; + subtitle?: string; } export function UserCard({ user, - onPress + onPress, + disabled, + subtitle }: UserCardProps) { const displayName = getDisplayName(user); const avatarUrl = user.avatar?.url || user.avatar?.path; @@ -26,16 +30,17 @@ export function UserCard({ ); // Create subtitle - const subtitle = `@${user.username}`; + const subtitleText = subtitle ?? `@${user.username}`; return ( <Item title={displayName} - subtitle={subtitle} + subtitle={subtitleText} subtitleLines={1} leftElement={avatarElement} onPress={onPress} showChevron={!!onPress} + disabled={disabled} /> ); -} \ No newline at end of file +} diff --git a/expo-app/sources/components/VoiceAssistantStatusBar.tsx b/expo-app/sources/components/VoiceAssistantStatusBar.tsx index d6dc8b09c..0b5526a84 100644 --- a/expo-app/sources/components/VoiceAssistantStatusBar.tsx +++ b/expo-app/sources/components/VoiceAssistantStatusBar.tsx @@ -1,13 +1,14 @@ import * as React from 'react'; -import { View, Text, Pressable, StyleSheet, Platform } from 'react-native'; +import { View, Text, Pressable, Platform } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useRealtimeStatus, useRealtimeMode } from '@/sync/storage'; import { StatusDot } from './StatusDot'; import { Typography } from '@/constants/Typography'; import { Ionicons } from '@expo/vector-icons'; import { stopRealtimeSession } from '@/realtime/RealtimeSession'; -import { useUnistyles } from 'react-native-unistyles'; +import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { VoiceBars } from './VoiceBars'; +import { t } from '@/text'; interface VoiceAssistantStatusBarProps { variant?: 'full' | 'sidebar'; @@ -34,7 +35,7 @@ export const VoiceAssistantStatusBar = React.memo(({ variant = 'full', style }: color: theme.colors.status.connecting, backgroundColor: theme.colors.surfaceHighest, isPulsing: true, - text: 'Connecting...', + text: t('voiceAssistant.connecting'), textColor: theme.colors.text }; case 'connected': @@ -42,7 +43,7 @@ export const VoiceAssistantStatusBar = React.memo(({ variant = 'full', style }: color: theme.colors.status.connected, backgroundColor: theme.colors.surfaceHighest, isPulsing: false, - text: 'Voice Assistant Active', + text: t('voiceAssistant.active'), textColor: theme.colors.text }; case 'error': @@ -50,7 +51,7 @@ export const VoiceAssistantStatusBar = React.memo(({ variant = 'full', style }: color: theme.colors.status.error, backgroundColor: theme.colors.surfaceHighest, isPulsing: false, - text: 'Connection Error', + text: t('voiceAssistant.connectionError'), textColor: theme.colors.text }; default: @@ -58,7 +59,7 @@ export const VoiceAssistantStatusBar = React.memo(({ variant = 'full', style }: color: theme.colors.status.default, backgroundColor: theme.colors.surfaceHighest, isPulsing: false, - text: 'Voice Assistant', + text: t('voiceAssistant.label'), textColor: theme.colors.text }; } @@ -128,7 +129,7 @@ export const VoiceAssistantStatusBar = React.memo(({ variant = 'full', style }: /> )} <Text style={[styles.tapToEndText, { color: statusInfo.textColor, marginLeft: isVoiceSpeaking ? 8 : 0 }]}> - Tap to end + {t('voiceAssistant.tapToEnd')} </Text> </View> </View> @@ -257,4 +258,4 @@ const styles = StyleSheet.create({ opacity: 0.8, ...Typography.default(), }, -}); \ No newline at end of file +}); diff --git a/expo-app/sources/components/autocomplete/suggestions.ts b/expo-app/sources/components/autocomplete/suggestions.ts index 9ef0b193d..242384bf3 100644 --- a/expo-app/sources/components/autocomplete/suggestions.ts +++ b/expo-app/sources/components/autocomplete/suggestions.ts @@ -1,4 +1,4 @@ -import { CommandSuggestion, FileMentionSuggestion } from '@/components/AgentInputSuggestionView'; +import { CommandSuggestion, FileMentionSuggestion } from '@/components/sessions/agentInput/components/AgentInputSuggestionView'; import * as React from 'react'; import { searchFiles, FileItem } from '@/sync/suggestionFile'; import { searchCommands, CommandItem } from '@/sync/suggestionCommands'; @@ -99,4 +99,4 @@ export async function getSuggestions(sessionId: string, query: string): Promise< // No suggestions for other queries console.log('💡 getSuggestions: No matching prefix, returning empty array'); return []; -} \ No newline at end of file +} diff --git a/expo-app/sources/components/autocomplete/useActiveSuggestions.ts b/expo-app/sources/components/autocomplete/useActiveSuggestions.ts index 2215b1884..1bae8e4f5 100644 --- a/expo-app/sources/components/autocomplete/useActiveSuggestions.ts +++ b/expo-app/sources/components/autocomplete/useActiveSuggestions.ts @@ -72,16 +72,10 @@ export function useActiveSuggestions( // Sync query to suggestions const sync = React.useMemo(() => { return new ValueSync<string | null>(async (query) => { - console.log('🎯 useActiveSuggestions: Processing query:', JSON.stringify(query)); if (!query) { - console.log('🎯 useActiveSuggestions: No query, skipping'); return; } const suggestions = await handler(query); - console.log('🎯 useActiveSuggestions: Got suggestions:', JSON.stringify(suggestions, (key, value) => { - if (key === 'component') return '[Function]'; - return value; - }, 2)); setState((prev) => { if (clampSelection) { // Simply clamp the selection to valid range diff --git a/expo-app/sources/components/machines/DetectedClisList.errorSnapshot.test.ts b/expo-app/sources/components/machines/DetectedClisList.errorSnapshot.test.ts new file mode 100644 index 000000000..ef1f68900 --- /dev/null +++ b/expo-app/sources/components/machines/DetectedClisList.errorSnapshot.test.ts @@ -0,0 +1,65 @@ +import * as React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import renderer, { act } from 'react-test-renderer'; + +// Required for React 18+ act() semantics with react-test-renderer. +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +vi.mock('react-native', () => ({ + Platform: { select: (options: any) => options.default ?? options.ios ?? null }, + Text: 'Text', + View: 'View', +})); + +vi.mock('@expo/vector-icons', () => ({ + Ionicons: 'Ionicons', +})); + +vi.mock('@/text', () => ({ + t: (key: string) => key, +})); + +vi.mock('@/sync/storage', () => ({ + useSetting: (key: string) => { + if (key === 'experiments') return false; + if (key === 'experimentalAgents') return {}; + return false; + }, +})); + +vi.mock('react-native-unistyles', () => ({ + useUnistyles: () => ({ theme: { colors: { textSecondary: '#666', status: { connected: '#0a0' } } } }), +})); + +vi.mock('@/components/ui/lists/Item', () => ({ + Item: (props: any) => React.createElement('Item', props), +})); + +describe('DetectedClisList', () => { + it('renders the last known snapshot when refresh fails', async () => { + const { DetectedClisList } = await import('./DetectedClisList'); + + const state: any = { + status: 'error', + snapshot: { + response: { + protocolVersion: 1, + results: { + 'cli.codex': { ok: true, checkedAt: 1, data: { available: true, version: '1.2.3', resolvedPath: '/usr/bin/codex' } }, + 'tool.tmux': { ok: true, checkedAt: 1, data: { available: false } }, + }, + }, + }, + }; + + let tree: renderer.ReactTestRenderer | null = null; + act(() => { + tree = renderer.create(React.createElement(DetectedClisList, { state })); + }); + const items = tree!.root.findAllByType('Item' as any); + const titles = items.map((n: any) => n.props.title); + + expect(titles).toEqual(expect.arrayContaining(['agentInput.agent.claude', 'agentInput.agent.codex', 'tmux'])); + expect(titles).not.toContain('machine.detectedCliUnknown'); + }); +}); diff --git a/expo-app/sources/components/machines/DetectedClisList.tsx b/expo-app/sources/components/machines/DetectedClisList.tsx new file mode 100644 index 000000000..594975886 --- /dev/null +++ b/expo-app/sources/components/machines/DetectedClisList.tsx @@ -0,0 +1,158 @@ +import * as React from 'react'; +import { Platform, Text, View } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { Typography } from '@/constants/Typography'; +import { Item } from '@/components/ui/lists/Item'; +import { useUnistyles } from 'react-native-unistyles'; +import { t } from '@/text'; +import type { MachineCapabilitiesCacheState } from '@/hooks/useMachineCapabilitiesCache'; +import type { CapabilityDetectResult, CapabilityId, CliCapabilityData, TmuxCapabilityData } from '@/sync/capabilitiesProtocol'; +import { getAgentCore } from '@/agents/catalog'; +import { useEnabledAgentIds } from '@/agents/useEnabledAgentIds'; + +type Props = { + state: MachineCapabilitiesCacheState; + layout?: 'inline' | 'stacked'; +}; + +export function DetectedClisList({ state, layout = 'inline' }: Props) { + const { theme } = useUnistyles(); + const enabledAgents = useEnabledAgentIds(); + + const extractSemver = React.useCallback((value: string | undefined): string | null => { + if (!value) return null; + const match = value.match(/\b\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?\b/); + return match?.[0] ?? null; + }, []); + + const subtitleBaseStyle = React.useMemo(() => { + return [ + Typography.default('regular'), + { + color: theme.colors.textSecondary, + fontSize: Platform.select({ ios: 15, default: 14 }), + lineHeight: 20, + letterSpacing: Platform.select({ ios: -0.24, default: 0.1 }), + flexWrap: 'wrap' as const, + }, + ]; + }, [theme.colors.textSecondary]); + + const snapshotForRender = React.useMemo(() => { + if (state.status === 'loaded') return state.snapshot; + if (state.status === 'error') return state.snapshot; + return undefined; + }, [state]); + + if (state.status === 'not-supported') { + return <Item title={t('machine.detectedCliNotSupported')} showChevron={false} />; + } + + if (state.status === 'loading' || state.status === 'idle') { + return ( + <Item + title={t('common.loading')} + showChevron={false} + rightElement={<Ionicons name="time-outline" size={18} color={theme.colors.textSecondary} />} + /> + ); + } + + if (!snapshotForRender) { + return <Item title={t('machine.detectedCliUnknown')} showChevron={false} />; + } + + const results = snapshotForRender.response.results ?? {}; + + function readCliResult(result: CapabilityDetectResult | undefined): { available: boolean | null; resolvedPath?: string; version?: string } { + if (!result || !result.ok) return { available: null }; + const data = result.data as Partial<CliCapabilityData>; + const available = typeof data.available === 'boolean' ? data.available : null; + if (!available) return { available }; + return { + available, + ...(typeof data.resolvedPath === 'string' ? { resolvedPath: data.resolvedPath } : {}), + ...(typeof data.version === 'string' ? { version: data.version } : {}), + }; + } + + function readTmuxResult(result: CapabilityDetectResult | undefined): { available: boolean | null; resolvedPath?: string; version?: string } { + if (!result || !result.ok) return { available: null }; + const data = result.data as Partial<TmuxCapabilityData>; + const available = typeof data.available === 'boolean' ? data.available : null; + if (!available) return { available }; + return { + available, + ...(typeof data.resolvedPath === 'string' ? { resolvedPath: data.resolvedPath } : {}), + ...(typeof data.version === 'string' ? { version: data.version } : {}), + }; + } + + const entries: Array<[string, { available: boolean | null; resolvedPath?: string; version?: string }]> = [ + ...enabledAgents.map((agentId): [string, { available: boolean | null; resolvedPath?: string; version?: string }] => { + const capId = `cli.${getAgentCore(agentId).cli.detectKey}` as CapabilityId; + return [t(getAgentCore(agentId).displayNameKey), readCliResult(results[capId])]; + }), + ['tmux', readTmuxResult(results['tool.tmux'])], + ]; + + return ( + <> + {entries.map(([name, entry], index) => { + const available = entry.available; + const iconName = available === true ? 'checkmark-circle' : available === false ? 'close-circle' : 'time-outline'; + const iconColor = available === true ? theme.colors.status.connected : theme.colors.textSecondary; + const version = name === 'tmux' ? (entry.version ?? null) : extractSemver(entry.version); + + const subtitle = available === false + ? t('machine.detectedCliNotDetected') + : available === null + ? t('machine.detectedCliUnknown') + : ( + layout === 'stacked' ? ( + <View style={{ gap: 2 }}> + {version ? ( + <Text style={subtitleBaseStyle}> + {version} + </Text> + ) : null} + {entry.resolvedPath ? ( + <Text style={[subtitleBaseStyle, { opacity: 0.6 }]}> + {entry.resolvedPath} + </Text> + ) : null} + {!version && !entry.resolvedPath ? ( + <Text style={subtitleBaseStyle}> + {t('machine.detectedCliUnknown')} + </Text> + ) : null} + </View> + ) : ( + <Text style={subtitleBaseStyle}> + {version ?? null} + {version && entry.resolvedPath ? ' • ' : null} + {entry.resolvedPath ? ( + <Text style={{ opacity: 0.6 }}> + {entry.resolvedPath} + </Text> + ) : null} + {!version && !entry.resolvedPath ? t('machine.detectedCliUnknown') : null} + </Text> + ) + ); + + return ( + <Item + key={name} + title={name} + subtitle={subtitle} + subtitleLines={0} + showChevron={false} + showDivider={index !== entries.length - 1} + leftElement={<Ionicons name={iconName as any} size={18} color={iconColor} />} + /> + ); + })} + </> + ); +} diff --git a/expo-app/sources/components/machines/DetectedClisModal.tsx b/expo-app/sources/components/machines/DetectedClisModal.tsx new file mode 100644 index 000000000..782e71726 --- /dev/null +++ b/expo-app/sources/components/machines/DetectedClisModal.tsx @@ -0,0 +1,107 @@ +import * as React from 'react'; +import { View, Text, Pressable, ActivityIndicator } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { StyleSheet, useUnistyles } from 'react-native-unistyles'; +import { Typography } from '@/constants/Typography'; +import { RoundButton } from '@/components/RoundButton'; +import { useMachineCapabilitiesCache } from '@/hooks/useMachineCapabilitiesCache'; +import { DetectedClisList } from '@/components/machines/DetectedClisList'; +import { t } from '@/text'; +import type { CustomModalInjectedProps } from '@/modal'; +import { CAPABILITIES_REQUEST_NEW_SESSION } from '@/capabilities/requests'; + +type Props = CustomModalInjectedProps & { + machineId: string; + isOnline: boolean; +}; + +const stylesheet = StyleSheet.create((theme) => ({ + container: { + backgroundColor: theme.colors.surface, + borderRadius: 14, + width: 360, + maxWidth: '92%', + overflow: 'hidden', + shadowColor: theme.colors.shadow.color, + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.25, + shadowRadius: 4, + elevation: 5, + }, + header: { + paddingHorizontal: 16, + paddingTop: 16, + paddingBottom: 10, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + borderBottomWidth: 1, + borderBottomColor: theme.colors.divider, + }, + title: { + fontSize: 17, + color: theme.colors.text, + ...Typography.default('semiBold'), + }, + body: { + paddingVertical: 4, + }, + footer: { + paddingHorizontal: 16, + paddingVertical: 14, + borderTopWidth: 1, + borderTopColor: theme.colors.divider, + alignItems: 'center', + }, +})); + +export function DetectedClisModal({ onClose, machineId, isOnline }: Props) { + const { theme } = useUnistyles(); + const styles = stylesheet; + + const { state, refresh } = useMachineCapabilitiesCache({ + machineId, + // Cache-first: never auto-fetch on mount; user can explicitly refresh. + enabled: false, + request: CAPABILITIES_REQUEST_NEW_SESSION, + }); + + return ( + <View style={styles.container}> + <View style={styles.header}> + <Text style={styles.title}>{t('machine.detectedClis')}</Text> + <View style={{ flexDirection: 'row', alignItems: 'center', gap: 10 }}> + <Pressable + onPress={() => refresh()} + hitSlop={10} + style={{ padding: 2 }} + accessibilityRole="button" + accessibilityLabel="Refresh" + disabled={!isOnline || state.status === 'loading'} + > + {state.status === 'loading' + ? <ActivityIndicator size="small" color={theme.colors.textSecondary} /> + : <Ionicons name="refresh" size={20} color={isOnline ? theme.colors.textSecondary : theme.colors.divider} />} + </Pressable> + <Pressable + onPress={onClose as any} + hitSlop={10} + style={{ padding: 2 }} + accessibilityRole="button" + accessibilityLabel="Close" + > + <Ionicons name="close" size={22} color={theme.colors.textSecondary} /> + </Pressable> + </View> + </View> + + <View style={styles.body}> + <DetectedClisList state={state} layout="stacked" /> + </View> + + <View style={styles.footer}> + <RoundButton title={t('common.ok')} size="normal" onPress={onClose} /> + </View> + </View> + ); +} diff --git a/expo-app/sources/components/machines/InstallableDepInstaller.tsx b/expo-app/sources/components/machines/InstallableDepInstaller.tsx new file mode 100644 index 000000000..1dd84ce76 --- /dev/null +++ b/expo-app/sources/components/machines/InstallableDepInstaller.tsx @@ -0,0 +1,209 @@ +import * as React from 'react'; +import { ActivityIndicator } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; + +import { Item } from '@/components/ui/lists/Item'; +import { ItemGroup } from '@/components/ui/lists/ItemGroup'; +import { Modal } from '@/modal'; +import { t } from '@/text'; +import { useSettingMutable } from '@/sync/storage'; +import { machineCapabilitiesInvoke } from '@/sync/ops'; +import type { CapabilityId } from '@/sync/capabilitiesProtocol'; +import type { Settings } from '@/sync/settings'; +import { compareVersions, parseVersion } from '@/utils/versionUtils'; +import { useUnistyles } from 'react-native-unistyles'; + +type InstallableDepData = { + installed: boolean; + installedVersion: string | null; + distTag: string; + lastInstallLogPath: string | null; + registry?: { ok: true; latestVersion: string | null } | { ok: false; errorMessage: string }; +}; + +type InstallSpecSettingKey = { + [K in keyof Settings]: Settings[K] extends string | null ? K : never; +}[keyof Settings] & string; + +function computeUpdateAvailable(data: InstallableDepData | null): boolean { + if (!data?.installed) return false; + const installed = data.installedVersion; + const latest = data.registry && data.registry.ok ? data.registry.latestVersion : null; + if (!installed || !latest) return false; + const installedParsed = parseVersion(installed); + const latestParsed = parseVersion(latest); + if (!installedParsed || !latestParsed) return false; + return compareVersions(installed, latest) < 0; +} + +export type InstallableDepInstallerProps = { + machineId: string; + enabled: boolean; + groupTitle: string; + depId: Extract<CapabilityId, `dep.${string}`>; + depTitle: string; + depIconName: React.ComponentProps<typeof Ionicons>['name']; + depStatus: InstallableDepData | null; + capabilitiesStatus: 'idle' | 'loading' | 'loaded' | 'error' | 'not-supported'; + installSpecSettingKey: InstallSpecSettingKey; + installSpecTitle: string; + installSpecDescription: string; + installLabels: { install: string; update: string; reinstall: string }; + installModal: { installTitle: string; updateTitle: string; reinstallTitle: string; description: string }; + refreshStatus: () => void; + refreshRegistry?: () => void; +}; + +export function InstallableDepInstaller(props: InstallableDepInstallerProps) { + const { theme } = useUnistyles(); + const [installSpec, setInstallSpec] = useSettingMutable(props.installSpecSettingKey); + const [isInstalling, setIsInstalling] = React.useState(false); + + if (!props.enabled) return null; + + const updateAvailable = computeUpdateAvailable(props.depStatus); + + const subtitle = (() => { + if (props.capabilitiesStatus === 'loading') return t('common.loading'); + if (props.capabilitiesStatus === 'not-supported') return t('deps.ui.notAvailableUpdateCli'); + if (props.capabilitiesStatus === 'error') return t('deps.ui.errorRefresh'); + if (props.capabilitiesStatus !== 'loaded') return t('deps.ui.notAvailable'); + + if (props.depStatus?.installed) { + if (updateAvailable) { + const installedV = props.depStatus.installedVersion ?? 'unknown'; + const latestV = props.depStatus.registry && props.depStatus.registry.ok + ? (props.depStatus.registry.latestVersion ?? 'unknown') + : 'unknown'; + return t('deps.ui.installedUpdateAvailable', { installedVersion: installedV, latestVersion: latestV }); + } + return props.depStatus.installedVersion + ? t('deps.ui.installedWithVersion', { version: props.depStatus.installedVersion }) + : t('deps.ui.installed'); + } + + return t('deps.ui.notInstalled'); + })(); + + const installButtonLabel = props.depStatus?.installed + ? (updateAvailable ? props.installLabels.update : props.installLabels.reinstall) + : props.installLabels.install; + + const openInstallSpecPrompt = async () => { + const next = await Modal.prompt( + props.installSpecTitle, + props.installSpecDescription, + { + defaultValue: installSpec ?? '', + placeholder: t('deps.ui.installSpecPlaceholder'), + confirmText: t('common.save'), + cancelText: t('common.cancel'), + }, + ); + if (typeof next === 'string') { + setInstallSpec(next); + } + }; + + const runInstall = async () => { + const isInstalled = props.depStatus?.installed === true; + const method = isInstalled ? (updateAvailable ? 'upgrade' : 'install') : 'install'; + const spec = typeof installSpec === 'string' && installSpec.trim().length > 0 ? installSpec.trim() : undefined; + + setIsInstalling(true); + try { + const invoke = await machineCapabilitiesInvoke( + props.machineId, + { + id: props.depId, + method, + ...(spec ? { params: { installSpec: spec } } : {}), + }, + { timeoutMs: 5 * 60_000 }, + ); + if (!invoke.supported) { + Modal.alert(t('common.error'), invoke.reason === 'not-supported' ? t('deps.installNotSupported') : t('deps.installFailed')); + } else if (!invoke.response.ok) { + Modal.alert(t('common.error'), invoke.response.error.message); + } else { + const logPath = (invoke.response.result as any)?.logPath; + Modal.alert(t('common.success'), typeof logPath === 'string' ? t('deps.installLog', { path: logPath }) : t('deps.installed')); + } + + props.refreshStatus(); + props.refreshRegistry?.(); + } catch (e) { + Modal.alert(t('common.error'), e instanceof Error ? e.message : t('deps.installFailed')); + } finally { + setIsInstalling(false); + } + }; + + return ( + <ItemGroup title={props.groupTitle}> + <Item + title={props.depTitle} + subtitle={subtitle} + icon={<Ionicons name={props.depIconName} size={22} color={theme.colors.textSecondary} />} + showChevron={false} + onPress={() => props.refreshRegistry?.()} + /> + + {props.depStatus?.registry && props.depStatus.registry.ok && props.depStatus.registry.latestVersion && ( + <Item + title={t('deps.ui.latest')} + subtitle={t('deps.ui.latestSubtitle', { version: props.depStatus.registry.latestVersion, tag: props.depStatus.distTag })} + icon={<Ionicons name="cloud-download-outline" size={22} color={theme.colors.textSecondary} />} + showChevron={false} + /> + )} + + {props.depStatus?.registry && !props.depStatus.registry.ok && ( + <Item + title={t('deps.ui.registryCheck')} + subtitle={t('deps.ui.registryCheckFailed', { error: props.depStatus.registry.errorMessage })} + icon={<Ionicons name="cloud-offline-outline" size={22} color={theme.colors.textSecondary} />} + showChevron={false} + /> + )} + + <Item + title={t('deps.ui.installSource')} + subtitle={typeof installSpec === 'string' && installSpec.trim() ? installSpec.trim() : t('deps.ui.installSourceDefault')} + icon={<Ionicons name="link-outline" size={22} color={theme.colors.textSecondary} />} + onPress={openInstallSpecPrompt} + /> + + <Item + title={installButtonLabel} + subtitle={props.installModal.description} + icon={<Ionicons name="download-outline" size={22} color={theme.colors.textSecondary} />} + disabled={isInstalling || props.capabilitiesStatus === 'loading'} + onPress={async () => { + const alertTitle = props.depStatus?.installed + ? (updateAvailable ? props.installModal.updateTitle : props.installModal.reinstallTitle) + : props.installModal.installTitle; + Modal.alert( + alertTitle, + props.installModal.description, + [ + { text: t('common.cancel'), style: 'cancel' }, + { text: installButtonLabel, onPress: runInstall }, + ], + ); + }} + rightElement={isInstalling ? <ActivityIndicator size="small" color={theme.colors.textSecondary} /> : undefined} + /> + + {props.depStatus?.lastInstallLogPath && ( + <Item + title={t('deps.ui.lastInstallLog')} + subtitle={props.depStatus.lastInstallLogPath} + icon={<Ionicons name="document-text-outline" size={22} color={theme.colors.textSecondary} />} + showChevron={false} + onPress={() => Modal.alert(t('deps.ui.installLogTitle'), props.depStatus?.lastInstallLogPath ?? '')} + /> + )} + </ItemGroup> + ); +} diff --git a/expo-app/sources/components/markdown/MarkdownView.tsx b/expo-app/sources/components/markdown/MarkdownView.tsx index d1bfded82..64c0b0f94 100644 --- a/expo-app/sources/components/markdown/MarkdownView.tsx +++ b/expo-app/sources/components/markdown/MarkdownView.tsx @@ -41,7 +41,7 @@ export const MarkdownView = React.memo((props: { router.push(`/text-selection?textId=${textId}`); } catch (error) { console.error('Error storing text for selection:', error); - Modal.alert('Error', 'Failed to open text selection. Please try again.'); + Modal.alert(t('common.error'), t('textSelection.failedToOpen')); } }, [props.markdown, router]); const renderContent = () => { @@ -537,4 +537,4 @@ const style = StyleSheet.create((theme) => ({ // Web-only CSS styles _____web_global_styles: {} } : {}), -})); \ No newline at end of file +})); diff --git a/expo-app/sources/components/markdown/MermaidRenderer.tsx b/expo-app/sources/components/markdown/MermaidRenderer.tsx index 290d48854..4cc6ca601 100644 --- a/expo-app/sources/components/markdown/MermaidRenderer.tsx +++ b/expo-app/sources/components/markdown/MermaidRenderer.tsx @@ -75,7 +75,7 @@ export const MermaidRenderer = React.memo((props: { return ( <View style={[style.container, style.errorContainer]}> <View style={style.errorContent}> - <Text style={style.errorText}>Mermaid diagram syntax error</Text> + <Text style={style.errorText}>{t('markdown.mermaidRenderFailed')}</Text> <View style={style.codeBlock}> <Text style={style.codeText}>{props.content}</Text> </View> diff --git a/expo-app/sources/components/navigation/ConnectionStatusControl.popover.test.ts b/expo-app/sources/components/navigation/ConnectionStatusControl.popover.test.ts new file mode 100644 index 000000000..93edead78 --- /dev/null +++ b/expo-app/sources/components/navigation/ConnectionStatusControl.popover.test.ts @@ -0,0 +1,122 @@ +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import renderer, { act } from 'react-test-renderer'; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +let lastPopoverProps: any = null; + +vi.mock('react-native', () => ({ + Platform: { OS: 'ios' }, + View: 'View', + Text: 'Text', + Pressable: 'Pressable', +})); + +vi.mock('react-native-unistyles', () => ({ + useUnistyles: () => ({ + theme: { + colors: { + status: { + connected: '#00ff00', + connecting: '#ffcc00', + disconnected: '#ff0000', + error: '#ff0000', + default: '#999999', + }, + text: '#111111', + textSecondary: '#666666', + }, + }, + }), + StyleSheet: { + create: (fn: any) => + fn( + { + colors: { + status: { + connected: '#00ff00', + connecting: '#ffcc00', + disconnected: '#ff0000', + error: '#ff0000', + default: '#999999', + }, + text: '#111111', + textSecondary: '#666666', + }, + }, + {}, + ), + }, +})); + +vi.mock('@expo/vector-icons', () => ({ + Ionicons: 'Ionicons', +})); + +vi.mock('@/text', () => ({ + t: (key: string) => key, +})); + +vi.mock('@/constants/Typography', () => ({ + Typography: { + default: () => ({}), + }, +})); + +vi.mock('@/components/StatusDot', () => ({ + StatusDot: 'StatusDot', +})); + +vi.mock('@/components/ui/lists/ActionListSection', () => ({ + ActionListSection: () => null, +})); + +vi.mock('@/components/FloatingOverlay', () => ({ + FloatingOverlay: () => null, +})); + +vi.mock('@/components/ui/popover', () => ({ + Popover: (props: any) => { + lastPopoverProps = props; + return null; + }, +})); + +vi.mock('@/sync/storage', () => ({ + useSocketStatus: () => ({ status: 'connected' }), + useSyncError: () => null, + useLastSyncAt: () => null, +})); + +vi.mock('@/sync/serverConfig', () => ({ + getServerUrl: () => 'http://localhost:3000', +})); + +vi.mock('@/auth/AuthContext', () => ({ + useAuth: () => ({ isAuthenticated: true }), +})); + +vi.mock('expo-router', () => ({ + useRouter: () => ({ push: vi.fn() }), +})); + +vi.mock('@/sync/sync', () => ({ + sync: { retryNow: vi.fn() }, +})); + +describe('ConnectionStatusControl (native popover config)', () => { + it('enables a native portal so the menu is not width-constrained to the trigger', async () => { + const { ConnectionStatusControl } = await import('./ConnectionStatusControl'); + lastPopoverProps = null; + + act(() => { + renderer.create(React.createElement(ConnectionStatusControl, { variant: 'sidebar' })); + }); + + expect(lastPopoverProps).toBeTruthy(); + expect(lastPopoverProps.portal?.web).toBe(true); + expect(lastPopoverProps.portal?.native).toBe(true); + expect(lastPopoverProps.portal?.matchAnchorWidth).toBe(false); + }); +}); diff --git a/expo-app/sources/components/navigation/ConnectionStatusControl.tsx b/expo-app/sources/components/navigation/ConnectionStatusControl.tsx new file mode 100644 index 000000000..8d2d912fc --- /dev/null +++ b/expo-app/sources/components/navigation/ConnectionStatusControl.tsx @@ -0,0 +1,263 @@ +import * as React from 'react'; +import { View, Text, Pressable } from 'react-native'; +import { StyleSheet, useUnistyles } from 'react-native-unistyles'; +import { Ionicons } from '@expo/vector-icons'; +import { t } from '@/text'; +import { StatusDot } from '@/components/StatusDot'; +import { Popover } from '@/components/ui/popover'; +import { ActionListSection } from '@/components/ui/lists/ActionListSection'; +import { FloatingOverlay } from '@/components/FloatingOverlay'; +import { useSocketStatus, useSyncError, useLastSyncAt } from '@/sync/storage'; +import { getServerUrl } from '@/sync/serverConfig'; +import { useAuth } from '@/auth/AuthContext'; +import { useRouter } from 'expo-router'; +import { sync } from '@/sync/sync'; +import { Typography } from '@/constants/Typography'; + +type Variant = 'sidebar' | 'header'; + +const stylesheet = StyleSheet.create((theme) => ({ + container: { + position: 'relative', + zIndex: 2000, + overflow: 'visible', + }, + statusContainer: { + flexDirection: 'row', + alignItems: 'center', + marginTop: -2, + flexWrap: 'nowrap' as const, + maxWidth: '100%', + overflow: 'visible', + }, + statusText: { + fontWeight: '500', + lineHeight: 16, + ...Typography.default(), + flexShrink: 1, + }, + statusChevron: { + marginLeft: 2, + marginTop: 1, + opacity: 0.9, + }, + popoverTitle: { + fontSize: 12, + color: theme.colors.textSecondary, + ...Typography.default('semiBold'), + marginBottom: 8, + paddingHorizontal: 16, + paddingTop: 6, + textTransform: 'uppercase', + }, + popoverRow: { + flexDirection: 'row', + justifyContent: 'space-between', + gap: 12, + marginBottom: 6, + paddingHorizontal: 16, + }, + popoverLabel: { + fontSize: 12, + color: theme.colors.textSecondary, + ...Typography.default(), + }, + popoverValue: { + fontSize: 12, + color: theme.colors.text, + ...Typography.default(), + flexShrink: 1, + textAlign: 'right', + }, +})); + +function formatTime(ts: number | null): string { + if (!ts) return '—'; + try { + return new Date(ts).toLocaleString(); + } catch { + return '—'; + } +} + +export const ConnectionStatusControl = React.memo(function ConnectionStatusControl(props: { + variant: Variant; + textSize?: number; + dotSize?: number; + chevronSize?: number; + alignSelf?: 'auto' | 'flex-start' | 'center' | 'flex-end' | 'stretch' | 'baseline'; +}) { + const styles = stylesheet; + const { theme } = useUnistyles(); + const router = useRouter(); + const auth = useAuth(); + const socketStatus = useSocketStatus(); + const syncError = useSyncError(); + const lastSyncAt = useLastSyncAt(); + + const [open, setOpen] = React.useState(false); + const anchorRef = React.useRef<any>(null); + + const connectionStatus = React.useMemo(() => { + switch (socketStatus.status) { + case 'connected': + return { color: theme.colors.status.connected, isPulsing: false, text: t('status.connected') }; + case 'connecting': + return { color: theme.colors.status.connecting, isPulsing: true, text: t('status.connecting') }; + case 'disconnected': + return { color: theme.colors.status.disconnected, isPulsing: false, text: t('status.disconnected') }; + case 'error': + return { color: theme.colors.status.error, isPulsing: false, text: t('status.error') }; + default: + return { color: theme.colors.status.default, isPulsing: false, text: '' }; + } + }, [socketStatus.status, theme.colors.status]); + + if (!connectionStatus.text) return null; + + const textSize = props.textSize ?? (props.variant === 'sidebar' ? 11 : 12); + const dotSize = props.dotSize ?? 6; + const chevronSize = props.chevronSize ?? 8; + + return ( + <> + {/* Use a View wrapper for the anchor ref (stable, measurable). */} + <View + style={[styles.container, props.alignSelf ? { alignSelf: props.alignSelf } : null]} + ref={anchorRef} + collapsable={false} + > + <Pressable + style={styles.statusContainer} + onPress={() => setOpen(true)} + accessibilityRole="button" + > + <StatusDot + color={connectionStatus.color} + isPulsing={connectionStatus.isPulsing} + size={dotSize} + style={{ marginRight: 4 }} + /> + <Text + style={[styles.statusText, { color: connectionStatus.color, fontSize: textSize }]} + numberOfLines={1} + > + {connectionStatus.text} + </Text> + <Ionicons + name={open ? "chevron-up" : "chevron-down"} + size={chevronSize} + color={connectionStatus.color} + style={styles.statusChevron} + /> + </Pressable> + <Popover + open={open} + anchorRef={anchorRef} + placement="bottom" + edgePadding={{ horizontal: 12, vertical: 12 }} + portal={{ + web: true, + native: true, + matchAnchorWidth: false, + anchorAlign: 'center', + }} + maxWidthCap={320} + maxHeightCap={520} + onRequestClose={() => setOpen(false)} + > + {({ maxHeight }) => ( + <FloatingOverlay + maxHeight={Math.max(220, Math.min(maxHeight, 520))} + keyboardShouldPersistTaps="always" + edgeFades={{ top: true, bottom: true, size: 18 }} + edgeIndicators={true} + > + <View style={{ paddingTop: 8 }}> + <Text style={styles.popoverTitle}>Connection</Text> + + <View style={styles.popoverRow}> + <Text style={styles.popoverLabel}>Server</Text> + <Text style={styles.popoverValue} numberOfLines={2}>{getServerUrl()}</Text> + </View> + + <View style={styles.popoverRow}> + <Text style={styles.popoverLabel}>Socket</Text> + <Text style={styles.popoverValue}>{socketStatus.status}</Text> + </View> + + <View style={styles.popoverRow}> + <Text style={styles.popoverLabel}>Authenticated</Text> + <Text style={styles.popoverValue}>{auth.isAuthenticated ? 'Yes' : 'No'}</Text> + </View> + + <View style={styles.popoverRow}> + <Text style={styles.popoverLabel}>Last sync</Text> + <Text style={styles.popoverValue}>{formatTime(lastSyncAt)}</Text> + </View> + + {syncError?.nextRetryAt ? ( + <View style={styles.popoverRow}> + <Text style={styles.popoverLabel}>Next retry</Text> + <Text style={styles.popoverValue}>{formatTime(syncError.nextRetryAt)}</Text> + </View> + ) : null} + + {syncError ? ( + <View style={styles.popoverRow}> + <Text style={styles.popoverLabel}>Last error</Text> + <Text style={styles.popoverValue} numberOfLines={3}>{syncError.message}</Text> + </View> + ) : null} + + <ActionListSection + title="Actions" + actions={[ + { + id: 'retry', + label: t('common.retry'), + icon: <Ionicons name="refresh-outline" size={18} color={theme.colors.text} />, + disabled: syncError?.retryable === false, + onPress: () => { + sync.retryNow(); + setOpen(false); + } + }, + syncError?.kind === 'auth' ? { + id: 'restore', + label: t('connect.restoreAccount'), + icon: <Ionicons name="key-outline" size={18} color={theme.colors.text} />, + onPress: () => { + setOpen(false); + router.push('/restore'); + } + } : null, + { + id: 'server', + label: t('server.serverConfiguration'), + icon: <Ionicons name="server-outline" size={18} color={theme.colors.text} />, + onPress: () => { + setOpen(false); + router.push('/server'); + } + }, + { + id: 'account', + label: t('settings.account'), + icon: <Ionicons name="person-outline" size={18} color={theme.colors.text} />, + onPress: () => { + setOpen(false); + router.push('/settings/account'); + } + }, + ]} + /> + </View> + </FloatingOverlay> + )} + </Popover> + </View> + + </> + ); +}); diff --git a/expo-app/sources/components/navigation/HeaderTitleWithAction.tsx b/expo-app/sources/components/navigation/HeaderTitleWithAction.tsx new file mode 100644 index 000000000..4bf49301e --- /dev/null +++ b/expo-app/sources/components/navigation/HeaderTitleWithAction.tsx @@ -0,0 +1,65 @@ +import * as React from 'react'; +import { ActivityIndicator, Pressable, Text, View } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { StyleSheet } from 'react-native-unistyles'; +import { Typography } from '@/constants/Typography'; + +export type HeaderTitleWithActionProps = { + title: string; + tintColor?: string; + actionLabel: string; + actionIconName: React.ComponentProps<typeof Ionicons>['name']; + actionColor?: string; + actionDisabled?: boolean; + actionLoading?: boolean; + onActionPress: () => void; +}; + +export const HeaderTitleWithAction = React.memo((props: HeaderTitleWithActionProps) => { + const styles = stylesheet; + + return ( + <View style={styles.container}> + <Text + style={[styles.title, { color: props.tintColor ?? '#000' }]} + numberOfLines={1} + accessibilityRole="header" + > + {props.title} + </Text> + <Pressable + onPress={props.onActionPress} + hitSlop={10} + style={({ pressed }) => [styles.actionButton, pressed && styles.actionButtonPressed]} + accessibilityRole="button" + accessibilityLabel={props.actionLabel} + disabled={props.actionDisabled === true} + > + {props.actionLoading === true + ? <ActivityIndicator size="small" color={props.actionColor ?? props.tintColor ?? '#000'} /> + : <Ionicons name={props.actionIconName} size={18} color={props.actionColor ?? props.tintColor ?? '#000'} />} + </Pressable> + </View> + ); +}); + +const stylesheet = StyleSheet.create(() => ({ + container: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + maxWidth: '100%', + }, + title: { + fontSize: 17, + fontWeight: '600', + ...Typography.default('semiBold'), + }, + actionButton: { + padding: 2, + }, + actionButtonPressed: { + opacity: 0.7, + }, +})); + diff --git a/expo-app/sources/components/profiles/ProfileRequirementsBadge.tsx b/expo-app/sources/components/profiles/ProfileRequirementsBadge.tsx new file mode 100644 index 000000000..8f266b86f --- /dev/null +++ b/expo-app/sources/components/profiles/ProfileRequirementsBadge.tsx @@ -0,0 +1,114 @@ +import React from 'react'; +import { Pressable, Text, View } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { StyleSheet, useUnistyles } from 'react-native-unistyles'; + +import type { AIBackendProfile } from '@/sync/settings'; +import { t } from '@/text'; +import { useProfileEnvRequirements } from '@/hooks/useProfileEnvRequirements'; +import { hasRequiredSecret } from '@/sync/profileSecrets'; + +export interface ProfileRequirementsBadgeProps { + profile: AIBackendProfile; + machineId: string | null; + onPressIn?: () => void; + onPress?: () => void; + /** + * Optional override when the API key requirement is satisfied via a saved/session key + * (not the machine environment). Used by New Session flows. + */ + overrideReady?: boolean; + /** + * Optional override for machine-env preflight readiness/loading. + * When provided, this component will NOT run its own env preflight hook. + */ + machineEnvOverride?: { + isReady: boolean; + isLoading: boolean; + } | null; +} + +export function ProfileRequirementsBadge(props: ProfileRequirementsBadgeProps) { + const { theme } = useUnistyles(); + const styles = stylesheet; + + const show = hasRequiredSecret(props.profile); + const requirements = useProfileEnvRequirements( + props.machineEnvOverride ? null : props.machineId, + props.machineEnvOverride ? null : (show ? props.profile : null), + ); + + if (!show) { + return null; + } + + const machineIsReady = props.machineEnvOverride ? props.machineEnvOverride.isReady : requirements.isReady; + const machineIsLoading = props.machineEnvOverride ? props.machineEnvOverride.isLoading : requirements.isLoading; + + const isReady = machineIsReady || props.overrideReady === true; + const isLoading = machineIsLoading && !isReady; + + const statusColor = isLoading + ? theme.colors.status.connecting + : isReady + ? theme.colors.status.connected + : theme.colors.status.disconnected; + + const label = isReady + ? t('secrets.badgeReady') + : t('secrets.badgeRequired'); + + const iconName = isLoading + ? 'time-outline' + : isReady + ? 'checkmark-circle-outline' + : 'key-outline'; + + return ( + <Pressable + onPressIn={(e) => { + e?.stopPropagation?.(); + props.onPressIn?.(); + }} + onPress={(e) => { + e?.stopPropagation?.(); + props.onPress?.(); + }} + style={({ pressed }) => [ + styles.badge, + { + borderColor: statusColor, + opacity: pressed ? 0.85 : 1, + }, + ]} + > + <View style={styles.badgeRow}> + <Ionicons name={iconName as any} size={14} color={statusColor} /> + <Text style={[styles.badgeText, { color: statusColor }]} numberOfLines={1}> + {label} + </Text> + </View> + </Pressable> + ); +} + + +const stylesheet = StyleSheet.create((theme) => ({ + badge: { + maxWidth: 140, + borderWidth: 1, + borderRadius: 999, + paddingHorizontal: 10, + paddingVertical: 6, + backgroundColor: theme.colors.surface, + }, + badgeRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + }, + badgeText: { + fontSize: 12, + fontWeight: '600', + }, +})); diff --git a/expo-app/sources/components/profiles/ProfilesList.tsx b/expo-app/sources/components/profiles/ProfilesList.tsx new file mode 100644 index 000000000..bd6f9e1fe --- /dev/null +++ b/expo-app/sources/components/profiles/ProfilesList.tsx @@ -0,0 +1,484 @@ +import React from 'react'; +import { View, Text, Platform, useWindowDimensions } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { useUnistyles } from 'react-native-unistyles'; + +import { ItemList } from '@/components/ui/lists/ItemList'; +import { ItemGroup } from '@/components/ui/lists/ItemGroup'; +import { Item } from '@/components/ui/lists/Item'; +import { ItemRowActions } from '@/components/ui/lists/ItemRowActions'; +import type { ItemAction } from '@/components/ui/lists/itemActions'; + +import type { AIBackendProfile } from '@/sync/settings'; +import { ProfileCompatibilityIcon } from '@/components/sessions/new/components/ProfileCompatibilityIcon'; +import { ProfileRequirementsBadge } from '@/components/profiles/ProfileRequirementsBadge'; +import { ignoreNextRowPress } from '@/utils/ui/ignoreNextRowPress'; +import { toggleFavoriteProfileId } from '@/sync/profileGrouping'; +import { buildProfileActions } from '@/components/profiles/profileActions'; +import { getDefaultProfileListStrings, getProfileSubtitle, buildProfilesListGroups } from '@/components/profiles/profileListModel'; +import { getProfileDisplayName } from '@/components/profiles/profileDisplay'; +import { t } from '@/text'; +import { Typography } from '@/constants/Typography'; +import { hasRequiredSecret } from '@/sync/profileSecrets'; +import { useSetting } from '@/sync/storage'; +import { getEnabledAgentIds } from '@/agents/enabled'; + +export interface ProfilesListProps { + customProfiles: AIBackendProfile[]; + favoriteProfileIds: string[]; + onFavoriteProfileIdsChange: (next: string[]) => void; + experimentsEnabled: boolean; + + selectedProfileId: string | null; + onPressProfile?: (profile: AIBackendProfile) => void | Promise<void>; + onPressDefaultEnvironment?: () => void; + + machineId: string | null; + + includeDefaultEnvironmentRow?: boolean; + includeAddProfileRow?: boolean; + onAddProfilePress?: () => void; + + getProfileDisabled?: (profile: AIBackendProfile) => boolean; + getProfileSubtitleExtra?: (profile: AIBackendProfile) => string | null; + + onEditProfile?: (profile: AIBackendProfile) => void; + onDuplicateProfile?: (profile: AIBackendProfile) => void; + onDeleteProfile?: (profile: AIBackendProfile) => void; + getHasEnvironmentVariables?: (profile: AIBackendProfile) => boolean; + onViewEnvironmentVariables?: (profile: AIBackendProfile) => void; + extraActions?: (profile: AIBackendProfile) => ItemAction[]; + + onSecretBadgePress?: (profile: AIBackendProfile) => void; + + groupTitles?: { + favorites?: string; + custom?: string; + builtIn?: string; + }; + builtInGroupFooter?: string; + /** + * Optional explicit boundary ref for row action popovers. Useful when this list is rendered + * inside a scroll viewport (e.g. NewSessionWizard) and the popover should be clamped to the + * visible portion of that scroll container. + */ + popoverBoundaryRef?: React.RefObject<any> | null; + /** + * When provided, allows callers to mark API key requirements as satisfied via a saved/session key, + * not only machine environment. + */ + getSecretOverrideReady?: (profile: AIBackendProfile) => boolean; + /** + * When provided, supplies machine-env preflight readiness/loading for the profile's required secret env var. + * This allows callers to batch/cache daemon env checks instead of doing one request per row. + */ + getSecretMachineEnvOverride?: (profile: AIBackendProfile) => { isReady: boolean; isLoading: boolean } | null; +} + +type ProfileRowProps = { + profile: AIBackendProfile; + displayName: string; + isSelected: boolean; + isFavorite: boolean; + isDisabled: boolean; + showDivider: boolean; + isMobile: boolean; + machineId: string | null; + subtitleText: string; + showMobileBadge: boolean; + onPressProfile?: (profile: AIBackendProfile) => void | Promise<void>; + onSecretBadgePress?: (profile: AIBackendProfile) => void; + rightElement: React.ReactNode; + ignoreRowPressRef: React.MutableRefObject<boolean>; + getSecretOverrideReady?: (profile: AIBackendProfile) => boolean; + getSecretMachineEnvOverride?: (profile: AIBackendProfile) => { isReady: boolean; isLoading: boolean } | null; +}; + +const ProfileRow = React.memo(function ProfileRow(props: ProfileRowProps) { + const theme = useUnistyles().theme; + + const subtitle = React.useMemo(() => { + if (!props.showMobileBadge) return props.subtitleText; + return ( + <View style={{ gap: 6 }}> + <Text + style={{ + ...Typography.default('regular'), + color: theme.colors.textSecondary, + fontSize: Platform.select({ ios: 15, default: 14 }), + lineHeight: 20, + letterSpacing: Platform.select({ ios: -0.24, default: 0.1 }), + }} + > + {props.subtitleText} + </Text> + <View style={{ alignSelf: 'flex-start' }}> + <ProfileRequirementsBadge + profile={props.profile} + machineId={props.machineId} + overrideReady={props.getSecretOverrideReady?.(props.profile) ?? false} + machineEnvOverride={props.getSecretMachineEnvOverride?.(props.profile) ?? null} + onPressIn={() => ignoreNextRowPress(props.ignoreRowPressRef)} + onPress={() => { + props.onSecretBadgePress?.(props.profile); + }} + /> + </View> + </View> + ); + }, [props.ignoreRowPressRef, props.machineId, props.onSecretBadgePress, props.profile, props.showMobileBadge, props.subtitleText, theme.colors.textSecondary]); + + const onPress = React.useCallback(() => { + if (props.isDisabled) return; + if (props.ignoreRowPressRef.current) { + props.ignoreRowPressRef.current = false; + return; + } + void props.onPressProfile?.(props.profile); + }, [props.ignoreRowPressRef, props.isDisabled, props.onPressProfile, props.profile]); + + return ( + <Item + key={props.profile.id} + title={props.displayName} + subtitle={subtitle} + leftElement={<ProfileCompatibilityIcon profile={props.profile} />} + showChevron={false} + selected={props.isSelected} + disabled={props.isDisabled} + onPress={onPress} + rightElement={props.rightElement} + showDivider={props.showDivider} + /> + ); +}); + +export function ProfilesList(props: ProfilesListProps) { + const { theme, rt } = useUnistyles(); + const experimentalAgents = useSetting('experimentalAgents'); + const enabledAgentIds = React.useMemo(() => { + return getEnabledAgentIds({ experiments: props.experimentsEnabled, experimentalAgents }); + }, [experimentalAgents, props.experimentsEnabled]); + const strings = React.useMemo(() => getDefaultProfileListStrings(enabledAgentIds), [enabledAgentIds]); + const { + extraActions, + getHasEnvironmentVariables, + onDeleteProfile, + onDuplicateProfile, + onEditProfile, + onViewEnvironmentVariables, + } = props; + + const ignoreRowPressRef = React.useRef(false); + const selectedIndicatorColor = rt.themeName === 'dark' ? theme.colors.text : theme.colors.button.primary.background; + const isMobile = useWindowDimensions().width < 580; + + const groups = React.useMemo(() => { + return buildProfilesListGroups({ customProfiles: props.customProfiles, favoriteProfileIds: props.favoriteProfileIds, enabledAgentIds }); + }, [enabledAgentIds, props.customProfiles, props.favoriteProfileIds]); + + const isDefaultEnvironmentFavorite = groups.favoriteIds.has(''); + const showFavoritesGroup = groups.favoriteProfiles.length > 0 || (props.includeDefaultEnvironmentRow && isDefaultEnvironmentFavorite); + + const toggleFavorite = React.useCallback((profileId: string) => { + props.onFavoriteProfileIdsChange(toggleFavoriteProfileId(props.favoriteProfileIds, profileId)); + }, [props.favoriteProfileIds, props.onFavoriteProfileIdsChange]); + + // Precompute action arrays so selection changes don't rebuild them for every row. + const actionsByProfileId = React.useMemo(() => { + const map = new Map<string, { actions: ItemAction[]; compactActionIds: string[] }>(); + + const build = (profile: AIBackendProfile) => { + const isFavorite = groups.favoriteIds.has(profile.id); + const hasEnvVars = getHasEnvironmentVariables ? getHasEnvironmentVariables(profile) : false; + const canViewEnvVars = hasEnvVars && Boolean(onViewEnvironmentVariables); + const actions: ItemAction[] = [ + ...(extraActions ? extraActions(profile) : []), + ...buildProfileActions({ + profile, + isFavorite, + favoriteActionColor: selectedIndicatorColor, + nonFavoriteActionColor: theme.colors.textSecondary, + onToggleFavorite: () => toggleFavorite(profile.id), + onEdit: () => onEditProfile?.(profile), + onDuplicate: () => onDuplicateProfile?.(profile), + onDelete: onDeleteProfile ? () => onDeleteProfile?.(profile) : undefined, + onViewEnvironmentVariables: canViewEnvVars ? () => onViewEnvironmentVariables?.(profile) : undefined, + }), + ]; + const compactActionIds = ['favorite', ...(canViewEnvVars ? ['envVars'] : [])]; + map.set(profile.id, { actions, compactActionIds }); + }; + + for (const p of groups.favoriteProfiles) build(p); + for (const p of groups.customProfiles) build(p); + for (const p of groups.builtInProfiles) build(p); + + return map; + }, [ + groups.builtInProfiles, + groups.customProfiles, + groups.favoriteIds, + groups.favoriteProfiles, + extraActions, + getHasEnvironmentVariables, + onDeleteProfile, + onDuplicateProfile, + onEditProfile, + onViewEnvironmentVariables, + selectedIndicatorColor, + theme.colors.textSecondary, + toggleFavorite, + ]); + + const renderDefaultEnvironmentRightElement = React.useCallback((isSelected: boolean) => { + const isFavorite = isDefaultEnvironmentFavorite; + const actions: ItemAction[] = [ + { + id: 'favorite', + title: isFavorite ? t('profiles.actions.removeFromFavorites') : t('profiles.actions.addToFavorites'), + icon: isFavorite ? 'star' : 'star-outline', + onPress: () => toggleFavorite(''), + color: isFavorite ? selectedIndicatorColor : theme.colors.textSecondary, + }, + ]; + + return ( + <View style={{ flexDirection: 'row', alignItems: 'center', gap: 16 }}> + <View style={{ width: 24, alignItems: 'center', justifyContent: 'center' }}> + <Ionicons name="checkmark-circle" size={24} color={selectedIndicatorColor} style={{ opacity: isSelected ? 1 : 0 }} /> + </View> + <ItemRowActions + title={t('profiles.noProfile')} + actions={actions} + compactActionIds={['favorite']} + pinnedActionIds={['favorite']} + overflowPosition="beforePinned" + iconSize={20} + onActionPressIn={() => ignoreNextRowPress(ignoreRowPressRef)} + popoverBoundaryRef={props.popoverBoundaryRef} + /> + </View> + ); + }, [isDefaultEnvironmentFavorite, selectedIndicatorColor, theme.colors.textSecondary, toggleFavorite]); + + const renderProfileRightElement = React.useCallback((profile: AIBackendProfile, displayName: string, isSelected: boolean, isFavorite: boolean) => { + const entry = actionsByProfileId.get(profile.id); + const actions = entry?.actions ?? []; + const compactActionIds = entry?.compactActionIds ?? ['favorite']; + + return ( + <View style={{ flexDirection: 'row', alignItems: 'center', gap: 16 }}> + {!isMobile && ( + <ProfileRequirementsBadge + profile={profile} + machineId={props.machineId} + overrideReady={props.getSecretOverrideReady?.(profile) ?? false} + machineEnvOverride={props.getSecretMachineEnvOverride?.(profile) ?? null} + onPressIn={() => ignoreNextRowPress(ignoreRowPressRef)} + onPress={props.onSecretBadgePress ? () => { + props.onSecretBadgePress?.(profile); + } : undefined} + /> + )} + <View style={{ width: 24, alignItems: 'center', justifyContent: 'center' }}> + <Ionicons name="checkmark-circle" size={24} color={selectedIndicatorColor} style={{ opacity: isSelected ? 1 : 0 }} /> + </View> + <ItemRowActions + title={displayName} + actions={actions} + compactActionIds={compactActionIds} + pinnedActionIds={['favorite']} + overflowPosition="beforePinned" + iconSize={20} + onActionPressIn={() => ignoreNextRowPress(ignoreRowPressRef)} + popoverBoundaryRef={props.popoverBoundaryRef} + /> + </View> + ); + }, [ + actionsByProfileId, + isMobile, + props, + selectedIndicatorColor, + ]); + + return ( + <ItemList style={{ paddingTop: 0 }}> + {showFavoritesGroup && ( + <ItemGroup + title={props.groupTitles?.favorites ?? t('profiles.groups.favorites')} + selectableItemCountOverride={Math.max( + 1, + (props.includeDefaultEnvironmentRow && isDefaultEnvironmentFavorite ? 1 : 0) + groups.favoriteProfiles.length, + )} + > + {props.includeDefaultEnvironmentRow && isDefaultEnvironmentFavorite && ( + <Item + title={t('profiles.noProfile')} + subtitle={t('profiles.noProfileDescription')} + leftElement={<Ionicons name="home-outline" size={29} color={theme.colors.textSecondary} />} + showChevron={false} + selected={!props.selectedProfileId} + onPress={() => { + if (ignoreRowPressRef.current) { + ignoreRowPressRef.current = false; + return; + } + props.onPressDefaultEnvironment?.(); + }} + rightElement={renderDefaultEnvironmentRightElement(!props.selectedProfileId)} + showDivider={groups.favoriteProfiles.length > 0} + /> + )} + {groups.favoriteProfiles.map((profile, index) => { + const displayName = getProfileDisplayName(profile); + const isLast = index === groups.favoriteProfiles.length - 1; + const isSelected = props.selectedProfileId === profile.id; + const isDisabled = props.getProfileDisabled ? props.getProfileDisabled(profile) : false; + const baseSubtitle = getProfileSubtitle({ profile, enabledAgentIds, strings }); + const extra = props.getProfileSubtitleExtra?.(profile); + const subtitleText = extra ? `${baseSubtitle} · ${extra}` : baseSubtitle; + const showMobileBadge = isMobile && hasRequiredSecret(profile) && Boolean(props.onSecretBadgePress); + return ( + <ProfileRow + key={profile.id} + profile={profile} + displayName={displayName} + isSelected={isSelected} + isFavorite={true} + isDisabled={isDisabled} + showDivider={!isLast} + isMobile={isMobile} + machineId={props.machineId} + subtitleText={subtitleText} + showMobileBadge={showMobileBadge} + onPressProfile={props.onPressProfile} + onSecretBadgePress={props.onSecretBadgePress} + rightElement={renderProfileRightElement(profile, displayName, isSelected, true)} + ignoreRowPressRef={ignoreRowPressRef} + getSecretOverrideReady={props.getSecretOverrideReady} + getSecretMachineEnvOverride={props.getSecretMachineEnvOverride} + /> + ); + })} + </ItemGroup> + )} + + {groups.customProfiles.length > 0 && ( + <ItemGroup + title={props.groupTitles?.custom ?? t('profiles.groups.custom')} + selectableItemCountOverride={Math.max(2, groups.customProfiles.length)} + > + {groups.customProfiles.map((profile, index) => { + const displayName = getProfileDisplayName(profile); + const isLast = index === groups.customProfiles.length - 1; + const isFavorite = groups.favoriteIds.has(profile.id); + const isSelected = props.selectedProfileId === profile.id; + const isDisabled = props.getProfileDisabled ? props.getProfileDisabled(profile) : false; + const baseSubtitle = getProfileSubtitle({ profile, enabledAgentIds, strings }); + const extra = props.getProfileSubtitleExtra?.(profile); + const subtitleText = extra ? `${baseSubtitle} · ${extra}` : baseSubtitle; + const showMobileBadge = isMobile && hasRequiredSecret(profile) && Boolean(props.onSecretBadgePress); + return ( + <ProfileRow + key={profile.id} + profile={profile} + displayName={displayName} + isSelected={isSelected} + isFavorite={isFavorite} + isDisabled={isDisabled} + showDivider={!isLast} + isMobile={isMobile} + machineId={props.machineId} + subtitleText={subtitleText} + showMobileBadge={showMobileBadge} + onPressProfile={props.onPressProfile} + onSecretBadgePress={props.onSecretBadgePress} + rightElement={renderProfileRightElement(profile, displayName, isSelected, isFavorite)} + ignoreRowPressRef={ignoreRowPressRef} + getSecretOverrideReady={props.getSecretOverrideReady} + getSecretMachineEnvOverride={props.getSecretMachineEnvOverride} + /> + ); + })} + </ItemGroup> + )} + + <ItemGroup + title={props.groupTitles?.builtIn ?? t('profiles.groups.builtIn')} + footer={props.builtInGroupFooter} + selectableItemCountOverride={ + Math.max( + 1, + (props.includeDefaultEnvironmentRow && !isDefaultEnvironmentFavorite ? 1 : 0) + groups.builtInProfiles.length, + ) + } + > + {props.includeDefaultEnvironmentRow && !isDefaultEnvironmentFavorite && ( + <Item + title={t('profiles.noProfile')} + subtitle={t('profiles.noProfileDescription')} + leftElement={<Ionicons name="home-outline" size={29} color={theme.colors.textSecondary} />} + showChevron={false} + selected={!props.selectedProfileId} + onPress={() => { + if (ignoreRowPressRef.current) { + ignoreRowPressRef.current = false; + return; + } + props.onPressDefaultEnvironment?.(); + }} + rightElement={renderDefaultEnvironmentRightElement(!props.selectedProfileId)} + showDivider={groups.builtInProfiles.length > 0} + /> + )} + {groups.builtInProfiles.map((profile, index) => { + const displayName = getProfileDisplayName(profile); + const isLast = index === groups.builtInProfiles.length - 1; + const isFavorite = groups.favoriteIds.has(profile.id); + const isSelected = props.selectedProfileId === profile.id; + const isDisabled = props.getProfileDisabled ? props.getProfileDisabled(profile) : false; + const baseSubtitle = getProfileSubtitle({ profile, enabledAgentIds, strings }); + const extra = props.getProfileSubtitleExtra?.(profile); + const subtitleText = extra ? `${baseSubtitle} · ${extra}` : baseSubtitle; + const showMobileBadge = isMobile && hasRequiredSecret(profile) && Boolean(props.onSecretBadgePress); + return ( + <ProfileRow + key={profile.id} + profile={profile} + displayName={displayName} + isSelected={isSelected} + isFavorite={isFavorite} + isDisabled={isDisabled} + showDivider={!isLast} + isMobile={isMobile} + machineId={props.machineId} + subtitleText={subtitleText} + showMobileBadge={showMobileBadge} + onPressProfile={props.onPressProfile} + onSecretBadgePress={props.onSecretBadgePress} + rightElement={renderProfileRightElement(profile, displayName, isSelected, isFavorite)} + ignoreRowPressRef={ignoreRowPressRef} + getSecretOverrideReady={props.getSecretOverrideReady} + getSecretMachineEnvOverride={props.getSecretMachineEnvOverride} + /> + ); + })} + </ItemGroup> + + {props.includeAddProfileRow && props.onAddProfilePress && ( + <ItemGroup title="" selectableItemCountOverride={1}> + <Item + title={t('profiles.addProfile')} + subtitle={t('profiles.subtitle')} + leftElement={<Ionicons name="add-circle-outline" size={29} color={theme.colors.button.secondary.tint} />} + onPress={props.onAddProfilePress} + showChevron={false} + showDivider={false} + /> + </ItemGroup> + )} + </ItemList> + ); +} diff --git a/expo-app/sources/components/profiles/edit/MachinePreviewModal.tsx b/expo-app/sources/components/profiles/edit/MachinePreviewModal.tsx new file mode 100644 index 000000000..4d792f40c --- /dev/null +++ b/expo-app/sources/components/profiles/edit/MachinePreviewModal.tsx @@ -0,0 +1,95 @@ +import React from 'react'; +import { View, Text, Pressable, useWindowDimensions } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { StyleSheet, useUnistyles } from 'react-native-unistyles'; +import { Typography } from '@/constants/Typography'; +import { t } from '@/text'; +import { MachineSelector } from '@/components/sessions/new/components/MachineSelector'; +import type { Machine } from '@/sync/storageTypes'; + +export interface MachinePreviewModalProps { + machines: Machine[]; + favoriteMachineIds: string[]; + selectedMachineId: string | null; + onSelect: (machineId: string) => void; + onToggleFavorite: (machineId: string) => void; + onClose: () => void; +} + +export function MachinePreviewModal(props: MachinePreviewModalProps) { + const { theme } = useUnistyles(); + const styles = stylesheet; + const { height: windowHeight } = useWindowDimensions(); + + const selectedMachine = React.useMemo(() => { + if (!props.selectedMachineId) return null; + return props.machines.find((m) => m.id === props.selectedMachineId) ?? null; + }, [props.machines, props.selectedMachineId]); + + const favoriteMachines = React.useMemo(() => { + const byId = new Map(props.machines.map((m) => [m.id, m] as const)); + return props.favoriteMachineIds.map((id) => byId.get(id)).filter(Boolean) as Machine[]; + }, [props.favoriteMachineIds, props.machines]); + + const maxHeight = Math.min(720, Math.max(420, Math.floor(windowHeight * 0.85))); + + return ( + <View style={[styles.machinePreviewModalContainer, { height: maxHeight, maxHeight }]}> + <View style={styles.machinePreviewModalHeader}> + <Text style={styles.machinePreviewModalTitle}>{t('profiles.previewMachine.title')}</Text> + + <Pressable + onPress={props.onClose} + hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} + style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })} + > + <Ionicons name="close" size={20} color={theme.colors.textSecondary} /> + </Pressable> + </View> + + <View style={{ flex: 1 }}> + <MachineSelector + machines={props.machines} + selectedMachine={selectedMachine} + favoriteMachines={favoriteMachines} + showRecent={false} + showFavorites={favoriteMachines.length > 0} + showSearch + searchPlacement={favoriteMachines.length > 0 ? 'favorites' : 'all'} + onSelect={(machine) => { + props.onSelect(machine.id); + props.onClose(); + }} + onToggleFavorite={(machine) => props.onToggleFavorite(machine.id)} + /> + </View> + </View> + ); +} + +const stylesheet = StyleSheet.create((theme) => ({ + machinePreviewModalContainer: { + width: '92%', + maxWidth: 560, + backgroundColor: theme.colors.groupped.background, + borderRadius: 16, + overflow: 'hidden', + borderWidth: 1, + borderColor: theme.colors.divider, + flexShrink: 1, + }, + machinePreviewModalHeader: { + paddingHorizontal: 16, + paddingVertical: 12, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + borderBottomWidth: 1, + borderBottomColor: theme.colors.divider, + }, + machinePreviewModalTitle: { + fontSize: 17, + color: theme.colors.text, + ...Typography.default('semiBold'), + }, +})); diff --git a/expo-app/sources/components/profiles/edit/ProfileEditForm.previewMachinePicker.test.ts b/expo-app/sources/components/profiles/edit/ProfileEditForm.previewMachinePicker.test.ts new file mode 100644 index 000000000..ac00539f3 --- /dev/null +++ b/expo-app/sources/components/profiles/edit/ProfileEditForm.previewMachinePicker.test.ts @@ -0,0 +1,201 @@ +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import renderer, { act } from 'react-test-renderer'; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +vi.mock('@/text', () => ({ + t: (key: string) => key, +})); + +const routerPushMock = vi.fn(); + +vi.mock('react-native', () => ({ + Platform: { OS: 'ios', select: (spec: any) => (spec && 'ios' in spec ? spec.ios : spec?.default) }, + View: 'View', + Text: 'Text', + TextInput: 'TextInput', + Pressable: 'Pressable', + Linking: {}, + useWindowDimensions: () => ({ height: 800, width: 400 }), +})); + +vi.mock('expo-router', () => ({ + useRouter: () => ({ push: routerPushMock }), + useLocalSearchParams: () => ({}), +})); + +vi.mock('react-native-unistyles', () => ({ + useUnistyles: () => ({ + theme: { + colors: { + header: { tint: '#000' }, + textSecondary: '#666', + button: { secondary: { tint: '#000' }, primary: { background: '#00f' } }, + surface: '#fff', + text: '#000', + status: { connected: '#0f0', disconnected: '#f00' }, + input: { placeholder: '#999' }, + }, + }, + rt: { themeName: 'light' }, + }), + StyleSheet: { create: () => ({}) }, +})); + +vi.mock('@expo/vector-icons', () => ({ + Ionicons: 'Ionicons', +})); + +const modalShowMock = vi.fn(); +vi.mock('@/modal', () => ({ + Modal: { show: (...args: any[]) => modalShowMock(...args), alert: vi.fn() }, +})); + +vi.mock('@/sync/storage', () => ({ + useSetting: () => ({}), + useAllMachines: () => [{ id: 'm1', metadata: { displayName: 'M1' } }], + useMachine: () => null, + useSettingMutable: (key: string) => { + if (key === 'favoriteMachines') return [[], vi.fn()]; + if (key === 'secrets') return [[], vi.fn()]; + if (key === 'secretBindingsByProfileId') return [{}, vi.fn()]; + return [[], vi.fn()]; + }, +})); + +vi.mock('@/components/sessions/new/components/MachineSelector', () => ({ + MachineSelector: () => null, +})); + +vi.mock('@/hooks/useCLIDetection', () => ({ + useCLIDetection: () => ({ status: 'unknown' }), +})); + +vi.mock('@/components/profiles/environmentVariables/EnvironmentVariablesList', () => ({ + EnvironmentVariablesList: () => null, +})); + +vi.mock('@/components/SessionTypeSelector', () => ({ + SessionTypeSelector: () => null, +})); + +vi.mock('@/components/ui/forms/OptionTiles', () => ({ + OptionTiles: () => null, +})); + +vi.mock('@/agents/useEnabledAgentIds', () => ({ + useEnabledAgentIds: () => [], +})); + +vi.mock('@/agents/catalog', () => ({ + getAgentCore: () => ({ permissions: { modeGroup: 'default' } }), +})); + +vi.mock('@/components/ui/forms/dropdown/DropdownMenu', () => ({ + DropdownMenu: () => null, +})); + +vi.mock('@/components/ui/lists/ItemList', () => ({ + ItemList: ({ children }: any) => React.createElement(React.Fragment, null, children), +})); + +vi.mock('@/components/ui/lists/ItemGroup', () => ({ + ItemGroup: ({ children }: any) => React.createElement(React.Fragment, null, children), +})); + +let capturedPreviewMachineItem: any = null; +vi.mock('@/components/ui/lists/Item', () => ({ + Item: (props: any) => { + if (props?.onPress && props?.title === 'profiles.previewMachine.itemTitle') { + capturedPreviewMachineItem = props; + } + return null; + }, +})); + +vi.mock('@/components/Switch', () => ({ + Switch: () => null, +})); + +vi.mock('@/utils/machineUtils', () => ({ + isMachineOnline: () => true, +})); + +vi.mock('@/sync/profileUtils', () => ({ + getBuiltInProfileDocumentation: () => null, +})); + +vi.mock('@/sync/permissionTypes', () => ({ + normalizeProfileDefaultPermissionMode: (x: any) => x, +})); + +vi.mock('@/sync/permissionModeOptions', () => ({ + getPermissionModeLabelForAgentType: () => '', + getPermissionModeOptionsForAgentType: () => [], + normalizePermissionModeForAgentType: (x: any) => x, +})); + +vi.mock('@/sync/permissionDefaults', () => ({ + inferSourceModeGroupForPermissionMode: () => 'default', +})); + +vi.mock('@/sync/permissionMapping', () => ({ + mapPermissionModeAcrossAgents: (x: any) => x, +})); + +vi.mock('@/components/layout', () => ({ + layout: { maxWidth: 900 }, +})); + +vi.mock('@/utils/profiles/envVarTemplate', () => ({ + parseEnvVarTemplate: () => ({ variables: [] }), +})); + +vi.mock('@/components/secrets/requirements', () => ({ + SecretRequirementModal: () => null, +})); + +describe('ProfileEditForm (native preview machine picker)', () => { + it('opens a picker screen instead of a modal overlay on native', async () => { + const { ProfileEditForm } = await import('@/components/profiles/edit'); + capturedPreviewMachineItem = null; + routerPushMock.mockClear(); + modalShowMock.mockClear(); + + await act(async () => { + renderer.create( + React.createElement(ProfileEditForm, { + profile: { + id: 'p1', + name: 'P', + environmentVariables: [], + defaultPermissionModeByAgent: {}, + compatibility: { claude: true, codex: true, gemini: true }, + envVarRequirements: [], + isBuiltIn: false, + createdAt: 0, + updatedAt: 0, + version: '1.0.0', + }, + machineId: null, + onSave: () => true, + onCancel: vi.fn(), + }), + ); + }); + + expect(capturedPreviewMachineItem).toBeTruthy(); + + await act(async () => { + capturedPreviewMachineItem.onPress(); + }); + + expect(modalShowMock).not.toHaveBeenCalled(); + expect(routerPushMock).toHaveBeenCalledTimes(1); + expect(routerPushMock).toHaveBeenCalledWith({ + pathname: '/new/pick/preview-machine', + params: {}, + }); + }); +}); diff --git a/expo-app/sources/components/profiles/edit/ProfileEditForm.tsx b/expo-app/sources/components/profiles/edit/ProfileEditForm.tsx new file mode 100644 index 000000000..f55329076 --- /dev/null +++ b/expo-app/sources/components/profiles/edit/ProfileEditForm.tsx @@ -0,0 +1,916 @@ +import React from 'react'; +import { View, Text, TextInput, ViewStyle, Linking, Platform, Pressable } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { StyleSheet } from 'react-native-unistyles'; +import { useUnistyles } from 'react-native-unistyles'; +import { Typography } from '@/constants/Typography'; +import { t } from '@/text'; +import { AIBackendProfile } from '@/sync/settings'; +import { normalizeProfileDefaultPermissionMode, type PermissionMode } from '@/sync/permissionTypes'; +import { getPermissionModeLabelForAgentType, getPermissionModeOptionsForAgentType, normalizePermissionModeForAgentType } from '@/sync/permissionModeOptions'; +import { inferSourceModeGroupForPermissionMode } from '@/sync/permissionDefaults'; +import { mapPermissionModeAcrossAgents } from '@/sync/permissionMapping'; +import { SessionTypeSelector } from '@/components/SessionTypeSelector'; +import { ItemList } from '@/components/ui/lists/ItemList'; +import { ItemGroup } from '@/components/ui/lists/ItemGroup'; +import { Item } from '@/components/ui/lists/Item'; +import { Switch } from '@/components/Switch'; +import { DropdownMenu } from '@/components/ui/forms/dropdown/DropdownMenu'; +import { getBuiltInProfileDocumentation } from '@/sync/profileUtils'; +import { EnvironmentVariablesList } from '@/components/profiles/environmentVariables/EnvironmentVariablesList'; +import { useSetting, useAllMachines, useMachine, useSettingMutable } from '@/sync/storage'; +import { Modal } from '@/modal'; +import { isMachineOnline } from '@/utils/machineUtils'; +import { OptionTiles } from '@/components/ui/forms/OptionTiles'; +import { useCLIDetection } from '@/hooks/useCLIDetection'; +import { layout } from '@/components/layout'; +import { SecretRequirementModal, type SecretRequirementModalResult } from '@/components/secrets/requirements'; +import { parseEnvVarTemplate } from '@/utils/profiles/envVarTemplate'; +import { useEnabledAgentIds } from '@/agents/useEnabledAgentIds'; +import { DEFAULT_AGENT_ID, getAgentCore, type AgentId, type MachineLoginKey } from '@/agents/catalog'; +import { useLocalSearchParams, useRouter } from 'expo-router'; +import { MachinePreviewModal } from './MachinePreviewModal'; + +export interface ProfileEditFormProps { + profile: AIBackendProfile; + machineId: string | null; + /** + * Return true when the profile was successfully saved. + * Return false when saving failed (e.g. validation error). + */ + onSave: (profile: AIBackendProfile) => boolean; + onCancel: () => void; + onDirtyChange?: (isDirty: boolean) => void; + containerStyle?: ViewStyle; + saveRef?: React.MutableRefObject<(() => boolean) | null>; +} + +export function ProfileEditForm({ + profile, + machineId, + onSave, + onCancel, + onDirtyChange, + containerStyle, + saveRef, +}: ProfileEditFormProps) { + const { theme, rt } = useUnistyles(); + const router = useRouter(); + const routeParams = useLocalSearchParams<{ previewMachineId?: string | string[] }>(); + const previewMachineIdParam = Array.isArray(routeParams.previewMachineId) ? routeParams.previewMachineId[0] : routeParams.previewMachineId; + const selectedIndicatorColor = rt.themeName === 'dark' ? theme.colors.text : theme.colors.button.primary.background; + const styles = stylesheet; + const popoverBoundaryRef = React.useRef<any>(null); + const enabledAgentIds = useEnabledAgentIds(); + const machines = useAllMachines(); + const [favoriteMachines, setFavoriteMachines] = useSettingMutable('favoriteMachines'); + const [secrets, setSecrets] = useSettingMutable('secrets'); + const [secretBindingsByProfileId, setSecretBindingsByProfileId] = useSettingMutable('secretBindingsByProfileId'); + const routeMachine = machineId; + const [previewMachineId, setPreviewMachineId] = React.useState<string | null>(routeMachine); + + React.useEffect(() => { + setPreviewMachineId(routeMachine); + }, [routeMachine]); + + React.useEffect(() => { + if (routeMachine) return; + if (typeof previewMachineIdParam !== 'string') return; + const trimmed = previewMachineIdParam.trim(); + if (trimmed.length === 0) { + setPreviewMachineId(null); + return; + } + setPreviewMachineId(trimmed); + }, [previewMachineIdParam, routeMachine]); + + const resolvedMachineId = routeMachine ?? previewMachineId; + const resolvedMachine = useMachine(resolvedMachineId ?? ''); + const cliDetection = useCLIDetection(resolvedMachineId, { includeLoginStatus: Boolean(resolvedMachineId) }); + + const toggleFavoriteMachineId = React.useCallback((machineIdToToggle: string) => { + if (favoriteMachines.includes(machineIdToToggle)) { + setFavoriteMachines(favoriteMachines.filter((id) => id !== machineIdToToggle)); + } else { + setFavoriteMachines([machineIdToToggle, ...favoriteMachines]); + } + }, [favoriteMachines, setFavoriteMachines]); + + const MachinePreviewModalWrapper = React.useCallback(({ onClose }: { onClose: () => void }) => { + return ( + <MachinePreviewModal + machines={machines} + favoriteMachineIds={favoriteMachines} + selectedMachineId={previewMachineId} + onSelect={setPreviewMachineId} + onToggleFavorite={toggleFavoriteMachineId} + onClose={onClose} + /> + ); + }, [favoriteMachines, machines, previewMachineId, toggleFavoriteMachineId]); + + const showMachinePreviewPicker = React.useCallback(() => { + if (Platform.OS !== 'web') { + const params = previewMachineId ? { selectedId: previewMachineId } : {}; + router.push({ pathname: '/new/pick/preview-machine', params } as any); + return; + } + Modal.show({ + component: MachinePreviewModalWrapper, + props: {}, + }); + }, [MachinePreviewModalWrapper, previewMachineId, router]); + + const profileDocs = React.useMemo(() => { + if (!profile.isBuiltIn) return null; + return getBuiltInProfileDocumentation(profile.id); + }, [profile.id, profile.isBuiltIn]); + + const [environmentVariables, setEnvironmentVariables] = React.useState<Array<{ name: string; value: string; isSecret?: boolean }>>( + profile.environmentVariables || [], + ); + + const [name, setName] = React.useState(profile.name || ''); + const [defaultSessionType, setDefaultSessionType] = React.useState<'simple' | 'worktree'>( + profile.defaultSessionType || 'simple', + ); + const sessionDefaultPermissionModeByAgent = useSetting('sessionDefaultPermissionModeByAgent'); + + const [defaultPermissionModes, setDefaultPermissionModes] = React.useState<Partial<Record<AgentId, PermissionMode | null>>>(() => { + const explicitByAgent = (profile.defaultPermissionModeByAgent as Record<string, PermissionMode | undefined>) ?? {}; + const out: Partial<Record<AgentId, PermissionMode | null>> = {}; + + for (const agentId of enabledAgentIds) { + const explicit = explicitByAgent[agentId]; + out[agentId] = explicit ? normalizePermissionModeForAgentType(explicit, agentId) : null; + } + + const hasAnyExplicit = enabledAgentIds.some((agentId) => Boolean(out[agentId])); + if (hasAnyExplicit) return out; + + const legacyRaw = profile.defaultPermissionMode as PermissionMode | undefined; + const legacy = legacyRaw ? normalizeProfileDefaultPermissionMode(legacyRaw) : undefined; + if (!legacy) return out; + + const fromGroup = inferSourceModeGroupForPermissionMode(legacy); + const from = + enabledAgentIds.find((id) => getAgentCore(id).permissions.modeGroup === fromGroup) ?? + enabledAgentIds[0] ?? + DEFAULT_AGENT_ID; + const compat = profile.compatibility ?? {}; + + for (const agentId of enabledAgentIds) { + const explicitCompat = compat[agentId]; + const isCompat = typeof explicitCompat === 'boolean' ? explicitCompat : (profile.isBuiltIn ? false : true); + if (!isCompat) continue; + out[agentId] = normalizePermissionModeForAgentType(mapPermissionModeAcrossAgents(legacy, from, agentId), agentId); + } + + return out; + }); + + const [compatibility, setCompatibility] = React.useState<NonNullable<AIBackendProfile['compatibility']>>(() => { + const base: NonNullable<AIBackendProfile['compatibility']> = { ...(profile.compatibility ?? {}) }; + for (const agentId of enabledAgentIds) { + if (typeof base[agentId] !== 'boolean') { + base[agentId] = profile.isBuiltIn ? false : true; + } + } + if (enabledAgentIds.length > 0 && enabledAgentIds.every((agentId) => base[agentId] !== true)) { + base[enabledAgentIds[0]] = true; + } + return base; + }); + + React.useEffect(() => { + setCompatibility((prev) => { + let changed = false; + const next: NonNullable<AIBackendProfile['compatibility']> = { ...prev }; + for (const agentId of enabledAgentIds) { + if (typeof next[agentId] !== 'boolean') { + next[agentId] = profile.isBuiltIn ? false : true; + changed = true; + } + } + return changed ? next : prev; + }); + }, [enabledAgentIds, profile.isBuiltIn]); + + const [authMode, setAuthMode] = React.useState<AIBackendProfile['authMode']>(profile.authMode); + const [requiresMachineLogin, setRequiresMachineLogin] = React.useState<AIBackendProfile['requiresMachineLogin']>(profile.requiresMachineLogin); + /** + * Requirements live in the env-var editor UI, but are persisted in `profile.envVarRequirements` + * (derived) and `secretBindingsByProfileId` (per-profile default saved secret choice). + * + * Attachment model: + * - When a row uses `${SOURCE_VAR}`, requirements attach to `SOURCE_VAR` + * - Otherwise, requirements attach to the env var name itself (e.g. `OPENAI_API_KEY`) + */ + const [sourceRequirementsByName, setSourceRequirementsByName] = React.useState<Record<string, { required: boolean; useSecretVault: boolean }>>(() => { + const map: Record<string, { required: boolean; useSecretVault: boolean }> = {}; + for (const req of profile.envVarRequirements ?? []) { + if (!req || typeof (req as any).name !== 'string') continue; + const name = String((req as any).name).trim().toUpperCase(); + if (!name) continue; + const kind = ((req as any).kind ?? 'secret') as 'secret' | 'config'; + map[name] = { + required: Boolean((req as any).required), + useSecretVault: kind === 'secret', + }; + } + return map; + }); + + const usedRequirementVarNames = React.useMemo(() => { + const set = new Set<string>(); + for (const v of environmentVariables) { + const tpl = parseEnvVarTemplate(v.value); + const name = (tpl?.sourceVar ? tpl.sourceVar : v.name).trim().toUpperCase(); + if (name) set.add(name); + } + return set; + }, [environmentVariables]); + + // Prune requirements that no longer correspond to any referenced requirement var name. + React.useEffect(() => { + setSourceRequirementsByName((prev) => { + let changed = false; + const next: Record<string, { required: boolean; useSecretVault: boolean }> = {}; + for (const [name, state] of Object.entries(prev)) { + if (usedRequirementVarNames.has(name)) { + next[name] = state; + } else { + changed = true; + } + } + return changed ? next : prev; + }); + }, [usedRequirementVarNames]); + + // Prune default secret bindings when the requirement var name is no longer used or no longer uses the vault. + React.useEffect(() => { + const existing = secretBindingsByProfileId[profile.id]; + if (!existing) return; + + let changed = false; + const nextBindings: Record<string, string> = {}; + for (const [envVarName, secretId] of Object.entries(existing)) { + const req = sourceRequirementsByName[envVarName]; + const keep = usedRequirementVarNames.has(envVarName) && Boolean(req?.useSecretVault); + if (keep) { + nextBindings[envVarName] = secretId; + } else { + changed = true; + } + } + if (!changed) return; + + const out = { ...secretBindingsByProfileId }; + if (Object.keys(nextBindings).length === 0) { + delete out[profile.id]; + } else { + out[profile.id] = nextBindings; + } + setSecretBindingsByProfileId(out); + }, [profile.id, secretBindingsByProfileId, setSecretBindingsByProfileId, sourceRequirementsByName, usedRequirementVarNames]); + + const derivedEnvVarRequirements = React.useMemo<NonNullable<AIBackendProfile['envVarRequirements']>>(() => { + const out = Object.entries(sourceRequirementsByName) + .filter(([name]) => usedRequirementVarNames.has(name)) + .map(([name, state]) => ({ + name, + kind: state.useSecretVault ? 'secret' as const : 'config' as const, + required: Boolean(state.required), + })); + out.sort((a, b) => a.name.localeCompare(b.name)); + return out; + }, [sourceRequirementsByName, usedRequirementVarNames]); + + const getDefaultSecretNameForSourceVar = React.useCallback((sourceVarName: string): string | null => { + const id = secretBindingsByProfileId[profile.id]?.[sourceVarName] ?? null; + if (!id) return null; + return secrets.find((s) => s.id === id)?.name ?? null; + }, [profile.id, secretBindingsByProfileId, secrets]); + + const openDefaultSecretModalForSourceVar = React.useCallback((sourceVarName: string) => { + const normalized = sourceVarName.trim().toUpperCase(); + if (!normalized) return; + + // Use derived requirements so the modal reflects the current editor state. + const previewProfile: AIBackendProfile = { + ...profile, + name, + envVarRequirements: derivedEnvVarRequirements, + }; + + const defaultSecretId = secretBindingsByProfileId[profile.id]?.[normalized] ?? null; + + const setDefaultSecretId = (id: string | null) => { + const existing = secretBindingsByProfileId[profile.id] ?? {}; + const nextBindings = { ...existing }; + if (!id) { + delete nextBindings[normalized]; + } else { + nextBindings[normalized] = id; + } + const out = { ...secretBindingsByProfileId }; + if (Object.keys(nextBindings).length === 0) { + delete out[profile.id]; + } else { + out[profile.id] = nextBindings; + } + setSecretBindingsByProfileId(out); + }; + + const handleResolve = (result: SecretRequirementModalResult) => { + if (result.action !== 'selectSaved') return; + setDefaultSecretId(result.secretId); + }; + + Modal.show({ + component: SecretRequirementModal, + props: { + profile: previewProfile, + secretEnvVarName: normalized, + machineId: null, + secrets, + defaultSecretId, + selectedSavedSecretId: defaultSecretId, + onSetDefaultSecretId: setDefaultSecretId, + variant: 'defaultForProfile', + titleOverride: t('secrets.defineDefaultForProfileTitle'), + onChangeSecrets: setSecrets, + allowSessionOnly: false, + onResolve: handleResolve, + onRequestClose: () => handleResolve({ action: 'cancel' } as SecretRequirementModalResult), + }, + closeOnBackdrop: true, + }); + }, [derivedEnvVarRequirements, name, profile, secretBindingsByProfileId, secrets, setSecretBindingsByProfileId, setSecrets]); + + const updateSourceRequirement = React.useCallback(( + sourceVarName: string, + next: { required: boolean; useSecretVault: boolean } | null + ) => { + const normalized = sourceVarName.trim().toUpperCase(); + if (!normalized) return; + + setSourceRequirementsByName((prev) => { + const out = { ...prev }; + if (next === null) { + delete out[normalized]; + } else { + out[normalized] = { required: Boolean(next.required), useSecretVault: Boolean(next.useSecretVault) }; + } + return out; + }); + + // If the vault is disabled (or requirement removed), drop any default secret binding immediately. + if (next === null || next.useSecretVault !== true) { + const existing = secretBindingsByProfileId[profile.id]; + if (existing && (normalized in existing)) { + const nextBindings = { ...existing }; + delete nextBindings[normalized]; + const out = { ...secretBindingsByProfileId }; + if (Object.keys(nextBindings).length === 0) { + delete out[profile.id]; + } else { + out[profile.id] = nextBindings; + } + setSecretBindingsByProfileId(out); + } + } + }, [profile.id, secretBindingsByProfileId, setSecretBindingsByProfileId]); + + const allowedMachineLoginOptions = React.useMemo(() => { + const options: MachineLoginKey[] = []; + for (const agentId of enabledAgentIds) { + if (compatibility[agentId] !== true) continue; + options.push(getAgentCore(agentId).cli.machineLoginKey); + } + return options; + }, [compatibility, enabledAgentIds]); + + const [openPermissionProvider, setOpenPermissionProvider] = React.useState<null | AgentId>(null); + + const setDefaultPermissionModeForProvider = React.useCallback((provider: AgentId, next: PermissionMode | null) => { + setDefaultPermissionModes((prev) => { + if (prev[provider] === next) return prev; + return { ...prev, [provider]: next }; + }); + }, []); + + const accountDefaultPermissionModes = React.useMemo(() => { + const out: Partial<Record<AgentId, PermissionMode>> = {}; + for (const agentId of enabledAgentIds) { + const raw = (sessionDefaultPermissionModeByAgent as any)?.[agentId] as PermissionMode | undefined; + out[agentId] = normalizePermissionModeForAgentType((raw ?? 'default') as PermissionMode, agentId); + } + return out; + }, [enabledAgentIds, sessionDefaultPermissionModeByAgent]); + + const getPermissionIconNameForAgent = React.useCallback((agent: AgentId, mode: PermissionMode) => { + return getPermissionModeOptionsForAgentType(agent).find((opt) => opt.value === mode)?.icon ?? 'shield-outline'; + }, []); + + React.useEffect(() => { + if (authMode !== 'machineLogin') return; + // If exactly one backend is enabled, we can persist the explicit CLI requirement. + // If multiple are enabled, the required CLI is derived at session-start from the selected backend. + if (allowedMachineLoginOptions.length === 1) { + const only = allowedMachineLoginOptions[0]; + if (requiresMachineLogin !== only) { + setRequiresMachineLogin(only); + } + return; + } + if (requiresMachineLogin) { + setRequiresMachineLogin(undefined); + } + }, [allowedMachineLoginOptions, authMode, requiresMachineLogin]); + + const initialSnapshotRef = React.useRef<string | null>(null); + if (initialSnapshotRef.current === null) { + initialSnapshotRef.current = JSON.stringify({ + name, + environmentVariables, + defaultSessionType, + defaultPermissionModes, + compatibility, + authMode, + requiresMachineLogin, + derivedEnvVarRequirements, + // Bindings are settings-level but edited here; include for dirty tracking. + secretBindings: secretBindingsByProfileId[profile.id] ?? null, + }); + } + + const isDirty = React.useMemo(() => { + const currentSnapshot = JSON.stringify({ + name, + environmentVariables, + defaultSessionType, + defaultPermissionModes, + compatibility, + authMode, + requiresMachineLogin, + derivedEnvVarRequirements, + secretBindings: secretBindingsByProfileId[profile.id] ?? null, + }); + return currentSnapshot !== initialSnapshotRef.current; + }, [ + authMode, + compatibility, + defaultPermissionModes, + defaultSessionType, + environmentVariables, + name, + derivedEnvVarRequirements, + requiresMachineLogin, + secretBindingsByProfileId, + profile.id, + ]); + + React.useEffect(() => { + onDirtyChange?.(isDirty); + }, [isDirty, onDirtyChange]); + + const toggleCompatibility = React.useCallback((agentId: AgentId) => { + setCompatibility((prev) => { + const next = { ...prev, [agentId]: !prev[agentId] }; + const enabledCount = enabledAgentIds.filter((id) => next[id] === true).length; + if (enabledCount === 0) { + Modal.alert(t('common.error'), t('profiles.aiBackend.selectAtLeastOneError')); + return prev; + } + return next; + }); + }, [enabledAgentIds]); + + const openSetupGuide = React.useCallback(async () => { + const url = profileDocs?.setupGuideUrl; + if (!url) return; + try { + if (Platform.OS === 'web') { + window.open(url, '_blank'); + } else { + await Linking.openURL(url); + } + } catch (error) { + console.error('Failed to open URL:', error); + } + }, [profileDocs?.setupGuideUrl]); + + const handleSave = React.useCallback((): boolean => { + if (!name.trim()) { + Modal.alert(t('common.error'), t('profiles.nameRequired')); + return false; + } + + const { defaultPermissionModeClaude, defaultPermissionModeCodex, defaultPermissionModeGemini, ...profileBase } = profile as any; + const defaultPermissionModeByAgent: Record<string, PermissionMode> = {}; + for (const agentId of enabledAgentIds) { + const mode = (defaultPermissionModes as any)?.[agentId] as PermissionMode | null | undefined; + if (mode) defaultPermissionModeByAgent[agentId] = mode; + } + + return onSave({ + ...profileBase, + name: name.trim(), + environmentVariables, + authMode, + requiresMachineLogin: authMode === 'machineLogin' && allowedMachineLoginOptions.length === 1 + ? allowedMachineLoginOptions[0] + : undefined, + envVarRequirements: derivedEnvVarRequirements, + defaultSessionType, + // Prefer provider-specific defaults; clear legacy field on save. + defaultPermissionMode: undefined, + defaultPermissionModeByAgent, + compatibility, + updatedAt: Date.now(), + }); + }, [ + allowedMachineLoginOptions, + enabledAgentIds, + derivedEnvVarRequirements, + compatibility, + defaultPermissionModes, + defaultSessionType, + environmentVariables, + name, + onSave, + profile, + authMode, + ]); + + React.useEffect(() => { + if (!saveRef) { + return; + } + saveRef.current = handleSave; + return () => { + saveRef.current = null; + }; + }, [handleSave, saveRef]); + + return ( + <ItemList ref={popoverBoundaryRef} style={containerStyle} keyboardShouldPersistTaps="handled"> + <ItemGroup title={t('profiles.profileName')}> + <React.Fragment> + <View style={styles.inputContainer}> + <TextInput + style={styles.textInput} + placeholder={t('profiles.enterName')} + placeholderTextColor={theme.colors.input.placeholder} + value={name} + onChangeText={setName} + /> + </View> + </React.Fragment> + </ItemGroup> + + {profile.isBuiltIn && profileDocs?.setupGuideUrl && ( + <ItemGroup title={t('profiles.setupInstructions.title')} footer={profileDocs.description}> + <Item + title={t('profiles.setupInstructions.viewOfficialGuide')} + icon={<Ionicons name="book-outline" size={29} color={theme.colors.button.secondary.tint} />} + onPress={() => void openSetupGuide()} + /> + </ItemGroup> + )} + + <ItemGroup title={t('profiles.requirements.sectionTitle')} footer={t('profiles.requirements.sectionSubtitle')}> + <Item + title={t('profiles.machineLogin.title')} + subtitle={t('profiles.machineLogin.subtitle')} + leftElement={<Ionicons name="terminal-outline" size={24} color={theme.colors.textSecondary} />} + rightElement={( + <Switch + value={authMode === 'machineLogin'} + onValueChange={(next) => { + if (!next) { + setAuthMode(undefined); + setRequiresMachineLogin(undefined); + return; + } + setAuthMode('machineLogin'); + setRequiresMachineLogin(undefined); + }} + /> + )} + showChevron={false} + onPress={() => { + const next = authMode !== 'machineLogin'; + if (!next) { + setAuthMode(undefined); + setRequiresMachineLogin(undefined); + return; + } + setAuthMode('machineLogin'); + setRequiresMachineLogin(undefined); + }} + showDivider={false} + /> + </ItemGroup> + + <ItemGroup title={t('profiles.aiBackend.title')}> + {(() => { + const shouldShowLoginStatus = authMode === 'machineLogin' && Boolean(resolvedMachineId); + + const renderLoginStatus = (status: boolean) => ( + <Text style={[styles.aiBackendStatus, { color: status ? theme.colors.status.connected : theme.colors.status.disconnected }]}> + {status ? t('profiles.machineLogin.status.loggedIn') : t('profiles.machineLogin.status.notLoggedIn')} + </Text> + ); + + return ( + <> + {enabledAgentIds.map((agentId, index) => { + const core = getAgentCore(agentId); + const defaultSubtitle = t(core.subtitleKey); + const loginStatus = shouldShowLoginStatus ? cliDetection.login[agentId] : null; + const subtitle = shouldShowLoginStatus && typeof loginStatus === 'boolean' + ? renderLoginStatus(loginStatus) + : defaultSubtitle; + const enabled = compatibility[agentId] === true; + const showDivider = index < enabledAgentIds.length - 1; + return ( + <Item + key={agentId} + title={t(core.displayNameKey)} + subtitle={subtitle} + leftElement={<Ionicons name={core.ui.agentPickerIconName as any} size={24} color={theme.colors.textSecondary} />} + rightElement={<Switch value={enabled} onValueChange={() => toggleCompatibility(agentId)} />} + showChevron={false} + onPress={() => toggleCompatibility(agentId)} + showDivider={showDivider} + /> + ); + })} + </> + ); + })()} + </ItemGroup> + + <ItemGroup title={t('profiles.defaultSessionType')}> + <SessionTypeSelector value={defaultSessionType} onChange={setDefaultSessionType} title={null} /> + </ItemGroup> + + <ItemGroup + title="Default permissions" + footer="Overrides the account-level default permissions for new sessions when this profile is selected." + > + {enabledAgentIds + .filter((agentId) => compatibility[agentId] === true) + .map((agentId, index, items) => { + const core = getAgentCore(agentId); + const override = (defaultPermissionModes as any)?.[agentId] as PermissionMode | null | undefined; + const accountDefault = ((accountDefaultPermissionModes as any)?.[agentId] ?? 'default') as PermissionMode; + const effectiveMode = (override ?? accountDefault) as PermissionMode; + const showDivider = index < items.length - 1; + + return ( + <DropdownMenu + key={agentId} + open={openPermissionProvider === agentId} + onOpenChange={(next) => setOpenPermissionProvider(next ? agentId : null)} + popoverBoundaryRef={popoverBoundaryRef} + variant="selectable" + search={false} + showCategoryTitles={false} + matchTriggerWidth={true} + connectToTrigger={true} + rowKind="item" + selectedId={override ?? '__account__'} + trigger={({ open, toggle }) => ( + <Item + selected={false} + title={t(core.displayNameKey)} + subtitle={override + ? getPermissionModeLabelForAgentType(agentId, override) + : `Account default: ${getPermissionModeLabelForAgentType(agentId, accountDefault)}` + } + icon={<Ionicons name={core.ui.agentPickerIconName as any} size={29} color={theme.colors.textSecondary} />} + rightElement={( + <View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}> + <Ionicons + name={getPermissionIconNameForAgent(agentId, effectiveMode) as any} + size={22} + color={theme.colors.textSecondary} + /> + <Ionicons + name={open ? 'chevron-up' : 'chevron-down'} + size={20} + color={theme.colors.textSecondary} + /> + </View> + )} + showChevron={false} + onPress={toggle} + showDivider={showDivider} + /> + )} + items={[ + { + id: '__account__', + title: 'Use account default', + subtitle: `Currently: ${getPermissionModeLabelForAgentType(agentId, accountDefault)}`, + icon: ( + <View style={{ width: 32, height: 32, alignItems: 'center', justifyContent: 'center' }}> + <Ionicons name="settings-outline" size={22} color={theme.colors.textSecondary} /> + </View> + ), + }, + ...getPermissionModeOptionsForAgentType(agentId).map((opt) => ({ + id: opt.value, + title: opt.label, + subtitle: opt.description, + icon: ( + <View style={{ width: 32, height: 32, alignItems: 'center', justifyContent: 'center' }}> + <Ionicons name={opt.icon as any} size={22} color={theme.colors.textSecondary} /> + </View> + ), + })), + ]} + onSelect={(id) => { + if (id === '__account__') { + setDefaultPermissionModeForProvider(agentId, null); + } else { + setDefaultPermissionModeForProvider(agentId, id as any); + } + setOpenPermissionProvider(null); + }} + /> + ); + })} + </ItemGroup> + + {!routeMachine && ( + <ItemGroup title={t('profiles.previewMachine.title')}> + <Item + title={t('profiles.previewMachine.itemTitle')} + subtitle={resolvedMachine ? t('profiles.previewMachine.resolveSubtitle') : t('profiles.previewMachine.selectSubtitle')} + detail={resolvedMachine ? (resolvedMachine.metadata?.displayName || resolvedMachine.metadata?.host || resolvedMachine.id) : undefined} + detailStyle={resolvedMachine + ? { color: isMachineOnline(resolvedMachine) ? theme.colors.status.connected : theme.colors.status.disconnected } + : undefined} + icon={<Ionicons name="desktop-outline" size={29} color={theme.colors.button.secondary.tint} />} + onPress={showMachinePreviewPicker} + /> + </ItemGroup> + )} + + <EnvironmentVariablesList + environmentVariables={environmentVariables} + machineId={resolvedMachineId} + machineName={resolvedMachine ? (resolvedMachine.metadata?.displayName || resolvedMachine.metadata?.host || resolvedMachine.id) : null} + profileDocs={profileDocs} + onChange={setEnvironmentVariables} + sourceRequirementsByName={sourceRequirementsByName} + onUpdateSourceRequirement={updateSourceRequirement} + getDefaultSecretNameForSourceVar={getDefaultSecretNameForSourceVar} + onPickDefaultSecretForSourceVar={openDefaultSecretModalForSourceVar} + /> + + <View style={{ paddingHorizontal: Platform.select({ ios: 16, default: 12 }), paddingTop: 12 }}> + <View style={{ flexDirection: 'row', gap: 12 }}> + <View style={{ flex: 1 }}> + <Pressable + onPress={onCancel} + style={({ pressed }) => ({ + backgroundColor: theme.colors.surface, + borderRadius: 10, + paddingVertical: 12, + alignItems: 'center', + opacity: pressed ? 0.85 : 1, + })} + > + <Text style={{ color: theme.colors.text, ...Typography.default('semiBold') }}> + {t('common.cancel')} + </Text> + </Pressable> + </View> + <View style={{ flex: 1 }}> + <Pressable + onPress={handleSave} + style={({ pressed }) => ({ + backgroundColor: theme.colors.button.primary.background, + borderRadius: 10, + paddingVertical: 12, + alignItems: 'center', + opacity: pressed ? 0.85 : 1, + })} + > + <Text style={{ color: theme.colors.button.primary.tint, ...Typography.default('semiBold') }}> + {profile.isBuiltIn ? t('common.saveAs') : t('common.save')} + </Text> + </Pressable> + </View> + </View> + </View> + </ItemList> + ); +} + +const stylesheet = StyleSheet.create((theme) => ({ + inputContainer: { + paddingHorizontal: 16, + paddingVertical: 12, + }, + selectorContainer: { + paddingHorizontal: 12, + paddingBottom: 4, + }, + requirementsHeader: { + width: '100%', + maxWidth: layout.maxWidth, + alignSelf: 'center', + paddingTop: Platform.select({ ios: 26, default: 20 }), + paddingBottom: Platform.select({ ios: 8, default: 8 }), + paddingHorizontal: Platform.select({ ios: 32, default: 24 }), + }, + requirementsTitle: { + ...Typography.default('regular'), + color: theme.colors.groupped.sectionTitle, + fontSize: Platform.select({ ios: 13, default: 14 }), + lineHeight: Platform.select({ ios: 18, default: 20 }), + letterSpacing: Platform.select({ ios: -0.08, default: 0.1 }), + textTransform: 'uppercase', + fontWeight: Platform.select({ ios: 'normal', default: '500' }), + }, + requirementsSubtitle: { + ...Typography.default('regular'), + color: theme.colors.groupped.sectionTitle, + fontSize: Platform.select({ ios: 13, default: 14 }), + lineHeight: Platform.select({ ios: 18, default: 20 }), + letterSpacing: Platform.select({ ios: -0.08, default: 0 }), + marginTop: Platform.select({ ios: 6, default: 8 }), + }, + requirementsTilesContainer: { + width: '100%', + maxWidth: layout.maxWidth, + alignSelf: 'center', + paddingHorizontal: Platform.select({ ios: 16, default: 12 }), + paddingBottom: 8, + }, + fieldLabel: { + ...Typography.default('semiBold'), + fontSize: 13, + color: theme.colors.groupped.sectionTitle, + marginBottom: 4, + }, + aiBackendStatus: { + ...Typography.default('regular'), + fontSize: Platform.select({ ios: 15, default: 14 }), + lineHeight: 20, + letterSpacing: Platform.select({ ios: -0.24, default: 0.1 }), + }, + textInput: { + ...Typography.default('regular'), + backgroundColor: theme.colors.input.background, + borderRadius: 10, + paddingHorizontal: 12, + paddingVertical: Platform.select({ ios: 10, default: 12 }), + fontSize: Platform.select({ ios: 17, default: 16 }), + lineHeight: Platform.select({ ios: 22, default: 24 }), + letterSpacing: Platform.select({ ios: -0.41, default: 0.15 }), + color: theme.colors.input.text, + ...(Platform.select({ + web: { + outline: 'none', + outlineStyle: 'none', + outlineWidth: 0, + outlineColor: 'transparent', + boxShadow: 'none', + WebkitBoxShadow: 'none', + WebkitAppearance: 'none', + }, + default: {}, + }) as object), + }, + multilineInput: { + ...Typography.default('regular'), + backgroundColor: theme.colors.input.background, + borderRadius: 10, + paddingHorizontal: 12, + paddingVertical: 12, + fontSize: 14, + lineHeight: 20, + color: theme.colors.input.text, + fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace', + minHeight: 120, + ...(Platform.select({ + web: { + outline: 'none', + outlineStyle: 'none', + outlineWidth: 0, + outlineColor: 'transparent', + boxShadow: 'none', + WebkitBoxShadow: 'none', + WebkitAppearance: 'none', + }, + default: {}, + }) as object), + }, +})); diff --git a/expo-app/sources/components/profiles/edit/index.ts b/expo-app/sources/components/profiles/edit/index.ts new file mode 100644 index 000000000..050d2a939 --- /dev/null +++ b/expo-app/sources/components/profiles/edit/index.ts @@ -0,0 +1,2 @@ +export * from './ProfileEditForm'; + diff --git a/expo-app/sources/components/profiles/environmentVariables/EnvironmentVariableCard.test.ts b/expo-app/sources/components/profiles/environmentVariables/EnvironmentVariableCard.test.ts new file mode 100644 index 000000000..06baab102 --- /dev/null +++ b/expo-app/sources/components/profiles/environmentVariables/EnvironmentVariableCard.test.ts @@ -0,0 +1,212 @@ +import { describe, it, expect, vi } from 'vitest'; +import renderer, { act } from 'react-test-renderer'; +import React from 'react'; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +vi.mock('@/text', () => ({ + t: (key: string) => key, +})); + +vi.mock('react-native', () => ({ + View: 'View', + Text: 'Text', + Pressable: 'Pressable', + TextInput: 'TextInput', + Platform: { + OS: 'web', + select: (options: { web?: unknown; ios?: unknown; default?: unknown }) => options.web ?? options.ios ?? options.default, + }, +})); + +vi.mock('@expo/vector-icons', () => { + const React = require('react'); + return { + Ionicons: (props: unknown) => React.createElement('Ionicons', props), + }; +}); + +vi.mock('react-native-unistyles', () => ({ + useUnistyles: () => ({ + theme: { + margins: { md: 8 }, + iconSize: { small: 12, large: 16 }, + colors: { + surface: '#fff', + groupped: { sectionTitle: '#666', background: '#fff' }, + shadow: { color: '#000', opacity: 0.1 }, + text: '#000', + textSecondary: '#666', + textDestructive: '#f00', + divider: '#ddd', + input: { background: '#fff', text: '#000', placeholder: '#999' }, + button: { + primary: { background: '#000', tint: '#fff' }, + secondary: { tint: '#000' }, + }, + deleteAction: '#f00', + warning: '#f90', + success: '#0a0', + }, + }, + }), + StyleSheet: { + create: (factory: (theme: any) => any) => factory({ + margins: { md: 8 }, + iconSize: { small: 12, large: 16 }, + colors: { + surface: '#fff', + groupped: { sectionTitle: '#666', background: '#fff' }, + shadow: { color: '#000', opacity: 0.1 }, + text: '#000', + textSecondary: '#666', + textDestructive: '#f00', + divider: '#ddd', + input: { background: '#fff', text: '#000', placeholder: '#999' }, + button: { + primary: { background: '#000', tint: '#fff' }, + secondary: { tint: '#000' }, + }, + deleteAction: '#f00', + warning: '#f90', + success: '#0a0', + }, + }), + }, +})); + +vi.mock('@/components/Switch', () => { + const React = require('react'); + return { + Switch: (props: unknown) => React.createElement('Switch', props), + }; +}); + +vi.mock('@/components/ui/lists/Item', () => { + const React = require('react'); + return { + Item: (props: any) => { + // Render title/subtitle/rightElement so behavior tests can find inputs/switches. + return React.createElement( + 'Item', + props, + props.title ? React.createElement('Text', null, props.title) : null, + props.subtitle ?? null, + props.rightElement ?? null, + ); + }, + }; +}); + +vi.mock('@/components/ui/lists/ItemGroup', () => { + const React = require('react'); + return { + ItemGroup: (props: any) => React.createElement('ItemGroup', props, props.children), + }; +}); + +import { EnvironmentVariableCard } from './EnvironmentVariableCard'; + +describe('EnvironmentVariableCard', () => { + it('syncs remote-variable state when variable.value changes externally', () => { + const onUpdate = vi.fn(); + + let tree: ReturnType<typeof renderer.create> | undefined; + + act(() => { + tree = renderer.create( + React.createElement(EnvironmentVariableCard, { + variable: { name: 'FOO', value: '${BAR:-baz}' }, + index: 0, + machineId: 'machine-1', + onUpdate, + onDelete: () => {}, + onDuplicate: () => {}, + }), + ); + }); + + const firstSwitches = tree?.root.findAllByType('Switch' as any) ?? []; + const firstUseMachineSwitch = firstSwitches.find((s: any) => !s?.props?.disabled); + expect(firstUseMachineSwitch?.props.value).toBe(true); + + act(() => { + tree?.update( + React.createElement(EnvironmentVariableCard, { + variable: { name: 'FOO', value: 'literal' }, + index: 0, + machineId: 'machine-1', + onUpdate, + onDelete: () => {}, + onDuplicate: () => {}, + }), + ); + }); + + const secondSwitches = tree?.root.findAllByType('Switch' as any) ?? []; + const secondUseMachineSwitch = secondSwitches.find((s: any) => !s?.props?.disabled); + expect(secondUseMachineSwitch?.props.value).toBe(false); + }); + + it('adds a fallback operator when user enters a fallback for a template without one', () => { + const onUpdate = vi.fn(); + + let tree: ReturnType<typeof renderer.create> | undefined; + + act(() => { + tree = renderer.create( + React.createElement(EnvironmentVariableCard, { + variable: { name: 'FOO', value: '${BAR}' }, + index: 0, + machineId: 'machine-1', + onUpdate, + onDelete: () => {}, + onDuplicate: () => {}, + }), + ); + }); + + const inputs = tree?.root.findAllByType('TextInput' as any); + expect(inputs?.length).toBeGreaterThan(0); + + act(() => { + inputs?.[0]?.props.onChangeText?.('baz'); + }); + + expect(onUpdate).toHaveBeenCalled(); + const lastCall = onUpdate.mock.calls.at(-1) as unknown as [number, string]; + expect(lastCall[0]).toBe(0); + expect(lastCall[1]).toBe('${BAR:-baz}'); + }); + + it('removes the operator when user clears the fallback value', () => { + const onUpdate = vi.fn(); + + let tree: ReturnType<typeof renderer.create> | undefined; + + act(() => { + tree = renderer.create( + React.createElement(EnvironmentVariableCard, { + variable: { name: 'FOO', value: '${BAR:=baz}' }, + index: 0, + machineId: 'machine-1', + onUpdate, + onDelete: () => {}, + onDuplicate: () => {}, + }), + ); + }); + + const inputs = tree?.root.findAllByType('TextInput' as any); + expect(inputs?.length).toBeGreaterThan(0); + + act(() => { + inputs?.[0]?.props.onChangeText?.(''); + }); + + expect(onUpdate).toHaveBeenCalled(); + const lastCall = onUpdate.mock.calls.at(-1) as unknown as [number, string]; + expect(lastCall[0]).toBe(0); + expect(lastCall[1]).toBe('${BAR}'); + }); +}); diff --git a/expo-app/sources/components/profiles/environmentVariables/EnvironmentVariableCard.tsx b/expo-app/sources/components/profiles/environmentVariables/EnvironmentVariableCard.tsx new file mode 100644 index 000000000..7bfdb8433 --- /dev/null +++ b/expo-app/sources/components/profiles/environmentVariables/EnvironmentVariableCard.tsx @@ -0,0 +1,631 @@ +import React from 'react'; +import { View, Text, TextInput, Pressable, Platform } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { StyleSheet, useUnistyles } from 'react-native-unistyles'; +import { Typography } from '@/constants/Typography'; +import { Switch } from '@/components/Switch'; +import { Item } from '@/components/ui/lists/Item'; +import { ItemGroup } from '@/components/ui/lists/ItemGroup'; +import { formatEnvVarTemplate, parseEnvVarTemplate, type EnvVarTemplateOperator } from '@/utils/profiles/envVarTemplate'; +import { t } from '@/text'; +import type { EnvPreviewSecretsPolicy, PreviewEnvValue } from '@/sync/ops'; + +export interface EnvironmentVariableCardProps { + variable: { name: string; value: string; isSecret?: boolean }; + index: number; + machineId: string | null; + machineName?: string | null; + machineEnv?: Record<string, PreviewEnvValue>; + machineEnvPolicy?: EnvPreviewSecretsPolicy | null; + isMachineEnvLoading?: boolean; + expectedValue?: string; // From profile documentation + description?: string; // Variable description + isSecret?: boolean; // Whether this is a secret (never query remote) + secretOverride?: boolean; // user override (true/false) or undefined for auto + autoSecret?: boolean; // UI auto classification (docs + heuristic) + isForcedSensitive?: boolean; // daemon-enforced sensitivity + sourceRequirement?: { required: boolean; useSecretVault: boolean } | null; + onUpdateSourceRequirement?: ( + sourceVarName: string, + next: { required: boolean; useSecretVault: boolean } | null + ) => void; + defaultSecretNameForSourceVar?: string | null; + onPickDefaultSecretForSourceVar?: (sourceVarName: string) => void; + onUpdateSecretOverride?: (index: number, isSecret: boolean | undefined) => void; + onUpdate: (index: number, newValue: string) => void; + onDelete: (index: number) => void; + onDuplicate: (index: number) => void; +} + +/** + * Parse environment variable value to determine configuration + */ +function parseVariableValue(value: string): { + useRemoteVariable: boolean; + remoteVariableName: string; + defaultValue: string; + fallbackOperator: EnvVarTemplateOperator | null; +} { + const parsedTemplate = parseEnvVarTemplate(value); + if (parsedTemplate) { + return { + useRemoteVariable: true, + remoteVariableName: parsedTemplate.sourceVar, + defaultValue: parsedTemplate.fallback, + fallbackOperator: parsedTemplate.operator, + }; + } + + // Literal value (no template) + return { + useRemoteVariable: false, + remoteVariableName: '', + defaultValue: value, + fallbackOperator: null, + }; +} + +/** + * Single environment variable card component + * Matches profile list pattern from index.tsx:1163-1217 + */ +export function EnvironmentVariableCard({ + variable, + index, + machineId, + machineName, + machineEnv, + machineEnvPolicy = null, + isMachineEnvLoading = false, + expectedValue, + description, + isSecret = false, + secretOverride, + autoSecret = false, + isForcedSensitive = false, + sourceRequirement = null, + onUpdateSourceRequirement, + defaultSecretNameForSourceVar = null, + onPickDefaultSecretForSourceVar, + onUpdateSecretOverride, + onUpdate, + onDelete, + onDuplicate, +}: EnvironmentVariableCardProps) { + const { theme } = useUnistyles(); + const styles = stylesheet; + + // Parse current value + const parsed = React.useMemo(() => parseVariableValue(variable.value), [variable.value]); + const [useRemoteVariable, setUseRemoteVariable] = React.useState(parsed.useRemoteVariable); + const [remoteVariableName, setRemoteVariableName] = React.useState(parsed.remoteVariableName); + const [defaultValue, setDefaultValue] = React.useState(parsed.defaultValue); + const fallbackOperator = parsed.fallbackOperator; + + React.useEffect(() => { + setUseRemoteVariable(parsed.useRemoteVariable); + setRemoteVariableName(parsed.remoteVariableName); + setDefaultValue(parsed.defaultValue); + }, [parsed.defaultValue, parsed.remoteVariableName, parsed.useRemoteVariable]); + + /** + * The requirement key is the env var name that is actually *required/resolved* at session start. + * + * If the value is a template (e.g. `${SOURCE_VAR}`), then the requirement applies to `SOURCE_VAR` + * (not necessarily `variable.name`) because that's what the daemon will read from the machine env. + */ + const requirementVarName = React.useMemo(() => { + if (parsed.useRemoteVariable) { + const name = parsed.remoteVariableName.trim().toUpperCase(); + return name.length > 0 ? name : variable.name.trim().toUpperCase(); + } + return variable.name.trim().toUpperCase(); + }, [parsed.remoteVariableName, parsed.useRemoteVariable, variable.name]); + + const hasRequirementVarName = requirementVarName.length > 0; + const effectiveSourceRequirement = hasRequirementVarName + ? (sourceRequirement ?? { required: false, useSecretVault: false }) + : null; + const useSecretVault = Boolean(effectiveSourceRequirement?.useSecretVault); + const hideValueInUi = Boolean(isSecret) || useSecretVault; + + // Vault-enforced secrets must not persist plaintext or fallbacks. + React.useEffect(() => { + if (!useSecretVault) return; + if (defaultValue.trim() !== '') { + setDefaultValue(''); + } + }, [defaultValue, useSecretVault]); + + // If the user opts into the secret vault, we must enforce hiding the value in the UI. + // This is treated similarly to daemon-enforced sensitivity: the user cannot disable it while vault is enabled. + React.useEffect(() => { + if (!useSecretVault) return; + if (!onUpdateSecretOverride) return; + if (isForcedSensitive) return; + if (Boolean(isSecret) === true) return; + onUpdateSecretOverride(index, true); + }, [index, isForcedSensitive, isSecret, onUpdateSecretOverride, useSecretVault]); + + const remoteEntry = remoteVariableName ? machineEnv?.[remoteVariableName] : undefined; + const remoteValue = remoteEntry?.value; + const hasFallback = defaultValue.trim() !== ''; + const computedOperator: EnvVarTemplateOperator | null = useSecretVault + ? null + : (hasFallback ? (fallbackOperator ?? ':-') : null); + const machineLabel = machineName?.trim() ? machineName.trim() : t('common.machine'); + + const emptyValue = t('profiles.environmentVariables.preview.emptyValue'); + + const canEditSecret = Boolean(onUpdateSecretOverride) && !isForcedSensitive && !useSecretVault; + const showResetToAuto = canEditSecret && secretOverride !== undefined; + + // Update parent when local state changes + React.useEffect(() => { + // Important UX: when "use machine env" is enabled, allow the user to clear/edit the + // source variable name without implicitly disabling the mode or overwriting the stored + // template value. Only persist when source var is non-empty. + if (useRemoteVariable && remoteVariableName.trim() === '') { + return; + } + + const newValue = useRemoteVariable + ? formatEnvVarTemplate({ sourceVar: remoteVariableName, fallback: defaultValue, operator: computedOperator }) + : defaultValue; + + if (newValue !== variable.value) { + onUpdate(index, newValue); + } + }, [computedOperator, defaultValue, index, onUpdate, remoteVariableName, useRemoteVariable, useSecretVault, variable.value]); + + // Determine status + const showRemoteDiffersWarning = remoteValue !== null && expectedValue && remoteValue !== expectedValue; + const showDefaultOverrideWarning = expectedValue && defaultValue !== expectedValue; + + const computedTemplateValue = + useRemoteVariable && remoteVariableName.trim() !== '' + ? formatEnvVarTemplate({ sourceVar: remoteVariableName, fallback: defaultValue, operator: computedOperator }) + : defaultValue; + + const targetEntry = machineEnv?.[variable.name]; + const resolvedSessionValue = (() => { + // Prefer daemon-computed effective value for the target env var (matches spawn exactly). + if (machineId && targetEntry) { + if (targetEntry.display === 'full' || targetEntry.display === 'redacted') { + return targetEntry.value ?? emptyValue; + } + if (targetEntry.display === 'hidden') { + return t('profiles.environmentVariables.preview.hiddenValue'); + } + return emptyValue; // unset + } + + // Fallback (no machine context / older daemon): best-effort preview. + if (hideValueInUi) { + // If daemon policy is known and allows showing secrets, targetEntry would have handled it above. + // Otherwise, keep secrets hidden in UI. + if (useRemoteVariable && remoteVariableName) { + return t('profiles.environmentVariables.preview.secretValueHidden', { + value: formatEnvVarTemplate({ + sourceVar: remoteVariableName, + fallback: defaultValue !== '' ? '***' : '', + operator: computedOperator, + }), + }); + } + return defaultValue ? t('profiles.environmentVariables.preview.hiddenValue') : emptyValue; + } + + if (useRemoteVariable && machineId && remoteEntry !== undefined) { + // Note: remoteEntry may be hidden/redacted by daemon policy. We do NOT treat hidden as missing. + if (remoteEntry.display === 'hidden') return t('profiles.environmentVariables.preview.hiddenValue'); + if (remoteEntry.display === 'unset' || remoteValue === null || remoteValue === '') { + return hasFallback ? defaultValue : emptyValue; + } + return remoteValue; + } + + return computedTemplateValue || emptyValue; + })(); + + const valueRowTitle = (useRemoteVariable + ? t('profiles.environmentVariables.card.fallbackValueLabel') + : t('profiles.environmentVariables.card.valueLabel') + ).replace(/:$/, ''); + + const valueRowSubtitle = !useSecretVault ? ( + <View style={styles.valueRowContent}> + <TextInput + style={styles.valueInput} + placeholder={ + expectedValue || + (useRemoteVariable + ? t('profiles.environmentVariables.card.defaultValueInputPlaceholder') + : t('profiles.environmentVariables.card.valueInputPlaceholder')) + } + placeholderTextColor={theme.colors.input.placeholder} + value={defaultValue} + onChangeText={setDefaultValue} + autoCapitalize="none" + autoCorrect={false} + secureTextEntry={hideValueInUi} + editable={!useSecretVault} + selectTextOnFocus={!useSecretVault} + /> + + {hideValueInUi && (machineEnvPolicy === null || machineEnvPolicy === 'none') && ( + <Text style={[styles.secondaryText, styles.helperText, styles.helperTextItalic]}> + {t('profiles.environmentVariables.card.secretNotRetrieved')} + </Text> + )} + + {showDefaultOverrideWarning && !hideValueInUi && ( + <Text style={[styles.secondaryText, styles.helperText]}> + {t('profiles.environmentVariables.card.overridingDefault', { expectedValue })} + </Text> + )} + </View> + ) : (Boolean(effectiveSourceRequirement?.useSecretVault) ? ( + <Pressable + onPress={() => onPickDefaultSecretForSourceVar?.(requirementVarName)} + style={({ pressed }) => ({ opacity: pressed ? 0.85 : 1 })} + > + <View style={styles.valueRowContent}> + <View style={styles.vaultRow}> + <Text style={[styles.secondaryText, styles.vaultRowLabel]}> + {t('profiles.environmentVariables.card.defaultSecretLabel')} + </Text> + <View style={styles.vaultRowRight}> + <Text style={[styles.secondaryText, styles.vaultRowValue]}> + {defaultSecretNameForSourceVar ?? t('secrets.noneTitle')} + </Text> + <Ionicons name="chevron-forward" size={18} color={theme.colors.textSecondary} /> + </View> + </View> + </View> + </Pressable> + ) : null); + + const machineEnvRowSubtitle = ( + <View style={styles.sectionContent}> + <Text style={[styles.secondaryText, styles.helperText]}> + {t('profiles.environmentVariables.card.resolvedOnSessionStart')} + </Text> + + {useRemoteVariable && ( + <> + <Text style={[styles.secondaryText, styles.fieldLabel]}> + {t('profiles.environmentVariables.card.sourceVariableLabel')} + </Text> + <TextInput + style={styles.sourceInput} + placeholder={t('profiles.environmentVariables.card.sourceVariablePlaceholder')} + placeholderTextColor={theme.colors.input.placeholder} + value={remoteVariableName} + onChangeText={(text) => setRemoteVariableName(text.toUpperCase())} + autoCapitalize="characters" + autoCorrect={false} + /> + + {(!hideValueInUi && machineId && remoteVariableName.trim() !== '') && ( + <View style={styles.machineStatusContainer}> + {isMachineEnvLoading || remoteEntry === undefined ? ( + <Text style={[styles.secondaryText, styles.machineStatusLoading]}> + {t('profiles.environmentVariables.card.checkingMachine', { machine: machineLabel })} + </Text> + ) : (remoteEntry.display === 'unset' || remoteValue === null || remoteValue === '') ? ( + <Text style={[styles.secondaryText, styles.machineStatusWarning]}> + {remoteValue === '' ? ( + hasFallback + ? t('profiles.environmentVariables.card.emptyOnMachineUsingFallback', { machine: machineLabel }) + : t('profiles.environmentVariables.card.emptyOnMachine', { machine: machineLabel }) + ) : ( + hasFallback + ? t('profiles.environmentVariables.card.notFoundOnMachineUsingFallback', { machine: machineLabel }) + : t('profiles.environmentVariables.card.notFoundOnMachine', { machine: machineLabel }) + )} + </Text> + ) : ( + <> + <Text style={[styles.secondaryText, styles.machineStatusSuccess]}> + {t('profiles.environmentVariables.card.valueFoundOnMachine', { machine: machineLabel })} + </Text> + {showRemoteDiffersWarning && ( + <Text style={[styles.secondaryText, styles.machineStatusDiffers]}> + {t('profiles.environmentVariables.card.differsFromDocumented', { expectedValue })} + </Text> + )} + </> + )} + </View> + )} + </> + )} + + <Text style={[styles.secondaryText, styles.sessionPreview]}> + {t('profiles.environmentVariables.preview.sessionWillReceive', { + name: variable.name, + value: resolvedSessionValue ?? emptyValue, + })} + </Text> + </View> + ); + + const secretRowSubtitle = ( + isForcedSensitive + ? t('profiles.environmentVariables.card.secretToggleEnforcedByDaemon') + : useSecretVault + ? t('profiles.environmentVariables.card.secretToggleEnforcedByVault') + : t('profiles.environmentVariables.card.secretToggleSubtitle') + ); + + return ( + <ItemGroup + // Hide the ItemGroup header spacing: this card renders its own "title row" as the first Item. + title={<View />} + headerStyle={styles.hiddenHeader} + style={styles.groupWrapper} + containerStyle={styles.groupContainer} + > + <Item + title={variable.name} + subtitle={description} + showChevron={false} + rightElement={( + <View style={styles.titleRowActions}> + {hideValueInUi && ( + <Ionicons + name="lock-closed" + size={theme.iconSize.small} + color={theme.colors.textDestructive} + /> + )} + <Pressable + hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} + onPress={() => onDelete(index)} + > + <Ionicons name="trash-outline" size={theme.iconSize.large} color={theme.colors.deleteAction} /> + </Pressable> + <Pressable + hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} + onPress={() => onDuplicate(index)} + > + <Ionicons name="copy-outline" size={theme.iconSize.large} color={theme.colors.button.secondary.tint} /> + </Pressable> + </View> + )} + /> + + <Item + title={valueRowTitle} + subtitle={valueRowSubtitle} + showChevron={false} + /> + + <Item + title={t('profiles.environmentVariables.card.useMachineEnvToggle')} + subtitle={machineEnvRowSubtitle} + showChevron={false} + rightElement={( + <Switch + value={useRemoteVariable} + onValueChange={setUseRemoteVariable} + /> + )} + /> + + <Item + title={t('profiles.environmentVariables.card.secretToggleLabel')} + subtitle={secretRowSubtitle} + showChevron={false} + rightElement={( + <View style={styles.secretRowRight}> + {showResetToAuto && ( + <Pressable + onPress={() => onUpdateSecretOverride?.(index, undefined)} + hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} + style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })} + > + <Text style={styles.resetToAutoText}> + {t('profiles.environmentVariables.card.secretToggleResetToAuto')} + </Text> + </Pressable> + )} + <Switch + value={hideValueInUi} + onValueChange={(next) => { + if (!canEditSecret) return; + onUpdateSecretOverride?.(index, next); + }} + disabled={!canEditSecret} + /> + </View> + )} + /> + + {hasRequirementVarName ? ( + <> + <Item + title={t('profiles.environmentVariables.card.requirementRequiredLabel')} + subtitle={t('profiles.environmentVariables.card.requirementRequiredSubtitle')} + showChevron={false} + rightElement={( + <Switch + value={Boolean(effectiveSourceRequirement?.required)} + onValueChange={(next) => { + if (!onUpdateSourceRequirement) return; + onUpdateSourceRequirement(requirementVarName, { + required: next, + useSecretVault: Boolean(effectiveSourceRequirement?.useSecretVault), + }); + }} + /> + )} + /> + <Item + title={t('profiles.environmentVariables.card.requirementUseVaultLabel')} + subtitle={t('profiles.environmentVariables.card.requirementUseVaultSubtitle')} + showChevron={false} + rightElement={( + <Switch + value={Boolean(effectiveSourceRequirement?.useSecretVault)} + onValueChange={(next) => { + if (!onUpdateSourceRequirement) return; + const prevRequired = Boolean(effectiveSourceRequirement?.required); + onUpdateSourceRequirement(requirementVarName, { + required: next ? (prevRequired || true) : prevRequired, + useSecretVault: next, + }); + }} + /> + )} + /> + </> + ) : null} + </ItemGroup> + ); +} + + +const stylesheet = StyleSheet.create((theme) => ({ + groupWrapper: { + // The card spacing between env vars should match other grouped settings lists. + marginBottom: 12, + }, + hiddenHeader: { + paddingTop: 0, + paddingBottom: 0, + paddingHorizontal: 0, + height: 0, + overflow: 'hidden', + }, + groupContainer: { + // Avoid double horizontal margins: the list should not add its own margin. + marginHorizontal: 0, + }, + secretRowRight: { + flexDirection: 'row', + alignItems: 'center', + gap: 10, + }, + resetToAutoText: { + color: theme.colors.button.secondary.tint, + fontSize: Platform.select({ ios: 13, default: 12 }), + ...Typography.default('semiBold'), + }, + titleRowActions: { + flexDirection: 'row', + alignItems: 'center', + gap: theme.margins.md, + }, + secondaryText: { + fontSize: Platform.select({ ios: 15, default: 14 }), + lineHeight: 20, + letterSpacing: Platform.select({ ios: -0.24, default: 0.1 }), + ...Typography.default(), + }, + valueInput: { + ...Typography.default('regular'), + backgroundColor: theme.colors.input.background, + borderRadius: 10, + paddingHorizontal: 12, + paddingVertical: Platform.select({ ios: 10, default: 12 }), + fontSize: Platform.select({ ios: 17, default: 16 }), + lineHeight: Platform.select({ ios: 22, default: 24 }), + letterSpacing: Platform.select({ ios: -0.41, default: 0.15 }), + color: theme.colors.input.text, + ...(Platform.select({ + web: { + outline: 'none', + outlineStyle: 'none', + outlineWidth: 0, + outlineColor: 'transparent', + boxShadow: 'none', + WebkitBoxShadow: 'none', + WebkitAppearance: 'none', + }, + default: {}, + }) as object), + }, + sectionContent: { + marginTop: 4, + }, + helperText: { + color: theme.colors.textSecondary, + }, + helperTextItalic: { + fontStyle: 'italic', + }, + fieldLabel: { + ...Typography.default('semiBold'), + fontSize: 13, + color: theme.colors.groupped.sectionTitle, + marginTop: 10, + marginBottom: 6, + }, + sourceInput: { + ...Typography.default('regular'), + backgroundColor: theme.colors.input.background, + borderRadius: 10, + paddingHorizontal: 12, + paddingVertical: Platform.select({ ios: 10, default: 12 }), + fontSize: Platform.select({ ios: 17, default: 16 }), + lineHeight: Platform.select({ ios: 22, default: 24 }), + letterSpacing: Platform.select({ ios: -0.41, default: 0.15 }), + color: theme.colors.input.text, + marginBottom: 2, + ...(Platform.select({ + web: { + outline: 'none', + outlineStyle: 'none', + outlineWidth: 0, + outlineColor: 'transparent', + boxShadow: 'none', + WebkitBoxShadow: 'none', + WebkitAppearance: 'none', + }, + default: {}, + }) as object), + }, + valueRowContent: { + marginTop: 8, + }, + vaultRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + vaultRowLabel: { + color: theme.colors.textSecondary, + flex: 1, + }, + vaultRowRight: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + vaultRowValue: { + color: theme.colors.textSecondary, + }, + machineStatusContainer: { + marginTop: 8, + }, + machineStatusLoading: { + color: theme.colors.textSecondary, + fontStyle: 'italic', + }, + machineStatusWarning: { + color: theme.colors.warning, + }, + machineStatusSuccess: { + color: theme.colors.success, + }, + machineStatusDiffers: { + color: theme.colors.textSecondary, + marginTop: 2, + }, + sessionPreview: { + color: theme.colors.textSecondary, + marginTop: 10, + }, +})); diff --git a/expo-app/sources/components/profiles/environmentVariables/EnvironmentVariablesList.test.ts b/expo-app/sources/components/profiles/environmentVariables/EnvironmentVariablesList.test.ts new file mode 100644 index 000000000..cbdb1ab0a --- /dev/null +++ b/expo-app/sources/components/profiles/environmentVariables/EnvironmentVariablesList.test.ts @@ -0,0 +1,271 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import renderer, { act } from 'react-test-renderer'; +import React from 'react'; +import type { ProfileDocumentation } from '@/sync/profileUtils'; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +vi.mock('@/text', () => ({ + t: (key: string) => key, +})); + +vi.mock('@/modal', () => ({ + Modal: { alert: vi.fn() }, +})); + +vi.mock('react-native', () => ({ + View: 'View', + Text: 'Text', + Pressable: 'Pressable', + TextInput: 'TextInput', + Platform: { + OS: 'web', + select: (options: { web?: unknown; default?: unknown }) => options.web ?? options.default, + }, +})); + +const useEnvironmentVariablesMock = vi.fn((_machineId: any, _refs: any, _options?: any) => ({ + variables: {}, + meta: {}, + policy: null as any, + isPreviewEnvSupported: false, + isLoading: false, +})); + +vi.mock('@/hooks/useEnvironmentVariables', () => ({ + useEnvironmentVariables: (machineId: any, refs: any, options?: any) => useEnvironmentVariablesMock(machineId, refs, options), +})); + +vi.mock('@expo/vector-icons', () => { + const React = require('react'); + return { + Ionicons: (props: unknown) => React.createElement('Ionicons', props), + }; +}); + +vi.mock('react-native-unistyles', () => ({ + useUnistyles: () => ({ + theme: { + colors: { + groupped: { sectionTitle: '#000' }, + input: { background: '#fff', text: '#000', placeholder: '#999' }, + button: { + primary: { background: '#000', tint: '#fff' }, + secondary: { tint: '#000' }, + }, + surface: '#fff', + shadow: { color: '#000', opacity: 0.1 }, + }, + }, + }), + StyleSheet: { + create: (factory: (theme: any) => any) => factory({ + colors: { + groupped: { sectionTitle: '#000' }, + input: { background: '#fff', text: '#000', placeholder: '#999' }, + button: { + primary: { background: '#000', tint: '#fff' }, + secondary: { tint: '#000' }, + }, + surface: '#fff', + shadow: { color: '#000', opacity: 0.1 }, + }, + }), + }, +})); + +vi.mock('@/components/ui/lists/Item', () => { + const React = require('react'); + return { + Item: (props: unknown) => React.createElement('Item', props), + }; +}); + +vi.mock('./EnvironmentVariableCard', () => { + const React = require('react'); + return { + EnvironmentVariableCard: (props: unknown) => React.createElement('EnvironmentVariableCard', props), + }; +}); + +import { EnvironmentVariablesList } from './EnvironmentVariablesList'; + +describe('EnvironmentVariablesList', () => { + beforeEach(() => { + useEnvironmentVariablesMock.mockClear(); + }); + + it('adds a variable via the inline expander', () => { + const onChange = vi.fn(); + + let tree: ReturnType<typeof renderer.create> | undefined; + act(() => { + tree = renderer.create( + React.createElement(EnvironmentVariablesList, { + environmentVariables: [], + machineId: 'machine-1', + profileDocs: null, + onChange, + sourceRequirementsByName: {}, + onUpdateSourceRequirement: () => {}, + getDefaultSecretNameForSourceVar: () => null, + onPickDefaultSecretForSourceVar: () => {}, + }), + ); + }); + + const addItem = tree!.root + .findAllByType('Item' as any) + .find((n: any) => n.props.title === 'profiles.environmentVariables.addVariable'); + expect(addItem).toBeTruthy(); + + act(() => { + addItem!.props.onPress(); + }); + + const inputs = tree!.root.findAllByType('TextInput' as any); + expect(inputs.length).toBeGreaterThanOrEqual(2); + + act(() => { + inputs[0]!.props.onChangeText('FOO'); + inputs[1]!.props.onChangeText('bar'); + }); + + const saveButton = tree!.root + .findAllByType('Pressable' as any) + .find((n: any) => n.props.accessibilityLabel === 'common.save'); + expect(saveButton).toBeTruthy(); + + act(() => { + saveButton!.props.onPress(); + }); + + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange.mock.calls[0]?.[0]).toEqual([{ name: 'FOO', value: 'bar' }]); + }); + + it('marks documented secret refs as sensitive keys (daemon-controlled disclosure)', () => { + const profileDocs: ProfileDocumentation = { + description: 'test', + environmentVariables: [ + { + name: 'MAGIC', + expectedValue: '***', + description: 'secret but name is not secret-like', + isSecret: true, + }, + ], + shellConfigExample: '', + }; + + act(() => { + renderer.create( + React.createElement(EnvironmentVariablesList, { + environmentVariables: [ + { name: 'FOO', value: '${MAGIC}' }, + { name: 'BAR', value: '${HOME}' }, + ], + machineId: 'machine-1', + profileDocs, + onChange: () => {}, + sourceRequirementsByName: {}, + onUpdateSourceRequirement: () => {}, + getDefaultSecretNameForSourceVar: () => null, + onPickDefaultSecretForSourceVar: () => {}, + }), + ); + }); + + expect(useEnvironmentVariablesMock).toHaveBeenCalled(); + const [_machineId, keys, options] = useEnvironmentVariablesMock.mock.calls[0] as unknown as [string, string[], any]; + expect(keys).toContain('FOO'); + expect(keys).toContain('BAR'); + expect(keys).toContain('MAGIC'); + expect(keys).toContain('HOME'); + expect(Array.isArray(options?.sensitiveKeys) ? options.sensitiveKeys : []).toContain('MAGIC'); + }); + + it('treats a documented-secret variable name as secret even when its value references another var', () => { + const profileDocs: ProfileDocumentation = { + description: 'test', + environmentVariables: [ + { + name: 'MAGIC', + expectedValue: '***', + description: 'secret', + isSecret: true, + }, + ], + shellConfigExample: '', + }; + + let tree: ReturnType<typeof renderer.create> | undefined; + act(() => { + tree = renderer.create( + React.createElement(EnvironmentVariablesList, { + environmentVariables: [{ name: 'MAGIC', value: '${HOME}' }], + machineId: 'machine-1', + profileDocs, + onChange: () => {}, + sourceRequirementsByName: {}, + onUpdateSourceRequirement: () => {}, + getDefaultSecretNameForSourceVar: () => null, + onPickDefaultSecretForSourceVar: () => {}, + }), + ); + }); + + expect(useEnvironmentVariablesMock).toHaveBeenCalled(); + const [_machineId, keys, options] = useEnvironmentVariablesMock.mock.calls[0] as unknown as [string, string[], any]; + expect(keys).toContain('MAGIC'); + expect(keys).toContain('HOME'); + expect(Array.isArray(options?.sensitiveKeys) ? options.sensitiveKeys : []).toContain('MAGIC'); + expect(Array.isArray(options?.sensitiveKeys) ? options.sensitiveKeys : []).toContain('HOME'); + + const cards = tree?.root.findAllByType('EnvironmentVariableCard' as any); + expect(cards?.length).toBe(1); + expect(cards?.[0]?.props.isSecret).toBe(true); + expect(cards?.[0]?.props.expectedValue).toBe('***'); + }); + + it('treats daemon-forced-sensitive vars as secret and marks toggle as forced', () => { + useEnvironmentVariablesMock.mockReturnValueOnce({ + variables: {}, + meta: { + AUTH_MODE: { + value: null, + isSet: true, + isSensitive: true, + isForcedSensitive: true, + sensitivitySource: 'forced', + display: 'hidden', + }, + }, + policy: 'none', + isPreviewEnvSupported: true, + isLoading: false, + }); + + let tree: ReturnType<typeof renderer.create> | undefined; + act(() => { + tree = renderer.create( + React.createElement(EnvironmentVariablesList, { + environmentVariables: [{ name: 'AUTH_MODE', value: 'interactive', isSecret: false }], + machineId: 'machine-1', + profileDocs: null, + onChange: () => {}, + sourceRequirementsByName: {}, + onUpdateSourceRequirement: () => {}, + getDefaultSecretNameForSourceVar: () => null, + onPickDefaultSecretForSourceVar: () => {}, + }), + ); + }); + + const cards = tree?.root.findAllByType('EnvironmentVariableCard' as any); + expect(cards?.length).toBe(1); + expect(cards?.[0]?.props.isSecret).toBe(true); + expect(cards?.[0]?.props.isForcedSensitive).toBe(true); + expect(cards?.[0]?.props.secretOverride).toBe(false); + }); +}); diff --git a/expo-app/sources/components/profiles/environmentVariables/EnvironmentVariablesList.tsx b/expo-app/sources/components/profiles/environmentVariables/EnvironmentVariablesList.tsx new file mode 100644 index 000000000..3df657d3c --- /dev/null +++ b/expo-app/sources/components/profiles/environmentVariables/EnvironmentVariablesList.tsx @@ -0,0 +1,382 @@ +import React from 'react'; +import { View, Text, TextInput, Platform } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { StyleSheet, useUnistyles } from 'react-native-unistyles'; +import { Typography } from '@/constants/Typography'; +import { EnvironmentVariableCard } from './EnvironmentVariableCard'; +import type { ProfileDocumentation } from '@/sync/profileUtils'; +import { InlineAddExpander } from '@/components/ui/forms/InlineAddExpander'; +import { Modal } from '@/modal'; +import { t } from '@/text'; +import { useEnvironmentVariables } from '@/hooks/useEnvironmentVariables'; +import { parseEnvVarTemplate } from '@/utils/profiles/envVarTemplate'; + +export interface EnvironmentVariablesListProps { + environmentVariables: Array<{ name: string; value: string; isSecret?: boolean }>; + machineId: string | null; + machineName?: string | null; + profileDocs?: ProfileDocumentation | null; + onChange: (newVariables: Array<{ name: string; value: string; isSecret?: boolean }>) => void; + sourceRequirementsByName: Record<string, { required: boolean; useSecretVault: boolean } | undefined>; + onUpdateSourceRequirement: ( + sourceVarName: string, + next: { required: boolean; useSecretVault: boolean } | null + ) => void; + getDefaultSecretNameForSourceVar: (sourceVarName: string) => string | null; + onPickDefaultSecretForSourceVar: (sourceVarName: string) => void; +} + +const SECRET_NAME_REGEX = /TOKEN|KEY|SECRET|AUTH|PASS|PASSWORD|COOKIE/i; +const ENV_VAR_TEMPLATE_REF_REGEX = /\$\{([A-Z_][A-Z0-9_]*)(?::[-=][^}]*)?\}/g; + +/** + * Complete environment variables section with title, add button, and editable cards + * Matches profile list pattern from index.tsx:1159-1308 + */ +export function EnvironmentVariablesList({ + environmentVariables, + machineId, + machineName, + profileDocs, + onChange, + sourceRequirementsByName, + onUpdateSourceRequirement, + getDefaultSecretNameForSourceVar, + onPickDefaultSecretForSourceVar, +}: EnvironmentVariablesListProps) { + const { theme } = useUnistyles(); + const styles = stylesheet; + + const extractVarRefsFromValue = React.useCallback((value: string): string[] => { + const refs: string[] = []; + if (!value) return refs; + let match: RegExpExecArray | null; + // Reset regex state defensively (global regex). + ENV_VAR_TEMPLATE_REF_REGEX.lastIndex = 0; + while ((match = ENV_VAR_TEMPLATE_REF_REGEX.exec(value)) !== null) { + const name = match[1]; + if (name) refs.push(name); + } + return refs; + }, []); + + const documentedSecretNames = React.useMemo(() => { + if (!profileDocs) return new Set<string>(); + + return new Set( + profileDocs.environmentVariables + .filter((envVar) => envVar.isSecret) + .map((envVar) => envVar.name), + ); + }, [profileDocs]); + + const { keysToQuery, extraEnv, sensitiveKeys } = React.useMemo(() => { + const keys = new Set<string>(); + const env: Record<string, string> = {}; + const sensitive = new Set<string>(); + + const isSecretName = (name: string) => + documentedSecretNames.has(name) || SECRET_NAME_REGEX.test(name); + + environmentVariables.forEach((envVar) => { + keys.add(envVar.name); + env[envVar.name] = envVar.value; + + const valueRefs = extractVarRefsFromValue(envVar.value); + valueRefs.forEach((ref) => keys.add(ref)); + + const isSensitive = isSecretName(envVar.name) || valueRefs.some(isSecretName); + if (isSensitive) { + sensitive.add(envVar.name); + valueRefs.forEach((ref) => { sensitive.add(ref); }); + } else { + if (SECRET_NAME_REGEX.test(envVar.name)) sensitive.add(envVar.name); + valueRefs.forEach((ref) => { + if (SECRET_NAME_REGEX.test(ref)) sensitive.add(ref); + }); + } + }); + + return { + keysToQuery: Array.from(keys), + extraEnv: env, + sensitiveKeys: Array.from(sensitive), + }; + }, [documentedSecretNames, environmentVariables, extractVarRefsFromValue]); + + const { meta: machineEnv, isLoading: isMachineEnvLoading, policy: machineEnvPolicy } = useEnvironmentVariables( + machineId, + keysToQuery, + { extraEnv, sensitiveKeys }, + ); + + // Add variable inline form state + const [isAddExpanded, setIsAddExpanded] = React.useState(false); + const [newVarName, setNewVarName] = React.useState(''); + const [newVarValue, setNewVarValue] = React.useState(''); + const nameInputRef = React.useRef<TextInput>(null); + + const resetAddDraft = React.useCallback(() => { + setNewVarName(''); + setNewVarValue(''); + setIsAddExpanded(false); + }, []); + + // Helper to get expected value and description from documentation + const getDocumentation = React.useCallback((varName: string) => { + if (!profileDocs) return { expectedValue: undefined, description: undefined, isSecret: false }; + + const doc = profileDocs.environmentVariables.find(ev => ev.name === varName); + return { + expectedValue: doc?.expectedValue, + description: doc?.description, + isSecret: doc?.isSecret || false + }; + }, [profileDocs]); + + const handleUpdateVariable = React.useCallback((index: number, newValue: string) => { + const updated = [...environmentVariables]; + updated[index] = { ...updated[index], value: newValue }; + onChange(updated); + }, [environmentVariables, onChange]); + + const handleUpdateSecretOverride = React.useCallback((index: number, isSecret: boolean | undefined) => { + const updated = [...environmentVariables]; + updated[index] = { ...updated[index], isSecret }; + onChange(updated); + }, [environmentVariables, onChange]); + + const handleDeleteVariable = React.useCallback((index: number) => { + onChange(environmentVariables.filter((_, i) => i !== index)); + }, [environmentVariables, onChange]); + + const handleDuplicateVariable = React.useCallback((index: number) => { + const envVar = environmentVariables[index]; + const baseName = envVar.name.replace(/_COPY\d*$/, ''); + + // Find next available copy number + let copyNum = 1; + while (environmentVariables.some(v => v.name === `${baseName}_COPY${copyNum}`)) { + copyNum++; + } + + const duplicated = { + name: `${baseName}_COPY${copyNum}`, + value: envVar.value, + isSecret: envVar.isSecret, + }; + onChange([...environmentVariables, duplicated]); + }, [environmentVariables, onChange]); + + const handleAddVariable = React.useCallback(() => { + const normalizedName = newVarName.trim().toUpperCase(); + if (!normalizedName) { + Modal.alert(t('common.error'), t('profiles.environmentVariables.validation.nameRequired')); + return; + } + + // Validate variable name format + if (!/^[A-Z_][A-Z0-9_]*$/.test(normalizedName)) { + Modal.alert( + t('common.error'), + t('profiles.environmentVariables.validation.invalidNameFormat'), + ); + return; + } + + // Check for duplicates + if (environmentVariables.some(v => v.name === normalizedName)) { + Modal.alert(t('common.error'), t('profiles.environmentVariables.validation.duplicateName')); + return; + } + + onChange([...environmentVariables, { + name: normalizedName, + value: newVarValue.trim() || '', + }]); + + resetAddDraft(); + }, [environmentVariables, newVarName, newVarValue, onChange, resetAddDraft]); + + return ( + <View style={styles.container}> + <View style={styles.titleContainer}> + <Text style={styles.titleText}> + {t('profiles.environmentVariables.title')} + </Text> + </View> + + {environmentVariables.length > 0 && ( + <View> + {environmentVariables.map((envVar, index) => { + const refs = extractVarRefsFromValue(envVar.value); + const primaryRef = refs[0] ?? null; + const primaryDocs = getDocumentation(envVar.name); + const refDocs = primaryRef ? getDocumentation(primaryRef) : undefined; + const autoSecret = + primaryDocs.isSecret || + refDocs?.isSecret || + SECRET_NAME_REGEX.test(envVar.name) || + refs.some((ref) => SECRET_NAME_REGEX.test(ref)); + const forcedSecret = Boolean(machineEnv?.[envVar.name]?.isForcedSensitive); + const effectiveIsSecret = forcedSecret + ? true + : envVar.isSecret !== undefined + ? envVar.isSecret + : autoSecret; + const expectedValue = primaryDocs.expectedValue ?? refDocs?.expectedValue; + const description = primaryDocs.description ?? refDocs?.description; + const template = parseEnvVarTemplate(envVar.value); + const sourceVarName = template?.sourceVar ?? null; + const requirementVarName = (sourceVarName ?? envVar.name).trim().toUpperCase(); + + return ( + <EnvironmentVariableCard + key={envVar.name} + variable={envVar} + index={index} + machineId={machineId} + machineName={machineName ?? null} + machineEnv={machineEnv} + machineEnvPolicy={machineEnvPolicy} + isMachineEnvLoading={isMachineEnvLoading} + expectedValue={expectedValue} + description={description} + isSecret={effectiveIsSecret} + secretOverride={envVar.isSecret} + autoSecret={autoSecret} + isForcedSensitive={forcedSecret} + sourceRequirement={requirementVarName ? (sourceRequirementsByName[requirementVarName] ?? null) : null} + onUpdateSourceRequirement={onUpdateSourceRequirement} + defaultSecretNameForSourceVar={requirementVarName ? getDefaultSecretNameForSourceVar(requirementVarName) : null} + onPickDefaultSecretForSourceVar={onPickDefaultSecretForSourceVar} + onUpdateSecretOverride={handleUpdateSecretOverride} + onUpdate={handleUpdateVariable} + onDelete={handleDeleteVariable} + onDuplicate={handleDuplicateVariable} + /> + ); + })} + </View> + )} + + <View style={styles.addContainer}> + <InlineAddExpander + isOpen={isAddExpanded} + onOpenChange={setIsAddExpanded} + title={t('profiles.environmentVariables.addVariable')} + icon={<Ionicons name="add-circle-outline" size={29} color={theme.colors.button.secondary.tint} />} + onCancel={resetAddDraft} + onSave={handleAddVariable} + saveDisabled={!newVarName.trim()} + cancelLabel={t('common.cancel')} + saveLabel={t('common.save')} + autoFocusRef={nameInputRef} + > + <Text style={styles.fieldLabel}> + {t('secrets.fields.name')} + </Text> + <View style={styles.addInputRow}> + <TextInput + ref={nameInputRef} + style={styles.addTextInput} + placeholder={t('profiles.environmentVariables.namePlaceholder')} + placeholderTextColor={theme.colors.input.placeholder} + value={newVarName} + onChangeText={(text) => setNewVarName(text.toUpperCase())} + autoCapitalize="characters" + autoCorrect={false} + /> + </View> + + <Text style={styles.fieldLabel}> + {t('secrets.fields.value')} + </Text> + <View style={[styles.addInputRow, styles.addInputRowLast]}> + <TextInput + style={styles.addTextInput} + placeholder={t('profiles.environmentVariables.valuePlaceholder')} + placeholderTextColor={theme.colors.input.placeholder} + value={newVarValue} + onChangeText={setNewVarValue} + autoCapitalize="none" + autoCorrect={false} + /> + </View> + </InlineAddExpander> + </View> + </View> + ); +} + + +const stylesheet = StyleSheet.create((theme) => ({ + container: { + marginBottom: 16, + }, + titleContainer: { + paddingTop: Platform.select({ ios: 35, default: 16 }), + paddingBottom: Platform.select({ ios: 6, default: 8 }), + paddingHorizontal: Platform.select({ ios: 32, default: 24 }), + }, + titleText: { + ...Typography.default('regular'), + color: theme.colors.groupped.sectionTitle, + fontSize: Platform.select({ ios: 13, default: 14 }), + lineHeight: Platform.select({ ios: 18, default: 20 }), + letterSpacing: Platform.select({ ios: -0.08, default: 0.1 }), + textTransform: 'uppercase', + fontWeight: '500', + }, + envVarListContainer: { + // Intentionally unused: each EnvironmentVariableCard is an ItemGroup + // and provides its own consistent horizontal margins. + }, + addContainer: { + backgroundColor: theme.colors.surface, + marginHorizontal: Platform.select({ ios: 16, default: 12 }), + borderRadius: Platform.select({ ios: 10, default: 16 }), + overflow: 'hidden', + shadowColor: theme.colors.shadow.color, + shadowOffset: { width: 0, height: 0.33 }, + shadowOpacity: theme.colors.shadow.opacity, + shadowRadius: 0, + elevation: 1, + }, + fieldLabel: { + ...Typography.default('semiBold'), + fontSize: 13, + color: theme.colors.groupped.sectionTitle, + marginBottom: 8, + }, + addInputRow: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: theme.colors.input.background, + borderRadius: 10, + paddingHorizontal: 12, + paddingVertical: 8, + marginBottom: 8, + }, + addInputRowLast: { + marginBottom: 12, + }, + addTextInput: { + flex: 1, + fontSize: 16, + color: theme.colors.input.text, + ...Typography.default('regular'), + ...(Platform.select({ + web: { + outline: 'none', + outlineStyle: 'none', + outlineWidth: 0, + outlineColor: 'transparent', + boxShadow: 'none', + WebkitBoxShadow: 'none', + WebkitAppearance: 'none', + }, + default: {}, + }) as object), + }, +})); diff --git a/expo-app/sources/components/profiles/profileActions.ts b/expo-app/sources/components/profiles/profileActions.ts new file mode 100644 index 000000000..3310820da --- /dev/null +++ b/expo-app/sources/components/profiles/profileActions.ts @@ -0,0 +1,65 @@ +import type { ItemAction } from '@/components/ui/lists/itemActions'; +import type { AIBackendProfile } from '@/sync/settings'; +import { t } from '@/text'; + +export function buildProfileActions(params: { + profile: AIBackendProfile; + isFavorite: boolean; + favoriteActionColor?: string; + nonFavoriteActionColor?: string; + onToggleFavorite: () => void; + onEdit: () => void; + onDuplicate: () => void; + onDelete?: () => void; + onViewEnvironmentVariables?: () => void; +}): ItemAction[] { + const actions: ItemAction[] = []; + + if (params.onViewEnvironmentVariables) { + actions.push({ + id: 'envVars', + title: t('profiles.actions.viewEnvironmentVariables'), + icon: 'list-outline', + onPress: params.onViewEnvironmentVariables, + }); + } + + const favoriteColor = params.isFavorite ? params.favoriteActionColor : params.nonFavoriteActionColor; + const favoriteAction: ItemAction = { + id: 'favorite', + title: params.isFavorite ? t('profiles.actions.removeFromFavorites') : t('profiles.actions.addToFavorites'), + icon: params.isFavorite ? 'star' : 'star-outline', + onPress: params.onToggleFavorite, + }; + if (favoriteColor) { + favoriteAction.color = favoriteColor; + } + actions.push({ + id: 'edit', + title: t('profiles.actions.editProfile'), + icon: 'create-outline', + onPress: params.onEdit, + }); + + actions.push({ + id: 'copy', + title: t('profiles.actions.duplicateProfile'), + icon: 'copy-outline', + onPress: params.onDuplicate, + }); + + if (!params.profile.isBuiltIn && params.onDelete) { + actions.push({ + id: 'delete', + title: t('profiles.actions.deleteProfile'), + icon: 'trash-outline', + destructive: true, + onPress: params.onDelete, + }); + } + + // Keep favorite as the far-right inline action (and last in compact rows too). + actions.push(favoriteAction); + + return actions; +} diff --git a/expo-app/sources/components/profiles/profileDisplay.ts b/expo-app/sources/components/profiles/profileDisplay.ts new file mode 100644 index 000000000..67ededdf1 --- /dev/null +++ b/expo-app/sources/components/profiles/profileDisplay.ts @@ -0,0 +1,14 @@ +import type { AIBackendProfile } from '@/sync/settings'; +import { getBuiltInProfileNameKey } from '@/sync/profileUtils'; +import { t } from '@/text'; + +export function getProfileDisplayName(profile: Pick<AIBackendProfile, 'id' | 'name' | 'isBuiltIn'>): string { + if (profile.isBuiltIn) { + const key = getBuiltInProfileNameKey(profile.id); + if (key) { + return t(key); + } + } + return profile.name; +} + diff --git a/expo-app/sources/components/profiles/profileListModel.test.ts b/expo-app/sources/components/profiles/profileListModel.test.ts new file mode 100644 index 000000000..59d6cffce --- /dev/null +++ b/expo-app/sources/components/profiles/profileListModel.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { AIBackendProfile } from '@/sync/settings'; +import { getProfileBackendSubtitle, getProfileSubtitle } from '@/components/profiles/profileListModel'; + +vi.mock('@/text', () => ({ + t: (key: string) => key, +})); + +describe('profileListModel', () => { + const strings = { + builtInLabel: 'Built-in', + customLabel: 'Custom', + agentLabelById: { + claude: 'Claude', + codex: 'Codex', + opencode: 'OpenCode', + gemini: 'Gemini', + auggie: 'Auggie', + }, + }; + + it('builds backend subtitle for enabled compatible agents', () => { + const profile = { isBuiltIn: false, compatibility: { claude: true, codex: true, opencode: true, gemini: true, auggie: true } } as Pick<AIBackendProfile, 'compatibility' | 'isBuiltIn'>; + expect(getProfileBackendSubtitle({ profile, enabledAgentIds: ['claude', 'codex'], strings })).toBe('Claude • Codex'); + }); + + it('skips disabled agents even if compatible', () => { + const profile = { isBuiltIn: false, compatibility: { claude: true, codex: true, opencode: true, gemini: true, auggie: true } } as Pick<AIBackendProfile, 'compatibility' | 'isBuiltIn'>; + expect(getProfileBackendSubtitle({ profile, enabledAgentIds: ['claude', 'gemini'], strings })).toBe('Claude • Gemini'); + }); + + it('builds built-in subtitle with backend', () => { + const profile = { isBuiltIn: true, compatibility: { claude: true, codex: false, opencode: false, gemini: false, auggie: false } } as Pick<AIBackendProfile, 'compatibility' | 'isBuiltIn'>; + expect(getProfileSubtitle({ profile, enabledAgentIds: ['claude', 'codex'], strings })).toBe('Built-in · Claude'); + }); + + it('builds custom subtitle without backend', () => { + const profile = { isBuiltIn: false, compatibility: { claude: false, codex: false, opencode: false, gemini: false, auggie: false } } as Pick<AIBackendProfile, 'compatibility' | 'isBuiltIn'>; + expect(getProfileSubtitle({ profile, enabledAgentIds: ['claude', 'codex', 'gemini'], strings })).toBe('Custom'); + }); +}); diff --git a/expo-app/sources/components/profiles/profileListModel.ts b/expo-app/sources/components/profiles/profileListModel.ts new file mode 100644 index 000000000..29612bde9 --- /dev/null +++ b/expo-app/sources/components/profiles/profileListModel.ts @@ -0,0 +1,65 @@ +import type { AIBackendProfile } from '@/sync/settings'; +import { buildProfileGroups, type ProfileGroups } from '@/sync/profileGrouping'; +import { t } from '@/text'; +import { getAgentCore, type AgentId } from '@/agents/catalog'; +import { isProfileCompatibleWithAgent } from '@/sync/settings'; + +export interface ProfileListStrings { + builtInLabel: string; + customLabel: string; + agentLabelById: Readonly<Record<AgentId, string>>; +} + +export function getDefaultProfileListStrings(enabledAgentIds: readonly AgentId[]): ProfileListStrings { + const agentLabelById: Record<AgentId, string> = {} as any; + for (const agentId of enabledAgentIds) { + agentLabelById[agentId] = t(getAgentCore(agentId).displayNameKey); + } + return { + builtInLabel: t('profiles.builtIn'), + customLabel: t('profiles.custom'), + agentLabelById, + }; +} + +export function getProfileBackendSubtitle(params: { + profile: Pick<AIBackendProfile, 'compatibility' | 'isBuiltIn'>; + enabledAgentIds: readonly AgentId[]; + strings: ProfileListStrings; +}): string { + const parts: string[] = []; + for (const agentId of params.enabledAgentIds) { + if (isProfileCompatibleWithAgent(params.profile, agentId)) { + const label = params.strings.agentLabelById[agentId]; + if (label) parts.push(label); + } + } + return parts.length > 0 ? parts.join(' • ') : ''; +} + +export function getProfileSubtitle(params: { + profile: Pick<AIBackendProfile, 'compatibility' | 'isBuiltIn'>; + enabledAgentIds: readonly AgentId[]; + strings: ProfileListStrings; +}): string { + const backend = getProfileBackendSubtitle({ + profile: params.profile, + enabledAgentIds: params.enabledAgentIds, + strings: params.strings, + }); + + const label = params.profile.isBuiltIn ? params.strings.builtInLabel : params.strings.customLabel; + return backend ? `${label} · ${backend}` : label; +} + +export function buildProfilesListGroups(params: { + customProfiles: AIBackendProfile[]; + favoriteProfileIds: string[]; + enabledAgentIds?: readonly AgentId[]; +}): ProfileGroups { + return buildProfileGroups({ + customProfiles: params.customProfiles, + favoriteProfileIds: params.favoriteProfileIds, + enabledAgentIds: params.enabledAgentIds, + }); +} diff --git a/expo-app/sources/components/secrets/SecretAddModal.tsx b/expo-app/sources/components/secrets/SecretAddModal.tsx new file mode 100644 index 000000000..40e2f6be5 --- /dev/null +++ b/expo-app/sources/components/secrets/SecretAddModal.tsx @@ -0,0 +1,201 @@ +import React from 'react'; +import { View, Text, TextInput, Pressable, Platform } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { StyleSheet, useUnistyles } from 'react-native-unistyles'; + +import { Typography } from '@/constants/Typography'; +import { t } from '@/text'; +import { ItemListStatic } from '@/components/ui/lists/ItemList'; +import { ItemGroup } from '@/components/ui/lists/ItemGroup'; + +export interface SecretAddModalResult { + name: string; + value: string; +} + +export interface SecretAddModalProps { + onClose: () => void; + onSubmit: (result: SecretAddModalResult) => void; + title?: string; +} + +export function SecretAddModal(props: SecretAddModalProps) { + const { theme } = useUnistyles(); + const styles = stylesheet; + + const [name, setName] = React.useState(''); + const [value, setValue] = React.useState(''); + + const submit = React.useCallback(() => { + const trimmedName = name.trim(); + const trimmedValue = value.trim(); + if (!trimmedName) return; + if (!trimmedValue) return; + props.onSubmit({ name: trimmedName, value: trimmedValue }); + props.onClose(); + }, [name, props, value]); + + return ( + <View style={styles.container}> + <View style={styles.header}> + <Text style={styles.headerTitle}>{props.title ?? t('secrets.addTitle')}</Text> + <Pressable + onPress={props.onClose} + hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} + style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })} + > + <Ionicons name="close" size={20} color={theme.colors.textSecondary} /> + </Pressable> + </View> + + <View style={styles.body}> + <Text style={styles.helpText}> + {t('settings.secretsSubtitle')} + </Text> + + <ItemListStatic style={{ backgroundColor: 'transparent' }}> + <ItemGroup title={t('secrets.addTitle')} containerStyle={{ marginHorizontal: 0 }}> + <View style={styles.inputContainer}> + <Text style={styles.fieldLabel}>{t('secrets.fields.name')}</Text> + <TextInput + style={styles.textInput} + placeholder={t('secrets.placeholders.nameExample')} + placeholderTextColor={theme.colors.input.placeholder} + value={name} + onChangeText={setName} + autoCapitalize="none" + autoCorrect={false} + /> + + <View style={{ height: 12 }} /> + + <Text style={styles.fieldLabel}>{t('secrets.fields.value')}</Text> + <TextInput + style={styles.textInput} + placeholder="sk-..." + placeholderTextColor={theme.colors.input.placeholder} + value={value} + onChangeText={setValue} + autoCapitalize="none" + autoCorrect={false} + secureTextEntry + textContentType={Platform.OS === 'ios' ? 'password' : undefined} + /> + </View> + </ItemGroup> + </ItemListStatic> + + <View style={{ height: 16 }} /> + + <View style={{ flexDirection: 'row', gap: 12 }}> + <View style={{ flex: 1 }}> + <Pressable + onPress={props.onClose} + style={({ pressed }) => ({ + backgroundColor: theme.colors.surface, + borderRadius: 10, + paddingVertical: 12, + alignItems: 'center', + opacity: pressed ? 0.85 : 1, + })} + > + <Text style={{ color: theme.colors.text, ...Typography.default('semiBold') }}> + {t('common.cancel')} + </Text> + </Pressable> + </View> + <View style={{ flex: 1 }}> + <Pressable + onPress={submit} + disabled={!name.trim() || !value.trim()} + style={({ pressed }) => ({ + backgroundColor: theme.colors.button.primary.background, + borderRadius: 10, + paddingVertical: 12, + alignItems: 'center', + opacity: (!name.trim() || !value.trim()) ? 0.5 : (pressed ? 0.85 : 1), + })} + > + <Text style={{ color: theme.colors.button.primary.tint, ...Typography.default('semiBold') }}> + {t('common.save')} + </Text> + </Pressable> + </View> + </View> + </View> + </View> + ); +} + +const stylesheet = StyleSheet.create((theme) => ({ + container: { + width: '92%', + maxWidth: 560, + backgroundColor: theme.colors.groupped.background, + borderRadius: 16, + overflow: 'hidden', + borderWidth: 1, + borderColor: theme.colors.divider, + flexShrink: 1, + }, + header: { + paddingHorizontal: 16, + paddingVertical: 12, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + borderBottomWidth: 1, + borderBottomColor: theme.colors.divider, + }, + headerTitle: { + fontSize: 17, + color: theme.colors.text, + ...Typography.default('semiBold'), + }, + body: { + paddingHorizontal: 16, + paddingVertical: 12, + }, + inputContainer: { + paddingHorizontal: 16, + paddingVertical: 12, + }, + helpText: { + color: theme.colors.textSecondary, + fontSize: Platform.select({ ios: 15, default: 14 }), + lineHeight: 20, + letterSpacing: Platform.select({ ios: -0.24, default: 0.1 }), + marginBottom: 12, + ...Typography.default(), + }, + fieldLabel: { + ...Typography.default('semiBold'), + fontSize: 13, + color: theme.colors.groupped.sectionTitle, + marginBottom: 8, + }, + textInput: { + ...Typography.default('regular'), + backgroundColor: theme.colors.input.background, + borderRadius: 10, + paddingHorizontal: 12, + paddingVertical: Platform.select({ ios: 10, default: 12 }), + fontSize: Platform.select({ ios: 17, default: 16 }), + lineHeight: Platform.select({ ios: 22, default: 24 }), + letterSpacing: Platform.select({ ios: -0.41, default: 0.15 }), + color: theme.colors.input.text, + ...(Platform.select({ + web: { + outline: 'none', + outlineStyle: 'none', + outlineWidth: 0, + outlineColor: 'transparent', + boxShadow: 'none', + WebkitBoxShadow: 'none', + WebkitAppearance: 'none', + }, + default: {}, + }) as object), + }, +})); + diff --git a/expo-app/sources/components/secrets/SecretsList.test.ts b/expo-app/sources/components/secrets/SecretsList.test.ts new file mode 100644 index 000000000..89be2c7e6 --- /dev/null +++ b/expo-app/sources/components/secrets/SecretsList.test.ts @@ -0,0 +1,137 @@ +import React from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import renderer, { act } from 'react-test-renderer'; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +vi.mock('@/text', () => ({ + t: (key: string) => key, +})); + +vi.mock('@expo/vector-icons', () => ({ + Ionicons: 'Ionicons', +})); + +vi.mock('react-native-unistyles', () => ({ + useUnistyles: () => ({ + theme: { + colors: { + text: '#000', + textSecondary: '#666', + divider: '#ddd', + surface: '#fff', + button: { primary: { background: '#00f', tint: '#fff' }, secondary: { tint: '#00f' } }, + input: { background: '#fff', placeholder: '#999', text: '#000' }, + groupped: { sectionTitle: '#333' }, + }, + }, + }), + StyleSheet: { create: (x: any) => x }, +})); + +vi.mock('react-native', () => { + const React = require('react'); + const Pressable = (props: any) => React.createElement('Pressable', props, props.children); + const Text = (props: any) => React.createElement('Text', props, props.children); + const View = (props: any) => React.createElement('View', props, props.children); + const TextInput = React.forwardRef((props: any, ref: any) => { + if (ref) { + ref.current = { focus: () => {} }; + } + return React.createElement('TextInput', props); + }); + return { + Platform: { OS: 'ios', select: (obj: any) => obj.ios ?? obj.default }, + Pressable, + Text, + View, + TextInput, + }; +}); + +vi.mock('@/components/ui/lists/ItemList', () => ({ + ItemList: ({ children }: any) => React.createElement(React.Fragment, null, children), +})); + +vi.mock('@/components/ui/lists/ItemGroup', () => ({ + ItemGroup: ({ children }: any) => React.createElement(React.Fragment, null, children), +})); + +vi.mock('@/components/ui/lists/ItemRowActions', () => ({ + ItemRowActions: () => null, +})); + +vi.mock('@/components/ui/lists/Item', () => ({ + Item: (props: any) => React.createElement('Item', props), +})); + +vi.mock('@/modal', () => ({ + Modal: { show: vi.fn(), prompt: vi.fn(), confirm: vi.fn(), alert: vi.fn() }, +})); + +describe('SecretsList', () => { + beforeEach(() => { + vi.stubGlobal('crypto', { randomUUID: () => 'uuid-1' }); + vi.spyOn(Date, 'now').mockReturnValue(123456); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it('adds a secret via the inline expander (no modal)', async () => { + const onChangeSecrets = vi.fn(); + const onAfterAddSelectId = vi.fn(); + + const { SecretsList } = await import('./SecretsList'); + + let tree: renderer.ReactTestRenderer; + await act(async () => { + tree = renderer.create( + React.createElement(SecretsList, { + secrets: [], + onChangeSecrets, + onAfterAddSelectId, + allowAdd: true, + }), + ); + }); + + const addItem = tree!.root.findAllByType('Item' as any).find((n: any) => n.props.title === 'common.add'); + expect(addItem).toBeTruthy(); + + await act(async () => { + addItem!.props.onPress(); + }); + + const inputs = tree!.root.findAllByType('TextInput' as any); + expect(inputs.length).toBe(2); + + await act(async () => { + inputs[0]!.props.onChangeText('My Key'); + inputs[1]!.props.onChangeText('sk-test'); + }); + + const saveButton = tree!.root.findAllByType('Pressable' as any).find((p: any) => p.props.accessibilityLabel === 'common.save'); + expect(saveButton).toBeTruthy(); + expect(saveButton!.props.disabled).toBe(false); + + await act(async () => { + saveButton!.props.onPress(); + }); + + expect(onChangeSecrets).toHaveBeenCalledTimes(1); + const nextSecrets = onChangeSecrets.mock.calls[0]![0]; + expect(nextSecrets[0]).toMatchObject({ + id: 'uuid-1', + name: 'My Key', + kind: 'apiKey', + encryptedValue: { _isSecretValue: true, value: 'sk-test' }, + createdAt: 123456, + updatedAt: 123456, + }); + expect(onAfterAddSelectId).toHaveBeenCalledWith('uuid-1'); + }); +}); + diff --git a/expo-app/sources/components/secrets/SecretsList.tsx b/expo-app/sources/components/secrets/SecretsList.tsx new file mode 100644 index 000000000..be8d061ab --- /dev/null +++ b/expo-app/sources/components/secrets/SecretsList.tsx @@ -0,0 +1,326 @@ +import React from 'react'; +import { Platform, Text, TextInput, View } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { StyleSheet, useUnistyles } from 'react-native-unistyles'; + +import { Item } from '@/components/ui/lists/Item'; +import { ItemGroup } from '@/components/ui/lists/ItemGroup'; +import { ItemList } from '@/components/ui/lists/ItemList'; +import { ItemRowActions } from '@/components/ui/lists/ItemRowActions'; +import { InlineAddExpander } from '@/components/ui/forms/InlineAddExpander'; +import { Modal } from '@/modal'; +import type { SavedSecret } from '@/sync/settings'; +import { Typography } from '@/constants/Typography'; +import { t } from '@/text'; + +function newId(): string { + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const c: any = (globalThis as any).crypto; + if (c && typeof c.randomUUID === 'function') return c.randomUUID(); + } catch { } + return `${Date.now()}-${Math.random().toString(16).slice(2)}`; +} + +export interface SecretsListProps { + secrets: SavedSecret[]; + onChangeSecrets: (next: SavedSecret[]) => void; + + title?: string; + footer?: string | null; + + selectedId?: string; + onSelectId?: (id: string) => void; + + includeNoneRow?: boolean; + noneSubtitle?: string; + + defaultId?: string | null; + onSetDefaultId?: (id: string | null) => void; + + allowAdd?: boolean; + allowEdit?: boolean; + onAfterAddSelectId?: (id: string) => void; + + wrapInItemList?: boolean; +} + +export function SecretsList(props: SecretsListProps) { + const { theme } = useUnistyles(); + const styles = stylesheet; + const { + secrets, + defaultId, + onChangeSecrets, + onAfterAddSelectId, + selectedId, + onSelectId, + onSetDefaultId, + } = props; + + const orderedSecrets = React.useMemo(() => { + const resolvedDefaultId = defaultId ?? null; + if (!resolvedDefaultId) return secrets; + const defaultSecret = secrets.find((k) => k.id === resolvedDefaultId) ?? null; + if (!defaultSecret) return secrets; + const rest = secrets.filter((k) => k.id !== resolvedDefaultId); + return [defaultSecret, ...rest]; + }, [defaultId, secrets]); + + const [isAddExpanded, setIsAddExpanded] = React.useState(false); + const [draftName, setDraftName] = React.useState(''); + const [draftValue, setDraftValue] = React.useState(''); + const nameInputRef = React.useRef<TextInput>(null); + + const resetAddDraft = React.useCallback(() => { + setDraftName(''); + setDraftValue(''); + setIsAddExpanded(false); + }, []); + + const submitAddSecret = React.useCallback(() => { + const name = draftName.trim(); + const value = draftValue.trim(); + if (!name) return; + if (!value) return; + + const now = Date.now(); + const next: SavedSecret = { + id: newId(), + name, + kind: 'apiKey', + encryptedValue: { _isSecretValue: true, value }, + createdAt: now, + updatedAt: now, + }; + onChangeSecrets([next, ...secrets]); + onAfterAddSelectId?.(next.id); + resetAddDraft(); + }, [draftName, draftValue, onAfterAddSelectId, onChangeSecrets, resetAddDraft, secrets]); + + const renameSecret = React.useCallback(async (secret: SavedSecret) => { + const name = await Modal.prompt( + t('secrets.prompts.renameTitle'), + t('secrets.prompts.renameDescription'), + { defaultValue: secret.name, placeholder: t('secrets.fields.name'), cancelText: t('common.cancel'), confirmText: t('common.rename') }, + ); + if (name === null) return; + if (!name.trim()) { + Modal.alert(t('common.error'), t('secrets.validation.nameRequired')); + return; + } + const now = Date.now(); + onChangeSecrets(secrets.map((k) => (k.id === secret.id ? { ...k, name: name.trim(), updatedAt: now } : k))); + }, [onChangeSecrets, secrets]); + + const replaceSecretValue = React.useCallback(async (secret: SavedSecret) => { + const value = await Modal.prompt( + t('secrets.prompts.replaceValueTitle'), + t('secrets.prompts.replaceValueDescription'), + { placeholder: 'sk-...', inputType: 'secure-text', cancelText: t('common.cancel'), confirmText: t('secrets.actions.replace') }, + ); + if (value === null) return; + if (!value.trim()) { + Modal.alert(t('common.error'), t('secrets.validation.valueRequired')); + return; + } + const now = Date.now(); + onChangeSecrets(secrets.map((k) => ( + k.id === secret.id + ? { ...k, encryptedValue: { ...(k.encryptedValue ?? { _isSecretValue: true }), _isSecretValue: true, value: value.trim() }, updatedAt: now } + : k + ))); + }, [onChangeSecrets, secrets]); + + const deleteSecret = React.useCallback(async (secret: SavedSecret) => { + const confirmed = await Modal.confirm( + t('secrets.prompts.deleteTitle'), + t('secrets.prompts.deleteConfirm', { name: secret.name }), + { cancelText: t('common.cancel'), confirmText: t('common.delete'), destructive: true }, + ); + if (!confirmed) return; + onChangeSecrets(secrets.filter((k) => k.id !== secret.id)); + if (selectedId === secret.id) { + onSelectId?.(''); + } + if (defaultId === secret.id) { + onSetDefaultId?.(null); + } + }, [defaultId, onChangeSecrets, onSelectId, onSetDefaultId, secrets, selectedId]); + + const groupTitle = props.title ?? t('settings.secrets'); + const groupFooter = props.footer === undefined ? t('settings.secretsSubtitle') : (props.footer ?? undefined); + + const group = ( + <> + <ItemGroup title={groupTitle}> + {props.includeNoneRow && ( + <Item + title={t('secrets.noneTitle')} + subtitle={props.noneSubtitle ?? t('secrets.noneSubtitle')} + icon={<Ionicons name="close-circle-outline" size={29} color={theme.colors.textSecondary} />} + onPress={() => props.onSelectId?.('')} + showChevron={false} + selected={props.selectedId === ''} + showDivider + /> + )} + + {props.secrets.length === 0 ? ( + <Item + title={t('secrets.emptyTitle')} + subtitle={t('secrets.emptySubtitle')} + icon={<Ionicons name="key-outline" size={29} color={theme.colors.textSecondary} />} + showChevron={false} + /> + ) : ( + orderedSecrets.map((secret, idx) => { + const isSelected = props.selectedId === secret.id; + const isDefault = props.defaultId === secret.id; + return ( + <Item + key={secret.id} + title={secret.name} + subtitle={t('secrets.savedHiddenSubtitle')} + icon={<Ionicons name="key-outline" size={29} color={theme.colors.button.secondary.tint} />} + onPress={props.onSelectId ? () => props.onSelectId?.(secret.id) : undefined} + showChevron={false} + selected={Boolean(props.onSelectId) ? isSelected : false} + showDivider={idx < orderedSecrets.length - 1} + rightElement={( + <View style={{ flexDirection: 'row', alignItems: 'center', gap: 12 }}> + {props.onSetDefaultId && ( + <ItemRowActions + title={t('secrets.defaultLabel')} + compactActionIds={['default']} + iconSize={18} + actions={[ + { + id: 'default', + title: isDefault ? t('secrets.actions.unsetDefault') : t('secrets.actions.setDefault'), + icon: isDefault ? 'star' : 'star-outline', + color: isDefault ? theme.colors.button.primary.background : theme.colors.textSecondary, + onPress: () => props.onSetDefaultId?.(isDefault ? null : secret.id), + }, + ]} + /> + )} + + {props.allowEdit !== false && ( + <ItemRowActions + title={secret.name} + compactActionIds={['edit']} + actions={[ + { id: 'edit', title: t('common.rename'), icon: 'pencil-outline', onPress: () => { void renameSecret(secret); } }, + { id: 'replace', title: t('secrets.actions.replaceValue'), icon: 'refresh-outline', onPress: () => { void replaceSecretValue(secret); } }, + { id: 'delete', title: t('common.delete'), icon: 'trash-outline', destructive: true, onPress: () => { void deleteSecret(secret); } }, + ]} + /> + )} + + {props.onSelectId && ( + <View style={{ width: 24, alignItems: 'center', justifyContent: 'center' }}> + <Ionicons + name="checkmark-circle" + size={24} + color={theme.colors.text} + style={{ opacity: isSelected ? 1 : 0 }} + /> + </View> + )} + </View> + )} + /> + ); + }) + )} + </ItemGroup> + <ItemGroup footer={groupFooter}> + {props.allowAdd !== false ? ( + <InlineAddExpander + isOpen={isAddExpanded} + onOpenChange={setIsAddExpanded} + title={t('common.add')} + subtitle={t('secrets.addSubtitle')} + icon={<Ionicons name="add-circle-outline" size={29} color={theme.colors.button.secondary.tint} />} + onCancel={resetAddDraft} + onSave={submitAddSecret} + saveDisabled={!draftName.trim() || !draftValue.trim()} + cancelLabel={t('common.cancel')} + saveLabel={t('common.save')} + autoFocusRef={nameInputRef} + > + <Text style={styles.fieldLabel}>{t('secrets.fields.name')}</Text> + <TextInput + ref={nameInputRef} + style={styles.textInput} + placeholder={t('secrets.placeholders.nameExample')} + placeholderTextColor={theme.colors.input.placeholder} + value={draftName} + onChangeText={setDraftName} + autoCapitalize="none" + autoCorrect={false} + /> + + <View style={{ height: 12 }} /> + + <Text style={styles.fieldLabel}>{t('secrets.fields.value')}</Text> + <TextInput + style={styles.textInput} + placeholder="sk-..." + placeholderTextColor={theme.colors.input.placeholder} + value={draftValue} + onChangeText={setDraftValue} + autoCapitalize="none" + autoCorrect={false} + secureTextEntry + textContentType={Platform.OS === 'ios' ? 'password' : undefined} + /> + </InlineAddExpander> + ) : null} + </ItemGroup> + </> + ); + + if (props.wrapInItemList === false) { + return group; + } + + return ( + <ItemList style={{ paddingTop: 0 }}> + {group} + </ItemList> + ); +} + +const stylesheet = StyleSheet.create((theme) => ({ + fieldLabel: { + ...Typography.default('semiBold'), + fontSize: 13, + color: theme.colors.groupped.sectionTitle, + marginBottom: 8, + }, + textInput: { + ...Typography.default('regular'), + backgroundColor: theme.colors.input.background, + borderRadius: 10, + paddingHorizontal: 12, + paddingVertical: Platform.select({ ios: 8, default: 10 }), + fontSize: Platform.select({ ios: 16, default: 16 }), + lineHeight: Platform.select({ ios: 20, default: 22 }), + letterSpacing: Platform.select({ ios: -0.24, default: 0.1 }), + color: theme.colors.input.text, + ...(Platform.select({ + web: { + outline: 'none', + outlineStyle: 'none', + outlineWidth: 0, + outlineColor: 'transparent', + boxShadow: 'none', + WebkitBoxShadow: 'none', + WebkitAppearance: 'none', + }, + default: {}, + }) as object), + }, +})); diff --git a/expo-app/sources/components/secrets/requirements/SecretRequirementModal.tsx b/expo-app/sources/components/secrets/requirements/SecretRequirementModal.tsx new file mode 100644 index 000000000..a94d785f4 --- /dev/null +++ b/expo-app/sources/components/secrets/requirements/SecretRequirementModal.tsx @@ -0,0 +1,819 @@ +import React from 'react'; +import { View, Text, Pressable, TextInput, Platform, ScrollView, useWindowDimensions } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { StyleSheet, useUnistyles } from 'react-native-unistyles'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import type { AIBackendProfile, SavedSecret } from '@/sync/settings'; +import { Typography } from '@/constants/Typography'; +import { t } from '@/text'; +import { useMachineEnvPresence } from '@/hooks/useMachineEnvPresence'; +import { SecretsList } from '@/components/secrets/SecretsList'; +import { ItemListStatic } from '@/components/ui/lists/ItemList'; +import { ItemGroup } from '@/components/ui/lists/ItemGroup'; +import { Item } from '@/components/ui/lists/Item'; +import { useMachine } from '@/sync/storage'; +import { isMachineOnline } from '@/utils/machineUtils'; +import { DropdownMenu } from '@/components/ui/forms/dropdown/DropdownMenu'; +import { useScrollEdgeFades } from '@/components/ui/scroll/useScrollEdgeFades'; +import { ScrollEdgeFades } from '@/components/ui/scroll/ScrollEdgeFades'; +import { ScrollEdgeIndicators } from '@/components/ui/scroll/ScrollEdgeIndicators'; + +const secretRequirementSelectionMemory = new Map<string, 'machine' | 'saved' | 'once'>(); + +export type SecretRequirementModalResult = + | { action: 'cancel' } + | { action: 'useMachine'; envVarName: string } + | { action: 'selectSaved'; envVarName: string; secretId: string; setDefault: boolean } + | { action: 'enterOnce'; envVarName: string; value: string }; + +export type SecretRequirementModalVariant = 'requirement' | 'defaultForProfile'; + +export interface SecretRequirementModalProps { + profile: AIBackendProfile; + /** + * The specific secret environment variable name this modal is resolving (e.g. OPENAI_API_KEY). + * This must correspond to a `profile.envVarRequirements[]` entry with `kind='secret'`. + */ + secretEnvVarName: string; + /** + * Optional: allow resolving multiple secret env vars within the same modal. + * When provided (and when `variant="requirement"`), the user can switch which secret + * they're resolving via a dropdown. + */ + secretEnvVarNames?: ReadonlyArray<string>; + machineId: string | null; + secrets: SavedSecret[]; + defaultSecretId: string | null; + selectedSavedSecretId?: string | null; + /** + * Optional per-env state (used to preselect and persist across reopens). + * These are keyed by env var name (UPPERCASE). + */ + selectedSecretIdByEnvVarName?: Readonly<Record<string, string | null | undefined>> | null; + sessionOnlySecretValueByEnvVarName?: Readonly<Record<string, string | null | undefined>> | null; + defaultSecretIdByEnvVarName?: Readonly<Record<string, string | null | undefined>> | null; + /** + * When provided, toggling "default" updates the default without selecting a key for the current flow. + * (Lets the user keep the modal open and still pick a different key for just this session.) + */ + onSetDefaultSecretId?: (id: string | null) => void; + /** + * Controls presentation. `defaultForProfile` is a simplified view that only lets the user choose + * a saved key as the profile default. + */ + variant?: SecretRequirementModalVariant; + titleOverride?: string; + onChangeSecrets?: (next: SavedSecret[]) => void; + onResolve: (result: SecretRequirementModalResult) => void; + onClose: () => void; + /** + * Optional hook invoked when the modal is dismissed (e.g. backdrop tap). + * Used by the modal host to route dismiss -> cancel. + */ + onRequestClose?: () => void; + allowSessionOnly?: boolean; + /** + * Layout variant: + * - `modal` (default): centered, content-sized card (web + legacy overlays) + * - `screen`: full-width/full-height screen content (native route screens) + */ + layoutVariant?: 'modal' | 'screen'; +} + +export function SecretRequirementModal(props: SecretRequirementModalProps) { + const { theme } = useUnistyles(); + const styles = stylesheet; + const insets = useSafeAreaInsets(); + const { height: windowHeight } = useWindowDimensions(); + + const layoutVariant: 'modal' | 'screen' = props.layoutVariant ?? 'modal'; + + // Dynamic sizing: content-sized until we hit a max height, then scroll internally. + const maxHeight = React.useMemo(() => { + if (layoutVariant === 'screen') { + return Math.max(260, windowHeight); + } + // Keep some breathing room from the screen edges. + const margin = 24; + // NOTE: `useWindowDimensions().height` is already affected by navigation presentation on iOS. + // Subtracting safe-area again can over-shrink and cause awkward cropping. + return Math.max(260, windowHeight - margin * 2); + }, [layoutVariant, windowHeight]); + + const [headerHeight, setHeaderHeight] = React.useState(0); + const scrollMaxHeight = Math.max(0, maxHeight - headerHeight); + const popoverBoundaryRef = React.useRef<View>(null); + // IMPORTANT: + // The secret requirement modal can be intentionally small (content-sized). If we use the modal's + // internal scroll container as the Popover boundary, dropdown menus get their maxHeight clipped + // to the modal instead of the screen. Use a "null boundary" ref so Popover falls back to the + // full window bounds while still anchoring to the trigger. + const screenPopoverBoundaryRef = React.useMemo(() => ({ current: null } as React.RefObject<any>), []); + + const fades = useScrollEdgeFades({ + enabledEdges: { top: true, bottom: true }, + overflowThreshold: 1, + edgeThreshold: 1, + }); + + const normalizedSecretEnvVarName = React.useMemo(() => props.secretEnvVarName.trim().toUpperCase(), [props.secretEnvVarName]); + const secretEnvVarNames = React.useMemo(() => { + const raw = props.secretEnvVarNames && props.secretEnvVarNames.length > 0 + ? props.secretEnvVarNames + : [normalizedSecretEnvVarName]; + const uniq: string[] = []; + for (const n of raw) { + const v = String(n ?? '').trim().toUpperCase(); + if (!v) continue; + if (!uniq.includes(v)) uniq.push(v); + } + return uniq; + }, [normalizedSecretEnvVarName, props.secretEnvVarNames]); + + const [activeEnvVarName, setActiveEnvVarName] = React.useState(() => normalizedSecretEnvVarName); + const envPresence = useMachineEnvPresence( + props.machineId, + secretEnvVarNames, + { ttlMs: 2 * 60_000 }, + ); + const machine = useMachine(props.machineId ?? ''); + + const variant: SecretRequirementModalVariant = props.variant ?? 'requirement'; + + const [sessionOnlyValue, setSessionOnlyValue] = React.useState(() => { + const initial = props.sessionOnlySecretValueByEnvVarName?.[activeEnvVarName]; + return typeof initial === 'string' ? initial : ''; + }); + const sessionOnlyInputRef = React.useRef<TextInput>(null); + const selectionKey = `${props.profile.id}:${activeEnvVarName}:${props.machineId ?? 'no-machine'}`; + const [selectedSource, setSelectedSource] = React.useState<'machine' | 'saved' | 'once' | null>(() => { + if (variant === 'defaultForProfile') return 'saved'; + const selectedRaw = props.selectedSecretIdByEnvVarName?.[activeEnvVarName]; + const hasSessionOnly = typeof props.sessionOnlySecretValueByEnvVarName?.[activeEnvVarName] === 'string' + && String(props.sessionOnlySecretValueByEnvVarName?.[activeEnvVarName]).trim().length > 0; + if (hasSessionOnly) return 'once'; + if (selectedRaw === '') return 'machine'; + if (typeof selectedRaw === 'string' && selectedRaw.trim().length > 0) return 'saved'; + // Default later once machine-env status is known. + return null; + }); + + const [localDefaultSecretId, setLocalDefaultSecretId] = React.useState<string | null>(() => { + const byName = props.defaultSecretIdByEnvVarName?.[activeEnvVarName]; + if (typeof byName === 'string') return byName; + return props.defaultSecretId ?? null; + }); + + const machineHasRequiredSecret = React.useMemo(() => { + if (!props.machineId) return null; + if (!activeEnvVarName) return null; + if (envPresence.isLoading) return null; + if (!envPresence.isPreviewEnvSupported) return null; + return Boolean(envPresence.meta[activeEnvVarName]?.isSet); + }, [activeEnvVarName, envPresence.isLoading, envPresence.isPreviewEnvSupported, envPresence.meta, props.machineId]); + + const machineName = React.useMemo(() => { + if (!props.machineId) return null; + if (!machine) return props.machineId; + return machine.metadata?.displayName || machine.metadata?.host || machine.id; + }, [machine, props.machineId]); + + const machineNameColor = React.useMemo(() => { + if (!props.machineId) return theme.colors.textSecondary; + if (!machine) return theme.colors.textSecondary; + return isMachineOnline(machine) ? theme.colors.status.connected : theme.colors.status.disconnected; + }, [machine, props.machineId, theme.colors.status.connected, theme.colors.status.disconnected, theme.colors.textSecondary]); + + const allowedSources = React.useMemo(() => { + const sources: Array<'machine' | 'saved' | 'once'> = []; + if (variant === 'defaultForProfile') { + sources.push('saved'); + return sources; + } + if (props.machineId) sources.push('machine'); + sources.push('saved'); + if (props.allowSessionOnly !== false) sources.push('once'); + return sources; + }, [props.allowSessionOnly, props.machineId, variant]); + + React.useEffect(() => { + if (selectedSource && allowedSources.includes(selectedSource)) return; + if (variant === 'defaultForProfile') { + setSelectedSource('saved'); + return; + } + setSelectedSource(null); + }, [allowedSources, localDefaultSecretId, props.defaultSecretId, props.machineId, selectedSource, variant]); + + React.useEffect(() => { + if (!selectedSource) return; + secretRequirementSelectionMemory.set(selectionKey, selectedSource); + }, [selectionKey, selectedSource]); + + // When "Use once" is selected, focus the input. This avoids cases where touch handling + // inside nested modal/list layouts makes the TextInput hard to focus. + React.useEffect(() => { + if (selectedSource !== 'once') return; + const id = setTimeout(() => { + sessionOnlyInputRef.current?.focus(); + }, 50); + return () => clearTimeout(id); + }, [selectedSource]); + + const machineEnvTitle = React.useMemo(() => { + const envName = activeEnvVarName || t('profiles.requirements.secretRequired'); + if (!props.machineId) return t('profiles.requirements.machineEnvStatus.checkFor', { env: envName }); + const target = machineName ?? t('profiles.requirements.machineEnvStatus.theMachine'); + if (envPresence.isLoading) return t('profiles.requirements.machineEnvStatus.checking', { env: envName }); + if (machineHasRequiredSecret) return t('profiles.requirements.machineEnvStatus.found', { env: envName, machine: target }); + return t('profiles.requirements.machineEnvStatus.notFound', { env: envName, machine: target }); + }, [activeEnvVarName, envPresence.isLoading, machineHasRequiredSecret, machineName, props.machineId]); + + const machineEnvSubtitle = React.useMemo(() => { + if (!props.machineId) return undefined; + if (envPresence.isLoading) return t('profiles.requirements.machineEnvSubtitle.checking'); + if (machineHasRequiredSecret) return t('profiles.requirements.machineEnvSubtitle.found'); + return t('profiles.requirements.machineEnvSubtitle.notFound'); + }, [envPresence.isLoading, machineHasRequiredSecret, props.machineId]); + + const activeSelectedSavedSecretId = React.useMemo(() => { + const selectedRaw = props.selectedSecretIdByEnvVarName?.[activeEnvVarName]; + if (typeof selectedRaw === 'string' && selectedRaw.trim().length > 0 && selectedRaw !== '') { + return selectedRaw; + } + if (activeEnvVarName === normalizedSecretEnvVarName) { + return props.selectedSavedSecretId ?? null; + } + return null; + }, [activeEnvVarName, normalizedSecretEnvVarName, props.selectedSavedSecretId, props.selectedSecretIdByEnvVarName]); + + const activeDefaultSecretId = React.useMemo(() => { + const byName = props.defaultSecretIdByEnvVarName?.[activeEnvVarName]; + if (typeof byName === 'string' && byName.trim().length > 0) return byName; + if (activeEnvVarName === normalizedSecretEnvVarName) return props.defaultSecretId ?? null; + return null; + }, [activeEnvVarName, normalizedSecretEnvVarName, props.defaultSecretId, props.defaultSecretIdByEnvVarName]); + + const [showChoiceDropdown, setShowChoiceDropdown] = React.useState(false); + const [showEnvVarDropdown, setShowEnvVarDropdown] = React.useState(false); + + // If the machine env option is disabled, never show it as the selected option. + React.useEffect(() => { + if (variant !== 'requirement') return; + if (selectedSource === 'machine' && machineHasRequiredSecret !== true) { + setSelectedSource('saved'); + } + // If nothing has been selected yet, default to the first enabled option. + if (selectedSource === null) { + // Precedence (no explicit session override): + // - default saved secret (if set) wins + // - else machine env (if detected) wins + // - else saved secret option + if (activeDefaultSecretId) { + setSelectedSource('saved'); + return; + } + if (props.machineId && machineHasRequiredSecret === true) { + setSelectedSource('machine'); + return; + } + setSelectedSource('saved'); + } + }, [activeDefaultSecretId, machineHasRequiredSecret, props.machineId, selectedSource, variant]); + + React.useEffect(() => { + // When switching which env var we're resolving, restore any stored session-only value + // and default the source based on current state. + const nextSessionOnly = props.sessionOnlySecretValueByEnvVarName?.[activeEnvVarName]; + setSessionOnlyValue(typeof nextSessionOnly === 'string' ? nextSessionOnly : ''); + + const selectedRaw = props.selectedSecretIdByEnvVarName?.[activeEnvVarName]; + const hasSessionOnly = typeof nextSessionOnly === 'string' && nextSessionOnly.trim().length > 0; + if (variant === 'defaultForProfile') { + setSelectedSource('saved'); + return; + } + if (hasSessionOnly) { + setSelectedSource('once'); + return; + } + if (selectedRaw === '') { + setSelectedSource('machine'); + return; + } + if (typeof selectedRaw === 'string' && selectedRaw.trim().length > 0) { + setSelectedSource('saved'); + return; + } + if (activeDefaultSecretId) { + setSelectedSource('saved'); + return; + } + if (props.machineId && machineHasRequiredSecret === true) { + setSelectedSource('machine'); + return; + } + setSelectedSource('saved'); + }, [ + activeDefaultSecretId, + activeEnvVarName, + machineHasRequiredSecret, + props.machineId, + props.selectedSecretIdByEnvVarName, + props.sessionOnlySecretValueByEnvVarName, + variant, + ]); + + return ( + <View style={[layoutVariant === 'screen' ? styles.containerScreen : styles.container, { maxHeight }]}> + <View + style={styles.header} + onLayout={(e) => { + const next = e?.nativeEvent?.layout?.height ?? 0; + if (typeof next === 'number' && next > 0 && next !== headerHeight) { + setHeaderHeight(next); + } + }} + > + <View style={{ flex: 1 }}> + <Text style={styles.headerTitle}> + {props.titleOverride ?? t('profiles.requirements.modalTitle')} + </Text> + <Text style={styles.headerSubtitle} numberOfLines={1}> + {props.profile.name} + </Text> + </View> + <Pressable + onPress={props.onClose} + hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} + style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })} + > + <Ionicons name="close" size={20} color={theme.colors.textSecondary} /> + </Pressable> + </View> + + <View ref={popoverBoundaryRef} style={[styles.scrollWrap, { maxHeight: scrollMaxHeight }]}> + <ScrollView + style={[styles.scroll, { maxHeight: scrollMaxHeight }]} + contentContainerStyle={styles.scrollContent} + keyboardShouldPersistTaps="handled" + showsVerticalScrollIndicator={true} + scrollEventThrottle={32} + onLayout={fades.onViewportLayout} + onContentSizeChange={fades.onContentSizeChange} + onScroll={fades.onScroll} + > + {variant === 'requirement' ? ( + <View style={styles.helpContainer}> + <Text style={styles.helpText}> + {activeEnvVarName + ? t('profiles.requirements.modalHelpWithEnv', { env: activeEnvVarName }) + : t('profiles.requirements.modalHelpGeneric')} + </Text> + </View> + ) : null} + + <ItemListStatic style={{ backgroundColor: 'transparent' }} containerStyle={{ paddingTop: 0 }}> + {variant === 'requirement' && secretEnvVarNames.length > 1 ? ( + <ItemGroup title="" containerStyle={{ backgroundColor: 'transparent' }}> + <DropdownMenu + open={showEnvVarDropdown} + onOpenChange={setShowEnvVarDropdown} + variant="selectable" + search={false} + selectedId={activeEnvVarName} + showCategoryTitles={false} + matchTriggerWidth={true} + connectToTrigger={true} + rowKind="item" + popoverBoundaryRef={screenPopoverBoundaryRef} + popoverPortalWebTarget="body" + trigger={({ open, toggle }) => ( + <View + style={[ + open + ? (Platform.OS === 'web' + ? ({ + boxShadow: theme.dark + ? '0 0px 3.84px rgba(0, 0, 0, 0.30), 0 3px 3.84px rgba(0, 0, 0, 0.30)' + : '0 0px 3.84px rgba(0, 0, 0, 0.08), 0 3px 3.84px rgba(0, 0, 0, 0.08)', + } as any) + : ({ + shadowColor: theme.colors.shadow.color, + shadowOffset: { width: 0, height: 1 }, + shadowRadius: 3.84, + shadowOpacity: theme.colors.shadow.opacity * 0.8, + elevation: 5, + } as any)) + : null, + { + borderRadius: 12, + borderBottomLeftRadius: open ? 0 : 12, + borderBottomRightRadius: open ? 0 : 12, + backgroundColor: open + ? (theme.dark ? theme.colors.surfaceHighest : theme.colors.surfaceHigh) + : theme.colors.surface, + }, + ]} + > + <Item + selected={false} + title={activeEnvVarName} + subtitle={t('profiles.requirements.modalHelpWithEnv', { env: activeEnvVarName })} + icon={<Ionicons name="key-outline" size={24} color={theme.colors.textSecondary} />} + rightElement={( + <Ionicons + name={open ? 'chevron-up' : 'chevron-down'} + size={20} + color={theme.colors.textSecondary} + /> + )} + showChevron={false} + showDivider={false} + onPress={toggle} + pressableStyle={{ + borderRadius: 12, + borderBottomLeftRadius: open ? 0 : 12, + borderBottomRightRadius: open ? 0 : 12, + overflow: 'hidden', + }} + /> + </View> + )} + items={secretEnvVarNames.map((name) => ({ + id: name, + title: name, + subtitle: undefined, + icon: ( + <View style={{ width: 32, height: 32, alignItems: 'center', justifyContent: 'center' }}> + <Ionicons name="key-outline" size={24} color={theme.colors.textSecondary} /> + </View> + ), + }))} + onSelect={(id) => { + setActiveEnvVarName(id); + }} + /> + </ItemGroup> + ) : null} + {variant === 'requirement' ? ( + <ItemGroup title={t('profiles.requirements.chooseOptionTitle')} containerStyle={{ backgroundColor: 'transparent' }}> + <DropdownMenu + open={showChoiceDropdown} + onOpenChange={setShowChoiceDropdown} + variant="selectable" + search={false} + selectedId={selectedSource} + showCategoryTitles={false} + matchTriggerWidth={true} + connectToTrigger={true} + rowKind="item" + popoverBoundaryRef={screenPopoverBoundaryRef} + popoverPortalWebTarget="body" + trigger={({ open, toggle }) => ( + <View + style={[ + // When open, use the same shadow "strength" as FloatingOverlay, but bias it + // upward so the trigger is visually separated from the background. + open + ? (Platform.OS === 'web' + ? ({ + boxShadow: theme.dark + ? '0 0px 3.84px rgba(0, 0, 0, 0.30), 0 3px 3.84px rgba(0, 0, 0, 0.30)' + : '0 0px 3.84px rgba(0, 0, 0, 0.08), 0 3px 3.84px rgba(0, 0, 0, 0.08)', + } as any) + : ({ + shadowColor: theme.colors.shadow.color, + shadowOffset: { width: 0, height: 1 }, + shadowRadius: 3.84, + shadowOpacity: theme.colors.shadow.opacity * 0.8, + elevation: 5, + } as any)) + : null, + { + borderRadius: 12, + borderBottomLeftRadius: open ? 0 : 12, + borderBottomRightRadius: open ? 0 : 12, + backgroundColor: open + ? (theme.dark ? theme.colors.surfaceHighest : theme.colors.surfaceHigh) + : theme.colors.surface, + }, + ]} + > + <Item + // Provide `selected={false}` so Item can apply "selectable hover" affordances on web. + selected={false} + title={ + selectedSource === 'saved' + ? t('profiles.requirements.options.useSavedSecret.title') + : selectedSource === 'once' + ? t('profiles.requirements.options.enterOnce.title') + : selectedSource === 'machine' + ? machineEnvTitle + : t('profiles.requirements.chooseOptionTitle') + } + subtitle={ + selectedSource === 'saved' + ? t('profiles.requirements.options.useSavedSecret.subtitle') + : selectedSource === 'once' + ? t('profiles.requirements.options.enterOnce.subtitle') + : selectedSource === 'machine' + ? machineEnvSubtitle + : undefined + } + icon={( + <Ionicons + name={ + selectedSource === 'saved' + ? 'key-outline' + : selectedSource === 'once' + ? 'flash-outline' + : selectedSource === 'machine' + ? 'desktop-outline' + : 'options-outline' + } + size={24} + color={theme.colors.textSecondary} + /> + )} + rightElement={( + <Ionicons + name={open ? 'chevron-up' : 'chevron-down'} + size={20} + color={theme.colors.textSecondary} + /> + )} + showChevron={false} + showDivider={false} + onPress={toggle} + pressableStyle={{ + borderRadius: 12, + borderBottomLeftRadius: open ? 0 : 12, + borderBottomRightRadius: open ? 0 : 12, + // Keep clipping for rounded corners, but the shadow comes from the wrapper above. + overflow: 'hidden', + }} + /> + </View> + )} + items={[ + ...(props.machineId ? [{ + id: 'machine', + title: machineEnvTitle, + subtitle: machineEnvSubtitle, + icon: ( + <View style={{ width: 32, height: 32, alignItems: 'center', justifyContent: 'center' }}> + <Ionicons name="desktop-outline" size={24} color={theme.colors.textSecondary} /> + </View> + ), + disabled: machineHasRequiredSecret !== true, + }] : []), + { + id: 'saved', + title: t('profiles.requirements.options.useSavedSecret.title'), + subtitle: t('profiles.requirements.options.useSavedSecret.subtitle'), + icon: ( + <View style={{ width: 32, height: 32, alignItems: 'center', justifyContent: 'center' }}> + <Ionicons name="key-outline" size={24} color={theme.colors.textSecondary} /> + </View> + ), + }, + ...(props.allowSessionOnly !== false ? [{ + id: 'once', + title: t('profiles.requirements.options.enterOnce.title'), + subtitle: t('profiles.requirements.options.enterOnce.subtitle'), + icon: ( + <View style={{ width: 32, height: 32, alignItems: 'center', justifyContent: 'center' }}> + <Ionicons name="flash-outline" size={24} color={theme.colors.textSecondary} /> + </View> + ), + }] : []), + ]} + onSelect={(id) => { + if (id === 'machine') { + if (machineHasRequiredSecret === true) { + setSelectedSource('machine'); + props.onResolve({ action: 'useMachine', envVarName: activeEnvVarName }); + props.onClose(); + } + return; + } + setSelectedSource(id as any); + }} + /> + </ItemGroup> + ) : null} + + {selectedSource === 'saved' && ( + <SecretsList + wrapInItemList={false} + secrets={props.secrets} + onChangeSecrets={(next) => props.onChangeSecrets?.(next)} + allowAdd={Boolean(props.onChangeSecrets)} + allowEdit + title={t('secrets.savedTitle')} + footer={null} + includeNoneRow={variant === 'defaultForProfile'} + noneSubtitle={variant === 'defaultForProfile' ? t('secrets.noneSubtitle') : undefined} + selectedId={variant === 'defaultForProfile' + ? (localDefaultSecretId ?? '') + : (activeSelectedSavedSecretId ?? '') + } + onSelectId={(id) => { + if (variant === 'defaultForProfile') { + const current = localDefaultSecretId ?? null; + const next = id === '' ? null : id; + + // UX: tapping the currently-selected default should unset it. + if (next === current) { + setLocalDefaultSecretId(null); + props.onSetDefaultSecretId?.(null); + props.onResolve({ action: 'cancel' }); + props.onClose(); + return; + } + + setLocalDefaultSecretId(next); + props.onSetDefaultSecretId?.(next); + if (next) { + props.onResolve({ action: 'selectSaved', envVarName: activeEnvVarName, secretId: next, setDefault: true }); + } else { + props.onResolve({ action: 'cancel' }); + } + props.onClose(); + return; + } + if (!id) return; + props.onResolve({ action: 'selectSaved', envVarName: activeEnvVarName, secretId: id, setDefault: false }); + props.onClose(); + }} + onAfterAddSelectId={(id) => { + if (variant === 'defaultForProfile') { + setLocalDefaultSecretId(id); + if (props.onSetDefaultSecretId) { + props.onSetDefaultSecretId(id); + } + props.onResolve({ action: 'selectSaved', envVarName: activeEnvVarName, secretId: id, setDefault: true }); + props.onClose(); + return; + } + props.onResolve({ action: 'selectSaved', envVarName: activeEnvVarName, secretId: id, setDefault: false }); + props.onClose(); + }} + /> + )} + + {selectedSource === 'once' && props.allowSessionOnly !== false && ( + <ItemGroup title={t('profiles.requirements.sections.useOnceTitle')} footer={t('profiles.requirements.sections.useOnceFooter')}> + <View style={{ paddingHorizontal: 16, paddingTop: 12, paddingBottom: 16 }}> + <Text style={styles.fieldLabel}>{t('profiles.requirements.sections.useOnceLabel')}</Text> + <TextInput + ref={sessionOnlyInputRef} + style={styles.textInput} + placeholder="sk-..." + placeholderTextColor={theme.colors.input.placeholder} + value={sessionOnlyValue} + onChangeText={setSessionOnlyValue} + autoFocus={selectedSource === 'once'} + autoCapitalize="none" + autoCorrect={false} + secureTextEntry + textContentType={Platform.OS === 'ios' ? 'password' : undefined} + /> + <View style={{ height: 10 }} /> + <Pressable + disabled={!sessionOnlyValue.trim()} + onPress={() => { + const v = sessionOnlyValue.trim(); + if (!v) return; + props.onResolve({ action: 'enterOnce', envVarName: activeEnvVarName, value: v }); + props.onClose(); + }} + style={({ pressed }) => [ + styles.primaryButton, + { + opacity: !sessionOnlyValue.trim() ? 0.5 : (pressed ? 0.85 : 1), + backgroundColor: theme.colors.button.primary.background, + }, + ]} + > + <Text style={[styles.primaryButtonText, { color: theme.colors.button.primary.tint }]}> + {t('profiles.requirements.actions.useOnceButton')} + </Text> + </Pressable> + </View> + </ItemGroup> + )} + </ItemListStatic> + </ScrollView> + + <ScrollEdgeFades + color={theme.colors.groupped.background} + size={18} + edges={fades.visibility} + /> + <ScrollEdgeIndicators + edges={fades.visibility} + color={theme.colors.textSecondary} + size={14} + opacity={0.35} + /> + </View> + </View> + ); +} + +const stylesheet = StyleSheet.create((theme) => ({ + container: { + width: '92%', + maxWidth: 560, + backgroundColor: theme.colors.groupped.background, + borderRadius: 16, + overflow: 'hidden', + borderWidth: 1, + borderColor: theme.colors.divider, + flexShrink: 1, + alignSelf: 'center', + }, + containerScreen: { + flex: 1, + width: '100%', + backgroundColor: theme.colors.groupped.background, + borderRadius: 0, + overflow: 'hidden', + borderWidth: 0, + borderColor: 'transparent', + alignSelf: 'stretch', + }, + header: { + paddingHorizontal: Platform.select({ ios: 32, default: 24 }), + paddingTop: 14, + paddingBottom: 12, + flexDirection: 'row', + alignItems: 'center', + gap: 12, + borderBottomWidth: 1, + borderBottomColor: theme.colors.divider, + }, + headerTitle: { + fontSize: 16, + fontWeight: '700', + color: theme.colors.text, + ...Typography.default('semiBold'), + }, + headerSubtitle: { + fontSize: 12, + color: theme.colors.textSecondary, + marginTop: 2, + ...Typography.default(), + }, + scrollWrap: { + position: 'relative', + }, + scroll: {}, + scrollContent: { + paddingBottom: 18, + }, + helpContainer: { + width: '100%', + paddingHorizontal: Platform.select({ ios: 32, default: 24 }), + paddingTop: 14, + paddingBottom: 8, + alignSelf: 'center', + }, + helpText: { + ...Typography.default(), + color: theme.colors.textSecondary, + fontSize: Platform.select({ ios: 15, default: 14 }), + lineHeight: 20, + letterSpacing: Platform.select({ ios: -0.24, default: 0.1 }), + }, + primaryButton: { + borderRadius: 10, + paddingVertical: 10, + alignItems: 'center', + justifyContent: 'center', + }, + primaryButtonText: { + fontSize: 13, + fontWeight: '600', + ...Typography.default('semiBold'), + }, + fieldLabel: { + ...Typography.default('semiBold'), + fontSize: 13, + color: theme.colors.groupped.sectionTitle, + marginBottom: 4, + }, + textInput: { + backgroundColor: theme.colors.input.background, + borderRadius: 10, + borderWidth: 1, + borderColor: theme.colors.divider, + paddingHorizontal: 12, + paddingVertical: 10, + color: theme.colors.text, + ...Typography.default(), + }, +})); diff --git a/expo-app/sources/components/secrets/requirements/SecretRequirementScreen.tsx b/expo-app/sources/components/secrets/requirements/SecretRequirementScreen.tsx new file mode 100644 index 000000000..1fa9e14f4 --- /dev/null +++ b/expo-app/sources/components/secrets/requirements/SecretRequirementScreen.tsx @@ -0,0 +1,10 @@ +import React from 'react'; +import type { SecretRequirementModalProps } from './SecretRequirementModal'; +import { SecretRequirementModal } from './SecretRequirementModal'; + +export type SecretRequirementScreenProps = SecretRequirementModalProps; + +export function SecretRequirementScreen(props: SecretRequirementScreenProps) { + return <SecretRequirementModal {...props} layoutVariant="screen" />; +} + diff --git a/expo-app/sources/components/secrets/requirements/index.ts b/expo-app/sources/components/secrets/requirements/index.ts new file mode 100644 index 000000000..3a34a18c8 --- /dev/null +++ b/expo-app/sources/components/secrets/requirements/index.ts @@ -0,0 +1,2 @@ +export * from './SecretRequirementModal'; +export * from './SecretRequirementScreen'; diff --git a/expo-app/sources/components/sessionSharing/components/FriendSelector.tsx b/expo-app/sources/components/sessionSharing/components/FriendSelector.tsx new file mode 100644 index 000000000..31c2f930e --- /dev/null +++ b/expo-app/sources/components/sessionSharing/components/FriendSelector.tsx @@ -0,0 +1,238 @@ +import React, { memo, useState, useMemo } from 'react'; +import { View, Text, TextInput, FlatList, ScrollView } from 'react-native'; +import { StyleSheet } from 'react-native-unistyles'; +import { UserProfile, getDisplayName } from '@/sync/friendTypes'; +import { ShareAccessLevel } from '@/sync/sharingTypes'; +import { UserCard } from '@/components/UserCard'; +import { Item } from '@/components/Item'; +import { t } from '@/text'; + +/** + * Props for FriendSelector component + */ +export interface FriendSelectorProps { + /** List of friends to choose from */ + friends: UserProfile[]; + /** IDs of users already having access */ + excludedUserIds: string[]; + /** Callback when a friend is selected */ + onSelect: (userId: string, accessLevel: ShareAccessLevel) => void; + /** Currently selected user ID (optional) */ + selectedUserId?: string | null; + /** Currently selected access level (optional) */ + selectedAccessLevel?: ShareAccessLevel; +} + +/** + * Friend selector component for sharing + * + * @remarks + * Displays a searchable list of friends and allows selecting + * an access level. This is a controlled component - parent + * manages the modal and button states. + */ +export const FriendSelector = memo(function FriendSelector({ + friends, + excludedUserIds, + onSelect, + selectedUserId: initialSelectedUserId = null, + selectedAccessLevel: initialSelectedAccessLevel = 'view', +}: FriendSelectorProps) { + const [searchQuery, setSearchQuery] = useState(''); + const [selectedUserId, setSelectedUserId] = useState<string | null>(initialSelectedUserId); + const [selectedAccessLevel, setSelectedAccessLevel] = useState<ShareAccessLevel>(initialSelectedAccessLevel); + + // Filter friends based on search and exclusions + const filteredFriends = useMemo(() => { + const excluded = new Set(excludedUserIds); + return friends.filter(friend => { + if (excluded.has(friend.id)) return false; + if (!searchQuery) return true; + + const displayName = getDisplayName(friend).toLowerCase(); + const username = friend.username.toLowerCase(); + const query = searchQuery.toLowerCase(); + + return displayName.includes(query) || username.includes(query); + }); + }, [friends, excludedUserIds, searchQuery]); + + const selectedFriend = useMemo(() => { + return friends.find(f => f.id === selectedUserId); + }, [friends, selectedUserId]); + + // Call onSelect when both user and access level are chosen + React.useEffect(() => { + if (selectedUserId && selectedAccessLevel) { + onSelect(selectedUserId, selectedAccessLevel); + } + }, [selectedUserId, selectedAccessLevel, onSelect]); + + return ( + <ScrollView style={styles.container}> + {/* Search input */} + <TextInput + style={styles.searchInput} + placeholder={t('friends.searchPlaceholder')} + value={searchQuery} + onChangeText={setSearchQuery} + autoFocus + /> + + {/* Friend list */} + <View style={styles.friendList}> + <FlatList + data={filteredFriends} + keyExtractor={(item) => item.id} + renderItem={({ item }) => ( + <View style={styles.friendItem}> + <UserCard + user={item} + onPress={item.contentPublicKey && item.contentPublicKeySig ? () => setSelectedUserId(item.id) : undefined} + disabled={!item.contentPublicKey || !item.contentPublicKeySig} + subtitle={!item.contentPublicKey || !item.contentPublicKeySig ? t('session.sharing.recipientMissingKeys') : undefined} + /> + {selectedUserId === item.id && ( + <View style={styles.selectedIndicator} /> + )} + </View> + )} + ListEmptyComponent={ + <View style={styles.emptyState}> + <Text style={styles.emptyText}> + {searchQuery + ? t('common.noMatches') + : t('friends.noFriendsYet') + } + </Text> + </View> + } + scrollEnabled={false} + /> + </View> + + {/* Access level selection (only shown when friend is selected) */} + {selectedFriend && ( + <View style={styles.accessLevelSection}> + <Text style={styles.sectionTitle}> + {t('session.sharing.accessLevel')} + </Text> + <Item + title={t('session.sharing.viewOnly')} + subtitle={t('session.sharing.viewOnlyDescription')} + onPress={() => setSelectedAccessLevel('view')} + rightElement={ + selectedAccessLevel === 'view' ? ( + <View style={styles.radioSelected}> + <View style={styles.radioDot} /> + </View> + ) : ( + <View style={styles.radioUnselected} /> + ) + } + /> + <Item + title={t('session.sharing.canEdit')} + subtitle={t('session.sharing.canEditDescription')} + onPress={() => setSelectedAccessLevel('edit')} + rightElement={ + selectedAccessLevel === 'edit' ? ( + <View style={styles.radioSelected}> + <View style={styles.radioDot} /> + </View> + ) : ( + <View style={styles.radioUnselected} /> + ) + } + /> + <Item + title={t('session.sharing.canManage')} + subtitle={t('session.sharing.canManageDescription')} + onPress={() => setSelectedAccessLevel('admin')} + rightElement={ + selectedAccessLevel === 'admin' ? ( + <View style={styles.radioSelected}> + <View style={styles.radioDot} /> + </View> + ) : ( + <View style={styles.radioUnselected} /> + ) + } + /> + </View> + )} + </ScrollView> + ); +}); + +const styles = StyleSheet.create((theme) => ({ + container: { + flex: 1, + padding: 16, + }, + searchInput: { + height: 40, + borderRadius: 8, + backgroundColor: theme.colors.surfaceHigh, + paddingHorizontal: 12, + marginBottom: 16, + fontSize: 16, + color: theme.colors.text, + }, + friendList: { + marginBottom: 16, + }, + friendItem: { + position: 'relative', + }, + selectedIndicator: { + position: 'absolute', + left: 0, + top: 0, + bottom: 0, + width: 4, + backgroundColor: theme.colors.textLink, + }, + emptyState: { + padding: 32, + alignItems: 'center', + }, + emptyText: { + fontSize: 16, + color: theme.colors.textSecondary, + textAlign: 'center', + }, + accessLevelSection: { + marginTop: 8, + }, + sectionTitle: { + fontSize: 17, + fontWeight: '600', + color: theme.colors.text, + marginBottom: 12, + paddingHorizontal: 4, + }, + radioSelected: { + width: 20, + height: 20, + borderRadius: 10, + backgroundColor: 'transparent', + borderWidth: 2, + borderColor: theme.colors.radio.active, + alignItems: 'center', + justifyContent: 'center', + }, + radioDot: { + width: 10, + height: 10, + borderRadius: 5, + backgroundColor: theme.colors.radio.dot, + }, + radioUnselected: { + width: 20, + height: 20, + borderRadius: 10, + borderWidth: 2, + borderColor: theme.colors.radio.inactive, + }, +})); diff --git a/expo-app/sources/components/sessionSharing/components/PublicLinkDialog.tsx b/expo-app/sources/components/sessionSharing/components/PublicLinkDialog.tsx new file mode 100644 index 000000000..3ac659a79 --- /dev/null +++ b/expo-app/sources/components/sessionSharing/components/PublicLinkDialog.tsx @@ -0,0 +1,439 @@ +import React, { memo, useState, useEffect } from 'react'; +import { View, Text, ScrollView, Switch, Platform, Linking } from 'react-native'; +import { StyleSheet } from 'react-native-unistyles'; +import QRCode from 'qrcode'; +import { Image } from 'expo-image'; +import * as Clipboard from 'expo-clipboard'; +import { PublicSessionShare } from '@/sync/sharingTypes'; +import { Item } from '@/components/Item'; +import { ItemList } from '@/components/ItemList'; +import { RoundButton } from '@/components/RoundButton'; +import { t } from '@/text'; +import { Ionicons } from '@expo/vector-icons'; +import { BaseModal } from '@/modal/components/BaseModal'; +import { Modal } from '@/modal'; + +/** + * Props for PublicLinkDialog component + */ +export interface PublicLinkDialogProps { + /** Existing public share if any */ + publicShare: PublicSessionShare | null; + /** Callback to create a new public share */ + onCreate: (options: { + expiresInDays?: number; + maxUses?: number; + isConsentRequired: boolean; + }) => void; + /** Callback to delete the public share */ + onDelete: () => void; + /** Callback when cancelled */ + onCancel: () => void; +} + +/** + * Dialog for managing public share links + * + * @remarks + * Displays the current public link with QR code, or allows creating a new one. + * Shows expiration date, usage count, and allows configuring consent requirement. + */ +export const PublicLinkDialog = memo(function PublicLinkDialog({ + publicShare, + onCreate, + onDelete, + onCancel +}: PublicLinkDialogProps) { + const [qrDataUrl, setQrDataUrl] = useState<string | null>(null); + const [shareUrl, setShareUrl] = useState<string | null>(null); + const [isConfiguring, setIsConfiguring] = useState(false); + const [expiresInDays, setExpiresInDays] = useState<number | undefined>(7); + const [maxUses, setMaxUses] = useState<number | undefined>(undefined); + const [isConsentRequired, setIsConsentRequired] = useState(true); + + const buildPublicShareUrl = (token: string): string => { + const path = `/share/${token}`; + + if (Platform.OS === 'web') { + const origin = + typeof window !== 'undefined' && window.location?.origin + ? window.location.origin + : ''; + return `${origin}${path}`; + } + + const configuredWebAppUrl = (process.env.EXPO_PUBLIC_HAPPY_WEBAPP_URL || '').trim(); + const webAppUrl = configuredWebAppUrl || 'https://app.happy.engineering'; + return `${webAppUrl}${path}`; + }; + + // Generate QR code when public share exists + useEffect(() => { + if (!publicShare?.token) { + setQrDataUrl(null); + setShareUrl(null); + return; + } + + // IMPORTANT: Public share links point to the web app route (`/share/:token`), + // not the API server URL. + const url = buildPublicShareUrl(publicShare.token); + setShareUrl(url); + + QRCode.toDataURL(url, { + width: 250, + margin: 2, + color: { + dark: '#000000', + light: '#FFFFFF', + }, + }) + .then(setQrDataUrl) + .catch(() => setQrDataUrl(null)); + }, [publicShare?.token]); + + const handleCreate = () => { + setIsConfiguring(false); + onCreate({ + expiresInDays, + maxUses, + isConsentRequired, + }); + }; + + const formatDate = (timestamp: number) => { + return new Date(timestamp).toLocaleDateString(); + }; + + const handleOpenLink = async () => { + if (!shareUrl) return; + try { + if (Platform.OS === 'web') { + window.open(shareUrl, '_blank', 'noopener,noreferrer'); + return; + } + await Linking.openURL(shareUrl); + } catch { + // ignore + } + }; + + const handleCopyLink = async () => { + if (!shareUrl) return; + try { + await Clipboard.setStringAsync(shareUrl); + Modal.alert(t('common.copied'), t('items.copiedToClipboard', { label: t('session.sharing.publicLink') })); + } catch { + Modal.alert(t('common.error'), t('textSelection.failedToCopy')); + } + }; + + return ( + <BaseModal visible={true} onClose={onCancel}> + <View style={styles.container}> + <View style={styles.header}> + <Text style={styles.title}>{t('session.sharing.publicLink')}</Text> + <Item + title={t('common.cancel')} + onPress={onCancel} + /> + </View> + + <ScrollView style={styles.content}> + {!publicShare || isConfiguring ? ( + <ItemList> + <Text style={styles.description}> + {t('session.sharing.publicLinkDescription')} + </Text> + + {/* Expiration */} + <View style={styles.optionGroup}> + <Text style={styles.groupTitle}> + {t('session.sharing.expiresIn')} + </Text> + <Item + title={t('session.sharing.days7')} + onPress={() => setExpiresInDays(7)} + rightElement={ + expiresInDays === 7 ? ( + <View style={styles.radioSelected}> + <View style={styles.radioDot} /> + </View> + ) : ( + <View style={styles.radioUnselected} /> + ) + } + /> + <Item + title={t('session.sharing.days30')} + onPress={() => setExpiresInDays(30)} + rightElement={ + expiresInDays === 30 ? ( + <View style={styles.radioSelected}> + <View style={styles.radioDot} /> + </View> + ) : ( + <View style={styles.radioUnselected} /> + ) + } + /> + <Item + title={t('session.sharing.never')} + onPress={() => setExpiresInDays(undefined)} + rightElement={ + expiresInDays === undefined ? ( + <View style={styles.radioSelected}> + <View style={styles.radioDot} /> + </View> + ) : ( + <View style={styles.radioUnselected} /> + ) + } + /> + </View> + + {/* Max uses */} + <View style={styles.optionGroup}> + <Text style={styles.groupTitle}> + {t('session.sharing.maxUsesLabel')} + </Text> + <Item + title={t('session.sharing.unlimited')} + onPress={() => setMaxUses(undefined)} + rightElement={ + maxUses === undefined ? ( + <View style={styles.radioSelected}> + <View style={styles.radioDot} /> + </View> + ) : ( + <View style={styles.radioUnselected} /> + ) + } + /> + <Item + title={t('session.sharing.uses10')} + onPress={() => setMaxUses(10)} + rightElement={ + maxUses === 10 ? ( + <View style={styles.radioSelected}> + <View style={styles.radioDot} /> + </View> + ) : ( + <View style={styles.radioUnselected} /> + ) + } + /> + <Item + title={t('session.sharing.uses50')} + onPress={() => setMaxUses(50)} + rightElement={ + maxUses === 50 ? ( + <View style={styles.radioSelected}> + <View style={styles.radioDot} /> + </View> + ) : ( + <View style={styles.radioUnselected} /> + ) + } + /> + </View> + + {/* Consent */} + <View style={styles.optionGroup}> + <Item + title={t('session.sharing.requireConsent')} + subtitle={t('session.sharing.requireConsentDescription')} + rightElement={ + <Switch + value={isConsentRequired} + onValueChange={setIsConsentRequired} + /> + } + /> + </View> + + {/* Create button */} + <View style={styles.buttonContainer}> + <RoundButton + title={publicShare ? t('session.sharing.regeneratePublicLink') : t('session.sharing.createPublicLink')} + onPress={handleCreate} + size="large" + style={{ width: '100%', maxWidth: 400 }} + /> + </View> + </ItemList> + ) : publicShare ? ( + <ItemList> + <Item + title={t('session.sharing.regeneratePublicLink')} + onPress={() => setIsConfiguring(true)} + icon={<Ionicons name="refresh-outline" size={29} color="#007AFF" />} + /> + + {/* QR Code */} + {qrDataUrl && ( + <View style={styles.qrContainer}> + <Image + source={{ uri: qrDataUrl }} + style={{ width: 250, height: 250 }} + contentFit="contain" + /> + </View> + )} + + {/* Public link */} + {shareUrl ? ( + <> + <Item + title={t('session.sharing.publicLink')} + subtitle={<Text selectable>{shareUrl}</Text>} + subtitleLines={0} + onPress={handleOpenLink} + /> + <Item + title={t('common.copy')} + icon={<Ionicons name="copy-outline" size={29} color="#007AFF" />} + onPress={handleCopyLink} + /> + </> + ) : null} + + {/* Info */} + {publicShare.token ? ( + <Item + title={t('session.sharing.linkToken')} + subtitle={publicShare.token} + subtitleLines={1} + /> + ) : ( + <Item + title={t('session.sharing.tokenNotRecoverable')} + subtitle={t('session.sharing.tokenNotRecoverableDescription')} + showChevron={false} + /> + )} + {publicShare.expiresAt && ( + <Item + title={t('session.sharing.expiresOn')} + subtitle={formatDate(publicShare.expiresAt)} + /> + )} + <Item + title={t('session.sharing.usageCount')} + subtitle={ + publicShare.maxUses + ? t('session.sharing.usageCountWithMax', { + used: publicShare.useCount, + max: publicShare.maxUses, + }) + : t('session.sharing.usageCountUnlimited', { + used: publicShare.useCount, + }) + } + /> + <Item + title={t('session.sharing.requireConsent')} + subtitle={ + publicShare.isConsentRequired + ? t('common.yes') + : t('common.no') + } + /> + + {/* Delete button */} + <View style={styles.buttonContainer}> + <Item + title={t('session.sharing.deletePublicLink')} + onPress={onDelete} + destructive + /> + </View> + </ItemList> + ) : null} + </ScrollView> + </View> + </BaseModal> + ); +}); + +const styles = StyleSheet.create((theme) => ({ + container: { + width: 600, + maxWidth: '90%', + maxHeight: '80%', + backgroundColor: theme.colors.surface, + borderRadius: 12, + overflow: 'hidden', + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 16, + paddingVertical: 12, + borderBottomWidth: 1, + borderBottomColor: theme.colors.divider, + }, + title: { + fontSize: 18, + fontWeight: '600', + color: theme.colors.text, + }, + content: { + flex: 1, + }, + description: { + fontSize: 14, + color: theme.colors.textSecondary, + paddingHorizontal: 16, + paddingTop: 16, + paddingBottom: 8, + lineHeight: 20, + }, + optionGroup: { + marginTop: 16, + }, + groupTitle: { + fontSize: 14, + fontWeight: '600', + color: theme.colors.textSecondary, + paddingHorizontal: 16, + paddingBottom: 8, + textTransform: 'uppercase', + }, + radioSelected: { + width: 20, + height: 20, + borderRadius: 10, + backgroundColor: 'transparent', + borderWidth: 2, + borderColor: theme.colors.radio.active, + alignItems: 'center', + justifyContent: 'center', + }, + radioDot: { + width: 10, + height: 10, + borderRadius: 5, + backgroundColor: theme.colors.radio.dot, + }, + radioUnselected: { + width: 20, + height: 20, + borderRadius: 10, + backgroundColor: 'transparent', + borderWidth: 2, + borderColor: theme.colors.radio.inactive, + }, + qrContainer: { + alignItems: 'center', + padding: 24, + backgroundColor: theme.colors.surface, + }, + buttonContainer: { + marginTop: 24, + marginBottom: 16, + paddingHorizontal: 16, + alignItems: 'center', + }, +})); diff --git a/expo-app/sources/components/sessionSharing/components/SessionShareDialog.tsx b/expo-app/sources/components/sessionSharing/components/SessionShareDialog.tsx new file mode 100644 index 000000000..550996376 --- /dev/null +++ b/expo-app/sources/components/sessionSharing/components/SessionShareDialog.tsx @@ -0,0 +1,271 @@ +import React, { memo, useCallback, useState } from 'react'; +import { View, Text, ScrollView } from 'react-native'; +import { StyleSheet } from 'react-native-unistyles'; +import { Ionicons } from '@expo/vector-icons'; +import { Item } from '@/components/Item'; +import { ItemList } from '@/components/ItemList'; +import { t } from '@/text'; +import { SessionShare, ShareAccessLevel } from '@/sync/sharingTypes'; +import { Avatar } from '@/components/Avatar'; + +/** + * Props for the SessionShareDialog component + */ +interface SessionShareDialogProps { + /** ID of the session being shared */ + sessionId: string; + /** Current shares for this session */ + shares: SessionShare[]; + /** Whether the current user can manage shares (owner/admin) */ + canManage: boolean; + /** Callback when user wants to add a new share */ + onAddShare: () => void; + /** Callback when user updates share access level */ + onUpdateShare: (shareId: string, accessLevel: ShareAccessLevel) => void; + /** Callback when user removes a share */ + onRemoveShare: (shareId: string) => void; + /** Callback when user wants to create/manage public link */ + onManagePublicLink: () => void; + /** Callback to close the dialog */ + onClose: () => void; +} + +/** + * Dialog for managing session sharing + * + * @remarks + * Displays current shares and allows managing them. Shows: + * - List of users the session is shared with + * - Their access levels (view/edit/admin) + * - Options to add/remove shares (if canManage) + * - Link to public share management + */ +export const SessionShareDialog = memo(function SessionShareDialog({ + sessionId, + shares, + canManage, + onAddShare, + onUpdateShare, + onRemoveShare, + onManagePublicLink, + onClose +}: SessionShareDialogProps) { + const [selectedShareId, setSelectedShareId] = useState<string | null>(null); + + const handleSharePress = useCallback((shareId: string) => { + if (canManage) { + setSelectedShareId(selectedShareId === shareId ? null : shareId); + } + }, [canManage, selectedShareId]); + + const handleAccessLevelChange = useCallback((shareId: string, accessLevel: ShareAccessLevel) => { + onUpdateShare(shareId, accessLevel); + setSelectedShareId(null); + }, [onUpdateShare]); + + const handleRemoveShare = useCallback((shareId: string) => { + onRemoveShare(shareId); + setSelectedShareId(null); + }, [onRemoveShare]); + + return ( + <View style={styles.container}> + <View style={styles.header}> + <Text style={styles.title}>{t('session.sharing.title')}</Text> + <Item + title={t('common.cancel')} + onPress={onClose} + /> + </View> + + <ScrollView style={styles.content}> + <ItemList> + {/* Add share button */} + {canManage && ( + <Item + title={t('session.sharing.shareWith')} + icon={<Ionicons name="person-add-outline" size={29} color="#007AFF" />} + onPress={onAddShare} + /> + )} + + {/* Public link management */} + {canManage && ( + <Item + title={t('session.sharing.publicLink')} + icon={<Ionicons name="link-outline" size={29} color="#007AFF" />} + onPress={onManagePublicLink} + /> + )} + + {/* Current shares */} + {shares.length > 0 && ( + <View style={styles.section}> + <Text style={styles.sectionTitle}> + {t('session.sharing.sharedWith')} + </Text> + {shares.map(share => ( + <ShareItem + key={share.id} + share={share} + canManage={canManage} + isSelected={selectedShareId === share.id} + onPress={() => handleSharePress(share.id)} + onAccessLevelChange={handleAccessLevelChange} + onRemove={handleRemoveShare} + /> + ))} + </View> + )} + + {shares.length === 0 && !canManage && ( + <View style={styles.emptyState}> + <Text style={styles.emptyText}> + {t('session.sharing.noShares')} + </Text> + </View> + )} + </ItemList> + </ScrollView> + </View> + ); +}); + +/** + * Individual share item component + */ +interface ShareItemProps { + share: SessionShare; + canManage: boolean; + isSelected: boolean; + onPress: () => void; + onAccessLevelChange: (shareId: string, accessLevel: ShareAccessLevel) => void; + onRemove: (shareId: string) => void; +} + +const ShareItem = memo(function ShareItem({ + share, + canManage, + isSelected, + onPress, + onAccessLevelChange, + onRemove +}: ShareItemProps) { + const accessLevelLabel = getAccessLevelLabel(share.accessLevel); + const userName = share.sharedWithUser.username || [share.sharedWithUser.firstName, share.sharedWithUser.lastName] + .filter(Boolean) + .join(' '); + + return ( + <View> + <Item + title={userName} + subtitle={accessLevelLabel} + icon={ + <Avatar + id={share.sharedWithUser.id} + imageUrl={share.sharedWithUser.avatar} + size={32} + /> + } + onPress={canManage ? onPress : undefined} + showChevron={canManage} + /> + + {/* Access level options (shown when selected) */} + {isSelected && canManage && ( + <View style={styles.options}> + <Item + title={t('session.sharing.viewOnly')} + subtitle={t('session.sharing.viewOnlyDescription')} + onPress={() => onAccessLevelChange(share.id, 'view')} + selected={share.accessLevel === 'view'} + /> + <Item + title={t('session.sharing.canEdit')} + subtitle={t('session.sharing.canEditDescription')} + onPress={() => onAccessLevelChange(share.id, 'edit')} + selected={share.accessLevel === 'edit'} + /> + <Item + title={t('session.sharing.canManage')} + subtitle={t('session.sharing.canManageDescription')} + onPress={() => onAccessLevelChange(share.id, 'admin')} + selected={share.accessLevel === 'admin'} + /> + <Item + title={t('session.sharing.stopSharing')} + onPress={() => onRemove(share.id)} + destructive + /> + </View> + )} + </View> + ); +}); + +/** + * Get localized label for access level + */ +function getAccessLevelLabel(level: ShareAccessLevel): string { + switch (level) { + case 'view': + return t('session.sharing.viewOnly'); + case 'edit': + return t('session.sharing.canEdit'); + case 'admin': + return t('session.sharing.canManage'); + } +} + +const styles = StyleSheet.create((theme) => ({ + container: { + width: 600, + maxWidth: '90%', + maxHeight: '80%', + backgroundColor: theme.colors.surface, + borderRadius: 12, + overflow: 'hidden', + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 16, + paddingVertical: 12, + borderBottomWidth: 1, + borderBottomColor: theme.colors.divider, + }, + title: { + fontSize: 18, + fontWeight: '600', + color: theme.colors.text, + }, + content: { + flex: 1, + }, + section: { + marginTop: 16, + }, + sectionTitle: { + fontSize: 14, + fontWeight: '600', + color: theme.colors.textSecondary, + paddingHorizontal: 16, + paddingVertical: 8, + textTransform: 'uppercase', + }, + options: { + paddingLeft: 24, + backgroundColor: theme.colors.surfaceHigh, + }, + emptyState: { + padding: 32, + alignItems: 'center', + }, + emptyText: { + fontSize: 16, + color: theme.colors.textSecondary, + textAlign: 'center', + }, +})); diff --git a/expo-app/sources/components/sessionSharing/index.ts b/expo-app/sources/components/sessionSharing/index.ts new file mode 100644 index 000000000..7def6286d --- /dev/null +++ b/expo-app/sources/components/sessionSharing/index.ts @@ -0,0 +1,4 @@ +export { FriendSelector } from './components/FriendSelector'; +export { PublicLinkDialog } from './components/PublicLinkDialog'; +export { SessionShareDialog } from './components/SessionShareDialog'; + diff --git a/expo-app/sources/components/sessions/SessionNoticeBanner.tsx b/expo-app/sources/components/sessions/SessionNoticeBanner.tsx new file mode 100644 index 000000000..86ad906b5 --- /dev/null +++ b/expo-app/sources/components/sessions/SessionNoticeBanner.tsx @@ -0,0 +1,35 @@ +import * as React from 'react'; +import { Text, View, type ViewStyle } from 'react-native'; +import { useUnistyles } from 'react-native-unistyles'; + +export type SessionNoticeBannerProps = { + title: string; + body: string; + style?: ViewStyle; +}; + +export const SessionNoticeBanner = React.memo((props: SessionNoticeBannerProps) => { + const { theme } = useUnistyles(); + + return ( + <View + style={[ + { + paddingHorizontal: 14, + paddingVertical: 12, + borderRadius: 12, + backgroundColor: theme.dark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)', + }, + props.style, + ]} + > + <Text style={{ color: theme.colors.text, fontSize: 14, fontWeight: '700', marginBottom: 4 }}> + {props.title} + </Text> + <Text style={{ color: theme.colors.textSecondary, fontSize: 13, lineHeight: 18 }}> + {props.body} + </Text> + </View> + ); +}); + diff --git a/expo-app/sources/components/sessions/agentInput/AgentInput.tsx b/expo-app/sources/components/sessions/agentInput/AgentInput.tsx new file mode 100644 index 000000000..f860bef87 --- /dev/null +++ b/expo-app/sources/components/sessions/agentInput/AgentInput.tsx @@ -0,0 +1,1447 @@ +import { Ionicons, Octicons } from '@expo/vector-icons'; +import * as React from 'react'; +import { View, Platform, useWindowDimensions, ViewStyle, Text, ActivityIndicator, Pressable, ScrollView } from 'react-native'; +import { Image } from 'expo-image'; +import { layout } from '@/components/layout'; +import { MultiTextInput, KeyPressEvent } from '@/components/MultiTextInput'; +import { Typography } from '@/constants/Typography'; +import type { PermissionMode, ModelMode } from '@/sync/permissionTypes'; +import { getModelOptionsForAgentType } from '@/sync/modelOptions'; +import { getPermissionModeBadgeLabelForAgentType, getPermissionModeLabelForAgentType, getPermissionModeTitleForAgentType, getPermissionModesForAgentType, normalizePermissionModeForAgentType } from '@/sync/permissionModeOptions'; +import { hapticsLight, hapticsError } from '@/components/haptics'; +import { Shaker, ShakeInstance } from '@/components/Shaker'; +import { StatusDot } from '@/components/StatusDot'; +import { useActiveWord } from '@/components/autocomplete/useActiveWord'; +import { useActiveSuggestions } from '@/components/autocomplete/useActiveSuggestions'; +import { AgentInputAutocomplete } from './components/AgentInputAutocomplete'; +import { FloatingOverlay } from '@/components/FloatingOverlay'; +import { Popover } from '@/components/ui/popover'; +import { ScrollEdgeFades } from '@/components/ui/scroll/ScrollEdgeFades'; +import { ScrollEdgeIndicators } from '@/components/ui/scroll/ScrollEdgeIndicators'; +import { ActionListSection } from '@/components/ui/lists/ActionListSection'; +import { TextInputState, MultiTextInputHandle } from '@/components/MultiTextInput'; +import { applySuggestion } from '@/components/autocomplete/applySuggestion'; +import { GitStatusBadge, useHasMeaningfulGitStatus } from '@/components/GitStatusBadge'; +import { StyleSheet, useUnistyles } from 'react-native-unistyles'; +import { useSetting } from '@/sync/storage'; +import { Theme } from '@/theme'; +import { t } from '@/text'; +import { Metadata } from '@/sync/storageTypes'; +import { AIBackendProfile, getProfileEnvironmentVariables } from '@/sync/settings'; +import { DEFAULT_AGENT_ID, getAgentCore, resolveAgentIdFromFlavor, type AgentId } from '@/agents/catalog'; +import { resolveProfileById } from '@/sync/profileUtils'; +import { getProfileDisplayName } from '@/components/profiles/profileDisplay'; +import { useScrollEdgeFades } from '@/components/ui/scroll/useScrollEdgeFades'; +import { ResumeChip, formatResumeChipLabel, RESUME_CHIP_ICON_NAME, RESUME_CHIP_ICON_SIZE } from './ResumeChip'; +import { PathAndResumeRow } from './PathAndResumeRow'; +import { getHasAnyAgentInputActions, shouldShowPathAndResumeRow } from './actionBarLogic'; +import { useKeyboardHeight } from '@/hooks/useKeyboardHeight'; +import { computeAgentInputDefaultMaxHeight } from './inputMaxHeight'; +import { getContextWarning } from './contextWarning'; +import { buildAgentInputActionMenuActions } from './actionMenuActions'; + +export type AgentInputExtraActionChipRenderContext = Readonly<{ + chipStyle: (pressed: boolean) => any; + showLabel: boolean; + iconColor: string; + textStyle: any; +}>; + +export type AgentInputExtraActionChip = Readonly<{ + key: string; + render: (ctx: AgentInputExtraActionChipRenderContext) => React.ReactNode; +}>; + +interface AgentInputProps { + value: string; + placeholder: string; + onChangeText: (text: string) => void; + sessionId?: string; + onSend: () => void; + sendIcon?: React.ReactNode; + onMicPress?: () => void; + isMicActive?: boolean; + permissionMode?: PermissionMode; + onPermissionModeChange?: (mode: PermissionMode) => void; + onPermissionClick?: () => void; + modelMode?: ModelMode; + onModelModeChange?: (mode: ModelMode) => void; + metadata?: Metadata | null; + onAbort?: () => void | Promise<void>; + showAbortButton?: boolean; + connectionStatus?: { + text: string; + color: string; + dotColor: string; + isPulsing?: boolean; + }; + autocompletePrefixes: string[]; + autocompleteSuggestions: (query: string) => Promise<{ key: string, text: string, component: React.ElementType }[]>; + usageData?: { + inputTokens: number; + outputTokens: number; + cacheCreation: number; + cacheRead: number; + contextSize: number; + }; + alwaysShowContextSize?: boolean; + onFileViewerPress?: () => void; + agentType?: AgentId; + onAgentClick?: () => void; + machineName?: string | null; + onMachineClick?: () => void; + currentPath?: string | null; + onPathClick?: () => void; + resumeSessionId?: string | null; + onResumeClick?: () => void; + resumeIsChecking?: boolean; + isSendDisabled?: boolean; + isSending?: boolean; + disabled?: boolean; + minHeight?: number; + inputMaxHeight?: number; + profileId?: string | null; + onProfileClick?: () => void; + envVarsCount?: number; + onEnvVarsClick?: () => void; + contentPaddingHorizontal?: number; + panelStyle?: ViewStyle; + extraActionChips?: ReadonlyArray<AgentInputExtraActionChip>; +} + +function truncateWithEllipsis(value: string, maxChars: number) { + if (value.length <= maxChars) return value; + return `${value.slice(0, maxChars)}…`; +} + +const stylesheet = StyleSheet.create((theme, runtime) => ({ + container: { + alignItems: 'center', + width: '100%', + paddingBottom: 8, + paddingTop: 8, + }, + innerContainer: { + width: '100%', + position: 'relative', + }, + unifiedPanel: { + backgroundColor: theme.colors.input.background, + borderRadius: Platform.select({ default: 16, android: 20 }), + overflow: 'hidden', + paddingVertical: 2, + paddingBottom: 8, + paddingHorizontal: 8, + }, + inputContainer: { + flexDirection: 'row', + alignItems: 'center', + borderWidth: 0, + paddingLeft: 8, + paddingRight: 8, + paddingVertical: 4, + minHeight: 40, + }, + + // Overlay styles + settingsOverlay: { + // positioning is handled by `Popover` + }, + overlayBackdrop: { + position: 'absolute', + top: -1000, + left: -1000, + right: -1000, + bottom: -1000, + zIndex: 999, + }, + overlaySection: { + paddingVertical: 16, + }, + overlaySectionTitle: { + fontSize: 12, + fontWeight: '600', + color: theme.colors.textSecondary, + paddingHorizontal: 16, + paddingBottom: 4, + ...Typography.default('semiBold'), + }, + overlayDivider: { + height: 1, + backgroundColor: theme.colors.divider, + marginHorizontal: 16, + }, + + // Selection styles + selectionItem: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 16, + paddingVertical: 8, + backgroundColor: 'transparent', + }, + selectionItemPressed: { + backgroundColor: theme.colors.surfacePressed, + }, + radioButton: { + width: 16, + height: 16, + borderRadius: 8, + borderWidth: 2, + alignItems: 'center', + justifyContent: 'center', + marginRight: 12, + }, + radioButtonActive: { + borderColor: theme.colors.radio.active, + }, + radioButtonInactive: { + borderColor: theme.colors.radio.inactive, + }, + radioButtonDot: { + width: 6, + height: 6, + borderRadius: 3, + backgroundColor: theme.colors.radio.dot, + }, + selectionLabel: { + fontSize: 14, + ...Typography.default(), + }, + selectionLabelActive: { + color: theme.colors.radio.active, + }, + selectionLabelInactive: { + color: theme.colors.text, + }, + + // Status styles + statusContainer: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 16, + paddingBottom: 4, + }, + statusRow: { + flexDirection: 'row', + alignItems: 'center', + flexWrap: 'wrap', + }, + statusText: { + fontSize: 11, + ...Typography.default(), + }, + statusDot: { + marginRight: 6, + }, + permissionModeContainer: { + flexDirection: 'column', + alignItems: 'flex-end', + }, + permissionModeText: { + fontSize: 11, + ...Typography.default(), + }, + contextWarningText: { + fontSize: 11, + marginLeft: 8, + ...Typography.default(), + }, + + // Button styles + actionButtonsContainer: { + flexDirection: 'row', + alignItems: 'flex-end', + justifyContent: 'space-between', + paddingHorizontal: 0, + }, + actionButtonsColumn: { + flexDirection: 'column', + flex: 1, + gap: 3, + }, + actionButtonsColumnNarrow: { + flexDirection: 'column', + flex: 1, + gap: 2, + }, + actionButtonsRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + pathRow: { + flexDirection: 'row', + alignItems: 'center', + }, + actionButtonsLeft: { + flexDirection: 'row', + columnGap: 6, + rowGap: 3, + flex: 1, + flexWrap: 'wrap', + overflow: 'visible', + }, + actionButtonsLeftScroll: { + flex: 1, + overflow: 'visible', + }, + actionButtonsLeftScrollContent: { + flexDirection: 'row', + alignItems: 'center', + columnGap: 6, + paddingRight: 6, + }, + actionButtonsFadeLeft: { + position: 'absolute', + left: 0, + top: 0, + bottom: 0, + width: 24, + zIndex: 2, + }, + actionButtonsFadeRight: { + position: 'absolute', + right: 0, + top: 0, + bottom: 0, + width: 24, + zIndex: 2, + }, + actionButtonsLeftNarrow: { + columnGap: 4, + }, + actionButtonsLeftNoFlex: { + flex: 0, + }, + actionChip: { + flexDirection: 'row', + alignItems: 'center', + borderRadius: Platform.select({ default: 16, android: 20 }), + paddingHorizontal: 10, + paddingVertical: 6, + justifyContent: 'center', + height: 32, + gap: 6, + }, + actionChipIconOnly: { + paddingHorizontal: 8, + gap: 0, + }, + actionChipPressed: { + opacity: 0.7, + }, + actionChipText: { + fontSize: 13, + color: theme.colors.button.secondary.tint, + fontWeight: '600', + ...Typography.default('semiBold'), + }, + overlayOptionRow: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 16, + paddingVertical: 8, + }, + overlayOptionRowPressed: { + backgroundColor: theme.colors.surfacePressed, + }, + overlayRadioOuter: { + width: 16, + height: 16, + borderRadius: 8, + borderWidth: 2, + alignItems: 'center', + justifyContent: 'center', + marginRight: 12, + }, + overlayRadioOuterSelected: { + borderColor: theme.colors.radio.active, + }, + overlayRadioOuterUnselected: { + borderColor: theme.colors.radio.inactive, + }, + overlayRadioInner: { + width: 6, + height: 6, + borderRadius: 3, + backgroundColor: theme.colors.radio.dot, + }, + overlayOptionLabel: { + fontSize: 14, + color: theme.colors.text, + ...Typography.default(), + }, + overlayOptionLabelSelected: { + color: theme.colors.radio.active, + }, + overlayOptionLabelUnselected: { + color: theme.colors.text, + }, + overlayOptionDescription: { + fontSize: 11, + color: theme.colors.textSecondary, + ...Typography.default(), + }, + overlayEmptyText: { + fontSize: 13, + color: theme.colors.textSecondary, + paddingHorizontal: 16, + paddingVertical: 8, + ...Typography.default(), + }, + actionButton: { + flexDirection: 'row', + alignItems: 'center', + borderRadius: Platform.select({ default: 16, android: 20 }), + paddingHorizontal: 8, + paddingVertical: 6, + justifyContent: 'center', + height: 32, + }, + actionButtonPressed: { + opacity: 0.7, + }, + actionButtonIcon: { + color: theme.colors.button.secondary.tint, + }, + sendButton: { + width: 32, + height: 32, + borderRadius: 16, + justifyContent: 'center', + alignItems: 'center', + flexShrink: 0, + marginLeft: 8, + marginRight: 8, + }, + sendButtonActive: { + backgroundColor: theme.colors.button.primary.background, + }, + sendButtonInactive: { + backgroundColor: theme.colors.button.primary.disabled, + }, + sendButtonInner: { + width: '100%', + height: '100%', + alignItems: 'center', + justifyContent: 'center', + }, + sendButtonInnerPressed: { + opacity: 0.7, + }, + sendButtonIcon: { + color: theme.colors.button.primary.tint, + }, +})); + +export const AgentInput = React.memo(React.forwardRef<MultiTextInputHandle, AgentInputProps>((props, ref) => { + const styles = stylesheet; + const { theme } = useUnistyles(); + const { width: screenWidth, height: screenHeight } = useWindowDimensions(); + const keyboardHeight = useKeyboardHeight(); + + const defaultInputMaxHeight = React.useMemo(() => { + return computeAgentInputDefaultMaxHeight({ + platform: Platform.OS, + screenHeight, + keyboardHeight, + }); + }, [keyboardHeight, screenHeight]); + + const hasText = props.value.trim().length > 0; + + const agentId: AgentId = resolveAgentIdFromFlavor(props.metadata?.flavor) ?? props.agentType ?? DEFAULT_AGENT_ID; + const modelOptions = React.useMemo(() => getModelOptionsForAgentType(agentId), [agentId]); + + // Profile data + const profiles = useSetting('profiles'); + const currentProfile = React.useMemo(() => { + if (props.profileId === undefined || props.profileId === null || props.profileId.trim() === '') { + return null; + } + return resolveProfileById(props.profileId, profiles); + }, [profiles, props.profileId]); + + const profileLabel = React.useMemo(() => { + if (props.profileId === undefined) { + return null; + } + if (props.profileId === null || props.profileId.trim() === '') { + return t('profiles.noProfile'); + } + if (currentProfile) { + return getProfileDisplayName(currentProfile); + } + const shortId = props.profileId.length > 8 ? `${props.profileId.slice(0, 8)}…` : props.profileId; + return `${t('status.unknown')} (${shortId})`; + }, [props.profileId, currentProfile]); + + const profileIcon = React.useMemo(() => { + // Always show a stable "profile" icon so the chip reads as Profile selection (not "current provider"). + return 'person-circle-outline'; + }, []); + + // Calculate context warning + const contextWarning = props.usageData?.contextSize + ? getContextWarning(props.usageData.contextSize, props.alwaysShowContextSize ?? false, theme) + : null; + + const agentInputEnterToSend = useSetting('agentInputEnterToSend'); + const agentInputActionBarLayout = useSetting('agentInputActionBarLayout'); + const agentInputChipDensity = useSetting('agentInputChipDensity'); + + const effectiveChipDensity = React.useMemo<'labels' | 'icons'>(() => { + if (agentInputChipDensity === 'labels' || agentInputChipDensity === 'icons') { + return agentInputChipDensity; + } + // auto + return screenWidth < 420 ? 'icons' : 'labels'; + }, [agentInputChipDensity, screenWidth]); + + const effectiveActionBarLayout = React.useMemo<'wrap' | 'scroll' | 'collapsed'>(() => { + if (agentInputActionBarLayout === 'wrap' || agentInputActionBarLayout === 'scroll' || agentInputActionBarLayout === 'collapsed') { + return agentInputActionBarLayout; + } + // auto + return screenWidth < 420 ? 'scroll' : 'wrap'; + }, [agentInputActionBarLayout, screenWidth]); + + const showChipLabels = effectiveChipDensity === 'labels'; + + + // Abort button state + const [isAborting, setIsAborting] = React.useState(false); + const shakerRef = React.useRef<ShakeInstance>(null); + const inputRef = React.useRef<MultiTextInputHandle>(null); + + // Forward ref to the MultiTextInput + React.useImperativeHandle(ref, () => inputRef.current!, []); + + // Autocomplete state - track text and selection together + const [inputState, setInputState] = React.useState<TextInputState>({ + text: props.value, + selection: { start: 0, end: 0 } + }); + + // Handle combined text and selection state changes + const handleInputStateChange = React.useCallback((newState: TextInputState) => { + setInputState(newState); + }, []); + + // Use the tracked selection from inputState + const activeWord = useActiveWord(inputState.text, inputState.selection, props.autocompletePrefixes); + // Using default options: clampSelection=true, autoSelectFirst=true, wrapAround=true + // To customize: useActiveSuggestions(activeWord, props.autocompleteSuggestions, { clampSelection: false, wrapAround: false }) + const [suggestions, selected, moveUp, moveDown] = useActiveSuggestions(activeWord, props.autocompleteSuggestions, { clampSelection: true, wrapAround: true }); + + // Handle suggestion selection + const handleSuggestionSelect = React.useCallback((index: number) => { + if (!suggestions[index] || !inputRef.current) return; + + const suggestion = suggestions[index]; + + // Apply the suggestion + const result = applySuggestion( + inputState.text, + inputState.selection, + suggestion.text, + props.autocompletePrefixes, + true // add space after + ); + + // Use imperative API to set text and selection + inputRef.current.setTextAndSelection(result.text, { + start: result.cursorPosition, + end: result.cursorPosition + }); + + // Small haptic feedback + hapticsLight(); + }, [suggestions, inputState, props.autocompletePrefixes]); + + // Settings modal state + const [showSettings, setShowSettings] = React.useState(false); + const overlayAnchorRef = React.useRef<View>(null); + + const actionBarFades = useScrollEdgeFades({ + enabledEdges: { left: true, right: true }, + // Match previous behavior: require a bit of overflow before enabling scroll. + overflowThreshold: 8, + // Match previous behavior: avoid showing fades for tiny offsets. + edgeThreshold: 2, + }); + + const normalizedPermissionMode = React.useMemo(() => { + return normalizePermissionModeForAgentType(props.permissionMode ?? 'default', agentId); + }, [agentId, props.permissionMode]); + + const permissionChipLabel = React.useMemo(() => { + return getPermissionModeBadgeLabelForAgentType(agentId, normalizedPermissionMode); + }, [agentId, normalizedPermissionMode]); + + // Handle settings button press + const handleSettingsPress = React.useCallback(() => { + hapticsLight(); + setShowSettings(prev => !prev); + }, []); + + // NOTE: settings overlay sizing is handled by `Popover` now (anchor + boundary measurement). + + const showPermissionChip = Boolean(props.onPermissionModeChange || props.onPermissionClick); + const hasProfile = Boolean(props.onProfileClick); + const hasEnvVars = Boolean(props.onEnvVarsClick); + const hasAgent = Boolean(props.agentType && props.onAgentClick); + const hasMachine = Boolean(props.machineName !== undefined && props.onMachineClick); + const hasPath = Boolean(props.currentPath && props.onPathClick); + const hasResume = Boolean(props.onResumeClick); + const hasFiles = Boolean(props.sessionId && props.onFileViewerPress); + const hasStop = Boolean(props.onAbort); + const hasAnyActions = getHasAnyAgentInputActions({ + showPermissionChip, + hasProfile, + hasEnvVars, + hasAgent, + hasMachine, + hasPath, + hasResume, + hasFiles, + hasStop, + }); + + const actionBarShouldScroll = effectiveActionBarLayout === 'scroll'; + const actionBarIsCollapsed = effectiveActionBarLayout === 'collapsed'; + const showPathAndResumeRow = shouldShowPathAndResumeRow(effectiveActionBarLayout); + + const canActionBarScroll = actionBarShouldScroll && actionBarFades.canScrollX; + const showActionBarFadeLeft = canActionBarScroll && actionBarFades.visibility.left; + const showActionBarFadeRight = canActionBarScroll && actionBarFades.visibility.right; + + const actionBarFadeColor = React.useMemo(() => { + return theme.colors.input.background; + }, [theme.colors.input.background]); + + // Handle abort button press + const handleAbortPress = React.useCallback(async () => { + if (!props.onAbort) return; + + hapticsError(); + setIsAborting(true); + const startTime = Date.now(); + + try { + await props.onAbort?.(); + + // Ensure minimum 300ms loading time + const elapsed = Date.now() - startTime; + if (elapsed < 300) { + await new Promise(resolve => setTimeout(resolve, 300 - elapsed)); + } + } catch (error) { + // Shake on error + shakerRef.current?.shake(); + console.error('Abort RPC call failed:', error); + } finally { + setIsAborting(false); + } + }, [props.onAbort]); + + const actionMenuActions = React.useMemo(() => { + return buildAgentInputActionMenuActions({ + actionBarIsCollapsed, + hasAnyActions, + tint: theme.colors.button.secondary.tint, + agentId, + profileLabel, + profileIcon, + envVarsCount: props.envVarsCount, + agentType: props.agentType, + machineName: props.machineName, + currentPath: props.currentPath, + resumeSessionId: props.resumeSessionId, + sessionId: props.sessionId, + onProfileClick: props.onProfileClick, + onEnvVarsClick: props.onEnvVarsClick, + onAgentClick: props.onAgentClick, + onMachineClick: props.onMachineClick, + onPathClick: props.onPathClick, + onResumeClick: props.onResumeClick, + onFileViewerPress: props.onFileViewerPress, + canStop: Boolean(props.onAbort), + onStop: () => { + void handleAbortPress(); + }, + dismiss: () => setShowSettings(false), + blurInput: () => inputRef.current?.blur(), + }); + }, [ + actionBarIsCollapsed, + hasAnyActions, + handleAbortPress, + agentId, + profileIcon, + profileLabel, + props.agentType, + props.currentPath, + props.envVarsCount, + props.machineName, + props.onResumeClick, + props.resumeSessionId, + props.onAbort, + props.onAgentClick, + props.onEnvVarsClick, + props.onFileViewerPress, + props.onMachineClick, + props.onPathClick, + props.onProfileClick, + props.sessionId, + theme.colors.button.secondary.tint, + ]); + + // Handle settings selection + const handleSettingsSelect = React.useCallback((mode: PermissionMode) => { + hapticsLight(); + props.onPermissionModeChange?.(mode); + // Don't close the settings overlay - let users see the change and potentially switch again + }, [props.onPermissionModeChange]); + + // Handle keyboard navigation + const handleKeyPress = React.useCallback((event: KeyPressEvent): boolean => { + // Handle autocomplete navigation first + if (suggestions.length > 0) { + if (event.key === 'ArrowUp') { + moveUp(); + return true; + } else if (event.key === 'ArrowDown') { + moveDown(); + return true; + } else if ((event.key === 'Enter' || (event.key === 'Tab' && !event.shiftKey))) { + // Both Enter and Tab select the current suggestion + // If none selected (selected === -1), select the first one + const indexToSelect = selected >= 0 ? selected : 0; + handleSuggestionSelect(indexToSelect); + return true; + } else if (event.key === 'Escape') { + // Clear suggestions by collapsing selection (triggers activeWord to clear) + if (inputRef.current) { + const cursorPos = inputState.selection.start; + inputRef.current.setTextAndSelection(inputState.text, { + start: cursorPos, + end: cursorPos + }); + } + return true; + } + } + + // Handle Escape for abort when no suggestions are visible + if (event.key === 'Escape' && props.showAbortButton && props.onAbort && !isAborting) { + handleAbortPress(); + return true; + } + + // Original key handling + if (Platform.OS === 'web') { + if (agentInputEnterToSend && event.key === 'Enter' && !event.shiftKey) { + if (props.value.trim()) { + props.onSend(); + return true; // Key was handled + } + } + // Handle Shift+Tab for permission mode switching + if (event.key === 'Tab' && event.shiftKey && props.onPermissionModeChange) { + const modeOrder = [...getPermissionModesForAgentType(agentId)]; + const current = normalizePermissionModeForAgentType(props.permissionMode || 'default', agentId); + const currentIndex = modeOrder.indexOf(current); + const nextIndex = (currentIndex + 1) % modeOrder.length; + props.onPermissionModeChange(modeOrder[nextIndex]); + hapticsLight(); + return true; // Key was handled, prevent default tab behavior + } + + } + return false; // Key was not handled + }, [suggestions, moveUp, moveDown, selected, handleSuggestionSelect, props.showAbortButton, props.onAbort, isAborting, handleAbortPress, agentInputEnterToSend, props.value, props.onSend, props.permissionMode, props.onPermissionModeChange, agentId]); + + + + + return ( + <View style={[ + styles.container, + { paddingHorizontal: props.contentPaddingHorizontal ?? (screenWidth > 700 ? 16 : 8) } + ]}> + <View style={[ + styles.innerContainer, + { maxWidth: layout.maxWidth } + ]} ref={overlayAnchorRef}> + {/* Autocomplete suggestions overlay */} + {suggestions.length > 0 && ( + <Popover + open={suggestions.length > 0} + anchorRef={overlayAnchorRef} + placement="top" + gap={8} + maxHeightCap={240} + // Allow the suggestions popover to match the full input width on wide screens. + maxWidthCap={layout.maxWidth} + backdrop={false} + containerStyle={{ paddingHorizontal: screenWidth > 700 ? 0 : 8 }} + > + {({ maxHeight }) => ( + <AgentInputAutocomplete + maxHeight={maxHeight} + suggestions={suggestions.map(s => { + const Component = s.component; + return <Component key={s.key} />; + })} + selectedIndex={selected} + onSelect={handleSuggestionSelect} + itemHeight={48} + /> + )} + </Popover> + )} + + {/* Settings overlay */} + {showSettings && ( + <Popover + open={showSettings} + anchorRef={overlayAnchorRef} + boundaryRef={null} + placement="top" + gap={8} + maxHeightCap={400} + portal={{ web: true }} + edgePadding={{ + horizontal: Platform.OS === 'web' ? (screenWidth > 700 ? 12 : 16) : 0, + vertical: 12, + }} + onRequestClose={() => setShowSettings(false)} + backdrop={{ style: styles.overlayBackdrop }} + > + {({ maxHeight }) => ( + <FloatingOverlay + maxHeight={maxHeight} + keyboardShouldPersistTaps="always" + edgeFades={{ top: true, bottom: true, size: 28 }} + edgeIndicators={true} + > + {/* Action shortcuts (collapsed layout) */} + {actionMenuActions.length > 0 ? ( + <ActionListSection + title={t('agentInput.actionMenu.title')} + actions={actionMenuActions} + /> + ) : null} + + {actionBarIsCollapsed && hasAnyActions ? ( + <View style={styles.overlayDivider} /> + ) : null} + + {/* Permission Mode Section */} + <View style={styles.overlaySection}> + <Text style={styles.overlaySectionTitle}> + {getPermissionModeTitleForAgentType(agentId)} + </Text> + {getPermissionModesForAgentType(agentId).map((mode) => { + const isSelected = normalizedPermissionMode === mode; + + return ( + <Pressable + key={mode} + onPress={() => handleSettingsSelect(mode)} + style={({ pressed }) => [ + styles.overlayOptionRow, + pressed ? styles.overlayOptionRowPressed : null, + ]} + > + <View + style={[ + styles.overlayRadioOuter, + isSelected + ? styles.overlayRadioOuterSelected + : styles.overlayRadioOuterUnselected, + ]} + > + {isSelected && ( + <View style={styles.overlayRadioInner} /> + )} + </View> + <Text + style={[ + styles.overlayOptionLabel, + isSelected ? styles.overlayOptionLabelSelected : styles.overlayOptionLabelUnselected, + ]} + > + {getPermissionModeLabelForAgentType(agentId, mode)} + </Text> + </Pressable> + ); + })} + </View> + + {/* Divider */} + <View style={styles.overlayDivider} /> + + {/* Model Section */} + <View style={styles.overlaySection}> + <Text style={styles.overlaySectionTitle}> + {t('agentInput.model.title')} + </Text> + {modelOptions.length > 0 ? ( + modelOptions.map((option) => { + const isSelected = props.modelMode === option.value; + return ( + <Pressable + key={option.value} + onPress={() => { + hapticsLight(); + props.onModelModeChange?.(option.value); + }} + style={({ pressed }) => [ + styles.overlayOptionRow, + pressed ? styles.overlayOptionRowPressed : null, + ]} + > + <View + style={[ + styles.overlayRadioOuter, + isSelected + ? styles.overlayRadioOuterSelected + : styles.overlayRadioOuterUnselected, + ]} + > + {isSelected && ( + <View style={styles.overlayRadioInner} /> + )} + </View> + <View> + <Text + style={[ + styles.overlayOptionLabel, + isSelected + ? styles.overlayOptionLabelSelected + : styles.overlayOptionLabelUnselected, + ]} + > + {option.label} + </Text> + <Text style={styles.overlayOptionDescription}> + {option.description} + </Text> + </View> + </Pressable> + ); + }) + ) : ( + <Text style={styles.overlayEmptyText}> + {t('agentInput.model.configureInCli')} + </Text> + )} + </View> + </FloatingOverlay> + )} + </Popover> + )} + + {/* Connection status, context warning, and permission mode */} + {(props.connectionStatus || contextWarning) && ( + <View style={styles.statusContainer}> + <View style={styles.statusRow}> + {props.connectionStatus && ( + <> + <StatusDot + color={props.connectionStatus.dotColor} + isPulsing={props.connectionStatus.isPulsing} + size={6} + style={styles.statusDot} + /> + <Text style={[styles.statusText, { color: props.connectionStatus.color }]}> + {props.connectionStatus.text} + </Text> + </> + )} + {contextWarning && ( + <Text + style={[ + styles.statusText, + { + color: contextWarning.color, + marginLeft: props.connectionStatus ? 8 : 0, + }, + ]} + > + {props.connectionStatus ? '• ' : ''}{contextWarning.text} + </Text> + )} + </View> + <View style={styles.permissionModeContainer}> + {permissionChipLabel && ( + <Text + style={[ + styles.permissionModeText, + { + color: normalizedPermissionMode === 'acceptEdits' ? theme.colors.permission.acceptEdits : + normalizedPermissionMode === 'bypassPermissions' ? theme.colors.permission.bypass : + normalizedPermissionMode === 'plan' ? theme.colors.permission.plan : + normalizedPermissionMode === 'read-only' ? theme.colors.permission.readOnly : + normalizedPermissionMode === 'safe-yolo' ? theme.colors.permission.safeYolo : + normalizedPermissionMode === 'yolo' ? theme.colors.permission.yolo : + theme.colors.textSecondary, // Use secondary text color for default + }, + ]} + > + {permissionChipLabel} + </Text> + )} + </View> + </View> + )} + + {/* Box 2: Action Area (Input + Send) */} + <View style={[styles.unifiedPanel, props.panelStyle]}> + {/* Input field */} + <View style={[styles.inputContainer, props.minHeight ? { minHeight: props.minHeight } : undefined]}> + <MultiTextInput + ref={inputRef} + value={props.value} + paddingTop={Platform.OS === 'web' ? 10 : 8} + paddingBottom={Platform.OS === 'web' ? 10 : 8} + onChangeText={props.onChangeText} + placeholder={props.placeholder} + onKeyPress={handleKeyPress} + onStateChange={handleInputStateChange} + maxHeight={props.inputMaxHeight ?? defaultInputMaxHeight} + editable={!props.disabled} + /> + </View> + + {/* Action buttons below input */} + <View style={styles.actionButtonsContainer}> + <View style={screenWidth < 420 ? styles.actionButtonsColumnNarrow : styles.actionButtonsColumn}>{[ + // Row 1: Settings, Profile (FIRST), Agent, Abort, Git Status + <View key="row1" style={styles.actionButtonsRow}> + {(() => { + const chipStyle = (pressed: boolean) => ([ + styles.actionChip, + !showChipLabels ? styles.actionChipIconOnly : null, + pressed ? styles.actionChipPressed : null, + ]); + const extraChips = (props.extraActionChips ?? []).map((chip) => ( + <React.Fragment key={chip.key}> + {chip.render({ + chipStyle, + showLabel: showChipLabels, + iconColor: theme.colors.button.secondary.tint, + textStyle: styles.actionChipText, + })} + </React.Fragment> + )); + + const permissionOrControlsChip = (showPermissionChip || actionBarIsCollapsed) ? ( + <Pressable + key="permission" + onPress={() => { + hapticsLight(); + if (!actionBarIsCollapsed && props.onPermissionClick) { + props.onPermissionClick(); + return; + } + handleSettingsPress(); + }} + hitSlop={{ top: 5, bottom: 10, left: 0, right: 0 }} + style={(p) => chipStyle(p.pressed)} + > + <Octicons + name="gear" + size={16} + color={theme.colors.button.secondary.tint} + /> + {showChipLabels && permissionChipLabel ? ( + <Text style={styles.actionChipText}> + {permissionChipLabel} + </Text> + ) : null} + </Pressable> + ) : null; + + const profileChip = props.onProfileClick ? ( + <Pressable + key="profile" + onPress={() => { + hapticsLight(); + props.onProfileClick?.(); + }} + hitSlop={{ top: 5, bottom: 10, left: 0, right: 0 }} + style={(p) => chipStyle(p.pressed)} + > + <Ionicons + name={profileIcon as any} + size={16} + color={theme.colors.button.secondary.tint} + /> + {showChipLabels ? ( + <Text style={styles.actionChipText}> + {profileLabel ?? t('profiles.noProfile')} + </Text> + ) : null} + </Pressable> + ) : null; + + const envVarsChip = props.onEnvVarsClick ? ( + <Pressable + key="envVars" + onPress={() => { + hapticsLight(); + props.onEnvVarsClick?.(); + }} + hitSlop={{ top: 5, bottom: 10, left: 0, right: 0 }} + style={(p) => chipStyle(p.pressed)} + > + <Ionicons + name="list-outline" + size={16} + color={theme.colors.button.secondary.tint} + /> + {showChipLabels ? ( + <Text style={styles.actionChipText}> + {props.envVarsCount === undefined + ? t('agentInput.envVars.title') + : t('agentInput.envVars.titleWithCount', { count: props.envVarsCount })} + </Text> + ) : null} + </Pressable> + ) : null; + + const agentChip = (props.agentType && props.onAgentClick) ? ( + <Pressable + key="agent" + onPress={() => { + hapticsLight(); + props.onAgentClick?.(); + }} + hitSlop={{ top: 5, bottom: 10, left: 0, right: 0 }} + style={(p) => chipStyle(p.pressed)} + > + <Octicons + name="cpu" + size={16} + color={theme.colors.button.secondary.tint} + /> + {showChipLabels ? ( + <Text style={styles.actionChipText}> + {t(getAgentCore(props.agentType).displayNameKey)} + </Text> + ) : null} + </Pressable> + ) : null; + + const machineChip = ((props.machineName !== undefined) && props.onMachineClick) ? ( + <Pressable + key="machine" + onPress={() => { + hapticsLight(); + props.onMachineClick?.(); + }} + hitSlop={{ top: 5, bottom: 10, left: 0, right: 0 }} + style={(p) => chipStyle(p.pressed)} + > + <Ionicons + name="desktop-outline" + size={16} + color={theme.colors.button.secondary.tint} + /> + {showChipLabels ? ( + <Text style={styles.actionChipText}> + {props.machineName === null + ? t('agentInput.noMachinesAvailable') + : truncateWithEllipsis(props.machineName, 12)} + </Text> + ) : null} + </Pressable> + ) : null; + + const pathChip = (props.currentPath && props.onPathClick) ? ( + <Pressable + key="path" + onPress={() => { + hapticsLight(); + props.onPathClick?.(); + }} + hitSlop={{ top: 5, bottom: 10, left: 0, right: 0 }} + style={(p) => chipStyle(p.pressed)} + > + <Ionicons + name="folder-outline" + size={16} + color={theme.colors.button.secondary.tint} + /> + {showChipLabels ? ( + <Text style={styles.actionChipText}> + {props.currentPath} + </Text> + ) : null} + </Pressable> + ) : null; + + const resumeChip = props.onResumeClick ? ( + <ResumeChip + key="resume" + onPress={() => { + hapticsLight(); + inputRef.current?.blur(); + props.onResumeClick?.(); + }} + showLabel={showChipLabels} + resumeSessionId={props.resumeSessionId} + isChecking={props.resumeIsChecking === true} + labelTitle={t('newSession.resume.title')} + labelOptional={t('newSession.resume.optional')} + iconColor={theme.colors.button.secondary.tint} + pressableStyle={chipStyle} + textStyle={styles.actionChipText} + /> + ) : null; + + const abortButton = props.onAbort && !actionBarIsCollapsed ? ( + <Shaker key="abort" ref={shakerRef}> + <Pressable + style={(p) => [ + styles.actionButton, + p.pressed ? styles.actionButtonPressed : null, + ]} + hitSlop={{ top: 5, bottom: 10, left: 0, right: 0 }} + onPress={handleAbortPress} + disabled={isAborting} + > + {isAborting ? ( + <ActivityIndicator + size="small" + color={theme.colors.button.secondary.tint} + /> + ) : ( + <Octicons + name={"stop"} + size={16} + color={theme.colors.button.secondary.tint} + /> + )} + </Pressable> + </Shaker> + ) : null; + + const gitStatusChip = !actionBarIsCollapsed ? ( + <GitStatusButton + key="git" + sessionId={props.sessionId} + onPress={props.onFileViewerPress} + compact={actionBarShouldScroll || !showChipLabels} + /> + ) : null; + + const chips = actionBarIsCollapsed + ? [permissionOrControlsChip].filter(Boolean) + : [ + permissionOrControlsChip, + profileChip, + envVarsChip, + agentChip, + ...extraChips, + machineChip, + ...(actionBarShouldScroll ? [pathChip, resumeChip] : []), + abortButton, + gitStatusChip, + ].filter(Boolean); + + // IMPORTANT: We must always render the ScrollView in "scroll layout" mode, + // otherwise we never measure content/viewport widths and can't know whether + // scrolling is needed (deadlock). + if (actionBarShouldScroll) { + return ( + <View style={styles.actionButtonsLeftScroll}> + <ScrollView + horizontal + showsHorizontalScrollIndicator={false} + scrollEnabled={canActionBarScroll} + alwaysBounceHorizontal={false} + directionalLockEnabled + keyboardShouldPersistTaps="handled" + contentContainerStyle={styles.actionButtonsLeftScrollContent as any} + onLayout={actionBarFades.onViewportLayout} + onContentSizeChange={actionBarFades.onContentSizeChange} + onScroll={actionBarFades.onScroll} + scrollEventThrottle={16} + > + {chips as any} + </ScrollView> + <ScrollEdgeFades + color={actionBarFadeColor} + size={24} + edges={{ left: showActionBarFadeLeft, right: showActionBarFadeRight }} + leftStyle={styles.actionButtonsFadeLeft as any} + rightStyle={styles.actionButtonsFadeRight as any} + /> + <ScrollEdgeIndicators + edges={{ left: showActionBarFadeLeft, right: showActionBarFadeRight }} + color={theme.colors.button.secondary.tint} + size={14} + opacity={0.28} + // Keep indicators within the same fade gutters. + leftStyle={styles.actionButtonsFadeLeft as any} + rightStyle={styles.actionButtonsFadeRight as any} + /> + </View> + ); + } + + return ( + <View style={[styles.actionButtonsLeft, screenWidth < 420 ? styles.actionButtonsLeftNarrow : null]}> + {chips as any} + </View> + ); + })()} + + {/* Send/Voice button - aligned with first row */} + <View + style={[ + styles.sendButton, + (hasText || props.isSending || (props.onMicPress && !props.isMicActive)) + ? styles.sendButtonActive + : styles.sendButtonInactive + ]} + > + <Pressable + style={(p) => [ + styles.sendButtonInner, + p.pressed ? styles.sendButtonInnerPressed : null, + ]} + hitSlop={{ top: 5, bottom: 10, left: 0, right: 0 }} + onPress={() => { + hapticsLight(); + if (hasText) { + props.onSend(); + } else { + props.onMicPress?.(); + } + }} + disabled={props.disabled || props.isSendDisabled || props.isSending || (!hasText && !props.onMicPress)} + > + {props.isSending ? ( + <ActivityIndicator + size="small" + color={theme.colors.button.primary.tint} + /> + ) : hasText ? ( + <Octicons + name="arrow-up" + size={16} + color={theme.colors.button.primary.tint} + style={[ + styles.sendButtonIcon, + { marginTop: Platform.OS === 'web' ? 2 : 0 } + ]} + /> + ) : props.onMicPress && !props.isMicActive ? ( + <Image + source={require('@/assets/images/icon-voice-white.png')} + style={{ width: 24, height: 24 }} + tintColor={theme.colors.button.primary.tint} + /> + ) : ( + <Octicons + name="arrow-up" + size={16} + color={theme.colors.button.primary.tint} + style={[ + styles.sendButtonIcon, + { marginTop: Platform.OS === 'web' ? 2 : 0 } + ]} + /> + )} + </Pressable> + </View> + </View>, + + // Row 2: Path + Resume selectors (separate line to match pre-PR272 layout) + // - wrap: shown below + // - scroll: folds into row 1 + // - collapsed: moved into settings popover + (showPathAndResumeRow) ? ( + <PathAndResumeRow + key="row2" + styles={{ + pathRow: styles.pathRow, + actionButtonsLeft: styles.actionButtonsLeft, + actionChip: styles.actionChip, + actionChipIconOnly: styles.actionChipIconOnly, + actionChipPressed: styles.actionChipPressed, + actionChipText: styles.actionChipText, + }} + showChipLabels={showChipLabels} + iconColor={theme.colors.button.secondary.tint} + currentPath={props.currentPath} + onPathClick={props.onPathClick ? () => { + hapticsLight(); + props.onPathClick?.(); + } : undefined} + resumeSessionId={props.resumeSessionId} + onResumeClick={props.onResumeClick ? () => { + hapticsLight(); + inputRef.current?.blur(); + props.onResumeClick?.(); + } : undefined} + resumeLabelTitle={t('newSession.resume.title')} + resumeLabelOptional={t('newSession.resume.optional')} + /> + ) : null, + ]}</View> + </View> + </View> + </View> + </View> + ); +})); + +// Git Status Button Component +function GitStatusButton({ sessionId, onPress, compact }: { sessionId?: string, onPress?: () => void, compact?: boolean }) { + const hasMeaningfulGitStatus = useHasMeaningfulGitStatus(sessionId || ''); + const styles = stylesheet; + const { theme } = useUnistyles(); + + if (!sessionId || !onPress) { + return null; + } + + return ( + <Pressable + style={(p) => ({ + flexDirection: 'row', + alignItems: 'center', + borderRadius: Platform.select({ default: 16, android: 20 }), + paddingHorizontal: 8, + paddingVertical: 6, + height: 32, + opacity: p.pressed ? 0.7 : 1, + flex: compact ? 0 : 1, + overflow: 'hidden', + })} + hitSlop={{ top: 5, bottom: 10, left: 0, right: 0 }} + onPress={() => { + hapticsLight(); + onPress?.(); + }} + > + {hasMeaningfulGitStatus ? ( + <GitStatusBadge sessionId={sessionId} /> + ) : ( + <Octicons + name="git-branch" + size={16} + color={theme.colors.button.secondary.tint} + /> + )} + </Pressable> + ); +} diff --git a/expo-app/sources/components/sessions/agentInput/PathAndResumeRow.test.ts b/expo-app/sources/components/sessions/agentInput/PathAndResumeRow.test.ts new file mode 100644 index 000000000..3c9ffcbf9 --- /dev/null +++ b/expo-app/sources/components/sessions/agentInput/PathAndResumeRow.test.ts @@ -0,0 +1,62 @@ +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import renderer, { act, type ReactTestRenderer } from 'react-test-renderer'; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +vi.mock('react-native', () => ({ + Pressable: (props: any) => React.createElement('Pressable', props, props.children), + Text: (props: any) => React.createElement('Text', props, props.children), + View: (props: any) => React.createElement('View', props, props.children), +})); + +vi.mock('@expo/vector-icons', () => ({ + Ionicons: (props: any) => React.createElement('Ionicons', props, null), +})); + +vi.mock('./ResumeChip', () => ({ + ResumeChip: (props: any) => React.createElement('ResumeChip', props, null), +})); + +describe('PathAndResumeRow', () => { + it('does not let the path chip flex-grow (keeps chips left-aligned)', async () => { + const { PathAndResumeRow } = await import('./PathAndResumeRow'); + + const styles = { + pathRow: {}, + actionButtonsLeft: {}, + actionChip: {}, + actionChipIconOnly: {}, + actionChipPressed: {}, + actionChipText: {}, + }; + + let tree!: ReactTestRenderer; + act(() => { + tree = renderer.create( + React.createElement(PathAndResumeRow, { + styles, + showChipLabels: true, + iconColor: '#000', + currentPath: '/Users/leeroy/Development/happy-local', + onPathClick: () => {}, + resumeSessionId: null, + onResumeClick: () => {}, + resumeLabelTitle: 'Resume session', + resumeLabelOptional: 'Resume: Optional', + }), + ); + }); + + const pressables = tree.root.findAllByType('Pressable' as any) ?? []; + expect(pressables.length).toBe(1); + + const styleFn = pressables[0]?.props?.style; + expect(typeof styleFn).toBe('function'); + + const computed = styleFn({ pressed: false }); + const arr = Array.isArray(computed) ? computed : [computed]; + const hasFlexGrow1 = arr.some((v: any) => v && typeof v === 'object' && v.flexGrow === 1); + expect(hasFlexGrow1).toBe(false); + }); +}); diff --git a/expo-app/sources/components/sessions/agentInput/PathAndResumeRow.tsx b/expo-app/sources/components/sessions/agentInput/PathAndResumeRow.tsx new file mode 100644 index 000000000..e0dd6e0d8 --- /dev/null +++ b/expo-app/sources/components/sessions/agentInput/PathAndResumeRow.tsx @@ -0,0 +1,81 @@ +import { Ionicons } from '@expo/vector-icons'; +import * as React from 'react'; +import { Pressable, Text, View } from 'react-native'; +import { ResumeChip } from './ResumeChip'; + +export type PathAndResumeRowStyles = { + pathRow: any; + actionButtonsLeft: any; + actionChip: any; + actionChipIconOnly: any; + actionChipPressed: any; + actionChipText: any; +}; + +export type PathAndResumeRowProps = { + styles: PathAndResumeRowStyles; + showChipLabels: boolean; + iconColor: string; + currentPath?: string | null; + onPathClick?: () => void; + resumeSessionId?: string | null; + onResumeClick?: () => void; + resumeLabelTitle: string; + resumeLabelOptional: string; +}; + +export function PathAndResumeRow(props: PathAndResumeRowProps) { + const hasPath = Boolean(props.currentPath && props.onPathClick); + const hasResume = Boolean(props.onResumeClick); + if (!hasPath && !hasResume) return null; + + return ( + <View style={[props.styles.pathRow, { flex: 1, minWidth: 0 }]} testID="agentInput-pathResumeRow"> + <View style={[props.styles.actionButtonsLeft, { flex: 1, flexWrap: 'nowrap', minWidth: 0 }]}> + {hasPath ? ( + <Pressable + onPress={props.onPathClick} + hitSlop={{ top: 5, bottom: 10, left: 0, right: 0 }} + style={(p) => ([ + props.styles.actionChip, + p.pressed ? props.styles.actionChipPressed : null, + // Do not grow to fill the row; it should behave like other chips and stay left-aligned. + { flexShrink: 1, minWidth: 0 }, + ])} + > + <Ionicons + name="folder-outline" + size={16} + color={props.iconColor} + /> + <Text + numberOfLines={1} + ellipsizeMode="middle" + style={[props.styles.actionChipText, { flexShrink: 1 }]} + > + {props.currentPath} + </Text> + </Pressable> + ) : null} + + {hasResume ? ( + <ResumeChip + onPress={props.onResumeClick!} + showLabel={props.showChipLabels} + resumeSessionId={props.resumeSessionId} + labelTitle={props.resumeLabelTitle} + labelOptional={props.resumeLabelOptional} + iconColor={props.iconColor} + pressableStyle={(pressed) => ([ + props.styles.actionChip, + !props.showChipLabels ? props.styles.actionChipIconOnly : null, + pressed ? props.styles.actionChipPressed : null, + { flexShrink: 0 }, + ])} + textStyle={props.styles.actionChipText} + /> + ) : null} + </View> + </View> + ); +} diff --git a/expo-app/sources/components/sessions/agentInput/ResumeChip.tsx b/expo-app/sources/components/sessions/agentInput/ResumeChip.tsx new file mode 100644 index 000000000..583f993f0 --- /dev/null +++ b/expo-app/sources/components/sessions/agentInput/ResumeChip.tsx @@ -0,0 +1,69 @@ +import { Ionicons } from '@expo/vector-icons'; +import * as React from 'react'; +import { ActivityIndicator, Pressable, Text } from 'react-native'; +import { t } from '@/text'; + +export const RESUME_CHIP_ICON_NAME = 'refresh-outline' as const; +export const RESUME_CHIP_ICON_SIZE = 16 as const; + +export function formatResumeChipLabel(params: { + resumeSessionId: string | null | undefined; + labelTitle: string; + labelOptional: string; +}): string { + const id = typeof params.resumeSessionId === 'string' ? params.resumeSessionId.trim() : ''; + if (!id) return params.labelOptional; + + // Avoid overlap/duplication when the id is short. + if (id.length <= 20) return t('agentInput.resumeChip.withId', { title: params.labelTitle, id }); + + return t('agentInput.resumeChip.withIdTruncated', { title: params.labelTitle, prefix: id.slice(0, 8), suffix: id.slice(-8) }); +} + +export type ResumeChipProps = { + onPress: () => void; + showLabel: boolean; + resumeSessionId: string | null | undefined; + isChecking?: boolean; + labelTitle: string; + labelOptional: string; + iconColor: string; + pressableStyle: (pressed: boolean) => any; + textStyle: any; +}; + +export function ResumeChip(props: ResumeChipProps) { + const label = props.showLabel + ? formatResumeChipLabel({ + resumeSessionId: props.resumeSessionId, + labelTitle: props.labelTitle, + labelOptional: props.labelOptional, + }) + : null; + + return ( + <Pressable + onPress={props.onPress} + hitSlop={{ top: 5, bottom: 10, left: 0, right: 0 }} + style={(p) => props.pressableStyle(p.pressed)} + > + {props.isChecking ? ( + <ActivityIndicator + size="small" + color={props.iconColor} + /> + ) : ( + <Ionicons + name={RESUME_CHIP_ICON_NAME} + size={RESUME_CHIP_ICON_SIZE} + color={props.iconColor} + /> + )} + {label ? ( + <Text style={props.textStyle}> + {label} + </Text> + ) : null} + </Pressable> + ); +} diff --git a/expo-app/sources/components/sessions/agentInput/actionBarLogic.test.ts b/expo-app/sources/components/sessions/agentInput/actionBarLogic.test.ts new file mode 100644 index 000000000..818134caa --- /dev/null +++ b/expo-app/sources/components/sessions/agentInput/actionBarLogic.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import { getHasAnyAgentInputActions, shouldShowPathAndResumeRow } from './actionBarLogic'; + +describe('agentInput/actionBarLogic', () => { + it('shows the path+resume row only in wrap mode', () => { + expect(shouldShowPathAndResumeRow('wrap')).toBe(true); + expect(shouldShowPathAndResumeRow('scroll')).toBe(false); + expect(shouldShowPathAndResumeRow('collapsed')).toBe(false); + }); + + it('treats resume as an action (prevents collapsed menu from being empty)', () => { + expect(getHasAnyAgentInputActions({ + showPermissionChip: false, + hasProfile: false, + hasEnvVars: false, + hasAgent: false, + hasMachine: false, + hasPath: false, + hasResume: true, + hasFiles: false, + hasStop: false, + })).toBe(true); + }); + + it('returns false when there are no actions', () => { + expect(getHasAnyAgentInputActions({ + showPermissionChip: false, + hasProfile: false, + hasEnvVars: false, + hasAgent: false, + hasMachine: false, + hasPath: false, + hasResume: false, + hasFiles: false, + hasStop: false, + })).toBe(false); + }); +}); + diff --git a/expo-app/sources/components/sessions/agentInput/actionBarLogic.ts b/expo-app/sources/components/sessions/agentInput/actionBarLogic.ts new file mode 100644 index 000000000..e3ab2e367 --- /dev/null +++ b/expo-app/sources/components/sessions/agentInput/actionBarLogic.ts @@ -0,0 +1,34 @@ +export type AgentInputActionBarLayout = 'wrap' | 'scroll' | 'collapsed'; + +export type AgentInputActionBarActionFlags = Readonly<{ + showPermissionChip: boolean; + hasProfile: boolean; + hasEnvVars: boolean; + hasAgent: boolean; + hasMachine: boolean; + hasPath: boolean; + hasResume: boolean; + hasFiles: boolean; + hasStop: boolean; +}>; + +export function getHasAnyAgentInputActions(flags: AgentInputActionBarActionFlags): boolean { + return Boolean( + flags.showPermissionChip || + flags.hasProfile || + flags.hasEnvVars || + flags.hasAgent || + flags.hasMachine || + flags.hasPath || + flags.hasResume || + flags.hasFiles || + flags.hasStop + ); +} + +export function shouldShowPathAndResumeRow(actionBarLayout: AgentInputActionBarLayout): boolean { + // Path/Resume live on a separate row only in the "wrap" action bar layout. + // In "scroll" they fold into the first row; in "collapsed" they move into the popover menu. + return actionBarLayout === 'wrap'; +} + diff --git a/expo-app/sources/components/sessions/agentInput/actionMenuActions.tsx b/expo-app/sources/components/sessions/agentInput/actionMenuActions.tsx new file mode 100644 index 000000000..644529e83 --- /dev/null +++ b/expo-app/sources/components/sessions/agentInput/actionMenuActions.tsx @@ -0,0 +1,151 @@ +import { Ionicons, Octicons } from '@expo/vector-icons'; +import * as React from 'react'; +import { t } from '@/text'; +import type { AgentId } from '@/agents/catalog'; +import { getAgentCore } from '@/agents/catalog'; +import type { ActionListItem } from '@/components/ui/lists/ActionListSection'; +import { hapticsLight } from '@/components/haptics'; +import { formatResumeChipLabel, RESUME_CHIP_ICON_NAME, RESUME_CHIP_ICON_SIZE } from './ResumeChip'; + +export function buildAgentInputActionMenuActions(opts: { + actionBarIsCollapsed: boolean; + hasAnyActions: boolean; + tint: string; + agentId: AgentId; + profileLabel: string | null; + profileIcon: string; + envVarsCount?: number; + agentType?: AgentId; + machineName?: string | null; + currentPath?: string | null; + resumeSessionId?: string | null; + sessionId?: string; + onProfileClick?: () => void; + onEnvVarsClick?: () => void; + onAgentClick?: () => void; + onMachineClick?: () => void; + onPathClick?: () => void; + onResumeClick?: () => void; + onFileViewerPress?: () => void; + canStop?: boolean; + onStop?: () => void; + dismiss: () => void; + blurInput: () => void; +}): ActionListItem[] { + if (!opts.actionBarIsCollapsed || !opts.hasAnyActions) return [] as ActionListItem[]; + + const actions: ActionListItem[] = []; + + if (opts.onProfileClick) { + actions.push({ + id: 'profile', + label: opts.profileLabel ?? t('profiles.noProfile'), + icon: <Ionicons name={opts.profileIcon as any} size={16} color={opts.tint} />, + onPress: () => { + hapticsLight(); + opts.dismiss(); + opts.onProfileClick?.(); + }, + }); + } + + if (opts.onEnvVarsClick) { + actions.push({ + id: 'env-vars', + label: + opts.envVarsCount === undefined + ? t('agentInput.envVars.title') + : t('agentInput.envVars.titleWithCount', { count: opts.envVarsCount }), + icon: <Ionicons name="list-outline" size={16} color={opts.tint} />, + onPress: () => { + hapticsLight(); + opts.dismiss(); + opts.onEnvVarsClick?.(); + }, + }); + } + + if (opts.agentType && opts.onAgentClick) { + actions.push({ + id: 'agent', + label: t(getAgentCore(opts.agentId).displayNameKey), + icon: <Octicons name="cpu" size={16} color={opts.tint} />, + onPress: () => { + hapticsLight(); + opts.dismiss(); + opts.onAgentClick?.(); + }, + }); + } + + if (opts.machineName !== undefined && opts.onMachineClick) { + actions.push({ + id: 'machine', + label: opts.machineName === null ? t('agentInput.noMachinesAvailable') : opts.machineName, + icon: <Ionicons name="desktop-outline" size={16} color={opts.tint} />, + onPress: () => { + hapticsLight(); + opts.dismiss(); + opts.onMachineClick?.(); + }, + }); + } + + if (opts.currentPath && opts.onPathClick) { + actions.push({ + id: 'path', + label: opts.currentPath, + icon: <Ionicons name="folder-outline" size={16} color={opts.tint} />, + onPress: () => { + hapticsLight(); + opts.dismiss(); + opts.onPathClick?.(); + }, + }); + } + + if (opts.onResumeClick) { + actions.push({ + id: 'resume', + label: formatResumeChipLabel({ + resumeSessionId: opts.resumeSessionId, + labelTitle: t('newSession.resume.title'), + labelOptional: t('newSession.resume.optional'), + }), + icon: <Ionicons name={RESUME_CHIP_ICON_NAME} size={RESUME_CHIP_ICON_SIZE} color={opts.tint} />, + onPress: () => { + hapticsLight(); + opts.dismiss(); + opts.blurInput(); + opts.onResumeClick?.(); + }, + }); + } + + if (opts.sessionId && opts.onFileViewerPress) { + actions.push({ + id: 'files', + label: t('agentInput.actionMenu.files'), + icon: <Octicons name="git-branch" size={16} color={opts.tint} />, + onPress: () => { + hapticsLight(); + opts.dismiss(); + opts.onFileViewerPress?.(); + }, + }); + } + + if (opts.canStop && opts.onStop) { + actions.push({ + id: 'stop', + label: t('agentInput.actionMenu.stop'), + icon: <Octicons name="stop" size={16} color={opts.tint} />, + onPress: () => { + opts.dismiss(); + opts.onStop?.(); + }, + }); + } + + return actions; +} diff --git a/expo-app/sources/components/sessions/agentInput/components/AgentInputAutocomplete.test.ts b/expo-app/sources/components/sessions/agentInput/components/AgentInputAutocomplete.test.ts new file mode 100644 index 000000000..8cc99bbeb --- /dev/null +++ b/expo-app/sources/components/sessions/agentInput/components/AgentInputAutocomplete.test.ts @@ -0,0 +1,90 @@ +import React from 'react'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import renderer, { act } from 'react-test-renderer'; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +let lastFloatingOverlayProps: any = null; + +vi.mock('react-native', () => ({ + Pressable: 'Pressable', +})); + +vi.mock('react-native-unistyles', () => ({ + useUnistyles: () => ({ + theme: { colors: { surfacePressed: '#eee', surfaceSelected: '#ddd' } }, + }), +})); + +vi.mock('@/components/FloatingOverlay', () => { + const React = require('react'); + return { + FloatingOverlay: (props: any) => { + lastFloatingOverlayProps = props; + return React.createElement('FloatingOverlay', props, props.children); + }, + }; +}); + +describe('AgentInputAutocomplete', () => { + beforeEach(() => { + lastFloatingOverlayProps = null; + }); + + it('returns null when suggestions are empty', async () => { + const { AgentInputAutocomplete } = await import('./AgentInputAutocomplete'); + let tree: ReturnType<typeof renderer.create> | null = null; + act(() => { + tree = renderer.create( + React.createElement(AgentInputAutocomplete, { + suggestions: [], + onSelect: () => {}, + itemHeight: 48, + }), + ); + }); + expect(tree).not.toBeNull(); + expect(tree!.toJSON()).toBe(null); + }); + + it('passes maxHeight through to FloatingOverlay', async () => { + const { AgentInputAutocomplete } = await import('./AgentInputAutocomplete'); + act(() => { + renderer.create( + React.createElement(AgentInputAutocomplete, { + suggestions: [React.createElement('Suggestion', { key: 's1' })], + onSelect: () => {}, + itemHeight: 48, + maxHeight: 123, + }), + ); + }); + expect(lastFloatingOverlayProps?.maxHeight).toBe(123); + }); + + it('calls onSelect with the pressed index', async () => { + const { AgentInputAutocomplete } = await import('./AgentInputAutocomplete'); + const onSelect = vi.fn(); + let tree: ReturnType<typeof renderer.create> | undefined; + act(() => { + tree = renderer.create( + React.createElement(AgentInputAutocomplete, { + suggestions: [ + React.createElement('Suggestion', { key: 's1' }), + React.createElement('Suggestion', { key: 's2' }), + ], + onSelect, + itemHeight: 48, + }), + ); + }); + + const pressables = tree?.root.findAllByType('Pressable' as any) ?? []; + expect(pressables.length).toBe(2); + act(() => { + pressables[1]?.props?.onPress?.(); + }); + expect(onSelect).toHaveBeenCalledTimes(1); + expect(onSelect).toHaveBeenCalledWith(1); + }); +}); diff --git a/expo-app/sources/components/AgentInputAutocomplete.tsx b/expo-app/sources/components/sessions/agentInput/components/AgentInputAutocomplete.tsx similarity index 85% rename from expo-app/sources/components/AgentInputAutocomplete.tsx rename to expo-app/sources/components/sessions/agentInput/components/AgentInputAutocomplete.tsx index 18a7fec2f..1c296e726 100644 --- a/expo-app/sources/components/AgentInputAutocomplete.tsx +++ b/expo-app/sources/components/sessions/agentInput/components/AgentInputAutocomplete.tsx @@ -1,17 +1,18 @@ import * as React from 'react'; import { Pressable } from 'react-native'; import { useUnistyles } from 'react-native-unistyles'; -import { FloatingOverlay } from './FloatingOverlay'; +import { FloatingOverlay } from '@/components/FloatingOverlay'; interface AgentInputAutocompleteProps { suggestions: React.ReactElement[]; selectedIndex?: number; onSelect: (index: number) => void; itemHeight: number; + maxHeight?: number; } export const AgentInputAutocomplete = React.memo((props: AgentInputAutocompleteProps) => { - const { suggestions, selectedIndex = -1, onSelect, itemHeight } = props; + const { suggestions, selectedIndex = -1, onSelect, itemHeight, maxHeight = 240 } = props; const { theme } = useUnistyles(); if (suggestions.length === 0) { @@ -19,7 +20,7 @@ export const AgentInputAutocomplete = React.memo((props: AgentInputAutocompleteP } return ( - <FloatingOverlay maxHeight={240} keyboardShouldPersistTaps="handled"> + <FloatingOverlay maxHeight={maxHeight} keyboardShouldPersistTaps="handled"> {suggestions.map((suggestion, index) => ( <Pressable key={index} @@ -38,4 +39,4 @@ export const AgentInputAutocomplete = React.memo((props: AgentInputAutocompleteP ))} </FloatingOverlay> ); -}); \ No newline at end of file +}); diff --git a/expo-app/sources/components/AgentInputSuggestionView.tsx b/expo-app/sources/components/sessions/agentInput/components/AgentInputSuggestionView.tsx similarity index 100% rename from expo-app/sources/components/AgentInputSuggestionView.tsx rename to expo-app/sources/components/sessions/agentInput/components/AgentInputSuggestionView.tsx diff --git a/expo-app/sources/components/sessions/agentInput/contextWarning.ts b/expo-app/sources/components/sessions/agentInput/contextWarning.ts new file mode 100644 index 000000000..3800377da --- /dev/null +++ b/expo-app/sources/components/sessions/agentInput/contextWarning.ts @@ -0,0 +1,20 @@ +import type { Theme } from '@/theme'; +import { t } from '@/text'; + +const MAX_CONTEXT_SIZE = 190000; + +export function getContextWarning(contextSize: number, alwaysShow: boolean = false, theme: Theme) { + const percentageUsed = (contextSize / MAX_CONTEXT_SIZE) * 100; + const percentageRemaining = Math.max(0, Math.min(100, 100 - percentageUsed)); + + if (percentageRemaining <= 5) { + return { text: t('agentInput.context.remaining', { percent: Math.round(percentageRemaining) }), color: theme.colors.warningCritical }; + } else if (percentageRemaining <= 10) { + return { text: t('agentInput.context.remaining', { percent: Math.round(percentageRemaining) }), color: theme.colors.warning }; + } else if (alwaysShow) { + // Show context remaining in neutral color when not near limit + return { text: t('agentInput.context.remaining', { percent: Math.round(percentageRemaining) }), color: theme.colors.warning }; + } + return null; // No display needed +} + diff --git a/expo-app/sources/components/sessions/agentInput/index.ts b/expo-app/sources/components/sessions/agentInput/index.ts new file mode 100644 index 000000000..715f06ecf --- /dev/null +++ b/expo-app/sources/components/sessions/agentInput/index.ts @@ -0,0 +1 @@ +export * from './AgentInput'; diff --git a/expo-app/sources/components/sessions/agentInput/inputMaxHeight.test.ts b/expo-app/sources/components/sessions/agentInput/inputMaxHeight.test.ts new file mode 100644 index 000000000..9a408949d --- /dev/null +++ b/expo-app/sources/components/sessions/agentInput/inputMaxHeight.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { computeAgentInputDefaultMaxHeight, computeNewSessionInputMaxHeight } from './inputMaxHeight'; + +describe('inputMaxHeight', () => { + it('reduces default max height when keyboard is open (native)', () => { + const closed = computeAgentInputDefaultMaxHeight({ platform: 'ios', screenHeight: 800, keyboardHeight: 0 }); + const open = computeAgentInputDefaultMaxHeight({ platform: 'ios', screenHeight: 800, keyboardHeight: 300 }); + expect(open).toBeLessThan(closed); + }); + + it('reduces default max height when keyboard is open (web)', () => { + const closed = computeAgentInputDefaultMaxHeight({ platform: 'web', screenHeight: 900, keyboardHeight: 0 }); + const open = computeAgentInputDefaultMaxHeight({ platform: 'web', screenHeight: 900, keyboardHeight: 400 }); + expect(open).toBeLessThan(closed); + }); + + it('allocates less space to the input when enhanced wizard is enabled', () => { + const simple = computeNewSessionInputMaxHeight({ useEnhancedSessionWizard: false, screenHeight: 900, keyboardHeight: 0 }); + const wizard = computeNewSessionInputMaxHeight({ useEnhancedSessionWizard: true, screenHeight: 900, keyboardHeight: 0 }); + expect(wizard).toBeLessThan(simple); + }); + + it('caps /new input more aggressively when keyboard is open (simple)', () => { + const closed = computeNewSessionInputMaxHeight({ useEnhancedSessionWizard: false, screenHeight: 900, keyboardHeight: 0 }); + const open = computeNewSessionInputMaxHeight({ useEnhancedSessionWizard: false, screenHeight: 900, keyboardHeight: 400 }); + expect(open).toBeLessThan(closed); + expect(open).toBeLessThanOrEqual(360); + }); + + it('keeps /new wizard input cap when keyboard is open', () => { + const open = computeNewSessionInputMaxHeight({ useEnhancedSessionWizard: true, screenHeight: 900, keyboardHeight: 400 }); + expect(open).toBeLessThanOrEqual(240); + }); +}); diff --git a/expo-app/sources/components/sessions/agentInput/inputMaxHeight.ts b/expo-app/sources/components/sessions/agentInput/inputMaxHeight.ts new file mode 100644 index 000000000..fa4467d07 --- /dev/null +++ b/expo-app/sources/components/sessions/agentInput/inputMaxHeight.ts @@ -0,0 +1,41 @@ +export function clampNumber(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} + +export function computeAvailableHeight(screenHeight: number, keyboardHeight: number): number { + const safeScreen = Number.isFinite(screenHeight) ? screenHeight : 0; + const safeKeyboard = Number.isFinite(keyboardHeight) ? keyboardHeight : 0; + return Math.max(0, safeScreen - safeKeyboard); +} + +export function computeAgentInputDefaultMaxHeight(params: { + platform: string; + screenHeight: number; + keyboardHeight: number; +}): number { + const available = computeAvailableHeight(params.screenHeight, params.keyboardHeight); + if (params.platform === 'web') { + return clampNumber(Math.round(available * 0.75), 200, 900); + } + return clampNumber(Math.round(available * 0.4), 120, 360); +} + +export function computeNewSessionInputMaxHeight(params: { + useEnhancedSessionWizard: boolean; + screenHeight: number; + keyboardHeight: number; +}): number { + const available = computeAvailableHeight(params.screenHeight, params.keyboardHeight); + const keyboardVisible = params.keyboardHeight > 0; + const ratio = params.useEnhancedSessionWizard + ? 0.25 + : keyboardVisible + ? 0.5 + : 0.75; + const cap = params.useEnhancedSessionWizard + ? 240 + : keyboardVisible + ? 360 + : 900; + return clampNumber(Math.round(available * ratio), 120, cap); +} diff --git a/expo-app/sources/components/sessions/chatListItems.test.ts b/expo-app/sources/components/sessions/chatListItems.test.ts new file mode 100644 index 000000000..4209057fc --- /dev/null +++ b/expo-app/sources/components/sessions/chatListItems.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; +import type { PendingMessage } from '@/sync/storageTypes'; +import type { Message } from '@/sync/typesMessage'; +import { buildChatListItems } from './chatListItems'; + +describe('buildChatListItems', () => { + it('prepends pending messages before transcript messages', () => { + const messages: Message[] = [ + { kind: 'agent-text', id: 'm2', localId: null, createdAt: 2, text: 'agent' }, + { kind: 'user-text', id: 'm1', localId: 'u1', createdAt: 1, text: 'user' }, + ]; + const pending: PendingMessage[] = [ + { id: 'p1', localId: 'p1', createdAt: 10, updatedAt: 10, text: 'pending 1', rawRecord: {} as any }, + { id: 'p2', localId: 'p2', createdAt: 11, updatedAt: 11, text: 'pending 2', rawRecord: {} as any }, + ]; + + const items = buildChatListItems({ messages, pendingMessages: pending }); + + expect(items.map((i) => i.kind)).toEqual(['pending-user-text', 'pending-user-text', 'message', 'message']); + expect(items[0]?.kind === 'pending-user-text' && items[0].pending.localId).toBe('p1'); + expect(items[1]?.kind === 'pending-user-text' && items[1].pending.localId).toBe('p2'); + expect(items[2]?.kind === 'message' && items[2].message.id).toBe('m2'); + expect(items[3]?.kind === 'message' && items[3].message.id).toBe('m1'); + }); + + it('drops pending messages that are already materialized in the transcript', () => { + const messages: Message[] = [ + { kind: 'user-text', id: 'm1', localId: 'p1', createdAt: 20, text: 'materialized' }, + ]; + const pending: PendingMessage[] = [ + { id: 'p1', localId: 'p1', createdAt: 10, updatedAt: 10, text: 'pending 1', rawRecord: {} as any }, + { id: 'p2', localId: 'p2', createdAt: 11, updatedAt: 11, text: 'pending 2', rawRecord: {} as any }, + ]; + + const items = buildChatListItems({ messages, pendingMessages: pending }); + + expect(items.map((i) => (i.kind === 'pending-user-text' ? i.pending.localId : i.message.id))).toEqual(['p2', 'm1']); + }); + + it('sets otherPendingCount only for the next pending message', () => { + const messages: Message[] = []; + const pending: PendingMessage[] = [ + { id: 'p1', localId: 'p1', createdAt: 10, updatedAt: 10, text: 'pending 1', rawRecord: {} as any }, + { id: 'p2', localId: 'p2', createdAt: 11, updatedAt: 11, text: 'pending 2', rawRecord: {} as any }, + { id: 'p3', localId: 'p3', createdAt: 12, updatedAt: 12, text: 'pending 3', rawRecord: {} as any }, + ]; + + const items = buildChatListItems({ messages, pendingMessages: pending }); + + expect(items[0]?.kind === 'pending-user-text' && items[0].otherPendingCount).toBe(2); + expect(items[1]?.kind === 'pending-user-text' && items[1].otherPendingCount).toBe(0); + expect(items[2]?.kind === 'pending-user-text' && items[2].otherPendingCount).toBe(0); + }); +}); + diff --git a/expo-app/sources/components/sessions/chatListItems.ts b/expo-app/sources/components/sessions/chatListItems.ts new file mode 100644 index 000000000..38e424000 --- /dev/null +++ b/expo-app/sources/components/sessions/chatListItems.ts @@ -0,0 +1,54 @@ +import type { PendingMessage } from '@/sync/storageTypes'; +import type { Message } from '@/sync/typesMessage'; + +export type ChatListItem = + | { + kind: 'message'; + id: string; + message: Message; + } + | { + kind: 'pending-user-text'; + id: string; + pending: PendingMessage; + otherPendingCount: number; + }; + +export function buildChatListItems(opts: { + messages: Message[]; + pendingMessages: PendingMessage[]; +}): ChatListItem[] { + const localIdsInTranscript = new Set<string>(); + for (const m of opts.messages) { + if ('localId' in m && m.localId) { + localIdsInTranscript.add(m.localId); + } + } + + const pending = opts.pendingMessages.filter((p) => !p.localId || !localIdsInTranscript.has(p.localId)); + const items: ChatListItem[] = []; + + for (let i = 0; i < pending.length; i++) { + const p = pending[i]!; + const pendingId = + typeof p.localId === 'string' && p.localId.length > 0 + ? p.localId + : `fallback-${i}`; + items.push({ + kind: 'pending-user-text', + id: `pending:${pendingId}`, + pending: p, + otherPendingCount: i === 0 ? Math.max(0, pending.length - 1) : 0, + }); + } + + for (const m of opts.messages) { + items.push({ + kind: 'message', + id: m.id, + message: m, + }); + } + + return items; +} diff --git a/expo-app/sources/components/sessions/new/components/CliNotDetectedBanner.tsx b/expo-app/sources/components/sessions/new/components/CliNotDetectedBanner.tsx new file mode 100644 index 000000000..2981f03e1 --- /dev/null +++ b/expo-app/sources/components/sessions/new/components/CliNotDetectedBanner.tsx @@ -0,0 +1,103 @@ +import * as React from 'react'; +import { Linking, Platform, Pressable, Text, View } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; + +import { Typography } from '@/constants/Typography'; +import { t } from '@/text'; +import { getAgentCore, type AgentId } from '@/agents/catalog'; + +export type CliNotDetectedBannerDismissScope = 'machine' | 'global' | 'temporary'; + +export function CliNotDetectedBanner(props: { + agentId: AgentId; + theme: any; + onDismiss: (scope: CliNotDetectedBannerDismissScope) => void; +}) { + const core = getAgentCore(props.agentId); + const cliLabel = t(core.displayNameKey); + const guideUrl = core.cli.installBanner.guideUrl; + + return ( + <View style={{ + backgroundColor: props.theme.colors.box.warning.background, + borderRadius: 10, + padding: 12, + marginBottom: 12, + borderWidth: 1, + borderColor: props.theme.colors.box.warning.border, + }}> + <View style={{ flexDirection: 'row', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 6 }}> + <View style={{ flex: 1, flexDirection: 'row', alignItems: 'center', flexWrap: 'wrap', gap: 6, marginRight: 16 }}> + <Ionicons name="warning" size={16} color={props.theme.colors.warning} /> + <Text style={{ fontSize: 13, fontWeight: '600', color: props.theme.colors.text, ...Typography.default('semiBold') }}> + {t('newSession.cliBanners.cliNotDetectedTitle', { cli: cliLabel })} + </Text> + <View style={{ flex: 1, minWidth: 20 }} /> + <Text style={{ fontSize: 10, color: props.theme.colors.textSecondary, ...Typography.default() }}> + {t('newSession.cliBanners.dontShowFor')} + </Text> + <Pressable + onPress={() => props.onDismiss('machine')} + style={{ + borderRadius: 4, + borderWidth: 1, + borderColor: props.theme.colors.textSecondary, + paddingHorizontal: 8, + paddingVertical: 3, + }} + > + <Text style={{ fontSize: 10, color: props.theme.colors.textSecondary, ...Typography.default() }}> + {t('newSession.cliBanners.thisMachine')} + </Text> + </Pressable> + <Pressable + onPress={() => props.onDismiss('global')} + style={{ + borderRadius: 4, + borderWidth: 1, + borderColor: props.theme.colors.textSecondary, + paddingHorizontal: 8, + paddingVertical: 3, + }} + > + <Text style={{ fontSize: 10, color: props.theme.colors.textSecondary, ...Typography.default() }}> + {t('newSession.cliBanners.anyMachine')} + </Text> + </Pressable> + </View> + <Pressable + onPress={() => props.onDismiss('temporary')} + hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} + > + <Ionicons name="close" size={18} color={props.theme.colors.textSecondary} /> + </Pressable> + </View> + + <View style={{ flexDirection: 'row', alignItems: 'center', flexWrap: 'wrap', gap: 4 }}> + {core.cli.installBanner.installKind === 'command' ? ( + <Text style={{ fontSize: 11, color: props.theme.colors.textSecondary, ...Typography.default() }}> + {t('newSession.cliBanners.installCommand', { command: core.cli.installBanner.installCommand ?? '' })} + </Text> + ) : ( + <Text style={{ fontSize: 11, color: props.theme.colors.textSecondary, ...Typography.default() }}> + {t('newSession.cliBanners.installCliIfAvailable', { cli: cliLabel })} + </Text> + )} + + {guideUrl ? ( + <Pressable onPress={() => { + if (Platform.OS === 'web') { + window.open(guideUrl, '_blank'); + } else { + void Linking.openURL(guideUrl).catch(() => {}); + } + }}> + <Text style={{ fontSize: 11, color: props.theme.colors.textLink, ...Typography.default() }}> + {t('newSession.cliBanners.viewInstallationGuide')} + </Text> + </Pressable> + ) : null} + </View> + </View> + ); +} diff --git a/expo-app/sources/components/sessions/new/components/EnvironmentVariablesPreviewModal.tsx b/expo-app/sources/components/sessions/new/components/EnvironmentVariablesPreviewModal.tsx new file mode 100644 index 000000000..15f7ae055 --- /dev/null +++ b/expo-app/sources/components/sessions/new/components/EnvironmentVariablesPreviewModal.tsx @@ -0,0 +1,316 @@ +import React from 'react'; +import { View, Text, ScrollView, Pressable, Platform, useWindowDimensions } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { StyleSheet, useUnistyles } from 'react-native-unistyles'; +import { Typography } from '@/constants/Typography'; +import { ItemGroup } from '@/components/ui/lists/ItemGroup'; +import { Item } from '@/components/ui/lists/Item'; +import { useEnvironmentVariables } from '@/hooks/useEnvironmentVariables'; +import { t } from '@/text'; +import { formatEnvVarTemplate, parseEnvVarTemplate } from '@/utils/profiles/envVarTemplate'; + +export interface EnvironmentVariablesPreviewModalProps { + environmentVariables: Record<string, string>; + machineId: string | null; + machineName?: string | null; + profileName?: string | null; + onClose: () => void; +} + +function isSecretLike(name: string) { + return /TOKEN|KEY|SECRET|AUTH|PASS|PASSWORD|COOKIE/i.test(name); +} + +const ENV_VAR_TEMPLATE_REF_REGEX = /\$\{([A-Z_][A-Z0-9_]*)(?::[-=][^}]*)?\}/g; + +function extractVarRefsFromValue(value: string): string[] { + const refs: string[] = []; + if (!value) return refs; + ENV_VAR_TEMPLATE_REF_REGEX.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = ENV_VAR_TEMPLATE_REF_REGEX.exec(value)) !== null) { + const name = match[1]; + if (name) refs.push(name); + } + return refs; +} + +const stylesheet = StyleSheet.create((theme, runtime) => ({ + container: { + width: '92%', + maxWidth: 560, + backgroundColor: theme.colors.groupped.background, + borderRadius: 16, + overflow: 'hidden', + borderWidth: 1, + borderColor: theme.colors.divider, + flexShrink: 1, + }, + header: { + paddingHorizontal: 16, + paddingVertical: 12, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + borderBottomWidth: 1, + borderBottomColor: theme.colors.divider, + }, + headerTitle: { + fontSize: 17, + color: theme.colors.text, + ...Typography.default('semiBold'), + }, + scroll: { + flex: 1, + }, + scrollContent: { + paddingBottom: 16, + flexGrow: 1, + }, + section: { + paddingHorizontal: 16, + paddingTop: 12, + }, + descriptionText: { + color: theme.colors.textSecondary, + fontSize: Platform.select({ ios: 15, default: 14 }), + lineHeight: 20, + letterSpacing: Platform.select({ ios: -0.24, default: 0.1 }), + ...Typography.default(), + }, + machineNameText: { + color: theme.colors.status.connected, + ...Typography.default('semiBold'), + }, + detailText: { + fontSize: 13, + ...Typography.default('semiBold'), + }, +})); + +export function EnvironmentVariablesPreviewModal(props: EnvironmentVariablesPreviewModalProps) { + const { theme } = useUnistyles(); + const styles = stylesheet; + const { height: windowHeight } = useWindowDimensions(); + const scrollRef = React.useRef<ScrollView>(null); + const scrollYRef = React.useRef(0); + + const handleScroll = React.useCallback((e: any) => { + scrollYRef.current = e?.nativeEvent?.contentOffset?.y ?? 0; + }, []); + + // On web, RN ScrollView inside a modal doesn't reliably respond to mouse wheel / trackpad scroll. + // Manually translate wheel deltas into scrollTo. + const handleWheel = React.useCallback((e: any) => { + if (Platform.OS !== 'web') return; + const deltaY = e?.deltaY; + if (typeof deltaY !== 'number' || Number.isNaN(deltaY)) return; + + if (e?.cancelable) { + e?.preventDefault?.(); + } + e?.stopPropagation?.(); + scrollRef.current?.scrollTo({ y: Math.max(0, scrollYRef.current + deltaY), animated: false }); + }, []); + + const envVarEntries = React.useMemo(() => { + return Object.entries(props.environmentVariables) + .map(([name, value]) => ({ name, value })) + .sort((a, b) => a.name.localeCompare(b.name)); + }, [props.environmentVariables]); + + const refsToQuery = React.useMemo(() => { + const refs = new Set<string>(); + envVarEntries.forEach((envVar) => { + // Query both target keys and any referenced keys so preview can show the effective spawned value. + refs.add(envVar.name); + extractVarRefsFromValue(envVar.value).forEach((ref) => refs.add(ref)); + }); + return Array.from(refs); + }, [envVarEntries]); + + const sensitiveKeys = React.useMemo(() => { + const keys = new Set<string>(); + envVarEntries.forEach((envVar) => { + const refs = extractVarRefsFromValue(envVar.value); + const isSensitive = isSecretLike(envVar.name) || refs.some(isSecretLike); + if (isSensitive) { + keys.add(envVar.name); + refs.forEach((ref) => { keys.add(ref); }); + } + }); + return Array.from(keys); + }, [envVarEntries]); + + const { meta: machineEnv, policy: machineEnvPolicy } = useEnvironmentVariables( + props.machineId, + refsToQuery, + { extraEnv: props.environmentVariables, sensitiveKeys }, + ); + + const title = props.profileName + ? t('profiles.environmentVariables.previewModal.titleWithProfile', { profileName: props.profileName }) + : t('profiles.environmentVariables.title'); + const maxHeight = Math.min(720, Math.max(360, Math.floor(windowHeight * 0.85))); + const emptyValue = t('profiles.environmentVariables.preview.emptyValue'); + + return ( + <View + style={[styles.container, { height: maxHeight, maxHeight }]} + {...(Platform.OS === 'web' ? ({ onWheel: handleWheel } as any) : {})} + > + <View style={styles.header}> + <Text style={styles.headerTitle}> + {title} + </Text> + + <Pressable + onPress={props.onClose} + hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} + style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })} + > + <Ionicons name="close" size={20} color={theme.colors.textSecondary} /> + </Pressable> + </View> + + <ScrollView + ref={scrollRef} + style={styles.scroll} + contentContainerStyle={styles.scrollContent} + showsVerticalScrollIndicator + nestedScrollEnabled + keyboardShouldPersistTaps="handled" + onScroll={handleScroll} + scrollEventThrottle={16} + > + <View style={styles.section}> + <Text style={styles.descriptionText}> + {t('profiles.environmentVariables.previewModal.descriptionPrefix')}{' '} + {props.machineName ? ( + <Text style={styles.machineNameText}> + {props.machineName} + </Text> + ) : ( + t('profiles.environmentVariables.previewModal.descriptionFallbackMachine') + )} + {t('profiles.environmentVariables.previewModal.descriptionSuffix')} + </Text> + </View> + + {envVarEntries.length === 0 ? ( + <View style={styles.section}> + <Text style={styles.descriptionText}> + {t('profiles.environmentVariables.previewModal.emptyMessage')} + </Text> + </View> + ) : ( + <ItemGroup title={t('profiles.environmentVariables.title')}> + {envVarEntries.map((envVar, idx) => { + const parsed = parseEnvVarTemplate(envVar.value); + const refs = extractVarRefsFromValue(envVar.value); + const primaryRef = refs[0]; + const secret = isSecretLike(envVar.name) || (primaryRef ? isSecretLike(primaryRef) : false); + + const hasMachineContext = Boolean(props.machineId); + const targetEntry = machineEnv?.[envVar.name]; + const resolvedValue = parsed?.sourceVar ? machineEnv?.[parsed.sourceVar] : undefined; + const isMachineBased = Boolean(refs.length > 0); + + let displayValue: string; + if (hasMachineContext && targetEntry) { + if (targetEntry.display === 'full' || targetEntry.display === 'redacted') { + displayValue = targetEntry.value ?? emptyValue; + } else if (targetEntry.display === 'hidden') { + displayValue = '•••'; + } else { + displayValue = emptyValue; + } + } else if (secret) { + // If daemon policy is known and allows showing secrets, we would have used targetEntry above. + displayValue = machineEnvPolicy === 'full' || machineEnvPolicy === 'redacted' ? (envVar.value || emptyValue) : '•••'; + } else if (parsed) { + if (!hasMachineContext) { + displayValue = formatEnvVarTemplate(parsed); + } else if (resolvedValue === undefined) { + displayValue = `${formatEnvVarTemplate(parsed)} ${t('profiles.environmentVariables.previewModal.checkingSuffix')}`; + } else if (resolvedValue.display === 'hidden') { + displayValue = '•••'; + } else if (resolvedValue.display === 'unset' || resolvedValue.value === null || resolvedValue.value === '') { + displayValue = parsed.fallback ? parsed.fallback : emptyValue; + } else { + displayValue = resolvedValue.value ?? emptyValue; + } + } else { + displayValue = envVar.value || emptyValue; + } + + type DetailKind = 'fixed' | 'machine' | 'checking' | 'fallback' | 'missing'; + + const detailKind: DetailKind | undefined = (() => { + if (secret) return undefined; + if (!isMachineBased) return 'fixed'; + if (!hasMachineContext) return 'machine'; + if (parsed?.sourceVar && resolvedValue === undefined) return 'checking'; + if (parsed?.sourceVar && resolvedValue && (resolvedValue.display === 'unset' || resolvedValue.value === null || resolvedValue.value === '')) { + return parsed?.fallback ? 'fallback' : 'missing'; + } + return 'machine'; + })(); + + const detailLabel = (() => { + if (!detailKind) return undefined; + return detailKind === 'fixed' + ? t('profiles.environmentVariables.previewModal.detail.fixed') + : detailKind === 'machine' + ? t('profiles.environmentVariables.previewModal.detail.machine') + : detailKind === 'checking' + ? t('profiles.environmentVariables.previewModal.detail.checking') + : detailKind === 'fallback' + ? t('profiles.environmentVariables.previewModal.detail.fallback') + : t('profiles.environmentVariables.previewModal.detail.missing'); + })(); + + const detailColor = + detailKind === 'machine' + ? theme.colors.status.connected + : detailKind === 'fallback' || detailKind === 'missing' + ? theme.colors.warning + : theme.colors.textSecondary; + + const rightElement = (() => { + if (secret) return undefined; + if (!isMachineBased) return undefined; + if (!hasMachineContext || detailKind === 'checking') { + return <Ionicons name="time-outline" size={18} color={theme.colors.textSecondary} />; + } + return <Ionicons name="desktop-outline" size={18} color={detailColor} />; + })(); + + const canCopy = (() => { + if (secret) return false; + return Boolean(displayValue); + })(); + + return ( + <Item + key={`${envVar.name}-${idx}`} + title={envVar.name} + subtitle={displayValue} + subtitleLines={0} + copy={canCopy ? displayValue : false} + detail={detailLabel} + detailStyle={{ + color: detailColor, + ...styles.detailText, + }} + rightElement={rightElement} + showChevron={false} + /> + ); + })} + </ItemGroup> + )} + </ScrollView> + </View> + ); +} diff --git a/expo-app/sources/components/sessions/new/components/MachineCliGlyphs.tsx b/expo-app/sources/components/sessions/new/components/MachineCliGlyphs.tsx new file mode 100644 index 000000000..794bca4ad --- /dev/null +++ b/expo-app/sources/components/sessions/new/components/MachineCliGlyphs.tsx @@ -0,0 +1,114 @@ +import * as React from 'react'; +import { Pressable, Text, View } from 'react-native'; +import { StyleSheet, useUnistyles } from 'react-native-unistyles'; +import { Typography } from '@/constants/Typography'; +import { Modal } from '@/modal'; +import { useMachineCapabilitiesCache } from '@/hooks/useMachineCapabilitiesCache'; +import { DetectedClisModal } from '@/components/machines/DetectedClisModal'; +import { CAPABILITIES_REQUEST_NEW_SESSION } from '@/capabilities/requests'; +import { getAgentCore, getAgentCliGlyph } from '@/agents/catalog'; +import { useEnabledAgentIds } from '@/agents/useEnabledAgentIds'; +import type { CapabilityId } from '@/sync/capabilitiesProtocol'; + +type Props = { + machineId: string; + isOnline: boolean; + /** + * When true, the component may trigger capabilities detection fetches. + * When false, it will render cached results only (no automatic fetching). + */ + autoDetect?: boolean; +}; + +const stylesheet = StyleSheet.create((theme) => ({ + container: { + flexDirection: 'row', + alignItems: 'center', + gap: 2, + paddingHorizontal: 4, + paddingVertical: 2, + borderRadius: 6, + }, + glyph: { + color: theme.colors.textSecondary, + ...Typography.default(), + }, + glyphMuted: { + opacity: 0.35, + }, +})); + +// iOS can render some dingbat glyphs as emoji; force text presentation (U+FE0E). +export const MachineCliGlyphs = React.memo(({ machineId, isOnline, autoDetect = true }: Props) => { + useUnistyles(); // re-render on theme changes + const styles = stylesheet; + const enabledAgents = useEnabledAgentIds(); + + const { state } = useMachineCapabilitiesCache({ + machineId, + enabled: autoDetect && isOnline, + request: CAPABILITIES_REQUEST_NEW_SESSION, + }); + + const onPress = React.useCallback(() => { + // Cache-first: opening this modal should NOT fetch by default. + // Users can explicitly refresh inside the modal if needed. + Modal.show({ + component: DetectedClisModal, + props: { + machineId, + isOnline, + }, + }); + }, [isOnline, machineId]); + + const glyphs = React.useMemo(() => { + if (state.status !== 'loaded') { + return [{ key: 'unknown', glyph: '•', factor: 0.85, muted: true }]; + } + + const items: Array<{ key: string; glyph: string; factor: number; muted: boolean }> = []; + const results = state.snapshot.response.results; + for (const agentId of enabledAgents) { + const capId = `cli.${getAgentCore(agentId).cli.detectKey}` as CapabilityId; + const available = (results[capId]?.ok && (results[capId].data as any)?.available === true) ?? false; + if (!available) continue; + const core = getAgentCore(agentId); + items.push({ + key: agentId, + glyph: getAgentCliGlyph(agentId), + factor: core.ui.cliGlyphScale ?? 1.0, + muted: false, + }); + } + + if (items.length === 0) { + items.push({ key: 'none', glyph: '•', factor: 0.85, muted: true }); + } + + return items; + }, [enabledAgents, state]); + + return ( + <Pressable + onPress={onPress} + style={({ pressed }) => [ + styles.container, + { opacity: !isOnline ? 0.5 : (pressed ? 0.7 : 1) }, + ]} + > + {glyphs.map((item) => ( + <Text + key={item.key} + style={[ + styles.glyph, + item.muted ? styles.glyphMuted : null, + { fontSize: Math.round(14 * item.factor), lineHeight: 16 }, + ]} + > + {item.glyph} + </Text> + ))} + </Pressable> + ); +}); diff --git a/expo-app/sources/components/sessions/new/components/MachineSelector.tsx b/expo-app/sources/components/sessions/new/components/MachineSelector.tsx new file mode 100644 index 000000000..ebf94633d --- /dev/null +++ b/expo-app/sources/components/sessions/new/components/MachineSelector.tsx @@ -0,0 +1,137 @@ +import React from 'react'; +import { Ionicons } from '@expo/vector-icons'; +import { useUnistyles } from 'react-native-unistyles'; +import { SearchableListSelector } from '@/components/SearchableListSelector'; +import type { Machine } from '@/sync/storageTypes'; +import { isMachineOnline } from '@/utils/machineUtils'; +import { t } from '@/text'; +import { MachineCliGlyphs } from '@/components/sessions/new/components/MachineCliGlyphs'; + +export interface MachineSelectorProps { + machines: Machine[]; + selectedMachine: Machine | null; + recentMachines?: Machine[]; + favoriteMachines?: Machine[]; + onSelect: (machine: Machine) => void; + onToggleFavorite?: (machine: Machine) => void; + showFavorites?: boolean; + showRecent?: boolean; + showSearch?: boolean; + /** + * When true, show small CLI glyphs per machine row. + * + * NOTE: This can be expensive on iOS because each glyph can trigger CLI detection + * work; keep this off in high-interaction contexts like the new session wizard. + */ + showCliGlyphs?: boolean; + /** + * When false, glyphs will render from cache only and will not auto-trigger detection. + * You can still refresh from the Detected CLIs modal by tapping the glyphs. + */ + autoDetectCliGlyphs?: boolean; + searchPlacement?: 'header' | 'recent' | 'favorites' | 'all'; + searchPlaceholder?: string; + recentSectionTitle?: string; + favoritesSectionTitle?: string; + allSectionTitle?: string; + noItemsMessage?: string; +} + +export function MachineSelector({ + machines, + selectedMachine, + recentMachines = [], + favoriteMachines = [], + onSelect, + onToggleFavorite, + showFavorites = true, + showRecent = true, + showSearch = true, + showCliGlyphs = true, + autoDetectCliGlyphs = true, + searchPlacement = 'header', + searchPlaceholder: searchPlaceholderProp, + recentSectionTitle: recentSectionTitleProp, + favoritesSectionTitle: favoritesSectionTitleProp, + allSectionTitle: allSectionTitleProp, + noItemsMessage: noItemsMessageProp, +}: MachineSelectorProps) { + const { theme } = useUnistyles(); + + const searchPlaceholder = searchPlaceholderProp ?? t('newSession.machinePicker.searchPlaceholder'); + const recentSectionTitle = recentSectionTitleProp ?? t('newSession.machinePicker.recentTitle'); + const favoritesSectionTitle = favoritesSectionTitleProp ?? t('newSession.machinePicker.favoritesTitle'); + const allSectionTitle = allSectionTitleProp ?? t('newSession.machinePicker.allTitle'); + const noItemsMessage = noItemsMessageProp ?? t('newSession.machinePicker.emptyMessage'); + + return ( + <SearchableListSelector<Machine> + config={{ + getItemId: (machine) => machine.id, + getItemTitle: (machine) => machine.metadata?.displayName || machine.metadata?.host || machine.id, + getItemSubtitle: undefined, + getItemIcon: () => ( + <Ionicons + name="desktop-outline" + size={24} + color={theme.colors.textSecondary} + /> + ), + getRecentItemIcon: () => ( + <Ionicons + name="time-outline" + size={24} + color={theme.colors.textSecondary} + /> + ), + getItemStatus: (machine) => { + const offline = !isMachineOnline(machine); + return { + text: offline ? t('status.offline') : t('status.online'), + color: offline ? theme.colors.status.disconnected : theme.colors.status.connected, + dotColor: offline ? theme.colors.status.disconnected : theme.colors.status.connected, + isPulsing: !offline, + }; + }, + ...(showCliGlyphs ? { + getItemStatusExtra: (machine: Machine) => ( + <MachineCliGlyphs + machineId={machine.id} + isOnline={isMachineOnline(machine)} + autoDetect={autoDetectCliGlyphs} + /> + ), + } : {}), + formatForDisplay: (machine) => machine.metadata?.displayName || machine.metadata?.host || machine.id, + parseFromDisplay: (text) => { + return machines.find(m => + m.metadata?.displayName === text || m.metadata?.host === text || m.id === text + ) || null; + }, + filterItem: (machine, searchText) => { + const displayName = (machine.metadata?.displayName || '').toLowerCase(); + const host = (machine.metadata?.host || '').toLowerCase(); + const id = machine.id.toLowerCase(); + const search = searchText.toLowerCase(); + return displayName.includes(search) || host.includes(search) || id.includes(search); + }, + searchPlaceholder, + recentSectionTitle, + favoritesSectionTitle, + allSectionTitle, + noItemsMessage, + showFavorites, + showRecent, + showSearch, + allowCustomInput: false, + }} + items={machines} + recentItems={recentMachines} + favoriteItems={favoriteMachines} + selectedItem={selectedMachine} + onSelect={onSelect} + onToggleFavorite={onToggleFavorite} + searchPlacement={searchPlacement} + /> + ); +} diff --git a/expo-app/sources/components/sessions/new/components/NewSessionSimplePanel.tsx b/expo-app/sources/components/sessions/new/components/NewSessionSimplePanel.tsx new file mode 100644 index 000000000..a6153e13a --- /dev/null +++ b/expo-app/sources/components/sessions/new/components/NewSessionSimplePanel.tsx @@ -0,0 +1,160 @@ +import * as React from 'react'; +import type { ViewStyle } from 'react-native'; +import { Platform, View } from 'react-native'; +import { KeyboardAvoidingView } from 'react-native-keyboard-controller'; +import { ItemGroup } from '@/components/ui/lists/ItemGroup'; +import { SessionTypeSelectorRows } from '@/components/SessionTypeSelector'; +import { layout } from '@/components/layout'; +import { AgentInput } from '@/components/sessions/agentInput'; +import { PopoverBoundaryProvider } from '@/components/ui/popover'; +import { PopoverPortalTargetProvider } from '@/components/ui/popover'; +import { t } from '@/text'; + +export function NewSessionSimplePanel(props: Readonly<{ + popoverBoundaryRef: React.RefObject<View>; + headerHeight: number; + safeAreaTop: number; + safeAreaBottom: number; + newSessionSidePadding: number; + newSessionBottomPadding: number; + containerStyle: ViewStyle; + experimentsEnabled: boolean; + expSessionType: boolean; + sessionType: 'simple' | 'worktree'; + setSessionType: (t: 'simple' | 'worktree') => void; + sessionPrompt: string; + setSessionPrompt: (v: string) => void; + handleCreateSession: () => void; + canCreate: boolean; + isCreating: boolean; + emptyAutocompletePrefixes: React.ComponentProps<typeof AgentInput>['autocompletePrefixes']; + emptyAutocompleteSuggestions: React.ComponentProps<typeof AgentInput>['autocompleteSuggestions']; + sessionPromptInputMaxHeight: number; + agentInputExtraActionChips?: React.ComponentProps<typeof AgentInput>['extraActionChips']; + agentType: React.ComponentProps<typeof AgentInput>['agentType']; + handleAgentClick: React.ComponentProps<typeof AgentInput>['onAgentClick']; + permissionMode: React.ComponentProps<typeof AgentInput>['permissionMode']; + handlePermissionModeChange: React.ComponentProps<typeof AgentInput>['onPermissionModeChange']; + modelMode: React.ComponentProps<typeof AgentInput>['modelMode']; + setModelMode: React.ComponentProps<typeof AgentInput>['onModelModeChange']; + connectionStatus: React.ComponentProps<typeof AgentInput>['connectionStatus']; + machineName: string | undefined; + handleMachineClick: React.ComponentProps<typeof AgentInput>['onMachineClick']; + selectedPath: string; + handlePathClick: React.ComponentProps<typeof AgentInput>['onPathClick']; + showResumePicker: boolean; + resumeSessionId: string | null; + handleResumeClick: React.ComponentProps<typeof AgentInput>['onResumeClick']; + isResumeSupportChecking: boolean; + useProfiles: boolean; + selectedProfileId: string | null; + handleProfileClick: React.ComponentProps<typeof AgentInput>['onProfileClick']; + selectedProfileEnvVarsCount: number; + handleEnvVarsClick: () => void; +}>): React.ReactElement { + return ( + <KeyboardAvoidingView + behavior={Platform.OS === 'ios' ? 'padding' : 'height'} + keyboardVerticalOffset={Platform.OS === 'ios' ? props.headerHeight + props.safeAreaBottom + 16 : 0} + style={[ + props.containerStyle, + ...(Platform.OS === 'web' + ? [ + { + justifyContent: 'center' as const, + paddingTop: 0, + }, + ] + : [ + { + justifyContent: 'flex-end' as const, + paddingTop: 40, + }, + ]), + ]} + > + <View + ref={props.popoverBoundaryRef} + style={{ + flex: 1, + width: '100%', + // Keep the content centered on web. Without this, the boundary wrapper (flex:1) + // can cause the inner content to stick to the top even when the modal is centered. + justifyContent: Platform.OS === 'web' ? 'center' : 'flex-end', + }} + > + <PopoverPortalTargetProvider> + <PopoverBoundaryProvider boundaryRef={props.popoverBoundaryRef}> + <View + style={{ + width: '100%', + alignSelf: 'center', + paddingTop: props.safeAreaTop, + paddingBottom: props.safeAreaBottom, + }} + > + {/* Session type selector only if enabled via experiments */} + {props.experimentsEnabled && props.expSessionType && ( + <View style={{ paddingHorizontal: props.newSessionSidePadding, marginBottom: 16 }}> + <View style={{ maxWidth: layout.maxWidth, width: '100%', alignSelf: 'center' }}> + <ItemGroup title={t('newSession.sessionType.title')} containerStyle={{ marginHorizontal: 0 }}> + <SessionTypeSelectorRows value={props.sessionType} onChange={props.setSessionType} /> + </ItemGroup> + </View> + </View> + )} + + {/* AgentInput with inline chips - sticky at bottom */} + <View + style={{ + paddingTop: 12, + paddingBottom: props.newSessionBottomPadding, + }} + > + <View style={{ paddingHorizontal: props.newSessionSidePadding }}> + <View style={{ maxWidth: layout.maxWidth, width: '100%', alignSelf: 'center' }}> + <AgentInput + value={props.sessionPrompt} + onChangeText={props.setSessionPrompt} + onSend={props.handleCreateSession} + isSendDisabled={!props.canCreate} + isSending={props.isCreating} + placeholder={t('session.inputPlaceholder')} + autocompletePrefixes={props.emptyAutocompletePrefixes} + autocompleteSuggestions={props.emptyAutocompleteSuggestions} + extraActionChips={props.agentInputExtraActionChips} + inputMaxHeight={props.sessionPromptInputMaxHeight} + agentType={props.agentType} + onAgentClick={props.handleAgentClick} + permissionMode={props.permissionMode} + onPermissionModeChange={props.handlePermissionModeChange} + modelMode={props.modelMode} + onModelModeChange={props.setModelMode} + connectionStatus={props.connectionStatus} + machineName={props.machineName} + onMachineClick={props.handleMachineClick} + currentPath={props.selectedPath} + onPathClick={props.handlePathClick} + resumeSessionId={props.showResumePicker ? props.resumeSessionId : undefined} + onResumeClick={props.showResumePicker ? props.handleResumeClick : undefined} + resumeIsChecking={props.isResumeSupportChecking} + contentPaddingHorizontal={0} + {...(props.useProfiles + ? { + profileId: props.selectedProfileId, + onProfileClick: props.handleProfileClick, + envVarsCount: props.selectedProfileEnvVarsCount || undefined, + onEnvVarsClick: props.selectedProfileEnvVarsCount > 0 ? props.handleEnvVarsClick : undefined, + } + : {})} + /> + </View> + </View> + </View> + </View> + </PopoverBoundaryProvider> + </PopoverPortalTargetProvider> + </View> + </KeyboardAvoidingView> + ); +} diff --git a/expo-app/sources/components/sessions/new/components/NewSessionWizard.tsx b/expo-app/sources/components/sessions/new/components/NewSessionWizard.tsx new file mode 100644 index 000000000..c89d68ba4 --- /dev/null +++ b/expo-app/sources/components/sessions/new/components/NewSessionWizard.tsx @@ -0,0 +1,725 @@ +import { Ionicons } from '@expo/vector-icons'; +import * as React from 'react'; +import { Platform, Pressable, ScrollView, Text, View } from 'react-native'; +import { KeyboardAvoidingView } from 'react-native-keyboard-controller'; +import { LinearGradient } from 'expo-linear-gradient'; +import Color from 'color'; +import { Typography } from '@/constants/Typography'; +import { AgentInput } from '@/components/sessions/agentInput'; +import { Item } from '@/components/ui/lists/Item'; +import { ItemGroup } from '@/components/ui/lists/ItemGroup'; +import { MachineSelector } from '@/components/sessions/new/components/MachineSelector'; +import { PathSelector } from '@/components/sessions/new/components/PathSelector'; +import { WizardSectionHeaderRow } from '@/components/sessions/new/components/WizardSectionHeaderRow'; +import { ProfilesList } from '@/components/profiles/ProfilesList'; +import { SessionTypeSelectorRows } from '@/components/SessionTypeSelector'; +import { layout } from '@/components/layout'; +import { Modal } from '@/modal'; +import { t } from '@/text'; +import { getBuiltInProfile } from '@/sync/profileUtils'; +import { getProfileEnvironmentVariables, isProfileCompatibleWithAgent, type AIBackendProfile } from '@/sync/settings'; +import { useSetting } from '@/sync/storage'; +import type { Machine } from '@/sync/storageTypes'; +import type { PermissionMode, ModelMode } from '@/sync/permissionTypes'; +import { getPermissionModeOptionsForAgentType } from '@/sync/permissionModeOptions'; +import type { SecretSatisfactionResult } from '@/utils/secrets/secretSatisfaction'; +import type { CLIAvailability } from '@/hooks/useCLIDetection'; +import type { AgentId } from '@/agents/catalog'; +import { getAgentCore } from '@/agents/catalog'; +import { getAgentPickerOptions } from '@/agents/agentPickerOptions'; +import { CliNotDetectedBanner, type CliNotDetectedBannerDismissScope } from '@/components/sessions/new/components/CliNotDetectedBanner'; +import { InstallableDepInstaller, type InstallableDepInstallerProps } from '@/components/machines/InstallableDepInstaller'; + +export interface NewSessionWizardLayoutProps { + theme: any; + styles: any; + safeAreaBottom: number; + headerHeight: number; + newSessionSidePadding: number; + newSessionBottomPadding: number; +} + +export interface NewSessionWizardProfilesProps { + useProfiles: boolean; + profiles: AIBackendProfile[]; + favoriteProfileIds: string[]; + setFavoriteProfileIds: (ids: string[]) => void; + experimentsEnabled: boolean; + selectedProfileId: string | null; + onPressDefaultEnvironment: () => void; + onPressProfile: (profile: AIBackendProfile) => void; + selectedMachineId: string | null; + getProfileDisabled: (profile: AIBackendProfile) => boolean; + getProfileSubtitleExtra: (profile: AIBackendProfile) => string | null; + handleAddProfile: () => void; + openProfileEdit: (params: { profileId: string }) => void; + handleDuplicateProfile: (profile: AIBackendProfile) => void; + handleDeleteProfile: (profile: AIBackendProfile) => void; + openProfileEnvVarsPreview: (profile: AIBackendProfile) => void; + suppressNextSecretAutoPromptKeyRef: React.MutableRefObject<string | null>; + openSecretRequirementModal: (profile: AIBackendProfile, opts: { revertOnCancel: boolean }) => void; + profilesGroupTitles: { favorites: string; custom: string; builtIn: string }; + getSecretOverrideReady: (profile: AIBackendProfile) => boolean; + // NOTE: Multi-secret satisfaction result shape is evolving; wizard only needs `isSatisfied`. + // Keep this permissive to avoid cross-file type coupling. + getSecretSatisfactionForProfile: (profile: AIBackendProfile) => { isSatisfied: boolean }; + getSecretMachineEnvOverride?: (profile: AIBackendProfile) => { isReady: boolean; isLoading: boolean } | null; +} + +export interface NewSessionWizardAgentProps { + cliAvailability: CLIAvailability; + tmuxRequested: boolean; + enabledAgentIds: AgentId[]; + isCliBannerDismissed: (agentId: AgentId) => boolean; + dismissCliBanner: (agentId: AgentId, scope: CliNotDetectedBannerDismissScope) => void; + agentType: AgentId; + setAgentType: (agent: AgentId) => void; + modelOptions: ReadonlyArray<{ value: ModelMode; label: string; description: string }>; + modelMode: ModelMode | undefined; + setModelMode: (mode: ModelMode) => void; + selectedIndicatorColor: string; + profileMap: Map<string, AIBackendProfile>; + permissionMode: PermissionMode; + handlePermissionModeChange: (mode: PermissionMode) => void; + sessionType: 'simple' | 'worktree'; + setSessionType: (t: 'simple' | 'worktree') => void; + installableDepInstallers?: InstallableDepInstallerProps[]; +} + +export interface NewSessionWizardMachineProps { + machines: Machine[]; + selectedMachine: Machine | null; + recentMachines: Machine[]; + favoriteMachineItems: Machine[]; + useMachinePickerSearch: boolean; + onRefreshMachines?: () => void; + setSelectedMachineId: (id: string) => void; + getBestPathForMachine: (id: string) => string; + setSelectedPath: (path: string) => void; + favoriteMachines: string[]; + setFavoriteMachines: (ids: string[]) => void; + selectedPath: string; + recentPaths: string[]; + usePathPickerSearch: boolean; + favoriteDirectories: string[]; + setFavoriteDirectories: (dirs: string[]) => void; +} + +export interface NewSessionWizardFooterProps { + sessionPrompt: string; + setSessionPrompt: (v: string) => void; + handleCreateSession: () => void; + canCreate: boolean; + isCreating: boolean; + emptyAutocompletePrefixes: React.ComponentProps<typeof AgentInput>['autocompletePrefixes']; + emptyAutocompleteSuggestions: React.ComponentProps<typeof AgentInput>['autocompleteSuggestions']; + connectionStatus?: React.ComponentProps<typeof AgentInput>['connectionStatus']; + resumeSessionId?: string | null; + onResumeClick?: () => void; + resumeIsChecking?: boolean; + selectedProfileEnvVarsCount: number; + handleEnvVarsClick: () => void; + inputMaxHeight?: number; + agentInputExtraActionChips?: React.ComponentProps<typeof AgentInput>['extraActionChips']; +} + +export interface NewSessionWizardProps { + layout: NewSessionWizardLayoutProps; + profiles: NewSessionWizardProfilesProps; + agent: NewSessionWizardAgentProps; + machine: NewSessionWizardMachineProps; + footer: NewSessionWizardFooterProps; +} + +export const NewSessionWizard = React.memo(function NewSessionWizard(props: NewSessionWizardProps) { + const { + theme, + styles, + safeAreaBottom, + headerHeight, + newSessionSidePadding, + newSessionBottomPadding, + } = props.layout; + + // Wizard-only scroll bookkeeping (keep it out of NewSessionScreen) + const scrollViewRef = React.useRef<ScrollView>(null); + const wizardSectionOffsets = React.useRef<{ + profile?: number; + agent?: number; + model?: number; + machine?: number; + path?: number; + permission?: number; + sessionType?: number; + }>({}); + const registerWizardSectionOffset = React.useCallback((key: keyof typeof wizardSectionOffsets.current) => { + return (e: any) => { + wizardSectionOffsets.current[key] = e?.nativeEvent?.layout?.y ?? 0; + }; + }, []); + const scrollToWizardSection = React.useCallback((key: keyof typeof wizardSectionOffsets.current) => { + const y = wizardSectionOffsets.current[key]; + if (typeof y !== 'number' || !scrollViewRef.current) return; + scrollViewRef.current.scrollTo({ y: Math.max(0, y - 20), animated: true }); + }, []); + + const handleAgentInputProfileClick = React.useCallback(() => { + scrollToWizardSection('profile'); + }, [scrollToWizardSection]); + + const handleAgentInputMachineClick = React.useCallback(() => { + scrollToWizardSection('machine'); + }, [scrollToWizardSection]); + + const handleAgentInputPathClick = React.useCallback(() => { + scrollToWizardSection('path'); + }, [scrollToWizardSection]); + + const handleAgentInputPermissionClick = React.useCallback(() => { + scrollToWizardSection('permission'); + }, [scrollToWizardSection]); + + const handleAgentInputAgentClick = React.useCallback(() => { + scrollToWizardSection('agent'); + }, [scrollToWizardSection]); + + const onRefreshMachines = props.machine.onRefreshMachines; + + const { + useProfiles, + profiles, + favoriteProfileIds, + setFavoriteProfileIds, + experimentsEnabled, + selectedProfileId, + onPressDefaultEnvironment, + onPressProfile, + selectedMachineId, + getProfileDisabled, + getProfileSubtitleExtra, + handleAddProfile, + openProfileEdit, + handleDuplicateProfile, + handleDeleteProfile, + openProfileEnvVarsPreview, + suppressNextSecretAutoPromptKeyRef, + openSecretRequirementModal, + profilesGroupTitles, + getSecretOverrideReady, + getSecretSatisfactionForProfile, + getSecretMachineEnvOverride, + } = props.profiles; + + const expSessionType = useSetting('expSessionType'); + const showSessionTypeSelector = experimentsEnabled && expSessionType; + + const { + cliAvailability, + tmuxRequested, + enabledAgentIds, + isCliBannerDismissed, + dismissCliBanner, + agentType, + setAgentType, + modelOptions, + modelMode, + setModelMode, + selectedIndicatorColor, + profileMap, + permissionMode, + handlePermissionModeChange, + sessionType, + setSessionType, + installableDepInstallers, + } = props.agent; + + const { + machines, + selectedMachine, + recentMachines, + favoriteMachineItems, + useMachinePickerSearch, + setSelectedMachineId, + getBestPathForMachine, + setSelectedPath, + favoriteMachines, + setFavoriteMachines, + selectedPath, + recentPaths, + usePathPickerSearch, + favoriteDirectories, + setFavoriteDirectories, + } = props.machine; + + const { + sessionPrompt, + setSessionPrompt, + handleCreateSession, + canCreate, + isCreating, + emptyAutocompletePrefixes, + emptyAutocompleteSuggestions, + connectionStatus, + resumeSessionId, + onResumeClick, + resumeIsChecking, + selectedProfileEnvVarsCount, + handleEnvVarsClick, + inputMaxHeight, + } = props.footer; + + return ( + <KeyboardAvoidingView + behavior={Platform.OS === 'ios' ? 'padding' : 'height'} + keyboardVerticalOffset={Platform.OS === 'ios' ? headerHeight + safeAreaBottom + 16 : 0} + style={[styles.container, { backgroundColor: theme.colors.groupped.background }]} + > + <View style={{ flex: 1 }}> + <ScrollView + ref={scrollViewRef} + style={styles.scrollContainer} + contentContainerStyle={styles.contentContainer} + keyboardShouldPersistTaps="handled" + > + <View style={{ paddingHorizontal: 0 }}> + <View style={[ + { maxWidth: layout.maxWidth, flex: 1, width: '100%', alignSelf: 'center' } + ]}> + <View onLayout={registerWizardSectionOffset('profile')} style={styles.wizardContainer}> + {useProfiles && ( + <> + <View style={styles.wizardSectionHeaderRow}> + <Ionicons name="person-outline" size={18} color={theme.colors.text} /> + <Text style={[styles.sectionHeader, { marginBottom: 0, marginTop: 0 }]}> + {t('newSession.selectAiProfileTitle')} + </Text> + </View> + <Text style={styles.sectionDescription}> + {t('newSession.selectAiProfileDescription')} + </Text> + <ProfilesList + customProfiles={profiles} + favoriteProfileIds={favoriteProfileIds} + onFavoriteProfileIdsChange={setFavoriteProfileIds} + experimentsEnabled={experimentsEnabled} + selectedProfileId={selectedProfileId} + popoverBoundaryRef={scrollViewRef} + includeDefaultEnvironmentRow + onPressDefaultEnvironment={onPressDefaultEnvironment} + onPressProfile={onPressProfile} + machineId={selectedMachineId ?? null} + getSecretOverrideReady={getSecretOverrideReady} + getSecretMachineEnvOverride={getSecretMachineEnvOverride} + getProfileDisabled={getProfileDisabled} + getProfileSubtitleExtra={getProfileSubtitleExtra} + includeAddProfileRow + onAddProfilePress={handleAddProfile} + onEditProfile={(profile) => openProfileEdit({ profileId: profile.id })} + onDuplicateProfile={handleDuplicateProfile} + onDeleteProfile={handleDeleteProfile} + getHasEnvironmentVariables={(profile) => Object.keys(getProfileEnvironmentVariables(profile)).length > 0} + onViewEnvironmentVariables={openProfileEnvVarsPreview} + onSecretBadgePress={(profile) => { + const satisfaction = getSecretSatisfactionForProfile(profile); + const isMissingForSelectedProfile = + profile.id === selectedProfileId && !satisfaction.isSatisfied; + openSecretRequirementModal(profile, { revertOnCancel: isMissingForSelectedProfile }); + }} + groupTitles={profilesGroupTitles} + /> + + <View style={{ height: 24 }} /> + </> + )} + + {/* Section: AI Backend */} + <View onLayout={registerWizardSectionOffset('agent')}> + <View style={styles.wizardSectionHeaderRow}> + <Ionicons name="hardware-chip-outline" size={18} color={theme.colors.text} /> + <Text style={[styles.sectionHeader, { marginBottom: 0, marginTop: 0 }]}> + {t('newSession.selectAiBackendTitle')} + </Text> + </View> + </View> + <Text style={styles.sectionDescription}> + {useProfiles && selectedProfileId + ? t('newSession.aiBackendLimitedByProfileAndMachineClis') + : t('newSession.aiBackendSelectWhichAiRuns')} + </Text> + + {/* Missing CLI Installation Banners */} + {selectedMachineId && tmuxRequested && cliAvailability.tmux === false && ( + <View style={{ + backgroundColor: theme.colors.box.warning.background, + borderRadius: 10, + padding: 12, + marginBottom: 12, + borderWidth: 1, + borderColor: theme.colors.box.warning.border, + }}> + <View style={{ flexDirection: 'row', alignItems: 'center', gap: 6, marginBottom: 6 }}> + <Ionicons name="warning" size={16} color={theme.colors.warning} /> + <Text style={{ fontSize: 13, fontWeight: '600', color: theme.colors.text, ...Typography.default('semiBold') }}> + {t('machine.tmux.notDetectedSubtitle')} + </Text> + </View> + <Text style={{ fontSize: 11, color: theme.colors.textSecondary, ...Typography.default() }}> + {t('machine.tmux.notDetectedMessage')} + </Text> + </View> + )} + + {installableDepInstallers && installableDepInstallers.length > 0 ? ( + <> + {installableDepInstallers.map((installer) => ( + <InstallableDepInstaller key={installer.depId} {...installer} /> + ))} + </> + ) : null} + + {selectedMachineId ? ( + enabledAgentIds + .filter((agentId) => cliAvailability.available[agentId] === false) + .filter((agentId) => !isCliBannerDismissed(agentId)) + .map((agentId) => ( + <CliNotDetectedBanner + key={agentId} + agentId={agentId} + theme={theme} + onDismiss={(scope) => dismissCliBanner(agentId, scope)} + /> + )) + ) : null} + + <ItemGroup title={<View />} headerStyle={{ paddingTop: 0, paddingBottom: 0 }}> + {(() => { + const selectedProfile = useProfiles && selectedProfileId + ? (profileMap.get(selectedProfileId) || getBuiltInProfile(selectedProfileId)) + : null; + + const options = getAgentPickerOptions(enabledAgentIds); + + return options.map((option, index) => { + const compatible = !selectedProfile || isProfileCompatibleWithAgent(selectedProfile, option.agentId); + const cliOk = cliAvailability.available[option.agentId] !== false; + const disabledReason = !compatible + ? t('newSession.aiBackendNotCompatibleWithSelectedProfile') + : !cliOk + ? t('newSession.aiBackendCliNotDetectedOnMachine', { cli: t(option.titleKey) }) + : null; + + const isSelected = agentType === option.agentId; + + return ( + <Item + key={option.agentId} + title={t(option.titleKey)} + subtitle={disabledReason ?? t(option.subtitleKey)} + leftElement={<Ionicons name={option.iconName as any} size={24} color={theme.colors.textSecondary} />} + selected={isSelected} + disabled={!!disabledReason} + onPress={() => { + if (disabledReason) { + Modal.alert( + t('profiles.aiBackend.title'), + disabledReason, + compatible + ? [{ text: t('common.ok'), style: 'cancel' }] + : [ + { text: t('common.ok'), style: 'cancel' }, + ...(useProfiles && selectedProfileId ? [{ text: t('newSession.changeProfile'), onPress: handleAgentInputProfileClick }] : []), + ], + ); + return; + } + setAgentType(option.agentId); + }} + rightElement={( + <View style={{ width: 24, alignItems: 'center', justifyContent: 'center' }}> + <Ionicons + name="checkmark-circle" + size={24} + color={selectedIndicatorColor} + style={{ opacity: isSelected ? 1 : 0 }} + /> + </View> + )} + showChevron={false} + showDivider={index < options.length - 1} + /> + ); + }); + })()} + </ItemGroup> + + {modelOptions.length > 0 && ( + <View style={{ marginTop: 24 }}> + <View onLayout={registerWizardSectionOffset('model')}> + <View style={styles.wizardSectionHeaderRow}> + <Ionicons name="sparkles-outline" size={18} color={theme.colors.text} /> + <Text style={[styles.sectionHeader, { marginBottom: 0, marginTop: 0 }]}>{t('newSession.selectModelTitle')}</Text> + </View> + </View> + <Text style={styles.sectionDescription}> + {t('newSession.selectModelDescription')} + </Text> + <ItemGroup title=""> + {modelOptions.map((option, index, options) => { + const isSelected = modelMode === option.value; + return ( + <Item + key={option.value} + title={option.label} + subtitle={option.description} + leftElement={<Ionicons name="sparkles-outline" size={24} color={theme.colors.textSecondary} />} + showChevron={false} + selected={isSelected} + onPress={() => setModelMode(option.value)} + rightElement={( + <View style={{ width: 24, alignItems: 'center', justifyContent: 'center' }}> + <Ionicons + name="checkmark-circle" + size={24} + color={selectedIndicatorColor} + style={{ opacity: isSelected ? 1 : 0 }} + /> + </View> + )} + showDivider={index < options.length - 1} + /> + ); + })} + </ItemGroup> + </View> + )} + + <View style={{ height: 24 }} /> + + {/* Section 2: Machine Selection */} + <View onLayout={registerWizardSectionOffset('machine')}> + <WizardSectionHeaderRow + rowStyle={styles.wizardSectionHeaderRow} + iconName="desktop-outline" + iconColor={theme.colors.text} + title={t('newSession.selectMachineTitle')} + titleStyle={[styles.sectionHeader, { marginBottom: 0, marginTop: 0 }]} + action={onRefreshMachines ? { + accessibilityLabel: t('common.refresh'), + iconName: 'refresh-outline', + iconColor: theme.colors.textSecondary, + onPress: onRefreshMachines, + } : undefined} + /> + </View> + <Text style={styles.sectionDescription}> + {t('newSession.selectMachineDescription')} + </Text> + + <View style={{ marginBottom: 24 }}> + <MachineSelector + machines={machines} + selectedMachine={selectedMachine || null} + recentMachines={recentMachines} + favoriteMachines={favoriteMachineItems} + showCliGlyphs={true} + autoDetectCliGlyphs={false} + showFavorites={true} + showSearch={useMachinePickerSearch} + searchPlacement="all" + searchPlaceholder="Search machines..." + onSelect={(machine) => { + setSelectedMachineId(machine.id); + const bestPath = getBestPathForMachine(machine.id); + setSelectedPath(bestPath); + }} + onToggleFavorite={(machine) => { + const isInFavorites = favoriteMachines.includes(machine.id); + if (isInFavorites) { + setFavoriteMachines(favoriteMachines.filter(id => id !== machine.id)); + } else { + setFavoriteMachines([...favoriteMachines, machine.id]); + } + }} + /> + </View> + + {/* API key selection is now handled inline from the profile list (via the requirements badge). */} + + {/* Section 3: Working Directory */} + <View onLayout={registerWizardSectionOffset('path')}> + <View style={styles.wizardSectionHeaderRow}> + <Ionicons name="folder-outline" size={18} color={theme.colors.text} /> + <Text style={[styles.sectionHeader, { marginBottom: 0, marginTop: 0 }]}>{t('newSession.selectWorkingDirectoryTitle')}</Text> + </View> + </View> + <Text style={styles.sectionDescription}> + {t('newSession.selectWorkingDirectoryDescription')} + </Text> + + <View style={{ marginBottom: 24 }}> + <PathSelector + machineHomeDir={selectedMachine?.metadata?.homeDir || '/home'} + selectedPath={selectedPath} + onChangeSelectedPath={setSelectedPath} + recentPaths={recentPaths} + usePickerSearch={usePathPickerSearch} + searchVariant="group" + focusInputOnSelect={false} + favoriteDirectories={favoriteDirectories} + onChangeFavoriteDirectories={setFavoriteDirectories} + /> + </View> + + {/* Section 4: Permission Mode */} + <View onLayout={registerWizardSectionOffset('permission')}> + <View style={styles.wizardSectionHeaderRow}> + <Ionicons name="shield-outline" size={18} color={theme.colors.text} /> + <Text style={[styles.sectionHeader, { marginBottom: 0, marginTop: 0 }]}>{t('newSession.selectPermissionModeTitle')}</Text> + </View> + </View> + <Text style={styles.sectionDescription}> + {t('newSession.selectPermissionModeDescription')} + </Text> + <ItemGroup title=""> + {getPermissionModeOptionsForAgentType(agentType).map((option, index, array) => ( + <Item + key={option.value} + title={option.label} + subtitle={option.description} + leftElement={ + <Ionicons + name={option.icon as any} + size={24} + color={theme.colors.textSecondary} + /> + } + rightElement={permissionMode === option.value ? ( + <Ionicons + name="checkmark-circle" + size={24} + color={selectedIndicatorColor} + /> + ) : null} + onPress={() => handlePermissionModeChange(option.value)} + showChevron={false} + selected={permissionMode === option.value} + showDivider={index < array.length - 1} + /> + ))} + </ItemGroup> + + <View style={{ height: 24 }} /> + + {/* Section 5: Session Type */} + {showSessionTypeSelector && ( + <> + <View onLayout={registerWizardSectionOffset('sessionType')}> + <View style={styles.wizardSectionHeaderRow}> + <Ionicons name="layers-outline" size={18} color={theme.colors.text} /> + <Text style={[styles.sectionHeader, { marginBottom: 0, marginTop: 0 }]}>{t('newSession.selectSessionTypeTitle')}</Text> + </View> + </View> + <Text style={styles.sectionDescription}> + {t('newSession.selectSessionTypeDescription')} + </Text> + + <View style={{ marginBottom: 0 }}> + <ItemGroup title={<View />} headerStyle={{ paddingTop: 0, paddingBottom: 0 }}> + <SessionTypeSelectorRows value={sessionType} onChange={setSessionType} /> + </ItemGroup> + </View> + </> + )} + </View> + </View> + </View> + </ScrollView> + + {/* AgentInput - Sticky at bottom */} + <View style={{ + paddingTop: 12, + paddingBottom: newSessionBottomPadding, + position: 'relative', + overflow: 'visible', + ...Platform.select({ + web: { boxShadow: '0 -10px 30px rgba(0,0,0,0.08)' } as any, + ios: { + shadowColor: theme.colors.shadow.color, + shadowOffset: { width: 0, height: -4 }, + shadowOpacity: 0.08, + shadowRadius: 14, + }, + android: { borderTopWidth: 1, borderTopColor: theme.colors.divider }, + default: {}, + }), + }}> + {/* Always-on top divider gradient (wizard only). + Matches web: boxShadow 0 -10px 30px rgba(0,0,0,0.08) and fades into true transparency above. */} + {Platform.OS !== 'web' ? ( + <LinearGradient + pointerEvents="none" + colors={[ + (() => { + try { + return Color(theme.colors.shadow.color).alpha(0.08).rgb().string(); + } catch { + return 'rgba(0,0,0,0.08)'; + } + })(), + 'transparent', + ]} + start={{ x: 0.5, y: 1 }} + end={{ x: 0.5, y: 0 }} + style={{ + position: 'absolute', + top: -30, + left: -1000, + right: -1000, + height: 30, + zIndex: 10, + }} + /> + ) : null} + <View style={{ paddingHorizontal: newSessionSidePadding }}> + <View style={{ maxWidth: layout.maxWidth, width: '100%', alignSelf: 'center' }}> + <AgentInput + value={sessionPrompt} + onChangeText={setSessionPrompt} + onSend={handleCreateSession} + isSendDisabled={!canCreate} + isSending={isCreating} + placeholder={t('session.inputPlaceholder')} + autocompletePrefixes={emptyAutocompletePrefixes} + autocompleteSuggestions={emptyAutocompleteSuggestions} + extraActionChips={props.footer.agentInputExtraActionChips} + inputMaxHeight={inputMaxHeight} + agentType={agentType} + onAgentClick={handleAgentInputAgentClick} + permissionMode={permissionMode} + onPermissionModeChange={handlePermissionModeChange} + onPermissionClick={handleAgentInputPermissionClick} + modelMode={modelMode} + onModelModeChange={setModelMode} + connectionStatus={connectionStatus} + machineName={selectedMachine?.metadata?.displayName || selectedMachine?.metadata?.host} + onMachineClick={handleAgentInputMachineClick} + currentPath={selectedPath} + onPathClick={handleAgentInputPathClick} + resumeSessionId={resumeSessionId} + onResumeClick={onResumeClick} + resumeIsChecking={resumeIsChecking} + contentPaddingHorizontal={0} + {...(useProfiles ? { + profileId: selectedProfileId, + onProfileClick: handleAgentInputProfileClick, + envVarsCount: selectedProfileEnvVarsCount || undefined, + onEnvVarsClick: selectedProfileEnvVarsCount > 0 ? handleEnvVarsClick : undefined, + } : {})} + /> + </View> + </View> + </View> + </View> + </KeyboardAvoidingView> + ); +}); diff --git a/expo-app/sources/components/sessions/new/components/PathSelector.tsx b/expo-app/sources/components/sessions/new/components/PathSelector.tsx new file mode 100644 index 000000000..b42b36ad1 --- /dev/null +++ b/expo-app/sources/components/sessions/new/components/PathSelector.tsx @@ -0,0 +1,614 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { View, Pressable, TextInput, Platform } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { StyleSheet, useUnistyles } from 'react-native-unistyles'; +import { ItemGroup } from '@/components/ui/lists/ItemGroup'; +import { Item } from '@/components/ui/lists/Item'; +import { SearchHeader } from '@/components/ui/forms/SearchHeader'; +import { Typography } from '@/constants/Typography'; +import { formatPathRelativeToHome } from '@/utils/sessionUtils'; +import { resolveAbsolutePath } from '@/utils/pathUtils'; +import { t } from '@/text'; + +type PathSelectorBaseProps = { + machineHomeDir: string; + selectedPath: string; + onChangeSelectedPath: (path: string) => void; + onSubmitSelectedPath?: (path: string) => void; + submitBehavior?: 'showRow' | 'confirm'; + recentPaths: string[]; + usePickerSearch: boolean; + searchVariant?: 'header' | 'group' | 'none'; + favoriteDirectories: string[]; + onChangeFavoriteDirectories: (dirs: string[]) => void; + /** + * When true, clicking a path row will focus the input (and try to place cursor at the end). + * Wizard UX generally wants this OFF; the dedicated picker screen wants this ON. + */ + focusInputOnSelect?: boolean; +}; + +type PathSelectorControlledSearchProps = { + searchQuery: string; + onChangeSearchQuery: (text: string) => void; +}; + +type PathSelectorUncontrolledSearchProps = { + searchQuery?: undefined; + onChangeSearchQuery?: undefined; +}; + +export type PathSelectorProps = + & PathSelectorBaseProps + & (PathSelectorControlledSearchProps | PathSelectorUncontrolledSearchProps); + +const ITEM_RIGHT_GAP = 16; + +const stylesheet = StyleSheet.create((theme) => ({ + pathInputContainer: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + paddingHorizontal: 16, + paddingVertical: 16, + }, + pathInput: { + flex: 1, + backgroundColor: theme.colors.input.background, + borderRadius: 10, + paddingHorizontal: 12, + minHeight: 36, + position: 'relative', + borderWidth: 0.5, + borderColor: theme.colors.divider, + }, + searchHeaderContainer: { + backgroundColor: 'transparent', + borderBottomWidth: 0, + }, + rightElementRow: { + flexDirection: 'row', + alignItems: 'center', + gap: ITEM_RIGHT_GAP, + }, + iconSlot: { + width: 24, + alignItems: 'center', + justifyContent: 'center', + }, +})); + +export function PathSelector({ + machineHomeDir, + selectedPath, + onChangeSelectedPath, + recentPaths, + usePickerSearch, + searchVariant = 'header', + searchQuery: controlledSearchQuery, + onChangeSearchQuery: onChangeSearchQueryProp, + favoriteDirectories, + onChangeFavoriteDirectories, + onSubmitSelectedPath, + submitBehavior = 'showRow', + focusInputOnSelect = true, +}: PathSelectorProps) { + const { theme, rt } = useUnistyles(); + const selectedIndicatorColor = rt.themeName === 'dark' ? theme.colors.text : theme.colors.button.primary.background; + const styles = stylesheet; + const inputRef = useRef<TextInput>(null); + const searchInputRef = useRef<TextInput>(null); + const searchWasFocusedRef = useRef(false); + + const [uncontrolledSearchQuery, setUncontrolledSearchQuery] = useState(''); + const isSearchQueryControlled = controlledSearchQuery !== undefined && onChangeSearchQueryProp !== undefined; + const searchQuery = isSearchQueryControlled ? controlledSearchQuery : uncontrolledSearchQuery; + const setSearchQuery = isSearchQueryControlled ? onChangeSearchQueryProp : setUncontrolledSearchQuery; + const [submittedCustomPath, setSubmittedCustomPath] = useState<string | null>(null); + + const suggestedPaths = useMemo(() => { + const homeDir = machineHomeDir || '/home'; + return [ + homeDir, + `${homeDir}/projects`, + `${homeDir}/Documents`, + `${homeDir}/Desktop`, + ]; + }, [machineHomeDir]); + + const favoritePaths = useMemo(() => { + const homeDir = machineHomeDir || '/home'; + const paths = favoriteDirectories.map((fav) => resolveAbsolutePath(fav, homeDir)); + const seen = new Set<string>(); + const ordered: string[] = []; + for (const p of paths) { + if (!p) continue; + if (seen.has(p)) continue; + seen.add(p); + ordered.push(p); + } + return ordered; + }, [favoriteDirectories, machineHomeDir]); + + const filteredFavoritePaths = useMemo(() => { + if (!usePickerSearch || !searchQuery.trim()) return favoritePaths; + const query = searchQuery.toLowerCase(); + return favoritePaths.filter((path) => path.toLowerCase().includes(query)); + }, [favoritePaths, searchQuery, usePickerSearch]); + + const filteredRecentPaths = useMemo(() => { + const base = recentPaths.filter((p) => !favoritePaths.includes(p)); + if (!usePickerSearch || !searchQuery.trim()) return base; + const query = searchQuery.toLowerCase(); + return base.filter((path) => path.toLowerCase().includes(query)); + }, [favoritePaths, recentPaths, searchQuery, usePickerSearch]); + + const filteredSuggestedPaths = useMemo(() => { + const base = suggestedPaths.filter((p) => !favoritePaths.includes(p)); + if (!usePickerSearch || !searchQuery.trim()) return base; + const query = searchQuery.toLowerCase(); + return base.filter((path) => path.toLowerCase().includes(query)); + }, [favoritePaths, searchQuery, suggestedPaths, usePickerSearch]); + + const baseRecentPaths = useMemo(() => { + return recentPaths.filter((p) => !favoritePaths.includes(p)); + }, [favoritePaths, recentPaths]); + + const baseSuggestedPaths = useMemo(() => { + return suggestedPaths.filter((p) => !favoritePaths.includes(p)); + }, [favoritePaths, suggestedPaths]); + + const effectiveGroupSearchPlacement = useMemo(() => { + if (!usePickerSearch || searchVariant !== 'group') return null as null | 'favorites' | 'recent' | 'suggested' | 'fallback'; + const preferred: 'suggested' | 'recent' | 'favorites' | 'fallback' = + baseSuggestedPaths.length > 0 ? 'suggested' + : baseRecentPaths.length > 0 ? 'recent' + : favoritePaths.length > 0 ? 'favorites' + : 'fallback'; + + if (preferred === 'suggested') { + if (filteredSuggestedPaths.length > 0) return 'suggested'; + if (filteredFavoritePaths.length > 0) return 'favorites'; + if (filteredRecentPaths.length > 0) return 'recent'; + return 'suggested'; + } + + if (preferred === 'recent') { + if (filteredRecentPaths.length > 0) return 'recent'; + if (filteredFavoritePaths.length > 0) return 'favorites'; + if (filteredSuggestedPaths.length > 0) return 'suggested'; + return 'recent'; + } + + if (preferred === 'favorites') { + if (filteredFavoritePaths.length > 0) return 'favorites'; + if (filteredRecentPaths.length > 0) return 'recent'; + if (filteredSuggestedPaths.length > 0) return 'suggested'; + return 'favorites'; + } + + return 'fallback'; + }, [ + baseRecentPaths.length, + baseSuggestedPaths.length, + favoritePaths.length, + filteredFavoritePaths.length, + filteredRecentPaths.length, + filteredSuggestedPaths.length, + searchVariant, + usePickerSearch, + ]); + + useEffect(() => { + if (!usePickerSearch || searchVariant !== 'group') return; + if (!searchWasFocusedRef.current) return; + + const id = setTimeout(() => { + // Keep the search box usable while it moves between groups by restoring focus. + // (The underlying TextInput unmounts/remounts as placement changes.) + try { + searchInputRef.current?.focus?.(); + } catch { } + }, 0); + return () => clearTimeout(id); + }, [effectiveGroupSearchPlacement, searchVariant, usePickerSearch]); + + const showNoMatchesRow = usePickerSearch && searchQuery.trim().length > 0; + const shouldRenderFavoritesGroup = filteredFavoritePaths.length > 0 || effectiveGroupSearchPlacement === 'favorites'; + const shouldRenderRecentGroup = filteredRecentPaths.length > 0 || effectiveGroupSearchPlacement === 'recent'; + const shouldRenderSuggestedGroup = filteredSuggestedPaths.length > 0 || effectiveGroupSearchPlacement === 'suggested'; + const shouldRenderFallbackGroup = effectiveGroupSearchPlacement === 'fallback'; + + const toggleFavorite = React.useCallback((absolutePath: string) => { + const homeDir = machineHomeDir || '/home'; + + const relativePath = formatPathRelativeToHome(absolutePath, homeDir); + const resolved = resolveAbsolutePath(relativePath, homeDir); + const isInFavorites = favoriteDirectories.some((fav) => resolveAbsolutePath(fav, homeDir) === resolved); + + onChangeFavoriteDirectories(isInFavorites + ? favoriteDirectories.filter((fav) => resolveAbsolutePath(fav, homeDir) !== resolved) + : [...favoriteDirectories, relativePath] + ); + }, [favoriteDirectories, machineHomeDir, onChangeFavoriteDirectories]); + + const handleChangeSelectedPath = React.useCallback((text: string) => { + onChangeSelectedPath(text); + if (submittedCustomPath && text.trim() !== submittedCustomPath) { + setSubmittedCustomPath(null); + } + }, [onChangeSelectedPath, submittedCustomPath]); + + const focusInputAtEnd = React.useCallback((value: string) => { + if (!focusInputOnSelect) return; + // Small delay so RN has applied the value before selection. + setTimeout(() => { + const input = inputRef.current; + input?.focus?.(); + try { + input?.setNativeProps?.({ selection: { start: value.length, end: value.length } }); + } catch { } + }, 50); + }, [focusInputOnSelect]); + + const setPathAndFocus = React.useCallback((path: string) => { + onChangeSelectedPath(path); + setSubmittedCustomPath(null); + focusInputAtEnd(path); + }, [focusInputAtEnd, onChangeSelectedPath]); + + const handleSubmitPath = React.useCallback(() => { + const trimmed = selectedPath.trim(); + if (!trimmed) return; + + if (trimmed !== selectedPath) { + onChangeSelectedPath(trimmed); + } + + onSubmitSelectedPath?.(trimmed); + if (submitBehavior !== 'confirm') { + setSubmittedCustomPath(trimmed); + } + }, [onChangeSelectedPath, onSubmitSelectedPath, selectedPath, submitBehavior]); + + const renderRightElement = React.useCallback((absolutePath: string, isSelected: boolean, isFavorite: boolean) => { + return ( + <View style={styles.rightElementRow}> + <View style={styles.iconSlot}> + <Ionicons + name="checkmark-circle" + size={24} + color={selectedIndicatorColor} + style={{ opacity: isSelected ? 1 : 0 }} + /> + </View> + <Pressable + hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} + onPress={(e) => { + e.stopPropagation(); + toggleFavorite(absolutePath); + }} + > + <Ionicons + name={isFavorite ? 'star' : 'star-outline'} + size={24} + color={isFavorite ? selectedIndicatorColor : theme.colors.textSecondary} + /> + </Pressable> + </View> + ); + }, [selectedIndicatorColor, theme.colors.textSecondary, toggleFavorite]); + + const renderCustomRightElement = React.useCallback((absolutePath: string) => { + const isFavorite = favoritePaths.includes(absolutePath); + return ( + <View style={styles.rightElementRow}> + <View style={styles.iconSlot}> + <Ionicons + name="checkmark-circle" + size={24} + color={selectedIndicatorColor} + /> + </View> + <Pressable + hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} + onPress={(e) => { + e.stopPropagation(); + toggleFavorite(absolutePath); + }} + > + <Ionicons + name={isFavorite ? 'star' : 'star-outline'} + size={24} + color={isFavorite ? selectedIndicatorColor : theme.colors.textSecondary} + /> + </Pressable> + <Pressable + hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} + onPress={(e) => { + e.stopPropagation(); + setSubmittedCustomPath(null); + onChangeSelectedPath(''); + setTimeout(() => inputRef.current?.focus(), 50); + }} + > + <Ionicons + name="close-circle" + size={24} + color={theme.colors.textSecondary} + /> + </Pressable> + </View> + ); + }, [favoritePaths, onChangeSelectedPath, selectedIndicatorColor, theme.colors.textSecondary, toggleFavorite]); + + const showSubmittedCustomPathRow = useMemo(() => { + if (!submittedCustomPath) return null; + const trimmed = selectedPath.trim(); + if (!trimmed) return null; + if (trimmed !== submittedCustomPath) return null; + + const visiblePaths = new Set<string>([ + ...filteredFavoritePaths, + ...filteredRecentPaths, + ...filteredSuggestedPaths, + ]); + if (visiblePaths.has(trimmed)) return null; + + return trimmed; + }, [filteredFavoritePaths, filteredRecentPaths, filteredSuggestedPaths, selectedPath, submittedCustomPath]); + + return ( + <> + {usePickerSearch && searchVariant === 'header' && ( + <SearchHeader + value={searchQuery} + onChangeText={setSearchQuery} + placeholder={t('newSession.searchPathsPlaceholder')} + /> + )} + + <ItemGroup title={t('newSession.pathPicker.enterPathTitle')}> + <View style={styles.pathInputContainer}> + <View style={[styles.pathInput, { paddingVertical: 8 }]}> + <TextInput + ref={inputRef} + value={selectedPath} + onChangeText={handleChangeSelectedPath} + placeholder={t('newSession.pathPicker.enterPathPlaceholder')} + placeholderTextColor={theme.colors.input.placeholder} + style={[ + { + color: theme.colors.input.text, + paddingTop: 8, + paddingBottom: 8, + }, + Typography.default(), + Platform.OS === 'web' + ? ({ + outlineStyle: 'none', + outlineWidth: 0, + boxShadow: 'none', + } as any) + : undefined, + ]} + autoCapitalize="none" + autoCorrect={false} + autoComplete="off" + textContentType="none" + importantForAutofill="no" + returnKeyType="done" + blurOnSubmit={true} + multiline={false} + onSubmitEditing={handleSubmitPath} + /> + </View> + </View> + </ItemGroup> + + {showSubmittedCustomPathRow && ( + <ItemGroup title={t('newSession.pathPicker.customPathTitle')}> + <Item + key={showSubmittedCustomPathRow} + title={showSubmittedCustomPathRow} + leftElement={<Ionicons name="folder-outline" size={24} color={theme.colors.textSecondary} />} + onPress={() => focusInputAtEnd(showSubmittedCustomPathRow)} + selected={true} + showChevron={false} + rightElement={renderCustomRightElement(showSubmittedCustomPathRow)} + showDivider={false} + /> + </ItemGroup> + )} + + {usePickerSearch && searchVariant === 'group' && shouldRenderRecentGroup && ( + <ItemGroup title={t('newSession.pathPicker.recentTitle')}> + {effectiveGroupSearchPlacement === 'recent' && ( + <SearchHeader + value={searchQuery} + onChangeText={setSearchQuery} + placeholder={t('newSession.searchPathsPlaceholder')} + inputRef={searchInputRef} + onFocus={() => { searchWasFocusedRef.current = true; }} + onBlur={() => { searchWasFocusedRef.current = false; }} + containerStyle={styles.searchHeaderContainer} + /> + )} + {filteredRecentPaths.length === 0 + ? ( + <Item + title={showNoMatchesRow ? t('common.noMatches') : t('newSession.pathPicker.emptyRecent')} + showChevron={false} + showDivider={false} + disabled={true} + /> + ) + : filteredRecentPaths.map((path, index) => { + const isSelected = selectedPath.trim() === path; + const isLast = index === filteredRecentPaths.length - 1; + const isFavorite = favoritePaths.includes(path); + return ( + <Item + key={path} + title={path} + leftElement={<Ionicons name="folder-outline" size={24} color={theme.colors.textSecondary} />} + onPress={() => setPathAndFocus(path)} + selected={isSelected} + showChevron={false} + rightElement={renderRightElement(path, isSelected, isFavorite)} + showDivider={!isLast} + /> + ); + })} + </ItemGroup> + )} + + {shouldRenderFavoritesGroup && ( + <ItemGroup title={t('newSession.pathPicker.favoritesTitle')}> + {usePickerSearch && searchVariant === 'group' && effectiveGroupSearchPlacement === 'favorites' && ( + <SearchHeader + value={searchQuery} + onChangeText={setSearchQuery} + placeholder={t('newSession.searchPathsPlaceholder')} + inputRef={searchInputRef} + onFocus={() => { searchWasFocusedRef.current = true; }} + onBlur={() => { searchWasFocusedRef.current = false; }} + containerStyle={styles.searchHeaderContainer} + /> + )} + {filteredFavoritePaths.length === 0 + ? ( + <Item + title={showNoMatchesRow ? t('common.noMatches') : t('newSession.pathPicker.emptyFavorites')} + showChevron={false} + showDivider={false} + disabled={true} + /> + ) + : filteredFavoritePaths.map((path, index) => { + const isSelected = selectedPath.trim() === path; + const isLast = index === filteredFavoritePaths.length - 1; + return ( + <Item + key={path} + title={path} + leftElement={<Ionicons name="folder-outline" size={18} color={theme.colors.textSecondary} />} + onPress={() => setPathAndFocus(path)} + selected={isSelected} + showChevron={false} + rightElement={renderRightElement(path, isSelected, true)} + showDivider={!isLast} + /> + ); + })} + </ItemGroup> + )} + + {filteredRecentPaths.length > 0 && searchVariant !== 'group' && ( + <ItemGroup title={t('newSession.pathPicker.recentTitle')}> + {filteredRecentPaths.map((path, index) => { + const isSelected = selectedPath.trim() === path; + const isLast = index === filteredRecentPaths.length - 1; + const isFavorite = favoritePaths.includes(path); + return ( + <Item + key={path} + title={path} + leftElement={<Ionicons name="folder-outline" size={18} color={theme.colors.textSecondary} />} + onPress={() => setPathAndFocus(path)} + selected={isSelected} + showChevron={false} + rightElement={renderRightElement(path, isSelected, isFavorite)} + showDivider={!isLast} + /> + ); + })} + </ItemGroup> + )} + + {usePickerSearch && searchVariant === 'group' && shouldRenderSuggestedGroup && ( + <ItemGroup title={t('newSession.pathPicker.suggestedTitle')}> + {effectiveGroupSearchPlacement === 'suggested' && ( + <SearchHeader + value={searchQuery} + onChangeText={setSearchQuery} + placeholder={t('newSession.searchPathsPlaceholder')} + inputRef={searchInputRef} + onFocus={() => { searchWasFocusedRef.current = true; }} + onBlur={() => { searchWasFocusedRef.current = false; }} + containerStyle={styles.searchHeaderContainer} + /> + )} + {filteredSuggestedPaths.length === 0 + ? ( + <Item + title={showNoMatchesRow ? t('common.noMatches') : t('newSession.pathPicker.emptySuggested')} + showChevron={false} + showDivider={false} + disabled={true} + /> + ) + : filteredSuggestedPaths.map((path, index) => { + const isSelected = selectedPath.trim() === path; + const isLast = index === filteredSuggestedPaths.length - 1; + const isFavorite = favoritePaths.includes(path); + return ( + <Item + key={path} + title={path} + leftElement={<Ionicons name="folder-outline" size={24} color={theme.colors.textSecondary} />} + onPress={() => setPathAndFocus(path)} + selected={isSelected} + showChevron={false} + rightElement={renderRightElement(path, isSelected, isFavorite)} + showDivider={!isLast} + /> + ); + })} + </ItemGroup> + )} + + {filteredRecentPaths.length === 0 && filteredSuggestedPaths.length > 0 && searchVariant !== 'group' && ( + <ItemGroup title={t('newSession.pathPicker.suggestedTitle')}> + {filteredSuggestedPaths.map((path, index) => { + const isSelected = selectedPath.trim() === path; + const isLast = index === filteredSuggestedPaths.length - 1; + const isFavorite = favoritePaths.includes(path); + return ( + <Item + key={path} + title={path} + leftElement={<Ionicons name="folder-outline" size={24} color={theme.colors.textSecondary} />} + onPress={() => setPathAndFocus(path)} + selected={isSelected} + showChevron={false} + rightElement={renderRightElement(path, isSelected, isFavorite)} + showDivider={!isLast} + /> + ); + })} + </ItemGroup> + )} + + {usePickerSearch && searchVariant === 'group' && shouldRenderFallbackGroup && ( + <ItemGroup title={t('newSession.pathPicker.allTitle')}> + <SearchHeader + value={searchQuery} + onChangeText={setSearchQuery} + placeholder={t('newSession.searchPathsPlaceholder')} + inputRef={searchInputRef} + onFocus={() => { searchWasFocusedRef.current = true; }} + onBlur={() => { searchWasFocusedRef.current = false; }} + containerStyle={styles.searchHeaderContainer} + /> + <Item + title={showNoMatchesRow ? t('common.noMatches') : t('newSession.pathPicker.emptyAll')} + showChevron={false} + showDivider={false} + disabled={true} + /> + </ItemGroup> + )} + </> + ); +} diff --git a/expo-app/sources/components/sessions/new/components/ProfileCompatibilityIcon.tsx b/expo-app/sources/components/sessions/new/components/ProfileCompatibilityIcon.tsx new file mode 100644 index 000000000..630ec71cb --- /dev/null +++ b/expo-app/sources/components/sessions/new/components/ProfileCompatibilityIcon.tsx @@ -0,0 +1,84 @@ +import React from 'react'; +import { Text, View, type ViewStyle } from 'react-native'; +import { StyleSheet, useUnistyles } from 'react-native-unistyles'; +import { Typography } from '@/constants/Typography'; +import type { AIBackendProfile } from '@/sync/settings'; +import { isProfileCompatibleWithAgent } from '@/sync/settings'; +import { getAgentCliGlyph, getAgentCore } from '@/agents/catalog'; +import { useEnabledAgentIds } from '@/agents/useEnabledAgentIds'; + +type Props = { + profile: Pick<AIBackendProfile, 'compatibility' | 'isBuiltIn'>; + size?: number; + style?: ViewStyle; +}; + +const stylesheet = StyleSheet.create((theme) => ({ + container: { + alignItems: 'center', + justifyContent: 'center', + }, + stack: { + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + gap: 0, + }, + glyph: { + color: theme.colors.textSecondary, + ...Typography.default(), + }, +})); + +export function ProfileCompatibilityIcon({ profile, size = 32, style }: Props) { + useUnistyles(); // Subscribe to theme changes for re-render + const styles = stylesheet; + const enabledAgents = useEnabledAgentIds(); + + const glyphs = React.useMemo(() => { + const items: Array<{ key: string; glyph: string; factor: number }> = []; + for (const agentId of enabledAgents) { + if (!isProfileCompatibleWithAgent(profile, agentId)) continue; + const core = getAgentCore(agentId); + items.push({ + key: agentId, + glyph: getAgentCliGlyph(agentId), + factor: core.ui.profileCompatibilityGlyphScale ?? 1.0, + }); + } + if (items.length === 0) items.push({ key: 'none', glyph: '•', factor: 0.85 }); + return items; + }, [enabledAgents, profile.compatibility]); + + const multiScale = glyphs.length === 1 ? 1 : glyphs.length === 2 ? 0.6 : 0.5; + + return ( + <View style={[styles.container, { width: size, height: size }, style]}> + {glyphs.length === 1 ? ( + <Text style={[styles.glyph, { fontSize: Math.round(size * glyphs[0].factor) }]}> + {glyphs[0].glyph} + </Text> + ) : ( + <View style={styles.stack}> + {glyphs.map((item) => { + const fontSize = Math.round(size * multiScale * item.factor); + return ( + <Text + key={item.key} + style={[ + styles.glyph, + { + fontSize, + lineHeight: Math.max(10, Math.round(fontSize * 0.92)), + }, + ]} + > + {item.glyph} + </Text> + ); + })} + </View> + )} + </View> + ); +} diff --git a/expo-app/sources/components/sessions/new/components/WizardSectionHeaderRow.test.ts b/expo-app/sources/components/sessions/new/components/WizardSectionHeaderRow.test.ts new file mode 100644 index 000000000..90ee7656a --- /dev/null +++ b/expo-app/sources/components/sessions/new/components/WizardSectionHeaderRow.test.ts @@ -0,0 +1,32 @@ +import * as React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import renderer, { act } from 'react-test-renderer'; +import { WizardSectionHeaderRow } from './WizardSectionHeaderRow'; + +vi.mock('@expo/vector-icons', () => ({ + Ionicons: 'Ionicons', +})); + +describe('WizardSectionHeaderRow', () => { + it('renders the optional action immediately after the title', () => { + (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + let tree: ReturnType<typeof renderer.create> | null = null; + act(() => { + tree = renderer.create(React.createElement(WizardSectionHeaderRow, { + iconName: 'desktop-outline', + title: 'Select Machine', + action: { + accessibilityLabel: 'Refresh machines', + iconName: 'refresh-outline', + onPress: vi.fn(), + }, + })); + }); + + const rootView = tree!.root.findByType('View' as any); + const children = React.Children.toArray(rootView.props.children) as any[]; + + expect(children.map((c: any) => c.type)).toEqual(['Ionicons', 'Text', 'Pressable']); + expect(children[1].props.children).toBe('Select Machine'); + }); +}); diff --git a/expo-app/sources/components/sessions/new/components/WizardSectionHeaderRow.tsx b/expo-app/sources/components/sessions/new/components/WizardSectionHeaderRow.tsx new file mode 100644 index 000000000..d052c0d1d --- /dev/null +++ b/expo-app/sources/components/sessions/new/components/WizardSectionHeaderRow.tsx @@ -0,0 +1,44 @@ +import * as React from 'react'; +import { Pressable, Text, View } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; + +export type WizardSectionHeaderRowAction = { + accessibilityLabel: string; + iconName: React.ComponentProps<typeof Ionicons>['name']; + iconColor?: string; + onPress: () => void; +}; + +export type WizardSectionHeaderRowProps = { + rowStyle?: any; + iconName: React.ComponentProps<typeof Ionicons>['name']; + iconColor?: string; + title: string; + titleStyle?: any; + action?: WizardSectionHeaderRowAction; +}; + +export const WizardSectionHeaderRow = React.memo((props: WizardSectionHeaderRowProps) => { + return ( + <View style={props.rowStyle}> + <Ionicons name={props.iconName} size={18} color={props.iconColor} /> + <Text style={props.titleStyle}>{props.title}</Text> + {props.action ? ( + <Pressable + onPress={props.action.onPress} + hitSlop={10} + style={{ padding: 2 }} + accessibilityRole="button" + accessibilityLabel={props.action.accessibilityLabel} + > + <Ionicons + name={props.action.iconName} + size={18} + color={props.action.iconColor} + /> + </Pressable> + ) : null} + </View> + ); +}); + diff --git a/expo-app/sources/components/sessions/new/hooks/useCreateNewSession.ts b/expo-app/sources/components/sessions/new/hooks/useCreateNewSession.ts new file mode 100644 index 000000000..a67db0b94 --- /dev/null +++ b/expo-app/sources/components/sessions/new/hooks/useCreateNewSession.ts @@ -0,0 +1,376 @@ +import * as React from 'react'; + +import { t } from '@/text'; +import { Modal } from '@/modal'; +import { sync } from '@/sync/sync'; +import { storage } from '@/sync/storage'; +import { machineSpawnNewSession } from '@/sync/ops'; +import { resolveTerminalSpawnOptions } from '@/sync/terminalSettings'; +import { createWorktree } from '@/utils/createWorktree'; +import { getMissingRequiredConfigEnvVarNames } from '@/utils/profiles/profileConfigRequirements'; +import { getSecretSatisfaction } from '@/utils/secrets/secretSatisfaction'; +import type { SecretChoiceByProfileIdByEnvVarName } from '@/utils/secrets/secretRequirementApply'; +import { clearNewSessionDraft } from '@/sync/persistence'; +import { getBuiltInProfile } from '@/sync/profileUtils'; +import type { AIBackendProfile, SavedSecret, Settings } from '@/sync/settings'; +import { getAgentCore, type AgentId } from '@/agents/catalog'; +import { buildResumeCapabilityOptionsFromUiState, buildSpawnEnvironmentVariablesFromUiState, buildSpawnSessionExtrasFromUiState, getAgentResumeExperimentsFromSettings, getNewSessionPreflightIssues, getResumeRuntimeSupportPrefetchPlan } from '@/agents/catalog'; +import { describeAcpLoadSessionSupport } from '@/agents/acpRuntimeResume'; +import { canAgentResume } from '@/agents/resumeCapabilities'; +import { formatResumeSupportDetailCode } from '@/components/sessions/new/modules/formatResumeSupportDetailCode'; +import { transformProfileToEnvironmentVars } from '@/components/sessions/new/modules/profileHelpers'; +import type { UseMachineEnvPresenceResult } from '@/hooks/useMachineEnvPresence'; +import { getMachineCapabilitiesSnapshot, prefetchMachineCapabilities } from '@/hooks/useMachineCapabilitiesCache'; +import type { PermissionMode, ModelMode } from '@/sync/permissionTypes'; +import { SPAWN_SESSION_ERROR_CODES } from '@happy/protocol'; + +export function useCreateNewSession(params: Readonly<{ + router: { push: (options: any) => void; replace: (path: any, options?: any) => void }; + + selectedMachineId: string | null; + selectedPath: string; + selectedMachine: any; + + setIsCreating: (v: boolean) => void; + setIsResumeSupportChecking: (v: boolean) => void; + + sessionType: 'simple' | 'worktree'; + settings: Settings; + useProfiles: boolean; + selectedProfileId: string | null; + profileMap: Map<string, AIBackendProfile>; + + recentMachinePaths: Array<{ machineId: string; path: string }>; + + agentType: AgentId; + permissionMode: PermissionMode; + modelMode: ModelMode; + + sessionPrompt: string; + resumeSessionId: string; + agentNewSessionOptions?: Record<string, unknown> | null; + + machineEnvPresence: UseMachineEnvPresenceResult; + secrets: SavedSecret[]; + secretBindingsByProfileId: Record<string, Record<string, string>>; + selectedSecretIdByProfileIdByEnvVarName: SecretChoiceByProfileIdByEnvVarName; + sessionOnlySecretValueByProfileIdByEnvVarName: SecretChoiceByProfileIdByEnvVarName; + + selectedMachineCapabilities: any; +}>): Readonly<{ + handleCreateSession: () => void; +}> { + const handleCreateSession = React.useCallback(async () => { + if (!params.selectedMachineId) { + Modal.alert(t('common.error'), t('newSession.noMachineSelected')); + return; + } + if (!params.selectedPath) { + Modal.alert(t('common.error'), t('newSession.noPathSelected')); + return; + } + + params.setIsCreating(true); + + try { + let actualPath = params.selectedPath; + + // Handle worktree creation + if (params.sessionType === 'worktree' && params.settings.experiments === true) { + const worktreeResult = await createWorktree(params.selectedMachineId, params.selectedPath); + + if (!worktreeResult.success) { + if (worktreeResult.error === 'Not a Git repository') { + Modal.alert(t('common.error'), t('newSession.worktree.notGitRepo')); + } else { + Modal.alert(t('common.error'), t('newSession.worktree.failed', { error: worktreeResult.error || 'Unknown error' })); + } + params.setIsCreating(false); + return; + } + + actualPath = worktreeResult.worktreePath; + } + + // Save settings + const updatedPaths = [{ machineId: params.selectedMachineId, path: params.selectedPath }, ...params.recentMachinePaths.filter(rp => rp.machineId !== params.selectedMachineId)].slice(0, 10); + const profilesActive = params.useProfiles; + + // Keep prod session creation behavior unchanged: + // only persist/apply profiles & model when an explicit opt-in flag is enabled. + const settingsUpdate: Parameters<typeof sync.applySettings>[0] = { + recentMachinePaths: updatedPaths, + lastUsedAgent: params.agentType, + lastUsedPermissionMode: params.permissionMode, + }; + if (profilesActive) { + settingsUpdate.lastUsedProfile = params.selectedProfileId; + } + sync.applySettings(settingsUpdate); + + // Get environment variables from selected profile + let environmentVariables = undefined; + if (profilesActive && params.selectedProfileId) { + const selectedProfile = params.profileMap.get(params.selectedProfileId) || getBuiltInProfile(params.selectedProfileId); + if (selectedProfile) { + environmentVariables = transformProfileToEnvironmentVars(selectedProfile); + + // Spawn-time secret injection overlay (saved key / session-only key) + const selectedSecretIdByEnvVarName = params.selectedSecretIdByProfileIdByEnvVarName[params.selectedProfileId] ?? {}; + const sessionOnlySecretValueByEnvVarName = params.sessionOnlySecretValueByProfileIdByEnvVarName[params.selectedProfileId] ?? {}; + const machineEnvReadyByName = Object.fromEntries( + Object.entries(params.machineEnvPresence.meta ?? {}).map(([k, v]) => [k, Boolean(v?.isSet)]), + ); + + if (params.machineEnvPresence.isPreviewEnvSupported && !params.machineEnvPresence.isLoading) { + const missingConfig = getMissingRequiredConfigEnvVarNames(selectedProfile, machineEnvReadyByName); + if (missingConfig.length > 0) { + Modal.alert( + t('common.error'), + t('profiles.requirements.missingConfigForProfile', { env: missingConfig.join(', ') }) + ); + params.setIsCreating(false); + return; + } + } + + const satisfaction = getSecretSatisfaction({ + profile: selectedProfile, + secrets: params.secrets, + defaultBindings: params.secretBindingsByProfileId[params.selectedProfileId] ?? null, + selectedSecretIds: selectedSecretIdByEnvVarName, + sessionOnlyValues: sessionOnlySecretValueByEnvVarName, + machineEnvReadyByName, + }); + + if (!satisfaction.isSatisfied) { + // If not satisfied, prompt the user to resolve secrets. + // Note: The wizard already encourages resolving before creating; this is a last-resort guard. + Modal.alert(t('common.error'), t('profiles.requirements.modalBody')); + params.setIsCreating(false); + return; + } + + // Inject any secrets that were satisfied via saved key or session-only. + // Machine-env satisfied secrets are not injected (daemon will resolve from its env). + for (const item of satisfaction.items) { + if (!item.isSatisfied) continue; + let injected: string | null = null; + + if (item.satisfiedBy === 'sessionOnly') { + injected = sessionOnlySecretValueByEnvVarName[item.envVarName] ?? null; + } else if ( + item.satisfiedBy === 'selectedSaved' || + item.satisfiedBy === 'rememberedSaved' || + item.satisfiedBy === 'defaultSaved' + ) { + const id = item.savedSecretId; + const secret = id ? (params.secrets.find((k) => k.id === id) ?? null) : null; + injected = sync.decryptSecretValue(secret?.encryptedValue ?? null); + } + + if (typeof injected === 'string' && injected.length > 0) { + environmentVariables = { + ...environmentVariables, + [item.envVarName]: injected, + }; + } + } + } + } + + environmentVariables = buildSpawnEnvironmentVariablesFromUiState({ + agentId: params.agentType, + environmentVariables, + newSessionOptions: params.agentNewSessionOptions, + }); + + const terminal = resolveTerminalSpawnOptions({ + settings: storage.getState().settings, + machineId: params.selectedMachineId, + }); + + const machineCapsSnapshot = getMachineCapabilitiesSnapshot(params.selectedMachineId); + const machineCapsResults = machineCapsSnapshot?.response.results as any; + const experiments = getAgentResumeExperimentsFromSettings(params.agentType, params.settings); + const preflightIssues = getNewSessionPreflightIssues({ + agentId: params.agentType, + experiments, + resumeSessionId: params.resumeSessionId, + results: machineCapsResults, + }); + const blockingIssue = preflightIssues[0] ?? null; + if (blockingIssue) { + const openMachine = await Modal.confirm( + t(blockingIssue.titleKey), + t(blockingIssue.messageKey), + { confirmText: t(blockingIssue.confirmTextKey) } + ); + if (openMachine && blockingIssue.action === 'openMachine') { + params.router.push(`/machine/${params.selectedMachineId}` as any); + } + params.setIsCreating(false); + return; + } + + const resumeDecision = await (async (): Promise<{ resume?: string; reason?: string }> => { + const wanted = params.resumeSessionId.trim(); + if (!wanted) return {}; + + const computeOptions = (results: any) => buildResumeCapabilityOptionsFromUiState({ settings: params.settings, results }); + + const snapshot = getMachineCapabilitiesSnapshot(params.selectedMachineId!); + const results = snapshot?.response.results as any; + let options = computeOptions(results); + + if (!canAgentResume(params.agentType, options)) { + const plan = getResumeRuntimeSupportPrefetchPlan({ agentId: params.agentType, settings: params.settings, results }); + if (plan) { + params.setIsResumeSupportChecking(true); + try { + await prefetchMachineCapabilities({ + machineId: params.selectedMachineId!, + request: plan.request, + timeoutMs: plan.timeoutMs, + }); + } catch { + // Non-blocking: we'll fall back to starting a new session if resume is still gated. + } finally { + params.setIsResumeSupportChecking(false); + } + + const snapshot2 = getMachineCapabilitiesSnapshot(params.selectedMachineId!); + const results2 = snapshot2?.response.results as any; + options = computeOptions(results2); + } + } + + if (canAgentResume(params.agentType, options)) return { resume: wanted }; + + const snapshotFinal = getMachineCapabilitiesSnapshot(params.selectedMachineId!); + const resultsFinal = snapshotFinal?.response.results as any; + const desc = describeAcpLoadSessionSupport(params.agentType, resultsFinal); + const detailLines: string[] = []; + if (desc.code) { + detailLines.push(formatResumeSupportDetailCode(desc.code)); + } + if (desc.rawMessage) { + detailLines.push(desc.rawMessage); + } + const detail = detailLines.length > 0 ? `\n\n${t('common.details')}: ${detailLines.join('\n')}` : ''; + return { reason: `${t('newSession.resume.cannotApplyBody')}${detail}` }; + })(); + + if (params.resumeSessionId.trim() && !resumeDecision.resume) { + const proceed = await Modal.confirm( + t('session.resumeFailed'), + resumeDecision.reason ?? t('newSession.resume.cannotApplyBody'), + { confirmText: t('common.continue') }, + ); + if (!proceed) { + params.setIsCreating(false); + return; + } + } + + const result = await machineSpawnNewSession({ + machineId: params.selectedMachineId, + directory: actualPath, + approvedNewDirectoryCreation: true, + agent: params.agentType, + profileId: profilesActive ? (params.selectedProfileId ?? '') : undefined, + environmentVariables, + resume: resumeDecision.resume, + ...buildSpawnSessionExtrasFromUiState({ + agentId: params.agentType, + settings: params.settings, + resumeSessionId: params.resumeSessionId, + }), + terminal, + }); + + if (result.type === 'success' && result.sessionId) { + // Clear draft state on successful session creation + clearNewSessionDraft(); + + await sync.refreshSessions(); + + // Set permission mode and model mode on the session + storage.getState().updateSessionPermissionMode(result.sessionId, params.permissionMode); + if (getAgentCore(params.agentType).model.supportsSelection && params.modelMode && params.modelMode !== 'default') { + storage.getState().updateSessionModelMode(result.sessionId, params.modelMode); + } + + // Send initial message if provided + if (params.sessionPrompt.trim()) { + await sync.sendMessage(result.sessionId, params.sessionPrompt); + } + + params.router.replace(`/session/${result.sessionId}`, { + dangerouslySingular() { + return 'session' + }, + }); + } else if (result.type === 'requestToApproveDirectoryCreation') { + Modal.alert(t('common.error'), t('newSession.failedToStart')); + params.setIsCreating(false); + } else if (result.type === 'error') { + const extraDetail = (() => { + switch (result.errorCode) { + case SPAWN_SESSION_ERROR_CODES.RESUME_NOT_SUPPORTED: + return 'Resume is not supported for this agent on this machine.'; + case SPAWN_SESSION_ERROR_CODES.CHILD_EXITED_BEFORE_WEBHOOK: + return 'The agent process exited before it could connect. Check that the agent CLI is installed and available to the daemon (PATH).'; + case SPAWN_SESSION_ERROR_CODES.SESSION_WEBHOOK_TIMEOUT: + return 'Session startup timed out. The machine may be slow or the agent CLI may be stuck starting.'; + default: + return null; + } + })(); + const detail = extraDetail ? `\n\n${t('common.details')}: ${extraDetail}` : ''; + Modal.alert(t('common.error'), `${result.errorMessage}${detail}`); + params.setIsCreating(false); + } else { + throw new Error('Session spawning failed - no session ID returned.'); + } + } catch (error) { + console.error('Failed to start session', error); + let errorMessage = 'Failed to start session. Make sure the daemon is running on the target machine.'; + if (error instanceof Error) { + if (error.message.includes('timeout')) { + errorMessage = 'Session startup timed out. The machine may be slow or the daemon may not be responding.'; + } else if (error.message.includes('Socket not connected')) { + errorMessage = 'Not connected to server. Check your internet connection.'; + } + } + Modal.alert(t('common.error'), errorMessage); + params.setIsCreating(false); + } + }, [ + params.agentType, + params.machineEnvPresence.meta, + params.modelMode, + params.permissionMode, + params.profileMap, + params.recentMachinePaths, + params.resumeSessionId, + params.router, + params.agentNewSessionOptions, + params.settings, + params.secretBindingsByProfileId, + params.secrets, + params.selectedMachineCapabilities, + params.selectedSecretIdByProfileIdByEnvVarName, + params.selectedMachineId, + params.selectedPath, + params.selectedProfileId, + params.sessionOnlySecretValueByProfileIdByEnvVarName, + params.sessionPrompt, + params.sessionType, + params.setIsCreating, + params.setIsResumeSupportChecking, + params.useProfiles, + ]); + + return { handleCreateSession }; +} diff --git a/expo-app/sources/components/sessions/new/hooks/useNewSessionCapabilitiesPrefetch.ts b/expo-app/sources/components/sessions/new/hooks/useNewSessionCapabilitiesPrefetch.ts new file mode 100644 index 000000000..4d2e0c384 --- /dev/null +++ b/expo-app/sources/components/sessions/new/hooks/useNewSessionCapabilitiesPrefetch.ts @@ -0,0 +1,74 @@ +import * as React from 'react'; +import { InteractionManager } from 'react-native'; + +export function useNewSessionCapabilitiesPrefetch(params: Readonly<{ + enabled: boolean; + machines: ReadonlyArray<{ id: string }>; + favoriteMachineItems: ReadonlyArray<{ id: string }>; + recentMachines: ReadonlyArray<{ id: string }>; + selectedMachineId: string | null; + isMachineOnline: (machine: any) => boolean; + staleMs: number; + request: any; + prefetchMachineCapabilitiesIfStale: (args: { machineId: string; staleMs: number; request: any }) => Promise<any> | void; +}>): void { + // One-time prefetch of machine capabilities for the wizard machine list. + // This keeps machine glyphs responsive (cache-only in the list) without + // triggering per-row auto-detect work during taps. + const didPrefetchWizardMachineGlyphsRef = React.useRef(false); + React.useEffect(() => { + if (!params.enabled) return; + if (didPrefetchWizardMachineGlyphsRef.current) return; + didPrefetchWizardMachineGlyphsRef.current = true; + + InteractionManager.runAfterInteractions(() => { + try { + const candidates: string[] = []; + for (const m of params.favoriteMachineItems) candidates.push(m.id); + for (const m of params.recentMachines) candidates.push(m.id); + for (const m of params.machines.slice(0, 8)) candidates.push(m.id); + + const seen = new Set<string>(); + const unique = candidates.filter((id) => { + if (seen.has(id)) return false; + seen.add(id); + return true; + }); + + // Limit to avoid a thundering herd on iOS. + const toPrefetch = unique.slice(0, 12); + for (const machineId of toPrefetch) { + const machine = params.machines.find((m) => m.id === machineId); + if (!machine) continue; + if (!params.isMachineOnline(machine)) continue; + void params.prefetchMachineCapabilitiesIfStale({ + machineId, + staleMs: params.staleMs, + request: params.request, + }); + } + } catch { + // best-effort prefetch only + } + }); + }, [params.favoriteMachineItems, params.machines, params.recentMachines, params.enabled]); + + // Cache-first + background refresh: for the actively selected machine, prefetch capabilities + // if missing or stale. This updates the banners/agent availability on screen open, but avoids + // any fetches on tap handlers. + React.useEffect(() => { + if (!params.selectedMachineId) return; + const machine = params.machines.find((m) => m.id === params.selectedMachineId); + if (!machine) return; + if (!params.isMachineOnline(machine)) return; + + InteractionManager.runAfterInteractions(() => { + void params.prefetchMachineCapabilitiesIfStale({ + machineId: params.selectedMachineId!, + staleMs: params.staleMs, + request: params.request, + }); + }); + }, [params.machines, params.selectedMachineId]); +} + diff --git a/expo-app/sources/components/sessions/new/hooks/useNewSessionDraftAutoPersist.ts b/expo-app/sources/components/sessions/new/hooks/useNewSessionDraftAutoPersist.ts new file mode 100644 index 000000000..eadabd4da --- /dev/null +++ b/expo-app/sources/components/sessions/new/hooks/useNewSessionDraftAutoPersist.ts @@ -0,0 +1,33 @@ +import * as React from 'react'; +import { InteractionManager, Platform } from 'react-native'; + +export function useNewSessionDraftAutoPersist(params: Readonly<{ + persistDraftNow: () => void; +}>): void { + // Persist the current wizard state so it survives remounts and screen navigation + // Uses debouncing to avoid excessive writes + const draftSaveTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null); + React.useEffect(() => { + if (draftSaveTimerRef.current) { + clearTimeout(draftSaveTimerRef.current); + } + const delayMs = Platform.OS === 'web' ? 250 : 900; + draftSaveTimerRef.current = setTimeout(() => { + // Persisting uses synchronous storage under the hood (MMKV), which can block the JS thread on iOS. + // Run after interactions so taps/animations stay responsive. + if (Platform.OS === 'web') { + params.persistDraftNow(); + } else { + InteractionManager.runAfterInteractions(() => { + params.persistDraftNow(); + }); + } + }, delayMs); + return () => { + if (draftSaveTimerRef.current) { + clearTimeout(draftSaveTimerRef.current); + } + }; + }, [params.persistDraftNow]); +} + diff --git a/expo-app/sources/components/sessions/new/hooks/useNewSessionScreenModel.ts b/expo-app/sources/components/sessions/new/hooks/useNewSessionScreenModel.ts new file mode 100644 index 000000000..b5a45e931 --- /dev/null +++ b/expo-app/sources/components/sessions/new/hooks/useNewSessionScreenModel.ts @@ -0,0 +1,1650 @@ +import React from 'react'; +import { View, Platform, useWindowDimensions } from 'react-native'; +import { useAllMachines, storage, useSetting, useSettingMutable, useSettings } from '@/sync/storage'; +import { useRouter, useLocalSearchParams, useNavigation, usePathname } from 'expo-router'; +import { useUnistyles } from 'react-native-unistyles'; +import { t } from '@/text'; +import { useHeaderHeight } from '@/utils/responsive'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { Modal } from '@/modal'; +import { sync } from '@/sync/sync'; +import { getTempData, type NewSessionData } from '@/utils/tempDataStore'; +import type { PermissionMode, ModelMode } from '@/sync/permissionTypes'; +import { mapPermissionModeAcrossAgents } from '@/sync/permissionMapping'; +import { readAccountPermissionDefaults, resolveNewSessionDefaultPermissionMode } from '@/sync/permissionDefaults'; +import { AIBackendProfile, getProfileEnvironmentVariables, isProfileCompatibleWithAgent } from '@/sync/settings'; +import { getBuiltInProfile, DEFAULT_PROFILES, getProfilePrimaryCli, getProfileSupportedAgentIds, isProfileCompatibleWithAnyAgent } from '@/sync/profileUtils'; +import { useCLIDetection } from '@/hooks/useCLIDetection'; +import { DEFAULT_AGENT_ID, getAgentCore, isAgentId, resolveAgentIdFromCliDetectKey, type AgentId } from '@/agents/catalog'; +import { useEnabledAgentIds } from '@/agents/useEnabledAgentIds'; +import { applyCliWarningDismissal, isCliWarningDismissed } from '@/agents/cliWarnings'; + +import { isMachineOnline } from '@/utils/machineUtils'; +import { loadNewSessionDraft, saveNewSessionDraft } from '@/sync/persistence'; +import { EnvironmentVariablesPreviewModal } from '@/components/sessions/new/components/EnvironmentVariablesPreviewModal'; +import { consumeProfileIdParam, consumeSecretIdParam } from '@/profileRouteParams'; +import { getModelOptionsForAgentType } from '@/sync/modelOptions'; +import { useFocusEffect } from '@react-navigation/native'; +import { getRecentPathsForMachine } from '@/utils/sessions/recentPaths'; +import { useMachineEnvPresence } from '@/hooks/useMachineEnvPresence'; +import { InteractionManager } from 'react-native'; +import { getMachineCapabilitiesSnapshot, prefetchMachineCapabilities, prefetchMachineCapabilitiesIfStale, useMachineCapabilitiesCache } from '@/hooks/useMachineCapabilitiesCache'; +import { CAPABILITIES_REQUEST_NEW_SESSION } from '@/capabilities/requests'; +import { getInstallableDepRegistryEntries } from '@/capabilities/installableDepsRegistry'; +import { resolveTerminalSpawnOptions } from '@/sync/terminalSettings'; +import type { CapabilityId } from '@/sync/capabilitiesProtocol'; +import { + buildResumeCapabilityOptionsFromUiState, + getAgentResumeExperimentsFromSettings, + getAllowExperimentalResumeByAgentIdFromUiState, + buildNewSessionOptionsFromUiState, + getNewSessionAgentInputExtraActionChips, + getNewSessionRelevantInstallableDepKeys, + getResumeRuntimeSupportPrefetchPlan, +} from '@/agents/catalog'; +import type { SecretChoiceByProfileIdByEnvVarName } from '@/utils/secrets/secretRequirementApply'; +import { getSecretSatisfaction } from '@/utils/secrets/secretSatisfaction'; +import { useKeyboardHeight } from '@/hooks/useKeyboardHeight'; +import { computeNewSessionInputMaxHeight } from '@/components/sessions/agentInput/inputMaxHeight'; +import { useProfileMap, transformProfileToEnvironmentVars } from '@/components/sessions/new/modules/profileHelpers'; +import { newSessionScreenStyles } from '@/components/sessions/new/newSessionScreenStyles'; +import { useSecretRequirementFlow } from '@/components/sessions/new/hooks/useSecretRequirementFlow'; +import { useNewSessionCapabilitiesPrefetch } from '@/components/sessions/new/hooks/useNewSessionCapabilitiesPrefetch'; +import { useNewSessionDraftAutoPersist } from '@/components/sessions/new/hooks/useNewSessionDraftAutoPersist'; +import { useCreateNewSession } from '@/components/sessions/new/hooks/useCreateNewSession'; +import { useNewSessionWizardProps } from '@/components/sessions/new/hooks/useNewSessionWizardProps'; + +// Configuration constants +const RECENT_PATHS_DEFAULT_VISIBLE = 5; +const styles = newSessionScreenStyles; + +export type NewSessionScreenModel = + | Readonly<{ + variant: 'simple'; + popoverBoundaryRef: React.RefObject<View>; + simpleProps: any; + }> + | Readonly<{ + variant: 'wizard'; + popoverBoundaryRef: React.RefObject<View>; + wizardProps: Readonly<{ + layout: any; + profiles: any; + agent: any; + machine: any; + footer: any; + }>; + }>; + +export function useNewSessionScreenModel(): NewSessionScreenModel { + const { theme, rt } = useUnistyles(); + const router = useRouter(); + const navigation = useNavigation(); + const pathname = usePathname(); + const safeArea = useSafeAreaInsets(); + const headerHeight = useHeaderHeight(); + const { width: screenWidth, height: screenHeight } = useWindowDimensions(); + const keyboardHeight = useKeyboardHeight(); + const selectedIndicatorColor = rt.themeName === 'dark' ? theme.colors.text : theme.colors.button.primary.background; + const popoverBoundaryRef = React.useRef<View>(null!); + + const newSessionSidePadding = 16; + const newSessionBottomPadding = Math.max(screenWidth < 420 ? 8 : 16, safeArea.bottom); + const { + prompt, + dataId, + machineId: machineIdParam, + path: pathParam, + profileId: profileIdParam, + resumeSessionId: resumeSessionIdParam, + secretId: secretIdParam, + secretSessionOnlyId, + secretRequirementResultId, + } = useLocalSearchParams<{ + prompt?: string; + dataId?: string; + machineId?: string; + path?: string; + profileId?: string; + resumeSessionId?: string; + secretId?: string; + secretSessionOnlyId?: string; + secretRequirementResultId?: string; + }>(); + + // Try to get data from temporary store first + const tempSessionData = React.useMemo(() => { + if (dataId) { + return getTempData<NewSessionData>(dataId); + } + return null; + }, [dataId]); + + // Load persisted draft state (survives remounts/screen navigation) + const persistedDraft = React.useRef(loadNewSessionDraft()).current; + + const [resumeSessionId, setResumeSessionId] = React.useState(() => { + if (typeof tempSessionData?.resumeSessionId === 'string') { + return tempSessionData.resumeSessionId; + } + if (typeof persistedDraft?.resumeSessionId === 'string') { + return persistedDraft.resumeSessionId; + } + return typeof resumeSessionIdParam === 'string' ? resumeSessionIdParam : ''; + }); + + const [agentNewSessionOptionStateByAgentId, setAgentNewSessionOptionStateByAgentId] = React.useState< + Partial<Record<AgentId, Record<string, unknown>>> + >(() => { + const raw = (persistedDraft as any)?.agentNewSessionOptionStateByAgentId; + return raw && typeof raw === 'object' ? (raw as any) : {}; + }); + + // Settings and state + const recentMachinePaths = useSetting('recentMachinePaths'); + const lastUsedAgent = useSetting('lastUsedAgent'); + const lastUsedPermissionMode = useSetting('lastUsedPermissionMode'); + + // A/B Test Flag - determines which wizard UI to show + // Control A (false): Simpler AgentInput-driven layout + // Variant B (true): Enhanced profile-first wizard with sections + const useEnhancedSessionWizard = useSetting('useEnhancedSessionWizard'); + + const previousHappyRouteRef = React.useRef<string | undefined>(undefined); + const hasCapturedPreviousHappyRouteRef = React.useRef(false); + React.useEffect(() => { + if (Platform.OS !== 'web') return; + if (typeof document === 'undefined') return; + + const root = document.documentElement; + if (!hasCapturedPreviousHappyRouteRef.current) { + previousHappyRouteRef.current = root.dataset.happyRoute; + hasCapturedPreviousHappyRouteRef.current = true; + } + + const previous = previousHappyRouteRef.current; + if (pathname === '/new') { + root.dataset.happyRoute = 'new'; + } else { + if (previous === undefined) { + delete root.dataset.happyRoute; + } else { + root.dataset.happyRoute = previous; + } + } + return () => { + if (pathname !== '/new') return; + if (root.dataset.happyRoute !== 'new') return; + if (previous === undefined) { + delete root.dataset.happyRoute; + } else { + root.dataset.happyRoute = previous; + } + }; + }, [pathname]); + + const sessionPromptInputMaxHeight = React.useMemo(() => { + return computeNewSessionInputMaxHeight({ + useEnhancedSessionWizard, + screenHeight, + keyboardHeight, + }); + }, [keyboardHeight, screenHeight, useEnhancedSessionWizard]); + const useProfiles = useSetting('useProfiles'); + const [secrets, setSecrets] = useSettingMutable('secrets'); + const [secretBindingsByProfileId, setSecretBindingsByProfileId] = useSettingMutable('secretBindingsByProfileId'); + const sessionDefaultPermissionModeByAgent = useSetting('sessionDefaultPermissionModeByAgent'); + const settings = useSettings(); + const experimentsEnabled = settings.experiments; + const experimentalAgents = useSetting('experimentalAgents'); + const expSessionType = useSetting('expSessionType'); + const resumeCapabilityOptions = React.useMemo(() => { + return buildResumeCapabilityOptionsFromUiState({ + settings, + results: undefined, + }); + }, [settings]); + const useMachinePickerSearch = useSetting('useMachinePickerSearch'); + const usePathPickerSearch = useSetting('usePathPickerSearch'); + const [profiles, setProfiles] = useSettingMutable('profiles'); + const lastUsedProfile = useSetting('lastUsedProfile'); + const [favoriteDirectories, setFavoriteDirectories] = useSettingMutable('favoriteDirectories'); + const [favoriteMachines, setFavoriteMachines] = useSettingMutable('favoriteMachines'); + const [favoriteProfileIds, setFavoriteProfileIds] = useSettingMutable('favoriteProfiles'); + const [dismissedCLIWarnings, setDismissedCLIWarnings] = useSettingMutable('dismissedCLIWarnings'); + const terminalUseTmux = useSetting('sessionUseTmux'); + const terminalTmuxByMachineId = useSetting('sessionTmuxByMachineId'); + + const enabledAgentIds = useEnabledAgentIds(); + + useFocusEffect( + React.useCallback(() => { + // Ensure newly-registered machines show up without requiring an app restart. + // Throttled to avoid spamming the server when navigating back/forth. + // Defer until after interactions so the screen feels instant on iOS. + InteractionManager.runAfterInteractions(() => { + void sync.refreshMachinesThrottled({ staleMs: 15_000 }); + }); + }, []) + ); + + // (prefetch effect moved below, after machines/recent/favorites are defined) + + // Combined profiles (built-in + custom) + const allProfiles = React.useMemo(() => { + const builtInProfiles = DEFAULT_PROFILES.map(bp => getBuiltInProfile(bp.id)!); + return [...builtInProfiles, ...profiles]; + }, [profiles]); + + const profileMap = useProfileMap(allProfiles); + const machines = useAllMachines(); + + // Wizard state + const [selectedProfileId, setSelectedProfileId] = React.useState<string | null>(() => { + if (!useProfiles) { + return null; + } + const draftProfileId = persistedDraft?.selectedProfileId; + if (draftProfileId && profileMap.has(draftProfileId)) { + return draftProfileId; + } + if (lastUsedProfile && profileMap.has(lastUsedProfile)) { + return lastUsedProfile; + } + // Default to "no profile" so default session creation remains unchanged. + return null; + }); + + /** + * Per-profile per-env-var secret selections for the current flow (multi-secret). + * This allows the user to resolve secrets for multiple profiles without switching selection. + * + * - value === '' means “prefer machine env” for that env var (disallow default saved). + * - value === savedSecretId means “use saved secret” + * - null/undefined means “no explicit choice yet” + */ + const [selectedSecretIdByProfileIdByEnvVarName, setSelectedSecretIdByProfileIdByEnvVarName] = React.useState<SecretChoiceByProfileIdByEnvVarName>(() => { + const raw = persistedDraft?.selectedSecretIdByProfileIdByEnvVarName; + if (!raw || typeof raw !== 'object') return {}; + const out: SecretChoiceByProfileIdByEnvVarName = {}; + for (const [profileId, byEnv] of Object.entries(raw)) { + if (!byEnv || typeof byEnv !== 'object') continue; + const inner: Record<string, string | null> = {}; + for (const [envVarName, v] of Object.entries(byEnv as any)) { + if (v === null) inner[envVarName] = null; + else if (typeof v === 'string') inner[envVarName] = v; + } + if (Object.keys(inner).length > 0) out[profileId] = inner; + } + return out; + }); + /** + * Session-only secrets (never persisted in plaintext), keyed by profileId then env var name. + */ + const [sessionOnlySecretValueByProfileIdByEnvVarName, setSessionOnlySecretValueByProfileIdByEnvVarName] = React.useState<SecretChoiceByProfileIdByEnvVarName>(() => { + const raw = persistedDraft?.sessionOnlySecretValueEncByProfileIdByEnvVarName; + if (!raw || typeof raw !== 'object') return {}; + const out: SecretChoiceByProfileIdByEnvVarName = {}; + for (const [profileId, byEnv] of Object.entries(raw)) { + if (!byEnv || typeof byEnv !== 'object') continue; + const inner: Record<string, string | null> = {}; + for (const [envVarName, enc] of Object.entries(byEnv as any)) { + const decrypted = enc ? sync.decryptSecretValue(enc as any) : null; + if (typeof decrypted === 'string' && decrypted.trim().length > 0) { + inner[envVarName] = decrypted; + } + } + if (Object.keys(inner).length > 0) out[profileId] = inner; + } + return out; + }); + + const prevProfileIdBeforeSecretPromptRef = React.useRef<string | null>(null); + const lastSecretPromptKeyRef = React.useRef<string | null>(null); + const suppressNextSecretAutoPromptKeyRef = React.useRef<string | null>(null); + const isSecretRequirementModalOpenRef = React.useRef(false); + + const getSessionOnlySecretValueEncByProfileIdByEnvVarName = React.useCallback(() => { + const out: Record<string, Record<string, any>> = {}; + for (const [profileId, byEnv] of Object.entries(sessionOnlySecretValueByProfileIdByEnvVarName)) { + if (!byEnv || typeof byEnv !== 'object') continue; + for (const [envVarName, value] of Object.entries(byEnv)) { + const v = typeof value === 'string' ? value.trim() : ''; + if (!v) continue; + const enc = sync.encryptSecretValue(v); + if (!enc) continue; + if (!out[profileId]) out[profileId] = {}; + out[profileId]![envVarName] = enc; + } + } + return Object.keys(out).length > 0 ? out : null; + }, [sessionOnlySecretValueByProfileIdByEnvVarName]); + + React.useEffect(() => { + if (!useProfiles && selectedProfileId !== null) { + setSelectedProfileId(null); + } + }, [useProfiles, selectedProfileId]); + + React.useEffect(() => { + if (!useProfiles) return; + if (!selectedProfileId) return; + const selected = profileMap.get(selectedProfileId) ?? getBuiltInProfile(selectedProfileId); + if (!selected) { + setSelectedProfileId(null); + return; + } + if (isProfileCompatibleWithAnyAgent(selected, enabledAgentIds)) return; + setSelectedProfileId(null); + }, [enabledAgentIds, profileMap, selectedProfileId, useProfiles]); + + // AgentInput autocomplete is unused on this screen today, but passing a new + // function/array each render forces autocomplete hooks to re-sync. + // Keep these stable to avoid unnecessary work during taps/selection changes. + const emptyAutocompletePrefixes = React.useMemo(() => [], []); + const emptyAutocompleteSuggestions = React.useCallback(async () => [], []); + + const [agentType, setAgentType] = React.useState<AgentId>(() => { + const fromTemp = tempSessionData?.agentType; + if (isAgentId(fromTemp) && enabledAgentIds.includes(fromTemp)) { + return fromTemp; + } + if (isAgentId(lastUsedAgent) && enabledAgentIds.includes(lastUsedAgent)) { + return lastUsedAgent; + } + return enabledAgentIds[0] ?? DEFAULT_AGENT_ID; + }); + + React.useEffect(() => { + if (enabledAgentIds.includes(agentType)) return; + setAgentType(enabledAgentIds[0] ?? DEFAULT_AGENT_ID); + }, [agentType, enabledAgentIds]); + + // Agent cycling handler (cycles through enabled agents) + // Note: Does NOT persist immediately - persistence is handled by useEffect below + const handleAgentCycle = React.useCallback(() => { + setAgentType(prev => { + const enabled = enabledAgentIds; + if (enabled.length === 0) return prev; + const idx = enabled.indexOf(prev); + if (idx < 0) return enabled[0] ?? prev; + return enabled[(idx + 1) % enabled.length] ?? prev; + }); + }, [enabledAgentIds]); + + // Persist agent selection changes, but avoid no-op writes (especially on initial mount). + // `sync.applySettings()` triggers a server POST, so only write when it actually changed. + React.useEffect(() => { + if (lastUsedAgent === agentType) return; + sync.applySettings({ lastUsedAgent: agentType }); + }, [agentType, lastUsedAgent]); + + const [sessionType, setSessionType] = React.useState<'simple' | 'worktree'>('simple'); + const [permissionMode, setPermissionMode] = React.useState<PermissionMode>(() => { + const accountDefaults = readAccountPermissionDefaults(sessionDefaultPermissionModeByAgent, enabledAgentIds); + + // If a profile is pre-selected (e.g. from draft), use its override; otherwise fall back to account defaults. + const profile = selectedProfileId ? (profileMap.get(selectedProfileId) || getBuiltInProfile(selectedProfileId)) : null; + + return resolveNewSessionDefaultPermissionMode({ + agentType, + accountDefaults, + profileDefaults: profile ? profile.defaultPermissionModeByAgent : null, + legacyProfileDefaultPermissionMode: (profile?.defaultPermissionMode as PermissionMode | undefined) ?? undefined, + }); + }); + + // NOTE: Permission mode reset on agentType change is handled by the validation useEffect below (lines ~670-681) + // which intelligently resets only when the current mode is invalid for the new agent type. + // A duplicate unconditional reset here was removed to prevent race conditions. + + const [modelMode, setModelMode] = React.useState<ModelMode>(() => { + const core = getAgentCore(agentType); + const draftMode = typeof persistedDraft?.modelMode === 'string' ? persistedDraft.modelMode : null; + if (draftMode && (core.model.allowedModes as readonly string[]).includes(draftMode)) { + return draftMode as ModelMode; + } + return core.model.defaultMode; + }); + const modelOptions = React.useMemo(() => getModelOptionsForAgentType(agentType), [agentType]); + + // Session details state + const [selectedMachineId, setSelectedMachineId] = React.useState<string | null>(() => { + if (machines.length > 0) { + if (recentMachinePaths.length > 0) { + for (const recent of recentMachinePaths) { + if (machines.find(m => m.id === recent.machineId)) { + return recent.machineId; + } + } + } + return machines[0].id; + } + return null; + }); + + const allProfilesRequirementNames = React.useMemo(() => { + const names = new Set<string>(); + for (const p of allProfiles) { + for (const req of p.envVarRequirements ?? []) { + const name = typeof req?.name === 'string' ? req.name : ''; + if (name) names.add(name); + } + } + return Array.from(names); + }, [allProfiles]); + + const machineEnvPresence = useMachineEnvPresence( + selectedMachineId ?? null, + allProfilesRequirementNames, + { ttlMs: 5 * 60_000 }, + ); + const refreshMachineEnvPresence = machineEnvPresence.refresh; + + const getBestPathForMachine = React.useCallback((machineId: string | null): string => { + if (!machineId) return ''; + const recent = getRecentPathsForMachine({ + machineId, + recentMachinePaths, + sessions: null, + }); + if (recent.length > 0) return recent[0]!; + const machine = machines.find((m) => m.id === machineId); + return machine?.metadata?.homeDir ?? ''; + }, [machines, recentMachinePaths]); + + const hasUserSelectedPermissionModeRef = React.useRef(false); + const permissionModeRef = React.useRef(permissionMode); + React.useEffect(() => { + permissionModeRef.current = permissionMode; + }, [permissionMode]); + + const applyPermissionMode = React.useCallback((mode: PermissionMode, source: 'user' | 'auto') => { + setPermissionMode((prev) => (prev === mode ? prev : mode)); + if (source === 'user') { + sync.applySettings({ lastUsedPermissionMode: mode }); + hasUserSelectedPermissionModeRef.current = true; + } + }, []); + + const handlePermissionModeChange = React.useCallback((mode: PermissionMode) => { + applyPermissionMode(mode, 'user'); + }, [applyPermissionMode]); + + // + // Path selection + // + + const [selectedPath, setSelectedPath] = React.useState<string>(() => { + return getBestPathForMachine(selectedMachineId); + }); + const [sessionPrompt, setSessionPrompt] = React.useState(() => { + return tempSessionData?.prompt || prompt || persistedDraft?.input || ''; + }); + const [isCreating, setIsCreating] = React.useState(false); + const [isResumeSupportChecking, setIsResumeSupportChecking] = React.useState(false); + + // Handle machineId route param from picker screens (main's navigation pattern) + React.useEffect(() => { + if (typeof machineIdParam !== 'string' || machines.length === 0) { + return; + } + if (!machines.some(m => m.id === machineIdParam)) { + return; + } + if (machineIdParam !== selectedMachineId) { + setSelectedMachineId(machineIdParam); + const bestPath = getBestPathForMachine(machineIdParam); + setSelectedPath(bestPath); + } + }, [machineIdParam, machines, recentMachinePaths, selectedMachineId]); + + // Ensure a machine is pre-selected once machines have loaded (wizard expects this). + React.useEffect(() => { + if (selectedMachineId !== null) { + return; + } + if (machines.length === 0) { + return; + } + + let machineIdToUse: string | null = null; + if (recentMachinePaths.length > 0) { + for (const recent of recentMachinePaths) { + if (machines.find(m => m.id === recent.machineId)) { + machineIdToUse = recent.machineId; + break; + } + } + } + if (!machineIdToUse) { + machineIdToUse = machines[0].id; + } + + setSelectedMachineId(machineIdToUse); + setSelectedPath(getBestPathForMachine(machineIdToUse)); + }, [machines, recentMachinePaths, selectedMachineId]); + + // Handle path route param from picker screens (main's navigation pattern) + React.useEffect(() => { + if (typeof pathParam !== 'string') { + return; + } + const trimmedPath = pathParam.trim(); + if (trimmedPath && trimmedPath !== selectedPath) { + setSelectedPath(trimmedPath); + } + }, [pathParam, selectedPath]); + + // Handle resumeSessionId param from the resume picker screen + React.useEffect(() => { + if (typeof resumeSessionIdParam !== 'string') { + return; + } + setResumeSessionId(resumeSessionIdParam); + }, [resumeSessionIdParam]); + + // Path selection state - initialize with formatted selected path + + // CLI Detection - automatic, non-blocking detection of installed CLIs on selected machine + const cliAvailability = useCLIDetection(selectedMachineId, { autoDetect: false }); + const { state: selectedMachineCapabilities } = useMachineCapabilitiesCache({ + machineId: selectedMachineId, + enabled: false, + request: CAPABILITIES_REQUEST_NEW_SESSION, + }); + + const tmuxRequested = React.useMemo(() => { + return Boolean(resolveTerminalSpawnOptions({ + settings: storage.getState().settings, + machineId: selectedMachineId, + })); + }, [selectedMachineId, terminalTmuxByMachineId, terminalUseTmux]); + + const selectedMachineCapabilitiesSnapshot = React.useMemo(() => { + return selectedMachineCapabilities.status === 'loaded' + ? selectedMachineCapabilities.snapshot + : selectedMachineCapabilities.status === 'loading' + ? selectedMachineCapabilities.snapshot + : selectedMachineCapabilities.status === 'error' + ? selectedMachineCapabilities.snapshot + : undefined; + }, [selectedMachineCapabilities]); + + const resumeCapabilityOptionsResolved = React.useMemo(() => { + return buildResumeCapabilityOptionsFromUiState({ + settings, + results: selectedMachineCapabilitiesSnapshot?.response.results as any, + }); + }, [selectedMachineCapabilitiesSnapshot, settings]); + + const showResumePicker = React.useMemo(() => { + const core = getAgentCore(agentType); + if (core.resume.supportsVendorResume !== true) { + return core.resume.runtimeGate !== null; + } + if (core.resume.experimental !== true) return true; + const allowExperimental = getAllowExperimentalResumeByAgentIdFromUiState(settings); + return allowExperimental[agentType] === true; + }, [agentType, settings]); + + const wizardInstallableDeps = React.useMemo(() => { + if (!selectedMachineId) return []; + if (experimentsEnabled !== true) return []; + if (cliAvailability.available[agentType] !== true) return []; + + const experiments = getAgentResumeExperimentsFromSettings(agentType, settings); + const relevantKeys = getNewSessionRelevantInstallableDepKeys({ + agentId: agentType, + experiments, + resumeSessionId, + }); + if (relevantKeys.length === 0) return []; + + const entries = getInstallableDepRegistryEntries().filter((e) => relevantKeys.includes(e.key)); + const results = selectedMachineCapabilitiesSnapshot?.response.results; + return entries.map((entry) => { + const depStatus = entry.getDepStatus(results); + const detectResult = entry.getDetectResult(results); + return { entry, depStatus, detectResult }; + }); + }, [ + agentType, + cliAvailability.available, + experimentsEnabled, + settings, + resumeSessionId, + selectedMachineCapabilitiesSnapshot, + selectedMachineId, + ]); + + React.useEffect(() => { + if (!selectedMachineId) return; + if (!experimentsEnabled) return; + if (wizardInstallableDeps.length === 0) return; + + const machine = machines.find((m) => m.id === selectedMachineId); + if (!machine || !isMachineOnline(machine)) return; + + const requests = wizardInstallableDeps + .filter((d) => + d.entry.shouldPrefetchRegistry({ requireExistingResult: true, result: d.detectResult, data: d.depStatus }), + ) + .flatMap((d) => d.entry.buildRegistryDetectRequest().requests ?? []); + + if (requests.length === 0) return; + + InteractionManager.runAfterInteractions(() => { + void prefetchMachineCapabilities({ + machineId: selectedMachineId, + request: { requests }, + timeoutMs: 12_000, + }); + }); + }, [experimentsEnabled, machines, selectedMachineId, wizardInstallableDeps]); + + React.useEffect(() => { + const results = selectedMachineCapabilitiesSnapshot?.response.results as any; + const plan = getResumeRuntimeSupportPrefetchPlan({ agentId: agentType, settings, results }); + if (!plan) return; + if (!selectedMachineId) return; + const machine = machines.find((m) => m.id === selectedMachineId); + if (!machine || !isMachineOnline(machine)) return; + + InteractionManager.runAfterInteractions(() => { + void prefetchMachineCapabilities({ + machineId: selectedMachineId, + request: plan.request, + timeoutMs: plan.timeoutMs, + }); + }); + }, [agentType, experimentsEnabled, machines, selectedMachineCapabilitiesSnapshot, selectedMachineId, settings]); + + // Auto-correct invalid agent selection after CLI detection completes + // This handles the case where lastUsedAgent was 'codex' but codex is not installed + React.useEffect(() => { + // Only act when detection has completed (timestamp > 0) + if (cliAvailability.timestamp === 0) return; + + const agentAvailable = cliAvailability.available[agentType]; + + if (agentAvailable !== false) return; + + const firstInstalled = enabledAgentIds.find((id) => cliAvailability.available[id] === true); + const fallback = enabledAgentIds[0] ?? DEFAULT_AGENT_ID; + const nextAgent = firstInstalled ?? fallback; + setAgentType(nextAgent); + }, [ + cliAvailability.timestamp, + cliAvailability.available, + agentType, + enabledAgentIds, + ]); + + const [hiddenCliWarningKeys, setHiddenCliWarningKeys] = React.useState<Record<string, boolean>>({}); + + const isCliBannerDismissed = React.useCallback((agentId: AgentId): boolean => { + const warningKey = getAgentCore(agentId).cli.detectKey; + if (hiddenCliWarningKeys[warningKey] === true) return true; + return isCliWarningDismissed({ dismissed: dismissedCLIWarnings as any, machineId: selectedMachineId, warningKey }); + }, [dismissedCLIWarnings, hiddenCliWarningKeys, selectedMachineId]); + + const dismissCliBanner = React.useCallback((agentId: AgentId, scope: 'machine' | 'global' | 'temporary') => { + const warningKey = getAgentCore(agentId).cli.detectKey; + if (scope === 'temporary') { + setHiddenCliWarningKeys((prev) => ({ ...prev, [warningKey]: true })); + return; + } + setDismissedCLIWarnings( + applyCliWarningDismissal({ + dismissed: dismissedCLIWarnings as any, + machineId: selectedMachineId, + warningKey, + scope, + }) as any, + ); + }, [dismissedCLIWarnings, selectedMachineId, setDismissedCLIWarnings]); + + // Helper to check if profile is available (CLI detected + experiments gating) + const isProfileAvailable = React.useCallback((profile: AIBackendProfile): { available: boolean; reason?: string } => { + const allowedCLIs = getProfileSupportedAgentIds(profile).filter((agentId) => enabledAgentIds.includes(agentId)); + + if (allowedCLIs.length === 0) { + return { + available: false, + reason: 'no-supported-cli', + }; + } + + // If a profile requires exactly one CLI, enforce that one. + if (allowedCLIs.length === 1) { + const requiredCLI = allowedCLIs[0]; + if (cliAvailability.available[requiredCLI] === false) { + return { + available: false, + reason: `cli-not-detected:${requiredCLI}`, + }; + } + return { available: true }; + } + + // Multi-CLI profiles: available if *any* supported CLI is available (or detection not finished). + const anyAvailable = allowedCLIs.some((cli) => cliAvailability.available[cli] !== false); + if (!anyAvailable) { + return { + available: false, + reason: 'cli-not-detected:any', + }; + } + return { available: true }; + }, [cliAvailability, enabledAgentIds]); + + const profileAvailabilityById = React.useMemo(() => { + const map = new Map<string, { available: boolean; reason?: string }>(); + for (const profile of allProfiles) { + map.set(profile.id, isProfileAvailable(profile)); + } + return map; + }, [allProfiles, isProfileAvailable]); + + // Computed values + const compatibleProfiles = React.useMemo(() => { + return allProfiles.filter((profile) => isProfileCompatibleWithAgent(profile, agentType)); + }, [allProfiles, agentType]); + + const selectedProfile = React.useMemo(() => { + if (!selectedProfileId) { + return null; + } + // Check custom profiles first + if (profileMap.has(selectedProfileId)) { + return profileMap.get(selectedProfileId)!; + } + // Check built-in profiles + return getBuiltInProfile(selectedProfileId); + }, [selectedProfileId, profileMap]); + + // NOTE: we intentionally do NOT clear per-profile secret overrides when profile changes. + // Users may resolve secrets for multiple profiles and then switch between them before creating a session. + + const selectedMachine = React.useMemo(() => { + if (!selectedMachineId) return null; + return machines.find(m => m.id === selectedMachineId); + }, [selectedMachineId, machines]); + + const secretRequirements = React.useMemo(() => { + const reqs = selectedProfile?.envVarRequirements ?? []; + return reqs + .filter((r) => (r?.kind ?? 'secret') === 'secret') + .map((r) => ({ name: r.name, required: r.required === true })) + .filter((r) => typeof r.name === 'string' && r.name.length > 0) as Array<{ name: string; required: boolean }>; + }, [selectedProfile]); + const shouldShowSecretSection = secretRequirements.length > 0; + + const { openSecretRequirementModal } = useSecretRequirementFlow({ + router, + navigation, + useProfiles, + selectedProfileId, + selectedProfile, + setSelectedProfileId, + shouldShowSecretSection, + selectedMachineId, + machineEnvPresence, + secrets, + setSecrets, + secretBindingsByProfileId, + setSecretBindingsByProfileId, + selectedSecretIdByProfileIdByEnvVarName, + setSelectedSecretIdByProfileIdByEnvVarName, + sessionOnlySecretValueByProfileIdByEnvVarName, + setSessionOnlySecretValueByProfileIdByEnvVarName, + secretRequirementResultId: typeof secretRequirementResultId === 'string' ? secretRequirementResultId : undefined, + prevProfileIdBeforeSecretPromptRef, + lastSecretPromptKeyRef, + suppressNextSecretAutoPromptKeyRef, + isSecretRequirementModalOpenRef, + }); + + // Legacy convenience: treat the first required secret (or first secret) as the “primary” secret for + // older single-secret UI paths (e.g. route params, draft persistence). Multi-secret enforcement uses + // the full maps + `getSecretSatisfaction`. + const primarySecretEnvVarName = React.useMemo(() => { + const required = secretRequirements.find((r) => r.required)?.name ?? null; + return required ?? (secretRequirements[0]?.name ?? null); + }, [secretRequirements]); + + const selectedSecretId = React.useMemo(() => { + if (!primarySecretEnvVarName) return null; + if (!selectedProfileId) return null; + const v = (selectedSecretIdByProfileIdByEnvVarName[selectedProfileId] ?? {})[primarySecretEnvVarName]; + return typeof v === 'string' ? v : null; + }, [primarySecretEnvVarName, selectedProfileId, selectedSecretIdByProfileIdByEnvVarName]); + + const setSelectedSecretId = React.useCallback((next: string | null) => { + if (!primarySecretEnvVarName) return; + if (!selectedProfileId) return; + setSelectedSecretIdByProfileIdByEnvVarName((prev) => ({ + ...prev, + [selectedProfileId]: { + ...(prev[selectedProfileId] ?? {}), + [primarySecretEnvVarName]: next, + }, + })); + }, [primarySecretEnvVarName, selectedProfileId]); + + const sessionOnlySecretValue = React.useMemo(() => { + if (!primarySecretEnvVarName) return null; + if (!selectedProfileId) return null; + const v = (sessionOnlySecretValueByProfileIdByEnvVarName[selectedProfileId] ?? {})[primarySecretEnvVarName]; + return typeof v === 'string' ? v : null; + }, [primarySecretEnvVarName, selectedProfileId, sessionOnlySecretValueByProfileIdByEnvVarName]); + + const setSessionOnlySecretValue = React.useCallback((next: string | null) => { + if (!primarySecretEnvVarName) return; + if (!selectedProfileId) return; + setSessionOnlySecretValueByProfileIdByEnvVarName((prev) => ({ + ...prev, + [selectedProfileId]: { + ...(prev[selectedProfileId] ?? {}), + [primarySecretEnvVarName]: next, + }, + })); + }, [primarySecretEnvVarName, selectedProfileId]); + + const refreshMachineData = React.useCallback(() => { + // Treat this as “refresh machine-related data”: + // - machine list from server (new machines / metadata updates) + // - CLI detection cache for selected machine (glyphs + login/availability) + // - machine env presence preflight cache (API key env var presence) + void sync.refreshMachinesThrottled({ staleMs: 0, force: true }); + refreshMachineEnvPresence(); + + if (selectedMachineId) { + void prefetchMachineCapabilities({ machineId: selectedMachineId, request: CAPABILITIES_REQUEST_NEW_SESSION }); + } + }, [refreshMachineEnvPresence, selectedMachineId, sync]); + + const selectedSavedSecret = React.useMemo(() => { + if (!selectedSecretId) return null; + return secrets.find((k) => k.id === selectedSecretId) ?? null; + }, [secrets, selectedSecretId]); + + React.useEffect(() => { + if (!selectedProfileId) return; + if (selectedSecretId !== null) return; + if (!primarySecretEnvVarName) return; + const nextDefault = secretBindingsByProfileId[selectedProfileId]?.[primarySecretEnvVarName] ?? null; + if (typeof nextDefault === 'string' && nextDefault.length > 0) { + setSelectedSecretId(nextDefault); + } + }, [primarySecretEnvVarName, secretBindingsByProfileId, selectedSecretId, selectedProfileId]); + + const activeSecretSource = sessionOnlySecretValue + ? 'sessionOnly' + : selectedSecretId + ? 'saved' + : 'machineEnv'; + + const openProfileEdit = React.useCallback((params: { profileId?: string; cloneFromProfileId?: string }) => { + // Persisting can block the JS thread on iOS (MMKV). Navigation should be instant, + // so we persist after the navigation transition. + const draft = { + input: sessionPrompt, + selectedMachineId, + selectedPath, + selectedProfileId: useProfiles ? selectedProfileId : null, + selectedSecretId, + selectedSecretIdByProfileIdByEnvVarName, + sessionOnlySecretValueEncByProfileIdByEnvVarName: getSessionOnlySecretValueEncByProfileIdByEnvVarName(), + agentType, + permissionMode, + modelMode, + sessionType, + updatedAt: Date.now(), + }; + + router.push({ + pathname: '/new/pick/profile-edit', + params: { + ...params, + ...(selectedMachineId ? { machineId: selectedMachineId } : {}), + }, + } as any); + + InteractionManager.runAfterInteractions(() => { + saveNewSessionDraft(draft); + }); + }, [ + agentType, + getSessionOnlySecretValueEncByProfileIdByEnvVarName, + modelMode, + permissionMode, + router, + selectedMachineId, + selectedPath, + selectedProfileId, + selectedSecretId, + selectedSecretIdByProfileIdByEnvVarName, + sessionPrompt, + sessionType, + useProfiles, + ]); + + const handleAddProfile = React.useCallback(() => { + openProfileEdit({}); + }, [openProfileEdit]); + + const handleDuplicateProfile = React.useCallback((profile: AIBackendProfile) => { + openProfileEdit({ cloneFromProfileId: profile.id }); + }, [openProfileEdit]); + + const handleDeleteProfile = React.useCallback((profile: AIBackendProfile) => { + Modal.alert( + t('profiles.delete.title'), + t('profiles.delete.message', { name: profile.name }), + [ + { text: t('profiles.delete.cancel'), style: 'cancel' }, + { + text: t('profiles.delete.confirm'), + style: 'destructive', + onPress: () => { + const updatedProfiles = profiles.filter(p => p.id !== profile.id); + setProfiles(updatedProfiles); + if (selectedProfileId === profile.id) { + setSelectedProfileId(null); + } + }, + }, + ], + ); + }, [profiles, selectedProfileId, setProfiles]); + + // Get recent paths for the selected machine + // Recent machines computed from recentMachinePaths (lightweight; avoids subscribing to sessions updates) + const recentMachines = React.useMemo(() => { + if (machines.length === 0) return []; + if (!recentMachinePaths || recentMachinePaths.length === 0) return []; + + const byId = new Map(machines.map((m) => [m.id, m] as const)); + const seen = new Set<string>(); + const result: typeof machines = []; + for (const entry of recentMachinePaths) { + if (seen.has(entry.machineId)) continue; + const m = byId.get(entry.machineId); + if (!m) continue; + seen.add(entry.machineId); + result.push(m); + } + return result; + }, [machines, recentMachinePaths]); + + const favoriteMachineItems = React.useMemo(() => { + return machines.filter(m => favoriteMachines.includes(m.id)); + }, [machines, favoriteMachines]); + + // Background refresh on open: pick up newly-installed CLIs without fetching on taps. + // Keep this fairly conservative to avoid impacting iOS responsiveness. + const CLI_DETECT_REVALIDATE_STALE_MS = 2 * 60 * 1000; // 2 minutes + useNewSessionCapabilitiesPrefetch({ + enabled: useEnhancedSessionWizard, + machines, + favoriteMachineItems, + recentMachines, + selectedMachineId, + isMachineOnline, + staleMs: CLI_DETECT_REVALIDATE_STALE_MS, + request: CAPABILITIES_REQUEST_NEW_SESSION, + prefetchMachineCapabilitiesIfStale, + }); + + const recentPaths = React.useMemo(() => { + if (!selectedMachineId) return []; + return getRecentPathsForMachine({ + machineId: selectedMachineId, + recentMachinePaths, + sessions: null, + }); + }, [recentMachinePaths, selectedMachineId]); + + // Validation + const canCreate = React.useMemo(() => { + return selectedMachineId !== null && selectedPath.trim() !== ''; + }, [selectedMachineId, selectedPath]); + + // On iOS, keep tap handlers extremely light so selection state can commit instantly. + // We defer any follow-up adjustments (agent/session-type/permission defaults) until after interactions. + const pendingProfileSelectionRef = React.useRef<{ profileId: string; prevProfileId: string | null } | null>(null); + + const selectProfile = React.useCallback((profileId: string) => { + const prevSelectedProfileId = selectedProfileId; + prevProfileIdBeforeSecretPromptRef.current = prevSelectedProfileId; + // Ensure selecting a profile can re-prompt if needed. + lastSecretPromptKeyRef.current = null; + pendingProfileSelectionRef.current = { profileId, prevProfileId: prevSelectedProfileId }; + setSelectedProfileId(profileId); + }, [selectedProfileId]); + + React.useEffect(() => { + if (!selectedProfileId) return; + const pending = pendingProfileSelectionRef.current; + if (!pending || pending.profileId !== selectedProfileId) return; + pendingProfileSelectionRef.current = null; + + InteractionManager.runAfterInteractions(() => { + // Ensure nothing changed while we waited. + if (selectedProfileId !== pending.profileId) return; + + const profile = profileMap.get(pending.profileId) || getBuiltInProfile(pending.profileId); + if (!profile) return; + + const supportedAgents = getProfileSupportedAgentIds(profile).filter((agentId) => enabledAgentIds.includes(agentId)); + + if (supportedAgents.length > 0 && !supportedAgents.includes(agentType)) { + setAgentType(supportedAgents[0] ?? (enabledAgentIds[0] ?? agentType)); + } + + if (profile.defaultSessionType) { + setSessionType(profile.defaultSessionType); + } + + if (!hasUserSelectedPermissionModeRef.current) { + const accountDefaults = readAccountPermissionDefaults(sessionDefaultPermissionModeByAgent, enabledAgentIds); + const nextMode = resolveNewSessionDefaultPermissionMode({ + agentType, + accountDefaults, + profileDefaults: profile.defaultPermissionModeByAgent, + legacyProfileDefaultPermissionMode: (profile.defaultPermissionMode as PermissionMode | undefined) ?? undefined, + }); + applyPermissionMode(nextMode, 'auto'); + } + }); + }, [ + agentType, + applyPermissionMode, + experimentsEnabled, + experimentalAgents, + profileMap, + selectedProfileId, + sessionDefaultPermissionModeByAgent, + ]); + + // Keep ProfilesList props stable to avoid rerendering the whole list on + // unrelated state updates (iOS perf). + const profilesGroupTitles = React.useMemo(() => { + return { + favorites: t('profiles.groups.favorites'), + custom: t('profiles.groups.custom'), + builtIn: t('profiles.groups.builtIn'), + }; + }, []); + + const getProfileDisabled = React.useCallback((profile: { id: string }) => { + return !(profileAvailabilityById.get(profile.id) ?? { available: true }).available; + }, [profileAvailabilityById]); + + const getProfileSubtitleExtra = React.useCallback((profile: { id: string }) => { + const availability = profileAvailabilityById.get(profile.id) ?? { available: true }; + if (availability.available || !availability.reason) return null; + if (availability.reason.startsWith('requires-agent:')) { + const required = availability.reason.split(':')[1]; + const agentLabel = isAgentId(required) ? t(getAgentCore(required).displayNameKey) : required; + return t('newSession.profileAvailability.requiresAgent', { agent: agentLabel }); + } + if (availability.reason.startsWith('cli-not-detected:')) { + const cli = availability.reason.split(':')[1]; + const agentFromCli = resolveAgentIdFromCliDetectKey(cli); + const cliLabel = agentFromCli ? t(getAgentCore(agentFromCli).displayNameKey) : cli; + return t('newSession.profileAvailability.cliNotDetected', { cli: cliLabel }); + } + return availability.reason; + }, [profileAvailabilityById]); + + const onPressProfile = React.useCallback((profile: { id: string }) => { + const availability = profileAvailabilityById.get(profile.id) ?? { available: true }; + if (!availability.available) return; + selectProfile(profile.id); + }, [profileAvailabilityById, selectProfile]); + + const onPressDefaultEnvironment = React.useCallback(() => { + setSelectedProfileId(null); + }, []); + + // Handle profile route param from picker screens + React.useEffect(() => { + if (!useProfiles) { + return; + } + + const { nextSelectedProfileId, shouldClearParam } = consumeProfileIdParam({ + profileIdParam, + selectedProfileId, + }); + + if (nextSelectedProfileId === null) { + if (selectedProfileId !== null) { + setSelectedProfileId(null); + } + } else if (typeof nextSelectedProfileId === 'string') { + selectProfile(nextSelectedProfileId); + } + + if (shouldClearParam) { + const setParams = (navigation as any)?.setParams; + if (typeof setParams === 'function') { + setParams({ profileId: undefined }); + } else { + navigation.dispatch({ + type: 'SET_PARAMS', + payload: { params: { profileId: undefined } }, + } as never); + } + } + }, [navigation, profileIdParam, selectedProfileId, selectProfile, useProfiles]); + + // Handle secret route param from picker screens + React.useEffect(() => { + const { nextSelectedSecretId, shouldClearParam } = consumeSecretIdParam({ + secretIdParam, + selectedSecretId, + }); + + if (nextSelectedSecretId === null) { + if (selectedSecretId !== null) { + setSelectedSecretId(null); + } + } else if (typeof nextSelectedSecretId === 'string') { + setSelectedSecretId(nextSelectedSecretId); + } + + if (shouldClearParam) { + const setParams = (navigation as any)?.setParams; + if (typeof setParams === 'function') { + setParams({ secretId: undefined }); + } else { + navigation.dispatch({ + type: 'SET_PARAMS', + payload: { params: { secretId: undefined } }, + } as never); + } + } + }, [navigation, secretIdParam, selectedSecretId]); + + // Handle session-only secret temp id from picker screens (value is stored in-memory only). + React.useEffect(() => { + if (typeof secretSessionOnlyId !== 'string' || secretSessionOnlyId.length === 0) { + return; + } + + const entry = getTempData<{ secret?: string }>(secretSessionOnlyId); + const value = entry?.secret; + if (typeof value === 'string' && value.length > 0) { + setSessionOnlySecretValue(value); + setSelectedSecretId(null); + } + + const setParams = (navigation as any)?.setParams; + if (typeof setParams === 'function') { + setParams({ secretSessionOnlyId: undefined }); + } else { + navigation.dispatch({ + type: 'SET_PARAMS', + payload: { params: { secretSessionOnlyId: undefined } }, + } as never); + } + }, [navigation, secretSessionOnlyId]); + + // Keep agentType compatible with the currently selected profile. + React.useEffect(() => { + if (!useProfiles || selectedProfileId === null) { + return; + } + + const profile = profileMap.get(selectedProfileId) || getBuiltInProfile(selectedProfileId); + if (!profile) { + return; + } + + const supportedAgents = getProfileSupportedAgentIds(profile).filter((agentId) => enabledAgentIds.includes(agentId)); + + if (supportedAgents.length > 0 && !supportedAgents.includes(agentType)) { + setAgentType(supportedAgents[0]!); + } + }, [agentType, enabledAgentIds, profileMap, selectedProfileId, useProfiles]); + + const prevAgentTypeRef = React.useRef(agentType); + + // When agent type changes, keep the "permission level" consistent by mapping modes across backends. + React.useEffect(() => { + const prev = prevAgentTypeRef.current; + if (prev === agentType) { + return; + } + prevAgentTypeRef.current = agentType; + + // Defaults should only apply in the new-session flow (not in existing sessions), + // and only if the user hasn't explicitly chosen a mode on this screen. + if (!hasUserSelectedPermissionModeRef.current) { + const profile = selectedProfileId ? (profileMap.get(selectedProfileId) || getBuiltInProfile(selectedProfileId)) : null; + const accountDefaults = readAccountPermissionDefaults(sessionDefaultPermissionModeByAgent, enabledAgentIds); + const nextMode = resolveNewSessionDefaultPermissionMode({ + agentType, + accountDefaults, + profileDefaults: profile ? profile.defaultPermissionModeByAgent : null, + legacyProfileDefaultPermissionMode: (profile?.defaultPermissionMode as PermissionMode | undefined) ?? undefined, + }); + applyPermissionMode(nextMode, 'auto'); + return; + } + + const current = permissionModeRef.current; + const mapped = mapPermissionModeAcrossAgents(current, prev, agentType); + applyPermissionMode(mapped, 'auto'); + }, [ + agentType, + applyPermissionMode, + profileMap, + selectedProfileId, + sessionDefaultPermissionModeByAgent, + ]); + + // Reset model mode when agent type changes to appropriate default + React.useEffect(() => { + const core = getAgentCore(agentType); + if ((core.model.allowedModes as readonly ModelMode[]).includes(modelMode)) return; + setModelMode(core.model.defaultMode); + }, [agentType, modelMode]); + + const openProfileEnvVarsPreview = React.useCallback((profile: AIBackendProfile) => { + Modal.show({ + component: EnvironmentVariablesPreviewModal, + props: { + environmentVariables: getProfileEnvironmentVariables(profile), + machineId: selectedMachineId, + machineName: selectedMachine?.metadata?.displayName || selectedMachine?.metadata?.host, + profileName: profile.name, + }, + }); + }, [selectedMachine, selectedMachineId]); + + const handleMachineClick = React.useCallback(() => { + router.push({ + pathname: '/new/pick/machine', + params: selectedMachineId ? { selectedId: selectedMachineId } : {}, + }); + }, [router, selectedMachineId]); + + const handleProfileClick = React.useCallback(() => { + router.push({ + pathname: '/new/pick/profile', + params: { + ...(selectedProfileId ? { selectedId: selectedProfileId } : {}), + ...(selectedMachineId ? { machineId: selectedMachineId } : {}), + }, + }); + }, [router, selectedMachineId, selectedProfileId]); + + const handleAgentClick = React.useCallback(() => { + if (useProfiles && selectedProfileId !== null) { + const profile = profileMap.get(selectedProfileId) || getBuiltInProfile(selectedProfileId); + const supportedAgents = profile + ? getProfileSupportedAgentIds(profile).filter((agentId) => enabledAgentIds.includes(agentId)) + : []; + + if (supportedAgents.length <= 1) { + Modal.alert( + t('profiles.aiBackend.title'), + t('newSession.aiBackendSelectedByProfile'), + [ + { text: t('common.ok'), style: 'cancel' }, + { text: t('newSession.changeProfile'), onPress: handleProfileClick }, + ], + ); + return; + } + + const currentIndex = supportedAgents.indexOf(agentType); + const nextIndex = (currentIndex + 1) % supportedAgents.length; + setAgentType(supportedAgents[nextIndex] ?? supportedAgents[0] ?? DEFAULT_AGENT_ID); + return; + } + + handleAgentCycle(); + }, [ + agentType, + enabledAgentIds, + handleAgentCycle, + handleProfileClick, + profileMap, + selectedProfileId, + setAgentType, + useProfiles, + ]); + + const handlePathClick = React.useCallback(() => { + if (selectedMachineId) { + router.push({ + pathname: '/new/pick/path', + params: { + machineId: selectedMachineId, + selectedPath, + }, + }); + } + }, [selectedMachineId, selectedPath, router]); + + const handleResumeClick = React.useCallback(() => { + router.push({ + pathname: '/new/pick/resume' as any, + params: { + currentResumeId: resumeSessionId, + agentType, + }, + }); + }, [router, resumeSessionId, agentType]); + + const selectedProfileForEnvVars = React.useMemo(() => { + if (!useProfiles || !selectedProfileId) return null; + return profileMap.get(selectedProfileId) || getBuiltInProfile(selectedProfileId) || null; + }, [profileMap, selectedProfileId, useProfiles]); + + const selectedProfileEnvVars = React.useMemo(() => { + if (!selectedProfileForEnvVars) return {}; + return transformProfileToEnvironmentVars(selectedProfileForEnvVars) ?? {}; + }, [selectedProfileForEnvVars]); + + const selectedProfileEnvVarsCount = React.useMemo(() => { + return Object.keys(selectedProfileEnvVars).length; + }, [selectedProfileEnvVars]); + + const handleEnvVarsClick = React.useCallback(() => { + if (!selectedProfileForEnvVars) return; + Modal.show({ + component: EnvironmentVariablesPreviewModal, + props: { + environmentVariables: selectedProfileEnvVars, + machineId: selectedMachineId, + machineName: selectedMachine?.metadata?.displayName || selectedMachine?.metadata?.host, + profileName: selectedProfileForEnvVars.name, + }, + }); + }, [selectedMachine, selectedMachineId, selectedProfileEnvVars, selectedProfileForEnvVars]); + + const agentOptionState = agentNewSessionOptionStateByAgentId[agentType] ?? null; + const agentNewSessionOptions = React.useMemo(() => { + return buildNewSessionOptionsFromUiState({ agentId: agentType, agentOptionState }); + }, [agentOptionState, agentType]); + + const { handleCreateSession } = useCreateNewSession({ + router, + selectedMachineId, + selectedPath, + selectedMachine, + setIsCreating, + setIsResumeSupportChecking, + sessionType, + settings, + useProfiles, + selectedProfileId, + profileMap, + recentMachinePaths, + agentType, + permissionMode, + modelMode, + sessionPrompt, + resumeSessionId, + agentNewSessionOptions, + machineEnvPresence, + secrets, + secretBindingsByProfileId, + selectedSecretIdByProfileIdByEnvVarName, + sessionOnlySecretValueByProfileIdByEnvVarName, + selectedMachineCapabilities, + }); + + const handleCloseModal = React.useCallback(() => { + // On web (especially mobile), `router.back()` can be a no-op if the modal is the first history entry. + // Fall back to home so the user always has an exit. + if (Platform.OS === 'web') { + if (typeof window !== 'undefined' && window.history.length > 1) { + router.back(); + } else { + router.replace('/'); + } + return; + } + + router.back(); + }, [router]); + + // Machine online status for AgentInput (DRY - reused in info box too) + const connectionStatus = React.useMemo(() => { + if (!selectedMachine) return undefined; + const isOnline = isMachineOnline(selectedMachine); + + return { + text: isOnline ? 'online' : 'offline', + color: isOnline ? theme.colors.success : theme.colors.textDestructive, + dotColor: isOnline ? theme.colors.success : theme.colors.textDestructive, + isPulsing: isOnline, + }; + }, [selectedMachine, theme]); + + const setAgentOptionStateForCurrentAgent = React.useCallback((key: string, value: unknown) => { + setAgentNewSessionOptionStateByAgentId((prev) => { + const nextForAgent = { ...(prev[agentType] ?? {}), [key]: value }; + return { ...prev, [agentType]: nextForAgent }; + }); + }, [agentType]); + + const agentInputExtraActionChips = React.useMemo(() => { + return getNewSessionAgentInputExtraActionChips({ + agentId: agentType, + agentOptionState, + setAgentOptionState: setAgentOptionStateForCurrentAgent, + }); + }, [agentOptionState, agentType, setAgentOptionStateForCurrentAgent]); + + const persistDraftNow = React.useCallback(() => { + saveNewSessionDraft({ + input: sessionPrompt, + selectedMachineId, + selectedPath, + selectedProfileId: useProfiles ? selectedProfileId : null, + selectedSecretId, + selectedSecretIdByProfileIdByEnvVarName, + sessionOnlySecretValueEncByProfileIdByEnvVarName: getSessionOnlySecretValueEncByProfileIdByEnvVarName(), + agentType, + permissionMode, + modelMode, + sessionType, + resumeSessionId, + agentNewSessionOptionStateByAgentId, + updatedAt: Date.now(), + }); + }, [ + agentType, + agentNewSessionOptionStateByAgentId, + getSessionOnlySecretValueEncByProfileIdByEnvVarName, + modelMode, + permissionMode, + resumeSessionId, + selectedSecretId, + selectedSecretIdByProfileIdByEnvVarName, + selectedMachineId, + selectedPath, + selectedProfileId, + sessionPrompt, + sessionType, + useProfiles, + ]); + + // Persist the current wizard state so it survives remounts and screen navigation + // Uses debouncing to avoid excessive writes + useNewSessionDraftAutoPersist({ persistDraftNow }); + + // ======================================================================== + // CONTROL A: Simpler AgentInput-driven layout (flag OFF) + // Shows machine/path selection via chips that navigate to picker screens + // ======================================================================== + if (!useEnhancedSessionWizard) { + return { + variant: 'simple', + popoverBoundaryRef, + simpleProps: { + popoverBoundaryRef, + headerHeight, + safeAreaTop: safeArea.top, + safeAreaBottom: safeArea.bottom, + newSessionSidePadding, + newSessionBottomPadding, + containerStyle: styles.container as any, + experimentsEnabled: experimentsEnabled === true, + expSessionType: expSessionType === true, + sessionType, + setSessionType, + sessionPrompt, + setSessionPrompt, + handleCreateSession, + canCreate, + isCreating, + emptyAutocompletePrefixes, + emptyAutocompleteSuggestions, + sessionPromptInputMaxHeight, + agentType, + handleAgentClick, + permissionMode, + handlePermissionModeChange, + modelMode, + setModelMode, + connectionStatus, + machineName: selectedMachine?.metadata?.displayName || selectedMachine?.metadata?.host, + handleMachineClick, + selectedPath, + handlePathClick, + showResumePicker, + resumeSessionId, + handleResumeClick, + isResumeSupportChecking, + agentInputExtraActionChips, + useProfiles, + selectedProfileId, + handleProfileClick, + selectedProfileEnvVarsCount, + handleEnvVarsClick, + }, + }; + } + + // ======================================================================== + // VARIANT B: Enhanced profile-first wizard (flag ON) + // Full wizard with numbered sections, profile management, CLI detection + // ======================================================================== + + const { + layout: wizardLayoutProps, + profiles: wizardProfilesProps, + agent: wizardAgentProps, + machine: wizardMachineProps, + footer: wizardFooterProps, + } = useNewSessionWizardProps({ + theme, + styles, + safeAreaBottom: safeArea.bottom, + headerHeight, + newSessionSidePadding, + newSessionBottomPadding, + + useProfiles, + profiles, + favoriteProfileIds, + setFavoriteProfileIds, + experimentsEnabled, + selectedProfileId, + onPressDefaultEnvironment, + onPressProfile, + selectedMachineId, + getProfileDisabled, + getProfileSubtitleExtra, + handleAddProfile, + openProfileEdit, + handleDuplicateProfile, + handleDeleteProfile, + openProfileEnvVarsPreview, + suppressNextSecretAutoPromptKeyRef, + openSecretRequirementModal, + profilesGroupTitles, + + machineEnvPresence, + secrets, + secretBindingsByProfileId, + selectedSecretIdByProfileIdByEnvVarName, + sessionOnlySecretValueByProfileIdByEnvVarName, + + wizardInstallableDeps, + selectedMachineCapabilities, + + cliAvailability, + tmuxRequested, + enabledAgentIds, + isCliBannerDismissed, + dismissCliBanner, + agentType, + setAgentType, + modelOptions, + modelMode, + setModelMode, + selectedIndicatorColor, + profileMap, + permissionMode, + handlePermissionModeChange, + sessionType, + setSessionType, + + machines, + selectedMachine: selectedMachine ?? null, + recentMachines, + favoriteMachineItems, + useMachinePickerSearch, + refreshMachineData, + setSelectedMachineId, + getBestPathForMachine, + setSelectedPath, + favoriteMachines, + setFavoriteMachines, + selectedPath, + recentPaths, + usePathPickerSearch, + favoriteDirectories, + setFavoriteDirectories, + + sessionPrompt, + setSessionPrompt, + handleCreateSession, + canCreate, + isCreating, + emptyAutocompletePrefixes, + emptyAutocompleteSuggestions, + connectionStatus, + selectedProfileEnvVarsCount, + handleEnvVarsClick, + resumeSessionId, + showResumePicker, + handleResumeClick, + isResumeSupportChecking, + sessionPromptInputMaxHeight, + agentInputExtraActionChips, + }); + + return { + variant: 'wizard', + popoverBoundaryRef, + wizardProps: { + layout: wizardLayoutProps, + profiles: wizardProfilesProps, + agent: wizardAgentProps, + machine: wizardMachineProps, + footer: wizardFooterProps, + }, + }; +} diff --git a/expo-app/sources/components/sessions/new/hooks/useNewSessionWizardProps.ts b/expo-app/sources/components/sessions/new/hooks/useNewSessionWizardProps.ts new file mode 100644 index 000000000..a6d8c940a --- /dev/null +++ b/expo-app/sources/components/sessions/new/hooks/useNewSessionWizardProps.ts @@ -0,0 +1,411 @@ +import * as React from 'react'; + +import type { AgentId } from '@/agents/catalog'; +import { t } from '@/text'; +import { getRequiredSecretEnvVarNames } from '@/sync/profileSecrets'; +import type { AIBackendProfile, SavedSecret } from '@/sync/settings'; +import type { Machine } from '@/sync/storageTypes'; +import type { PermissionMode, ModelMode } from '@/sync/permissionTypes'; +import type { CLIAvailability } from '@/hooks/useCLIDetection'; +import type { UseMachineEnvPresenceResult } from '@/hooks/useMachineEnvPresence'; +import { prefetchMachineCapabilities } from '@/hooks/useMachineCapabilitiesCache'; +import { CAPABILITIES_REQUEST_NEW_SESSION } from '@/capabilities/requests'; +import { getSecretSatisfaction } from '@/utils/secrets/secretSatisfaction'; +import type { SecretChoiceByProfileIdByEnvVarName } from '@/utils/secrets/secretRequirementApply'; + +import type { AgentInputExtraActionChip } from '@/components/sessions/agentInput'; +import type { InstallableDepInstallerProps } from '@/components/machines/InstallableDepInstaller'; +import type { + NewSessionWizardAgentProps, + NewSessionWizardFooterProps, + NewSessionWizardLayoutProps, + NewSessionWizardMachineProps, + NewSessionWizardProfilesProps, +} from '../components/NewSessionWizard'; +import type { CliNotDetectedBannerDismissScope } from '../components/CliNotDetectedBanner'; + +function tNoParams(key: string): string { + return (t as any)(key); +} + +export function useNewSessionWizardProps(params: Readonly<{ + // Layout + theme: any; + styles: any; + safeAreaBottom: number; + headerHeight: number; + newSessionSidePadding: number; + newSessionBottomPadding: number; + + // Profiles section + useProfiles: boolean; + profiles: AIBackendProfile[]; + favoriteProfileIds: string[]; + setFavoriteProfileIds: (ids: string[]) => void; + experimentsEnabled: boolean; + selectedProfileId: string | null; + onPressDefaultEnvironment: () => void; + onPressProfile: (profile: AIBackendProfile) => void; + selectedMachineId: string | null; + getProfileDisabled: (profile: AIBackendProfile) => boolean; + getProfileSubtitleExtra: (profile: AIBackendProfile) => string | null; + handleAddProfile: () => void; + openProfileEdit: (params: { profileId: string }) => void; + handleDuplicateProfile: (profile: AIBackendProfile) => void; + handleDeleteProfile: (profile: AIBackendProfile) => void; + openProfileEnvVarsPreview: (profile: AIBackendProfile) => void; + suppressNextSecretAutoPromptKeyRef: React.MutableRefObject<string | null>; + openSecretRequirementModal: (profile: AIBackendProfile, opts: { revertOnCancel: boolean }) => void; + profilesGroupTitles: { favorites: string; custom: string; builtIn: string }; + + // Secret satisfaction helpers + machineEnvPresence: UseMachineEnvPresenceResult; + secrets: SavedSecret[]; + secretBindingsByProfileId: Record<string, Record<string, string>>; + selectedSecretIdByProfileIdByEnvVarName: SecretChoiceByProfileIdByEnvVarName; + sessionOnlySecretValueByProfileIdByEnvVarName: SecretChoiceByProfileIdByEnvVarName; + + // Installable deps + wizardInstallableDeps: Array<{ entry: any; depStatus: any }>; + selectedMachineCapabilities: { status: any }; + + // Agent section + cliAvailability: CLIAvailability; + tmuxRequested: boolean; + enabledAgentIds: AgentId[]; + isCliBannerDismissed: (agentId: AgentId) => boolean; + dismissCliBanner: (agentId: AgentId, scope: CliNotDetectedBannerDismissScope) => void; + agentType: AgentId; + setAgentType: (agent: AgentId) => void; + modelOptions: ReadonlyArray<{ value: ModelMode; label: string; description: string }>; + modelMode: ModelMode | undefined; + setModelMode: (mode: ModelMode) => void; + selectedIndicatorColor: string; + profileMap: Map<string, AIBackendProfile>; + permissionMode: PermissionMode; + handlePermissionModeChange: (mode: PermissionMode) => void; + sessionType: 'simple' | 'worktree'; + setSessionType: (t: 'simple' | 'worktree') => void; + + // Machine section + machines: Machine[]; + selectedMachine: Machine | null; + recentMachines: Machine[]; + favoriteMachineItems: Machine[]; + useMachinePickerSearch: boolean; + refreshMachineData: () => void; + setSelectedMachineId: (id: string) => void; + getBestPathForMachine: (id: string | null) => string; + setSelectedPath: (path: string) => void; + favoriteMachines: string[]; + setFavoriteMachines: (ids: string[]) => void; + selectedPath: string; + recentPaths: string[]; + usePathPickerSearch: boolean; + favoriteDirectories: string[]; + setFavoriteDirectories: (dirs: string[]) => void; + + // Footer section + sessionPrompt: string; + setSessionPrompt: (v: string) => void; + handleCreateSession: () => void; + canCreate: boolean; + isCreating: boolean; + emptyAutocompletePrefixes: any; + emptyAutocompleteSuggestions: any; + connectionStatus?: any; + selectedProfileEnvVarsCount: number; + handleEnvVarsClick: () => void; + resumeSessionId: string; + showResumePicker: boolean; + handleResumeClick: () => void; + isResumeSupportChecking: boolean; + sessionPromptInputMaxHeight: number; + agentInputExtraActionChips?: ReadonlyArray<AgentInputExtraActionChip>; +}>): Readonly<{ + layout: NewSessionWizardLayoutProps; + profiles: NewSessionWizardProfilesProps; + agent: NewSessionWizardAgentProps; + machine: NewSessionWizardMachineProps; + footer: NewSessionWizardFooterProps; +}> { + const wizardLayoutProps = React.useMemo((): NewSessionWizardLayoutProps => { + return { + theme: params.theme, + styles: params.styles, + safeAreaBottom: params.safeAreaBottom, + headerHeight: params.headerHeight, + newSessionSidePadding: params.newSessionSidePadding, + newSessionBottomPadding: params.newSessionBottomPadding, + }; + }, [ + params.headerHeight, + params.newSessionBottomPadding, + params.newSessionSidePadding, + params.safeAreaBottom, + params.theme, + params.styles, + ]); + + const getSecretSatisfactionForProfile = React.useCallback((profile: AIBackendProfile) => { + const selectedSecretIds = params.selectedSecretIdByProfileIdByEnvVarName[profile.id] ?? null; + const sessionOnlyValues = params.sessionOnlySecretValueByProfileIdByEnvVarName[profile.id] ?? null; + const machineEnvReadyByName = Object.fromEntries( + Object.entries(params.machineEnvPresence.meta ?? {}).map(([k, v]) => [k, Boolean(v?.isSet)]), + ); + return getSecretSatisfaction({ + profile, + secrets: params.secrets, + defaultBindings: params.secretBindingsByProfileId[profile.id] ?? null, + selectedSecretIds, + sessionOnlyValues, + machineEnvReadyByName, + }); + }, [ + params.machineEnvPresence.meta, + params.secrets, + params.secretBindingsByProfileId, + params.selectedSecretIdByProfileIdByEnvVarName, + params.sessionOnlySecretValueByProfileIdByEnvVarName, + ]); + + const getSecretOverrideReady = React.useCallback((profile: AIBackendProfile): boolean => { + const satisfaction = getSecretSatisfactionForProfile(profile); + // Override should only represent non-machine satisfaction (defaults / saved / session-only). + if (!satisfaction.hasSecretRequirements) return false; + const required = satisfaction.items.filter((i) => i.required); + if (required.length === 0) return false; + if (!required.every((i) => i.isSatisfied)) return false; + return required.some((i) => i.satisfiedBy !== 'machineEnv'); + }, [getSecretSatisfactionForProfile]); + + const getSecretMachineEnvOverride = React.useCallback((profile: AIBackendProfile) => { + if (!params.selectedMachineId) return null; + if (!params.machineEnvPresence.isPreviewEnvSupported) return null; + const requiredNames = getRequiredSecretEnvVarNames(profile); + if (requiredNames.length === 0) return null; + return { + isReady: requiredNames.every((name) => Boolean(params.machineEnvPresence.meta[name]?.isSet)), + isLoading: params.machineEnvPresence.isLoading, + }; + }, [ + params.machineEnvPresence.isLoading, + params.machineEnvPresence.isPreviewEnvSupported, + params.machineEnvPresence.meta, + params.selectedMachineId, + ]); + + const wizardProfilesProps = React.useMemo((): NewSessionWizardProfilesProps => { + return { + useProfiles: params.useProfiles, + profiles: params.profiles, + favoriteProfileIds: params.favoriteProfileIds, + setFavoriteProfileIds: params.setFavoriteProfileIds, + experimentsEnabled: params.experimentsEnabled, + selectedProfileId: params.selectedProfileId, + onPressDefaultEnvironment: params.onPressDefaultEnvironment, + onPressProfile: params.onPressProfile, + selectedMachineId: params.selectedMachineId, + getProfileDisabled: params.getProfileDisabled, + getProfileSubtitleExtra: params.getProfileSubtitleExtra, + handleAddProfile: params.handleAddProfile, + openProfileEdit: params.openProfileEdit, + handleDuplicateProfile: params.handleDuplicateProfile, + handleDeleteProfile: params.handleDeleteProfile, + openProfileEnvVarsPreview: params.openProfileEnvVarsPreview, + suppressNextSecretAutoPromptKeyRef: params.suppressNextSecretAutoPromptKeyRef, + openSecretRequirementModal: params.openSecretRequirementModal, + profilesGroupTitles: params.profilesGroupTitles, + getSecretOverrideReady, + getSecretSatisfactionForProfile, + getSecretMachineEnvOverride, + }; + }, [ + params.experimentsEnabled, + params.favoriteProfileIds, + params.getProfileDisabled, + params.getProfileSubtitleExtra, + params.handleAddProfile, + params.handleDeleteProfile, + params.handleDuplicateProfile, + params.onPressDefaultEnvironment, + params.onPressProfile, + params.openProfileEdit, + params.openProfileEnvVarsPreview, + params.openSecretRequirementModal, + params.profiles, + params.profilesGroupTitles, + params.selectedMachineId, + params.selectedProfileId, + params.setFavoriteProfileIds, + params.suppressNextSecretAutoPromptKeyRef, + params.useProfiles, + getSecretOverrideReady, + getSecretSatisfactionForProfile, + getSecretMachineEnvOverride, + ]); + + const installableDepInstallers = React.useMemo((): InstallableDepInstallerProps[] => { + if (!params.selectedMachineId) return []; + if (params.wizardInstallableDeps.length === 0) return []; + + return params.wizardInstallableDeps.map(({ entry, depStatus }) => ({ + machineId: params.selectedMachineId!, + enabled: true, + groupTitle: `${tNoParams(entry.groupTitleKey)}${entry.experimental ? ' (experimental)' : ''}`, + depId: entry.depId, + depTitle: entry.depTitle, + depIconName: entry.depIconName as any, + depStatus, + capabilitiesStatus: params.selectedMachineCapabilities.status, + installSpecSettingKey: entry.installSpecSettingKey, + installSpecTitle: entry.installSpecTitle, + installSpecDescription: entry.installSpecDescription, + installLabels: { + install: tNoParams(entry.installLabels.installKey), + update: tNoParams(entry.installLabels.updateKey), + reinstall: tNoParams(entry.installLabels.reinstallKey), + }, + installModal: { + installTitle: tNoParams(entry.installModal.installTitleKey), + updateTitle: tNoParams(entry.installModal.updateTitleKey), + reinstallTitle: tNoParams(entry.installModal.reinstallTitleKey), + description: tNoParams(entry.installModal.descriptionKey), + }, + refreshStatus: () => { + void prefetchMachineCapabilities({ machineId: params.selectedMachineId!, request: CAPABILITIES_REQUEST_NEW_SESSION }); + }, + refreshRegistry: () => { + void prefetchMachineCapabilities({ machineId: params.selectedMachineId!, request: entry.buildRegistryDetectRequest(), timeoutMs: 12_000 }); + }, + })); + }, [params.selectedMachineCapabilities.status, params.selectedMachineId, params.wizardInstallableDeps]); + + const wizardAgentProps = React.useMemo((): NewSessionWizardAgentProps => { + return { + cliAvailability: params.cliAvailability, + tmuxRequested: params.tmuxRequested, + enabledAgentIds: params.enabledAgentIds, + isCliBannerDismissed: params.isCliBannerDismissed, + dismissCliBanner: params.dismissCliBanner, + agentType: params.agentType, + setAgentType: params.setAgentType, + modelOptions: params.modelOptions, + modelMode: params.modelMode, + setModelMode: params.setModelMode, + selectedIndicatorColor: params.selectedIndicatorColor, + profileMap: params.profileMap, + permissionMode: params.permissionMode, + handlePermissionModeChange: params.handlePermissionModeChange, + sessionType: params.sessionType, + setSessionType: params.setSessionType, + installableDepInstallers, + }; + }, [ + params.agentType, + params.cliAvailability, + params.dismissCliBanner, + params.enabledAgentIds, + params.isCliBannerDismissed, + params.modelMode, + params.modelOptions, + params.permissionMode, + params.profileMap, + params.selectedIndicatorColor, + params.sessionType, + params.setAgentType, + params.setModelMode, + params.setSessionType, + params.handlePermissionModeChange, + params.tmuxRequested, + installableDepInstallers, + ]); + + const wizardMachineProps = React.useMemo((): NewSessionWizardMachineProps => { + return { + machines: params.machines, + selectedMachine: params.selectedMachine || null, + recentMachines: params.recentMachines, + favoriteMachineItems: params.favoriteMachineItems, + useMachinePickerSearch: params.useMachinePickerSearch, + onRefreshMachines: params.refreshMachineData, + setSelectedMachineId: params.setSelectedMachineId as any, + getBestPathForMachine: params.getBestPathForMachine as any, + setSelectedPath: params.setSelectedPath, + favoriteMachines: params.favoriteMachines, + setFavoriteMachines: params.setFavoriteMachines, + selectedPath: params.selectedPath, + recentPaths: params.recentPaths, + usePathPickerSearch: params.usePathPickerSearch, + favoriteDirectories: params.favoriteDirectories, + setFavoriteDirectories: params.setFavoriteDirectories, + }; + }, [ + params.favoriteDirectories, + params.favoriteMachineItems, + params.favoriteMachines, + params.getBestPathForMachine, + params.machines, + params.recentMachines, + params.recentPaths, + params.refreshMachineData, + params.selectedMachine, + params.selectedPath, + params.setFavoriteDirectories, + params.setFavoriteMachines, + params.setSelectedMachineId, + params.setSelectedPath, + params.useMachinePickerSearch, + params.usePathPickerSearch, + ]); + + const wizardFooterProps = React.useMemo((): NewSessionWizardFooterProps => { + return { + sessionPrompt: params.sessionPrompt, + setSessionPrompt: params.setSessionPrompt, + handleCreateSession: params.handleCreateSession, + canCreate: params.canCreate, + isCreating: params.isCreating, + emptyAutocompletePrefixes: params.emptyAutocompletePrefixes, + emptyAutocompleteSuggestions: params.emptyAutocompleteSuggestions, + connectionStatus: params.connectionStatus, + selectedProfileEnvVarsCount: params.selectedProfileEnvVarsCount, + handleEnvVarsClick: params.handleEnvVarsClick, + resumeSessionId: params.resumeSessionId, + onResumeClick: params.showResumePicker ? params.handleResumeClick : undefined, + resumeIsChecking: params.isResumeSupportChecking, + inputMaxHeight: params.sessionPromptInputMaxHeight, + agentInputExtraActionChips: params.agentInputExtraActionChips, + }; + // NOTE: Agent selection doesn't affect these props, but keeping dependencies + // broad mirrors the previous in-screen memoization behavior and avoids subtle + // referential changes during refactors. + }, [ + params.agentType, + params.agentInputExtraActionChips, + params.canCreate, + params.connectionStatus, + params.experimentsEnabled, + params.emptyAutocompletePrefixes, + params.emptyAutocompleteSuggestions, + params.handleCreateSession, + params.handleEnvVarsClick, + params.handleResumeClick, + params.isCreating, + params.isResumeSupportChecking, + params.resumeSessionId, + params.selectedProfileEnvVarsCount, + params.sessionPrompt, + params.sessionPromptInputMaxHeight, + params.showResumePicker, + params.setSessionPrompt, + ]); + + return { + layout: wizardLayoutProps, + profiles: wizardProfilesProps, + agent: wizardAgentProps, + machine: wizardMachineProps, + footer: wizardFooterProps, + }; +} diff --git a/expo-app/sources/components/sessions/new/hooks/useSecretRequirementFlow.ts b/expo-app/sources/components/sessions/new/hooks/useSecretRequirementFlow.ts new file mode 100644 index 000000000..f404fe51c --- /dev/null +++ b/expo-app/sources/components/sessions/new/hooks/useSecretRequirementFlow.ts @@ -0,0 +1,350 @@ +import * as React from 'react'; +import { Platform } from 'react-native'; +import { applySecretRequirementResult, type SecretChoiceByProfileIdByEnvVarName } from '@/utils/secrets/secretRequirementApply'; +import { shouldAutoPromptSecretRequirement } from '@/utils/secrets/secretRequirementPromptEligibility'; +import { getSecretSatisfaction } from '@/utils/secrets/secretSatisfaction'; +import { Modal } from '@/modal'; +import { SecretRequirementModal, type SecretRequirementModalResult } from '@/components/secrets/requirements'; +import type { AIBackendProfile, SavedSecret } from '@/sync/settings'; +import type { UseMachineEnvPresenceResult } from '@/hooks/useMachineEnvPresence'; +import { getTempData } from '@/utils/tempDataStore'; + +export function useSecretRequirementFlow(params: Readonly<{ + router: { push: (options: any) => void }; + navigation: any; + useProfiles: boolean; + selectedProfileId: string | null; + selectedProfile: AIBackendProfile | null; + setSelectedProfileId: (id: string | null) => void; + shouldShowSecretSection: boolean; + selectedMachineId: string | null; + machineEnvPresence: UseMachineEnvPresenceResult; + secrets: SavedSecret[]; + setSecrets: (secrets: SavedSecret[]) => void; + secretBindingsByProfileId: Record<string, Record<string, string>>; + setSecretBindingsByProfileId: (next: Record<string, Record<string, string>>) => void; + selectedSecretIdByProfileIdByEnvVarName: SecretChoiceByProfileIdByEnvVarName; + setSelectedSecretIdByProfileIdByEnvVarName: React.Dispatch<React.SetStateAction<SecretChoiceByProfileIdByEnvVarName>>; + sessionOnlySecretValueByProfileIdByEnvVarName: SecretChoiceByProfileIdByEnvVarName; + setSessionOnlySecretValueByProfileIdByEnvVarName: React.Dispatch<React.SetStateAction<SecretChoiceByProfileIdByEnvVarName>>; + secretRequirementResultId: string | undefined; + prevProfileIdBeforeSecretPromptRef: React.MutableRefObject<string | null>; + lastSecretPromptKeyRef: React.MutableRefObject<string | null>; + suppressNextSecretAutoPromptKeyRef: React.MutableRefObject<string | null>; + isSecretRequirementModalOpenRef: React.MutableRefObject<boolean>; +}>): Readonly<{ + openSecretRequirementModal: (profile: AIBackendProfile, options: { revertOnCancel: boolean }) => void; +}> { + const openSecretRequirementModal = React.useCallback((profile: AIBackendProfile, options: { revertOnCancel: boolean }) => { + const selectedSecretIdByEnvVarName = params.selectedSecretIdByProfileIdByEnvVarName[profile.id] ?? {}; + const sessionOnlySecretValueByEnvVarName = params.sessionOnlySecretValueByProfileIdByEnvVarName[profile.id] ?? {}; + + const satisfaction = getSecretSatisfaction({ + profile, + secrets: params.secrets, + defaultBindings: params.secretBindingsByProfileId[profile.id] ?? null, + selectedSecretIds: selectedSecretIdByEnvVarName, + sessionOnlyValues: sessionOnlySecretValueByEnvVarName, + machineEnvReadyByName: Object.fromEntries( + Object.entries(params.machineEnvPresence.meta ?? {}).map(([k, v]) => [k, Boolean(v?.isSet)]), + ), + }); + + const targetEnvVarName = + satisfaction.items.find((i) => i.required && !i.isSatisfied)?.envVarName ?? + satisfaction.items[0]?.envVarName ?? + null; + if (!targetEnvVarName) { + params.isSecretRequirementModalOpenRef.current = false; + return; + } + params.isSecretRequirementModalOpenRef.current = true; + + if (Platform.OS !== 'web') { + // On iOS, /new is presented as a navigation modal. Rendering portal-style overlays from the + // app root (ModalProvider) can appear behind the navigation modal while still blocking touches. + // Present the secret requirement UI as a navigation modal screen within the same stack instead. + const secretEnvVarNames = satisfaction.items.map((i) => i.envVarName).filter(Boolean); + params.router.push({ + pathname: '/new/pick/secret-requirement', + params: { + profileId: profile.id, + machineId: params.selectedMachineId ?? '', + secretEnvVarName: targetEnvVarName, + secretEnvVarNames: secretEnvVarNames.join(','), + revertOnCancel: options.revertOnCancel ? '1' : '0', + selectedSecretIdByEnvVarName: encodeURIComponent(JSON.stringify(selectedSecretIdByEnvVarName)), + }, + } as any); + return; + } + + const selectedRaw = selectedSecretIdByEnvVarName[targetEnvVarName]; + const selectedSavedSecretIdForProfile = + typeof selectedRaw === 'string' && selectedRaw.length > 0 && selectedRaw !== '' + ? selectedRaw + : null; + + const handleResolve = (result: SecretRequirementModalResult) => { + if (result.action === 'cancel') { + params.isSecretRequirementModalOpenRef.current = false; + // Always allow future prompts for this profile. + params.lastSecretPromptKeyRef.current = null; + params.suppressNextSecretAutoPromptKeyRef.current = null; + if (options.revertOnCancel) { + const prev = params.prevProfileIdBeforeSecretPromptRef.current; + params.setSelectedProfileId(prev); + } + return; + } + + params.isSecretRequirementModalOpenRef.current = false; + + if (result.action === 'useMachine') { + params.setSelectedSecretIdByProfileIdByEnvVarName((prev) => ({ + ...prev, + [profile.id]: { + ...(prev[profile.id] ?? {}), + [result.envVarName]: '', + }, + })); + params.setSessionOnlySecretValueByProfileIdByEnvVarName((prev) => ({ + ...prev, + [profile.id]: { + ...(prev[profile.id] ?? {}), + [result.envVarName]: null, + }, + })); + return; + } + + if (result.action === 'enterOnce') { + params.setSelectedSecretIdByProfileIdByEnvVarName((prev) => ({ + ...prev, + [profile.id]: { + ...(prev[profile.id] ?? {}), + [result.envVarName]: '', + }, + })); + params.setSessionOnlySecretValueByProfileIdByEnvVarName((prev) => ({ + ...prev, + [profile.id]: { + ...(prev[profile.id] ?? {}), + [result.envVarName]: result.value, + }, + })); + return; + } + + if (result.action === 'selectSaved') { + params.setSessionOnlySecretValueByProfileIdByEnvVarName((prev) => ({ + ...prev, + [profile.id]: { + ...(prev[profile.id] ?? {}), + [result.envVarName]: null, + }, + })); + params.setSelectedSecretIdByProfileIdByEnvVarName((prev) => ({ + ...prev, + [profile.id]: { + ...(prev[profile.id] ?? {}), + [result.envVarName]: result.secretId, + }, + })); + if (result.setDefault) { + params.setSecretBindingsByProfileId({ + ...params.secretBindingsByProfileId, + [profile.id]: { + ...(params.secretBindingsByProfileId[profile.id] ?? {}), + [result.envVarName]: result.secretId, + }, + }); + } + } + }; + + Modal.show({ + component: SecretRequirementModal, + props: { + profile, + secretEnvVarName: targetEnvVarName, + secretEnvVarNames: satisfaction.items.map((i) => i.envVarName), + machineId: params.selectedMachineId ?? null, + secrets: params.secrets, + defaultSecretId: params.secretBindingsByProfileId[profile.id]?.[targetEnvVarName] ?? null, + selectedSavedSecretId: selectedSavedSecretIdForProfile, + selectedSecretIdByEnvVarName: selectedSecretIdByEnvVarName, + sessionOnlySecretValueByEnvVarName: sessionOnlySecretValueByEnvVarName, + defaultSecretIdByEnvVarName: params.secretBindingsByProfileId[profile.id] ?? null, + onSetDefaultSecretId: (id) => { + if (!id) return; + params.setSecretBindingsByProfileId({ + ...params.secretBindingsByProfileId, + [profile.id]: { + ...(params.secretBindingsByProfileId[profile.id] ?? {}), + [targetEnvVarName]: id, + }, + }); + }, + onChangeSecrets: params.setSecrets, + allowSessionOnly: true, + onResolve: handleResolve, + onRequestClose: () => handleResolve({ action: 'cancel' }), + }, + closeOnBackdrop: true, + }); + }, [ + params.machineEnvPresence.meta, + params.secrets, + params.secretBindingsByProfileId, + params.selectedSecretIdByProfileIdByEnvVarName, + params.selectedMachineId, + params.selectedProfileId, + params.sessionOnlySecretValueByProfileIdByEnvVarName, + params.setSecretBindingsByProfileId, + params.router, + ]); + + // If a selected profile requires an API key and the key isn't available on the selected machine, + // prompt immediately and revert selection on cancel (so the profile isn't "selected" without a key). + React.useEffect(() => { + const isEligible = shouldAutoPromptSecretRequirement({ + useProfiles: params.useProfiles, + selectedProfileId: params.selectedProfileId, + shouldShowSecretSection: params.shouldShowSecretSection, + isModalOpen: params.isSecretRequirementModalOpenRef.current, + machineEnvPresenceIsLoading: params.machineEnvPresence.isLoading, + selectedMachineId: params.selectedMachineId, + }); + if (!isEligible) return; + + const selectedSecretIdByEnvVarName = params.selectedProfileId + ? (params.selectedSecretIdByProfileIdByEnvVarName[params.selectedProfileId] ?? {}) + : {}; + const sessionOnlySecretValueByEnvVarName = params.selectedProfileId + ? (params.sessionOnlySecretValueByProfileIdByEnvVarName[params.selectedProfileId] ?? {}) + : {}; + + const satisfaction = getSecretSatisfaction({ + profile: params.selectedProfile ?? null, + secrets: params.secrets, + defaultBindings: params.selectedProfileId ? (params.secretBindingsByProfileId[params.selectedProfileId] ?? null) : null, + selectedSecretIds: selectedSecretIdByEnvVarName, + sessionOnlyValues: sessionOnlySecretValueByEnvVarName, + machineEnvReadyByName: Object.fromEntries( + Object.entries(params.machineEnvPresence.meta ?? {}).map(([k, v]) => [k, Boolean(v?.isSet)]), + ), + }); + + if (satisfaction.isSatisfied) { + // Reset prompt key when requirements are satisfied so future selections can prompt again if needed. + params.lastSecretPromptKeyRef.current = null; + return; + } + + const missing = satisfaction.items.find((i) => i.required && !i.isSatisfied) ?? null; + const promptKey = `${params.selectedMachineId ?? 'no-machine'}:${params.selectedProfileId}:${missing?.envVarName ?? 'unknown'}`; + if (params.suppressNextSecretAutoPromptKeyRef.current === promptKey) { + // One-shot suppression (used when the user explicitly opened the modal via the badge). + params.suppressNextSecretAutoPromptKeyRef.current = null; + return; + } + if (params.lastSecretPromptKeyRef.current === promptKey) { + return; + } + params.lastSecretPromptKeyRef.current = promptKey; + if (!params.selectedProfile) { + return; + } + openSecretRequirementModal(params.selectedProfile, { revertOnCancel: true }); + }, [ + params.secrets, + params.secretBindingsByProfileId, + params.machineEnvPresence.isLoading, + params.machineEnvPresence.meta, + openSecretRequirementModal, + params.selectedSecretIdByProfileIdByEnvVarName, + params.selectedMachineId, + params.selectedProfileId, + params.selectedProfile, + params.sessionOnlySecretValueByProfileIdByEnvVarName, + params.shouldShowSecretSection, + params.suppressNextSecretAutoPromptKeyRef, + params.useProfiles, + ]); + + // Handle secret requirement results from the native modal route (value stored in-memory only). + React.useEffect(() => { + if (typeof params.secretRequirementResultId !== 'string' || params.secretRequirementResultId.length === 0) { + return; + } + + const entry = getTempData<{ + profileId: string; + revertOnCancel: boolean; + result: SecretRequirementModalResult; + }>(params.secretRequirementResultId); + + // Always unlock the guard so follow-up prompts can show. + params.isSecretRequirementModalOpenRef.current = false; + + if (!entry) { + const setParams = (params.navigation as any)?.setParams; + if (typeof setParams === 'function') { + setParams({ secretRequirementResultId: undefined }); + } else { + params.navigation.dispatch({ + type: 'SET_PARAMS', + payload: { params: { secretRequirementResultId: undefined } }, + } as never); + } + return; + } + + const result = entry?.result; + if (result?.action === 'cancel') { + // Allow future prompts for this profile. + params.lastSecretPromptKeyRef.current = null; + params.suppressNextSecretAutoPromptKeyRef.current = null; + if (entry?.revertOnCancel) { + const prev = params.prevProfileIdBeforeSecretPromptRef.current; + params.setSelectedProfileId(prev); + } + } else if (result) { + const profileId = entry.profileId; + const applied = applySecretRequirementResult({ + profileId, + result, + selectedSecretIdByProfileIdByEnvVarName: params.selectedSecretIdByProfileIdByEnvVarName, + sessionOnlySecretValueByProfileIdByEnvVarName: params.sessionOnlySecretValueByProfileIdByEnvVarName, + secretBindingsByProfileId: params.secretBindingsByProfileId, + }); + params.setSelectedSecretIdByProfileIdByEnvVarName(applied.nextSelectedSecretIdByProfileIdByEnvVarName); + params.setSessionOnlySecretValueByProfileIdByEnvVarName(applied.nextSessionOnlySecretValueByProfileIdByEnvVarName); + if (applied.nextSecretBindingsByProfileId !== params.secretBindingsByProfileId) { + params.setSecretBindingsByProfileId(applied.nextSecretBindingsByProfileId); + } + } + + const setParams = (params.navigation as any)?.setParams; + if (typeof setParams === 'function') { + setParams({ secretRequirementResultId: undefined }); + } else { + params.navigation.dispatch({ + type: 'SET_PARAMS', + payload: { params: { secretRequirementResultId: undefined } }, + } as never); + } + }, [ + params.navigation, + params.secretBindingsByProfileId, + params.secretRequirementResultId, + params.selectedSecretIdByProfileIdByEnvVarName, + params.sessionOnlySecretValueByProfileIdByEnvVarName, + params.setSecretBindingsByProfileId, + params.setSelectedSecretIdByProfileIdByEnvVarName, + params.setSessionOnlySecretValueByProfileIdByEnvVarName, + ]); + + return { openSecretRequirementModal }; +} diff --git a/expo-app/sources/components/sessions/new/modules/formatResumeSupportDetailCode.ts b/expo-app/sources/components/sessions/new/modules/formatResumeSupportDetailCode.ts new file mode 100644 index 000000000..f9418e8fd --- /dev/null +++ b/expo-app/sources/components/sessions/new/modules/formatResumeSupportDetailCode.ts @@ -0,0 +1,17 @@ +import { t } from '@/text'; + +export type ResumeSupportDetailCode = 'cliNotDetected' | 'capabilityProbeFailed' | 'acpProbeFailed' | 'loadSessionFalse'; + +export function formatResumeSupportDetailCode(code: ResumeSupportDetailCode): string { + switch (code) { + case 'cliNotDetected': + return t('session.resumeSupportDetails.cliNotDetected'); + case 'capabilityProbeFailed': + return t('session.resumeSupportDetails.capabilityProbeFailed'); + case 'acpProbeFailed': + return t('session.resumeSupportDetails.acpProbeFailed'); + case 'loadSessionFalse': + return t('session.resumeSupportDetails.loadSessionFalse'); + } +} + diff --git a/expo-app/sources/components/sessions/new/modules/profileHelpers.ts b/expo-app/sources/components/sessions/new/modules/profileHelpers.ts new file mode 100644 index 000000000..f38322c3a --- /dev/null +++ b/expo-app/sources/components/sessions/new/modules/profileHelpers.ts @@ -0,0 +1,18 @@ +import React from 'react'; +import { getProfileEnvironmentVariables, type AIBackendProfile } from '@/sync/settings'; + +// Optimized profile lookup utility +export const useProfileMap = (profiles: AIBackendProfile[]) => { + return React.useMemo(() => + new Map(profiles.map(p => [p.id, p])), + [profiles] + ); +}; + +// Environment variable transformation helper +// Returns ALL profile environment variables - daemon will use them as-is +export const transformProfileToEnvironmentVars = (profile: AIBackendProfile) => { + // getProfileEnvironmentVariables already returns ALL env vars from profile + // including custom environmentVariables array + return getProfileEnvironmentVariables(profile); +}; diff --git a/expo-app/sources/components/sessions/new/newSessionScreenStyles.ts b/expo-app/sources/components/sessions/new/newSessionScreenStyles.ts new file mode 100644 index 000000000..36fd17c9f --- /dev/null +++ b/expo-app/sources/components/sessions/new/newSessionScreenStyles.ts @@ -0,0 +1,161 @@ +import { Platform } from 'react-native'; +import { StyleSheet } from 'react-native-unistyles'; +import { Typography } from '@/constants/Typography'; + +export const newSessionScreenStyles = StyleSheet.create((theme, rt) => ({ + container: { + flex: 1, + justifyContent: Platform.OS === 'web' ? 'center' : 'flex-end', + paddingTop: Platform.OS === 'web' ? 20 : 10, + ...(Platform.select({ + web: { minHeight: 0 }, + default: {}, + }) as any), + }, + scrollContainer: { + flex: 1, + ...(Platform.select({ + web: { minHeight: 0 }, + default: {}, + }) as any), + }, + contentContainer: { + width: '100%', + alignSelf: 'center', + paddingTop: 0, + paddingBottom: 16, + }, + wizardContainer: { + marginBottom: 16, + }, + wizardSectionHeaderRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + marginBottom: 6, + marginTop: 12, + paddingHorizontal: 16, + }, + sectionHeader: { + fontSize: 17, + fontWeight: '600', + color: theme.colors.text, + marginBottom: 8, + marginTop: 12, + ...Typography.default('semiBold') + }, + sectionDescription: { + fontSize: 12, + color: theme.colors.textSecondary, + marginBottom: Platform.OS === 'web' ? 8 : 0, + lineHeight: 18, + paddingHorizontal: 16, + ...Typography.default() + }, + profileListItem: { + backgroundColor: theme.colors.input.background, + borderRadius: 12, + padding: 8, + marginBottom: 8, + flexDirection: 'row', + alignItems: 'center', + borderWidth: 2, + borderColor: 'transparent', + }, + profileListItemSelected: { + borderWidth: 2, + borderColor: theme.colors.text, + }, + profileIcon: { + width: 20, + height: 20, + borderRadius: 10, + backgroundColor: theme.colors.button.primary.background, + justifyContent: 'center', + alignItems: 'center', + marginRight: 10, + }, + profileListName: { + fontSize: 13, + fontWeight: '600', + color: theme.colors.text, + ...Typography.default('semiBold') + }, + profileListDetails: { + fontSize: 12, + color: theme.colors.textSecondary, + marginTop: 2, + ...Typography.default() + }, + addProfileButton: { + backgroundColor: theme.colors.surface, + borderRadius: 12, + padding: 12, + marginBottom: 12, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + }, + addProfileButtonText: { + fontSize: 13, + fontWeight: '600', + color: theme.colors.button.secondary.tint, + marginLeft: 8, + ...Typography.default('semiBold') + }, + selectorButton: { + backgroundColor: theme.colors.input.background, + borderRadius: 8, + padding: 10, + marginBottom: 12, + borderWidth: 1, + borderColor: theme.colors.divider, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + selectorButtonText: { + color: theme.colors.text, + fontSize: 13, + flex: 1, + ...Typography.default() + }, + permissionGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + justifyContent: 'space-between', + marginBottom: 16, + }, + permissionButton: { + width: '48%', + backgroundColor: theme.colors.input.background, + borderRadius: 12, + padding: 16, + marginBottom: 12, + alignItems: 'center', + borderWidth: 2, + borderColor: 'transparent', + }, + permissionButtonSelected: { + borderColor: theme.colors.button.primary.background, + backgroundColor: theme.colors.button.primary.background + '10', + }, + permissionButtonText: { + fontSize: 14, + fontWeight: '600', + color: theme.colors.text, + marginTop: 8, + textAlign: 'center', + ...Typography.default('semiBold') + }, + permissionButtonTextSelected: { + color: theme.colors.button.primary.background, + }, + permissionButtonDesc: { + fontSize: 11, + color: theme.colors.textSecondary, + marginTop: 4, + textAlign: 'center', + ...Typography.default() + }, +})); diff --git a/expo-app/sources/components/sessions/pending/PendingMessagesModal.discardFallback.test.ts b/expo-app/sources/components/sessions/pending/PendingMessagesModal.discardFallback.test.ts new file mode 100644 index 000000000..ca43b8d6a --- /dev/null +++ b/expo-app/sources/components/sessions/pending/PendingMessagesModal.discardFallback.test.ts @@ -0,0 +1,122 @@ +import React from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import renderer, { act } from 'react-test-renderer'; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +const fetchPendingMessages = vi.fn(); +const sendMessage = vi.fn(); +const deletePendingMessage = vi.fn(); +const discardPendingMessage = vi.fn(); +const sessionAbort = vi.fn(); +const modalConfirm = vi.fn(); +const modalAlert = vi.fn(); + +vi.mock('@/constants/Typography', () => ({ + Typography: { + default: () => ({}), + }, +})); + +vi.mock('@/sync/storage', () => ({ + useSessionPendingMessages: () => ({ + isLoaded: true, + messages: [ + { id: 'p1', text: 'hello', displayText: null, createdAt: 0, updatedAt: 0 }, + ], + discarded: [], + }), +})); + +vi.mock('@/sync/sync', () => ({ + sync: { + fetchPendingMessages: (...args: any[]) => fetchPendingMessages(...args), + sendMessage: (...args: any[]) => sendMessage(...args), + deletePendingMessage: (...args: any[]) => deletePendingMessage(...args), + discardPendingMessage: (...args: any[]) => discardPendingMessage(...args), + updatePendingMessage: vi.fn(), + restoreDiscardedPendingMessage: vi.fn(), + deleteDiscardedPendingMessage: vi.fn(), + }, +})); + +vi.mock('@/sync/ops', () => ({ + sessionAbort: (...args: any[]) => sessionAbort(...args), +})); + +vi.mock('@/modal', () => ({ + Modal: { + confirm: (...args: any[]) => modalConfirm(...args), + alert: (...args: any[]) => modalAlert(...args), + prompt: vi.fn(), + }, +})); + +vi.mock('react-native', () => ({ + View: 'View', + Text: 'Text', + Pressable: 'Pressable', + ScrollView: 'ScrollView', + ActivityIndicator: 'ActivityIndicator', +})); + +vi.mock('react-native-unistyles', () => ({ + useUnistyles: () => ({ + theme: { + colors: { + text: '#000', + textSecondary: '#666', + input: { background: '#fff' }, + button: { secondary: { background: '#eee', tint: '#000' } }, + box: { danger: { background: '#fdd', text: '#a00' } }, + }, + }, + }), +})); + +vi.mock('@expo/vector-icons', () => ({ + Ionicons: 'Ionicons', +})); + +describe('PendingMessagesModal discard fallback', () => { + beforeEach(() => { + fetchPendingMessages.mockReset(); + sendMessage.mockReset(); + deletePendingMessage.mockReset(); + discardPendingMessage.mockReset(); + sessionAbort.mockReset(); + modalConfirm.mockReset(); + modalAlert.mockReset(); + }); + + it('falls back to discarding when delete fails after send', async () => { + modalConfirm.mockResolvedValueOnce(true); + sessionAbort.mockResolvedValueOnce(undefined); + sendMessage.mockResolvedValueOnce(undefined); + deletePendingMessage.mockRejectedValueOnce(new Error('delete failed')); + discardPendingMessage.mockResolvedValueOnce(undefined); + + const onClose = vi.fn(); + const { PendingMessagesModal } = await import('./PendingMessagesModal'); + + let tree: ReturnType<typeof renderer.create> | undefined; + await act(async () => { + tree = renderer.create(React.createElement(PendingMessagesModal, { sessionId: 's1', onClose })); + }); + + const sendNow = tree!.root + .findAllByType('Pressable' as any) + .find((p) => p.props.testID === 'pendingMessages.sendNow:p1'); + expect(sendNow).toBeTruthy(); + + await act(async () => { + await sendNow!.props.onPress(); + }); + + expect(deletePendingMessage).toHaveBeenCalledTimes(1); + expect(discardPendingMessage).toHaveBeenCalledTimes(1); + expect(onClose).toHaveBeenCalledTimes(1); + expect(modalAlert).toHaveBeenCalledTimes(0); + }); +}); + diff --git a/expo-app/sources/components/sessions/pending/PendingMessagesModal.test.ts b/expo-app/sources/components/sessions/pending/PendingMessagesModal.test.ts new file mode 100644 index 000000000..de544594b --- /dev/null +++ b/expo-app/sources/components/sessions/pending/PendingMessagesModal.test.ts @@ -0,0 +1,206 @@ +import React from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import renderer, { act } from 'react-test-renderer'; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +const fetchPendingMessages = vi.fn(); +const sendMessage = vi.fn(); +const deletePendingMessage = vi.fn(); +const discardPendingMessage = vi.fn(); +const deleteDiscardedPendingMessage = vi.fn(); +const sessionAbort = vi.fn(); +const modalConfirm = vi.fn(); +const modalAlert = vi.fn(); + +vi.mock('@/constants/Typography', () => ({ + Typography: { + default: () => ({}), + }, +})); + +vi.mock('@/sync/storage', () => ({ + useSessionPendingMessages: () => ({ + isLoaded: true, + messages: [ + { id: 'p1', text: 'hello', displayText: null, createdAt: 0, updatedAt: 0 }, + ], + discarded: [], + }), +})); + +vi.mock('@/sync/sync', () => ({ + sync: { + fetchPendingMessages: (...args: any[]) => fetchPendingMessages(...args), + sendMessage: (...args: any[]) => sendMessage(...args), + deletePendingMessage: (...args: any[]) => deletePendingMessage(...args), + discardPendingMessage: (...args: any[]) => discardPendingMessage(...args), + updatePendingMessage: vi.fn(), + restoreDiscardedPendingMessage: vi.fn(), + deleteDiscardedPendingMessage: (...args: any[]) => deleteDiscardedPendingMessage(...args), + }, +})); + +vi.mock('@/sync/ops', () => ({ + sessionAbort: (...args: any[]) => sessionAbort(...args), +})); + +vi.mock('@/modal', () => ({ + Modal: { + confirm: (...args: any[]) => modalConfirm(...args), + alert: (...args: any[]) => modalAlert(...args), + prompt: vi.fn(), + }, +})); + +vi.mock('react-native', () => ({ + View: 'View', + Text: 'Text', + Pressable: 'Pressable', + ScrollView: 'ScrollView', + ActivityIndicator: 'ActivityIndicator', +})); + +vi.mock('react-native-unistyles', () => ({ + useUnistyles: () => ({ + theme: { + colors: { + text: '#000', + textSecondary: '#666', + surfaceHighest: '#eee', + input: { background: '#fff' }, + button: { + // Match app theme shape: secondary has tint but no background. + secondary: { tint: '#000' }, + }, + box: { + // Match app theme shape: error (not danger). + error: { background: '#fdd', text: '#a00' }, + }, + }, + }, + }), +})); + +vi.mock('@expo/vector-icons', () => ({ + Ionicons: 'Ionicons', +})); + +describe('PendingMessagesModal', () => { + beforeEach(() => { + fetchPendingMessages.mockReset(); + sendMessage.mockReset(); + deletePendingMessage.mockReset(); + discardPendingMessage.mockReset(); + deleteDiscardedPendingMessage.mockReset(); + sessionAbort.mockReset(); + modalConfirm.mockReset(); + modalAlert.mockReset(); + }); + + it('does not close the modal until abort+send+delete succeed', async () => { + modalConfirm.mockResolvedValueOnce(true); + sessionAbort.mockResolvedValueOnce(undefined); + sendMessage.mockResolvedValueOnce(undefined); + deletePendingMessage.mockResolvedValueOnce(undefined); + + const onClose = vi.fn(); + const { PendingMessagesModal } = await import('./PendingMessagesModal'); + + let tree: ReturnType<typeof renderer.create> | undefined; + await act(async () => { + tree = renderer.create(React.createElement(PendingMessagesModal, { sessionId: 's1', onClose })); + }); + + const sendNow = tree!.root + .findAllByType('Pressable' as any) + .find((p) => p.props.testID === 'pendingMessages.sendNow:p1'); + expect(sendNow).toBeTruthy(); + + await act(async () => { + await sendNow!.props.onPress(); + }); + + expect(sessionAbort).toHaveBeenCalledTimes(1); + expect(sendMessage).toHaveBeenCalledTimes(1); + expect(deletePendingMessage).toHaveBeenCalledTimes(1); + expect(onClose).toHaveBeenCalledTimes(1); + + const abortOrder = sessionAbort.mock.invocationCallOrder[0]!; + const sendOrder = sendMessage.mock.invocationCallOrder[0]!; + const deleteOrder = deletePendingMessage.mock.invocationCallOrder[0]!; + const closeOrder = onClose.mock.invocationCallOrder[0]!; + + expect(abortOrder).toBeLessThan(sendOrder); + expect(sendOrder).toBeLessThan(deleteOrder); + expect(deleteOrder).toBeLessThan(closeOrder); + }); + + it('falls back to discarding when delete fails after send', async () => { + modalConfirm.mockResolvedValueOnce(true); + sessionAbort.mockResolvedValueOnce(undefined); + sendMessage.mockResolvedValueOnce(undefined); + deletePendingMessage.mockRejectedValueOnce(new Error('delete failed')); + discardPendingMessage.mockResolvedValueOnce(undefined); + + const onClose = vi.fn(); + const { PendingMessagesModal } = await import('./PendingMessagesModal'); + + let tree: ReturnType<typeof renderer.create> | undefined; + await act(async () => { + tree = renderer.create(React.createElement(PendingMessagesModal, { sessionId: 's1', onClose })); + }); + + const sendNow = tree!.root + .findAllByType('Pressable' as any) + .find((p) => p.props.testID === 'pendingMessages.sendNow:p1'); + expect(sendNow).toBeTruthy(); + + await act(async () => { + await sendNow!.props.onPress(); + }); + + expect(deletePendingMessage).toHaveBeenCalledTimes(1); + expect(discardPendingMessage).toHaveBeenCalledTimes(1); + expect(onClose).toHaveBeenCalledTimes(1); + expect(modalAlert).toHaveBeenCalledTimes(0); + }); + + it('renders with app theme shape (no secondary background / no danger box)', async () => { + const onClose = vi.fn(); + const { PendingMessagesModal } = await import('./PendingMessagesModal'); + + await expect((async () => { + await act(async () => { + renderer.create(React.createElement(PendingMessagesModal, { sessionId: 's1', onClose })); + }); + })()).resolves.toBeUndefined(); + }); + + it('does not delete or close when send fails', async () => { + modalConfirm.mockResolvedValueOnce(true); + sessionAbort.mockResolvedValueOnce(undefined); + sendMessage.mockRejectedValueOnce(new Error('send failed')); + + const onClose = vi.fn(); + const { PendingMessagesModal } = await import('./PendingMessagesModal'); + + let tree: ReturnType<typeof renderer.create> | undefined; + await act(async () => { + tree = renderer.create(React.createElement(PendingMessagesModal, { sessionId: 's1', onClose })); + }); + + const sendNow = tree!.root + .findAllByType('Pressable' as any) + .find((p) => p.props.testID === 'pendingMessages.sendNow:p1'); + expect(sendNow).toBeTruthy(); + + await act(async () => { + await sendNow!.props.onPress(); + }); + + expect(deletePendingMessage).toHaveBeenCalledTimes(0); + expect(onClose).toHaveBeenCalledTimes(0); + expect(modalAlert).toHaveBeenCalledTimes(1); + }); +}); diff --git a/expo-app/sources/components/sessions/pending/PendingMessagesModal.tsx b/expo-app/sources/components/sessions/pending/PendingMessagesModal.tsx new file mode 100644 index 000000000..63e74bc01 --- /dev/null +++ b/expo-app/sources/components/sessions/pending/PendingMessagesModal.tsx @@ -0,0 +1,309 @@ +import React from 'react'; +import { ActivityIndicator, Pressable, ScrollView, Text, View } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { useUnistyles } from 'react-native-unistyles'; +import { Typography } from '@/constants/Typography'; +import { useSessionPendingMessages } from '@/sync/storage'; +import { sync } from '@/sync/sync'; +import { Modal } from '@/modal'; +import { sessionAbort } from '@/sync/ops'; + +export function PendingMessagesModal(props: { sessionId: string; onClose: () => void }) { + const { theme } = useUnistyles(); + const { messages, discarded, isLoaded } = useSessionPendingMessages(props.sessionId); + + React.useEffect(() => { + void sync.fetchPendingMessages(props.sessionId); + }, [props.sessionId]); + + const handleEdit = React.useCallback(async (pendingId: string, currentText: string) => { + const next = await Modal.prompt( + 'Edit pending message', + undefined, + { defaultValue: currentText, confirmText: 'Save' } + ); + if (next === null) return; + if (!next.trim()) return; + try { + await sync.updatePendingMessage(props.sessionId, pendingId, next); + } catch (e) { + Modal.alert('Error', e instanceof Error ? e.message : 'Failed to update pending message'); + } + }, [props.sessionId]); + + const handleRemove = React.useCallback(async (pendingId: string) => { + const confirmed = await Modal.confirm( + 'Remove pending message?', + 'This will delete the pending message.', + { confirmText: 'Remove', destructive: true } + ); + if (!confirmed) return; + try { + await sync.deletePendingMessage(props.sessionId, pendingId); + } catch (e) { + Modal.alert('Error', e instanceof Error ? e.message : 'Failed to delete pending message'); + } + }, [props.sessionId]); + + const handleSendNow = React.useCallback(async (pendingId: string, text: string) => { + const confirmed = await Modal.confirm( + 'Send now?', + 'This will stop the current turn and send this message immediately.', + { confirmText: 'Send now' } + ); + if (!confirmed) return; + + try { + await sessionAbort(props.sessionId); + await sync.sendMessage(props.sessionId, text); + try { + await sync.deletePendingMessage(props.sessionId, pendingId); + } catch (deleteError) { + try { + await sync.discardPendingMessage(props.sessionId, pendingId); + } catch { + throw deleteError; + } + } + props.onClose(); + } catch (e) { + Modal.alert('Error', e instanceof Error ? e.message : 'Failed to send pending message'); + } + }, [props.sessionId, props.onClose]); + + const handleRequeueDiscarded = React.useCallback(async (pendingId: string) => { + try { + await sync.restoreDiscardedPendingMessage(props.sessionId, pendingId); + } catch (e) { + Modal.alert('Error', e instanceof Error ? e.message : 'Failed to restore discarded message'); + } + }, [props.sessionId]); + + const handleRemoveDiscarded = React.useCallback(async (pendingId: string) => { + const confirmed = await Modal.confirm( + 'Remove discarded message?', + 'This will delete the discarded message.', + { confirmText: 'Remove', destructive: true } + ); + if (!confirmed) return; + try { + await sync.deleteDiscardedPendingMessage(props.sessionId, pendingId); + } catch (e) { + Modal.alert('Error', e instanceof Error ? e.message : 'Failed to delete discarded message'); + } + }, [props.sessionId]); + + const handleSendDiscardedNow = React.useCallback(async (pendingId: string, text: string) => { + const confirmed = await Modal.confirm( + 'Send now?', + 'This will stop the current turn and send this message immediately.', + { confirmText: 'Send now' } + ); + if (!confirmed) return; + + try { + await sessionAbort(props.sessionId); + await sync.sendMessage(props.sessionId, text); + await sync.deleteDiscardedPendingMessage(props.sessionId, pendingId); + props.onClose(); + } catch (e) { + Modal.alert('Error', e instanceof Error ? e.message : 'Failed to send discarded message'); + } + }, [props.sessionId, props.onClose]); + + return ( + <View style={{ padding: 16, width: '100%', maxWidth: 720 }}> + <View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}> + <Text style={{ fontSize: 16, fontWeight: '700', color: theme.colors.text, ...Typography.default('semiBold') }}> + Pending messages + </Text> + <Pressable + onPress={props.onClose} + style={(p) => ({ + padding: 8, + borderRadius: 10, + backgroundColor: p.pressed ? theme.colors.input.background : 'transparent' + })} + > + <Ionicons name="close" size={20} color={theme.colors.textSecondary} /> + </Pressable> + </View> + + {!isLoaded && ( + <View style={{ paddingVertical: 24 }}> + <ActivityIndicator /> + </View> + )} + + {isLoaded && messages.length === 0 && discarded.length === 0 && ( + <Text style={{ marginTop: 12, color: theme.colors.textSecondary, ...Typography.default() }}> + No pending messages. + </Text> + )} + + {messages.length > 0 && ( + <ScrollView style={{ marginTop: 12, maxHeight: 520 }}> + {messages.map((m) => ( + <View + key={m.id} + style={{ + borderRadius: 12, + backgroundColor: theme.colors.input.background, + padding: 12, + marginBottom: 10, + }} + > + <Text + numberOfLines={4} + style={{ + color: theme.colors.text, + fontSize: 14, + ...Typography.default(), + }} + > + {(m.displayText ?? m.text).trim()} + </Text> + + <View style={{ flexDirection: 'row', gap: 10, marginTop: 10 }}> + <ActionButton + title="Edit" + onPress={() => handleEdit(m.id, m.text)} + theme={theme} + testID={`pendingMessages.edit:${m.id}`} + /> + <ActionButton + title="Remove" + onPress={() => handleRemove(m.id)} + theme={theme} + destructive + testID={`pendingMessages.remove:${m.id}`} + /> + <ActionButton + title="Send now" + onPress={() => handleSendNow(m.id, m.text)} + theme={theme} + testID={`pendingMessages.sendNow:${m.id}`} + /> + </View> + </View> + ))} + </ScrollView> + )} + + {isLoaded && discarded.length > 0 && ( + <View style={{ marginTop: 16 }}> + <Text style={{ fontSize: 14, color: theme.colors.textSecondary, ...Typography.default('semiBold') }}> + Discarded messages + </Text> + <Text style={{ marginTop: 6, color: theme.colors.textSecondary, ...Typography.default() }}> + These messages were not sent to the agent (for example, when switching from remote to local). + </Text> + + <ScrollView style={{ marginTop: 12, maxHeight: 360 }}> + {discarded + .slice() + .sort((a, b) => a.discardedAt - b.discardedAt) + .map((m) => ( + <View + key={`discarded-${m.id}`} + style={{ + borderRadius: 12, + backgroundColor: theme.colors.input.background, + padding: 12, + marginBottom: 10, + opacity: 0.8, + }} + > + <Text + numberOfLines={4} + style={{ + color: theme.colors.text, + fontSize: 14, + ...Typography.default(), + }} + > + {(m.displayText ?? m.text).trim()} + </Text> + <Text style={{ marginTop: 6, color: theme.colors.textSecondary, ...Typography.default() }}> + Discarded + </Text> + + <View style={{ flexDirection: 'row', gap: 10, marginTop: 10 }}> + <ActionButton + title="Re-queue" + onPress={() => handleRequeueDiscarded(m.id)} + theme={theme} + testID={`pendingMessages.discarded.requeue:${m.id}`} + /> + <ActionButton + title="Remove" + onPress={() => handleRemoveDiscarded(m.id)} + theme={theme} + destructive + testID={`pendingMessages.discarded.remove:${m.id}`} + /> + <ActionButton + title="Send now" + onPress={() => handleSendDiscardedNow(m.id, m.text)} + theme={theme} + testID={`pendingMessages.discarded.sendNow:${m.id}`} + /> + </View> + </View> + ))} + </ScrollView> + </View> + )} + </View> + ); +} + + +function ActionButton(props: { + title: string; + onPress: () => void; + theme: any; + destructive?: boolean; + testID?: string; +}) { + const secondaryBackground = + props.theme?.colors?.button?.secondary?.background ?? + props.theme?.colors?.input?.background ?? + 'transparent'; + const destructiveBackground = + props.theme?.colors?.box?.error?.background ?? + props.theme?.colors?.box?.warning?.background ?? + secondaryBackground; + + const backgroundColor = props.destructive ? destructiveBackground : secondaryBackground; + + const secondaryTint = + props.theme?.colors?.button?.secondary?.tint ?? + props.theme?.colors?.text ?? + '#000'; + const destructiveTint = + props.theme?.colors?.box?.error?.text ?? + props.theme?.colors?.text ?? + secondaryTint; + return ( + <Pressable + onPress={props.onPress} + testID={props.testID} + style={(p) => ({ + paddingHorizontal: 12, + paddingVertical: 8, + borderRadius: 10, + backgroundColor, + opacity: p.pressed ? 0.85 : 1 + })} + > + <Text style={{ + color: props.destructive ? destructiveTint : secondaryTint, + fontWeight: '600', + ...Typography.default('semiBold') + }}> + {props.title} + </Text> + </Pressable> + ); +} diff --git a/expo-app/sources/components/sessions/pending/PendingQueueIndicator.test.ts b/expo-app/sources/components/sessions/pending/PendingQueueIndicator.test.ts new file mode 100644 index 000000000..7e54ac035 --- /dev/null +++ b/expo-app/sources/components/sessions/pending/PendingQueueIndicator.test.ts @@ -0,0 +1,142 @@ +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import renderer, { act } from 'react-test-renderer'; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; +vi.useFakeTimers(); +vi.clearAllMocks(); + +vi.mock('react-native', () => ({ + View: 'View', + Text: 'Text', + Pressable: 'Pressable', +})); + +vi.mock('@expo/vector-icons', () => ({ + Ionicons: 'Ionicons', +})); + +vi.mock('react-native-unistyles', () => ({ + useUnistyles: () => ({ + theme: { + colors: { + input: { background: '#fff' }, + text: '#000', + textSecondary: '#666', + }, + }, + }), +})); + +vi.mock('@/constants/Typography', () => ({ + Typography: { default: () => ({}) }, +})); + +vi.mock('@/components/layout', () => ({ + layout: { maxWidth: 800, headerMaxWidth: 800 }, +})); + +const modalShow = vi.fn(); +vi.mock('@/modal', () => ({ + Modal: { + show: (...args: any[]) => modalShow(...args), + }, +})); + +vi.mock('./PendingMessagesModal', () => ({ + PendingMessagesModal: 'PendingMessagesModal', +})); + +describe('PendingQueueIndicator', () => { + const cleanupTimers = () => { + vi.clearAllTimers(); + }; + + it('renders null when count is 0', async () => { + const { PendingQueueIndicator } = await import('./PendingQueueIndicator'); + let tree: ReturnType<typeof renderer.create> | undefined; + await act(async () => { + tree = renderer.create(React.createElement(PendingQueueIndicator, { sessionId: 's1', count: 0 })); + }); + expect(tree!.toJSON()).toBeNull(); + tree!.unmount(); + cleanupTimers(); + }); + + it('renders a preview when provided', async () => { + const { PendingQueueIndicator } = await import('./PendingQueueIndicator'); + let tree: ReturnType<typeof renderer.create> | undefined; + await act(async () => { + tree = renderer.create( + React.createElement(PendingQueueIndicator, { + sessionId: 's1', + count: 2, + preview: 'next up: hello', + } as any) + ); + }); + + await act(async () => { + vi.advanceTimersByTime(250); + }); + + const texts = tree!.root.findAllByType('Text' as any).map((n) => n.props.children).flat(); + expect(texts.join(' ')).toContain('next up: hello'); + tree!.unmount(); + cleanupTimers(); + }); + + it('constrains width to layout.maxWidth', async () => { + const { PendingQueueIndicator } = await import('./PendingQueueIndicator'); + let tree: ReturnType<typeof renderer.create> | undefined; + await act(async () => { + tree = renderer.create( + React.createElement(PendingQueueIndicator, { + sessionId: 's1', + count: 1, + } as any) + ); + }); + + await act(async () => { + vi.advanceTimersByTime(250); + }); + + const views = tree!.root.findAllByType('View' as any); + const hasMaxWidthContainer = views.some((v) => { + const style = v.props.style; + return style && style.maxWidth === 800 && style.width === '100%'; + }); + expect(hasMaxWidthContainer).toBe(true); + + const pressable = tree!.root.findByType('Pressable' as any); + const style = pressable.props.style({ pressed: false }); + expect(style.width).toBe('100%'); + tree!.unmount(); + cleanupTimers(); + }); + + it('does not flicker pending UI for fast enqueue→dequeue transitions', async () => { + const { PendingQueueIndicator } = await import('./PendingQueueIndicator'); + let tree: ReturnType<typeof renderer.create> | undefined; + await act(async () => { + tree = renderer.create(React.createElement(PendingQueueIndicator, { sessionId: 's1', count: 0 })); + }); + expect(tree!.toJSON()).toBeNull(); + + await act(async () => { + tree!.update(React.createElement(PendingQueueIndicator, { sessionId: 's1', count: 1, preview: 'hello' })); + }); + // Still hidden until debounce elapses. + expect(tree!.toJSON()).toBeNull(); + + await act(async () => { + vi.advanceTimersByTime(50); + tree!.update(React.createElement(PendingQueueIndicator, { sessionId: 's1', count: 0 })); + }); + // If the pending queue drains quickly, we should never render. + expect(tree!.toJSON()).toBeNull(); + tree!.unmount(); + cleanupTimers(); + }); +}); diff --git a/expo-app/sources/components/sessions/pending/PendingQueueIndicator.tsx b/expo-app/sources/components/sessions/pending/PendingQueueIndicator.tsx new file mode 100644 index 000000000..0a45c1852 --- /dev/null +++ b/expo-app/sources/components/sessions/pending/PendingQueueIndicator.tsx @@ -0,0 +1,98 @@ +import React from 'react'; +import { Pressable, Text, View } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { useUnistyles } from 'react-native-unistyles'; +import { Typography } from '@/constants/Typography'; +import { Modal } from '@/modal'; +import { PendingMessagesModal } from './PendingMessagesModal'; +import { layout } from '@/components/layout'; + +const PENDING_INDICATOR_DEBOUNCE_MS = 250; + +export const PendingQueueIndicator = React.memo((props: { sessionId: string; count: number; preview?: string }) => { + const { theme } = useUnistyles(); + const [visible, setVisible] = React.useState(false); + const debounceTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null); + + React.useEffect(() => { + if (props.count <= 0) { + if (debounceTimer.current) { + clearTimeout(debounceTimer.current); + debounceTimer.current = null; + } + if (visible) setVisible(false); + return; + } + if (visible) return; + if (debounceTimer.current) return; + + debounceTimer.current = setTimeout(() => { + debounceTimer.current = null; + setVisible(true); + }, PENDING_INDICATOR_DEBOUNCE_MS); + return () => { + if (debounceTimer.current) { + clearTimeout(debounceTimer.current); + debounceTimer.current = null; + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [props.count, visible]); + + if (props.count <= 0) return null; + if (!visible) return null; + + return ( + <View style={{ alignItems: 'center', paddingBottom: 8 }}> + <View style={{ width: '100%', maxWidth: layout.maxWidth, paddingHorizontal: 12 }}> + <Pressable + onPress={() => { + Modal.show({ + component: PendingMessagesModal, + props: { sessionId: props.sessionId } + }); + }} + style={(p) => ({ + width: '100%', + backgroundColor: theme.colors.input.background, + borderRadius: 14, + paddingHorizontal: 12, + paddingVertical: 10, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + opacity: p.pressed ? 0.85 : 1 + })} + > + <View style={{ flexDirection: 'row', alignItems: 'center' }}> + <Ionicons name="time-outline" size={16} color={theme.colors.textSecondary} /> + <View style={{ marginLeft: 8, flexShrink: 1 }}> + <Text style={{ + color: theme.colors.text, + fontSize: 13, + fontWeight: '600', + ...Typography.default('semiBold') + }}> + Pending ({props.count}) + </Text> + {props.preview ? ( + <Text + numberOfLines={1} + style={{ + marginTop: 2, + color: theme.colors.textSecondary, + fontSize: 12, + ...Typography.default(), + }} + > + {props.preview.trim()} + </Text> + ) : null} + </View> + </View> + <Ionicons name="chevron-forward" size={16} color={theme.colors.textSecondary} /> + </Pressable> + </View> + </View> + ); +}); diff --git a/expo-app/sources/components/sessions/pending/PendingUserTextMessageView.test.tsx b/expo-app/sources/components/sessions/pending/PendingUserTextMessageView.test.tsx new file mode 100644 index 000000000..dfc381b0a --- /dev/null +++ b/expo-app/sources/components/sessions/pending/PendingUserTextMessageView.test.tsx @@ -0,0 +1,81 @@ +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import renderer, { act } from 'react-test-renderer'; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +vi.mock('react-native', () => ({ + View: 'View', + Text: 'Text', + Pressable: 'Pressable', +})); + +vi.mock('@expo/vector-icons', () => ({ + Ionicons: 'Ionicons', +})); + +vi.mock('react-native-unistyles', () => ({ + StyleSheet: { create: (fn: any) => fn({ colors: { userMessageBackground: '#eee' } }) }, + useUnistyles: () => ({ + theme: { + colors: { + input: { background: '#fff' }, + textSecondary: '#666', + userMessageBackground: '#eee', + }, + }, + }), +})); + +vi.mock('@/constants/Typography', () => ({ + Typography: { default: () => ({}) }, +})); + +vi.mock('@/components/layout', () => ({ + layout: { maxWidth: 800, headerMaxWidth: 800 }, +})); + +vi.mock('@/components/markdown/MarkdownView', () => ({ + MarkdownView: 'MarkdownView', +})); + +const modalShow = vi.fn(); +vi.mock('@/modal', () => ({ + Modal: { + show: (...args: any[]) => modalShow(...args), + }, +})); + +vi.mock('./PendingMessagesModal', () => ({ + PendingMessagesModal: 'PendingMessagesModal', +})); + +describe('PendingUserTextMessageView', () => { + it('renders a badge with a pending count when there are other pending messages', async () => { + const { PendingUserTextMessageView } = await import('./PendingUserTextMessageView'); + + let tree: ReturnType<typeof renderer.create> | undefined; + await act(async () => { + tree = renderer.create( + React.createElement(PendingUserTextMessageView, { + sessionId: 's1', + otherPendingCount: 2, + message: { + id: 'p1', + localId: 'p1', + createdAt: 1, + updatedAt: 1, + text: 'hello', + rawRecord: {} as any, + }, + } as any), + ); + }); + + const pressables = tree!.root.findAllByType('Pressable' as any); + expect(pressables.some((p) => p.props.accessibilityLabel === 'Pending (+2)')).toBe(true); + + tree!.unmount(); + }); +}); + diff --git a/expo-app/sources/components/sessions/pending/PendingUserTextMessageView.tsx b/expo-app/sources/components/sessions/pending/PendingUserTextMessageView.tsx new file mode 100644 index 000000000..17b81b289 --- /dev/null +++ b/expo-app/sources/components/sessions/pending/PendingUserTextMessageView.tsx @@ -0,0 +1,103 @@ +import * as React from 'react'; +import { Pressable, Text, View } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { StyleSheet, useUnistyles } from 'react-native-unistyles'; +import { Modal } from '@/modal'; +import { Typography } from '@/constants/Typography'; +import type { PendingMessage } from '@/sync/storageTypes'; +import { MarkdownView } from '@/components/markdown/MarkdownView'; +import { PendingMessagesModal } from './PendingMessagesModal'; +import { layout } from '@/components/layout'; + +export function PendingUserTextMessageView(props: { + sessionId: string; + message: PendingMessage; + otherPendingCount: number; +}) { + const { theme } = useUnistyles(); + + const badgeLabel = props.otherPendingCount > 0 + ? `Pending (+${props.otherPendingCount})` + : 'Pending'; + + return ( + <View style={styles.messageContainer} renderToHardwareTextureAndroid={true}> + <View style={styles.messageContent}> + <View style={styles.userMessageContainer}> + <View style={[styles.userMessageBubble, { opacity: 0.85 }]}> + <Pressable + onPress={() => { + Modal.show({ + component: PendingMessagesModal, + props: { sessionId: props.sessionId }, + }); + }} + accessibilityRole="button" + accessibilityLabel={badgeLabel} + hitSlop={10} + style={({ pressed }) => ([ + styles.pendingBadge, + { + backgroundColor: theme.colors.input.background, + opacity: pressed ? 0.85 : 1, + } + ])} + > + <Ionicons name="time-outline" size={14} color={theme.colors.textSecondary} /> + <Text style={[styles.pendingBadgeText, { color: theme.colors.textSecondary }]}> + {badgeLabel} + </Text> + </Pressable> + <MarkdownView markdown={props.message.displayText || props.message.text} /> + </View> + </View> + </View> + </View> + ); +} + + +const styles = StyleSheet.create((theme) => ({ + messageContainer: { + flexDirection: 'row', + justifyContent: 'center', + }, + messageContent: { + flexDirection: 'column', + flexGrow: 1, + flexBasis: 0, + maxWidth: layout.maxWidth, + }, + userMessageContainer: { + maxWidth: '100%', + flexDirection: 'column', + alignItems: 'flex-end', + justifyContent: 'flex-end', + paddingHorizontal: 16, + }, + userMessageBubble: { + backgroundColor: theme.colors.userMessageBackground, + paddingHorizontal: 12, + paddingVertical: 8, + borderRadius: 12, + marginBottom: 12, + maxWidth: '100%', + position: 'relative', + }, + pendingBadge: { + position: 'absolute', + top: -10, + right: -10, + flexDirection: 'row', + alignItems: 'center', + borderRadius: 999, + paddingHorizontal: 10, + paddingVertical: 6, + cursor: 'pointer', + }, + pendingBadgeText: { + marginLeft: 6, + fontSize: 12, + ...Typography.default('semiBold'), + }, +})); diff --git a/expo-app/sources/components/tools/PermissionFooter.codexDecision.test.tsx b/expo-app/sources/components/tools/PermissionFooter.codexDecision.test.tsx new file mode 100644 index 000000000..6497ae5b6 --- /dev/null +++ b/expo-app/sources/components/tools/PermissionFooter.codexDecision.test.tsx @@ -0,0 +1,117 @@ +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import renderer, { act } from 'react-test-renderer'; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +vi.mock('react-native', () => ({ + View: 'View', + Text: 'Text', + TouchableOpacity: 'TouchableOpacity', + ActivityIndicator: 'ActivityIndicator', + Platform: { OS: 'ios', select: (v: any) => v.ios }, + StyleSheet: { create: (styles: any) => styles }, +})); + +vi.mock('react-native-unistyles', () => ({ + useUnistyles: () => ({ + theme: { + colors: { + text: '#000', + textSecondary: '#666', + permissionButton: { + allow: { background: '#0f0' }, + deny: { background: '#f00' }, + allowAll: { background: '#00f' }, + }, + }, + }, + }), +})); + +vi.mock('@expo/vector-icons', () => ({ + Ionicons: 'Ionicons', +})); + +const sessionDeny = vi.fn<(...args: any[]) => Promise<void>>(async (..._args: any[]) => {}); +const sessionAbort = vi.fn<(...args: any[]) => Promise<void>>(async (..._args: any[]) => {}); +vi.mock('@/sync/ops', () => ({ + sessionAllow: vi.fn(async () => {}), + sessionDeny: (...args: any[]) => sessionDeny(...args), + sessionAbort: (...args: any[]) => sessionAbort(...args), +})); + +vi.mock('@/sync/storage', () => ({ + storage: { getState: () => ({ updateSessionPermissionMode: vi.fn() }) }, +})); + +vi.mock('@/text', () => ({ + t: (key: string) => key, +})); + +vi.mock('@/agents/resolve', () => ({ + resolveAgentIdForPermissionUi: () => 'codex', +})); + +vi.mock('@/agents/permissionUiCopy', () => ({ + getPermissionFooterCopy: () => ({ + protocol: 'codexDecision', + yesAlwaysAllowCommandKey: 'codex.permissions.yesAlwaysAllowCommand', + yesForSessionKey: 'codex.permissions.yesForSession', + stopAndExplainKey: 'codex.permissions.stopAndExplain', + }), +})); + +describe('PermissionFooter (codexDecision)', () => { + it('shows a permission summary line', async () => { + const { PermissionFooter } = await import('./PermissionFooter'); + + let tree: renderer.ReactTestRenderer | undefined; + await act(async () => { + tree = renderer.create( + React.createElement(PermissionFooter, { + permission: { id: 'p1', status: 'pending' }, + sessionId: 's1', + toolName: 'execute', + toolInput: { command: 'pwd' }, + metadata: { flavor: 'codex' }, + }), + ); + }); + + const texts = tree!.root.findAllByType('Text' as any).map((n: any) => n.props.children); + const flattened = texts.flatMap((c: any) => Array.isArray(c) ? c : [c]).filter((c: any) => typeof c === 'string'); + expect(flattened).toContain('Run: pwd'); + }); + + it('Stop denies permission and aborts the run', async () => { + sessionDeny.mockClear(); + sessionAbort.mockClear(); + + const { PermissionFooter } = await import('./PermissionFooter'); + + let tree: renderer.ReactTestRenderer | undefined; + await act(async () => { + tree = renderer.create( + React.createElement(PermissionFooter, { + permission: { id: 'p1', status: 'pending' }, + sessionId: 's1', + toolName: 'execute', + toolInput: { command: 'pwd' }, + metadata: { flavor: 'codex' }, + }), + ); + }); + + const buttons = tree!.root.findAllByType('TouchableOpacity' as any); + // Last button is "stop and explain" + const stop = buttons[buttons.length - 1]; + + await act(async () => { + await stop.props.onPress(); + }); + + expect(sessionDeny).toHaveBeenCalledTimes(1); + expect(sessionAbort).toHaveBeenCalledTimes(1); + }); +}); diff --git a/expo-app/sources/components/tools/PermissionFooter.stopAbortsRun.test.tsx b/expo-app/sources/components/tools/PermissionFooter.stopAbortsRun.test.tsx new file mode 100644 index 000000000..5b17c46a7 --- /dev/null +++ b/expo-app/sources/components/tools/PermissionFooter.stopAbortsRun.test.tsx @@ -0,0 +1,96 @@ +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import renderer, { act } from 'react-test-renderer'; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +vi.mock('react-native', () => ({ + View: 'View', + Text: 'Text', + TouchableOpacity: 'TouchableOpacity', + ActivityIndicator: 'ActivityIndicator', + Platform: { OS: 'ios', select: (v: any) => v.ios }, + StyleSheet: { create: (styles: any) => styles }, +})); + +vi.mock('react-native-unistyles', () => ({ + useUnistyles: () => ({ + theme: { + colors: { + text: '#000', + textSecondary: '#666', + permissionButton: { + allow: { background: '#0f0' }, + deny: { background: '#f00' }, + allowAll: { background: '#00f' }, + }, + }, + }, + }), +})); + +vi.mock('@expo/vector-icons', () => ({ + Ionicons: 'Ionicons', +})); + +const sessionDeny = vi.fn<(...args: any[]) => Promise<void>>(async (..._args: any[]) => {}); +const sessionAbort = vi.fn<(...args: any[]) => Promise<void>>(async (..._args: any[]) => {}); +vi.mock('@/sync/ops', () => ({ + sessionAllow: vi.fn(async () => {}), + sessionDeny: (...args: any[]) => sessionDeny(...args), + sessionAbort: (...args: any[]) => sessionAbort(...args), +})); + +vi.mock('@/sync/storage', () => ({ + storage: { getState: () => ({ updateSessionPermissionMode: vi.fn() }) }, +})); + +vi.mock('@/text', () => ({ + t: (key: string) => key, +})); + +vi.mock('@/agents/resolve', () => ({ + resolveAgentIdForPermissionUi: () => 'opencode', +})); + +vi.mock('@/agents/permissionUiCopy', () => ({ + getPermissionFooterCopy: () => ({ + protocol: 'claude', + yesAllowAllEditsKey: 'claude.permissions.yesAllowAllEdits', + yesForToolKey: 'claude.permissions.yesForTool', + noTellAgentKey: 'claude.permissions.stopAndExplain', + }), +})); + +describe('PermissionFooter (non-codex)', () => { + it('Stop denies permission (abort) and aborts the run', async () => { + sessionDeny.mockClear(); + sessionAbort.mockClear(); + + const { PermissionFooter } = await import('./PermissionFooter'); + + let tree: renderer.ReactTestRenderer | undefined; + await act(async () => { + tree = renderer.create( + React.createElement(PermissionFooter, { + permission: { id: 'p1', status: 'pending' }, + sessionId: 's1', + toolName: 'Read', + toolInput: { filepath: '/etc/hosts' }, + metadata: { flavor: 'opencode' }, + }), + ); + }); + + const buttons = tree!.root.findAllByType('TouchableOpacity' as any); + const stop = buttons[buttons.length - 1]; + + await act(async () => { + await stop.props.onPress(); + }); + + expect(sessionDeny).toHaveBeenCalledTimes(1); + expect((sessionDeny as any).mock.calls[0]?.[4]).toBe('abort'); + expect(sessionAbort).toHaveBeenCalledTimes(1); + }); +}); diff --git a/expo-app/sources/components/tools/PermissionFooter.tsx b/expo-app/sources/components/tools/PermissionFooter.tsx index 0737d399e..34b586b6f 100644 --- a/expo-app/sources/components/tools/PermissionFooter.tsx +++ b/expo-app/sources/components/tools/PermissionFooter.tsx @@ -1,10 +1,15 @@ import React, { useState } from 'react'; -import { View, Text, TouchableOpacity, ActivityIndicator, StyleSheet, Platform } from 'react-native'; +import { View, Text, TouchableOpacity, ActivityIndicator, Platform } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; -import { sessionAllow, sessionDeny } from '@/sync/ops'; -import { useUnistyles } from 'react-native-unistyles'; +import { sessionAbort, sessionAllow, sessionDeny } from '@/sync/ops'; +import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { storage } from '@/sync/storage'; import { t } from '@/text'; +import { resolveAgentIdForPermissionUi } from '@/agents/resolve'; +import { getPermissionFooterCopy } from '@/agents/permissionUiCopy'; +import { extractShellCommand } from './utils/shellCommand'; +import { parseParenIdentifier } from './utils/parseParenIdentifier'; +import { formatPermissionRequestSummary } from './utils/permissionSummary'; interface PermissionFooterProps { permission: { @@ -13,7 +18,8 @@ interface PermissionFooterProps { reason?: string; mode?: string; allowedTools?: string[]; - decision?: 'approved' | 'approved_for_session' | 'denied' | 'abort'; + allowTools?: string[]; // legacy alias + decision?: 'approved' | 'approved_for_session' | 'approved_execpolicy_amendment' | 'denied' | 'abort'; }; sessionId: string; toolName: string; @@ -26,9 +32,22 @@ export const PermissionFooter: React.FC<PermissionFooterProps> = ({ permission, const [loadingButton, setLoadingButton] = useState<'allow' | 'deny' | 'abort' | null>(null); const [loadingAllEdits, setLoadingAllEdits] = useState(false); const [loadingForSession, setLoadingForSession] = useState(false); + const [loadingForSessionPrefix, setLoadingForSessionPrefix] = useState(false); + const [loadingForSessionCommandName, setLoadingForSessionCommandName] = useState(false); + const [loadingExecPolicy, setLoadingExecPolicy] = useState(false); - // Check if this is a Codex session - check both metadata.flavor and tool name prefix - const isCodex = metadata?.flavor === 'codex' || toolName.startsWith('Codex'); + const agentId = resolveAgentIdForPermissionUi({ flavor: metadata?.flavor, toolName }); + const copy = getPermissionFooterCopy(agentId); + const isCodexDecision = copy.protocol === 'codexDecision'; + // Codex always provides proposed_execpolicy_amendment + const execPolicyCommand = (() => { + const proposedAmendment = toolInput?.proposedExecpolicyAmendment ?? toolInput?.proposed_execpolicy_amendment; + if (Array.isArray(proposedAmendment)) { + return proposedAmendment.filter((part: unknown): part is string => typeof part === 'string' && part.length > 0); + } + return []; + })(); + const canApproveExecPolicy = isCodexDecision && execPolicyCommand.length > 0; const handleApprove = async () => { if (permission.status !== 'pending' || loadingButton !== null || loadingAllEdits || loadingForSession) return; @@ -59,15 +78,16 @@ export const PermissionFooter: React.FC<PermissionFooterProps> = ({ permission, }; const handleApproveForSession = async () => { - if (permission.status !== 'pending' || loadingButton !== null || loadingAllEdits || loadingForSession || !toolName) return; + if (permission.status !== 'pending' || loadingButton !== null || loadingAllEdits || loadingForSession || loadingForSessionPrefix || !toolName) return; setLoadingForSession(true); try { - // Special handling for Bash tool - include exact command + // Special handling for shell/exec tools - include exact command let toolIdentifier = toolName; - if (toolName === 'Bash' && toolInput?.command) { - const command = toolInput.command; - toolIdentifier = `Bash(${command})`; + const command = extractShellCommand(toolInput); + const lower = toolName.toLowerCase(); + if (command && (lower === 'bash' || lower === 'execute' || lower === 'shell')) { + toolIdentifier = `${toolName}(${command})`; } await sessionAllow(sessionId, permission.id, undefined, [toolIdentifier]); @@ -78,12 +98,67 @@ export const PermissionFooter: React.FC<PermissionFooterProps> = ({ permission, } }; + const handleApproveForSessionSubcommand = async () => { + if (permission.status !== 'pending' || loadingButton !== null || loadingAllEdits || loadingForSession || loadingForSessionPrefix || loadingForSessionCommandName || !toolName) return; + + const command = extractShellCommand(toolInput); + const lower = toolName.toLowerCase(); + if (!command || !(lower === 'bash' || lower === 'execute' || lower === 'shell')) return; + + const stripped = stripSimpleEnvPrelude(command); + const parts = stripped.split(/\s+/).filter(Boolean); + const cmd = parts[0]; + const sub = parts[1]; + const canUseSubcommand = + Boolean(cmd) && + Boolean(sub) && + !sub.startsWith('-') && + // Only offer subcommand-level approvals for common subcommand CLIs. + ['git', 'npm', 'yarn', 'pnpm', 'cargo', 'docker', 'kubectl', 'gh', 'brew'].includes(cmd); + if (!canUseSubcommand) return; + + setLoadingForSessionPrefix(true); + try { + const toolIdentifier = `${toolName}(${cmd} ${sub}:*)`; + await sessionAllow(sessionId, permission.id, undefined, [toolIdentifier]); + } catch (error) { + console.error('Failed to approve subcommand for session:', error); + } finally { + setLoadingForSessionPrefix(false); + } + }; + + const handleApproveForSessionCommandName = async () => { + if (permission.status !== 'pending' || loadingButton !== null || loadingAllEdits || loadingForSession || loadingForSessionPrefix || loadingForSessionCommandName || !toolName) return; + + const command = extractShellCommand(toolInput); + const lower = toolName.toLowerCase(); + if (!command || !(lower === 'bash' || lower === 'execute' || lower === 'shell')) return; + + const stripped = stripSimpleEnvPrelude(command); + const first = stripped.split(/\s+/).filter(Boolean)[0]; + if (!first) return; + + setLoadingForSessionCommandName(true); + try { + const toolIdentifier = `${toolName}(${first}:*)`; + await sessionAllow(sessionId, permission.id, undefined, [toolIdentifier]); + } catch (error) { + console.error('Failed to approve command name for session:', error); + } finally { + setLoadingForSessionCommandName(false); + } + }; + const handleDeny = async () => { if (permission.status !== 'pending' || loadingButton !== null || loadingAllEdits || loadingForSession) return; setLoadingButton('deny'); try { - await sessionDeny(sessionId, permission.id); + await sessionDeny(sessionId, permission.id, undefined, undefined, 'abort'); + // Denying a single tool call is not always enough to stop the agent from continuing. + // Also abort the current session run so the agent stops and waits for the user. + await sessionAbort(sessionId); } catch (error) { console.error('Failed to deny permission:', error); } finally { @@ -93,7 +168,7 @@ export const PermissionFooter: React.FC<PermissionFooterProps> = ({ permission, // Codex-specific handlers const handleCodexApprove = async () => { - if (permission.status !== 'pending' || loadingButton !== null || loadingForSession) return; + if (permission.status !== 'pending' || loadingButton !== null || loadingForSession || loadingExecPolicy) return; setLoadingButton('allow'); try { @@ -106,7 +181,7 @@ export const PermissionFooter: React.FC<PermissionFooterProps> = ({ permission, }; const handleCodexApproveForSession = async () => { - if (permission.status !== 'pending' || loadingButton !== null || loadingForSession) return; + if (permission.status !== 'pending' || loadingButton !== null || loadingForSession || loadingExecPolicy) return; setLoadingForSession(true); try { @@ -117,13 +192,36 @@ export const PermissionFooter: React.FC<PermissionFooterProps> = ({ permission, setLoadingForSession(false); } }; + + const handleCodexApproveExecPolicy = async () => { + if (permission.status !== 'pending' || loadingButton !== null || loadingForSession || loadingExecPolicy || !canApproveExecPolicy) return; + + setLoadingExecPolicy(true); + try { + await sessionAllow( + sessionId, + permission.id, + undefined, + undefined, + 'approved_execpolicy_amendment', + { command: execPolicyCommand } + ); + } catch (error) { + console.error('Failed to approve with execpolicy amendment:', error); + } finally { + setLoadingExecPolicy(false); + } + }; const handleCodexAbort = async () => { - if (permission.status !== 'pending' || loadingButton !== null || loadingForSession) return; + if (permission.status !== 'pending' || loadingButton !== null || loadingForSession || loadingExecPolicy) return; setLoadingButton('abort'); try { await sessionDeny(sessionId, permission.id, undefined, undefined, 'abort'); + // Denying a single tool call is not always enough to stop the agent from continuing. + // Also abort the current session run so the agent stops and waits for the user. + await sessionAbort(sessionId); } catch (error) { console.error('Failed to abort permission:', error); } finally { @@ -136,36 +234,145 @@ export const PermissionFooter: React.FC<PermissionFooterProps> = ({ permission, const isPending = permission.status === 'pending'; // Helper function to check if tool matches allowed pattern + const getAllowedToolsList = (permission: any): string[] | undefined => { + const list = permission?.allowedTools ?? permission?.allowTools; + return Array.isArray(list) ? list : undefined; + }; + + const shellToolNames = new Set(['bash', 'execute', 'shell']); + + const stripSimpleEnvPrelude = (command: string): string => { + const parts = command.trim().split(/\s+/); + let i = 0; + while (i < parts.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(parts[i])) { + i++; + } + return parts.slice(i).join(' '); + }; + + const matchesPrefix = (command: string, prefix: string): boolean => { + if (!command || !prefix) return false; + if (!command.startsWith(prefix)) return false; + if (command.length === prefix.length) return true; + if (prefix.endsWith(' ')) return true; + return command[prefix.length] === ' '; + }; + const isToolAllowed = (toolName: string, toolInput: any, allowedTools: string[] | undefined): boolean => { if (!allowedTools) return false; // Direct match for non-Bash tools if (allowedTools.includes(toolName)) return true; - // For Bash, check exact command match - if (toolName === 'Bash' && toolInput?.command) { - const command = toolInput.command; - return allowedTools.includes(`Bash(${command})`); + // For shell/exec tools, check exact command match + const command = extractShellCommand(toolInput); + const lower = toolName.toLowerCase(); + if (command && shellToolNames.has(lower)) { + const exact = `${toolName}(${command})`; + if (allowedTools.includes(exact)) return true; + + // Also accept prefixes (e.g. `Bash(git status:*)`) and shell-tool synonyms. + const effectiveCommand = stripSimpleEnvPrelude(command); + for (const item of allowedTools) { + if (typeof item !== 'string') continue; + const parsed = parseParenIdentifier(item); + if (!parsed) continue; + if (!shellToolNames.has(parsed.name.toLowerCase())) continue; + + const spec = parsed.spec; + if (spec.endsWith(':*')) { + const prefix = spec.slice(0, -2); + if (prefix && matchesPrefix(effectiveCommand, prefix)) return true; + } else if (spec === command) { + return true; + } + } } return false; }; // Detect which button was used based on mode (for Claude) or decision (for Codex) - const isApprovedViaAllow = isApproved && permission.mode !== 'acceptEdits' && !isToolAllowed(toolName, toolInput, permission.allowedTools); + const allowedTools = getAllowedToolsList(permission); + const commandForShell = extractShellCommand(toolInput); + const isShellTool = shellToolNames.has(toolName.toLowerCase()); + + const isApprovedForSessionSubcommand = (() => { + if (!isApproved || !allowedTools || !isShellTool || !commandForShell) return false; + const effectiveCommand = stripSimpleEnvPrelude(commandForShell); + const parts = effectiveCommand.split(/\s+/).filter(Boolean); + const cmd = parts[0]; + const sub = parts[1]; + if (!cmd || !sub) return false; + if (sub.startsWith('-')) return false; + if (!['git', 'npm', 'yarn', 'pnpm', 'cargo', 'docker', 'kubectl', 'gh', 'brew'].includes(cmd)) return false; + + for (const item of allowedTools) { + if (typeof item !== 'string') continue; + const parsed = parseParenIdentifier(item); + if (!parsed) continue; + if (!shellToolNames.has(parsed.name.toLowerCase())) continue; + const spec = parsed.spec; + if (spec.endsWith(':*')) { + const prefix = spec.slice(0, -2); + if (prefix && matchesPrefix(effectiveCommand, prefix) && prefix.trim() === `${cmd} ${sub}`) return true; + } + } + return false; + })(); + + const isApprovedForSessionExact = (() => { + if (!isApproved || !allowedTools || !isShellTool || !commandForShell) return false; + for (const item of allowedTools) { + if (typeof item !== 'string') continue; + const parsed = parseParenIdentifier(item); + if (!parsed) continue; + if (!shellToolNames.has(parsed.name.toLowerCase())) continue; + if (!parsed.spec.endsWith(':*') && parsed.spec === commandForShell) return true; + } + return false; + })(); + + const isApprovedForSessionCommandName = (() => { + if (!isApproved || !allowedTools || !isShellTool || !commandForShell) return false; + const effective = stripSimpleEnvPrelude(commandForShell); + const first = effective.split(/\s+/).filter(Boolean)[0]; + if (!first) return false; + for (const item of allowedTools) { + if (typeof item !== 'string') continue; + const parsed = parseParenIdentifier(item); + if (!parsed) continue; + if (!shellToolNames.has(parsed.name.toLowerCase())) continue; + if (parsed.spec === `${first}:*`) return true; + } + return false; + })(); + + const isApprovedForSession = isApproved && ( + isShellTool + ? (isApprovedForSessionExact || isApprovedForSessionSubcommand) + : isToolAllowed(toolName, toolInput, allowedTools) + ); + + const isApprovedViaAllow = isApproved && permission.mode !== 'acceptEdits' && !isApprovedForSession; const isApprovedViaAllEdits = isApproved && permission.mode === 'acceptEdits'; - const isApprovedForSession = isApproved && isToolAllowed(toolName, toolInput, permission.allowedTools); // Codex-specific status detection with fallback - const isCodexApproved = isCodex && isApproved && (permission.decision === 'approved' || !permission.decision); - const isCodexApprovedForSession = isCodex && isApproved && permission.decision === 'approved_for_session'; - const isCodexAborted = isCodex && isDenied && permission.decision === 'abort'; + const isCodexApproved = isCodexDecision && isApproved && (permission.decision === 'approved' || !permission.decision); + const isCodexApprovedForSession = isCodexDecision && isApproved && permission.decision === 'approved_for_session'; + const isCodexApprovedExecPolicy = isCodexDecision && isApproved && permission.decision === 'approved_execpolicy_amendment'; + const isCodexAborted = isCodexDecision && isDenied && permission.decision === 'abort'; const styles = StyleSheet.create({ container: { paddingHorizontal: 12, paddingVertical: 8, justifyContent: 'center', + gap: 10, + }, + summary: { + fontSize: 12, + color: theme.colors.textSecondary, }, buttonContainer: { flexDirection: 'column', @@ -257,10 +464,13 @@ export const PermissionFooter: React.FC<PermissionFooterProps> = ({ permission, }, }); - // Render Codex buttons if this is a Codex session - if (isCodex) { + // Render Codex-style decision buttons if the agent uses the Codex decision protocol. + if (copy.protocol === 'codexDecision') { return ( <View style={styles.container}> + <Text style={styles.summary} numberOfLines={2} ellipsizeMode="tail"> + {formatPermissionRequestSummary({ toolName, toolInput })} + </Text> <View style={styles.buttonContainer}> {/* Codex: Yes button */} <TouchableOpacity @@ -268,10 +478,10 @@ export const PermissionFooter: React.FC<PermissionFooterProps> = ({ permission, styles.button, isPending && styles.buttonAllow, isCodexApproved && styles.buttonSelected, - (isCodexAborted || isCodexApprovedForSession) && styles.buttonInactive + (isCodexAborted || isCodexApprovedForSession || isCodexApprovedExecPolicy) && styles.buttonInactive ]} onPress={handleCodexApprove} - disabled={!isPending || loadingButton !== null || loadingForSession} + disabled={!isPending || loadingButton !== null || loadingForSession || loadingExecPolicy} activeOpacity={isPending ? 0.7 : 1} > {loadingButton === 'allow' && isPending ? ( @@ -291,16 +501,47 @@ export const PermissionFooter: React.FC<PermissionFooterProps> = ({ permission, )} </TouchableOpacity> + {/* Codex: Yes, always allow this command button */} + {canApproveExecPolicy && ( + <TouchableOpacity + style={[ + styles.button, + isPending && styles.buttonForSession, + isCodexApprovedExecPolicy && styles.buttonSelected, + (isCodexAborted || isCodexApproved || isCodexApprovedForSession) && styles.buttonInactive + ]} + onPress={handleCodexApproveExecPolicy} + disabled={!isPending || loadingButton !== null || loadingForSession || loadingExecPolicy} + activeOpacity={isPending ? 0.7 : 1} + > + {loadingExecPolicy && isPending ? ( + <View style={[styles.buttonContent, { width: 40, height: 20, justifyContent: 'center' }]}> + <ActivityIndicator size={Platform.OS === 'ios' ? "small" : 14 as any} color={styles.loadingIndicatorForSession.color} /> + </View> + ) : ( + <View style={styles.buttonContent}> + <Text style={[ + styles.buttonText, + isPending && styles.buttonTextForSession, + isCodexApprovedExecPolicy && styles.buttonTextSelected + ]} numberOfLines={1} ellipsizeMode="tail"> + {t(copy.yesAlwaysAllowCommandKey)} + </Text> + </View> + )} + </TouchableOpacity> + )} + {/* Codex: Yes, and don't ask for a session button */} <TouchableOpacity style={[ styles.button, isPending && styles.buttonForSession, isCodexApprovedForSession && styles.buttonSelected, - (isCodexAborted || isCodexApproved) && styles.buttonInactive + (isCodexAborted || isCodexApproved || isCodexApprovedExecPolicy) && styles.buttonInactive ]} onPress={handleCodexApproveForSession} - disabled={!isPending || loadingButton !== null || loadingForSession} + disabled={!isPending || loadingButton !== null || loadingForSession || loadingExecPolicy} activeOpacity={isPending ? 0.7 : 1} > {loadingForSession && isPending ? ( @@ -314,7 +555,7 @@ export const PermissionFooter: React.FC<PermissionFooterProps> = ({ permission, isPending && styles.buttonTextForSession, isCodexApprovedForSession && styles.buttonTextSelected ]} numberOfLines={1} ellipsizeMode="tail"> - {t('codex.permissions.yesForSession')} + {t(copy.yesForSessionKey)} </Text> </View> )} @@ -326,10 +567,10 @@ export const PermissionFooter: React.FC<PermissionFooterProps> = ({ permission, styles.button, isPending && styles.buttonDeny, isCodexAborted && styles.buttonSelected, - (isCodexApproved || isCodexApprovedForSession) && styles.buttonInactive + (isCodexApproved || isCodexApprovedForSession || isCodexApprovedExecPolicy) && styles.buttonInactive ]} onPress={handleCodexAbort} - disabled={!isPending || loadingButton !== null || loadingForSession} + disabled={!isPending || loadingButton !== null || loadingForSession || loadingExecPolicy} activeOpacity={isPending ? 0.7 : 1} > {loadingButton === 'abort' && isPending ? ( @@ -343,7 +584,7 @@ export const PermissionFooter: React.FC<PermissionFooterProps> = ({ permission, isPending && styles.buttonTextDeny, isCodexAborted && styles.buttonTextSelected ]} numberOfLines={1} ellipsizeMode="tail"> - {t('codex.permissions.stopAndExplain')} + {t(copy.stopAndExplainKey)} </Text> </View> )} @@ -354,8 +595,19 @@ export const PermissionFooter: React.FC<PermissionFooterProps> = ({ permission, } // Render Claude buttons (existing behavior) + const showAllowForSessionSubcommand = isShellTool && typeof commandForShell === 'string' && (() => { + const stripped = stripSimpleEnvPrelude(String(commandForShell)); + const parts = stripped.split(/\s+/).filter(Boolean); + const cmd = parts[0]; + const sub = parts[1]; + return Boolean(cmd) && Boolean(sub) && !String(sub).startsWith('-') && ['git', 'npm', 'yarn', 'pnpm', 'cargo', 'docker', 'kubectl', 'gh', 'brew'].includes(String(cmd)); + })(); + const showAllowForSessionCommandName = isShellTool && typeof commandForShell === 'string' && commandForShell.length > 0 && Boolean(stripSimpleEnvPrelude(String(commandForShell)).split(/\s+/).filter(Boolean)[0]); return ( <View style={styles.container}> + <Text style={styles.summary} numberOfLines={2} ellipsizeMode="tail"> + {formatPermissionRequestSummary({ toolName, toolInput })} + </Text> <View style={styles.buttonContainer}> <TouchableOpacity style={[ @@ -385,8 +637,8 @@ export const PermissionFooter: React.FC<PermissionFooterProps> = ({ permission, )} </TouchableOpacity> - {/* Allow All Edits button - only show for Edit and MultiEdit tools */} - {(toolName === 'Edit' || toolName === 'MultiEdit' || toolName === 'Write' || toolName === 'NotebookEdit' || toolName === 'exit_plan_mode' || toolName === 'ExitPlanMode') && ( + {/* Allow All Edits button - only show for edit/write tools */} + {(toolName === 'Edit' || toolName === 'MultiEdit' || toolName === 'Write' || toolName === 'NotebookEdit') && ( <TouchableOpacity style={[ styles.button, @@ -409,7 +661,7 @@ export const PermissionFooter: React.FC<PermissionFooterProps> = ({ permission, isPending && styles.buttonTextAllowAll, isApprovedViaAllEdits && styles.buttonTextSelected ]} numberOfLines={1} ellipsizeMode="tail"> - {t('claude.permissions.yesAllowAllEdits')} + {t(copy.yesAllowAllEditsKey)} </Text> </View> )} @@ -422,11 +674,11 @@ export const PermissionFooter: React.FC<PermissionFooterProps> = ({ permission, style={[ styles.button, isPending && styles.buttonForSession, - isApprovedForSession && styles.buttonSelected, + ((isShellTool ? isApprovedForSessionExact : isApprovedForSession) && styles.buttonSelected), (isDenied || isApprovedViaAllow || isApprovedViaAllEdits) && styles.buttonInactive ]} onPress={handleApproveForSession} - disabled={!isPending || loadingButton !== null || loadingAllEdits || loadingForSession} + disabled={!isPending || loadingButton !== null || loadingAllEdits || loadingForSession || loadingForSessionPrefix || loadingForSessionCommandName} activeOpacity={isPending ? 0.7 : 1} > {loadingForSession && isPending ? ( @@ -438,9 +690,77 @@ export const PermissionFooter: React.FC<PermissionFooterProps> = ({ permission, <Text style={[ styles.buttonText, isPending && styles.buttonTextForSession, - isApprovedForSession && styles.buttonTextSelected + (isShellTool ? isApprovedForSessionExact : isApprovedForSession) && styles.buttonTextSelected + ]} numberOfLines={1} ellipsizeMode="tail"> + {t(copy.yesForToolKey)} + </Text> + </View> + )} + </TouchableOpacity> + )} + + {/* Allow subcommand for session (shell tools only) */} + {toolName && toolName !== 'Edit' && toolName !== 'MultiEdit' && toolName !== 'Write' && toolName !== 'NotebookEdit' && toolName !== 'exit_plan_mode' && toolName !== 'ExitPlanMode' && showAllowForSessionSubcommand && ( + <TouchableOpacity + style={[ + styles.button, + isPending && styles.buttonForSession, + (isApprovedForSessionSubcommand && !isApprovedForSessionCommandName) && styles.buttonSelected, + (isDenied || isApprovedViaAllow || isApprovedViaAllEdits || (isShellTool ? isApprovedForSessionExact : isApprovedForSession)) && styles.buttonInactive + ]} + onPress={handleApproveForSessionSubcommand} + disabled={!isPending || loadingButton !== null || loadingAllEdits || loadingForSession || loadingForSessionPrefix || loadingForSessionCommandName} + activeOpacity={isPending ? 0.7 : 1} + > + {loadingForSessionPrefix && isPending ? ( + <View style={[styles.buttonContent, { width: 40, height: 20, justifyContent: 'center' }]}> + <ActivityIndicator size={Platform.OS === 'ios' ? "small" : 14 as any} color={styles.loadingIndicatorForSession.color} /> + </View> + ) : ( + <View style={styles.buttonContent}> + <Text style={[ + styles.buttonText, + isPending && styles.buttonTextForSession, + (isApprovedForSessionSubcommand && !isApprovedForSessionCommandName) && styles.buttonTextSelected + ]} numberOfLines={1} ellipsizeMode="tail"> + {(() => { + const stripped = stripSimpleEnvPrelude(String(commandForShell)); + const parts = stripped.split(/\s+/).filter(Boolean); + const cmd = parts[0] ?? ''; + const sub = parts[1] ?? ''; + return `${t('claude.permissions.yesForSubcommand')}${cmd && sub ? ` (${cmd} ${sub})` : ''}`; + })()} + </Text> + </View> + )} + </TouchableOpacity> + )} + + {/* Allow command name for session (shell tools only) */} + {toolName && toolName !== 'Edit' && toolName !== 'MultiEdit' && toolName !== 'Write' && toolName !== 'NotebookEdit' && toolName !== 'exit_plan_mode' && toolName !== 'ExitPlanMode' && showAllowForSessionCommandName && ( + <TouchableOpacity + style={[ + styles.button, + isPending && styles.buttonForSession, + isApprovedForSessionCommandName && styles.buttonSelected, + (isDenied || isApprovedViaAllow || isApprovedViaAllEdits || (isShellTool ? isApprovedForSessionExact : isApprovedForSession)) && styles.buttonInactive + ]} + onPress={handleApproveForSessionCommandName} + disabled={!isPending || loadingButton !== null || loadingAllEdits || loadingForSession || loadingForSessionPrefix || loadingForSessionCommandName} + activeOpacity={isPending ? 0.7 : 1} + > + {loadingForSessionCommandName && isPending ? ( + <View style={[styles.buttonContent, { width: 40, height: 20, justifyContent: 'center' }]}> + <ActivityIndicator size={Platform.OS === 'ios' ? "small" : 14 as any} color={styles.loadingIndicatorForSession.color} /> + </View> + ) : ( + <View style={styles.buttonContent}> + <Text style={[ + styles.buttonText, + isPending && styles.buttonTextForSession, + isApprovedForSessionCommandName && styles.buttonTextSelected ]} numberOfLines={1} ellipsizeMode="tail"> - {t('claude.permissions.yesForTool')} + {t('claude.permissions.yesForCommandName')}{typeof commandForShell === 'string' ? ` (${stripSimpleEnvPrelude(commandForShell).split(/\s+/).filter(Boolean)[0] ?? ''})` : ''} </Text> </View> )} @@ -469,7 +789,7 @@ export const PermissionFooter: React.FC<PermissionFooterProps> = ({ permission, isPending && styles.buttonTextDeny, isDenied && styles.buttonTextSelected ]} numberOfLines={1} ellipsizeMode="tail"> - {t('claude.permissions.noTellClaude')} + {t(copy.noTellAgentKey)} </Text> </View> )} @@ -477,4 +797,4 @@ export const PermissionFooter: React.FC<PermissionFooterProps> = ({ permission, </View> </View> ); -}; \ No newline at end of file +}; diff --git a/expo-app/sources/components/tools/ToolFullView.inference.test.ts b/expo-app/sources/components/tools/ToolFullView.inference.test.ts new file mode 100644 index 000000000..8c4bff887 --- /dev/null +++ b/expo-app/sources/components/tools/ToolFullView.inference.test.ts @@ -0,0 +1,134 @@ +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import renderer, { act } from 'react-test-renderer'; +import { type ToolCall } from '@/sync/typesMessage'; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +vi.mock('@expo/vector-icons', () => ({ + Ionicons: 'Ionicons', +})); + +vi.mock('react-native-device-info', () => ({ + getDeviceType: () => 'Handset', +})); + +vi.mock('react-native-unistyles', () => ({ + StyleSheet: { create: (styles: any) => styles }, +})); + +vi.mock('@/sync/storage', () => ({ + useLocalSetting: () => false, +})); + +vi.mock('@/text', () => ({ + t: (key: string) => key, +})); + +vi.mock('../CodeView', () => ({ + CodeView: () => null, +})); + +const renderedFullViewSpy = vi.fn(); +const renderedViewSpy = vi.fn(); + +const getToolFullViewComponentSpy = vi.fn((toolName: string) => { + if (toolName === 'execute') { + return (props: any) => { + renderedFullViewSpy(props); + return React.createElement('FullToolView', { name: toolName }); + }; + } + return null; +}); + +const getToolViewComponentSpy = vi.fn((toolName: string) => { + if (toolName === 'Read') { + return (props: any) => { + renderedViewSpy(props); + return React.createElement('ToolView', { name: toolName }); + }; + } + return null; +}); + +vi.mock('./views/_registry', () => ({ + getToolFullViewComponent: getToolFullViewComponentSpy, + getToolViewComponent: getToolViewComponentSpy, +})); + +vi.mock('@/components/tools/knownTools', () => ({ + knownTools: { + execute: { title: 'Terminal' }, + Read: { title: 'Read' }, + }, +})); + +vi.mock('./views/StructuredResultView', () => ({ + StructuredResultView: () => null, +})); + +vi.mock('./PermissionFooter', () => ({ + PermissionFooter: () => null, +})); + +describe('ToolFullView (inference + view selection)', () => { + it('uses tool.input._acp.kind to select a full view component', async () => { + renderedFullViewSpy.mockReset(); + renderedViewSpy.mockReset(); + getToolFullViewComponentSpy.mockClear(); + getToolViewComponentSpy.mockClear(); + const { ToolFullView } = await import('./ToolFullView'); + + const tool: ToolCall = { + name: 'Run echo hello', + state: 'completed', + input: { _acp: { kind: 'execute', title: 'Run echo hello' }, command: ['/bin/zsh', '-lc', 'echo hello'] }, + result: { stdout: 'hello\n', stderr: '' }, + createdAt: Date.now(), + startedAt: Date.now(), + completedAt: Date.now(), + description: 'Run echo hello', + permission: undefined, + }; + + let tree!: renderer.ReactTestRenderer; + await act(async () => { + tree = renderer.create(React.createElement(ToolFullView, { tool, metadata: null, messages: [] })); + }); + + expect(tree.root.findAllByType('FullToolView' as any)).toHaveLength(1); + expect(renderedFullViewSpy).toHaveBeenCalled(); + expect(getToolFullViewComponentSpy).toHaveBeenCalledWith('execute'); + }); + + it('falls back to the normal tool view component when no full view component exists', async () => { + renderedFullViewSpy.mockReset(); + renderedViewSpy.mockReset(); + getToolFullViewComponentSpy.mockClear(); + getToolViewComponentSpy.mockClear(); + const { ToolFullView } = await import('./ToolFullView'); + + const tool: ToolCall = { + name: 'Read', + state: 'completed', + input: { file_path: '/tmp/a.txt' }, + result: { content: 'hello' }, + createdAt: Date.now(), + startedAt: Date.now(), + completedAt: Date.now(), + description: null, + permission: undefined, + }; + + let tree!: renderer.ReactTestRenderer; + await act(async () => { + tree = renderer.create(React.createElement(ToolFullView, { tool, metadata: null, messages: [] })); + }); + + expect(tree.root.findAllByType('ToolView' as any)).toHaveLength(1); + expect(renderedViewSpy).toHaveBeenCalled(); + expect(renderedFullViewSpy).not.toHaveBeenCalled(); + expect(getToolViewComponentSpy).toHaveBeenCalledWith('Read'); + }); +}); diff --git a/expo-app/sources/components/tools/ToolFullView.permissionPending.test.tsx b/expo-app/sources/components/tools/ToolFullView.permissionPending.test.tsx new file mode 100644 index 000000000..fd412d6c1 --- /dev/null +++ b/expo-app/sources/components/tools/ToolFullView.permissionPending.test.tsx @@ -0,0 +1,80 @@ +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import renderer, { act } from 'react-test-renderer'; +import type { ToolCall } from '@/sync/typesMessage'; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +vi.mock('@expo/vector-icons', () => ({ + Ionicons: 'Ionicons', +})); + +vi.mock('react-native-device-info', () => ({ + getDeviceType: () => 'Handset', +})); + +vi.mock('react-native', () => ({ + View: 'View', + Text: 'Text', + ScrollView: 'ScrollView', + Platform: { OS: 'ios', select: (v: any) => v.ios }, + useWindowDimensions: () => ({ width: 800, height: 600 }), +})); + +vi.mock('react-native-unistyles', () => ({ + StyleSheet: { create: (styles: any) => styles }, +})); + +vi.mock('@/sync/storage', () => ({ + useLocalSetting: () => false, +})); + +vi.mock('@/text', () => ({ + t: (key: string) => key, +})); + +vi.mock('./views/_registry', () => ({ + getToolFullViewComponent: () => null, + getToolViewComponent: () => null, +})); + +vi.mock('@/components/tools/knownTools', () => ({ + knownTools: { + edit: { title: 'Edit' }, + }, +})); + +vi.mock('./views/StructuredResultView', () => ({ + StructuredResultView: () => null, +})); + +vi.mock('./PermissionFooter', () => ({ + PermissionFooter: (props: any) => React.createElement('PermissionFooter', props), +})); + +describe('ToolFullView (permission pending)', () => { + it('renders PermissionFooter so users can approve/deny from the full view', async () => { + const { ToolFullView } = await import('./ToolFullView'); + + const tool: ToolCall = { + name: 'edit', + state: 'running', + input: {}, + result: null, + createdAt: Date.now(), + startedAt: Date.now(), + completedAt: null, + description: 'edit', + permission: { id: 'perm1', status: 'pending' }, + }; + + let tree: ReturnType<typeof renderer.create> | undefined; + await act(async () => { + tree = renderer.create( + React.createElement(ToolFullView as any, { tool, metadata: null, messages: [], sessionId: 's1' }), + ); + }); + + expect(tree!.root.findAllByType('PermissionFooter' as any).length).toBe(1); + }); +}); diff --git a/expo-app/sources/components/tools/ToolFullView.tsx b/expo-app/sources/components/tools/ToolFullView.tsx index b83464d4a..019ca5c33 100644 --- a/expo-app/sources/components/tools/ToolFullView.tsx +++ b/expo-app/sources/components/tools/ToolFullView.tsx @@ -4,83 +4,118 @@ import { Ionicons } from '@expo/vector-icons'; import { ToolCall, Message } from '@/sync/typesMessage'; import { CodeView } from '../CodeView'; import { Metadata } from '@/sync/storageTypes'; -import { getToolFullViewComponent } from './views/_all'; +import { getToolFullViewComponent, getToolViewComponent } from './views/_registry'; import { layout } from '../layout'; import { useLocalSetting } from '@/sync/storage'; import { StyleSheet } from 'react-native-unistyles'; import { t } from '@/text'; +import { StructuredResultView } from './views/StructuredResultView'; +import { normalizeToolCallForRendering } from './utils/normalizeToolCallForRendering'; +import { inferToolNameForRendering } from './utils/toolNameInference'; +import { knownTools } from '@/components/tools/knownTools'; +import { PermissionFooter } from './PermissionFooter'; + +const KNOWN_TOOL_KEYS = Object.keys(knownTools); interface ToolFullViewProps { tool: ToolCall; + sessionId?: string; metadata?: Metadata | null; messages?: Message[]; } -export function ToolFullView({ tool, metadata, messages = [] }: ToolFullViewProps) { - // Check if there's a specialized content view for this tool - const SpecializedFullView = getToolFullViewComponent(tool.name); +export function ToolFullView({ tool, sessionId, metadata, messages = [] }: ToolFullViewProps) { + const toolForRendering = React.useMemo<ToolCall>(() => normalizeToolCallForRendering(tool), [tool]); + + const normalizedToolName = React.useMemo(() => { + if (toolForRendering.name.startsWith('mcp__')) return toolForRendering.name; + const inferred = inferToolNameForRendering({ + toolName: toolForRendering.name, + toolInput: toolForRendering.input, + toolDescription: toolForRendering.description, + knownToolKeys: KNOWN_TOOL_KEYS, + }); + return inferred.normalizedToolName; + }, [toolForRendering.name, toolForRendering.input, toolForRendering.description]); + + // Check if there's a specialized content view for this tool. + // Prefer a dedicated full view, but fall back to the regular tool view when available. + const SpecializedFullView = + getToolFullViewComponent(normalizedToolName) ?? + getToolViewComponent(normalizedToolName); const screenWidth = useWindowDimensions().width; const devModeEnabled = (useLocalSetting('devModeEnabled') || __DEV__); - console.log('ToolFullView', devModeEnabled); + const isWaitingForPermission = + toolForRendering.permission?.status === 'pending' && toolForRendering.state !== 'completed'; return ( <ScrollView style={[styles.container, { paddingHorizontal: screenWidth > 700 ? 16 : 0 }]}> <View style={styles.contentWrapper}> {/* Tool-specific content or generic fallback */} {SpecializedFullView ? ( - <SpecializedFullView tool={tool} metadata={metadata || null} messages={messages} /> + <SpecializedFullView tool={toolForRendering} metadata={metadata || null} messages={messages} sessionId={sessionId} /> ) : ( <> {/* Generic fallback for tools without specialized views */} {/* Tool Description */} - {tool.description && ( + {toolForRendering.description && ( <View style={styles.section}> <View style={styles.sectionHeader}> <Ionicons name="information-circle" size={20} color="#5856D6" /> <Text style={styles.sectionTitle}>{t('tools.fullView.description')}</Text> </View> - <Text style={styles.description}>{tool.description}</Text> + <Text style={styles.description}>{toolForRendering.description}</Text> </View> )} {/* Input Parameters */} - {tool.input && ( + {toolForRendering.input && ( <View style={styles.section}> <View style={styles.sectionHeader}> <Ionicons name="log-in" size={20} color="#5856D6" /> <Text style={styles.sectionTitle}>{t('tools.fullView.inputParams')}</Text> </View> - <CodeView code={JSON.stringify(tool.input, null, 2)} /> + <CodeView code={JSON.stringify(toolForRendering.input, null, 2)} /> </View> )} {/* Result/Output */} - {tool.state === 'completed' && tool.result && ( + {toolForRendering.state === 'completed' && toolForRendering.result && ( <View style={styles.section}> <View style={styles.sectionHeader}> <Ionicons name="log-out" size={20} color="#34C759" /> <Text style={styles.sectionTitle}>{t('tools.fullView.output')}</Text> </View> <CodeView - code={typeof tool.result === 'string' ? tool.result : JSON.stringify(tool.result, null, 2)} + code={typeof toolForRendering.result === 'string' ? toolForRendering.result : JSON.stringify(toolForRendering.result, null, 2)} /> </View> )} + {toolForRendering.state === 'running' && toolForRendering.result && ( + <View style={styles.section}> + <View style={styles.sectionHeader}> + <Ionicons name="log-out" size={20} color="#34C759" /> + <Text style={styles.sectionTitle}>{t('tools.fullView.output')}</Text> + </View> + <StructuredResultView tool={toolForRendering} metadata={metadata || null} messages={messages} sessionId={sessionId} /> + </View> + )} + {/* Error Details */} - {tool.state === 'error' && tool.result && ( + {toolForRendering.state === 'error' && toolForRendering.result && ( <View style={styles.section}> <View style={styles.sectionHeader}> <Ionicons name="close-circle" size={20} color="#FF3B30" /> <Text style={styles.sectionTitle}>{t('tools.fullView.error')}</Text> </View> <View style={styles.errorContainer}> - <Text style={styles.errorText}>{String(tool.result)}</Text> + <Text style={styles.errorText}>{String(toolForRendering.result)}</Text> </View> </View> )} {/* No Output Message */} - {tool.state === 'completed' && !tool.result && ( + {toolForRendering.state === 'completed' && !toolForRendering.result && ( <View style={styles.section}> <View style={styles.emptyOutputContainer}> <Ionicons name="checkmark-circle-outline" size={48} color="#34C759" /> @@ -92,6 +127,17 @@ export function ToolFullView({ tool, metadata, messages = [] }: ToolFullViewProp </> )} + + {/* Permission footer - allow approve/deny from the full view */} + {isWaitingForPermission && toolForRendering.permission && sessionId && toolForRendering.name !== 'AskUserQuestion' && toolForRendering.name !== 'ExitPlanMode' && toolForRendering.name !== 'exit_plan_mode' && toolForRendering.name !== 'AcpHistoryImport' && ( + <PermissionFooter + permission={toolForRendering.permission} + sessionId={sessionId} + toolName={normalizedToolName} + toolInput={toolForRendering.input} + metadata={metadata || null} + /> + )} {/* Raw JSON View (Dev Mode Only) */} {devModeEnabled && ( @@ -103,14 +149,14 @@ export function ToolFullView({ tool, metadata, messages = [] }: ToolFullViewProp <CodeView code={JSON.stringify({ name: tool.name, - state: tool.state, - description: tool.description, - input: tool.input, - result: tool.result, - createdAt: tool.createdAt, - startedAt: tool.startedAt, - completedAt: tool.completedAt, - permission: tool.permission, + state: toolForRendering.state, + description: toolForRendering.description, + input: toolForRendering.input, + result: toolForRendering.result, + createdAt: toolForRendering.createdAt, + startedAt: toolForRendering.startedAt, + completedAt: toolForRendering.completedAt, + permission: toolForRendering.permission, messages }, null, 2)} /> diff --git a/expo-app/sources/components/tools/ToolStatusIndicator.tsx b/expo-app/sources/components/tools/ToolStatusIndicator.tsx index e3c630ed2..26e1e2f2b 100644 --- a/expo-app/sources/components/tools/ToolStatusIndicator.tsx +++ b/expo-app/sources/components/tools/ToolStatusIndicator.tsx @@ -1,7 +1,8 @@ import * as React from 'react'; -import { View, StyleSheet, ActivityIndicator } from 'react-native'; +import { View, ActivityIndicator } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { ToolCall } from '@/sync/typesMessage'; +import { StyleSheet } from 'react-native-unistyles'; interface ToolStatusIndicatorProps { tool: ToolCall; } @@ -33,4 +34,4 @@ const styles = StyleSheet.create({ alignItems: 'center', justifyContent: 'center', }, -}); \ No newline at end of file +}); diff --git a/expo-app/sources/components/tools/ToolView.acpKindFallback.test.tsx b/expo-app/sources/components/tools/ToolView.acpKindFallback.test.tsx new file mode 100644 index 000000000..a0216eafb --- /dev/null +++ b/expo-app/sources/components/tools/ToolView.acpKindFallback.test.tsx @@ -0,0 +1,113 @@ +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import renderer, { act } from 'react-test-renderer'; +import type { ToolCall } from '@/sync/typesMessage'; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +vi.mock('expo-router', () => ({ + useRouter: () => ({ push: vi.fn() }), +})); + +vi.mock('react-native', () => ({ + View: 'View', + Text: 'Text', + TouchableOpacity: 'TouchableOpacity', + ActivityIndicator: 'ActivityIndicator', + Platform: { OS: 'ios', select: (v: any) => v.ios }, +})); + +vi.mock('react-native-unistyles', () => ({ + StyleSheet: { create: (styles: any) => styles }, + useUnistyles: () => ({ + theme: { + colors: { + surfaceHigh: '#fff', + surfaceHighest: '#fff', + text: '#000', + textSecondary: '#666', + warning: '#f00', + }, + }, + }), +})); + +vi.mock('@expo/vector-icons', () => ({ + Ionicons: 'Ionicons', + Octicons: 'Octicons', +})); + +vi.mock('@/components/tools/views/_registry', () => ({ + getToolViewComponent: (toolName: string) => + toolName === 'execute' + ? (props: any) => React.createElement('SpecificToolView', { resolvedName: props.tool?.name }) + : null, +})); + +vi.mock('@/components/tools/knownTools', () => ({ + knownTools: { + execute: { + title: 'Terminal', + }, + }, +})); + +vi.mock('@/components/tools/views/MCPToolView', () => ({ + formatMCPTitle: () => 'MCP', +})); + +vi.mock('@/utils/toolErrorParser', () => ({ + parseToolUseError: () => ({ isToolUseError: false }), +})); + +vi.mock('../CodeView', () => ({ + CodeView: () => null, +})); + +vi.mock('./ToolSectionView', () => ({ + ToolSectionView: ({ children }: any) => React.createElement(React.Fragment, null, children), +})); + +vi.mock('./ToolError', () => ({ + ToolError: () => null, +})); + +vi.mock('./PermissionFooter', () => ({ + PermissionFooter: () => React.createElement('PermissionFooter', null), +})); + +vi.mock('@/text', () => ({ + t: (key: string) => key, +})); + +vi.mock('@/agents/catalog', () => ({ + getAgentCore: () => ({ toolRendering: { hideUnknownToolsByDefault: false } }), + resolveAgentIdFromFlavor: () => null, +})); + +describe('ToolView (ACP kind fallback)', () => { + it('uses tool.input._acp.kind to pick a specific view when tool.name is not a stable key', async () => { + const { ToolView } = await import('./ToolView'); + + const tool: ToolCall = { + name: 'Run echo hello', + state: 'completed', + input: { _acp: { kind: 'execute', title: 'Run echo hello' }, command: ['/bin/zsh', '-lc', 'echo hello'] }, + result: { stdout: 'hello\n', stderr: '' }, + createdAt: Date.now(), + startedAt: Date.now(), + completedAt: Date.now(), + description: 'Run echo hello', + permission: undefined, + }; + + let tree: ReturnType<typeof renderer.create> | undefined; + await act(async () => { + tree = renderer.create( + React.createElement(ToolView, { tool, metadata: null, messages: [], sessionId: 's1', messageId: 'm1' }), + ); + }); + + expect(tree!.root.findAllByType('SpecificToolView' as any)).toHaveLength(1); + }); +}); diff --git a/expo-app/sources/components/tools/ToolView.exitPlanMode.test.ts b/expo-app/sources/components/tools/ToolView.exitPlanMode.test.ts new file mode 100644 index 000000000..1e3bfee38 --- /dev/null +++ b/expo-app/sources/components/tools/ToolView.exitPlanMode.test.ts @@ -0,0 +1,136 @@ +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import renderer, { act } from 'react-test-renderer'; +import type { ToolCall } from '@/sync/typesMessage'; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +vi.mock('expo-router', () => ({ + useRouter: () => ({ push: vi.fn() }), +})); + +vi.mock('react-native', () => ({ + View: 'View', + Text: 'Text', + TouchableOpacity: 'TouchableOpacity', + ActivityIndicator: 'ActivityIndicator', + Platform: { OS: 'ios', select: (v: any) => v.ios }, +})); + +vi.mock('react-native-unistyles', () => ({ + StyleSheet: { create: (styles: any) => styles }, + useUnistyles: () => ({ + theme: { + colors: { + surfaceHigh: '#fff', + surfaceHighest: '#fff', + text: '#000', + textSecondary: '#666', + warning: '#f00', + }, + }, + }), +})); + +vi.mock('@expo/vector-icons', () => ({ + Ionicons: 'Ionicons', + Octicons: 'Octicons', +})); + +vi.mock('@/components/tools/views/_registry', () => ({ + getToolViewComponent: () => null, +})); + +vi.mock('@/components/tools/knownTools', () => ({ + knownTools: { + ExitPlanMode: { + title: 'Plan proposal', + }, + exit_plan_mode: { + title: 'Plan proposal', + }, + }, +})); + +vi.mock('@/components/tools/views/MCPToolView', () => ({ + formatMCPTitle: () => 'MCP', +})); + +vi.mock('@/utils/toolErrorParser', () => ({ + parseToolUseError: () => ({ isToolUseError: false }), +})); + +vi.mock('../CodeView', () => ({ + CodeView: () => null, +})); + +vi.mock('./ToolSectionView', () => ({ + ToolSectionView: ({ children }: any) => React.createElement(React.Fragment, null, children), +})); + +vi.mock('./ToolError', () => ({ + ToolError: () => null, +})); + +vi.mock('./PermissionFooter', () => ({ + PermissionFooter: () => React.createElement('PermissionFooter', null), +})); + +vi.mock('@/text', () => ({ + t: (key: string) => key, +})); + +vi.mock('@/agents/catalog', () => ({ + getAgentCore: () => ({ toolRendering: { hideUnknownToolsByDefault: false } }), + resolveAgentIdFromFlavor: () => null, +})); + +describe('ToolView (ExitPlanMode)', () => { + it('does not render PermissionFooter for ExitPlanMode', async () => { + const { ToolView } = await import('./ToolView'); + + const tool: ToolCall = { + name: 'ExitPlanMode', + state: 'running', + input: { plan: 'plan' }, + createdAt: Date.now(), + startedAt: Date.now(), + completedAt: null, + description: null, + permission: { id: 'perm1', status: 'pending' }, + }; + + let tree: ReturnType<typeof renderer.create> | undefined; + await act(async () => { + tree = renderer.create( + React.createElement(ToolView, { tool, metadata: null, messages: [], sessionId: 's1', messageId: 'm1' }), + ); + }); + + expect(tree!.root.findAllByType('PermissionFooter' as any)).toHaveLength(0); + }); + + it('renders PermissionFooter for normal tools', async () => { + const { ToolView } = await import('./ToolView'); + + const tool: ToolCall = { + name: 'Write', + state: 'running', + input: { file_path: '/tmp/x', content: 'x' }, + createdAt: Date.now(), + startedAt: Date.now(), + completedAt: null, + description: null, + permission: { id: 'perm1', status: 'pending' }, + }; + + let tree: ReturnType<typeof renderer.create> | undefined; + await act(async () => { + tree = renderer.create( + React.createElement(ToolView, { tool, metadata: null, messages: [], sessionId: 's1', messageId: 'm1' }), + ); + }); + + expect(tree!.root.findAllByType('PermissionFooter' as any).length).toBeGreaterThan(0); + }); +}); diff --git a/expo-app/sources/components/tools/ToolView.minimalSpecificView.test.ts b/expo-app/sources/components/tools/ToolView.minimalSpecificView.test.ts new file mode 100644 index 000000000..f338f259e --- /dev/null +++ b/expo-app/sources/components/tools/ToolView.minimalSpecificView.test.ts @@ -0,0 +1,111 @@ +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import renderer, { act } from 'react-test-renderer'; +import type { ToolCall } from '@/sync/typesMessage'; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +vi.mock('expo-router', () => ({ + useRouter: () => ({ push: vi.fn() }), +})); + +vi.mock('react-native', () => ({ + View: 'View', + Text: 'Text', + TouchableOpacity: 'TouchableOpacity', + ActivityIndicator: 'ActivityIndicator', + Platform: { OS: 'ios', select: (v: any) => v.ios }, +})); + +vi.mock('react-native-unistyles', () => ({ + StyleSheet: { create: (styles: any) => styles }, + useUnistyles: () => ({ + theme: { + colors: { + surfaceHigh: '#fff', + surfaceHighest: '#fff', + text: '#000', + textSecondary: '#666', + warning: '#f00', + }, + }, + }), +})); + +vi.mock('@expo/vector-icons', () => ({ + Ionicons: 'Ionicons', + Octicons: 'Octicons', +})); + +vi.mock('@/components/tools/views/_registry', () => ({ + getToolViewComponent: () => (props: any) => React.createElement('SpecificToolView', { toolName: props.tool?.name }), +})); + +vi.mock('@/components/tools/knownTools', () => ({ + knownTools: { + Bash: { + title: 'Terminal', + minimal: true, + }, + }, +})); + +vi.mock('@/components/tools/views/MCPToolView', () => ({ + formatMCPTitle: () => 'MCP', +})); + +vi.mock('@/utils/toolErrorParser', () => ({ + parseToolUseError: () => ({ isToolUseError: false }), +})); + +vi.mock('../CodeView', () => ({ + CodeView: () => null, +})); + +vi.mock('./ToolSectionView', () => ({ + ToolSectionView: ({ children }: any) => React.createElement(React.Fragment, null, children), +})); + +vi.mock('./ToolError', () => ({ + ToolError: () => null, +})); + +vi.mock('./PermissionFooter', () => ({ + PermissionFooter: () => React.createElement('PermissionFooter', null), +})); + +vi.mock('@/text', () => ({ + t: (key: string) => key, +})); + +vi.mock('@/agents/catalog', () => ({ + getAgentCore: () => ({ toolRendering: { hideUnknownToolsByDefault: false } }), + resolveAgentIdFromFlavor: () => null, +})); + +describe('ToolView (minimal tools)', () => { + it('renders a specific tool view even when the tool is marked minimal', async () => { + const { ToolView } = await import('./ToolView'); + + const tool: ToolCall = { + name: 'Bash', + state: 'completed', + input: { command: 'echo hello' }, + result: { stdout: 'hello\n', stderr: '' }, + createdAt: Date.now(), + startedAt: Date.now(), + completedAt: Date.now(), + description: null, + permission: undefined, + }; + + let tree: ReturnType<typeof renderer.create> | undefined; + await act(async () => { + tree = renderer.create( + React.createElement(ToolView, { tool, metadata: null, messages: [], sessionId: 's1', messageId: 'm1' }), + ); + }); + + expect(tree!.root.findAllByType('SpecificToolView' as any)).toHaveLength(1); + }); +}); diff --git a/expo-app/sources/components/tools/ToolView.minimalStructuredFallback.test.ts b/expo-app/sources/components/tools/ToolView.minimalStructuredFallback.test.ts new file mode 100644 index 000000000..3328323f2 --- /dev/null +++ b/expo-app/sources/components/tools/ToolView.minimalStructuredFallback.test.ts @@ -0,0 +1,144 @@ +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import renderer, { act } from 'react-test-renderer'; +import type { ToolCall } from '@/sync/typesMessage'; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +vi.mock('expo-router', () => ({ + useRouter: () => ({ push: vi.fn() }), +})); + +vi.mock('react-native', () => ({ + View: 'View', + Text: 'Text', + TouchableOpacity: 'TouchableOpacity', + ActivityIndicator: 'ActivityIndicator', + Platform: { OS: 'ios', select: (v: any) => v.ios }, +})); + +vi.mock('react-native-unistyles', () => ({ + StyleSheet: { create: (styles: any) => styles }, + useUnistyles: () => ({ + theme: { + colors: { + surfaceHigh: '#fff', + surfaceHighest: '#fff', + text: '#000', + textSecondary: '#666', + warning: '#f00', + }, + }, + }), +})); + +vi.mock('@expo/vector-icons', () => ({ + Ionicons: 'Ionicons', + Octicons: 'Octicons', +})); + +vi.mock('@/components/tools/views/_registry', () => ({ + getToolViewComponent: () => null, +})); + +vi.mock('@/components/tools/knownTools', () => ({ + knownTools: { + Bash: { + title: 'Terminal', + minimal: true, + }, + }, +})); + +vi.mock('@/components/tools/views/MCPToolView', () => ({ + formatMCPTitle: () => 'MCP', +})); + +vi.mock('@/utils/toolErrorParser', () => ({ + parseToolUseError: () => ({ isToolUseError: false }), +})); + +vi.mock('../CodeView', () => ({ + CodeView: () => null, +})); + +vi.mock('@/components/CodeView', () => ({ + CodeView: () => null, +})); + +vi.mock('./ToolSectionView', () => ({ + ToolSectionView: ({ children }: any) => React.createElement(React.Fragment, null, children), +})); + +vi.mock('./ToolError', () => ({ + ToolError: () => null, +})); + +vi.mock('./PermissionFooter', () => ({ + PermissionFooter: () => React.createElement('PermissionFooter', null), +})); + +vi.mock('@/text', () => ({ + t: (key: string) => key, +})); + +vi.mock('@/agents/catalog', () => ({ + getAgentCore: () => ({ toolRendering: { hideUnknownToolsByDefault: false } }), + resolveAgentIdFromFlavor: () => null, +})); + +describe('ToolView (minimal tools)', () => { + it('renders a structured fallback view for minimal tools without a specific view', async () => { + const { ToolView } = await import('./ToolView'); + + const tool: ToolCall = { + name: 'Bash', + state: 'completed', + input: { command: 'echo hello' }, + result: { stdout: 'hello\n', stderr: '' }, + createdAt: Date.now(), + startedAt: Date.now(), + completedAt: Date.now(), + description: null, + permission: undefined, + }; + + let tree: ReturnType<typeof renderer.create> | undefined; + await act(async () => { + tree = renderer.create( + React.createElement(ToolView, { tool, metadata: null, messages: [], sessionId: 's1', messageId: 'm1' }), + ); + }); + + const texts = tree!.root.findAllByType('Text' as any).map((n: any) => n.props.children); + const flattened = texts.flatMap((c: any) => Array.isArray(c) ? c : [c]).filter((c: any) => typeof c === 'string'); + expect(flattened).toContain('stdout'); + }); + + it('renders a structured fallback view for running minimal tools that stream stdout', async () => { + const { ToolView } = await import('./ToolView'); + + const tool: ToolCall = { + name: 'Bash', + state: 'running', + input: { command: 'echo hello' }, + result: { stdout: 'hello\n', stderr: '' }, + createdAt: Date.now(), + startedAt: Date.now(), + completedAt: null, + description: null, + permission: undefined, + }; + + let tree: ReturnType<typeof renderer.create> | undefined; + await act(async () => { + tree = renderer.create( + React.createElement(ToolView, { tool, metadata: null, messages: [], sessionId: 's1', messageId: 'm1' }), + ); + }); + + const texts = tree!.root.findAllByType('Text' as any).map((n: any) => n.props.children); + const flattened = texts.flatMap((c: any) => Array.isArray(c) ? c : [c]).filter((c: any) => typeof c === 'string'); + expect(flattened).toContain('stdout'); + }); +}); diff --git a/expo-app/sources/components/tools/ToolView.permissionPending.test.tsx b/expo-app/sources/components/tools/ToolView.permissionPending.test.tsx new file mode 100644 index 000000000..aa2e7818a --- /dev/null +++ b/expo-app/sources/components/tools/ToolView.permissionPending.test.tsx @@ -0,0 +1,169 @@ +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import renderer, { act } from 'react-test-renderer'; +import type { ToolCall } from '@/sync/typesMessage'; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +vi.mock('expo-router', () => ({ + useRouter: () => ({ push: vi.fn() }), +})); + +vi.mock('react-native', () => ({ + View: 'View', + Text: 'Text', + TouchableOpacity: 'TouchableOpacity', + ActivityIndicator: 'ActivityIndicator', + Platform: { OS: 'ios', select: (v: any) => v.ios }, +})); + +vi.mock('react-native-unistyles', () => ({ + StyleSheet: { create: (styles: any) => styles }, + useUnistyles: () => ({ + theme: { + colors: { + surfaceHigh: '#fff', + surfaceHighest: '#fff', + text: '#000', + textSecondary: '#666', + warning: '#f90', + }, + }, + }), +})); + +vi.mock('@expo/vector-icons', () => ({ + Ionicons: 'Ionicons', + Octicons: 'Octicons', +})); + +vi.mock('@/hooks/useElapsedTime', () => ({ + useElapsedTime: () => 123.4, +})); + +vi.mock('@/components/tools/views/_registry', () => ({ + getToolViewComponent: () => null, +})); + +vi.mock('@/components/tools/knownTools', () => ({ + knownTools: {}, +})); + +vi.mock('@/components/tools/views/MCPToolView', () => ({ + formatMCPTitle: () => 'MCP', +})); + +vi.mock('@/utils/toolErrorParser', () => ({ + parseToolUseError: () => ({ isToolUseError: false }), +})); + +vi.mock('../CodeView', () => ({ + CodeView: () => null, +})); + +vi.mock('@/components/CodeView', () => ({ + CodeView: () => null, +})); + +vi.mock('./ToolSectionView', () => ({ + ToolSectionView: ({ children }: any) => React.createElement(React.Fragment, null, children), +})); + +vi.mock('./ToolError', () => ({ + ToolError: () => null, +})); + +vi.mock('./PermissionFooter', () => ({ + PermissionFooter: () => React.createElement('PermissionFooter', null), +})); + +vi.mock('@/text', () => ({ + t: (key: string) => key, +})); + +vi.mock('@/agents/catalog', () => ({ + getAgentCore: () => ({ toolRendering: { hideUnknownToolsByDefault: false } }), + resolveAgentIdFromFlavor: () => null, +})); + +describe('ToolView (permission pending)', () => { + it('does not show elapsed time while waiting for permission', async () => { + const { ToolView } = await import('./ToolView'); + + const tool: ToolCall = { + name: 'execute', + state: 'running', + input: { command: 'pwd' }, + result: null, + createdAt: Date.now(), + startedAt: Date.now(), + completedAt: null, + description: null, + permission: { id: 'perm1', status: 'pending' }, + }; + + let tree: ReturnType<typeof renderer.create> | undefined; + await act(async () => { + tree = renderer.create( + React.createElement(ToolView, { tool, metadata: null, messages: [], sessionId: 's1', messageId: 'm1' }), + ); + }); + + const texts = tree!.root.findAllByType('Text' as any).map((n: any) => n.props.children); + const flattened = texts.flatMap((c: any) => Array.isArray(c) ? c : [c]).filter((c: any) => typeof c === 'string'); + expect(flattened).not.toContain('123.4s'); + }); + + it('shows elapsed time when running without pending permission', async () => { + const { ToolView } = await import('./ToolView'); + + const tool: ToolCall = { + name: 'execute', + state: 'running', + input: { command: 'pwd' }, + result: null, + createdAt: Date.now(), + startedAt: Date.now(), + completedAt: null, + description: null, + permission: undefined, + }; + + let tree: ReturnType<typeof renderer.create> | undefined; + await act(async () => { + tree = renderer.create( + React.createElement(ToolView, { tool, metadata: null, messages: [], sessionId: 's1', messageId: 'm1' }), + ); + }); + + const texts = tree!.root.findAllByType('Text' as any).map((n: any) => n.props.children); + const flattened = texts.flatMap((c: any) => Array.isArray(c) ? c : [c]).filter((c: any) => typeof c === 'string'); + expect(flattened).toContain('123.4s'); + }); + + it('does not render PermissionFooter once the tool is completed', async () => { + const { ToolView } = await import('./ToolView'); + + const tool: ToolCall = { + name: 'execute', + state: 'completed', + input: { command: 'pwd' }, + result: { stdout: '/tmp\n' } as any, + createdAt: Date.now(), + startedAt: Date.now(), + completedAt: Date.now(), + description: null, + // Some providers can leave permission status stale; ToolView should not show action buttons in that case. + permission: { id: 'perm1', status: 'pending' }, + }; + + let tree: ReturnType<typeof renderer.create> | undefined; + await act(async () => { + tree = renderer.create( + React.createElement(ToolView, { tool, metadata: null, messages: [], sessionId: 's1', messageId: 'm1' }), + ); + }); + + expect(tree!.root.findAllByType('PermissionFooter' as any).length).toBe(0); + }); +}); diff --git a/expo-app/sources/components/tools/ToolView.runningStructuredFallback.test.ts b/expo-app/sources/components/tools/ToolView.runningStructuredFallback.test.ts new file mode 100644 index 000000000..cd35e8d0b --- /dev/null +++ b/expo-app/sources/components/tools/ToolView.runningStructuredFallback.test.ts @@ -0,0 +1,112 @@ +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import renderer, { act } from 'react-test-renderer'; +import type { ToolCall } from '@/sync/typesMessage'; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +vi.mock('expo-router', () => ({ + useRouter: () => ({ push: vi.fn() }), +})); + +vi.mock('react-native', () => ({ + View: 'View', + Text: 'Text', + TouchableOpacity: 'TouchableOpacity', + ActivityIndicator: 'ActivityIndicator', + Platform: { OS: 'ios', select: (v: any) => v.ios }, +})); + +vi.mock('react-native-unistyles', () => ({ + StyleSheet: { create: (styles: any) => styles }, + useUnistyles: () => ({ + theme: { + colors: { + surfaceHigh: '#fff', + surfaceHighest: '#fff', + text: '#000', + textSecondary: '#666', + warning: '#f00', + }, + }, + }), +})); + +vi.mock('@expo/vector-icons', () => ({ + Ionicons: 'Ionicons', + Octicons: 'Octicons', +})); + +vi.mock('@/components/tools/views/_registry', () => ({ + getToolViewComponent: () => null, +})); + +vi.mock('@/components/tools/knownTools', () => ({ + knownTools: {}, +})); + +vi.mock('@/components/tools/views/MCPToolView', () => ({ + formatMCPTitle: () => 'MCP', +})); + +vi.mock('@/utils/toolErrorParser', () => ({ + parseToolUseError: () => ({ isToolUseError: false }), +})); + +vi.mock('../CodeView', () => ({ + CodeView: () => null, +})); + +vi.mock('@/components/CodeView', () => ({ + CodeView: () => null, +})); + +vi.mock('./ToolSectionView', () => ({ + ToolSectionView: ({ children }: any) => React.createElement(React.Fragment, null, children), +})); + +vi.mock('./ToolError', () => ({ + ToolError: () => null, +})); + +vi.mock('./PermissionFooter', () => ({ + PermissionFooter: () => React.createElement('PermissionFooter', null), +})); + +vi.mock('@/text', () => ({ + t: (key: string) => key, +})); + +vi.mock('@/agents/catalog', () => ({ + getAgentCore: () => ({ toolRendering: { hideUnknownToolsByDefault: false } }), + resolveAgentIdFromFlavor: () => null, +})); + +describe('ToolView (running tools)', () => { + it('renders structured stdout/stderr while running when a tool streams output', async () => { + const { ToolView } = await import('./ToolView'); + + const tool: ToolCall = { + name: 'SomeUnknownTool', + state: 'running', + input: { anything: true }, + result: { stdout: 'hello\n', stderr: '' }, + createdAt: Date.now(), + startedAt: Date.now(), + completedAt: null, + description: null, + permission: undefined, + }; + + let tree: ReturnType<typeof renderer.create> | undefined; + await act(async () => { + tree = renderer.create( + React.createElement(ToolView, { tool, metadata: null, messages: [], sessionId: 's1', messageId: 'm1' }), + ); + }); + + const texts = tree!.root.findAllByType('Text' as any).map((n: any) => n.props.children); + const flattened = texts.flatMap((c: any) => Array.isArray(c) ? c : [c]).filter((c: any) => typeof c === 'string'); + expect(flattened).toContain('stdout'); + }); +}); diff --git a/expo-app/sources/components/tools/ToolView.tsx b/expo-app/sources/components/tools/ToolView.tsx index 15b8c8567..e39dbc57e 100644 --- a/expo-app/sources/components/tools/ToolView.tsx +++ b/expo-app/sources/components/tools/ToolView.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { Text, View, TouchableOpacity, ActivityIndicator, Platform } from 'react-native'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { Ionicons, Octicons } from '@expo/vector-icons'; -import { getToolViewComponent } from './views/_all'; +import { getToolViewComponent } from './views/_registry'; import { Message, ToolCall } from '@/sync/typesMessage'; import { CodeView } from '../CodeView'; import { ToolSectionView } from './ToolSectionView'; @@ -15,6 +15,12 @@ import { PermissionFooter } from './PermissionFooter'; import { parseToolUseError } from '@/utils/toolErrorParser'; import { formatMCPTitle } from './views/MCPToolView'; import { t } from '@/text'; +import { getAgentCore, resolveAgentIdFromFlavor } from '@/agents/catalog'; +import { StructuredResultView } from './views/StructuredResultView'; +import { inferToolNameForRendering } from './utils/toolNameInference'; +import { normalizeToolCallForRendering } from './utils/normalizeToolCallForRendering'; + +const KNOWN_TOOL_KEYS = Object.keys(knownTools); interface ToolViewProps { metadata: Metadata | null; @@ -29,6 +35,9 @@ export const ToolView = React.memo<ToolViewProps>((props) => { const { tool, onPress, sessionId, messageId } = props; const router = useRouter(); const { theme } = useUnistyles(); + const toolForRendering = React.useMemo<ToolCall>(() => normalizeToolCallForRendering(tool), [tool]); + const isWaitingForPermission = toolForRendering.permission?.status === 'pending' && toolForRendering.state === 'running'; + const parsedToolInput = toolForRendering.input; // Create default onPress handler for navigation const handlePress = React.useCallback(() => { @@ -42,7 +51,17 @@ export const ToolView = React.memo<ToolViewProps>((props) => { // Enable pressable if either onPress is provided or we have navigation params const isPressable = !!(onPress || (sessionId && messageId)); - let knownTool = knownTools[tool.name as keyof typeof knownTools] as any; + const inferredTool = inferToolNameForRendering({ + toolName: toolForRendering.name, + toolInput: parsedToolInput, + toolDescription: toolForRendering.description, + knownToolKeys: KNOWN_TOOL_KEYS, + }); + const normalizedToolName = toolForRendering.name.startsWith('mcp__') ? toolForRendering.name : inferredTool.normalizedToolName; + const usedInferenceFallback = + !toolForRendering.name.startsWith('mcp__') && inferredTool.source !== 'original' && inferredTool.normalizedToolName !== toolForRendering.name; + + let knownTool = knownTools[normalizedToolName as keyof typeof knownTools] as any; let description: string | null = null; let status: string | null = null; @@ -51,11 +70,11 @@ export const ToolView = React.memo<ToolViewProps>((props) => { let noStatus = false; let hideDefaultError = false; - // For Gemini: unknown tools should be rendered as minimal (hidden) - // This prevents showing raw INPUT/OUTPUT for internal Gemini tools - // that we haven't explicitly added to knownTools - const isGemini = props.metadata?.flavor === 'gemini'; - if (!knownTool && isGemini) { + // For some agents (e.g. Gemini): unknown tools should be rendered as minimal (hidden) + // to avoid showing raw INPUT/OUTPUT for internal tools we haven't explicitly supported yet. + const agentId = resolveAgentIdFromFlavor(props.metadata?.flavor); + const hideUnknownToolsByDefault = agentId ? getAgentCore(agentId).toolRendering.hideUnknownToolsByDefault : false; + if (!knownTool && hideUnknownToolsByDefault) { minimal = true; } @@ -68,7 +87,7 @@ export const ToolView = React.memo<ToolViewProps>((props) => { } // Handle optional title and function type - let toolTitle = tool.name; + let toolTitle = normalizedToolName; // Special handling for MCP tools if (tool.name.startsWith('mcp__')) { @@ -77,29 +96,33 @@ export const ToolView = React.memo<ToolViewProps>((props) => { minimal = true; } else if (knownTool?.title) { if (typeof knownTool.title === 'function') { - toolTitle = knownTool.title({ tool, metadata: props.metadata }); + toolTitle = knownTool.title({ tool: toolForRendering, metadata: props.metadata }); } else { toolTitle = knownTool.title; } } + if (usedInferenceFallback && typeof toolForRendering.description === 'string' && toolForRendering.description.trim().length > 0) { + toolTitle = toolForRendering.description.trim(); + } + if (knownTool && typeof knownTool.extractSubtitle === 'function') { - const subtitle = knownTool.extractSubtitle({ tool, metadata: props.metadata }); + const subtitle = knownTool.extractSubtitle({ tool: toolForRendering, metadata: props.metadata }); if (typeof subtitle === 'string' && subtitle) { description = subtitle; } } if (knownTool && knownTool.minimal !== undefined) { if (typeof knownTool.minimal === 'function') { - minimal = knownTool.minimal({ tool, metadata: props.metadata, messages: props.messages }); + minimal = knownTool.minimal({ tool: toolForRendering, metadata: props.metadata, messages: props.messages }); } else { minimal = knownTool.minimal; } } // Special handling for CodexBash to determine icon based on parsed_cmd - if (tool.name === 'CodexBash' && tool.input?.parsed_cmd && Array.isArray(tool.input.parsed_cmd) && tool.input.parsed_cmd.length > 0) { - const parsedCmd = tool.input.parsed_cmd[0]; + if (toolForRendering.name === 'CodexBash' && toolForRendering.input?.parsed_cmd && Array.isArray(toolForRendering.input.parsed_cmd) && toolForRendering.input.parsed_cmd.length > 0) { + const parsedCmd = toolForRendering.input.parsed_cmd[0]; if (parsedCmd.type === 'read') { icon = <Octicons name="eye" size={18} color={theme.colors.text} />; } else if (parsedCmd.type === 'write') { @@ -121,14 +144,16 @@ export const ToolView = React.memo<ToolViewProps>((props) => { let statusIcon = null; let isToolUseError = false; - if (tool.state === 'error' && tool.result && parseToolUseError(tool.result).isToolUseError) { + if (toolForRendering.state === 'error' && toolForRendering.result && parseToolUseError(toolForRendering.result).isToolUseError) { isToolUseError = true; - console.log('isToolUseError', tool.result); + console.log('isToolUseError', toolForRendering.result); } // Check permission status first for denied/canceled states if (tool.permission && (tool.permission.status === 'denied' || tool.permission.status === 'canceled')) { statusIcon = <Ionicons name="remove-circle-outline" size={20} color={theme.colors.textSecondary} />; + } else if (isWaitingForPermission) { + statusIcon = <Ionicons name="lock-closed-outline" size={20} color={theme.colors.warning} />; } else if (isToolUseError) { statusIcon = <Ionicons name="remove-circle-outline" size={20} color={theme.colors.textSecondary} />; hideDefaultError = true; @@ -167,7 +192,7 @@ export const ToolView = React.memo<ToolViewProps>((props) => { </Text> )} </View> - {tool.state === 'running' && ( + {tool.state === 'running' && !isWaitingForPermission && ( <View style={styles.elapsedContainer}> <ElapsedView from={tool.createdAt} /> </View> @@ -189,7 +214,7 @@ export const ToolView = React.memo<ToolViewProps>((props) => { </Text> )} </View> - {tool.state === 'running' && ( + {tool.state === 'running' && !isWaitingForPermission && ( <View style={styles.elapsedContainer}> <ElapsedView from={tool.createdAt} /> </View> @@ -201,33 +226,40 @@ export const ToolView = React.memo<ToolViewProps>((props) => { {/* Content area - either custom children or tool-specific view */} {(() => { - // Check if minimal first - minimal tools don't show content - if (minimal) { - return null; - } - // Try to use a specific tool view component first - const SpecificToolView = getToolViewComponent(tool.name); + const SpecificToolView = getToolViewComponent(normalizedToolName); if (SpecificToolView) { return ( <View style={styles.content}> - <SpecificToolView tool={tool} metadata={props.metadata} messages={props.messages ?? []} sessionId={sessionId} /> - {tool.state === 'error' && tool.result && - !(tool.permission && (tool.permission.status === 'denied' || tool.permission.status === 'canceled')) && + <SpecificToolView tool={toolForRendering} metadata={props.metadata} messages={props.messages ?? []} sessionId={sessionId} /> + {toolForRendering.state === 'error' && toolForRendering.result && + !(toolForRendering.permission && (toolForRendering.permission.status === 'denied' || toolForRendering.permission.status === 'canceled')) && !hideDefaultError && ( - <ToolError message={String(tool.result)} /> + <ToolError message={String(toolForRendering.result)} /> )} </View> ); } + // Minimal tools don't show default INPUT/OUTPUT blocks. + if (minimal) { + if (toolForRendering.result) { + return ( + <View style={styles.content}> + <StructuredResultView tool={toolForRendering} metadata={props.metadata} messages={props.messages ?? []} sessionId={sessionId} /> + </View> + ); + } + return null; + } + // Show error state if present (but not for denied/canceled permissions and not when hideDefaultError is true) - if (tool.state === 'error' && tool.result && - !(tool.permission && (tool.permission.status === 'denied' || tool.permission.status === 'canceled')) && + if (toolForRendering.state === 'error' && toolForRendering.result && + !(toolForRendering.permission && (toolForRendering.permission.status === 'denied' || toolForRendering.permission.status === 'canceled')) && !isToolUseError) { return ( <View style={styles.content}> - <ToolError message={String(tool.result)} /> + <ToolError message={String(toolForRendering.result)} /> </View> ); } @@ -236,16 +268,20 @@ export const ToolView = React.memo<ToolViewProps>((props) => { return ( <View style={styles.content}> {/* Default content when no custom view available */} - {tool.input && ( + {toolForRendering.input && ( <ToolSectionView title={t('toolView.input')}> - <CodeView code={JSON.stringify(tool.input, null, 2)} /> + <CodeView code={JSON.stringify(toolForRendering.input, null, 2)} /> </ToolSectionView> )} - {tool.state === 'completed' && tool.result && ( + {toolForRendering.state === 'running' && toolForRendering.result && ( + <StructuredResultView tool={toolForRendering} metadata={props.metadata} messages={props.messages ?? []} sessionId={sessionId} /> + )} + + {toolForRendering.state === 'completed' && toolForRendering.result && ( <ToolSectionView title={t('toolView.output')}> <CodeView - code={typeof tool.result === 'string' ? tool.result : JSON.stringify(tool.result, null, 2)} + code={typeof toolForRendering.result === 'string' ? toolForRendering.result : JSON.stringify(toolForRendering.result, null, 2)} /> </ToolSectionView> )} @@ -253,10 +289,10 @@ export const ToolView = React.memo<ToolViewProps>((props) => { ); })()} - {/* Permission footer - always renders when permission exists to maintain consistent height */} - {/* AskUserQuestion has its own Submit button UI - no permission footer needed */} - {tool.permission && sessionId && tool.name !== 'AskUserQuestion' && ( - <PermissionFooter permission={tool.permission} sessionId={sessionId} toolName={tool.name} toolInput={tool.input} metadata={props.metadata} /> + {/* Permission footer - rendered for most tools */} + {/* AskUserQuestion and ExitPlanMode have custom action UIs */} + {isWaitingForPermission && toolForRendering.permission && sessionId && toolForRendering.name !== 'AskUserQuestion' && toolForRendering.name !== 'ExitPlanMode' && toolForRendering.name !== 'exit_plan_mode' && toolForRendering.name !== 'AcpHistoryImport' && ( + <PermissionFooter permission={toolForRendering.permission} sessionId={sessionId} toolName={normalizedToolName} toolInput={toolForRendering.input} metadata={props.metadata} /> )} </View> ); diff --git a/expo-app/sources/components/tools/knownTools.tsx b/expo-app/sources/components/tools/knownTools.tsx deleted file mode 100644 index 696f8315e..000000000 --- a/expo-app/sources/components/tools/knownTools.tsx +++ /dev/null @@ -1,951 +0,0 @@ -import { Metadata } from '@/sync/storageTypes'; -import { ToolCall, Message } from '@/sync/typesMessage'; -import { resolvePath } from '@/utils/pathUtils'; -import * as z from 'zod'; -import { Ionicons, Octicons } from '@expo/vector-icons'; -import React from 'react'; -import { t } from '@/text'; - -// Icon factory functions -const ICON_TASK = (size: number = 24, color: string = '#000') => <Octicons name="rocket" size={size} color={color} />; -const ICON_TERMINAL = (size: number = 24, color: string = '#000') => <Octicons name="terminal" size={size} color={color} />; -const ICON_SEARCH = (size: number = 24, color: string = '#000') => <Octicons name="search" size={size} color={color} />; -const ICON_READ = (size: number = 24, color: string = '#000') => <Octicons name="eye" size={size} color={color} />; -const ICON_EDIT = (size: number = 24, color: string = '#000') => <Octicons name="file-diff" size={size} color={color} />; -const ICON_WEB = (size: number = 24, color: string = '#000') => <Ionicons name="globe-outline" size={size} color={color} />; -const ICON_EXIT = (size: number = 24, color: string = '#000') => <Ionicons name="exit-outline" size={size} color={color} />; -const ICON_TODO = (size: number = 24, color: string = '#000') => <Ionicons name="bulb-outline" size={size} color={color} />; -const ICON_REASONING = (size: number = 24, color: string = '#000') => <Octicons name="light-bulb" size={size} color={color} />; -const ICON_QUESTION = (size: number = 24, color: string = '#000') => <Ionicons name="help-circle-outline" size={size} color={color} />; - -export const knownTools = { - 'Task': { - title: (opts: { metadata: Metadata | null, tool: ToolCall }) => { - // Check for description field at runtime - if (opts.tool.input && opts.tool.input.description && typeof opts.tool.input.description === 'string') { - return opts.tool.input.description; - } - return t('tools.names.task'); - }, - icon: ICON_TASK, - isMutable: true, - minimal: (opts: { metadata: Metadata | null, tool: ToolCall, messages?: Message[] }) => { - // Check if there would be any filtered tasks - const messages = opts.messages || []; - for (let m of messages) { - if (m.kind === 'tool-call' && - (m.tool.state === 'running' || m.tool.state === 'completed' || m.tool.state === 'error')) { - return false; // Has active sub-tasks, show expanded - } - } - return true; // No active sub-tasks, render as minimal - }, - input: z.object({ - prompt: z.string().describe('The task for the agent to perform'), - subagent_type: z.string().optional().describe('The type of specialized agent to use') - }).partial().loose() - }, - 'Bash': { - title: (opts: { metadata: Metadata | null, tool: ToolCall }) => { - if (opts.tool.description) { - return opts.tool.description; - } - return t('tools.names.terminal'); - }, - icon: ICON_TERMINAL, - minimal: true, - hideDefaultError: true, - isMutable: true, - input: z.object({ - command: z.string().describe('The command to execute'), - timeout: z.number().optional().describe('Timeout in milliseconds (max 600000)') - }), - result: z.object({ - stderr: z.string(), - stdout: z.string(), - }).partial().loose(), - extractDescription: (opts: { metadata: Metadata | null, tool: ToolCall }) => { - if (typeof opts.tool.input.command === 'string') { - const cmd = opts.tool.input.command; - // Extract just the command name for common commands - const firstWord = cmd.split(' ')[0]; - if (['cd', 'ls', 'pwd', 'mkdir', 'rm', 'cp', 'mv', 'npm', 'yarn', 'git'].includes(firstWord)) { - return t('tools.desc.terminalCmd', { cmd: firstWord }); - } - // For other commands, show truncated version - const truncated = cmd.length > 20 ? cmd.substring(0, 20) + '...' : cmd; - return t('tools.desc.terminalCmd', { cmd: truncated }); - } - return t('tools.names.terminal'); - }, - extractSubtitle: (opts: { metadata: Metadata | null, tool: ToolCall }) => { - if (typeof opts.tool.input.command === 'string') { - return opts.tool.input.command; - } - return null; - } - }, - 'Glob': { - title: (opts: { metadata: Metadata | null, tool: ToolCall }) => { - if (typeof opts.tool.input.pattern === 'string') { - return opts.tool.input.pattern; - } - return t('tools.names.searchFiles'); - }, - icon: ICON_SEARCH, - minimal: true, - input: z.object({ - pattern: z.string().describe('The glob pattern to match files against'), - path: z.string().optional().describe('The directory to search in') - }).partial().loose(), - extractDescription: (opts: { metadata: Metadata | null, tool: ToolCall }) => { - if (typeof opts.tool.input.pattern === 'string') { - return t('tools.desc.searchPattern', { pattern: opts.tool.input.pattern }); - } - return t('tools.names.search'); - } - }, - 'Grep': { - title: (opts: { metadata: Metadata | null, tool: ToolCall }) => { - if (typeof opts.tool.input.pattern === 'string') { - return `grep(pattern: ${opts.tool.input.pattern})`; - } - return 'Search Content'; - }, - icon: ICON_READ, - minimal: true, - input: z.object({ - pattern: z.string().describe('The regular expression pattern to search for'), - path: z.string().optional().describe('File or directory to search in'), - output_mode: z.enum(['content', 'files_with_matches', 'count']).optional(), - '-n': z.boolean().optional().describe('Show line numbers'), - '-i': z.boolean().optional().describe('Case insensitive search'), - '-A': z.number().optional().describe('Lines to show after match'), - '-B': z.number().optional().describe('Lines to show before match'), - '-C': z.number().optional().describe('Lines to show before and after match'), - glob: z.string().optional().describe('Glob pattern to filter files'), - type: z.string().optional().describe('File type to search'), - head_limit: z.number().optional().describe('Limit output to first N lines/entries'), - multiline: z.boolean().optional().describe('Enable multiline mode') - }).partial().loose(), - extractDescription: (opts: { metadata: Metadata | null, tool: ToolCall }) => { - if (typeof opts.tool.input.pattern === 'string') { - const pattern = opts.tool.input.pattern.length > 20 - ? opts.tool.input.pattern.substring(0, 20) + '...' - : opts.tool.input.pattern; - return `Search(pattern: ${pattern})`; - } - return 'Search'; - } - }, - 'LS': { - title: (opts: { metadata: Metadata | null, tool: ToolCall }) => { - if (typeof opts.tool.input.path === 'string') { - return resolvePath(opts.tool.input.path, opts.metadata); - } - return t('tools.names.listFiles'); - }, - icon: ICON_SEARCH, - minimal: true, - input: z.object({ - path: z.string().describe('The absolute path to the directory to list'), - ignore: z.array(z.string()).optional().describe('List of glob patterns to ignore') - }).partial().loose(), - extractDescription: (opts: { metadata: Metadata | null, tool: ToolCall }) => { - if (typeof opts.tool.input.path === 'string') { - const path = resolvePath(opts.tool.input.path, opts.metadata); - const basename = path.split('/').pop() || path; - return t('tools.desc.searchPath', { basename }); - } - return t('tools.names.search'); - } - }, - 'ExitPlanMode': { - title: t('tools.names.planProposal'), - icon: ICON_EXIT, - input: z.object({ - plan: z.string().describe('The plan you came up with') - }).partial().loose() - }, - 'exit_plan_mode': { - title: t('tools.names.planProposal'), - icon: ICON_EXIT, - input: z.object({ - plan: z.string().describe('The plan you came up with') - }).partial().loose() - }, - 'Read': { - title: (opts: { metadata: Metadata | null, tool: ToolCall }) => { - if (typeof opts.tool.input.file_path === 'string') { - const path = resolvePath(opts.tool.input.file_path, opts.metadata); - return path; - } - // Gemini uses 'locations' array with 'path' field - if (opts.tool.input.locations && Array.isArray(opts.tool.input.locations) && opts.tool.input.locations[0]?.path) { - const path = resolvePath(opts.tool.input.locations[0].path, opts.metadata); - return path; - } - return t('tools.names.readFile'); - }, - minimal: true, - icon: ICON_READ, - input: z.object({ - file_path: z.string().describe('The absolute path to the file to read'), - limit: z.number().optional().describe('The number of lines to read'), - offset: z.number().optional().describe('The line number to start reading from'), - // Gemini format - items: z.array(z.any()).optional(), - locations: z.array(z.object({ path: z.string() }).loose()).optional() - }).partial().loose(), - result: z.object({ - file: z.object({ - filePath: z.string().describe('The absolute path to the file to read'), - content: z.string().describe('The content of the file'), - numLines: z.number().describe('The number of lines in the file'), - startLine: z.number().describe('The line number to start reading from'), - totalLines: z.number().describe('The total number of lines in the file') - }).loose().optional() - }).partial().loose() - }, - // Gemini uses lowercase 'read' - 'read': { - title: (opts: { metadata: Metadata | null, tool: ToolCall }) => { - // Gemini uses 'locations' array with 'path' field - if (opts.tool.input.locations && Array.isArray(opts.tool.input.locations) && opts.tool.input.locations[0]?.path) { - const path = resolvePath(opts.tool.input.locations[0].path, opts.metadata); - return path; - } - if (typeof opts.tool.input.file_path === 'string') { - const path = resolvePath(opts.tool.input.file_path, opts.metadata); - return path; - } - return t('tools.names.readFile'); - }, - minimal: true, - icon: ICON_READ, - input: z.object({ - items: z.array(z.any()).optional(), - locations: z.array(z.object({ path: z.string() }).loose()).optional(), - file_path: z.string().optional() - }).partial().loose() - }, - 'Edit': { - title: (opts: { metadata: Metadata | null, tool: ToolCall }) => { - if (typeof opts.tool.input.file_path === 'string') { - const path = resolvePath(opts.tool.input.file_path, opts.metadata); - return path; - } - return t('tools.names.editFile'); - }, - icon: ICON_EDIT, - isMutable: true, - input: z.object({ - file_path: z.string().describe('The absolute path to the file to modify'), - old_string: z.string().describe('The text to replace'), - new_string: z.string().describe('The text to replace it with'), - replace_all: z.boolean().optional().default(false).describe('Replace all occurrences') - }).partial().loose() - }, - 'MultiEdit': { - title: (opts: { metadata: Metadata | null, tool: ToolCall }) => { - if (typeof opts.tool.input.file_path === 'string') { - const path = resolvePath(opts.tool.input.file_path, opts.metadata); - const editCount = Array.isArray(opts.tool.input.edits) ? opts.tool.input.edits.length : 0; - if (editCount > 1) { - return t('tools.desc.multiEditEdits', { path, count: editCount }); - } - return path; - } - return t('tools.names.editFile'); - }, - icon: ICON_EDIT, - isMutable: true, - input: z.object({ - file_path: z.string().describe('The absolute path to the file to modify'), - edits: z.array(z.object({ - old_string: z.string().describe('The text to replace'), - new_string: z.string().describe('The text to replace it with'), - replace_all: z.boolean().optional().default(false).describe('Replace all occurrences') - })).describe('Array of edit operations') - }).partial().loose(), - extractStatus: (opts: { metadata: Metadata | null, tool: ToolCall }) => { - if (typeof opts.tool.input.file_path === 'string') { - const path = resolvePath(opts.tool.input.file_path, opts.metadata); - const editCount = Array.isArray(opts.tool.input.edits) ? opts.tool.input.edits.length : 0; - if (editCount > 0) { - return t('tools.desc.multiEditEdits', { path, count: editCount }); - } - return path; - } - return null; - } - }, - 'Write': { - title: (opts: { metadata: Metadata | null, tool: ToolCall }) => { - if (typeof opts.tool.input.file_path === 'string') { - const path = resolvePath(opts.tool.input.file_path, opts.metadata); - return path; - } - return t('tools.names.writeFile'); - }, - icon: ICON_EDIT, - isMutable: true, - input: z.object({ - file_path: z.string().describe('The absolute path to the file to write'), - content: z.string().describe('The content to write to the file') - }).partial().loose() - }, - 'WebFetch': { - title: (opts: { metadata: Metadata | null, tool: ToolCall }) => { - if (typeof opts.tool.input.url === 'string') { - try { - const url = new URL(opts.tool.input.url); - return url.hostname; - } catch { - return t('tools.names.fetchUrl'); - } - } - return t('tools.names.fetchUrl'); - }, - icon: ICON_WEB, - minimal: true, - input: z.object({ - url: z.string().url().describe('The URL to fetch content from'), - prompt: z.string().describe('The prompt to run on the fetched content') - }).partial().loose(), - extractDescription: (opts: { metadata: Metadata | null, tool: ToolCall }) => { - if (typeof opts.tool.input.url === 'string') { - try { - const url = new URL(opts.tool.input.url); - return t('tools.desc.fetchUrlHost', { host: url.hostname }); - } catch { - return t('tools.names.fetchUrl'); - } - } - return 'Fetch URL'; - } - }, - 'NotebookRead': { - title: (opts: { metadata: Metadata | null, tool: ToolCall }) => { - if (typeof opts.tool.input.notebook_path === 'string') { - const path = resolvePath(opts.tool.input.notebook_path, opts.metadata); - return path; - } - return t('tools.names.readNotebook'); - }, - icon: ICON_READ, - minimal: true, - input: z.object({ - notebook_path: z.string().describe('The absolute path to the Jupyter notebook file'), - cell_id: z.string().optional().describe('The ID of a specific cell to read') - }).partial().loose() - }, - 'NotebookEdit': { - title: (opts: { metadata: Metadata | null, tool: ToolCall }) => { - if (typeof opts.tool.input.notebook_path === 'string') { - const path = resolvePath(opts.tool.input.notebook_path, opts.metadata); - return path; - } - return t('tools.names.editNotebook'); - }, - icon: ICON_EDIT, - isMutable: true, - input: z.object({ - notebook_path: z.string().describe('The absolute path to the notebook file'), - new_source: z.string().describe('The new source for the cell'), - cell_id: z.string().optional().describe('The ID of the cell to edit'), - cell_type: z.enum(['code', 'markdown']).optional().describe('The type of the cell'), - edit_mode: z.enum(['replace', 'insert', 'delete']).optional().describe('The type of edit to make') - }).partial().loose(), - extractDescription: (opts: { metadata: Metadata | null, tool: ToolCall }) => { - if (typeof opts.tool.input.notebook_path === 'string') { - const path = resolvePath(opts.tool.input.notebook_path, opts.metadata); - const mode = opts.tool.input.edit_mode || 'replace'; - return t('tools.desc.editNotebookMode', { path, mode }); - } - return t('tools.names.editNotebook'); - } - }, - 'TodoWrite': { - title: t('tools.names.todoList'), - icon: ICON_TODO, - noStatus: true, - minimal: (opts: { metadata: Metadata | null, tool: ToolCall, messages?: Message[] }) => { - // Check if there are todos in the input - if (opts.tool.input?.todos && Array.isArray(opts.tool.input.todos) && opts.tool.input.todos.length > 0) { - return false; // Has todos, show expanded - } - - // Check if there are todos in the result - if (opts.tool.result?.newTodos && Array.isArray(opts.tool.result.newTodos) && opts.tool.result.newTodos.length > 0) { - return false; // Has todos, show expanded - } - - return true; // No todos, render as minimal - }, - input: z.object({ - todos: z.array(z.object({ - content: z.string().describe('The todo item content'), - status: z.enum(['pending', 'in_progress', 'completed']).describe('The status of the todo'), - priority: z.enum(['high', 'medium', 'low']).optional().describe('The priority of the todo'), - id: z.string().optional().describe('Unique identifier for the todo') - }).loose()).describe('The updated todo list') - }).partial().loose(), - result: z.object({ - oldTodos: z.array(z.object({ - content: z.string().describe('The todo item content'), - status: z.enum(['pending', 'in_progress', 'completed']).describe('The status of the todo'), - priority: z.enum(['high', 'medium', 'low']).optional().describe('The priority of the todo'), - id: z.string().describe('Unique identifier for the todo') - }).loose()).describe('The old todo list'), - newTodos: z.array(z.object({ - content: z.string().describe('The todo item content'), - status: z.enum(['pending', 'in_progress', 'completed']).describe('The status of the todo'), - priority: z.enum(['high', 'medium', 'low']).optional().describe('The priority of the todo'), - id: z.string().describe('Unique identifier for the todo') - }).loose()).describe('The new todo list') - }).partial().loose(), - extractDescription: (opts: { metadata: Metadata | null, tool: ToolCall }) => { - if (Array.isArray(opts.tool.input.todos)) { - const count = opts.tool.input.todos.length; - return t('tools.desc.todoListCount', { count }); - } - return t('tools.names.todoList'); - }, - }, - 'WebSearch': { - title: (opts: { metadata: Metadata | null, tool: ToolCall }) => { - if (typeof opts.tool.input.query === 'string') { - return opts.tool.input.query; - } - return t('tools.names.webSearch'); - }, - icon: ICON_WEB, - minimal: true, - input: z.object({ - query: z.string().min(2).describe('The search query to use'), - allowed_domains: z.array(z.string()).optional().describe('Only include results from these domains'), - blocked_domains: z.array(z.string()).optional().describe('Never include results from these domains') - }).partial().loose(), - extractDescription: (opts: { metadata: Metadata | null, tool: ToolCall }) => { - if (typeof opts.tool.input.query === 'string') { - const query = opts.tool.input.query.length > 30 - ? opts.tool.input.query.substring(0, 30) + '...' - : opts.tool.input.query; - return t('tools.desc.webSearchQuery', { query }); - } - return t('tools.names.webSearch'); - } - }, - 'CodexBash': { - title: (opts: { metadata: Metadata | null, tool: ToolCall }) => { - // Check if this is a single read command - if (opts.tool.input?.parsed_cmd && - Array.isArray(opts.tool.input.parsed_cmd) && - opts.tool.input.parsed_cmd.length === 1 && - opts.tool.input.parsed_cmd[0].type === 'read' && - opts.tool.input.parsed_cmd[0].name) { - // Display the file name being read - const path = resolvePath(opts.tool.input.parsed_cmd[0].name, opts.metadata); - return path; - } - return t('tools.names.terminal'); - }, - icon: ICON_TERMINAL, - minimal: true, - hideDefaultError: true, - isMutable: true, - input: z.object({ - command: z.array(z.string()).describe('The command array to execute'), - cwd: z.string().optional().describe('Current working directory'), - parsed_cmd: z.array(z.object({ - type: z.string().describe('Type of parsed command (read, write, bash, etc.)'), - cmd: z.string().optional().describe('The command string'), - name: z.string().optional().describe('File name or resource name') - }).loose()).optional().describe('Parsed command information') - }).partial().loose(), - extractSubtitle: (opts: { metadata: Metadata | null, tool: ToolCall }) => { - // For single read commands, show the actual command - if (opts.tool.input?.parsed_cmd && - Array.isArray(opts.tool.input.parsed_cmd) && - opts.tool.input.parsed_cmd.length === 1 && - opts.tool.input.parsed_cmd[0].type === 'read') { - const parsedCmd = opts.tool.input.parsed_cmd[0]; - if (parsedCmd.cmd) { - // Show the command but truncate if too long - const cmd = parsedCmd.cmd; - return cmd.length > 50 ? cmd.substring(0, 50) + '...' : cmd; - } - } - // Show the actual command being executed for other cases - if (opts.tool.input?.parsed_cmd && Array.isArray(opts.tool.input.parsed_cmd) && opts.tool.input.parsed_cmd.length > 0) { - const parsedCmd = opts.tool.input.parsed_cmd[0]; - if (parsedCmd.cmd) { - return parsedCmd.cmd; - } - } - if (opts.tool.input?.command && Array.isArray(opts.tool.input.command)) { - let cmdArray = opts.tool.input.command; - // Remove shell wrapper prefix if present (bash/zsh with -lc flag) - if (cmdArray.length >= 3 && (cmdArray[0] === 'bash' || cmdArray[0] === '/bin/bash' || cmdArray[0] === 'zsh' || cmdArray[0] === '/bin/zsh') && cmdArray[1] === '-lc') { - // The actual command is in the third element - return cmdArray[2]; - } - return cmdArray.join(' '); - } - return null; - }, - extractDescription: (opts: { metadata: Metadata | null, tool: ToolCall }) => { - // Provide a description based on the parsed command type - if (opts.tool.input?.parsed_cmd && - Array.isArray(opts.tool.input.parsed_cmd) && - opts.tool.input.parsed_cmd.length === 1) { - const parsedCmd = opts.tool.input.parsed_cmd[0]; - if (parsedCmd.type === 'read' && parsedCmd.name) { - // For single read commands, show "Reading" as simple description - // The file path is already in the title - const path = resolvePath(parsedCmd.name, opts.metadata); - const basename = path.split('/').pop() || path; - return t('tools.desc.readingFile', { file: basename }); - } else if (parsedCmd.type === 'write' && parsedCmd.name) { - const path = resolvePath(parsedCmd.name, opts.metadata); - const basename = path.split('/').pop() || path; - return t('tools.desc.writingFile', { file: basename }); - } - } - return t('tools.names.terminal'); - } - }, - 'CodexReasoning': { - title: (opts: { metadata: Metadata | null, tool: ToolCall }) => { - // Use the title from input if provided - if (opts.tool.input?.title && typeof opts.tool.input.title === 'string') { - return opts.tool.input.title; - } - return t('tools.names.reasoning'); - }, - icon: ICON_REASONING, - minimal: true, - input: z.object({ - title: z.string().describe('The title of the reasoning') - }).partial().loose(), - result: z.object({ - content: z.string().describe('The reasoning content'), - status: z.enum(['completed', 'in_progress', 'error']).optional().describe('The status of the reasoning') - }).partial().loose(), - extractDescription: (opts: { metadata: Metadata | null, tool: ToolCall }) => { - if (opts.tool.input?.title && typeof opts.tool.input.title === 'string') { - return opts.tool.input.title; - } - return t('tools.names.reasoning'); - } - }, - 'GeminiReasoning': { - title: (opts: { metadata: Metadata | null, tool: ToolCall }) => { - // Use the title from input if provided - if (opts.tool.input?.title && typeof opts.tool.input.title === 'string') { - return opts.tool.input.title; - } - return t('tools.names.reasoning'); - }, - icon: ICON_REASONING, - minimal: true, - input: z.object({ - title: z.string().describe('The title of the reasoning') - }).partial().loose(), - result: z.object({ - content: z.string().describe('The reasoning content'), - status: z.enum(['completed', 'in_progress', 'canceled']).optional().describe('The status of the reasoning') - }).partial().loose(), - extractDescription: (opts: { metadata: Metadata | null, tool: ToolCall }) => { - if (opts.tool.input?.title && typeof opts.tool.input.title === 'string') { - return opts.tool.input.title; - } - return t('tools.names.reasoning'); - } - }, - 'think': { - title: (opts: { metadata: Metadata | null, tool: ToolCall }) => { - // Use the title from input if provided - if (opts.tool.input?.title && typeof opts.tool.input.title === 'string') { - return opts.tool.input.title; - } - return t('tools.names.reasoning'); - }, - icon: ICON_REASONING, - minimal: true, - input: z.object({ - title: z.string().optional().describe('The title of the thinking'), - items: z.array(z.any()).optional().describe('Items to think about'), - locations: z.array(z.any()).optional().describe('Locations to consider') - }).partial().loose(), - result: z.object({ - content: z.string().optional().describe('The reasoning content'), - text: z.string().optional().describe('The reasoning text'), - status: z.enum(['completed', 'in_progress', 'canceled']).optional().describe('The status') - }).partial().loose(), - extractDescription: (opts: { metadata: Metadata | null, tool: ToolCall }) => { - if (opts.tool.input?.title && typeof opts.tool.input.title === 'string') { - return opts.tool.input.title; - } - return t('tools.names.reasoning'); - } - }, - 'change_title': { - title: 'Change Title', - icon: ICON_EDIT, - minimal: true, - noStatus: true, - input: z.object({ - title: z.string().optional().describe('New session title') - }).partial().loose(), - result: z.object({}).partial().loose() - }, - // Gemini internal tools - should be hidden (minimal) - 'search': { - title: t('tools.names.search'), - icon: ICON_SEARCH, - minimal: true, - input: z.object({ - items: z.array(z.any()).optional(), - locations: z.array(z.any()).optional() - }).partial().loose() - }, - 'edit': { - title: (opts: { metadata: Metadata | null, tool: ToolCall }) => { - // Gemini sends data in nested structure, try multiple locations - let filePath: string | undefined; - - // 1. Check toolCall.content[0].path - if (opts.tool.input?.toolCall?.content?.[0]?.path) { - filePath = opts.tool.input.toolCall.content[0].path; - } - // 2. Check toolCall.title (has nice "Writing to ..." format) - else if (opts.tool.input?.toolCall?.title) { - return opts.tool.input.toolCall.title; - } - // 3. Check input[0].path (array format) - else if (Array.isArray(opts.tool.input?.input) && opts.tool.input.input[0]?.path) { - filePath = opts.tool.input.input[0].path; - } - // 4. Check direct path field - else if (typeof opts.tool.input?.path === 'string') { - filePath = opts.tool.input.path; - } - - if (filePath) { - return resolvePath(filePath, opts.metadata); - } - return t('tools.names.editFile'); - }, - icon: ICON_EDIT, - isMutable: true, - input: z.object({ - path: z.string().describe('The file path to edit'), - oldText: z.string().describe('The text to replace'), - newText: z.string().describe('The new text'), - type: z.string().optional().describe('Type of edit (diff)') - }).partial().loose() - }, - 'shell': { - title: t('tools.names.terminal'), - icon: ICON_TERMINAL, - minimal: true, - isMutable: true, - input: z.object({}).partial().loose() - }, - 'execute': { - title: (opts: { metadata: Metadata | null, tool: ToolCall }) => { - // Gemini sends nice title in toolCall.title - if (opts.tool.input?.toolCall?.title) { - // Title is like "rm file.txt [cwd /path] (description)" - // Extract just the command part before [ - const fullTitle = opts.tool.input.toolCall.title; - const bracketIdx = fullTitle.indexOf(' ['); - if (bracketIdx > 0) { - return fullTitle.substring(0, bracketIdx); - } - return fullTitle; - } - return t('tools.names.terminal'); - }, - icon: ICON_TERMINAL, - isMutable: true, - input: z.object({}).partial().loose(), - extractSubtitle: (opts: { metadata: Metadata | null, tool: ToolCall }) => { - // Extract description from parentheses at the end - if (opts.tool.input?.toolCall?.title) { - const title = opts.tool.input.toolCall.title; - const parenMatch = title.match(/\(([^)]+)\)$/); - if (parenMatch) { - return parenMatch[1]; - } - } - return null; - } - }, - 'CodexPatch': { - title: t('tools.names.applyChanges'), - icon: ICON_EDIT, - minimal: true, - hideDefaultError: true, - input: z.object({ - auto_approved: z.boolean().optional().describe('Whether changes were auto-approved'), - changes: z.record(z.string(), z.object({ - add: z.object({ - content: z.string() - }).optional(), - modify: z.object({ - old_content: z.string(), - new_content: z.string() - }).optional(), - delete: z.object({ - content: z.string() - }).optional() - }).loose()).describe('File changes to apply') - }).partial().loose(), - extractSubtitle: (opts: { metadata: Metadata | null, tool: ToolCall }) => { - // Show the first file being modified - if (opts.tool.input?.changes && typeof opts.tool.input.changes === 'object') { - const files = Object.keys(opts.tool.input.changes); - if (files.length > 0) { - const path = resolvePath(files[0], opts.metadata); - const fileName = path.split('/').pop() || path; - if (files.length > 1) { - return t('tools.desc.modifyingMultipleFiles', { - file: fileName, - count: files.length - 1 - }); - } - return fileName; - } - } - return null; - }, - extractDescription: (opts: { metadata: Metadata | null, tool: ToolCall }) => { - // Show the number of files being modified - if (opts.tool.input?.changes && typeof opts.tool.input.changes === 'object') { - const files = Object.keys(opts.tool.input.changes); - const fileCount = files.length; - if (fileCount === 1) { - const path = resolvePath(files[0], opts.metadata); - const fileName = path.split('/').pop() || path; - return t('tools.desc.modifyingFile', { file: fileName }); - } else if (fileCount > 1) { - return t('tools.desc.modifyingFiles', { count: fileCount }); - } - } - return t('tools.names.applyChanges'); - } - }, - 'GeminiBash': { - title: t('tools.names.terminal'), - icon: ICON_TERMINAL, - minimal: true, - hideDefaultError: true, - isMutable: true, - input: z.object({ - command: z.array(z.string()).describe('The command array to execute'), - cwd: z.string().optional().describe('Current working directory') - }).partial().loose(), - extractSubtitle: (opts: { metadata: Metadata | null, tool: ToolCall }) => { - if (opts.tool.input?.command && Array.isArray(opts.tool.input.command)) { - let cmdArray = opts.tool.input.command; - // Remove shell wrapper prefix if present (bash/zsh with -lc flag) - if (cmdArray.length >= 3 && (cmdArray[0] === 'bash' || cmdArray[0] === '/bin/bash' || cmdArray[0] === 'zsh' || cmdArray[0] === '/bin/zsh') && cmdArray[1] === '-lc') { - return cmdArray[2]; - } - return cmdArray.join(' '); - } - return null; - } - }, - 'GeminiPatch': { - title: t('tools.names.applyChanges'), - icon: ICON_EDIT, - minimal: true, - hideDefaultError: true, - isMutable: true, - input: z.object({ - auto_approved: z.boolean().optional().describe('Whether changes were auto-approved'), - changes: z.record(z.string(), z.object({ - add: z.object({ - content: z.string() - }).optional(), - modify: z.object({ - old_content: z.string(), - new_content: z.string() - }).optional(), - delete: z.object({ - content: z.string() - }).optional() - }).loose()).describe('File changes to apply') - }).partial().loose(), - extractSubtitle: (opts: { metadata: Metadata | null, tool: ToolCall }) => { - // Show the first file being modified - if (opts.tool.input?.changes && typeof opts.tool.input.changes === 'object') { - const files = Object.keys(opts.tool.input.changes); - if (files.length > 0) { - const path = resolvePath(files[0], opts.metadata); - const fileName = path.split('/').pop() || path; - if (files.length > 1) { - return t('tools.desc.modifyingMultipleFiles', { - file: fileName, - count: files.length - 1 - }); - } - return fileName; - } - } - return null; - }, - extractDescription: (opts: { metadata: Metadata | null, tool: ToolCall }) => { - // Show the number of files being modified - if (opts.tool.input?.changes && typeof opts.tool.input.changes === 'object') { - const files = Object.keys(opts.tool.input.changes); - const fileCount = files.length; - if (fileCount === 1) { - const path = resolvePath(files[0], opts.metadata); - const fileName = path.split('/').pop() || path; - return t('tools.desc.modifyingFile', { file: fileName }); - } else if (fileCount > 1) { - return t('tools.desc.modifyingFiles', { count: fileCount }); - } - } - return t('tools.names.applyChanges'); - } - }, - 'CodexDiff': { - title: t('tools.names.viewDiff'), - icon: ICON_EDIT, - minimal: false, // Show full diff view - hideDefaultError: true, - noStatus: true, // Always successful, stateless like Task - input: z.object({ - unified_diff: z.string().describe('Unified diff content') - }).partial().loose(), - result: z.object({ - status: z.literal('completed').describe('Always completed') - }).partial().loose(), - extractSubtitle: (opts: { metadata: Metadata | null, tool: ToolCall }) => { - // Try to extract filename from unified diff - if (opts.tool.input?.unified_diff && typeof opts.tool.input.unified_diff === 'string') { - const diffLines = opts.tool.input.unified_diff.split('\n'); - for (const line of diffLines) { - if (line.startsWith('+++ b/') || line.startsWith('+++ ')) { - const fileName = line.replace(/^\+\+\+ (b\/)?/, ''); - const basename = fileName.split('/').pop() || fileName; - return basename; - } - } - } - return null; - }, - extractDescription: (opts: { metadata: Metadata | null, tool: ToolCall }) => { - return t('tools.desc.showingDiff'); - } - }, - 'GeminiDiff': { - title: t('tools.names.viewDiff'), - icon: ICON_EDIT, - minimal: false, // Show full diff view - hideDefaultError: true, - noStatus: true, // Always successful, stateless like Task - input: z.object({ - unified_diff: z.string().optional().describe('Unified diff content'), - filePath: z.string().optional().describe('File path'), - description: z.string().optional().describe('Edit description') - }).partial().loose(), - result: z.object({ - status: z.literal('completed').describe('Always completed') - }).partial().loose(), - extractSubtitle: (opts: { metadata: Metadata | null, tool: ToolCall }) => { - // Try to extract filename from filePath first - if (opts.tool.input?.filePath && typeof opts.tool.input.filePath === 'string') { - const basename = opts.tool.input.filePath.split('/').pop() || opts.tool.input.filePath; - return basename; - } - // Fall back to extracting from unified diff - if (opts.tool.input?.unified_diff && typeof opts.tool.input.unified_diff === 'string') { - const diffLines = opts.tool.input.unified_diff.split('\n'); - for (const line of diffLines) { - if (line.startsWith('+++ b/') || line.startsWith('+++ ')) { - const fileName = line.replace(/^\+\+\+ (b\/)?/, ''); - const basename = fileName.split('/').pop() || fileName; - return basename; - } - } - } - return null; - }, - extractDescription: (opts: { metadata: Metadata | null, tool: ToolCall }) => { - return t('tools.desc.showingDiff'); - } - }, - 'AskUserQuestion': { - title: (opts: { metadata: Metadata | null, tool: ToolCall }) => { - // Use first question header as title if available - if (opts.tool.input?.questions && Array.isArray(opts.tool.input.questions) && opts.tool.input.questions.length > 0) { - const firstQuestion = opts.tool.input.questions[0]; - if (firstQuestion.header) { - return firstQuestion.header; - } - } - return t('tools.names.question'); - }, - icon: ICON_QUESTION, - minimal: false, // Always show expanded to display options - noStatus: true, - input: z.object({ - questions: z.array(z.object({ - question: z.string().describe('The question to ask'), - header: z.string().describe('Short label for the question'), - options: z.array(z.object({ - label: z.string().describe('Option label'), - description: z.string().describe('Option description') - })).describe('Available choices'), - multiSelect: z.boolean().describe('Allow multiple selections') - })).describe('Questions to ask the user') - }).partial().loose(), - extractSubtitle: (opts: { metadata: Metadata | null, tool: ToolCall }) => { - if (opts.tool.input?.questions && Array.isArray(opts.tool.input.questions)) { - const count = opts.tool.input.questions.length; - if (count === 1) { - return opts.tool.input.questions[0].question; - } - return t('tools.askUserQuestion.multipleQuestions', { count }); - } - return null; - } - } -} satisfies Record<string, { - title?: string | ((opts: { metadata: Metadata | null, tool: ToolCall }) => string); - icon: (size: number, color: string) => React.ReactNode; - noStatus?: boolean; - hideDefaultError?: boolean; - isMutable?: boolean; - input?: z.ZodObject<any>; - result?: z.ZodObject<any>; - minimal?: boolean | ((opts: { metadata: Metadata | null, tool: ToolCall, messages?: Message[] }) => boolean); - extractDescription?: (opts: { metadata: Metadata | null, tool: ToolCall }) => string; - extractSubtitle?: (opts: { metadata: Metadata | null, tool: ToolCall }) => string | null; - extractStatus?: (opts: { metadata: Metadata | null, tool: ToolCall }) => string | null; -}>; - -/** - * Check if a tool is mutable (can potentially modify files) - * @param toolName The name of the tool to check - * @returns true if the tool is mutable or unknown, false if it's read-only - */ -export function isMutableTool(toolName: string): boolean { - const tool = knownTools[toolName as keyof typeof knownTools]; - if (tool) { - if ('isMutable' in tool) { - return tool.isMutable === true; - } else { - return false; - } - } - // If tool is unknown, assume it's mutable to be safe - return true; -} diff --git a/expo-app/sources/components/tools/knownTools/_types.ts b/expo-app/sources/components/tools/knownTools/_types.ts new file mode 100644 index 000000000..601a17c16 --- /dev/null +++ b/expo-app/sources/components/tools/knownTools/_types.ts @@ -0,0 +1,19 @@ +import type { Metadata } from '@/sync/storageTypes'; +import type { ToolCall, Message } from '@/sync/typesMessage'; +import type { ReactNode } from 'react'; +import type * as z from 'zod'; + +export type KnownToolDefinition = { + title?: string | ((opts: { metadata: Metadata | null, tool: ToolCall }) => string); + icon: (size: number, color: string) => ReactNode; + noStatus?: boolean; + hideDefaultError?: boolean; + isMutable?: boolean; + input?: z.ZodObject<any>; + result?: z.ZodObject<any>; + minimal?: boolean | ((opts: { metadata: Metadata | null, tool: ToolCall, messages?: Message[] }) => boolean); + extractDescription?: (opts: { metadata: Metadata | null, tool: ToolCall }) => string; + extractSubtitle?: (opts: { metadata: Metadata | null, tool: ToolCall }) => string | null; + extractStatus?: (opts: { metadata: Metadata | null, tool: ToolCall }) => string | null; +}; + diff --git a/expo-app/sources/components/tools/knownTools/core/files.tsx b/expo-app/sources/components/tools/knownTools/core/files.tsx new file mode 100644 index 000000000..5977d2a78 --- /dev/null +++ b/expo-app/sources/components/tools/knownTools/core/files.tsx @@ -0,0 +1,138 @@ +import type { Metadata } from '@/sync/storageTypes'; +import type { ToolCall } from '@/sync/typesMessage'; +import { resolvePath } from '@/utils/pathUtils'; +import * as z from 'zod'; +import { t } from '@/text'; +import { ICON_READ, ICON_EDIT } from '../icons'; +import type { KnownToolDefinition } from '../_types'; + +export const coreFileTools = { + 'Read': { + title: (opts: { metadata: Metadata | null, tool: ToolCall }) => { + if (typeof opts.tool.input.file_path === 'string') { + const path = resolvePath(opts.tool.input.file_path, opts.metadata); + return path; + } + // Gemini uses 'locations' array with 'path' field + if (Array.isArray(opts.tool.input.locations)) { + const maybePath = opts.tool.input.locations[0]?.path; + if (typeof maybePath === 'string' && maybePath.length > 0) { + const path = resolvePath(maybePath, opts.metadata); + return path; + } + } + return t('tools.names.readFile'); + }, + minimal: true, + icon: ICON_READ, + input: z.object({ + file_path: z.string().describe('The absolute path to the file to read'), + limit: z.number().optional().describe('The number of lines to read'), + offset: z.number().optional().describe('The line number to start reading from'), + // Gemini format + items: z.array(z.any()).optional(), + locations: z.array(z.object({ path: z.string() }).loose()).optional() + }).partial().loose(), + result: z.object({ + file: z.object({ + filePath: z.string().describe('The absolute path to the file to read'), + content: z.string().describe('The content of the file'), + numLines: z.number().describe('The number of lines in the file'), + startLine: z.number().describe('The line number to start reading from'), + totalLines: z.number().describe('The total number of lines in the file') + }).loose().optional() + }).partial().loose() + }, + // Gemini uses lowercase 'read' + 'read': { + title: (opts: { metadata: Metadata | null, tool: ToolCall }) => { + // Gemini uses 'locations' array with 'path' field + if (Array.isArray(opts.tool.input.locations)) { + const maybePath = opts.tool.input.locations[0]?.path; + if (typeof maybePath === 'string' && maybePath.length > 0) { + const path = resolvePath(maybePath, opts.metadata); + return path; + } + } + if (typeof opts.tool.input.file_path === 'string') { + const path = resolvePath(opts.tool.input.file_path, opts.metadata); + return path; + } + return t('tools.names.readFile'); + }, + minimal: true, + icon: ICON_READ, + input: z.object({ + items: z.array(z.any()).optional(), + locations: z.array(z.object({ path: z.string() }).loose()).optional(), + file_path: z.string().optional() + }).partial().loose() + }, + 'Edit': { + title: (opts: { metadata: Metadata | null, tool: ToolCall }) => { + if (typeof opts.tool.input.file_path === 'string') { + const path = resolvePath(opts.tool.input.file_path, opts.metadata); + return path; + } + return t('tools.names.editFile'); + }, + icon: ICON_EDIT, + isMutable: true, + input: z.object({ + file_path: z.string().describe('The absolute path to the file to modify'), + old_string: z.string().describe('The text to replace'), + new_string: z.string().describe('The text to replace it with'), + replace_all: z.boolean().optional().default(false).describe('Replace all occurrences') + }).partial().loose() + }, + 'MultiEdit': { + title: (opts: { metadata: Metadata | null, tool: ToolCall }) => { + if (typeof opts.tool.input.file_path === 'string') { + const path = resolvePath(opts.tool.input.file_path, opts.metadata); + const editCount = Array.isArray(opts.tool.input.edits) ? opts.tool.input.edits.length : 0; + if (editCount > 1) { + return t('tools.desc.multiEditEdits', { path, count: editCount }); + } + return path; + } + return t('tools.names.editFile'); + }, + icon: ICON_EDIT, + isMutable: true, + input: z.object({ + file_path: z.string().describe('The absolute path to the file to modify'), + edits: z.array(z.object({ + old_string: z.string().describe('The text to replace'), + new_string: z.string().describe('The text to replace it with'), + replace_all: z.boolean().optional().default(false).describe('Replace all occurrences') + })).describe('Array of edit operations') + }).partial().loose(), + extractStatus: (opts: { metadata: Metadata | null, tool: ToolCall }) => { + if (typeof opts.tool.input.file_path === 'string') { + const path = resolvePath(opts.tool.input.file_path, opts.metadata); + const editCount = Array.isArray(opts.tool.input.edits) ? opts.tool.input.edits.length : 0; + if (editCount > 0) { + return t('tools.desc.multiEditEdits', { path, count: editCount }); + } + return path; + } + return null; + } + }, + 'Write': { + title: (opts: { metadata: Metadata | null, tool: ToolCall }) => { + if (typeof opts.tool.input.file_path === 'string') { + const path = resolvePath(opts.tool.input.file_path, opts.metadata); + return path; + } + return t('tools.names.writeFile'); + }, + icon: ICON_EDIT, + isMutable: true, + input: z.object({ + file_path: z.string().describe('The absolute path to the file to write'), + content: z.string().describe('The content to write to the file') + }).partial().loose() + }, +} satisfies Record<string, KnownToolDefinition>; + diff --git a/expo-app/sources/components/tools/knownTools/core/notebook.tsx b/expo-app/sources/components/tools/knownTools/core/notebook.tsx new file mode 100644 index 000000000..37fb69186 --- /dev/null +++ b/expo-app/sources/components/tools/knownTools/core/notebook.tsx @@ -0,0 +1,52 @@ +import type { Metadata } from '@/sync/storageTypes'; +import type { ToolCall } from '@/sync/typesMessage'; +import { resolvePath } from '@/utils/pathUtils'; +import * as z from 'zod'; +import { t } from '@/text'; +import { ICON_READ, ICON_EDIT } from '../icons'; +import type { KnownToolDefinition } from '../_types'; + +export const coreNotebookTools = { + 'NotebookRead': { + title: (opts: { metadata: Metadata | null, tool: ToolCall }) => { + if (typeof opts.tool.input.notebook_path === 'string') { + const path = resolvePath(opts.tool.input.notebook_path, opts.metadata); + return path; + } + return t('tools.names.readNotebook'); + }, + icon: ICON_READ, + minimal: true, + input: z.object({ + notebook_path: z.string().describe('The absolute path to the Jupyter notebook file'), + cell_id: z.string().optional().describe('The ID of a specific cell to read') + }).partial().loose() + }, + 'NotebookEdit': { + title: (opts: { metadata: Metadata | null, tool: ToolCall }) => { + if (typeof opts.tool.input.notebook_path === 'string') { + const path = resolvePath(opts.tool.input.notebook_path, opts.metadata); + return path; + } + return t('tools.names.editNotebook'); + }, + icon: ICON_EDIT, + isMutable: true, + input: z.object({ + notebook_path: z.string().describe('The absolute path to the notebook file'), + new_source: z.string().describe('The new source for the cell'), + cell_id: z.string().optional().describe('The ID of the cell to edit'), + cell_type: z.enum(['code', 'markdown']).optional().describe('The type of the cell'), + edit_mode: z.enum(['replace', 'insert', 'delete']).optional().describe('The type of edit to make') + }).partial().loose(), + extractDescription: (opts: { metadata: Metadata | null, tool: ToolCall }) => { + if (typeof opts.tool.input.notebook_path === 'string') { + const path = resolvePath(opts.tool.input.notebook_path, opts.metadata); + const mode = opts.tool.input.edit_mode || 'replace'; + return t('tools.desc.editNotebookMode', { path, mode }); + } + return t('tools.names.editNotebook'); + } + }, +} satisfies Record<string, KnownToolDefinition>; + diff --git a/expo-app/sources/components/tools/knownTools/core/search.tsx b/expo-app/sources/components/tools/knownTools/core/search.tsx new file mode 100644 index 000000000..35e305063 --- /dev/null +++ b/expo-app/sources/components/tools/knownTools/core/search.tsx @@ -0,0 +1,116 @@ +import type { Metadata } from '@/sync/storageTypes'; +import type { ToolCall } from '@/sync/typesMessage'; +import { resolvePath } from '@/utils/pathUtils'; +import * as z from 'zod'; +import { t } from '@/text'; +import { ICON_SEARCH, ICON_READ } from '../icons'; +import type { KnownToolDefinition } from '../_types'; + +export const coreSearchTools = { + 'Glob': { + title: (opts: { metadata: Metadata | null, tool: ToolCall }) => { + if (typeof opts.tool.input.pattern === 'string') { + return opts.tool.input.pattern; + } + return t('tools.names.searchFiles'); + }, + icon: ICON_SEARCH, + minimal: true, + input: z.object({ + pattern: z.string().describe('The glob pattern to match files against'), + path: z.string().optional().describe('The directory to search in') + }).partial().loose(), + extractDescription: (opts: { metadata: Metadata | null, tool: ToolCall }) => { + if (typeof opts.tool.input.pattern === 'string') { + return t('tools.desc.searchPattern', { pattern: opts.tool.input.pattern }); + } + return t('tools.names.search'); + } + }, + 'Grep': { + title: (opts: { metadata: Metadata | null, tool: ToolCall }) => { + if (typeof opts.tool.input.pattern === 'string') { + return `grep(pattern: ${opts.tool.input.pattern})`; + } + return 'Search Content'; + }, + icon: ICON_READ, + minimal: true, + input: z.object({ + pattern: z.string().describe('The regular expression pattern to search for'), + path: z.string().optional().describe('File or directory to search in'), + output_mode: z.enum(['content', 'files_with_matches', 'count']).optional(), + '-n': z.boolean().optional().describe('Show line numbers'), + '-i': z.boolean().optional().describe('Case insensitive search'), + '-A': z.number().optional().describe('Lines to show after match'), + '-B': z.number().optional().describe('Lines to show before match'), + '-C': z.number().optional().describe('Lines to show before and after match'), + glob: z.string().optional().describe('Glob pattern to filter files'), + type: z.string().optional().describe('File type to search'), + head_limit: z.number().optional().describe('Limit output to first N lines/entries'), + multiline: z.boolean().optional().describe('Enable multiline mode') + }).partial().loose(), + extractDescription: (opts: { metadata: Metadata | null, tool: ToolCall }) => { + if (typeof opts.tool.input.pattern === 'string') { + const pattern = opts.tool.input.pattern.length > 20 + ? opts.tool.input.pattern.substring(0, 20) + '...' + : opts.tool.input.pattern; + return `Search(pattern: ${pattern})`; + } + return 'Search'; + } + }, + 'LS': { + title: (opts: { metadata: Metadata | null, tool: ToolCall }) => { + if (typeof opts.tool.input.path === 'string') { + return resolvePath(opts.tool.input.path, opts.metadata); + } + return t('tools.names.listFiles'); + }, + icon: ICON_SEARCH, + minimal: true, + input: z.object({ + path: z.string().describe('The absolute path to the directory to list'), + ignore: z.array(z.string()).optional().describe('List of glob patterns to ignore') + }).partial().loose(), + extractDescription: (opts: { metadata: Metadata | null, tool: ToolCall }) => { + if (typeof opts.tool.input.path === 'string') { + const path = resolvePath(opts.tool.input.path, opts.metadata); + const basename = path.split('/').pop() || path; + return t('tools.desc.searchPath', { basename }); + } + return t('tools.names.search'); + } + }, + 'CodeSearch': { + title: (opts: { metadata: Metadata | null, tool: ToolCall }) => { + const query = typeof opts.tool.input?.query === 'string' + ? opts.tool.input.query + : typeof opts.tool.input?.pattern === 'string' + ? opts.tool.input.pattern + : null; + if (query && query.trim()) return query.trim(); + return 'Code Search'; + }, + icon: ICON_SEARCH, + minimal: true, + input: z.object({ + query: z.string().optional().describe('The search query'), + pattern: z.string().optional().describe('The search pattern'), + path: z.string().optional().describe('Optional path scope'), + }).partial().loose(), + extractDescription: (opts: { metadata: Metadata | null, tool: ToolCall }) => { + const query = typeof opts.tool.input?.query === 'string' + ? opts.tool.input.query + : typeof opts.tool.input?.pattern === 'string' + ? opts.tool.input.pattern + : null; + if (query && query.trim()) { + const truncated = query.length > 30 ? query.substring(0, 30) + '...' : query; + return truncated; + } + return 'Search in code'; + } + }, +} satisfies Record<string, KnownToolDefinition>; + diff --git a/expo-app/sources/components/tools/knownTools/core/task.tsx b/expo-app/sources/components/tools/knownTools/core/task.tsx new file mode 100644 index 000000000..7acf2a3ec --- /dev/null +++ b/expo-app/sources/components/tools/knownTools/core/task.tsx @@ -0,0 +1,36 @@ +import type { Metadata } from '@/sync/storageTypes'; +import type { ToolCall, Message } from '@/sync/typesMessage'; +import * as z from 'zod'; +import { t } from '@/text'; +import { ICON_TASK } from '../icons'; +import type { KnownToolDefinition } from '../_types'; + +export const coreTaskTools = { + 'Task': { + title: (opts: { metadata: Metadata | null, tool: ToolCall }) => { + // Check for description field at runtime + if (opts.tool.input && opts.tool.input.description && typeof opts.tool.input.description === 'string') { + return opts.tool.input.description; + } + return t('tools.names.task'); + }, + icon: ICON_TASK, + isMutable: true, + minimal: (opts: { metadata: Metadata | null, tool: ToolCall, messages?: Message[] }) => { + // Check if there would be any filtered tasks + const messages = opts.messages || []; + for (let m of messages) { + if (m.kind === 'tool-call' && + (m.tool.state === 'running' || m.tool.state === 'completed' || m.tool.state === 'error')) { + return false; // Has active sub-tasks, show expanded + } + } + return true; // No active sub-tasks, render as minimal + }, + input: z.object({ + prompt: z.string().describe('The task for the agent to perform'), + subagent_type: z.string().optional().describe('The type of specialized agent to use') + }).partial().loose() + }, +} satisfies Record<string, KnownToolDefinition>; + diff --git a/expo-app/sources/components/tools/knownTools/core/terminal.tsx b/expo-app/sources/components/tools/knownTools/core/terminal.tsx new file mode 100644 index 000000000..f29c8845d --- /dev/null +++ b/expo-app/sources/components/tools/knownTools/core/terminal.tsx @@ -0,0 +1,64 @@ +import type { Metadata } from '@/sync/storageTypes'; +import type { ToolCall } from '@/sync/typesMessage'; +import * as z from 'zod'; +import { t } from '@/text'; +import { ICON_TERMINAL, ICON_EXIT } from '../icons'; +import type { KnownToolDefinition } from '../_types'; +import { extractShellCommand } from '../../utils/shellCommand'; + +export const coreTerminalTools = { + 'Bash': { + title: (opts: { metadata: Metadata | null, tool: ToolCall }) => { + if (opts.tool.description) { + return opts.tool.description; + } + return t('tools.names.terminal'); + }, + icon: ICON_TERMINAL, + minimal: true, + hideDefaultError: true, + isMutable: true, + input: z.object({ + command: z.string().describe('The command to execute'), + timeout: z.number().optional().describe('Timeout in milliseconds (max 600000)') + }), + result: z.object({ + stderr: z.string(), + stdout: z.string(), + }).partial().loose(), + extractDescription: (opts: { metadata: Metadata | null, tool: ToolCall }) => { + const cmd = extractShellCommand(opts.tool.input); + if (typeof cmd === 'string' && cmd.length > 0) { + // Extract just the command name for common commands + const firstWord = cmd.split(' ')[0]; + if (['cd', 'ls', 'pwd', 'mkdir', 'rm', 'cp', 'mv', 'npm', 'yarn', 'git'].includes(firstWord)) { + return t('tools.desc.terminalCmd', { cmd: firstWord }); + } + // For other commands, show truncated version + const truncated = cmd.length > 20 ? cmd.substring(0, 20) + '...' : cmd; + return t('tools.desc.terminalCmd', { cmd: truncated }); + } + return t('tools.names.terminal'); + }, + extractSubtitle: (opts: { metadata: Metadata | null, tool: ToolCall }) => { + const cmd = extractShellCommand(opts.tool.input); + if (typeof cmd === 'string' && cmd.length > 0) return cmd; + return null; + } + }, + 'ExitPlanMode': { + title: t('tools.names.planProposal'), + icon: ICON_EXIT, + input: z.object({ + plan: z.string().describe('The plan you came up with') + }).partial().loose() + }, + 'exit_plan_mode': { + title: t('tools.names.planProposal'), + icon: ICON_EXIT, + input: z.object({ + plan: z.string().describe('The plan you came up with') + }).partial().loose() + }, +} satisfies Record<string, KnownToolDefinition>; + diff --git a/expo-app/sources/components/tools/knownTools/core/todo.tsx b/expo-app/sources/components/tools/knownTools/core/todo.tsx new file mode 100644 index 000000000..f2138e111 --- /dev/null +++ b/expo-app/sources/components/tools/knownTools/core/todo.tsx @@ -0,0 +1,78 @@ +import type { Metadata } from '@/sync/storageTypes'; +import type { ToolCall, Message } from '@/sync/typesMessage'; +import * as z from 'zod'; +import { t } from '@/text'; +import { ICON_TODO } from '../icons'; +import type { KnownToolDefinition } from '../_types'; + +export const coreTodoTools = { + 'TodoWrite': { + title: t('tools.names.todoList'), + icon: ICON_TODO, + noStatus: true, + minimal: (opts: { metadata: Metadata | null, tool: ToolCall, messages?: Message[] }) => { + // Check if there are todos in the input + if (opts.tool.input?.todos && Array.isArray(opts.tool.input.todos) && opts.tool.input.todos.length > 0) { + return false; // Has todos, show expanded + } + + // Check if there are todos in the result + if (opts.tool.result?.newTodos && Array.isArray(opts.tool.result.newTodos) && opts.tool.result.newTodos.length > 0) { + return false; // Has todos, show expanded + } + + return true; // No todos, render as minimal + }, + input: z.object({ + todos: z.array(z.object({ + content: z.string().describe('The todo item content'), + status: z.enum(['pending', 'in_progress', 'completed']).describe('The status of the todo'), + priority: z.enum(['high', 'medium', 'low']).optional().describe('The priority of the todo'), + id: z.string().optional().describe('Unique identifier for the todo') + }).loose()).describe('The updated todo list') + }).partial().loose(), + result: z.object({ + oldTodos: z.array(z.object({ + content: z.string().describe('The todo item content'), + status: z.enum(['pending', 'in_progress', 'completed']).describe('The status of the todo'), + priority: z.enum(['high', 'medium', 'low']).optional().describe('The priority of the todo'), + id: z.string().describe('Unique identifier for the todo') + }).loose()).describe('The old todo list'), + newTodos: z.array(z.object({ + content: z.string().describe('The todo item content'), + status: z.enum(['pending', 'in_progress', 'completed']).describe('The status of the todo'), + priority: z.enum(['high', 'medium', 'low']).optional().describe('The priority of the todo'), + id: z.string().describe('Unique identifier for the todo') + }).loose()).describe('The new todo list') + }).partial().loose(), + extractDescription: (opts: { metadata: Metadata | null, tool: ToolCall }) => { + if (Array.isArray(opts.tool.input.todos)) { + const count = opts.tool.input.todos.length; + return t('tools.desc.todoListCount', { count }); + } + return t('tools.names.todoList'); + }, + }, + 'TodoRead': { + title: t('tools.names.todoList'), + icon: ICON_TODO, + noStatus: true, + minimal: true, + result: z.object({ + todos: z.array(z.object({ + content: z.string().describe('The todo item content'), + status: z.enum(['pending', 'in_progress', 'completed']).describe('The status of the todo'), + priority: z.enum(['high', 'medium', 'low']).optional().describe('The priority of the todo'), + id: z.string().optional().describe('Unique identifier for the todo') + }).loose()).describe('The current todo list') + }).partial().loose(), + extractDescription: (opts: { metadata: Metadata | null, tool: ToolCall }) => { + const list = Array.isArray(opts.tool.result?.todos) ? opts.tool.result.todos : null; + if (list) { + return t('tools.desc.todoListCount', { count: list.length }); + } + return t('tools.names.todoList'); + }, + }, +} satisfies Record<string, KnownToolDefinition>; + diff --git a/expo-app/sources/components/tools/knownTools/core/web.tsx b/expo-app/sources/components/tools/knownTools/core/web.tsx new file mode 100644 index 000000000..164137f19 --- /dev/null +++ b/expo-app/sources/components/tools/knownTools/core/web.tsx @@ -0,0 +1,64 @@ +import type { Metadata } from '@/sync/storageTypes'; +import type { ToolCall } from '@/sync/typesMessage'; +import * as z from 'zod'; +import { t } from '@/text'; +import { ICON_WEB } from '../icons'; +import type { KnownToolDefinition } from '../_types'; + +export const coreWebTools = { + 'WebFetch': { + title: (opts: { metadata: Metadata | null, tool: ToolCall }) => { + if (typeof opts.tool.input.url === 'string') { + try { + const url = new URL(opts.tool.input.url); + return url.hostname; + } catch { + return t('tools.names.fetchUrl'); + } + } + return t('tools.names.fetchUrl'); + }, + icon: ICON_WEB, + minimal: true, + input: z.object({ + url: z.string().url().describe('The URL to fetch content from'), + prompt: z.string().describe('The prompt to run on the fetched content') + }).partial().loose(), + extractDescription: (opts: { metadata: Metadata | null, tool: ToolCall }) => { + if (typeof opts.tool.input.url === 'string') { + try { + const url = new URL(opts.tool.input.url); + return t('tools.desc.fetchUrlHost', { host: url.hostname }); + } catch { + return t('tools.names.fetchUrl'); + } + } + return 'Fetch URL'; + } + }, + 'WebSearch': { + title: (opts: { metadata: Metadata | null, tool: ToolCall }) => { + if (typeof opts.tool.input.query === 'string') { + return opts.tool.input.query; + } + return t('tools.names.webSearch'); + }, + icon: ICON_WEB, + minimal: true, + input: z.object({ + query: z.string().min(2).describe('The search query to use'), + allowed_domains: z.array(z.string()).optional().describe('Only include results from these domains'), + blocked_domains: z.array(z.string()).optional().describe('Never include results from these domains') + }).partial().loose(), + extractDescription: (opts: { metadata: Metadata | null, tool: ToolCall }) => { + if (typeof opts.tool.input.query === 'string') { + const query = opts.tool.input.query.length > 30 + ? opts.tool.input.query.substring(0, 30) + '...' + : opts.tool.input.query; + return t('tools.desc.webSearchQuery', { query }); + } + return t('tools.names.webSearch'); + } + }, +} satisfies Record<string, KnownToolDefinition>; + diff --git a/expo-app/sources/components/tools/knownTools/coreTools.tsx b/expo-app/sources/components/tools/knownTools/coreTools.tsx new file mode 100644 index 000000000..e55c92bde --- /dev/null +++ b/expo-app/sources/components/tools/knownTools/coreTools.tsx @@ -0,0 +1,18 @@ +import type { KnownToolDefinition } from './_types'; +import { coreTaskTools } from './core/task'; +import { coreTerminalTools } from './core/terminal'; +import { coreSearchTools } from './core/search'; +import { coreFileTools } from './core/files'; +import { coreWebTools } from './core/web'; +import { coreNotebookTools } from './core/notebook'; +import { coreTodoTools } from './core/todo'; + +export const knownToolsCore = { + ...coreTaskTools, + ...coreTerminalTools, + ...coreSearchTools, + ...coreFileTools, + ...coreWebTools, + ...coreNotebookTools, + ...coreTodoTools, +} satisfies Record<string, KnownToolDefinition>; diff --git a/expo-app/sources/components/tools/knownTools/icons.tsx b/expo-app/sources/components/tools/knownTools/icons.tsx new file mode 100644 index 000000000..cb4e286e5 --- /dev/null +++ b/expo-app/sources/components/tools/knownTools/icons.tsx @@ -0,0 +1,14 @@ +import { Ionicons, Octicons } from '@expo/vector-icons'; +import React from 'react'; + +export const ICON_TASK = (size: number = 24, color: string = '#000') => <Octicons name="rocket" size={size} color={color} />; +export const ICON_TERMINAL = (size: number = 24, color: string = '#000') => <Octicons name="terminal" size={size} color={color} />; +export const ICON_SEARCH = (size: number = 24, color: string = '#000') => <Octicons name="search" size={size} color={color} />; +export const ICON_READ = (size: number = 24, color: string = '#000') => <Octicons name="eye" size={size} color={color} />; +export const ICON_EDIT = (size: number = 24, color: string = '#000') => <Octicons name="file-diff" size={size} color={color} />; +export const ICON_WEB = (size: number = 24, color: string = '#000') => <Ionicons name="globe-outline" size={size} color={color} />; +export const ICON_EXIT = (size: number = 24, color: string = '#000') => <Ionicons name="exit-outline" size={size} color={color} />; +export const ICON_TODO = (size: number = 24, color: string = '#000') => <Ionicons name="bulb-outline" size={size} color={color} />; +export const ICON_REASONING = (size: number = 24, color: string = '#000') => <Octicons name="light-bulb" size={size} color={color} />; +export const ICON_QUESTION = (size: number = 24, color: string = '#000') => <Ionicons name="help-circle-outline" size={size} color={color} />; + diff --git a/expo-app/sources/components/tools/knownTools/index.tsx b/expo-app/sources/components/tools/knownTools/index.tsx new file mode 100644 index 000000000..d567f92a3 --- /dev/null +++ b/expo-app/sources/components/tools/knownTools/index.tsx @@ -0,0 +1,26 @@ +import type { KnownToolDefinition } from './_types'; +import { knownToolsCore } from './coreTools'; +import { knownToolsProviders } from './providerTools'; + +export const knownTools = { + ...knownToolsCore, + ...knownToolsProviders, +} satisfies Record<string, KnownToolDefinition>; + +/** + * Check if a tool is mutable (can potentially modify files) + * @param toolName The name of the tool to check + * @returns true if the tool is mutable or unknown, false if it's read-only + */ +export function isMutableTool(toolName: string): boolean { + const tool = knownTools[toolName as keyof typeof knownTools]; + if (tool) { + if ('isMutable' in tool) { + return tool.isMutable === true; + } else { + return false; + } + } + // If tool is unknown, assume it's mutable to be safe + return true; +} diff --git a/expo-app/sources/components/tools/knownTools/providerTools.tsx b/expo-app/sources/components/tools/knownTools/providerTools.tsx new file mode 100644 index 000000000..0fac03eb0 --- /dev/null +++ b/expo-app/sources/components/tools/knownTools/providerTools.tsx @@ -0,0 +1,18 @@ +import type { KnownToolDefinition } from './_types'; +import { providerShellTools } from './providers/shell'; +import { providerReasoningTools } from './providers/reasoning'; +import { providerUiTools } from './providers/ui'; +import { providerSearchTools } from './providers/search'; +import { providerPatchTools } from './providers/patch'; +import { providerDiffTools } from './providers/diff'; +import { providerAskUserQuestionTools } from './providers/askUserQuestion'; + +export const knownToolsProviders = { + ...providerShellTools, + ...providerReasoningTools, + ...providerUiTools, + ...providerSearchTools, + ...providerPatchTools, + ...providerDiffTools, + ...providerAskUserQuestionTools, +} satisfies Record<string, KnownToolDefinition>; diff --git a/expo-app/sources/components/tools/knownTools/providers/askUserQuestion.tsx b/expo-app/sources/components/tools/knownTools/providers/askUserQuestion.tsx new file mode 100644 index 000000000..032338d13 --- /dev/null +++ b/expo-app/sources/components/tools/knownTools/providers/askUserQuestion.tsx @@ -0,0 +1,46 @@ +import type { Metadata } from '@/sync/storageTypes'; +import type { ToolCall } from '@/sync/typesMessage'; +import * as z from 'zod'; +import { t } from '@/text'; +import { ICON_QUESTION } from '../icons'; +import type { KnownToolDefinition } from '../_types'; + +export const providerAskUserQuestionTools = { + 'AskUserQuestion': { + title: (opts: { metadata: Metadata | null, tool: ToolCall }) => { + // Use first question header as title if available + if (opts.tool.input?.questions && Array.isArray(opts.tool.input.questions) && opts.tool.input.questions.length > 0) { + const firstQuestion = opts.tool.input.questions[0]; + if (firstQuestion.header) { + return firstQuestion.header; + } + } + return t('tools.names.question'); + }, + icon: ICON_QUESTION, + minimal: false, // Always show expanded to display options + noStatus: true, + input: z.object({ + questions: z.array(z.object({ + question: z.string().describe('The question to ask'), + header: z.string().describe('Short label for the question'), + options: z.array(z.object({ + label: z.string().describe('Option label'), + description: z.string().describe('Option description') + })).describe('Available choices'), + multiSelect: z.boolean().describe('Allow multiple selections') + })).describe('Questions to ask the user') + }).partial().loose(), + extractSubtitle: (opts: { metadata: Metadata | null, tool: ToolCall }) => { + if (opts.tool.input?.questions && Array.isArray(opts.tool.input.questions)) { + const count = opts.tool.input.questions.length; + if (count === 1) { + return opts.tool.input.questions[0].question; + } + return t('tools.askUserQuestion.multipleQuestions', { count }); + } + return null; + } + } +} satisfies Record<string, KnownToolDefinition>; + diff --git a/expo-app/sources/components/tools/knownTools/providers/diff.tsx b/expo-app/sources/components/tools/knownTools/providers/diff.tsx new file mode 100644 index 000000000..2ab14e172 --- /dev/null +++ b/expo-app/sources/components/tools/knownTools/providers/diff.tsx @@ -0,0 +1,77 @@ +import type { Metadata } from '@/sync/storageTypes'; +import type { ToolCall } from '@/sync/typesMessage'; +import * as z from 'zod'; +import { t } from '@/text'; +import { ICON_EDIT } from '../icons'; +import type { KnownToolDefinition } from '../_types'; + +export const providerDiffTools = { + 'CodexDiff': { + title: t('tools.names.viewDiff'), + icon: ICON_EDIT, + minimal: false, // Show full diff view + hideDefaultError: true, + noStatus: true, // Always successful, stateless like Task + input: z.object({ + unified_diff: z.string().describe('Unified diff content') + }).partial().loose(), + result: z.object({ + status: z.literal('completed').describe('Always completed') + }).partial().loose(), + extractSubtitle: (opts: { metadata: Metadata | null, tool: ToolCall }) => { + // Try to extract filename from unified diff + if (opts.tool.input?.unified_diff && typeof opts.tool.input.unified_diff === 'string') { + const diffLines = opts.tool.input.unified_diff.split('\n'); + for (const line of diffLines) { + if (line.startsWith('+++ b/') || line.startsWith('+++ ')) { + const fileName = line.replace(/^\+\+\+ (b\/)?/, ''); + const basename = fileName.split('/').pop() || fileName; + return basename; + } + } + } + return null; + }, + extractDescription: (opts: { metadata: Metadata | null, tool: ToolCall }) => { + return t('tools.desc.showingDiff'); + } + }, + 'GeminiDiff': { + title: t('tools.names.viewDiff'), + icon: ICON_EDIT, + minimal: false, // Show full diff view + hideDefaultError: true, + noStatus: true, // Always successful, stateless like Task + input: z.object({ + unified_diff: z.string().optional().describe('Unified diff content'), + filePath: z.string().optional().describe('File path'), + description: z.string().optional().describe('Edit description') + }).partial().loose(), + result: z.object({ + status: z.literal('completed').describe('Always completed') + }).partial().loose(), + extractSubtitle: (opts: { metadata: Metadata | null, tool: ToolCall }) => { + // Try to extract filename from filePath first + if (opts.tool.input?.filePath && typeof opts.tool.input.filePath === 'string') { + const basename = opts.tool.input.filePath.split('/').pop() || opts.tool.input.filePath; + return basename; + } + // Fall back to extracting from unified diff + if (opts.tool.input?.unified_diff && typeof opts.tool.input.unified_diff === 'string') { + const diffLines = opts.tool.input.unified_diff.split('\n'); + for (const line of diffLines) { + if (line.startsWith('+++ b/') || line.startsWith('+++ ')) { + const fileName = line.replace(/^\+\+\+ (b\/)?/, ''); + const basename = fileName.split('/').pop() || fileName; + return basename; + } + } + } + return null; + }, + extractDescription: (opts: { metadata: Metadata | null, tool: ToolCall }) => { + return t('tools.desc.showingDiff'); + } + }, +} satisfies Record<string, KnownToolDefinition>; + diff --git a/expo-app/sources/components/tools/knownTools/providers/patch.tsx b/expo-app/sources/components/tools/knownTools/providers/patch.tsx new file mode 100644 index 000000000..22ecc75d5 --- /dev/null +++ b/expo-app/sources/components/tools/knownTools/providers/patch.tsx @@ -0,0 +1,156 @@ +import type { Metadata } from '@/sync/storageTypes'; +import type { ToolCall } from '@/sync/typesMessage'; +import { resolvePath } from '@/utils/pathUtils'; +import * as z from 'zod'; +import { t } from '@/text'; +import { ICON_EDIT } from '../icons'; +import type { KnownToolDefinition } from '../_types'; + +export const providerPatchTools = { + 'edit': { + title: (opts: { metadata: Metadata | null, tool: ToolCall }) => { + // Gemini sends data in nested structure, try multiple locations + let filePath: string | undefined; + + // 1. Check toolCall.content[0].path + if (typeof opts.tool.input?.toolCall?.content?.[0]?.path === 'string') { + filePath = opts.tool.input.toolCall.content[0].path; + } + // 2. Check toolCall.title (has nice "Writing to ..." format) + else if (typeof opts.tool.input?.toolCall?.title === 'string') { + return opts.tool.input.toolCall.title; + } + // 3. Check input[0].path (array format) + else if (Array.isArray(opts.tool.input?.input) && typeof opts.tool.input.input[0]?.path === 'string') { + filePath = opts.tool.input.input[0].path; + } + // 4. Check direct path field + else if (typeof opts.tool.input?.path === 'string') { + filePath = opts.tool.input.path; + } + + if (typeof filePath === 'string' && filePath.length > 0) { + return resolvePath(filePath, opts.metadata); + } + return t('tools.names.editFile'); + }, + icon: ICON_EDIT, + isMutable: true, + input: z.object({ + path: z.string().describe('The file path to edit'), + oldText: z.string().describe('The text to replace'), + newText: z.string().describe('The new text'), + type: z.string().optional().describe('Type of edit (diff)') + }).partial().loose() + }, + 'CodexPatch': { + title: t('tools.names.applyChanges'), + icon: ICON_EDIT, + minimal: true, + hideDefaultError: true, + input: z.object({ + auto_approved: z.boolean().optional().describe('Whether changes were auto-approved'), + changes: z.record(z.string(), z.object({ + add: z.object({ + content: z.string() + }).optional(), + modify: z.object({ + old_content: z.string(), + new_content: z.string() + }).optional(), + delete: z.object({ + content: z.string() + }).optional() + }).loose()).describe('File changes to apply') + }).partial().loose(), + extractSubtitle: (opts: { metadata: Metadata | null, tool: ToolCall }) => { + // Show the first file being modified + if (opts.tool.input?.changes && typeof opts.tool.input.changes === 'object') { + const files = Object.keys(opts.tool.input.changes); + if (files.length > 0) { + const path = resolvePath(files[0], opts.metadata); + const fileName = path.split('/').pop() || path; + if (files.length > 1) { + return t('tools.desc.modifyingMultipleFiles', { + file: fileName, + count: files.length - 1 + }); + } + return fileName; + } + } + return null; + }, + extractDescription: (opts: { metadata: Metadata | null, tool: ToolCall }) => { + // Show the number of files being modified + if (opts.tool.input?.changes && typeof opts.tool.input.changes === 'object') { + const files = Object.keys(opts.tool.input.changes); + const fileCount = files.length; + if (fileCount === 1) { + const path = resolvePath(files[0], opts.metadata); + const fileName = path.split('/').pop() || path; + return t('tools.desc.modifyingFile', { file: fileName }); + } else if (fileCount > 1) { + return t('tools.desc.modifyingFiles', { count: fileCount }); + } + } + return t('tools.names.applyChanges'); + } + }, + 'GeminiPatch': { + title: t('tools.names.applyChanges'), + icon: ICON_EDIT, + minimal: true, + hideDefaultError: true, + isMutable: true, + input: z.object({ + auto_approved: z.boolean().optional().describe('Whether changes were auto-approved'), + changes: z.record(z.string(), z.object({ + add: z.object({ + content: z.string() + }).optional(), + modify: z.object({ + old_content: z.string(), + new_content: z.string() + }).optional(), + delete: z.object({ + content: z.string() + }).optional() + }).loose()).describe('File changes to apply') + }).partial().loose(), + extractSubtitle: (opts: { metadata: Metadata | null, tool: ToolCall }) => { + // Show the first file being modified + if (opts.tool.input?.changes && typeof opts.tool.input.changes === 'object') { + const files = Object.keys(opts.tool.input.changes); + if (files.length > 0) { + const path = resolvePath(files[0], opts.metadata); + const fileName = path.split('/').pop() || path; + if (files.length > 1) { + return t('tools.desc.modifyingMultipleFiles', { + file: fileName, + count: files.length - 1 + }); + } + return fileName; + } + } + return null; + }, + extractDescription: (opts: { metadata: Metadata | null, tool: ToolCall }) => { + // Show the number of files being modified + if (opts.tool.input?.changes && typeof opts.tool.input.changes === 'object') { + const files = Object.keys(opts.tool.input.changes); + const fileCount = files.length; + if (fileCount === 1) { + const path = resolvePath(files[0], opts.metadata); + const fileName = path.split('/').pop() || path; + return t('tools.desc.modifyingFile', { file: fileName }); + } else if (fileCount > 1) { + return t('tools.desc.modifyingFiles', { count: fileCount }); + } + } + return t('tools.names.applyChanges'); + } + }, +} satisfies Record<string, KnownToolDefinition>; + diff --git a/expo-app/sources/components/tools/knownTools/providers/reasoning.tsx b/expo-app/sources/components/tools/knownTools/providers/reasoning.tsx new file mode 100644 index 000000000..a5b30c03b --- /dev/null +++ b/expo-app/sources/components/tools/knownTools/providers/reasoning.tsx @@ -0,0 +1,85 @@ +import type { Metadata } from '@/sync/storageTypes'; +import type { ToolCall } from '@/sync/typesMessage'; +import * as z from 'zod'; +import { t } from '@/text'; +import { ICON_REASONING } from '../icons'; +import type { KnownToolDefinition } from '../_types'; + +export const providerReasoningTools = { + 'CodexReasoning': { + title: (opts: { metadata: Metadata | null, tool: ToolCall }) => { + // Use the title from input if provided + if (opts.tool.input?.title && typeof opts.tool.input.title === 'string') { + return opts.tool.input.title; + } + return t('tools.names.reasoning'); + }, + icon: ICON_REASONING, + minimal: true, + input: z.object({ + title: z.string().describe('The title of the reasoning') + }).partial().loose(), + result: z.object({ + content: z.string().describe('The reasoning content'), + status: z.enum(['completed', 'in_progress', 'error']).optional().describe('The status of the reasoning') + }).partial().loose(), + extractDescription: (opts: { metadata: Metadata | null, tool: ToolCall }) => { + if (opts.tool.input?.title && typeof opts.tool.input.title === 'string') { + return opts.tool.input.title; + } + return t('tools.names.reasoning'); + } + }, + 'GeminiReasoning': { + title: (opts: { metadata: Metadata | null, tool: ToolCall }) => { + // Use the title from input if provided + if (opts.tool.input?.title && typeof opts.tool.input.title === 'string') { + return opts.tool.input.title; + } + return t('tools.names.reasoning'); + }, + icon: ICON_REASONING, + minimal: true, + input: z.object({ + title: z.string().describe('The title of the reasoning') + }).partial().loose(), + result: z.object({ + content: z.string().describe('The reasoning content'), + status: z.enum(['completed', 'in_progress', 'canceled']).optional().describe('The status of the reasoning') + }).partial().loose(), + extractDescription: (opts: { metadata: Metadata | null, tool: ToolCall }) => { + if (opts.tool.input?.title && typeof opts.tool.input.title === 'string') { + return opts.tool.input.title; + } + return t('tools.names.reasoning'); + } + }, + 'think': { + title: (opts: { metadata: Metadata | null, tool: ToolCall }) => { + // Use the title from input if provided + if (opts.tool.input?.title && typeof opts.tool.input.title === 'string') { + return opts.tool.input.title; + } + return t('tools.names.reasoning'); + }, + icon: ICON_REASONING, + minimal: true, + input: z.object({ + title: z.string().optional().describe('The title of the thinking'), + items: z.array(z.any()).optional().describe('Items to think about'), + locations: z.array(z.any()).optional().describe('Locations to consider') + }).partial().loose(), + result: z.object({ + content: z.string().optional().describe('The reasoning content'), + text: z.string().optional().describe('The reasoning text'), + status: z.enum(['completed', 'in_progress', 'canceled']).optional().describe('The status') + }).partial().loose(), + extractDescription: (opts: { metadata: Metadata | null, tool: ToolCall }) => { + if (opts.tool.input?.title && typeof opts.tool.input.title === 'string') { + return opts.tool.input.title; + } + return t('tools.names.reasoning'); + } + }, +} satisfies Record<string, KnownToolDefinition>; + diff --git a/expo-app/sources/components/tools/knownTools/providers/search.tsx b/expo-app/sources/components/tools/knownTools/providers/search.tsx new file mode 100644 index 000000000..5aa3bf2c3 --- /dev/null +++ b/expo-app/sources/components/tools/knownTools/providers/search.tsx @@ -0,0 +1,18 @@ +import * as z from 'zod'; +import { t } from '@/text'; +import { ICON_SEARCH } from '../icons'; +import type { KnownToolDefinition } from '../_types'; + +export const providerSearchTools = { + // Gemini internal tools - should be hidden (minimal) + 'search': { + title: t('tools.names.search'), + icon: ICON_SEARCH, + minimal: true, + input: z.object({ + items: z.array(z.any()).optional(), + locations: z.array(z.any()).optional() + }).partial().loose() + }, +} satisfies Record<string, KnownToolDefinition>; + diff --git a/expo-app/sources/components/tools/knownTools/providers/shell.tsx b/expo-app/sources/components/tools/knownTools/providers/shell.tsx new file mode 100644 index 000000000..52a9dfaaf --- /dev/null +++ b/expo-app/sources/components/tools/knownTools/providers/shell.tsx @@ -0,0 +1,149 @@ +import type { Metadata } from '@/sync/storageTypes'; +import type { ToolCall } from '@/sync/typesMessage'; +import { resolvePath } from '@/utils/pathUtils'; +import * as z from 'zod'; +import { t } from '@/text'; +import { ICON_TERMINAL } from '../icons'; +import type { KnownToolDefinition } from '../_types'; +import { extractShellCommand } from '../../utils/shellCommand'; + +export const providerShellTools = { + 'CodexBash': { + title: (opts: { metadata: Metadata | null, tool: ToolCall }) => { + // Check if this is a single read command + if (opts.tool.input?.parsed_cmd && + Array.isArray(opts.tool.input.parsed_cmd) && + opts.tool.input.parsed_cmd.length === 1 && + opts.tool.input.parsed_cmd[0].type === 'read' && + opts.tool.input.parsed_cmd[0].name) { + // Display the file name being read + const path = resolvePath(opts.tool.input.parsed_cmd[0].name, opts.metadata); + return path; + } + return t('tools.names.terminal'); + }, + icon: ICON_TERMINAL, + minimal: true, + hideDefaultError: true, + isMutable: true, + input: z.object({ + command: z.array(z.string()).describe('The command array to execute'), + cwd: z.string().optional().describe('Current working directory'), + parsed_cmd: z.array(z.object({ + type: z.string().describe('Type of parsed command (read, write, bash, etc.)'), + cmd: z.string().optional().describe('The command string'), + name: z.string().optional().describe('File name or resource name') + }).loose()).optional().describe('Parsed command information') + }).partial().loose(), + extractSubtitle: (opts: { metadata: Metadata | null, tool: ToolCall }) => { + // For single read commands, show the actual command + if (opts.tool.input?.parsed_cmd && + Array.isArray(opts.tool.input.parsed_cmd) && + opts.tool.input.parsed_cmd.length === 1 && + opts.tool.input.parsed_cmd[0].type === 'read') { + const parsedCmd = opts.tool.input.parsed_cmd[0]; + if (parsedCmd.cmd) { + // Show the command but truncate if too long + const cmd = parsedCmd.cmd; + return cmd.length > 50 ? cmd.substring(0, 50) + '...' : cmd; + } + } + // Show the actual command being executed for other cases + if (opts.tool.input?.parsed_cmd && Array.isArray(opts.tool.input.parsed_cmd) && opts.tool.input.parsed_cmd.length > 0) { + const parsedCmd = opts.tool.input.parsed_cmd[0]; + if (parsedCmd.cmd) { + return parsedCmd.cmd; + } + } + if (opts.tool.input?.command && Array.isArray(opts.tool.input.command)) { + let cmdArray = opts.tool.input.command; + // Remove shell wrapper prefix if present (bash/zsh with -lc flag) + if (cmdArray.length >= 3 && (cmdArray[0] === 'bash' || cmdArray[0] === '/bin/bash' || cmdArray[0] === 'zsh' || cmdArray[0] === '/bin/zsh') && cmdArray[1] === '-lc') { + // The actual command is in the third element + return cmdArray[2]; + } + return cmdArray.join(' '); + } + return null; + }, + extractDescription: (opts: { metadata: Metadata | null, tool: ToolCall }) => { + // Provide a description based on the parsed command type + if (opts.tool.input?.parsed_cmd && + Array.isArray(opts.tool.input.parsed_cmd) && + opts.tool.input.parsed_cmd.length === 1) { + const parsedCmd = opts.tool.input.parsed_cmd[0]; + if (parsedCmd.type === 'read' && parsedCmd.name) { + // For single read commands, show "Reading" as simple description + // The file path is already in the title + const path = resolvePath(parsedCmd.name, opts.metadata); + const basename = path.split('/').pop() || path; + return t('tools.desc.readingFile', { file: basename }); + } else if (parsedCmd.type === 'write' && parsedCmd.name) { + const path = resolvePath(parsedCmd.name, opts.metadata); + const basename = path.split('/').pop() || path; + return t('tools.desc.writingFile', { file: basename }); + } + } + return t('tools.names.terminal'); + } + }, + 'GeminiBash': { + title: t('tools.names.terminal'), + icon: ICON_TERMINAL, + minimal: true, + hideDefaultError: true, + isMutable: true, + input: z.object({ + command: z.array(z.string()).describe('The command array to execute'), + cwd: z.string().optional().describe('Current working directory') + }).partial().loose(), + extractSubtitle: (opts: { metadata: Metadata | null, tool: ToolCall }) => { + if (opts.tool.input?.command && Array.isArray(opts.tool.input.command)) { + let cmdArray = opts.tool.input.command; + // Remove shell wrapper prefix if present (bash/zsh with -lc flag) + if (cmdArray.length >= 3 && (cmdArray[0] === 'bash' || cmdArray[0] === '/bin/bash' || cmdArray[0] === 'zsh' || cmdArray[0] === '/bin/zsh') && cmdArray[1] === '-lc') { + return cmdArray[2]; + } + return cmdArray.join(' '); + } + return null; + } + }, + 'shell': { + title: t('tools.names.terminal'), + icon: ICON_TERMINAL, + minimal: true, + isMutable: true, + input: z.object({}).partial().loose() + }, + 'execute': { + title: (opts: { metadata: Metadata | null, tool: ToolCall }) => { + // Prefer a human-readable title when provided by ACP metadata + const acpTitle = + typeof opts.tool.input?._acp?.title === 'string' + ? opts.tool.input._acp.title + : typeof opts.tool.input?.toolCall?.title === 'string' + ? opts.tool.input.toolCall.title + : null; + if (acpTitle) { + // Title is often like "rm file.txt [cwd /path] (description)". + // Extract just the command part before [ + const bracketIdx = acpTitle.indexOf(' ['); + if (bracketIdx > 0) return acpTitle.substring(0, bracketIdx); + return acpTitle; + } + const cmd = extractShellCommand(opts.tool.input); + if (cmd) return cmd; + return t('tools.names.terminal'); + }, + icon: ICON_TERMINAL, + isMutable: true, + input: z.object({}).partial().loose(), + extractSubtitle: (opts: { metadata: Metadata | null, tool: ToolCall }) => { + const cmd = extractShellCommand(opts.tool.input); + if (cmd) return cmd; + return null; + } + }, +} satisfies Record<string, KnownToolDefinition>; + diff --git a/expo-app/sources/components/tools/knownTools/providers/ui.tsx b/expo-app/sources/components/tools/knownTools/providers/ui.tsx new file mode 100644 index 000000000..15a5b09e3 --- /dev/null +++ b/expo-app/sources/components/tools/knownTools/providers/ui.tsx @@ -0,0 +1,18 @@ +import * as z from 'zod'; +import { t } from '@/text'; +import { ICON_EDIT } from '../icons'; +import type { KnownToolDefinition } from '../_types'; + +export const providerUiTools = { + 'change_title': { + title: t('tools.names.changeTitle'), + icon: ICON_EDIT, + minimal: true, + noStatus: true, + input: z.object({ + title: z.string().optional().describe('New session title') + }).partial().loose(), + result: z.object({}).partial().loose() + }, +} satisfies Record<string, KnownToolDefinition>; + diff --git a/expo-app/sources/components/tools/utils/normalizeToolCallForRendering.test.ts b/expo-app/sources/components/tools/utils/normalizeToolCallForRendering.test.ts new file mode 100644 index 000000000..2d3268cfc --- /dev/null +++ b/expo-app/sources/components/tools/utils/normalizeToolCallForRendering.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from 'vitest'; +import { normalizeToolCallForRendering } from './normalizeToolCallForRendering'; + +describe('normalizeToolCallForRendering', () => { + it('parses JSON-string inputs/results into objects', () => { + const tool = { + name: 'unknown', + state: 'running' as const, + input: '{"a":1}', + result: '[1,2,3]', + createdAt: 0, + startedAt: 0, + completedAt: null, + description: null, + }; + + const normalized = normalizeToolCallForRendering(tool as any); + expect(normalized).not.toBe(tool); + expect(normalized.input).toEqual({ a: 1 }); + expect(normalized.result).toEqual([1, 2, 3]); + }); + + it('returns the same reference when no parsing is needed', () => { + const tool = { + name: 'read', + state: 'completed' as const, + input: { file_path: '/etc/hosts' }, + result: { ok: true }, + createdAt: 0, + startedAt: 0, + completedAt: 1, + description: null, + }; + + const normalized = normalizeToolCallForRendering(tool as any); + expect(normalized).toBe(tool); + }); + + it('normalizes common edit aliases into old_string/new_string + file_path', () => { + const tool = { + name: 'edit', + state: 'completed' as const, + input: { + filePath: '/tmp/a.txt', + oldText: 'hello', + newText: 'hi', + }, + result: '', + createdAt: 0, + startedAt: 0, + completedAt: 1, + description: null, + }; + + const normalized = normalizeToolCallForRendering(tool as any); + expect(normalized.input).toMatchObject({ + file_path: '/tmp/a.txt', + old_string: 'hello', + new_string: 'hi', + }); + }); + + it('normalizes ACP-style items[] diffs for write into content + file_path', () => { + const tool = { + name: 'write', + state: 'completed' as const, + input: { + items: [{ path: '/tmp/a.txt', oldText: 'hello', newText: 'hi', type: 'diff' }], + }, + result: '', + createdAt: 0, + startedAt: 0, + completedAt: 1, + description: null, + }; + + const normalized = normalizeToolCallForRendering(tool as any); + expect(normalized.input).toMatchObject({ + file_path: '/tmp/a.txt', + content: 'hi', + }); + }); + + it('normalizes ACP-style items[] diffs for edit into old_string/new_string + file_path', () => { + const tool = { + name: 'edit', + state: 'completed' as const, + input: { + items: [{ path: '/tmp/a.txt', oldText: 'hello', newText: 'hi', type: 'diff' }], + }, + result: '', + createdAt: 0, + startedAt: 0, + completedAt: 1, + description: null, + }; + + const normalized = normalizeToolCallForRendering(tool as any); + expect(normalized.input).toMatchObject({ + file_path: '/tmp/a.txt', + old_string: 'hello', + new_string: 'hi', + }); + }); +}); diff --git a/expo-app/sources/components/tools/utils/normalizeToolCallForRendering.ts b/expo-app/sources/components/tools/utils/normalizeToolCallForRendering.ts new file mode 100644 index 000000000..c268e2788 --- /dev/null +++ b/expo-app/sources/components/tools/utils/normalizeToolCallForRendering.ts @@ -0,0 +1,150 @@ +import type { ToolCall } from '@/sync/typesMessage'; +import { maybeParseJson } from './parseJson'; + +function asRecord(value: unknown): Record<string, unknown> | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + return value as Record<string, unknown>; +} + +function firstNonEmptyString(value: unknown): string | null { + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null; +} + +function coerceSingleLocationPath(locations: unknown): string | null { + if (!Array.isArray(locations) || locations.length !== 1) return null; + const first = asRecord(locations[0]); + if (!first) return null; + return ( + firstNonEmptyString(first.path) ?? + firstNonEmptyString(first.filePath) ?? + null + ); +} + +function normalizeFilePathFromLocations(input: Record<string, unknown>): Record<string, unknown> | null { + if (typeof input.file_path === 'string' && input.file_path.trim().length > 0) return null; + const locPath = coerceSingleLocationPath(input.locations); + if (!locPath) return null; + return { ...input, file_path: locPath }; +} + +function normalizeFromAcpItems(input: Record<string, unknown>, opts: { toolNameLower: string }): Record<string, unknown> | null { + const items = Array.isArray((input as any).items) ? ((input as any).items as unknown[]) : null; + if (!items || items.length === 0) return null; + const first = asRecord(items[0]); + if (!first) return null; + + const itemPath = + firstNonEmptyString(first.path) ?? + firstNonEmptyString(first.filePath) ?? + null; + const oldText = + firstNonEmptyString(first.oldText) ?? + firstNonEmptyString(first.old_string) ?? + firstNonEmptyString(first.oldString) ?? + null; + const newText = + firstNonEmptyString(first.newText) ?? + firstNonEmptyString(first.new_string) ?? + firstNonEmptyString(first.newString) ?? + null; + + let changed = false; + const next: Record<string, unknown> = { ...input }; + + if (itemPath && (typeof next.file_path !== 'string' || next.file_path.trim().length === 0)) { + next.file_path = itemPath; + changed = true; + } + + if (opts.toolNameLower === 'write') { + if (typeof next.content !== 'string' && newText) { + next.content = newText; + changed = true; + } + } + + if (opts.toolNameLower === 'edit') { + if (typeof next.old_string !== 'string' && oldText) { + next.old_string = oldText; + changed = true; + } + if (typeof next.new_string !== 'string' && newText) { + next.new_string = newText; + changed = true; + } + } + + return changed ? next : null; +} + +function normalizeFilePathAliases(input: Record<string, unknown>): Record<string, unknown> | null { + const currentFilePath = typeof input.file_path === 'string' ? input.file_path : null; + const alias = + typeof input.filePath === 'string' + ? input.filePath + : typeof input.path === 'string' + ? input.path + : null; + if (!currentFilePath && alias) { + return { ...input, file_path: alias }; + } + return null; +} + +function normalizeEditAliases(input: Record<string, unknown>): Record<string, unknown> | null { + const maybeWithPath = normalizeFilePathAliases(input) ?? input; + + const hasOld = typeof maybeWithPath.old_string === 'string'; + const hasNew = typeof maybeWithPath.new_string === 'string'; + const oldAlias = + typeof maybeWithPath.oldText === 'string' + ? maybeWithPath.oldText + : typeof maybeWithPath.oldString === 'string' + ? maybeWithPath.oldString + : null; + const newAlias = + typeof maybeWithPath.newText === 'string' + ? maybeWithPath.newText + : typeof maybeWithPath.newString === 'string' + ? maybeWithPath.newString + : null; + + const next: Record<string, unknown> = { ...maybeWithPath }; + let changed = maybeWithPath !== input; + if (!hasOld && oldAlias) { + next.old_string = oldAlias; + changed = true; + } + if (!hasNew && newAlias) { + next.new_string = newAlias; + changed = true; + } + return changed ? next : null; +} + +export function normalizeToolCallForRendering(tool: ToolCall): ToolCall { + const parsedInput = maybeParseJson(tool.input); + const parsedResult = maybeParseJson(tool.result); + let nextInput: unknown = parsedInput; + + const inputRecord = asRecord(nextInput); + if (inputRecord) { + const toolNameLower = tool.name.toLowerCase(); + nextInput = + normalizeFilePathFromLocations(inputRecord) ?? + normalizeFromAcpItems(inputRecord, { toolNameLower }) ?? + inputRecord; + const inputRecord2 = asRecord(nextInput) ?? inputRecord; + if (toolNameLower === 'edit') { + nextInput = normalizeEditAliases(inputRecord2) ?? inputRecord2; + } else if (toolNameLower === 'write' || toolNameLower === 'read') { + nextInput = normalizeFilePathAliases(inputRecord2) ?? inputRecord2; + } + } + + const inputChanged = nextInput !== tool.input; + const resultChanged = parsedResult !== tool.result; + if (!inputChanged && !resultChanged) return tool; + return { ...tool, input: nextInput, result: parsedResult }; +} diff --git a/expo-app/sources/components/tools/utils/parseJson.ts b/expo-app/sources/components/tools/utils/parseJson.ts new file mode 100644 index 000000000..7f0bed626 --- /dev/null +++ b/expo-app/sources/components/tools/utils/parseJson.ts @@ -0,0 +1,13 @@ +export function maybeParseJson(value: unknown): unknown { + if (typeof value !== 'string') return value; + const trimmed = value.trim(); + if (!trimmed) return value; + const first = trimmed[0]; + if (first !== '{' && first !== '[') return value; + try { + return JSON.parse(trimmed) as unknown; + } catch { + return value; + } +} + diff --git a/expo-app/sources/components/tools/utils/parseParenIdentifier.test.ts b/expo-app/sources/components/tools/utils/parseParenIdentifier.test.ts new file mode 100644 index 000000000..d1327c820 --- /dev/null +++ b/expo-app/sources/components/tools/utils/parseParenIdentifier.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest'; + +import { parseParenIdentifier } from './parseParenIdentifier'; + +describe('parseParenIdentifier', () => { + it('parses a name(spec) identifier', () => { + expect(parseParenIdentifier('Bash(echo hello)')).toEqual({ name: 'Bash', spec: 'echo hello' }); + }); + + it('returns null when value does not contain parentheses', () => { + expect(parseParenIdentifier('Bash')).toBeNull(); + }); +}); + diff --git a/expo-app/sources/components/tools/utils/parseParenIdentifier.ts b/expo-app/sources/components/tools/utils/parseParenIdentifier.ts new file mode 100644 index 000000000..a0928706f --- /dev/null +++ b/expo-app/sources/components/tools/utils/parseParenIdentifier.ts @@ -0,0 +1,8 @@ +export type ParsedParenIdentifier = { name: string; spec: string }; + +export function parseParenIdentifier(value: string): ParsedParenIdentifier | null { + const match = value.match(/^([^(]+)\((.+)\)$/); + if (!match) return null; + return { name: match[1], spec: match[2] }; +} + diff --git a/expo-app/sources/components/tools/utils/permissionSummary.test.ts b/expo-app/sources/components/tools/utils/permissionSummary.test.ts new file mode 100644 index 000000000..37cbeff9b --- /dev/null +++ b/expo-app/sources/components/tools/utils/permissionSummary.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; +import { formatPermissionRequestSummary } from './permissionSummary'; + +describe('formatPermissionRequestSummary', () => { + it('prefers permission title when present', () => { + const summary = formatPermissionRequestSummary({ + toolName: 'unknown', + toolInput: { permission: { title: 'Access file outside working directory: /etc/hosts' } }, + }); + expect(summary).toBe('Access file outside working directory: /etc/hosts'); + }); + + it('summarizes shell command permissions', () => { + const summary = formatPermissionRequestSummary({ + toolName: 'bash', + toolInput: { command: 'echo hello' }, + }); + expect(summary).toBe('Run: echo hello'); + }); + + it('summarizes file read permissions', () => { + const summary = formatPermissionRequestSummary({ + toolName: 'read', + toolInput: { filepath: '/etc/hosts' }, + }); + expect(summary).toBe('Read: /etc/hosts'); + }); + + it('summarizes file read permissions from locations[]', () => { + const summary = formatPermissionRequestSummary({ + toolName: 'read', + toolInput: { locations: [{ path: '/etc/hosts' }] }, + }); + expect(summary).toBe('Read: /etc/hosts'); + }); + + it('summarizes file write permissions from items[]', () => { + const summary = formatPermissionRequestSummary({ + toolName: 'write', + toolInput: { items: [{ path: '/tmp/a.txt', type: 'diff' }] }, + }); + expect(summary).toBe('Write: /tmp/a.txt'); + }); +}); diff --git a/expo-app/sources/components/tools/utils/permissionSummary.ts b/expo-app/sources/components/tools/utils/permissionSummary.ts new file mode 100644 index 000000000..8c66e3c2b --- /dev/null +++ b/expo-app/sources/components/tools/utils/permissionSummary.ts @@ -0,0 +1,114 @@ +import { extractShellCommand } from './shellCommand'; + +type FormatPermissionRequestSummaryParams = { + toolName: string; + toolInput: unknown; +}; + +function asRecord(value: unknown): Record<string, unknown> | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + return value as Record<string, unknown>; +} + +function firstString(value: unknown): string | null { + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null; +} + +function extractFirstLocationPath(locations: unknown): string | null { + if (!Array.isArray(locations) || locations.length === 0) return null; + const first = asRecord(locations[0]); + if (!first) return null; + return ( + firstString(first.path) ?? + firstString(first.filePath) ?? + null + ); +} + +function extractFirstItemPath(items: unknown): string | null { + if (!Array.isArray(items) || items.length === 0) return null; + const first = asRecord(items[0]); + if (!first) return null; + return ( + firstString(first.path) ?? + firstString(first.filePath) ?? + null + ); +} + +function extractFilePathLike(input: unknown): string | null { + const obj = asRecord(input); + if (!obj) return null; + + // Common ACP-style format: { locations: [{ path }] } + const locPath = extractFirstLocationPath(obj.locations); + if (locPath) return locPath; + + // Gemini ACP-style nested format: { toolCall: { content: [{ path }] } } + const toolCall = asRecord(obj.toolCall); + const toolCallLocPath = extractFirstLocationPath(toolCall?.locations); + if (toolCallLocPath) return toolCallLocPath; + const contentArr = toolCall && Array.isArray((toolCall as any).content) ? ((toolCall as any).content as unknown[]) : null; + if (contentArr && contentArr.length > 0) { + const first = asRecord(contentArr[0]); + const nestedPath = firstString(first?.path); + if (nestedPath) return nestedPath; + } + + // Gemini ACP-style array format: { input: [{ path }] } + const inputArr = Array.isArray((obj as any).input) ? ((obj as any).input as unknown[]) : null; + if (inputArr && inputArr.length > 0) { + const first = asRecord(inputArr[0]); + const nestedPath = firstString(first?.path); + if (nestedPath) return nestedPath; + } + + // ACP diff-style format: { items: [{ path }] } + const itemPath = extractFirstItemPath(obj.items); + if (itemPath) return itemPath; + + return ( + firstString(obj.filePath) ?? + firstString(obj.file_path) ?? + firstString(obj.path) ?? + firstString(obj.filepath) ?? + firstString(obj.file) ?? + null + ); +} + +export function formatPermissionRequestSummary(params: FormatPermissionRequestSummaryParams): string { + const toolName = params.toolName || 'unknown'; + const lower = toolName.toLowerCase(); + + const obj = asRecord(params.toolInput); + const permissionTitle = (() => { + const permission = asRecord(obj?.permission); + return ( + firstString(permission?.title) ?? + firstString(obj?.title) ?? + null + ); + })(); + if (permissionTitle) { + return permissionTitle; + } + + const command = extractShellCommand(params.toolInput); + if (command && (lower === 'bash' || lower === 'execute' || lower === 'shell')) { + return `Run: ${command}`; + } + + const filePath = extractFilePathLike(params.toolInput); + if (filePath && (lower === 'read' || lower === 'write' || lower === 'edit' || lower === 'multiedit')) { + const verb = lower === 'read' ? 'Read' : lower === 'write' ? 'Write' : 'Edit'; + return `${verb}: ${filePath}`; + } + + const hasAnyKeys = obj ? Object.keys(obj).length > 0 : false; + if (!hasAnyKeys) { + return `Permission required: ${toolName} (details unavailable)`; + } + + return `Permission required: ${toolName}`; +} diff --git a/expo-app/sources/components/tools/utils/shellCommand.test.ts b/expo-app/sources/components/tools/utils/shellCommand.test.ts new file mode 100644 index 000000000..f01a04546 --- /dev/null +++ b/expo-app/sources/components/tools/utils/shellCommand.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; + +import { extractShellCommand } from './shellCommand'; + +describe('extractShellCommand', () => { + it('extracts a command from JSON-stringified ACP args', () => { + const input = JSON.stringify({ + command: ['/bin/zsh', '-lc', 'echo hello'], + cwd: '/tmp', + }); + expect(extractShellCommand(input)).toBe('echo hello'); + }); + + it('extracts a command from JSON-stringified simple args', () => { + const input = JSON.stringify({ command: 'pwd' }); + expect(extractShellCommand(input)).toBe('pwd'); + }); +}); + diff --git a/expo-app/sources/components/tools/utils/shellCommand.ts b/expo-app/sources/components/tools/utils/shellCommand.ts new file mode 100644 index 000000000..f9e721a58 --- /dev/null +++ b/expo-app/sources/components/tools/utils/shellCommand.ts @@ -0,0 +1,76 @@ +import { maybeParseJson } from './parseJson'; + +type UnknownRecord = Record<string, unknown>; + +function asRecord(value: unknown): UnknownRecord | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + return value as UnknownRecord; +} + +function extractCommandArrayLike(value: unknown): string[] | null { + if (!Array.isArray(value)) return null; + const parts: string[] = []; + for (const item of value) { + if (typeof item !== 'string') return null; + parts.push(item); + } + return parts; +} + +export function extractShellCommand(input: unknown): string | null { + const parsed = maybeParseJson(input); + const obj = asRecord(parsed); + if (!obj) return null; + + // Common: { command: string } + const command = obj.command; + if (typeof command === 'string' && command.trim().length > 0) { + return command.trim(); + } + + // Common: { command: string[] } + const cmdArray = extractCommandArrayLike(command); + if (cmdArray && cmdArray.length > 0) { + // Remove shell wrapper prefix if present (bash/zsh with -lc flag) + if ( + cmdArray.length >= 3 + && (cmdArray[0] === 'bash' || cmdArray[0] === '/bin/bash' || cmdArray[0] === 'zsh' || cmdArray[0] === '/bin/zsh') + && cmdArray[1] === '-lc' + && typeof cmdArray[2] === 'string' + ) { + return cmdArray[2]; + } + return cmdArray.join(' '); + } + + // Common: { cmd: string | string[] } + const cmd = obj.cmd; + if (typeof cmd === 'string' && cmd.trim().length > 0) { + return cmd.trim(); + } + const cmdArray2 = extractCommandArrayLike(cmd); + if (cmdArray2 && cmdArray2.length > 0) { + return extractShellCommand({ command: cmdArray2 }); + } + + // Common: { argv: string[] } + const argvArray = extractCommandArrayLike(obj.argv); + if (argvArray && argvArray.length > 0) { + return extractShellCommand({ command: argvArray }); + } + + // Our ACP parser wraps raw arrays as { items: [...] } + const itemsArray = extractCommandArrayLike(obj.items); + if (itemsArray && itemsArray.length > 0) { + return extractShellCommand({ command: itemsArray }); + } + + // Nested: { toolCall: { rawInput: { command } } } + const toolCall = asRecord(obj.toolCall); + const rawInput = toolCall ? asRecord(toolCall.rawInput) : null; + if (rawInput) { + return extractShellCommand(rawInput); + } + + return null; +} diff --git a/expo-app/sources/components/tools/utils/stdStreams.ts b/expo-app/sources/components/tools/utils/stdStreams.ts new file mode 100644 index 000000000..69d6066f4 --- /dev/null +++ b/expo-app/sources/components/tools/utils/stdStreams.ts @@ -0,0 +1,27 @@ +import { maybeParseJson } from './parseJson'; + +function asRecord(value: unknown): Record<string, unknown> | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + return value as Record<string, unknown>; +} + +export type StdStreams = { stdout?: string; stderr?: string }; + +export function extractStdStreams(result: unknown): StdStreams | null { + const parsed = maybeParseJson(result); + const obj = asRecord(parsed); + if (!obj) return null; + + const stdout = typeof obj.stdout === 'string' ? obj.stdout : undefined; + const stderr = typeof obj.stderr === 'string' ? obj.stderr : undefined; + if (!stdout && !stderr) return null; + + return { stdout, stderr }; +} + +export function tailTextWithEllipsis(text: string, maxChars: number): string { + if (maxChars <= 0) return ''; + if (text.length <= maxChars) return text; + return `…${text.slice(-maxChars)}`; +} + diff --git a/expo-app/sources/components/tools/utils/toolNameInference.test.ts b/expo-app/sources/components/tools/utils/toolNameInference.test.ts new file mode 100644 index 000000000..c3e2a291a --- /dev/null +++ b/expo-app/sources/components/tools/utils/toolNameInference.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; +import { inferToolNameForRendering } from './toolNameInference'; + +describe('inferToolNameForRendering', () => { + const known = ['read', 'write', 'edit', 'bash', 'execute', 'TodoWrite', 'TodoRead']; + + it('prefers toolInput.toolName when tool name is unknown', () => { + const result = inferToolNameForRendering({ + toolName: 'unknown', + toolInput: { toolName: 'read', filepath: '/etc/hosts' }, + toolDescription: null, + knownToolKeys: known, + }); + expect(result).toEqual({ normalizedToolName: 'read', source: 'toolInputToolName' }); + }); + + it('falls back to toolInput.permission.toolName when present', () => { + const result = inferToolNameForRendering({ + toolName: 'unknown', + toolInput: { permission: { toolName: 'write' } }, + toolDescription: null, + knownToolKeys: known, + }); + expect(result).toEqual({ normalizedToolName: 'write', source: 'toolInputPermissionToolName' }); + }); + + it('uses _acp.kind when present and non-unknown', () => { + const result = inferToolNameForRendering({ + toolName: 'Run echo hello', + toolInput: { _acp: { kind: 'execute' } }, + toolDescription: 'Run echo hello', + knownToolKeys: known, + }); + expect(result).toEqual({ normalizedToolName: 'execute', source: 'acpKind' }); + }); + + it('can derive from toolDescription when it is a stable key', () => { + const result = inferToolNameForRendering({ + toolName: 'unknown', + toolInput: {}, + toolDescription: 'read', + knownToolKeys: known, + }); + expect(result).toEqual({ normalizedToolName: 'read', source: 'toolDescription' }); + }); + + it('normalizes todoread to TodoRead via known tool keys', () => { + const result = inferToolNameForRendering({ + toolName: 'todoread', + toolInput: {}, + toolDescription: null, + knownToolKeys: known, + }); + expect(result).toEqual({ normalizedToolName: 'TodoRead', source: 'original' }); + }); +}); diff --git a/expo-app/sources/components/tools/utils/toolNameInference.ts b/expo-app/sources/components/tools/utils/toolNameInference.ts new file mode 100644 index 000000000..f2432f791 --- /dev/null +++ b/expo-app/sources/components/tools/utils/toolNameInference.ts @@ -0,0 +1,80 @@ +type UnknownRecord = Record<string, unknown>; + +function asRecord(value: unknown): UnknownRecord | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + return value as UnknownRecord; +} + +function firstNonEmptyString(value: unknown): string | null { + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null; +} + +function normalizeByKnownKeys(name: string, knownToolKeys: readonly string[]): string { + if (knownToolKeys.includes(name)) return name; + const lower = name.toLowerCase(); + const byLower = knownToolKeys.find((k) => k.toLowerCase() === lower); + if (byLower) return byLower; + const simplified = lower.replace(/[^a-z0-9]/g, ''); + const bySimplified = knownToolKeys.find((k) => k.toLowerCase().replace(/[^a-z0-9]/g, '') === simplified); + return bySimplified ?? name; +} + +export type InferToolNameResult = { + normalizedToolName: string; + source: + | 'original' + | 'acpKind' + | 'toolInputToolName' + | 'toolInputPermissionToolName' + | 'toolDescription' + | 'acpTitle'; +}; + +export function inferToolNameForRendering(params: { + toolName: string; + toolInput: unknown; + toolDescription?: string | null; + knownToolKeys: readonly string[]; +}): InferToolNameResult { + const normalizedOriginal = normalizeByKnownKeys(params.toolName, params.knownToolKeys); + if (normalizedOriginal !== params.toolName || params.knownToolKeys.includes(params.toolName)) { + return { normalizedToolName: normalizedOriginal, source: 'original' }; + } + + const input = asRecord(params.toolInput); + + const acpKind = firstNonEmptyString(asRecord(input?._acp)?.kind); + if (acpKind && acpKind.toLowerCase() !== 'unknown') { + return { normalizedToolName: normalizeByKnownKeys(acpKind, params.knownToolKeys), source: 'acpKind' }; + } + + const toolInputToolName = firstNonEmptyString(input?.toolName); + if (toolInputToolName) { + return { normalizedToolName: normalizeByKnownKeys(toolInputToolName, params.knownToolKeys), source: 'toolInputToolName' }; + } + + const permission = asRecord(input?.permission); + const permissionToolName = firstNonEmptyString(permission?.toolName); + if (permissionToolName) { + return { normalizedToolName: normalizeByKnownKeys(permissionToolName, params.knownToolKeys), source: 'toolInputPermissionToolName' }; + } + + const toolDescription = firstNonEmptyString(params.toolDescription); + if (toolDescription && !toolDescription.includes(' ')) { + const normalized = normalizeByKnownKeys(toolDescription, params.knownToolKeys); + if (normalized !== toolDescription || params.knownToolKeys.includes(toolDescription)) { + return { normalizedToolName: normalized, source: 'toolDescription' }; + } + } + + const acpTitle = firstNonEmptyString(asRecord(input?._acp)?.title); + if (acpTitle && !acpTitle.includes(' ')) { + const normalized = normalizeByKnownKeys(acpTitle, params.knownToolKeys); + if (normalized !== acpTitle || params.knownToolKeys.includes(acpTitle)) { + return { normalizedToolName: normalized, source: 'acpTitle' }; + } + } + + return { normalizedToolName: params.toolName, source: 'original' }; +} + diff --git a/expo-app/sources/components/tools/views/AcpHistoryImportView.tsx b/expo-app/sources/components/tools/views/AcpHistoryImportView.tsx new file mode 100644 index 000000000..cc1217319 --- /dev/null +++ b/expo-app/sources/components/tools/views/AcpHistoryImportView.tsx @@ -0,0 +1,212 @@ +import * as React from 'react'; +import { View, Text, TouchableOpacity, ActivityIndicator } from 'react-native'; +import { StyleSheet, useUnistyles } from 'react-native-unistyles'; +import { ToolViewProps } from './_registry'; +import { ToolSectionView } from '../ToolSectionView'; +import { sessionAllow, sessionDeny } from '@/sync/ops'; +import { Modal } from '@/modal'; +import { t } from '@/text'; + +type HistoryPreviewItem = { role?: string; text?: string }; + +function asPreviewList(input: unknown): HistoryPreviewItem[] { + if (!Array.isArray(input)) return []; + return input + .filter((v) => v && typeof v === 'object') + .map((v) => { + const obj = v as any; + return { + role: typeof obj.role === 'string' ? obj.role : undefined, + text: typeof obj.text === 'string' ? obj.text : undefined, + }; + }); +} + +export const AcpHistoryImportView = React.memo<ToolViewProps>(({ tool, sessionId }) => { + const { theme } = useUnistyles(); + const [loading, setLoading] = React.useState<'import' | 'skip' | null>(null); + + if (!sessionId) return null; + const permissionId = tool.permission?.id; + if (!permissionId) return null; + + const input = tool.input as any; + const provider = typeof input?.provider === 'string' ? input.provider : 'acp'; + const remoteSessionId = typeof input?.remoteSessionId === 'string' ? input.remoteSessionId : undefined; + const localCount = typeof input?.localCount === 'number' ? input.localCount : undefined; + const remoteCount = typeof input?.remoteCount === 'number' ? input.remoteCount : undefined; + const localTail = asPreviewList(input?.localTail); + const remoteTail = asPreviewList(input?.remoteTail); + const note = typeof input?.note === 'string' ? input.note : undefined; + + const isPending = tool.permission?.status === 'pending'; + + const onImport = async () => { + if (!isPending || loading) return; + setLoading('import'); + try { + await sessionAllow(sessionId, permissionId); + } catch (e) { + Modal.alert(t('common.error'), e instanceof Error ? e.message : t('errors.failedToSendMessage')); + } finally { + setLoading(null); + } + }; + + const onSkip = async () => { + if (!isPending || loading) return; + setLoading('skip'); + try { + await sessionDeny(sessionId, permissionId, undefined, undefined, 'denied'); + } catch (e) { + Modal.alert(t('common.error'), e instanceof Error ? e.message : t('errors.failedToSendMessage')); + } finally { + setLoading(null); + } + }; + + return ( + <ToolSectionView> + <View style={styles.container}> + <Text style={styles.title}>Import session history?</Text> + <Text style={styles.subtitle}> + {provider}{remoteSessionId ? ` • ${remoteSessionId}` : ''} + </Text> + <Text style={styles.body}> + {note ?? 'This session history differs from what is already in Happy. Importing may create duplicates.'} + </Text> + + {(typeof localCount === 'number' || typeof remoteCount === 'number') && ( + <View style={styles.countRow}> + {typeof localCount === 'number' && <Text style={styles.countText}>Local: {localCount}</Text>} + {typeof remoteCount === 'number' && <Text style={styles.countText}>Remote: {remoteCount}</Text>} + </View> + )} + + {(localTail.length > 0 || remoteTail.length > 0) && ( + <View style={styles.previewContainer}> + {localTail.length > 0 && ( + <View style={styles.previewBlock}> + <Text style={styles.previewHeader}>Local (tail)</Text> + {localTail.map((m, idx) => ( + <Text key={idx} style={styles.previewLine} numberOfLines={2}> + {(m.role ?? 'unknown')}: {m.text ?? ''} + </Text> + ))} + </View> + )} + {remoteTail.length > 0 && ( + <View style={styles.previewBlock}> + <Text style={styles.previewHeader}>Remote (tail)</Text> + {remoteTail.map((m, idx) => ( + <Text key={idx} style={styles.previewLine} numberOfLines={2}> + {(m.role ?? 'unknown')}: {m.text ?? ''} + </Text> + ))} + </View> + )} + </View> + )} + + <View style={styles.actions}> + <TouchableOpacity + style={[styles.button, styles.primaryButton, !isPending && styles.disabled]} + disabled={!isPending || loading !== null} + onPress={onImport} + > + {loading === 'import' ? <ActivityIndicator color={theme.colors.button.primary.tint} /> : <Text style={styles.primaryText}>Import</Text>} + </TouchableOpacity> + <TouchableOpacity + style={[styles.button, styles.secondaryButton, !isPending && styles.disabled]} + disabled={!isPending || loading !== null} + onPress={onSkip} + > + {loading === 'skip' ? <ActivityIndicator color={theme.colors.text} /> : <Text style={styles.secondaryText}>Skip</Text>} + </TouchableOpacity> + </View> + </View> + </ToolSectionView> + ); +}); + +const styles = StyleSheet.create((theme) => ({ + container: { + gap: 10, + paddingVertical: 4, + }, + title: { + fontSize: 15, + fontWeight: '700', + color: theme.colors.text, + }, + subtitle: { + fontSize: 12, + color: theme.colors.textSecondary, + }, + body: { + fontSize: 13, + color: theme.colors.text, + lineHeight: 18, + }, + countRow: { + flexDirection: 'row', + gap: 12, + }, + countText: { + fontSize: 12, + color: theme.colors.textSecondary, + }, + previewContainer: { + gap: 10, + }, + previewBlock: { + gap: 6, + padding: 10, + borderRadius: 8, + backgroundColor: theme.colors.surfaceHighest, + }, + previewHeader: { + fontSize: 12, + fontWeight: '600', + color: theme.colors.textSecondary, + textTransform: 'uppercase', + }, + previewLine: { + fontSize: 12, + color: theme.colors.text, + }, + actions: { + flexDirection: 'row', + gap: 10, + marginTop: 6, + }, + button: { + flex: 1, + paddingVertical: 12, + borderRadius: 8, + alignItems: 'center', + justifyContent: 'center', + minHeight: 44, + }, + primaryButton: { + backgroundColor: theme.colors.button.primary.background, + }, + primaryText: { + color: theme.colors.button.primary.tint, + fontSize: 14, + fontWeight: '600', + }, + secondaryButton: { + backgroundColor: theme.colors.surfaceHigh, + borderWidth: 1, + borderColor: theme.colors.divider, + }, + secondaryText: { + color: theme.colors.text, + fontSize: 14, + fontWeight: '600', + }, + disabled: { + opacity: 0.5, + }, +})); diff --git a/expo-app/sources/components/tools/views/AskUserQuestionView.test.ts b/expo-app/sources/components/tools/views/AskUserQuestionView.test.ts new file mode 100644 index 000000000..e5726f2cb --- /dev/null +++ b/expo-app/sources/components/tools/views/AskUserQuestionView.test.ts @@ -0,0 +1,229 @@ +import React from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import renderer, { act } from 'react-test-renderer'; +import type { ToolCall } from '@/sync/typesMessage'; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +const sessionDeny = vi.fn(); +const sendMessage = vi.fn(); +const sessionAllowWithAnswers = vi.fn(); +const modalAlert = vi.fn(); + +vi.mock('@/text', () => ({ + t: (key: string) => key, +})); + +vi.mock('@/modal', () => ({ + Modal: { + alert: (...args: any[]) => modalAlert(...args), + }, +})); + +vi.mock('react-native', () => ({ + View: 'View', + Text: 'Text', + TouchableOpacity: 'TouchableOpacity', + ActivityIndicator: 'ActivityIndicator', +})); + +vi.mock('react-native-unistyles', () => ({ + StyleSheet: { create: (styles: any) => styles }, + useUnistyles: () => ({ + theme: { + colors: { + button: { primary: { background: '#00f', tint: '#fff' } }, + divider: '#ddd', + text: '#000', + textSecondary: '#666', + surface: '#fff', + input: { background: '#fff', placeholder: '#aaa', text: '#000' }, + }, + }, + }), +})); + +vi.mock('@expo/vector-icons', () => ({ + Ionicons: 'Ionicons', +})); + +vi.mock('../ToolSectionView', () => ({ + ToolSectionView: ({ children }: any) => React.createElement(React.Fragment, null, children), +})); + +vi.mock('@/sync/ops', () => ({ + sessionDeny: (...args: any[]) => sessionDeny(...args), + sessionAllowWithAnswers: (...args: any[]) => sessionAllowWithAnswers(...args), +})); + +vi.mock('@/sync/storage', () => ({ + storage: { + getState: () => ({ + sessions: { + s1: { agentState: { capabilities: { askUserQuestionAnswersInPermission: true } } }, + }, + }), + }, +})); + +vi.mock('@/sync/sync', () => ({ + sync: { + sendMessage: (...args: any[]) => sendMessage(...args), + }, +})); + +describe('AskUserQuestionView', () => { + beforeEach(() => { + sessionDeny.mockReset(); + sendMessage.mockReset(); + sessionAllowWithAnswers.mockReset(); + modalAlert.mockReset(); + }); + + it('submits answers via permission approval without sending a follow-up user message', async () => { + sessionAllowWithAnswers.mockResolvedValueOnce(undefined); + + const { AskUserQuestionView } = await import('./AskUserQuestionView'); + + const tool: ToolCall = { + name: 'AskUserQuestion', + state: 'running', + input: { + questions: [ + { + header: 'Q1', + question: 'Pick one', + multiSelect: false, + options: [{ label: 'A', description: '' }, { label: 'B', description: '' }], + }, + ], + }, + createdAt: Date.now(), + startedAt: Date.now(), + completedAt: null, + description: null, + permission: { id: 'toolu_1', status: 'pending' }, + }; + + let tree: ReturnType<typeof renderer.create> | undefined; + await act(async () => { + tree = renderer.create( + React.createElement(AskUserQuestionView, { tool, sessionId: 's1', metadata: null, messages: [] }), + ); + }); + + // Select the first option. + await act(async () => { + const touchables = tree!.root.findAllByType('TouchableOpacity' as any); + await touchables[0].props.onPress(); + }); + + // Press submit (last touchable in this view). + await act(async () => { + const touchables = tree!.root.findAllByType('TouchableOpacity' as any); + await touchables[touchables.length - 1].props.onPress(); + }); + + expect(sessionAllowWithAnswers).toHaveBeenCalledTimes(1); + expect(sessionDeny).toHaveBeenCalledTimes(0); + expect(sendMessage).toHaveBeenCalledTimes(0); + }); + + it('shows an error when permission id is missing and does not submit', async () => { + const { AskUserQuestionView } = await import('./AskUserQuestionView'); + + const tool: ToolCall = { + name: 'AskUserQuestion', + state: 'running', + input: { + questions: [ + { + header: 'Q1', + question: 'Pick one', + multiSelect: false, + options: [{ label: 'A', description: '' }, { label: 'B', description: '' }], + }, + ], + }, + createdAt: Date.now(), + startedAt: Date.now(), + completedAt: null, + description: null, + permission: undefined, + }; + + let tree: ReturnType<typeof renderer.create> | undefined; + await act(async () => { + tree = renderer.create( + React.createElement(AskUserQuestionView, { tool, sessionId: 's1', metadata: null, messages: [] }), + ); + }); + + // Select the first option so submit becomes enabled. + await act(async () => { + const touchables = tree!.root.findAllByType('TouchableOpacity' as any); + await touchables[0].props.onPress(); + }); + + // Press submit (last touchable in this view). + await act(async () => { + const touchables = tree!.root.findAllByType('TouchableOpacity' as any); + await touchables[touchables.length - 1].props.onPress(); + }); + + expect(sessionAllowWithAnswers).toHaveBeenCalledTimes(0); + expect(sessionDeny).toHaveBeenCalledTimes(0); + expect(sendMessage).toHaveBeenCalledTimes(0); + expect(modalAlert).toHaveBeenCalledWith('common.error', 'errors.missingPermissionId'); + }); + + it('shows an error when permission approval fails', async () => { + sessionAllowWithAnswers.mockRejectedValueOnce(new Error('boom')); + + const { AskUserQuestionView } = await import('./AskUserQuestionView'); + + const tool: ToolCall = { + name: 'AskUserQuestion', + state: 'running', + input: { + questions: [ + { + header: 'Q1', + question: 'Pick one', + multiSelect: false, + options: [{ label: 'A', description: '' }, { label: 'B', description: '' }], + }, + ], + }, + createdAt: Date.now(), + startedAt: Date.now(), + completedAt: null, + description: null, + permission: { id: 'toolu_1', status: 'pending' }, + }; + + let tree: ReturnType<typeof renderer.create> | undefined; + await act(async () => { + tree = renderer.create( + React.createElement(AskUserQuestionView, { tool, sessionId: 's1', metadata: null, messages: [] }), + ); + }); + + // Select the first option. + await act(async () => { + const touchables = tree!.root.findAllByType('TouchableOpacity' as any); + await touchables[0].props.onPress(); + }); + + // Press submit (last touchable in this view). + await act(async () => { + const touchables = tree!.root.findAllByType('TouchableOpacity' as any); + await touchables[touchables.length - 1].props.onPress(); + }); + + expect(sessionAllowWithAnswers).toHaveBeenCalledTimes(1); + expect(sessionDeny).toHaveBeenCalledTimes(0); + expect(sendMessage).toHaveBeenCalledTimes(0); + expect(modalAlert).toHaveBeenCalledWith('common.error', 'boom'); + }); +}); diff --git a/expo-app/sources/components/tools/views/AskUserQuestionView.tsx b/expo-app/sources/components/tools/views/AskUserQuestionView.tsx index 13a18b31b..8b4b080f0 100644 --- a/expo-app/sources/components/tools/views/AskUserQuestionView.tsx +++ b/expo-app/sources/components/tools/views/AskUserQuestionView.tsx @@ -1,10 +1,12 @@ import * as React from 'react'; import { View, Text, TouchableOpacity, ActivityIndicator } from 'react-native'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; -import { ToolViewProps } from './_all'; +import { ToolViewProps } from './_registry'; import { ToolSectionView } from '../ToolSectionView'; -import { sessionDeny } from '@/sync/ops'; +import { sessionAllowWithAnswers, sessionDeny } from '@/sync/ops'; +import { storage } from '@/sync/storage'; import { sync } from '@/sync/sync'; +import { Modal } from '@/modal'; import { t } from '@/text'; import { Ionicons } from '@expo/vector-icons'; @@ -24,6 +26,20 @@ interface AskUserQuestionInput { questions: Question[]; } +function parseAskUserQuestionAnswersFromToolResult(result: unknown): Record<string, string> | null { + if (!result || typeof result !== 'object') return null; + const maybeAnswers = (result as any).answers; + if (!maybeAnswers || typeof maybeAnswers !== 'object' || Array.isArray(maybeAnswers)) return null; + + const answers: Record<string, string> = {}; + for (const [key, value] of Object.entries(maybeAnswers as Record<string, unknown>)) { + if (typeof value === 'string') { + answers[key] = value; + } + } + return answers; +} + // Styles MUST be defined outside the component to prevent infinite re-renders // with react-native-unistyles. The theme is passed as a function parameter. const styles = StyleSheet.create((theme) => ({ @@ -220,30 +236,50 @@ export const AskUserQuestionView = React.memo<ToolViewProps>(({ tool, sessionId // Format answers as readable text const responseLines: string[] = []; + const answers: Record<string, string> = {}; questions.forEach((q, qIndex) => { const selected = selections.get(qIndex); if (selected && selected.size > 0) { - const selectedLabels = Array.from(selected) + const selectedLabelsArray = Array.from(selected) .map(optIndex => q.options[optIndex]?.label) .filter(Boolean) - .join(', '); - responseLines.push(`${q.header}: ${selectedLabels}`); + const selectedLabelsText = selectedLabelsArray.join(', '); + responseLines.push(`${q.header}: ${selectedLabelsText}`); + + // Claude Code AskUserQuestion expects `answers` keyed by the *question text*, + // with values as strings (multi-select is comma-separated). + const questionKey = typeof q.question === 'string' && q.question.trim().length > 0 ? q.question : q.header; + answers[questionKey] = selectedLabelsText; } }); const responseText = responseLines.join('\n'); try { - // TODO: The proper fix is to update happy-cli to not require permission for AskUserQuestion, - // or to accept answers via the permission RPC. For now, we deny the permission (which cancels - // the tool without side effects) and send answers as a regular user message. - if (tool.permission?.id) { - await sessionDeny(sessionId, tool.permission.id); + const toolCallId = tool.permission?.id; + if (!toolCallId) { + Modal.alert(t('common.error'), t('errors.missingPermissionId')); + return; + } + + const session = storage.getState().sessions[sessionId]; + const supportsAnswersInPermission = Boolean( + (session as any)?.agentState?.capabilities?.askUserQuestionAnswersInPermission, + ); + + if (supportsAnswersInPermission) { + // Preferred: attach answers directly to the existing permission approval RPC. + // This matches how Claude Code expects AskUserQuestion to be completed. + await sessionAllowWithAnswers(sessionId, toolCallId, answers); + } else { + // Back-compat: older agents won't understand answers-on-permission. Abort the tool call and + // send a normal user message so the agent can continue using the same information. + await sessionDeny(sessionId, toolCallId); + await sync.sendMessage(sessionId, responseText); } - await sync.sendMessage(sessionId, responseText); setIsSubmitted(true); } catch (error) { - console.error('Failed to submit answer:', error); + Modal.alert(t('common.error'), error instanceof Error ? error.message : t('errors.failedToSendMessage')); } finally { setIsSubmitting(false); } @@ -251,17 +287,20 @@ export const AskUserQuestionView = React.memo<ToolViewProps>(({ tool, sessionId // Show submitted state if (isSubmitted || tool.state === 'completed') { + const answersFromResult = parseAskUserQuestionAnswersFromToolResult(tool.result); return ( <ToolSectionView> <View style={styles.submittedContainer}> {questions.map((q, qIndex) => { const selected = selections.get(qIndex); - const selectedLabels = selected - ? Array.from(selected) - .map(optIndex => q.options[optIndex]?.label) - .filter(Boolean) - .join(', ') - : '-'; + const questionKey = typeof q.question === 'string' && q.question.trim().length > 0 ? q.question : q.header; + const selectedLabels = + selected && selected.size > 0 + ? Array.from(selected) + .map(optIndex => q.options[optIndex]?.label) + .filter(Boolean) + .join(', ') + : (answersFromResult?.[questionKey] ?? '-'); return ( <View key={qIndex} style={styles.submittedItem}> <Text style={styles.submittedHeader}>{q.header}:</Text> diff --git a/expo-app/sources/components/tools/views/BashView.test.tsx b/expo-app/sources/components/tools/views/BashView.test.tsx new file mode 100644 index 000000000..57ba61992 --- /dev/null +++ b/expo-app/sources/components/tools/views/BashView.test.tsx @@ -0,0 +1,98 @@ +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import renderer, { act } from 'react-test-renderer'; +import type { ToolCall } from '@/sync/typesMessage'; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +const commandViewSpy = vi.fn(); + +vi.mock('react-native', () => ({ + View: 'View', + StyleSheet: { create: (styles: any) => styles }, +})); + +vi.mock('@/components/CommandView', () => ({ + CommandView: (props: any) => { + commandViewSpy(props); + return React.createElement('CommandView', props); + }, +})); + +vi.mock('../../tools/ToolSectionView', () => ({ + ToolSectionView: ({ children }: any) => React.createElement(React.Fragment, null, children), +})); + +describe('BashView', () => { + it('shows stdout on completed tools', async () => { + commandViewSpy.mockReset(); + const { BashView } = await import('./BashView'); + + const tool: ToolCall = { + name: 'Bash', + state: 'completed', + input: { command: 'echo hi' }, + result: { stdout: 'hi\n', stderr: '' } as any, + createdAt: Date.now(), + startedAt: Date.now(), + completedAt: Date.now(), + description: null, + permission: undefined, + }; + + await act(async () => { + renderer.create(React.createElement(BashView, { tool, metadata: null } as any)); + }); + + const props = commandViewSpy.mock.calls.at(-1)?.[0]; + expect(props?.stdout).toBe('hi\n'); + }); + + it('treats plain string tool results as stdout', async () => { + commandViewSpy.mockReset(); + const { BashView } = await import('./BashView'); + + const tool: ToolCall = { + name: 'Bash', + state: 'completed', + input: { command: 'pwd' }, + result: '/tmp\n' as any, + createdAt: Date.now(), + startedAt: Date.now(), + completedAt: Date.now(), + description: null, + permission: undefined, + }; + + await act(async () => { + renderer.create(React.createElement(BashView, { tool, metadata: null } as any)); + }); + + const props = commandViewSpy.mock.calls.at(-1)?.[0]; + expect(props?.stdout).toBe('/tmp\n'); + }); + + it('uses aggregated_output when stdout is missing', async () => { + commandViewSpy.mockReset(); + const { BashView } = await import('./BashView'); + + const tool: ToolCall = { + name: 'Bash', + state: 'completed', + input: { command: 'echo hi' }, + result: { aggregated_output: 'hi\n', stderr: '' } as any, + createdAt: Date.now(), + startedAt: Date.now(), + completedAt: Date.now(), + description: null, + permission: undefined, + }; + + await act(async () => { + renderer.create(React.createElement(BashView, { tool, metadata: null } as any)); + }); + + const props = commandViewSpy.mock.calls.at(-1)?.[0]; + expect(props?.stdout).toBe('hi\n'); + }); +}); diff --git a/expo-app/sources/components/tools/views/BashView.tsx b/expo-app/sources/components/tools/views/BashView.tsx index 15234b766..23067cbff 100644 --- a/expo-app/sources/components/tools/views/BashView.tsx +++ b/expo-app/sources/components/tools/views/BashView.tsx @@ -2,46 +2,48 @@ import * as React from 'react'; import { ToolCall } from '@/sync/typesMessage'; import { ToolSectionView } from '../../tools/ToolSectionView'; import { CommandView } from '@/components/CommandView'; -import { knownTools } from '@/components/tools/knownTools'; import { Metadata } from '@/sync/storageTypes'; +import { extractShellCommand } from '../utils/shellCommand'; +import { maybeParseJson } from '../utils/parseJson'; +import { extractStdStreams, tailTextWithEllipsis } from '../utils/stdStreams'; export const BashView = React.memo((props: { tool: ToolCall, metadata: Metadata | null }) => { const { input, result, state } = props.tool; + const command = extractShellCommand(input) ?? (typeof (input as any)?.command === 'string' ? (input as any).command : ''); - let parsedResult: { stdout?: string; stderr?: string } | null = null; + const parsedStreams = extractStdStreams(result); let unparsedOutput: string | null = null; let error: string | null = null; - if (state === 'completed' && result) { - if (typeof result === 'string') { - // Handle unparsed string result - unparsedOutput = result; - } else { - // Try to parse as structured result - const parsed = knownTools.Bash.result.safeParse(result); - if (parsed.success) { - parsedResult = parsed.data; - } else { - // If parsing fails but it's not a string, stringify it - unparsedOutput = JSON.stringify(result); - } + if (result && state === 'completed') { + const parsedMaybe = maybeParseJson(result); + if (typeof parsedMaybe === 'string') { + unparsedOutput = parsedMaybe; + } else if (!parsedStreams) { + unparsedOutput = JSON.stringify(parsedMaybe); } } else if (state === 'error' && typeof result === 'string') { error = result; } + const maxStreamingChars = 2000; + const streamingStdout = parsedStreams?.stdout ? tailTextWithEllipsis(parsedStreams.stdout, maxStreamingChars) : null; + const streamingStderr = parsedStreams?.stderr ? tailTextWithEllipsis(parsedStreams.stderr, maxStreamingChars) : null; + const maxCompletedChars = 6000; + const completedStdout = parsedStreams?.stdout ? tailTextWithEllipsis(parsedStreams.stdout, maxCompletedChars) : null; + const completedStderr = parsedStreams?.stderr ? tailTextWithEllipsis(parsedStreams.stderr, maxCompletedChars) : null; + return ( <> <ToolSectionView> <CommandView - command={input.command} - // Don't show output in compact view - stdout={null} - stderr={null} + command={command} + stdout={state === 'running' ? streamingStdout : (state === 'completed' ? completedStdout : null)} + stderr={state === 'running' ? streamingStderr : (state === 'completed' ? completedStderr : null)} error={error} hideEmptyOutput /> </ToolSectionView> </> ); -}); \ No newline at end of file +}); diff --git a/expo-app/sources/components/tools/views/BashViewFull.test.ts b/expo-app/sources/components/tools/views/BashViewFull.test.ts new file mode 100644 index 000000000..fbeb40855 --- /dev/null +++ b/expo-app/sources/components/tools/views/BashViewFull.test.ts @@ -0,0 +1,84 @@ +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import renderer, { act } from 'react-test-renderer'; +import type { ToolCall } from '@/sync/typesMessage'; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +const commandViewSpy = vi.fn(); + +vi.mock('react-native', () => ({ + View: 'View', + ScrollView: 'ScrollView', + StyleSheet: { create: (styles: any) => styles }, +})); + +vi.mock('react-native-unistyles', () => ({ + StyleSheet: { create: (styles: any) => styles }, +})); + +vi.mock('@/components/CommandView', () => ({ + CommandView: (props: any) => { + commandViewSpy(props); + return React.createElement('CommandView', props); + }, +})); + +vi.mock('../ToolFullView', () => ({ + toolFullViewStyles: {}, +})); + +describe('BashViewFull', () => { + it('renders streaming stdout while running', async () => { + commandViewSpy.mockReset(); + const { BashViewFull } = await import('./BashViewFull'); + + const tool: ToolCall = { + name: 'Bash', + state: 'running', + input: { command: 'echo hi' }, + result: { stdout: 'hello\n' } as any, + createdAt: Date.now(), + startedAt: Date.now(), + completedAt: null, + description: null, + permission: undefined, + }; + + await act(async () => { + renderer.create(React.createElement(BashViewFull, { tool, metadata: null })); + }); + + expect(commandViewSpy).toHaveBeenCalled(); + const lastCallArgs = commandViewSpy.mock.calls.at(-1)?.[0]; + expect(lastCallArgs?.stdout).toBe('hello\n'); + }); + + it('truncates long streaming stdout while running', async () => { + commandViewSpy.mockReset(); + const { BashViewFull } = await import('./BashViewFull'); + + const long = 'x'.repeat(20000) + 'TAIL'; + const tool: ToolCall = { + name: 'Bash', + state: 'running', + input: { command: 'echo hi' }, + result: { stdout: long } as any, + createdAt: Date.now(), + startedAt: Date.now(), + completedAt: null, + description: null, + permission: undefined, + }; + + await act(async () => { + renderer.create(React.createElement(BashViewFull, { tool, metadata: null })); + }); + + expect(commandViewSpy).toHaveBeenCalled(); + const lastCallArgs = commandViewSpy.mock.calls.at(-1)?.[0]; + expect(typeof lastCallArgs?.stdout).toBe('string'); + expect(lastCallArgs.stdout.length).toBeLessThan(long.length); + expect(lastCallArgs.stdout.endsWith('TAIL')).toBe(true); + }); +}); diff --git a/expo-app/sources/components/tools/views/BashViewFull.tsx b/expo-app/sources/components/tools/views/BashViewFull.tsx index 730ce2ec4..4cd58e01a 100644 --- a/expo-app/sources/components/tools/views/BashViewFull.tsx +++ b/expo-app/sources/components/tools/views/BashViewFull.tsx @@ -1,10 +1,13 @@ import * as React from 'react'; -import { View, ScrollView, StyleSheet } from 'react-native'; +import { View, ScrollView } from 'react-native'; +import { StyleSheet } from 'react-native-unistyles'; import { ToolCall } from '@/sync/typesMessage'; import { Metadata } from '@/sync/storageTypes'; -import { knownTools } from '@/components/tools/knownTools'; import { toolFullViewStyles } from '../ToolFullView'; import { CommandView } from '@/components/CommandView'; +import { extractShellCommand } from '../utils/shellCommand'; +import { maybeParseJson } from '../utils/parseJson'; +import { extractStdStreams, tailTextWithEllipsis } from '../utils/stdStreams'; interface BashViewFullProps { tool: ToolCall; @@ -13,30 +16,34 @@ interface BashViewFullProps { export const BashViewFull = React.memo<BashViewFullProps>(({ tool, metadata }) => { const { input, result, state } = tool; + const command = extractShellCommand(input) ?? (typeof (input as any)?.command === 'string' ? (input as any).command : ''); // Parse the result - let parsedResult: { stdout?: string; stderr?: string } | null = null; + const parsedStreams = extractStdStreams(result); let unparsedOutput: string | null = null; let error: string | null = null; - if (state === 'completed' && result) { - if (typeof result === 'string') { - // Handle unparsed string result - unparsedOutput = result; - } else { - // Try to parse as structured result - const parsed = knownTools.Bash.result.safeParse(result); - if (parsed.success) { - parsedResult = parsed.data; - } else { - // If parsing fails but it's not a string, stringify it - unparsedOutput = JSON.stringify(result); - } - } - } else if (state === 'error' && typeof result === 'string') { + if (state === 'error' && typeof result === 'string') { error = result; + } else if (result) { + const parsedMaybe = maybeParseJson(result); + if (typeof parsedMaybe === 'string') { + unparsedOutput = parsedMaybe; + } else if (!parsedStreams) { + unparsedOutput = JSON.stringify(parsedMaybe); + } } + const maxStreamingChars = 8000; + const stdout = + parsedStreams?.stdout + ? (state === 'running' ? tailTextWithEllipsis(parsedStreams.stdout, maxStreamingChars) : parsedStreams.stdout) + : unparsedOutput; + const stderr = + parsedStreams?.stderr + ? (state === 'running' ? tailTextWithEllipsis(parsedStreams.stderr, maxStreamingChars) : parsedStreams.stderr) + : null; + return ( <View style={styles.container}> <View style={styles.terminalContainer}> @@ -47,9 +54,9 @@ export const BashViewFull = React.memo<BashViewFullProps>(({ tool, metadata }) = > <View style={styles.commandWrapper}> <CommandView - command={input.command} - stdout={parsedResult?.stdout || unparsedOutput} - stderr={parsedResult?.stderr} + command={command} + stdout={stdout} + stderr={stderr} error={error} fullWidth /> @@ -78,4 +85,4 @@ const styles = StyleSheet.create({ flex: 1, minWidth: '100%', }, -}); \ No newline at end of file +}); diff --git a/expo-app/sources/components/tools/views/CodeSearchView.tsx b/expo-app/sources/components/tools/views/CodeSearchView.tsx new file mode 100644 index 000000000..eca9e79ce --- /dev/null +++ b/expo-app/sources/components/tools/views/CodeSearchView.tsx @@ -0,0 +1,116 @@ +import * as React from 'react'; +import { View, Text } from 'react-native'; +import { StyleSheet } from 'react-native-unistyles'; +import type { ToolViewProps } from './_registry'; +import { ToolSectionView } from '../ToolSectionView'; +import { maybeParseJson } from '../utils/parseJson'; + +type SearchMatch = { file?: string; path?: string; line?: number; text?: string }; + +function asRecord(value: unknown): Record<string, unknown> | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + return value as Record<string, unknown>; +} + +function coerceMatches(value: unknown): SearchMatch[] { + const parsed = maybeParseJson(value); + + if (Array.isArray(parsed)) { + const out: SearchMatch[] = []; + for (const item of parsed) { + if (typeof item === 'string') { + out.push({ text: item }); + } else { + const obj = asRecord(item); + if (!obj) continue; + out.push({ + file: typeof obj.file === 'string' ? obj.file : undefined, + path: typeof obj.path === 'string' ? obj.path : (typeof obj.file_path === 'string' ? obj.file_path : undefined), + line: typeof obj.line === 'number' ? obj.line : (typeof obj.line_number === 'number' ? obj.line_number : undefined), + text: typeof obj.text === 'string' ? obj.text : (typeof obj.snippet === 'string' ? obj.snippet : undefined), + }); + } + } + return out; + } + + const obj = asRecord(parsed); + if (obj) { + const candidates = [obj.matches, obj.results, obj.items]; + for (const c of candidates) { + if (Array.isArray(c)) return coerceMatches(c); + } + if (typeof obj.stdout === 'string') { + return obj.stdout + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .map((text) => ({ text })); + } + } + + if (typeof parsed === 'string' && parsed.trim()) { + return parsed + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .map((text) => ({ text })); + } + + return []; +} + +export const CodeSearchView = React.memo<ToolViewProps>(({ tool }) => { + if (tool.state !== 'completed') return null; + const matches = coerceMatches(tool.result); + if (matches.length === 0) return null; + + const shown = matches.slice(0, 6); + const more = matches.length - shown.length; + + return ( + <ToolSectionView> + <View style={styles.container}> + {shown.map((m, idx) => { + const label = (m.path ?? m.file) + ? `${m.path ?? m.file}${typeof m.line === 'number' ? `:${m.line}` : ''}` + : null; + return ( + <View key={idx} style={styles.row}> + {label ? <Text style={styles.label} numberOfLines={1}>{label}</Text> : null} + {m.text ? <Text style={styles.text} numberOfLines={2}>{m.text}</Text> : null} + </View> + ); + })} + {more > 0 ? <Text style={styles.more}>+{more} more</Text> : null} + </View> + </ToolSectionView> + ); +}); + +const styles = StyleSheet.create((theme) => ({ + container: { + padding: 12, + borderRadius: 8, + backgroundColor: theme.colors.surfaceHigh, + gap: 10, + }, + row: { + gap: 4, + }, + label: { + fontSize: 12, + color: theme.colors.textSecondary, + fontFamily: 'Menlo', + }, + text: { + fontSize: 13, + color: theme.colors.text, + fontFamily: 'Menlo', + }, + more: { + fontSize: 12, + color: theme.colors.textSecondary, + fontFamily: 'Menlo', + }, +})); diff --git a/expo-app/sources/components/tools/views/CodexBashView.tsx b/expo-app/sources/components/tools/views/CodexBashView.tsx index 3617f669d..49f269067 100644 --- a/expo-app/sources/components/tools/views/CodexBashView.tsx +++ b/expo-app/sources/components/tools/views/CodexBashView.tsx @@ -1,21 +1,17 @@ import * as React from 'react'; import { View, Text } from 'react-native'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; -import { Ionicons, Octicons } from '@expo/vector-icons'; -import { ToolCall } from '@/sync/typesMessage'; +import { Octicons } from '@expo/vector-icons'; import { ToolSectionView } from '../ToolSectionView'; import { CommandView } from '@/components/CommandView'; -import { CodeView } from '@/components/CodeView'; import { Metadata } from '@/sync/storageTypes'; import { resolvePath } from '@/utils/pathUtils'; import { t } from '@/text'; +import type { ToolViewProps } from './_registry'; +import { extractStdStreams, tailTextWithEllipsis } from '../utils/stdStreams'; +import { StructuredResultView } from './StructuredResultView'; -interface CodexBashViewProps { - tool: ToolCall; - metadata: Metadata | null; -} - -export const CodexBashView = React.memo<CodexBashViewProps>(({ tool, metadata }) => { +export const CodexBashView = React.memo<ToolViewProps>(({ tool, metadata, messages, sessionId }) => { const { theme } = useUnistyles(); const { input, result, state } = tool; @@ -65,6 +61,7 @@ export const CodexBashView = React.memo<CodexBashViewProps>(({ tool, metadata }) <Text style={styles.commandText}>{commandStr}</Text> )} </View> + <StructuredResultView tool={tool} metadata={metadata} messages={messages} sessionId={sessionId} /> </ToolSectionView> ); } else if (operationType === 'write' && fileName) { @@ -82,18 +79,27 @@ export const CodexBashView = React.memo<CodexBashViewProps>(({ tool, metadata }) <Text style={styles.commandText}>{commandStr}</Text> )} </View> + <StructuredResultView tool={tool} metadata={metadata} messages={messages} sessionId={sessionId} /> </ToolSectionView> ); } else { // Display as a regular command const commandDisplay = commandStr || (command && Array.isArray(command) ? command.join(' ') : ''); + + const streams = extractStdStreams(result); + const stdout = streams?.stdout + ? tailTextWithEllipsis(streams.stdout, state === 'running' ? 2000 : 6000) + : null; + const stderr = streams?.stderr + ? tailTextWithEllipsis(streams.stderr, state === 'running' ? 1200 : 3000) + : null; return ( <ToolSectionView> <CommandView command={commandDisplay} - stdout={null} - stderr={null} + stdout={stdout} + stderr={stderr} error={state === 'error' && typeof result === 'string' ? result : null} hideEmptyOutput /> @@ -124,4 +130,4 @@ const styles = StyleSheet.create((theme) => ({ fontFamily: 'monospace', marginTop: 8, }, -})); \ No newline at end of file +})); diff --git a/expo-app/sources/components/tools/views/EditView.tsx b/expo-app/sources/components/tools/views/EditView.tsx index 242d7514d..67138e035 100644 --- a/expo-app/sources/components/tools/views/EditView.tsx +++ b/expo-app/sources/components/tools/views/EditView.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import { ToolSectionView } from '../../tools/ToolSectionView'; -import { ToolViewProps } from './_all'; +import { ToolViewProps } from './_registry'; import { ToolDiffView } from '@/components/tools/ToolDiffView'; import { knownTools } from '../../tools/knownTools'; import { trimIdent } from '@/utils/trimIdent'; @@ -30,4 +30,4 @@ export const EditView = React.memo<ToolViewProps>(({ tool }) => { </ToolSectionView> </> ); -}); \ No newline at end of file +}); diff --git a/expo-app/sources/components/tools/views/ExitPlanToolView.test.ts b/expo-app/sources/components/tools/views/ExitPlanToolView.test.ts new file mode 100644 index 000000000..cfe370fb7 --- /dev/null +++ b/expo-app/sources/components/tools/views/ExitPlanToolView.test.ts @@ -0,0 +1,283 @@ +import React from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import renderer, { act } from 'react-test-renderer'; +import type { ToolCall } from '@/sync/typesMessage'; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +const sessionAllow = vi.fn(); +const sessionDeny = vi.fn(); +const sendMessage = vi.fn(); +const modalAlert = vi.fn(); + +vi.mock('@/text', () => ({ + t: (key: string) => key, +})); + +vi.mock('@/modal', () => ({ + Modal: { + alert: (...args: any[]) => modalAlert(...args), + }, +})); + +vi.mock('react-native', () => ({ + View: 'View', + Text: 'Text', + TouchableOpacity: 'TouchableOpacity', + ActivityIndicator: 'ActivityIndicator', + TextInput: 'TextInput', +})); + +vi.mock('react-native-unistyles', () => ({ + StyleSheet: { create: (styles: any) => styles }, + useUnistyles: () => ({ + theme: { + colors: { + button: { primary: { background: '#00f', tint: '#fff' } }, + divider: '#ddd', + text: '#000', + textSecondary: '#666', + }, + }, + }), +})); + +vi.mock('@expo/vector-icons', () => ({ + Ionicons: 'Ionicons', +})); + +vi.mock('@/components/markdown/MarkdownView', () => ({ + MarkdownView: () => null, +})); + +vi.mock('../../tools/ToolSectionView', () => ({ + ToolSectionView: ({ children }: any) => React.createElement(React.Fragment, null, children), +})); + +vi.mock('../../tools/knownTools', () => ({ + knownTools: { + ExitPlanMode: { + input: { + safeParse: () => ({ success: true, data: { plan: 'plan' } }), + }, + }, + }, +})); + +vi.mock('@/sync/ops', () => ({ + sessionAllow: (...args: any[]) => sessionAllow(...args), + sessionDeny: (...args: any[]) => sessionDeny(...args), +})); + +vi.mock('@/sync/sync', () => ({ + sync: { + sendMessage: (...args: any[]) => sendMessage(...args), + }, +})); + +describe('ExitPlanToolView', () => { + beforeEach(() => { + sessionAllow.mockReset(); + sessionDeny.mockReset(); + sendMessage.mockReset(); + modalAlert.mockReset(); + }); + + it('approves via permission RPC and does not send a follow-up user message', async () => { + const { ExitPlanToolView } = await import('./ExitPlanToolView'); + + const tool: ToolCall = { + name: 'ExitPlanMode', + state: 'running', + input: { plan: 'plan' }, + createdAt: Date.now(), + startedAt: Date.now(), + completedAt: null, + description: null, + permission: { id: 'perm1', status: 'pending' }, + }; + + let tree: ReturnType<typeof renderer.create> | undefined; + await act(async () => { + tree = renderer.create( + React.createElement(ExitPlanToolView, { tool, sessionId: 's1', metadata: null, messages: [] }), + ); + }); + + await act(async () => { + await tree!.root.findByProps({ testID: 'exit-plan-approve' }).props.onPress(); + }); + + expect(sessionAllow).toHaveBeenCalledTimes(1); + expect(sendMessage).toHaveBeenCalledTimes(0); + }); + + it('rejects via permission RPC and does not send a follow-up user message', async () => { + const { ExitPlanToolView } = await import('./ExitPlanToolView'); + + const tool: ToolCall = { + name: 'ExitPlanMode', + state: 'running', + input: { plan: 'plan' }, + createdAt: Date.now(), + startedAt: Date.now(), + completedAt: null, + description: null, + permission: { id: 'perm1', status: 'pending' }, + }; + + let tree: ReturnType<typeof renderer.create> | undefined; + await act(async () => { + tree = renderer.create( + React.createElement(ExitPlanToolView, { tool, sessionId: 's1', metadata: null, messages: [] }), + ); + }); + + await act(async () => { + await tree!.root.findByProps({ testID: 'exit-plan-reject' }).props.onPress(); + }); + + expect(sessionDeny).toHaveBeenCalledTimes(1); + expect(sendMessage).toHaveBeenCalledTimes(0); + }); + + it('requests changes via permission RPC with a reason', async () => { + const { ExitPlanToolView } = await import('./ExitPlanToolView'); + + const tool: ToolCall = { + name: 'ExitPlanMode', + state: 'running', + input: { plan: 'plan' }, + createdAt: Date.now(), + startedAt: Date.now(), + completedAt: null, + description: null, + permission: { id: 'perm1', status: 'pending' }, + }; + + let tree: ReturnType<typeof renderer.create> | undefined; + await act(async () => { + tree = renderer.create( + React.createElement(ExitPlanToolView, { tool, sessionId: 's1', metadata: null, messages: [] }), + ); + }); + + await act(async () => { + await tree!.root.findByProps({ testID: 'exit-plan-request-changes' }).props.onPress(); + }); + + await act(async () => { + tree!.root.findByProps({ testID: 'exit-plan-request-changes-input' }).props.onChangeText('Please change step 2'); + }); + + await act(async () => { + await tree!.root.findByProps({ testID: 'exit-plan-request-changes-send' }).props.onPress(); + }); + + expect(sessionDeny).toHaveBeenCalledTimes(1); + expect(sessionDeny.mock.calls[0]?.[5]).toBe('Please change step 2'); + expect(sendMessage).toHaveBeenCalledTimes(0); + }); + + it('shows an error when requesting plan changes fails', async () => { + sessionDeny.mockRejectedValueOnce(new Error('network')); + + const { ExitPlanToolView } = await import('./ExitPlanToolView'); + + const tool: ToolCall = { + name: 'ExitPlanMode', + state: 'running', + input: { plan: 'plan' }, + createdAt: Date.now(), + startedAt: Date.now(), + completedAt: null, + description: null, + permission: { id: 'perm1', status: 'pending' }, + }; + + let tree: ReturnType<typeof renderer.create> | undefined; + await act(async () => { + tree = renderer.create( + React.createElement(ExitPlanToolView, { tool, sessionId: 's1', metadata: null, messages: [] }), + ); + }); + + await act(async () => { + await tree!.root.findByProps({ testID: 'exit-plan-request-changes' }).props.onPress(); + }); + + await act(async () => { + tree!.root.findByProps({ testID: 'exit-plan-request-changes-input' }).props.onChangeText('Please change step 2'); + }); + + await act(async () => { + await tree!.root.findByProps({ testID: 'exit-plan-request-changes-send' }).props.onPress(); + }); + + expect(modalAlert).toHaveBeenCalledWith('common.error', 'tools.exitPlanMode.requestChangesFailed'); + }); + + it('does not mark as responded when approve is pressed without a permission id', async () => { + const { ExitPlanToolView } = await import('./ExitPlanToolView'); + + const tool: ToolCall = { + name: 'ExitPlanMode', + state: 'running', + input: { plan: 'plan' }, + createdAt: Date.now(), + startedAt: Date.now(), + completedAt: null, + description: null, + permission: undefined, + }; + + let tree: ReturnType<typeof renderer.create> | undefined; + await act(async () => { + tree = renderer.create( + React.createElement(ExitPlanToolView, { tool, sessionId: 's1', metadata: null, messages: [] }), + ); + }); + + await act(async () => { + await tree!.root.findByProps({ testID: 'exit-plan-approve' }).props.onPress(); + }); + + expect(sessionAllow).toHaveBeenCalledTimes(0); + expect(modalAlert).toHaveBeenCalledWith('common.error', 'errors.missingPermissionId'); + + const buttonsAfter = tree!.root.findAllByType('TouchableOpacity' as any); + expect(buttonsAfter.length).toBeGreaterThanOrEqual(2); + }); + + it('does not mark as responded when reject is pressed without a permission id', async () => { + const { ExitPlanToolView } = await import('./ExitPlanToolView'); + + const tool: ToolCall = { + name: 'ExitPlanMode', + state: 'running', + input: { plan: 'plan' }, + createdAt: Date.now(), + startedAt: Date.now(), + completedAt: null, + description: null, + permission: undefined, + }; + + let tree: ReturnType<typeof renderer.create> | undefined; + await act(async () => { + tree = renderer.create( + React.createElement(ExitPlanToolView, { tool, sessionId: 's1', metadata: null, messages: [] }), + ); + }); + + await act(async () => { + await tree!.root.findByProps({ testID: 'exit-plan-reject' }).props.onPress(); + }); + + expect(sessionDeny).toHaveBeenCalledTimes(0); + expect(modalAlert).toHaveBeenCalledWith('common.error', 'errors.missingPermissionId'); + + const buttonsAfter = tree!.root.findAllByType('TouchableOpacity' as any); + expect(buttonsAfter.length).toBeGreaterThanOrEqual(2); + }); +}); diff --git a/expo-app/sources/components/tools/views/ExitPlanToolView.tsx b/expo-app/sources/components/tools/views/ExitPlanToolView.tsx index c36691ec9..6e7e18d0f 100644 --- a/expo-app/sources/components/tools/views/ExitPlanToolView.tsx +++ b/expo-app/sources/components/tools/views/ExitPlanToolView.tsx @@ -1,21 +1,340 @@ import * as React from 'react'; -import { ToolViewProps } from "./_all"; +import { View, Text, TouchableOpacity, ActivityIndicator, TextInput } from 'react-native'; +import { StyleSheet, useUnistyles } from 'react-native-unistyles'; +import { ToolViewProps } from './_registry'; import { ToolSectionView } from '../../tools/ToolSectionView'; import { MarkdownView } from '@/components/markdown/MarkdownView'; import { knownTools } from '../../tools/knownTools'; -import { View } from 'react-native'; +import { sessionAllow, sessionDeny } from '@/sync/ops'; +import { Modal } from '@/modal'; +import { t } from '@/text'; +import { Ionicons } from '@expo/vector-icons'; -export const ExitPlanToolView = React.memo<ToolViewProps>(({ tool }) => { - let plan = '<empty>' +export const ExitPlanToolView = React.memo<ToolViewProps>(({ tool, sessionId }) => { + const { theme } = useUnistyles(); + const [isApproving, setIsApproving] = React.useState(false); + const [isRejecting, setIsRejecting] = React.useState(false); + const [isResponded, setIsResponded] = React.useState(false); + const [isRequestingChanges, setIsRequestingChanges] = React.useState(false); + const [changeRequestText, setChangeRequestText] = React.useState(''); + const isSendingChangeRequest = isRequestingChanges && isRejecting; + + let plan = '<empty>'; const parsed = knownTools.ExitPlanMode.input.safeParse(tool.input); if (parsed.success) { plan = parsed.data.plan ?? '<empty>'; } + + const isRunning = tool.state === 'running'; + const canInteract = isRunning && !isResponded && sessionId; + + const handleApprove = React.useCallback(async () => { + if (!sessionId || isApproving || isRejecting || !canInteract) return; + const permissionId = tool.permission?.id; + if (!permissionId) { + Modal.alert(t('common.error'), t('errors.missingPermissionId')); + return; + } + + setIsApproving(true); + try { + await sessionAllow(sessionId, permissionId); + setIsResponded(true); + } catch (error) { + console.error('Failed to approve plan:', error); + } finally { + setIsApproving(false); + } + }, [sessionId, tool.permission?.id, canInteract, isApproving, isRejecting]); + + const handleReject = React.useCallback(async () => { + if (!sessionId || isApproving || isRejecting || !canInteract) return; + const permissionId = tool.permission?.id; + if (!permissionId) { + Modal.alert(t('common.error'), t('errors.missingPermissionId')); + return; + } + + setIsRejecting(true); + try { + await sessionDeny(sessionId, permissionId); + setIsResponded(true); + } catch (error) { + console.error('Failed to reject plan:', error); + } finally { + setIsRejecting(false); + } + }, [sessionId, tool.permission?.id, canInteract, isApproving, isRejecting]); + + const handleRequestChanges = React.useCallback(() => { + if (!canInteract || isApproving || isRejecting) return; + setIsRequestingChanges(true); + }, [canInteract, isApproving, isRejecting]); + + const handleCancelRequestChanges = React.useCallback(() => { + if (isApproving || isRejecting) return; + setIsRequestingChanges(false); + setChangeRequestText(''); + }, [isApproving, isRejecting]); + + const handleSendChangeRequest = React.useCallback(async () => { + if (!sessionId || isApproving || isRejecting || !canInteract) return; + const permissionId = tool.permission?.id; + if (!permissionId) { + Modal.alert(t('common.error'), t('errors.missingPermissionId')); + return; + } + + const trimmed = changeRequestText.trim(); + if (!trimmed) { + Modal.alert(t('common.error'), t('tools.exitPlanMode.requestChangesEmpty')); + return; + } + + setIsRejecting(true); + try { + await sessionDeny(sessionId, permissionId, undefined, undefined, undefined, trimmed); + setIsResponded(true); + } catch (error) { + console.error('Failed to request plan changes:', error); + Modal.alert(t('common.error'), t('tools.exitPlanMode.requestChangesFailed')); + } finally { + setIsRejecting(false); + } + }, [sessionId, tool.permission?.id, canInteract, isApproving, isRejecting, changeRequestText]); + + const styles = StyleSheet.create({ + container: { + gap: 16, + }, + planContainer: { + paddingHorizontal: 8, + marginTop: -10, + }, + actionsContainer: { + flexDirection: 'row', + gap: 12, + marginTop: 16, + paddingHorizontal: 8, + justifyContent: 'flex-end', + }, + feedbackContainer: { + paddingHorizontal: 8, + gap: 10, + }, + feedbackInput: { + borderWidth: 1, + borderColor: theme.colors.divider, + borderRadius: 10, + paddingHorizontal: 12, + paddingVertical: 10, + minHeight: 88, + color: theme.colors.text, + textAlignVertical: 'top', + }, + feedbackActions: { + flexDirection: 'row', + gap: 12, + justifyContent: 'flex-end', + }, + approveButton: { + backgroundColor: theme.colors.button.primary.background, + paddingHorizontal: 20, + paddingVertical: 12, + borderRadius: 8, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 6, + minHeight: 44, + }, + rejectButton: { + backgroundColor: 'transparent', + borderWidth: 1, + borderColor: theme.colors.divider, + paddingHorizontal: 20, + paddingVertical: 12, + borderRadius: 8, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 6, + minHeight: 44, + }, + buttonDisabled: { + opacity: 0.5, + }, + approveButtonText: { + color: theme.colors.button.primary.tint, + fontSize: 14, + fontWeight: '600', + }, + rejectButtonText: { + color: theme.colors.text, + fontSize: 14, + fontWeight: '600', + }, + requestChangesButton: { + backgroundColor: 'transparent', + borderWidth: 1, + borderColor: theme.colors.divider, + paddingHorizontal: 16, + paddingVertical: 12, + borderRadius: 8, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 6, + minHeight: 44, + }, + requestChangesButtonText: { + color: theme.colors.text, + fontSize: 14, + fontWeight: '600', + }, + respondedContainer: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + paddingHorizontal: 8, + marginTop: 12, + }, + respondedText: { + fontSize: 14, + color: theme.colors.textSecondary, + }, + }); + return ( <ToolSectionView> - <View style={{ paddingHorizontal: 8, marginTop: -10 }}> - <MarkdownView markdown={plan} /> + <View style={styles.container}> + <View style={styles.planContainer}> + <MarkdownView markdown={plan} /> + </View> + + {isResponded || tool.state === 'completed' ? ( + <View style={styles.respondedContainer}> + <Ionicons + name="checkmark-circle" + size={18} + color={theme.colors.textSecondary} + /> + <Text style={styles.respondedText}> + {t('tools.exitPlanMode.responded')} + </Text> + </View> + ) : canInteract ? ( + <> + {isRequestingChanges ? ( + <View style={styles.feedbackContainer}> + <TextInput + testID="exit-plan-request-changes-input" + style={styles.feedbackInput} + value={changeRequestText} + onChangeText={setChangeRequestText} + placeholder={t('tools.exitPlanMode.requestChangesPlaceholder')} + placeholderTextColor={theme.colors.textSecondary} + multiline + editable={!isApproving && !isRejecting} + /> + <View style={styles.feedbackActions}> + <TouchableOpacity + testID="exit-plan-request-changes-cancel" + style={[ + styles.rejectButton, + (isApproving || isRejecting) && styles.buttonDisabled, + ]} + onPress={handleCancelRequestChanges} + disabled={isApproving || isRejecting} + activeOpacity={0.7} + > + <Text style={styles.rejectButtonText}> + {t('common.cancel')} + </Text> + </TouchableOpacity> + <TouchableOpacity + testID="exit-plan-request-changes-send" + style={[ + styles.approveButton, + (isApproving || isRejecting || !changeRequestText.trim()) && styles.buttonDisabled, + ]} + onPress={handleSendChangeRequest} + disabled={isApproving || isRejecting || !changeRequestText.trim()} + activeOpacity={0.7} + > + {isSendingChangeRequest ? ( + <ActivityIndicator size="small" color={theme.colors.button.primary.tint} /> + ) : ( + <Text style={styles.approveButtonText}> + {t('tools.exitPlanMode.requestChangesSend')} + </Text> + )} + </TouchableOpacity> + </View> + </View> + ) : ( + <View style={styles.actionsContainer}> + <TouchableOpacity + testID="exit-plan-reject" + style={[ + styles.rejectButton, + (isApproving || isRejecting) && styles.buttonDisabled, + ]} + onPress={handleReject} + disabled={isApproving || isRejecting} + activeOpacity={0.7} + > + {isRejecting ? ( + <ActivityIndicator size="small" color={theme.colors.text} /> + ) : ( + <> + <Ionicons name="close" size={18} color={theme.colors.text} /> + <Text style={styles.rejectButtonText}> + {t('tools.exitPlanMode.reject')} + </Text> + </> + )} + </TouchableOpacity> + <TouchableOpacity + testID="exit-plan-request-changes" + style={[ + styles.requestChangesButton, + (isApproving || isRejecting) && styles.buttonDisabled, + ]} + onPress={handleRequestChanges} + disabled={isApproving || isRejecting} + activeOpacity={0.7} + > + <Text style={styles.requestChangesButtonText}> + {t('tools.exitPlanMode.requestChanges')} + </Text> + </TouchableOpacity> + <TouchableOpacity + testID="exit-plan-approve" + style={[ + styles.approveButton, + (isApproving || isRejecting) && styles.buttonDisabled, + ]} + onPress={handleApprove} + disabled={isApproving || isRejecting} + activeOpacity={0.7} + > + {isApproving ? ( + <ActivityIndicator size="small" color={theme.colors.button.primary.tint} /> + ) : ( + <> + <Ionicons name="checkmark" size={18} color={theme.colors.button.primary.tint} /> + <Text style={styles.approveButtonText}> + {t('tools.exitPlanMode.approve')} + </Text> + </> + )} + </TouchableOpacity> + </View> + )} + </> + ) : null} </View> </ToolSectionView> ); -}); \ No newline at end of file +}); diff --git a/expo-app/sources/components/tools/views/GeminiEditView.tsx b/expo-app/sources/components/tools/views/GeminiEditView.tsx index 9a4255634..89f719324 100644 --- a/expo-app/sources/components/tools/views/GeminiEditView.tsx +++ b/expo-app/sources/components/tools/views/GeminiEditView.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import { ToolSectionView } from '../../tools/ToolSectionView'; -import { ToolViewProps } from './_all'; +import { ToolViewProps } from './_registry'; import { ToolDiffView } from '@/components/tools/ToolDiffView'; import { trimIdent } from '@/utils/trimIdent'; import { useSetting } from '@/sync/storage'; @@ -72,4 +72,3 @@ export const GeminiEditView = React.memo<ToolViewProps>(({ tool }) => { </> ); }); - diff --git a/expo-app/sources/components/tools/views/GeminiExecuteView.test.ts b/expo-app/sources/components/tools/views/GeminiExecuteView.test.ts new file mode 100644 index 000000000..574214794 --- /dev/null +++ b/expo-app/sources/components/tools/views/GeminiExecuteView.test.ts @@ -0,0 +1,105 @@ +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import renderer, { act } from 'react-test-renderer'; +import type { ToolCall } from '@/sync/typesMessage'; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +const commandViewSpy = vi.fn(); + +vi.mock('react-native', () => ({ + View: 'View', + Text: 'Text', + StyleSheet: { create: (styles: any) => styles }, +})); + +vi.mock('react-native-unistyles', () => ({ + StyleSheet: { create: (styles: any) => styles }, + useUnistyles: () => ({ + theme: { + colors: { + surfaceHigh: '#fff', + text: '#000', + textSecondary: '#666', + }, + }, + }), +})); + +vi.mock('@/text', () => ({ + t: (key: string) => key, +})); + +vi.mock('@/components/CommandView', () => ({ + CommandView: (props: any) => { + commandViewSpy(props); + return React.createElement('CommandView', props); + }, +})); + +vi.mock('../../tools/ToolSectionView', () => ({ + ToolSectionView: ({ children }: any) => React.createElement(React.Fragment, null, children), +})); + +describe('GeminiExecuteView', () => { + it('renders structured stdout when tool result includes stdout', async () => { + commandViewSpy.mockReset(); + const { GeminiExecuteView } = await import('./GeminiExecuteView'); + + const tool: ToolCall = { + name: 'execute', + state: 'completed', + input: { + toolCall: { + title: 'echo hi [current working directory /tmp] (desc)', + }, + }, + result: { stdout: 'hi\n' }, + createdAt: Date.now(), + startedAt: Date.now(), + completedAt: Date.now(), + description: null, + permission: undefined, + }; + + let tree: ReturnType<typeof renderer.create> | undefined; + await act(async () => { + tree = renderer.create( + React.createElement(GeminiExecuteView, { tool, metadata: null, messages: [], sessionId: 'test-session' }), + ); + }); + + expect(commandViewSpy).toHaveBeenCalled(); + const lastCallArgs = commandViewSpy.mock.calls.at(-1)?.[0]; + expect(lastCallArgs?.stdout).toBe('hi\n'); + expect(typeof lastCallArgs?.command).toBe('string'); + expect(lastCallArgs?.command).toContain('echo hi'); + }); + + it('renders string tool result as stdout fallback', async () => { + commandViewSpy.mockReset(); + const { GeminiExecuteView } = await import('./GeminiExecuteView'); + + const tool: ToolCall = { + name: 'execute', + state: 'completed', + input: { command: 'echo hi' }, + result: 'hi\n' as any, + createdAt: Date.now(), + startedAt: Date.now(), + completedAt: Date.now(), + description: null, + permission: undefined, + }; + + await act(async () => { + renderer.create( + React.createElement(GeminiExecuteView, { tool, metadata: null, messages: [], sessionId: 'test-session' }), + ); + }); + + expect(commandViewSpy).toHaveBeenCalled(); + const lastCallArgs = commandViewSpy.mock.calls.at(-1)?.[0]; + expect(lastCallArgs?.stdout).toBe('hi\n'); + }); +}); diff --git a/expo-app/sources/components/tools/views/GeminiExecuteView.tsx b/expo-app/sources/components/tools/views/GeminiExecuteView.tsx index 86fe20e84..1fe03d574 100644 --- a/expo-app/sources/components/tools/views/GeminiExecuteView.tsx +++ b/expo-app/sources/components/tools/views/GeminiExecuteView.tsx @@ -2,8 +2,11 @@ import * as React from 'react'; import { View, Text } from 'react-native'; import { StyleSheet } from 'react-native-unistyles'; import { ToolSectionView } from '../../tools/ToolSectionView'; -import { ToolViewProps } from './_all'; -import { CodeView } from '@/components/CodeView'; +import { ToolViewProps } from './_registry'; +import { t } from '@/text'; +import { CommandView } from '@/components/CommandView'; +import { extractShellCommand } from '../utils/shellCommand'; +import { extractStdStreams, tailTextWithEllipsis } from '../utils/stdStreams'; /** * Extract execute command info from Gemini's nested input format. @@ -47,22 +50,56 @@ function extractExecuteInfo(input: any): { command: string; description: string; * * Displays shell/terminal commands from Gemini's execute tool. */ -export const GeminiExecuteView = React.memo<ToolViewProps>(({ tool }) => { - const { command, description, cwd } = extractExecuteInfo(tool.input); +export const GeminiExecuteView = React.memo<ToolViewProps>(({ tool, metadata, messages, sessionId }) => { + const nested = extractExecuteInfo(tool.input); + const command = nested.command || extractShellCommand(tool.input) || ''; + const { description, cwd } = nested; if (!command) { return null; } + const streams = extractStdStreams(tool.result); + const rawResult = tool.result as any; + const stdoutFallback = + typeof rawResult === 'string' + ? rawResult + : typeof rawResult?.stdout === 'string' + ? rawResult.stdout + : typeof rawResult?.formatted_output === 'string' + ? rawResult.formatted_output + : typeof rawResult?.aggregated_output === 'string' + ? rawResult.aggregated_output + : null; + const stderrFallback = + typeof rawResult?.stderr === 'string' + ? rawResult.stderr + : null; + const maxStdout = tool.state === 'running' ? 2000 : 6000; + const maxStderr = tool.state === 'running' ? 1200 : 3000; + const stdout = (streams?.stdout ?? stdoutFallback) + ? tailTextWithEllipsis((streams?.stdout ?? stdoutFallback) as string, maxStdout) + : null; + const stderr = (streams?.stderr ?? stderrFallback) + ? tailTextWithEllipsis((streams?.stderr ?? stderrFallback) as string, maxStderr) + : null; + return ( <> - <ToolSectionView fullWidth> - <CodeView code={command} /> + <ToolSectionView> + <CommandView + command={command} + stdout={stdout} + stderr={stderr} + error={null} + hideEmptyOutput + fullWidth + /> </ToolSectionView> {(description || cwd) && ( <View style={styles.infoContainer}> {cwd && ( - <Text style={styles.cwdText}>📁 {cwd}</Text> + <Text style={styles.cwdText}>{t('tools.geminiExecute.cwd', { cwd })}</Text> )} {description && ( <Text style={styles.descriptionText}>{description}</Text> @@ -89,4 +126,3 @@ const styles = StyleSheet.create((theme) => ({ fontStyle: 'italic', }, })); - diff --git a/expo-app/sources/components/tools/views/GlobView.tsx b/expo-app/sources/components/tools/views/GlobView.tsx new file mode 100644 index 000000000..410f0923e --- /dev/null +++ b/expo-app/sources/components/tools/views/GlobView.tsx @@ -0,0 +1,77 @@ +import * as React from 'react'; +import { View, Text } from 'react-native'; +import { StyleSheet, useUnistyles } from 'react-native-unistyles'; +import { ToolSectionView } from '../ToolSectionView'; +import type { ToolViewProps } from './_registry'; +import { maybeParseJson } from '../utils/parseJson'; + +function coerceStringArray(value: unknown): string[] | null { + if (!Array.isArray(value)) return null; + const out: string[] = []; + for (const item of value) { + if (typeof item !== 'string') return null; + out.push(item); + } + return out; +} + +function getGlobMatches(result: unknown): string[] { + const parsed = maybeParseJson(result); + + const direct = coerceStringArray(parsed); + if (direct) return direct; + + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + const obj = parsed as Record<string, unknown>; + const candidates = [obj.files, obj.matches, obj.paths, obj.results]; + for (const candidate of candidates) { + const arr = coerceStringArray(candidate); + if (arr) return arr; + } + } + + return []; +} + +export const GlobView = React.memo<ToolViewProps>(({ tool }) => { + const { theme } = useUnistyles(); + if (tool.state !== 'completed') return null; + + const matches = getGlobMatches(tool.result); + if (matches.length === 0) return null; + + const max = 8; + const shown = matches.slice(0, max); + const more = matches.length - shown.length; + + return ( + <ToolSectionView> + <View style={styles.container}> + {shown.map((path, idx) => ( + <Text key={`${idx}-${path}`} style={styles.path} numberOfLines={1}> + {path} + </Text> + ))} + {more > 0 && ( + <Text style={[styles.path, { color: theme.colors.textSecondary }]}> + +{more} more + </Text> + )} + </View> + </ToolSectionView> + ); +}); + +const styles = StyleSheet.create((theme) => ({ + container: { + padding: 12, + borderRadius: 8, + backgroundColor: theme.colors.surfaceHigh, + gap: 6, + }, + path: { + fontSize: 13, + color: theme.colors.text, + fontFamily: 'Menlo', + }, +})); diff --git a/expo-app/sources/components/tools/views/GrepView.tsx b/expo-app/sources/components/tools/views/GrepView.tsx new file mode 100644 index 000000000..c93b545c9 --- /dev/null +++ b/expo-app/sources/components/tools/views/GrepView.tsx @@ -0,0 +1,113 @@ +import * as React from 'react'; +import { View, Text } from 'react-native'; +import { StyleSheet } from 'react-native-unistyles'; +import { ToolSectionView } from '../ToolSectionView'; +import type { ToolViewProps } from './_registry'; +import { maybeParseJson } from '../utils/parseJson'; + +type GrepMatch = { file?: string; path?: string; line?: number; text?: string }; + +function coerceMatches(value: unknown): GrepMatch[] { + const parsed = maybeParseJson(value); + + if (Array.isArray(parsed)) { + const out: GrepMatch[] = []; + for (const item of parsed) { + if (typeof item === 'string') { + out.push({ text: item }); + } else if (item && typeof item === 'object' && !Array.isArray(item)) { + const obj = item as Record<string, unknown>; + out.push({ + file: typeof obj.file === 'string' ? obj.file : undefined, + path: typeof obj.path === 'string' ? obj.path : undefined, + line: typeof obj.line === 'number' ? obj.line : undefined, + text: typeof obj.text === 'string' ? obj.text : undefined, + }); + } + } + return out; + } + + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + const obj = parsed as Record<string, unknown>; + const candidates = [obj.matches, obj.results, obj.items]; + for (const candidate of candidates) { + if (Array.isArray(candidate)) { + return coerceMatches(candidate); + } + } + if (typeof obj.stdout === 'string') { + return obj.stdout + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .map((text) => ({ text })); + } + } + + if (typeof parsed === 'string' && parsed.trim()) { + return parsed + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .map((text) => ({ text })); + } + + return []; +} + +export const GrepView = React.memo<ToolViewProps>(({ tool }) => { + if (tool.state !== 'completed') return null; + const matches = coerceMatches(tool.result); + if (matches.length === 0) return null; + + const max = 6; + const shown = matches.slice(0, max); + const more = matches.length - shown.length; + + return ( + <ToolSectionView> + <View style={styles.container}> + {shown.map((m, idx) => { + const label = (m.path ?? m.file) + ? `${m.path ?? m.file}${typeof m.line === 'number' ? `:${m.line}` : ''}` + : null; + return ( + <View key={idx} style={styles.row}> + {label ? <Text style={styles.label} numberOfLines={1}>{label}</Text> : null} + {m.text ? <Text style={styles.text} numberOfLines={2}>{m.text}</Text> : null} + </View> + ); + })} + {more > 0 && <Text style={styles.more}>+{more} more</Text>} + </View> + </ToolSectionView> + ); +}); + +const styles = StyleSheet.create((theme) => ({ + container: { + padding: 12, + borderRadius: 8, + backgroundColor: theme.colors.surfaceHigh, + gap: 10, + }, + row: { + gap: 4, + }, + label: { + fontSize: 12, + color: theme.colors.textSecondary, + fontFamily: 'Menlo', + }, + text: { + fontSize: 13, + color: theme.colors.text, + fontFamily: 'Menlo', + }, + more: { + fontSize: 12, + color: theme.colors.textSecondary, + fontFamily: 'Menlo', + }, +})); diff --git a/expo-app/sources/components/tools/views/MultiEditView.tsx b/expo-app/sources/components/tools/views/MultiEditView.tsx index 8d4b5d6c7..9938c4bfa 100644 --- a/expo-app/sources/components/tools/views/MultiEditView.tsx +++ b/expo-app/sources/components/tools/views/MultiEditView.tsx @@ -1,7 +1,8 @@ import * as React from 'react'; -import { View, Text, StyleSheet, ScrollView } from 'react-native'; +import { View, Text, ScrollView } from 'react-native'; +import { StyleSheet } from 'react-native-unistyles'; import { ToolSectionView } from '../../tools/ToolSectionView'; -import { ToolViewProps } from './_all'; +import { ToolViewProps } from './_registry'; import { DiffView } from '@/components/diff/DiffView'; import { knownTools } from '../../tools/knownTools'; import { trimIdent } from '@/utils/trimIdent'; @@ -73,4 +74,4 @@ const styles = StyleSheet.create({ separator: { height: 8, }, -}); \ No newline at end of file +}); diff --git a/expo-app/sources/components/tools/views/MultiEditViewFull.tsx b/expo-app/sources/components/tools/views/MultiEditViewFull.tsx index 13b521fb9..33eca9813 100644 --- a/expo-app/sources/components/tools/views/MultiEditViewFull.tsx +++ b/expo-app/sources/components/tools/views/MultiEditViewFull.tsx @@ -1,5 +1,6 @@ import * as React from 'react'; -import { View, Text, StyleSheet, ScrollView } from 'react-native'; +import { View, Text, ScrollView } from 'react-native'; +import { StyleSheet } from 'react-native-unistyles'; import { ToolCall } from '@/sync/typesMessage'; import { Metadata } from '@/sync/storageTypes'; import { knownTools } from '@/components/tools/knownTools'; diff --git a/expo-app/sources/components/tools/views/ReadView.tsx b/expo-app/sources/components/tools/views/ReadView.tsx new file mode 100644 index 000000000..f1ef2ac20 --- /dev/null +++ b/expo-app/sources/components/tools/views/ReadView.tsx @@ -0,0 +1,66 @@ +import * as React from 'react'; +import { View, Text } from 'react-native'; +import { StyleSheet } from 'react-native-unistyles'; +import { ToolSectionView } from '../ToolSectionView'; +import type { ToolViewProps } from './_registry'; +import { CodeView } from '@/components/CodeView'; +import { maybeParseJson } from '../utils/parseJson'; + +function extractReadContent(result: unknown): { content: string; numLines?: number } | null { + const parsed = maybeParseJson(result); + if (typeof parsed === 'string' && parsed.trim().length > 0) { + return { content: parsed }; + } + + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + const obj = parsed as Record<string, unknown>; + const file = (obj.file && typeof obj.file === 'object' && !Array.isArray(obj.file)) ? (obj.file as Record<string, unknown>) : null; + const content = (file && typeof file.content === 'string') + ? file.content + : (typeof obj.content === 'string') + ? obj.content + : null; + if (!content) return null; + + const numLines = typeof file?.numLines === 'number' ? (file.numLines as number) : undefined; + return { content, numLines }; + } + + return null; +} + +function truncateLines(text: string, maxLines: number): { text: string; truncated: boolean } { + const lines = text.replace(/\r\n/g, '\n').split('\n'); + if (lines.length <= maxLines) return { text, truncated: false }; + return { text: lines.slice(0, maxLines).join('\n'), truncated: true }; +} + +export const ReadView = React.memo<ToolViewProps>(({ tool }) => { + if (tool.state !== 'completed') return null; + const extracted = extractReadContent(tool.result); + if (!extracted) return null; + + const { text, truncated } = truncateLines(extracted.content, 20); + return ( + <ToolSectionView fullWidth> + <View style={styles.container}> + <CodeView code={text} /> + {truncated ? <Text style={styles.more}>…</Text> : null} + </View> + </ToolSectionView> + ); +}); + +const styles = StyleSheet.create((theme) => ({ + container: { + padding: 12, + borderRadius: 8, + backgroundColor: theme.colors.surfaceHigh, + gap: 6, + }, + more: { + fontSize: 12, + color: theme.colors.textSecondary, + fontFamily: 'Menlo', + }, +})); diff --git a/expo-app/sources/components/tools/views/ReasoningView.test.tsx b/expo-app/sources/components/tools/views/ReasoningView.test.tsx new file mode 100644 index 000000000..34f71417e --- /dev/null +++ b/expo-app/sources/components/tools/views/ReasoningView.test.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import renderer, { act } from 'react-test-renderer'; +import type { ToolCall } from '@/sync/typesMessage'; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +const markdownViewSpy = vi.fn(); + +vi.mock('react-native', () => ({ + View: 'View', + Text: 'Text', +})); + +vi.mock('react-native-unistyles', () => ({ + StyleSheet: { create: (styles: any) => styles }, +})); + +vi.mock('@/components/markdown/MarkdownView', () => ({ + MarkdownView: (props: any) => { + markdownViewSpy(props); + return React.createElement('MarkdownView', props); + }, +})); + +vi.mock('../../tools/ToolSectionView', () => ({ + ToolSectionView: ({ children }: any) => React.createElement(React.Fragment, null, children), +})); + +describe('ReasoningView', () => { + it('renders tool.result.content as markdown', async () => { + markdownViewSpy.mockReset(); + const { ReasoningView } = await import('./ReasoningView'); + + const tool: ToolCall = { + name: 'GeminiReasoning', + state: 'completed', + input: { title: 'Thinking' }, + result: { content: 'Hello **world**' } as any, + createdAt: Date.now(), + startedAt: Date.now(), + completedAt: Date.now(), + description: null, + permission: undefined, + }; + + await act(async () => { + renderer.create(React.createElement(ReasoningView, { tool, metadata: null, messages: [], sessionId: 's1' })); + }); + + expect(markdownViewSpy).toHaveBeenCalled(); + const lastCall = markdownViewSpy.mock.calls.at(-1)?.[0]; + expect(lastCall?.markdown).toBe('Hello **world**'); + }); +}); + diff --git a/expo-app/sources/components/tools/views/ReasoningView.tsx b/expo-app/sources/components/tools/views/ReasoningView.tsx new file mode 100644 index 000000000..2ff32448a --- /dev/null +++ b/expo-app/sources/components/tools/views/ReasoningView.tsx @@ -0,0 +1,30 @@ +import * as React from 'react'; +import { View } from 'react-native'; +import type { ToolViewProps } from './_registry'; +import { ToolSectionView } from '../../tools/ToolSectionView'; +import { MarkdownView } from '@/components/markdown/MarkdownView'; + +function extractReasoningMarkdown(result: unknown): string | null { + if (!result) return null; + if (typeof result === 'string') return result; + if (typeof result === 'object' && !Array.isArray(result)) { + const obj = result as Record<string, unknown>; + if (typeof obj.content === 'string') return obj.content; + if (typeof obj.text === 'string') return obj.text; + if (typeof obj.reasoning === 'string') return obj.reasoning; + } + return null; +} + +export const ReasoningView = React.memo<ToolViewProps>(({ tool }) => { + const markdown = extractReasoningMarkdown(tool.result); + if (!markdown) return null; + + return ( + <ToolSectionView> + <View style={{ width: '100%' }}> + <MarkdownView markdown={markdown} /> + </View> + </ToolSectionView> + ); +}); diff --git a/expo-app/sources/components/tools/views/StructuredResultView.tsx b/expo-app/sources/components/tools/views/StructuredResultView.tsx new file mode 100644 index 000000000..d0afdcebc --- /dev/null +++ b/expo-app/sources/components/tools/views/StructuredResultView.tsx @@ -0,0 +1,223 @@ +import * as React from 'react'; +import { View, Text } from 'react-native'; +import { StyleSheet, useUnistyles } from 'react-native-unistyles'; +import type { ToolViewProps } from './_registry'; +import { ToolSectionView } from '../ToolSectionView'; +import { CodeView } from '@/components/CodeView'; +import { maybeParseJson } from '../utils/parseJson'; +import { tailTextWithEllipsis } from '../utils/stdStreams'; + +function truncate(text: string, maxChars: number): string { + if (text.length <= maxChars) return text; + return text.slice(0, Math.max(0, maxChars - 1)) + '…'; +} + +function asRecord(value: unknown): Record<string, unknown> | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + return value as Record<string, unknown>; +} + +function asString(value: unknown): string | null { + return typeof value === 'string' ? value : null; +} + +function asNumber(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) ? value : null; +} + +function coerceStringArray(value: unknown): string[] | null { + if (!Array.isArray(value)) return null; + const out: string[] = []; + for (const item of value) { + if (typeof item !== 'string') return null; + out.push(item); + } + return out; +} + +function coerceTextFromBlockArray(value: unknown): string | null { + if (!Array.isArray(value)) return null; + const parts: string[] = []; + for (const item of value) { + if (typeof item === 'string') { + if (item.trim()) parts.push(item); + continue; + } + const obj = asRecord(item); + if (!obj) continue; + const text = asString(obj.text) ?? asString(obj.content) ?? asString(obj.message); + if (text && text.trim()) parts.push(text); + } + if (parts.length === 0) return null; + return parts.join('\n'); +} + +function getStdStreams(result: unknown): { stdout?: string; stderr?: string; exitCode?: number } | null { + const parsed = maybeParseJson(result); + const obj = asRecord(parsed); + if (!obj) return null; + + const stdout = asString(obj.stdout) ?? asString(obj.out) ?? undefined; + const stderr = asString(obj.stderr) ?? asString(obj.err) ?? undefined; + const exitCode = asNumber(obj.exitCode) ?? asNumber(obj.code) ?? undefined; + if (!stdout && !stderr && typeof exitCode !== 'number') return null; + return { stdout, stderr, exitCode }; +} + +function getDiff(result: unknown): string | null { + const parsed = maybeParseJson(result); + const obj = asRecord(parsed); + if (obj && typeof obj.diff === 'string' && obj.diff.trim()) return obj.diff; + return null; +} + +function getPaths(result: unknown): string[] { + const parsed = maybeParseJson(result); + const obj = asRecord(parsed); + if (obj) { + const candidates = [obj.paths, obj.files, obj.matches]; + for (const c of candidates) { + const arr = coerceStringArray(c); + if (arr) return arr; + } + } + const direct = coerceStringArray(parsed); + return direct ?? []; +} + +function getText(result: unknown): string | null { + const parsed = maybeParseJson(result); + if (typeof parsed === 'string' && parsed.trim()) return parsed; + const obj = asRecord(parsed); + if (!obj) return null; + if (typeof obj.error === 'string' && obj.error.trim()) return obj.error; + if (typeof obj.reason === 'string' && obj.reason.trim()) return obj.reason; + if (obj.error && typeof obj.error === 'object') { + const errObj = asRecord(obj.error); + const msg = errObj ? asString(errObj.message) : null; + if (msg && msg.trim()) return msg; + } + const candidates = [ + obj.text, + obj.content, + obj.body, + obj.markdown, + obj.message, + ]; + for (const c of candidates) { + if (typeof c === 'string' && c.trim()) return c; + const blockText = coerceTextFromBlockArray(c); + if (blockText && blockText.trim()) return blockText; + } + return null; +} + +export const StructuredResultView = React.memo<ToolViewProps>(({ tool }) => { + const { theme } = useUnistyles(); + if (tool.state !== 'completed' && tool.state !== 'running') return null; + if (!tool.result) return null; + + const streams = getStdStreams(tool.result); + const diff = getDiff(tool.result); + const paths = getPaths(tool.result); + const text = getText(tool.result); + + // When running, only render stdio-like streams (avoid showing partial diffs/paths). + if (tool.state === 'running' && !streams) return null; + + if (!streams && !diff && paths.length === 0 && !text) return null; + + return ( + <ToolSectionView> + <View style={styles.container}> + {typeof streams?.exitCode === 'number' && ( + <Text style={[styles.meta, { color: theme.colors.textSecondary }]}> + exit {streams.exitCode} + </Text> + )} + + {streams?.stdout && streams.stdout.trim() ? ( + <View style={styles.block}> + <Text style={styles.label}>stdout</Text> + <CodeView + code={ + tool.state === 'running' + ? tailTextWithEllipsis(streams.stdout, 1200) + : truncate(streams.stdout, 2000) + } + /> + </View> + ) : null} + + {streams?.stderr && streams.stderr.trim() ? ( + <View style={styles.block}> + <Text style={styles.label}>stderr</Text> + <CodeView + code={ + tool.state === 'running' + ? tailTextWithEllipsis(streams.stderr, 900) + : truncate(streams.stderr, 1200) + } + /> + </View> + ) : null} + + {diff && ( + <View style={styles.block}> + <Text style={styles.label}>diff</Text> + <CodeView code={truncate(diff, 2200)} /> + </View> + )} + + {!streams?.stdout && !streams?.stderr && !diff && text && ( + <View style={styles.block}> + <Text style={styles.label}>result</Text> + <CodeView code={truncate(text, 2200)} /> + </View> + )} + + {paths.length > 0 && ( + <View style={styles.block}> + <Text style={styles.label}>items</Text> + {paths.slice(0, 8).map((p, idx) => ( + <Text key={`${idx}-${p}`} style={styles.path} numberOfLines={1}> + {p} + </Text> + ))} + {paths.length > 8 && ( + <Text style={[styles.meta, { color: theme.colors.textSecondary }]}> + +{paths.length - 8} more + </Text> + )} + </View> + )} + </View> + </ToolSectionView> + ); +}); + +const styles = StyleSheet.create((theme) => ({ + container: { + padding: 12, + borderRadius: 8, + backgroundColor: theme.colors.surfaceHigh, + gap: 10, + }, + block: { + gap: 6, + }, + label: { + fontSize: 12, + color: theme.colors.textSecondary, + fontFamily: 'Menlo', + }, + path: { + fontSize: 13, + color: theme.colors.text, + fontFamily: 'Menlo', + }, + meta: { + fontSize: 12, + fontFamily: 'Menlo', + }, +})); diff --git a/expo-app/sources/components/tools/views/TaskView.tsx b/expo-app/sources/components/tools/views/TaskView.tsx index ff3e0a807..3dc278487 100644 --- a/expo-app/sources/components/tools/views/TaskView.tsx +++ b/expo-app/sources/components/tools/views/TaskView.tsx @@ -1,10 +1,10 @@ import * as React from 'react'; -import { ToolViewProps } from './_all'; -import { Text, View, ActivityIndicator, StyleSheet, Platform } from 'react-native'; +import { ToolViewProps } from './_registry'; +import { Text, View, ActivityIndicator, Platform } from 'react-native'; import { knownTools } from '../../tools/knownTools'; import { Ionicons } from '@expo/vector-icons'; import { ToolCall } from '@/sync/typesMessage'; -import { useUnistyles } from 'react-native-unistyles'; +import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { t } from '@/text'; interface FilteredTool { diff --git a/expo-app/sources/components/tools/views/TodoView.test.tsx b/expo-app/sources/components/tools/views/TodoView.test.tsx new file mode 100644 index 000000000..c089d034c --- /dev/null +++ b/expo-app/sources/components/tools/views/TodoView.test.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import renderer, { act } from 'react-test-renderer'; +import type { ToolCall } from '@/sync/typesMessage'; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +vi.mock('react-native', () => ({ + View: 'View', + Text: 'Text', +})); + +vi.mock('react-native-unistyles', () => ({ + StyleSheet: { create: (styles: any) => styles }, +})); + +vi.mock('../../tools/ToolSectionView', () => ({ + ToolSectionView: ({ children }: any) => React.createElement(React.Fragment, null, children), +})); + +describe('TodoView', () => { + it('renders todos from TodoRead result.todos', async () => { + const { TodoView } = await import('./TodoView'); + + const tool: ToolCall = { + name: 'TodoRead', + state: 'completed', + input: {}, + result: { todos: [{ content: 'Hello', status: 'pending' }] } as any, + createdAt: Date.now(), + startedAt: Date.now(), + completedAt: Date.now(), + description: null, + permission: undefined, + }; + + let tree: renderer.ReactTestRenderer | undefined; + await act(async () => { + tree = renderer.create(React.createElement(TodoView, { tool, metadata: null, messages: [] } as any)); + }); + + const texts = tree!.root.findAllByType('Text' as any).map((n: any) => n.props.children); + const flattened = texts.flatMap((c: any) => Array.isArray(c) ? c : [c]).filter((c: any) => typeof c === 'string'); + expect(flattened.join(' ')).toContain('Hello'); + }); +}); + diff --git a/expo-app/sources/components/tools/views/TodoView.tsx b/expo-app/sources/components/tools/views/TodoView.tsx index 2c3df552d..ffb7c12ad 100644 --- a/expo-app/sources/components/tools/views/TodoView.tsx +++ b/expo-app/sources/components/tools/views/TodoView.tsx @@ -1,8 +1,10 @@ import * as React from 'react'; -import { View, Text, StyleSheet } from 'react-native'; -import { ToolViewProps } from "./_all"; +import { View, Text } from 'react-native'; +import { StyleSheet } from 'react-native-unistyles'; +import { ToolViewProps } from './_registry'; import { knownTools } from '../../tools/knownTools'; import { ToolSectionView } from '../../tools/ToolSectionView'; +import { maybeParseJson } from '../utils/parseJson'; export interface Todo { content: string; @@ -21,10 +23,17 @@ export const TodoView = React.memo<ToolViewProps>(({ tool }) => { } // If we have a properly structured result, use newTodos from there - let parsed = knownTools.TodoWrite.result.safeParse(tool.result); + const parsedMaybeResult = maybeParseJson(tool.result); + let parsed = knownTools.TodoWrite.result.safeParse(parsedMaybeResult); if (parsed.success && parsed.data.newTodos) { todosList = parsed.data.newTodos; } + + // TodoRead: some providers emit the current list as `result.todos` + const parsedRead = knownTools.TodoRead?.result.safeParse(parsedMaybeResult as any); + if (parsedRead?.success && parsedRead.data.todos) { + todosList = parsedRead.data.todos as Todo[]; + } // If we have todos to display, show them if (todosList.length > 0) { @@ -65,7 +74,7 @@ export const TodoView = React.memo<ToolViewProps>(({ tool }) => { return null; }); -const styles = StyleSheet.create({ +const styles = StyleSheet.create((theme) => ({ container: { gap: 4, }, @@ -74,17 +83,17 @@ const styles = StyleSheet.create({ }, todoText: { fontSize: 14, - color: '#000', + color: theme.colors.text, flex: 1, }, completedText: { - color: '#34C759', + color: theme.colors.success, textDecorationLine: 'line-through', }, inProgressText: { - color: '#007AFF', + color: theme.colors.text, }, pendingText: { - color: '#666', + color: theme.colors.textSecondary, }, -}); \ No newline at end of file +})); diff --git a/expo-app/sources/components/tools/views/WebFetchView.tsx b/expo-app/sources/components/tools/views/WebFetchView.tsx new file mode 100644 index 000000000..567ca49d6 --- /dev/null +++ b/expo-app/sources/components/tools/views/WebFetchView.tsx @@ -0,0 +1,59 @@ +import * as React from 'react'; +import { View, Text } from 'react-native'; +import { StyleSheet } from 'react-native-unistyles'; +import type { ToolViewProps } from './_registry'; +import { ToolSectionView } from '../ToolSectionView'; +import { CodeView } from '@/components/CodeView'; +import { maybeParseJson } from '../utils/parseJson'; + +function asRecord(value: unknown): Record<string, unknown> | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + return value as Record<string, unknown>; +} + +function truncate(text: string, maxChars: number): string { + if (text.length <= maxChars) return text; + return text.slice(0, Math.max(0, maxChars - 1)) + '…'; +} + +function getText(result: unknown): string | null { + const parsed = maybeParseJson(result); + if (typeof parsed === 'string' && parsed.trim()) return parsed; + const obj = asRecord(parsed); + if (!obj) return null; + const candidates = [obj.text, obj.content, obj.body, obj.markdown, obj.result, obj.output]; + for (const c of candidates) { + if (typeof c === 'string' && c.trim()) return c; + } + return null; +} + +export const WebFetchView = React.memo<ToolViewProps>(({ tool }) => { + if (tool.state !== 'completed') return null; + const url = typeof tool.input?.url === 'string' ? tool.input.url : null; + const text = getText(tool.result); + if (!url && !text) return null; + + return ( + <ToolSectionView> + <View style={styles.container}> + {url ? <Text style={styles.url} numberOfLines={2}>{url}</Text> : null} + {text ? <CodeView code={truncate(text, 2200)} /> : null} + </View> + </ToolSectionView> + ); +}); + +const styles = StyleSheet.create((theme) => ({ + container: { + padding: 12, + borderRadius: 8, + backgroundColor: theme.colors.surfaceHigh, + gap: 10, + }, + url: { + fontSize: 12, + color: theme.colors.textSecondary, + fontFamily: 'Menlo', + }, +})); diff --git a/expo-app/sources/components/tools/views/WebSearchView.tsx b/expo-app/sources/components/tools/views/WebSearchView.tsx new file mode 100644 index 000000000..2ad5931ff --- /dev/null +++ b/expo-app/sources/components/tools/views/WebSearchView.tsx @@ -0,0 +1,102 @@ +import * as React from 'react'; +import { View, Text } from 'react-native'; +import { StyleSheet } from 'react-native-unistyles'; +import type { ToolViewProps } from './_registry'; +import { ToolSectionView } from '../ToolSectionView'; +import { maybeParseJson } from '../utils/parseJson'; + +type WebResult = { title?: string; url?: string; snippet?: string }; + +function asRecord(value: unknown): Record<string, unknown> | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + return value as Record<string, unknown>; +} + +function coerceResults(value: unknown): WebResult[] { + const parsed = maybeParseJson(value); + const arr = Array.isArray(parsed) ? parsed : null; + const obj = asRecord(parsed); + + const candidates = arr + ? arr + : obj && Array.isArray(obj.results) + ? obj.results + : obj && Array.isArray(obj.items) + ? obj.items + : null; + + if (!candidates) return []; + + const out: WebResult[] = []; + for (const item of candidates) { + if (!item) continue; + if (typeof item === 'string') { + out.push({ url: item }); + continue; + } + const rec = asRecord(item); + if (!rec) continue; + out.push({ + title: typeof rec.title === 'string' ? rec.title : undefined, + url: typeof rec.url === 'string' ? rec.url : (typeof rec.link === 'string' ? rec.link : undefined), + snippet: typeof rec.snippet === 'string' ? rec.snippet : (typeof rec.description === 'string' ? rec.description : undefined), + }); + } + return out; +} + +export const WebSearchView = React.memo<ToolViewProps>(({ tool }) => { + if (tool.state !== 'completed') return null; + const results = coerceResults(tool.result); + if (results.length === 0) return null; + + const shown = results.slice(0, 5); + const more = results.length - shown.length; + + return ( + <ToolSectionView> + <View style={styles.container}> + {shown.map((r, idx) => ( + <View key={idx} style={styles.row}> + {r.title ? <Text style={styles.title} numberOfLines={2}>{r.title}</Text> : null} + {r.url ? <Text style={styles.url} numberOfLines={1}>{r.url}</Text> : null} + {r.snippet ? <Text style={styles.snippet} numberOfLines={3}>{r.snippet}</Text> : null} + </View> + ))} + {more > 0 ? <Text style={styles.more}>+{more} more</Text> : null} + </View> + </ToolSectionView> + ); +}); + +const styles = StyleSheet.create((theme) => ({ + container: { + padding: 12, + borderRadius: 8, + backgroundColor: theme.colors.surfaceHigh, + gap: 12, + }, + row: { + gap: 4, + }, + title: { + fontSize: 13, + color: theme.colors.text, + fontWeight: '500', + }, + url: { + fontSize: 12, + color: theme.colors.textSecondary, + fontFamily: 'Menlo', + }, + snippet: { + fontSize: 13, + color: theme.colors.text, + opacity: 0.9, + }, + more: { + fontSize: 12, + color: theme.colors.textSecondary, + fontFamily: 'Menlo', + }, +})); diff --git a/expo-app/sources/components/tools/views/WriteView.tsx b/expo-app/sources/components/tools/views/WriteView.tsx index 5163f7763..58969ed30 100644 --- a/expo-app/sources/components/tools/views/WriteView.tsx +++ b/expo-app/sources/components/tools/views/WriteView.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { ToolViewProps } from './_all'; +import { ToolViewProps } from './_registry'; import { ToolSectionView } from '../../tools/ToolSectionView'; import { knownTools } from '@/components/tools/knownTools'; import { ToolDiffView } from '@/components/tools/ToolDiffView'; @@ -26,4 +26,4 @@ export const WriteView = React.memo<ToolViewProps>(({ tool }) => { </ToolSectionView> </> ); -}); \ No newline at end of file +}); diff --git a/expo-app/sources/components/tools/views/_registry.test.tsx b/expo-app/sources/components/tools/views/_registry.test.tsx new file mode 100644 index 000000000..fdb81257b --- /dev/null +++ b/expo-app/sources/components/tools/views/_registry.test.tsx @@ -0,0 +1,9 @@ +import { describe, expect, it } from 'vitest'; +import { getToolViewComponent } from './_registry'; +import { ReadView } from './ReadView'; + +describe('toolViewRegistry', () => { + it('registers a Read view for lowercase read tool name', () => { + expect(getToolViewComponent('read')).toBe(ReadView); + }); +}); diff --git a/expo-app/sources/components/tools/views/_all.tsx b/expo-app/sources/components/tools/views/_registry.tsx similarity index 71% rename from expo-app/sources/components/tools/views/_all.tsx rename to expo-app/sources/components/tools/views/_registry.tsx index fe087a377..f69dfa620 100644 --- a/expo-app/sources/components/tools/views/_all.tsx +++ b/expo-app/sources/components/tools/views/_registry.tsx @@ -17,6 +17,14 @@ import { CodexDiffView } from './CodexDiffView'; import { AskUserQuestionView } from './AskUserQuestionView'; import { GeminiEditView } from './GeminiEditView'; import { GeminiExecuteView } from './GeminiExecuteView'; +import { AcpHistoryImportView } from './AcpHistoryImportView'; +import { GlobView } from './GlobView'; +import { GrepView } from './GrepView'; +import { ReadView } from './ReadView'; +import { WebFetchView } from './WebFetchView'; +import { WebSearchView } from './WebSearchView'; +import { CodeSearchView } from './CodeSearchView'; +import { ReasoningView } from './ReasoningView'; export type ToolViewProps = { tool: ToolCall; @@ -36,21 +44,36 @@ export const toolViewRegistry: Record<string, ToolViewComponent> = { CodexPatch: CodexPatchView, CodexDiff: CodexDiffView, Write: WriteView, + Read: ReadView, + read: ReadView, + Glob: GlobView, + Grep: GrepView, + WebFetch: WebFetchView, + WebSearch: WebSearchView, + CodeSearch: CodeSearchView, TodoWrite: TodoView, + TodoRead: TodoView, ExitPlanMode: ExitPlanToolView, exit_plan_mode: ExitPlanToolView, MultiEdit: MultiEditView, Task: TaskView, AskUserQuestion: AskUserQuestionView, + AcpHistoryImport: AcpHistoryImportView, // Gemini tools (lowercase) edit: GeminiEditView, execute: GeminiExecuteView, + GeminiReasoning: ReasoningView, + CodexReasoning: ReasoningView, + think: ReasoningView, }; export const toolFullViewRegistry: Record<string, ToolViewComponent> = { Bash: BashViewFull, Edit: EditViewFull, - MultiEdit: MultiEditViewFull + MultiEdit: MultiEditViewFull, + // ACP providers often use lowercase tool names + execute: BashViewFull, + edit: EditViewFull, }; // Helper function to get the appropriate view component for a tool @@ -78,3 +101,10 @@ export { TaskView } from './TaskView'; export { AskUserQuestionView } from './AskUserQuestionView'; export { GeminiEditView } from './GeminiEditView'; export { GeminiExecuteView } from './GeminiExecuteView'; +export { AcpHistoryImportView } from './AcpHistoryImportView'; +export { GlobView } from './GlobView'; +export { GrepView } from './GrepView'; +export { ReadView } from './ReadView'; +export { WebFetchView } from './WebFetchView'; +export { WebSearchView } from './WebSearchView'; +export { CodeSearchView } from './CodeSearchView'; diff --git a/expo-app/sources/components/ui/forms/InlineAddExpander.tsx b/expo-app/sources/components/ui/forms/InlineAddExpander.tsx new file mode 100644 index 000000000..918b62b5f --- /dev/null +++ b/expo-app/sources/components/ui/forms/InlineAddExpander.tsx @@ -0,0 +1,144 @@ +import React from 'react'; +import { Pressable, Text, TextInput, View } from 'react-native'; +import { StyleSheet, useUnistyles } from 'react-native-unistyles'; +import type { StyleProp, ViewStyle } from 'react-native'; + +import { Item } from '@/components/ui/lists/Item'; +import { Typography } from '@/constants/Typography'; + +export interface InlineAddExpanderProps { + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; + + title: string; + subtitle?: string; + icon?: React.ReactNode; + + helpText?: string; + children: React.ReactNode; + + onCancel: () => void; + onSave: () => void; + saveDisabled?: boolean; + + cancelLabel: string; + saveLabel: string; + + autoFocusRef?: React.RefObject<TextInput | null>; + expandedContainerStyle?: StyleProp<ViewStyle>; +} + +export function InlineAddExpander({ + isOpen, + onOpenChange, + title, + subtitle, + icon, + helpText, + children, + onCancel, + onSave, + saveDisabled = false, + cancelLabel, + saveLabel, + autoFocusRef, + expandedContainerStyle, +}: InlineAddExpanderProps) { + const { theme } = useUnistyles(); + const styles = stylesheet; + + React.useEffect(() => { + if (!isOpen) return; + if (!autoFocusRef?.current) return; + const id = setTimeout(() => autoFocusRef.current?.focus(), 30); + return () => clearTimeout(id); + }, [autoFocusRef, isOpen]); + + return ( + <> + <Item + title={title} + subtitle={subtitle} + icon={icon} + onPress={() => onOpenChange(!isOpen)} + showChevron={false} + showDivider={Boolean(isOpen)} + /> + + {isOpen ? ( + <View style={[styles.expandedContainer, expandedContainerStyle]}> + {helpText ? ( + <Text style={styles.helpText}> + {helpText} + </Text> + ) : null} + + {children} + + <View style={{ height: 16 }} /> + + <View style={styles.actionsRow}> + <View style={{ flex: 1 }}> + <Pressable + onPress={onCancel} + accessibilityRole="button" + accessibilityLabel={cancelLabel} + style={({ pressed }) => ({ + backgroundColor: theme.colors.surface, + borderRadius: 10, + paddingVertical: 12, + alignItems: 'center', + opacity: pressed ? 0.85 : 1, + })} + > + <Text style={{ color: theme.colors.text, ...Typography.default('semiBold') }}> + {cancelLabel} + </Text> + </Pressable> + </View> + + <View style={{ flex: 1 }}> + <Pressable + onPress={onSave} + disabled={saveDisabled} + accessibilityRole="button" + accessibilityLabel={saveLabel} + style={({ pressed }) => ({ + backgroundColor: theme.colors.button.primary.background, + borderRadius: 10, + paddingVertical: 12, + alignItems: 'center', + opacity: saveDisabled ? 0.5 : (pressed ? 0.85 : 1), + })} + > + <Text style={{ color: theme.colors.button.primary.tint, ...Typography.default('semiBold') }}> + {saveLabel} + </Text> + </Pressable> + </View> + </View> + </View> + ) : null} + </> + ); +} + + +const stylesheet = StyleSheet.create((theme) => ({ + expandedContainer: { + paddingHorizontal: 16, + paddingTop: 12, + paddingBottom: 16, + }, + helpText: { + color: theme.colors.textSecondary, + fontSize: 14, + lineHeight: 20, + marginBottom: 12, + ...Typography.default(), + }, + actionsRow: { + flexDirection: 'row', + gap: 12, + }, +})); diff --git a/expo-app/sources/components/ui/forms/OptionTiles.tsx b/expo-app/sources/components/ui/forms/OptionTiles.tsx new file mode 100644 index 000000000..b9adf5297 --- /dev/null +++ b/expo-app/sources/components/ui/forms/OptionTiles.tsx @@ -0,0 +1,126 @@ +import React from 'react'; +import { View, Text, Pressable, Platform } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { StyleSheet, useUnistyles } from 'react-native-unistyles'; +import { Typography } from '@/constants/Typography'; + +export interface OptionTile<T extends string> { + id: T; + title: string; + subtitle?: string; + icon?: React.ComponentProps<typeof Ionicons>['name']; + disabled?: boolean; +} + +export interface OptionTilesProps<T extends string> { + options: Array<OptionTile<T>>; + value: T | null; + onChange: (next: T | null) => void; +} + +export function OptionTiles<T extends string>(props: OptionTilesProps<T>) { + const { theme } = useUnistyles(); + const styles = stylesheet; + + const [width, setWidth] = React.useState<number>(0); + const columns = React.useMemo(() => { + // Avoid the awkward 2+1 layout for 3 options. + if (props.options.length === 3) { + return width >= 560 ? 3 : 1; + } + if (width >= 640) return Math.min(3, props.options.length); + if (width >= 420) return Math.min(2, props.options.length); + return 1; + }, [props.options.length, width]); + + const gap = 10; + const tileWidth = React.useMemo(() => { + if (width <= 0) return undefined; + const totalGap = gap * (columns - 1); + return Math.floor((width - totalGap) / columns); + }, [columns, width]); + + return ( + <View + onLayout={(e) => setWidth(e.nativeEvent.layout.width)} + style={[ + styles.grid, + { flexDirection: 'row', flexWrap: 'wrap', gap }, + ]} + > + {props.options.map((opt) => { + const selected = props.value === opt.id; + const disabled = opt.disabled === true; + return ( + <Pressable + key={opt.id} + disabled={disabled} + onPress={() => { + if (disabled) return; + props.onChange(opt.id); + }} + style={({ pressed }) => [ + styles.tile, + tileWidth ? { width: tileWidth } : null, + { + borderColor: selected ? theme.colors.button.primary.background : theme.colors.divider, + opacity: disabled ? 0.45 : (pressed ? 0.85 : 1), + }, + ]} + > + <View style={{ flexDirection: 'row', alignItems: 'center', gap: 16 }}> + <View style={styles.iconSlot}> + <Ionicons + name={opt.icon ?? (selected ? 'checkmark-circle-outline' : 'ellipse-outline')} + size={29} + color={theme.colors.textSecondary} + /> + </View> + <View style={{ flex: 1 }}> + <Text style={styles.title} numberOfLines={2}>{opt.title}</Text> + {opt.subtitle ? ( + <Text style={styles.subtitle} numberOfLines={3}>{opt.subtitle}</Text> + ) : null} + </View> + </View> + </Pressable> + ); + })} + </View> + ); +} + +const stylesheet = StyleSheet.create((theme) => ({ + grid: { + // Intentionally transparent: this component is meant to sit directly on + // the screen/group background (so gutters are visible between tiles). + }, + tile: { + backgroundColor: theme.colors.surface, + borderRadius: 12, + borderWidth: 2, + padding: 12, + paddingTop: 16, + paddingBottom: 20 + }, + iconSlot: { + width: 29, + height: 29, + alignItems: 'center', + justifyContent: 'center', + }, + title: { + ...Typography.default('regular'), + fontSize: Platform.select({ ios: 17, default: 16 }), + lineHeight: Platform.select({ ios: 22, default: 24 }), + letterSpacing: Platform.select({ ios: -0.41, default: 0.15 }), + color: theme.colors.text, + }, + subtitle: { + ...Typography.default(), + fontSize: Platform.select({ ios: 15, default: 14 }), + lineHeight: 20, + letterSpacing: Platform.select({ ios: -0.24, default: 0.1 }), + color: theme.colors.textSecondary, + }, +})); diff --git a/expo-app/sources/components/ui/forms/SearchHeader.tsx b/expo-app/sources/components/ui/forms/SearchHeader.tsx new file mode 100644 index 000000000..91218c045 --- /dev/null +++ b/expo-app/sources/components/ui/forms/SearchHeader.tsx @@ -0,0 +1,125 @@ +import * as React from 'react'; +import { View, TextInput, Platform, Pressable, StyleProp, ViewStyle } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { StyleSheet, useUnistyles } from 'react-native-unistyles'; +import { Typography } from '@/constants/Typography'; +import { layout } from '@/components/layout'; +import { t } from '@/text'; + +export interface SearchHeaderProps { + value: string; + onChangeText: (text: string) => void; + placeholder: string; + containerStyle?: StyleProp<ViewStyle>; + autoCapitalize?: 'none' | 'sentences' | 'words' | 'characters'; + autoCorrect?: boolean; + inputRef?: React.Ref<TextInput>; + onFocus?: () => void; + onBlur?: () => void; +} + +const INPUT_BORDER_RADIUS = 10; + +const stylesheet = StyleSheet.create((theme) => ({ + container: { + backgroundColor: theme.colors.surface, + paddingHorizontal: 16, + paddingVertical: 12, + borderBottomWidth: 1, + borderBottomColor: theme.colors.divider, + }, + content: { + width: '100%', + maxWidth: layout.maxWidth, + alignSelf: 'center', + }, + inputWrapper: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: theme.colors.input.background, + borderRadius: INPUT_BORDER_RADIUS, + paddingHorizontal: 12, + paddingVertical: 8, + }, + textInput: { + flex: 1, + ...Typography.default('regular'), + fontSize: Platform.select({ ios: 17, default: 16 }), + lineHeight: Platform.select({ ios: 22, default: 24 }), + letterSpacing: Platform.select({ ios: -0.41, default: 0.15 }), + color: theme.colors.input.text, + paddingVertical: 0, + ...(Platform.select({ + web: { + outline: 'none', + outlineStyle: 'none', + outlineWidth: 0, + outlineColor: 'transparent', + boxShadow: 'none', + WebkitBoxShadow: 'none', + WebkitAppearance: 'none', + }, + default: {}, + }) as object), + }, + clearIcon: { + marginLeft: 8, + }, +})); + +export function SearchHeader({ + value, + onChangeText, + placeholder, + containerStyle, + autoCapitalize = 'none', + autoCorrect = false, + inputRef, + onFocus, + onBlur, +}: SearchHeaderProps) { + const { theme } = useUnistyles(); + const styles = stylesheet; + + return ( + <View style={[styles.container, containerStyle]}> + <View style={styles.content}> + <View style={styles.inputWrapper}> + <Ionicons + name="search-outline" + size={20} + color={theme.colors.textSecondary} + style={{ marginRight: 8 }} + /> + <TextInput + ref={inputRef} + value={value} + onChangeText={onChangeText} + placeholder={placeholder} + placeholderTextColor={theme.colors.input.placeholder} + autoCapitalize={autoCapitalize} + autoCorrect={autoCorrect} + onFocus={onFocus} + onBlur={onBlur} + style={styles.textInput} + /> + {value.length > 0 && ( + <Pressable + onPress={() => onChangeText('')} + hitSlop={8} + accessibilityRole="button" + accessibilityLabel={t('common.clearSearch')} + > + <Ionicons + name="close-circle" + size={20} + color={theme.colors.textSecondary} + style={styles.clearIcon} + /> + </Pressable> + )} + </View> + </View> + </View> + ); +} diff --git a/expo-app/sources/components/ui/forms/dropdown/DropdownMenu.test.ts b/expo-app/sources/components/ui/forms/dropdown/DropdownMenu.test.ts new file mode 100644 index 000000000..3a088c265 --- /dev/null +++ b/expo-app/sources/components/ui/forms/dropdown/DropdownMenu.test.ts @@ -0,0 +1,147 @@ +import React from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import renderer, { act } from 'react-test-renderer'; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +vi.mock('react-native', () => { + const React = require('react'); + return { + Platform: { OS: 'web' }, + Text: (props: any) => React.createElement('Text', props, props.children), + TextInput: (props: any) => React.createElement('TextInput', props, props.children), + View: (props: any) => React.createElement('View', props, props.children), + Pressable: (props: any) => React.createElement('Pressable', props, props.children), + }; +}); + +vi.mock('@expo/vector-icons', () => ({ + Ionicons: (props: any) => { + const React = require('react'); + return React.createElement('Ionicons', props); + }, +})); + +vi.mock('react-native-unistyles', () => ({ + useUnistyles: () => ({ + theme: { + colors: { + textSecondary: '#666', + divider: '#ddd', + text: '#111', + }, + }, + }), +})); + +vi.mock('@/components/ui/popover', () => ({ + Popover: (props: any) => { + const React = require('react'); + return React.createElement( + 'Popover', + props, + typeof props.children === 'function' + ? props.children({ maxHeight: 200, maxWidth: 400, placement: props.placement ?? 'bottom' }) + : props.children, + ); + }, +})); + +vi.mock('@/components/FloatingOverlay', () => ({ + FloatingOverlay: (props: any) => { + const React = require('react'); + return React.createElement('FloatingOverlay', props, props.children); + }, +})); + +vi.mock('@/components/ui/forms/dropdown/useSelectableMenu', () => ({ + useSelectableMenu: () => ({ + searchQuery: '', + selectedIndex: 0, + filteredCategories: [], + inputRef: { current: null }, + setSelectedIndex: () => {}, + handleSearchChange: () => {}, + handleKeyPress: () => {}, + }), +})); + +vi.mock('@/components/ui/forms/dropdown/SelectableMenuResults', () => ({ + SelectableMenuResults: (props: any) => { + const React = require('react'); + return React.createElement('SelectableMenuResults', props); + }, +})); + +vi.mock('@/text', () => ({ + t: (key: string) => key, +})); + +describe('DropdownMenu', () => { + beforeEach(() => { + vi.stubGlobal('requestAnimationFrame', (cb: () => void) => { + cb(); + return 0 as any; + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('provides a toggle handler to the trigger and uses it to open/close', async () => { + const { DropdownMenu } = await import('./DropdownMenu'); + const { Pressable, Text } = await import('react-native'); + + const onOpenChange = vi.fn(); + + let tree: ReturnType<typeof renderer.create> | undefined; + act(() => { + tree = renderer.create( + React.createElement(DropdownMenu, { + open: false, + onOpenChange, + items: [{ id: 'a', title: 'A' }], + onSelect: () => {}, + trigger: ({ toggle }: any) => + React.createElement( + Pressable, + { onPress: toggle }, + React.createElement(Text, null, 'Trigger'), + ), + }), + ); + }); + + const pressable = tree?.root.findByType(Pressable); + expect(pressable).toBeTruthy(); + + act(() => { + pressable?.props?.onPress?.(); + }); + expect(onOpenChange).toHaveBeenCalledWith(true); + + act(() => { + tree?.update( + React.createElement(DropdownMenu, { + open: true, + onOpenChange, + items: [{ id: 'a', title: 'A' }], + onSelect: () => {}, + trigger: ({ toggle }: any) => + React.createElement( + Pressable, + { onPress: toggle }, + React.createElement(Text, null, 'Trigger'), + ), + }), + ); + }); + + const pressable2 = tree?.root.findByType(Pressable); + act(() => { + pressable2?.props?.onPress?.(); + }); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); +}); diff --git a/expo-app/sources/components/ui/forms/dropdown/DropdownMenu.tsx b/expo-app/sources/components/ui/forms/dropdown/DropdownMenu.tsx new file mode 100644 index 000000000..3b2de1988 --- /dev/null +++ b/expo-app/sources/components/ui/forms/dropdown/DropdownMenu.tsx @@ -0,0 +1,282 @@ +import * as React from 'react'; +import { Platform, Text, TextInput, View, type ViewStyle } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { useUnistyles } from 'react-native-unistyles'; + +import { Popover, type PopoverPlacement } from '@/components/ui/popover'; +import { FloatingOverlay } from '@/components/FloatingOverlay'; +import { t } from '@/text'; +import type { SelectableRowVariant } from '@/components/ui/lists/SelectableRow'; +import { SelectableMenuResults } from '@/components/ui/forms/dropdown/SelectableMenuResults'; +import type { SelectableMenuItem } from '@/components/ui/forms/dropdown/selectableMenuTypes'; +import { useSelectableMenu } from '@/components/ui/forms/dropdown/useSelectableMenu'; + +export type DropdownMenuItem = Readonly<{ + id: string; + title: string; + subtitle?: string; + category?: string; + icon?: React.ReactNode; + shortcut?: string; + disabled?: boolean; +}>; + +export type DropdownMenuProps = Readonly<{ + /** + * The trigger element. + * Prefer the render-prop form so DropdownMenu can provide a consistent `toggle()` helper. + * A ref will be attached internally for anchoring (the trigger is rendered inside that host). + */ + trigger: + | React.ReactNode + | ((props: Readonly<{ + open: boolean; + toggle: () => void; + openMenu: () => void; + closeMenu: () => void; + }>) => React.ReactNode); + open: boolean; + onOpenChange: (next: boolean) => void; + + items: ReadonlyArray<DropdownMenuItem>; + onSelect: (itemId: string) => void; + /** + * Optional: the currently-selected item ID. Used for initial keyboard highlight. + * If it points to a disabled item, it is ignored. + */ + selectedId?: string | null; + + /** + * Visual style of rows: + * - slim: compact action-list feel + * - default: standard app row + * - selectable: CommandPalette-style (hover/selected borders) + */ + variant?: SelectableRowVariant; + /** When true, shows a search field and enables keyboard navigation on web. */ + search?: boolean; + searchPlaceholder?: string; + emptyLabel?: string; + placement?: PopoverPlacement; + /** Gap between the trigger and the menu (default 0 for dropdown feel). */ + gap?: number; + maxHeightCap?: number; + maxWidthCap?: number; + /** Match the popover width to the trigger width in web portal mode (default true). */ + matchTriggerWidth?: boolean; + popoverBoundaryRef?: React.RefObject<any> | null; + /** + * Web-only: controls where the popover portal is mounted. + * Defaults to Popover's behavior (which prefers the modal portal target when inside a modal). + * Set to 'body' to allow menus to escape overflow-clipped modals. + */ + popoverPortalWebTarget?: 'body' | 'modal' | 'boundary'; + overlayStyle?: ViewStyle; + /** When false, category titles like "General" are not rendered. */ + showCategoryTitles?: boolean; + /** Render rows using the app `Item` component for perfect icon/typography parity. */ + rowKind?: 'selectableRow' | 'item'; + /** + * Make the menu visually connect to the trigger (no gap; squared top corners; no top border). + * Intended for "dropdown" inputs where the menu should feel like a single control. + */ + connectToTrigger?: boolean; +}>; + +export function DropdownMenu(props: DropdownMenuProps) { + const { theme } = useUnistyles(); + const anchorRef = React.useRef<View>(null); + + const rowVariant: SelectableRowVariant = props.variant ?? 'slim'; + const matchTriggerWidth = props.matchTriggerWidth ?? true; + const maxWidthCap = props.maxWidthCap ?? (matchTriggerWidth ? 1024 : 320); + const edgePadding = React.useMemo(() => { + // When the menu is meant to visually "connect" to the trigger, horizontal edge padding + // creates an inset that makes the popover look misaligned. Keep vertical breathing room. + if (props.connectToTrigger || matchTriggerWidth) return { vertical: 8, horizontal: 0 } as const; + return { vertical: 8, horizontal: 8 } as const; + }, [matchTriggerWidth, props.connectToTrigger]); + + const selectableItems = React.useMemo((): SelectableMenuItem[] => { + return props.items.map((item) => ({ + id: item.id, + title: item.title, + subtitle: item.subtitle, + category: item.category, + disabled: item.disabled, + left: item.icon ?? null, + right: item.shortcut + ? ( + <View style={{ paddingHorizontal: 10, paddingVertical: 5, backgroundColor: 'rgba(0, 0, 0, 0.04)', borderRadius: 6 }}> + <Text style={{ fontSize: 12, color: '#666', fontWeight: '500' }}> + {item.shortcut} + </Text> + </View> + ) + : ( + <Ionicons + name="chevron-forward" + size={18} + color={theme.colors.textSecondary} + style={{ opacity: rowVariant === 'slim' ? 0 : 1 }} + /> + ), + })); + }, [props.items, rowVariant, theme.colors.textSecondary]); + + const onRequestClose = React.useCallback(() => props.onOpenChange(false), [props]); + const schedule = React.useCallback((cb: () => void) => { + // Opening an overlay on the same click can sometimes immediately trigger a backdrop close + // (especially on web). Deferring by one tick ensures the opening press completes first. + if (typeof requestAnimationFrame === 'function') { + requestAnimationFrame(cb); + return; + } + setTimeout(cb, 0); + }, []); + const openMenu = React.useCallback(() => { + schedule(() => props.onOpenChange(true)); + }, [props, schedule]); + const closeMenu = React.useCallback(() => props.onOpenChange(false), [props]); + const toggle = React.useCallback(() => { + if (props.open) { + props.onOpenChange(false); + return; + } + openMenu(); + }, [openMenu, props]); + const triggerNode = React.useMemo(() => { + if (typeof props.trigger === 'function') { + return props.trigger({ + open: props.open, + toggle, + openMenu, + closeMenu, + }); + } + return props.trigger; + }, [closeMenu, openMenu, props, toggle]); + + const { + searchQuery, + selectedIndex, + filteredCategories, + inputRef, + handleSearchChange, + handleKeyPress, + setSelectedIndex, + } = useSelectableMenu({ + items: selectableItems, + onRequestClose, + initialSelectedId: props.selectedId ?? null, + }); + + const handleKeyDown = React.useCallback((e: any) => { + if (Platform.OS !== 'web') return; + const key = e?.nativeEvent?.key; + if (typeof key !== 'string') return; + if (!['ArrowDown', 'ArrowUp', 'Enter', 'Escape'].includes(key)) return; + e.preventDefault?.(); + e.stopPropagation?.(); + handleKeyPress(key, (item) => { + props.onOpenChange(false); + props.onSelect(item.id); + }); + }, [handleKeyPress, props]); + + return ( + <View + ref={anchorRef} + // Ensure this wrapper exists in the native hierarchy so `measureInWindow` is reliable. + // Without this, RN can "collapse" the View and measurement can return 0x0, causing + // dropdowns to overlap their trigger (notably on iOS). + collapsable={false} + style={{ position: 'relative' }} + > + {triggerNode} + {props.open ? ( + <Popover + open={props.open} + anchorRef={anchorRef} + placement={props.placement ?? 'bottom'} + gap={props.gap ?? 0} + maxHeightCap={props.maxHeightCap ?? 320} + maxWidthCap={maxWidthCap} + edgePadding={edgePadding} + portal={{ + web: props.popoverPortalWebTarget ? { target: props.popoverPortalWebTarget } : true, + native: true, + matchAnchorWidth: matchTriggerWidth, + anchorAlignVertical: 'start', + }} + boundaryRef={props.popoverBoundaryRef} + onRequestClose={onRequestClose} + > + {({ maxHeight, maxWidth }) => ( + <FloatingOverlay + maxHeight={maxHeight} + edgeFades={{ top: true, bottom: true }} + edgeIndicators={{ size: 14, opacity: 0.35 }} + containerStyle={[ + // Dropdowns should be shadow-only (no borders). + { borderWidth: 0, borderColor: 'transparent' } as any, + props.connectToTrigger + ? ({ + borderTopLeftRadius: 0, + borderTopRightRadius: 0, + marginTop: -1, + borderTopWidth: 0, + } as any) + : null, + props.overlayStyle ?? null, + ]} + > + {props.search ? ( + <View style={{ + paddingHorizontal: rowVariant === 'slim' ? 12 : 16, + paddingTop: rowVariant === 'slim' ? 8 : 10, + paddingBottom: rowVariant === 'slim' ? 6 : 8, + }}> + <TextInput + ref={inputRef as any} + value={searchQuery} + onChangeText={handleSearchChange} + placeholder={props.searchPlaceholder ?? t('commandPalette.placeholder')} + placeholderTextColor="#999" + autoCorrect={false} + autoCapitalize="none" + autoFocus + onKeyPress={handleKeyDown} + style={{ + borderRadius: rowVariant === 'slim' ? 8 : 10, + borderWidth: 1, + borderColor: theme.colors.divider, + paddingHorizontal: rowVariant === 'slim' ? 10 : 12, + paddingVertical: rowVariant === 'slim' ? 8 : 10, + fontSize: rowVariant === 'slim' ? 14 : 15, + color: theme.colors.text, + }} + /> + </View> + ) : null} + + <SelectableMenuResults + categories={filteredCategories} + selectedIndex={selectedIndex} + onSelectionChange={setSelectedIndex} + onPressItem={(item) => { + props.onOpenChange(false); + props.onSelect(item.id); + }} + rowVariant={rowVariant} + emptyLabel={props.emptyLabel ?? t('commandPalette.noCommandsFound')} + showCategoryTitles={props.showCategoryTitles} + rowKind={props.rowKind} + /> + </FloatingOverlay> + )} + </Popover> + ) : null} + </View> + ); +} diff --git a/expo-app/sources/components/ui/forms/dropdown/SelectableMenuResults.scrollIntoView.test.ts b/expo-app/sources/components/ui/forms/dropdown/SelectableMenuResults.scrollIntoView.test.ts new file mode 100644 index 000000000..e8f10942f --- /dev/null +++ b/expo-app/sources/components/ui/forms/dropdown/SelectableMenuResults.scrollIntoView.test.ts @@ -0,0 +1,105 @@ +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import renderer, { act } from 'react-test-renderer'; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +const scrollIntoViewSpy = vi.fn(); + +vi.mock('react-native', () => { + const React = require('react'); + return { + Platform: { OS: 'web' }, + Text: (props: any) => React.createElement('Text', props, props.children), + View: React.forwardRef((props: any, ref: any) => { + React.useImperativeHandle(ref, () => ({ scrollIntoView: scrollIntoViewSpy })); + return React.createElement('View', props, props.children); + }), + }; +}); + +vi.mock('react-native-unistyles', () => ({ + StyleSheet: { create: () => ({}) }, +})); + +vi.mock('@/constants/Typography', () => ({ + Typography: { default: () => ({}) }, +})); + +vi.mock('@/components/ui/lists/SelectableRow', () => { + const React = require('react'); + return { + SelectableRow: (props: any) => React.createElement('SelectableRow', props, props.children), + }; +}); + +vi.mock('@/components/ui/lists/Item', () => { + const React = require('react'); + return { + Item: (props: any) => React.createElement('Item', props, props.children), + }; +}); + +vi.mock('@/components/ui/lists/ItemGroup', () => { + const React = require('react'); + return { + ItemGroupSelectionContext: { + Provider: (props: any) => React.createElement('ItemGroupSelectionContextProvider', props, props.children), + }, + }; +}); + +vi.mock('@/components/ui/lists/ItemGroupRowPosition', () => { + const React = require('react'); + return { + ItemGroupRowPositionBoundary: (props: any) => React.createElement('ItemGroupRowPositionBoundary', props, props.children), + }; +}); + +describe('SelectableMenuResults (web)', () => { + it('does not call DOM scrollIntoView (prevents scrolling the underlying page when opening dropdowns)', async () => { + const { SelectableMenuResults } = await import('./SelectableMenuResults'); + + const categories = [ + { + id: 'general', + title: 'General', + items: [ + { id: 'a', title: 'A', disabled: false, left: null, right: null }, + { id: 'b', title: 'B', disabled: false, left: null, right: null }, + ], + }, + ] as any; + + let tree: ReturnType<typeof renderer.create> | undefined; + act(() => { + tree = renderer.create( + React.createElement(SelectableMenuResults, { + categories, + selectedIndex: 0, + onSelectionChange: () => {}, + onPressItem: () => {}, + rowVariant: 'slim', + emptyLabel: 'empty', + rowKind: 'item', + }), + ); + }); + + act(() => { + tree?.update( + React.createElement(SelectableMenuResults, { + categories, + selectedIndex: 1, + onSelectionChange: () => {}, + onPressItem: () => {}, + rowVariant: 'slim', + emptyLabel: 'empty', + rowKind: 'item', + }), + ); + }); + + expect(scrollIntoViewSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/expo-app/sources/components/ui/forms/dropdown/SelectableMenuResults.tsx b/expo-app/sources/components/ui/forms/dropdown/SelectableMenuResults.tsx new file mode 100644 index 000000000..8dae81194 --- /dev/null +++ b/expo-app/sources/components/ui/forms/dropdown/SelectableMenuResults.tsx @@ -0,0 +1,147 @@ +import * as React from 'react'; +import { Text, View } from 'react-native'; +import { StyleSheet } from 'react-native-unistyles'; +import { Typography } from '@/constants/Typography'; +import { SelectableRow, type SelectableRowVariant } from '@/components/ui/lists/SelectableRow'; +import { Item } from '@/components/ui/lists/Item'; +import { ItemGroupSelectionContext } from '@/components/ui/lists/ItemGroup'; +import { ItemGroupRowPositionBoundary } from '@/components/ui/lists/ItemGroupRowPosition'; +import type { SelectableMenuCategory, SelectableMenuItem } from './selectableMenuTypes'; + +const stylesheet = StyleSheet.create(() => ({ + container: { + paddingVertical: 0, + }, + emptyContainer: { + padding: 48, + alignItems: 'center', + }, + emptyText: { + fontSize: 15, + color: '#999', + letterSpacing: -0.2, + ...Typography.default(), + }, + categoryTitle: { + paddingHorizontal: 32, + paddingTop: 16, + paddingBottom: 8, + fontSize: 12, + color: '#999', + textTransform: 'uppercase', + letterSpacing: 0.8, + fontWeight: '600', + ...Typography.default('semiBold'), + }, +})); + +export function SelectableMenuResults(props: { + categories: ReadonlyArray<SelectableMenuCategory>; + selectedIndex: number; + onSelectionChange: (index: number) => void; + onPressItem: (item: SelectableMenuItem) => void; + rowVariant: SelectableRowVariant; + emptyLabel: string; + showCategoryTitles?: boolean; + rowKind?: 'selectableRow' | 'item'; +}) { + const styles = stylesheet; + const itemRefs = React.useRef<Record<number, View | null>>({}); + + const allItems = React.useMemo(() => props.categories.flatMap((c) => c.items), [props.categories]); + + if (props.categories.length === 0 || allItems.length === 0) { + return ( + <View style={styles.emptyContainer}> + <Text style={styles.emptyText}> + {props.emptyLabel} + </Text> + </View> + ); + } + + let currentIndex = 0; + const showCategoryTitles = props.showCategoryTitles !== false; + const rowKind = props.rowKind ?? 'selectableRow'; + + const content = ( + <View style={styles.container}> + {props.categories.map((category) => { + if (category.items.length === 0) return null; + + const categoryStartIndex = currentIndex; + const categoryItems = category.items.map((item, idx) => { + const itemIndex = categoryStartIndex + idx; + const isSelected = itemIndex === props.selectedIndex; + currentIndex++; + return ( + <View + key={item.id} + ref={(ref) => { itemRefs.current[itemIndex] = ref; }} + > + {rowKind === 'item' ? ( + <Item + title={item.title} + subtitle={item.subtitle} + icon={item.left} + rightElement={item.right} + selected={isSelected} + disabled={item.disabled} + showChevron={false} + showDivider={false} + onPress={() => { + if (item.disabled) return; + props.onPressItem(item); + }} + /> + ) : ( + <SelectableRow + variant={props.rowVariant} + selected={isSelected} + disabled={item.disabled} + left={item.left} + right={item.right} + title={item.title} + subtitle={item.subtitle} + onPress={() => { + if (item.disabled) return; + props.onPressItem(item); + }} + onHover={() => { + if (item.disabled) return; + props.onSelectionChange(itemIndex); + }} + /> + )} + </View> + ); + }); + + return ( + <View key={category.id}> + {showCategoryTitles ? ( + <Text style={styles.categoryTitle}> + {category.title} + </Text> + ) : null} + {categoryItems} + </View> + ); + })} + </View> + ); + + if (rowKind === 'item') { + // Ensure Item's "selected row background" behavior is enabled, + // and prevent row-position context from leaking into the popover. + return ( + <ItemGroupRowPositionBoundary> + <ItemGroupSelectionContext.Provider value={{ selectableItemCount: 2 }}> + {content} + </ItemGroupSelectionContext.Provider> + </ItemGroupRowPositionBoundary> + ); + } + + return content; +} diff --git a/expo-app/sources/components/ui/forms/dropdown/selectableMenuTypes.ts b/expo-app/sources/components/ui/forms/dropdown/selectableMenuTypes.ts new file mode 100644 index 000000000..4687b83f6 --- /dev/null +++ b/expo-app/sources/components/ui/forms/dropdown/selectableMenuTypes.ts @@ -0,0 +1,20 @@ +import type * as React from 'react'; + +export type SelectableMenuItem = Readonly<{ + id: string; + title: string; + subtitle?: string; + /** Used for grouping headers (optional). */ + category?: string; + /** Optional left/right visuals (icon, shortcut chip, checkmark, etc). */ + left?: React.ReactNode; + right?: React.ReactNode; + disabled?: boolean; +}>; + +export type SelectableMenuCategory = Readonly<{ + id: string; + title: string; + items: ReadonlyArray<SelectableMenuItem>; +}>; + diff --git a/expo-app/sources/components/ui/forms/dropdown/useSelectableMenu.ts b/expo-app/sources/components/ui/forms/dropdown/useSelectableMenu.ts new file mode 100644 index 000000000..13f668137 --- /dev/null +++ b/expo-app/sources/components/ui/forms/dropdown/useSelectableMenu.ts @@ -0,0 +1,133 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { TextInput } from 'react-native'; +import type { SelectableMenuCategory, SelectableMenuItem } from './selectableMenuTypes'; +import { t } from '@/text'; + +function toCategoryId(title: string): string { + return title.toLowerCase().replace(/\s+/g, '-'); +} + +function groupByCategory(items: ReadonlyArray<SelectableMenuItem>, defaultCategory: string): SelectableMenuCategory[] { + const grouped = items.reduce((acc, item) => { + const category = item.category || defaultCategory; + if (!acc[category]) acc[category] = []; + acc[category]!.push(item); + return acc; + }, {} as Record<string, SelectableMenuItem[]>); + + return Object.entries(grouped).map(([title, groupedItems]) => ({ + id: toCategoryId(title), + title, + items: groupedItems, + })); +} + +export function useSelectableMenu(params: { + items: ReadonlyArray<SelectableMenuItem>; + onRequestClose: () => void; + initialSelectedId?: string | null; +}) { + const [searchQuery, setSearchQuery] = useState(''); + const [selectedIndex, setSelectedIndex] = useState(0); + const inputRef = useRef<TextInput>(null); + + const allItemsRaw = useMemo(() => params.items, [params.items]); + const defaultCategoryTitle = t('dropdown.category.general'); + const resultsCategoryTitle = t('dropdown.category.results'); + + const filteredCategories = useMemo((): SelectableMenuCategory[] => { + const query = searchQuery.trim().toLowerCase(); + + if (!query) { + return groupByCategory(allItemsRaw, defaultCategoryTitle); + } + + const filtered = allItemsRaw.filter((item) => { + const titleMatch = item.title.toLowerCase().includes(query); + const subtitleMatch = item.subtitle?.toLowerCase().includes(query) ?? false; + return titleMatch || subtitleMatch; + }); + + if (filtered.length === 0) return []; + return groupByCategory(filtered, resultsCategoryTitle); + }, [allItemsRaw, defaultCategoryTitle, resultsCategoryTitle, searchQuery]); + + const allItems = useMemo(() => { + return filteredCategories.flatMap((c) => c.items); + }, [filteredCategories]); + + const firstEnabledIndex = useCallback((): number => { + for (let i = 0; i < allItems.length; i += 1) { + if (!allItems[i]?.disabled) return i; + } + return 0; + }, [allItems]); + + const isEnabledIndex = useCallback((idx: number) => { + const item = allItems[idx]; + return Boolean(item && !item.disabled); + }, [allItems]); + + const clampToEnabled = useCallback((idx: number): number => { + if (allItems.length === 0) return 0; + if (idx < 0 || idx >= allItems.length) return firstEnabledIndex(); + if (isEnabledIndex(idx)) return idx; + return firstEnabledIndex(); + }, [allItems.length, firstEnabledIndex, isEnabledIndex]); + + // Initialize / reset selection when the query or available items change. + useEffect(() => { + const preferredId = params.initialSelectedId ?? null; + if (preferredId) { + const idx = allItems.findIndex((i) => i.id === preferredId); + if (idx >= 0 && isEnabledIndex(idx)) { + setSelectedIndex(idx); + return; + } + } + setSelectedIndex(firstEnabledIndex()); + }, [allItems, firstEnabledIndex, isEnabledIndex, params.initialSelectedId, searchQuery]); + + const moveSelection = useCallback((dir: -1 | 1) => { + if (allItems.length === 0) return; + let next = selectedIndex; + for (let step = 0; step < allItems.length; step += 1) { + next = Math.min(allItems.length - 1, Math.max(0, next + dir)); + if (isEnabledIndex(next)) { + setSelectedIndex(next); + return; + } + } + }, [allItems.length, isEnabledIndex, selectedIndex]); + + const handleKeyPress = useCallback((key: string, onActivate: (item: SelectableMenuItem) => void) => { + switch (key) { + case 'Escape': + params.onRequestClose(); + break; + case 'ArrowDown': + moveSelection(1); + break; + case 'ArrowUp': + moveSelection(-1); + break; + case 'Enter': + if (isEnabledIndex(selectedIndex) && allItems[selectedIndex]) { + onActivate(allItems[selectedIndex]!); + } + break; + } + }, [allItems, isEnabledIndex, moveSelection, params, selectedIndex]); + + const handleSearchChange = useCallback((text: string) => setSearchQuery(text), []); + + return { + searchQuery, + selectedIndex, + filteredCategories, + inputRef, + handleSearchChange, + handleKeyPress, + setSelectedIndex: (idx: number) => setSelectedIndex(clampToEnabled(idx)), + }; +} diff --git a/expo-app/sources/components/ui/lists/ActionListSection.tsx b/expo-app/sources/components/ui/lists/ActionListSection.tsx new file mode 100644 index 000000000..0fc8ba866 --- /dev/null +++ b/expo-app/sources/components/ui/lists/ActionListSection.tsx @@ -0,0 +1,70 @@ +import * as React from 'react'; +import { Text, View } from 'react-native'; +import { StyleSheet, useUnistyles } from 'react-native-unistyles'; +import { Typography } from '@/constants/Typography'; +import { SelectableRow } from './SelectableRow'; + +export type ActionListItem = Readonly<{ + id: string; + label: string; + icon?: React.ReactNode; + onPress?: () => void; + disabled?: boolean; +}>; + +const stylesheet = StyleSheet.create((theme) => ({ + section: { + paddingTop: 12, + paddingBottom: 8 + }, + title: { + fontSize: 12, + fontWeight: '600', + color: theme.colors.textSecondary, + paddingHorizontal: 16, + paddingBottom: 4, + ...Typography.default('semiBold'), + textTransform: 'uppercase', + }, + label: { + fontSize: 14, + color: theme.colors.text, + ...Typography.default(), + }, +})); + +export function ActionListSection(props: { + title?: string; + actions: ReadonlyArray<ActionListItem | null | undefined>; +}) { + const styles = stylesheet; + useUnistyles(); + + const actions = React.useMemo(() => { + return (props.actions ?? []).filter(Boolean) as ActionListItem[]; + }, [props.actions]); + + if (actions.length === 0) return null; + + return ( + <View style={styles.section}> + {props.title ? ( + <Text style={styles.title}> + {props.title} + </Text> + ) : null} + + {actions.map((action) => ( + <SelectableRow + key={action.id} + disabled={action.disabled} + onPress={action.onPress} + left={action.icon ? <View>{action.icon}</View> : null} + title={action.label} + titleStyle={styles.label} + variant="slim" + /> + ))} + </View> + ); +} diff --git a/expo-app/sources/components/ui/lists/Item.tsx b/expo-app/sources/components/ui/lists/Item.tsx new file mode 100644 index 000000000..d2fee3241 --- /dev/null +++ b/expo-app/sources/components/ui/lists/Item.tsx @@ -0,0 +1,367 @@ +import * as React from 'react'; +import { + View, + Text, + Pressable, + StyleProp, + ViewStyle, + TextStyle, + Platform, + ActivityIndicator +} from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { Typography } from '@/constants/Typography'; +import * as Clipboard from 'expo-clipboard'; +import { Modal } from '@/modal'; +import { t } from '@/text'; +import { StyleSheet, useUnistyles } from 'react-native-unistyles'; +import { ItemGroupSelectionContext } from '@/components/ui/lists/ItemGroup'; +import { useItemGroupRowPosition } from '@/components/ui/lists/ItemGroupRowPosition'; +import { getItemGroupRowCornerRadii } from '@/components/ui/lists/itemGroupRowCorners'; + +export interface ItemProps { + title: string; + subtitle?: React.ReactNode; + subtitleLines?: number; // set 0 or undefined for auto/multiline + detail?: string; + icon?: React.ReactNode; + leftElement?: React.ReactNode; + rightElement?: React.ReactNode; + onPress?: () => void; + onLongPress?: () => void; + disabled?: boolean; + loading?: boolean; + selected?: boolean; + destructive?: boolean; + style?: StyleProp<ViewStyle>; + titleStyle?: StyleProp<TextStyle>; + subtitleStyle?: StyleProp<TextStyle>; + detailStyle?: StyleProp<TextStyle>; + showChevron?: boolean; + showDivider?: boolean; + dividerInset?: number; + pressableStyle?: StyleProp<ViewStyle>; + copy?: boolean | string; +} + +const stylesheet = StyleSheet.create((theme, runtime) => ({ + container: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 16, + minHeight: Platform.select({ ios: 44, default: 56 }), + }, + containerWithSubtitle: { + paddingVertical: Platform.select({ ios: 11, default: 16 }), + }, + containerWithoutSubtitle: { + paddingVertical: Platform.select({ ios: 12, default: 16 }), + }, + iconContainer: { + marginRight: 12, + width: Platform.select({ ios: 29, default: 32 }), + height: Platform.select({ ios: 29, default: 32 }), + alignItems: 'center', + justifyContent: 'center', + }, + centerContent: { + flex: 1, + justifyContent: 'center', + }, + title: { + ...Typography.default('regular'), + fontSize: Platform.select({ ios: 17, default: 16 }), + lineHeight: Platform.select({ ios: 22, default: 24 }), + letterSpacing: Platform.select({ ios: -0.41, default: 0.15 }), + }, + titleNormal: { + color: theme.colors.text, + }, + titleSelected: { + color: theme.colors.text, + }, + titleDestructive: { + color: theme.colors.textDestructive, + }, + subtitle: { + ...Typography.default('regular'), + color: theme.colors.textSecondary, + fontSize: Platform.select({ ios: 15, default: 14 }), + lineHeight: 20, + letterSpacing: Platform.select({ ios: -0.24, default: 0.1 }), + marginTop: Platform.select({ ios: 2, default: 0 }), + }, + rightSection: { + flexDirection: 'row', + alignItems: 'center', + marginLeft: 8, + }, + detail: { + ...Typography.default('regular'), + color: theme.colors.textSecondary, + fontSize: 17, + letterSpacing: -0.41, + }, + divider: { + height: Platform.select({ ios: 0.33, default: 0 }), + backgroundColor: theme.colors.divider, + }, + pressablePressed: { + backgroundColor: theme.colors.surfacePressedOverlay, + }, +})); + +export const Item = React.memo<ItemProps>((props) => { + const { theme } = useUnistyles(); + const styles = stylesheet; + const selectionContext = React.useContext(ItemGroupSelectionContext); + const rowPosition = useItemGroupRowPosition(); + + // Platform-specific measurements + const isIOS = Platform.OS === 'ios'; + const isAndroid = Platform.OS === 'android'; + const isWeb = Platform.OS === 'web'; + const hoverBackgroundColor = isWeb + ? (theme.dark ? theme.colors.surfaceHighest : theme.colors.surfaceHigh) + : theme.colors.surfacePressedOverlay; + + // Timer ref for long press copy functionality + const longPressTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null); + + const { + title, + subtitle, + subtitleLines, + detail, + icon, + leftElement, + rightElement, + onPress, + onLongPress, + disabled, + loading, + selected, + destructive, + style, + titleStyle, + subtitleStyle, + detailStyle, + showChevron = true, + showDivider = true, + dividerInset = isIOS ? 15 : 16, + pressableStyle, + copy + } = props; + + // Handle copy functionality + const handleCopy = React.useCallback(async () => { + if (!copy || isWeb) return; + + let textToCopy: string; + const subtitleText = typeof subtitle === 'string' ? subtitle : null; + + if (typeof copy === 'string') { + // If copy is a string, use it directly + textToCopy = copy; + } else { + // If copy is true, try to figure out what to copy + // Priority: detail > subtitle > title + textToCopy = detail || subtitleText || title; + } + + try { + await Clipboard.setStringAsync(textToCopy); + Modal.alert(t('common.copied'), t('items.copiedToClipboard', { label: title })); + } catch (error) { + console.error('Failed to copy:', error); + } + }, [copy, isWeb, title, subtitle, detail]); + + // Handle long press for copy functionality + const handlePressIn = React.useCallback(() => { + if (copy && !isWeb && !onPress) { + longPressTimer.current = setTimeout(() => { + handleCopy(); + }, 500); // 500ms delay for long press + } + }, [copy, isWeb, onPress, handleCopy]); + + const handlePressOut = React.useCallback(() => { + if (longPressTimer.current) { + clearTimeout(longPressTimer.current); + longPressTimer.current = null; + } + }, []); + + // Clean up timer on unmount + React.useEffect(() => { + return () => { + if (longPressTimer.current) { + clearTimeout(longPressTimer.current); + } + }; + }, []); + + // If copy is enabled and no onPress is provided, don't set a regular press handler + // The copy will be handled by long press instead + const handlePress = onPress; + + const isInteractive = handlePress || onLongPress || (copy && !isWeb); + const showAccessory = isInteractive && showChevron && !rightElement; + const chevronSize = (isIOS && !isWeb) ? 17 : 24; + const showSelectedBackground = !!selected && ((selectionContext?.selectableItemCount ?? 2) > 1); + const groupCornerRadius = Platform.select({ ios: 10, default: 16 }); + + const titleColor = destructive ? styles.titleDestructive : (selected ? styles.titleSelected : styles.titleNormal); + const containerPadding = subtitle ? styles.containerWithSubtitle : styles.containerWithoutSubtitle; + + const isSelectableRow = React.useMemo(() => { + // Only show hover for "selection lists" (where rows participate in a selected-state group). + // This avoids making all navigation rows hoverable. + // NOTE: we intentionally do NOT gate on `selectableItemCount > 1` because single-item + // selection lists should still have hover affordances. + return typeof selected === 'boolean' && Boolean(selectionContext); + }, [selected, selectionContext]); + + const [isHovered, setIsHovered] = React.useState(false); + React.useEffect(() => { + // Keep hover state coherent with disabled/loading changes. + if (disabled || loading) setIsHovered(false); + }, [disabled, loading]); + + const content = ( + <> + <View style={[styles.container, containerPadding, style]}> + {/* Left Section */} + {(icon || leftElement) && ( + <View style={styles.iconContainer}> + {leftElement || icon} + </View> + )} + + {/* Center Section */} + <View style={styles.centerContent}> + <Text + style={[styles.title, titleColor, titleStyle]} + numberOfLines={subtitle ? 1 : 2} + > + {title} + </Text> + {subtitle && (() => { + // If subtitle is a ReactNode (not string), render as-is. + // This enables richer subtitle layouts (e.g. inline glyphs). + if (typeof subtitle !== 'string') { + return ( + <View style={{ marginTop: Platform.select({ ios: 2, default: 0 }) }}> + {subtitle} + </View> + ); + } + + // Allow multiline when requested or when content contains line breaks + const effectiveLines = subtitleLines !== undefined + ? (subtitleLines <= 0 ? undefined : subtitleLines) + : (subtitle.indexOf('\n') !== -1 ? undefined : 1); + + return ( + <Text + style={[styles.subtitle, subtitleStyle]} + numberOfLines={effectiveLines} + > + {subtitle} + </Text> + ); + })()} + </View> + + {/* Right Section */} + <View style={styles.rightSection}> + {detail && !rightElement && ( + <Text + style={[ + styles.detail, + { marginRight: showAccessory ? 6 : 0 }, + detailStyle + ]} + numberOfLines={1} + > + {detail} + </Text> + )} + {loading && ( + <ActivityIndicator + size="small" + color={theme.colors.textSecondary} + style={{ marginRight: showAccessory ? 6 : 0 }} + /> + )} + {rightElement} + {showAccessory && ( + <Ionicons + name="chevron-forward" + size={chevronSize} + color={theme.colors.groupped.chevron} + style={{ marginLeft: 4 }} + /> + )} + </View> + </View> + + {/* Divider */} + {showDivider && ( + <View + style={[ + styles.divider, + { + marginLeft: (isAndroid || isWeb) ? 0 : (dividerInset + (icon || leftElement ? (16 + ((isIOS && !isWeb) ? 29 : 32) + 15) : 16)) + } + ]} + /> + )} + </> + ); + + if (isInteractive) { + return ( + <Pressable + onPress={handlePress} + onLongPress={onLongPress} + onPressIn={handlePressIn} + onPressOut={handlePressOut} + onHoverIn={isWeb && isSelectableRow && !disabled && !loading ? () => setIsHovered(true) : undefined} + onHoverOut={isWeb ? () => setIsHovered(false) : undefined} + disabled={disabled || loading} + style={({ pressed }) => { + const backgroundColor = (() => { + if (pressed && isIOS && !isWeb) return theme.colors.surfacePressedOverlay; + if (showSelectedBackground) return theme.colors.surfaceSelected; + // Web-only hover affordance for selectable rows (no hover when disabled). + if (isWeb && isSelectableRow && isHovered && !disabled && !loading) return hoverBackgroundColor; + return 'transparent'; + })(); + + const roundedCornersStyle = getItemGroupRowCornerRadii({ + hasBackground: backgroundColor !== 'transparent', + position: rowPosition, + radius: groupCornerRadius, + }); + + return [ + { backgroundColor, opacity: disabled ? 0.5 : 1 }, + roundedCornersStyle, + pressableStyle, + ]; + }} + android_ripple={(isAndroid || isWeb) ? { + color: theme.colors.surfaceRipple, + borderless: false, + foreground: true + } : undefined} + > + {content} + </Pressable> + ); + } + + return <View style={[{ opacity: disabled ? 0.5 : 1 }, pressableStyle]}>{content}</View>; +}); diff --git a/expo-app/sources/components/ui/lists/ItemGroup.dividers.test.ts b/expo-app/sources/components/ui/lists/ItemGroup.dividers.test.ts new file mode 100644 index 000000000..b627ced85 --- /dev/null +++ b/expo-app/sources/components/ui/lists/ItemGroup.dividers.test.ts @@ -0,0 +1,73 @@ +import React from 'react'; +import { describe, expect, it } from 'vitest'; +import { withItemGroupDividers } from './ItemGroup.dividers'; +import { ItemGroupRowPositionProvider } from './ItemGroupRowPosition'; + +type FragmentProps = { + children?: React.ReactNode; +}; + +function TestItem(_props: { id: string; showDivider?: boolean }) { + return null; +} + +function collectShowDividers(node: React.ReactNode): Array<boolean | undefined> { + const values: Array<boolean | undefined> = []; + + const walk = (n: React.ReactNode) => { + React.Children.forEach(n, (child) => { + if (!React.isValidElement(child)) return; + if (child.type === React.Fragment) { + const fragment = child as React.ReactElement<FragmentProps>; + walk(fragment.props.children); + return; + } + if (child.type === ItemGroupRowPositionProvider) { + const provider = child as React.ReactElement<{ children?: React.ReactNode }>; + walk(provider.props.children); + return; + } + if (child.type === TestItem) { + const element = child as React.ReactElement<{ showDivider?: boolean }>; + values.push(element.props.showDivider); + return; + } + // Ignore other element types. + }); + }; + + walk(node); + return values; +} + +describe('withItemGroupDividers', () => { + it('treats fragment children as part of the divider sequence', () => { + const children = React.createElement( + React.Fragment, + null, + React.createElement(TestItem, { id: 'a' }), + React.createElement( + React.Fragment, + null, + React.createElement(TestItem, { id: 'b' }), + React.createElement(TestItem, { id: 'c' }), + ), + ); + + const processed = withItemGroupDividers(children); + expect(collectShowDividers(processed)).toEqual([true, true, false]); + }); + + it('preserves explicit showDivider={false} overrides', () => { + const children = React.createElement( + React.Fragment, + null, + React.createElement(TestItem, { id: 'a', showDivider: false }), + React.createElement(TestItem, { id: 'b' }), + React.createElement(TestItem, { id: 'c' }), + ); + + const processed = withItemGroupDividers(children); + expect(collectShowDividers(processed)).toEqual([false, true, false]); + }); +}); diff --git a/expo-app/sources/components/ui/lists/ItemGroup.dividers.ts b/expo-app/sources/components/ui/lists/ItemGroup.dividers.ts new file mode 100644 index 000000000..0831efe82 --- /dev/null +++ b/expo-app/sources/components/ui/lists/ItemGroup.dividers.ts @@ -0,0 +1,56 @@ +import * as React from 'react'; +import { ItemGroupRowPositionProvider } from './ItemGroupRowPosition'; + +type DividerChildProps = { + showDivider?: boolean; +}; + +type FragmentProps = { + children?: React.ReactNode; +}; + +export function withItemGroupDividers(children: React.ReactNode): React.ReactNode { + const countNonFragmentElements = (node: React.ReactNode): number => { + return React.Children.toArray(node).reduce<number>((count, child) => { + if (!React.isValidElement(child)) { + return count; + } + if (child.type === React.Fragment) { + const fragment = child as React.ReactElement<FragmentProps>; + return count + countNonFragmentElements(fragment.props.children); + } + return count + 1; + }, 0); + }; + + const total = countNonFragmentElements(children); + if (total === 0) return children; + + let index = 0; + const apply = (node: React.ReactNode): React.ReactNode => { + return React.Children.map(node, (child) => { + if (!React.isValidElement(child)) { + return child; + } + if (child.type === React.Fragment) { + const fragment = child as React.ReactElement<FragmentProps>; + return React.cloneElement(fragment, {}, apply(fragment.props.children)); + } + + const isFirst = index === 0; + const isLast = index === total - 1; + index += 1; + + const element = child as React.ReactElement<DividerChildProps>; + const showDivider = !isLast && element.props.showDivider !== false; + const wrapperKey = element.key ?? `row-${index - 1}`; + return React.createElement( + ItemGroupRowPositionProvider, + { key: wrapperKey as any, value: { isFirst, isLast } }, + React.cloneElement(element, { showDivider }), + ); + }); + }; + + return apply(children); +} diff --git a/expo-app/sources/components/ui/lists/ItemGroup.selectableCount.test.ts b/expo-app/sources/components/ui/lists/ItemGroup.selectableCount.test.ts new file mode 100644 index 000000000..ee7b0de51 --- /dev/null +++ b/expo-app/sources/components/ui/lists/ItemGroup.selectableCount.test.ts @@ -0,0 +1,47 @@ +import React from 'react'; +import { describe, expect, it } from 'vitest'; +import { countSelectableItems } from './ItemGroup.selectableCount'; + +function TestItem(_props: { title?: React.ReactNode; onPress?: () => void; onLongPress?: () => void }) { + return null; +} + +describe('countSelectableItems', () => { + it('counts items with ReactNode titles as selectable', () => { + const node = React.createElement( + React.Fragment, + null, + React.createElement(TestItem, { title: React.createElement('span', null, 'X'), onPress: () => {} }), + React.createElement(TestItem, { title: 'Y', onPress: () => {} }), + ); + + expect(countSelectableItems(node)).toBe(2); + }); + + it('does not count items with empty-string titles', () => { + const node = React.createElement( + React.Fragment, + null, + React.createElement(TestItem, { title: '', onPress: () => {} }), + React.createElement(TestItem, { title: 'ok', onPress: () => {} }), + ); + + expect(countSelectableItems(node)).toBe(1); + }); + + it('recurse-counts Fragment children', () => { + const node = React.createElement( + React.Fragment, + null, + React.createElement(TestItem, { title: 'a', onPress: () => {} }), + React.createElement( + React.Fragment, + null, + React.createElement(TestItem, { title: React.createElement('span', null, 'b'), onPress: () => {} }), + React.createElement(TestItem, { title: undefined, onPress: () => {} }), + ), + ); + + expect(countSelectableItems(node)).toBe(2); + }); +}); diff --git a/expo-app/sources/components/ui/lists/ItemGroup.selectableCount.ts b/expo-app/sources/components/ui/lists/ItemGroup.selectableCount.ts new file mode 100644 index 000000000..1265140dc --- /dev/null +++ b/expo-app/sources/components/ui/lists/ItemGroup.selectableCount.ts @@ -0,0 +1,24 @@ +import * as React from 'react'; + +type ItemChildProps = { + title?: unknown; + onPress?: unknown; + onLongPress?: unknown; +}; + +export function countSelectableItems(node: React.ReactNode): number { + return React.Children.toArray(node).reduce<number>((count, child) => { + if (!React.isValidElement(child)) { + return count; + } + if (child.type === React.Fragment) { + const fragment = child as React.ReactElement<{ children?: React.ReactNode }>; + return count + countSelectableItems(fragment.props.children); + } + const propsAny = (child as React.ReactElement<ItemChildProps>).props as any; + const title = propsAny?.title; + const hasTitle = title !== null && title !== undefined && title !== ''; + const isSelectable = typeof propsAny?.onPress === 'function' || typeof propsAny?.onLongPress === 'function'; + return count + (hasTitle && isSelectable ? 1 : 0); + }, 0); +} diff --git a/expo-app/sources/components/ui/lists/ItemGroup.tsx b/expo-app/sources/components/ui/lists/ItemGroup.tsx new file mode 100644 index 000000000..feaa756c8 --- /dev/null +++ b/expo-app/sources/components/ui/lists/ItemGroup.tsx @@ -0,0 +1,163 @@ +import * as React from 'react'; +import { + View, + Text, + StyleProp, + ViewStyle, + TextStyle, + Platform +} from 'react-native'; +import { Typography } from '@/constants/Typography'; +import { layout } from '@/components/layout'; +import { StyleSheet, useUnistyles } from 'react-native-unistyles'; +import { withItemGroupDividers } from './ItemGroup.dividers'; +import { countSelectableItems } from './ItemGroup.selectableCount'; +import { PopoverBoundaryProvider } from '@/components/ui/popover'; + +export { withItemGroupDividers } from './ItemGroup.dividers'; + +export const ItemGroupSelectionContext = React.createContext<{ selectableItemCount: number } | null>(null); + +export interface ItemGroupProps { + title?: string | React.ReactNode; + footer?: string; + children: React.ReactNode; + style?: StyleProp<ViewStyle>; + headerStyle?: StyleProp<ViewStyle>; + footerStyle?: StyleProp<ViewStyle>; + titleStyle?: StyleProp<TextStyle>; + footerTextStyle?: StyleProp<TextStyle>; + containerStyle?: StyleProp<ViewStyle>; + /** + * Performance: when you already know how many selectable rows are inside the group, + * pass this to avoid walking the full React children tree on every render. + */ + selectableItemCountOverride?: number; +} + +const stylesheet = StyleSheet.create((theme, runtime) => ({ + wrapper: { + alignItems: 'center', + }, + container: { + width: '100%', + maxWidth: layout.maxWidth, + paddingHorizontal: Platform.select({ ios: 0, default: 4 }), + }, + header: { + paddingTop: Platform.select({ ios: 26, default: 20 }), + paddingBottom: Platform.select({ ios: 8, default: 8 }), + paddingHorizontal: Platform.select({ ios: 32, default: 24 }), + }, + headerNoTitle: { + paddingTop: Platform.select({ ios: 20, default: 16 }), + }, + headerText: { + ...Typography.default('regular'), + color: theme.colors.groupped.sectionTitle, + fontSize: Platform.select({ ios: 13, default: 14 }), + lineHeight: Platform.select({ ios: 18, default: 20 }), + letterSpacing: Platform.select({ ios: -0.08, default: 0.1 }), + textTransform: 'uppercase', + fontWeight: Platform.select({ ios: 'normal', default: '500' }), + }, + contentContainerOuter: { + backgroundColor: theme.colors.surface, + marginHorizontal: Platform.select({ ios: 16, default: 12 }), + borderRadius: Platform.select({ ios: 10, default: 16 }), + // IMPORTANT: allow popovers to overflow this rounded container. + overflow: 'visible', + shadowColor: theme.colors.shadow.color, + shadowOffset: { width: 0, height: 0.33 }, + shadowOpacity: theme.colors.shadow.opacity, + shadowRadius: 0, + elevation: 1 + }, + contentContainerInner: { + borderRadius: Platform.select({ ios: 10, default: 16 }), + }, + footer: { + paddingTop: Platform.select({ ios: 6, default: 8 }), + paddingBottom: Platform.select({ ios: 8, default: 16 }), + paddingHorizontal: Platform.select({ ios: 32, default: 24 }), + }, + footerText: { + ...Typography.default('regular'), + color: theme.colors.groupped.sectionTitle, + fontSize: Platform.select({ ios: 13, default: 14 }), + lineHeight: Platform.select({ ios: 18, default: 20 }), + letterSpacing: Platform.select({ ios: -0.08, default: 0 }), + }, +})); + +export const ItemGroup = React.memo<ItemGroupProps>((props) => { + const { theme } = useUnistyles(); + const styles = stylesheet; + const popoverBoundaryRef = React.useRef<View>(null); + + const { + title, + footer, + children, + style, + headerStyle, + footerStyle, + titleStyle, + footerTextStyle, + containerStyle, + selectableItemCountOverride + } = props; + + const selectableItemCount = React.useMemo(() => { + if (typeof selectableItemCountOverride === 'number') { + return selectableItemCountOverride; + } + return countSelectableItems(children); + }, [children, selectableItemCountOverride]); + + const selectionContextValue = React.useMemo(() => { + return { selectableItemCount }; + }, [selectableItemCount]); + + return ( + <View style={[styles.wrapper, style]}> + <View style={styles.container}> + {/* Header */} + {title ? ( + <View style={[styles.header, headerStyle]}> + {typeof title === 'string' ? ( + <Text style={[styles.headerText, titleStyle]}> + {title} + </Text> + ) : ( + title + )} + </View> + ) : ( + // Add top margin when there's no title + <View style={styles.headerNoTitle} /> + )} + + {/* Content Container */} + <View ref={popoverBoundaryRef} style={[styles.contentContainerOuter, containerStyle]}> + <PopoverBoundaryProvider boundaryRef={popoverBoundaryRef}> + <View style={styles.contentContainerInner}> + <ItemGroupSelectionContext.Provider value={selectionContextValue}> + {withItemGroupDividers(children)} + </ItemGroupSelectionContext.Provider> + </View> + </PopoverBoundaryProvider> + </View> + + {/* Footer */} + {footer && ( + <View style={[styles.footer, footerStyle]}> + <Text style={[styles.footerText, footerTextStyle]}> + {footer} + </Text> + </View> + )} + </View> + </View> + ); +}); diff --git a/expo-app/sources/components/ui/lists/ItemGroupRowPosition.tsx b/expo-app/sources/components/ui/lists/ItemGroupRowPosition.tsx new file mode 100644 index 000000000..6acbc16cf --- /dev/null +++ b/expo-app/sources/components/ui/lists/ItemGroupRowPosition.tsx @@ -0,0 +1,36 @@ +import * as React from 'react'; + +export type ItemGroupRowPosition = Readonly<{ + isFirst: boolean; + isLast: boolean; +}>; + +const ItemGroupRowPositionContext = React.createContext<ItemGroupRowPosition | null>(null); + +export function ItemGroupRowPositionProvider(props: { + value: ItemGroupRowPosition | null; + children?: React.ReactNode; +}) { + return ( + <ItemGroupRowPositionContext.Provider value={props.value}> + {props.children} + </ItemGroupRowPositionContext.Provider> + ); +} + +/** + * Resets any inherited ItemGroup row-position context for descendants. + * Useful for portal/popover content (e.g. dropdown menus) where context would + * otherwise “leak” from the trigger row. + */ +export function ItemGroupRowPositionBoundary(props: { children?: React.ReactNode }) { + return ( + <ItemGroupRowPositionProvider value={null}> + {props.children} + </ItemGroupRowPositionProvider> + ); +} + +export function useItemGroupRowPosition(): ItemGroupRowPosition | null { + return React.useContext(ItemGroupRowPositionContext); +} diff --git a/expo-app/sources/components/ui/lists/ItemGroupTitleWithAction.test.ts b/expo-app/sources/components/ui/lists/ItemGroupTitleWithAction.test.ts new file mode 100644 index 000000000..3464b7729 --- /dev/null +++ b/expo-app/sources/components/ui/lists/ItemGroupTitleWithAction.test.ts @@ -0,0 +1,42 @@ +import * as React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import renderer, { act } from 'react-test-renderer'; + +// Required for React 18+ act() semantics with react-test-renderer. +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +vi.mock('react-native', () => ({ + View: 'View', + Text: 'Text', + Pressable: 'Pressable', + ActivityIndicator: 'ActivityIndicator', +})); + +vi.mock('@expo/vector-icons', () => ({ + Ionicons: 'Ionicons', +})); + +describe('ItemGroupTitleWithAction', () => { + it('renders the action button immediately after the title', async () => { + const { ItemGroupTitleWithAction } = await import('./ItemGroupTitleWithAction'); + + let tree: renderer.ReactTestRenderer | null = null; + act(() => { + tree = renderer.create(React.createElement(ItemGroupTitleWithAction, { + title: 'Detected CLIs', + titleStyle: { color: '#000' }, + action: { + accessibilityLabel: 'Refresh', + iconName: 'refresh', + iconColor: '#666', + onPress: vi.fn(), + }, + })); + }); + + const rootView = tree!.root.findByType('View' as any); + const children = React.Children.toArray(rootView.props.children) as any[]; + expect(children.map((c) => c.type)).toEqual(['Text', 'Pressable']); + expect(children[0]?.props?.children).toBe('Detected CLIs'); + }); +}); diff --git a/expo-app/sources/components/ui/lists/ItemGroupTitleWithAction.tsx b/expo-app/sources/components/ui/lists/ItemGroupTitleWithAction.tsx new file mode 100644 index 000000000..a7b69055f --- /dev/null +++ b/expo-app/sources/components/ui/lists/ItemGroupTitleWithAction.tsx @@ -0,0 +1,44 @@ +import * as React from 'react'; +import { ActivityIndicator, Pressable, Text, View } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; + +export type ItemGroupTitleAction = { + accessibilityLabel: string; + iconName: React.ComponentProps<typeof Ionicons>['name']; + iconColor?: string; + disabled?: boolean; + loading?: boolean; + onPress: () => void; +}; + +export type ItemGroupTitleWithActionProps = { + title: string; + titleStyle?: any; + containerStyle?: any; + action?: ItemGroupTitleAction; +}; + +export const ItemGroupTitleWithAction = React.memo((props: ItemGroupTitleWithActionProps) => { + return ( + <View style={[{ flexDirection: 'row', alignItems: 'center' }, props.containerStyle]}> + <Text style={props.titleStyle} numberOfLines={1}> + {props.title} + </Text> + {props.action ? ( + <Pressable + onPress={props.action.onPress} + hitSlop={10} + style={{ padding: 2, marginLeft: 8 }} + accessibilityRole="button" + accessibilityLabel={props.action.accessibilityLabel} + disabled={props.action.disabled === true} + > + {props.action.loading === true + ? <ActivityIndicator size="small" color={props.action.iconColor} /> + : <Ionicons name={props.action.iconName} size={18} color={props.action.iconColor} />} + </Pressable> + ) : null} + </View> + ); +}); + diff --git a/expo-app/sources/components/ui/lists/ItemList.tsx b/expo-app/sources/components/ui/lists/ItemList.tsx new file mode 100644 index 000000000..33c6d9ac9 --- /dev/null +++ b/expo-app/sources/components/ui/lists/ItemList.tsx @@ -0,0 +1,105 @@ +import * as React from 'react'; +import { + ScrollView, + View, + StyleProp, + ViewStyle, + Platform, + ScrollViewProps +} from 'react-native'; +import { StyleSheet, useUnistyles } from 'react-native-unistyles'; + +export interface ItemListProps extends ScrollViewProps { + children: React.ReactNode; + style?: StyleProp<ViewStyle>; + containerStyle?: StyleProp<ViewStyle>; + insetGrouped?: boolean; +} + +const stylesheet = StyleSheet.create((theme, runtime) => ({ + container: { + flex: 1, + backgroundColor: theme.colors.groupped.background, + }, + contentContainer: { + paddingBottom: Platform.select({ ios: 34, default: 16 }), + paddingTop: 0, + }, +})); + +export const ItemList = React.memo(React.forwardRef<ScrollView, ItemListProps>((props, ref) => { + const { theme } = useUnistyles(); + const styles = stylesheet; + + const { + children, + style, + containerStyle, + insetGrouped = true, + ...scrollViewProps + } = props; + + const isIOS = Platform.OS === 'ios'; + const isWeb = Platform.OS === 'web'; + + // Override background for non-inset grouped lists on iOS + const backgroundColor = (isIOS && !insetGrouped) ? '#FFFFFF' : theme.colors.groupped.background; + + return ( + <ScrollView + ref={ref} + style={[ + styles.container, + { backgroundColor }, + style + ]} + contentContainerStyle={[ + styles.contentContainer, + containerStyle + ]} + showsVerticalScrollIndicator={scrollViewProps.showsVerticalScrollIndicator !== undefined + ? scrollViewProps.showsVerticalScrollIndicator + : true} + contentInsetAdjustmentBehavior={(isIOS && !isWeb) ? 'automatic' : undefined} + {...scrollViewProps} + > + {children} + </ScrollView> + ); +})); + +ItemList.displayName = 'ItemList'; + +export const ItemListStatic = React.memo<Omit<ItemListProps, keyof ScrollViewProps> & { + children: React.ReactNode; + style?: StyleProp<ViewStyle>; + containerStyle?: StyleProp<ViewStyle>; + insetGrouped?: boolean; +}>((props) => { + const { theme } = useUnistyles(); + + const { + children, + style, + containerStyle, + insetGrouped = true + } = props; + + const isIOS = Platform.OS === 'ios'; + + // Override background for non-inset grouped lists on iOS + const backgroundColor = (isIOS && !insetGrouped) ? '#FFFFFF' : theme.colors.groupped.background; + + return ( + <View + style={[ + { backgroundColor }, + style + ]} + > + <View style={containerStyle}> + {children} + </View> + </View> + ); +}); diff --git a/expo-app/sources/components/ui/lists/ItemRowActions.test.ts b/expo-app/sources/components/ui/lists/ItemRowActions.test.ts new file mode 100644 index 000000000..c06c2c41d --- /dev/null +++ b/expo-app/sources/components/ui/lists/ItemRowActions.test.ts @@ -0,0 +1,125 @@ +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import renderer, { act } from 'react-test-renderer'; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +vi.mock('@/components/ui/popover', () => ({ + usePopoverBoundaryRef: () => null, +})); + +vi.mock('@/components/FloatingOverlay', () => { + const React = require('react'); + return { + FloatingOverlay: (props: any) => React.createElement('FloatingOverlay', props, props.children), + }; +}); + +vi.mock('@/components/ui/popover', () => { + const React = require('react'); + return { + Popover: (props: any) => { + if (!props.open) return null; + return React.createElement( + 'Popover', + props, + props.children({ + maxHeight: 400, + maxWidth: 400, + placement: props.placement === 'auto' ? 'bottom' : (props.placement ?? 'bottom'), + }), + ); + }, + }; +}); + +vi.mock('@expo/vector-icons', () => { + const React = require('react'); + return { + Ionicons: (props: any) => React.createElement('Ionicons', props, props.children), + }; +}); + +vi.mock('react-native-unistyles', () => { + const theme = { + dark: false, + colors: { + surface: '#ffffff', + surfacePressed: '#f1f1f1', + surfacePressedOverlay: '#f7f7f7', + divider: 'rgba(0,0,0,0.12)', + text: '#111111', + textSecondary: '#666666', + textDestructive: '#cc0000', + deleteAction: '#cc0000', + button: { secondary: { tint: '#111111' } }, + }, + }; + + return { + StyleSheet: { create: (factory: any) => factory(theme, {}) }, + useUnistyles: () => ({ + theme, + }), + }; +}); + +vi.mock('react-native', () => { + const React = require('react'); + return { + Platform: { OS: 'ios', select: (m: any) => m?.ios ?? m?.default }, + InteractionManager: { runAfterInteractions: () => {} }, + useWindowDimensions: () => ({ width: 320, height: 800 }), + StyleSheet: { + absoluteFill: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 }, + }, + View: (props: any) => React.createElement('View', props, props.children), + Text: (props: any) => React.createElement('Text', props, props.children), + Pressable: (props: any) => React.createElement('Pressable', props, props.children), + }; +}); + +describe('ItemRowActions', () => { + it('invokes overflow actions even when InteractionManager does not run callbacks', async () => { + const { ItemRowActions } = await import('./ItemRowActions'); + const { SelectableRow } = await import('@/components/ui/lists/SelectableRow'); + + const onEdit = vi.fn(); + + let tree: ReturnType<typeof renderer.create> | undefined; + act(() => { + tree = renderer.create( + React.createElement(ItemRowActions, { + title: 'Profile', + actions: [ + { id: 'edit', title: 'Edit profile', icon: 'create-outline', onPress: onEdit }, + ], + }), + ); + }); + + const trigger = (tree?.root.findAllByType('Pressable' as any) ?? []).find( + (node: any) => node.props?.accessibilityLabel === 'More actions', + ); + expect(trigger).toBeTruthy(); + + act(() => { + trigger?.props?.onPress?.({ stopPropagation: () => {} }); + }); + + const editRow = (tree?.root.findAllByType(SelectableRow as any) ?? []).find( + (node: any) => node.props?.title === 'Edit profile', + ); + expect(editRow).toBeTruthy(); + + act(() => { + editRow?.props?.onPress?.(); + }); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(onEdit).toHaveBeenCalledTimes(1); + }); +}); diff --git a/expo-app/sources/components/ui/lists/ItemRowActions.tsx b/expo-app/sources/components/ui/lists/ItemRowActions.tsx new file mode 100644 index 000000000..9cfc85f8c --- /dev/null +++ b/expo-app/sources/components/ui/lists/ItemRowActions.tsx @@ -0,0 +1,244 @@ +import React from 'react'; +import { View, Pressable, useWindowDimensions, type GestureResponderEvent, InteractionManager, Platform } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import Color from 'color'; +import { StyleSheet, useUnistyles } from 'react-native-unistyles'; +import { type ItemAction } from '@/components/ui/lists/itemActions'; +import { Popover } from '@/components/ui/popover'; +import { FloatingOverlay } from '@/components/FloatingOverlay'; +import { ActionListSection, type ActionListItem } from '@/components/ui/lists/ActionListSection'; + +export interface ItemRowActionsProps { + title: string; + actions: ItemAction[]; + compactThreshold?: number; + compactActionIds?: string[]; + /** + * Action IDs that should remain visible on compact layouts and be rendered + * at the far right of the row. + */ + pinnedActionIds?: string[]; + /** + * Where to render the overflow (ellipsis) trigger on compact layouts. + * - 'end': after all inline actions (default) + * - 'beforePinned': between inline actions and pinned actions + */ + overflowPosition?: 'end' | 'beforePinned'; + iconSize?: number; + gap?: number; + onActionPressIn?: () => void; + /** + * Optional explicit boundary ref for the popover. Useful when the row is rendered + * inside a scroll container that should bound the popover sizing/placement. + * If omitted, the PopoverBoundaryProvider context (e.g. ItemGroup) is used. + */ + popoverBoundaryRef?: React.RefObject<any> | null; +} + +export function ItemRowActions(props: ItemRowActionsProps) { + const { theme } = useUnistyles(); + const styles = stylesheet; + const { width } = useWindowDimensions(); + const compact = width < (props.compactThreshold ?? 450); + const [showOverflow, setShowOverflow] = React.useState(false); + const overflowAnchorRef = React.useRef<View>(null); + + const blurTintOnWeb = React.useMemo(() => { + try { + const alpha = theme.dark ? 0.20 : 0.25; + return Color(theme.colors.surface).alpha(alpha).rgb().string(); + } catch { + return theme.dark ? 'rgba(0, 0, 0, 0.20)' : 'rgba(255, 255, 255, 0.25)'; + } + }, [theme.colors.surface, theme.dark]); + + const compactIds = React.useMemo(() => new Set(props.compactActionIds ?? []), [props.compactActionIds]); + const pinnedIds = React.useMemo(() => new Set(props.pinnedActionIds ?? []), [props.pinnedActionIds]); + const overflowPosition = props.overflowPosition ?? 'end'; + + const inlineActions = React.useMemo(() => { + if (!compact) return props.actions; + return props.actions.filter((a) => compactIds.has(a.id)); + }, [compact, compactIds, props.actions]); + + const pinnedActions = React.useMemo(() => { + if (!compact) return [] as ItemAction[]; + return inlineActions.filter((a) => pinnedIds.has(a.id)); + }, [compact, inlineActions, pinnedIds]); + + const nonPinnedInlineActions = React.useMemo(() => { + if (!compact) return inlineActions; + return inlineActions.filter((a) => !pinnedIds.has(a.id)); + }, [compact, inlineActions, pinnedIds]); + const overflowActions = React.useMemo(() => { + if (!compact) return []; + return props.actions.filter((a) => !compactIds.has(a.id)); + }, [compact, compactIds, props.actions]); + + const closeThen = React.useCallback((fn: () => void) => { + setShowOverflow(false); + let didRun = false; + const runOnce = () => { + if (didRun) return; + didRun = true; + fn(); + }; + + // InteractionManager can be delayed by long/continuous interactions (scroll, gestures). + // Use a fast timeout fallback so the action still runs promptly. + const fallback = setTimeout(runOnce, 0); + try { + InteractionManager.runAfterInteractions(() => { + clearTimeout(fallback); + runOnce(); + }); + } catch { + // If InteractionManager isn't available, rely on the fallback. + } + }, []); + + const overflowActionItems = React.useMemo((): ActionListItem[] => { + return overflowActions.map((action) => { + const color = action.color ?? (action.destructive ? theme.colors.deleteAction : theme.colors.button.secondary.tint); + return { + id: action.id, + label: action.title, + icon: <Ionicons name={action.icon} size={18} color={color} />, + onPress: () => closeThen(action.onPress), + }; + }); + }, [closeThen, overflowActions, theme.colors.button.secondary.tint, theme.colors.deleteAction]); + + const iconSize = props.iconSize ?? 20; + const gap = props.gap ?? 16; + + const renderInlineAction = React.useCallback((action: ItemAction) => { + return ( + <Pressable + key={action.id} + hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} + onPressIn={() => props.onActionPressIn?.()} + onPress={(e: GestureResponderEvent) => { + e?.stopPropagation?.(); + action.onPress(); + }} + accessibilityRole="button" + accessibilityLabel={action.title} + > + <Ionicons + name={action.icon} + size={iconSize} + color={action.color ?? (action.destructive ? theme.colors.deleteAction : theme.colors.button.secondary.tint)} + /> + </Pressable> + ); + }, [iconSize, props, theme.colors.button.secondary.tint, theme.colors.deleteAction]); + + const renderOverflow = React.useCallback(() => { + return ( + <View key="overflow" style={{ position: 'relative' }}> + <View ref={overflowAnchorRef}> + <Pressable + hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} + style={showOverflow ? { opacity: 0 } : undefined} + onPressIn={() => props.onActionPressIn?.()} + onPress={(e: GestureResponderEvent) => { + e?.stopPropagation?.(); + setShowOverflow((v) => !v); + }} + accessibilityRole="button" + accessibilityLabel="More actions" + accessibilityHint="Opens a menu with more actions" + > + <Ionicons + name="ellipsis-vertical" + size={iconSize + 2} + color={theme.colors.button.secondary.tint} + /> + </Pressable> + </View> + + {showOverflow ? ( + <Popover + open={showOverflow} + anchorRef={overflowAnchorRef} + placement="left" + gap={10} + maxHeightCap={280} + maxWidthCap={260} + edgePadding={{ vertical: 8, horizontal: 8 }} + portal={{ + web: true, + native: true, + // Menus are typically content-sized; allow the overlay to be wider than the trigger. + matchAnchorWidth: false, + anchorAlignVertical: 'center', + }} + boundaryRef={props.popoverBoundaryRef} + onRequestClose={() => setShowOverflow(false)} + backdrop={{ + effect: 'blur', + blurOnWeb: Platform.OS === 'web' ? { px: 3, tintColor: blurTintOnWeb } : undefined, + anchorOverlay: () => ( + <Ionicons + name="ellipsis-vertical" + size={iconSize + 2} + color={theme.colors.button.secondary.tint} + /> + ), + closeOnPan: true, + }} + > + {({ maxHeight, placement }) => ( + <FloatingOverlay + maxHeight={maxHeight} + arrow={{ placement }} + keyboardShouldPersistTaps="always" + edgeFades={{ top: true, bottom: true, size: 24 }} + edgeIndicators={true} + > + <ActionListSection + title={props.title} + actions={overflowActionItems} + /> + </FloatingOverlay> + )} + </Popover> + ) : null} + </View> + ); + }, [iconSize, overflowActionItems, props, showOverflow, theme.colors.button.secondary.tint]); + + return ( + <View style={[styles.container, { gap }]}> + {(compact ? nonPinnedInlineActions : inlineActions).map(renderInlineAction)} + + {compact && overflowActions.length > 0 && overflowPosition === 'beforePinned' ? ( + <> + {renderOverflow()} + {pinnedActions.map(renderInlineAction)} + </> + ) : null} + + {compact && overflowActions.length > 0 && overflowPosition === 'end' ? ( + <> + {pinnedActions.map(renderInlineAction)} + {renderOverflow()} + </> + ) : null} + + {compact && overflowActions.length === 0 && pinnedActions.length > 0 ? ( + <> + {pinnedActions.map(renderInlineAction)} + </> + ) : null} + </View> + ); +} + +const stylesheet = StyleSheet.create(() => ({ + container: { + flexDirection: 'row', + alignItems: 'center', + }, +})); diff --git a/expo-app/sources/components/ui/lists/SelectableRow.tsx b/expo-app/sources/components/ui/lists/SelectableRow.tsx new file mode 100644 index 000000000..5145b5b1a --- /dev/null +++ b/expo-app/sources/components/ui/lists/SelectableRow.tsx @@ -0,0 +1,201 @@ +import * as React from 'react'; +import { Platform, Pressable, Text, View, type StyleProp, type ViewStyle, type TextStyle } from 'react-native'; +import { StyleSheet, useUnistyles } from 'react-native-unistyles'; +import { Typography } from '@/constants/Typography'; + +export type SelectableRowVariant = 'slim' | 'default' | 'selectable'; + +export type SelectableRowProps = Readonly<{ + title: React.ReactNode; + subtitle?: React.ReactNode; + left?: React.ReactNode; + right?: React.ReactNode; + + selected?: boolean; + disabled?: boolean; + destructive?: boolean; + + variant?: SelectableRowVariant; + onPress?: () => void; + onHover?: () => void; + + containerStyle?: StyleProp<ViewStyle>; + titleStyle?: StyleProp<TextStyle>; + subtitleStyle?: StyleProp<TextStyle>; +}>; + +const stylesheet = StyleSheet.create((theme) => ({ + row: { + flexDirection: 'row', + alignItems: 'center', + borderRadius: 10, + backgroundColor: 'transparent', + }, + rowSlim: { + paddingHorizontal: 16, + paddingVertical: 8, + borderRadius: 0, + }, + rowDefault: { + paddingHorizontal: 16, + paddingVertical: 10, + }, + rowSelectable: { + // Match historical CommandPalette look + paddingHorizontal: 24, + paddingVertical: 12, + marginHorizontal: 8, + marginVertical: 2, + borderRadius: 8, + borderWidth: 2, + borderColor: 'transparent', + }, + rowPressed: { + backgroundColor: theme.colors.surfacePressed, + }, + rowHovered: { + backgroundColor: theme.colors.surfacePressed, + }, + rowSelected: { + backgroundColor: theme.colors.surfacePressedOverlay, + borderColor: theme.colors.divider, + }, + // Palette variant states (match old CommandPaletteItem styles exactly) + rowSelectablePressed: { + backgroundColor: '#F5F5F5', + }, + rowSelectableHovered: { + backgroundColor: '#F8F8F8', + }, + rowSelectableSelected: { + backgroundColor: '#F0F7FF', + borderColor: '#007AFF20', + }, + rowDisabled: { + opacity: 0.5, + }, + left: { + marginRight: 12, + alignItems: 'center', + justifyContent: 'center', + }, + content: { + flex: 1, + minWidth: 0, + }, + title: { + ...Typography.default(), + color: theme.colors.text, + fontSize: Platform.select({ ios: 16, default: 15 }), + lineHeight: Platform.select({ ios: 20, default: 20 }), + letterSpacing: Platform.select({ ios: -0.2, default: 0 }), + }, + titleSelectable: { + color: '#000', + fontSize: 15, + letterSpacing: -0.2, + }, + titleDestructive: { + color: theme.colors.textDestructive, + }, + subtitle: { + ...Typography.default(), + marginTop: 2, + color: theme.colors.textSecondary, + fontSize: Platform.select({ ios: 13, default: 13 }), + lineHeight: 18, + }, + subtitleSelectable: { + color: '#666', + letterSpacing: -0.1, + }, + right: { + marginLeft: 12, + alignItems: 'center', + justifyContent: 'center', + }, +})); + +export function SelectableRow(props: SelectableRowProps) { + const { theme } = useUnistyles(); + const styles = stylesheet; + const [isHovered, setIsHovered] = React.useState(false); + + const variant: SelectableRowVariant = props.variant ?? 'default'; + const selected = Boolean(props.selected); + const disabled = Boolean(props.disabled); + + const canHover = Platform.OS === 'web' && !disabled; + + const pressableProps: any = {}; + if (Platform.OS === 'web') { + pressableProps.onMouseEnter = () => { + if (!canHover) return; + setIsHovered(true); + props.onHover?.(); + }; + pressableProps.onMouseLeave = () => { + if (!canHover) return; + setIsHovered(false); + }; + } + + const rowVariantStyle = + variant === 'slim' + ? styles.rowSlim + : variant === 'selectable' + ? styles.rowSelectable + : styles.rowDefault; + + const titleColorStyle = props.destructive ? styles.titleDestructive : null; + const titleVariantStyle = variant === 'selectable' ? styles.titleSelectable : null; + const subtitleVariantStyle = variant === 'selectable' ? styles.subtitleSelectable : null; + + return ( + <Pressable + onPress={disabled ? undefined : props.onPress} + disabled={disabled} + accessibilityRole={props.onPress ? 'button' : undefined} + style={({ pressed }) => ([ + styles.row, + rowVariantStyle, + pressed && !disabled + ? (variant === 'selectable' ? styles.rowSelectablePressed : styles.rowPressed) + : null, + isHovered && !selected && !disabled + ? (variant === 'selectable' ? styles.rowSelectableHovered : styles.rowHovered) + : null, + selected + ? (variant === 'selectable' ? styles.rowSelectableSelected : styles.rowSelected) + : null, + disabled ? styles.rowDisabled : null, + props.containerStyle, + ])} + {...pressableProps} + > + {props.left ? ( + <View style={styles.left}> + {props.left} + </View> + ) : null} + + <View style={styles.content}> + <Text style={[styles.title, titleVariantStyle, titleColorStyle, props.titleStyle]} numberOfLines={1}> + {props.title} + </Text> + {props.subtitle ? ( + <Text style={[styles.subtitle, subtitleVariantStyle, props.subtitleStyle]} numberOfLines={2}> + {props.subtitle} + </Text> + ) : null} + </View> + + {props.right ? ( + <View style={styles.right}> + {props.right} + </View> + ) : null} + </Pressable> + ); +} + diff --git a/expo-app/sources/components/ui/lists/itemActions.ts b/expo-app/sources/components/ui/lists/itemActions.ts new file mode 100644 index 000000000..581989ffe --- /dev/null +++ b/expo-app/sources/components/ui/lists/itemActions.ts @@ -0,0 +1,12 @@ +import type React from 'react'; +import type { Ionicons } from '@expo/vector-icons'; + +export type ItemAction = { + id: string; + title: string; + icon: React.ComponentProps<typeof Ionicons>['name']; + onPress: () => void; + destructive?: boolean; + color?: string; +}; + diff --git a/expo-app/sources/components/ui/lists/itemGroupRowCorners.test.ts b/expo-app/sources/components/ui/lists/itemGroupRowCorners.test.ts new file mode 100644 index 000000000..a8c0415d9 --- /dev/null +++ b/expo-app/sources/components/ui/lists/itemGroupRowCorners.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; +import { getItemGroupRowCornerRadii } from './itemGroupRowCorners'; + +describe('getItemGroupRowCornerRadii', () => { + it('returns empty when there is no background', () => { + expect(getItemGroupRowCornerRadii({ hasBackground: false, position: { isFirst: true, isLast: true }, radius: 16 })).toEqual({}); + }); + + it('returns empty when position is missing', () => { + expect(getItemGroupRowCornerRadii({ hasBackground: true, position: null, radius: 16 })).toEqual({}); + }); + + it('applies top corners for first row', () => { + expect(getItemGroupRowCornerRadii({ hasBackground: true, position: { isFirst: true, isLast: false }, radius: 16 })) + .toEqual({ borderTopLeftRadius: 16, borderTopRightRadius: 16 }); + }); + + it('applies bottom corners for last row', () => { + expect(getItemGroupRowCornerRadii({ hasBackground: true, position: { isFirst: false, isLast: true }, radius: 16 })) + .toEqual({ borderBottomLeftRadius: 16, borderBottomRightRadius: 16 }); + }); + + it('applies all corners for a single-row group', () => { + expect(getItemGroupRowCornerRadii({ hasBackground: true, position: { isFirst: true, isLast: true }, radius: 16 })) + .toEqual({ + borderTopLeftRadius: 16, + borderTopRightRadius: 16, + borderBottomLeftRadius: 16, + borderBottomRightRadius: 16, + }); + }); +}); + diff --git a/expo-app/sources/components/ui/lists/itemGroupRowCorners.ts b/expo-app/sources/components/ui/lists/itemGroupRowCorners.ts new file mode 100644 index 000000000..a89c1ad7b --- /dev/null +++ b/expo-app/sources/components/ui/lists/itemGroupRowCorners.ts @@ -0,0 +1,20 @@ +import type { ItemGroupRowPosition } from './ItemGroupRowPosition'; + +export function getItemGroupRowCornerRadii(params: Readonly<{ + hasBackground: boolean; + position: ItemGroupRowPosition | null; + radius: number; +}>) { + if (!params.hasBackground) return {}; + if (!params.position) return {}; + + return { + ...(params.position.isFirst + ? { borderTopLeftRadius: params.radius, borderTopRightRadius: params.radius } + : null), + ...(params.position.isLast + ? { borderBottomLeftRadius: params.radius, borderBottomRightRadius: params.radius } + : null), + }; +} + diff --git a/expo-app/sources/components/ui/popover/OverlayPortal.test.ts b/expo-app/sources/components/ui/popover/OverlayPortal.test.ts new file mode 100644 index 000000000..5dcae3475 --- /dev/null +++ b/expo-app/sources/components/ui/popover/OverlayPortal.test.ts @@ -0,0 +1,58 @@ +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import renderer, { act } from 'react-test-renderer'; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +vi.mock('react-native', () => { + const React = require('react'); + return { + StyleSheet: { + absoluteFill: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 }, + }, + View: (props: any) => React.createElement('View', props, props.children), + }; +}); + +describe('OverlayPortalProvider', () => { + it('does not re-render its children when portal nodes change', async () => { + const { OverlayPortalHost, OverlayPortalProvider, useOverlayPortal } = await import('./OverlayPortal'); + + let renderCount = 0; + let dispatch: ReturnType<typeof useOverlayPortal> | null = null; + + function RenderCountChild() { + renderCount += 1; + return React.createElement('RenderCountChild'); + } + + function CaptureDispatch() { + dispatch = useOverlayPortal(); + return React.createElement('CaptureDispatch'); + } + + let tree: ReturnType<typeof renderer.create> | undefined; + act(() => { + tree = renderer.create( + React.createElement( + OverlayPortalProvider, + null, + React.createElement(RenderCountChild), + React.createElement(CaptureDispatch), + React.createElement(OverlayPortalHost), + ), + ); + }); + + expect(renderCount).toBe(1); + expect(dispatch).toBeTruthy(); + + act(() => { + dispatch?.setPortalNode('test-node', React.createElement('PortalContent')); + }); + + expect(tree?.root.findAllByType('PortalContent' as any).length).toBe(1); + expect(renderCount).toBe(1); + }); +}); + diff --git a/expo-app/sources/components/ui/popover/OverlayPortal.tsx b/expo-app/sources/components/ui/popover/OverlayPortal.tsx new file mode 100644 index 000000000..4f4339bea --- /dev/null +++ b/expo-app/sources/components/ui/popover/OverlayPortal.tsx @@ -0,0 +1,70 @@ +import * as React from 'react'; +import { View } from 'react-native'; +import { StyleSheet } from 'react-native-unistyles'; + +type OverlayPortalDispatch = Readonly<{ + setPortalNode: (id: string, node: React.ReactNode) => void; + removePortalNode: (id: string) => void; +}>; + +const OverlayPortalDispatchContext = React.createContext<OverlayPortalDispatch | null>(null); +const OverlayPortalNodesContext = React.createContext<ReadonlyMap<string, React.ReactNode> | null>(null); + +export function OverlayPortalProvider(props: { children: React.ReactNode }) { + const [nodes, setNodes] = React.useState<Map<string, React.ReactNode>>(() => new Map()); + + const setPortalNode = React.useCallback((id: string, node: React.ReactNode) => { + setNodes((prev) => { + const next = new Map(prev); + next.set(id, node); + return next; + }); + }, []); + + const removePortalNode = React.useCallback((id: string) => { + setNodes((prev) => { + if (!prev.has(id)) return prev; + const next = new Map(prev); + next.delete(id); + return next; + }); + }, []); + + const dispatch = React.useMemo<OverlayPortalDispatch>(() => { + return { setPortalNode, removePortalNode }; + }, [removePortalNode, setPortalNode]); + + return ( + <OverlayPortalDispatchContext.Provider value={dispatch}> + <OverlayPortalNodesContext.Provider value={nodes}> + {props.children} + </OverlayPortalNodesContext.Provider> + </OverlayPortalDispatchContext.Provider> + ); +} + +export function useOverlayPortal() { + return React.useContext(OverlayPortalDispatchContext); +} + +function useOverlayPortalNodes() { + return React.useContext(OverlayPortalNodesContext); +} + +export function OverlayPortalHost(props: { pointerEvents?: 'box-none' | 'none' | 'auto' | 'box-only' } = {}) { + const nodes = useOverlayPortalNodes(); + if (!nodes || nodes.size === 0) return null; + + return ( + <View + pointerEvents={props.pointerEvents ?? 'box-none'} + style={[StyleSheet.absoluteFill, { zIndex: 999999, elevation: 999999 }]} + > + {Array.from(nodes.entries()).map(([id, node]) => ( + <React.Fragment key={id}> + {node} + </React.Fragment> + ))} + </View> + ); +} diff --git a/expo-app/sources/components/ui/popover/Popover.nativePortal.test.ts b/expo-app/sources/components/ui/popover/Popover.nativePortal.test.ts new file mode 100644 index 000000000..3335fdca5 --- /dev/null +++ b/expo-app/sources/components/ui/popover/Popover.nativePortal.test.ts @@ -0,0 +1,484 @@ +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import renderer, { act } from 'react-test-renderer'; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +function flattenStyle(style: any): Record<string, any> { + if (!style) return {}; + if (Array.isArray(style)) { + return style.reduce((acc, item) => ({ ...acc, ...flattenStyle(item) }), {}); + } + return style; +} + +function nearestView(instance: any) { + let node = instance?.parent; + while (node && node.type !== 'View') node = node.parent; + return node; +} + +function flushMicrotasks(times: number) { + return new Promise<void>((resolve) => { + let remaining = times; + const step = () => { + remaining -= 1; + if (remaining <= 0) return resolve(); + queueMicrotask(step); + }; + queueMicrotask(step); + }); +} + +vi.mock('@/components/ui/popover', () => ({ + usePopoverBoundaryRef: () => null, +})); + +vi.mock('expo-blur', () => { + const React = require('react'); + return { + BlurView: (props: any) => React.createElement('BlurView', props, props.children), + }; +}); + +vi.mock('react-native', () => { + const React = require('react'); + return { + Platform: { OS: 'ios' }, + useWindowDimensions: () => ({ width: 390, height: 844 }), + View: (props: any) => React.createElement('View', props, props.children), + Pressable: (props: any) => React.createElement('Pressable', props, props.children), + }; +}); + +function PopoverChild() { + return React.createElement('PopoverChild'); +} + +describe('Popover (native portal)', () => { + it('positions using anchor coordinates relative to the portal root when available (avoids iOS header/sheet offsets)', async () => { + const { OverlayPortalHost, OverlayPortalProvider } = await import('./OverlayPortal'); + const { Popover } = await import('./Popover'); + const { PopoverPortalTargetContextProvider } = await import('./PopoverPortalTarget'); + + const portalRootNode = { _id: 'portal-root' }; + + const anchorRef = { + current: { + measureLayout: (relativeTo: any, onSuccess: any) => { + // Simulate coordinates relative to the portal root (e.g. inside a screen with a header). + if (relativeTo !== portalRootNode) throw new Error('expected measureLayout relativeTo portal root'); + queueMicrotask(() => onSuccess(10, 20, 30, 40)); + }, + // If Popover mistakenly uses window coords here, it will position incorrectly. + measureInWindow: (cb: any) => queueMicrotask(() => cb(999, 999, 30, 40)), + }, + } as any; + + let tree: ReturnType<typeof renderer.create> | undefined; + await act(async () => { + tree = renderer.create( + React.createElement( + OverlayPortalProvider, + null, + React.createElement( + PopoverPortalTargetContextProvider, + { + value: { rootRef: { current: portalRootNode } as any, layout: { width: 390, height: 844 } }, + children: React.createElement(Popover, { + open: true, + anchorRef, + placement: 'bottom', + portal: { native: true }, + backdrop: false, + children: () => React.createElement(PopoverChild), + } as any), + } as any, + ), + React.createElement(OverlayPortalHost), + ), + ); + }); + + await act(async () => { + await flushMicrotasks(3); + }); + + const child = tree?.root.findByType('PopoverChild' as any); + const container = nearestView(child); + const style = flattenStyle(container?.props?.style); + + // placement=bottom => top = y + height + gap (default gap=8) + expect(style.left).toBe(10); + expect(style.top).toBe(68); + expect(style.width).toBe(30); + }); + + it('does not mix window-relative boundary measurements with portal-root-relative anchor measurements (prevents off-screen menus)', async () => { + const { OverlayPortalHost, OverlayPortalProvider } = await import('./OverlayPortal'); + const { Popover } = await import('./Popover'); + const { PopoverPortalTargetContextProvider } = await import('./PopoverPortalTarget'); + + const portalRootNode = { _id: 'portal-root' }; + + const anchorRef = { + current: { + measureLayout: (relativeTo: any, onSuccess: any) => { + if (relativeTo !== portalRootNode) throw new Error('expected measureLayout relativeTo portal root'); + queueMicrotask(() => onSuccess(10, 100, 30, 40)); + }, + measureInWindow: (cb: any) => queueMicrotask(() => cb(999, 999, 30, 40)), + }, + } as any; + + const boundaryRef = { + current: { + // If Popover wrongly uses this window-relative boundary rect while the anchor rect is + // portal-root-relative, `topForBottom` clamps `top` to boundaryRect.y (off-screen). + measureInWindow: (cb: any) => queueMicrotask(() => cb(0, 600, 390, 844)), + }, + } as any; + + let tree: ReturnType<typeof renderer.create> | undefined; + await act(async () => { + tree = renderer.create( + React.createElement( + OverlayPortalProvider, + null, + React.createElement( + PopoverPortalTargetContextProvider, + { + value: { rootRef: { current: portalRootNode } as any, layout: { width: 0, height: 0 } }, + children: React.createElement(Popover, { + open: true, + anchorRef, + boundaryRef, + placement: 'bottom', + portal: { native: true }, + backdrop: false, + children: () => React.createElement(PopoverChild), + } as any), + } as any, + ), + React.createElement(OverlayPortalHost), + ), + ); + }); + + await act(async () => { + await flushMicrotasks(3); + }); + + const child = tree?.root.findByType('PopoverChild' as any); + const container = nearestView(child); + const style = flattenStyle(container?.props?.style); + + // placement=bottom => top = y + height + gap (default gap=8) + expect(style.top).toBe(148); + expect(style.left).toBe(10); + }); + + it('retries measurement when the initial anchor rect is zero-sized (prevents iOS dropdowns from overlapping the trigger)', async () => { + const { OverlayPortalHost, OverlayPortalProvider } = await import('./OverlayPortal'); + const { Popover } = await import('./Popover'); + + const originalRaf = (globalThis as any).requestAnimationFrame; + (globalThis as any).requestAnimationFrame = (cb: () => void) => { + cb(); + return 0 as any; + }; + + let measureCalls = 0; + const anchorRef = { + current: { + measureInWindow: (cb: any) => { + measureCalls += 1; + if (measureCalls === 1) { + cb(200, 200, 0, 0); + return; + } + cb(200, 200, 20, 20); + }, + }, + } as any; + + let tree: ReturnType<typeof renderer.create> | undefined; + await act(async () => { + tree = renderer.create( + React.createElement( + OverlayPortalProvider, + null, + React.createElement(Popover, { + open: true, + anchorRef, + placement: 'bottom', + portal: { native: true }, + backdrop: false, + children: () => React.createElement(PopoverChild), + }), + React.createElement(OverlayPortalHost), + ), + ); + }); + + await act(async () => { + await flushMicrotasks(3); + }); + + expect(measureCalls).toBeGreaterThanOrEqual(2); + + const child = tree?.root.findByType('PopoverChild' as any); + const contentView = nearestView(child); + expect(flattenStyle(contentView?.props?.style).opacity).toBe(1); + + (globalThis as any).requestAnimationFrame = originalRaf; + }); + + it('renders inline when no OverlayPortalProvider is present', async () => { + const { Popover } = await import('./Popover'); + + const anchorRef = { + current: { + measureInWindow: (cb: any) => cb(100, 100, 20, 20), + }, + } as any; + + let tree: ReturnType<typeof renderer.create> | undefined; + await act(async () => { + tree = renderer.create( + React.createElement( + 'View', + { testID: 'inline-slot' }, + React.createElement(Popover, { + open: true, + anchorRef, + portal: { native: true }, + backdrop: false, + children: () => React.createElement(PopoverChild), + }), + ), + ); + }); + + expect(tree?.root.findByProps({ testID: 'inline-slot' }).findAllByType('PopoverChild' as any).length).toBe(1); + }); + + it('renders into OverlayPortalHost when usePortalOnNative is enabled', async () => { + const { OverlayPortalHost, OverlayPortalProvider } = await import('./OverlayPortal'); + const { Popover } = await import('./Popover'); + + const anchorRef = { + current: { + measureInWindow: (cb: any) => cb(200, 200, 20, 20), + }, + } as any; + + let tree: ReturnType<typeof renderer.create> | undefined; + await act(async () => { + tree = renderer.create( + React.createElement( + OverlayPortalProvider, + null, + React.createElement( + 'View', + { testID: 'inline-slot' }, + React.createElement(Popover, { + open: true, + anchorRef, + portal: { native: true }, + backdrop: false, + children: () => React.createElement(PopoverChild), + }), + ), + React.createElement( + 'View', + { testID: 'host-slot' }, + React.createElement(OverlayPortalHost), + ), + ), + ); + }); + + expect(tree?.root.findByProps({ testID: 'inline-slot' }).findAllByType('PopoverChild' as any).length).toBe(0); + expect(tree?.root.findByProps({ testID: 'host-slot' }).findAllByType('PopoverChild' as any).length).toBe(1); + + await act(async () => { + tree?.update( + React.createElement( + OverlayPortalProvider, + null, + React.createElement( + 'View', + { testID: 'inline-slot' }, + React.createElement(Popover, { + open: false, + anchorRef, + portal: { native: true }, + backdrop: false, + children: () => React.createElement(PopoverChild), + }), + ), + React.createElement( + 'View', + { testID: 'host-slot' }, + React.createElement(OverlayPortalHost), + ), + ), + ); + }); + + expect(tree?.root.findByProps({ testID: 'host-slot' }).findAllByType('PopoverChild' as any).length).toBe(0); + }); + + it('keeps portal content hidden until it can be positioned (prevents visible jiggle)', async () => { + const { OverlayPortalHost, OverlayPortalProvider } = await import('./OverlayPortal'); + const { Popover } = await import('./Popover'); + + const anchorRef = { + current: { + measureInWindow: (cb: any) => { + queueMicrotask(() => cb(200, 200, 20, 20)); + }, + }, + } as any; + + let tree: ReturnType<typeof renderer.create> | undefined; + await act(async () => { + tree = renderer.create( + React.createElement( + OverlayPortalProvider, + null, + React.createElement(Popover, { + open: true, + anchorRef, + placement: 'left', + portal: { native: true, anchorAlignVertical: 'center' }, + backdrop: false, + children: () => React.createElement(PopoverChild), + }), + React.createElement(OverlayPortalHost), + ), + ); + }); + + const child = tree?.root.findByType('PopoverChild' as any); + const contentView = nearestView(child); + expect(flattenStyle(contentView?.props?.style).opacity).toBe(0); + + await act(async () => { + await flushMicrotasks(3); + }); + + const childAfterMeasure = tree?.root.findByType('PopoverChild' as any); + const contentViewAfterMeasure = nearestView(childAfterMeasure); + expect(flattenStyle(contentViewAfterMeasure?.props?.style).opacity).toBe(0); + + await act(async () => { + contentViewAfterMeasure?.props?.onLayout?.({ nativeEvent: { layout: { width: 180, height: 0 } } }); + }); + + const childAfterFirstLayout = tree?.root.findByType('PopoverChild' as any); + const contentViewAfterFirstLayout = nearestView(childAfterFirstLayout); + expect(flattenStyle(contentViewAfterFirstLayout?.props?.style).opacity).toBe(0); + + await act(async () => { + contentViewAfterFirstLayout?.props?.onLayout?.({ nativeEvent: { layout: { width: 180, height: 120 } } }); + }); + + const childAfterLayout = tree?.root.findByType('PopoverChild' as any); + const contentViewAfterLayout = nearestView(childAfterLayout); + expect(flattenStyle(contentViewAfterLayout?.props?.style).opacity).toBe(1); + }); + + it('can spotlight the anchor so it stays crisp above the blur', async () => { + const { OverlayPortalHost, OverlayPortalProvider } = await import('./OverlayPortal'); + const { Popover } = await import('./Popover'); + + const anchorRef = { + current: { + measureInWindow: (cb: any) => { + queueMicrotask(() => cb(100, 100, 20, 20)); + }, + }, + } as any; + + let tree: ReturnType<typeof renderer.create> | undefined; + await act(async () => { + tree = renderer.create( + React.createElement( + OverlayPortalProvider, + null, + React.createElement(Popover, { + open: true, + anchorRef, + placement: 'bottom', + portal: { native: true }, + onRequestClose: () => {}, + backdrop: { effect: 'blur', spotlight: true }, + children: () => React.createElement(PopoverChild), + } as any), + React.createElement(OverlayPortalHost), + ), + ); + }); + + await act(async () => { + await flushMicrotasks(3); + }); + + const effects = tree?.root.findAllByProps({ testID: 'popover-backdrop-effect' } as any) ?? []; + // Our native test shims represent `BlurView` as a wrapper component returning a host element, + // so `findAllByProps` will match both. Filter to host nodes for stable assertions. + const hostEffects = effects.filter((node: any) => typeof node.type === 'string'); + expect(hostEffects.length).toBe(4); + }); + + it('can render an anchor overlay above the blur backdrop (keeps the trigger crisp without cutout seams)', async () => { + const { OverlayPortalHost, OverlayPortalProvider } = await import('./OverlayPortal'); + const { Popover } = await import('./Popover'); + + const anchorRef = { + current: { + measureInWindow: (cb: any) => { + queueMicrotask(() => cb(140, 120, 28, 28)); + }, + }, + } as any; + + let tree: ReturnType<typeof renderer.create> | undefined; + await act(async () => { + tree = renderer.create( + React.createElement( + OverlayPortalProvider, + null, + React.createElement(Popover, { + open: true, + anchorRef, + placement: 'bottom', + portal: { native: true }, + onRequestClose: () => {}, + backdrop: { effect: 'blur', anchorOverlay: () => React.createElement('AnchorOverlay') }, + children: () => React.createElement(PopoverChild), + } as any), + React.createElement(OverlayPortalHost), + ), + ); + }); + + await act(async () => { + await flushMicrotasks(3); + }); + + const overlays = tree?.root.findAllByProps({ testID: 'popover-anchor-overlay' } as any) ?? []; + const hostOverlays = overlays.filter((node: any) => typeof node.type === 'string'); + expect(hostOverlays.length).toBe(1); + + const overlayStyle = flattenStyle(hostOverlays[0]?.props?.style); + expect(overlayStyle.position).toBe('absolute'); + expect(overlayStyle.left).toBe(140); + expect(overlayStyle.top).toBe(120); + expect(overlayStyle.width).toBe(28); + expect(overlayStyle.height).toBe(28); + }); + +}); diff --git a/expo-app/sources/components/ui/popover/Popover.test.ts b/expo-app/sources/components/ui/popover/Popover.test.ts new file mode 100644 index 000000000..f232bf145 --- /dev/null +++ b/expo-app/sources/components/ui/popover/Popover.test.ts @@ -0,0 +1,840 @@ +import React from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import renderer, { act } from 'react-test-renderer'; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +function flushMicrotasks(times: number) { + return new Promise<void>((resolve) => { + let remaining = times; + const step = () => { + remaining -= 1; + if (remaining <= 0) return resolve(); + queueMicrotask(step); + }; + queueMicrotask(step); + }); +} + +function flattenStyle(style: any): Record<string, any> { + if (!style) return {}; + if (Array.isArray(style)) { + return style.reduce((acc, item) => ({ ...acc, ...flattenStyle(item) }), {}); + } + return style; +} + +function nearestView(instance: any) { + let node = instance?.parent; + while (node && node.type !== 'View') node = node.parent; + return node; +} + +vi.mock('@/utils/web/radixCjs', () => { + const React = require('react'); + return { + requireRadixDismissableLayer: () => ({ + Branch: (props: any) => React.createElement('DismissableLayerBranch', props, props.children), + }), + }; +}); + +vi.mock('@/utils/web/reactDomCjs', () => ({ + requireReactDOM: () => ({ + createPortal: (node: any, target: any) => { + const React = require('react'); + return React.createElement('Portal', { target }, node); + }, + }), +})); + +vi.mock('react-native', () => { + const React = require('react'); + return { + Platform: { OS: 'web' }, + useWindowDimensions: () => ({ width: 1000, height: 800 }), + StyleSheet: { + absoluteFill: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 }, + }, + View: (props: any) => React.createElement('View', props, props.children), + Pressable: (props: any) => React.createElement('Pressable', props, props.children), + }; +}); + +describe('Popover (web)', () => { + beforeEach(() => { + // Minimal window stubs for node test environment. + vi.stubGlobal('window', { + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + setTimeout: globalThis.setTimeout.bind(globalThis), + clearTimeout: globalThis.clearTimeout.bind(globalThis), + }); + vi.stubGlobal('requestAnimationFrame', (cb: () => void) => { + cb(); + return 0; + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('keeps the content above the backdrop when not using a portal', async () => { + const { Popover } = await import('./Popover'); + + const anchorRef = { current: null } as any; + + let tree: ReturnType<typeof renderer.create> | undefined; + act(() => { + tree = renderer.create( + React.createElement( + Popover, + { + open: true, + anchorRef, + backdrop: { enabled: true, blockOutsidePointerEvents: true }, + onRequestClose: () => {}, + children: () => React.createElement('PopoverChild'), + }, + ), + ); + }); + + const pressables = tree?.root.findAllByType('Pressable' as any) ?? []; + const backdrop = pressables.find((p: any) => flattenStyle(p.props.style).top === 0); + expect(backdrop).toBeTruthy(); + expect(flattenStyle(backdrop?.props.style).position).toBe('fixed'); + + const child = tree?.root.findByType('PopoverChild' as any); + const content = nearestView(child); + expect(content).toBeTruthy(); + + const backdropZ = flattenStyle(backdrop?.props.style).zIndex; + const contentZ = flattenStyle(content?.props.style).zIndex; + expect(typeof backdropZ).toBe('number'); + expect(typeof contentZ).toBe('number'); + expect(contentZ).toBeGreaterThan(backdropZ); + }); + + it('wraps portal-to-body popovers in a Radix DismissableLayer Branch so underlying Vaul/Radix layers don’t treat it as “outside”', async () => { + const { Popover } = await import('./Popover'); + + const anchorRef = { current: null } as any; + + let tree: ReturnType<typeof renderer.create> | undefined; + act(() => { + tree = renderer.create( + React.createElement( + Popover, + { + open: true, + anchorRef, + portal: { web: true }, + onRequestClose: () => {}, + children: () => React.createElement('PopoverChild'), + }, + ), + ); + }); + + expect(tree?.root.findAllByType('DismissableLayerBranch' as any).length).toBe(1); + }); + + it('portals to a modal portal host when available (prevents Radix Dialog scroll-lock from swallowing wheel/touch scroll)', async () => { + const { Popover } = await import('./Popover'); + const { ModalPortalTargetProvider } = await import('@/modal/portal/ModalPortalTarget'); + + const anchorRef = { current: null } as any; + const modalTarget = {} as any; + + let tree: ReturnType<typeof renderer.create> | undefined; + act(() => { + tree = renderer.create( + React.createElement( + ModalPortalTargetProvider, + { + target: modalTarget, + children: React.createElement(Popover, { + open: true, + anchorRef, + portal: { web: true }, + onRequestClose: () => {}, + children: () => React.createElement('PopoverChild'), + }), + }, + ), + ); + }); + + const portal = tree?.root.findAllByType('Portal' as any)?.[0]; + expect(portal).toBeTruthy(); + expect((portal as any)?.props?.target).toBe(modalTarget); + }); + + it('does not subscribe to scroll events when portaling into a modal/boundary target (avoids scroll jank on mobile web)', async () => { + const { Popover } = await import('./Popover'); + const { ModalPortalTargetProvider } = await import('@/modal/portal/ModalPortalTarget'); + + const anchorRef = { current: null } as any; + const modalTarget = {} as any; + + act(() => { + renderer.create( + React.createElement( + ModalPortalTargetProvider, + { + target: modalTarget, + children: React.createElement(Popover, { + open: true, + anchorRef, + portal: { web: true }, + onRequestClose: () => {}, + children: () => React.createElement('PopoverChild'), + }), + }, + ), + ); + }); + + const add = (globalThis as any).window?.addEventListener as any; + const calls = add?.mock?.calls ?? []; + const events = calls.map((c: any[]) => c?.[0]).filter(Boolean); + expect(events).toContain('resize'); + expect(events).not.toContain('scroll'); + }); + + it('portals to the PopoverBoundary when in an Expo Router modal (prevents Vaul/Radix scroll-lock from swallowing wheel/touch scroll)', async () => { + const boundaryTarget = { + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + appendChild: vi.fn(), + } as any; + const boundaryRef = { current: boundaryTarget } as any; + const { Popover } = await import('./Popover'); + const { PopoverBoundaryProvider } = await import('@/components/ui/popover'); + + const anchorRef = { current: null } as any; + let tree: ReturnType<typeof renderer.create> | undefined; + act(() => { + tree = renderer.create( + React.createElement( + PopoverBoundaryProvider, + { + boundaryRef, + children: React.createElement(Popover, { + open: true, + anchorRef, + portal: { web: true }, + onRequestClose: () => {}, + children: () => React.createElement('PopoverChild'), + }), + }, + ), + ); + }); + + const portal = tree?.root.findAllByType('Portal' as any)?.[0]; + expect(portal).toBeTruthy(); + expect((portal as any)?.props?.target).toBe(boundaryTarget); + }); + + it('accounts for portal-target scroll offset when positioning inside a scrollable boundary (prevents dropdowns from drifting upward)', async () => { + const { Popover } = await import('./Popover'); + const { PopoverBoundaryProvider } = await import('@/components/ui/popover'); + + const boundaryTarget = { + scrollTop: 400, + scrollLeft: 0, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + appendChild: vi.fn(), + getBoundingClientRect: () => ({ + left: 0, + top: 50, + width: 1000, + height: 800, + x: 0, + y: 50, + }), + } as any; + + const boundaryRef = { current: boundaryTarget } as any; + const anchorRef = { + current: { + getBoundingClientRect: () => ({ + left: 0, + top: 600, + width: 300, + height: 40, + x: 0, + y: 600, + }), + }, + } as any; + + let tree: ReturnType<typeof renderer.create> | undefined; + await act(async () => { + tree = renderer.create( + React.createElement( + PopoverBoundaryProvider, + { + boundaryRef, + children: React.createElement(Popover, { + open: true, + anchorRef, + boundaryRef, + portal: { web: { target: 'boundary' } }, + placement: 'bottom', + gap: 0, + maxHeightCap: 320, + onRequestClose: () => {}, + children: () => React.createElement('PopoverChild'), + }), + }, + ), + ); + await flushMicrotasks(6); + }); + + const child = tree?.root.findByType('PopoverChild' as any); + const content = nearestView(child); + expect(content).toBeTruthy(); + + const style = flattenStyle(content?.props?.style); + // Desired viewport top is anchorBottom (= 600 + 40) = 640. + // Portal target top is 50; when positioned absolute inside a scrollable element, the style.top + // must include scrollTop to avoid being offset by scrolling (640 - 50 + 400 = 990). + expect(style.top).toBe(990); + }); + + it('stops wheel propagation in portal mode (prevents document-level scroll-lock listeners from breaking popover scrolling)', async () => { + const { Popover } = await import('./Popover'); + + const anchorRef = { current: null } as any; + + let tree: ReturnType<typeof renderer.create> | undefined; + act(() => { + tree = renderer.create( + React.createElement(Popover, { + open: true, + anchorRef, + portal: { web: true }, + onRequestClose: () => {}, + children: () => React.createElement('PopoverChild'), + }), + ); + }); + + const child = tree?.root.findByType('PopoverChild' as any); + const content = nearestView(child); + expect(content).toBeTruthy(); + + const stopPropagation = vi.fn(); + act(() => { + content?.props?.onWheel?.({ stopPropagation }); + }); + expect(stopPropagation).toHaveBeenCalledTimes(1); + }); + + it('treats boundaryRef={null} as an explicit override (uses viewport fallback even when a PopoverBoundaryProvider is present)', async () => { + const { Popover } = await import('./Popover'); + const { PopoverBoundaryProvider } = await import('@/components/ui/popover'); + + const anchorRef = { + current: { + getBoundingClientRect: () => ({ + left: 0, + top: 650, + width: 100, + height: 40, + x: 0, + y: 650, + }), + }, + } as any; + + const boundaryRef = { + current: { + getBoundingClientRect: () => ({ + left: 0, + top: 500, + width: 1000, + height: 200, + x: 0, + y: 500, + }), + }, + } as any; + + const renders: Array<{ maxHeight: number }> = []; + + let tree: ReturnType<typeof renderer.create> | undefined; + await act(async () => { + tree = renderer.create( + React.createElement( + PopoverBoundaryProvider, + { + boundaryRef, + children: React.createElement(Popover, { + open: true, + anchorRef, + boundaryRef: null, + portal: { web: true }, + placement: 'top', + maxHeightCap: 400, + onRequestClose: () => {}, + children: (renderProps: any) => { + renders.push({ maxHeight: renderProps.maxHeight }); + return React.createElement('PopoverChild'); + }, + }), + }, + ), + ); + await flushMicrotasks(6); + }); + + expect(tree).toBeTruthy(); + // With boundaryRef=null, it should ignore the boundary provider and use viewport fallback. + // Available top is 650 - 0 - 8 = 642, capped by maxHeightCap=400. + expect(renders.at(-1)?.maxHeight).toBe(400); + }); + + it('positions top-placed portal popovers using the measured content height (avoids “mid-screen” placement)', async () => { + const { Popover } = await import('./Popover'); + + const anchorRef = { + current: { + getBoundingClientRect: () => ({ + left: 0, + top: 600, + width: 300, + height: 40, + x: 0, + y: 600, + }), + }, + } as any; + + let tree: ReturnType<typeof renderer.create> | undefined; + await act(async () => { + tree = renderer.create( + React.createElement(Popover, { + open: true, + anchorRef, + portal: { web: true }, + placement: 'top', + gap: 8, + maxHeightCap: 400, + onRequestClose: () => {}, + children: () => React.createElement('PopoverChild'), + }), + ); + await flushMicrotasks(6); + }); + + const child = tree?.root.findByType('PopoverChild' as any); + expect(child).toBeTruthy(); + + const contentView = tree?.root.findAllByType('View' as any).find((v: any) => typeof v.props.onLayout === 'function'); + expect(contentView).toBeTruthy(); + + // Simulate measuring the popover content. + await act(async () => { + contentView?.props?.onLayout?.({ nativeEvent: { layout: { width: 520, height: 200 } } }); + await flushMicrotasks(2); + }); + + const updatedChild = tree?.root.findByType('PopoverChild' as any); + const updatedContent = updatedChild ? nearestView(updatedChild) : undefined; + expect(updatedContent).toBeTruthy(); + + const style = flattenStyle(updatedContent?.props?.style); + // top should be anchorTop - contentHeight - gap = 600 - 200 - 8 = 392 + expect(style.top).toBe(392); + }); + + it('does not attach wheel propagation stoppers when not using a portal', async () => { + const { Popover } = await import('./Popover'); + + const anchorRef = { current: null } as any; + + let tree: ReturnType<typeof renderer.create> | undefined; + act(() => { + tree = renderer.create( + React.createElement(Popover, { + open: true, + anchorRef, + backdrop: false, + children: () => React.createElement('PopoverChild'), + }), + ); + }); + + const child = tree?.root.findByType('PopoverChild' as any); + const content = nearestView(child); + expect(content).toBeTruthy(); + expect(content?.props?.onWheel).toBeUndefined(); + expect(content?.props?.onTouchMove).toBeUndefined(); + }); + + it('keeps portal popovers hidden until the anchor is measured (prevents visible jiggle)', async () => { + const { Popover } = await import('./Popover'); + + const anchorRef = { + current: { + measureInWindow: (cb: any) => { + queueMicrotask(() => cb(100, 100, 20, 20)); + }, + }, + } as any; + + let tree: ReturnType<typeof renderer.create> | undefined; + act(() => { + tree = renderer.create( + React.createElement(Popover, { + open: true, + anchorRef, + placement: 'bottom', + portal: { web: true }, + backdrop: false, + children: () => React.createElement('PopoverChild'), + }), + ); + }); + + const child = tree?.root.findByType('PopoverChild' as any); + const contentView = nearestView(child); + expect(flattenStyle(contentView?.props?.style).opacity).toBe(0); + + await act(async () => { + await flushMicrotasks(3); + }); + + const childAfter = tree?.root.findByType('PopoverChild' as any); + const contentViewAfter = nearestView(childAfter); + expect(flattenStyle(contentViewAfter?.props?.style).opacity).toBe(1); + }); + + it('measures DOM anchors on web when measureInWindow is unavailable (prevents invisible portal popovers)', async () => { + const { Popover } = await import('./Popover'); + + const anchorRef = { + current: { + getBoundingClientRect: () => ({ left: 120, top: 140, width: 48, height: 22 }), + }, + } as any; + + let tree: ReturnType<typeof renderer.create> | undefined; + act(() => { + tree = renderer.create( + React.createElement(Popover, { + open: true, + anchorRef, + placement: 'bottom', + portal: { web: true }, + backdrop: false, + children: () => React.createElement('PopoverChild'), + }), + ); + }); + + const child = tree?.root.findByType('PopoverChild' as any); + const contentView = nearestView(child); + expect(flattenStyle(contentView?.props?.style).opacity).toBe(0); + + await act(async () => { + await flushMicrotasks(3); + }); + + const childAfter = tree?.root.findByType('PopoverChild' as any); + const contentViewAfter = nearestView(childAfter); + expect(flattenStyle(contentViewAfter?.props?.style).opacity).toBe(1); + }); + + it('falls back to DOM anchors on web when measureInWindow returns invalid values (prevents stuck invisible portal popovers)', async () => { + const { Popover } = await import('./Popover'); + + const anchorRef = { + current: { + measureInWindow: (cb: any) => { + queueMicrotask(() => cb(NaN, NaN, NaN, NaN)); + }, + getBoundingClientRect: () => ({ left: 120, top: 140, width: 48, height: 22 }), + }, + } as any; + + let tree: ReturnType<typeof renderer.create> | undefined; + act(() => { + tree = renderer.create( + React.createElement(Popover, { + open: true, + anchorRef, + placement: 'bottom', + portal: { web: true }, + backdrop: false, + children: () => React.createElement('PopoverChild'), + }), + ); + }); + + const child = tree?.root.findByType('PopoverChild' as any); + const contentView = nearestView(child); + expect(flattenStyle(contentView?.props?.style).opacity).toBe(0); + + await act(async () => { + await flushMicrotasks(3); + }); + + const childAfter = tree?.root.findByType('PopoverChild' as any); + const contentViewAfter = nearestView(childAfter); + expect(flattenStyle(contentViewAfter?.props?.style).opacity).toBe(1); + }); + + it('retries measuring portal anchors on web when measureInWindow returns invalid values (prevents needing a resize)', async () => { + const { Popover } = await import('./Popover'); + + let calls = 0; + const anchorRef = { + current: { + measureInWindow: (cb: any) => { + calls += 1; + queueMicrotask(() => { + if (calls === 1) return cb(NaN, NaN, NaN, NaN); + cb(100, 100, 20, 20); + }); + }, + }, + } as any; + + let tree: ReturnType<typeof renderer.create> | undefined; + act(() => { + tree = renderer.create( + React.createElement(Popover, { + open: true, + anchorRef, + placement: 'bottom', + portal: { web: true }, + backdrop: false, + children: () => React.createElement('PopoverChild'), + }), + ); + }); + + const child = tree?.root.findByType('PopoverChild' as any); + const contentView = nearestView(child); + expect(flattenStyle(contentView?.props?.style).opacity).toBe(0); + + await act(async () => { + await flushMicrotasks(6); + }); + + const childAfter = tree?.root.findByType('PopoverChild' as any); + const contentViewAfter = nearestView(childAfter); + expect(flattenStyle(contentViewAfter?.props?.style).opacity).toBe(1); + }); + + it('keeps left/right portal popovers hidden until content layout is known (prevents recenter jiggle)', async () => { + const { Popover } = await import('./Popover'); + + const anchorRef = { + current: { + measureInWindow: (cb: any) => { + queueMicrotask(() => cb(200, 200, 20, 20)); + }, + }, + } as any; + + let tree: ReturnType<typeof renderer.create> | undefined; + act(() => { + tree = renderer.create( + React.createElement(Popover, { + open: true, + anchorRef, + placement: 'left', + portal: { + web: true, + matchAnchorWidth: false, + anchorAlignVertical: 'center', + }, + backdrop: false, + children: () => React.createElement('PopoverChild'), + }), + ); + }); + + await act(async () => { + await flushMicrotasks(3); + }); + + const child = tree?.root.findByType('PopoverChild' as any); + const contentView = nearestView(child); + expect(flattenStyle(contentView?.props?.style).opacity).toBe(0); + + await act(async () => { + contentView?.props?.onLayout?.({ nativeEvent: { layout: { width: 180, height: 0 } } }); + }); + + const childAfterFirstLayout = tree?.root.findByType('PopoverChild' as any); + const contentViewAfterFirstLayout = nearestView(childAfterFirstLayout); + expect(flattenStyle(contentViewAfterFirstLayout?.props?.style).opacity).toBe(0); + + await act(async () => { + contentViewAfterFirstLayout?.props?.onLayout?.({ nativeEvent: { layout: { width: 180, height: 120 } } }); + }); + + const childAfter = tree?.root.findByType('PopoverChild' as any); + const contentViewAfter = nearestView(childAfter); + expect(flattenStyle(contentViewAfter?.props?.style).opacity).toBe(1); + }); + + it('supports a blur backdrop behind the popover content (context-menu focus)', async () => { + const { Popover } = await import('./Popover'); + + const anchorRef = { current: null } as any; + + let tree: ReturnType<typeof renderer.create> | undefined; + act(() => { + tree = renderer.create( + React.createElement(Popover, { + open: true, + anchorRef, + onRequestClose: () => {}, + backdrop: { effect: 'blur' }, + children: () => React.createElement('PopoverChild'), + } as any), + ); + }); + + const views = tree?.root.findAllByType('View' as any) ?? []; + expect(views.some((v: any) => v.props?.testID === 'popover-backdrop-effect')).toBe(true); + }); + + it('allows configuring web blur strength and tint for blur backdrops', async () => { + const { Popover } = await import('./Popover'); + + const anchorRef = { + current: { + measureInWindow: (cb: any) => { + queueMicrotask(() => cb(100, 100, 20, 20)); + }, + }, + } as any; + + let tree: ReturnType<typeof renderer.create> | undefined; + act(() => { + tree = renderer.create( + React.createElement(Popover, { + open: true, + anchorRef, + placement: 'bottom', + portal: { web: true }, + backdrop: { + effect: 'blur', + blurOnWeb: { px: 3, tintColor: 'rgba(255, 255, 255, 0.18)' }, + }, + onRequestClose: () => {}, + children: () => React.createElement('PopoverChild'), + } as any), + ); + }); + + await act(async () => { + await flushMicrotasks(3); + }); + + const effects = tree?.root.findAllByProps({ testID: 'popover-backdrop-effect' } as any) ?? []; + const hostEffects = effects.filter((node: any) => typeof node.type === 'string'); + expect(hostEffects.length).toBe(1); + + const style = flattenStyle(hostEffects[0]?.props?.style); + expect(style.backdropFilter).toBe('blur(3px)'); + expect(style.backgroundColor).toBe('rgba(255, 255, 255, 0.18)'); + }); + + it('can spotlight the anchor so it stays crisp above the blur', async () => { + const { Popover } = await import('./Popover'); + + const anchorRef = { + current: { + measureInWindow: (cb: any) => { + queueMicrotask(() => cb(100, 100, 20, 20)); + }, + }, + } as any; + + let tree: ReturnType<typeof renderer.create> | undefined; + act(() => { + tree = renderer.create( + React.createElement(Popover, { + open: true, + anchorRef, + placement: 'bottom', + portal: { web: true }, + backdrop: { + effect: 'blur', + spotlight: true, + }, + onRequestClose: () => {}, + children: () => React.createElement('PopoverChild'), + } as any), + ); + }); + + await act(async () => { + await flushMicrotasks(3); + }); + + const effects = tree?.root.findAllByProps({ testID: 'popover-backdrop-effect' } as any) ?? []; + // Our RN-web test shim represents `View` as a wrapper component returning a host element, + // so `findAllByProps` will match both. Filter to host nodes for stable assertions. + const hostEffects = effects.filter((node: any) => typeof node.type === 'string'); + expect(hostEffects.length).toBe(4); + }); + + it('can render an anchor overlay above the blur backdrop (keeps the trigger crisp without cutout seams)', async () => { + const { Popover } = await import('./Popover'); + + const anchorRef = { + current: { + measureInWindow: (cb: any) => { + queueMicrotask(() => cb(120, 80, 24, 24)); + }, + }, + } as any; + + let tree: ReturnType<typeof renderer.create> | undefined; + act(() => { + tree = renderer.create( + React.createElement(Popover, { + open: true, + anchorRef, + placement: 'bottom', + portal: { web: { target: 'body' } }, + backdrop: { + effect: 'blur', + anchorOverlay: () => React.createElement('AnchorOverlay'), + }, + onRequestClose: () => {}, + children: () => React.createElement('PopoverChild'), + } as any), + ); + }); + + await act(async () => { + await flushMicrotasks(3); + }); + + const overlays = tree?.root.findAllByProps({ testID: 'popover-anchor-overlay' } as any) ?? []; + const hostOverlays = overlays.filter((node: any) => typeof node.type === 'string'); + expect(hostOverlays.length).toBe(1); + + const overlayStyle = flattenStyle(hostOverlays[0]?.props?.style); + expect(overlayStyle.position).toBe('fixed'); + expect(overlayStyle.left).toBe(120); + expect(overlayStyle.top).toBe(80); + expect(overlayStyle.width).toBe(24); + expect(overlayStyle.height).toBe(24); + }); +}); diff --git a/expo-app/sources/components/ui/popover/Popover.tsx b/expo-app/sources/components/ui/popover/Popover.tsx new file mode 100644 index 000000000..6a386780b --- /dev/null +++ b/expo-app/sources/components/ui/popover/Popover.tsx @@ -0,0 +1,718 @@ +import * as React from 'react'; +import { Platform, View, type StyleProp, type ViewProps, type ViewStyle, useWindowDimensions } from 'react-native'; +import { usePopoverBoundaryRef } from './PopoverBoundary'; +import { requireRadixDismissableLayer } from '@/utils/web/radixCjs'; +import { useOverlayPortal } from './OverlayPortal'; +import { useModalPortalTarget } from '@/modal/portal/ModalPortalTarget'; +import { usePopoverPortalTarget } from './PopoverPortalTarget'; +import type { + PopoverBackdropEffect, + PopoverBackdropOptions, + PopoverPlacement, + PopoverPortalOptions, + PopoverRenderProps, + PopoverWindowRect, + ResolvedPopoverPlacement, +} from './_types'; +import { getFallbackBoundaryRect, measureInWindow, measureLayoutRelativeTo } from './measure'; +import { resolvePlacement } from './positioning'; +import { PopoverBackdrop } from './backdrop'; +import { tryRenderWebPortal, useNativeOverlayPortalNode } from './portal'; + +const ViewWithWheel = View as unknown as React.ComponentType<ViewProps & { onWheel?: any }>; + +export type { + PopoverBackdropEffect, + PopoverBackdropOptions, + PopoverPlacement, + PopoverPortalOptions, + PopoverRenderProps, + PopoverWindowRect, + ResolvedPopoverPlacement, +} from './_types'; + +type WindowRect = PopoverWindowRect; + +type PopoverCommonProps = Readonly<{ + open: boolean; + anchorRef: React.RefObject<any>; + boundaryRef?: React.RefObject<any> | null; + placement?: PopoverPlacement; + gap?: number; + maxHeightCap?: number; + maxWidthCap?: number; + portal?: PopoverPortalOptions; + /** + * Adds padding around the popover content inside the anchored container. + * This is the easiest way to ensure the popover doesn't sit flush against + * the anchor/container edges, especially when using `left: 0, right: 0`. + */ + edgePadding?: number | Readonly<{ horizontal?: number; vertical?: number }>; + /** Extra styles applied to the positioned popover container. */ + containerStyle?: StyleProp<ViewStyle>; + children: (render: PopoverRenderProps) => React.ReactNode; +}>; + +type PopoverWithBackdrop = PopoverCommonProps & Readonly<{ + backdrop?: true | PopoverBackdropOptions | undefined; + onRequestClose: () => void; +}>; + +type PopoverWithoutBackdrop = PopoverCommonProps & Readonly<{ + backdrop: false | (PopoverBackdropOptions & Readonly<{ enabled: false }>); + onRequestClose?: () => void; +}>; + +export function Popover(props: PopoverWithBackdrop | PopoverWithoutBackdrop) { + const { + open, + anchorRef, + boundaryRef: boundaryRefProp, + placement = 'auto', + gap = 8, + maxHeightCap = 400, + maxWidthCap = 520, + onRequestClose, + edgePadding = 0, + backdrop, + containerStyle, + children, + } = props; + + const boundaryFromContext = usePopoverBoundaryRef(); + // `boundaryRef` can be provided explicitly (including `null`) to override any boundary from context. + // This is useful when a PopoverBoundaryProvider is present (e.g. inside an Expo Router modal) but a + // particular popover should instead be constrained to the viewport. + const boundaryRef = boundaryRefProp === undefined ? boundaryFromContext : boundaryRefProp; + const { width: windowWidth, height: windowHeight } = useWindowDimensions(); + const overlayPortal = useOverlayPortal(); + const modalPortalTarget = useModalPortalTarget(); + const portalTarget = usePopoverPortalTarget(); + const portalWeb = props.portal?.web; + const portalNative = props.portal?.native; + const defaultPortalTargetOnWeb: 'body' | 'boundary' | 'modal' = + modalPortalTarget + ? 'modal' + : boundaryRef + ? 'boundary' + : 'body'; + const portalTargetOnWeb = + typeof portalWeb === 'object' && portalWeb + ? (portalWeb.target ?? defaultPortalTargetOnWeb) + : defaultPortalTargetOnWeb; + const matchAnchorWidthOnPortal = props.portal?.matchAnchorWidth ?? true; + const anchorAlignOnPortal = props.portal?.anchorAlign ?? 'start'; + const anchorAlignVerticalOnPortal = props.portal?.anchorAlignVertical ?? 'center'; + + const shouldPortalWeb = Platform.OS === 'web' && Boolean(portalWeb); + const shouldPortalNative = Platform.OS !== 'web' && Boolean(portalNative) && Boolean(overlayPortal); + const shouldPortal = shouldPortalWeb || shouldPortalNative; + const shouldUseOverlayPortalOnNative = shouldPortalNative; + const portalIdRef = React.useRef<string | null>(null); + if (portalIdRef.current === null) { + portalIdRef.current = `popover-${Math.random().toString(36).slice(2)}`; + } + const contentContainerRef = React.useRef<any>(null); + + const getDomElementFromNode = React.useCallback((candidate: any): HTMLElement | null => { + if (!candidate) return null; + if (typeof candidate.contains === 'function') return candidate as HTMLElement; + const scrollable = candidate.getScrollableNode?.(); + if (scrollable && typeof scrollable.contains === 'function') return scrollable as HTMLElement; + return null; + }, []); + + const getBoundaryDomElement = React.useCallback((): HTMLElement | null => { + const boundaryNode = boundaryRef?.current as any; + if (!boundaryNode) return null; + // Direct DOM element (RN-web View ref often is the DOM element) + if (typeof boundaryNode.addEventListener === 'function' && typeof boundaryNode.appendChild === 'function') { + return boundaryNode as HTMLElement; + } + // RN ScrollView refs often expose getScrollableNode() + const scrollable = boundaryNode.getScrollableNode?.(); + if (scrollable && typeof scrollable.addEventListener === 'function' && typeof scrollable.appendChild === 'function') { + return scrollable as HTMLElement; + } + return null; + }, [boundaryRef]); + + const getWebPortalTarget = React.useCallback((): HTMLElement | null => { + if (Platform.OS !== 'web') return null; + if (portalTargetOnWeb === 'modal') return (modalPortalTarget as any) ?? null; + if (portalTargetOnWeb === 'boundary') return getBoundaryDomElement(); + return typeof document !== 'undefined' ? document.body : null; + }, [getBoundaryDomElement, modalPortalTarget, portalTargetOnWeb]); + + const portalPositionOnWeb: ViewStyle['position'] = + Platform.OS === 'web' && shouldPortalWeb && portalTargetOnWeb !== 'body' + ? 'absolute' + : ('fixed' as any); + const webPortalTarget = shouldPortalWeb ? getWebPortalTarget() : null; + const webPortalTargetRect = + shouldPortalWeb && portalTargetOnWeb !== 'body' + ? webPortalTarget?.getBoundingClientRect?.() ?? null + : null; + // When positioning `absolute` inside a scrollable container, account for its scroll offset. + // Otherwise, the portal content is shifted by `-scrollTop`/`-scrollLeft` (it appears to drift + // upward/left as you scroll the boundary). Using (rect - scroll) means later `top - offset` + // effectively adds scroll back in. + const portalScrollLeft = portalPositionOnWeb === 'absolute' ? (webPortalTarget as any)?.scrollLeft ?? 0 : 0; + const portalScrollTop = portalPositionOnWeb === 'absolute' ? (webPortalTarget as any)?.scrollTop ?? 0 : 0; + const webPortalOffsetX = (webPortalTargetRect?.left ?? webPortalTargetRect?.x ?? 0) - portalScrollLeft; + const webPortalOffsetY = (webPortalTargetRect?.top ?? webPortalTargetRect?.y ?? 0) - portalScrollTop; + + const [computed, setComputed] = React.useState<PopoverRenderProps>(() => ({ + maxHeight: maxHeightCap, + maxWidth: maxWidthCap, + placement: placement === 'auto' ? 'top' : placement, + })); + const [anchorRectState, setAnchorRectState] = React.useState<WindowRect | null>(null); + const [boundaryRectState, setBoundaryRectState] = React.useState<WindowRect | null>(null); + const [contentRectState, setContentRectState] = React.useState<WindowRect | null>(null); + const isMountedRef = React.useRef(true); + React.useEffect(() => { + return () => { + isMountedRef.current = false; + }; + }, []); + + const edgeInsets = React.useMemo(() => { + const horizontal = + typeof edgePadding === 'number' + ? edgePadding + : (edgePadding.horizontal ?? 0); + const vertical = + typeof edgePadding === 'number' + ? edgePadding + : (edgePadding.vertical ?? 0); + + return { horizontal, vertical }; + }, [edgePadding]); + + const recompute = React.useCallback(async () => { + if (!open) return; + + const measureOnce = async (): Promise<boolean> => { + const anchorNode = anchorRef.current as any; + const boundaryNodeRaw = boundaryRef?.current as any; + const portalRootNode = + Platform.OS !== 'web' && shouldPortalNative + ? (portalTarget?.rootRef?.current as any) + : null; + // On web, if boundary is a ScrollView ref, measure the real scrollable node to match + // the element we attach scroll listeners to. This reduces coordinate mismatches. + const boundaryNode = + Platform.OS === 'web' + ? (boundaryNodeRaw?.getScrollableNode?.() ?? boundaryNodeRaw) + : boundaryNodeRaw; + + let anchorRect: WindowRect | null = null; + let anchorIsPortalRelative = false; + + if (portalRootNode) { + const relative = await measureLayoutRelativeTo(anchorNode, portalRootNode); + if (relative) { + anchorRect = relative; + anchorIsPortalRelative = true; + } + } + + if (!anchorRect) { + anchorRect = await measureInWindow(anchorNode); + } + + const boundaryRectRaw = await (async () => { + // IMPORTANT: Keep anchor + boundary in the same coordinate space. + // If we position using portal-root-relative anchor coords (measureLayout), then using + // a window-relative boundary (measureInWindow) can clamp the menu off-screen. + if (portalRootNode && anchorIsPortalRelative) { + const relativeBoundary = boundaryNode ? await measureLayoutRelativeTo(boundaryNode, portalRootNode) : null; + if (relativeBoundary) return relativeBoundary; + + const targetLayout = portalTarget?.layout; + if (targetLayout && targetLayout.width > 0 && targetLayout.height > 0) { + return { x: 0, y: 0, width: targetLayout.width, height: targetLayout.height }; + } + + const rootRect = await measureInWindow(portalRootNode); + if (rootRect?.width && rootRect?.height) { + return { x: 0, y: 0, width: rootRect.width, height: rootRect.height }; + } + + return null; + } + + if (portalRootNode) { + const relativeBoundary = boundaryNode ? await measureLayoutRelativeTo(boundaryNode, portalRootNode) : null; + if (relativeBoundary) return relativeBoundary; + const targetLayout = portalTarget?.layout; + if (targetLayout && targetLayout.width > 0 && targetLayout.height > 0) { + return { x: 0, y: 0, width: targetLayout.width, height: targetLayout.height }; + } + } + + return boundaryNode ? measureInWindow(boundaryNode) : Promise.resolve(null); + })(); + + if (!isMountedRef.current) return false; + if (!anchorRect) return false; + // When portaling (web/native), a zero-sized anchor can cause the popover to render in + // the wrong place (often overlapping the trigger). Treat it as an invalid measurement + // and retry a couple times to allow layout to settle. + if ((shouldPortalWeb || shouldPortalNative) && (anchorRect.width < 1 || anchorRect.height < 1)) { + return false; + } + + const boundaryRect = + boundaryRectRaw ?? + (portalRootNode && portalTarget?.layout?.width && portalTarget?.layout?.height + ? { x: 0, y: 0, width: portalTarget.layout.width, height: portalTarget.layout.height } + : getFallbackBoundaryRect({ windowWidth, windowHeight })); + + // Shrink the usable boundary so the popover doesn't sit flush to the container edges. + // (This also makes maxHeight/maxWidth clamping respect the margin.) + const effectiveBoundaryRect: WindowRect = { + x: boundaryRect.x + edgeInsets.horizontal, + y: boundaryRect.y + edgeInsets.vertical, + width: Math.max(0, boundaryRect.width - edgeInsets.horizontal * 2), + height: Math.max(0, boundaryRect.height - edgeInsets.vertical * 2), + }; + + const availableTop = (anchorRect.y - effectiveBoundaryRect.y) - gap; + const availableBottom = (effectiveBoundaryRect.y + effectiveBoundaryRect.height - (anchorRect.y + anchorRect.height)) - gap; + const availableLeft = (anchorRect.x - effectiveBoundaryRect.x) - gap; + const availableRight = (effectiveBoundaryRect.x + effectiveBoundaryRect.width - (anchorRect.x + anchorRect.width)) - gap; + + const resolvedPlacement = resolvePlacement({ + placement, + available: { + top: availableTop, + bottom: availableBottom, + left: availableLeft, + right: availableRight, + }, + }); + + const maxHeightAvailable = + resolvedPlacement === 'bottom' + ? availableBottom + : resolvedPlacement === 'top' + ? availableTop + : effectiveBoundaryRect.height - gap * 2; + + const maxWidthAvailable = + resolvedPlacement === 'right' + ? availableRight + : resolvedPlacement === 'left' + ? availableLeft + : effectiveBoundaryRect.width - gap * 2; + + setComputed({ + placement: resolvedPlacement, + maxHeight: Math.max(0, Math.min(maxHeightCap, Math.floor(maxHeightAvailable))), + maxWidth: Math.max(0, Math.min(maxWidthCap, Math.floor(maxWidthAvailable))), + }); + setAnchorRectState(anchorRect); + setBoundaryRectState(effectiveBoundaryRect); + return true; + }; + + const scheduleFrame = (cb: () => void) => { + // In some test/non-browser environments, rAF may be missing. + // Prefer rAF when available so layout has a chance to settle. + if (typeof requestAnimationFrame === 'function') { + requestAnimationFrame(cb); + return; + } + if (typeof queueMicrotask === 'function') { + queueMicrotask(cb); + return; + } + setTimeout(cb, 0); + }; + + const shouldRetry = Platform.OS === 'web' || shouldPortalNative; + if (!shouldRetry) { + void measureOnce(); + return; + } + + // On web and native portal overlays, layout can "settle" a frame later (especially when opening). + // If the initial measurement returns invalid values, retry a couple times so we don't get stuck + // with incorrect placement or invisible portal content. + const measureWithRetries = async (attempt: number) => { + const ok = await measureOnce(); + if (ok) return; + if (!isMountedRef.current) return; + if (attempt >= 2) return; + scheduleFrame(() => { + void measureWithRetries(attempt + 1); + }); + }; + + scheduleFrame(() => { + void measureWithRetries(0); + }); + }, [anchorRef, boundaryRef, edgeInsets.horizontal, edgeInsets.vertical, gap, maxHeightCap, maxWidthCap, open, placement, shouldPortalNative, shouldPortalWeb, windowHeight, windowWidth, portalTarget]); + + React.useLayoutEffect(() => { + if (!open) return; + recompute(); + }, [open, recompute]); + + React.useEffect(() => { + if (!open) return; + if (Platform.OS !== 'web') return; + + let timer: number | null = null; + const debounceMs = 90; + + const schedule = () => { + if (timer !== null) window.clearTimeout(timer); + timer = window.setTimeout(() => { + timer = null; + recompute(); + }, debounceMs); + }; + + window.addEventListener('resize', schedule); + + // Only subscribe to scroll events when we portal to `document.body` (fixed positioning). + // For portals mounted inside the modal/boundary target (absolute positioning), the popover + // is positioned in the same scroll coordinate space as its anchor, so it stays aligned + // without recomputing on every scroll (avoids scroll jank on mobile web). + const shouldSubscribeToScroll = shouldPortalWeb && portalTargetOnWeb === 'body'; + const boundaryEl = shouldSubscribeToScroll ? getBoundaryDomElement() : null; + if (shouldSubscribeToScroll) { + // Window scroll covers page-level scrolling, but RN-web ScrollViews scroll their own + // internal div. Subscribe to both so fixed-position popovers track their anchor. + window.addEventListener('scroll', schedule, { passive: true } as any); + if (boundaryEl) { + boundaryEl.addEventListener('scroll', schedule, { passive: true } as any); + } + } + return () => { + if (timer !== null) window.clearTimeout(timer); + window.removeEventListener('resize', schedule); + if (shouldSubscribeToScroll) { + window.removeEventListener('scroll', schedule as any); + if (boundaryEl) { + boundaryEl.removeEventListener('scroll', schedule as any); + } + } + }; + }, [getBoundaryDomElement, open, portalTargetOnWeb, recompute, shouldPortalWeb]); + + const fixedPositionOnWeb = (Platform.OS === 'web' ? ('fixed' as any) : 'absolute') as ViewStyle['position']; + + const placementStyle: ViewStyle = (() => { + // On web, optional: render as a viewport-fixed overlay so it can escape any overflow:hidden ancestors. + // This is especially important for headers/sidebars which often clip overflow. + if (shouldPortal && anchorRectState) { + const boundaryRect = boundaryRectState ?? getFallbackBoundaryRect({ windowWidth, windowHeight }); + const position = Platform.OS === 'web' && shouldPortalWeb ? portalPositionOnWeb : fixedPositionOnWeb; + const desiredWidth = (() => { + // Preserve historical sizing: for top/bottom, the popover was anchored to the + // container width (left:0,right:0) and capped by maxWidth. The closest equivalent + // in portal+fixed mode is to optionally cap width to anchor width. + if (computed.placement === 'top' || computed.placement === 'bottom') { + return matchAnchorWidthOnPortal + ? Math.min(computed.maxWidth, Math.floor(anchorRectState.width)) + : computed.maxWidth; + } + // For left/right, menus are typically content-sized; use computed maxWidth. + return computed.maxWidth; + })(); + + const left = (() => { + if (computed.placement === 'left') { + return anchorRectState.x - gap - desiredWidth; + } + if (computed.placement === 'right') { + return anchorRectState.x + anchorRectState.width + gap; + } + // top/bottom + const desiredLeftRaw = (() => { + switch (anchorAlignOnPortal) { + case 'end': + return anchorRectState.x + anchorRectState.width - desiredWidth; + case 'center': + return anchorRectState.x + (anchorRectState.width - desiredWidth) / 2; + case 'start': + default: + return anchorRectState.x; + } + })(); + return desiredLeftRaw; + })(); + + const top = (() => { + if (computed.placement === 'left' || computed.placement === 'right') { + const contentHeight = contentRectState?.height ?? computed.maxHeight; + const desiredTopRaw = (() => { + switch (anchorAlignVerticalOnPortal) { + case 'end': + return anchorRectState.y + anchorRectState.height - contentHeight; + case 'start': + return anchorRectState.y; + case 'center': + default: + return anchorRectState.y + (anchorRectState.height - contentHeight) / 2; + } + })(); + + return Math.min( + boundaryRect.y + boundaryRect.height - contentHeight, + Math.max(boundaryRect.y, desiredTopRaw), + ); + } + + // top/bottom + const contentHeight = contentRectState?.height ?? computed.maxHeight; + const topForBottom = Math.min( + boundaryRect.y + boundaryRect.height - contentHeight, + Math.max(boundaryRect.y, anchorRectState.y + anchorRectState.height + gap), + ); + const topForTop = Math.max( + boundaryRect.y, + Math.min(boundaryRect.y + boundaryRect.height - contentHeight, anchorRectState.y - contentHeight - gap), + ); + return computed.placement === 'top' ? topForTop : topForBottom; + })(); + + const clampedLeft = Math.min( + boundaryRect.x + boundaryRect.width - desiredWidth, + Math.max(boundaryRect.x, left), + ); + + return { + position, + left: Math.floor(clampedLeft - (position === 'absolute' ? webPortalOffsetX : 0)), + top: Math.floor(top - (position === 'absolute' ? webPortalOffsetY : 0)), + zIndex: 1000, + width: + computed.placement === 'top' || + computed.placement === 'bottom' || + computed.placement === 'left' || + computed.placement === 'right' + ? desiredWidth + : undefined, + }; + } + + switch (computed.placement) { + case 'top': + return { position: 'absolute', bottom: '100%', left: 0, right: 0, marginBottom: gap, zIndex: 1000 }; + case 'bottom': + return { position: 'absolute', top: '100%', left: 0, right: 0, marginTop: gap, zIndex: 1000 }; + case 'left': + return { position: 'absolute', right: '100%', top: 0, marginRight: gap, zIndex: 1000 }; + case 'right': + return { position: 'absolute', left: '100%', top: 0, marginLeft: gap, zIndex: 1000 }; + } + })(); + + const portalOpacity = (() => { + // Web portal popovers should not "jiggle" (render in one place then snap). + // Hide them until we have enough layout info to position them correctly. + if (!shouldPortalWeb && !shouldPortalNative) return 1; + if (!anchorRectState) return 0; + if ( + (computed.placement === 'left' || computed.placement === 'right') && + anchorAlignVerticalOnPortal !== 'start' && + (!contentRectState || contentRectState.height < 1) + ) { + return 0; + } + return 1; + })(); + + const stopScrollEventPropagationOnWeb = React.useCallback((event: any) => { + // Expo Router (Vaul/Radix) modals on web often install document-level scroll-lock listeners + // that `preventDefault()` wheel/touch scroll, which breaks scrolling inside portaled popovers. + // Stopping propagation here keeps the event within the popover subtree so native scrolling works. + if (Platform.OS !== 'web') return; + if (!shouldPortalWeb) return; + if (typeof event?.stopPropagation === 'function') event.stopPropagation(); + }, [shouldPortalWeb]); + + // IMPORTANT: hooks must not be conditional. This must run even when `open === false` + // to avoid changing hook order between renders. + const paddingStyle = React.useMemo<ViewStyle>(() => { + const horizontal = + typeof edgePadding === 'number' + ? edgePadding + : (edgePadding.horizontal ?? 0); + const vertical = + typeof edgePadding === 'number' + ? edgePadding + : (edgePadding.vertical ?? 0); + + if (computed.placement === 'top' || computed.placement === 'bottom') { + return horizontal > 0 ? { paddingHorizontal: horizontal } : {}; + } + if (computed.placement === 'left' || computed.placement === 'right') { + return vertical > 0 ? { paddingVertical: vertical } : {}; + } + return {}; + }, [computed.placement, edgePadding]); + + // Must be above BaseModal (100000) and other header overlays. + const portalZ = 200000; + + const backdropEnabled = + typeof backdrop === 'boolean' + ? backdrop + : (backdrop?.enabled ?? true); + const backdropBlocksOutsidePointerEvents = + typeof backdrop === 'object' && backdrop + ? (backdrop.blockOutsidePointerEvents ?? (Platform.OS === 'web' ? false : true)) + : (Platform.OS === 'web' ? false : true); + const backdropEffect: PopoverBackdropEffect = + typeof backdrop === 'object' && backdrop + ? (backdrop.effect ?? 'none') + : 'none'; + const backdropBlurOnWeb = typeof backdrop === 'object' && backdrop ? backdrop.blurOnWeb : undefined; + const backdropSpotlight = typeof backdrop === 'object' && backdrop ? (backdrop.spotlight ?? false) : false; + const backdropAnchorOverlay = typeof backdrop === 'object' && backdrop ? backdrop.anchorOverlay : undefined; + const backdropStyle = typeof backdrop === 'object' && backdrop ? backdrop.style : undefined; + const closeOnBackdropPan = typeof backdrop === 'object' && backdrop ? (backdrop.closeOnPan ?? false) : false; + + React.useEffect(() => { + if (Platform.OS !== 'web') return; + if (!open) return; + if (!onRequestClose) return; + if (backdropEnabled && backdropBlocksOutsidePointerEvents) return; + if (typeof document === 'undefined') return; + + const handlePointerDownCapture = (event: Event) => { + const target = event.target as Node | null; + if (!target) return; + const contentEl = getDomElementFromNode(contentContainerRef.current); + if (contentEl && contentEl.contains(target)) return; + const anchorEl = getDomElementFromNode(anchorRef.current); + if (anchorEl && anchorEl.contains(target)) return; + onRequestClose(); + }; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + onRequestClose(); + } + }; + + document.addEventListener('pointerdown', handlePointerDownCapture, true); + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('pointerdown', handlePointerDownCapture, true); + document.removeEventListener('keydown', handleKeyDown); + }; + }, [ + anchorRef, + backdropBlocksOutsidePointerEvents, + backdropEnabled, + getDomElementFromNode, + onRequestClose, + open, + ]); + + const content = open ? ( + <> + <PopoverBackdrop + backdrop={backdropEnabled ? backdrop : false} + backdropBlocksOutsidePointerEvents={backdropBlocksOutsidePointerEvents} + backdropEffect={backdropEffect} + backdropBlurOnWeb={backdropBlurOnWeb} + backdropSpotlight={backdropSpotlight} + backdropAnchorOverlay={backdropAnchorOverlay} + backdropStyle={backdropStyle} + closeOnBackdropPan={closeOnBackdropPan} + onRequestClose={onRequestClose} + shouldPortal={shouldPortal} + shouldPortalWeb={shouldPortalWeb} + portal={props.portal} + portalOpacity={portalOpacity} + portalPositionOnWeb={portalPositionOnWeb} + fixedPositionOnWeb={fixedPositionOnWeb} + portalZ={portalZ} + anchorRect={anchorRectState} + windowWidth={windowWidth} + windowHeight={windowHeight} + webPortalOffsetX={webPortalOffsetX} + webPortalOffsetY={webPortalOffsetY} + /> + <ViewWithWheel + ref={contentContainerRef} + {...(shouldPortalWeb + ? ({ onWheel: stopScrollEventPropagationOnWeb, onTouchMove: stopScrollEventPropagationOnWeb } as any) + : {})} + style={[ + placementStyle, + paddingStyle, + containerStyle, + { maxWidth: computed.maxWidth }, + (shouldPortalWeb || shouldPortalNative) ? { opacity: portalOpacity } : null, + shouldPortal ? { zIndex: portalZ + 1 } : null, + ]} + pointerEvents={(shouldPortalWeb || shouldPortalNative) && portalOpacity === 0 ? 'none' : 'auto'} + onLayout={(e) => { + // Used to improve portal alignment (especially left/right centering) + const layout = e?.nativeEvent?.layout; + if (!layout) return; + const next = { x: 0, y: 0, width: layout.width ?? 0, height: layout.height ?? 0 }; + // Avoid rerender loops from tiny float changes + setContentRectState((prev) => { + if (!prev) return next; + if (Math.abs(prev.width - next.width) > 1 || Math.abs(prev.height - next.height) > 1) { + return next; + } + return prev; + }); + }} + > + {children(computed)} + </ViewWithWheel> + </> + ) : null; + + const contentWithRadixBranch = (() => { + if (!content) return null; + if (!shouldPortalWeb) return content; + try { + // IMPORTANT: + // Use the CJS entrypoints (`require`) so Radix singletons (DismissableLayer stacks) + // are shared with Vaul / expo-router on web. Without this, "outside click" logic + // can treat portaled popovers as outside the active modal. + const { Branch: DismissableLayerBranch } = requireRadixDismissableLayer(); + return ( + <DismissableLayerBranch> + {content} + </DismissableLayerBranch> + ); + } catch { + return content; + } + })(); + + useNativeOverlayPortalNode({ + overlayPortal, + portalId: portalIdRef.current as string, + enabled: shouldUseOverlayPortalOnNative, + content, + }); + + if (!open) return null; + + const webPortal = tryRenderWebPortal({ + shouldPortalWeb, + portalTargetOnWeb, + modalPortalTarget: (modalPortalTarget as any) ?? null, + getBoundaryDomElement, + content: contentWithRadixBranch, + }); + if (webPortal) return webPortal; + + if (shouldUseOverlayPortalOnNative) return null; + return contentWithRadixBranch; +} diff --git a/expo-app/sources/components/ui/popover/PopoverBoundary.tsx b/expo-app/sources/components/ui/popover/PopoverBoundary.tsx new file mode 100644 index 000000000..074fbfcf5 --- /dev/null +++ b/expo-app/sources/components/ui/popover/PopoverBoundary.tsx @@ -0,0 +1,17 @@ +import * as React from 'react'; +const PopoverBoundaryContext = React.createContext<React.RefObject<any> | null>(null); + +export function PopoverBoundaryProvider(props: { + boundaryRef: React.RefObject<any>; + children: React.ReactNode; +}) { + return ( + <PopoverBoundaryContext.Provider value={props.boundaryRef}> + {props.children} + </PopoverBoundaryContext.Provider> + ); +} + +export function usePopoverBoundaryRef() { + return React.useContext(PopoverBoundaryContext); +} diff --git a/expo-app/sources/components/ui/popover/PopoverPortalTarget.tsx b/expo-app/sources/components/ui/popover/PopoverPortalTarget.tsx new file mode 100644 index 000000000..9a87e2ef0 --- /dev/null +++ b/expo-app/sources/components/ui/popover/PopoverPortalTarget.tsx @@ -0,0 +1,26 @@ +import * as React from 'react'; + +export type PopoverPortalTargetState = Readonly<{ + /** + * A native view that acts as the coordinate root for portaled popovers. + * When present, popovers can measure anchors relative to this view via `measureLayout` + * and position themselves in the same coordinate space they render into. + */ + rootRef: React.RefObject<any>; + /** Size of the coordinate root. */ + layout: Readonly<{ width: number; height: number }>; +}>; + +const PopoverPortalTargetContext = React.createContext<PopoverPortalTargetState | null>(null); + +export function PopoverPortalTargetContextProvider(props: { value: PopoverPortalTargetState; children: React.ReactNode }) { + return ( + <PopoverPortalTargetContext.Provider value={props.value}> + {props.children} + </PopoverPortalTargetContext.Provider> + ); +} + +export function usePopoverPortalTarget() { + return React.useContext(PopoverPortalTargetContext); +} diff --git a/expo-app/sources/components/ui/popover/PopoverPortalTargetProvider.test.ts b/expo-app/sources/components/ui/popover/PopoverPortalTargetProvider.test.ts new file mode 100644 index 000000000..11e28dd6c --- /dev/null +++ b/expo-app/sources/components/ui/popover/PopoverPortalTargetProvider.test.ts @@ -0,0 +1,74 @@ +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import renderer, { act } from 'react-test-renderer'; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +vi.mock('@/components/ui/popover', () => ({ + usePopoverBoundaryRef: () => null, +})); + +vi.mock('react-native', () => { + const React = require('react'); + return { + Platform: { OS: 'ios' }, + useWindowDimensions: () => ({ width: 390, height: 844 }), + View: (props: any) => React.createElement('View', props, props.children), + Pressable: (props: any) => React.createElement('Pressable', props, props.children), + }; +}); + +function PopoverChild() { + return React.createElement('PopoverChild'); +} + +describe('PopoverPortalTargetProvider (native)', () => { + it('renders popovers into a screen-local OverlayPortalHost (avoids coordinate-space mismatch in contained modals)', async () => { + const { OverlayPortalHost, OverlayPortalProvider } = await import('./OverlayPortal'); + const { Popover } = await import('./Popover'); + const { PopoverPortalTargetProvider } = await import('./PopoverPortalTargetProvider'); + + const anchorRef = { + current: { + measureInWindow: (cb: any) => cb(200, 200, 20, 20), + }, + } as any; + + let tree: ReturnType<typeof renderer.create> | undefined; + await act(async () => { + tree = renderer.create( + React.createElement( + OverlayPortalProvider, + null, + React.createElement( + 'View', + { testID: 'inner-root' }, + React.createElement( + PopoverPortalTargetProvider, + null, + React.createElement(Popover, { + open: true, + anchorRef, + placement: 'bottom', + portal: { native: true }, + onRequestClose: () => {}, + backdrop: true, + children: () => React.createElement(PopoverChild), + } as any), + ), + ), + React.createElement( + 'View', + { testID: 'outer-host' }, + React.createElement(OverlayPortalHost), + ), + ), + ); + }); + + const innerRoot = tree?.root.findByProps({ testID: 'inner-root' }); + expect(innerRoot?.findAllByType('PopoverChild' as any).length).toBe(1); + expect(tree?.root.findByProps({ testID: 'outer-host' }).findAllByType('PopoverChild' as any).length).toBe(0); + }); + +}); diff --git a/expo-app/sources/components/ui/popover/PopoverPortalTargetProvider.tsx b/expo-app/sources/components/ui/popover/PopoverPortalTargetProvider.tsx new file mode 100644 index 000000000..ea6fdf09e --- /dev/null +++ b/expo-app/sources/components/ui/popover/PopoverPortalTargetProvider.tsx @@ -0,0 +1,47 @@ +import * as React from 'react'; +import { Platform, View } from 'react-native'; +import { OverlayPortalHost, OverlayPortalProvider } from './OverlayPortal'; +import { PopoverPortalTargetContextProvider } from './PopoverPortalTarget'; + +/** + * Creates a screen-local portal host for native popovers/dropdowns. + * + * Why this exists: + * - On iOS, screens presented as `containedModal` / sheet-like presentations can live in a + * different native coordinate space than the app root. + * - If popovers portal to an app-root host, anchor measurements and overlay positioning can + * mismatch (menus appear vertically offset). + * + * By scoping an `OverlayPortalProvider` + `OverlayPortalHost` to the current screen subtree, + * popovers render in the same coordinate space as their anchors. + */ +export function PopoverPortalTargetProvider(props: { children: React.ReactNode }) { + // Web uses ReactDOM portals; scoping a native overlay host is unnecessary. + if (Platform.OS === 'web') return <>{props.children}</>; + + const rootRef = React.useRef<any>(null); + const [layout, setLayout] = React.useState(() => ({ width: 0, height: 0 })); + + return ( + <PopoverPortalTargetContextProvider value={{ rootRef, layout }}> + <OverlayPortalProvider> + <View + ref={rootRef} + style={{ flex: 1 }} + pointerEvents="box-none" + onLayout={(e) => { + const next = e?.nativeEvent?.layout; + if (!next) return; + setLayout((prev) => { + if (prev.width === next.width && prev.height === next.height) return prev; + return { width: next.width, height: next.height }; + }); + }} + > + {props.children} + <OverlayPortalHost /> + </View> + </OverlayPortalProvider> + </PopoverPortalTargetContextProvider> + ); +} diff --git a/expo-app/sources/components/ui/popover/_types.ts b/expo-app/sources/components/ui/popover/_types.ts new file mode 100644 index 000000000..02f670cc7 --- /dev/null +++ b/expo-app/sources/components/ui/popover/_types.ts @@ -0,0 +1,91 @@ +import type * as React from 'react'; +import type { StyleProp, ViewStyle } from 'react-native'; + +export type PopoverPlacement = 'top' | 'bottom' | 'left' | 'right' | 'auto'; +export type ResolvedPopoverPlacement = Exclude<PopoverPlacement, 'auto'>; +export type PopoverBackdropEffect = 'none' | 'dim' | 'blur'; + +type WindowRect = Readonly<{ x: number; y: number; width: number; height: number }>; +export type PopoverWindowRect = WindowRect; + +export type PopoverPortalOptions = Readonly<{ + /** + * Web only: render the popover in a portal using fixed positioning. + * Useful when the anchor is inside overflow-clipped containers. + */ + web?: boolean | Readonly<{ target?: 'body' | 'boundary' | 'modal' }>; + /** + * Native only: render the popover in a portal host mounted near the app root. + * This allows popovers to escape overflow clipping from lists/rows/scrollviews. + */ + native?: boolean; + /** + * When true, the popover width is capped to the anchor width for top/bottom placements. + * Defaults to true to preserve historical behavior. + */ + matchAnchorWidth?: boolean; + /** + * Horizontal alignment relative to the anchor for top/bottom placements. + * Defaults to 'start' to preserve historical behavior. + */ + anchorAlign?: 'start' | 'center' | 'end'; + /** + * Vertical alignment relative to the anchor for left/right placements. + * Defaults to 'center' for menus/tooltips. + */ + anchorAlignVertical?: 'start' | 'center' | 'end'; +}>; + +export type PopoverBackdropOptions = Readonly<{ + /** + * Whether to render a full-screen layer behind the popover that intercepts taps. + * Defaults to true. + * + * NOTE: when enabled, `onRequestClose` must be provided (Popover is controlled). + */ + enabled?: boolean; + /** + * When true, blocks interactions outside the popover while it's open. + * + * - Web: defaults to `false` (popover behaves like a non-modal menu; outside clicks close it but + * still allow the underlying target to receive the event). + * - Native: defaults to `true` (outside taps are intercepted by a full-screen Pressable). + */ + blockOutsidePointerEvents?: boolean; + /** Optional visual effect for the backdrop layer. */ + effect?: PopoverBackdropEffect; + /** + * Web-only options for `effect="blur"` (CSS `backdrop-filter`). + * This does not affect native, where `expo-blur` controls intensity/tint. + */ + blurOnWeb?: Readonly<{ px?: number; tintColor?: string }>; + /** + * When enabled (and when `effect` is `dim|blur`), keeps the anchor area visually “uncovered” + * by the effect so the trigger stays crisp/visible. + * + * This is mainly intended for context-menu style popovers. + */ + spotlight?: boolean | Readonly<{ padding?: number }>; + /** + * When provided (and when `effect` is `dim|blur` in portal mode), renders a visual overlay + * positioned over the anchor *above* the backdrop effect. This avoids “cutout seams” + * from spotlight-hole techniques and keeps the trigger crisp. + * + * Note: this overlay is visual-only and always uses `pointerEvents="none"`. + */ + anchorOverlay?: React.ReactNode | ((params: Readonly<{ rect: WindowRect }>) => React.ReactNode); + /** Extra styles applied to the backdrop layer. */ + style?: StyleProp<ViewStyle>; + /** + * When enabled, dragging on the backdrop will close the popover. + * Useful for context-menu style popovers in scrollable screens. + */ + closeOnPan?: boolean; +}>; + +export type PopoverRenderProps = Readonly<{ + maxHeight: number; + maxWidth: number; + placement: ResolvedPopoverPlacement; +}>; + diff --git a/expo-app/sources/components/ui/popover/backdrop.tsx b/expo-app/sources/components/ui/popover/backdrop.tsx new file mode 100644 index 000000000..d7e2a8677 --- /dev/null +++ b/expo-app/sources/components/ui/popover/backdrop.tsx @@ -0,0 +1,273 @@ +import * as React from 'react'; +import { Platform, Pressable, View, type ViewStyle } from 'react-native'; +import { StyleSheet } from 'react-native-unistyles'; + +import type { PopoverBackdropEffect, PopoverPortalOptions, PopoverWindowRect } from './_types'; + +export function PopoverBackdrop(props: Readonly<{ + backdrop: boolean | Readonly<{ enabled?: boolean }> | undefined; + backdropBlocksOutsidePointerEvents: boolean; + backdropEffect: PopoverBackdropEffect; + backdropBlurOnWeb: Readonly<{ px?: number; tintColor?: string }> | undefined; + backdropSpotlight: boolean | Readonly<{ padding?: number }>; + backdropAnchorOverlay: React.ReactNode | ((params: Readonly<{ rect: PopoverWindowRect }>) => React.ReactNode) | undefined; + backdropStyle: any; + closeOnBackdropPan: boolean; + onRequestClose: (() => void) | undefined; + + shouldPortal: boolean; + shouldPortalWeb: boolean; + portal: PopoverPortalOptions | undefined; + portalOpacity: number; + portalPositionOnWeb: ViewStyle['position']; + fixedPositionOnWeb: ViewStyle['position']; + portalZ: number; + + anchorRect: PopoverWindowRect | null; + windowWidth: number; + windowHeight: number; + webPortalOffsetX: number; + webPortalOffsetY: number; +}>) { + const backdropEnabled = + typeof props.backdrop === 'boolean' + ? props.backdrop + : ((props.backdrop as any)?.enabled ?? true); + + if (!backdropEnabled) return null; + + return ( + <> + {props.backdropEffect !== 'none' ? ( + <PopoverBackdropEffectLayer + backdropEffect={props.backdropEffect} + backdropBlurOnWeb={props.backdropBlurOnWeb} + backdropSpotlight={props.backdropSpotlight} + shouldPortal={props.shouldPortal} + shouldPortalWeb={props.shouldPortalWeb} + portalOpacity={props.portalOpacity} + portalPositionOnWeb={props.portalPositionOnWeb} + fixedPositionOnWeb={props.fixedPositionOnWeb} + portalZ={props.portalZ} + anchorRect={props.anchorRect} + windowWidth={props.windowWidth} + windowHeight={props.windowHeight} + webPortalOffsetX={props.webPortalOffsetX} + webPortalOffsetY={props.webPortalOffsetY} + /> + ) : null} + + {props.backdropBlocksOutsidePointerEvents ? ( + <Pressable + onPress={props.onRequestClose} + pointerEvents={props.portalOpacity === 0 ? 'none' : 'auto'} + onMoveShouldSetResponderCapture={() => { + if (!props.closeOnBackdropPan || !props.onRequestClose) return false; + props.onRequestClose(); + return false; + }} + style={[ + { + position: props.fixedPositionOnWeb, + top: Platform.OS === 'web' ? 0 : (props.shouldPortal ? 0 : -1000), + left: Platform.OS === 'web' ? 0 : (props.shouldPortal ? 0 : -1000), + right: Platform.OS === 'web' ? 0 : (props.shouldPortal ? 0 : -1000), + bottom: Platform.OS === 'web' ? 0 : (props.shouldPortal ? 0 : -1000), + opacity: props.portalOpacity, + zIndex: props.shouldPortal ? props.portalZ : 999, + }, + props.backdropStyle, + ]} + /> + ) : null} + + {props.shouldPortal && props.backdropEffect !== 'none' && props.backdropAnchorOverlay && props.anchorRect ? ( + <View + testID="popover-anchor-overlay" + pointerEvents="none" + style={[ + { + position: props.shouldPortalWeb ? props.portalPositionOnWeb : 'absolute', + left: (() => { + const offsetX = props.portalPositionOnWeb === 'absolute' ? props.webPortalOffsetX : 0; + return Math.max(0, Math.floor(props.anchorRect!.x - offsetX)); + })(), + top: (() => { + const offsetY = props.portalPositionOnWeb === 'absolute' ? props.webPortalOffsetY : 0; + return Math.max(0, Math.floor(props.anchorRect!.y - offsetY)); + })(), + width: (() => { + const offsetX = props.portalPositionOnWeb === 'absolute' ? props.webPortalOffsetX : 0; + const left = Math.max(0, Math.floor(props.anchorRect!.x - offsetX)); + return Math.max(0, Math.min(props.windowWidth - left, Math.ceil(props.anchorRect!.width))); + })(), + height: (() => { + const offsetY = props.portalPositionOnWeb === 'absolute' ? props.webPortalOffsetY : 0; + const top = Math.max(0, Math.floor(props.anchorRect!.y - offsetY)); + return Math.max(0, Math.min(props.windowHeight - top, Math.ceil(props.anchorRect!.height))); + })(), + opacity: props.portalOpacity, + zIndex: props.portalZ + 1, + } as const, + ]} + > + {typeof props.backdropAnchorOverlay === 'function' + ? props.backdropAnchorOverlay({ rect: props.anchorRect }) + : props.backdropAnchorOverlay} + </View> + ) : null} + </> + ); +} + +function PopoverBackdropEffectLayer(props: Readonly<{ + backdropEffect: PopoverBackdropEffect; + backdropBlurOnWeb: Readonly<{ px?: number; tintColor?: string }> | undefined; + backdropSpotlight: boolean | Readonly<{ padding?: number }>; + shouldPortal: boolean; + shouldPortalWeb: boolean; + portalOpacity: number; + portalPositionOnWeb: ViewStyle['position']; + fixedPositionOnWeb: ViewStyle['position']; + portalZ: number; + anchorRect: PopoverWindowRect | null; + windowWidth: number; + windowHeight: number; + webPortalOffsetX: number; + webPortalOffsetY: number; +}>) { + const position = + Platform.OS === 'web' && props.shouldPortalWeb + ? props.portalPositionOnWeb + : props.fixedPositionOnWeb; + const zIndex = props.shouldPortal ? props.portalZ : 998; + const edge = Platform.OS === 'web' ? 0 : (props.shouldPortal ? 0 : -1000); + + const fullScreenStyle = [ + StyleSheet.absoluteFill, + { + position, + top: position === 'absolute' ? 0 : edge, + left: position === 'absolute' ? 0 : edge, + right: position === 'absolute' ? 0 : edge, + bottom: position === 'absolute' ? 0 : edge, + opacity: props.portalOpacity, + zIndex, + } as const, + ]; + + const spotlightPadding = (() => { + if (!props.backdropSpotlight) return 0; + if (props.backdropSpotlight === true) return 8; + const candidate = props.backdropSpotlight.padding; + return typeof candidate === 'number' ? candidate : 8; + })(); + + const spotlightStyles = (() => { + if (!props.shouldPortal) return null; + if (!props.anchorRect) return null; + if (!props.backdropSpotlight) return null; + + const offsetX = position === 'absolute' ? props.webPortalOffsetX : 0; + const offsetY = position === 'absolute' ? props.webPortalOffsetY : 0; + + const left = Math.max(0, Math.floor(props.anchorRect.x - spotlightPadding - offsetX)); + const top = Math.max(0, Math.floor(props.anchorRect.y - spotlightPadding - offsetY)); + const right = Math.min( + props.windowWidth, + Math.ceil(props.anchorRect.x + props.anchorRect.width + spotlightPadding - offsetX), + ); + const bottom = Math.min( + props.windowHeight, + Math.ceil(props.anchorRect.y + props.anchorRect.height + spotlightPadding - offsetY), + ); + + const holeHeight = Math.max(0, bottom - top); + + const base: ViewStyle = { + position, + opacity: props.portalOpacity, + zIndex, + }; + + return [ + // top + [{ ...base, top: 0, left: 0, right: 0, height: top }], + // bottom + [{ ...base, top: bottom, left: 0, right: 0, bottom: 0 }], + // left + [{ ...base, top, left: 0, width: left, height: holeHeight }], + // right + [{ ...base, top, left: right, right: 0, height: holeHeight }], + ] as const; + })(); + + const effectStyles = spotlightStyles ?? [fullScreenStyle]; + + if (props.backdropEffect === 'blur') { + const webBlurPx = typeof props.backdropBlurOnWeb?.px === 'number' ? props.backdropBlurOnWeb.px : 12; + const webBlurTint = props.backdropBlurOnWeb?.tintColor ?? 'rgba(0,0,0,0.10)'; + if (Platform.OS !== 'web') { + try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { BlurView } = require('expo-blur'); + if (BlurView) { + return ( + <> + {effectStyles.map((style, index) => ( + // eslint-disable-next-line react/no-array-index-key + <BlurView + key={index} + testID="popover-backdrop-effect" + intensity={Platform.OS === 'ios' ? 12 : 3} + tint="default" + pointerEvents="none" + style={style} + /> + ))} + </> + ); + } + } catch { + // fall through to dim fallback + } + } + + return ( + <> + {effectStyles.map((style, index) => ( + <View + // eslint-disable-next-line react/no-array-index-key + key={index} + testID="popover-backdrop-effect" + pointerEvents="none" + style={[ + style, + Platform.OS === 'web' + ? ({ backdropFilter: `blur(${webBlurPx}px)`, backgroundColor: webBlurTint } as any) + : ({ backgroundColor: 'rgba(0,0,0,0.08)' } as any), + ]} + /> + ))} + </> + ); + } + + return ( + <> + {effectStyles.map((style, index) => ( + <View + // eslint-disable-next-line react/no-array-index-key + key={index} + testID="popover-backdrop-effect" + pointerEvents="none" + style={[ + style, + { backgroundColor: 'rgba(0,0,0,0.08)' }, + ]} + /> + ))} + </> + ); +} + diff --git a/expo-app/sources/components/ui/popover/index.ts b/expo-app/sources/components/ui/popover/index.ts new file mode 100644 index 000000000..2be016867 --- /dev/null +++ b/expo-app/sources/components/ui/popover/index.ts @@ -0,0 +1,8 @@ +export * from './Popover'; +export * from './PopoverBoundary'; +export * from './PopoverPortalTarget'; +export * from './PopoverPortalTargetProvider'; +export * from './OverlayPortal'; + +export type { PopoverPortalTargetState } from './PopoverPortalTarget'; +export { usePopoverPortalTarget } from './PopoverPortalTarget'; diff --git a/expo-app/sources/components/ui/popover/measure.ts b/expo-app/sources/components/ui/popover/measure.ts new file mode 100644 index 000000000..375d5abdd --- /dev/null +++ b/expo-app/sources/components/ui/popover/measure.ts @@ -0,0 +1,98 @@ +import { Platform } from 'react-native'; +import type { PopoverWindowRect } from './_types'; + +export function measureInWindow(node: any): Promise<PopoverWindowRect | null> { + return new Promise(resolve => { + try { + if (!node) return resolve(null); + + const measureDomRect = (candidate: any): PopoverWindowRect | null => { + const el: any = + typeof candidate?.getBoundingClientRect === 'function' + ? candidate + : candidate?.getScrollableNode?.(); + if (!el || typeof el.getBoundingClientRect !== 'function') return null; + const rect = el.getBoundingClientRect(); + const x = rect?.left ?? rect?.x; + const y = rect?.top ?? rect?.y; + const width = rect?.width; + const height = rect?.height; + if (![x, y, width, height].every(n => Number.isFinite(n))) return null; + // Treat 0x0 rects as invalid: on iOS (and occasionally RN-web), refs can report 0x0 + // for a frame while layout settles. Using these values causes menus to overlap the + // trigger and prevents subsequent recomputes from correcting placement. + if (width <= 0 || height <= 0) return null; + return { x, y, width, height }; + }; + + // On web, prefer DOM measurement. It's synchronous and avoids cases where + // RN-web's `measureInWindow` returns invalid values or never calls back. + if (Platform.OS === 'web') { + const rect = measureDomRect(node); + if (rect) return resolve(rect); + } + + // On native, `measure` can provide pageX/pageY values that are sometimes more reliable + // than `measureInWindow` when using react-native-screens (modal/drawer presentations). + // Prefer it when available. + if (Platform.OS !== 'web' && typeof node.measure === 'function') { + node.measure((x: number, y: number, width: number, height: number, pageX: number, pageY: number) => { + if (![pageX, pageY, width, height].every(n => Number.isFinite(n)) || width <= 0 || height <= 0) { + return resolve(null); + } + resolve({ x: pageX, y: pageY, width, height }); + }); + return; + } + + if (typeof node.measureInWindow === 'function') { + node.measureInWindow((x: number, y: number, width: number, height: number) => { + if (![x, y, width, height].every(n => Number.isFinite(n)) || width <= 0 || height <= 0) { + if (Platform.OS === 'web') { + const rect = measureDomRect(node); + if (rect) return resolve(rect); + } + return resolve(null); + } + resolve({ x, y, width, height }); + }); + return; + } + + if (Platform.OS === 'web') return resolve(measureDomRect(node)); + + resolve(null); + } catch { + resolve(null); + } + }); +} + +export function measureLayoutRelativeTo(node: any, relativeToNode: any): Promise<PopoverWindowRect | null> { + return new Promise(resolve => { + try { + if (!node || !relativeToNode) return resolve(null); + if (typeof node.measureLayout !== 'function') return resolve(null); + node.measureLayout( + relativeToNode, + (x: number, y: number, width: number, height: number) => { + if (![x, y, width, height].every(n => Number.isFinite(n)) || width <= 0 || height <= 0) { + resolve(null); + return; + } + resolve({ x, y, width, height }); + }, + () => resolve(null), + ); + } catch { + resolve(null); + } + }); +} + +export function getFallbackBoundaryRect(params: { windowWidth: number; windowHeight: number }): PopoverWindowRect { + // On native, the "window" coordinate space is the best available fallback. + // On web, this maps closely to the viewport (measureInWindow is viewport-relative). + return { x: 0, y: 0, width: params.windowWidth, height: params.windowHeight }; +} + diff --git a/expo-app/sources/components/ui/popover/portal.tsx b/expo-app/sources/components/ui/popover/portal.tsx new file mode 100644 index 000000000..905c661c4 --- /dev/null +++ b/expo-app/sources/components/ui/popover/portal.tsx @@ -0,0 +1,62 @@ +import * as React from 'react'; +import { Platform } from 'react-native'; + +import { requireReactDOM } from '@/utils/web/reactDomCjs'; + +type OverlayPortalDispatch = Readonly<{ + setPortalNode: (id: string, node: React.ReactNode) => void; + removePortalNode: (id: string) => void; +}>; + +export function useNativeOverlayPortalNode(params: Readonly<{ + overlayPortal: OverlayPortalDispatch | null; + portalId: string; + enabled: boolean; + content: React.ReactNode | null; +}>) { + const { overlayPortal, portalId, enabled, content } = params; + + React.useLayoutEffect(() => { + if (!overlayPortal) return; + if (!enabled || !content) { + overlayPortal.removePortalNode(portalId); + return; + } + overlayPortal.setPortalNode(portalId, content); + return () => { + overlayPortal.removePortalNode(portalId); + }; + }, [content, enabled, overlayPortal, portalId]); +} + +export function tryRenderWebPortal(params: Readonly<{ + shouldPortalWeb: boolean; + portalTargetOnWeb: 'body' | 'boundary' | 'modal'; + modalPortalTarget: HTMLElement | null; + getBoundaryDomElement: () => HTMLElement | null; + content: React.ReactNode; +}>): React.ReactNode | null { + if (!params.shouldPortalWeb) return null; + if (Platform.OS !== 'web') return null; + + try { + const ReactDOM = requireReactDOM(); + const boundaryEl = params.getBoundaryDomElement(); + const targetRequested = + params.portalTargetOnWeb === 'modal' + ? params.modalPortalTarget + : params.portalTargetOnWeb === 'boundary' + ? boundaryEl + : (typeof document !== 'undefined' ? document.body : null); + + const target = targetRequested ?? (typeof document !== 'undefined' ? document.body : null); + if (target && ReactDOM?.createPortal) { + return ReactDOM.createPortal(params.content, target); + } + } catch { + // fall back to inline render + } + + return null; +} + diff --git a/expo-app/sources/components/ui/popover/positioning.ts b/expo-app/sources/components/ui/popover/positioning.ts new file mode 100644 index 000000000..12d7875e6 --- /dev/null +++ b/expo-app/sources/components/ui/popover/positioning.ts @@ -0,0 +1,12 @@ +import type { PopoverPlacement, ResolvedPopoverPlacement } from './_types'; + +export function resolvePlacement(params: { + placement: PopoverPlacement; + available: Record<ResolvedPopoverPlacement, number>; +}): ResolvedPopoverPlacement { + if (params.placement !== 'auto') return params.placement; + const entries = Object.entries(params.available) as Array<[ResolvedPopoverPlacement, number]>; + entries.sort((a, b) => b[1] - a[1]); + return entries[0]?.[0] ?? 'top'; +} + diff --git a/expo-app/sources/components/ui/scroll/ScrollEdgeFades.tsx b/expo-app/sources/components/ui/scroll/ScrollEdgeFades.tsx new file mode 100644 index 000000000..2795162c2 --- /dev/null +++ b/expo-app/sources/components/ui/scroll/ScrollEdgeFades.tsx @@ -0,0 +1,135 @@ +import * as React from 'react'; +import { View, type ViewStyle } from 'react-native'; +import { LinearGradient } from 'expo-linear-gradient'; +import Color from 'color'; + +export type ScrollEdgeFadeVisibility = Readonly<{ + top?: boolean; + bottom?: boolean; + left?: boolean; + right?: boolean; +}>; + +export function ScrollEdgeFades(props: { + color: string; + size?: number; + edges: ScrollEdgeFadeVisibility; + topStyle?: ViewStyle; + bottomStyle?: ViewStyle; + leftStyle?: ViewStyle; + rightStyle?: ViewStyle; +}) { + const size = typeof props.size === 'number' ? props.size : 18; + const edges = props.edges; + + const transparent = React.useMemo(() => { + try { + return Color(props.color).alpha(0).rgb().string(); + } catch { + return 'transparent'; + } + }, [props.color]); + + if (!edges.top && !edges.bottom && !edges.left && !edges.right) return null; + + return ( + <> + {edges.top ? ( + <View + style={[ + { + position: 'absolute', + left: 0, + right: 0, + top: 0, + height: size, + zIndex: 10, + pointerEvents: 'none', + }, + props.topStyle, + ]} + > + <LinearGradient + colors={[props.color, transparent]} + start={{ x: 0.5, y: 0 }} + end={{ x: 0.5, y: 1 }} + style={{ height: '100%', width: '100%', pointerEvents: 'none' }} + /> + </View> + ) : null} + + {edges.bottom ? ( + <View + style={[ + { + position: 'absolute', + left: 0, + right: 0, + bottom: 0, + height: size, + zIndex: 10, + pointerEvents: 'none', + }, + props.bottomStyle, + ]} + > + <LinearGradient + colors={[transparent, props.color]} + start={{ x: 0.5, y: 0 }} + end={{ x: 0.5, y: 1 }} + style={{ height: '100%', width: '100%', pointerEvents: 'none' }} + /> + </View> + ) : null} + + {edges.left ? ( + <View + style={[ + { + position: 'absolute', + left: 0, + top: 0, + bottom: 0, + width: size, + zIndex: 10, + pointerEvents: 'none', + }, + props.leftStyle, + ]} + > + <LinearGradient + colors={[props.color, transparent]} + start={{ x: 0, y: 0.5 }} + end={{ x: 1, y: 0.5 }} + style={{ height: '100%', width: '100%', pointerEvents: 'none' }} + /> + </View> + ) : null} + + {edges.right ? ( + <View + style={[ + { + position: 'absolute', + right: 0, + top: 0, + bottom: 0, + width: size, + zIndex: 10, + pointerEvents: 'none', + }, + props.rightStyle, + ]} + > + <LinearGradient + colors={[transparent, props.color]} + start={{ x: 0, y: 0.5 }} + end={{ x: 1, y: 0.5 }} + style={{ height: '100%', width: '100%', pointerEvents: 'none' }} + /> + </View> + ) : null} + </> + ); +} + diff --git a/expo-app/sources/components/ui/scroll/ScrollEdgeIndicators.tsx b/expo-app/sources/components/ui/scroll/ScrollEdgeIndicators.tsx new file mode 100644 index 000000000..93fb73c0a --- /dev/null +++ b/expo-app/sources/components/ui/scroll/ScrollEdgeIndicators.tsx @@ -0,0 +1,116 @@ +import * as React from 'react'; +import { View, type ViewStyle } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; + +export type ScrollEdgeIndicatorVisibility = Readonly<{ + top?: boolean; + bottom?: boolean; + left?: boolean; + right?: boolean; +}>; + +export function ScrollEdgeIndicators(props: { + edges: ScrollEdgeIndicatorVisibility; + color: string; + size?: number; + opacity?: number; + topStyle?: ViewStyle; + bottomStyle?: ViewStyle; + leftStyle?: ViewStyle; + rightStyle?: ViewStyle; +}) { + const edges = props.edges; + const size = typeof props.size === 'number' ? props.size : 14; + const opacity = typeof props.opacity === 'number' ? props.opacity : 0.35; + + if (!edges.top && !edges.bottom && !edges.left && !edges.right) return null; + + return ( + <> + {edges.top ? ( + <View + style={[ + { + position: 'absolute', + top: 6, + left: 0, + right: 0, + alignItems: 'center', + zIndex: 11, + opacity, + pointerEvents: 'none', + }, + props.topStyle, + ]} + > + <Ionicons name="chevron-up" size={size} color={props.color} /> + </View> + ) : null} + + {edges.bottom ? ( + <View + style={[ + { + position: 'absolute', + bottom: 6, + left: 0, + right: 0, + alignItems: 'center', + zIndex: 11, + opacity, + pointerEvents: 'none', + }, + props.bottomStyle, + ]} + > + <Ionicons name="chevron-down" size={size} color={props.color} /> + </View> + ) : null} + + {edges.left ? ( + <View + style={[ + { + position: 'absolute', + left: 0, + top: 0, + bottom: 0, + justifyContent: 'center', + zIndex: 11, + opacity, + pointerEvents: 'none', + }, + props.leftStyle, + ]} + > + <View style={{ width: '100%', alignItems: 'center' }}> + <Ionicons name="chevron-back" size={size} color={props.color} /> + </View> + </View> + ) : null} + + {edges.right ? ( + <View + style={[ + { + position: 'absolute', + right: 0, + top: 0, + bottom: 0, + justifyContent: 'center', + zIndex: 11, + opacity, + pointerEvents: 'none', + }, + props.rightStyle, + ]} + > + <View style={{ width: '100%', alignItems: 'center' }}> + <Ionicons name="chevron-forward" size={size} color={props.color} /> + </View> + </View> + ) : null} + </> + ); +} + diff --git a/expo-app/sources/components/ui/scroll/useScrollEdgeFades.ts b/expo-app/sources/components/ui/scroll/useScrollEdgeFades.ts new file mode 100644 index 000000000..ba9c2744f --- /dev/null +++ b/expo-app/sources/components/ui/scroll/useScrollEdgeFades.ts @@ -0,0 +1,143 @@ +import * as React from 'react'; + +export type ScrollEdge = 'top' | 'bottom' | 'left' | 'right'; + +export type ScrollEdgeVisibility = Readonly<{ + top: boolean; + bottom: boolean; + left: boolean; + right: boolean; +}>; + +export type UseScrollEdgeFadesParams = Readonly<{ + enabledEdges: Partial<Record<ScrollEdge, boolean>>; + /** + * Minimum overflow (content - viewport) before we consider scrolling possible. + * Helps avoid flicker from 0-1px rounding differences. + */ + overflowThreshold?: number; + /** + * Distance from the edge before we show the fade (px). + */ + edgeThreshold?: number; +}>; + +type Size = Readonly<{ width: number; height: number }>; +type Offset = Readonly<{ x: number; y: number }>; + +const defaultVisibility: ScrollEdgeVisibility = Object.freeze({ + top: false, + bottom: false, + left: false, + right: false, +}); + +export function useScrollEdgeFades(params: UseScrollEdgeFadesParams) { + const overflowThreshold = params.overflowThreshold ?? 1; + const edgeThreshold = params.edgeThreshold ?? 1; + + const enabled = React.useMemo(() => { + return { + top: Boolean(params.enabledEdges.top), + bottom: Boolean(params.enabledEdges.bottom), + left: Boolean(params.enabledEdges.left), + right: Boolean(params.enabledEdges.right), + }; + }, [params.enabledEdges.bottom, params.enabledEdges.left, params.enabledEdges.right, params.enabledEdges.top]); + + const viewportRef = React.useRef<Size>({ width: 0, height: 0 }); + const contentRef = React.useRef<Size>({ width: 0, height: 0 }); + const offsetRef = React.useRef<Offset>({ x: 0, y: 0 }); + + const [canScroll, setCanScroll] = React.useState(() => ({ x: false, y: false })); + + const visibilityRef = React.useRef<ScrollEdgeVisibility>(defaultVisibility); + const [visibility, setVisibility] = React.useState<ScrollEdgeVisibility>(defaultVisibility); + + const recompute = React.useCallback(() => { + const viewport = viewportRef.current; + const content = contentRef.current; + const offset = offsetRef.current; + + const canScrollX = content.width > viewport.width + overflowThreshold; + const canScrollY = content.height > viewport.height + overflowThreshold; + + const top = enabled.top && canScrollY && offset.y > edgeThreshold; + const bottom = + enabled.bottom && + canScrollY && + (offset.y + viewport.height) < (content.height - edgeThreshold); + + const left = enabled.left && canScrollX && offset.x > edgeThreshold; + const right = + enabled.right && + canScrollX && + (offset.x + viewport.width) < (content.width - edgeThreshold); + + const nextVisibility: ScrollEdgeVisibility = { top, bottom, left, right }; + + const prevVisibility = visibilityRef.current; + if ( + prevVisibility.top !== nextVisibility.top || + prevVisibility.bottom !== nextVisibility.bottom || + prevVisibility.left !== nextVisibility.left || + prevVisibility.right !== nextVisibility.right + ) { + visibilityRef.current = nextVisibility; + setVisibility(nextVisibility); + } + + setCanScroll(prev => { + if (prev.x === canScrollX && prev.y === canScrollY) return prev; + return { x: canScrollX, y: canScrollY }; + }); + }, [edgeThreshold, enabled.bottom, enabled.left, enabled.right, enabled.top, overflowThreshold]); + + const onViewportLayout = React.useCallback((e: any) => { + const width = e?.nativeEvent?.layout?.width ?? 0; + const height = e?.nativeEvent?.layout?.height ?? 0; + viewportRef.current = { width, height }; + recompute(); + }, [recompute]); + + const onContentSizeChange = React.useCallback((width: number, height: number) => { + contentRef.current = { width, height }; + recompute(); + }, [recompute]); + + const onScroll = React.useCallback((e: any) => { + const ne = e?.nativeEvent; + if (!ne) return; + + const x = ne.contentOffset?.x ?? 0; + const y = ne.contentOffset?.y ?? 0; + + // Prefer event-provided sizes (more accurate during momentum scroll), + // but keep refs updated too. + const vw = ne.layoutMeasurement?.width; + const vh = ne.layoutMeasurement?.height; + const cw = ne.contentSize?.width; + const ch = ne.contentSize?.height; + + offsetRef.current = { x, y }; + + if (typeof vw === 'number' && typeof vh === 'number') { + viewportRef.current = { width: vw, height: vh }; + } + if (typeof cw === 'number' && typeof ch === 'number') { + contentRef.current = { width: cw, height: ch }; + } + + recompute(); + }, [recompute]); + + return { + canScrollX: canScroll.x, + canScrollY: canScroll.y, + visibility, + onViewportLayout, + onContentSizeChange, + onScroll, + } as const; +} + diff --git a/expo-app/sources/components/usage/UsageChart.tsx b/expo-app/sources/components/usage/UsageChart.tsx index ac2c4713e..b63727e58 100644 --- a/expo-app/sources/components/usage/UsageChart.tsx +++ b/expo-app/sources/components/usage/UsageChart.tsx @@ -3,6 +3,7 @@ import { View, ScrollView, Pressable } from 'react-native'; import { Text } from '@/components/StyledText'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { UsageDataPoint } from '@/sync/apiUsage'; +import { t } from '@/text'; interface UsageChartProps { data: UsageDataPoint[]; @@ -68,7 +69,7 @@ export const UsageChart: React.FC<UsageChartProps> = ({ if (!data || data.length === 0) { return ( <View style={styles.emptyState}> - <Text style={styles.emptyText}>No usage data available</Text> + <Text style={styles.emptyText}>{t('usage.noData')}</Text> </View> ); } @@ -161,4 +162,4 @@ export const UsageChart: React.FC<UsageChartProps> = ({ </ScrollView> </View> ); -}; \ No newline at end of file +}; diff --git a/expo-app/sources/components/usage/UsagePanel.tsx b/expo-app/sources/components/usage/UsagePanel.tsx index f5e038136..3bccfe9c0 100644 --- a/expo-app/sources/components/usage/UsagePanel.tsx +++ b/expo-app/sources/components/usage/UsagePanel.tsx @@ -3,8 +3,8 @@ import { View, ActivityIndicator, ScrollView, Pressable } from 'react-native'; import { Text } from '@/components/StyledText'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { useAuth } from '@/auth/AuthContext'; -import { Item } from '@/components/Item'; -import { ItemGroup } from '@/components/ItemGroup'; +import { Item } from '@/components/ui/lists/Item'; +import { ItemGroup } from '@/components/ui/lists/ItemGroup'; import { UsageChart } from './UsageChart'; import { UsageBar } from './UsageBar'; import { getUsageForPeriod, calculateTotals, UsageDataPoint } from '@/sync/apiUsage'; diff --git a/expo-app/sources/constants/PermissionModes.ts b/expo-app/sources/constants/PermissionModes.ts new file mode 100644 index 000000000..b45e0b326 --- /dev/null +++ b/expo-app/sources/constants/PermissionModes.ts @@ -0,0 +1,12 @@ +export const PERMISSION_MODES = [ + 'default', + 'acceptEdits', + 'bypassPermissions', + 'plan', + 'read-only', + 'safe-yolo', + 'yolo', +] as const; + +export type PermissionMode = (typeof PERMISSION_MODES)[number]; + diff --git a/expo-app/sources/dev/expoLocalizationStub.ts b/expo-app/sources/dev/expoLocalizationStub.ts new file mode 100644 index 000000000..592eeba23 --- /dev/null +++ b/expo-app/sources/dev/expoLocalizationStub.ts @@ -0,0 +1,12 @@ +// Vitest runs in a Node environment; `expo-localization` depends on Expo modules that are not present. +// This stub provides the minimal surface needed by `sources/text/index.ts`. + +export type Locale = { + languageCode?: string | null; + languageScriptCode?: string | null; +}; + +export function getLocales(): Locale[] { + return [{ languageCode: 'en', languageScriptCode: null }]; +} + diff --git a/expo-app/sources/dev/expoModulesCoreStub.ts b/expo-app/sources/dev/expoModulesCoreStub.ts new file mode 100644 index 000000000..0805e9f9f --- /dev/null +++ b/expo-app/sources/dev/expoModulesCoreStub.ts @@ -0,0 +1,22 @@ +// Vitest runs in a Node environment; `expo-modules-core` is designed for Expo/Metro and +// imports `react-native` (Flow) via its TS source entrypoint. For unit tests we only need +// a minimal subset of the surface area used by other Expo packages (e.g. `expo-localization`). + +export const Platform = { + // Match the shape used by `expo-localization` on web/Node. + isDOMAvailable: typeof window !== 'undefined' && typeof document !== 'undefined', + OS: 'node', + select: <T,>(specifics: Record<string, T> & { default?: T }) => + (specifics as any).node ?? (specifics as any).default, +} as const; + +// Expo modules use this to access native modules (which don't exist in Vitest/node). +export function requireOptionalNativeModule() { + return null; +} + +export function requireNativeModule(moduleName: string): never { + // Return a dummy module so packages can be imported in Vitest without exploding at import-time. + // Tests that actually rely on native behavior should mock the specific module. + return {} as never; +} diff --git a/expo-app/sources/dev/reactNativeStub.ts b/expo-app/sources/dev/reactNativeStub.ts new file mode 100644 index 000000000..00b1a2d7f --- /dev/null +++ b/expo-app/sources/dev/reactNativeStub.ts @@ -0,0 +1,27 @@ +// Vitest/node stub for `react-native`. +// This avoids Vite trying to parse the real React Native entrypoint (Flow syntax). + +// Provide basic host components so tests that rely on `react-test-renderer` can render trees +// without having to mock `react-native` in every file. +export const View = 'View' as any; +export const Text = 'Text' as any; +export const ScrollView = 'ScrollView' as any; +export const Pressable = 'Pressable' as any; +export const TextInput = 'TextInput' as any; +export const ActivityIndicator = 'ActivityIndicator' as any; + +export const Dimensions = { + get: () => ({ width: 800, height: 600, scale: 2, fontScale: 1 }), +} as const; + +export const Platform = { OS: 'node', select: (x: any) => x?.default } as const; +export const AppState = { addEventListener: () => ({ remove: () => {} }) } as const; +export const InteractionManager = { runAfterInteractions: (fn: () => void) => fn() } as const; + +export function useWindowDimensions() { + return { width: 800, height: 600 }; +} + +export function processColor(value: any) { + return value as any; +} diff --git a/expo-app/sources/dev/stackScreenInlineOptions.test.ts b/expo-app/sources/dev/stackScreenInlineOptions.test.ts new file mode 100644 index 000000000..0663044a4 --- /dev/null +++ b/expo-app/sources/dev/stackScreenInlineOptions.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest'; + +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { join, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; + +function walkFiles(rootDir: string): string[] { + const results: string[] = []; + const stack: string[] = [rootDir]; + + while (stack.length > 0) { + const currentDir = stack.pop(); + if (!currentDir) continue; + + for (const entry of readdirSync(currentDir)) { + const fullPath = join(currentDir, entry); + const stat = statSync(fullPath); + + if (stat.isDirectory()) { + stack.push(fullPath); + continue; + } + + if (fullPath.endsWith('.ts') || fullPath.endsWith('.tsx')) { + results.push(fullPath); + } + } + } + + return results; +} + +function isStackScreenJsx(tagName: ts.JsxTagNameExpression): boolean { + if (!ts.isPropertyAccessExpression(tagName)) return false; + if (!ts.isIdentifier(tagName.expression)) return false; + return tagName.expression.text === 'Stack' && tagName.name.text === 'Screen'; +} + +describe('Stack.Screen options invariants', () => { + it('does not pass an inline object literal to <Stack.Screen options={...}> in app/(app) screens', () => { + const testDir = fileURLToPath(new URL('.', import.meta.url)); + const sourcesDir = join(testDir, '..'); // sources/ + const appDir = join(sourcesDir, 'app', '(app)'); + + const excludedFiles = new Set<string>([ + join(appDir, '_layout.tsx'), + ]); + + const offenders: Array<{ file: string; line: number }> = []; + + for (const file of walkFiles(appDir)) { + if (excludedFiles.has(file)) continue; + const content = readFileSync(file, 'utf8'); + if (!content.includes('Stack.Screen') || !content.includes('options')) continue; + + const sourceFile = ts.createSourceFile( + file, + content, + ts.ScriptTarget.Latest, + true, + file.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS + ); + + const visit = (node: ts.Node) => { + if (ts.isJsxSelfClosingElement(node) || ts.isJsxOpeningElement(node)) { + if (isStackScreenJsx(node.tagName)) { + for (const prop of node.attributes.properties) { + if (!ts.isJsxAttribute(prop)) continue; + if (prop.name.getText(sourceFile) !== 'options') continue; + + const init = prop.initializer; + if (!init || !ts.isJsxExpression(init) || !init.expression) continue; + if (ts.isObjectLiteralExpression(init.expression)) { + const { line } = ts.getLineAndCharacterOfPosition(sourceFile, prop.getStart(sourceFile)); + offenders.push({ file, line: line + 1 }); + } + } + } + } + + ts.forEachChild(node, visit); + }; + + visit(sourceFile); + } + + expect(offenders.map(({ file, line }) => `${relative(appDir, file)}:${line}`)).toEqual([]); + }); +}); diff --git a/expo-app/sources/dev/unistylesStyleSheetImports.test.ts b/expo-app/sources/dev/unistylesStyleSheetImports.test.ts new file mode 100644 index 000000000..da31e3446 --- /dev/null +++ b/expo-app/sources/dev/unistylesStyleSheetImports.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest'; + +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { join, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; + +function walkFiles(rootDir: string): string[] { + const results: string[] = []; + const stack: string[] = [rootDir]; + + while (stack.length > 0) { + const currentDir = stack.pop(); + if (!currentDir) { + continue; + } + + for (const entry of readdirSync(currentDir)) { + const fullPath = join(currentDir, entry); + const stat = statSync(fullPath); + + if (stat.isDirectory()) { + stack.push(fullPath); + continue; + } + + if (fullPath.endsWith('.ts') || fullPath.endsWith('.tsx')) { + results.push(fullPath); + } + } + } + + return results; +} + +describe('Unistyles StyleSheet import invariants', () => { + it('does not import StyleSheet from react-native inside sources/', () => { + const testDir = fileURLToPath(new URL('.', import.meta.url)); + const sourcesDir = join(testDir, '..'); // sources/ + + const excludedPrefixes = [ + join(sourcesDir, 'dev') + '/', + join(sourcesDir, 'sync', '__testdata__') + '/', + ]; + + const offenders: Array<{ file: string; line: number }> = []; + + for (const file of walkFiles(sourcesDir)) { + const normalized = file.replaceAll('\\', '/'); + if (excludedPrefixes.some((prefix) => normalized.startsWith(prefix.replaceAll('\\', '/')))) { + continue; + } + + const content = readFileSync(file, 'utf8'); + if (!content.includes('StyleSheet') || !content.includes('react-native')) { + continue; + } + + const sourceFile = ts.createSourceFile( + file, + content, + ts.ScriptTarget.Latest, + true, + file.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS + ); + + for (const statement of sourceFile.statements) { + if (!ts.isImportDeclaration(statement)) { + continue; + } + + if (!ts.isStringLiteral(statement.moduleSpecifier) || statement.moduleSpecifier.text !== 'react-native') { + continue; + } + + const namedBindings = statement.importClause?.namedBindings; + if (!namedBindings || !ts.isNamedImports(namedBindings)) { + continue; + } + + const hasStyleSheet = namedBindings.elements.some((specifier) => { + const importedName = specifier.propertyName?.text ?? specifier.name.text; + return importedName === 'StyleSheet'; + }); + + if (!hasStyleSheet) { + continue; + } + + const { line } = ts.getLineAndCharacterOfPosition(sourceFile, statement.getStart(sourceFile)); + offenders.push({ file, line: line + 1 }); + } + } + + expect( + offenders.map(({ file, line }) => `${relative(sourcesDir, file)}:${line}`) + ).toEqual([]); + }); +}); diff --git a/expo-app/sources/dev/vitestSetup.ts b/expo-app/sources/dev/vitestSetup.ts new file mode 100644 index 000000000..9386aab01 --- /dev/null +++ b/expo-app/sources/dev/vitestSetup.ts @@ -0,0 +1,32 @@ +import { beforeEach, vi } from 'vitest'; + +// Vitest runs in Node; `react-native-mmkv` depends on React Native internals and can fail to parse. +// Provide a minimal in-memory implementation for tests. +const store = new Map<string, string>(); + +beforeEach(() => { + store.clear(); +}); + +vi.mock('react-native-mmkv', () => { + class MMKV { + getString(key: string) { + return store.get(key); + } + + set(key: string, value: string) { + store.set(key, value); + } + + delete(key: string) { + store.delete(key); + } + + clearAll() { + store.clear(); + } + } + + return { MMKV }; +}); + diff --git a/expo-app/sources/encryption/aes.appspec.ts b/expo-app/sources/encryption/aes.appspec.ts index 46853e9cd..b6f6a18c7 100644 --- a/expo-app/sources/encryption/aes.appspec.ts +++ b/expo-app/sources/encryption/aes.appspec.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from '@/dev/testRunner'; import { decryptAESGCM, decryptAESGCMString, encryptAESGCM, encryptAESGCMString } from './aes'; -import { getRandomBytes } from 'expo-crypto'; +import { getRandomBytes } from '@/platform/cryptoRandom'; import { encodeBase64 } from '@/encryption/base64'; describe('AES Tests', () => { diff --git a/expo-app/sources/encryption/base64.appspec.ts b/expo-app/sources/encryption/base64.appspec.ts index af6e8d3a4..d8c7e0c46 100644 --- a/expo-app/sources/encryption/base64.appspec.ts +++ b/expo-app/sources/encryption/base64.appspec.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from '@/dev/testRunner'; import { encodeBase64, decodeBase64 } from './base64'; -import { getRandomBytes } from 'expo-crypto'; +import { getRandomBytes } from '@/platform/cryptoRandom'; describe('Base64 Tests', () => { describe('Standard Base64 Encoding/Decoding', () => { diff --git a/expo-app/sources/encryption/hmac_sha512.ts b/expo-app/sources/encryption/hmac_sha512.ts index d7973515d..3d78ce1b2 100644 --- a/expo-app/sources/encryption/hmac_sha512.ts +++ b/expo-app/sources/encryption/hmac_sha512.ts @@ -1,42 +1,11 @@ -import * as Crypto from 'expo-crypto'; +import { hmacSha512 } from '@/platform/hmacSha512'; -export async function hmac_sha512(key: Uint8Array, data: Uint8Array): Promise<Uint8Array> { - const blockSize = 128; // SHA512 block size in bytes - const opad = 0x5c; - const ipad = 0x36; - - // Prepare key - let actualKey = key; - if (key.length > blockSize) { - // If key is longer than block size, hash it - const keyHash = await Crypto.digest(Crypto.CryptoDigestAlgorithm.SHA512, new Uint8Array(key)); - actualKey = new Uint8Array(keyHash); - } - - // Pad key to block size - const paddedKey = new Uint8Array(blockSize); - paddedKey.set(actualKey); - - // Create inner and outer padded keys - const innerKey = new Uint8Array(blockSize); - const outerKey = new Uint8Array(blockSize); - - for (let i = 0; i < blockSize; i++) { - innerKey[i] = paddedKey[i] ^ ipad; - outerKey[i] = paddedKey[i] ^ opad; - } - - // Inner hash: SHA512(innerKey || data) - const innerData = new Uint8Array(blockSize + data.length); - innerData.set(innerKey); - innerData.set(data, blockSize); - const innerHash = await Crypto.digest(Crypto.CryptoDigestAlgorithm.SHA512, innerData); - - // Outer hash: SHA512(outerKey || innerHash) - const outerData = new Uint8Array(blockSize + 64); // 64 bytes for SHA512 hash - outerData.set(outerKey); - outerData.set(new Uint8Array(innerHash), blockSize); - const finalHash = await Crypto.digest(Crypto.CryptoDigestAlgorithm.SHA512, outerData); - - return new Uint8Array(finalHash); +/** + * Compatibility export used by `sources/encryption/deriveKey.ts`. + * + * NOTE: Avoid static imports of platform-only crypto (expo-crypto) here. + * We use platform adapters with `.native/.web/.node` implementations. + */ +export async function hmac_sha512(key: Uint8Array, data: Uint8Array): Promise<Uint8Array> { + return await hmacSha512(key, data); } \ No newline at end of file diff --git a/expo-app/sources/encryption/libsodium.lib.web.ts b/expo-app/sources/encryption/libsodium.lib.web.ts index df16d5e0f..d31eb17b3 100644 --- a/expo-app/sources/encryption/libsodium.lib.web.ts +++ b/expo-app/sources/encryption/libsodium.lib.web.ts @@ -1,2 +1,15 @@ -import sodium from 'libsodium-wrappers'; -export default sodium; \ No newline at end of file +import type sodiumType from 'libsodium-wrappers'; + +// IMPORTANT: +// Metro web bundles are currently executed as classic scripts (not ESM modules). +// Importing `libsodium-wrappers` via its package `exports.import` path pulls in ESM +// builds which (via Expo's Node builtin polyfills) can introduce top-level `await`, +// causing a hard syntax error and a blank page in the web dev server. +// +// Force the CommonJS build on web to avoid top-level-await parsing errors. +// Use require() so TypeScript doesn't need to resolve the deep subpath (monorepo installs +// typically hoist `node_modules` to the workspace root). +// eslint-disable-next-line @typescript-eslint/no-var-requires +const sodium = require('libsodium-wrappers/dist/modules/libsodium-wrappers.js'); + +export default sodium as typeof sodiumType; diff --git a/expo-app/sources/encryption/libsodium.ts b/expo-app/sources/encryption/libsodium.ts index 2ca0372c8..83b17d809 100644 --- a/expo-app/sources/encryption/libsodium.ts +++ b/expo-app/sources/encryption/libsodium.ts @@ -1,5 +1,5 @@ -import { getRandomBytes } from 'expo-crypto'; import sodium from '@/encryption/libsodium.lib'; +import { getRandomBytes } from '@/platform/cryptoRandom'; export function getPublicKeyForBox(secretKey: Uint8Array): Uint8Array { return sodium.crypto_box_seed_keypair(secretKey).publicKey; diff --git a/expo-app/sources/experiments/inboxFriends.test.ts b/expo-app/sources/experiments/inboxFriends.test.ts new file mode 100644 index 000000000..bac68b802 --- /dev/null +++ b/expo-app/sources/experiments/inboxFriends.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest'; +import { isInboxFriendsEnabled } from './inboxFriends'; + +describe('isInboxFriendsEnabled', () => { + it('returns false when experiments master switch is off', () => { + expect(isInboxFriendsEnabled({ experiments: false, expInboxFriends: true })).toBe(false); + expect(isInboxFriendsEnabled({ experiments: false, expInboxFriends: false })).toBe(false); + }); + + it('returns false when inbox/friends toggle is off', () => { + expect(isInboxFriendsEnabled({ experiments: true, expInboxFriends: false })).toBe(false); + }); + + it('returns true when both toggles are on', () => { + expect(isInboxFriendsEnabled({ experiments: true, expInboxFriends: true })).toBe(true); + }); +}); + diff --git a/expo-app/sources/experiments/inboxFriends.ts b/expo-app/sources/experiments/inboxFriends.ts new file mode 100644 index 000000000..05fc49e41 --- /dev/null +++ b/expo-app/sources/experiments/inboxFriends.ts @@ -0,0 +1,4 @@ +export function isInboxFriendsEnabled(input: { experiments: boolean; expInboxFriends: boolean }): boolean { + return input.experiments === true && input.expInboxFriends === true; +} + diff --git a/expo-app/sources/hooks/envVarUtils.ts b/expo-app/sources/hooks/envVarUtils.ts index 325404655..e839a6b10 100644 --- a/expo-app/sources/hooks/envVarUtils.ts +++ b/expo-app/sources/hooks/envVarUtils.ts @@ -32,23 +32,27 @@ export function resolveEnvVarSubstitution( value: string, daemonEnv: EnvironmentVariables ): string | null { - // Match ${VAR} or ${VAR:-default} or ${VAR:=default} (bash parameter expansion) + // Match ${VAR} or ${VAR:-default} (bash parameter expansion subset). // Group 1: Variable name (required) - // Group 2: Default value (optional) - includes the :- or := prefix - // Group 3: The actual default value without prefix (optional) - const match = value.match(/^\$\{([A-Z_][A-Z0-9_]*)(:-(.*))?(:=(.*))?}$/); + // Group 2: Default value (optional) + const match = value.match(/^\$\{([A-Z_][A-Z0-9_]*)(?::[-=](.*))?\}$/); if (match) { const varName = match[1]; - const defaultValue = match[3] ?? match[5]; // :- default or := default + const defaultValue = match[2]; // :- default const daemonValue = daemonEnv[varName]; - if (daemonValue !== undefined && daemonValue !== null) { + // For ${VAR:-default} and ${VAR:=default}, treat empty string as "missing" (bash semantics). + // For plain ${VAR}, preserve empty string (it is an explicit value). + if (daemonValue !== undefined && daemonValue !== null && daemonValue !== '') { return daemonValue; } // Variable not set - use default if provided if (defaultValue !== undefined) { return defaultValue; } + if (daemonValue === '') { + return ''; + } return null; } // Not a substitution - return literal value @@ -76,9 +80,9 @@ export function extractEnvVarReferences( const refs = new Set<string>(); environmentVariables.forEach(ev => { - // Match ${VAR} or ${VAR:-default} or ${VAR:=default} (bash parameter expansion) + // Match ${VAR}, ${VAR:-default}, or ${VAR:=default} (bash parameter expansion subset). // Only capture the variable name, not the default value - const match = ev.value.match(/^\$\{([A-Z_][A-Z0-9_]*)(:-.*|:=.*)?\}$/); + const match = ev.value.match(/^\$\{([A-Z_][A-Z0-9_]*)(?::[-=].*)?\}$/); if (match) { // Variable name is already validated by regex pattern [A-Z_][A-Z0-9_]* refs.add(match[1]); diff --git a/expo-app/sources/hooks/useCLIDetection.hook.test.ts b/expo-app/sources/hooks/useCLIDetection.hook.test.ts new file mode 100644 index 000000000..06d6411c0 --- /dev/null +++ b/expo-app/sources/hooks/useCLIDetection.hook.test.ts @@ -0,0 +1,151 @@ +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import renderer, { act } from 'react-test-renderer'; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +const useMachineCapabilitiesCacheMock = vi.fn(); + +vi.mock('@/sync/storage', () => { + return { + useMachine: vi.fn(() => ({ id: 'm1', metadata: {} })), + }; +}); + +vi.mock('@/utils/machineUtils', () => { + return { + isMachineOnline: vi.fn(() => true), + }; +}); + +vi.mock('@/hooks/useMachineCapabilitiesCache', () => { + return { + useMachineCapabilitiesCache: (...args: any[]) => useMachineCapabilitiesCacheMock(...args), + }; +}); + +describe('useCLIDetection (hook)', () => { + it('includes tmux availability from capabilities results when present', async () => { + useMachineCapabilitiesCacheMock.mockReturnValue({ + state: { + status: 'loaded', + snapshot: { + response: { + protocolVersion: 1, + results: { + 'cli.claude': { ok: true, checkedAt: 1, data: { available: true } }, + 'cli.codex': { ok: true, checkedAt: 1, data: { available: true } }, + 'cli.gemini': { ok: true, checkedAt: 1, data: { available: true } }, + 'tool.tmux': { ok: true, checkedAt: 1, data: { available: true } }, + }, + }, + }, + }, + refresh: vi.fn(), + }); + + const { useCLIDetection } = await import('./useCLIDetection'); + + let latest: any = null; + function Test() { + latest = useCLIDetection('m1', { autoDetect: false }); + return React.createElement('View'); + } + + act(() => { + renderer.create(React.createElement(Test)); + }); + + expect(latest?.tmux).toBe(true); + }); + + it('treats missing tmux field as unknown (null) for older daemons', async () => { + useMachineCapabilitiesCacheMock.mockReturnValue({ + state: { + status: 'loaded', + snapshot: { + response: { + protocolVersion: 1, + results: { + 'cli.claude': { ok: true, checkedAt: 1, data: { available: true } }, + 'cli.codex': { ok: true, checkedAt: 1, data: { available: true } }, + 'cli.gemini': { ok: true, checkedAt: 1, data: { available: true } }, + }, + }, + }, + }, + refresh: vi.fn(), + }); + + const { useCLIDetection } = await import('./useCLIDetection'); + + let latest: any = null; + function Test() { + latest = useCLIDetection('m1', { autoDetect: false }); + return React.createElement('View'); + } + + act(() => { + renderer.create(React.createElement(Test)); + }); + + expect(latest?.tmux).toBe(null); + }); + + it('keeps timestamp stable when results have no checkedAt values', async () => { + vi.useFakeTimers(); + try { + vi.setSystemTime(1000); + + useMachineCapabilitiesCacheMock.mockReturnValueOnce({ + state: { + status: 'loaded', + snapshot: { + response: { + protocolVersion: 1, + results: {}, + }, + }, + }, + refresh: vi.fn(), + }); + + const { useCLIDetection } = await import('./useCLIDetection'); + + let latest: any = null; + function Test() { + latest = useCLIDetection('m1', { autoDetect: false }); + return React.createElement('View'); + } + + let root: any = null; + act(() => { + root = renderer.create(React.createElement(Test)); + }); + expect(latest?.timestamp).toBe(1000); + + vi.setSystemTime(2000); + + useMachineCapabilitiesCacheMock.mockReturnValueOnce({ + state: { + status: 'loaded', + snapshot: { + response: { + protocolVersion: 1, + results: {}, + }, + }, + }, + refresh: vi.fn(), + }); + + act(() => { + root.update(React.createElement(Test)); + }); + + expect(latest?.timestamp).toBe(1000); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/expo-app/sources/hooks/useCLIDetection.ts b/expo-app/sources/hooks/useCLIDetection.ts index bda5c547b..1ec098b96 100644 --- a/expo-app/sources/hooks/useCLIDetection.ts +++ b/expo-app/sources/hooks/useCLIDetection.ts @@ -1,128 +1,154 @@ -import { useState, useEffect } from 'react'; -import { machineBash } from '@/sync/ops'; +import { useMemo, useRef } from 'react'; +import { useMachine } from '@/sync/storage'; +import { isMachineOnline } from '@/utils/machineUtils'; +import { useMachineCapabilitiesCache } from '@/hooks/useMachineCapabilitiesCache'; +import type { CapabilityDetectResult, CliCapabilityData, TmuxCapabilityData } from '@/sync/capabilitiesProtocol'; +import { AGENT_IDS, type AgentId, getAgentCore } from '@/agents/catalog'; +import { CHECKLIST_IDS } from '@happy/protocol/checklists'; -interface CLIAvailability { - claude: boolean | null; // null = unknown/loading, true = installed, false = not installed - codex: boolean | null; - gemini: boolean | null; +export type CLIAvailability = Readonly<{ + available: Readonly<Record<AgentId, boolean | null>>; // null = unknown/loading, true = installed, false = not installed + login: Readonly<Record<AgentId, boolean | null>>; // null = unknown/unsupported + tmux: boolean | null; isDetecting: boolean; // Explicit loading state timestamp: number; // When detection completed error?: string; // Detection error message (for debugging) +}>; + +export interface UseCLIDetectionOptions { + /** + * When false, the hook will be cache-only (no automatic detection refresh). + */ + autoDetect?: boolean; + /** + * When true, requests login status detection (best-effort; may return null). + */ + includeLoginStatus?: boolean; +} + +function readCliAvailable(result: CapabilityDetectResult | undefined): boolean | null { + if (!result || !result.ok) return null; + const data = result.data as Partial<CliCapabilityData> | undefined; + return typeof data?.available === 'boolean' ? data.available : null; } -/** - * Detects which CLI tools (claude, codex, gemini) are installed on a remote machine. - * - * NON-BLOCKING: Detection runs asynchronously in useEffect. UI shows all profiles - * while detection is in progress, then updates when results arrive. - * - * Detection is automatic when machineId changes. Uses existing machineBash() RPC - * to run `command -v` checks on the remote machine. - * - * CONSERVATIVE FALLBACK: If detection fails (network error, timeout, bash error), - * sets all CLIs to null and timestamp to 0, hiding status from UI. - * User discovers CLI availability when attempting to spawn. - * - * @param machineId - The machine to detect CLIs on (null = no detection) - * @returns CLI availability status for claude, codex, and gemini - * - * @example - * const cliAvailability = useCLIDetection(selectedMachineId); - * if (cliAvailability.claude === false) { - * // Show "Claude CLI not detected" warning - * } - */ -export function useCLIDetection(machineId: string | null): CLIAvailability { - const [availability, setAvailability] = useState<CLIAvailability>({ - claude: null, - codex: null, - gemini: null, - isDetecting: false, - timestamp: 0, +function readCliLogin(result: CapabilityDetectResult | undefined): boolean | null { + if (!result || !result.ok) return null; + const data = result.data as Partial<CliCapabilityData> | undefined; + const v = data?.isLoggedIn; + return typeof v === 'boolean' ? v : null; +} + +function readTmuxAvailable(result: CapabilityDetectResult | undefined): boolean | null { + if (!result || !result.ok) return null; + const data = result.data as Partial<TmuxCapabilityData> | undefined; + return typeof data?.available === 'boolean' ? data.available : null; +} + +export function useCLIDetection(machineId: string | null, options?: UseCLIDetectionOptions): CLIAvailability { + const machine = useMachine(machineId ?? ''); + const isOnline = useMemo(() => { + if (!machineId || !machine) return false; + return isMachineOnline(machine); + }, [machine, machineId]); + + const includeLoginStatus = Boolean(options?.includeLoginStatus); + const request = useMemo(() => { + if (!includeLoginStatus) return { checklistId: CHECKLIST_IDS.NEW_SESSION }; + const overrides: Record<string, { params: { includeLoginStatus: true } }> = {}; + for (const agentId of AGENT_IDS) { + overrides[`cli.${getAgentCore(agentId).cli.detectKey}`] = { params: { includeLoginStatus: true } }; + } + return { + checklistId: CHECKLIST_IDS.NEW_SESSION, + overrides: overrides as any, + }; + }, [includeLoginStatus]); + + const { state: cached } = useMachineCapabilitiesCache({ + machineId, + enabled: isOnline && options?.autoDetect !== false, + request, }); - useEffect(() => { - if (!machineId) { - setAvailability({ claude: null, codex: null, gemini: null, isDetecting: false, timestamp: 0 }); - return; + const lastSuccessfulDetectAtRef = useRef<number>(0); + const fallbackDetectAtRef = useRef<number>(0); + + return useMemo((): CLIAvailability => { + if (!machineId || !isOnline) { + const available: Record<AgentId, boolean | null> = {} as any; + const login: Record<AgentId, boolean | null> = {} as any; + for (const agentId of AGENT_IDS) { + available[agentId] = null; + login[agentId] = null; + } + return { + available, + login, + tmux: null, + isDetecting: false, + timestamp: 0, + }; + } + + const snapshot = + cached.status === 'loaded' + ? cached.snapshot + : cached.status === 'loading' + ? cached.snapshot + : cached.status === 'error' + ? cached.snapshot + : undefined; + + const results = snapshot?.response.results ?? {}; + const resultsById = results as Record<string, CapabilityDetectResult | undefined>; + const now = Date.now(); + const latestCheckedAt = Math.max( + 0, + ...(Object.values(results) + .map((r) => (r && typeof r.checkedAt === 'number' ? r.checkedAt : 0))), + ); + + if (cached.status === 'loaded' && latestCheckedAt > 0) { + lastSuccessfulDetectAtRef.current = latestCheckedAt; + fallbackDetectAtRef.current = 0; + } else if (cached.status === 'loaded' && latestCheckedAt === 0 && lastSuccessfulDetectAtRef.current === 0 && fallbackDetectAtRef.current === 0) { + // Older/broken snapshots could omit checkedAt values; keep a stable "loaded" timestamp + // rather than flapping Date.now() on re-renders. + fallbackDetectAtRef.current = now; } - let cancelled = false; - - const detectCLIs = async () => { - // Set detecting flag (non-blocking - UI stays responsive) - setAvailability(prev => ({ ...prev, isDetecting: true })); - console.log('[useCLIDetection] Starting detection for machineId:', machineId); - - try { - // Use single bash command to check both CLIs efficiently - // command -v is POSIX compliant and more reliable than which - const result = await machineBash( - machineId, - '(command -v claude >/dev/null 2>&1 && echo "claude:true" || echo "claude:false") && ' + - '(command -v codex >/dev/null 2>&1 && echo "codex:true" || echo "codex:false") && ' + - '(command -v gemini >/dev/null 2>&1 && echo "gemini:true" || echo "gemini:false")', - '/' - ); - - if (cancelled) return; - console.log('[useCLIDetection] Result:', { success: result.success, exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr }); - - if (result.success && result.exitCode === 0) { - // Parse output: "claude:true\ncodex:false\ngemini:false" - const lines = result.stdout.trim().split('\n'); - const cliStatus: { claude?: boolean; codex?: boolean; gemini?: boolean } = {}; - - lines.forEach(line => { - const [cli, status] = line.split(':'); - if (cli && status) { - cliStatus[cli.trim() as 'claude' | 'codex' | 'gemini'] = status.trim() === 'true'; - } - }); - - console.log('[useCLIDetection] Parsed CLI status:', cliStatus); - setAvailability({ - claude: cliStatus.claude ?? null, - codex: cliStatus.codex ?? null, - gemini: cliStatus.gemini ?? null, - isDetecting: false, - timestamp: Date.now(), - }); - } else { - // Detection command failed - CONSERVATIVE fallback (don't assume availability) - console.log('[useCLIDetection] Detection failed (success=false or exitCode!=0):', result); - setAvailability({ - claude: null, - codex: null, - gemini: null, - isDetecting: false, - timestamp: 0, - error: `Detection failed: ${result.stderr || 'Unknown error'}`, - }); - } - } catch (error) { - if (cancelled) return; - - // Network/RPC error - CONSERVATIVE fallback (don't assume availability) - console.log('[useCLIDetection] Network/RPC error:', error); - setAvailability({ - claude: null, - codex: null, - gemini: null, - isDetecting: false, - timestamp: 0, - error: error instanceof Error ? error.message : 'Detection error', - }); + if (!snapshot) { + const available: Record<AgentId, boolean | null> = {} as any; + const login: Record<AgentId, boolean | null> = {} as any; + for (const agentId of AGENT_IDS) { + available[agentId] = null; + login[agentId] = null; } - }; + return { + available, + login, + tmux: null, + isDetecting: cached.status === 'loading', + timestamp: 0, + ...(cached.status === 'error' ? { error: 'Detection error' } : {}), + }; + } - detectCLIs(); + const available: Record<AgentId, boolean | null> = {} as any; + const login: Record<AgentId, boolean | null> = {} as any; + for (const agentId of AGENT_IDS) { + const capId = `cli.${getAgentCore(agentId).cli.detectKey}`; + available[agentId] = readCliAvailable(resultsById[capId]); + login[agentId] = includeLoginStatus ? readCliLogin(resultsById[capId]) : null; + } - // Cleanup: Cancel detection if component unmounts or machineId changes - return () => { - cancelled = true; + return { + available, + login, + tmux: readTmuxAvailable(results['tool.tmux']), + isDetecting: cached.status === 'loading', + timestamp: lastSuccessfulDetectAtRef.current || latestCheckedAt || fallbackDetectAtRef.current || 0, }; - }, [machineId]); - - return availability; + }, [cached, includeLoginStatus, isOnline, machineId]); } diff --git a/expo-app/sources/hooks/useEnvironmentVariables.hook.test.ts b/expo-app/sources/hooks/useEnvironmentVariables.hook.test.ts new file mode 100644 index 000000000..c083b0d93 --- /dev/null +++ b/expo-app/sources/hooks/useEnvironmentVariables.hook.test.ts @@ -0,0 +1,41 @@ +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import renderer, { act } from 'react-test-renderer'; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +vi.mock('@/sync/ops', () => { + return { + machinePreviewEnv: vi.fn(async () => { + // Keep the request pending so the hook stays "loading". + // This is a true system boundary (daemon RPC) so mocking is appropriate. + await new Promise(() => {}); + return { supported: true, response: { values: {}, policy: 'redacted' } }; + }), + machineBash: vi.fn(async () => { + await new Promise(() => {}); + return { success: false, error: 'not used' }; + }), + }; +}); + +describe('useEnvironmentVariables (hook)', () => { + it('sets isLoading=true before consumer useEffect can run', async () => { + const { useEnvironmentVariables } = await import('./useEnvironmentVariables'); + + let latestIsLoading: boolean | null = null; + + function Test() { + const res = useEnvironmentVariables('m1', ['OPENAI_API_KEY']); + latestIsLoading = res.isLoading; + return React.createElement('View'); + } + + act(() => { + renderer.create(React.createElement(Test)); + }); + + expect(latestIsLoading).toBe(true); + }); +}); + diff --git a/expo-app/sources/hooks/useEnvironmentVariables.test.ts b/expo-app/sources/hooks/useEnvironmentVariables.test.ts index e1bae6d24..45a978263 100644 --- a/expo-app/sources/hooks/useEnvironmentVariables.test.ts +++ b/expo-app/sources/hooks/useEnvironmentVariables.test.ts @@ -89,6 +89,10 @@ describe('resolveEnvVarSubstitution', () => { expect(resolveEnvVarSubstitution('${VAR:-fallback}', envWithNull)).toBe('fallback'); }); + it('returns default when VAR is empty string in ${VAR:-default}', () => { + expect(resolveEnvVarSubstitution('${EMPTY:-fallback}', daemonEnv)).toBe('fallback'); + }); + it('returns literal for non-substitution values', () => { expect(resolveEnvVarSubstitution('literal-value', daemonEnv)).toBe('literal-value'); }); diff --git a/expo-app/sources/hooks/useEnvironmentVariables.ts b/expo-app/sources/hooks/useEnvironmentVariables.ts index 568bb0583..e09d2280a 100644 --- a/expo-app/sources/hooks/useEnvironmentVariables.ts +++ b/expo-app/sources/hooks/useEnvironmentVariables.ts @@ -1,18 +1,37 @@ -import { useState, useEffect, useMemo } from 'react'; -import { machineBash } from '@/sync/ops'; +import { useState, useEffect, useLayoutEffect, useMemo } from 'react'; +import { machineBash, machinePreviewEnv, type EnvPreviewSecretsPolicy, type PreviewEnvValue } from '@/sync/ops'; // Re-export pure utility functions from envVarUtils for backwards compatibility export { resolveEnvVarSubstitution, extractEnvVarReferences } from './envVarUtils'; +const SECRET_NAME_REGEX = /TOKEN|KEY|SECRET|AUTH|PASS|PASSWORD|COOKIE/i; + interface EnvironmentVariables { [varName: string]: string | null; // null = variable not set in daemon environment } interface UseEnvironmentVariablesResult { variables: EnvironmentVariables; + meta: Record<string, PreviewEnvValue>; + policy: EnvPreviewSecretsPolicy | null; + isPreviewEnvSupported: boolean; isLoading: boolean; } +interface UseEnvironmentVariablesOptions { + /** + * When provided, the daemon will compute an effective spawn environment: + * effective = { ...daemon.process.env, ...expand(extraEnv) } + * This makes previews exactly match what sessions will receive. + */ + extraEnv?: Record<string, string>; + /** + * Marks variables as sensitive (at minimum). The daemon may also treat vars as sensitive + * based on name heuristics (TOKEN/KEY/etc). + */ + sensitiveKeys?: string[]; +} + /** * Queries environment variable values from the daemon's process environment. * @@ -36,18 +55,40 @@ interface UseEnvironmentVariablesResult { */ export function useEnvironmentVariables( machineId: string | null, - varNames: string[] + varNames: string[], + options?: UseEnvironmentVariablesOptions ): UseEnvironmentVariablesResult { const [variables, setVariables] = useState<EnvironmentVariables>({}); + const [meta, setMeta] = useState<Record<string, PreviewEnvValue>>({}); + const [policy, setPolicy] = useState<EnvPreviewSecretsPolicy | null>(null); + const [isPreviewEnvSupported, setIsPreviewEnvSupported] = useState(false); const [isLoading, setIsLoading] = useState(false); // Memoize sorted var names for stable dependency (avoid unnecessary re-queries) const sortedVarNames = useMemo(() => [...varNames].sort().join(','), [varNames]); + const extraEnvKey = useMemo(() => { + const entries = Object.entries(options?.extraEnv ?? {}).sort(([a], [b]) => a.localeCompare(b)); + return JSON.stringify(entries); + }, [options?.extraEnv]); + const sensitiveKeysKey = useMemo(() => { + const entries = [...(options?.sensitiveKeys ?? [])].sort((a, b) => a.localeCompare(b)); + return JSON.stringify(entries); + }, [options?.sensitiveKeys]); - useEffect(() => { + // IMPORTANT: + // We intentionally use a layout effect so `isLoading` flips to true before any consumer `useEffect` + // (e.g. auto-prompt logic) can run in the same commit. This prevents a race where: + // - consumer sees `isLoading=false` (initial) + `isSet=false` (initial) + // - and incorrectly treats the requirement as "missing" before the preflight check begins. + const useSafeLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect; + + useSafeLayoutEffect(() => { // Early exit conditions if (!machineId || varNames.length === 0) { setVariables({}); + setMeta({}); + setPolicy(null); + setIsPreviewEnvSupported(false); setIsLoading(false); return; } @@ -57,6 +98,7 @@ export function useEnvironmentVariables( const fetchVars = async () => { const results: EnvironmentVariables = {}; + const metaResults: Record<string, PreviewEnvValue> = {}; // SECURITY: Validate all variable names to prevent bash injection // Only accept valid environment variable names: [A-Z_][A-Z0-9_]* @@ -65,43 +107,168 @@ export function useEnvironmentVariables( if (validVarNames.length === 0) { // No valid variables to query setVariables({}); + setMeta({}); + setPolicy(null); + setIsPreviewEnvSupported(false); setIsLoading(false); return; } - // Build batched command: query all variables in single bash invocation - // Format: echo "VAR1=$VAR1" && echo "VAR2=$VAR2" && ... - // Using echo with variable expansion ensures we get daemon's environment - const command = validVarNames - .map(name => `echo "${name}=$${name}"`) - .join(' && '); + // Prefer daemon-native env preview if supported (more accurate + supports secret policy). + const preview = await machinePreviewEnv(machineId, { + keys: validVarNames, + extraEnv: options?.extraEnv, + sensitiveKeys: options?.sensitiveKeys, + }); + + if (cancelled) return; + + if (preview.supported) { + const response = preview.response; + validVarNames.forEach((name) => { + const entry = response.values[name]; + if (entry) { + metaResults[name] = entry; + results[name] = entry.value; + } else { + // Defensive fallback: treat as unset. + metaResults[name] = { + value: null, + isSet: false, + isSensitive: false, + isForcedSensitive: false, + sensitivitySource: 'none', + display: 'unset', + }; + results[name] = null; + } + }); + + if (!cancelled) { + setVariables(results); + setMeta(metaResults); + setPolicy(response.policy); + setIsPreviewEnvSupported(true); + setIsLoading(false); + } + return; + } + + // Fallback (older daemon): use bash probing for non-sensitive variables only. + // Never fetch secret-like values into UI memory via bash. + const sensitiveKeysSet = new Set(options?.sensitiveKeys ?? []); + const safeVarNames = validVarNames.filter((name) => !SECRET_NAME_REGEX.test(name) && !sensitiveKeysSet.has(name)); + + // Mark excluded keys as hidden (conservative). + validVarNames.forEach((name) => { + if (safeVarNames.includes(name)) return; + const isForcedSensitive = SECRET_NAME_REGEX.test(name); + metaResults[name] = { + value: null, + isSet: true, + isSensitive: true, + isForcedSensitive, + sensitivitySource: isForcedSensitive ? 'forced' : 'hinted', + display: 'hidden', + }; + results[name] = null; + }); + + // Query variables in a single machineBash() call. + // + // IMPORTANT: This runs inside the daemon process environment on the machine, because the + // RPC handler executes commands using Node's `exec()` without overriding `env`. + // That means this matches what `${VAR}` expansion uses when spawning sessions on the daemon + // (see happy-cli: expandEnvironmentVariables(..., process.env)). + // Prefer a JSON protocol (via `node`) to preserve newlines and distinguish unset vs empty. + // Fallback to bash-only output if node isn't available. + const nodeScript = [ + // node -e sets argv[1] to "-e", so args start at argv[2] + "const keys = process.argv.slice(2);", + "const out = {};", + "for (const k of keys) {", + " out[k] = Object.prototype.hasOwnProperty.call(process.env, k) ? process.env[k] : null;", + "}", + "process.stdout.write(JSON.stringify(out));", + ].join(""); + const jsonCommand = `node -e '${nodeScript.replace(/'/g, "'\\''")}' ${safeVarNames.join(' ')}`; + // Shell fallback uses `printenv` to distinguish unset vs empty via exit code. + // Note: values containing newlines may not round-trip here; the node/JSON path preserves them. + const shellFallback = [ + `for name in ${safeVarNames.join(' ')}; do`, + `if printenv "$name" >/dev/null 2>&1; then`, + `printf "%s=%s\\n" "$name" "$(printenv "$name")";`, + `else`, + `printf "%s=__HAPPY_UNSET__\\n" "$name";`, + `fi;`, + `done`, + ].join(' '); + + const command = `if command -v node >/dev/null 2>&1; then ${jsonCommand}; else ${shellFallback}; fi`; try { + if (safeVarNames.length === 0) { + if (!cancelled) { + setVariables(results); + setMeta(metaResults); + setPolicy(null); + setIsPreviewEnvSupported(false); + setIsLoading(false); + } + return; + } + const result = await machineBash(machineId, command, '/'); if (cancelled) return; if (result.success && result.exitCode === 0) { - // Parse output: "VAR1=value1\nVAR2=value2\nVAR3=" - const lines = result.stdout.trim().split('\n'); - lines.forEach(line => { - const equalsIndex = line.indexOf('='); - if (equalsIndex !== -1) { - const name = line.substring(0, equalsIndex); - const value = line.substring(equalsIndex + 1); - results[name] = value || null; // Empty string → null (not set) + const stdout = result.stdout; + + // JSON protocol: {"VAR":"value","MISSING":null} + // Be resilient to any stray output (log lines, warnings) by extracting the last JSON object. + let parsedJson = false; + const trimmed = stdout.trim(); + const firstBrace = trimmed.indexOf('{'); + const lastBrace = trimmed.lastIndexOf('}'); + if (firstBrace !== -1 && lastBrace !== -1 && lastBrace > firstBrace) { + const jsonSlice = trimmed.slice(firstBrace, lastBrace + 1); + try { + const parsed = JSON.parse(jsonSlice) as Record<string, string | null>; + safeVarNames.forEach((name) => { + results[name] = Object.prototype.hasOwnProperty.call(parsed, name) ? parsed[name] : null; + }); + parsedJson = true; + } catch { + // Fall through to line parser if JSON is malformed. } - }); + } + + // Fallback line parser: "VAR=value" or "VAR=__HAPPY_UNSET__" + if (!parsedJson) { + // Do not trim each line: it can corrupt values with meaningful whitespace. + const lines = stdout.split(/\r?\n/).filter((l) => l.length > 0); + lines.forEach((line) => { + // Ignore unrelated output (warnings, prompts, etc). + if (!/^[A-Z_][A-Z0-9_]*=/.test(line)) return; + const equalsIndex = line.indexOf('='); + if (equalsIndex !== -1) { + const name = line.substring(0, equalsIndex); + const value = line.substring(equalsIndex + 1); + results[name] = value === '__HAPPY_UNSET__' ? null : value; + } + }); + } // Ensure all requested variables have entries (even if missing from output) - validVarNames.forEach(name => { + safeVarNames.forEach(name => { if (!(name in results)) { results[name] = null; } }); } else { // Bash command failed - mark all variables as not set - validVarNames.forEach(name => { + safeVarNames.forEach(name => { results[name] = null; }); } @@ -109,13 +276,27 @@ export function useEnvironmentVariables( if (cancelled) return; // RPC error (network, encryption, etc.) - mark all as not set - validVarNames.forEach(name => { + safeVarNames.forEach(name => { results[name] = null; }); } if (!cancelled) { + safeVarNames.forEach((name) => { + const value = results[name]; + metaResults[name] = { + value, + isSet: value !== null, + isSensitive: false, + isForcedSensitive: false, + sensitivitySource: 'none', + display: value === null ? 'unset' : 'full', + }; + }); setVariables(results); + setMeta(metaResults); + setPolicy(null); + setIsPreviewEnvSupported(false); setIsLoading(false); } }; @@ -126,7 +307,7 @@ export function useEnvironmentVariables( return () => { cancelled = true; }; - }, [machineId, sortedVarNames]); + }, [extraEnvKey, machineId, sensitiveKeysKey, sortedVarNames]); - return { variables, isLoading }; + return { variables, meta, policy, isPreviewEnvSupported, isLoading }; } diff --git a/expo-app/sources/hooks/useHappyAction.ts b/expo-app/sources/hooks/useHappyAction.ts index 926767b6f..ba6a8b4e5 100644 --- a/expo-app/sources/hooks/useHappyAction.ts +++ b/expo-app/sources/hooks/useHappyAction.ts @@ -1,5 +1,6 @@ import * as React from 'react'; import { Modal } from '@/modal'; +import { t } from '@/text'; import { HappyError } from '@/utils/errors'; export function useHappyAction(action: () => Promise<void>) { @@ -27,10 +28,10 @@ export function useHappyAction(action: () => Promise<void>) { // await alert('Error', e.message, [{ text: 'OK', style: 'cancel' }]); // break; // } - Modal.alert('Error', e.message, [{ text: 'OK', style: 'cancel' }]); + Modal.alert(t('common.error'), e.message, [{ text: t('common.ok'), style: 'cancel' }]); break; } else { - Modal.alert('Error', 'Unknown error', [{ text: 'OK', style: 'cancel' }]); + Modal.alert(t('common.error'), t('errors.unknownError'), [{ text: t('common.ok'), style: 'cancel' }]); break; } } @@ -42,4 +43,4 @@ export function useHappyAction(action: () => Promise<void>) { })(); }, [action]); return [loading, doAction] as const; -} \ No newline at end of file +} diff --git a/expo-app/sources/hooks/useInboxFriendsEnabled.ts b/expo-app/sources/hooks/useInboxFriendsEnabled.ts new file mode 100644 index 000000000..97e91795c --- /dev/null +++ b/expo-app/sources/hooks/useInboxFriendsEnabled.ts @@ -0,0 +1,10 @@ +import { useSetting } from '@/sync/storage'; +import { isInboxFriendsEnabled } from '@/experiments/inboxFriends'; + +export function useInboxFriendsEnabled(): boolean { + const experiments = useSetting('experiments'); + const expInboxFriends = useSetting('expInboxFriends'); + + return isInboxFriendsEnabled({ experiments, expInboxFriends }); +} + diff --git a/expo-app/sources/hooks/useKeyboardHeight.native.ts b/expo-app/sources/hooks/useKeyboardHeight.native.ts new file mode 100644 index 000000000..c4ca622e0 --- /dev/null +++ b/expo-app/sources/hooks/useKeyboardHeight.native.ts @@ -0,0 +1,14 @@ +import { useKeyboardState } from 'react-native-keyboard-controller'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +export function useKeyboardHeight(): number { + const safeArea = useSafeAreaInsets(); + const keyboard = useKeyboardState(); + + if (!keyboard.isVisible) return 0; + + // `react-native-keyboard-controller`'s `height` includes the bottom inset on iOS. + // Subtract it so callers can treat this as "additional occupied height". + return Math.max(0, keyboard.height - safeArea.bottom); +} + diff --git a/expo-app/sources/hooks/useKeyboardHeight.ts b/expo-app/sources/hooks/useKeyboardHeight.ts new file mode 100644 index 000000000..7c90fd452 --- /dev/null +++ b/expo-app/sources/hooks/useKeyboardHeight.ts @@ -0,0 +1,33 @@ +import * as React from 'react'; +import { Keyboard, Platform, type KeyboardEvent } from 'react-native'; + +function getKeyboardHeight(e?: KeyboardEvent): number { + const h = e?.endCoordinates?.height; + return typeof h === 'number' && Number.isFinite(h) ? h : 0; +} + +export function useKeyboardHeight(): number { + const [height, setHeight] = React.useState(0); + + React.useEffect(() => { + if (Platform.OS === 'web') return; + if (typeof (Keyboard as any)?.addListener !== 'function') return; + + const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow'; + const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide'; + + const showSub = Keyboard.addListener(showEvent as any, (e: KeyboardEvent) => { + setHeight(getKeyboardHeight(e)); + }); + const hideSub = Keyboard.addListener(hideEvent as any, () => { + setHeight(0); + }); + + return () => { + showSub.remove(); + hideSub.remove(); + }; + }, []); + + return height; +} diff --git a/expo-app/sources/hooks/useMachineCapabilitiesCache.hook.test.ts b/expo-app/sources/hooks/useMachineCapabilitiesCache.hook.test.ts new file mode 100644 index 000000000..d33c99692 --- /dev/null +++ b/expo-app/sources/hooks/useMachineCapabilitiesCache.hook.test.ts @@ -0,0 +1,199 @@ +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import renderer, { act } from 'react-test-renderer'; +import { CHECKLIST_IDS } from '@happy/protocol/checklists'; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +describe('useMachineCapabilitiesCache (hook)', () => { + it('does not leave the cache stuck in loading when detection throws', async () => { + vi.resetModules(); + + vi.doMock('@/sync/ops', () => { + return { + machineCapabilitiesDetect: vi.fn(async () => { + throw new Error('boom'); + }), + }; + }); + + const { prefetchMachineCapabilities, useMachineCapabilitiesCache } = await import('./useMachineCapabilitiesCache'); + + await expect(prefetchMachineCapabilities({ + machineId: 'm1', + request: { checklistId: CHECKLIST_IDS.NEW_SESSION } as any, + timeoutMs: 1, + })).resolves.toBeUndefined(); + + let latest: any = null; + function Test() { + latest = useMachineCapabilitiesCache({ + machineId: 'm1', + enabled: false, + request: { checklistId: CHECKLIST_IDS.NEW_SESSION } as any, + timeoutMs: 1, + }).state; + return React.createElement('View'); + } + + act(() => { + renderer.create(React.createElement(Test)); + }); + + expect(latest?.status).toBe('error'); + }); + + it('keeps refresh stable when request identity changes and uses latest request', async () => { + vi.resetModules(); + + const machineCapabilitiesDetect = vi.fn(async (_machineId: string, _request: any) => { + return { supported: true, response: { protocolVersion: 1, results: {} } }; + }); + + vi.doMock('@/sync/ops', () => { + return { + machineCapabilitiesDetect, + }; + }); + + const { useMachineCapabilitiesCache } = await import('./useMachineCapabilitiesCache'); + + const requestA = { checklistId: CHECKLIST_IDS.NEW_SESSION } as any; + const requestB = { checklistId: CHECKLIST_IDS.NEW_SESSION } as any; + + let latestRefresh: null | (() => void) = null; + + function Test({ request }: { request: any }) { + const { refresh } = useMachineCapabilitiesCache({ + machineId: 'm1', + enabled: false, + request, + timeoutMs: 1, + }); + latestRefresh = refresh; + return React.createElement('View'); + } + + let tree: renderer.ReactTestRenderer | undefined; + act(() => { + tree = renderer.create(React.createElement(Test, { request: requestA })); + }); + const refreshA = latestRefresh!; + + act(() => { + tree!.update(React.createElement(Test, { request: requestB })); + }); + const refreshB = latestRefresh!; + + expect(refreshB).toBe(refreshA); + + await act(async () => { + refreshA(); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(machineCapabilitiesDetect).toHaveBeenCalled(); + expect(machineCapabilitiesDetect.mock.calls[0][1]).toBe(requestB); + }); + + it('uses a longer default timeout for machine-details detection', async () => { + vi.resetModules(); + + const machineCapabilitiesDetect = vi.fn(async (_machineId: string, _request: any, _opts: any) => { + return { supported: true, response: { protocolVersion: 1, results: {} } }; + }); + + vi.doMock('@/sync/ops', () => { + return { + machineCapabilitiesDetect, + }; + }); + + const { prefetchMachineCapabilities } = await import('./useMachineCapabilitiesCache'); + + await prefetchMachineCapabilities({ + machineId: 'm1', + request: { checklistId: 'machine-details' } as any, + }); + + expect(machineCapabilitiesDetect).toHaveBeenCalledTimes(1); + const opts = machineCapabilitiesDetect.mock.calls[0][2]; + expect(typeof opts?.timeoutMs).toBe('number'); + expect(opts.timeoutMs).toBeGreaterThanOrEqual(8000); + }); + + it('exposes the latest snapshot after a prefetch', async () => { + vi.resetModules(); + + vi.doMock('@/sync/ops', () => { + return { + machineCapabilitiesDetect: vi.fn(async () => { + return { + supported: true, + response: { + protocolVersion: 1, + results: { + 'cli.gemini': { ok: true, checkedAt: 1, data: { available: true } }, + }, + }, + }; + }), + }; + }); + + const { getMachineCapabilitiesSnapshot, prefetchMachineCapabilities } = await import('./useMachineCapabilitiesCache'); + + expect(getMachineCapabilitiesSnapshot('m1')).toBeNull(); + + await prefetchMachineCapabilities({ + machineId: 'm1', + request: { checklistId: CHECKLIST_IDS.NEW_SESSION } as any, + }); + + expect(getMachineCapabilitiesSnapshot('m1')?.response.results).toEqual({ + 'cli.gemini': { ok: true, checkedAt: 1, data: { available: true } }, + }); + }); + + it('prefetchMachineCapabilitiesIfStale only fetches when stale or missing', async () => { + vi.resetModules(); + + const machineCapabilitiesDetect = vi.fn(async () => { + return { supported: true, response: { protocolVersion: 1, results: {} } }; + }); + + vi.doMock('@/sync/ops', () => { + return { + machineCapabilitiesDetect, + }; + }); + + const { prefetchMachineCapabilitiesIfStale } = await import('./useMachineCapabilitiesCache'); + + await prefetchMachineCapabilitiesIfStale({ + machineId: 'm1', + staleMs: 60_000, + request: { checklistId: CHECKLIST_IDS.NEW_SESSION } as any, + timeoutMs: 1, + }); + expect(machineCapabilitiesDetect).toHaveBeenCalledTimes(1); + + // Fresh cache entry: should be a no-op. + await prefetchMachineCapabilitiesIfStale({ + machineId: 'm1', + staleMs: 60_000, + request: { checklistId: CHECKLIST_IDS.NEW_SESSION } as any, + timeoutMs: 1, + }); + expect(machineCapabilitiesDetect).toHaveBeenCalledTimes(1); + + // Force staleness: should fetch again. + await prefetchMachineCapabilitiesIfStale({ + machineId: 'm1', + staleMs: -1, + request: { checklistId: CHECKLIST_IDS.NEW_SESSION } as any, + timeoutMs: 1, + }); + expect(machineCapabilitiesDetect).toHaveBeenCalledTimes(2); + }); +}); diff --git a/expo-app/sources/hooks/useMachineCapabilitiesCache.race.test.ts b/expo-app/sources/hooks/useMachineCapabilitiesCache.race.test.ts new file mode 100644 index 000000000..1d1d55fa4 --- /dev/null +++ b/expo-app/sources/hooks/useMachineCapabilitiesCache.race.test.ts @@ -0,0 +1,74 @@ +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import renderer, { act } from 'react-test-renderer'; +import { CHECKLIST_IDS } from '@happy/protocol/checklists'; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +describe('useMachineCapabilitiesCache (race)', () => { + it('does not let older requests overwrite newer loaded state', async () => { + vi.resetModules(); + + const resolvers: Array<(value: any) => void> = []; + const machineCapabilitiesDetect = vi.fn(async () => { + return await new Promise((resolve) => { + resolvers.push(resolve as any); + }); + }); + + vi.doMock('@/sync/ops', () => { + return { machineCapabilitiesDetect }; + }); + + const { prefetchMachineCapabilities, useMachineCapabilitiesCache } = await import('./useMachineCapabilitiesCache'); + + const request = { checklistId: CHECKLIST_IDS.NEW_SESSION, requests: [] } as any; + + const p1 = prefetchMachineCapabilities({ machineId: 'm1', request, timeoutMs: 10_000 }); + const p2 = prefetchMachineCapabilities({ machineId: 'm1', request, timeoutMs: 10_000 }); + + expect(resolvers).toHaveLength(2); + + // Resolve the newer request first (version 2). + resolvers[1]!({ + supported: true, + response: { + protocolVersion: 1, + results: { + 'dep.test': { ok: true, data: { version: '2' } }, + }, + }, + }); + await p2; + + // Resolve the older request last (version 1). + resolvers[0]!({ + supported: true, + response: { + protocolVersion: 1, + results: { + 'dep.test': { ok: true, data: { version: '1' } }, + }, + }, + }); + await p1; + + let latest: any = null; + function Test() { + latest = useMachineCapabilitiesCache({ + machineId: 'm1', + enabled: false, + request, + timeoutMs: 1, + }).state; + return React.createElement('View'); + } + + act(() => { + renderer.create(React.createElement(Test)); + }); + + expect(latest?.status).toBe('loaded'); + expect(latest?.snapshot?.response?.results?.['dep.test']?.data?.version).toBe('2'); + }); +}); diff --git a/expo-app/sources/hooks/useMachineCapabilitiesCache.ts b/expo-app/sources/hooks/useMachineCapabilitiesCache.ts new file mode 100644 index 000000000..48029f17c --- /dev/null +++ b/expo-app/sources/hooks/useMachineCapabilitiesCache.ts @@ -0,0 +1,279 @@ +import * as React from 'react'; +import { + machineCapabilitiesDetect, + type MachineCapabilitiesDetectResult, +} from '@/sync/ops'; +import type { CapabilitiesDetectRequest, CapabilitiesDetectResponse, CapabilityDetectResult, CapabilityId } from '@/sync/capabilitiesProtocol'; +import { CHECKLIST_IDS, resumeChecklistId } from '@happy/protocol/checklists'; +import { AGENT_IDS } from '@/agents/catalog'; + +export type MachineCapabilitiesSnapshot = { + response: CapabilitiesDetectResponse; +}; + +export type MachineCapabilitiesCacheState = + | { status: 'idle' } + | { status: 'loading'; snapshot?: MachineCapabilitiesSnapshot } + | { status: 'loaded'; snapshot: MachineCapabilitiesSnapshot } + | { status: 'not-supported' } + | { status: 'error'; snapshot?: MachineCapabilitiesSnapshot }; + +type CacheEntry = { + state: MachineCapabilitiesCacheState; + updatedAt: number; + inFlightToken?: number; +}; + +const cache = new Map<string, CacheEntry>(); +const listeners = new Map<string, Set<(state: MachineCapabilitiesCacheState) => void>>(); + +const DEFAULT_STALE_MS = 24 * 60 * 60 * 1000; // 24 hours +const DEFAULT_FETCH_TIMEOUT_MS = 2500; + +function getEntry(cacheKey: string): CacheEntry | null { + return cache.get(cacheKey) ?? null; +} + +export function getMachineCapabilitiesCacheState(machineId: string): MachineCapabilitiesCacheState | null { + const entry = getEntry(machineId); + return entry ? entry.state : null; +} + +export function getMachineCapabilitiesSnapshot(machineId: string): MachineCapabilitiesSnapshot | null { + const state = getMachineCapabilitiesCacheState(machineId); + if (!state) return null; + if (state.status === 'loaded') return state.snapshot; + if (state.status === 'loading') return state.snapshot ?? null; + if (state.status === 'error') return state.snapshot ?? null; + return null; +} + +function notify(cacheKey: string) { + const entry = getEntry(cacheKey); + if (!entry) return; + const subs = listeners.get(cacheKey); + if (!subs || subs.size === 0) return; + for (const cb of subs) cb(entry.state); +} + +function setEntry(cacheKey: string, entry: CacheEntry) { + cache.set(cacheKey, entry); + notify(cacheKey); +} + +function subscribe(cacheKey: string, cb: (state: MachineCapabilitiesCacheState) => void): () => void { + let set = listeners.get(cacheKey); + if (!set) { + set = new Set(); + listeners.set(cacheKey, set); + } + set.add(cb); + return () => { + const current = listeners.get(cacheKey); + if (!current) return; + current.delete(cb); + if (current.size === 0) listeners.delete(cacheKey); + }; +} + +function isPlainObject(value: unknown): value is Record<string, unknown> { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function mergeCapabilityResult(id: CapabilityId, prev: CapabilityDetectResult | undefined, next: CapabilityDetectResult): CapabilityDetectResult { + if (!prev) return next; + if (!prev.ok || !next.ok) return next; + + // Only merge partial results for deps; CLI/tool checks should replace to avoid keeping stale paths/versions. + if (!id.startsWith('dep.')) return next; + if (!isPlainObject(prev.data) || !isPlainObject(next.data)) return next; + + return { ...next, data: { ...prev.data, ...next.data } }; +} + +function mergeDetectResponses(prev: CapabilitiesDetectResponse | null, next: CapabilitiesDetectResponse): CapabilitiesDetectResponse { + if (!prev) return next; + const merged: Partial<Record<CapabilityId, CapabilityDetectResult>> = { ...prev.results }; + for (const [id, result] of Object.entries(next.results) as Array<[CapabilityId, CapabilityDetectResult]>) { + merged[id] = mergeCapabilityResult(id, merged[id], result); + } + return { + protocolVersion: 1, + results: merged, + }; +} + +function getTimeoutMsForRequest(request: CapabilitiesDetectRequest, fallback: number): number { + // Default fast timeout; opt into longer waits for npm registry checks. + const requests = Array.isArray(request.requests) ? request.requests : []; + const hasRegistryCheck = requests.some((r) => Boolean((r.params as any)?.includeRegistry)); + const isResumeChecklist = AGENT_IDS.some((agentId) => request.checklistId === resumeChecklistId(agentId)); + const isMachineDetailsChecklist = request.checklistId === CHECKLIST_IDS.MACHINE_DETAILS; + if (hasRegistryCheck || isResumeChecklist) return Math.max(fallback, 12_000); + if (isMachineDetailsChecklist) return Math.max(fallback, 8_000); + return fallback; +} + +async function fetchAndMerge(params: { + machineId: string; + request: CapabilitiesDetectRequest; + timeoutMs?: number; +}): Promise<void> { + const cacheKey = params.machineId; + const token = Math.floor(Math.random() * Number.MAX_SAFE_INTEGER); + + const existing = getEntry(cacheKey); + const prevSnapshot = + existing?.state.status === 'loaded' + ? existing.state.snapshot + : existing?.state.status === 'loading' + ? existing.state.snapshot + : existing?.state.status === 'error' + ? existing.state.snapshot + : undefined; + + setEntry(cacheKey, { + state: { status: 'loading', ...(prevSnapshot ? { snapshot: prevSnapshot } : {}) }, + updatedAt: Date.now(), + inFlightToken: token, + }); + + const timeoutMs = typeof params.timeoutMs === 'number' + ? params.timeoutMs + : getTimeoutMsForRequest(params.request, DEFAULT_FETCH_TIMEOUT_MS); + + let result: MachineCapabilitiesDetectResult; + try { + result = await machineCapabilitiesDetect(params.machineId, params.request, { timeoutMs }); + } catch { + const current = getEntry(cacheKey); + if (!current || current.inFlightToken !== token) { + return; + } + + setEntry(cacheKey, { + state: prevSnapshot ? ({ status: 'error', snapshot: prevSnapshot } as const) : ({ status: 'error' } as const), + updatedAt: Date.now(), + }); + return; + } + + const current = getEntry(cacheKey); + if (!current || current.inFlightToken !== token) { + return; + } + const baseResponse = prevSnapshot?.response ?? null; + + const nextState = (() => { + if (result.supported) { + const merged = mergeDetectResponses(baseResponse, result.response); + const snapshot: MachineCapabilitiesSnapshot = { response: merged }; + const stillInFlight = current?.inFlightToken !== token && typeof current?.inFlightToken === 'number'; + return stillInFlight + ? ({ status: 'loading', snapshot } as const) + : ({ status: 'loaded', snapshot } as const); + } + + if (result.reason === 'not-supported') { + return { status: 'not-supported' } as const; + } + + return prevSnapshot + ? ({ status: 'error', snapshot: prevSnapshot } as const) + : ({ status: 'error' } as const); + })(); + + setEntry(cacheKey, { + state: nextState, + updatedAt: Date.now(), + }); +} + +export function prefetchMachineCapabilities(params: { + machineId: string; + request: CapabilitiesDetectRequest; + timeoutMs?: number; +}): Promise<void> { + return fetchAndMerge(params); +} + +export function prefetchMachineCapabilitiesIfStale(params: { + machineId: string; + staleMs: number; + request: CapabilitiesDetectRequest; + timeoutMs?: number; +}): Promise<void> { + const cacheKey = params.machineId; + const existing = getEntry(cacheKey); + if (!existing || existing.state.status === 'idle') { + return fetchAndMerge({ machineId: params.machineId, request: params.request, timeoutMs: params.timeoutMs }); + } + const now = Date.now(); + const isStale = (now - existing.updatedAt) > params.staleMs; + if (isStale) { + return fetchAndMerge({ machineId: params.machineId, request: params.request, timeoutMs: params.timeoutMs }); + } + return Promise.resolve(); +} + +export function useMachineCapabilitiesCache(params: { + machineId: string | null; + enabled: boolean; + staleMs?: number; + request: CapabilitiesDetectRequest; + timeoutMs?: number; +}): { state: MachineCapabilitiesCacheState; refresh: (next?: { request?: CapabilitiesDetectRequest; timeoutMs?: number }) => void } { + const { machineId, enabled, staleMs = DEFAULT_STALE_MS } = params; + const cacheKey = machineId ?? null; + + // Keep the refresh function referentially stable even when callers pass a new request + // object each render. This prevents effect churn (and, in extreme cases, navigation + // setOptions loops) while still ensuring refresh uses the latest request/timeout. + const requestRef = React.useRef<CapabilitiesDetectRequest>(params.request); + requestRef.current = params.request; + const timeoutMsRef = React.useRef<number | undefined>(params.timeoutMs); + timeoutMsRef.current = params.timeoutMs; + + const [state, setState] = React.useState<MachineCapabilitiesCacheState>(() => { + if (!cacheKey) return { status: 'idle' }; + const entry = getEntry(cacheKey); + return entry?.state ?? { status: 'idle' }; + }); + + const refresh = React.useCallback((next?: { request?: CapabilitiesDetectRequest; timeoutMs?: number }) => { + if (!machineId) return; + void fetchAndMerge({ + machineId, + request: next?.request ?? requestRef.current, + timeoutMs: typeof next?.timeoutMs === 'number' ? next.timeoutMs : timeoutMsRef.current, + }); + const entry = getEntry(machineId); + if (entry) setState(entry.state); + }, [machineId]); + + React.useEffect(() => { + if (!cacheKey) { + setState({ status: 'idle' }); + return; + } + + const unsubscribe = subscribe(cacheKey, (nextState) => setState(nextState)); + + const entry = getEntry(cacheKey); + if (entry) setState(entry.state); + + if (!enabled) { + return unsubscribe; + } + + const now = Date.now(); + const shouldFetch = !entry || (now - entry.updatedAt) > staleMs; + if (shouldFetch) { + refresh(); + } + + return unsubscribe; + }, [cacheKey, enabled, refresh, staleMs]); + + return { state, refresh }; +} diff --git a/expo-app/sources/hooks/useMachineEnvPresence.ts b/expo-app/sources/hooks/useMachineEnvPresence.ts new file mode 100644 index 000000000..5146f80e0 --- /dev/null +++ b/expo-app/sources/hooks/useMachineEnvPresence.ts @@ -0,0 +1,184 @@ +import * as React from 'react'; + +import { machinePreviewEnv, type PreviewEnvValue } from '@/sync/ops'; + +export type EnvPresenceMeta = Record<string, { isSet: boolean; display: PreviewEnvValue['display'] }>; + +export type UseMachineEnvPresenceResult = Readonly<{ + isLoading: boolean; + isPreviewEnvSupported: boolean; + meta: EnvPresenceMeta; + refreshedAt: number | null; + refresh: () => void; +}>; + +type CacheEntry = { + updatedAt: number; + isPreviewEnvSupported: boolean; + meta: EnvPresenceMeta; +}; + +const cache = new Map<string, CacheEntry>(); +const inflight = new Map<string, Promise<CacheEntry>>(); + +export function invalidateMachineEnvPresence(params?: { machineId?: string }) { + const prefix = params?.machineId ? `${params.machineId}::` : null; + for (const key of cache.keys()) { + if (!prefix || key.startsWith(prefix)) { + cache.delete(key); + } + } + for (const key of inflight.keys()) { + if (!prefix || key.startsWith(prefix)) { + inflight.delete(key); + } + } +} + +function makeCacheKey(machineId: string, keys: string[]): string { + const sorted = [...keys].sort((a, b) => a.localeCompare(b)).join(','); + return `${machineId}::${sorted}`; +} + +function normalizeKeys(keys: string[]): string[] { + const seen = new Set<string>(); + const out: string[] = []; + for (const raw of keys) { + if (typeof raw !== 'string') continue; + const name = raw.trim(); + if (!name) continue; + // Match the daemon-side var name validation. + if (!/^[A-Z_][A-Z0-9_]*$/.test(name)) continue; + if (seen.has(name)) continue; + seen.add(name); + out.push(name); + } + return out; +} + +export function useMachineEnvPresence( + machineId: string | null, + keys: string[], + opts?: { + ttlMs?: number; + }, +): UseMachineEnvPresenceResult { + const ttlMs = opts?.ttlMs ?? 2 * 60_000; + const [refreshNonce, setRefreshNonce] = React.useState(0); + + const normalizedKeys = React.useMemo(() => normalizeKeys(keys), [keys]); + const cacheKey = React.useMemo(() => { + if (!machineId || normalizedKeys.length === 0) return null; + return makeCacheKey(machineId, normalizedKeys); + }, [machineId, normalizedKeys]); + + const [state, setState] = React.useState<{ + isLoading: boolean; + isPreviewEnvSupported: boolean; + meta: EnvPresenceMeta; + refreshedAt: number | null; + }>(() => ({ + isLoading: false, + isPreviewEnvSupported: false, + meta: {}, + refreshedAt: null, + })); + + const refresh = React.useCallback(() => { + if (cacheKey) cache.delete(cacheKey); + setRefreshNonce((n) => n + 1); + }, [cacheKey]); + + React.useEffect(() => { + if (!machineId || normalizedKeys.length === 0 || !cacheKey) { + setState({ + isLoading: false, + isPreviewEnvSupported: false, + meta: {}, + refreshedAt: null, + }); + return; + } + + let cancelled = false; + const now = Date.now(); + const cached = cache.get(cacheKey); + const isFresh = cached ? now - cached.updatedAt <= ttlMs : false; + + if (cached && isFresh) { + setState({ + isLoading: false, + isPreviewEnvSupported: cached.isPreviewEnvSupported, + meta: cached.meta, + refreshedAt: cached.updatedAt, + }); + return; + } + + // Keep any cached meta while refreshing (so UI doesn't flicker). + setState((prev) => ({ + isLoading: true, + isPreviewEnvSupported: cached?.isPreviewEnvSupported ?? prev.isPreviewEnvSupported, + meta: cached?.meta ?? prev.meta, + refreshedAt: cached?.updatedAt ?? prev.refreshedAt, + })); + + const run = async (): Promise<CacheEntry> => { + const preview = await machinePreviewEnv(machineId, { + keys: normalizedKeys, + // Never fetch secret values for presence-only checks. + sensitiveKeys: normalizedKeys, + }); + + if (!preview.supported) { + return { + updatedAt: Date.now(), + isPreviewEnvSupported: false, + meta: {}, + }; + } + + const meta: EnvPresenceMeta = {}; + for (const name of normalizedKeys) { + const entry = preview.response.values[name]; + meta[name] = { + isSet: Boolean(entry?.isSet), + display: entry?.display ?? 'unset', + }; + } + + return { + updatedAt: Date.now(), + isPreviewEnvSupported: true, + meta, + }; + }; + + const p = inflight.get(cacheKey) ?? run().finally(() => inflight.delete(cacheKey)); + inflight.set(cacheKey, p); + + void p.then((next) => { + if (cancelled) return; + cache.set(cacheKey, next); + setState({ + isLoading: false, + isPreviewEnvSupported: next.isPreviewEnvSupported, + meta: next.meta, + refreshedAt: next.updatedAt, + }); + }).catch(() => { + if (cancelled) return; + setState((prev) => ({ ...prev, isLoading: false })); + }); + + return () => { + cancelled = true; + }; + }, [cacheKey, machineId, normalizedKeys, refreshNonce, ttlMs]); + + return { + ...state, + refresh, + }; +} + diff --git a/expo-app/sources/hooks/useProfileEnvRequirements.ts b/expo-app/sources/hooks/useProfileEnvRequirements.ts new file mode 100644 index 000000000..428f8c9fc --- /dev/null +++ b/expo-app/sources/hooks/useProfileEnvRequirements.ts @@ -0,0 +1,82 @@ +import { useMemo } from 'react'; + +import type { AIBackendProfile } from '@/sync/settings'; +import { getProfileEnvironmentVariables } from '@/sync/settings'; +import { useEnvironmentVariables } from '@/hooks/useEnvironmentVariables'; + +export interface ProfileEnvRequirement { + name: string; + kind: 'secret' | 'config'; +} + +export interface ProfileEnvRequirementsResult { + required: ProfileEnvRequirement[]; + isReady: boolean; + isLoading: boolean; + isPreviewEnvSupported: boolean; + policy: 'none' | 'redacted' | 'full' | null; + /** + * Per-key presence info returned by daemon (never rely on raw value for secrets). + */ + meta: Record<string, { isSet: boolean; display: 'full' | 'redacted' | 'hidden' | 'unset' }>; +} + +/** + * Preflight-check a profile's required env vars on a specific machine using the daemon's `preview-env` RPC. + * + * - Uses `extraEnv = getProfileEnvironmentVariables(profile)` so the preview matches spawn-time expansion. + * - Marks required secret keys as sensitive so they are never fetched into UI memory via fallback probing. + */ +export function useProfileEnvRequirements( + machineId: string | null, + profile: AIBackendProfile | null | undefined, +): ProfileEnvRequirementsResult { + const required = useMemo<ProfileEnvRequirement[]>(() => { + const raw = profile?.envVarRequirements ?? []; + return raw + .filter((v) => v.required === true) + .map((v) => ({ + name: v.name, + kind: v.kind ?? 'secret', + })); + }, [profile?.envVarRequirements]); + + const keysToQuery = useMemo(() => required.map((r) => r.name), [required]); + const sensitiveKeys = useMemo(() => required.filter((r) => r.kind === 'secret').map((r) => r.name), [required]); + const extraEnv = useMemo(() => (profile ? getProfileEnvironmentVariables(profile) : undefined), [profile]); + + const { meta, policy, isLoading, isPreviewEnvSupported } = useEnvironmentVariables(machineId, keysToQuery, { + extraEnv, + sensitiveKeys, + }); + + const isReady = useMemo(() => { + if (required.length === 0) return true; + return required.every((req) => Boolean(meta[req.name]?.isSet)); + }, [meta, required]); + + const metaSummary = useMemo(() => { + return Object.fromEntries( + required.map((req) => { + const entry = meta[req.name]; + return [ + req.name, + { + isSet: Boolean(entry?.isSet), + display: entry?.display ?? 'unset', + }, + ] as const; + }), + ); + }, [meta, required]); + + return { + required, + isReady, + isLoading, + isPreviewEnvSupported, + policy, + meta: metaSummary, + }; +} + diff --git a/expo-app/sources/hooks/useRequireInboxFriendsEnabled.ts b/expo-app/sources/hooks/useRequireInboxFriendsEnabled.ts new file mode 100644 index 000000000..6293d4c62 --- /dev/null +++ b/expo-app/sources/hooks/useRequireInboxFriendsEnabled.ts @@ -0,0 +1,16 @@ +import * as React from 'react'; +import { useRouter } from 'expo-router'; +import { useInboxFriendsEnabled } from '@/hooks/useInboxFriendsEnabled'; + +export function useRequireInboxFriendsEnabled(): boolean { + const router = useRouter(); + const enabled = useInboxFriendsEnabled(); + + React.useEffect(() => { + if (enabled) return; + router.replace('/'); + }, [enabled, router]); + + return enabled; +} + diff --git a/expo-app/sources/hooks/useSearch.hook.test.ts b/expo-app/sources/hooks/useSearch.hook.test.ts new file mode 100644 index 000000000..cc9f570fd --- /dev/null +++ b/expo-app/sources/hooks/useSearch.hook.test.ts @@ -0,0 +1,44 @@ +import React from 'react'; +import { describe, expect, it, vi, afterEach, beforeEach } from 'vitest'; +import renderer, { act } from 'react-test-renderer'; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +describe('useSearch (hook)', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('returns a stable error code when search fails after retries', async () => { + const searchFn = vi.fn().mockRejectedValue(new Error('boom')); + const { useSearch } = await import('./useSearch'); + + let latest: any = null; + function Test({ query }: { query: string }) { + latest = useSearch(query, searchFn); + return React.createElement('View'); + } + + await act(async () => { + renderer.create(React.createElement(Test, { query: 'abc' })); + }); + + // Debounce delay + await act(async () => { + vi.advanceTimersByTime(300); + }); + + // Retry delay (first attempt fails -> waits 750ms -> second attempt fails) + await act(async () => { + vi.advanceTimersByTime(750); + }); + + expect(searchFn).toHaveBeenCalledTimes(2); + expect(latest?.error).toBe('searchFailed'); + }); +}); + diff --git a/expo-app/sources/hooks/useSearch.ts b/expo-app/sources/hooks/useSearch.ts index e20cbcea7..aa22de60c 100644 --- a/expo-app/sources/hooks/useSearch.ts +++ b/expo-app/sources/hooks/useSearch.ts @@ -1,5 +1,7 @@ import { useEffect, useRef, useState, useCallback } from 'react'; +export type UseSearchError = 'searchFailed'; + /** * Production-ready search hook with automatic debouncing, caching, and retry logic. * @@ -12,46 +14,49 @@ import { useEffect, useRef, useState, useCallback } from 'react'; * * @param query - The search query string * @param searchFn - The async function to perform the search - * @returns Object with results array and isSearching boolean + * @returns Object with results array, isSearching boolean, and a stable error code (if any) */ export function useSearch<T>( query: string, searchFn: (query: string) => Promise<T[]> -): { results: T[]; isSearching: boolean } { +): { results: T[]; isSearching: boolean; error: UseSearchError | null } { const [results, setResults] = useState<T[]>([]); const [isSearching, setIsSearching] = useState(false); + const [error, setError] = useState<UseSearchError | null>(null); // Permanent cache for search results const cacheRef = useRef<Map<string, T[]>>(new Map()); - - // Ref to prevent parallel queries - const isSearchingRef = useRef(false); + const requestIdRef = useRef(0); // Timeout ref for debouncing const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); // Perform the search with retry logic const performSearch = useCallback(async (searchQuery: string) => { - // Skip if already searching - if (isSearchingRef.current) { - return; - } - // Check cache first const cached = cacheRef.current.get(searchQuery); if (cached) { setResults(cached); + setError(null); return; } - // Mark as searching - isSearchingRef.current = true; + const requestId = ++requestIdRef.current; setIsSearching(true); + setError(null); - // Retry logic with exponential backoff - let retryDelay = 1000; // Start with 1 second - - while (true) { + // IMPORTANT: do not retry forever. Persistent errors (bad auth/config) would otherwise + // cause infinite background requests and a "stuck loading" UI. + const maxAttempts = 2; + let attempt = 0; + let retryDelay = 750; // Start with 0.75s + try { + while (attempt < maxAttempts) { + // If a new search started, abandon this one. + if (requestIdRef.current !== requestId) { + return; + } + attempt++; try { const searchResults = await searchFn(searchQuery); @@ -60,22 +65,25 @@ export function useSearch<T>( // Update state setResults(searchResults); - break; // Success, exit the retry loop + setError(null); + return; // Success } catch (error) { - // Wait before retrying + if (attempt >= maxAttempts) { + setResults([]); + setError('searchFailed'); + return; + } + // Wait before retrying (bounded) await new Promise(resolve => setTimeout(resolve, retryDelay)); - - // Exponential backoff with max delay of 30 seconds - retryDelay = Math.min(retryDelay * 2, 30000); - - // Continue retrying (loop will continue) + retryDelay = Math.min(retryDelay * 2, 5000); + } + } + } finally { + if (requestIdRef.current === requestId) { + setIsSearching(false); } } - - // Mark as not searching - isSearchingRef.current = false; - setIsSearching(false); }, [searchFn]); // Effect to handle debounced search @@ -89,6 +97,7 @@ export function useSearch<T>( if (!query.trim()) { setResults([]); setIsSearching(false); + setError(null); return; } @@ -97,11 +106,13 @@ export function useSearch<T>( if (cached) { setResults(cached); setIsSearching(false); + setError(null); return; } // Set searching state immediately for better UX setIsSearching(true); + setError(null); // Debounce the actual search timeoutRef.current = setTimeout(() => { @@ -116,5 +127,5 @@ export function useSearch<T>( }; }, [query, performSearch]); - return { results, isSearching }; -} \ No newline at end of file + return { results, isSearching, error }; +} diff --git a/expo-app/sources/modal/ModalManager.test.ts b/expo-app/sources/modal/ModalManager.test.ts new file mode 100644 index 000000000..53bfb8b64 --- /dev/null +++ b/expo-app/sources/modal/ModalManager.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('@/text', () => ({ + t: (key: string) => key, +})); + +vi.mock('react-native', () => ({ + Platform: { + OS: 'ios', + select: (options: any) => options.ios ?? options.default, + }, + Alert: { + alert: vi.fn(), + prompt: vi.fn(), + }, +})); + +describe('Modal.prompt', () => { + it('uses the app modal prompt on iOS (not Alert.prompt)', async () => { + const { Modal } = await import('./ModalManager'); + const { Alert } = await import('react-native'); + + let lastModalConfig: any = null; + Modal.setFunctions( + (config) => { + lastModalConfig = config; + return 'prompt-1'; + }, + () => {}, + () => {}, + ); + + const promise = Modal.prompt('Title', 'Message'); + + expect((Alert as any).prompt).not.toHaveBeenCalled(); + expect(lastModalConfig?.type).toBe('prompt'); + + Modal.resolvePrompt('prompt-1', 'hello'); + await expect(promise).resolves.toBe('hello'); + }); +}); + diff --git a/expo-app/sources/modal/ModalManager.ts b/expo-app/sources/modal/ModalManager.ts index 1e0cf0aaf..5fe202410 100644 --- a/expo-app/sources/modal/ModalManager.ts +++ b/expo-app/sources/modal/ModalManager.ts @@ -1,6 +1,6 @@ import { Platform, Alert } from 'react-native'; import { t } from '@/text'; -import { AlertButton, ModalConfig, CustomModalConfig, IModal } from './types'; +import { AlertButton, ModalConfig, CustomModalConfig, IModal, type CustomModalInjectedProps } from './types'; class ModalManagerClass implements IModal { private showModalFn: ((config: Omit<ModalConfig, 'id'>) => string) | null = null; @@ -95,16 +95,24 @@ class ModalManagerClass implements IModal { } } - show(config: Omit<CustomModalConfig, 'id' | 'type'>): string { + show<P extends CustomModalInjectedProps>(config: { + component: CustomModalConfig<P>['component']; + props?: CustomModalConfig<P>['props']; + closeOnBackdrop?: boolean; + }): string { if (!this.showModalFn) { console.error('ModalManager not initialized. Make sure ModalProvider is mounted.'); return ''; } - return this.showModalFn({ - ...config, - type: 'custom' - }); + const modalConfig: Omit<CustomModalConfig, 'id'> = { + type: 'custom', + component: config.component as unknown as CustomModalConfig['component'], + props: config.props as unknown as CustomModalConfig['props'], + closeOnBackdrop: config.closeOnBackdrop, + }; + + return this.showModalFn(modalConfig); } hide(id: string): void { @@ -152,51 +160,26 @@ class ModalManagerClass implements IModal { inputType?: 'default' | 'secure-text' | 'email-address' | 'numeric'; } ): Promise<string | null> { - if (Platform.OS === 'ios' && !options?.inputType) { - // Use native Alert.prompt on iOS (only supports basic text input) - return new Promise<string | null>((resolve) => { - // @ts-ignore - Alert.prompt is iOS only - Alert.prompt( - title, - message, - [ - { - text: options?.cancelText || t('common.cancel'), - style: 'cancel', - onPress: () => resolve(null) - }, - { - text: options?.confirmText || t('common.ok'), - onPress: (text?: string) => resolve(text || null) - } - ], - 'plain-text', - options?.defaultValue, - 'default' - ); - }); - } else { - // Use custom modal for web and Android - if (!this.showModalFn) { - console.error('ModalManager not initialized. Make sure ModalProvider is mounted.'); - return null; - } - - const modalId = this.showModalFn({ - type: 'prompt', - title, - message, - placeholder: options?.placeholder, - defaultValue: options?.defaultValue, - cancelText: options?.cancelText, - confirmText: options?.confirmText, - inputType: options?.inputType - } as Omit<ModalConfig, 'id'>); - - return new Promise<string | null>((resolve) => { - this.promptResolvers.set(modalId, resolve); - }); + // Use custom modal everywhere (iOS/Android/web) so behavior is consistent. + if (!this.showModalFn) { + console.error('ModalManager not initialized. Make sure ModalProvider is mounted.'); + return null; } + + const modalId = this.showModalFn({ + type: 'prompt', + title, + message, + placeholder: options?.placeholder, + defaultValue: options?.defaultValue, + cancelText: options?.cancelText, + confirmText: options?.confirmText, + inputType: options?.inputType + } as Omit<ModalConfig, 'id'>); + + return new Promise<string | null>((resolve) => { + this.promptResolvers.set(modalId, resolve); + }); } } diff --git a/expo-app/sources/modal/ModalProvider.test.ts b/expo-app/sources/modal/ModalProvider.test.ts new file mode 100644 index 000000000..5e9cf7338 --- /dev/null +++ b/expo-app/sources/modal/ModalProvider.test.ts @@ -0,0 +1,112 @@ +import React from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import renderer, { act } from 'react-test-renderer'; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +vi.mock('@/text', () => ({ + t: (key: string) => key, +})); + +vi.mock('./components/WebAlertModal', () => ({ + WebAlertModal: () => null, +})); + +vi.mock('./components/WebPromptModal', () => ({ + WebPromptModal: () => null, +})); + +vi.mock('./components/CustomModal', () => { + const React = require('react'); + return { + CustomModal: ({ config, onClose, showBackdrop, zIndexBase }: any) => + React.createElement( + React.Fragment, + null, + React.createElement('Backdrop', { showBackdrop, zIndexBase }), + React.createElement(config.component, { ...(config.props ?? {}), onClose }), + ), + }; +}); + +function DummyModalA(_props: { onClose: () => void }) { + return React.createElement('DummyModalA'); +} + +function DummyModalB(_props: { onClose: () => void }) { + return React.createElement('DummyModalB'); +} + +describe('ModalProvider', () => { + afterEach(async () => { + const { Modal } = await import('./ModalManager'); + Modal.setFunctions(() => 'noop', () => {}, () => {}); + }); + + it('keeps earlier custom modals mounted when stacking', async () => { + const { ModalProvider } = await import('./ModalProvider'); + const { Modal } = await import('./ModalManager'); + + let tree: ReturnType<typeof renderer.create> | undefined; + act(() => { + tree = renderer.create(React.createElement(ModalProvider, { children: React.createElement('App') })); + }); + + act(() => { + Modal.show({ component: DummyModalA }); + }); + act(() => { + Modal.show({ component: DummyModalB }); + }); + + expect(tree?.root.findAllByType(DummyModalA).length).toBe(1); + expect(tree?.root.findAllByType(DummyModalB).length).toBe(1); + }); + + it('only enables the backdrop on the top-most modal', async () => { + const { ModalProvider } = await import('./ModalProvider'); + const { Modal } = await import('./ModalManager'); + + let tree: ReturnType<typeof renderer.create> | undefined; + act(() => { + tree = renderer.create(React.createElement(ModalProvider, { children: React.createElement('App') })); + }); + + act(() => { + Modal.show({ component: DummyModalA }); + }); + act(() => { + Modal.show({ component: DummyModalB }); + }); + + const backdrops = tree?.root.findAllByType('Backdrop' as any) ?? []; + expect(backdrops.filter((b: any) => Boolean(b.props.showBackdrop)).length).toBe(1); + }); + + it('assigns a higher zIndexBase to the top-most modal so its backdrop layers above earlier modals', async () => { + const { ModalProvider } = await import('./ModalProvider'); + const { Modal } = await import('./ModalManager'); + + let tree: ReturnType<typeof renderer.create> | undefined; + act(() => { + tree = renderer.create(React.createElement(ModalProvider, { children: React.createElement('App') })); + }); + + act(() => { + Modal.show({ component: DummyModalA }); + }); + act(() => { + Modal.show({ component: DummyModalB }); + }); + + const backdrops = tree?.root.findAllByType('Backdrop' as any) ?? []; + const top = backdrops.find((b: any) => Boolean(b.props.showBackdrop)); + const bottom = backdrops.find((b: any) => !Boolean(b.props.showBackdrop)); + + expect(top).toBeDefined(); + expect(bottom).toBeDefined(); + expect(typeof top?.props.zIndexBase).toBe('number'); + expect(typeof bottom?.props.zIndexBase).toBe('number'); + expect(top?.props.zIndexBase).toBeGreaterThan(bottom?.props.zIndexBase); + }); +}); diff --git a/expo-app/sources/modal/ModalProvider.tsx b/expo-app/sources/modal/ModalProvider.tsx index 70c0cd901..6bafc0a99 100644 --- a/expo-app/sources/modal/ModalProvider.tsx +++ b/expo-app/sources/modal/ModalProvider.tsx @@ -4,6 +4,7 @@ import { Modal } from './ModalManager'; import { WebAlertModal } from './components/WebAlertModal'; import { WebPromptModal } from './components/WebPromptModal'; import { CustomModal } from './components/CustomModal'; +import { OverlayPortalHost, OverlayPortalProvider } from '@/components/ui/popover'; const ModalContext = createContext<ModalContextValue | undefined>(undefined); @@ -57,47 +58,78 @@ export function ModalProvider({ children }: { children: React.ReactNode }) { hideAllModals }; - const currentModal = state.modals[state.modals.length - 1]; + const topIndex = state.modals.length - 1; + const zIndexStep = 10; + const zIndexBase = 100000; return ( - <ModalContext.Provider value={contextValue}> - {children} - {currentModal && ( - <> - {currentModal.type === 'alert' && ( - <WebAlertModal - config={currentModal} - onClose={() => hideModal(currentModal.id)} - /> - )} - {currentModal.type === 'confirm' && ( - <WebAlertModal - config={currentModal} - onClose={() => hideModal(currentModal.id)} - onConfirm={(value) => { - Modal.resolveConfirm(currentModal.id, value); - hideModal(currentModal.id); - }} - /> - )} - {currentModal.type === 'prompt' && ( - <WebPromptModal - config={currentModal} - onClose={() => hideModal(currentModal.id)} - onConfirm={(value) => { - Modal.resolvePrompt(currentModal.id, value); - hideModal(currentModal.id); - }} - /> - )} - {currentModal.type === 'custom' && ( - <CustomModal - config={currentModal} - onClose={() => hideModal(currentModal.id)} - /> - )} - </> - )} - </ModalContext.Provider> + <OverlayPortalProvider> + <ModalContext.Provider value={contextValue}> + {children} + {state.modals.map((modal, index) => { + const showBackdrop = index === topIndex; + const modalZIndexBase = zIndexBase + index * zIndexStep; + + if (modal.type === 'alert') { + return ( + <WebAlertModal + key={modal.id} + config={modal} + onClose={() => hideModal(modal.id)} + showBackdrop={showBackdrop} + zIndexBase={modalZIndexBase} + /> + ); + } + + if (modal.type === 'confirm') { + return ( + <WebAlertModal + key={modal.id} + config={modal} + onClose={() => hideModal(modal.id)} + onConfirm={(value) => { + Modal.resolveConfirm(modal.id, value); + hideModal(modal.id); + }} + showBackdrop={showBackdrop} + zIndexBase={modalZIndexBase} + /> + ); + } + + if (modal.type === 'prompt') { + return ( + <WebPromptModal + key={modal.id} + config={modal} + onClose={() => hideModal(modal.id)} + onConfirm={(value) => { + Modal.resolvePrompt(modal.id, value); + hideModal(modal.id); + }} + showBackdrop={showBackdrop} + zIndexBase={modalZIndexBase} + /> + ); + } + + if (modal.type === 'custom') { + return ( + <CustomModal + key={modal.id} + config={modal} + onClose={() => hideModal(modal.id)} + showBackdrop={showBackdrop} + zIndexBase={modalZIndexBase} + /> + ); + } + + return null; + })} + <OverlayPortalHost /> + </ModalContext.Provider> + </OverlayPortalProvider> ); -} \ No newline at end of file +} diff --git a/expo-app/sources/modal/components/BaseModal.test.ts b/expo-app/sources/modal/components/BaseModal.test.ts new file mode 100644 index 000000000..f5231d09a --- /dev/null +++ b/expo-app/sources/modal/components/BaseModal.test.ts @@ -0,0 +1,259 @@ +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import renderer, { act } from 'react-test-renderer'; +import { useModalPortalTarget } from '@/modal/portal/ModalPortalTarget'; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +vi.mock('@/utils/web/radixCjs', () => { + const React = require('react'); + return { + requireRadixDialog: () => ({ + Root: (props: any) => React.createElement('DialogRoot', props, props.children), + Portal: (props: any) => React.createElement('DialogPortal', props, props.children), + Overlay: (props: any) => React.createElement('DialogOverlay', props, props.children), + Content: (props: any) => React.createElement('DialogContent', props, props.children), + Title: (props: any) => React.createElement('DialogTitle', props, props.children), + }), + requireRadixDismissableLayer: () => ({ + Branch: (props: any) => React.createElement('DismissableLayerBranch', props, props.children), + DismissableLayerBranch: (props: any) => React.createElement('DismissableLayerBranch', props, props.children), + }), + }; +}); + +vi.mock('react-native', () => { + const React = require('react'); + + class AnimatedValue { + constructor(_value: number) {} + interpolate(_config: unknown) { + return 0; + } + } + + const Animated: any = { + Value: AnimatedValue, + timing: () => ({ start: (cb?: () => void) => cb?.() }), + spring: () => ({ start: (cb?: () => void) => cb?.() }), + View: (props: any) => React.createElement('AnimatedView', props, props.children), + }; + + return { + View: (props: any) => React.createElement('View', props, props.children), + TouchableWithoutFeedback: (props: any) => React.createElement('TouchableWithoutFeedback', props, props.children), + KeyboardAvoidingView: (props: any) => React.createElement('KeyboardAvoidingView', props, props.children), + Modal: (props: any) => React.createElement('RNModal', props, props.children), + Animated, + Platform: { + OS: 'web', + select: (options: any) => options.web ?? options.default, + }, + }; +}); + +vi.mock('react-native-unistyles', () => ({ + StyleSheet: { + create: (styles: any) => styles, + absoluteFillObject: {}, + }, +})); + +describe('BaseModal (web)', () => { + it('renders using Radix Dialog instead of react-native Modal', async () => { + const { BaseModal } = await import('./BaseModal'); + + let tree: ReturnType<typeof renderer.create> | undefined; + act(() => { + tree = renderer.create( + React.createElement(BaseModal, { visible: true, children: React.createElement('Child') }), + ); + }); + + expect(tree?.root.findAllByType('DialogRoot' as any).length).toBe(1); + expect(tree?.root.findAllByType('RNModal' as any).length).toBe(0); + }); + + it('wraps the dialog content in a DismissableLayer Branch (so underlying Vaul/Radix layers don’t dismiss)', async () => { + const { BaseModal } = await import('./BaseModal'); + + let tree: ReturnType<typeof renderer.create> | undefined; + act(() => { + tree = renderer.create( + React.createElement(BaseModal, { visible: true, children: React.createElement('Child') }), + ); + }); + + expect(tree?.root.findAllByType('DismissableLayerBranch' as any).length).toBe(1); + }); + + it('renders a DialogTitle for accessibility', async () => { + const { BaseModal } = await import('./BaseModal'); + + let tree: ReturnType<typeof renderer.create> | undefined; + act(() => { + tree = renderer.create( + React.createElement(BaseModal, { visible: true, children: React.createElement('Child') }), + ); + }); + + expect(tree?.root.findAllByType('DialogTitle' as any).length).toBe(1); + }); + + it('omits the overlay when showBackdrop is false', async () => { + const { BaseModal } = await import('./BaseModal'); + + let tree: ReturnType<typeof renderer.create> | undefined; + act(() => { + tree = renderer.create( + React.createElement(BaseModal, { visible: true, showBackdrop: false, children: React.createElement('Child') }), + ); + }); + + expect(tree?.root.findAllByType('DialogOverlay' as any).length).toBe(0); + }); + + it('prevents outside dismissal when closeOnBackdrop is false', async () => { + const { BaseModal } = await import('./BaseModal'); + + let tree: ReturnType<typeof renderer.create> | undefined; + act(() => { + tree = renderer.create( + React.createElement( + BaseModal, + { visible: true, closeOnBackdrop: false, onClose: () => {}, children: React.createElement('Child') }, + ), + ); + }); + + const content = tree?.root.findAllByType('DialogContent' as any)?.[0]; + expect(content?.props.onPointerDownOutside).toBeTypeOf('function'); + + const preventDefault = vi.fn(); + content?.props.onPointerDownOutside({ preventDefault }); + expect(preventDefault).toHaveBeenCalled(); + }); + + it('dismisses when clicking the backdrop area (pointer down on the content container itself)', async () => { + const { BaseModal } = await import('./BaseModal'); + + const onClose = vi.fn(); + + let tree: ReturnType<typeof renderer.create> | undefined; + act(() => { + tree = renderer.create( + React.createElement(BaseModal, { visible: true, onClose, children: React.createElement('Child') }), + ); + }); + + const content = tree?.root.findAllByType('DialogContent' as any)?.[0]; + expect(content?.props.onClick).toBeTypeOf('function'); + + const target = {}; + content?.props.onClick({ target, currentTarget: target, preventDefault: () => {}, stopPropagation: () => {} }); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('does not dismiss when clicking inside the modal content', async () => { + const { BaseModal } = await import('./BaseModal'); + + const onClose = vi.fn(); + + let tree: ReturnType<typeof renderer.create> | undefined; + act(() => { + tree = renderer.create( + React.createElement(BaseModal, { visible: true, onClose, children: React.createElement('Child') }), + ); + }); + + const content = tree?.root.findAllByType('DialogContent' as any)?.[0]; + expect(content?.props.onClick).toBeTypeOf('function'); + + const currentTarget = {}; + const innerTarget = {}; + content?.props.onClick({ target: innerTarget, currentTarget, preventDefault: () => {}, stopPropagation: () => {} }); + + expect(onClose).toHaveBeenCalledTimes(0); + }); + + it('sets the centering container to pointerEvents=\"box-none\" so backdrop clicks are not swallowed by RN-web wrappers', async () => { + const { BaseModal } = await import('./BaseModal'); + + let tree: ReturnType<typeof renderer.create> | undefined; + act(() => { + tree = renderer.create( + React.createElement(BaseModal, { visible: true, children: React.createElement('Child') }), + ); + }); + + const container = tree?.root.findAllByType('KeyboardAvoidingView' as any)?.[0]; + expect(container?.props.pointerEvents).toBe('box-none'); + }); + + it('sets the wrapper around children to pointerEvents=\"box-none\" so clicks outside the card dismiss (instead of hitting a full-width View)', async () => { + const { BaseModal } = await import('./BaseModal'); + + let tree: ReturnType<typeof renderer.create> | undefined; + act(() => { + tree = renderer.create( + React.createElement(BaseModal, { visible: true, children: React.createElement('Child') }), + ); + }); + + const child = tree?.root.findByType('Child' as any); + const wrapper = (child as any)?.parent; + + expect(wrapper?.type).toBe('View'); + expect(wrapper?.props.pointerEvents).toBe('box-none'); + }); + + it('applies zIndexBase to the overlay and content so stacked modals layer correctly', async () => { + const { BaseModal } = await import('./BaseModal'); + + let tree: ReturnType<typeof renderer.create> | undefined; + act(() => { + tree = renderer.create( + React.createElement(BaseModal, { + visible: true, + zIndexBase: 1234, + children: React.createElement('Child'), + }), + ); + }); + + const overlay = tree?.root.findAllByType('DialogOverlay' as any)?.[0]; + const content = tree?.root.findAllByType('DialogContent' as any)?.[0]; + + expect(overlay?.props.style?.zIndex).toBe(1234); + expect(content?.props.style?.zIndex).toBe(1235); + }); + + it('provides a modal portal target to descendants (so popovers can portal inside the dialog subtree)', async () => { + const { BaseModal } = await import('./BaseModal'); + + const portalHostMock = { nodeType: 1 } as any; + let observedTarget: any = undefined; + + function Probe() { + observedTarget = useModalPortalTarget(); + return React.createElement('Probe'); + } + + act(() => { + renderer.create( + React.createElement(BaseModal, { visible: true, children: React.createElement(Probe) }), + { + createNodeMock: (element: any) => { + if (element?.props?.['data-happy-modal-portal-host'] !== undefined) { + return portalHostMock; + } + return null; + }, + }, + ); + }); + + expect(observedTarget).toBe(portalHostMock); + }); +}); diff --git a/expo-app/sources/modal/components/BaseModal.tsx b/expo-app/sources/modal/components/BaseModal.tsx index 48ff2ab08..201fb7c34 100644 --- a/expo-app/sources/modal/components/BaseModal.tsx +++ b/expo-app/sources/modal/components/BaseModal.tsx @@ -1,32 +1,35 @@ import React, { useEffect, useRef } from 'react'; import { View, - Modal, TouchableWithoutFeedback, Animated, - StyleSheet, KeyboardAvoidingView, Platform } from 'react-native'; +import { StyleSheet } from 'react-native-unistyles'; +import { requireRadixDialog, requireRadixDismissableLayer } from '@/utils/web/radixCjs'; +import { ModalPortalTargetProvider } from '@/modal/portal/ModalPortalTarget'; interface BaseModalProps { visible: boolean; onClose?: () => void; children: React.ReactNode; - animationType?: 'fade' | 'slide' | 'none'; - transparent?: boolean; closeOnBackdrop?: boolean; + showBackdrop?: boolean; + zIndexBase?: number; } export function BaseModal({ visible, onClose, children, - animationType = 'fade', - transparent = true, - closeOnBackdrop = true + closeOnBackdrop = true, + showBackdrop = true, + zIndexBase, }: BaseModalProps) { const fadeAnim = useRef(new Animated.Value(0)).current; + const baseZ = zIndexBase ?? 100000; + const [modalPortalTarget, setModalPortalTarget] = React.useState<HTMLElement | null>(null); useEffect(() => { if (visible) { @@ -50,32 +53,155 @@ export function BaseModal({ } }; + if (Platform.OS === 'web') { + if (!visible) return null; + + // IMPORTANT: + // Use the CJS entrypoints (`require`) so Radix singletons (DismissableLayer / FocusScope stacks) + // are shared with Vaul / expo-router on web. With Metro, mixing ESM+CJS builds can lead to + // duplicate Radix modules and broken stacking/focus behavior. + const Dialog = requireRadixDialog(); + const { Branch: DismissableLayerBranch } = requireRadixDismissableLayer(); + + const overlayStyle: React.CSSProperties = { + position: 'fixed', + inset: 0, + backgroundColor: 'rgba(0, 0, 0, 0.5)', + zIndex: baseZ, + }; + + const contentStyle: React.CSSProperties = { + position: 'fixed', + inset: 0, + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + outline: 'none', + zIndex: baseZ + 1, + }; + + const visuallyHiddenStyle: React.CSSProperties = { + position: 'absolute', + width: 1, + height: 1, + padding: 0, + margin: -1, + overflow: 'hidden', + clip: 'rect(0, 0, 0, 0)', + whiteSpace: 'nowrap', + borderWidth: 0, + }; + + const portalHostStyle: React.CSSProperties = { + position: 'absolute', + top: 0, + left: 0, + width: 0, + height: 0, + overflow: 'visible', + }; + + return ( + <Dialog.Root + open={visible} + onOpenChange={(open) => { + if (!open && onClose) onClose(); + }} + > + <Dialog.Portal> + {showBackdrop ? <Dialog.Overlay style={overlayStyle} /> : null} + <DismissableLayerBranch asChild> + <Dialog.Content + aria-describedby={undefined} + style={contentStyle} + onClick={(e) => { + if (!closeOnBackdrop || !onClose) return; + // Close only when clicking the backdrop area (not inside the modal content). + // Since `Dialog.Content` covers the viewport, "backdrop" clicks are those where + // the event target is the container itself. + if (e.target === e.currentTarget) { + e.preventDefault(); + e.stopPropagation(); + onClose(); + } + }} + onPointerDownOutside={ + closeOnBackdrop ? undefined : (e) => e.preventDefault() + } + > + <Dialog.Title style={visuallyHiddenStyle}>Dialog</Dialog.Title> + {/* Host for web portals (e.g. popovers) that must live inside the dialog subtree. */} + <div + data-happy-modal-portal-host="" + ref={(node) => { + setModalPortalTarget((prev) => (prev === node ? prev : node)); + }} + style={portalHostStyle} + /> + <ModalPortalTargetProvider target={modalPortalTarget}> + <KeyboardAvoidingView + pointerEvents="box-none" + style={styles.container} + behavior={undefined} + > + <Animated.View + pointerEvents="box-none" + style={[ + styles.content, + { + opacity: fadeAnim, + transform: [{ + scale: fadeAnim.interpolate({ + inputRange: [0, 1], + outputRange: [0.9, 1] + }) + }] + } + ]} + > + <View pointerEvents="box-none" style={{ width: '100%', alignItems: 'center' }}> + {children} + </View> + </Animated.View> + </KeyboardAvoidingView> + </ModalPortalTargetProvider> + </Dialog.Content> + </DismissableLayerBranch> + </Dialog.Portal> + </Dialog.Root> + ); + } + + // IMPORTANT: + // On iOS, stacking native modals (expo-router / react-navigation modal screens + RN <Modal>) + // can lead to the RN modal rendering behind the navigation modal, while still blocking touches. + // To avoid this, we render "portal style" overlays on native (no RN <Modal>). + if (!visible) return null; + return ( - <Modal - visible={visible} - transparent={transparent} - animationType={animationType} - onRequestClose={onClose} - > - <KeyboardAvoidingView + <View style={[styles.portalRoot, { zIndex: baseZ, elevation: baseZ }]} pointerEvents="auto"> + <KeyboardAvoidingView style={styles.container} behavior={Platform.OS === 'ios' ? 'padding' : 'height'} > - <TouchableWithoutFeedback onPress={handleBackdropPress}> - <Animated.View - style={[ - styles.backdrop, - { - opacity: fadeAnim.interpolate({ - inputRange: [0, 1], - outputRange: [0, 0.5] - }) - } - ]} - /> - </TouchableWithoutFeedback> - + {showBackdrop ? ( + <TouchableWithoutFeedback onPress={handleBackdropPress}> + <Animated.View + style={[ + styles.backdrop, + { + opacity: fadeAnim.interpolate({ + inputRange: [0, 1], + outputRange: [0, 0.5] + }) + } + ]} + /> + </TouchableWithoutFeedback> + ) : null} + <Animated.View + pointerEvents="box-none" style={[ styles.content, { @@ -89,24 +215,34 @@ export function BaseModal({ } ]} > - {children} + <View pointerEvents="auto" style={{ width: '100%', alignItems: 'center' }}> + {children} + </View> </Animated.View> </KeyboardAvoidingView> - </Modal> + </View> ); } const styles = StyleSheet.create({ + portalRoot: { + ...StyleSheet.absoluteFillObject, + zIndex: 100000, + elevation: 100000, + }, container: { flex: 1, justifyContent: 'center', - alignItems: 'center' + alignItems: 'center', }, backdrop: { ...StyleSheet.absoluteFillObject, backgroundColor: 'black' }, content: { - zIndex: 1 + zIndex: 1, + // On web, some modal children use percentage widths; ensure they center reliably. + width: '100%', + alignItems: 'center', } -}); \ No newline at end of file +}); diff --git a/expo-app/sources/modal/components/CustomModal.tsx b/expo-app/sources/modal/components/CustomModal.tsx index 0c2a82504..c90fc3945 100644 --- a/expo-app/sources/modal/components/CustomModal.tsx +++ b/expo-app/sources/modal/components/CustomModal.tsx @@ -1,42 +1,42 @@ import React from 'react'; import { BaseModal } from './BaseModal'; import { CustomModalConfig } from '../types'; -import { CommandPaletteModal } from '@/components/CommandPalette/CommandPaletteModal'; -import { CommandPalette } from '@/components/CommandPalette'; interface CustomModalProps { config: CustomModalConfig; onClose: () => void; + showBackdrop?: boolean; + zIndexBase?: number; } -export function CustomModal({ config, onClose }: CustomModalProps) { +export function CustomModal({ config, onClose, showBackdrop = true, zIndexBase }: CustomModalProps) { const Component = config.component; - - // Use special modal wrapper for CommandPalette with animation support - if (Component === CommandPalette) { - return <CommandPaletteWithAnimation config={config} onClose={onClose} />; - } - - return ( - <BaseModal visible={true} onClose={onClose}> - <Component {...config.props} onClose={onClose} /> - </BaseModal> - ); -} -// Helper component to manage CommandPalette animation state -function CommandPaletteWithAnimation({ config, onClose }: CustomModalProps) { - const [isClosing, setIsClosing] = React.useState(false); - const handleClose = React.useCallback(() => { - setIsClosing(true); - // Wait for animation to complete before unmounting - setTimeout(onClose, 200); - }, [onClose]); + // Allow custom modals to run cleanup/cancel logic when the modal is dismissed + // (e.g. tapping the backdrop). + // NOTE: props are user-defined; we intentionally check this dynamically. + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const maybe = (config.props as any)?.onRequestClose; + if (typeof maybe === 'function') { + maybe(); + } + } catch { + // ignore + } + onClose(); + }, [config.props, onClose]); return ( - <CommandPaletteModal visible={!isClosing} onClose={onClose}> - <CommandPalette {...config.props} onClose={handleClose} /> - </CommandPaletteModal> + <BaseModal + visible={true} + onClose={handleClose} + closeOnBackdrop={config.closeOnBackdrop ?? true} + showBackdrop={showBackdrop} + zIndexBase={zIndexBase} + > + <Component {...config.props} onClose={handleClose} /> + </BaseModal> ); -} \ No newline at end of file +} diff --git a/expo-app/sources/modal/components/WebAlertModal.tsx b/expo-app/sources/modal/components/WebAlertModal.tsx index 67e61ae43..27be642f5 100644 --- a/expo-app/sources/modal/components/WebAlertModal.tsx +++ b/expo-app/sources/modal/components/WebAlertModal.tsx @@ -3,17 +3,96 @@ import { View, Text, Pressable } from 'react-native'; import { BaseModal } from './BaseModal'; import { AlertModalConfig, ConfirmModalConfig } from '../types'; import { Typography } from '@/constants/Typography'; -import { StyleSheet } from 'react-native'; -import { useUnistyles } from 'react-native-unistyles'; +import { StyleSheet, useUnistyles } from 'react-native-unistyles'; +import { t } from '@/text'; interface WebAlertModalProps { config: AlertModalConfig | ConfirmModalConfig; onClose: () => void; onConfirm?: (value: boolean) => void; + showBackdrop?: boolean; + zIndexBase?: number; } -export function WebAlertModal({ config, onClose, onConfirm }: WebAlertModalProps) { - const { theme } = useUnistyles(); +const stylesheet = StyleSheet.create((theme) => ({ + container: { + backgroundColor: theme.colors.surface, + borderRadius: 14, + width: 270, + overflow: 'hidden', + shadowColor: theme.colors.shadow.color, + shadowOffset: { + width: 0, + height: 2, + }, + shadowOpacity: 0.25, + shadowRadius: 4, + elevation: 5, + }, + content: { + paddingHorizontal: 16, + paddingTop: 20, + paddingBottom: 16, + alignItems: 'center', + }, + title: { + fontSize: 17, + textAlign: 'center', + color: theme.colors.text, + marginBottom: 4, + }, + message: { + fontSize: 13, + textAlign: 'center', + color: theme.colors.text, + marginTop: 4, + lineHeight: 18, + }, + buttonContainer: { + borderTopWidth: 1, + borderTopColor: theme.colors.divider, + }, + buttonRow: { + flexDirection: 'row', + }, + buttonColumn: { + flexDirection: 'column', + }, + button: { + flex: 1, + paddingVertical: 11, + alignItems: 'center', + justifyContent: 'center', + }, + buttonPressed: { + backgroundColor: theme.colors.divider, + }, + separatorVertical: { + width: 1, + backgroundColor: theme.colors.divider, + }, + separatorHorizontal: { + height: 1, + backgroundColor: theme.colors.divider, + }, + buttonText: { + fontSize: 17, + color: theme.colors.textLink, + }, + primaryText: { + color: theme.colors.text, + }, + cancelText: { + fontWeight: '400', + }, + destructiveText: { + color: theme.colors.textDestructive, + }, +})); + +export function WebAlertModal({ config, onClose, onConfirm, showBackdrop = true, zIndexBase }: WebAlertModalProps) { + useUnistyles(); + const styles = stylesheet; const isConfirm = config.type === 'confirm'; const handleButtonPress = (buttonIndex: number) => { @@ -27,77 +106,23 @@ export function WebAlertModal({ config, onClose, onConfirm }: WebAlertModalProps const buttons = isConfirm ? [ - { text: config.cancelText || 'Cancel', style: 'cancel' as const }, - { text: config.confirmText || 'OK', style: config.destructive ? 'destructive' as const : 'default' as const } + { text: config.cancelText || t('common.cancel'), style: 'cancel' as const }, + { text: config.confirmText || t('common.ok'), style: config.destructive ? 'destructive' as const : 'default' as const } ] - : config.buttons || [{ text: 'OK', style: 'default' as const }]; + : (config.buttons && config.buttons.length > 0) + ? config.buttons + : [{ text: t('common.ok'), style: 'default' as const }]; - const styles = StyleSheet.create({ - container: { - backgroundColor: theme.colors.surface, - borderRadius: 14, - width: 270, - overflow: 'hidden', - shadowColor: theme.colors.shadow.color, - shadowOffset: { - width: 0, - height: 2 - }, - shadowOpacity: 0.25, - shadowRadius: 4, - elevation: 5 - }, - content: { - paddingHorizontal: 16, - paddingTop: 20, - paddingBottom: 16, - alignItems: 'center' - }, - title: { - fontSize: 17, - textAlign: 'center', - color: theme.colors.text, - marginBottom: 4 - }, - message: { - fontSize: 13, - textAlign: 'center', - color: theme.colors.text, - marginTop: 4, - lineHeight: 18 - }, - buttonContainer: { - borderTopWidth: 1, - borderTopColor: theme.colors.divider, - flexDirection: 'row' - }, - button: { - flex: 1, - paddingVertical: 11, - alignItems: 'center', - justifyContent: 'center' - }, - buttonPressed: { - backgroundColor: theme.colors.divider - }, - buttonSeparator: { - width: 1, - backgroundColor: theme.colors.divider - }, - buttonText: { - fontSize: 17, - color: theme.colors.textLink - }, - cancelText: { - fontWeight: '400' - }, - destructiveText: { - color: theme.colors.textDestructive - } - }); + const buttonLayout = buttons.length === 3 ? 'twoPlusOne' : buttons.length > 3 ? 'column' : 'row'; return ( - <BaseModal visible={true} onClose={onClose} closeOnBackdrop={false}> + <BaseModal + visible={true} + onClose={onClose} + closeOnBackdrop={false} + showBackdrop={showBackdrop} + zIndexBase={zIndexBase} + > <View style={styles.container}> <View style={styles.content}> <Text style={[styles.title, Typography.default('semiBold')]}> @@ -110,30 +135,100 @@ export function WebAlertModal({ config, onClose, onConfirm }: WebAlertModalProps )} </View> - <View style={styles.buttonContainer}> - {buttons.map((button, index) => ( - <React.Fragment key={index}> - {index > 0 && <View style={styles.buttonSeparator} />} + {buttonLayout === 'twoPlusOne' ? ( + <View style={styles.buttonContainer}> + <View style={styles.buttonRow}> <Pressable style={({ pressed }) => [ styles.button, pressed && styles.buttonPressed ]} - onPress={() => handleButtonPress(index)} + onPress={() => handleButtonPress(0)} > <Text style={[ styles.buttonText, - button.style === 'cancel' && styles.cancelText, - button.style === 'destructive' && styles.destructiveText, - Typography.default(button.style === 'cancel' ? undefined : 'semiBold') + buttons[0]?.style === 'cancel' && styles.cancelText, + buttons[0]?.style === 'destructive' && styles.destructiveText, + Typography.default(buttons[0]?.style === 'cancel' ? undefined : 'semiBold') ]}> - {button.text} + {buttons[0]?.text} </Text> </Pressable> - </React.Fragment> - ))} - </View> + + <View style={styles.separatorVertical} /> + + <Pressable + style={({ pressed }) => [ + styles.button, + pressed && styles.buttonPressed + ]} + onPress={() => handleButtonPress(2)} + > + <Text style={[ + styles.buttonText, + buttons[2]?.style === 'cancel' && styles.cancelText, + buttons[2]?.style === 'destructive' && styles.destructiveText, + Typography.default(buttons[2]?.style === 'cancel' ? undefined : 'semiBold') + ]}> + {buttons[2]?.text} + </Text> + </Pressable> + </View> + + <View style={styles.separatorHorizontal} /> + + <Pressable + style={({ pressed }) => [ + styles.button, + pressed && styles.buttonPressed + ]} + onPress={() => handleButtonPress(1)} + > + <Text style={[ + styles.buttonText, + (buttons[1]?.style === 'default' || !buttons[1]?.style) && styles.primaryText, + buttons[1]?.style === 'cancel' && styles.cancelText, + buttons[1]?.style === 'destructive' && styles.destructiveText, + Typography.default(buttons[1]?.style === 'cancel' ? undefined : 'semiBold') + ]}> + {buttons[1]?.text} + </Text> + </Pressable> + </View> + ) : ( + <View + style={[ + styles.buttonContainer, + buttonLayout === 'row' ? styles.buttonRow : styles.buttonColumn, + ]} + > + {buttons.map((button, index) => ( + <React.Fragment key={index}> + {index > 0 && ( + <View style={buttonLayout === 'row' ? styles.separatorVertical : styles.separatorHorizontal} /> + )} + <Pressable + style={({ pressed }) => [ + styles.button, + pressed && styles.buttonPressed + ]} + onPress={() => handleButtonPress(index)} + > + <Text style={[ + styles.buttonText, + buttonLayout === 'column' && (button.style === 'default' || !button.style) && styles.primaryText, + button.style === 'cancel' && styles.cancelText, + button.style === 'destructive' && styles.destructiveText, + Typography.default(button.style === 'cancel' ? undefined : 'semiBold') + ]}> + {button.text} + </Text> + </Pressable> + </React.Fragment> + ))} + </View> + )} </View> </BaseModal> ); -} \ No newline at end of file +} diff --git a/expo-app/sources/modal/components/WebPromptModal.tsx b/expo-app/sources/modal/components/WebPromptModal.tsx index 737aac2a9..0afd74f4f 100644 --- a/expo-app/sources/modal/components/WebPromptModal.tsx +++ b/expo-app/sources/modal/components/WebPromptModal.tsx @@ -1,17 +1,19 @@ import React, { useState, useRef, useEffect } from 'react'; -import { View, Text, TextInput, Pressable, StyleSheet, KeyboardTypeOptions, Platform } from 'react-native'; +import { View, Text, TextInput, Pressable, KeyboardTypeOptions, Platform } from 'react-native'; import { BaseModal } from './BaseModal'; import { PromptModalConfig } from '../types'; import { Typography } from '@/constants/Typography'; -import { useUnistyles } from 'react-native-unistyles'; +import { StyleSheet, useUnistyles } from 'react-native-unistyles'; interface WebPromptModalProps { config: PromptModalConfig; onClose: () => void; onConfirm: (value: string | null) => void; + showBackdrop?: boolean; + zIndexBase?: number; } -export function WebPromptModal({ config, onClose, onConfirm }: WebPromptModalProps) { +export function WebPromptModal({ config, onClose, onConfirm, showBackdrop = true, zIndexBase }: WebPromptModalProps) { const { theme } = useUnistyles(); const [inputValue, setInputValue] = useState(config.defaultValue || ''); const inputRef = useRef<TextInput>(null); @@ -119,7 +121,13 @@ export function WebPromptModal({ config, onClose, onConfirm }: WebPromptModalPro }); return ( - <BaseModal visible={true} onClose={handleCancel} closeOnBackdrop={false}> + <BaseModal + visible={true} + onClose={handleCancel} + closeOnBackdrop={false} + showBackdrop={showBackdrop} + zIndexBase={zIndexBase} + > <View style={styles.container}> <View style={styles.content}> <Text style={[styles.title, Typography.default('semiBold')]}> @@ -182,4 +190,4 @@ export function WebPromptModal({ config, onClose, onConfirm }: WebPromptModalPro </View> </BaseModal> ); -} \ No newline at end of file +} diff --git a/expo-app/sources/modal/portal/ModalPortalTarget.tsx b/expo-app/sources/modal/portal/ModalPortalTarget.tsx new file mode 100644 index 000000000..abfa98aca --- /dev/null +++ b/expo-app/sources/modal/portal/ModalPortalTarget.tsx @@ -0,0 +1,21 @@ +import * as React from 'react'; + +export type ModalPortalTarget = Element | DocumentFragment | null; + +const ModalPortalTargetContext = React.createContext<ModalPortalTarget>(null); + +export function ModalPortalTargetProvider(props: { + target: ModalPortalTarget; + children: React.ReactNode; +}) { + return ( + <ModalPortalTargetContext.Provider value={props.target}> + {props.children} + </ModalPortalTargetContext.Provider> + ); +} + +export function useModalPortalTarget(): ModalPortalTarget { + return React.useContext(ModalPortalTargetContext); +} + diff --git a/expo-app/sources/modal/types.ts b/expo-app/sources/modal/types.ts index c9cfdc640..de5042bc5 100644 --- a/expo-app/sources/modal/types.ts +++ b/expo-app/sources/modal/types.ts @@ -40,13 +40,22 @@ export interface PromptModalConfig extends BaseModalConfig { inputType?: 'default' | 'secure-text' | 'email-address' | 'numeric'; } -export interface CustomModalConfig extends BaseModalConfig { +export type CustomModalInjectedProps = Readonly<{ + onClose: () => void; +}>; + +export interface CustomModalConfig<P extends CustomModalInjectedProps = any> extends BaseModalConfig { type: 'custom'; - component: ComponentType<any>; - props?: any; + component: ComponentType<P>; + props?: Omit<P, keyof CustomModalInjectedProps>; + /** + * Whether tapping the backdrop should close the modal. + * Defaults to true. + */ + closeOnBackdrop?: boolean; } -export type ModalConfig = AlertModalConfig | ConfirmModalConfig | PromptModalConfig | CustomModalConfig; +export type ModalConfig = AlertModalConfig | ConfirmModalConfig | PromptModalConfig | CustomModalConfig<any>; export interface ModalState { modals: ModalConfig[]; @@ -73,7 +82,10 @@ export interface IModal { confirmText?: string; inputType?: 'default' | 'secure-text' | 'email-address' | 'numeric'; }): Promise<string | null>; - show(config: Omit<CustomModalConfig, 'id' | 'type'>): string; + show<P extends CustomModalInjectedProps>(config: { + component: ComponentType<P>; + props?: Omit<P, keyof CustomModalInjectedProps>; + }): string; hide(id: string): void; hideAll(): void; -} \ No newline at end of file +} diff --git a/expo-app/sources/platform/cryptoRandom.node.ts b/expo-app/sources/platform/cryptoRandom.node.ts new file mode 100644 index 000000000..b33acd052 --- /dev/null +++ b/expo-app/sources/platform/cryptoRandom.node.ts @@ -0,0 +1,17 @@ +/** + * Platform adapter: cryptographically-secure random bytes (node). + * + * Used by vitest (node environment). + */ + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const crypto = require('node:crypto') as any; + +export function getRandomBytes(length: number): Uint8Array { + return new Uint8Array(crypto.randomBytes(length)); +} + +export async function getRandomBytesAsync(length: number): Promise<Uint8Array> { + return getRandomBytes(length); +} + diff --git a/expo-app/sources/platform/cryptoRandom.ts b/expo-app/sources/platform/cryptoRandom.ts new file mode 100644 index 000000000..ed49e53d2 --- /dev/null +++ b/expo-app/sources/platform/cryptoRandom.ts @@ -0,0 +1,24 @@ +/** + * Platform adapter: cryptographically-secure random bytes. + * + * Strategy: + * - App runtime (native + web): use `expo-crypto`. + * Expo implements a web-specific version internally (see `ExpoCrypto.web.ts` in Expo SDK), + * so we keep behavior consistent across Expo platforms without maintaining our own `.web` fork. + * - Tests (vitest/node): alias `@/platform/cryptoRandom` to `cryptoRandom.node.ts`. + * + * IMPORTANT: + * - Do NOT import `expo-crypto` from code that runs in node tests unless it’s behind a vitest alias. + */ + +import { getRandomBytes as expoGetRandomBytes, getRandomBytesAsync as expoGetRandomBytesAsync } from 'expo-crypto'; + +export function getRandomBytes(length: number): Uint8Array { + return expoGetRandomBytes(length); +} + +export async function getRandomBytesAsync(length: number): Promise<Uint8Array> { + // Prefer Expo's async API (when available) to preserve call-site behavior. + return await expoGetRandomBytesAsync(length); +} + diff --git a/expo-app/sources/platform/digest.node.ts b/expo-app/sources/platform/digest.node.ts new file mode 100644 index 000000000..49d412b27 --- /dev/null +++ b/expo-app/sources/platform/digest.node.ts @@ -0,0 +1,15 @@ +/** + * Platform adapter: message digest (node/vitest). + */ + +export type DigestAlgorithm = 'SHA-256' | 'SHA-512'; + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const crypto = require('node:crypto') as any; + +export async function digest(algorithm: DigestAlgorithm, data: Uint8Array): Promise<Uint8Array> { + const algo = algorithm === 'SHA-256' ? 'sha256' : 'sha512'; + const buf = crypto.createHash(algo).update(Buffer.from(data)).digest(); + return new Uint8Array(buf); +} + diff --git a/expo-app/sources/platform/digest.ts b/expo-app/sources/platform/digest.ts new file mode 100644 index 000000000..8c1e95021 --- /dev/null +++ b/expo-app/sources/platform/digest.ts @@ -0,0 +1,24 @@ +/** + * Platform adapter: message digest. + * + * Strategy: + * - App runtime (native + web): use `expo-crypto` (Expo provides a web implementation internally). + * - Tests (vitest/node): alias `@/platform/digest` to `digest.node.ts`. + */ + +import * as Crypto from 'expo-crypto'; + +export type DigestAlgorithm = 'SHA-256' | 'SHA-512'; + +export async function digest(algorithm: DigestAlgorithm, data: Uint8Array): Promise<Uint8Array> { + const expoAlgo = + algorithm === 'SHA-256' + ? Crypto.CryptoDigestAlgorithm.SHA256 + : Crypto.CryptoDigestAlgorithm.SHA512; + // `expo-crypto` expects `BufferSource` (ArrayBuffer-backed views). Some TS libs model `Uint8Array` + // as possibly backed by `SharedArrayBuffer`, so copy to a plain `ArrayBuffer`-backed view. + const safeData = new Uint8Array(data); + const out = await Crypto.digest(expoAlgo, safeData); + return new Uint8Array(out); +} + diff --git a/expo-app/sources/platform/hmacSha512.node.ts b/expo-app/sources/platform/hmacSha512.node.ts new file mode 100644 index 000000000..9dde343ab --- /dev/null +++ b/expo-app/sources/platform/hmacSha512.node.ts @@ -0,0 +1,14 @@ +/** + * Platform adapter: HMAC-SHA512 (node). + * + * Used by vitest (node environment). + */ + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const crypto = require('node:crypto') as any; + +export async function hmacSha512(key: Uint8Array, data: Uint8Array): Promise<Uint8Array> { + const buf = crypto.createHmac('sha512', Buffer.from(key)).update(Buffer.from(data)).digest(); + return new Uint8Array(buf); +} + diff --git a/expo-app/sources/platform/hmacSha512.ts b/expo-app/sources/platform/hmacSha512.ts new file mode 100644 index 000000000..38f1bf998 --- /dev/null +++ b/expo-app/sources/platform/hmacSha512.ts @@ -0,0 +1,52 @@ +/** + * Platform adapter: HMAC-SHA512. + * + * Strategy: + * - App runtime (native + web): implement HMAC via `expo-crypto` SHA-512 digest. + * (expo-crypto does not expose HMAC directly.) + * - Tests (vitest/node): alias `@/platform/hmacSha512` to `hmacSha512.node.ts`. + * + * IMPORTANT: + * - Do NOT import `expo-crypto` from code that runs in node tests unless it’s behind a vitest alias. + */ + +import * as Crypto from 'expo-crypto'; + +export async function hmacSha512(key: Uint8Array, data: Uint8Array): Promise<Uint8Array> { + const blockSize = 128; // SHA-512 block size in bytes + const opad = 0x5c; + const ipad = 0x36; + + // Prepare key + let actualKey = key; + if (key.length > blockSize) { + const keyHash = await Crypto.digest(Crypto.CryptoDigestAlgorithm.SHA512, new Uint8Array(key)); + actualKey = new Uint8Array(keyHash); + } + + // Pad key to block size + const paddedKey = new Uint8Array(blockSize); + paddedKey.set(actualKey); + + // Create inner and outer padded keys + const innerKey = new Uint8Array(blockSize); + const outerKey = new Uint8Array(blockSize); + for (let i = 0; i < blockSize; i++) { + innerKey[i] = paddedKey[i] ^ ipad; + outerKey[i] = paddedKey[i] ^ opad; + } + + // Inner hash: SHA512(innerKey || data) + const innerData = new Uint8Array(blockSize + data.length); + innerData.set(innerKey); + innerData.set(data, blockSize); + const innerHash = await Crypto.digest(Crypto.CryptoDigestAlgorithm.SHA512, innerData); + + // Outer hash: SHA512(outerKey || innerHash) + const outerData = new Uint8Array(blockSize + 64); + outerData.set(outerKey); + outerData.set(new Uint8Array(innerHash), blockSize); + const finalHash = await Crypto.digest(Crypto.CryptoDigestAlgorithm.SHA512, outerData); + return new Uint8Array(finalHash); +} + diff --git a/expo-app/sources/platform/randomUUID.node.ts b/expo-app/sources/platform/randomUUID.node.ts new file mode 100644 index 000000000..dc938cd85 --- /dev/null +++ b/expo-app/sources/platform/randomUUID.node.ts @@ -0,0 +1,19 @@ +/** + * Platform adapter: UUID v4 (node/vitest). + */ + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const crypto = require('node:crypto') as any; + +export function randomUUID(): string { + if (typeof crypto.randomUUID === 'function') { + return crypto.randomUUID(); + } + // Extremely old node fallback: generate via random bytes. + const bytes = crypto.randomBytes(16) as Buffer; + bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4 + bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant + const hex = bytes.toString('hex'); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; +} + diff --git a/expo-app/sources/platform/randomUUID.ts b/expo-app/sources/platform/randomUUID.ts new file mode 100644 index 000000000..b1a7ae8ce --- /dev/null +++ b/expo-app/sources/platform/randomUUID.ts @@ -0,0 +1,14 @@ +/** + * Platform adapter: UUID v4. + * + * Strategy: + * - App runtime (native + web): use `expo-crypto` (Expo provides a web implementation internally). + * - Tests (vitest/node): alias `@/platform/randomUUID` to `randomUUID.node.ts`. + */ + +import { randomUUID as expoRandomUUID } from 'expo-crypto'; + +export function randomUUID(): string { + return expoRandomUUID(); +} + diff --git a/expo-app/sources/profileRouteParams.test.ts b/expo-app/sources/profileRouteParams.test.ts new file mode 100644 index 000000000..166f0d0b3 --- /dev/null +++ b/expo-app/sources/profileRouteParams.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; +import { consumeProfileIdParam } from './profileRouteParams'; + +describe('consumeProfileIdParam', () => { + it('does nothing when param is missing', () => { + expect(consumeProfileIdParam({ profileIdParam: undefined, selectedProfileId: null })).toEqual({ + nextSelectedProfileId: undefined, + shouldClearParam: false, + }); + }); + + it('clears param and deselects when param is empty string', () => { + expect(consumeProfileIdParam({ profileIdParam: '', selectedProfileId: 'abc' })).toEqual({ + nextSelectedProfileId: null, + shouldClearParam: true, + }); + }); + + it('clears param without changing selection when it matches current selection', () => { + expect(consumeProfileIdParam({ profileIdParam: 'abc', selectedProfileId: 'abc' })).toEqual({ + nextSelectedProfileId: undefined, + shouldClearParam: true, + }); + }); + + it('clears param and selects when it differs from current selection', () => { + expect(consumeProfileIdParam({ profileIdParam: 'next', selectedProfileId: 'abc' })).toEqual({ + nextSelectedProfileId: 'next', + shouldClearParam: true, + }); + }); + + it('accepts array params and uses the first value', () => { + expect(consumeProfileIdParam({ profileIdParam: ['next', 'ignored'], selectedProfileId: null })).toEqual({ + nextSelectedProfileId: 'next', + shouldClearParam: true, + }); + }); + + it('treats empty array params as missing', () => { + expect(consumeProfileIdParam({ profileIdParam: [], selectedProfileId: null })).toEqual({ + nextSelectedProfileId: undefined, + shouldClearParam: false, + }); + }); +}); diff --git a/expo-app/sources/profileRouteParams.ts b/expo-app/sources/profileRouteParams.ts new file mode 100644 index 000000000..7cbc9eb0a --- /dev/null +++ b/expo-app/sources/profileRouteParams.ts @@ -0,0 +1,56 @@ +export function normalizeOptionalParam(value?: string | string[]) { + if (Array.isArray(value)) { + return value[0]; + } + return value; +} + +export function consumeProfileIdParam(params: { + profileIdParam?: string | string[]; + selectedProfileId: string | null; +}): { + nextSelectedProfileId: string | null | undefined; + shouldClearParam: boolean; +} { + const nextProfileIdFromParams = normalizeOptionalParam(params.profileIdParam); + + if (typeof nextProfileIdFromParams !== 'string') { + return { nextSelectedProfileId: undefined, shouldClearParam: false }; + } + + if (nextProfileIdFromParams === '') { + return { nextSelectedProfileId: null, shouldClearParam: true }; + } + + if (nextProfileIdFromParams === params.selectedProfileId) { + // Nothing to do, but still clear it so it doesn't lock the selection. + return { nextSelectedProfileId: undefined, shouldClearParam: true }; + } + + return { nextSelectedProfileId: nextProfileIdFromParams, shouldClearParam: true }; +} + +export function consumeSecretIdParam(params: { + secretIdParam?: string | string[]; + selectedSecretId: string | null; +}): { + nextSelectedSecretId: string | null | undefined; + shouldClearParam: boolean; +} { + const nextSecretIdFromParams = normalizeOptionalParam(params.secretIdParam); + + if (typeof nextSecretIdFromParams !== 'string') { + return { nextSelectedSecretId: undefined, shouldClearParam: false }; + } + + if (nextSecretIdFromParams === '') { + return { nextSelectedSecretId: null, shouldClearParam: true }; + } + + if (nextSecretIdFromParams === params.selectedSecretId) { + return { nextSelectedSecretId: undefined, shouldClearParam: true }; + } + + return { nextSelectedSecretId: nextSecretIdFromParams, shouldClearParam: true }; +} + diff --git a/expo-app/sources/realtime/RealtimeSession.ts b/expo-app/sources/realtime/RealtimeSession.ts index 93ab97318..374c81e0b 100644 --- a/expo-app/sources/realtime/RealtimeSession.ts +++ b/expo-app/sources/realtime/RealtimeSession.ts @@ -27,6 +27,8 @@ export async function startRealtimeSession(sessionId: string, initialContext?: s } const experimentsEnabled = storage.getState().settings.experiments; + const expVoiceAuthFlow = storage.getState().settings.expVoiceAuthFlow; + const useAuthFlow = experimentsEnabled && expVoiceAuthFlow; const agentId = __DEV__ ? config.elevenLabsAgentIdDev : config.elevenLabsAgentIdProd; if (!agentId) { @@ -36,7 +38,7 @@ export async function startRealtimeSession(sessionId: string, initialContext?: s try { // Simple path: No experiments = no auth needed - if (!experimentsEnabled) { + if (!useAuthFlow) { currentSessionId = sessionId; voiceSessionStarted = true; await voiceSession.startSession({ diff --git a/expo-app/sources/realtime/RealtimeVoiceSession.tsx b/expo-app/sources/realtime/RealtimeVoiceSession.tsx index da558e1ec..71445ca04 100644 --- a/expo-app/sources/realtime/RealtimeVoiceSession.tsx +++ b/expo-app/sources/realtime/RealtimeVoiceSession.tsx @@ -9,6 +9,11 @@ import type { VoiceSession, VoiceSessionConfig } from './types'; // Static reference to the conversation hook instance let conversationInstance: ReturnType<typeof useConversation> | null = null; +function debugLog(...args: unknown[]) { + if (!__DEV__) return; + console.debug(...args); +} + // Global voice session implementation class RealtimeVoiceSessionImpl implements VoiceSession { @@ -93,18 +98,18 @@ export const RealtimeVoiceSession: React.FC = () => { const conversation = useConversation({ clientTools: realtimeClientTools, onConnect: (data) => { - console.log('Realtime session connected:', data); + debugLog('Realtime session connected'); storage.getState().setRealtimeStatus('connected'); storage.getState().setRealtimeMode('idle'); }, onDisconnect: () => { - console.log('Realtime session disconnected'); + debugLog('Realtime session disconnected'); storage.getState().setRealtimeStatus('disconnected'); storage.getState().setRealtimeMode('idle', true); // immediate mode change storage.getState().clearRealtimeModeDebounce(); }, onMessage: (data) => { - console.log('Realtime message:', data); + debugLog('Realtime message received'); }, onError: (error) => { // Log but don't block app - voice features will be unavailable @@ -116,10 +121,10 @@ export const RealtimeVoiceSession: React.FC = () => { storage.getState().setRealtimeMode('idle', true); // immediate mode change }, onStatusChange: (data) => { - console.log('Realtime status change:', data); + debugLog('Realtime status change'); }, onModeChange: (data) => { - console.log('Realtime mode change:', data); + debugLog('Realtime mode change'); // Only animate when speaking const mode = data.mode as string; @@ -129,7 +134,7 @@ export const RealtimeVoiceSession: React.FC = () => { storage.getState().setRealtimeMode(isSpeaking ? 'speaking' : 'idle'); }, onDebug: (message) => { - console.debug('Realtime debug:', message); + debugLog('Realtime debug:', message); } }); @@ -157,4 +162,4 @@ export const RealtimeVoiceSession: React.FC = () => { // This component doesn't render anything visible return null; -}; \ No newline at end of file +}; diff --git a/expo-app/sources/realtime/RealtimeVoiceSession.web.tsx b/expo-app/sources/realtime/RealtimeVoiceSession.web.tsx index 54edb4672..1aa82a06d 100644 --- a/expo-app/sources/realtime/RealtimeVoiceSession.web.tsx +++ b/expo-app/sources/realtime/RealtimeVoiceSession.web.tsx @@ -9,11 +9,16 @@ import type { VoiceSession, VoiceSessionConfig } from './types'; // Static reference to the conversation hook instance let conversationInstance: ReturnType<typeof useConversation> | null = null; +function debugLog(...args: unknown[]) { + if (!__DEV__) return; + console.debug(...args); +} + // Global voice session implementation class RealtimeVoiceSessionImpl implements VoiceSession { async startSession(config: VoiceSessionConfig): Promise<void> { - console.log('[RealtimeVoiceSessionImpl] conversationInstance:', conversationInstance); + debugLog('[RealtimeVoiceSessionImpl] startSession'); if (!conversationInstance) { console.warn('Realtime voice session not initialized - conversationInstance is null'); return; @@ -55,7 +60,7 @@ class RealtimeVoiceSessionImpl implements VoiceSession { const conversationId = await conversationInstance.startSession(sessionConfig); - console.log('Started conversation with ID:', conversationId); + debugLog('Started conversation'); } catch (error) { console.error('Failed to start realtime session:', error); storage.getState().setRealtimeStatus('error'); @@ -98,18 +103,18 @@ export const RealtimeVoiceSession: React.FC = () => { const conversation = useConversation({ clientTools: realtimeClientTools, onConnect: () => { - console.log('Realtime session connected'); + debugLog('Realtime session connected'); storage.getState().setRealtimeStatus('connected'); storage.getState().setRealtimeMode('idle'); }, onDisconnect: () => { - console.log('Realtime session disconnected'); + debugLog('Realtime session disconnected'); storage.getState().setRealtimeStatus('disconnected'); storage.getState().setRealtimeMode('idle', true); // immediate mode change storage.getState().clearRealtimeModeDebounce(); }, onMessage: (data) => { - console.log('Realtime message:', data); + debugLog('Realtime message received'); }, onError: (error) => { // Log but don't block app - voice features will be unavailable @@ -121,10 +126,10 @@ export const RealtimeVoiceSession: React.FC = () => { storage.getState().setRealtimeMode('idle', true); // immediate mode change }, onStatusChange: (data) => { - console.log('Realtime status change:', data); + debugLog('Realtime status change'); }, onModeChange: (data) => { - console.log('Realtime mode change:', data); + debugLog('Realtime mode change'); // Only animate when speaking const mode = data.mode as string; @@ -134,7 +139,7 @@ export const RealtimeVoiceSession: React.FC = () => { storage.getState().setRealtimeMode(isSpeaking ? 'speaking' : 'idle'); }, onDebug: (message) => { - console.debug('Realtime debug:', message); + debugLog('Realtime debug:', message); } }); @@ -142,16 +147,16 @@ export const RealtimeVoiceSession: React.FC = () => { useEffect(() => { // Store the conversation instance globally - console.log('[RealtimeVoiceSession] Setting conversationInstance:', conversation); + debugLog('[RealtimeVoiceSession] Setting conversationInstance'); conversationInstance = conversation; // Register the voice session once if (!hasRegistered.current) { try { - console.log('[RealtimeVoiceSession] Registering voice session'); + debugLog('[RealtimeVoiceSession] Registering voice session'); registerVoiceSession(new RealtimeVoiceSessionImpl()); hasRegistered.current = true; - console.log('[RealtimeVoiceSession] Voice session registered successfully'); + debugLog('[RealtimeVoiceSession] Voice session registered successfully'); } catch (error) { console.error('Failed to register voice session:', error); } @@ -165,4 +170,4 @@ export const RealtimeVoiceSession: React.FC = () => { // This component doesn't render anything visible return null; -}; \ No newline at end of file +}; diff --git a/expo-app/sources/scripts/compareTranslations.ts b/expo-app/sources/scripts/compareTranslations.ts index 6e740716c..5f5316a53 100644 --- a/expo-app/sources/scripts/compareTranslations.ts +++ b/expo-app/sources/scripts/compareTranslations.ts @@ -14,8 +14,10 @@ import { ru } from '../text/translations/ru'; import { pl } from '../text/translations/pl'; import { es } from '../text/translations/es'; import { pt } from '../text/translations/pt'; +import { it } from '../text/translations/it'; import { ca } from '../text/translations/ca'; import { zhHans } from '../text/translations/zh-Hans'; +import { ja } from '../text/translations/ja'; const translations = { en, @@ -23,8 +25,10 @@ const translations = { pl, es, pt, + it, ca, 'zh-Hans': zhHans, + ja, }; const languageNames: Record<string, string> = { @@ -33,8 +37,10 @@ const languageNames: Record<string, string> = { pl: 'Polish', es: 'Spanish', pt: 'Portuguese', + it: 'Italian', ca: 'Catalan', 'zh-Hans': 'Chinese (Simplified)', + ja: 'Japanese', }; // Function to recursively extract all keys from an object @@ -214,4 +220,4 @@ for (const key of sampleKeys) { console.log(`- **${languageNames[langCode]}**: ${typeof value === 'string' ? `"${value}"` : '(function)'}`); } console.log(''); -} \ No newline at end of file +} diff --git a/expo-app/sources/scripts/findUntranslatedLiterals.ts b/expo-app/sources/scripts/findUntranslatedLiterals.ts new file mode 100644 index 000000000..c563de26b --- /dev/null +++ b/expo-app/sources/scripts/findUntranslatedLiterals.ts @@ -0,0 +1,263 @@ +#!/usr/bin/env tsx + +import * as fs from 'fs'; +import * as path from 'path'; +import ts from 'typescript'; + +type Finding = { + file: string; + line: number; + col: number; + kind: 'jsx-text' | 'jsx-attr' | 'call-arg'; + text: string; + context: string; +}; + +const projectRoot = path.resolve(__dirname, '../..'); +const sourcesRoot = path.join(projectRoot, 'sources'); + +const EXCLUDE_DIRS = new Set([ + 'node_modules', + '.git', + 'dist', + 'build', + 'coverage', +]); + +function isUnder(dir: string, filePath: string): boolean { + const rel = path.relative(dir, filePath); + return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel); +} + +function walk(dir: string, out: string[]) { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.name.startsWith('.')) continue; + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (EXCLUDE_DIRS.has(entry.name)) continue; + walk(full, out); + continue; + } + if (!entry.isFile()) continue; + if (!/\.(ts|tsx|js|jsx)$/.test(entry.name)) continue; + out.push(full); + } +} + +function getLineAndCol(sourceFile: ts.SourceFile, pos: number): { line: number; col: number } { + const lc = sourceFile.getLineAndCharacterOfPosition(pos); + return { line: lc.line + 1, col: lc.character + 1 }; +} + +function normalizeText(s: string): string { + return s.replace(/\s+/g, ' ').trim(); +} + +function shouldIgnoreLiteral(text: string): boolean { + const t = normalizeText(text); + if (!t) return true; + + // Likely not user-facing / or intentionally not translated + if (t.startsWith('http://') || t.startsWith('https://')) return true; + if (/^[A-Z0-9_]{3,}$/.test(t)) return true; // ENV keys, constants + if (/^[a-z0-9._/-]+$/.test(t) && t.length <= 32) return true; // ids/paths/slugs + if (/^#[0-9a-f]{3,8}$/i.test(t)) return true; + if (/^\d+(\.\d+)*$/.test(t)) return true; + + // Single punctuation / trivial + if (/^[•·\-\u2013\u2014]+$/.test(t)) return true; + + return false; +} + +const USER_FACING_ATTRS = new Set([ + 'title', + 'subtitle', + 'description', + 'message', + 'label', + 'placeholder', + 'hint', + 'helperText', + 'emptyTitle', + 'emptyDescription', + 'confirmText', + 'cancelText', + 'text', + 'header', +]); + +function isTCall(node: ts.Node): boolean { + if (!ts.isCallExpression(node)) return false; + if (ts.isIdentifier(node.expression)) return node.expression.text === 't'; + return false; +} + +function getNodeText(sourceFile: ts.SourceFile, node: ts.Node): string { + return sourceFile.text.slice(node.getStart(sourceFile), node.getEnd()); +} + +function takeContextLine(source: string, line: number): string { + const lines = source.split(/\r?\n/); + return lines[Math.max(0, Math.min(lines.length - 1, line - 1))]?.trim() ?? ''; +} + +function scanFile(filePath: string): Finding[] { + const rel = path.relative(projectRoot, filePath); + + // Ignore translation sources and scripts + if (rel.includes(`sources${path.sep}text${path.sep}translations${path.sep}`)) return []; + if (rel.includes(`sources${path.sep}text${path.sep}_default`)) return []; + if (rel.includes(`sources${path.sep}scripts${path.sep}`)) return []; + + const sourceText = fs.readFileSync(filePath, 'utf8'); + const scriptKind = + filePath.endsWith('.tsx') + ? ts.ScriptKind.TSX + : filePath.endsWith('.ts') + ? ts.ScriptKind.TS + : filePath.endsWith('.jsx') + ? ts.ScriptKind.JSX + : ts.ScriptKind.JS; + const sourceFile = ts.createSourceFile(filePath, sourceText, ts.ScriptTarget.Latest, true, scriptKind); + + const findings: Finding[] = []; + + const visit = (node: ts.Node) => { + // JSX text nodes: <Text>Some string</Text> + if (ts.isJsxText(node)) { + const value = normalizeText(node.getText(sourceFile)); + if (value && !shouldIgnoreLiteral(value)) { + const { line, col } = getLineAndCol(sourceFile, node.getStart(sourceFile)); + findings.push({ + file: rel, + line, + col, + kind: 'jsx-text', + text: value, + context: takeContextLine(sourceText, line), + }); + } + } + + // JSX attributes: title="Some" + if (ts.isJsxAttribute(node) && node.initializer) { + const attrName = node.name.getText(sourceFile); + if (USER_FACING_ATTRS.has(attrName)) { + const init = node.initializer; + if (ts.isStringLiteral(init) || ts.isNoSubstitutionTemplateLiteral(init)) { + const value = normalizeText(init.text); + if (value && !shouldIgnoreLiteral(value)) { + const { line, col } = getLineAndCol(sourceFile, init.getStart(sourceFile)); + findings.push({ + file: rel, + line, + col, + kind: 'jsx-attr', + text: value, + context: takeContextLine(sourceText, line), + }); + } + } + } + } + + // Call args: Modal.alert("Error", "…") + if (ts.isCallExpression(node) && !isTCall(node)) { + const exprText = getNodeText(sourceFile, node.expression); + const isLikelyUiAlert = + exprText.endsWith('.alert') || + exprText.endsWith('.confirm') || + exprText.endsWith('.prompt') || + exprText.includes('Toast') || + exprText.includes('Modal'); + + if (isLikelyUiAlert) { + for (const arg of node.arguments) { + if (ts.isStringLiteral(arg) || ts.isNoSubstitutionTemplateLiteral(arg)) { + const value = normalizeText(arg.text); + if (value && !shouldIgnoreLiteral(value)) { + const { line, col } = getLineAndCol(sourceFile, arg.getStart(sourceFile)); + findings.push({ + file: rel, + line, + col, + kind: 'call-arg', + text: value, + context: takeContextLine(sourceText, line), + }); + } + } + } + } + } + + ts.forEachChild(node, visit); + }; + + visit(sourceFile); + + // Deduplicate exact same hits (common when JSXText includes leading/trailing whitespace) + const seen = new Set<string>(); + const unique: Finding[] = []; + for (const f of findings) { + const key = `${f.file}:${f.line}:${f.col}:${f.kind}:${f.text}`; + if (seen.has(key)) continue; + seen.add(key); + unique.push(f); + } + return unique; +} + +const files: string[] = []; +const args = process.argv.slice(2); +if (args.length === 0) { + walk(sourcesRoot, files); +} else { + for (const arg of args) { + const full = path.isAbsolute(arg) ? arg : path.join(projectRoot, arg); + if (!fs.existsSync(full)) continue; + const stat = fs.statSync(full); + if (stat.isDirectory()) { + walk(full, files); + } else if (stat.isFile() && /\.(ts|tsx|js|jsx)$/.test(full)) { + files.push(full); + } + } +} + +const all: Finding[] = []; +for (const filePath of files) { + all.push(...scanFile(filePath)); +} + +all.sort((a, b) => { + if (a.file !== b.file) return a.file.localeCompare(b.file); + if (a.line !== b.line) return a.line - b.line; + return a.col - b.col; +}); + +const grouped = new Map<string, Finding[]>(); +for (const f of all) { + const key = `${f.kind}:${f.text}`; + const list = grouped.get(key) ?? []; + list.push(f); + grouped.set(key, list); +} + +console.log(`# Potential Untranslated UI Literals (${all.length} findings)\n`); +console.log(`Scanned: ${files.length} source files under ${path.relative(projectRoot, sourcesRoot)}\n`); + +for (const [key, list] of grouped.entries()) { + const colonIndex = key.indexOf(':'); + const kind = colonIndex >= 0 ? key.slice(0, colonIndex) : key; + const text = colonIndex >= 0 ? key.slice(colonIndex + 1) : ''; + console.log(`- ${kind}: "${text}" (${list.length} occurrence${list.length === 1 ? '' : 's'})`); + for (const f of list.slice(0, 10)) { + console.log(` - ${f.file}:${f.line}:${f.col} ${f.context}`); + } + if (list.length > 10) { + console.log(` - … ${list.length - 10} more`); + } +} diff --git a/expo-app/sources/sync/apiArtifacts.ts b/expo-app/sources/sync/apiArtifacts.ts index 33ccc52ed..3c775c6ee 100644 --- a/expo-app/sources/sync/apiArtifacts.ts +++ b/expo-app/sources/sync/apiArtifacts.ts @@ -2,6 +2,7 @@ import { AuthCredentials } from '@/auth/tokenStorage'; import { backoff } from '@/utils/time'; import { getServerUrl } from './serverConfig'; import { Artifact, ArtifactCreateRequest, ArtifactUpdateRequest, ArtifactUpdateResponse } from './artifactTypes'; +import { HappyError } from '@/utils/errors'; /** * Fetch all artifacts for the account @@ -18,6 +19,16 @@ export async function fetchArtifacts(credentials: AuthCredentials): Promise<Arti }); if (!response.ok) { + if (response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429) { + let message = 'Failed to fetch artifacts'; + try { + const error = await response.json(); + if (error?.error) message = error.error; + } catch { + // ignore + } + throw new HappyError(message, false); + } throw new Error(`Failed to fetch artifacts: ${response.status}`); } @@ -42,7 +53,17 @@ export async function fetchArtifact(credentials: AuthCredentials, artifactId: st if (!response.ok) { if (response.status === 404) { - throw new Error('Artifact not found'); + throw new HappyError('Artifact not found', false); + } + if (response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429) { + let message = 'Failed to fetch artifact'; + try { + const error = await response.json(); + if (error?.error) message = error.error; + } catch { + // ignore + } + throw new HappyError(message, false); } throw new Error(`Failed to fetch artifact: ${response.status}`); } @@ -73,7 +94,17 @@ export async function createArtifact( if (!response.ok) { if (response.status === 409) { - throw new Error('Artifact ID already exists'); + throw new HappyError('Artifact ID already exists', false); + } + if (response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429) { + let message = 'Failed to create artifact'; + try { + const error = await response.json(); + if (error?.error) message = error.error; + } catch { + // ignore + } + throw new HappyError(message, false); } throw new Error(`Failed to create artifact: ${response.status}`); } @@ -105,7 +136,17 @@ export async function updateArtifact( if (!response.ok) { if (response.status === 404) { - throw new Error('Artifact not found'); + throw new HappyError('Artifact not found', false); + } + if (response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429) { + let message = 'Failed to update artifact'; + try { + const error = await response.json(); + if (error?.error) message = error.error; + } catch { + // ignore + } + throw new HappyError(message, false); } throw new Error(`Failed to update artifact: ${response.status}`); } @@ -134,7 +175,17 @@ export async function deleteArtifact( if (!response.ok) { if (response.status === 404) { - throw new Error('Artifact not found'); + throw new HappyError('Artifact not found', false); + } + if (response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429) { + let message = 'Failed to delete artifact'; + try { + const error = await response.json(); + if (error?.error) message = error.error; + } catch { + // ignore + } + throw new HappyError(message, false); } throw new Error(`Failed to delete artifact: ${response.status}`); } diff --git a/expo-app/sources/sync/apiFeed.ts b/expo-app/sources/sync/apiFeed.ts index 98d7ec451..691e10282 100644 --- a/expo-app/sources/sync/apiFeed.ts +++ b/expo-app/sources/sync/apiFeed.ts @@ -1,5 +1,6 @@ import { AuthCredentials } from '@/auth/tokenStorage'; import { backoff } from '@/utils/time'; +import { HappyError } from '@/utils/errors'; import { getServerUrl } from './serverConfig'; import { FeedResponse, FeedResponseSchema, FeedItem } from './feedTypes'; import { log } from '@/log'; @@ -34,6 +35,16 @@ export async function fetchFeed( }); if (!response.ok) { + if (response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429) { + let message = 'Failed to fetch feed'; + try { + const error = await response.json(); + if (error?.error) message = error.error; + } catch { + // ignore + } + throw new HappyError(message, false); + } throw new Error(`Failed to fetch feed: ${response.status}`); } diff --git a/expo-app/sources/sync/apiFriends.ts b/expo-app/sources/sync/apiFriends.ts index b8ce68a0f..3502135b7 100644 --- a/expo-app/sources/sync/apiFriends.ts +++ b/expo-app/sources/sync/apiFriends.ts @@ -1,6 +1,7 @@ import { AuthCredentials } from '@/auth/tokenStorage'; import { backoff } from '@/utils/time'; import { getServerUrl } from './serverConfig'; +import { HappyError } from '@/utils/errors'; import { UserProfile, UserResponse, @@ -35,6 +36,16 @@ export async function searchUsersByUsername( if (response.status === 404) { return []; } + if (response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429) { + let message = 'Failed to search users'; + try { + const error = await response.json(); + if (error?.error) message = error.error; + } catch { + // ignore + } + throw new HappyError(message, false); + } throw new Error(`Failed to search users: ${response.status}`); } @@ -73,6 +84,16 @@ export async function getUserProfile( if (response.status === 404) { return null; } + if (response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429) { + let message = 'Failed to get user profile'; + try { + const error = await response.json(); + if (error?.error) message = error.error; + } catch { + // ignore + } + throw new HappyError(message, false); + } throw new Error(`Failed to get user profile: ${response.status}`); } @@ -127,6 +148,16 @@ export async function sendFriendRequest( if (response.status === 404) { return null; } + if (response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429) { + let message = 'Failed to add friend'; + try { + const error = await response.json(); + if (error?.error) message = error.error; + } catch { + // ignore + } + throw new HappyError(message, false); + } throw new Error(`Failed to add friend: ${response.status}`); } @@ -164,6 +195,16 @@ export async function getFriendsList( }); if (!response.ok) { + if (response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429) { + let message = 'Failed to get friends list'; + try { + const error = await response.json(); + if (error?.error) message = error.error; + } catch { + // ignore + } + throw new HappyError(message, false); + } throw new Error(`Failed to get friends list: ${response.status}`); } @@ -201,6 +242,16 @@ export async function removeFriend( if (response.status === 404) { return null; } + if (response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429) { + let message = 'Failed to remove friend'; + try { + const error = await response.json(); + if (error?.error) message = error.error; + } catch { + // ignore + } + throw new HappyError(message, false); + } throw new Error(`Failed to remove friend: ${response.status}`); } diff --git a/expo-app/sources/sync/apiGithub.test.ts b/expo-app/sources/sync/apiGithub.test.ts new file mode 100644 index 000000000..470deb850 --- /dev/null +++ b/expo-app/sources/sync/apiGithub.test.ts @@ -0,0 +1,64 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { HappyError } from '@/utils/errors'; +import { disconnectGitHub, getGitHubOAuthParams } from './apiGithub'; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('getGitHubOAuthParams', () => { + it('throws a config HappyError when a 400 response body is not JSON', async () => { + const jsonError = new Error('invalid json'); + (jsonError as any).canTryAgain = false; + + vi.stubGlobal( + 'fetch', + vi.fn(async () => ({ + ok: false, + status: 400, + json: async () => { + throw jsonError; + }, + })), + ); + + try { + await getGitHubOAuthParams({ token: 'test' } as any); + throw new Error('expected getGitHubOAuthParams to throw'); + } catch (e) { + expect(e).toBeInstanceOf(HappyError); + expect((e as HappyError).message).toBe('GitHub OAuth not configured'); + expect((e as HappyError).status).toBe(400); + expect((e as HappyError).kind).toBe('config'); + } + }); +}); + +describe('disconnectGitHub', () => { + it('throws a config HappyError when a 404 response body is not JSON', async () => { + const jsonError = new Error('invalid json'); + (jsonError as any).canTryAgain = false; + + vi.stubGlobal( + 'fetch', + vi.fn(async () => ({ + ok: false, + status: 404, + json: async () => { + throw jsonError; + }, + })), + ); + + try { + await disconnectGitHub({ token: 'test' } as any); + throw new Error('expected disconnectGitHub to throw'); + } catch (e) { + expect(e).toBeInstanceOf(HappyError); + expect((e as HappyError).message).toBe('GitHub account not connected'); + expect((e as HappyError).status).toBe(404); + expect((e as HappyError).kind).toBe('config'); + } + }); +}); diff --git a/expo-app/sources/sync/apiGithub.ts b/expo-app/sources/sync/apiGithub.ts index e7877c205..5a3b94ebc 100644 --- a/expo-app/sources/sync/apiGithub.ts +++ b/expo-app/sources/sync/apiGithub.ts @@ -1,5 +1,6 @@ import { AuthCredentials } from '@/auth/tokenStorage'; import { backoff } from '@/utils/time'; +import { HappyError } from '@/utils/errors'; import { getServerUrl } from './serverConfig'; export interface GitHubOAuthParams { @@ -37,8 +38,24 @@ export async function getGitHubOAuthParams(credentials: AuthCredentials): Promis if (!response.ok) { if (response.status === 400) { - const error = await response.json(); - throw new Error(error.error || 'GitHub OAuth not configured'); + let message = 'GitHub OAuth not configured'; + try { + const error = await response.json(); + if (error?.error) message = error.error; + } catch { + // ignore + } + throw new HappyError(message, false, { status: 400, kind: 'config' }); + } + if (response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429) { + let message = 'Failed to get GitHub OAuth params'; + try { + const error = await response.json(); + if (error?.error) message = error.error; + } catch { + // ignore + } + throw new HappyError(message, false, { status: response.status, kind: response.status === 401 || response.status === 403 ? 'auth' : 'config' }); } throw new Error(`Failed to get GitHub OAuth params: ${response.status}`); } @@ -64,6 +81,16 @@ export async function getAccountProfile(credentials: AuthCredentials): Promise<A }); if (!response.ok) { + if (response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429) { + let message = 'Failed to get account profile'; + try { + const error = await response.json(); + if (error?.error) message = error.error; + } catch { + // ignore + } + throw new HappyError(message, false, { status: response.status, kind: response.status === 401 || response.status === 403 ? 'auth' : 'config' }); + } throw new Error(`Failed to get account profile: ${response.status}`); } @@ -88,8 +115,24 @@ export async function disconnectGitHub(credentials: AuthCredentials): Promise<vo if (!response.ok) { if (response.status === 404) { - const error = await response.json(); - throw new Error(error.error || 'GitHub account not connected'); + let message = 'GitHub account not connected'; + try { + const error = await response.json(); + if (error?.error) message = error.error; + } catch { + // ignore + } + throw new HappyError(message, false, { status: 404, kind: 'config' }); + } + if (response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429) { + let message = 'Failed to disconnect GitHub'; + try { + const error = await response.json(); + if (error?.error) message = error.error; + } catch { + // ignore + } + throw new HappyError(message, false, { status: response.status, kind: response.status === 401 || response.status === 403 ? 'auth' : 'config' }); } throw new Error(`Failed to disconnect GitHub: ${response.status}`); } @@ -99,4 +142,4 @@ export async function disconnectGitHub(credentials: AuthCredentials): Promise<vo throw new Error('Failed to disconnect GitHub account'); } }); -} \ No newline at end of file +} diff --git a/expo-app/sources/sync/apiKv.ts b/expo-app/sources/sync/apiKv.ts index 41c5835be..6a687f44c 100644 --- a/expo-app/sources/sync/apiKv.ts +++ b/expo-app/sources/sync/apiKv.ts @@ -1,6 +1,7 @@ import { AuthCredentials } from '@/auth/tokenStorage'; import { backoff } from '@/utils/time'; import { getServerUrl } from './serverConfig'; +import { HappyError } from '@/utils/errors'; // // Types @@ -84,6 +85,16 @@ export async function kvGet( } if (!response.ok) { + if (response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429) { + let message = 'Failed to get KV value'; + try { + const error = await response.json(); + if (error?.error) message = error.error; + } catch { + // ignore + } + throw new HappyError(message, false); + } throw new Error(`Failed to get KV value: ${response.status}`); } @@ -121,6 +132,16 @@ export async function kvList( }); if (!response.ok) { + if (response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429) { + let message = 'Failed to list KV items'; + try { + const error = await response.json(); + if (error?.error) message = error.error; + } catch { + // ignore + } + throw new HappyError(message, false); + } throw new Error(`Failed to list KV items: ${response.status}`); } @@ -157,6 +178,16 @@ export async function kvBulkGet( }); if (!response.ok) { + if (response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429) { + let message = 'Failed to bulk get KV values'; + try { + const error = await response.json(); + if (error?.error) message = error.error; + } catch { + // ignore + } + throw new HappyError(message, false); + } throw new Error(`Failed to bulk get KV values: ${response.status}`); } @@ -200,6 +231,16 @@ export async function kvMutate( } if (!response.ok) { + if (response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429) { + let message = 'Failed to mutate KV values'; + try { + const error = await response.json(); + if (error?.error) message = error.error; + } catch { + // ignore + } + throw new HappyError(message, false); + } throw new Error(`Failed to mutate KV values: ${response.status}`); } diff --git a/expo-app/sources/sync/apiPush.ts b/expo-app/sources/sync/apiPush.ts index 503b56e74..f53adbeeb 100644 --- a/expo-app/sources/sync/apiPush.ts +++ b/expo-app/sources/sync/apiPush.ts @@ -1,5 +1,6 @@ import { AuthCredentials } from '@/auth/tokenStorage'; import { backoff } from '@/utils/time'; +import { HappyError } from '@/utils/errors'; import { getServerUrl } from './serverConfig'; export async function registerPushToken(credentials: AuthCredentials, token: string): Promise<void> { @@ -15,6 +16,16 @@ export async function registerPushToken(credentials: AuthCredentials, token: str }); if (!response.ok) { + if (response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429) { + let message = 'Failed to register push token'; + try { + const error = await response.json(); + if (error?.error) message = error.error; + } catch { + // ignore + } + throw new HappyError(message, false); + } throw new Error(`Failed to register push token: ${response.status}`); } diff --git a/expo-app/sources/sync/apiServices.test.ts b/expo-app/sources/sync/apiServices.test.ts new file mode 100644 index 000000000..ffaabdc52 --- /dev/null +++ b/expo-app/sources/sync/apiServices.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { HappyError } from '@/utils/errors'; +import { disconnectService } from './apiServices'; + +describe('disconnectService', () => { + it('throws a HappyError when a 404 response body is not JSON', async () => { + const jsonError = new Error('invalid json'); + (jsonError as any).canTryAgain = false; + + vi.stubGlobal( + 'fetch', + vi.fn(async () => ({ + ok: false, + status: 404, + json: async () => { + throw jsonError; + }, + })), + ); + + try { + await disconnectService({ token: 'test' } as any, 'github'); + throw new Error('expected disconnectService to throw'); + } catch (e) { + expect(e).toBeInstanceOf(HappyError); + expect((e as HappyError).message).toBe('github account not connected'); + } + }); +}); diff --git a/expo-app/sources/sync/apiServices.ts b/expo-app/sources/sync/apiServices.ts index 068853067..e928532f1 100644 --- a/expo-app/sources/sync/apiServices.ts +++ b/expo-app/sources/sync/apiServices.ts @@ -1,5 +1,6 @@ import { AuthCredentials } from '@/auth/tokenStorage'; import { backoff } from '@/utils/time'; +import { HappyError } from '@/utils/errors'; import { getServerUrl } from './serverConfig'; /** @@ -23,6 +24,16 @@ export async function connectService( }); if (!response.ok) { + if (response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429) { + let message = `Failed to connect ${service}`; + try { + const error = await response.json(); + if (error?.error) message = error.error; + } catch { + // ignore + } + throw new HappyError(message, false); + } throw new Error(`Failed to connect ${service}: ${response.status}`); } @@ -49,8 +60,24 @@ export async function disconnectService(credentials: AuthCredentials, service: s if (!response.ok) { if (response.status === 404) { - const error = await response.json(); - throw new Error(error.error || `${service} account not connected`); + let message = `${service} account not connected`; + try { + const error = await response.json(); + if (error?.error) message = error.error; + } catch { + // ignore + } + throw new HappyError(message, false); + } + if (response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429) { + let message = `Failed to disconnect ${service}`; + try { + const error = await response.json(); + if (error?.error) message = error.error; + } catch { + // ignore + } + throw new HappyError(message, false); } throw new Error(`Failed to disconnect ${service}: ${response.status}`); } @@ -60,4 +87,4 @@ export async function disconnectService(credentials: AuthCredentials, service: s throw new Error(`Failed to disconnect ${service} account`); } }); -} \ No newline at end of file +} diff --git a/expo-app/sources/sync/apiSharing.ts b/expo-app/sources/sync/apiSharing.ts new file mode 100644 index 000000000..731111dc4 --- /dev/null +++ b/expo-app/sources/sync/apiSharing.ts @@ -0,0 +1,470 @@ +import { AuthCredentials } from '@/auth/tokenStorage'; +import { backoff } from '@/utils/time'; +import { getServerUrl } from './serverConfig'; +import { + SessionShare, + SessionShareResponse, + SessionSharesResponse, + CreateSessionShareRequest, + PublicSessionShare, + PublicShareResponse, + CreatePublicShareRequest, + AccessPublicShareResponse, + PublicShareAccessLogsResponse, + PublicShareBlockedUsersResponse, + BlockPublicShareUserRequest, + ShareNotFoundError, + PublicShareNotFoundError, + ConsentRequiredError, + SessionSharingError +} from './sharingTypes'; + +const API_ENDPOINT = getServerUrl(); + +/** + * Get all shares for a session + * + * @param credentials - User authentication credentials + * @param sessionId - ID of the session to get shares for + * @returns List of all shares for the session + * @throws {SessionSharingError} If the user doesn't have permission (not owner/admin) + * @throws {Error} For other API errors + * + * @remarks + * Only the session owner or users with admin access can view all shares. + * The returned shares include information about who has access and their + * access levels. + */ +export async function getSessionShares( + credentials: AuthCredentials, + sessionId: string +): Promise<SessionShare[]> { + return await backoff(async () => { + const response = await fetch(`${API_ENDPOINT}/v1/sessions/${sessionId}/shares`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${credentials.token}`, + } + }); + + if (!response.ok) { + if (response.status === 403) { + throw new SessionSharingError('Forbidden'); + } + throw new Error(`Failed to get session shares: ${response.status}`); + } + + const data: SessionSharesResponse = await response.json(); + return data.shares; + }); +} + +/** + * Share a session with a specific user + * + * @param credentials - User authentication credentials + * @param sessionId - ID of the session to share + * @param request - Share creation request containing userId and accessLevel + * @returns The created or updated share + * @throws {SessionSharingError} If sharing fails (not friends, forbidden, etc.) + * @throws {Error} For other API errors + * + * @remarks + * Only the session owner or users with admin access can create shares. + * The target user must be a friend of the owner. If a share already exists + * for the user, it will be updated with the new access level. + * + * The client must provide `encryptedDataKey` (the session DEK wrapped for the + * recipient's content public key). The server stores it as an opaque blob. + */ +export async function createSessionShare( + credentials: AuthCredentials, + sessionId: string, + request: CreateSessionShareRequest +): Promise<SessionShare> { + return await backoff(async () => { + const response = await fetch(`${API_ENDPOINT}/v1/sessions/${sessionId}/shares`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${credentials.token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(request) + }); + + if (!response.ok) { + if (response.status === 403) { + const error = await response.json(); + throw new SessionSharingError(error.error || 'Forbidden'); + } + if (response.status === 400) { + const error = await response.json(); + throw new SessionSharingError(error.error || 'Bad request'); + } + throw new Error(`Failed to create session share: ${response.status}`); + } + + const data: SessionShareResponse = await response.json(); + return data.share; + }); +} + +/** + * Update the access level of an existing share + * + * @param credentials - User authentication credentials + * @param sessionId - ID of the session + * @param shareId - ID of the share to update + * @param accessLevel - New access level to grant + * @returns The updated share + * @throws {SessionSharingError} If the user doesn't have permission + * @throws {ShareNotFoundError} If the share doesn't exist + * @throws {Error} For other API errors + * + * @remarks + * Only the session owner or users with admin access can update shares. + */ +export async function updateSessionShare( + credentials: AuthCredentials, + sessionId: string, + shareId: string, + accessLevel: 'view' | 'edit' | 'admin' +): Promise<SessionShare> { + return await backoff(async () => { + const response = await fetch(`${API_ENDPOINT}/v1/sessions/${sessionId}/shares/${shareId}`, { + method: 'PATCH', + headers: { + 'Authorization': `Bearer ${credentials.token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ accessLevel }) + }); + + if (!response.ok) { + if (response.status === 403) { + throw new SessionSharingError('Forbidden'); + } + if (response.status === 404) { + throw new ShareNotFoundError(); + } + throw new Error(`Failed to update session share: ${response.status}`); + } + + const data: SessionShareResponse = await response.json(); + return data.share; + }); +} + +/** + * Delete a share and revoke user access + * + * @param credentials - User authentication credentials + * @param sessionId - ID of the session + * @param shareId - ID of the share to delete + * @throws {SessionSharingError} If the user doesn't have permission + * @throws {ShareNotFoundError} If the share doesn't exist + * @throws {Error} For other API errors + * + * @remarks + * Only the session owner or users with admin access can delete shares. + * The shared user will immediately lose access to the session. + */ +export async function deleteSessionShare( + credentials: AuthCredentials, + sessionId: string, + shareId: string +): Promise<void> { + return await backoff(async () => { + const response = await fetch(`${API_ENDPOINT}/v1/sessions/${sessionId}/shares/${shareId}`, { + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${credentials.token}`, + } + }); + + if (!response.ok) { + if (response.status === 403) { + throw new SessionSharingError('Forbidden'); + } + if (response.status === 404) { + throw new ShareNotFoundError(); + } + throw new Error(`Failed to delete session share: ${response.status}`); + } + }); +} + +/** + * Create or update a public share link for a session + * + * @param credentials - User authentication credentials + * @param sessionId - ID of the session to share publicly + * @param request - Public share configuration (expiration, limits, consent) + * @returns The created or updated public share with its token + * @throws {SessionSharingError} If the user doesn't have permission + * @throws {Error} For other API errors + * + * @remarks + * Only the session owner can create public shares. Public shares are always + * read-only for security. If a public share already exists for the session, + * it will be updated with the new settings. + * + * The returned `token` can be used to construct a public URL for sharing. + */ +export async function createPublicShare( + credentials: AuthCredentials, + sessionId: string, + request: CreatePublicShareRequest & { token: string } +): Promise<PublicSessionShare> { + return await backoff(async () => { + const response = await fetch(`${API_ENDPOINT}/v1/sessions/${sessionId}/public-share`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${credentials.token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(request) + }); + + if (!response.ok) { + if (response.status === 403) { + throw new SessionSharingError('Forbidden'); + } + throw new Error(`Failed to create public share: ${response.status}`); + } + + const data: PublicShareResponse = await response.json(); + return data.publicShare; + }); +} + +/** + * Get public share info for a session + */ +export async function getPublicShare( + credentials: AuthCredentials, + sessionId: string +): Promise<PublicSessionShare | null> { + return await backoff(async () => { + const response = await fetch(`${API_ENDPOINT}/v1/sessions/${sessionId}/public-share`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${credentials.token}`, + } + }); + + if (!response.ok) { + if (response.status === 403) { + throw new SessionSharingError('Forbidden'); + } + throw new Error(`Failed to get public share: ${response.status}`); + } + + const data: PublicShareResponse = await response.json(); + return data.publicShare; + }); +} + +/** + * Delete public share (disable public link) + */ +export async function deletePublicShare( + credentials: AuthCredentials, + sessionId: string +): Promise<void> { + return await backoff(async () => { + const response = await fetch(`${API_ENDPOINT}/v1/sessions/${sessionId}/public-share`, { + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${credentials.token}`, + } + }); + + if (!response.ok) { + if (response.status === 403) { + throw new SessionSharingError('Forbidden'); + } + throw new Error(`Failed to delete public share: ${response.status}`); + } + }); +} + +/** + * Access a session via a public share token + * + * @param token - The public share token from the URL + * @param consent - Whether the user consents to access logging (if required) + * @param credentials - Optional user credentials for authenticated access + * @returns Session data and encrypted key for decryption + * @throws {PublicShareNotFoundError} If the token is invalid, expired, or max uses reached + * @throws {ConsentRequiredError} If consent is required but not provided + * @throws {SessionSharingError} For other access errors + * @throws {Error} For other API errors + * + * @remarks + * This endpoint does not require authentication, allowing anonymous access. + * However, if credentials are provided, the user's identity will be logged. + * + * If the public share has `isConsentRequired` set to true, the `consent` + * parameter must be true, or a ConsentRequiredError will be thrown. + * + * Public shares are always read-only access. The returned session includes + * metadata and an encrypted data key for decrypting the session content. + */ +export async function accessPublicShare( + token: string, + consent?: boolean, + credentials?: AuthCredentials +): Promise<AccessPublicShareResponse> { + return await backoff(async () => { + const url = new URL(`${API_ENDPOINT}/v1/public-share/${token}`); + if (consent !== undefined) { + url.searchParams.set('consent', consent.toString()); + } + + const headers: Record<string, string> = {}; + if (credentials) { + headers['Authorization'] = `Bearer ${credentials.token}`; + } + + const response = await fetch(url.toString(), { + method: 'GET', + headers + }); + + if (!response.ok) { + if (response.status === 404) { + throw new PublicShareNotFoundError(); + } + if (response.status === 403) { + const error = await response.json(); + if (error.requiresConsent) { + throw new ConsentRequiredError(); + } + throw new SessionSharingError(error.error || 'Forbidden'); + } + throw new Error(`Failed to access public share: ${response.status}`); + } + + return await response.json(); + }); +} + +/** + * Get blocked users for public share + */ +export async function getPublicShareBlockedUsers( + credentials: AuthCredentials, + sessionId: string +): Promise<PublicShareBlockedUsersResponse> { + return await backoff(async () => { + const response = await fetch(`${API_ENDPOINT}/v1/sessions/${sessionId}/public-share/blocked-users`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${credentials.token}`, + } + }); + + if (!response.ok) { + if (response.status === 403) { + throw new SessionSharingError('Forbidden'); + } + if (response.status === 404) { + throw new PublicShareNotFoundError(); + } + throw new Error(`Failed to get blocked users: ${response.status}`); + } + + return await response.json(); + }); +} + +/** + * Block user from public share + */ +export async function blockPublicShareUser( + credentials: AuthCredentials, + sessionId: string, + request: BlockPublicShareUserRequest +): Promise<void> { + return await backoff(async () => { + const response = await fetch(`${API_ENDPOINT}/v1/sessions/${sessionId}/public-share/blocked-users`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${credentials.token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(request) + }); + + if (!response.ok) { + if (response.status === 403) { + throw new SessionSharingError('Forbidden'); + } + if (response.status === 404) { + throw new PublicShareNotFoundError(); + } + throw new Error(`Failed to block user: ${response.status}`); + } + }); +} + +/** + * Unblock user from public share + */ +export async function unblockPublicShareUser( + credentials: AuthCredentials, + sessionId: string, + blockedUserId: string +): Promise<void> { + return await backoff(async () => { + const response = await fetch(`${API_ENDPOINT}/v1/sessions/${sessionId}/public-share/blocked-users/${blockedUserId}`, { + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${credentials.token}`, + } + }); + + if (!response.ok) { + if (response.status === 403) { + throw new SessionSharingError('Forbidden'); + } + throw new Error(`Failed to unblock user: ${response.status}`); + } + }); +} + +/** + * Get access logs for public share + */ +export async function getPublicShareAccessLogs( + credentials: AuthCredentials, + sessionId: string, + limit?: number +): Promise<PublicShareAccessLogsResponse> { + return await backoff(async () => { + const url = new URL(`${API_ENDPOINT}/v1/sessions/${sessionId}/public-share/access-logs`); + if (limit !== undefined) { + url.searchParams.set('limit', limit.toString()); + } + + const response = await fetch(url.toString(), { + method: 'GET', + headers: { + 'Authorization': `Bearer ${credentials.token}`, + } + }); + + if (!response.ok) { + if (response.status === 403) { + throw new SessionSharingError('Forbidden'); + } + if (response.status === 404) { + throw new PublicShareNotFoundError(); + } + throw new Error(`Failed to get access logs: ${response.status}`); + } + + return await response.json(); + }); +} diff --git a/expo-app/sources/sync/apiSocket.ts b/expo-app/sources/sync/apiSocket.ts index 7e64ae583..5bbf0004b 100644 --- a/expo-app/sources/sync/apiSocket.ts +++ b/expo-app/sources/sync/apiSocket.ts @@ -1,6 +1,9 @@ import { io, Socket } from 'socket.io-client'; import { TokenStorage } from '@/auth/tokenStorage'; import { Encryption } from './encryption/encryption'; +import { observeServerTimestamp } from './time'; +import { createRpcCallError } from './rpcErrors'; +import { SOCKET_RPC_EVENTS } from '@happy/protocol/socketRpc'; // // Types @@ -32,6 +35,7 @@ class ApiSocket { private messageHandlers: Map<string, (data: any) => void> = new Map(); private reconnectedListeners: Set<() => void> = new Set(); private statusListeners: Set<(status: 'disconnected' | 'connecting' | 'connected' | 'error') => void> = new Set(); + private errorListeners: Set<(error: Error | null) => void> = new Set(); private currentStatus: 'disconnected' | 'connecting' | 'connected' | 'error' = 'disconnected'; // @@ -95,6 +99,11 @@ class ApiSocket { return () => this.statusListeners.delete(listener); }; + onError = (listener: (error: Error | null) => void) => { + this.errorListeners.add(listener); + return () => this.errorListeners.delete(listener); + }; + // // Message Handling // @@ -117,7 +126,7 @@ class ApiSocket { throw new Error(`Session encryption not found for ${sessionId}`); } - const result = await this.socket!.emitWithAck('rpc-call', { + const result: any = await this.socket!.emitWithAck(SOCKET_RPC_EVENTS.CALL, { method: `${sessionId}:${method}`, params: await sessionEncryption.encryptRaw(params) }); @@ -125,7 +134,10 @@ class ApiSocket { if (result.ok) { return await sessionEncryption.decryptRaw(result.result) as R; } - throw new Error('RPC call failed'); + throw createRpcCallError({ + error: typeof result.error === 'string' ? result.error : 'RPC call failed', + errorCode: typeof result.errorCode === 'string' ? result.errorCode : undefined, + }); } /** @@ -137,7 +149,7 @@ class ApiSocket { throw new Error(`Machine encryption not found for ${machineId}`); } - const result = await this.socket!.emitWithAck('rpc-call', { + const result: any = await this.socket!.emitWithAck(SOCKET_RPC_EVENTS.CALL, { method: `${machineId}:${method}`, params: await machineEncryption.encryptRaw(params) }); @@ -145,7 +157,10 @@ class ApiSocket { if (result.ok) { return await machineEncryption.decryptRaw(result.result) as R; } - throw new Error('RPC call failed'); + throw createRpcCallError({ + error: typeof result.error === 'string' ? result.error : 'RPC call failed', + errorCode: typeof result.errorCode === 'string' ? result.errorCode : undefined, + }); } send(event: string, data: any) { @@ -180,10 +195,26 @@ class ApiSocket { ...options?.headers }; - return fetch(url, { + const response = await fetch(url, { ...options, headers }); + + // Best-effort server time calibration using the HTTP Date header ("server now"). + // This avoids deriving "now" from potentially stale resource timestamps (e.g. session.updatedAt). + try { + const dateHeader = response.headers.get('date'); + if (dateHeader) { + const serverNow = Date.parse(dateHeader); + if (!Number.isNaN(serverNow)) { + observeServerTimestamp(serverNow); + } + } + } catch { + // Best-effort only + } + + return response; } // @@ -220,6 +251,8 @@ class ApiSocket { // console.log('🔌 SyncSocket: Connected, recovered: ' + this.socket?.recovered); // console.log('🔌 SyncSocket: Socket ID:', this.socket?.id); this.updateStatus('connected'); + // Clear last error on successful connect + this.errorListeners.forEach(listener => listener(null)); if (!this.socket?.recovered) { this.reconnectedListeners.forEach(listener => listener()); } @@ -234,11 +267,13 @@ class ApiSocket { this.socket.on('connect_error', (error) => { // console.error('🔌 SyncSocket: Connection error', error); this.updateStatus('error'); + this.errorListeners.forEach(listener => listener(error)); }); this.socket.on('error', (error) => { // console.error('🔌 SyncSocket: Error', error); this.updateStatus('error'); + this.errorListeners.forEach(listener => listener(error)); }); // Message handling @@ -259,4 +294,4 @@ class ApiSocket { // Singleton Export // -export const apiSocket = new ApiSocket(); \ No newline at end of file +export const apiSocket = new ApiSocket(); diff --git a/expo-app/sources/sync/apiTypes.ts b/expo-app/sources/sync/apiTypes.ts index 4de92559f..1aa7cf798 100644 --- a/expo-app/sources/sync/apiTypes.ts +++ b/expo-app/sources/sync/apiTypes.ts @@ -147,6 +147,24 @@ export const ApiKvBatchUpdateSchema = z.object({ })) }); +// Session sharing event schemas +export const ApiSessionSharedSchema = z.object({ + t: z.literal('session-shared'), + sessionId: z.string(), +}); + +export const ApiSessionShareUpdatedSchema = z.object({ + t: z.literal('session-share-updated'), + sessionId: z.string(), + shareId: z.string(), +}); + +export const ApiSessionShareRevokedSchema = z.object({ + t: z.literal('session-share-revoked'), + sessionId: z.string(), + shareId: z.string(), +}); + export const ApiUpdateSchema = z.discriminatedUnion('t', [ ApiUpdateNewMessageSchema, ApiUpdateNewSessionSchema, @@ -159,7 +177,10 @@ export const ApiUpdateSchema = z.discriminatedUnion('t', [ ApiDeleteArtifactSchema, ApiRelationshipUpdatedSchema, ApiNewFeedPostSchema, - ApiKvBatchUpdateSchema + ApiKvBatchUpdateSchema, + ApiSessionSharedSchema, + ApiSessionShareUpdatedSchema, + ApiSessionShareRevokedSchema ]); export type ApiUpdateNewMessage = z.infer<typeof ApiUpdateNewMessageSchema>; @@ -228,4 +249,4 @@ export type ApiEphemeralActivityUpdate = z.infer<typeof ApiEphemeralActivityUpda export type ApiEphemeralUpdate = z.infer<typeof ApiEphemeralUpdateSchema>; // Machine metadata updates use Partial<MachineMetadata> from storageTypes -// This matches how session metadata updates work \ No newline at end of file +// This matches how session metadata updates work diff --git a/expo-app/sources/sync/apiUsage.ts b/expo-app/sources/sync/apiUsage.ts index 751abbc7f..ac313a8a8 100644 --- a/expo-app/sources/sync/apiUsage.ts +++ b/expo-app/sources/sync/apiUsage.ts @@ -1,5 +1,6 @@ import { AuthCredentials } from '@/auth/tokenStorage'; import { backoff } from '@/utils/time'; +import { HappyError } from '@/utils/errors'; import { getServerUrl } from './serverConfig'; export interface UsageDataPoint { @@ -41,7 +42,17 @@ export async function queryUsage( if (!response.ok) { if (response.status === 404 && params.sessionId) { - throw new Error('Session not found'); + throw new HappyError('Session not found', false, { status: 404, kind: 'config' }); + } + if (response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429) { + let message = 'Failed to query usage'; + try { + const error = await response.json(); + if (error?.error) message = error.error; + } catch { + // ignore + } + throw new HappyError(message, false, { status: response.status, kind: response.status === 401 || response.status === 403 ? 'auth' : 'config' }); } throw new Error(`Failed to query usage: ${response.status}`); } diff --git a/expo-app/sources/sync/capabilitiesProtocol.ts b/expo-app/sources/sync/capabilitiesProtocol.ts new file mode 100644 index 000000000..907dc3db1 --- /dev/null +++ b/expo-app/sources/sync/capabilitiesProtocol.ts @@ -0,0 +1,193 @@ +import type { + CapabilityDetectRequest, + CapabilityDetectResult, + CapabilityDescriptor, + CapabilityId, + CapabilityKind, + CapabilitiesDescribeResponse, + CapabilitiesDetectResponse, + CapabilitiesInvokeRequest, + CapabilitiesInvokeResponse, +} from '@happy/protocol/capabilities'; +import type { ChecklistId as ProtocolChecklistId } from '@happy/protocol/checklists'; + +export type { + CapabilityDetectRequest, + CapabilityDetectResult, + CapabilityDescriptor, + CapabilityId, + CapabilityKind, + CapabilitiesDescribeResponse, + CapabilitiesDetectResponse, + CapabilitiesInvokeRequest, + CapabilitiesInvokeResponse, +}; + +export type ChecklistId = ProtocolChecklistId; + +export type CapabilitiesDetectRequest = { + checklistId?: ChecklistId | string; + requests?: CapabilityDetectRequest[]; + overrides?: Partial<Record<CapabilityId, { params?: Record<string, unknown> }>>; +}; + +export type CliCapabilityData = { + available: boolean; + resolvedPath?: string; + version?: string; + isLoggedIn?: boolean | null; +}; + +export type TmuxCapabilityData = { + available: boolean; + resolvedPath?: string; + version?: string; +}; + +export type CodexMcpResumeDepData = { + installed: boolean; + installDir: string; + binPath: string | null; + installedVersion: string | null; + distTag: string; + lastInstallLogPath: string | null; + registry?: { ok: true; latestVersion: string | null } | { ok: false; errorMessage: string }; +}; + +export type CodexAcpDepData = { + installed: boolean; + installDir: string; + binPath: string | null; + installedVersion: string | null; + distTag: string; + lastInstallLogPath: string | null; + registry?: { ok: true; latestVersion: string | null } | { ok: false; errorMessage: string }; +}; + +function isPlainObject(value: unknown): value is Record<string, unknown> { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function parseCapabilityId(raw: unknown): CapabilityId | null { + if (typeof raw !== 'string') return null; + const trimmed = raw.trim(); + if (!trimmed) return null; + if (/^(cli|tool|dep)\.[A-Za-z0-9][A-Za-z0-9._-]*$/.test(trimmed)) { + return trimmed as CapabilityId; + } + return null; +} + +function parseDescriptor(raw: unknown): CapabilityDescriptor | null { + if (!isPlainObject(raw)) return null; + const id = parseCapabilityId(raw.id); + const kind = raw.kind; + if (!id) return null; + if (!(kind === 'cli' || kind === 'tool' || kind === 'dep')) return null; + + const out: CapabilityDescriptor = { id, kind }; + if (typeof raw.title === 'string') out.title = raw.title; + if (isPlainObject(raw.methods)) { + const methods: Record<string, { title?: string }> = {}; + for (const [k, v] of Object.entries(raw.methods)) { + if (!isPlainObject(v)) continue; + methods[k] = typeof v.title === 'string' ? { title: v.title } : {}; + } + out.methods = methods; + } + return out; +} + +function parseDetectRequest(raw: unknown): CapabilityDetectRequest | null { + if (!isPlainObject(raw)) return null; + const id = parseCapabilityId(raw.id); + if (!id) return null; + const params = raw.params; + return { + id, + ...(isPlainObject(params) ? { params } : {}), + }; +} + +function parseDetectResult(raw: unknown): CapabilityDetectResult | null { + if (!isPlainObject(raw)) return null; + const ok = raw.ok; + const checkedAt = raw.checkedAt; + if (typeof ok !== 'boolean') return null; + if (typeof checkedAt !== 'number') return null; + if (ok) { + return { ok: true, checkedAt, data: (raw as any).data }; + } + const error = (raw as any).error; + if (!isPlainObject(error) || typeof error.message !== 'string') return null; + const code = (error as any).code; + return { ok: false, checkedAt, error: { message: error.message, ...(typeof code === 'string' ? { code } : {}) } }; +} + +export function parseCapabilitiesDescribeResponse(raw: unknown): CapabilitiesDescribeResponse | null { + if (!isPlainObject(raw)) return null; + if (raw.protocolVersion !== 1) return null; + + const capabilitiesRaw = raw.capabilities; + const checklistsRaw = raw.checklists; + if (!Array.isArray(capabilitiesRaw)) return null; + if (!isPlainObject(checklistsRaw)) return null; + + const capabilities: CapabilityDescriptor[] = []; + for (const c of capabilitiesRaw) { + const parsed = parseDescriptor(c); + if (parsed) capabilities.push(parsed); + } + + const checklists: Record<string, CapabilityDetectRequest[]> = {}; + for (const [k, v] of Object.entries(checklistsRaw)) { + if (!Array.isArray(v)) continue; + const list: CapabilityDetectRequest[] = []; + for (const entry of v) { + const parsed = parseDetectRequest(entry); + if (parsed) list.push(parsed); + } + checklists[k] = list; + } + + return { + protocolVersion: 1, + capabilities, + checklists, + }; +} + +export function parseCapabilitiesDetectResponse(raw: unknown): CapabilitiesDetectResponse | null { + if (!isPlainObject(raw)) return null; + if (raw.protocolVersion !== 1) return null; + const resultsRaw = raw.results; + if (!isPlainObject(resultsRaw)) return null; + + const results: Partial<Record<CapabilityId, CapabilityDetectResult>> = {}; + for (const [k, v] of Object.entries(resultsRaw)) { + const id = parseCapabilityId(k); + if (!id) continue; + const parsed = parseDetectResult(v); + if (parsed) results[id] = parsed; + } + + return { protocolVersion: 1, results }; +} + +export function parseCapabilitiesInvokeResponse(raw: unknown): CapabilitiesInvokeResponse | null { + if (!isPlainObject(raw)) return null; + const ok = raw.ok; + if (typeof ok !== 'boolean') return null; + if (ok) { + return { ok: true, result: (raw as any).result }; + } + const error = (raw as any).error; + if (!isPlainObject(error) || typeof error.message !== 'string') return null; + const code = (error as any).code; + const logPath = (raw as any).logPath; + return { + ok: false, + error: { message: error.message, ...(typeof code === 'string' ? { code } : {}) }, + ...((typeof logPath === 'string') ? { logPath } : {}), + }; +} diff --git a/expo-app/sources/sync/controlledByUserTransitions.test.ts b/expo-app/sources/sync/controlledByUserTransitions.test.ts new file mode 100644 index 000000000..575014e58 --- /dev/null +++ b/expo-app/sources/sync/controlledByUserTransitions.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; + +import { didControlReturnToMobile } from './controlledByUserTransitions'; + +describe('didControlReturnToMobile', () => { + it('returns true when controlledByUser flips from true to false', () => { + expect(didControlReturnToMobile(true, false)).toBe(true); + }); + + it('returns true when controlledByUser flips from true to nullish', () => { + expect(didControlReturnToMobile(true, null)).toBe(true); + expect(didControlReturnToMobile(true, undefined)).toBe(true); + }); + + it('returns false for all other transitions', () => { + expect(didControlReturnToMobile(false, true)).toBe(false); + expect(didControlReturnToMobile(false, false)).toBe(false); + expect(didControlReturnToMobile(undefined, true)).toBe(false); + expect(didControlReturnToMobile(undefined, undefined)).toBe(false); + expect(didControlReturnToMobile(null, false)).toBe(false); + }); +}); + diff --git a/expo-app/sources/sync/controlledByUserTransitions.ts b/expo-app/sources/sync/controlledByUserTransitions.ts new file mode 100644 index 000000000..60546efe8 --- /dev/null +++ b/expo-app/sources/sync/controlledByUserTransitions.ts @@ -0,0 +1,7 @@ +export function didControlReturnToMobile( + wasControlledByUser: boolean | null | undefined, + isNowControlledByUser: boolean | null | undefined +): boolean { + return wasControlledByUser === true && isNowControlledByUser !== true; +} + diff --git a/expo-app/sources/sync/debugSettings.ts b/expo-app/sources/sync/debugSettings.ts new file mode 100644 index 000000000..625472ce1 --- /dev/null +++ b/expo-app/sources/sync/debugSettings.ts @@ -0,0 +1,101 @@ +import type { Settings } from './settings'; + +const WEB_FLAG_KEY = 'HAPPY_DEBUG_SETTINGS_SYNC'; + +function readWebFlag(): boolean { + try { + if (typeof window === 'undefined') return false; + const v = window.localStorage?.getItem(WEB_FLAG_KEY); + if (!v) return false; + const normalized = v.trim().toLowerCase(); + return normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'on'; + } catch { + return false; + } +} + +/** + * Opt-in debug switch for verbose settings sync logging. + * + * - Web: `localStorage.setItem('HAPPY_DEBUG_SETTINGS_SYNC', '1')` then reload + * - Native: set env `EXPO_PUBLIC_HAPPY_DEBUG_SETTINGS_SYNC=1` + */ +export function isSettingsSyncDebugEnabled(env: Record<string, string | undefined> = process.env): boolean { + const fromEnv = env.EXPO_PUBLIC_HAPPY_DEBUG_SETTINGS_SYNC; + if (typeof fromEnv === 'string') { + const n = fromEnv.trim().toLowerCase(); + if (n === '1' || n === 'true' || n === 'yes' || n === 'on') return true; + } + // Avoid importing react-native here (breaks node/vitest due to Flow syntax in RN entrypoint). + // Web-only fallback: allow toggling via localStorage. + return typeof window !== 'undefined' ? readWebFlag() : false; +} + +function safeKeys(obj: unknown): string[] { + if (!obj || typeof obj !== 'object') return []; + return Object.keys(obj as Record<string, unknown>); +} + +export function summarizeSettingsDelta(delta: Partial<Settings>): Record<string, unknown> { + const keys = safeKeys(delta).sort(); + const out: Record<string, unknown> = { keys }; + + if ('secrets' in delta) { + const arr = (delta as any).secrets; + if (Array.isArray(arr)) { + out.secrets = { + count: arr.length, + entries: arr.slice(0, 20).map((k: any) => ({ + id: typeof k?.id === 'string' ? k.id : null, + name: typeof k?.name === 'string' ? k.name : null, + hasValue: typeof k?.encryptedValue?.value === 'string' && k.encryptedValue.value.length > 0, + hasEncryptedValue: Boolean(k?.encryptedValue?._isSecretValue === true && k?.encryptedValue?.encryptedValue && typeof k.encryptedValue.encryptedValue.c === 'string' && k.encryptedValue.encryptedValue.c.length > 0), + })), + }; + } else { + out.secrets = { type: typeof arr }; + } + } + + if ('secretBindingsByProfileId' in delta) { + const m = (delta as any).secretBindingsByProfileId; + out.secretBindingsByProfileId = { + keys: safeKeys(m).slice(0, 50).sort(), + }; + } + + return out; +} + +export function summarizeSettings(settings: Settings, extra?: { version?: number | null }): Record<string, unknown> { + return { + ...(extra ? extra : {}), + schemaVersion: (settings as any)?.schemaVersion ?? null, + secrets: { + count: Array.isArray((settings as any)?.secrets) ? (settings as any).secrets.length : null, + anyMissingValue: Array.isArray((settings as any)?.secrets) + ? (settings as any).secrets.some((k: any) => !( + (typeof k?.encryptedValue?.value === 'string' && k.encryptedValue.value.length > 0) || + (k?.encryptedValue?._isSecretValue === true && k?.encryptedValue?.encryptedValue && typeof k.encryptedValue.encryptedValue.c === 'string' && k.encryptedValue.encryptedValue.c.length > 0) + )) + : null, + }, + profilesCount: Array.isArray((settings as any)?.profiles) ? (settings as any).profiles.length : null, + }; +} + +export function dbgSettings( + label: string, + data?: Record<string, unknown>, + opts?: { force?: boolean; env?: Record<string, string | undefined> } +) { + const enabled = isSettingsSyncDebugEnabled(opts?.env); + if (!enabled && !opts?.force) return; + try { + // eslint-disable-next-line no-console + console.log(`[settings-sync] ${label}`, data ?? {}); + } catch { + // ignore + } +} + diff --git a/expo-app/sources/sync/directShareEncryption.ts b/expo-app/sources/sync/directShareEncryption.ts new file mode 100644 index 000000000..8bb450c78 --- /dev/null +++ b/expo-app/sources/sync/directShareEncryption.ts @@ -0,0 +1,38 @@ +import { encodeBase64, decodeBase64 } from '@/encryption/base64'; +import { encryptBox } from '@/encryption/libsodium'; +import { decodeHex } from '@/encryption/hex'; +import sodium from '@/encryption/libsodium.lib'; + +const CONTENT_KEY_BINDING_PREFIX = new TextEncoder().encode('Happy content key v1\u0000'); + +export function encryptDataKeyForRecipientV0( + sessionDataKey: Uint8Array, + recipientContentPublicKeyB64: string +): string { + const recipientPublicKey = decodeBase64(recipientContentPublicKeyB64, 'base64'); + const bundle = encryptBox(sessionDataKey, recipientPublicKey); + + const out = new Uint8Array(1 + bundle.length); + out[0] = 0; + out.set(bundle, 1); + + return encodeBase64(out, 'base64'); +} + +export function verifyRecipientContentPublicKeyBinding(params: { + signingPublicKeyHex: string; + contentPublicKeyB64: string; + contentPublicKeySigB64: string; +}): boolean { + try { + const signingPublicKey = decodeHex(params.signingPublicKeyHex); + const contentPublicKey = decodeBase64(params.contentPublicKeyB64, 'base64'); + const sig = decodeBase64(params.contentPublicKeySigB64, 'base64'); + const message = new Uint8Array(CONTENT_KEY_BINDING_PREFIX.length + contentPublicKey.length); + message.set(CONTENT_KEY_BINDING_PREFIX, 0); + message.set(contentPublicKey, CONTENT_KEY_BINDING_PREFIX.length); + return sodium.crypto_sign_verify_detached(sig, message, signingPublicKey); + } catch { + return false; + } +} diff --git a/expo-app/sources/sync/encryption/artifactEncryption.ts b/expo-app/sources/sync/encryption/artifactEncryption.ts index 6fe10dca1..160356367 100644 --- a/expo-app/sources/sync/encryption/artifactEncryption.ts +++ b/expo-app/sources/sync/encryption/artifactEncryption.ts @@ -1,7 +1,7 @@ import { decodeBase64, encodeBase64 } from '@/encryption/base64'; import { ArtifactHeader, ArtifactBody } from '../artifactTypes'; import { AES256Encryption } from './encryptor'; -import * as Random from 'expo-crypto'; +import { getRandomBytes } from '@/platform/cryptoRandom'; export class ArtifactEncryption { private encryptor: AES256Encryption; @@ -14,7 +14,7 @@ export class ArtifactEncryption { * Generate a new data encryption key for an artifact */ static generateDataEncryptionKey(): Uint8Array { - return Random.getRandomBytes(32); // 256 bits for AES-256 + return getRandomBytes(32); // 256 bits for AES-256 } /** diff --git a/expo-app/sources/sync/encryption/encryption.ts b/expo-app/sources/sync/encryption/encryption.ts index aaa94289e..c5e2717fd 100644 --- a/expo-app/sources/sync/encryption/encryption.ts +++ b/expo-app/sources/sync/encryption/encryption.ts @@ -7,7 +7,7 @@ import { MachineEncryption } from "./machineEncryption"; import { encodeBase64, decodeBase64 } from "@/encryption/base64"; import sodium from '@/encryption/libsodium.lib'; import { decryptBox, encryptBox } from "@/encryption/libsodium"; -import { randomUUID } from 'expo-crypto'; +import { randomUUID } from '@/platform/randomUUID'; export class Encryption { diff --git a/expo-app/sources/sync/encryption/encryptor.appspec.ts b/expo-app/sources/sync/encryption/encryptor.appspec.ts index 26e5ba0df..de3a9b42b 100644 --- a/expo-app/sources/sync/encryption/encryptor.appspec.ts +++ b/expo-app/sources/sync/encryption/encryptor.appspec.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from '@/dev/testRunner'; import { SecretBoxEncryption, BoxEncryption, AES256Encryption } from './encryptor'; -import { getRandomBytes } from 'expo-crypto'; +import { getRandomBytes } from '@/platform/cryptoRandom'; describe('SecretBoxEncryption', () => { it('should encrypt and decrypt single Uint8Array', async () => { diff --git a/expo-app/sources/sync/encryption/publicShareEncryption.ts b/expo-app/sources/sync/encryption/publicShareEncryption.ts new file mode 100644 index 000000000..f77bd4374 --- /dev/null +++ b/expo-app/sources/sync/encryption/publicShareEncryption.ts @@ -0,0 +1,68 @@ +import { deriveKey } from '@/encryption/deriveKey'; +import { encryptSecretBox, decryptSecretBox } from '@/encryption/libsodium'; +import { encodeBase64, decodeBase64 } from '@/encryption/base64'; + +/** + * Encrypt a data encryption key for public sharing using a token + * + * @param dataEncryptionKey - The session's data encryption key to encrypt + * @param token - The random public share token + * @returns Base64 encoded encrypted data key + * + * @remarks + * Uses SecretBox encryption with a key derived from the token. + * The token must be kept secret as it enables decryption. + */ +export async function encryptDataKeyForPublicShare( + dataEncryptionKey: Uint8Array, + token: string +): Promise<string> { + // Derive encryption key from token + const tokenBytes = new TextEncoder().encode(token); + const encryptionKey = await deriveKey(tokenBytes, 'Happy Public Share', ['v1']); + + // IMPORTANT: encryptSecretBox JSON-stringifies its input, so we must not pass Uint8Array directly. + const payload = { + v: 0, + keyB64: encodeBase64(dataEncryptionKey, 'base64'), + }; + const encrypted = encryptSecretBox(payload, encryptionKey); + + // Return as base64 + return encodeBase64(encrypted, 'base64'); +} + +/** + * Decrypt a data encryption key from a public share using a token + * + * @param encryptedDataKey - The encrypted data key (base64) + * @param token - The public share token + * @returns Decrypted data encryption key, or null if decryption fails + * + * @remarks + * This is the inverse of encryptDataKeyForPublicShare. + */ +export async function decryptDataKeyFromPublicShare( + encryptedDataKey: string, + token: string +): Promise<Uint8Array | null> { + try { + // Derive decryption key from token + const tokenBytes = new TextEncoder().encode(token); + const decryptionKey = await deriveKey(tokenBytes, 'Happy Public Share', ['v1']); + + // Decode from base64 + const encrypted = decodeBase64(encryptedDataKey, 'base64'); + + const payload = decryptSecretBox(encrypted, decryptionKey) as { v: number; keyB64: string } | null; + if (!payload || payload.v !== 0) { + return null; + } + if (typeof payload.keyB64 !== 'string') { + return null; + } + return decodeBase64(payload.keyB64, 'base64'); + } catch (error) { + return null; + } +} diff --git a/expo-app/sources/sync/engine/account.ts b/expo-app/sources/sync/engine/account.ts new file mode 100644 index 000000000..93eca72a3 --- /dev/null +++ b/expo-app/sources/sync/engine/account.ts @@ -0,0 +1,132 @@ +import Constants from 'expo-constants'; +import * as Notifications from 'expo-notifications'; +import { Platform } from 'react-native'; + +import { registerPushToken as registerPushTokenApi } from '../apiPush'; +import type { Encryption } from '../encryption/encryption'; +import type { Profile } from '../profile'; +import { profileParse } from '../profile'; +import { settingsParse, SUPPORTED_SCHEMA_VERSION } from '../settings'; +import { getServerUrl } from '../serverConfig'; +import type { AuthCredentials } from '@/auth/tokenStorage'; +import { HappyError } from '@/utils/errors'; + +export async function handleUpdateAccountSocketUpdate(params: { + accountUpdate: any; + updateCreatedAt: number; + currentProfile: Profile; + encryption: Encryption; + applyProfile: (profile: Profile) => void; + applySettings: (settings: any, version: number) => void; + log: { log: (message: string) => void }; +}): Promise<void> { + const { accountUpdate, updateCreatedAt, currentProfile, encryption, applyProfile, applySettings, log } = params; + + // Build updated profile with new data + const updatedProfile: Profile = { + ...currentProfile, + firstName: accountUpdate.firstName !== undefined ? accountUpdate.firstName : currentProfile.firstName, + lastName: accountUpdate.lastName !== undefined ? accountUpdate.lastName : currentProfile.lastName, + avatar: accountUpdate.avatar !== undefined ? accountUpdate.avatar : currentProfile.avatar, + github: accountUpdate.github !== undefined ? accountUpdate.github : currentProfile.github, + timestamp: updateCreatedAt, // Update timestamp to latest + }; + + // Apply the updated profile to storage + applyProfile(updatedProfile); + + // Handle settings updates (new for profile sync) + if (accountUpdate.settings?.value) { + try { + const decryptedSettings = await encryption.decryptRaw(accountUpdate.settings.value); + const parsedSettings = settingsParse(decryptedSettings); + + // Version compatibility check + const settingsSchemaVersion = parsedSettings.schemaVersion ?? 1; + if (settingsSchemaVersion > SUPPORTED_SCHEMA_VERSION) { + console.warn( + `⚠️ Received settings schema v${settingsSchemaVersion}, ` + + `we support v${SUPPORTED_SCHEMA_VERSION}. Update app for full functionality.`, + ); + } + + applySettings(parsedSettings, accountUpdate.settings.version); + log.log( + `📋 Settings synced from server (schema v${settingsSchemaVersion}, version ${accountUpdate.settings.version})`, + ); + } catch (error) { + console.error('❌ Failed to process settings update:', error); + // Don't crash on settings sync errors, just log + } + } +} + +export async function fetchAndApplyProfile(params: { + credentials: AuthCredentials; + applyProfile: (profile: Profile) => void; +}): Promise<void> { + const { credentials, applyProfile } = params; + + const API_ENDPOINT = getServerUrl(); + const response = await fetch(`${API_ENDPOINT}/v1/account/profile`, { + headers: { + 'Authorization': `Bearer ${credentials.token}`, + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + if (response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429) { + throw new HappyError(`Failed to fetch profile (${response.status})`, false); + } + throw new Error(`Failed to fetch profile: ${response.status}`); + } + + const data = await response.json(); + const parsedProfile = profileParse(data); + + // Apply profile to storage + applyProfile(parsedProfile); +} + +export async function registerPushTokenIfAvailable(params: { + credentials: AuthCredentials; + log: { log: (message: string) => void }; +}): Promise<void> { + const { credentials, log } = params; + + // Only register on mobile platforms + if (Platform.OS === 'web') { + return; + } + + // Request permission + const { status: existingStatus } = await Notifications.getPermissionsAsync(); + let finalStatus = existingStatus; + log.log('existingStatus: ' + JSON.stringify(existingStatus)); + + if (existingStatus !== 'granted') { + const { status } = await Notifications.requestPermissionsAsync(); + finalStatus = status; + } + log.log('finalStatus: ' + JSON.stringify(finalStatus)); + + if (finalStatus !== 'granted') { + log.log('Failed to get push token for push notification!'); + return; + } + + // Get push token + const projectId = Constants?.expoConfig?.extra?.eas?.projectId ?? Constants?.easConfig?.projectId; + + const tokenData = await Notifications.getExpoPushTokenAsync({ projectId }); + log.log('tokenData: ' + JSON.stringify(tokenData)); + + // Register with server + try { + await registerPushTokenApi(credentials, tokenData.data); + log.log('Push token registered successfully'); + } catch (error) { + log.log('Failed to register push token: ' + JSON.stringify(error)); + } +} diff --git a/expo-app/sources/sync/engine/artifacts.ts b/expo-app/sources/sync/engine/artifacts.ts new file mode 100644 index 000000000..1a57ac79f --- /dev/null +++ b/expo-app/sources/sync/engine/artifacts.ts @@ -0,0 +1,575 @@ +import type { AuthCredentials } from '@/auth/tokenStorage'; +import { encodeBase64 } from '@/encryption/base64'; +import { log } from '@/log'; +import { + createArtifact as createArtifactApi, + fetchArtifact as fetchArtifactApi, + fetchArtifacts as fetchArtifactsApi, + updateArtifact as updateArtifactApi, +} from '../apiArtifacts'; +import type { Encryption } from '../encryption/encryption'; +import { ArtifactEncryption } from '../encryption/artifactEncryption'; +import type { Artifact, ArtifactCreateRequest, ArtifactUpdateRequest, DecryptedArtifact } from '../artifactTypes'; + +export async function decryptArtifactListItem(params: { + artifact: Artifact; + encryption: Encryption; + artifactDataKeys: Map<string, Uint8Array>; +}): Promise<DecryptedArtifact | null> { + const { artifact, encryption, artifactDataKeys } = params; + + try { + // Decrypt the data encryption key + const decryptedKey = await encryption.decryptEncryptionKey(artifact.dataEncryptionKey); + if (!decryptedKey) { + console.error(`Failed to decrypt key for artifact ${artifact.id}`); + return null; + } + + // Store the decrypted key in memory + artifactDataKeys.set(artifact.id, decryptedKey); + + // Create artifact encryption instance + const artifactEncryption = new ArtifactEncryption(decryptedKey); + + // Decrypt header + const header = await artifactEncryption.decryptHeader(artifact.header); + + return { + id: artifact.id, + title: header?.title || null, + sessions: header?.sessions, + draft: header?.draft, + body: undefined, // Body not loaded in list + headerVersion: artifact.headerVersion, + bodyVersion: artifact.bodyVersion, + seq: artifact.seq, + createdAt: artifact.createdAt, + updatedAt: artifact.updatedAt, + isDecrypted: !!header, + }; + } catch (err) { + console.error(`Failed to decrypt artifact ${artifact.id}:`, err); + // Add with decryption failed flag (body is not loaded for list items) + return { + id: artifact.id, + title: null, + body: undefined, + headerVersion: artifact.headerVersion, + seq: artifact.seq, + createdAt: artifact.createdAt, + updatedAt: artifact.updatedAt, + isDecrypted: false, + }; + } +} + +export async function decryptArtifactWithBody(params: { + artifact: Artifact; + encryption: Encryption; + artifactDataKeys: Map<string, Uint8Array>; +}): Promise<DecryptedArtifact | null> { + const { artifact, encryption, artifactDataKeys } = params; + + try { + // Decrypt the data encryption key + const decryptedKey = await encryption.decryptEncryptionKey(artifact.dataEncryptionKey); + if (!decryptedKey) { + console.error(`Failed to decrypt key for artifact ${artifact.id}`); + return null; + } + + // Store the decrypted key in memory + artifactDataKeys.set(artifact.id, decryptedKey); + + // Create artifact encryption instance + const artifactEncryption = new ArtifactEncryption(decryptedKey); + + // Decrypt header and body + const header = await artifactEncryption.decryptHeader(artifact.header); + const body = artifact.body ? await artifactEncryption.decryptBody(artifact.body) : null; + + return { + id: artifact.id, + title: header?.title || null, + sessions: header?.sessions, + draft: header?.draft, + body: body?.body || null, + headerVersion: artifact.headerVersion, + bodyVersion: artifact.bodyVersion, + seq: artifact.seq, + createdAt: artifact.createdAt, + updatedAt: artifact.updatedAt, + isDecrypted: !!header, + }; + } catch (error) { + console.error(`Failed to decrypt artifact ${artifact.id}:`, error); + return null; + } +} + +export async function fetchAndApplyArtifactsList(params: { + credentials: AuthCredentials | null | undefined; + encryption: Encryption; + artifactDataKeys: Map<string, Uint8Array>; + applyArtifacts: (artifacts: DecryptedArtifact[]) => void; +}): Promise<void> { + const { credentials, encryption, artifactDataKeys, applyArtifacts } = params; + + log.log('📦 fetchArtifactsList: Starting artifact sync'); + if (!credentials) { + log.log('📦 fetchArtifactsList: No credentials, skipping'); + return; + } + + try { + log.log('📦 fetchArtifactsList: Fetching artifacts from server'); + const artifacts = await fetchArtifactsApi(credentials); + log.log(`📦 fetchArtifactsList: Received ${artifacts.length} artifacts from server`); + const decryptedArtifacts: DecryptedArtifact[] = []; + + for (const artifact of artifacts) { + const decrypted = await decryptArtifactListItem({ + artifact, + encryption, + artifactDataKeys, + }); + if (decrypted) { + decryptedArtifacts.push(decrypted); + } + } + + log.log(`📦 fetchArtifactsList: Successfully decrypted ${decryptedArtifacts.length} artifacts`); + applyArtifacts(decryptedArtifacts); + log.log('📦 fetchArtifactsList: Artifacts applied to storage'); + } catch (error) { + log.log(`📦 fetchArtifactsList: Error fetching artifacts: ${error}`); + console.error('Failed to fetch artifacts:', error); + throw error; + } +} + +export async function fetchArtifactWithBodyFromApi(params: { + credentials: AuthCredentials; + artifactId: string; + encryption: Encryption; + artifactDataKeys: Map<string, Uint8Array>; +}): Promise<DecryptedArtifact | null> { + const { credentials, artifactId, encryption, artifactDataKeys } = params; + + try { + const artifact = await fetchArtifactApi(credentials, artifactId); + return await decryptArtifactWithBody({ + artifact, + encryption, + artifactDataKeys, + }); + } catch (error) { + console.error(`Failed to fetch artifact ${artifactId}:`, error); + return null; + } +} + +export async function createArtifactViaApi(params: { + credentials: AuthCredentials; + title: string | null; + body: string | null; + sessions?: string[]; + draft?: boolean; + encryption: Encryption; + artifactDataKeys: Map<string, Uint8Array>; + addArtifact: (artifact: DecryptedArtifact) => void; +}): Promise<string> { + const { credentials, title, body, sessions, draft, encryption, artifactDataKeys, addArtifact } = params; + + try { + // Generate unique artifact ID + const artifactId = encryption.generateId(); + + // Generate data encryption key + const dataEncryptionKey = ArtifactEncryption.generateDataEncryptionKey(); + + // Store the decrypted key in memory + artifactDataKeys.set(artifactId, dataEncryptionKey); + + // Encrypt the data encryption key with user's key + const encryptedKey = await encryption.encryptEncryptionKey(dataEncryptionKey); + + // Create artifact encryption instance + const artifactEncryption = new ArtifactEncryption(dataEncryptionKey); + + // Encrypt header and body + const encryptedHeader = await artifactEncryption.encryptHeader({ title, sessions, draft }); + const encryptedBody = await artifactEncryption.encryptBody({ body }); + + // Create the request + const request: ArtifactCreateRequest = { + id: artifactId, + header: encryptedHeader, + body: encryptedBody, + dataEncryptionKey: encodeBase64(encryptedKey, 'base64'), + }; + + // Send to server + const artifact = await createArtifactApi(credentials, request); + + // Add to local storage + const decryptedArtifact: DecryptedArtifact = { + id: artifact.id, + title, + sessions, + draft, + body, + headerVersion: artifact.headerVersion, + bodyVersion: artifact.bodyVersion, + seq: artifact.seq, + createdAt: artifact.createdAt, + updatedAt: artifact.updatedAt, + isDecrypted: true, + }; + + addArtifact(decryptedArtifact); + + return artifactId; + } catch (error) { + console.error('Failed to create artifact:', error); + throw error; + } +} + +export async function updateArtifactViaApi(params: { + credentials: AuthCredentials; + artifactId: string; + title: string | null; + body: string | null; + sessions?: string[]; + draft?: boolean; + encryption: Encryption; + artifactDataKeys: Map<string, Uint8Array>; + getArtifact: (artifactId: string) => DecryptedArtifact | undefined; + updateArtifact: (artifact: DecryptedArtifact) => void; +}): Promise<void> { + const { credentials, artifactId, title, body, sessions, draft, encryption, artifactDataKeys, getArtifact, updateArtifact } = + params; + + try { + // Get current artifact from storage + const currentArtifact = getArtifact(artifactId); + if (!currentArtifact) { + throw new Error(`Artifact ${artifactId} not found`); + } + + // Get the data encryption key from memory + let dataEncryptionKey = artifactDataKeys.get(artifactId); + + // Determine current versions + let headerVersion = currentArtifact.headerVersion; + let bodyVersion = currentArtifact.bodyVersion; + + if (headerVersion === undefined || bodyVersion === undefined || !dataEncryptionKey) { + const fullArtifact = await fetchArtifactApi(credentials, artifactId); + headerVersion = fullArtifact.headerVersion; + bodyVersion = fullArtifact.bodyVersion; + + // Decrypt and store the data encryption key if we don't have it + if (!dataEncryptionKey) { + const decryptedKey = await encryption.decryptEncryptionKey(fullArtifact.dataEncryptionKey); + if (!decryptedKey) { + throw new Error('Failed to decrypt encryption key'); + } + artifactDataKeys.set(artifactId, decryptedKey); + dataEncryptionKey = decryptedKey; + } + } + + // Create artifact encryption instance + const artifactEncryption = new ArtifactEncryption(dataEncryptionKey); + + // Prepare update request + const updateRequest: ArtifactUpdateRequest = {}; + + // Check if header needs updating (title, sessions, or draft changed) + if ( + title !== currentArtifact.title || + JSON.stringify(sessions) !== JSON.stringify(currentArtifact.sessions) || + draft !== currentArtifact.draft + ) { + const encryptedHeader = await artifactEncryption.encryptHeader({ + title, + sessions, + draft, + }); + updateRequest.header = encryptedHeader; + updateRequest.expectedHeaderVersion = headerVersion; + } + + // Only update body if it changed + if (body !== currentArtifact.body) { + const encryptedBody = await artifactEncryption.encryptBody({ body }); + updateRequest.body = encryptedBody; + updateRequest.expectedBodyVersion = bodyVersion; + } + + // Skip if no changes + if (Object.keys(updateRequest).length === 0) { + return; + } + + // Send update to server + const response = await updateArtifactApi(credentials, artifactId, updateRequest); + + if (!response.success) { + // Handle version mismatch + if (response.error === 'version-mismatch') { + throw new Error('Artifact was modified by another client. Please refresh and try again.'); + } + throw new Error('Failed to update artifact'); + } + + // Update local storage + const updatedArtifact: DecryptedArtifact = { + ...currentArtifact, + title, + sessions, + draft, + body, + headerVersion: response.headerVersion !== undefined ? response.headerVersion : headerVersion, + bodyVersion: response.bodyVersion !== undefined ? response.bodyVersion : bodyVersion, + updatedAt: Date.now(), + }; + + updateArtifact(updatedArtifact); + } catch (error) { + console.error('Failed to update artifact:', error); + throw error; + } +} + +export async function decryptSocketNewArtifactUpdate(params: { + artifactId: string; + dataEncryptionKey: string; + header: string; + headerVersion: number; + body?: string | null; + bodyVersion?: number; + seq: number; + createdAt: number; + updatedAt: number; + encryption: Encryption; + artifactDataKeys: Map<string, Uint8Array>; +}): Promise<DecryptedArtifact | null> { + const { + artifactId, + dataEncryptionKey, + header, + headerVersion, + body, + bodyVersion, + seq, + createdAt, + updatedAt, + encryption, + artifactDataKeys, + } = params; + + // Decrypt the data encryption key + const decryptedKey = await encryption.decryptEncryptionKey(dataEncryptionKey); + if (!decryptedKey) { + console.error(`Failed to decrypt key for new artifact ${artifactId}`); + return null; + } + + // Store the decrypted key in memory + artifactDataKeys.set(artifactId, decryptedKey); + + // Create artifact encryption instance + const artifactEncryption = new ArtifactEncryption(decryptedKey); + + // Decrypt header + const decryptedHeader = await artifactEncryption.decryptHeader(header); + + // Decrypt body if provided + let decryptedBody: string | null | undefined = undefined; + if (body && bodyVersion !== undefined) { + const decrypted = await artifactEncryption.decryptBody(body); + decryptedBody = decrypted?.body || null; + } + + return { + id: artifactId, + title: decryptedHeader?.title || null, + body: decryptedBody, + headerVersion, + bodyVersion, + seq, + createdAt, + updatedAt, + isDecrypted: !!decryptedHeader, + }; +} + +export async function applySocketArtifactUpdate(params: { + existingArtifact: DecryptedArtifact; + seq: number; + createdAt: number; + dataEncryptionKey: Uint8Array; + header?: { version: number; value: string } | null; + body?: { version: number; value: string } | null; +}): Promise<DecryptedArtifact> { + const { existingArtifact, seq, createdAt, dataEncryptionKey, header, body } = params; + + const artifactEncryption = new ArtifactEncryption(dataEncryptionKey); + + // Update artifact with new data + const updatedArtifact: DecryptedArtifact = { + ...existingArtifact, + seq, + updatedAt: createdAt, + }; + + // Decrypt and update header if provided + if (header) { + const decryptedHeader = await artifactEncryption.decryptHeader(header.value); + updatedArtifact.title = decryptedHeader?.title || null; + updatedArtifact.sessions = decryptedHeader?.sessions; + updatedArtifact.draft = decryptedHeader?.draft; + updatedArtifact.headerVersion = header.version; + } + + // Decrypt and update body if provided + if (body) { + const decryptedBody = await artifactEncryption.decryptBody(body.value); + updatedArtifact.body = decryptedBody?.body || null; + updatedArtifact.bodyVersion = body.version; + } + + return updatedArtifact; +} + +export async function handleNewArtifactSocketUpdate(params: { + artifactId: string; + dataEncryptionKey: string; + header: string; + headerVersion: number; + body?: string | null; + bodyVersion?: number; + seq: number; + createdAt: number; + updatedAt: number; + encryption: Encryption; + artifactDataKeys: Map<string, Uint8Array>; + addArtifact: (artifact: DecryptedArtifact) => void; + log: { log: (message: string) => void }; +}): Promise<void> { + const { + artifactId, + dataEncryptionKey, + header, + headerVersion, + body, + bodyVersion, + seq, + createdAt, + updatedAt, + encryption, + artifactDataKeys, + addArtifact, + log, + } = params; + + try { + const decrypted = await decryptSocketNewArtifactUpdate({ + artifactId, + dataEncryptionKey, + header, + headerVersion, + body, + bodyVersion, + seq, + createdAt, + updatedAt, + encryption, + artifactDataKeys, + }); + if (!decrypted) { + return; + } + + addArtifact(decrypted); + log.log(`📦 Added new artifact ${artifactId} to storage`); + } catch (error) { + console.error(`Failed to process new artifact ${artifactId}:`, error); + } +} + +export async function handleUpdateArtifactSocketUpdate(params: { + artifactId: string; + seq: number; + createdAt: number; + header?: { version: number; value: string } | null; + body?: { version: number; value: string } | null; + artifactDataKeys: Map<string, Uint8Array>; + getExistingArtifact: (artifactId: string) => DecryptedArtifact | undefined; + updateArtifact: (artifact: DecryptedArtifact) => void; + invalidateArtifactsSync: () => void; + log: { log: (message: string) => void }; +}): Promise<void> { + const { + artifactId, + seq, + createdAt, + header, + body, + artifactDataKeys, + getExistingArtifact, + updateArtifact, + invalidateArtifactsSync, + log, + } = params; + + const existingArtifact = getExistingArtifact(artifactId); + if (!existingArtifact) { + console.error(`Artifact ${artifactId} not found in storage`); + // Fetch all artifacts to sync + invalidateArtifactsSync(); + return; + } + + try { + // Get the data encryption key from memory + const dataEncryptionKey = artifactDataKeys.get(artifactId); + if (!dataEncryptionKey) { + console.error(`Encryption key not found for artifact ${artifactId}, fetching artifacts`); + invalidateArtifactsSync(); + return; + } + + const updatedArtifact = await applySocketArtifactUpdate({ + existingArtifact, + seq, + createdAt, + dataEncryptionKey, + header, + body, + }); + + updateArtifact(updatedArtifact); + log.log(`📦 Updated artifact ${artifactId} in storage`); + } catch (error) { + console.error(`Failed to process artifact update ${artifactId}:`, error); + } +} + +export function handleDeleteArtifactSocketUpdate(params: { + artifactId: string; + deleteArtifact: (artifactId: string) => void; + artifactDataKeys: Map<string, Uint8Array>; +}): void { + const { artifactId, deleteArtifact, artifactDataKeys } = params; + + // Remove from storage + deleteArtifact(artifactId); + + // Remove encryption key from memory + artifactDataKeys.delete(artifactId); +} diff --git a/expo-app/sources/sync/engine/feed.ts b/expo-app/sources/sync/engine/feed.ts new file mode 100644 index 000000000..55bcfa740 --- /dev/null +++ b/expo-app/sources/sync/engine/feed.ts @@ -0,0 +1,193 @@ +import type { FeedItem } from '../feedTypes'; +import type { AuthCredentials } from '@/auth/tokenStorage'; +import type { UserProfile } from '../friendTypes'; +import { fetchFeed as fetchFeedApi } from '../apiFeed'; + +export async function handleNewFeedPostUpdate(params: { + feedUpdate: { + id: string; + body: FeedItem['body']; + cursor: string; + createdAt: number; + repeatKey?: string | null; + }; + assumeUsers: (userIds: string[]) => Promise<void>; + getUsers: () => Record<string, unknown>; + applyFeedItems: (items: FeedItem[]) => void; + log: { log: (message: string) => void }; +}): Promise<void> { + const { feedUpdate, assumeUsers, getUsers, applyFeedItems, log } = params; + + // Convert to FeedItem with counter from cursor + const feedItem: FeedItem = { + id: feedUpdate.id, + body: feedUpdate.body, + cursor: feedUpdate.cursor, + createdAt: feedUpdate.createdAt, + repeatKey: feedUpdate.repeatKey ?? null, + counter: parseInt(feedUpdate.cursor.substring(2), 10), + }; + + // Check if we need to fetch user for friend-related items + if (feedItem.body && (feedItem.body.kind === 'friend_request' || feedItem.body.kind === 'friend_accepted')) { + await assumeUsers([feedItem.body.uid]); + + // Check if user fetch failed (404) - don't store item if user not found + const users = getUsers(); + const userProfile = (users as Record<string, unknown>)[feedItem.body.uid]; + if (userProfile === null || userProfile === undefined) { + // User was not found or 404, don't store this item + log.log(`📰 Skipping feed item ${feedItem.id} - user ${feedItem.body.uid} not found`); + return; + } + } + + // Apply to storage (will handle repeatKey replacement) + applyFeedItems([feedItem]); +} + +export async function handleTodoKvBatchUpdate(params: { + kvUpdate: { changes?: unknown }; + applyTodoSocketUpdates: (changes: any[]) => Promise<void>; + invalidateTodosSync: () => void; + log: { log: (message: string) => void }; +}): Promise<void> { + const { kvUpdate, applyTodoSocketUpdates, invalidateTodosSync, log } = params; + + // Process KV changes for todos + if (kvUpdate.changes && Array.isArray(kvUpdate.changes)) { + const todoChanges = kvUpdate.changes.filter( + (change: any) => change.key && typeof change.key === 'string' && change.key.startsWith('todo.'), + ); + + if (todoChanges.length > 0) { + log.log(`📝 Processing ${todoChanges.length} todo KV changes from socket`); + + // Apply the changes directly to avoid unnecessary refetch + try { + await applyTodoSocketUpdates(todoChanges); + } catch (error) { + console.error('Failed to apply todo socket updates:', error); + // Fallback to refetch on error + invalidateTodosSync(); + } + } + } +} + +export function handleRelationshipUpdatedSocketUpdate(params: { + relationshipUpdate: any; + applyRelationshipUpdate: (update: any) => void; + invalidateFriends: () => void; + invalidateFriendRequests: () => void; + invalidateFeed: () => void; +}): void { + const { relationshipUpdate, applyRelationshipUpdate, invalidateFriends, invalidateFriendRequests, invalidateFeed } = params; + + // Apply the relationship update to storage + applyRelationshipUpdate({ + fromUserId: relationshipUpdate.fromUserId, + toUserId: relationshipUpdate.toUserId, + status: relationshipUpdate.status, + action: relationshipUpdate.action, + fromUser: relationshipUpdate.fromUser, + toUser: relationshipUpdate.toUser, + timestamp: relationshipUpdate.timestamp, + }); + + // Invalidate friends data to refresh with latest changes + invalidateFriends(); + invalidateFriendRequests(); + invalidateFeed(); +} + +export async function fetchAndApplyFeed(params: { + credentials: AuthCredentials; + getFeedItems: () => FeedItem[]; + getFeedHead: () => string | null; + assumeUsers: (userIds: string[]) => Promise<void>; + getUsers: () => Record<string, UserProfile | null>; + applyFeedItems: (items: FeedItem[]) => void; + log: { log: (message: string) => void }; +}): Promise<void> { + const { credentials, getFeedItems, getFeedHead, assumeUsers, getUsers, applyFeedItems, log } = params; + + try { + log.log('📰 Fetching feed...'); + const existingItems = getFeedItems(); + const head = getFeedHead(); + + // Load feed items - if we have a head, load newer items + const allItems: FeedItem[] = []; + let hasMore = true; + let cursor = head ? { after: head } : undefined; + let loadedCount = 0; + const maxItems = 500; + + // Keep loading until we reach known items or hit max limit + while (hasMore && loadedCount < maxItems) { + const response = await fetchFeedApi(credentials, { + limit: 100, + ...cursor, + }); + + // Check if we reached known items + const foundKnown = response.items.some((item) => existingItems.some((existing) => existing.id === item.id)); + + allItems.push(...response.items); + loadedCount += response.items.length; + hasMore = response.hasMore && !foundKnown; + + // Update cursor for next page + if (response.items.length > 0) { + const lastItem = response.items[response.items.length - 1]; + cursor = { after: lastItem.cursor }; + } + } + + // If this is initial load (no head), also load older items + if (!head && allItems.length < 100) { + const response = await fetchFeedApi(credentials, { + limit: 100, + }); + allItems.push(...response.items); + } + + // Collect user IDs from friend-related feed items + const userIds = new Set<string>(); + allItems.forEach((item) => { + if (item.body && (item.body.kind === 'friend_request' || item.body.kind === 'friend_accepted')) { + userIds.add(item.body.uid); + } + }); + + // Fetch missing users + if (userIds.size > 0) { + await assumeUsers(Array.from(userIds)); + } + + // Filter out items where user is not found (404) + const users = getUsers(); + const compatibleItems = allItems.filter((item) => { + // Keep text items + if (item.body.kind === 'text') return true; + + // For friend-related items, check if user exists and is not null (404) + if (item.body.kind === 'friend_request' || item.body.kind === 'friend_accepted') { + const userProfile = users[item.body.uid]; + // Keep item only if user exists and is not null + return userProfile !== null && userProfile !== undefined; + } + + return true; + }); + + // Apply only compatible items to storage + applyFeedItems(compatibleItems); + log.log( + `📰 fetchFeed completed - loaded ${compatibleItems.length} compatible items (${allItems.length - compatibleItems.length} filtered)`, + ); + } catch (error) { + console.error('Failed to fetch feed:', error); + } +} diff --git a/expo-app/sources/sync/engine/machines.ts b/expo-app/sources/sync/engine/machines.ts new file mode 100644 index 000000000..386528587 --- /dev/null +++ b/expo-app/sources/sync/engine/machines.ts @@ -0,0 +1,197 @@ +import type { AuthCredentials } from '@/auth/tokenStorage'; +import { log } from '@/log'; +import { getServerUrl } from '../serverConfig'; +import type { Machine } from '../storageTypes'; + +type MachineEncryption = { + decryptMetadata: (version: number, value: string) => Promise<any>; + decryptDaemonState: (version: number, value: string) => Promise<any>; +}; + +type SyncEncryption = { + decryptEncryptionKey: (value: string) => Promise<Uint8Array | null>; + initializeMachines: (machineKeysMap: Map<string, Uint8Array | null>) => Promise<void>; + getMachineEncryption: (machineId: string) => MachineEncryption | null; +}; + +export async function buildUpdatedMachineFromSocketUpdate(params: { + machineUpdate: any; + updateSeq: number; + updateCreatedAt: number; + existingMachine: Machine | undefined; + getMachineEncryption: (machineId: string) => MachineEncryption | null; +}): Promise<Machine | null> { + const { machineUpdate, updateSeq, updateCreatedAt, existingMachine, getMachineEncryption } = params; + + const machineId = machineUpdate.machineId; // Changed from .id to .machineId + + // Create or update machine with all required fields + const updatedMachine: Machine = { + id: machineId, + seq: updateSeq, + createdAt: existingMachine?.createdAt ?? updateCreatedAt, + updatedAt: updateCreatedAt, + active: machineUpdate.active ?? true, + activeAt: machineUpdate.activeAt ?? updateCreatedAt, + metadata: existingMachine?.metadata ?? null, + metadataVersion: existingMachine?.metadataVersion ?? 0, + daemonState: existingMachine?.daemonState ?? null, + daemonStateVersion: existingMachine?.daemonStateVersion ?? 0, + }; + + // Get machine-specific encryption (might not exist if machine wasn't initialized) + const machineEncryption = getMachineEncryption(machineId); + if (!machineEncryption) { + console.error(`Machine encryption not found for ${machineId} - cannot decrypt updates`); + return null; + } + + // If metadata is provided, decrypt and update it + const metadataUpdate = machineUpdate.metadata; + if (metadataUpdate) { + try { + const metadata = await machineEncryption.decryptMetadata(metadataUpdate.version, metadataUpdate.value); + updatedMachine.metadata = metadata; + updatedMachine.metadataVersion = metadataUpdate.version; + } catch (error) { + console.error(`Failed to decrypt machine metadata for ${machineId}:`, error); + } + } + + // If daemonState is provided, decrypt and update it + const daemonStateUpdate = machineUpdate.daemonState; + if (daemonStateUpdate) { + try { + const daemonState = await machineEncryption.decryptDaemonState(daemonStateUpdate.version, daemonStateUpdate.value); + updatedMachine.daemonState = daemonState; + updatedMachine.daemonStateVersion = daemonStateUpdate.version; + } catch (error) { + console.error(`Failed to decrypt machine daemonState for ${machineId}:`, error); + } + } + + return updatedMachine; +} + +export function buildMachineFromMachineActivityEphemeralUpdate(params: { + machine: Machine; + updateData: { active: boolean; activeAt: number }; +}): Machine { + const { machine, updateData } = params; + return { + ...machine, + active: updateData.active, + activeAt: updateData.activeAt, + }; +} + +export async function fetchAndApplyMachines(params: { + credentials: AuthCredentials; + encryption: SyncEncryption; + machineDataKeys: Map<string, Uint8Array>; + applyMachines: (machines: Machine[], replace?: boolean) => void; +}): Promise<void> { + const { credentials, encryption, machineDataKeys, applyMachines } = params; + + const API_ENDPOINT = getServerUrl(); + const response = await fetch(`${API_ENDPOINT}/v1/machines`, { + headers: { + 'Authorization': `Bearer ${credentials.token}`, + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + console.error(`Failed to fetch machines: ${response.status}`); + return; + } + + const data = await response.json(); + const machines = data as Array<{ + id: string; + metadata: string; + metadataVersion: number; + daemonState?: string | null; + daemonStateVersion?: number; + dataEncryptionKey?: string | null; // Add support for per-machine encryption keys + seq: number; + active: boolean; + activeAt: number; // Changed from lastActiveAt + createdAt: number; + updatedAt: number; + }>; + + // First, collect and decrypt encryption keys for all machines + const machineKeysMap = new Map<string, Uint8Array | null>(); + for (const machine of machines) { + if (machine.dataEncryptionKey) { + const decryptedKey = await encryption.decryptEncryptionKey(machine.dataEncryptionKey); + if (!decryptedKey) { + console.error(`Failed to decrypt data encryption key for machine ${machine.id}`); + continue; + } + machineKeysMap.set(machine.id, decryptedKey); + machineDataKeys.set(machine.id, decryptedKey); + } else { + machineKeysMap.set(machine.id, null); + } + } + + // Initialize machine encryptions + await encryption.initializeMachines(machineKeysMap); + + // Process all machines first, then update state once + const decryptedMachines: Machine[] = []; + + for (const machine of machines) { + // Get machine-specific encryption (might exist from previous initialization) + const machineEncryption = encryption.getMachineEncryption(machine.id); + if (!machineEncryption) { + console.error(`Machine encryption not found for ${machine.id} - this should never happen`); + continue; + } + + try { + // Use machine-specific encryption (which handles fallback internally) + const metadata = machine.metadata + ? await machineEncryption.decryptMetadata(machine.metadataVersion, machine.metadata) + : null; + + const daemonState = machine.daemonState + ? await machineEncryption.decryptDaemonState(machine.daemonStateVersion || 0, machine.daemonState) + : null; + + decryptedMachines.push({ + id: machine.id, + seq: machine.seq, + createdAt: machine.createdAt, + updatedAt: machine.updatedAt, + active: machine.active, + activeAt: machine.activeAt, + metadata, + metadataVersion: machine.metadataVersion, + daemonState, + daemonStateVersion: machine.daemonStateVersion || 0, + }); + } catch (error) { + console.error(`Failed to decrypt machine ${machine.id}:`, error); + // Still add the machine with null metadata + decryptedMachines.push({ + id: machine.id, + seq: machine.seq, + createdAt: machine.createdAt, + updatedAt: machine.updatedAt, + active: machine.active, + activeAt: machine.activeAt, + metadata: null, + metadataVersion: machine.metadataVersion, + daemonState: null, + daemonStateVersion: 0, + }); + } + } + + // Replace entire machine state with fetched machines + applyMachines(decryptedMachines, true); + log.log(`🖥️ fetchMachines completed - processed ${decryptedMachines.length} machines`); +} diff --git a/expo-app/sources/sync/engine/pendingSettings.ts b/expo-app/sources/sync/engine/pendingSettings.ts new file mode 100644 index 000000000..2b4b19b52 --- /dev/null +++ b/expo-app/sources/sync/engine/pendingSettings.ts @@ -0,0 +1,45 @@ +import { InteractionManager, Platform } from 'react-native'; + +type PendingFlushTimer = ReturnType<typeof setTimeout>; + +type ScheduleDebouncedPendingSettingsFlushParams = { + getTimer: () => PendingFlushTimer | null; + setTimer: (timer: PendingFlushTimer) => void; + markDirty: () => void; + consumeDirty: () => boolean; + flush: () => void; + delayMs: number; +}; + +export function scheduleDebouncedPendingSettingsFlush({ + getTimer, + setTimer, + markDirty, + consumeDirty, + flush, + delayMs, +}: ScheduleDebouncedPendingSettingsFlushParams) { + const timer = getTimer(); + if (timer) { + clearTimeout(timer); + } + + markDirty(); + + // Debounce disk write + network sync to keep UI interactions snappy. + // IMPORTANT: JSON.stringify + MMKV.set are synchronous and can stall taps on iOS if run too often. + setTimer( + setTimeout(() => { + if (!consumeDirty()) { + return; + } + + if (Platform.OS === 'web') { + flush(); + } else { + InteractionManager.runAfterInteractions(flush); + } + }, delayMs), + ); +} + diff --git a/expo-app/sources/sync/engine/purchases.ts b/expo-app/sources/sync/engine/purchases.ts new file mode 100644 index 000000000..6cc3881b4 --- /dev/null +++ b/expo-app/sources/sync/engine/purchases.ts @@ -0,0 +1,191 @@ +import { Platform } from 'react-native'; +import { config } from '@/config'; +import { RevenueCat, LogLevel, PaywallResult } from '../revenueCat'; + +export async function syncPurchases(params: { + serverID: string; + revenueCatInitialized: boolean; + setRevenueCatInitialized: (next: boolean) => void; + // RevenueCat types are not exported consistently across platforms; keep this loose. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + applyPurchases: (customerInfo: any) => void; +}): Promise<void> { + const { serverID, revenueCatInitialized, setRevenueCatInitialized, applyPurchases } = params; + + try { + // Initialize RevenueCat if not already done + if (!revenueCatInitialized) { + // Get the appropriate API key based on platform + let apiKey: string | undefined; + + if (Platform.OS === 'ios') { + apiKey = config.revenueCatAppleKey; + } else if (Platform.OS === 'android') { + apiKey = config.revenueCatGoogleKey; + } else if (Platform.OS === 'web') { + apiKey = config.revenueCatStripeKey; + } + + if (!apiKey) { + return; + } + + // Configure RevenueCat + if (__DEV__) { + RevenueCat.setLogLevel(LogLevel.DEBUG); + } + + // Initialize with the public ID as user ID + RevenueCat.configure({ + apiKey, + appUserID: serverID, // In server this is a CUID, which we can assume is globaly unique even between servers + useAmazon: false, + }); + + setRevenueCatInitialized(true); + } + + // Sync purchases + await RevenueCat.syncPurchases(); + + // Fetch customer info + const customerInfo = await RevenueCat.getCustomerInfo(); + + // Apply to storage (storage handles the transformation) + applyPurchases(customerInfo); + } catch (error) { + console.error('Failed to sync purchases:', error); + // Don't throw - purchases are optional + } +} + +export async function purchaseProduct(params: { + revenueCatInitialized: boolean; + productId: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + applyPurchases: (customerInfo: any) => void; +}): Promise<{ success: boolean; error?: string }> { + const { revenueCatInitialized, productId, applyPurchases } = params; + + try { + // Check if RevenueCat is initialized + if (!revenueCatInitialized) { + return { success: false, error: 'RevenueCat not initialized' }; + } + + // Fetch the product + const products = await RevenueCat.getProducts([productId]); + if (products.length === 0) { + return { success: false, error: `Product '${productId}' not found` }; + } + + // Purchase the product + const product = products[0]; + const { customerInfo } = await RevenueCat.purchaseStoreProduct(product); + + // Update local purchases data + applyPurchases(customerInfo); + + return { success: true }; + } catch (error: any) { + // Check if user cancelled + if (error.userCancelled) { + return { success: false, error: 'Purchase cancelled' }; + } + + // Return the error message + return { success: false, error: error.message || 'Purchase failed' }; + } +} + +export async function getOfferings(params: { + revenueCatInitialized: boolean; +}): Promise<{ success: boolean; offerings?: any; error?: string }> { + const { revenueCatInitialized } = params; + + try { + // Check if RevenueCat is initialized + if (!revenueCatInitialized) { + return { success: false, error: 'RevenueCat not initialized' }; + } + + // Fetch offerings + const offerings = await RevenueCat.getOfferings(); + + // Return the offerings data + return { + success: true, + offerings: { + current: offerings.current, + all: offerings.all, + }, + }; + } catch (error: any) { + return { success: false, error: error.message || 'Failed to fetch offerings' }; + } +} + +export async function presentPaywall(params: { + revenueCatInitialized: boolean; + trackPaywallPresented: () => void; + trackPaywallPurchased: () => void; + trackPaywallCancelled: () => void; + trackPaywallRestored: () => void; + trackPaywallError: (error: string) => void; + syncPurchases: () => Promise<void>; +}): Promise<{ success: boolean; purchased?: boolean; error?: string }> { + const { + revenueCatInitialized, + trackPaywallPresented, + trackPaywallPurchased, + trackPaywallCancelled, + trackPaywallRestored, + trackPaywallError, + syncPurchases, + } = params; + + try { + // Check if RevenueCat is initialized + if (!revenueCatInitialized) { + const error = 'RevenueCat not initialized'; + trackPaywallError(error); + return { success: false, error }; + } + + // Track paywall presentation + trackPaywallPresented(); + + // Present the paywall + const result = await RevenueCat.presentPaywall(); + + // Handle the result + switch (result) { + case PaywallResult.PURCHASED: + trackPaywallPurchased(); + // Refresh customer info after purchase + await syncPurchases(); + return { success: true, purchased: true }; + case PaywallResult.RESTORED: + trackPaywallRestored(); + // Refresh customer info after restore + await syncPurchases(); + return { success: true, purchased: true }; + case PaywallResult.CANCELLED: + trackPaywallCancelled(); + return { success: true, purchased: false }; + case PaywallResult.NOT_PRESENTED: + // Don't track error for NOT_PRESENTED as it's a platform limitation + return { success: false, error: 'Paywall not available on this platform' }; + case PaywallResult.ERROR: + default: { + const errorMsg = 'Failed to present paywall'; + trackPaywallError(errorMsg); + return { success: false, error: errorMsg }; + } + } + } catch (error: any) { + const errorMessage = error.message || 'Failed to present paywall'; + trackPaywallError(errorMessage); + return { success: false, error: errorMessage }; + } +} diff --git a/expo-app/sources/sync/engine/sessions.ts b/expo-app/sources/sync/engine/sessions.ts new file mode 100644 index 000000000..87cb5a021 --- /dev/null +++ b/expo-app/sources/sync/engine/sessions.ts @@ -0,0 +1,667 @@ +import type { NormalizedMessage, RawRecord } from '../typesRaw'; +import { normalizeRawMessage } from '../typesRaw'; +import { computeNextSessionSeqFromUpdate } from '../realtimeSessionSeq'; +import type { Session } from '../storageTypes'; +import type { Metadata } from '../storageTypes'; +import { computeNextReadStateV1 } from '../readStateV1'; +import { getServerUrl } from '../serverConfig'; +import type { AuthCredentials } from '@/auth/tokenStorage'; +import { HappyError } from '@/utils/errors'; +import type { ApiMessage } from '../apiTypes'; +import { storage } from '../storage'; +import type { Encryption } from '../encryption/encryption'; +import { nowServerMs } from '../time'; +import { systemPrompt } from '../prompt/systemPrompt'; +import { Platform } from 'react-native'; +import { isRunningOnMac } from '@/utils/platform'; +import { randomUUID } from '@/platform/randomUUID'; +import { buildOutgoingMessageMeta } from '../messageMeta'; +import { getAgentCore, resolveAgentIdFromFlavor } from '@/agents/catalog'; +import { + deleteMessageQueueV1DiscardedItem, + deleteMessageQueueV1Item, + discardMessageQueueV1Item, + enqueueMessageQueueV1Item, + restoreMessageQueueV1DiscardedItem, + updateMessageQueueV1Item, +} from '../messageQueueV1'; +import { decodeMessageQueueV1ToPendingMessages, reconcilePendingMessagesFromMetadata } from '../messageQueueV1Pending'; + +type SessionMessageEncryption = { + decryptMessage: (message: any) => Promise<any>; +}; + +function inferTaskLifecycleFromMessageContent(content: unknown): { isTaskComplete: boolean; isTaskStarted: boolean } { + const rawContent = content as { content?: { type?: string; data?: { type?: string } } } | null; + const contentType = rawContent?.content?.type; + const dataType = rawContent?.content?.data?.type; + + const isTaskComplete = + (contentType === 'acp' || contentType === 'codex') && + (dataType === 'task_complete' || dataType === 'turn_aborted'); + + const isTaskStarted = (contentType === 'acp' || contentType === 'codex') && dataType === 'task_started'; + + return { isTaskComplete, isTaskStarted }; +} + +type SessionEncryption = { + decryptAgentState: (version: number, value: string | null) => Promise<any>; + decryptMetadata: (version: number, value: string) => Promise<any>; +}; + +export async function handleNewMessageSocketUpdate(params: { + updateData: any; + getSessionEncryption: (sessionId: string) => SessionMessageEncryption | null; + getSession: (sessionId: string) => Session | undefined; + applySessions: (sessions: Array<Omit<Session, 'presence'> & { presence?: 'online' | number }>) => void; + fetchSessions: () => void; + applyMessages: (sessionId: string, messages: NormalizedMessage[]) => void; + isMutableToolCall: (sessionId: string, toolUseId: string) => boolean; + invalidateGitStatus: (sessionId: string) => void; + onSessionVisible: (sessionId: string) => void; +}): Promise<void> { + const { + updateData, + getSessionEncryption, + getSession, + applySessions, + fetchSessions, + applyMessages, + isMutableToolCall, + invalidateGitStatus, + onSessionVisible, + } = params; + + // Get encryption + const encryption = getSessionEncryption(updateData.body.sid); + if (!encryption) { + // Should never happen + console.error(`Session ${updateData.body.sid} not found`); + fetchSessions(); // Just fetch sessions again + return; + } + + // Decrypt message + let lastMessage: NormalizedMessage | null = null; + if (updateData.body.message) { + const decrypted = await encryption.decryptMessage(updateData.body.message); + if (decrypted) { + lastMessage = normalizeRawMessage(decrypted.id, decrypted.localId, decrypted.createdAt, decrypted.content); + + // Check for task lifecycle events to update thinking state. + // This ensures UI updates even if volatile activity updates are lost. + const { isTaskComplete, isTaskStarted } = inferTaskLifecycleFromMessageContent(decrypted.content); + + // Update session + const session = getSession(updateData.body.sid); + if (session) { + const nextSessionSeq = computeNextSessionSeqFromUpdate({ + currentSessionSeq: session.seq ?? 0, + updateType: 'new-message', + containerSeq: updateData.seq, + messageSeq: updateData.body.message?.seq, + }); + + applySessions([ + { + ...session, + updatedAt: updateData.createdAt, + seq: nextSessionSeq, + // Update thinking state based on task lifecycle events + ...(isTaskComplete ? { thinking: false } : {}), + ...(isTaskStarted ? { thinking: true } : {}), + }, + ]); + } else { + // Fetch sessions again if we don't have this session + fetchSessions(); + } + + // Update messages + if (lastMessage) { + applyMessages(updateData.body.sid, [lastMessage]); + let hasMutableTool = false; + if (lastMessage.role === 'agent' && lastMessage.content[0] && lastMessage.content[0].type === 'tool-result') { + hasMutableTool = isMutableToolCall(updateData.body.sid, lastMessage.content[0].tool_use_id); + } + if (hasMutableTool) { + invalidateGitStatus(updateData.body.sid); + } + } + } + } + + // Ping session + onSessionVisible(updateData.body.sid); +} + +export function handleDeleteSessionSocketUpdate(params: { + sessionId: string; + deleteSession: (sessionId: string) => void; + removeSessionEncryption: (sessionId: string) => void; + removeProjectManagerSession: (sessionId: string) => void; + clearGitStatusForSession: (sessionId: string) => void; + log: { log: (message: string) => void }; +}) { + const { sessionId, deleteSession, removeSessionEncryption, removeProjectManagerSession, clearGitStatusForSession, log } = params; + + // Remove session from storage + deleteSession(sessionId); + + // Remove encryption keys from memory + removeSessionEncryption(sessionId); + + // Remove from project manager + removeProjectManagerSession(sessionId); + + // Clear any cached git status + clearGitStatusForSession(sessionId); + + log.log(`🗑️ Session ${sessionId} deleted from local storage`); +} + +export async function buildUpdatedSessionFromSocketUpdate(params: { + session: Session; + updateBody: any; + updateSeq: number; + updateCreatedAt: number; + sessionEncryption: SessionEncryption; +}): Promise<{ nextSession: Session; agentState: any }> { + const { session, updateBody, updateSeq, updateCreatedAt, sessionEncryption } = params; + + const agentState = updateBody.agentState + ? await sessionEncryption.decryptAgentState(updateBody.agentState.version, updateBody.agentState.value) + : session.agentState; + + const metadata = updateBody.metadata + ? await sessionEncryption.decryptMetadata(updateBody.metadata.version, updateBody.metadata.value) + : session.metadata; + + const nextSession: Session = { + ...session, + agentState, + agentStateVersion: updateBody.agentState ? updateBody.agentState.version : session.agentStateVersion, + metadata, + metadataVersion: updateBody.metadata ? updateBody.metadata.version : session.metadataVersion, + updatedAt: updateCreatedAt, + seq: computeNextSessionSeqFromUpdate({ + currentSessionSeq: session.seq ?? 0, + updateType: 'update-session', + containerSeq: updateSeq, + messageSeq: undefined, + }), + }; + + return { nextSession, agentState }; +} + +export async function repairInvalidReadStateV1(params: { + sessionId: string; + sessionSeqUpperBound: number; + attempted: Set<string>; + inFlight: Set<string>; + getSession: (sessionId: string) => { metadata?: Metadata | null } | undefined; + updateSessionMetadataWithRetry: (sessionId: string, updater: (metadata: Metadata) => Metadata) => Promise<void>; + now: () => number; +}): Promise<void> { + const { sessionId, sessionSeqUpperBound, attempted, inFlight, getSession, updateSessionMetadataWithRetry, now } = params; + + if (attempted.has(sessionId) || inFlight.has(sessionId)) { + return; + } + + const session = getSession(sessionId); + const readState = session?.metadata?.readStateV1; + if (!readState) return; + if (readState.sessionSeq <= sessionSeqUpperBound) return; + + attempted.add(sessionId); + inFlight.add(sessionId); + try { + await updateSessionMetadataWithRetry(sessionId, (metadata) => { + const prev = metadata.readStateV1; + if (!prev) return metadata; + if (prev.sessionSeq <= sessionSeqUpperBound) return metadata; + + const result = computeNextReadStateV1({ + prev, + sessionSeq: sessionSeqUpperBound, + pendingActivityAt: prev.pendingActivityAt, + now: now(), + }); + if (!result.didChange) return metadata; + return { ...metadata, readStateV1: result.next }; + }); + } catch { + // ignore + } finally { + inFlight.delete(sessionId); + } +} + +type UpdateSessionMetadataWithRetry = (sessionId: string, updater: (metadata: Metadata) => Metadata) => Promise<void>; + +export async function fetchAndApplyPendingMessages(params: { + sessionId: string; + encryption: Encryption; +}): Promise<void> { + const { sessionId, encryption } = params; + + const sessionEncryption = encryption.getSessionEncryption(sessionId); + if (!sessionEncryption) { + storage.getState().applyPendingLoaded(sessionId); + storage.getState().applyDiscardedPendingMessages(sessionId, []); + return; + } + + const session = storage.getState().sessions[sessionId]; + if (!session) { + storage.getState().applyPendingLoaded(sessionId); + storage.getState().applyDiscardedPendingMessages(sessionId, []); + return; + } + + const decoded = await decodeMessageQueueV1ToPendingMessages({ + messageQueueV1: session.metadata?.messageQueueV1, + messageQueueV1Discarded: session.metadata?.messageQueueV1Discarded, + decryptRaw: (encrypted) => sessionEncryption.decryptRaw(encrypted), + }); + + const existingPendingState = storage.getState().sessionPending[sessionId]; + const reconciled = reconcilePendingMessagesFromMetadata({ + messageQueueV1: session.metadata?.messageQueueV1, + messageQueueV1Discarded: session.metadata?.messageQueueV1Discarded, + decodedPending: decoded.pending, + decodedDiscarded: decoded.discarded, + existingPending: existingPendingState?.messages ?? [], + existingDiscarded: existingPendingState?.discarded ?? [], + }); + + storage.getState().applyPendingMessages(sessionId, reconciled.pending); + storage.getState().applyDiscardedPendingMessages(sessionId, reconciled.discarded); +} + +export async function enqueuePendingMessage(params: { + sessionId: string; + text: string; + displayText?: string; + encryption: Encryption; + updateSessionMetadataWithRetry: UpdateSessionMetadataWithRetry; +}): Promise<void> { + const { sessionId, text, displayText, encryption, updateSessionMetadataWithRetry } = params; + + storage.getState().markSessionOptimisticThinking(sessionId); + + const sessionEncryption = encryption.getSessionEncryption(sessionId); + if (!sessionEncryption) { + storage.getState().clearSessionOptimisticThinking(sessionId); + throw new Error(`Session ${sessionId} not found`); + } + + const session = storage.getState().sessions[sessionId]; + if (!session) { + storage.getState().clearSessionOptimisticThinking(sessionId); + throw new Error(`Session ${sessionId} not found in storage`); + } + + const permissionMode = session.permissionMode || 'default'; + const flavor = session.metadata?.flavor; + const agentId = resolveAgentIdFromFlavor(flavor); + const modelMode = session.modelMode || (agentId ? getAgentCore(agentId).model.defaultMode : 'default'); + const model = agentId && getAgentCore(agentId).model.supportsSelection && modelMode !== 'default' ? modelMode : undefined; + + const localId = randomUUID(); + + let sentFrom: string; + if (Platform.OS === 'web') { + sentFrom = 'web'; + } else if (Platform.OS === 'android') { + sentFrom = 'android'; + } else if (Platform.OS === 'ios') { + sentFrom = isRunningOnMac() ? 'mac' : 'ios'; + } else { + sentFrom = 'web'; + } + + const content: RawRecord = { + role: 'user', + content: { + type: 'text', + text, + }, + meta: buildOutgoingMessageMeta({ + sentFrom, + permissionMode: permissionMode || 'default', + model, + appendSystemPrompt: systemPrompt, + displayText, + }), + }; + + const createdAt = nowServerMs(); + const updatedAt = createdAt; + const encryptedRawRecord = await sessionEncryption.encryptRawRecord(content); + + storage.getState().upsertPendingMessage(sessionId, { + id: localId, + localId, + createdAt, + updatedAt, + text, + displayText, + rawRecord: content, + }); + + try { + await updateSessionMetadataWithRetry(sessionId, (metadata) => + enqueueMessageQueueV1Item(metadata, { + localId, + message: encryptedRawRecord, + createdAt, + updatedAt, + }), + ); + } catch (e) { + storage.getState().removePendingMessage(sessionId, localId); + storage.getState().clearSessionOptimisticThinking(sessionId); + throw e; + } +} + +export async function updatePendingMessage(params: { + sessionId: string; + pendingId: string; + text: string; + encryption: Encryption; + updateSessionMetadataWithRetry: UpdateSessionMetadataWithRetry; +}): Promise<void> { + const { sessionId, pendingId, text, encryption, updateSessionMetadataWithRetry } = params; + + const sessionEncryption = encryption.getSessionEncryption(sessionId); + if (!sessionEncryption) { + throw new Error(`Session ${sessionId} not found`); + } + + const existing = storage.getState().sessionPending[sessionId]?.messages?.find((m) => m.id === pendingId); + if (!existing) { + throw new Error('Pending message not found'); + } + + const content: RawRecord = existing.rawRecord + ? { + ...(existing.rawRecord as any), + content: { + type: 'text', + text, + }, + } + : { + role: 'user', + content: { type: 'text', text }, + meta: { + appendSystemPrompt: systemPrompt, + }, + }; + + const encryptedRawRecord = await sessionEncryption.encryptRawRecord(content); + const updatedAt = nowServerMs(); + + await updateSessionMetadataWithRetry(sessionId, (metadata) => + updateMessageQueueV1Item(metadata, { + localId: pendingId, + message: encryptedRawRecord, + createdAt: existing.createdAt, + updatedAt, + }), + ); + + storage.getState().upsertPendingMessage(sessionId, { + ...existing, + text, + updatedAt, + rawRecord: content, + }); +} + +export async function deletePendingMessage(params: { + sessionId: string; + pendingId: string; + updateSessionMetadataWithRetry: UpdateSessionMetadataWithRetry; +}): Promise<void> { + const { sessionId, pendingId, updateSessionMetadataWithRetry } = params; + + await updateSessionMetadataWithRetry(sessionId, (metadata) => deleteMessageQueueV1Item(metadata, pendingId)); + storage.getState().removePendingMessage(sessionId, pendingId); +} + +export async function discardPendingMessage(params: { + sessionId: string; + pendingId: string; + opts?: { reason?: 'switch_to_local' | 'manual' }; + updateSessionMetadataWithRetry: UpdateSessionMetadataWithRetry; + encryption: Encryption; +}): Promise<void> { + const { sessionId, pendingId, opts, updateSessionMetadataWithRetry, encryption } = params; + + const discardedAt = nowServerMs(); + await updateSessionMetadataWithRetry(sessionId, (metadata) => + discardMessageQueueV1Item(metadata, { + localId: pendingId, + discardedAt, + discardedReason: opts?.reason ?? 'manual', + }), + ); + await fetchAndApplyPendingMessages({ sessionId, encryption }); +} + +export async function restoreDiscardedPendingMessage(params: { + sessionId: string; + pendingId: string; + updateSessionMetadataWithRetry: UpdateSessionMetadataWithRetry; + encryption: Encryption; +}): Promise<void> { + const { sessionId, pendingId, updateSessionMetadataWithRetry, encryption } = params; + + await updateSessionMetadataWithRetry(sessionId, (metadata) => + restoreMessageQueueV1DiscardedItem(metadata, { localId: pendingId, now: nowServerMs() }), + ); + await fetchAndApplyPendingMessages({ sessionId, encryption }); +} + +export async function deleteDiscardedPendingMessage(params: { + sessionId: string; + pendingId: string; + updateSessionMetadataWithRetry: UpdateSessionMetadataWithRetry; + encryption: Encryption; +}): Promise<void> { + const { sessionId, pendingId, updateSessionMetadataWithRetry, encryption } = params; + + await updateSessionMetadataWithRetry(sessionId, (metadata) => deleteMessageQueueV1DiscardedItem(metadata, pendingId)); + await fetchAndApplyPendingMessages({ sessionId, encryption }); +} + +type SessionListEncryption = { + decryptEncryptionKey: (value: string) => Promise<Uint8Array | null>; + initializeSessions: (sessionKeys: Map<string, Uint8Array | null>) => Promise<void>; + getSessionEncryption: (sessionId: string) => SessionEncryption | null; +}; + +export async function fetchAndApplySessions(params: { + credentials: AuthCredentials; + encryption: SessionListEncryption; + sessionDataKeys: Map<string, Uint8Array>; + applySessions: (sessions: Array<Omit<Session, 'presence'> & { presence?: 'online' | number }>) => void; + repairInvalidReadStateV1: (params: { sessionId: string; sessionSeqUpperBound: number }) => Promise<void>; + log: { log: (message: string) => void }; +}): Promise<void> { + const { credentials, encryption, sessionDataKeys, applySessions, repairInvalidReadStateV1, log } = params; + + const API_ENDPOINT = getServerUrl(); + const response = await fetch(`${API_ENDPOINT}/v1/sessions`, { + headers: { + 'Authorization': `Bearer ${credentials.token}`, + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + if (response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429) { + throw new HappyError(`Failed to fetch sessions (${response.status})`, false); + } + throw new Error(`Failed to fetch sessions: ${response.status}`); + } + + const data = await response.json(); + const sessions = data.sessions as Array<{ + id: string; + seq: number; + metadata: string; + metadataVersion: number; + agentState: string | null; + agentStateVersion: number; + dataEncryptionKey: string | null; + active: boolean; + activeAt: number; + createdAt: number; + updatedAt: number; + lastMessage: ApiMessage | null; + // Sharing (present only for sessions shared with the current user) + owner?: string; + ownerProfile?: { + id: string; + username: string | null; + firstName: string | null; + lastName: string | null; + avatar: string | null; + }; + accessLevel?: 'view' | 'edit' | 'admin'; + }>; + + // Initialize all session encryptions first + const sessionKeys = new Map<string, Uint8Array | null>(); + for (const session of sessions) { + if (session.dataEncryptionKey) { + const decrypted = await encryption.decryptEncryptionKey(session.dataEncryptionKey); + if (!decrypted) { + console.error(`Failed to decrypt data encryption key for session ${session.id}`); + continue; + } + sessionKeys.set(session.id, decrypted); + sessionDataKeys.set(session.id, decrypted); + } else { + sessionKeys.set(session.id, null); + sessionDataKeys.delete(session.id); + } + } + await encryption.initializeSessions(sessionKeys); + + // Decrypt sessions + const decryptedSessions: (Omit<Session, 'presence'> & { presence?: 'online' | number })[] = []; + for (const session of sessions) { + // Get session encryption (should always exist after initialization) + const sessionEncryption = encryption.getSessionEncryption(session.id); + if (!sessionEncryption) { + console.error(`Session encryption not found for ${session.id} - this should never happen`); + continue; + } + + // Decrypt metadata using session-specific encryption + const metadata = await sessionEncryption.decryptMetadata(session.metadataVersion, session.metadata); + + // Decrypt agent state using session-specific encryption + const agentState = await sessionEncryption.decryptAgentState(session.agentStateVersion, session.agentState); + + // Put it all together + decryptedSessions.push({ + ...session, + thinking: false, + thinkingAt: 0, + metadata, + agentState, + }); + } + + // Apply to storage + applySessions(decryptedSessions); + log.log(`📥 fetchSessions completed - processed ${decryptedSessions.length} sessions`); + + void (async () => { + for (const session of decryptedSessions) { + const readState = session.metadata?.readStateV1; + if (!readState) continue; + if (readState.sessionSeq <= session.seq) continue; + await repairInvalidReadStateV1({ sessionId: session.id, sessionSeqUpperBound: session.seq }); + } + })(); +} + +type SessionMessagesEncryption = { + decryptMessages: (messages: ApiMessage[]) => Promise<any[]>; +}; + +export async function fetchAndApplyMessages(params: { + sessionId: string; + getSessionEncryption: (sessionId: string) => SessionMessagesEncryption | null; + request: (path: string) => Promise<Response>; + sessionReceivedMessages: Map<string, Set<string>>; + applyMessages: (sessionId: string, messages: NormalizedMessage[]) => void; + markMessagesLoaded: (sessionId: string) => void; + log: { log: (message: string) => void }; +}): Promise<void> { + const { sessionId, getSessionEncryption, request, sessionReceivedMessages, applyMessages, markMessagesLoaded, log } = + params; + + log.log(`💬 fetchMessages starting for session ${sessionId} - acquiring lock`); + + // Get encryption - may not be ready yet if session was just created + // Throwing an error triggers backoff retry in InvalidateSync + const encryption = getSessionEncryption(sessionId); + if (!encryption) { + log.log(`💬 fetchMessages: Session encryption not ready for ${sessionId}, will retry`); + throw new Error(`Session encryption not ready for ${sessionId}`); + } + + // Request (apiSocket.request calibrates server time best-effort from the HTTP Date header) + const response = await request(`/v1/sessions/${sessionId}/messages`); + const data = await response.json(); + + // Collect existing messages + let eixstingMessages = sessionReceivedMessages.get(sessionId); + if (!eixstingMessages) { + eixstingMessages = new Set<string>(); + sessionReceivedMessages.set(sessionId, eixstingMessages); + } + + // Decrypt and normalize messages + const normalizedMessages: NormalizedMessage[] = []; + + // Filter out existing messages and prepare for batch decryption + const messagesToDecrypt: ApiMessage[] = []; + for (const msg of [...(data.messages as ApiMessage[])].reverse()) { + if (!eixstingMessages.has(msg.id)) { + messagesToDecrypt.push(msg); + } + } + + // Batch decrypt all messages at once + const decryptedMessages = await encryption.decryptMessages(messagesToDecrypt); + + // Process decrypted messages + for (let i = 0; i < decryptedMessages.length; i++) { + const decrypted = decryptedMessages[i]; + if (decrypted) { + eixstingMessages.add(decrypted.id); + // Normalize the decrypted message + const normalized = normalizeRawMessage(decrypted.id, decrypted.localId, decrypted.createdAt, decrypted.content); + if (normalized) { + normalizedMessages.push(normalized); + } + } + } + + // Apply to storage + applyMessages(sessionId, normalizedMessages); + markMessagesLoaded(sessionId); + log.log(`💬 fetchMessages completed for session ${sessionId} - processed ${normalizedMessages.length} messages`); +} diff --git a/expo-app/sources/sync/engine/settings.ts b/expo-app/sources/sync/engine/settings.ts new file mode 100644 index 000000000..75ef8303d --- /dev/null +++ b/expo-app/sources/sync/engine/settings.ts @@ -0,0 +1,246 @@ +import { tracking } from '@/track'; +import { HappyError } from '@/utils/errors'; +import { applySettings, settingsDefaults, settingsParse, type Settings } from '../settings'; +import { summarizeSettings, summarizeSettingsDelta, dbgSettings, isSettingsSyncDebugEnabled } from '../debugSettings'; +import { getServerUrl } from '../serverConfig'; +import { storage } from '../storage'; +import type { AuthCredentials } from '@/auth/tokenStorage'; +import type { Encryption } from '../encryption/encryption'; +import { sealSecretsDeep } from '../secretSettings'; + +export async function syncSettings(params: { + credentials: AuthCredentials; + encryption: Encryption; + pendingSettings: Partial<Settings>; + clearPendingSettings: () => void; +}): Promise<void> { + const { credentials, encryption, pendingSettings, clearPendingSettings } = params; + + const API_ENDPOINT = getServerUrl(); + const maxRetries = 3; + let retryCount = 0; + let lastVersionMismatch: { expectedVersion: number; currentVersion: number; pendingKeys: string[] } | null = null; + + // Apply pending settings + if (Object.keys(pendingSettings).length > 0) { + dbgSettings('syncSettings: pending detected; will POST', { + endpoint: API_ENDPOINT, + expectedVersion: storage.getState().settingsVersion ?? 0, + pendingKeys: Object.keys(pendingSettings).sort(), + pendingSummary: summarizeSettingsDelta(pendingSettings as Partial<Settings>), + base: summarizeSettings(storage.getState().settings, { version: storage.getState().settingsVersion }), + }); + + while (retryCount < maxRetries) { + const version = storage.getState().settingsVersion; + const settings = applySettings(storage.getState().settings, pendingSettings); + dbgSettings('syncSettings: POST attempt', { + endpoint: API_ENDPOINT, + attempt: retryCount + 1, + expectedVersion: version ?? 0, + merged: summarizeSettings(settings, { version }), + }); + + const response = await fetch(`${API_ENDPOINT}/v1/account/settings`, { + method: 'POST', + body: JSON.stringify({ + settings: await encryption.encryptRaw(settings), + expectedVersion: version ?? 0, + }), + headers: { + 'Authorization': `Bearer ${credentials.token}`, + 'Content-Type': 'application/json', + }, + }); + + const data = (await response.json()) as + | { + success: false; + error: string; + currentVersion: number; + currentSettings: string | null; + } + | { + success: true; + }; + + if (data.success) { + clearPendingSettings(); + dbgSettings('syncSettings: POST success; pending cleared', { + endpoint: API_ENDPOINT, + newServerVersion: (version ?? 0) + 1, + }); + break; + } + + if (data.error === 'version-mismatch') { + lastVersionMismatch = { + expectedVersion: version ?? 0, + currentVersion: data.currentVersion, + pendingKeys: Object.keys(pendingSettings).sort(), + }; + + // Parse server settings + const serverSettings = data.currentSettings + ? settingsParse(await encryption.decryptRaw(data.currentSettings)) + : { ...settingsDefaults }; + + // Merge: server base + our pending changes (our changes win) + const mergedSettings = applySettings(serverSettings, pendingSettings); + dbgSettings('syncSettings: version-mismatch merge', { + endpoint: API_ENDPOINT, + expectedVersion: version ?? 0, + currentVersion: data.currentVersion, + pendingKeys: Object.keys(pendingSettings).sort(), + serverParsed: summarizeSettings(serverSettings, { version: data.currentVersion }), + merged: summarizeSettings(mergedSettings, { version: data.currentVersion }), + }); + + // Update local storage with merged result at server's version. + // + // Important: `data.currentVersion` can be LOWER than our local `settingsVersion` + // (e.g. when switching accounts/servers, or after server-side reset). If we only + // "apply when newer", we'd never converge and would retry forever. + storage.getState().replaceSettings(mergedSettings, data.currentVersion); + + // Sync tracking state with merged settings + if (tracking) { + mergedSettings.analyticsOptOut ? tracking.optOut() : tracking.optIn(); + } + + // Log and retry + retryCount++; + continue; + } + + throw new Error(`Failed to sync settings: ${data.error}`); + } + } + + // If exhausted retries, throw to trigger outer backoff delay + if (retryCount >= maxRetries) { + const mismatchHint = lastVersionMismatch + ? ` (expected=${lastVersionMismatch.expectedVersion}, current=${lastVersionMismatch.currentVersion}, pendingKeys=${lastVersionMismatch.pendingKeys.join(',')})` + : ''; + throw new Error(`Settings sync failed after ${maxRetries} retries due to version conflicts${mismatchHint}`); + } + + // Run request + const response = await fetch(`${API_ENDPOINT}/v1/account/settings`, { + headers: { + 'Authorization': `Bearer ${credentials.token}`, + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + if (response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429) { + throw new HappyError(`Failed to fetch settings (${response.status})`, false); + } + throw new Error(`Failed to fetch settings: ${response.status}`); + } + + const data = (await response.json()) as { + settings: string | null; + settingsVersion: number; + }; + + // Parse response + const parsedSettings = data.settings + ? settingsParse(await encryption.decryptRaw(data.settings)) + : { ...settingsDefaults }; + + dbgSettings('syncSettings: GET applied', { + endpoint: API_ENDPOINT, + serverVersion: data.settingsVersion, + parsed: summarizeSettings(parsedSettings, { version: data.settingsVersion }), + }); + + // Apply settings to storage + storage.getState().applySettings(parsedSettings, data.settingsVersion); + + // Sync PostHog opt-out state with settings + if (tracking) { + parsedSettings.analyticsOptOut ? tracking.optOut() : tracking.optIn(); + } +} + +export function applySettingsLocalDelta(params: { + delta: Partial<Settings>; + settingsSecretsKey: Uint8Array | null; + getPendingSettings: () => Partial<Settings>; + setPendingSettings: (next: Partial<Settings>) => void; + schedulePendingSettingsFlush: () => void; +}): void { + const { settingsSecretsKey, getPendingSettings, setPendingSettings, schedulePendingSettingsFlush } = params; + let { delta } = params; + + // Seal secret settings fields before any persistence. + delta = sealSecretsDeep(delta, settingsSecretsKey); + + // Avoid no-op writes. Settings writes cause: + // - local persistence writes + // - pending delta persistence + // - a server POST (eventually) + // + // So we must not write when nothing actually changed. + const currentSettings = storage.getState().settings; + const deltaEntries = Object.entries(delta) as Array<[keyof Settings, unknown]>; + const hasRealChange = deltaEntries.some(([key, next]) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const prev = (currentSettings as any)[key]; + if (Object.is(prev, next)) return false; + + // Keep this O(1) and UI-friendly: + // - For objects/arrays/records, rely on reference changes. + // - Settings updates should always replace values immutably. + const prevIsObj = prev !== null && typeof prev === 'object'; + const nextIsObj = next !== null && typeof next === 'object'; + if (prevIsObj || nextIsObj) { + return prev !== next; + } + return true; + }); + if (!hasRealChange) { + dbgSettings('applySettings skipped (no-op delta)', { + delta: summarizeSettingsDelta(delta), + base: summarizeSettings(currentSettings, { version: storage.getState().settingsVersion }), + }); + return; + } + + if (isSettingsSyncDebugEnabled()) { + const stack = (() => { + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const s = (new Error('settings-sync trace') as any)?.stack; + return typeof s === 'string' ? s.split('\n').slice(0, 10).join('\n') : null; + } catch { + return null; + } + })(); + const st = storage.getState(); + dbgSettings('applySettings called', { + delta: summarizeSettingsDelta(delta), + base: summarizeSettings(st.settings, { version: st.settingsVersion }), + stack, + }); + } + + storage.getState().applySettingsLocal(delta); + + // Save pending settings + const nextPending = { ...getPendingSettings(), ...delta }; + setPendingSettings(nextPending); + dbgSettings('applySettings: pendingSettings updated', { + pendingKeys: Object.keys(nextPending).sort(), + }); + + // Sync PostHog opt-out state if it was changed + if (tracking && 'analyticsOptOut' in delta) { + const currentSettings = storage.getState().settings; + currentSettings.analyticsOptOut ? tracking.optOut() : tracking.optIn(); + } + + schedulePendingSettingsFlush(); +} diff --git a/expo-app/sources/sync/engine/socket.ts b/expo-app/sources/sync/engine/socket.ts new file mode 100644 index 000000000..596cbf409 --- /dev/null +++ b/expo-app/sources/sync/engine/socket.ts @@ -0,0 +1,427 @@ +import { ApiEphemeralUpdateSchema, ApiUpdateContainerSchema } from '../apiTypes'; +import type { ApiEphemeralActivityUpdate, ApiUpdateContainer } from '../apiTypes'; +import type { Encryption } from '../encryption/encryption'; +import type { NormalizedMessage } from '../typesRaw'; +import type { Session } from '../storageTypes'; +import type { Machine } from '../storageTypes'; +import { storage } from '../storage'; +import { projectManager } from '../projectManager'; +import { gitStatusSync } from '../gitStatusSync'; +import { voiceHooks } from '@/realtime/hooks/voiceHooks'; +import { didControlReturnToMobile } from '../controlledByUserTransitions'; +import { + buildUpdatedSessionFromSocketUpdate, + handleDeleteSessionSocketUpdate, + handleNewMessageSocketUpdate, +} from './sessions'; +import { + buildMachineFromMachineActivityEphemeralUpdate, + buildUpdatedMachineFromSocketUpdate, +} from './machines'; +import { handleUpdateAccountSocketUpdate } from './account'; +import { + handleDeleteArtifactSocketUpdate, + handleNewArtifactSocketUpdate, + handleUpdateArtifactSocketUpdate, +} from './artifacts'; +import { + handleNewFeedPostUpdate, + handleRelationshipUpdatedSocketUpdate, + handleTodoKvBatchUpdate, +} from './feed'; + +export function parseUpdateContainer(update: unknown) { + const validatedUpdate = ApiUpdateContainerSchema.safeParse(update); + if (!validatedUpdate.success) { + console.error('❌ Sync: Invalid update data:', update); + return null; + } + return validatedUpdate.data; +} + +export function parseEphemeralUpdate(update: unknown) { + const validatedUpdate = ApiEphemeralUpdateSchema.safeParse(update); + if (!validatedUpdate.success) { + console.error('Invalid ephemeral update received:', update); + return null; + } + return validatedUpdate.data; +} + +export function handleSocketReconnected(params: { + log: { log: (message: string) => void }; + invalidateSessions: () => void; + invalidateMachines: () => void; + invalidateArtifacts: () => void; + invalidateFriends: () => void; + invalidateFriendRequests: () => void; + invalidateFeed: () => void; + getSessionsData: () => any; + invalidateMessagesForSession: (sessionId: string) => void; + invalidateGitStatusForSession: (sessionId: string) => void; +}) { + const { + log, + invalidateSessions, + invalidateMachines, + invalidateArtifacts, + invalidateFriends, + invalidateFriendRequests, + invalidateFeed, + getSessionsData, + invalidateMessagesForSession, + invalidateGitStatusForSession, + } = params; + + log.log('🔌 Socket reconnected'); + invalidateSessions(); + invalidateMachines(); + log.log('🔌 Socket reconnected: Invalidating artifacts sync'); + invalidateArtifacts(); + invalidateFriends(); + invalidateFriendRequests(); + invalidateFeed(); + + const sessionsData = getSessionsData(); + if (sessionsData) { + for (const item of sessionsData as any[]) { + if (typeof item !== 'string') { + invalidateMessagesForSession(item.id); + // Also invalidate git status on reconnection + invalidateGitStatusForSession(item.id); + } + } + } +} + +type ApplySessions = (sessions: Array<Omit<Session, 'presence'> & { presence?: 'online' | number }>) => void; + +export async function handleSocketUpdate(params: { + update: unknown; + encryption: Encryption; + artifactDataKeys: Map<string, Uint8Array>; + applySessions: ApplySessions; + fetchSessions: () => void; + applyMessages: (sessionId: string, messages: NormalizedMessage[]) => void; + onSessionVisible: (sessionId: string) => void; + assumeUsers: (userIds: string[]) => Promise<void>; + applyTodoSocketUpdates: (changes: any[]) => Promise<void>; + invalidateSessions: () => void; + invalidateArtifacts: () => void; + invalidateFriends: () => void; + invalidateFriendRequests: () => void; + invalidateFeed: () => void; + invalidateTodos: () => void; + log: { log: (message: string) => void }; +}): Promise<void> { + const { + update, + encryption, + artifactDataKeys, + applySessions, + fetchSessions, + applyMessages, + onSessionVisible, + assumeUsers, + applyTodoSocketUpdates, + invalidateSessions, + invalidateArtifacts, + invalidateFriends, + invalidateFriendRequests, + invalidateFeed, + invalidateTodos, + log, + } = params; + + const updateData = parseUpdateContainer(update); + if (!updateData) return; + + await handleUpdateContainer({ + updateData, + encryption, + artifactDataKeys, + applySessions, + fetchSessions, + applyMessages, + onSessionVisible, + assumeUsers, + applyTodoSocketUpdates, + invalidateSessions, + invalidateArtifacts, + invalidateFriends, + invalidateFriendRequests, + invalidateFeed, + invalidateTodos, + log, + }); +} + +export async function handleUpdateContainer(params: { + updateData: ApiUpdateContainer; + encryption: Encryption; + artifactDataKeys: Map<string, Uint8Array>; + applySessions: ApplySessions; + fetchSessions: () => void; + applyMessages: (sessionId: string, messages: NormalizedMessage[]) => void; + onSessionVisible: (sessionId: string) => void; + assumeUsers: (userIds: string[]) => Promise<void>; + applyTodoSocketUpdates: (changes: any[]) => Promise<void>; + invalidateSessions: () => void; + invalidateArtifacts: () => void; + invalidateFriends: () => void; + invalidateFriendRequests: () => void; + invalidateFeed: () => void; + invalidateTodos: () => void; + log: { log: (message: string) => void }; +}): Promise<void> { + const { + updateData, + encryption, + artifactDataKeys, + applySessions, + fetchSessions, + applyMessages, + onSessionVisible, + assumeUsers, + applyTodoSocketUpdates, + invalidateSessions, + invalidateArtifacts, + invalidateFriends, + invalidateFriendRequests, + invalidateFeed, + invalidateTodos, + log, + } = params; + + if (updateData.body.t === 'new-message') { + await handleNewMessageSocketUpdate({ + updateData, + getSessionEncryption: (sessionId) => encryption.getSessionEncryption(sessionId), + getSession: (sessionId) => storage.getState().sessions[sessionId], + applySessions: (sessions) => applySessions(sessions), + fetchSessions, + applyMessages, + isMutableToolCall: (sessionId, toolUseId) => storage.getState().isMutableToolCall(sessionId, toolUseId), + invalidateGitStatus: (sessionId) => gitStatusSync.invalidate(sessionId), + onSessionVisible, + }); + } else if (updateData.body.t === 'new-session') { + log.log('🆕 New session update received'); + invalidateSessions(); + } else if (updateData.body.t === 'delete-session') { + log.log('🗑️ Delete session update received'); + handleDeleteSessionSocketUpdate({ + sessionId: updateData.body.sid, + deleteSession: (sessionId) => storage.getState().deleteSession(sessionId), + removeSessionEncryption: (sessionId) => encryption.removeSessionEncryption(sessionId), + removeProjectManagerSession: (sessionId) => projectManager.removeSession(sessionId), + clearGitStatusForSession: (sessionId) => gitStatusSync.clearForSession(sessionId), + log, + }); + } else if (updateData.body.t === 'update-session') { + const session = storage.getState().sessions[updateData.body.id]; + if (session) { + // Get session encryption + const sessionEncryption = encryption.getSessionEncryption(updateData.body.id); + if (!sessionEncryption) { + console.error(`Session encryption not found for ${updateData.body.id} - this should never happen`); + return; + } + + const { nextSession, agentState } = await buildUpdatedSessionFromSocketUpdate({ + session, + updateBody: updateData.body, + updateSeq: updateData.seq, + updateCreatedAt: updateData.createdAt, + sessionEncryption, + }); + + applySessions([nextSession]); + + // Invalidate git status when agent state changes (files may have been modified) + if (updateData.body.agentState) { + gitStatusSync.invalidate(updateData.body.id); + + // Check for new permission requests and notify voice assistant + if (agentState?.requests && Object.keys(agentState.requests).length > 0) { + const requestIds = Object.keys(agentState.requests); + const firstRequest = agentState.requests[requestIds[0]]; + const toolName = firstRequest?.tool; + voiceHooks.onPermissionRequested( + updateData.body.id, + requestIds[0], + toolName, + firstRequest?.arguments, + ); + } + + // Re-fetch messages when control returns to mobile (local -> remote mode switch) + // This catches up on any messages that were exchanged while desktop had control + const wasControlledByUser = session.agentState?.controlledByUser; + const isNowControlledByUser = agentState?.controlledByUser; + if (didControlReturnToMobile(wasControlledByUser, isNowControlledByUser)) { + log.log(`🔄 Control returned to mobile for session ${updateData.body.id}, re-fetching messages`); + onSessionVisible(updateData.body.id); + } + } + } + } else if (updateData.body.t === 'update-account') { + const accountUpdate = updateData.body; + const currentProfile = storage.getState().profile; + + await handleUpdateAccountSocketUpdate({ + accountUpdate, + updateCreatedAt: updateData.createdAt, + currentProfile, + encryption, + applyProfile: (profile) => storage.getState().applyProfile(profile), + applySettings: (settings, version) => storage.getState().applySettings(settings, version), + log, + }); + } else if (updateData.body.t === 'update-machine') { + const machineUpdate = updateData.body; + const machineId = machineUpdate.machineId; // Changed from .id to .machineId + const machine = storage.getState().machines[machineId]; + + const updatedMachine = await buildUpdatedMachineFromSocketUpdate({ + machineUpdate, + updateSeq: updateData.seq, + updateCreatedAt: updateData.createdAt, + existingMachine: machine, + getMachineEncryption: (id) => encryption.getMachineEncryption(id), + }); + if (!updatedMachine) return; + + // Update storage using applyMachines which rebuilds sessionListViewData + storage.getState().applyMachines([updatedMachine]); + } else if (updateData.body.t === 'relationship-updated') { + log.log('👥 Received relationship-updated update'); + const relationshipUpdate = updateData.body; + + handleRelationshipUpdatedSocketUpdate({ + relationshipUpdate, + applyRelationshipUpdate: (update) => storage.getState().applyRelationshipUpdate(update), + invalidateFriends, + invalidateFriendRequests, + invalidateFeed, + }); + } else if (updateData.body.t === 'new-artifact') { + log.log('📦 Received new-artifact update'); + const artifactUpdate = updateData.body; + const artifactId = artifactUpdate.artifactId; + + await handleNewArtifactSocketUpdate({ + artifactId, + dataEncryptionKey: artifactUpdate.dataEncryptionKey, + header: artifactUpdate.header, + headerVersion: artifactUpdate.headerVersion, + body: artifactUpdate.body, + bodyVersion: artifactUpdate.bodyVersion, + seq: artifactUpdate.seq, + createdAt: artifactUpdate.createdAt, + updatedAt: artifactUpdate.updatedAt, + encryption, + artifactDataKeys, + addArtifact: (artifact) => storage.getState().addArtifact(artifact), + log, + }); + } else if (updateData.body.t === 'update-artifact') { + log.log('📦 Received update-artifact update'); + const artifactUpdate = updateData.body; + const artifactId = artifactUpdate.artifactId; + + await handleUpdateArtifactSocketUpdate({ + artifactId, + seq: updateData.seq, + createdAt: updateData.createdAt, + header: artifactUpdate.header, + body: artifactUpdate.body, + artifactDataKeys, + getExistingArtifact: (id) => storage.getState().artifacts[id], + updateArtifact: (artifact) => storage.getState().updateArtifact(artifact), + invalidateArtifactsSync: invalidateArtifacts, + log, + }); + } else if (updateData.body.t === 'delete-artifact') { + log.log('📦 Received delete-artifact update'); + const artifactUpdate = updateData.body; + const artifactId = artifactUpdate.artifactId; + + handleDeleteArtifactSocketUpdate({ + artifactId, + deleteArtifact: (id) => storage.getState().deleteArtifact(id), + artifactDataKeys, + }); + } else if (updateData.body.t === 'new-feed-post') { + log.log('📰 Received new-feed-post update'); + const feedUpdate = updateData.body; + + await handleNewFeedPostUpdate({ + feedUpdate, + assumeUsers, + getUsers: () => storage.getState().users, + applyFeedItems: (items) => storage.getState().applyFeedItems(items), + log, + }); + } else if (updateData.body.t === 'kv-batch-update') { + log.log('📝 Received kv-batch-update'); + const kvUpdate = updateData.body; + + await handleTodoKvBatchUpdate({ + kvUpdate, + applyTodoSocketUpdates, + invalidateTodosSync: invalidateTodos, + log, + }); + } +} + +export function flushActivityUpdates(params: { updates: Map<string, ApiEphemeralActivityUpdate>; applySessions: ApplySessions }): void { + const { updates, applySessions } = params; + + const sessions: Session[] = []; + + for (const [sessionId, update] of updates) { + const session = storage.getState().sessions[sessionId]; + if (session) { + sessions.push({ + ...session, + active: update.active, + activeAt: update.activeAt, + thinking: update.thinking ?? false, + thinkingAt: update.activeAt, // Always use activeAt for consistency + }); + } + } + + if (sessions.length > 0) { + applySessions(sessions); + } +} + +export function handleEphemeralSocketUpdate(params: { + update: unknown; + addActivityUpdate: (update: any) => void; +}): void { + const { update, addActivityUpdate } = params; + + const updateData = parseEphemeralUpdate(update); + if (!updateData) return; + + // Process activity updates through smart debounce accumulator + if (updateData.type === 'activity') { + addActivityUpdate(updateData); + } + + // Handle machine activity updates + if (updateData.type === 'machine-activity') { + // Update machine's active status and lastActiveAt + const machine = storage.getState().machines[updateData.id]; + if (machine) { + const updatedMachine: Machine = buildMachineFromMachineActivityEphemeralUpdate({ machine, updateData }); + storage.getState().applyMachines([updatedMachine]); + } + } + + // daemon-status ephemeral updates are deprecated, machine status is handled via machine-activity +} diff --git a/expo-app/sources/sync/engine/todos.ts b/expo-app/sources/sync/engine/todos.ts new file mode 100644 index 000000000..8f41f4557 --- /dev/null +++ b/expo-app/sources/sync/engine/todos.ts @@ -0,0 +1,91 @@ +import type { AuthCredentials } from '@/auth/tokenStorage'; +import { log } from '@/log'; +import { initializeTodoSync } from '../../-zen/model/ops'; +import { storage } from '../storage'; + +type RawEncryption = { + decryptRaw: (value: string) => Promise<any>; +}; + +export async function fetchTodos(params: { credentials: AuthCredentials }): Promise<void> { + const { credentials } = params; + + try { + log.log('📝 Fetching todos...'); + await initializeTodoSync(credentials); + log.log('📝 Todos loaded'); + } catch (error) { + log.log('📝 Failed to fetch todos:'); + } +} + +export async function applyTodoSocketUpdates(params: { + changes: any[]; + encryption: RawEncryption; + invalidateTodosSync: () => void; +}): Promise<void> { + const { changes, encryption, invalidateTodosSync } = params; + + const currentState = storage.getState(); + const todoState = currentState.todoState; + if (!todoState) { + // No todo state yet, just refetch + invalidateTodosSync(); + return; + } + + const { todos, undoneOrder, doneOrder, versions } = todoState; + const updatedTodos = { ...todos }; + const updatedVersions = { ...versions }; + let newUndoneOrder = undoneOrder; + let newDoneOrder = doneOrder; + + // Process each change + for (const change of changes) { + try { + const key = change.key; + const version = change.version; + + // Update version tracking + updatedVersions[key] = version; + + if (change.value === null) { + // Item was deleted + if (key.startsWith('todo.') && key !== 'todo.index') { + const todoId = key.substring(5); // Remove 'todo.' prefix + delete updatedTodos[todoId]; + newUndoneOrder = newUndoneOrder.filter((id) => id !== todoId); + newDoneOrder = newDoneOrder.filter((id) => id !== todoId); + } + } else { + // Item was added or updated + const decrypted = await encryption.decryptRaw(change.value); + + if (key === 'todo.index') { + // Update the index + const index = decrypted as any; + newUndoneOrder = index.undoneOrder || []; + newDoneOrder = index.completedOrder || []; // Map completedOrder to doneOrder + } else if (key.startsWith('todo.')) { + // Update a todo item + const todoId = key.substring(5); + if (todoId && todoId !== 'index') { + updatedTodos[todoId] = decrypted as any; + } + } + } + } catch (error) { + console.error(`Failed to process todo change for key ${change.key}:`, error); + } + } + + // Apply the updated state + storage.getState().applyTodos({ + todos: updatedTodos, + undoneOrder: newUndoneOrder, + doneOrder: newDoneOrder, + versions: updatedVersions, + }); + + log.log('📝 Applied todo socket updates successfully'); +} diff --git a/expo-app/sources/sync/friendTypes.ts b/expo-app/sources/sync/friendTypes.ts index 4ad66bb98..73282910e 100644 --- a/expo-app/sources/sync/friendTypes.ts +++ b/expo-app/sources/sync/friendTypes.ts @@ -25,7 +25,10 @@ export const UserProfileSchema = z.object({ }).nullable(), username: z.string(), bio: z.string().nullable(), - status: RelationshipStatusSchema + status: RelationshipStatusSchema, + publicKey: z.string(), + contentPublicKey: z.string().nullable(), + contentPublicKeySig: z.string().nullable(), }); export type UserProfile = z.infer<typeof UserProfileSchema>; @@ -89,4 +92,4 @@ export function isPendingRequest(status: RelationshipStatus): boolean { export function isRequested(status: RelationshipStatus): boolean { return status === 'requested'; -} \ No newline at end of file +} diff --git a/expo-app/sources/sync/messageMeta.test.ts b/expo-app/sources/sync/messageMeta.test.ts new file mode 100644 index 000000000..558485cc4 --- /dev/null +++ b/expo-app/sources/sync/messageMeta.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect } from 'vitest'; +import { buildOutgoingMessageMeta } from './messageMeta'; + +describe('buildOutgoingMessageMeta', () => { + it('does not include model fields by default', () => { + const meta = buildOutgoingMessageMeta({ + sentFrom: 'web', + permissionMode: 'default', + appendSystemPrompt: 'PROMPT', + }); + + expect(meta.sentFrom).toBe('web'); + expect(meta.permissionMode).toBe('default'); + expect(meta.appendSystemPrompt).toBe('PROMPT'); + expect('model' in meta).toBe(false); + expect('fallbackModel' in meta).toBe(false); + }); + + it('includes model when explicitly provided', () => { + const meta = buildOutgoingMessageMeta({ + sentFrom: 'web', + permissionMode: 'default', + model: 'gemini-2.5-pro', + appendSystemPrompt: 'PROMPT', + }); + + expect(meta.model).toBe('gemini-2.5-pro'); + expect('model' in meta).toBe(true); + }); + + it('includes displayText when explicitly provided (including empty string)', () => { + const meta = buildOutgoingMessageMeta({ + sentFrom: 'web', + permissionMode: 'default', + appendSystemPrompt: 'PROMPT', + displayText: '', + }); + + expect('displayText' in meta).toBe(true); + expect(meta.displayText).toBe(''); + }); + + it('includes fallbackModel when explicitly provided', () => { + const meta = buildOutgoingMessageMeta({ + sentFrom: 'web', + permissionMode: 'default', + appendSystemPrompt: 'PROMPT', + fallbackModel: 'gemini-2.5-flash', + }); + + expect('fallbackModel' in meta).toBe(true); + expect(meta.fallbackModel).toBe('gemini-2.5-flash'); + }); +}); diff --git a/expo-app/sources/sync/messageMeta.ts b/expo-app/sources/sync/messageMeta.ts new file mode 100644 index 000000000..d97b22055 --- /dev/null +++ b/expo-app/sources/sync/messageMeta.ts @@ -0,0 +1,19 @@ +import type { MessageMeta } from './typesMessageMeta'; + +export function buildOutgoingMessageMeta(params: { + sentFrom: string; + permissionMode: NonNullable<MessageMeta['permissionMode']>; + model?: MessageMeta['model']; + fallbackModel?: MessageMeta['fallbackModel']; + appendSystemPrompt: string; + displayText?: string; +}): MessageMeta { + return { + sentFrom: params.sentFrom, + permissionMode: params.permissionMode, + appendSystemPrompt: params.appendSystemPrompt, + ...(params.displayText !== undefined ? { displayText: params.displayText } : {}), + ...(params.model !== undefined ? { model: params.model } : {}), + ...(params.fallbackModel !== undefined ? { fallbackModel: params.fallbackModel } : {}), + }; +} diff --git a/expo-app/sources/sync/messageQueueV1.test.ts b/expo-app/sources/sync/messageQueueV1.test.ts new file mode 100644 index 000000000..393752f4e --- /dev/null +++ b/expo-app/sources/sync/messageQueueV1.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it } from 'vitest'; + +import type { Metadata } from './storageTypes'; +import { deleteMessageQueueV1DiscardedItem, deleteMessageQueueV1Item, discardMessageQueueV1All, discardMessageQueueV1Item, enqueueMessageQueueV1Item, restoreMessageQueueV1DiscardedItem, updateMessageQueueV1Item } from './messageQueueV1'; + +function baseMetadata(): Metadata { + return { path: '/tmp', host: 'host' }; +} + +describe('messageQueueV1 helpers', () => { + it('enqueues items and preserves existing queue order', () => { + const m1 = enqueueMessageQueueV1Item(baseMetadata(), { + localId: 'a', + message: 'm1', + createdAt: 1, + updatedAt: 1, + }); + const m2 = enqueueMessageQueueV1Item(m1, { + localId: 'b', + message: 'm2', + createdAt: 2, + updatedAt: 2, + }); + + expect(m2.messageQueueV1?.queue.map((q) => q.localId)).toEqual(['a', 'b']); + }); + + it('updates an existing queued item by localId', () => { + const m1 = enqueueMessageQueueV1Item(baseMetadata(), { + localId: 'a', + message: 'm1', + createdAt: 1, + updatedAt: 1, + }); + const m2 = updateMessageQueueV1Item(m1, { + localId: 'a', + message: 'm1-updated', + createdAt: 1, + updatedAt: 2, + }); + + expect(m2.messageQueueV1?.queue).toEqual([ + { localId: 'a', message: 'm1-updated', createdAt: 1, updatedAt: 2 }, + ]); + }); + + it('deletes an item by localId', () => { + const m1 = enqueueMessageQueueV1Item(baseMetadata(), { + localId: 'a', + message: 'm1', + createdAt: 1, + updatedAt: 1, + }); + const m2 = enqueueMessageQueueV1Item(m1, { + localId: 'b', + message: 'm2', + createdAt: 2, + updatedAt: 2, + }); + const m3 = deleteMessageQueueV1Item(m2, 'a'); + expect(m3.messageQueueV1?.queue.map((q) => q.localId)).toEqual(['b']); + }); + + it('preserves inFlight when mutating queue', () => { + const metadata: Metadata = { + ...baseMetadata(), + messageQueueV1: { + v: 1, + queue: [], + inFlight: { localId: 'x', message: 'mx', createdAt: 1, updatedAt: 1, claimedAt: 1 }, + }, + }; + const next = enqueueMessageQueueV1Item(metadata, { + localId: 'a', + message: 'm1', + createdAt: 2, + updatedAt: 2, + }); + expect(next.messageQueueV1?.inFlight?.localId).toBe('x'); + }); + + it('moves queued + inFlight items into messageQueueV1Discarded and clears the queue', () => { + const metadata: Metadata = { + ...baseMetadata(), + messageQueueV1: { + v: 1, + queue: [{ localId: 'a', message: 'm1', createdAt: 1, updatedAt: 1 }], + inFlight: { localId: 'x', message: 'mx', createdAt: 2, updatedAt: 2, claimedAt: 3 }, + }, + }; + + const { metadata: next, discarded } = discardMessageQueueV1All(metadata, { + discardedAt: 10, + discardedReason: 'switch_to_local', + }); + + expect(discarded.map((d) => d.localId)).toEqual(['x', 'a']); + expect(next.messageQueueV1?.queue).toEqual([]); + expect(next.messageQueueV1?.inFlight).toBe(null); + expect(next.messageQueueV1Discarded?.map((d) => d.localId)).toEqual(['x', 'a']); + }); + + it('moves a queued item into messageQueueV1Discarded', () => { + const metadata: Metadata = { + ...baseMetadata(), + messageQueueV1: { + v: 1, + queue: [{ localId: 'a', message: 'm1', createdAt: 1, updatedAt: 1 }], + inFlight: null, + }, + }; + + const next = discardMessageQueueV1Item(metadata, { + localId: 'a', + discardedAt: 10, + discardedReason: 'manual', + }); + + expect(next.messageQueueV1?.queue).toEqual([]); + expect(next.messageQueueV1Discarded).toEqual([{ + localId: 'a', + message: 'm1', + createdAt: 1, + updatedAt: 1, + discardedAt: 10, + discardedReason: 'manual', + }]); + }); + + it('restores a discarded item back into the queue', () => { + const metadata: Metadata = { + ...baseMetadata(), + messageQueueV1: { v: 1, queue: [] }, + messageQueueV1Discarded: [{ + localId: 'a', + message: 'm1', + createdAt: 1, + updatedAt: 1, + discardedAt: 5, + discardedReason: 'switch_to_local', + }], + }; + + const next = restoreMessageQueueV1DiscardedItem(metadata, { localId: 'a', now: 20 }); + expect(next.messageQueueV1?.queue).toEqual([{ localId: 'a', message: 'm1', createdAt: 1, updatedAt: 20 }]); + expect(next.messageQueueV1Discarded).toEqual([]); + }); + + it('deletes a discarded item from messageQueueV1Discarded', () => { + const metadata: Metadata = { + ...baseMetadata(), + messageQueueV1: { v: 1, queue: [] }, + messageQueueV1Discarded: [{ + localId: 'a', + message: 'm1', + createdAt: 1, + updatedAt: 1, + discardedAt: 5, + discardedReason: 'switch_to_local', + }], + }; + + const next = deleteMessageQueueV1DiscardedItem(metadata, 'a'); + expect(next.messageQueueV1Discarded).toEqual([]); + }); +}); diff --git a/expo-app/sources/sync/messageQueueV1.ts b/expo-app/sources/sync/messageQueueV1.ts new file mode 100644 index 000000000..bae85445b --- /dev/null +++ b/expo-app/sources/sync/messageQueueV1.ts @@ -0,0 +1,224 @@ +import type { Metadata } from './storageTypes'; + +export type MessageQueueV1Item = { + localId: string; + message: string; + createdAt: number; + updatedAt: number; +}; + +export type MessageQueueV1InFlight = MessageQueueV1Item & { + claimedAt: number; +}; + +export type MessageQueueV1DiscardedReason = 'switch_to_local' | 'manual'; + +export type MessageQueueV1DiscardedItem = MessageQueueV1Item & { + discardedAt: number; + discardedReason: MessageQueueV1DiscardedReason; +}; + +export type MessageQueueV1 = { + v: 1; + queue: MessageQueueV1Item[]; + inFlight?: MessageQueueV1InFlight | null; +}; + +function ensureQueue(metadata: Metadata): MessageQueueV1 { + const existing = metadata.messageQueueV1; + if (existing && existing.v === 1 && Array.isArray(existing.queue)) { + return existing; + } + return { v: 1, queue: [] }; +} + +export function enqueueMessageQueueV1Item(metadata: Metadata, item: MessageQueueV1Item): Metadata { + const mq = ensureQueue(metadata); + const existingIndex = mq.queue.findIndex((q) => q.localId === item.localId); + const nextQueue = + existingIndex >= 0 + ? [...mq.queue.slice(0, existingIndex), item, ...mq.queue.slice(existingIndex + 1)] + : [...mq.queue, item]; + return { + ...metadata, + messageQueueV1: { + ...mq, + v: 1, + queue: nextQueue, + }, + }; +} + +export function updateMessageQueueV1Item(metadata: Metadata, item: MessageQueueV1Item): Metadata { + const mq = ensureQueue(metadata); + const existingIndex = mq.queue.findIndex((q) => q.localId === item.localId); + if (existingIndex < 0) { + return metadata; + } + const nextQueue = [...mq.queue.slice(0, existingIndex), item, ...mq.queue.slice(existingIndex + 1)]; + return { + ...metadata, + messageQueueV1: { + ...mq, + v: 1, + queue: nextQueue, + }, + }; +} + +export function deleteMessageQueueV1Item(metadata: Metadata, localId: string): Metadata { + const mq = ensureQueue(metadata); + const nextQueue = mq.queue.filter((q) => q.localId !== localId); + return { + ...metadata, + messageQueueV1: { + ...mq, + v: 1, + queue: nextQueue, + }, + }; +} + +export function discardMessageQueueV1All( + metadata: Metadata, + opts: { discardedAt: number; discardedReason: MessageQueueV1DiscardedReason; maxDiscarded?: number } +): { metadata: Metadata; discarded: MessageQueueV1DiscardedItem[] } { + const mq = ensureQueue(metadata); + const existingDiscarded = metadata.messageQueueV1Discarded ?? []; + const maxDiscarded = opts.maxDiscarded ?? 50; + + const discardFromQueue = mq.queue.map((q) => ({ + ...q, + discardedAt: opts.discardedAt, + discardedReason: opts.discardedReason, + })); + const discardFromInFlight = mq.inFlight + ? [{ + localId: mq.inFlight.localId, + message: mq.inFlight.message, + createdAt: mq.inFlight.createdAt, + updatedAt: mq.inFlight.updatedAt, + discardedAt: opts.discardedAt, + discardedReason: opts.discardedReason, + }] + : []; + + const discarded = [...discardFromInFlight, ...discardFromQueue]; + if (discarded.length === 0) { + return { metadata, discarded: [] }; + } + + const nextDiscarded = [...existingDiscarded, ...discarded].slice(-maxDiscarded); + return { + metadata: { + ...metadata, + messageQueueV1: { + ...mq, + queue: [], + inFlight: null, + }, + messageQueueV1Discarded: nextDiscarded, + }, + discarded, + }; +} + +export function discardMessageQueueV1Item( + metadata: Metadata, + opts: { localId: string; discardedAt: number; discardedReason: MessageQueueV1DiscardedReason; maxDiscarded?: number } +): Metadata { + const mq = ensureQueue(metadata); + const existingDiscarded = metadata.messageQueueV1Discarded ?? []; + const maxDiscarded = opts.maxDiscarded ?? 50; + + const queueIndex = mq.queue.findIndex((q) => q.localId === opts.localId); + const queueItem = queueIndex >= 0 ? mq.queue[queueIndex] : null; + + const inFlightItem = mq.inFlight && mq.inFlight.localId === opts.localId + ? mq.inFlight + : null; + + if (!queueItem && !inFlightItem) { + return metadata; + } + + const item: MessageQueueV1Item = queueItem + ? queueItem + : { + localId: inFlightItem!.localId, + message: inFlightItem!.message, + createdAt: inFlightItem!.createdAt, + updatedAt: inFlightItem!.updatedAt, + }; + + const discardedItem: MessageQueueV1DiscardedItem = { + ...item, + discardedAt: opts.discardedAt, + discardedReason: opts.discardedReason, + }; + + const nextQueue = queueItem + ? [...mq.queue.slice(0, queueIndex), ...mq.queue.slice(queueIndex + 1)] + : mq.queue; + + const next: MessageQueueV1 = { + ...mq, + v: 1, + queue: nextQueue, + }; + if (mq.inFlight !== undefined) { + next.inFlight = inFlightItem ? null : mq.inFlight; + } + + return { + ...metadata, + messageQueueV1: next, + messageQueueV1Discarded: [...existingDiscarded, discardedItem].slice(-maxDiscarded), + }; +} + +export function restoreMessageQueueV1DiscardedItem( + metadata: Metadata, + opts: { localId: string; now: number } +): Metadata { + const existingDiscarded = metadata.messageQueueV1Discarded ?? []; + const index = existingDiscarded.findIndex((d) => d.localId === opts.localId); + if (index < 0) return metadata; + + const discardedItem = existingDiscarded[index]; + const nextDiscarded = [...existingDiscarded.slice(0, index), ...existingDiscarded.slice(index + 1)]; + + const mq = ensureQueue(metadata); + const restoredItem: MessageQueueV1Item = { + localId: discardedItem.localId, + message: discardedItem.message, + createdAt: discardedItem.createdAt, + updatedAt: opts.now, + }; + + const existingQueueIndex = mq.queue.findIndex((q) => q.localId === opts.localId); + const nextQueue = + existingQueueIndex >= 0 + ? [...mq.queue.slice(0, existingQueueIndex), restoredItem, ...mq.queue.slice(existingQueueIndex + 1)] + : [...mq.queue, restoredItem]; + + return { + ...metadata, + messageQueueV1: { + ...mq, + v: 1, + queue: nextQueue, + }, + messageQueueV1Discarded: nextDiscarded, + }; +} + +export function deleteMessageQueueV1DiscardedItem(metadata: Metadata, localId: string): Metadata { + const existingDiscarded = metadata.messageQueueV1Discarded ?? []; + const nextDiscarded = existingDiscarded.filter((d) => d.localId !== localId); + if (nextDiscarded.length === existingDiscarded.length) return metadata; + return { + ...metadata, + messageQueueV1Discarded: nextDiscarded, + }; +} diff --git a/expo-app/sources/sync/messageQueueV1Pending.test.ts b/expo-app/sources/sync/messageQueueV1Pending.test.ts new file mode 100644 index 000000000..357342c1a --- /dev/null +++ b/expo-app/sources/sync/messageQueueV1Pending.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from 'vitest'; +import { decodeMessageQueueV1ToPendingMessages, reconcilePendingMessagesFromMetadata } from './messageQueueV1Pending'; + +describe('decodeMessageQueueV1ToPendingMessages', () => { + it('includes inFlight items along with queued items', async () => { + const result = await decodeMessageQueueV1ToPendingMessages({ + messageQueueV1: { + v: 1, + inFlight: { + localId: 'inflight-1', + message: 'enc-inflight', + createdAt: 10, + updatedAt: 10, + claimedAt: 11, + }, + queue: [ + { localId: 'q-1', message: 'enc-q1', createdAt: 20, updatedAt: 20 }, + ], + }, + messageQueueV1Discarded: [], + decryptRaw: async (encrypted) => { + if (encrypted === 'enc-inflight') return { content: { text: 'inflight msg' } }; + if (encrypted === 'enc-q1') return { content: { text: 'queued msg' } }; + throw new Error('unexpected encrypted'); + }, + }); + + expect(result.pending.map((m) => m.id)).toEqual(['inflight-1', 'q-1']); + expect(result.pending.map((m) => m.text)).toEqual(['inflight msg', 'queued msg']); + }); + + it('skips queue items that cannot be decoded into a text user message', async () => { + const result = await decodeMessageQueueV1ToPendingMessages({ + messageQueueV1: { + v: 1, + inFlight: null, + queue: [ + { localId: 'q-1', message: 'enc-q1', createdAt: 20, updatedAt: 20 }, + ], + }, + messageQueueV1Discarded: [], + decryptRaw: async () => ({ content: { text: 123 } }), + }); + + expect(result.pending).toEqual([]); + }); + + it('skips items that fail to decrypt without failing the whole decode', async () => { + const result = await decodeMessageQueueV1ToPendingMessages({ + messageQueueV1: { + v: 1, + inFlight: null, + queue: [ + { localId: 'q-1', message: 'enc-q1', createdAt: 20, updatedAt: 20 }, + ], + }, + messageQueueV1Discarded: [ + { localId: 'd-1', message: 'enc-d1', createdAt: 30, updatedAt: 30, discardedAt: 31, discardedReason: 'manual' }, + ], + decryptRaw: async (encrypted) => { + throw new Error(`boom:${encrypted}`); + }, + }); + + expect(result).toEqual({ pending: [], discarded: [] }); + }); +}); + +describe('reconcilePendingMessagesFromMetadata', () => { + it('keeps existing pending items for localIds that exist in metadata but fail to decode', () => { + const reconciled = reconcilePendingMessagesFromMetadata({ + messageQueueV1: { + v: 1, + inFlight: { + localId: 'inflight-1', + message: 'enc-inflight', + createdAt: 10, + updatedAt: 10, + claimedAt: 11, + }, + queue: [ + { localId: 'q-1', message: 'enc-q1', createdAt: 20, updatedAt: 20 }, + ], + }, + messageQueueV1Discarded: [], + decodedPending: [ + { + id: 'inflight-1', + localId: 'inflight-1', + createdAt: 10, + updatedAt: 10, + text: 'decoded inflight', + rawRecord: {} as any, + }, + ], + decodedDiscarded: [], + existingPending: [ + { + id: 'q-1', + localId: 'q-1', + createdAt: 20, + updatedAt: 20, + text: 'optimistic queued', + rawRecord: {} as any, + }, + ], + existingDiscarded: [], + }); + + expect(reconciled.pending.map((m) => m.localId)).toEqual(['inflight-1', 'q-1']); + expect(reconciled.pending.map((m) => m.text)).toEqual(['decoded inflight', 'optimistic queued']); + }); +}); diff --git a/expo-app/sources/sync/messageQueueV1Pending.ts b/expo-app/sources/sync/messageQueueV1Pending.ts new file mode 100644 index 000000000..b1d85cf9f --- /dev/null +++ b/expo-app/sources/sync/messageQueueV1Pending.ts @@ -0,0 +1,150 @@ +import type { DiscardedPendingMessage, Metadata, PendingMessage } from './storageTypes'; + +type DecryptRaw = (encrypted: string) => Promise<any>; + +export async function decodeMessageQueueV1ToPendingMessages(opts: { + messageQueueV1: NonNullable<Metadata['messageQueueV1']> | undefined; + messageQueueV1Discarded: NonNullable<Metadata['messageQueueV1Discarded']> | undefined; + decryptRaw: DecryptRaw; +}): Promise<{ pending: PendingMessage[]; discarded: DiscardedPendingMessage[] }> { + const pending: PendingMessage[] = []; + + const queue = opts.messageQueueV1?.queue ?? []; + const inFlight = opts.messageQueueV1?.inFlight ?? null; + const orderedItems = [ + ...(inFlight ? [{ localId: inFlight.localId, message: inFlight.message, createdAt: inFlight.createdAt, updatedAt: inFlight.updatedAt }] : []), + ...queue, + ]; + + for (const item of orderedItems) { + let raw: any; + try { + raw = await opts.decryptRaw(item.message); + } catch { + continue; + } + const text = (raw as any)?.content?.text; + if (typeof text !== 'string') continue; + pending.push({ + id: item.localId, + localId: item.localId, + createdAt: item.createdAt, + updatedAt: item.updatedAt, + text, + displayText: typeof (raw as any)?.meta?.displayText === 'string' ? (raw as any).meta.displayText : undefined, + rawRecord: raw as any, + }); + } + + const discarded: DiscardedPendingMessage[] = []; + const discardedQueue = opts.messageQueueV1Discarded ?? []; + for (const item of discardedQueue) { + let raw: any; + try { + raw = await opts.decryptRaw(item.message); + } catch { + continue; + } + const text = (raw as any)?.content?.text; + if (typeof text !== 'string') continue; + discarded.push({ + id: item.localId, + localId: item.localId, + createdAt: item.createdAt, + updatedAt: item.updatedAt, + discardedAt: item.discardedAt, + discardedReason: item.discardedReason, + text, + displayText: typeof (raw as any)?.meta?.displayText === 'string' ? (raw as any).meta.displayText : undefined, + rawRecord: raw as any, + }); + } + + return { pending, discarded }; +} + +export function reconcilePendingMessagesFromMetadata(opts: { + messageQueueV1: NonNullable<Metadata['messageQueueV1']> | undefined; + messageQueueV1Discarded: NonNullable<Metadata['messageQueueV1Discarded']> | undefined; + decodedPending: PendingMessage[]; + decodedDiscarded: DiscardedPendingMessage[]; + existingPending: PendingMessage[]; + existingDiscarded: DiscardedPendingMessage[]; +}): { pending: PendingMessage[]; discarded: DiscardedPendingMessage[] } { + const orderedPendingLocalIds: string[] = []; + const mq = opts.messageQueueV1; + if (mq?.inFlight?.localId) { + orderedPendingLocalIds.push(mq.inFlight.localId); + } + for (const item of mq?.queue ?? []) { + if (typeof item.localId === 'string' && item.localId.length > 0) { + orderedPendingLocalIds.push(item.localId); + } + } + + const decodedPendingByLocalId = new Map<string, PendingMessage>(); + for (const m of opts.decodedPending) { + if (typeof m.localId === 'string' && m.localId.length > 0) { + decodedPendingByLocalId.set(m.localId, m); + } + } + + const existingPendingByLocalId = new Map<string, PendingMessage>(); + for (const m of opts.existingPending) { + if (typeof m.localId === 'string' && m.localId.length > 0) { + existingPendingByLocalId.set(m.localId, m); + } + } + + const reconciledPending: PendingMessage[] = []; + for (const localId of orderedPendingLocalIds) { + const decoded = decodedPendingByLocalId.get(localId); + if (decoded) { + reconciledPending.push(decoded); + continue; + } + const existing = existingPendingByLocalId.get(localId); + if (existing) { + reconciledPending.push(existing); + } + } + + const orderedDiscardedLocalIds: string[] = []; + for (const item of opts.messageQueueV1Discarded ?? []) { + if (typeof item.localId === 'string' && item.localId.length > 0) { + orderedDiscardedLocalIds.push(item.localId); + } + } + + const decodedDiscardedByLocalId = new Map<string, DiscardedPendingMessage>(); + for (const m of opts.decodedDiscarded) { + if (typeof m.localId === 'string' && m.localId.length > 0) { + decodedDiscardedByLocalId.set(m.localId, m); + } + } + + const existingDiscardedByLocalId = new Map<string, DiscardedPendingMessage>(); + for (const m of opts.existingDiscarded) { + if (typeof m.localId === 'string' && m.localId.length > 0) { + existingDiscardedByLocalId.set(m.localId, m); + } + } + + const reconciledDiscarded: DiscardedPendingMessage[] = []; + for (const localId of orderedDiscardedLocalIds) { + const decoded = decodedDiscardedByLocalId.get(localId); + if (decoded) { + reconciledDiscarded.push(decoded); + continue; + } + const existing = existingDiscardedByLocalId.get(localId); + if (existing) { + reconciledDiscarded.push(existing); + } + } + + return { + pending: reconciledPending, + discarded: reconciledDiscarded, + }; +} diff --git a/expo-app/sources/sync/modelOptions.test.ts b/expo-app/sources/sync/modelOptions.test.ts new file mode 100644 index 000000000..a170881b0 --- /dev/null +++ b/expo-app/sources/sync/modelOptions.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; + +describe('modelOptions', () => { + it('builds generic options for unknown modes', async () => { + const { getModelOptionsForModes } = await import('./modelOptions'); + const out = getModelOptionsForModes(['gpt-5-low', 'default']); + expect(out.map((o) => o.value)).toEqual(['gpt-5-low', 'default']); + expect(out[0].label).toBe('gpt-5-low'); + expect(out[0].description).toBe(''); + }); + + it('returns options for agents with configurable model selection', async () => { + const { getModelOptionsForAgentType } = await import('./modelOptions'); + expect(getModelOptionsForAgentType('gemini').map((o) => o.value)).toEqual([ + 'gemini-2.5-pro', + 'gemini-2.5-flash', + 'gemini-2.5-flash-lite', + ]); + }); + + it('returns no options for agents without configurable model selection', async () => { + const { getModelOptionsForAgentType } = await import('./modelOptions'); + expect(getModelOptionsForAgentType('claude')).toEqual([]); + expect(getModelOptionsForAgentType('codex')).toEqual([]); + }); +}); diff --git a/expo-app/sources/sync/modelOptions.ts b/expo-app/sources/sync/modelOptions.ts new file mode 100644 index 000000000..3832b0997 --- /dev/null +++ b/expo-app/sources/sync/modelOptions.ts @@ -0,0 +1,51 @@ +import type { ModelMode } from './permissionTypes'; +import { t } from '@/text'; +import { getAgentCore, type AgentId } from '@/agents/catalog'; + +export type AgentType = AgentId; + +export type ModelOption = Readonly<{ + value: ModelMode; + label: string; + description: string; +}>; + +function getModelLabel(mode: ModelMode): string { + switch (mode) { + case 'gemini-2.5-pro': + return t('agentInput.geminiModel.gemini25Pro.label'); + case 'gemini-2.5-flash': + return t('agentInput.geminiModel.gemini25Flash.label'); + case 'gemini-2.5-flash-lite': + return t('agentInput.geminiModel.gemini25FlashLite.label'); + default: + return mode; + } +} + +function getModelDescription(mode: ModelMode): string { + switch (mode) { + case 'gemini-2.5-pro': + return t('agentInput.geminiModel.gemini25Pro.description'); + case 'gemini-2.5-flash': + return t('agentInput.geminiModel.gemini25Flash.description'); + case 'gemini-2.5-flash-lite': + return t('agentInput.geminiModel.gemini25FlashLite.description'); + default: + return ''; + } +} + +export function getModelOptionsForModes(modes: readonly ModelMode[]): readonly ModelOption[] { + return modes.map((mode) => ({ + value: mode, + label: getModelLabel(mode), + description: getModelDescription(mode), + })); +} + +export function getModelOptionsForAgentType(agentType: AgentType): readonly ModelOption[] { + const core = getAgentCore(agentType); + if (core.model.supportsSelection !== true) return []; + return getModelOptionsForModes(core.model.allowedModes); +} diff --git a/expo-app/sources/sync/ops.sessionAbort.test.ts b/expo-app/sources/sync/ops.sessionAbort.test.ts new file mode 100644 index 000000000..d300dfaf6 --- /dev/null +++ b/expo-app/sources/sync/ops.sessionAbort.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const { mockSessionRPC } = vi.hoisted(() => ({ + mockSessionRPC: vi.fn(), +})); + +vi.mock('./apiSocket', () => ({ + apiSocket: { + sessionRPC: mockSessionRPC, + }, +})); + +// ops.ts imports ./sync, which pulls in Expo-native modules in node/vitest. +// sessionAbort doesn't use sync, so we provide a lightweight mock. +vi.mock('./sync', () => ({ + sync: { + encryption: { + getSessionEncryption: () => null, + getMachineEncryption: () => null, + }, + }, +})); + +import { sessionAbort } from './ops'; +import { RPC_ERROR_CODES } from '@happy/protocol/rpc'; + +describe('sessionAbort', () => { + beforeEach(() => { + mockSessionRPC.mockReset(); + }); + + it('does not throw when RPC method is unavailable (errorCode)', async () => { + const err: any = new Error('RPC method not available'); + err.rpcErrorCode = RPC_ERROR_CODES.METHOD_NOT_AVAILABLE; + mockSessionRPC.mockRejectedValue(err); + + await expect(sessionAbort('sid-1')).resolves.toBeUndefined(); + }); + + it('keeps backward compatibility by not throwing on the legacy error message', async () => { + mockSessionRPC.mockRejectedValue(new Error('RPC method not available')); + + await expect(sessionAbort('sid-2')).resolves.toBeUndefined(); + }); + + it('rethrows non-RPC-method-unavailable failures', async () => { + mockSessionRPC.mockRejectedValue(new Error('boom')); + + await expect(sessionAbort('sid-3')).rejects.toThrow('boom'); + }); +}); diff --git a/expo-app/sources/sync/ops.sessionArchive.test.ts b/expo-app/sources/sync/ops.sessionArchive.test.ts new file mode 100644 index 000000000..70e6bf058 --- /dev/null +++ b/expo-app/sources/sync/ops.sessionArchive.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const { mockSend, mockSessionRPC } = vi.hoisted(() => ({ + mockSend: vi.fn(), + mockSessionRPC: vi.fn(), +})); + +vi.mock('./apiSocket', () => ({ + apiSocket: { + send: mockSend, + sessionRPC: mockSessionRPC, + }, +})); + +// ops.ts imports ./sync, which pulls in Expo-native modules in node/vitest. +// sessionArchive doesn't use sync, so we provide a lightweight mock. +vi.mock('./sync', () => ({ + sync: { + encryption: { + getSessionEncryption: () => null, + getMachineEncryption: () => null, + }, + }, +})); + +import { sessionArchive } from './ops'; +import { RPC_ERROR_CODES } from '@happy/protocol/rpc'; + +describe('sessionArchive', () => { + beforeEach(() => { + mockSend.mockReset(); + mockSessionRPC.mockReset(); + }); + + it('falls back to session-end when RPC method is unavailable (errorCode)', async () => { + const err: any = new Error('RPC method not available'); + err.rpcErrorCode = RPC_ERROR_CODES.METHOD_NOT_AVAILABLE; + mockSessionRPC.mockRejectedValue(err); + + const res = await sessionArchive('sid-1'); + expect(res).toEqual({ success: true }); + expect(mockSend).toHaveBeenCalledWith( + 'session-end', + expect.objectContaining({ sid: 'sid-1', time: expect.any(Number) }), + ); + }); + + it('keeps backward compatibility by falling back to the legacy error message', async () => { + mockSessionRPC.mockRejectedValue(new Error('RPC method not available')); + + const res = await sessionArchive('sid-2'); + expect(res).toEqual({ success: true }); + expect(mockSend).toHaveBeenCalledWith( + 'session-end', + expect.objectContaining({ sid: 'sid-2', time: expect.any(Number) }), + ); + }); + + it('returns an error for non-RPC-method-unavailable failures', async () => { + mockSessionRPC.mockRejectedValue(new Error('boom')); + + const res = await sessionArchive('sid-3'); + expect(res).toEqual({ success: false, message: 'boom' }); + expect(mockSend).not.toHaveBeenCalled(); + }); +}); diff --git a/expo-app/sources/sync/ops.spawnSessionPayload.test.ts b/expo-app/sources/sync/ops.spawnSessionPayload.test.ts new file mode 100644 index 000000000..3748e662f --- /dev/null +++ b/expo-app/sources/sync/ops.spawnSessionPayload.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from 'vitest'; + +import type { SpawnSessionOptions } from './spawnSessionPayload'; +import { buildSpawnHappySessionRpcParams } from './spawnSessionPayload'; + +describe('buildSpawnHappySessionRpcParams', () => { + it('includes terminal when provided', () => { + const params = buildSpawnHappySessionRpcParams({ + machineId: 'm1', + directory: '/tmp', + terminal: { + mode: 'tmux', + tmux: { + sessionName: '', + isolated: true, + tmpDir: null, + }, + }, + } satisfies SpawnSessionOptions); + + expect(params).toMatchObject({ + type: 'spawn-in-directory', + directory: '/tmp', + terminal: { + mode: 'tmux', + tmux: { + sessionName: '', + isolated: true, + tmpDir: null, + }, + }, + }); + }); + + it('omits terminal when null/undefined', () => { + const params = buildSpawnHappySessionRpcParams({ + machineId: 'm1', + directory: '/tmp', + terminal: null, + } satisfies SpawnSessionOptions); + + expect('terminal' in params).toBe(false); + }); +}); diff --git a/expo-app/sources/sync/ops.ts b/expo-app/sources/sync/ops.ts index 07f70e694..5e4b8250e 100644 --- a/expo-app/sources/sync/ops.ts +++ b/expo-app/sources/sync/ops.ts @@ -1,535 +1,18 @@ /** - * Session operations for remote procedure calls - * Provides strictly typed functions for all session-related RPC operations + * Operations barrel (split by domain) */ -import { apiSocket } from './apiSocket'; -import { sync } from './sync'; -import type { MachineMetadata } from './storageTypes'; +export * from './ops/machines'; +export * from './ops/capabilities'; +export * from './ops/sessions'; -// Strict type definitions for all operations -// Permission operation types -interface SessionPermissionRequest { - id: string; - approved: boolean; - reason?: string; - mode?: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan'; - allowTools?: string[]; - decision?: 'approved' | 'approved_for_session' | 'denied' | 'abort'; -} - -// Mode change operation types -interface SessionModeChangeRequest { - to: 'remote' | 'local'; -} - -// Bash operation types -interface SessionBashRequest { - command: string; - cwd?: string; - timeout?: number; -} - -interface SessionBashResponse { - success: boolean; - stdout: string; - stderr: string; - exitCode: number; - error?: string; -} - -// Read file operation types -interface SessionReadFileRequest { - path: string; -} - -interface SessionReadFileResponse { - success: boolean; - content?: string; // base64 encoded - error?: string; -} - -// Write file operation types -interface SessionWriteFileRequest { - path: string; - content: string; // base64 encoded - expectedHash?: string | null; -} - -interface SessionWriteFileResponse { - success: boolean; - hash?: string; - error?: string; -} - -// List directory operation types -interface SessionListDirectoryRequest { - path: string; -} - -interface DirectoryEntry { - name: string; - type: 'file' | 'directory' | 'other'; - size?: number; - modified?: number; -} - -interface SessionListDirectoryResponse { - success: boolean; - entries?: DirectoryEntry[]; - error?: string; -} - -// Directory tree operation types -interface SessionGetDirectoryTreeRequest { - path: string; - maxDepth: number; -} - -interface TreeNode { - name: string; - path: string; - type: 'file' | 'directory'; - size?: number; - modified?: number; - children?: TreeNode[]; -} - -interface SessionGetDirectoryTreeResponse { - success: boolean; - tree?: TreeNode; - error?: string; -} - -// Ripgrep operation types -interface SessionRipgrepRequest { - args: string[]; - cwd?: string; -} - -interface SessionRipgrepResponse { - success: boolean; - exitCode?: number; - stdout?: string; - stderr?: string; - error?: string; -} - -// Kill session operation types -interface SessionKillRequest { - // No parameters needed -} - -interface SessionKillResponse { - success: boolean; - message: string; -} - -// Response types for spawn session -export type SpawnSessionResult = - | { type: 'success'; sessionId: string } - | { type: 'requestToApproveDirectoryCreation'; directory: string } - | { type: 'error'; errorMessage: string }; - -// Options for spawning a session -export interface SpawnSessionOptions { - machineId: string; - directory: string; - approvedNewDirectoryCreation?: boolean; - token?: string; - agent?: 'codex' | 'claude' | 'gemini'; - // Environment variables from AI backend profile - // Accepts any environment variables - daemon will pass them to the agent process - // Common variables include: - // - ANTHROPIC_BASE_URL, ANTHROPIC_AUTH_TOKEN, ANTHROPIC_MODEL, ANTHROPIC_SMALL_FAST_MODEL - // - OPENAI_API_KEY, OPENAI_BASE_URL, OPENAI_MODEL, OPENAI_API_TIMEOUT_MS - // - AZURE_OPENAI_API_KEY, AZURE_OPENAI_ENDPOINT, AZURE_OPENAI_API_VERSION, AZURE_OPENAI_DEPLOYMENT_NAME - // - TOGETHER_API_KEY, TOGETHER_MODEL - // - TMUX_SESSION_NAME, TMUX_TMPDIR, TMUX_UPDATE_ENVIRONMENT - // - API_TIMEOUT_MS, CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC - // - Custom variables (DEEPSEEK_*, Z_AI_*, etc.) - environmentVariables?: Record<string, string>; -} - -// Exported session operation functions - -/** - * Spawn a new remote session on a specific machine - */ -export async function machineSpawnNewSession(options: SpawnSessionOptions): Promise<SpawnSessionResult> { - - const { machineId, directory, approvedNewDirectoryCreation = false, token, agent, environmentVariables } = options; - - try { - const result = await apiSocket.machineRPC<SpawnSessionResult, { - type: 'spawn-in-directory' - directory: string - approvedNewDirectoryCreation?: boolean, - token?: string, - agent?: 'codex' | 'claude' | 'gemini', - environmentVariables?: Record<string, string>; - }>( - machineId, - 'spawn-happy-session', - { type: 'spawn-in-directory', directory, approvedNewDirectoryCreation, token, agent, environmentVariables } - ); - return result; - } catch (error) { - // Handle RPC errors - return { - type: 'error', - errorMessage: error instanceof Error ? error.message : 'Failed to spawn session' - }; - } -} - -/** - * Stop the daemon on a specific machine - */ -export async function machineStopDaemon(machineId: string): Promise<{ message: string }> { - const result = await apiSocket.machineRPC<{ message: string }, {}>( - machineId, - 'stop-daemon', - {} - ); - return result; -} - -/** - * Execute a bash command on a specific machine - */ -export async function machineBash( - machineId: string, - command: string, - cwd: string -): Promise<{ - success: boolean; - stdout: string; - stderr: string; - exitCode: number; -}> { - try { - const result = await apiSocket.machineRPC<{ - success: boolean; - stdout: string; - stderr: string; - exitCode: number; - }, { - command: string; - cwd: string; - }>( - machineId, - 'bash', - { command, cwd } - ); - return result; - } catch (error) { - return { - success: false, - stdout: '', - stderr: error instanceof Error ? error.message : 'Unknown error', - exitCode: -1 - }; - } -} - -/** - * Update machine metadata with optimistic concurrency control and automatic retry - */ -export async function machineUpdateMetadata( - machineId: string, - metadata: MachineMetadata, - expectedVersion: number, - maxRetries: number = 3 -): Promise<{ version: number; metadata: string }> { - let currentVersion = expectedVersion; - let currentMetadata = { ...metadata }; - let retryCount = 0; - - const machineEncryption = sync.encryption.getMachineEncryption(machineId); - if (!machineEncryption) { - throw new Error(`Machine encryption not found for ${machineId}`); - } - - while (retryCount < maxRetries) { - const encryptedMetadata = await machineEncryption.encryptRaw(currentMetadata); - - const result = await apiSocket.emitWithAck<{ - result: 'success' | 'version-mismatch' | 'error'; - version?: number; - metadata?: string; - message?: string; - }>('machine-update-metadata', { - machineId, - metadata: encryptedMetadata, - expectedVersion: currentVersion - }); - - if (result.result === 'success') { - return { - version: result.version!, - metadata: result.metadata! - }; - } else if (result.result === 'version-mismatch') { - // Get the latest version and metadata from the response - currentVersion = result.version!; - const latestMetadata = await machineEncryption.decryptRaw(result.metadata!) as MachineMetadata; - - // Merge our changes with the latest metadata - // Preserve the displayName we're trying to set, but use latest values for other fields - currentMetadata = { - ...latestMetadata, - displayName: metadata.displayName // Keep our intended displayName change - }; - - retryCount++; - - // If we've exhausted retries, throw error - if (retryCount >= maxRetries) { - throw new Error(`Failed to update after ${maxRetries} retries due to version conflicts`); - } - - // Otherwise, loop will retry with updated version and merged metadata - } else { - throw new Error(result.message || 'Failed to update machine metadata'); - } - } - - throw new Error('Unexpected error in machineUpdateMetadata'); -} - -/** - * Abort the current session operation - */ -export async function sessionAbort(sessionId: string): Promise<void> { - await apiSocket.sessionRPC(sessionId, 'abort', { - reason: `The user doesn't want to proceed with this tool use. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). STOP what you are doing and wait for the user to tell you how to proceed.` - }); -} - -/** - * Allow a permission request - */ -export async function sessionAllow(sessionId: string, id: string, mode?: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan', allowedTools?: string[], decision?: 'approved' | 'approved_for_session'): Promise<void> { - const request: SessionPermissionRequest = { id, approved: true, mode, allowTools: allowedTools, decision }; - await apiSocket.sessionRPC(sessionId, 'permission', request); -} - -/** - * Deny a permission request - */ -export async function sessionDeny(sessionId: string, id: string, mode?: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan', allowedTools?: string[], decision?: 'denied' | 'abort'): Promise<void> { - const request: SessionPermissionRequest = { id, approved: false, mode, allowTools: allowedTools, decision }; - await apiSocket.sessionRPC(sessionId, 'permission', request); -} - -/** - * Request mode change for a session - */ -export async function sessionSwitch(sessionId: string, to: 'remote' | 'local'): Promise<boolean> { - const request: SessionModeChangeRequest = { to }; - const response = await apiSocket.sessionRPC<boolean, SessionModeChangeRequest>( - sessionId, - 'switch', - request, - ); - return response; -} - -/** - * Execute a bash command in the session - */ -export async function sessionBash(sessionId: string, request: SessionBashRequest): Promise<SessionBashResponse> { - try { - const response = await apiSocket.sessionRPC<SessionBashResponse, SessionBashRequest>( - sessionId, - 'bash', - request - ); - return response; - } catch (error) { - return { - success: false, - stdout: '', - stderr: error instanceof Error ? error.message : 'Unknown error', - exitCode: -1, - error: error instanceof Error ? error.message : 'Unknown error' - }; - } -} - -/** - * Read a file from the session - */ -export async function sessionReadFile(sessionId: string, path: string): Promise<SessionReadFileResponse> { - try { - const request: SessionReadFileRequest = { path }; - const response = await apiSocket.sessionRPC<SessionReadFileResponse, SessionReadFileRequest>( - sessionId, - 'readFile', - request - ); - return response; - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : 'Unknown error' - }; - } -} - -/** - * Write a file to the session - */ -export async function sessionWriteFile( - sessionId: string, - path: string, - content: string, - expectedHash?: string | null -): Promise<SessionWriteFileResponse> { - try { - const request: SessionWriteFileRequest = { path, content, expectedHash }; - const response = await apiSocket.sessionRPC<SessionWriteFileResponse, SessionWriteFileRequest>( - sessionId, - 'writeFile', - request - ); - return response; - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : 'Unknown error' - }; - } -} - -/** - * List directory contents in the session - */ -export async function sessionListDirectory(sessionId: string, path: string): Promise<SessionListDirectoryResponse> { - try { - const request: SessionListDirectoryRequest = { path }; - const response = await apiSocket.sessionRPC<SessionListDirectoryResponse, SessionListDirectoryRequest>( - sessionId, - 'listDirectory', - request - ); - return response; - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : 'Unknown error' - }; - } -} - -/** - * Get directory tree from the session - */ -export async function sessionGetDirectoryTree( - sessionId: string, - path: string, - maxDepth: number -): Promise<SessionGetDirectoryTreeResponse> { - try { - const request: SessionGetDirectoryTreeRequest = { path, maxDepth }; - const response = await apiSocket.sessionRPC<SessionGetDirectoryTreeResponse, SessionGetDirectoryTreeRequest>( - sessionId, - 'getDirectoryTree', - request - ); - return response; - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : 'Unknown error' - }; - } -} - -/** - * Run ripgrep in the session - */ -export async function sessionRipgrep( - sessionId: string, - args: string[], - cwd?: string -): Promise<SessionRipgrepResponse> { - try { - const request: SessionRipgrepRequest = { args, cwd }; - const response = await apiSocket.sessionRPC<SessionRipgrepResponse, SessionRipgrepRequest>( - sessionId, - 'ripgrep', - request - ); - return response; - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : 'Unknown error' - }; - } -} - -/** - * Kill the session process immediately - */ -export async function sessionKill(sessionId: string): Promise<SessionKillResponse> { - try { - const response = await apiSocket.sessionRPC<SessionKillResponse, {}>( - sessionId, - 'killSession', - {} - ); - return response; - } catch (error) { - return { - success: false, - message: error instanceof Error ? error.message : 'Unknown error' - }; - } -} - -/** - * Permanently delete a session from the server - * This will remove the session and all its associated data (messages, usage reports, access keys) - * The session should be inactive/archived before deletion - */ -export async function sessionDelete(sessionId: string): Promise<{ success: boolean; message?: string }> { - try { - const response = await apiSocket.request(`/v1/sessions/${sessionId}`, { - method: 'DELETE' - }); - - if (response.ok) { - const result = await response.json(); - return { success: true }; - } else { - const error = await response.text(); - return { - success: false, - message: error || 'Failed to delete session' - }; - } - } catch (error) { - return { - success: false, - message: error instanceof Error ? error.message : 'Unknown error' - }; - } -} - -// Export types for external use +export type { SpawnHappySessionRpcParams, SpawnSessionOptions } from './spawnSessionPayload'; +export { buildSpawnHappySessionRpcParams } from './spawnSessionPayload'; export type { - SessionBashRequest, - SessionBashResponse, - SessionReadFileResponse, - SessionWriteFileResponse, - SessionListDirectoryResponse, - DirectoryEntry, - SessionGetDirectoryTreeResponse, - TreeNode, - SessionRipgrepResponse, - SessionKillResponse -}; \ No newline at end of file + CapabilitiesDescribeResponse, + CapabilitiesDetectRequest, + CapabilitiesDetectResponse, + CapabilitiesInvokeRequest, + CapabilitiesInvokeResponse, +} from './capabilitiesProtocol'; diff --git a/expo-app/sources/sync/ops/_shared.ts b/expo-app/sources/sync/ops/_shared.ts new file mode 100644 index 000000000..48645fb5b --- /dev/null +++ b/expo-app/sources/sync/ops/_shared.ts @@ -0,0 +1,52 @@ +import { SPAWN_SESSION_ERROR_CODES, type SpawnSessionErrorCode, type SpawnSessionResult } from '@happy/protocol'; + +export function isPlainObject(value: unknown): value is Record<string, unknown> { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function isSpawnSessionErrorCode(value: unknown): value is SpawnSessionErrorCode { + if (typeof value !== 'string') return false; + return (Object.values(SPAWN_SESSION_ERROR_CODES) as string[]).includes(value); +} + +export function normalizeSpawnSessionResult(value: unknown): SpawnSessionResult { + if (!isPlainObject(value)) { + return { + type: 'error', + errorCode: SPAWN_SESSION_ERROR_CODES.UNEXPECTED, + errorMessage: 'Malformed spawn result', + }; + } + + const type = value.type; + if (type === 'success') { + const sessionId = typeof value.sessionId === 'string' ? value.sessionId : undefined; + return { type: 'success', ...(sessionId ? { sessionId } : {}) }; + } + + if (type === 'requestToApproveDirectoryCreation') { + const directory = typeof value.directory === 'string' ? value.directory : ''; + if (!directory) { + return { + type: 'error', + errorCode: SPAWN_SESSION_ERROR_CODES.UNEXPECTED, + errorMessage: 'Missing directory in spawn result', + }; + } + return { type: 'requestToApproveDirectoryCreation', directory }; + } + + if (type === 'error') { + const errorCode = isSpawnSessionErrorCode(value.errorCode) + ? value.errorCode + : SPAWN_SESSION_ERROR_CODES.UNEXPECTED; + const errorMessage = typeof value.errorMessage === 'string' ? value.errorMessage : 'Failed to spawn session'; + return { type: 'error', errorCode, errorMessage }; + } + + return { + type: 'error', + errorCode: SPAWN_SESSION_ERROR_CODES.UNEXPECTED, + errorMessage: 'Unknown spawn result type', + }; +} diff --git a/expo-app/sources/sync/ops/capabilities.ts b/expo-app/sources/sync/ops/capabilities.ts new file mode 100644 index 000000000..3ef993b94 --- /dev/null +++ b/expo-app/sources/sync/ops/capabilities.ts @@ -0,0 +1,104 @@ +/** + * Capability probe operations (machine RPC) + */ + +import { apiSocket } from '../apiSocket'; +import { isPlainObject } from './_shared'; +import { RPC_METHODS, isRpcMethodNotFoundResult } from '@happy/protocol/rpc'; +import { + parseCapabilitiesDescribeResponse, + parseCapabilitiesDetectResponse, + parseCapabilitiesInvokeResponse, + type CapabilitiesDescribeResponse, + type CapabilitiesDetectRequest, + type CapabilitiesDetectResponse, + type CapabilitiesInvokeRequest, + type CapabilitiesInvokeResponse, +} from '../capabilitiesProtocol'; + +export type { + CapabilitiesDescribeResponse, + CapabilitiesDetectRequest, + CapabilitiesDetectResponse, + CapabilitiesInvokeRequest, + CapabilitiesInvokeResponse, +} from '../capabilitiesProtocol'; + +export type MachineCapabilitiesDescribeResult = + | { supported: true; response: CapabilitiesDescribeResponse } + | { supported: false; reason: 'not-supported' | 'error' }; + +export async function machineCapabilitiesDescribe(machineId: string): Promise<MachineCapabilitiesDescribeResult> { + try { + const result = await apiSocket.machineRPC<unknown, {}>(machineId, RPC_METHODS.CAPABILITIES_DESCRIBE, {}); + if (isRpcMethodNotFoundResult(result)) return { supported: false, reason: 'not-supported' }; + if (isPlainObject(result) && typeof result.error === 'string') return { supported: false, reason: 'error' }; + const parsed = parseCapabilitiesDescribeResponse(result); + if (!parsed) return { supported: false, reason: 'error' }; + return { supported: true, response: parsed }; + } catch { + return { supported: false, reason: 'error' }; + } +} + +export type MachineCapabilitiesDetectResult = + | { supported: true; response: CapabilitiesDetectResponse } + | { supported: false; reason: 'not-supported' | 'error' }; + +export async function machineCapabilitiesDetect( + machineId: string, + request: CapabilitiesDetectRequest, + options?: { timeoutMs?: number }, +): Promise<MachineCapabilitiesDetectResult> { + try { + const timeoutMs = typeof options?.timeoutMs === 'number' ? options.timeoutMs : 2500; + const result = await Promise.race([ + apiSocket.machineRPC<unknown, CapabilitiesDetectRequest>(machineId, RPC_METHODS.CAPABILITIES_DETECT, request), + new Promise<{ error: string }>((resolve) => { + setTimeout(() => resolve({ error: 'Timeout' }), timeoutMs); + }), + ]); + + if (isRpcMethodNotFoundResult(result)) return { supported: false, reason: 'not-supported' }; + if (isPlainObject(result) && typeof result.error === 'string') return { supported: false, reason: 'error' }; + + const parsed = parseCapabilitiesDetectResponse(result); + if (!parsed) return { supported: false, reason: 'error' }; + return { supported: true, response: parsed }; + } catch { + return { supported: false, reason: 'error' }; + } +} + +export type MachineCapabilitiesInvokeResult = + | { supported: true; response: CapabilitiesInvokeResponse } + | { supported: false; reason: 'not-supported' | 'error' }; + +export async function machineCapabilitiesInvoke( + machineId: string, + request: CapabilitiesInvokeRequest, + options?: { timeoutMs?: number }, +): Promise<MachineCapabilitiesInvokeResult> { + try { + const timeoutMs = typeof options?.timeoutMs === 'number' ? options.timeoutMs : 30_000; + const result = await Promise.race([ + apiSocket.machineRPC<unknown, CapabilitiesInvokeRequest>(machineId, RPC_METHODS.CAPABILITIES_INVOKE, request), + new Promise<{ error: string }>((resolve) => { + setTimeout(() => resolve({ error: 'Timeout' }), timeoutMs); + }), + ]); + + if (isRpcMethodNotFoundResult(result)) return { supported: false, reason: 'not-supported' }; + if (isPlainObject(result) && typeof result.error === 'string') return { supported: false, reason: 'error' }; + + const parsed = parseCapabilitiesInvokeResponse(result); + if (!parsed) return { supported: false, reason: 'error' }; + return { supported: true, response: parsed }; + } catch { + return { supported: false, reason: 'error' }; + } +} + +/** + * Stop the daemon on a specific machine + */ diff --git a/expo-app/sources/sync/ops/machines.ts b/expo-app/sources/sync/ops/machines.ts new file mode 100644 index 000000000..6d13e3126 --- /dev/null +++ b/expo-app/sources/sync/ops/machines.ts @@ -0,0 +1,281 @@ +/** + * Machine operations for remote procedure calls + */ + +import type { SpawnSessionResult } from '@happy/protocol'; +import { SPAWN_SESSION_ERROR_CODES } from '@happy/protocol'; +import { RPC_METHODS, isRpcMethodNotFoundResult } from '@happy/protocol/rpc'; + +import { apiSocket } from '../apiSocket'; +import { sync } from '../sync'; +import type { MachineMetadata } from '../storageTypes'; +import { buildSpawnHappySessionRpcParams, type SpawnHappySessionRpcParams, type SpawnSessionOptions } from '../spawnSessionPayload'; +import { isPlainObject, normalizeSpawnSessionResult } from './_shared'; + +export type { SpawnHappySessionRpcParams, SpawnSessionOptions } from '../spawnSessionPayload'; +export { buildSpawnHappySessionRpcParams } from '../spawnSessionPayload'; + +// Exported session operation functions + +/** + * Spawn a new remote session on a specific machine + */ +export async function machineSpawnNewSession(options: SpawnSessionOptions): Promise<SpawnSessionResult> { + const { machineId } = options; + + try { + const params = buildSpawnHappySessionRpcParams(options); + const result = await apiSocket.machineRPC<unknown, SpawnHappySessionRpcParams>(machineId, RPC_METHODS.SPAWN_HAPPY_SESSION, params); + return normalizeSpawnSessionResult(result); + } catch (error) { + // Handle RPC errors + return { + type: 'error', + errorCode: SPAWN_SESSION_ERROR_CODES.UNEXPECTED, + errorMessage: error instanceof Error ? error.message : 'Failed to spawn session' + }; + } +} + +/** + * Stop the daemon on a specific machine + */ +export async function machineStopDaemon(machineId: string): Promise<{ message: string }> { + const result = await apiSocket.machineRPC<{ message: string }, {}>( + machineId, + RPC_METHODS.STOP_DAEMON, + {} + ); + return result; +} + +/** + * Execute a bash command on a specific machine + */ +export async function machineBash( + machineId: string, + command: string, + cwd: string +): Promise<{ + success: boolean; + stdout: string; + stderr: string; + exitCode: number; +}> { + try { + const result = await apiSocket.machineRPC<{ + success: boolean; + stdout: string; + stderr: string; + exitCode: number; + }, { + command: string; + cwd: string; + }>( + machineId, + 'bash', + { command, cwd } + ); + return result; + } catch (error) { + return { + success: false, + stdout: '', + stderr: error instanceof Error ? error.message : 'Unknown error', + exitCode: -1 + }; + } +} + +export type EnvPreviewSecretsPolicy = 'none' | 'redacted' | 'full'; + +export type PreviewEnvSensitivitySource = 'forced' | 'hinted' | 'none'; + +export interface PreviewEnvValue { + value: string | null; + isSet: boolean; + isSensitive: boolean; + isForcedSensitive: boolean; + sensitivitySource: PreviewEnvSensitivitySource; + display: 'full' | 'redacted' | 'hidden' | 'unset'; +} + +export interface PreviewEnvResponse { + policy: EnvPreviewSecretsPolicy; + values: Record<string, PreviewEnvValue>; +} + +interface PreviewEnvRequest { + keys: string[]; + extraEnv?: Record<string, string>; + sensitiveKeys?: string[]; +} + +export type MachinePreviewEnvResult = + | { supported: true; response: PreviewEnvResponse } + | { supported: false }; + + +/** + * Preview environment variables exactly as the daemon will spawn them. + * + * This calls the daemon's `preview-env` RPC (if supported). The daemon computes: + * - effective env = { ...daemon.process.env, ...expand(extraEnv) } + * - applies `HAPPY_ENV_PREVIEW_SECRETS` policy for sensitive variables + * + * If the daemon is old and doesn't support `preview-env`, returns `{ supported: false }`. + */ +export async function machinePreviewEnv( + machineId: string, + params: PreviewEnvRequest +): Promise<MachinePreviewEnvResult> { + try { + const result = await apiSocket.machineRPC<unknown, PreviewEnvRequest>( + machineId, + RPC_METHODS.PREVIEW_ENV, + params + ); + + // Older daemons (or errors) return an encrypted `{ error: ... }` payload. + // Treat method-not-found as “unsupported” and fallback to bash-based probing. + if (isRpcMethodNotFoundResult(result)) return { supported: false }; + // For any other error, degrade gracefully in UI by using fallback behavior. + if (isPlainObject(result) && typeof result.error === 'string') return { supported: false }; + + // Basic shape validation (be defensive for mixed daemon versions). + if ( + !isPlainObject(result) || + (result.policy !== 'none' && result.policy !== 'redacted' && result.policy !== 'full') || + !isPlainObject(result.values) + ) { + return { supported: false }; + } + + const response: PreviewEnvResponse = { + policy: result.policy as EnvPreviewSecretsPolicy, + values: Object.fromEntries( + Object.entries(result.values as Record<string, unknown>).map(([k, v]) => { + if (!isPlainObject(v)) { + const fallback: PreviewEnvValue = { + value: null, + isSet: false, + isSensitive: false, + isForcedSensitive: false, + sensitivitySource: 'none', + display: 'unset', + }; + return [k, fallback] as const; + } + + const display = v.display; + const safeDisplay = + display === 'full' || display === 'redacted' || display === 'hidden' || display === 'unset' + ? display + : 'unset'; + + const value = v.value; + const safeValue = typeof value === 'string' ? value : null; + + const isSet = v.isSet; + const safeIsSet = typeof isSet === 'boolean' ? isSet : safeValue !== null; + + const isSensitive = v.isSensitive; + const safeIsSensitive = typeof isSensitive === 'boolean' ? isSensitive : false; + + // Back-compat for intermediate daemons: default to “not forced” if missing. + const isForcedSensitive = v.isForcedSensitive; + const safeIsForcedSensitive = typeof isForcedSensitive === 'boolean' ? isForcedSensitive : false; + + const sensitivitySource = v.sensitivitySource; + const safeSensitivitySource: PreviewEnvSensitivitySource = + sensitivitySource === 'forced' || sensitivitySource === 'hinted' || sensitivitySource === 'none' + ? sensitivitySource + : (safeIsSensitive ? 'hinted' : 'none'); + + const entry: PreviewEnvValue = { + value: safeValue, + isSet: safeIsSet, + isSensitive: safeIsSensitive, + isForcedSensitive: safeIsForcedSensitive, + sensitivitySource: safeSensitivitySource, + display: safeDisplay, + }; + + return [k, entry] as const; + }), + ) as Record<string, PreviewEnvValue>, + }; + return { supported: true, response }; + } catch { + return { supported: false }; + } +} + +/** + * Update machine metadata with optimistic concurrency control and automatic retry + */ +export async function machineUpdateMetadata( + machineId: string, + metadata: MachineMetadata, + expectedVersion: number, + maxRetries: number = 3 +): Promise<{ version: number; metadata: string }> { + let currentVersion = expectedVersion; + let currentMetadata = { ...metadata }; + let retryCount = 0; + + const machineEncryption = sync.encryption.getMachineEncryption(machineId); + if (!machineEncryption) { + throw new Error(`Machine encryption not found for ${machineId}`); + } + + while (retryCount < maxRetries) { + const encryptedMetadata = await machineEncryption.encryptRaw(currentMetadata); + + const result = await apiSocket.emitWithAck<{ + result: 'success' | 'version-mismatch' | 'error'; + version?: number; + metadata?: string; + message?: string; + }>('machine-update-metadata', { + machineId, + metadata: encryptedMetadata, + expectedVersion: currentVersion + }); + + if (result.result === 'success') { + return { + version: result.version!, + metadata: result.metadata! + }; + } else if (result.result === 'version-mismatch') { + // Get the latest version and metadata from the response + currentVersion = result.version!; + const latestMetadata = await machineEncryption.decryptRaw(result.metadata!) as MachineMetadata; + + // Merge our changes with the latest metadata + // Preserve the displayName we're trying to set, but use latest values for other fields + currentMetadata = { + ...latestMetadata, + displayName: metadata.displayName // Keep our intended displayName change + }; + + retryCount++; + + // If we've exhausted retries, throw error + if (retryCount >= maxRetries) { + throw new Error(`Failed to update after ${maxRetries} retries due to version conflicts`); + } + + // Otherwise, loop will retry with updated version and merged metadata + } else { + throw new Error(result.message || 'Failed to update machine metadata'); + } + } + + throw new Error('Unexpected error in machineUpdateMetadata'); +} + +/** + * Abort the current session operation + */ diff --git a/expo-app/sources/sync/ops/sessions.ts b/expo-app/sources/sync/ops/sessions.ts new file mode 100644 index 000000000..e6145d5b7 --- /dev/null +++ b/expo-app/sources/sync/ops/sessions.ts @@ -0,0 +1,622 @@ +/** + * Session operations for remote procedure calls + */ + +import { apiSocket } from '../apiSocket'; +import { sync } from '../sync'; +import { isRpcMethodNotAvailableError } from '../rpcErrors'; +import { buildResumeHappySessionRpcParams, type ResumeHappySessionRpcParams } from '../resumeSessionPayload'; +import type { AgentId } from '@/agents/catalog'; +import type { PermissionMode } from '@/sync/permissionTypes'; +import type { SpawnSessionResult } from '@happy/protocol'; +import { SPAWN_SESSION_ERROR_CODES } from '@happy/protocol'; +import { RPC_METHODS } from '@happy/protocol/rpc'; +import { normalizeSpawnSessionResult } from './_shared'; + + +// Permission operation types +interface SessionPermissionRequest { + id: string; + approved: boolean; + reason?: string; + mode?: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan'; + allowedTools?: string[]; + decision?: 'approved' | 'approved_for_session' | 'approved_execpolicy_amendment' | 'denied' | 'abort'; + execPolicyAmendment?: { + command: string[]; + }; + /** + * AskUserQuestion: structured answers keyed by question text. + * When present, the agent can complete the tool call without requiring a follow-up user message. + */ + answers?: Record<string, string>; +} + +// Mode change operation types +interface SessionModeChangeRequest { + to: 'remote' | 'local'; +} + +// Bash operation types +interface SessionBashRequest { + command: string; + cwd?: string; + timeout?: number; +} + +interface SessionBashResponse { + success: boolean; + stdout: string; + stderr: string; + exitCode: number; + error?: string; +} + +// Read file operation types +interface SessionReadFileRequest { + path: string; +} + +interface SessionReadFileResponse { + success: boolean; + content?: string; // base64 encoded + error?: string; +} + +// Write file operation types +interface SessionWriteFileRequest { + path: string; + content: string; // base64 encoded + expectedHash?: string | null; +} + +interface SessionWriteFileResponse { + success: boolean; + hash?: string; + error?: string; +} + +// List directory operation types +interface SessionListDirectoryRequest { + path: string; +} + +interface DirectoryEntry { + name: string; + type: 'file' | 'directory' | 'other'; + size?: number; + modified?: number; +} + +interface SessionListDirectoryResponse { + success: boolean; + entries?: DirectoryEntry[]; + error?: string; +} + +// Directory tree operation types +interface SessionGetDirectoryTreeRequest { + path: string; + maxDepth: number; +} + +interface TreeNode { + name: string; + path: string; + type: 'file' | 'directory'; + size?: number; + modified?: number; + children?: TreeNode[]; +} + +interface SessionGetDirectoryTreeResponse { + success: boolean; + tree?: TreeNode; + error?: string; +} + +// Ripgrep operation types +interface SessionRipgrepRequest { + args: string[]; + cwd?: string; +} + +interface SessionRipgrepResponse { + success: boolean; + exitCode?: number; + stdout?: string; + stderr?: string; + error?: string; +} + +// Kill session operation types +interface SessionKillRequest { + // No parameters needed +} + +interface SessionKillResponse { + success: boolean; + message: string; + errorCode?: string; +} + +// Response types for spawn session +export type ResumeSessionResult = SpawnSessionResult; + +/** + * Options for resuming an inactive session. + */ +export interface ResumeSessionOptions { + /** The Happy session ID to resume */ + sessionId: string; + /** The machine ID where the session was running */ + machineId: string; + /** The directory where the session was running */ + directory: string; + /** The agent id */ + agent: AgentId; + /** Optional vendor resume id (e.g. Claude/Codex session id). */ + resume?: string; + /** Session encryption key (dataKey mode) encoded as base64. */ + sessionEncryptionKeyBase64: string; + /** Session encryption variant (only dataKey supported for resume). */ + sessionEncryptionVariant: 'dataKey'; + /** + * Optional: publish an explicit UI-selected permission mode at resume time. + * Use only when the UI selection is newer than metadata.permissionModeUpdatedAt. + */ + permissionMode?: PermissionMode; + permissionModeUpdatedAt?: number; + /** + * Experimental: allow Codex vendor resume when agent === 'codex'. + * Ignored for other agents. + */ + experimentalCodexResume?: boolean; + /** + * Experimental: route Codex through ACP (codex-acp) when agent === 'codex'. + * Ignored for other agents. + */ + experimentalCodexAcp?: boolean; +} + +/** + * Resume an inactive session by spawning a new CLI process that reconnects + * to the existing Happy session and resumes the agent. + */ +export async function resumeSession(options: ResumeSessionOptions): Promise<ResumeSessionResult> { + const { sessionId, machineId, directory, agent, resume, sessionEncryptionKeyBase64, sessionEncryptionVariant, permissionMode, permissionModeUpdatedAt, experimentalCodexResume, experimentalCodexAcp } = options; + + try { + const params: ResumeHappySessionRpcParams = buildResumeHappySessionRpcParams({ + sessionId, + directory, + agent, + ...(resume ? { resume } : {}), + sessionEncryptionKeyBase64, + sessionEncryptionVariant, + ...(permissionMode ? { permissionMode } : {}), + ...(typeof permissionModeUpdatedAt === 'number' ? { permissionModeUpdatedAt } : {}), + experimentalCodexResume, + experimentalCodexAcp, + }); + + const result = await apiSocket.machineRPC<unknown, ResumeHappySessionRpcParams>( + machineId, + RPC_METHODS.SPAWN_HAPPY_SESSION, + params + ); + return normalizeSpawnSessionResult(result); + } catch (error) { + return { + type: 'error', + errorCode: SPAWN_SESSION_ERROR_CODES.UNEXPECTED, + errorMessage: error instanceof Error ? error.message : 'Failed to resume session' + }; + } +} + +export async function sessionAbort(sessionId: string): Promise<void> { + try { + await apiSocket.sessionRPC(sessionId, 'abort', { + reason: `The user doesn't want to proceed with this tool use. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). STOP what you are doing and wait for the user to tell you how to proceed.` + }); + } catch (e) { + if (e instanceof Error && isRpcMethodNotAvailableError(e as any)) { + // Session RPCs are unavailable when no agent process is attached (inactive/resumable). + // Treat abort as a no-op in that case. + return; + } + throw e; + } +} + +/** + * Allow a permission request + */ +export async function sessionAllow( + sessionId: string, + id: string, + mode?: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan', + allowedTools?: string[], + decision?: 'approved' | 'approved_for_session' | 'approved_execpolicy_amendment', + execPolicyAmendment?: { command: string[] } +): Promise<void> { + const request: SessionPermissionRequest = { + id, + approved: true, + mode, + allowedTools, + decision, + execPolicyAmendment + }; + await apiSocket.sessionRPC(sessionId, 'permission', request); +} + +/** + * Allow a permission request and attach structured answers (AskUserQuestion). + * + * This uses the existing `permission` RPC (no separate RPC required). + */ +export async function sessionAllowWithAnswers( + sessionId: string, + id: string, + answers: Record<string, string>, +): Promise<void> { + const request: SessionPermissionRequest = { + id, + approved: true, + answers, + }; + await apiSocket.sessionRPC(sessionId, 'permission', request); +} + +/** + * Deny a permission request + */ +export async function sessionDeny( + sessionId: string, + id: string, + mode?: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan', + allowedTools?: string[], + decision?: 'denied' | 'abort', + reason?: string, +): Promise<void> { + const request: SessionPermissionRequest = { id, approved: false, mode, allowedTools, decision, reason }; + await apiSocket.sessionRPC(sessionId, 'permission', request); +} + +/** + * Request mode change for a session + */ +export async function sessionSwitch(sessionId: string, to: 'remote' | 'local'): Promise<boolean> { + const request: SessionModeChangeRequest = { to }; + const response = await apiSocket.sessionRPC<boolean, SessionModeChangeRequest>( + sessionId, + 'switch', + request, + ); + return response; +} + +/** + * Execute a bash command in the session + */ +export async function sessionBash(sessionId: string, request: SessionBashRequest): Promise<SessionBashResponse> { + try { + const response = await apiSocket.sessionRPC<SessionBashResponse, SessionBashRequest>( + sessionId, + 'bash', + request + ); + return response; + } catch (error) { + return { + success: false, + stdout: '', + stderr: error instanceof Error ? error.message : 'Unknown error', + exitCode: -1, + error: error instanceof Error ? error.message : 'Unknown error' + }; + } +} + +/** + * Read a file from the session + */ +export async function sessionReadFile(sessionId: string, path: string): Promise<SessionReadFileResponse> { + try { + const request: SessionReadFileRequest = { path }; + const response = await apiSocket.sessionRPC<SessionReadFileResponse, SessionReadFileRequest>( + sessionId, + 'readFile', + request + ); + return response; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error' + }; + } +} + +/** + * Write a file to the session + */ +export async function sessionWriteFile( + sessionId: string, + path: string, + content: string, + expectedHash?: string | null +): Promise<SessionWriteFileResponse> { + try { + const request: SessionWriteFileRequest = { path, content, expectedHash }; + const response = await apiSocket.sessionRPC<SessionWriteFileResponse, SessionWriteFileRequest>( + sessionId, + 'writeFile', + request + ); + return response; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error' + }; + } +} + +/** + * List directory contents in the session + */ +export async function sessionListDirectory(sessionId: string, path: string): Promise<SessionListDirectoryResponse> { + try { + const request: SessionListDirectoryRequest = { path }; + const response = await apiSocket.sessionRPC<SessionListDirectoryResponse, SessionListDirectoryRequest>( + sessionId, + 'listDirectory', + request + ); + return response; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error' + }; + } +} + +/** + * Get directory tree from the session + */ +export async function sessionGetDirectoryTree( + sessionId: string, + path: string, + maxDepth: number +): Promise<SessionGetDirectoryTreeResponse> { + try { + const request: SessionGetDirectoryTreeRequest = { path, maxDepth }; + const response = await apiSocket.sessionRPC<SessionGetDirectoryTreeResponse, SessionGetDirectoryTreeRequest>( + sessionId, + 'getDirectoryTree', + request + ); + return response; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error' + }; + } +} + +/** + * Run ripgrep in the session + */ +export async function sessionRipgrep( + sessionId: string, + args: string[], + cwd?: string +): Promise<SessionRipgrepResponse> { + try { + const request: SessionRipgrepRequest = { args, cwd }; + const response = await apiSocket.sessionRPC<SessionRipgrepResponse, SessionRipgrepRequest>( + sessionId, + 'ripgrep', + request + ); + return response; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error' + }; + } +} + +/** + * Kill the session process immediately + */ +export async function sessionKill(sessionId: string): Promise<SessionKillResponse> { + try { + const response = await apiSocket.sessionRPC<SessionKillResponse, {}>( + sessionId, + 'killSession', + {} + ); + return response; + } catch (error) { + return { + success: false, + message: error instanceof Error ? error.message : 'Unknown error', + errorCode: error && typeof error === 'object' ? (error as any).rpcErrorCode : undefined, + }; + } +} + +export interface SessionArchiveResponse { + success: boolean; + message?: string; +} + +/** + * Archive a session. + * + * Primary behavior: kill the session process (same as previous "archive" behavior). + * Fallback: if the session RPC method is unavailable (e.g. session crashed / disconnected), + * mark the session inactive server-side so it no longer appears "online". + */ +export async function sessionArchive(sessionId: string): Promise<SessionArchiveResponse> { + const killResult = await sessionKill(sessionId); + if (killResult.success) { + return { success: true }; + } + + const message = killResult.message || 'Failed to archive session'; + const isRpcMethodUnavailable = isRpcMethodNotAvailableError({ + rpcErrorCode: killResult.errorCode, + message, + }); + + if (isRpcMethodUnavailable) { + try { + apiSocket.send('session-end', { sid: sessionId, time: Date.now() }); + } catch { + // Best-effort: server will also eventually time out stale sessions. + } + return { success: true }; + } + + return { success: false, message }; +} + +/** + * Permanently delete a session from the server + * This will remove the session and all its associated data (messages, usage reports, access keys) + * The session should be inactive/archived before deletion + */ +export async function sessionDelete(sessionId: string): Promise<{ success: boolean; message?: string }> { + try { + const response = await apiSocket.request(`/v1/sessions/${sessionId}`, { + method: 'DELETE' + }); + + if (response.ok) { + const result = await response.json(); + return { success: true }; + } else { + const error = await response.text(); + return { + success: false, + message: error || 'Failed to delete session' + }; + } + } catch (error) { + return { + success: false, + message: error instanceof Error ? error.message : 'Unknown error' + }; + } +} + +// Session rename types +interface SessionRenameRequest { + title: string; +} + +interface SessionRenameResponse { + success: boolean; + message?: string; +} + +/** + * Rename a session by updating its metadata summary + * This updates the session title displayed in the UI + */ +export async function sessionRename(sessionId: string, title: string): Promise<SessionRenameResponse> { + try { + const sessionEncryption = sync.encryption.getSessionEncryption(sessionId); + if (!sessionEncryption) { + return { + success: false, + message: 'Session encryption not found' + }; + } + + // Get the current session from storage + const { storage } = await import('../storage'); + const currentSession = storage.getState().sessions[sessionId]; + if (!currentSession) { + return { + success: false, + message: 'Session not found in storage' + }; + } + + // Ensure we have valid metadata to update + if (!currentSession.metadata) { + return { + success: false, + message: 'Session metadata not available' + }; + } + + // Update metadata with new summary + const updatedMetadata = { + ...currentSession.metadata, + summary: { + text: title, + updatedAt: Date.now() + } + }; + + // Encrypt the updated metadata + const encryptedMetadata = await sessionEncryption.encryptMetadata(updatedMetadata); + + // Send update to server + const result = await apiSocket.emitWithAck<{ + result: 'success' | 'version-mismatch' | 'error'; + version?: number; + metadata?: string; + message?: string; + }>('update-metadata', { + sid: sessionId, + expectedVersion: currentSession.metadataVersion, + metadata: encryptedMetadata + }); + + if (result.result === 'success') { + return { success: true }; + } else if (result.result === 'version-mismatch') { + // Retry with updated version + return { + success: false, + message: 'Version conflict, please try again' + }; + } else { + return { + success: false, + message: result.message || 'Failed to rename session' + }; + } + } catch (error) { + return { + success: false, + message: error instanceof Error ? error.message : 'Unknown error' + }; + } +} + +// Export types for external use +export type { + SessionBashRequest, + SessionBashResponse, + SessionReadFileResponse, + SessionWriteFileResponse, + SessionListDirectoryResponse, + DirectoryEntry, + SessionGetDirectoryTreeResponse, + TreeNode, + SessionRipgrepResponse, + SessionKillResponse, + SessionRenameResponse +}; diff --git a/expo-app/sources/sync/pendingQueueWake.test.ts b/expo-app/sources/sync/pendingQueueWake.test.ts new file mode 100644 index 000000000..177833db1 --- /dev/null +++ b/expo-app/sources/sync/pendingQueueWake.test.ts @@ -0,0 +1,193 @@ +import { describe, expect, it } from 'vitest'; +import { getPendingQueueWakeResumeOptions } from './pendingQueueWake'; + +describe('getPendingQueueWakeResumeOptions', () => { + it('returns resume options for a resumable idle session', () => { + const session: any = { + thinking: false, + agentState: null, + metadata: { machineId: 'm1', path: '/tmp', flavor: 'claude', claudeSessionId: 'c1' }, + }; + + const res = getPendingQueueWakeResumeOptions({ + sessionId: 's1', + session, + resumeCapabilityOptions: {}, + }); + + expect(res).toEqual({ + sessionId: 's1', + machineId: 'm1', + directory: '/tmp', + agent: 'claude', + resume: 'c1', + }); + }); + + it('returns null when agent is thinking', () => { + const session: any = { + thinking: true, + agentState: null, + presence: 'online', + metadata: { machineId: 'm1', path: '/tmp', flavor: 'claude' }, + }; + expect(getPendingQueueWakeResumeOptions({ sessionId: 's1', session, resumeCapabilityOptions: {} })).toBeNull(); + }); + + it('returns null when permission is required', () => { + const session: any = { + thinking: false, + agentState: { requests: { r1: { id: 'r1' } } }, + presence: 'online', + metadata: { machineId: 'm1', path: '/tmp', flavor: 'claude' }, + }; + expect(getPendingQueueWakeResumeOptions({ sessionId: 's1', session, resumeCapabilityOptions: {} })).toBeNull(); + }); + + it('does not block wake for offline sessions with stale thinking state', () => { + const session: any = { + thinking: true, + agentState: null, + presence: 'offline', + metadata: { machineId: 'm1', path: '/tmp', flavor: 'claude', claudeSessionId: 'c1' }, + }; + + expect(getPendingQueueWakeResumeOptions({ sessionId: 's1', session, resumeCapabilityOptions: {} })).toEqual({ + sessionId: 's1', + machineId: 'm1', + directory: '/tmp', + agent: 'claude', + resume: 'c1', + }); + }); + + it('does not block wake for offline sessions with stale permission requests', () => { + const session: any = { + thinking: false, + agentState: { requests: { r1: { id: 'r1' } } }, + presence: 'offline', + metadata: { machineId: 'm1', path: '/tmp', flavor: 'claude', claudeSessionId: 'c1' }, + }; + + expect(getPendingQueueWakeResumeOptions({ sessionId: 's1', session, resumeCapabilityOptions: {} })).toEqual({ + sessionId: 's1', + machineId: 'm1', + directory: '/tmp', + agent: 'claude', + resume: 'c1', + }); + }); + + it('returns null when metadata is missing', () => { + const session: any = { thinking: false, agentState: null, metadata: null }; + expect(getPendingQueueWakeResumeOptions({ sessionId: 's1', session, resumeCapabilityOptions: {} })).toBeNull(); + }); + + it('returns null when flavor is unsupported', () => { + const session: any = { + thinking: false, + agentState: null, + metadata: { machineId: 'm1', path: '/tmp', flavor: 'unknown' }, + }; + expect(getPendingQueueWakeResumeOptions({ sessionId: 's1', session, resumeCapabilityOptions: {} })).toBeNull(); + }); + + it('returns null when codex vendor resume is disabled', () => { + const session: any = { + thinking: false, + agentState: null, + metadata: { machineId: 'm1', path: '/tmp', flavor: 'codex', codexSessionId: 'x1' }, + }; + expect(getPendingQueueWakeResumeOptions({ sessionId: 's1', session, resumeCapabilityOptions: {} })).toBeNull(); + }); + + it('returns codex options when codex resume is enabled', () => { + const session: any = { + thinking: false, + agentState: null, + metadata: { machineId: 'm1', path: '/tmp', flavor: 'codex', codexSessionId: 'x1' }, + }; + expect(getPendingQueueWakeResumeOptions({ + sessionId: 's1', + session, + resumeCapabilityOptions: { allowExperimentalResumeByAgentId: { codex: true } }, + })).toEqual({ + sessionId: 's1', + machineId: 'm1', + directory: '/tmp', + agent: 'codex', + resume: 'x1', + experimentalCodexResume: true, + }); + }); + + it('canonicalizes codex flavor aliases when building wake options', () => { + const session: any = { + thinking: false, + agentState: null, + metadata: { machineId: 'm1', path: '/tmp', flavor: 'openai', codexSessionId: 'x1' }, + }; + expect(getPendingQueueWakeResumeOptions({ + sessionId: 's1', + session, + resumeCapabilityOptions: { allowExperimentalResumeByAgentId: { codex: true } }, + })).toEqual({ + sessionId: 's1', + machineId: 'm1', + directory: '/tmp', + agent: 'codex', + resume: 'x1', + experimentalCodexResume: true, + }); + }); + + it('returns null when gemini vendor resume is not enabled', () => { + const session: any = { + thinking: false, + agentState: null, + metadata: { machineId: 'm1', path: '/tmp', flavor: 'gemini', geminiSessionId: 'g1' }, + }; + expect(getPendingQueueWakeResumeOptions({ sessionId: 's1', session, resumeCapabilityOptions: {} })).toBeNull(); + }); + + it('includes gemini resume id only when runtime resume is enabled', () => { + const session: any = { + thinking: false, + agentState: null, + metadata: { machineId: 'm1', path: '/tmp', flavor: 'gemini', geminiSessionId: 'g1' }, + }; + expect(getPendingQueueWakeResumeOptions({ + sessionId: 's1', + session, + resumeCapabilityOptions: { allowRuntimeResumeByAgentId: { gemini: true } }, + })).toEqual({ + sessionId: 's1', + machineId: 'm1', + directory: '/tmp', + agent: 'gemini', + resume: 'g1', + }); + }); + + it('passes through permission mode override when provided', () => { + const session: any = { + thinking: false, + agentState: null, + metadata: { machineId: 'm1', path: '/tmp', flavor: 'claude', claudeSessionId: 'c1' }, + }; + expect(getPendingQueueWakeResumeOptions({ + sessionId: 's1', + session, + resumeCapabilityOptions: {}, + permissionOverride: { permissionMode: 'plan', permissionModeUpdatedAt: 123 }, + })).toEqual({ + sessionId: 's1', + machineId: 'm1', + directory: '/tmp', + agent: 'claude', + resume: 'c1', + permissionMode: 'plan', + permissionModeUpdatedAt: 123, + }); + }); +}); diff --git a/expo-app/sources/sync/pendingQueueWake.ts b/expo-app/sources/sync/pendingQueueWake.ts new file mode 100644 index 000000000..12df0d7bd --- /dev/null +++ b/expo-app/sources/sync/pendingQueueWake.ts @@ -0,0 +1,51 @@ +import type { ResumeSessionOptions } from './ops'; +import type { Session } from './storageTypes'; +import { resolveAgentIdFromFlavor, buildWakeResumeExtras } from '@/agents/catalog'; +import type { ResumeCapabilityOptions } from '@/agents/resumeCapabilities'; +import type { PermissionModeOverrideForSpawn } from '@/sync/permissionModeOverride'; +import { buildResumeSessionBaseOptionsFromSession } from '@/sync/resumeSessionBase'; + +export type PendingQueueWakeResumeOptions = Omit< + ResumeSessionOptions, + 'sessionEncryptionKeyBase64' | 'sessionEncryptionVariant' +>; + +export function getPendingQueueWakeResumeOptions(opts: { + sessionId: string; + session: Session; + resumeCapabilityOptions: ResumeCapabilityOptions; + permissionOverride?: PermissionModeOverrideForSpawn | null; +}): PendingQueueWakeResumeOptions | null { + const { sessionId, session, resumeCapabilityOptions, permissionOverride } = opts; + + // Only gate waking on "idle" when the session is actively running. + // For inactive/archived sessions, `thinking` / `agentState.requests` can be stale; blocking wake would + // strand pending-queue messages until the user sends another message (or the state refreshes). + const isSessionActive = session.presence === 'online'; + if (isSessionActive) { + if (session.thinking === true) return null; + const requests = session.agentState?.requests; + if (requests && Object.keys(requests).length > 0) return null; + } + + const machineId = session.metadata?.machineId; + const directory = session.metadata?.path; + const flavor = session.metadata?.flavor; + if (!machineId || !directory || !flavor) return null; + + const agentId = resolveAgentIdFromFlavor(flavor); + if (!agentId) return null; + + const base = buildResumeSessionBaseOptionsFromSession({ + sessionId, + session, + resumeCapabilityOptions, + permissionOverride, + }); + if (!base) return null; + + return { + ...base, + ...buildWakeResumeExtras({ agentId, resumeCapabilityOptions }), + }; +} diff --git a/expo-app/sources/sync/permissionDefaults.test.ts b/expo-app/sources/sync/permissionDefaults.test.ts new file mode 100644 index 000000000..52022e90c --- /dev/null +++ b/expo-app/sources/sync/permissionDefaults.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; +import type { PermissionMode } from './permissionTypes'; +import { resolveNewSessionDefaultPermissionMode } from './permissionDefaults'; + +describe('resolveNewSessionDefaultPermissionMode', () => { + const accountDefaults = { + claude: 'plan' as PermissionMode, + codex: 'safe-yolo' as PermissionMode, + gemini: 'read-only' as PermissionMode, + }; + + it('uses account defaults when no profile override is present', () => { + expect(resolveNewSessionDefaultPermissionMode({ agentType: 'claude', accountDefaults })).toBe('plan'); + expect(resolveNewSessionDefaultPermissionMode({ agentType: 'codex', accountDefaults })).toBe('safe-yolo'); + expect(resolveNewSessionDefaultPermissionMode({ agentType: 'gemini', accountDefaults })).toBe('read-only'); + }); + + it('uses provider-specific profile overrides when present', () => { + const profileDefaults = { codex: 'yolo' as PermissionMode }; + expect(resolveNewSessionDefaultPermissionMode({ agentType: 'codex', accountDefaults, profileDefaults })).toBe('yolo'); + // Other providers fall back to account defaults when no override exists. + expect(resolveNewSessionDefaultPermissionMode({ agentType: 'claude', accountDefaults, profileDefaults })).toBe('plan'); + }); + + it('falls back to legacy profile override mapping when provider-specific override is missing', () => { + expect(resolveNewSessionDefaultPermissionMode({ agentType: 'claude', accountDefaults, legacyProfileDefaultPermissionMode: 'plan' })).toBe('plan'); + expect(resolveNewSessionDefaultPermissionMode({ agentType: 'codex', accountDefaults, legacyProfileDefaultPermissionMode: 'plan' })).toBe('safe-yolo'); + expect(resolveNewSessionDefaultPermissionMode({ agentType: 'gemini', accountDefaults, legacyProfileDefaultPermissionMode: 'bypassPermissions' })).toBe('yolo'); + }); + + it('clamps unsupported profile override modes to safe defaults for the target provider', () => { + // Claude has no "read-only" mode. + expect(resolveNewSessionDefaultPermissionMode({ agentType: 'claude', accountDefaults, legacyProfileDefaultPermissionMode: 'read-only' })).toBe('default'); + }); +}); diff --git a/expo-app/sources/sync/permissionDefaults.ts b/expo-app/sources/sync/permissionDefaults.ts new file mode 100644 index 000000000..c5de74546 --- /dev/null +++ b/expo-app/sources/sync/permissionDefaults.ts @@ -0,0 +1,60 @@ +import type { PermissionMode } from './permissionTypes'; +import { CLAUDE_PERMISSION_MODES, CODEX_LIKE_PERMISSION_MODES, normalizePermissionModeForGroup } from './permissionTypes'; +import { mapPermissionModeAcrossAgents } from './permissionMapping'; +import { AGENT_IDS, getAgentCore, type AgentId } from '@/agents/catalog'; +import { isPermissionMode } from './permissionTypes'; + +export type AccountPermissionDefaults = Readonly<Partial<Record<AgentId, PermissionMode>>>; + +export function readAccountPermissionDefaults( + raw: unknown, + enabledAgentIds: readonly AgentId[], +): AccountPermissionDefaults { + const input = raw && typeof raw === 'object' ? (raw as Record<string, unknown>) : {}; + const out: Partial<Record<AgentId, PermissionMode>> = {}; + for (const agentId of enabledAgentIds) { + const v = input[agentId]; + out[agentId] = isPermissionMode(v) ? v : 'default'; + } + return out; +} + +function normalizeForAgentType(mode: PermissionMode, agentType: AgentId): PermissionMode { + const group = getAgentCore(agentType).permissions.modeGroup; + return normalizePermissionModeForGroup(mode, group); +} + +export function inferSourceModeGroupForPermissionMode(mode: PermissionMode): 'claude' | 'codexLike' { + // Modes unique to Codex/Gemini should map as codex-like; modes unique to Claude map as Claude. + // For shared 'default', the source agent doesn't matter. + if ((CODEX_LIKE_PERMISSION_MODES as readonly string[]).includes(mode) && !(CLAUDE_PERMISSION_MODES as readonly string[]).includes(mode)) { + return 'codexLike'; + } + return 'claude'; +} + +export function resolveNewSessionDefaultPermissionMode(params: Readonly<{ + agentType: AgentId; + accountDefaults: AccountPermissionDefaults; + profileDefaults?: Partial<Record<AgentId, PermissionMode | undefined>> | null; + legacyProfileDefaultPermissionMode?: PermissionMode | null | undefined; +}>): PermissionMode { + const { agentType, accountDefaults, profileDefaults, legacyProfileDefaultPermissionMode } = params; + + const directProfileMode = profileDefaults?.[agentType]; + if (directProfileMode) { + return normalizeForAgentType(directProfileMode, agentType); + } + + if (legacyProfileDefaultPermissionMode) { + const fromGroup = inferSourceModeGroupForPermissionMode(legacyProfileDefaultPermissionMode); + const from = + AGENT_IDS.find((id) => getAgentCore(id).permissions.modeGroup === fromGroup) ?? + agentType; + const mapped = mapPermissionModeAcrossAgents(legacyProfileDefaultPermissionMode, from, agentType); + return normalizeForAgentType(mapped, agentType); + } + + const raw = accountDefaults[agentType] ?? 'default'; + return normalizeForAgentType(raw, agentType); +} diff --git a/expo-app/sources/sync/permissionMapping.test.ts b/expo-app/sources/sync/permissionMapping.test.ts new file mode 100644 index 000000000..583a14137 --- /dev/null +++ b/expo-app/sources/sync/permissionMapping.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import { mapPermissionModeAcrossAgents } from './permissionMapping'; + +describe('mapPermissionModeAcrossAgents', () => { + it('returns the same mode when from and to are the same', () => { + expect(mapPermissionModeAcrossAgents('plan', 'claude', 'claude')).toBe('plan'); + }); + + it('maps Claude plan to Gemini safe-yolo', () => { + expect(mapPermissionModeAcrossAgents('plan', 'claude', 'gemini')).toBe('safe-yolo'); + }); + + it('maps Claude bypassPermissions to Gemini yolo', () => { + expect(mapPermissionModeAcrossAgents('bypassPermissions', 'claude', 'gemini')).toBe('yolo'); + }); + + it('maps Claude acceptEdits to Gemini safe-yolo', () => { + expect(mapPermissionModeAcrossAgents('acceptEdits', 'claude', 'gemini')).toBe('safe-yolo'); + }); + + it('maps Codex yolo to Claude bypassPermissions', () => { + expect(mapPermissionModeAcrossAgents('yolo', 'codex', 'claude')).toBe('bypassPermissions'); + }); + + it('maps Gemini safe-yolo to Claude plan', () => { + expect(mapPermissionModeAcrossAgents('safe-yolo', 'gemini', 'claude')).toBe('plan'); + }); + + it('preserves read-only across agents', () => { + expect(mapPermissionModeAcrossAgents('read-only', 'claude', 'codex')).toBe('read-only'); + // Claude has no true "read-only" mode; map to the safest available Claude mode. + expect(mapPermissionModeAcrossAgents('read-only', 'codex', 'claude')).toBe('default'); + expect(mapPermissionModeAcrossAgents('read-only', 'gemini', 'claude')).toBe('default'); + }); + + it('keeps Codex/Gemini modes unchanged when switching between them', () => { + expect(mapPermissionModeAcrossAgents('read-only', 'gemini', 'codex')).toBe('read-only'); + expect(mapPermissionModeAcrossAgents('safe-yolo', 'codex', 'gemini')).toBe('safe-yolo'); + }); +}); diff --git a/expo-app/sources/sync/permissionMapping.ts b/expo-app/sources/sync/permissionMapping.ts new file mode 100644 index 000000000..a54adf545 --- /dev/null +++ b/expo-app/sources/sync/permissionMapping.ts @@ -0,0 +1,54 @@ +import type { PermissionMode } from './permissionTypes'; +import type { AgentType } from './modelOptions'; +import { getAgentCore } from '@/agents/catalog'; + +function isCodexLike(agent: AgentType) { + return getAgentCore(agent).permissions.modeGroup === 'codexLike'; +} + +export function mapPermissionModeAcrossAgents( + mode: PermissionMode, + from: AgentType, + to: AgentType, +): PermissionMode { + if (from === to) return mode; + + const fromCodexLike = isCodexLike(from); + const toCodexLike = isCodexLike(to); + + // Codex <-> Gemini uses the same permission mode set. + if (fromCodexLike && toCodexLike) return mode; + + if (!fromCodexLike && toCodexLike) { + // Claude -> Codex/Gemini + switch (mode) { + case 'bypassPermissions': + return 'yolo'; + case 'plan': + return 'safe-yolo'; + case 'acceptEdits': + return 'safe-yolo'; + case 'read-only': + return 'read-only'; + case 'default': + return 'default'; + default: + return 'default'; + } + } + + // Codex/Gemini -> Claude + switch (mode) { + case 'yolo': + return 'bypassPermissions'; + case 'safe-yolo': + return 'plan'; + case 'read-only': + // Claude has no true read-only; fall back to the safest available mode. + return 'default'; + case 'default': + return 'default'; + default: + return 'default'; + } +} diff --git a/expo-app/sources/sync/permissionModeOptions.test.ts b/expo-app/sources/sync/permissionModeOptions.test.ts new file mode 100644 index 000000000..7763324fd --- /dev/null +++ b/expo-app/sources/sync/permissionModeOptions.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import type { PermissionMode } from './permissionTypes'; + +describe('permissionModeOptions', () => { + it('normalizes unsupported modes per agent group', async () => { + const { normalizePermissionModeForAgentType } = await import('./permissionModeOptions'); + expect(normalizePermissionModeForAgentType('read-only', 'claude')).toBe('default'); + expect(normalizePermissionModeForAgentType('acceptEdits', 'codex')).toBe('default'); + }); + + it('returns empty badge for default mode', async () => { + const { getPermissionModeBadgeLabelForAgentType } = await import('./permissionModeOptions'); + expect(getPermissionModeBadgeLabelForAgentType('claude', 'default')).toBe(''); + expect(getPermissionModeBadgeLabelForAgentType('codex', 'default')).toBe(''); + }); + + it('returns a non-empty badge label for non-default supported modes', async () => { + const { getPermissionModeBadgeLabelForAgentType } = await import('./permissionModeOptions'); + expect(getPermissionModeBadgeLabelForAgentType('claude', 'acceptEdits' as PermissionMode)).not.toBe(''); + expect(getPermissionModeBadgeLabelForAgentType('codex', 'read-only' as PermissionMode)).not.toBe(''); + expect(getPermissionModeBadgeLabelForAgentType('gemini', 'safe-yolo' as PermissionMode)).not.toBe(''); + }); + + it('returns empty badge label when mode is unsupported for the agent', async () => { + const { getPermissionModeBadgeLabelForAgentType } = await import('./permissionModeOptions'); + expect(getPermissionModeBadgeLabelForAgentType('codex', 'acceptEdits' as PermissionMode)).toBe(''); + }); +}); diff --git a/expo-app/sources/sync/permissionModeOptions.ts b/expo-app/sources/sync/permissionModeOptions.ts new file mode 100644 index 000000000..d78a9f405 --- /dev/null +++ b/expo-app/sources/sync/permissionModeOptions.ts @@ -0,0 +1,98 @@ +import { t } from '@/text'; +import type { TranslationKey } from '@/text'; +import type { AgentType } from './modelOptions'; +import type { PermissionMode } from './permissionTypes'; +import { CLAUDE_PERMISSION_MODES, CODEX_LIKE_PERMISSION_MODES, normalizePermissionModeForGroup } from './permissionTypes'; +import { DEFAULT_AGENT_ID, getAgentCore, resolveAgentIdFromFlavor } from '@/agents/catalog'; + +export type PermissionModeOption = Readonly<{ + value: PermissionMode; + label: string; + description: string; + icon: string; +}>; + +const PERMISSION_MODE_KEY_SEGMENT: Record<PermissionMode, string> = { + default: 'default', + acceptEdits: 'acceptEdits', + bypassPermissions: 'bypassPermissions', + plan: 'plan', + 'read-only': 'readOnly', + 'safe-yolo': 'safeYolo', + yolo: 'yolo', +}; + +const BADGE_KEY_SEGMENT_CLAUDE: Partial<Record<PermissionMode, string>> = { + acceptEdits: 'badgeAccept', + plan: 'badgePlan', + bypassPermissions: 'badgeYolo', +}; + +const BADGE_KEY_SEGMENT_CODEX_LIKE: Partial<Record<PermissionMode, string>> = { + 'read-only': 'badgeReadOnly', + 'safe-yolo': 'badgeSafeYolo', + yolo: 'badgeYolo', +}; + +function getAgentPermissionModeI18nPrefix(agentType: AgentType): string { + const agentId = resolveAgentIdFromFlavor(agentType) ?? DEFAULT_AGENT_ID; + return getAgentCore(agentId).permissionModeI18nPrefix; +} + +export function getPermissionModeTitleForAgentType(agentType: AgentType): string { + const prefix = getAgentPermissionModeI18nPrefix(agentType); + return t(`${prefix}.title` as TranslationKey); +} + +export function getPermissionModeLabelForAgentType(agentType: AgentType, mode: PermissionMode): string { + const prefix = getAgentPermissionModeI18nPrefix(agentType); + const seg = PERMISSION_MODE_KEY_SEGMENT[mode] ?? 'default'; + return t(`${prefix}.${seg}` as TranslationKey); +} + +export function getPermissionModesForAgentType(agentType: AgentType): readonly PermissionMode[] { + const agentId = resolveAgentIdFromFlavor(agentType) ?? DEFAULT_AGENT_ID; + const group = getAgentCore(agentId).permissions.modeGroup; + return group === 'codexLike' ? CODEX_LIKE_PERMISSION_MODES : CLAUDE_PERMISSION_MODES; +} + +export function getPermissionModeOptionsForAgentType(agentType: AgentType): readonly PermissionModeOption[] { + const agentId = resolveAgentIdFromFlavor(agentType) ?? DEFAULT_AGENT_ID; + const group = getAgentCore(agentId).permissions.modeGroup; + if (group === 'codexLike') { + return [ + { value: 'default', label: getPermissionModeLabelForAgentType(agentType, 'default'), description: 'Use CLI permission settings', icon: 'shield-outline' }, + { value: 'read-only', label: getPermissionModeLabelForAgentType(agentType, 'read-only'), description: 'Read-only mode', icon: 'eye-outline' }, + { value: 'safe-yolo', label: getPermissionModeLabelForAgentType(agentType, 'safe-yolo'), description: 'Workspace write with approval', icon: 'shield-checkmark-outline' }, + { value: 'yolo', label: getPermissionModeLabelForAgentType(agentType, 'yolo'), description: 'Full access, skip permissions', icon: 'flash-outline' }, + ]; + } + + return [ + { value: 'default', label: getPermissionModeLabelForAgentType(agentType, 'default'), description: 'Ask for permissions', icon: 'shield-outline' }, + { value: 'acceptEdits', label: getPermissionModeLabelForAgentType(agentType, 'acceptEdits'), description: 'Auto-approve edits', icon: 'checkmark-outline' }, + { value: 'plan', label: getPermissionModeLabelForAgentType(agentType, 'plan'), description: 'Plan before executing', icon: 'list-outline' }, + { value: 'bypassPermissions', label: getPermissionModeLabelForAgentType(agentType, 'bypassPermissions'), description: 'Skip all permissions', icon: 'flash-outline' }, + ]; +} + +export function normalizePermissionModeForAgentType(mode: PermissionMode, agentType: AgentType): PermissionMode { + const agentId = resolveAgentIdFromFlavor(agentType) ?? DEFAULT_AGENT_ID; + const group = getAgentCore(agentId).permissions.modeGroup; + return normalizePermissionModeForGroup(mode, group); +} + +export function getPermissionModeBadgeLabelForAgentType(agentType: AgentType, mode: PermissionMode): string { + const agentId = resolveAgentIdFromFlavor(agentType) ?? DEFAULT_AGENT_ID; + const core = getAgentCore(agentId); + const group = core.permissions.modeGroup; + const normalized = normalizePermissionModeForAgentType(mode, agentType); + if (normalized === 'default') return ''; + + const seg = group === 'codexLike' + ? BADGE_KEY_SEGMENT_CODEX_LIKE[normalized] + : BADGE_KEY_SEGMENT_CLAUDE[normalized]; + if (!seg) return ''; + + return t(`${core.permissionModeI18nPrefix}.${seg}` as TranslationKey); +} diff --git a/expo-app/sources/sync/permissionModeOverride.test.ts b/expo-app/sources/sync/permissionModeOverride.test.ts new file mode 100644 index 000000000..c8dd56e6f --- /dev/null +++ b/expo-app/sources/sync/permissionModeOverride.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; + +import { getPermissionModeOverrideForSpawn } from './permissionModeOverride'; + +describe('getPermissionModeOverrideForSpawn', () => { + it('returns null when local permissionModeUpdatedAt is missing', () => { + expect(getPermissionModeOverrideForSpawn({ + id: 's1', + permissionMode: 'ask', + // permissionModeUpdatedAt missing + metadata: { permissionModeUpdatedAt: 1 }, + } as any)).toBeNull(); + }); + + it('returns null when local updatedAt is not newer than metadata updatedAt', () => { + expect(getPermissionModeOverrideForSpawn({ + id: 's1', + permissionMode: 'ask', + permissionModeUpdatedAt: 10, + metadata: { permissionModeUpdatedAt: 10 }, + } as any)).toBeNull(); + }); + + it('returns override when local updatedAt is newer than metadata updatedAt', () => { + expect(getPermissionModeOverrideForSpawn({ + id: 's1', + permissionMode: 'ask', + permissionModeUpdatedAt: 11, + metadata: { permissionModeUpdatedAt: 10 }, + } as any)).toEqual({ + permissionMode: 'ask', + permissionModeUpdatedAt: 11, + }); + }); + + it('defaults permissionMode to default when local mode is empty', () => { + expect(getPermissionModeOverrideForSpawn({ + id: 's1', + permissionMode: '', + permissionModeUpdatedAt: 11, + metadata: { permissionModeUpdatedAt: 10 }, + } as any)).toEqual({ + permissionMode: 'default', + permissionModeUpdatedAt: 11, + }); + }); +}); + diff --git a/expo-app/sources/sync/permissionModeOverride.ts b/expo-app/sources/sync/permissionModeOverride.ts new file mode 100644 index 000000000..f09d18aed --- /dev/null +++ b/expo-app/sources/sync/permissionModeOverride.ts @@ -0,0 +1,22 @@ +import type { PermissionMode } from '@/sync/permissionTypes'; +import type { Session } from './storageTypes'; + +export type PermissionModeOverrideForSpawn = { + permissionMode: PermissionMode; + permissionModeUpdatedAt: number; +}; + +export function getPermissionModeOverrideForSpawn(session: Session): PermissionModeOverrideForSpawn | null { + const localUpdatedAt = session.permissionModeUpdatedAt; + if (typeof localUpdatedAt !== 'number') return null; + + const metadataUpdatedAt = session.metadata?.permissionModeUpdatedAt ?? null; + const metadataUpdatedAtNumber = typeof metadataUpdatedAt === 'number' ? metadataUpdatedAt : 0; + if (localUpdatedAt <= metadataUpdatedAtNumber) return null; + + return { + permissionMode: session.permissionMode || 'default', + permissionModeUpdatedAt: localUpdatedAt, + }; +} + diff --git a/expo-app/sources/sync/permissionTypes.test.ts b/expo-app/sources/sync/permissionTypes.test.ts new file mode 100644 index 000000000..e81379c79 --- /dev/null +++ b/expo-app/sources/sync/permissionTypes.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest'; +import type { PermissionMode } from './permissionTypes'; +import { + isModelMode, + isPermissionMode, + getNextPermissionModeForGroup, + normalizePermissionModeForGroup, + normalizeProfileDefaultPermissionMode, +} from './permissionTypes'; + +describe('normalizePermissionModeForGroup', () => { + it('clamps non-codexLike permission modes to default for codexLike', () => { + expect(normalizePermissionModeForGroup('plan', 'codexLike')).toBe('default'); + }); + + it('clamps codex-like permission modes to default for claude', () => { + expect(normalizePermissionModeForGroup('read-only', 'claude')).toBe('default'); + }); + + it('preserves codex-like modes for codexLike', () => { + expect(normalizePermissionModeForGroup('safe-yolo', 'codexLike')).toBe('safe-yolo'); + expect(normalizePermissionModeForGroup('yolo', 'codexLike')).toBe('yolo'); + }); + + it('preserves claude modes for claude', () => { + const modes: PermissionMode[] = ['default', 'acceptEdits', 'plan', 'bypassPermissions']; + for (const mode of modes) { + expect(normalizePermissionModeForGroup(mode, 'claude')).toBe(mode); + } + }); +}); + +describe('isPermissionMode', () => { + it('returns true for valid permission modes', () => { + expect(isPermissionMode('default')).toBe(true); + expect(isPermissionMode('read-only')).toBe(true); + expect(isPermissionMode('plan')).toBe(true); + }); + + it('returns false for invalid values', () => { + expect(isPermissionMode('bogus')).toBe(false); + expect(isPermissionMode(null)).toBe(false); + expect(isPermissionMode(123)).toBe(false); + }); +}); + +describe('getNextPermissionModeForGroup', () => { + it('cycles through codex-like modes and clamps invalid current modes', () => { + expect(getNextPermissionModeForGroup('default', 'codexLike')).toBe('read-only'); + expect(getNextPermissionModeForGroup('read-only', 'codexLike')).toBe('safe-yolo'); + expect(getNextPermissionModeForGroup('safe-yolo', 'codexLike')).toBe('yolo'); + expect(getNextPermissionModeForGroup('yolo', 'codexLike')).toBe('default'); + + // If a claude-only mode slips in, treat it as default before cycling. + expect(getNextPermissionModeForGroup('plan', 'codexLike')).toBe('read-only'); + }); + + it('cycles through claude modes and clamps invalid current modes', () => { + expect(getNextPermissionModeForGroup('default', 'claude')).toBe('acceptEdits'); + expect(getNextPermissionModeForGroup('acceptEdits', 'claude')).toBe('plan'); + expect(getNextPermissionModeForGroup('plan', 'claude')).toBe('bypassPermissions'); + expect(getNextPermissionModeForGroup('bypassPermissions', 'claude')).toBe('default'); + + // If a codex-like mode slips in, treat it as default before cycling. + expect(getNextPermissionModeForGroup('read-only', 'claude')).toBe('acceptEdits'); + }); +}); + +describe('normalizeProfileDefaultPermissionMode', () => { + it('preserves codex-like modes for profile defaultPermissionMode', () => { + expect(normalizeProfileDefaultPermissionMode('read-only')).toBe('read-only'); + expect(normalizeProfileDefaultPermissionMode('safe-yolo')).toBe('safe-yolo'); + expect(normalizeProfileDefaultPermissionMode('yolo')).toBe('yolo'); + }); +}); + +describe('isModelMode', () => { + it('returns true for valid model modes', () => { + expect(isModelMode('default')).toBe(true); + expect(isModelMode('adaptiveUsage')).toBe(true); + expect(isModelMode('gemini-2.5-pro')).toBe(true); + }); + + it('returns false for invalid values', () => { + expect(isModelMode('bogus')).toBe(false); + expect(isModelMode(null)).toBe(false); + }); +}); diff --git a/expo-app/sources/sync/permissionTypes.ts b/expo-app/sources/sync/permissionTypes.ts new file mode 100644 index 000000000..648bd27f6 --- /dev/null +++ b/expo-app/sources/sync/permissionTypes.ts @@ -0,0 +1,76 @@ +export type PermissionMode = + | 'default' + | 'acceptEdits' + | 'bypassPermissions' + | 'plan' + | 'read-only' + | 'safe-yolo' + | 'yolo'; + +const ALL_PERMISSION_MODES = [ + 'default', + 'acceptEdits', + 'bypassPermissions', + 'plan', + 'read-only', + 'safe-yolo', + 'yolo', +] as const; + +export const CLAUDE_PERMISSION_MODES = ['default', 'acceptEdits', 'plan', 'bypassPermissions'] as const; +export const CODEX_LIKE_PERMISSION_MODES = ['default', 'read-only', 'safe-yolo', 'yolo'] as const; + +export type PermissionModeGroupId = 'claude' | 'codexLike'; + +export function isPermissionMode(value: unknown): value is PermissionMode { + return typeof value === 'string' && (ALL_PERMISSION_MODES as readonly string[]).includes(value); +} + +export function normalizePermissionModeForGroup(mode: PermissionMode, group: PermissionModeGroupId): PermissionMode { + const allowed = group === 'codexLike' ? CODEX_LIKE_PERMISSION_MODES : CLAUDE_PERMISSION_MODES; + return (allowed as readonly string[]).includes(mode) ? mode : 'default'; +} + +export function getNextPermissionModeForGroup(mode: PermissionMode, group: PermissionModeGroupId): PermissionMode { + if (group === 'codexLike') { + const normalized = normalizePermissionModeForGroup(mode, group) as (typeof CODEX_LIKE_PERMISSION_MODES)[number]; + const currentIndex = CODEX_LIKE_PERMISSION_MODES.indexOf(normalized); + const safeIndex = currentIndex >= 0 ? currentIndex : 0; + const nextIndex = (safeIndex + 1) % CODEX_LIKE_PERMISSION_MODES.length; + return CODEX_LIKE_PERMISSION_MODES[nextIndex]; + } + + const normalized = normalizePermissionModeForGroup(mode, group) as (typeof CLAUDE_PERMISSION_MODES)[number]; + const currentIndex = CLAUDE_PERMISSION_MODES.indexOf(normalized); + const safeIndex = currentIndex >= 0 ? currentIndex : 0; + const nextIndex = (safeIndex + 1) % CLAUDE_PERMISSION_MODES.length; + return CLAUDE_PERMISSION_MODES[nextIndex]; +} + +export function normalizeProfileDefaultPermissionMode(mode: PermissionMode | null | undefined): PermissionMode { + if (!mode) return 'default'; + return mode; +} + +export const MODEL_MODES = [ + 'default', + 'adaptiveUsage', + 'sonnet', + 'opus', + 'gpt-5-codex-high', + 'gpt-5-codex-medium', + 'gpt-5-codex-low', + 'gpt-5-minimal', + 'gpt-5-low', + 'gpt-5-medium', + 'gpt-5-high', + 'gemini-2.5-pro', + 'gemini-2.5-flash', + 'gemini-2.5-flash-lite', +] as const; + +export type ModelMode = (typeof MODEL_MODES)[number]; + +export function isModelMode(value: unknown): value is ModelMode { + return typeof value === 'string' && (MODEL_MODES as readonly string[]).includes(value); +} diff --git a/expo-app/sources/sync/persistence.test.ts b/expo-app/sources/sync/persistence.test.ts new file mode 100644 index 000000000..175f7b438 --- /dev/null +++ b/expo-app/sources/sync/persistence.test.ts @@ -0,0 +1,232 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const store = new Map<string, string>(); + +vi.mock('react-native-mmkv', () => { + class MMKV { + getString(key: string) { + return store.get(key); + } + + set(key: string, value: string) { + store.set(key, value); + } + + delete(key: string) { + store.delete(key); + } + + clearAll() { + store.clear(); + } + } + + return { MMKV }; +}); + +import { clearPersistence, loadNewSessionDraft, loadPendingSettings, savePendingSettings, loadSessionModelModes, saveSessionModelModes } from './persistence'; + +describe('persistence', () => { + beforeEach(() => { + clearPersistence(); + }); + + describe('session model modes', () => { + it('returns an empty object when nothing is persisted', () => { + expect(loadSessionModelModes()).toEqual({}); + }); + + it('roundtrips session model modes', () => { + saveSessionModelModes({ abc: 'gemini-2.5-pro' }); + expect(loadSessionModelModes()).toEqual({ abc: 'gemini-2.5-pro' }); + }); + + it('filters out invalid persisted model modes', () => { + store.set( + 'session-model-modes', + JSON.stringify({ abc: 'gemini-2.5-pro', bad: 'not-a-model' }), + ); + expect(loadSessionModelModes()).toEqual({ abc: 'gemini-2.5-pro' }); + }); + }); + + describe('pending settings', () => { + it('returns empty object when nothing is persisted', () => { + expect(loadPendingSettings()).toEqual({}); + }); + + it('does not materialize schema defaults when persisted pending is {}', () => { + // Historically, parsing pending via SettingsSchema.partial().parse({}) would + // synthesize defaults (secrets, dismissedCLIWarnings, etc) once defaults were + // added to the schema. Pending must remain delta-only. + store.set('pending-settings', JSON.stringify({})); + expect(loadPendingSettings()).toEqual({}); + }); + + it('returns empty object when pending-settings JSON is invalid', () => { + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + store.set('pending-settings', '{ this is not json'); + expect(loadPendingSettings()).toEqual({}); + spy.mockRestore(); + }); + + it('returns empty object when persisted pending is not an object', () => { + store.set('pending-settings', JSON.stringify(null)); + expect(loadPendingSettings()).toEqual({}); + + store.set('pending-settings', JSON.stringify('oops')); + expect(loadPendingSettings()).toEqual({}); + + store.set('pending-settings', JSON.stringify(123)); + expect(loadPendingSettings()).toEqual({}); + + store.set('pending-settings', JSON.stringify([1, 2, 3])); + expect(loadPendingSettings()).toEqual({}); + }); + + it('drops unknown keys from pending', () => { + store.set('pending-settings', JSON.stringify({ unknownFutureKey: 1, viewInline: true })); + expect(loadPendingSettings()).toEqual({ viewInline: true }); + }); + + it('drops invalid known keys from pending (type mismatch)', () => { + store.set('pending-settings', JSON.stringify({ viewInline: 'nope', analyticsOptOut: 123 })); + expect(loadPendingSettings()).toEqual({}); + }); + + it('keeps valid secrets delta and does not inject other defaults', () => { + store.set('pending-settings', JSON.stringify({ + secrets: [{ + id: 'k1', + name: 'Test', + kind: 'apiKey', + encryptedValue: { _isSecretValue: true, encryptedValue: { t: 'enc-v1', c: 'abc' } }, + createdAt: 1, + updatedAt: 1, + }], + })); + const pending = loadPendingSettings() as any; + expect(Object.keys(pending).sort()).toEqual(['secrets']); + expect(pending.secrets).toHaveLength(1); + expect(pending.secrets[0].id).toBe('k1'); + }); + + it('drops invalid secrets delta (missing value) and does not inject defaults', () => { + store.set('pending-settings', JSON.stringify({ + secrets: [{ id: 'k1', name: 'Missing value', encryptedValue: { _isSecretValue: true } }], + })); + expect(loadPendingSettings()).toEqual({}); + }); + + it('deletes pending-settings key when saving empty object', () => { + savePendingSettings({ someUnknownKey: 1 } as any); + expect(store.get('pending-settings')).toBeTruthy(); + savePendingSettings({}); + expect(store.get('pending-settings')).toBeUndefined(); + }); + }); + + describe('new session draft', () => { + it('preserves valid non-session modelMode values', () => { + store.set( + 'new-session-draft-v1', + JSON.stringify({ + input: '', + selectedMachineId: null, + selectedPath: null, + selectedProfileId: null, + agentType: 'claude', + permissionMode: 'default', + modelMode: 'adaptiveUsage', + sessionType: 'simple', + updatedAt: Date.now(), + }), + ); + + const draft = loadNewSessionDraft(); + expect(draft?.modelMode).toBe('adaptiveUsage'); + }); + + it('roundtrips resumeSessionId when persisted', () => { + store.set( + 'new-session-draft-v1', + JSON.stringify({ + input: '', + selectedMachineId: null, + selectedPath: null, + selectedProfileId: null, + agentType: 'claude', + permissionMode: 'default', + modelMode: 'default', + sessionType: 'simple', + resumeSessionId: 'abc123', + updatedAt: Date.now(), + }), + ); + + const draft = loadNewSessionDraft(); + expect(draft?.resumeSessionId).toBe('abc123'); + }); + + it('migrates legacy auggieAllowIndexing into agentNewSessionOptionStateByAgentId', () => { + store.set( + 'new-session-draft-v1', + JSON.stringify({ + input: '', + selectedMachineId: null, + selectedPath: null, + selectedProfileId: null, + agentType: 'auggie', + permissionMode: 'default', + modelMode: 'default', + sessionType: 'simple', + auggieAllowIndexing: true, + updatedAt: Date.now(), + }), + ); + + const draft = loadNewSessionDraft(); + expect((draft as any)?.agentNewSessionOptionStateByAgentId?.auggie?.allowIndexing).toBe(true); + }); + + it('clamps invalid permissionMode to default', () => { + store.set( + 'new-session-draft-v1', + JSON.stringify({ + input: '', + selectedMachineId: null, + selectedPath: null, + selectedProfileId: null, + agentType: 'gemini', + permissionMode: 'bogus', + modelMode: 'default', + sessionType: 'simple', + updatedAt: Date.now(), + }), + ); + + const draft = loadNewSessionDraft(); + expect(draft?.permissionMode).toBe('default'); + }); + + it('clamps invalid modelMode to default', () => { + store.set( + 'new-session-draft-v1', + JSON.stringify({ + input: '', + selectedMachineId: null, + selectedPath: null, + selectedProfileId: null, + agentType: 'gemini', + permissionMode: 'default', + modelMode: 'not-a-real-model', + sessionType: 'simple', + updatedAt: Date.now(), + }), + ); + + const draft = loadNewSessionDraft(); + expect(draft?.modelMode).toBe('default'); + }); + }); +}); diff --git a/expo-app/sources/sync/persistence.ts b/expo-app/sources/sync/persistence.ts index 2f9367523..fe5620cc2 100644 --- a/expo-app/sources/sync/persistence.ts +++ b/expo-app/sources/sync/persistence.ts @@ -3,30 +3,132 @@ import { Settings, settingsDefaults, settingsParse, SettingsSchema } from './set import { LocalSettings, localSettingsDefaults, localSettingsParse } from './localSettings'; import { Purchases, purchasesDefaults, purchasesParse } from './purchases'; import { Profile, profileDefaults, profileParse } from './profile'; -import type { PermissionMode } from '@/components/PermissionModeSelector'; +import { isModelMode, isPermissionMode, type PermissionMode, type ModelMode } from '@/sync/permissionTypes'; +import { DEFAULT_AGENT_ID, isAgentId, type AgentId } from '@/agents/catalog'; +import { readStorageScopeFromEnv, scopedStorageId } from '@/utils/storageScope'; +import { dbgSettings, summarizeSettingsDelta } from './debugSettings'; +import { SecretStringSchema, type SecretString } from './secretSettings'; -const mmkv = new MMKV(); +const isWebRuntime = typeof window !== 'undefined' && typeof document !== 'undefined'; +const storageScope = isWebRuntime ? null : readStorageScopeFromEnv(); +const mmkv = storageScope ? new MMKV({ id: scopedStorageId('default', storageScope) }) : new MMKV(); const NEW_SESSION_DRAFT_KEY = 'new-session-draft-v1'; -export type NewSessionAgentType = 'claude' | 'codex' | 'gemini'; export type NewSessionSessionType = 'simple' | 'worktree'; +export type NewSessionAgentType = AgentId; export interface NewSessionDraft { input: string; selectedMachineId: string | null; selectedPath: string | null; + selectedProfileId: string | null; + selectedSecretId: string | null; + /** + * Per-profile per-env-var secret selection (saved secret id or '' for "use machine env"). + * Used by the New Session wizard to preserve overrides while switching profiles. + */ + selectedSecretIdByProfileIdByEnvVarName?: Record<string, Record<string, string | null | undefined>> | null; + /** + * Per-profile per-env-var session-only secret values, encrypted-at-rest. + * (These are decrypted only when needed by the wizard.) + */ + sessionOnlySecretValueEncByProfileIdByEnvVarName?: Record<string, Record<string, SecretString | null | undefined>> | null; agentType: NewSessionAgentType; permissionMode: PermissionMode; + modelMode: ModelMode; sessionType: NewSessionSessionType; + resumeSessionId?: string; + /** + * Provider-specific new-session option state keyed by agent id. + * This is UI-only draft state (not sent to server). + */ + agentNewSessionOptionStateByAgentId?: Partial<Record<AgentId, Record<string, unknown>>> | null; updatedAt: number; } +type DraftNestedRecord<T> = Record<string, Record<string, T | null>>; + +/** + * Parse a "record of records" draft field while salvaging valid entries. + * We intentionally accept partial validity to avoid dropping all draft state + * due to a single malformed nested entry. + */ +function parseDraftNestedRecord<T>( + input: unknown, + parseValue: (value: unknown) => T | null | undefined +): DraftNestedRecord<T> | null { + if (!input || typeof input !== 'object' || Array.isArray(input)) return null; + const out: DraftNestedRecord<T> = {}; + + for (const [rawProfileId, byEnv] of Object.entries(input as Record<string, unknown>)) { + const profileId = typeof rawProfileId === 'string' ? rawProfileId.trim() : ''; + if (!profileId) continue; + if (!byEnv || typeof byEnv !== 'object' || Array.isArray(byEnv)) continue; + + const inner: Record<string, T | null> = {}; + for (const [rawEnvVarName, rawValue] of Object.entries(byEnv as Record<string, unknown>)) { + const envVarName = typeof rawEnvVarName === 'string' ? rawEnvVarName.trim().toUpperCase() : ''; + if (!envVarName) continue; + + const parsed = parseValue(rawValue); + if (parsed !== undefined) { + inner[envVarName] = parsed; + } + } + + if (Object.keys(inner).length > 0) out[profileId] = inner; + } + + return Object.keys(out).length > 0 ? out : null; +} + +function parseDraftStringOrNull(value: unknown): string | null | undefined { + if (value === null) return null; + if (typeof value === 'string') return value; + return undefined; +} + +function parseDraftSecretStringOrNull(value: unknown): SecretString | null | undefined { + if (value === null) return null; + const parsed = SecretStringSchema.safeParse(value); + if (parsed.success) return parsed.data; + return undefined; +} + +function parseDraftAgentNewSessionOptionStateByAgentId( + input: unknown, +): Partial<Record<AgentId, Record<string, unknown>>> | null { + if (!input || typeof input !== 'object' || Array.isArray(input)) return null; + const out: Partial<Record<AgentId, Record<string, unknown>>> = {}; + + for (const [rawAgentId, rawOptions] of Object.entries(input as Record<string, unknown>)) { + if (!isAgentId(rawAgentId)) continue; + if (!rawOptions || typeof rawOptions !== 'object' || Array.isArray(rawOptions)) continue; + + const options: Record<string, unknown> = {}; + for (const [rawKey, rawValue] of Object.entries(rawOptions as Record<string, unknown>)) { + const key = typeof rawKey === 'string' ? rawKey.trim() : ''; + if (!key) continue; + + // Only salvage JSON-safe primitives; objects can be added later if needed. + if (rawValue === null || typeof rawValue === 'boolean' || typeof rawValue === 'number' || typeof rawValue === 'string') { + options[key] = rawValue; + } + } + + if (Object.keys(options).length > 0) out[rawAgentId] = options; + } + + return Object.keys(out).length > 0 ? out : null; +} + export function loadSettings(): { settings: Settings, version: number | null } { const settings = mmkv.getString('settings'); if (settings) { try { const parsed = JSON.parse(settings); - return { settings: settingsParse(parsed.settings), version: parsed.version }; + const version = typeof parsed.version === 'number' ? parsed.version : null; + return { settings: settingsParse(parsed.settings), version }; } catch (e) { console.error('Failed to parse settings', e); return { settings: { ...settingsDefaults }, version: null }; @@ -39,22 +141,59 @@ export function saveSettings(settings: Settings, version: number) { mmkv.set('settings', JSON.stringify({ settings, version })); } +function parsePendingSettings(raw: unknown): Partial<Settings> { + // CRITICAL: Pending settings must represent ONLY user-intended deltas. + // We must NOT apply schema defaults here (otherwise `{}` becomes a non-empty delta, + // causing a POST on every startup and potentially overwriting server settings). + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + return {}; + } + const input = raw as Record<string, unknown>; + const out: Partial<Settings> = {}; + + (Object.keys(SettingsSchema.shape) as Array<keyof typeof SettingsSchema.shape>).forEach((key) => { + if (!Object.prototype.hasOwnProperty.call(input, key)) return; + const schema = SettingsSchema.shape[key]; + const parsed = schema.safeParse(input[key]); + if (parsed.success) { + (out as any)[key] = parsed.data; + } + }); + + return out; +} + export function loadPendingSettings(): Partial<Settings> { const pending = mmkv.getString('pending-settings'); if (pending) { try { const parsed = JSON.parse(pending); - return SettingsSchema.partial().parse(parsed); + const validated = parsePendingSettings(parsed); + dbgSettings('loadPendingSettings', { + pendingKeys: Object.keys(validated).sort(), + pendingSummary: summarizeSettingsDelta(validated), + }); + return validated; } catch (e) { console.error('Failed to parse pending settings', e); return {}; } } + dbgSettings('loadPendingSettings: none', {}); return {}; } export function savePendingSettings(settings: Partial<Settings>) { - mmkv.set('pending-settings', JSON.stringify(settings)); + // Recommended: delete key when empty to reduce churn/ambiguity. + if (Object.keys(settings).length === 0) { + mmkv.delete('pending-settings'); + } else { + mmkv.set('pending-settings', JSON.stringify(settings)); + } + dbgSettings('savePendingSettings', { + pendingKeys: Object.keys(settings).sort(), + pendingSummary: summarizeSettingsDelta(settings), + }); } export function loadLocalSettings(): LocalSettings { @@ -139,22 +278,59 @@ export function loadNewSessionDraft(): NewSessionDraft | null { const input = typeof parsed.input === 'string' ? parsed.input : ''; const selectedMachineId = typeof parsed.selectedMachineId === 'string' ? parsed.selectedMachineId : null; const selectedPath = typeof parsed.selectedPath === 'string' ? parsed.selectedPath : null; - const agentType: NewSessionAgentType = parsed.agentType === 'codex' || parsed.agentType === 'gemini' - ? parsed.agentType - : 'claude'; - const permissionMode: PermissionMode = typeof parsed.permissionMode === 'string' - ? (parsed.permissionMode as PermissionMode) + const selectedProfileId = typeof parsed.selectedProfileId === 'string' ? parsed.selectedProfileId : null; + const selectedSecretId = typeof parsed.selectedSecretId === 'string' ? parsed.selectedSecretId : null; + const selectedSecretIdByProfileIdByEnvVarName = parseDraftNestedRecord( + parsed.selectedSecretIdByProfileIdByEnvVarName, + parseDraftStringOrNull, + ); + const sessionOnlySecretValueEncByProfileIdByEnvVarName = parseDraftNestedRecord( + parsed.sessionOnlySecretValueEncByProfileIdByEnvVarName, + parseDraftSecretStringOrNull, + ); + const agentType: NewSessionAgentType = isAgentId(parsed.agentType) ? parsed.agentType : DEFAULT_AGENT_ID; + const permissionMode: PermissionMode = isPermissionMode(parsed.permissionMode) + ? parsed.permissionMode + : 'default'; + const modelMode: ModelMode = isModelMode(parsed.modelMode) + ? parsed.modelMode : 'default'; const sessionType: NewSessionSessionType = parsed.sessionType === 'worktree' ? 'worktree' : 'simple'; + const resumeSessionId = typeof parsed.resumeSessionId === 'string' ? parsed.resumeSessionId : undefined; + const agentNewSessionOptionStateByAgentId = parseDraftAgentNewSessionOptionStateByAgentId( + (parsed as any).agentNewSessionOptionStateByAgentId, + ); + const legacyAuggieAllowIndexing = typeof (parsed as any).auggieAllowIndexing === 'boolean' + ? (parsed as any).auggieAllowIndexing + : undefined; const updatedAt = typeof parsed.updatedAt === 'number' ? parsed.updatedAt : Date.now(); + const migratedAgentOptions: Partial<Record<AgentId, Record<string, unknown>>> = { + ...(agentNewSessionOptionStateByAgentId ?? {}), + }; + // Legacy migration: older drafts stored `auggieAllowIndexing` at top-level. + // Keep reading it so users don't lose their local draft state. + if (typeof legacyAuggieAllowIndexing === 'boolean') { + migratedAgentOptions.auggie = { + ...(migratedAgentOptions.auggie ?? {}), + allowIndexing: legacyAuggieAllowIndexing, + }; + } + return { input, selectedMachineId, selectedPath, + selectedProfileId, + selectedSecretId, + selectedSecretIdByProfileIdByEnvVarName, + sessionOnlySecretValueEncByProfileIdByEnvVarName, agentType, permissionMode, + modelMode, sessionType, + ...(resumeSessionId ? { resumeSessionId } : {}), + ...(Object.keys(migratedAgentOptions).length > 0 ? { agentNewSessionOptionStateByAgentId: migratedAgentOptions } : {}), updatedAt, }; } catch (e) { @@ -188,6 +364,90 @@ export function saveSessionPermissionModes(modes: Record<string, PermissionMode> mmkv.set('session-permission-modes', JSON.stringify(modes)); } +export function loadSessionPermissionModeUpdatedAts(): Record<string, number> { + const raw = mmkv.getString('session-permission-mode-updated-ats'); + if (raw) { + try { + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return {}; + } + + const result: Record<string, number> = {}; + for (const [sessionId, value] of Object.entries(parsed as Record<string, unknown>)) { + if (typeof value === 'number' && Number.isFinite(value)) { + result[sessionId] = value; + } + } + return result; + } catch (e) { + console.error('Failed to parse session permission mode updated timestamps', e); + return {}; + } + } + return {}; +} + +export function saveSessionPermissionModeUpdatedAts(updatedAts: Record<string, number>) { + mmkv.set('session-permission-mode-updated-ats', JSON.stringify(updatedAts)); +} + +export function loadSessionLastViewed(): Record<string, number> { + const raw = mmkv.getString('session-last-viewed'); + if (raw) { + try { + const parsed: unknown = JSON.parse(raw); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return {}; + } + + const result: Record<string, number> = {}; + for (const [sessionId, value] of Object.entries(parsed as Record<string, unknown>)) { + if (typeof value === 'number' && Number.isFinite(value)) { + result[sessionId] = value; + } + } + return result; + } catch (e) { + console.error('Failed to parse session last viewed timestamps', e); + return {}; + } + } + return {}; +} + +export function saveSessionLastViewed(data: Record<string, number>) { + mmkv.set('session-last-viewed', JSON.stringify(data)); +} + +export function loadSessionModelModes(): Record<string, ModelMode> { + const modes = mmkv.getString('session-model-modes'); + if (modes) { + try { + const parsed: unknown = JSON.parse(modes); + if (!parsed || typeof parsed !== 'object') { + return {}; + } + + const result: Record<string, ModelMode> = {}; + Object.entries(parsed as Record<string, unknown>).forEach(([sessionId, mode]) => { + if (isModelMode(mode)) { + result[sessionId] = mode; + } + }); + return result; + } catch (e) { + console.error('Failed to parse session model modes', e); + return {}; + } + } + return {}; +} + +export function saveSessionModelModes(modes: Record<string, ModelMode>) { + mmkv.set('session-model-modes', JSON.stringify(modes)); +} + export function loadProfile(): Profile { const profile = mmkv.getString('profile'); if (profile) { @@ -225,4 +485,4 @@ export function retrieveTempText(id: string): string | null { export function clearPersistence() { mmkv.clearAll(); -} \ No newline at end of file +} diff --git a/expo-app/sources/sync/profileGrouping.test.ts b/expo-app/sources/sync/profileGrouping.test.ts new file mode 100644 index 000000000..91d77f091 --- /dev/null +++ b/expo-app/sources/sync/profileGrouping.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest'; +import { buildProfileGroups, toggleFavoriteProfileId } from './profileGrouping'; + +describe('toggleFavoriteProfileId', () => { + it('adds the profile id to the front when missing', () => { + expect(toggleFavoriteProfileId([], 'anthropic')).toEqual(['anthropic']); + }); + + it('removes the profile id when already present', () => { + expect(toggleFavoriteProfileId(['anthropic', 'openai'], 'anthropic')).toEqual(['openai']); + }); + + it('supports favoriting the default environment (empty profile id)', () => { + expect(toggleFavoriteProfileId(['anthropic'], '')).toEqual(['', 'anthropic']); + expect(toggleFavoriteProfileId(['', 'anthropic'], '')).toEqual(['anthropic']); + }); +}); + +describe('buildProfileGroups', () => { + it('filters favoriteIds to resolvable profiles (preserves default environment favorite)', () => { + const customProfiles = [ + { + id: 'custom-profile', + name: 'Custom Profile', + environmentVariables: [], + defaultPermissionModeByAgent: {}, + compatibility: { claude: true, codex: true, gemini: true }, + envVarRequirements: [], + isBuiltIn: false, + createdAt: 0, + updatedAt: 0, + version: '1.0.0', + }, + ]; + + const groups = buildProfileGroups({ + customProfiles, + favoriteProfileIds: ['', 'anthropic', 'missing-profile', 'custom-profile'], + }); + + expect(groups.favoriteIds.has('')).toBe(true); + expect(groups.favoriteIds.has('anthropic')).toBe(true); + expect(groups.favoriteIds.has('custom-profile')).toBe(true); + expect(groups.favoriteIds.has('missing-profile')).toBe(false); + }); + + it('hides profiles that are incompatible with all enabled agents', () => { + const customProfiles = [ + { + id: 'custom-gemini-only', + name: 'Gemini Only', + environmentVariables: [], + defaultPermissionModeByAgent: {}, + compatibility: { claude: false, codex: false, gemini: true }, + envVarRequirements: [], + isBuiltIn: false, + createdAt: 0, + updatedAt: 0, + version: '1.0.0', + }, + ]; + + const groups = buildProfileGroups({ + customProfiles, + favoriteProfileIds: ['gemini', 'custom-gemini-only'], + enabledAgentIds: ['claude', 'codex'], + }); + + expect(groups.builtInProfiles.some((p) => p.id === 'gemini')).toBe(false); + expect(groups.builtInProfiles.some((p) => p.id === 'gemini-api-key')).toBe(false); + expect(groups.builtInProfiles.some((p) => p.id === 'gemini-vertex')).toBe(false); + expect(groups.favoriteProfiles.some((p) => p.id === 'custom-gemini-only')).toBe(false); + expect(groups.customProfiles.some((p) => p.id === 'custom-gemini-only')).toBe(false); + }); +}); diff --git a/expo-app/sources/sync/profileGrouping.ts b/expo-app/sources/sync/profileGrouping.ts new file mode 100644 index 000000000..8a3cbb945 --- /dev/null +++ b/expo-app/sources/sync/profileGrouping.ts @@ -0,0 +1,80 @@ +import { AIBackendProfile } from '@/sync/settings'; +import { DEFAULT_PROFILES, getBuiltInProfile } from '@/sync/profileUtils'; +import type { AgentId } from '@/agents/catalog'; +import { getProfileCompatibleAgentIds } from '@/sync/profileUtils'; + +export interface ProfileGroups { + favoriteProfiles: AIBackendProfile[]; + customProfiles: AIBackendProfile[]; + builtInProfiles: AIBackendProfile[]; + favoriteIds: Set<string>; + builtInIds: Set<string>; +} + +function isProfile(profile: AIBackendProfile | null | undefined): profile is AIBackendProfile { + return Boolean(profile); +} + +export function toggleFavoriteProfileId(favoriteProfileIds: string[], profileId: string): string[] { + const normalized: string[] = []; + const seen = new Set<string>(); + for (const id of favoriteProfileIds) { + if (seen.has(id)) continue; + seen.add(id); + normalized.push(id); + } + + if (seen.has(profileId)) { + return normalized.filter((id) => id !== profileId); + } + + return [profileId, ...normalized]; +} + +export function buildProfileGroups({ + customProfiles, + favoriteProfileIds, + enabledAgentIds, +}: { + customProfiles: AIBackendProfile[]; + favoriteProfileIds: string[]; + enabledAgentIds?: readonly AgentId[]; +}): ProfileGroups { + const builtInIds = new Set(DEFAULT_PROFILES.map((profile) => profile.id)); + + const customById = new Map(customProfiles.map((profile) => [profile.id, profile] as const)); + + const isVisible = (profile: AIBackendProfile): boolean => { + if (!enabledAgentIds) return true; + return getProfileCompatibleAgentIds(profile, enabledAgentIds).length > 0; + }; + + const favoriteProfiles = favoriteProfileIds + .map((id) => customById.get(id) ?? getBuiltInProfile(id)) + .filter(isProfile); + const visibleFavoriteProfiles = favoriteProfiles.filter(isVisible); + + const favoriteIds = new Set<string>(visibleFavoriteProfiles.map((profile) => profile.id)); + // Preserve "default environment" favorite marker (not a real profile object). + if (favoriteProfileIds.includes('')) { + favoriteIds.add(''); + } + + const nonFavoriteCustomProfiles = customProfiles + .filter(isVisible) + .filter((profile) => !favoriteIds.has(profile.id)); + + const nonFavoriteBuiltInProfiles = DEFAULT_PROFILES + .map((profile) => getBuiltInProfile(profile.id)) + .filter(isProfile) + .filter(isVisible) + .filter((profile) => !favoriteIds.has(profile.id)); + + return { + favoriteProfiles: visibleFavoriteProfiles, + customProfiles: nonFavoriteCustomProfiles, + builtInProfiles: nonFavoriteBuiltInProfiles, + favoriteIds, + builtInIds, + }; +} diff --git a/expo-app/sources/sync/profileMutations.ts b/expo-app/sources/sync/profileMutations.ts new file mode 100644 index 000000000..03c98e919 --- /dev/null +++ b/expo-app/sources/sync/profileMutations.ts @@ -0,0 +1,40 @@ +import { randomUUID } from '@/platform/randomUUID'; +import { AIBackendProfile } from '@/sync/settings'; + +export function createEmptyCustomProfile(): AIBackendProfile { + return { + id: randomUUID(), + name: '', + environmentVariables: [], + defaultPermissionModeByAgent: {}, + compatibility: { claude: true, codex: true, gemini: true }, + envVarRequirements: [], + isBuiltIn: false, + createdAt: Date.now(), + updatedAt: Date.now(), + version: '1.0.0', + }; +} + +export function duplicateProfileForEdit(profile: AIBackendProfile, opts?: { copySuffix?: string }): AIBackendProfile { + const suffix = opts?.copySuffix ?? '(Copy)'; + const separator = profile.name.trim().length > 0 ? ' ' : ''; + return { + ...profile, + id: randomUUID(), + name: `${profile.name}${separator}${suffix}`, + isBuiltIn: false, + createdAt: Date.now(), + updatedAt: Date.now(), + }; +} + +export function convertBuiltInProfileToCustom(profile: AIBackendProfile): AIBackendProfile { + return { + ...profile, + id: randomUUID(), + isBuiltIn: false, + createdAt: Date.now(), + updatedAt: Date.now(), + }; +} diff --git a/expo-app/sources/sync/profileSecrets.ts b/expo-app/sources/sync/profileSecrets.ts new file mode 100644 index 000000000..ec08bef12 --- /dev/null +++ b/expo-app/sources/sync/profileSecrets.ts @@ -0,0 +1,19 @@ +import type { AIBackendProfile } from '@/sync/settings'; + +export function getRequiredSecretEnvVarName(profile: AIBackendProfile | null | undefined): string | null { + const required = profile?.envVarRequirements ?? []; + const secret = required.find((v) => (v?.kind ?? 'secret') === 'secret' && v.required === true); + return typeof secret?.name === 'string' && secret.name.length > 0 ? secret.name : null; +} + +export function hasRequiredSecret(profile: AIBackendProfile | null | undefined): boolean { + return Boolean(getRequiredSecretEnvVarName(profile)); +} + +export function getRequiredSecretEnvVarNames(profile: AIBackendProfile | null | undefined): string[] { + const required = profile?.envVarRequirements ?? []; + return required + .filter((v) => (v?.kind ?? 'secret') === 'secret' && v.required === true) + .map((v) => v.name) + .filter((name): name is string => typeof name === 'string' && name.length > 0); +} diff --git a/expo-app/sources/sync/profileSync.ts b/expo-app/sources/sync/profileSync.ts deleted file mode 100644 index 694ea1410..000000000 --- a/expo-app/sources/sync/profileSync.ts +++ /dev/null @@ -1,453 +0,0 @@ -/** - * Profile Synchronization Service - * - * Handles bidirectional synchronization of profiles between GUI and CLI storage. - * Ensures consistent profile data across both systems with proper conflict resolution. - */ - -import { AIBackendProfile, validateProfileForAgent, getProfileEnvironmentVariables } from './settings'; -import { sync } from './sync'; -import { storage } from './storage'; -import { apiSocket } from './apiSocket'; -import { Modal } from '@/modal'; - -// Profile sync status types -export type SyncStatus = 'idle' | 'syncing' | 'success' | 'error'; -export type SyncDirection = 'gui-to-cli' | 'cli-to-gui' | 'bidirectional'; - -// Profile sync conflict resolution strategies -export type ConflictResolution = 'gui-wins' | 'cli-wins' | 'most-recent' | 'merge'; - -// Profile sync event data -export interface ProfileSyncEvent { - direction: SyncDirection; - status: SyncStatus; - profilesSynced?: number; - error?: string; - timestamp: number; - message?: string; - warning?: string; -} - -// Profile sync configuration -export interface ProfileSyncConfig { - autoSync: boolean; - conflictResolution: ConflictResolution; - syncOnProfileChange: boolean; - syncOnAppStart: boolean; -} - -// Default sync configuration -const DEFAULT_SYNC_CONFIG: ProfileSyncConfig = { - autoSync: true, - conflictResolution: 'most-recent', - syncOnProfileChange: true, - syncOnAppStart: true, -}; - -class ProfileSyncService { - private static instance: ProfileSyncService; - private syncStatus: SyncStatus = 'idle'; - private lastSyncTime: number = 0; - private config: ProfileSyncConfig = DEFAULT_SYNC_CONFIG; - private eventListeners: Array<(event: ProfileSyncEvent) => void> = []; - - private constructor() { - // Private constructor for singleton - } - - public static getInstance(): ProfileSyncService { - if (!ProfileSyncService.instance) { - ProfileSyncService.instance = new ProfileSyncService(); - } - return ProfileSyncService.instance; - } - - /** - * Add event listener for sync events - */ - public addEventListener(listener: (event: ProfileSyncEvent) => void): void { - this.eventListeners.push(listener); - } - - /** - * Remove event listener - */ - public removeEventListener(listener: (event: ProfileSyncEvent) => void): void { - const index = this.eventListeners.indexOf(listener); - if (index > -1) { - this.eventListeners.splice(index, 1); - } - } - - /** - * Emit sync event to all listeners - */ - private emitEvent(event: ProfileSyncEvent): void { - this.eventListeners.forEach(listener => { - try { - listener(event); - } catch (error) { - console.error('[ProfileSync] Event listener error:', error); - } - }); - } - - /** - * Update sync configuration - */ - public updateConfig(config: Partial<ProfileSyncConfig>): void { - this.config = { ...this.config, ...config }; - } - - /** - * Get current sync configuration - */ - public getConfig(): ProfileSyncConfig { - return { ...this.config }; - } - - /** - * Get current sync status - */ - public getSyncStatus(): SyncStatus { - return this.syncStatus; - } - - /** - * Get last sync time - */ - public getLastSyncTime(): number { - return this.lastSyncTime; - } - - /** - * Sync profiles from GUI to CLI using proper Happy infrastructure - * SECURITY NOTE: Direct file access is PROHIBITED - use Happy RPC infrastructure - */ - public async syncGuiToCli(profiles: AIBackendProfile[]): Promise<void> { - if (this.syncStatus === 'syncing') { - throw new Error('Sync already in progress'); - } - - this.syncStatus = 'syncing'; - this.emitEvent({ - direction: 'gui-to-cli', - status: 'syncing', - timestamp: Date.now(), - }); - - try { - // Profiles are stored in GUI settings and available through existing Happy sync system - // CLI daemon reads profiles from GUI settings via existing channels - // TODO: Implement machine RPC endpoints for profile management in CLI daemon - console.log(`[ProfileSync] GUI profiles stored in Happy settings. CLI access via existing infrastructure.`); - - this.lastSyncTime = Date.now(); - this.syncStatus = 'success'; - - this.emitEvent({ - direction: 'gui-to-cli', - status: 'success', - profilesSynced: profiles.length, - timestamp: Date.now(), - message: 'Profiles available through Happy settings system' - }); - } catch (error) { - this.syncStatus = 'error'; - const errorMessage = error instanceof Error ? error.message : 'Unknown sync error'; - - this.emitEvent({ - direction: 'gui-to-cli', - status: 'error', - error: errorMessage, - timestamp: Date.now(), - }); - - throw error; - } - } - - /** - * Sync profiles from CLI to GUI using proper Happy infrastructure - * SECURITY NOTE: Direct file access is PROHIBITED - use Happy RPC infrastructure - */ - public async syncCliToGui(): Promise<AIBackendProfile[]> { - if (this.syncStatus === 'syncing') { - throw new Error('Sync already in progress'); - } - - this.syncStatus = 'syncing'; - this.emitEvent({ - direction: 'cli-to-gui', - status: 'syncing', - timestamp: Date.now(), - }); - - try { - // CLI profiles are accessed through Happy settings system, not direct file access - // Return profiles from current GUI settings - const currentProfiles = storage.getState().settings.profiles || []; - - console.log(`[ProfileSync] Retrieved ${currentProfiles.length} profiles from Happy settings`); - - this.lastSyncTime = Date.now(); - this.syncStatus = 'success'; - - this.emitEvent({ - direction: 'cli-to-gui', - status: 'success', - profilesSynced: currentProfiles.length, - timestamp: Date.now(), - message: 'Profiles retrieved from Happy settings system' - }); - - return currentProfiles; - } catch (error) { - this.syncStatus = 'error'; - const errorMessage = error instanceof Error ? error.message : 'Unknown sync error'; - - this.emitEvent({ - direction: 'cli-to-gui', - status: 'error', - error: errorMessage, - timestamp: Date.now(), - }); - - throw error; - } - } - - /** - * Perform bidirectional sync with conflict resolution - */ - public async bidirectionalSync(guiProfiles: AIBackendProfile[]): Promise<AIBackendProfile[]> { - if (this.syncStatus === 'syncing') { - throw new Error('Sync already in progress'); - } - - this.syncStatus = 'syncing'; - this.emitEvent({ - direction: 'bidirectional', - status: 'syncing', - timestamp: Date.now(), - }); - - try { - // Get CLI profiles - const cliProfiles = await this.syncCliToGui(); - - // Resolve conflicts based on configuration - const resolvedProfiles = await this.resolveConflicts(guiProfiles, cliProfiles); - - // Update CLI with resolved profiles - await this.syncGuiToCli(resolvedProfiles); - - this.lastSyncTime = Date.now(); - this.syncStatus = 'success'; - - this.emitEvent({ - direction: 'bidirectional', - status: 'success', - profilesSynced: resolvedProfiles.length, - timestamp: Date.now(), - }); - - return resolvedProfiles; - } catch (error) { - this.syncStatus = 'error'; - const errorMessage = error instanceof Error ? error.message : 'Unknown sync error'; - - this.emitEvent({ - direction: 'bidirectional', - status: 'error', - error: errorMessage, - timestamp: Date.now(), - }); - - throw error; - } - } - - /** - * Resolve conflicts between GUI and CLI profiles - */ - private async resolveConflicts( - guiProfiles: AIBackendProfile[], - cliProfiles: AIBackendProfile[] - ): Promise<AIBackendProfile[]> { - const { conflictResolution } = this.config; - const resolvedProfiles: AIBackendProfile[] = []; - const processedIds = new Set<string>(); - - // Process profiles that exist in both GUI and CLI - for (const guiProfile of guiProfiles) { - const cliProfile = cliProfiles.find(p => p.id === guiProfile.id); - - if (cliProfile) { - let resolvedProfile: AIBackendProfile; - - switch (conflictResolution) { - case 'gui-wins': - resolvedProfile = { ...guiProfile, updatedAt: Date.now() }; - break; - case 'cli-wins': - resolvedProfile = { ...cliProfile, updatedAt: Date.now() }; - break; - case 'most-recent': - resolvedProfile = guiProfile.updatedAt! >= cliProfile.updatedAt! - ? { ...guiProfile } - : { ...cliProfile }; - break; - case 'merge': - resolvedProfile = await this.mergeProfiles(guiProfile, cliProfile); - break; - default: - resolvedProfile = { ...guiProfile }; - } - - resolvedProfiles.push(resolvedProfile); - processedIds.add(guiProfile.id); - } else { - // Profile exists only in GUI - resolvedProfiles.push({ ...guiProfile, updatedAt: Date.now() }); - processedIds.add(guiProfile.id); - } - } - - // Add profiles that exist only in CLI - for (const cliProfile of cliProfiles) { - if (!processedIds.has(cliProfile.id)) { - resolvedProfiles.push({ ...cliProfile, updatedAt: Date.now() }); - } - } - - return resolvedProfiles; - } - - /** - * Merge two profiles, preferring non-null values from both - */ - private async mergeProfiles( - guiProfile: AIBackendProfile, - cliProfile: AIBackendProfile - ): Promise<AIBackendProfile> { - const merged: AIBackendProfile = { - id: guiProfile.id, - name: guiProfile.name || cliProfile.name, - description: guiProfile.description || cliProfile.description, - anthropicConfig: { ...cliProfile.anthropicConfig, ...guiProfile.anthropicConfig }, - openaiConfig: { ...cliProfile.openaiConfig, ...guiProfile.openaiConfig }, - azureOpenAIConfig: { ...cliProfile.azureOpenAIConfig, ...guiProfile.azureOpenAIConfig }, - togetherAIConfig: { ...cliProfile.togetherAIConfig, ...guiProfile.togetherAIConfig }, - tmuxConfig: { ...cliProfile.tmuxConfig, ...guiProfile.tmuxConfig }, - environmentVariables: this.mergeEnvironmentVariables( - cliProfile.environmentVariables || [], - guiProfile.environmentVariables || [] - ), - compatibility: { ...cliProfile.compatibility, ...guiProfile.compatibility }, - isBuiltIn: guiProfile.isBuiltIn || cliProfile.isBuiltIn, - createdAt: Math.min(guiProfile.createdAt || 0, cliProfile.createdAt || 0), - updatedAt: Math.max(guiProfile.updatedAt || 0, cliProfile.updatedAt || 0), - version: guiProfile.version || cliProfile.version || '1.0.0', - }; - - return merged; - } - - /** - * Merge environment variables from two profiles - */ - private mergeEnvironmentVariables( - cliVars: Array<{ name: string; value: string }>, - guiVars: Array<{ name: string; value: string }> - ): Array<{ name: string; value: string }> { - const mergedVars = new Map<string, string>(); - - // Add CLI variables first - cliVars.forEach(v => mergedVars.set(v.name, v.value)); - - // Override with GUI variables - guiVars.forEach(v => mergedVars.set(v.name, v.value)); - - return Array.from(mergedVars.entries()).map(([name, value]) => ({ name, value })); - } - - /** - * Set active profile using Happy settings infrastructure - * SECURITY NOTE: Direct file access is PROHIBITED - use Happy settings system - */ - public async setActiveProfile(profileId: string): Promise<void> { - try { - // Store in GUI settings using Happy's settings system - sync.applySettings({ lastUsedProfile: profileId }); - - console.log(`[ProfileSync] Set active profile ${profileId} in Happy settings`); - - // Note: CLI daemon accesses active profile through Happy settings system - // TODO: Implement machine RPC endpoint for setting active profile in CLI daemon - } catch (error) { - console.error('[ProfileSync] Failed to set active profile:', error); - throw error; - } - } - - /** - * Get active profile using Happy settings infrastructure - * SECURITY NOTE: Direct file access is PROHIBITED - use Happy settings system - */ - public async getActiveProfile(): Promise<AIBackendProfile | null> { - try { - // Get active profile from Happy settings system - const lastUsedProfileId = storage.getState().settings.lastUsedProfile; - - if (!lastUsedProfileId) { - return null; - } - - const profiles = storage.getState().settings.profiles || []; - const activeProfile = profiles.find((p: AIBackendProfile) => p.id === lastUsedProfileId); - - if (activeProfile) { - console.log(`[ProfileSync] Retrieved active profile ${activeProfile.name} from Happy settings`); - return activeProfile; - } - - return null; - } catch (error) { - console.error('[ProfileSync] Failed to get active profile:', error); - return null; - } - } - - /** - * Auto-sync if enabled and conditions are met - */ - public async autoSyncIfNeeded(guiProfiles: AIBackendProfile[]): Promise<void> { - if (!this.config.autoSync) { - return; - } - - const timeSinceLastSync = Date.now() - this.lastSyncTime; - const AUTO_SYNC_INTERVAL = 5 * 60 * 1000; // 5 minutes - - if (timeSinceLastSync > AUTO_SYNC_INTERVAL) { - try { - await this.bidirectionalSync(guiProfiles); - } catch (error) { - console.error('[ProfileSync] Auto-sync failed:', error); - // Don't throw for auto-sync failures - } - } - } -} - -// Export singleton instance -export const profileSyncService = ProfileSyncService.getInstance(); - -// Export convenience functions -export const syncGuiToCli = (profiles: AIBackendProfile[]) => profileSyncService.syncGuiToCli(profiles); -export const syncCliToGui = () => profileSyncService.syncCliToGui(); -export const bidirectionalSync = (guiProfiles: AIBackendProfile[]) => profileSyncService.bidirectionalSync(guiProfiles); -export const setActiveProfile = (profileId: string) => profileSyncService.setActiveProfile(profileId); -export const getActiveProfile = () => profileSyncService.getActiveProfile(); \ No newline at end of file diff --git a/expo-app/sources/sync/profileUtils.test.ts b/expo-app/sources/sync/profileUtils.test.ts new file mode 100644 index 000000000..13f05bfc9 --- /dev/null +++ b/expo-app/sources/sync/profileUtils.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; +import { getBuiltInProfileNameKey, getProfilePrimaryCli, getProfileSupportedAgentIds, isProfileCompatibleWithAnyAgent } from './profileUtils'; + +describe('getProfilePrimaryCli', () => { + it('ignores unknown compatibility keys', () => { + const profile = { + compatibility: { unknownCli: true }, + } as any; + + expect(getProfilePrimaryCli(profile)).toBe('none'); + }); +}); + +describe('getProfileSupportedAgentIds', () => { + it('returns supported agent ids and ignores unknown keys', () => { + const profile = { + compatibility: { claude: true, codex: false, gemini: true, unknownCli: true }, + } as any; + + expect(getProfileSupportedAgentIds(profile)).toEqual(['claude', 'gemini']); + }); +}); + +describe('getBuiltInProfileNameKey', () => { + it('returns the translation key for known built-in profile ids', () => { + expect(getBuiltInProfileNameKey('anthropic')).toBe('profiles.builtInNames.anthropic'); + expect(getBuiltInProfileNameKey('deepseek')).toBe('profiles.builtInNames.deepseek'); + expect(getBuiltInProfileNameKey('zai')).toBe('profiles.builtInNames.zai'); + expect(getBuiltInProfileNameKey('openai')).toBe('profiles.builtInNames.openai'); + expect(getBuiltInProfileNameKey('azure-openai')).toBe('profiles.builtInNames.azureOpenai'); + }); + + it('returns null for unknown ids', () => { + expect(getBuiltInProfileNameKey('unknown')).toBeNull(); + }); +}); + +describe('isProfileCompatibleWithAnyAgent', () => { + it('returns false when no enabled agents are compatible', () => { + const profile = { + isBuiltIn: true, + compatibility: { gemini: true, codex: false, claude: false }, + } as any; + + expect(isProfileCompatibleWithAnyAgent(profile, ['claude', 'codex'])).toBe(false); + }); + + it('returns true when at least one enabled agent is compatible', () => { + const profile = { + isBuiltIn: true, + compatibility: { gemini: true, codex: false, claude: false }, + } as any; + + expect(isProfileCompatibleWithAnyAgent(profile, ['claude', 'gemini'])).toBe(true); + }); + + it('treats custom profiles with no compatibility map as compatible', () => { + const profile = { + isBuiltIn: false, + compatibility: undefined, + } as any; + + expect(isProfileCompatibleWithAnyAgent(profile, ['claude'])).toBe(true); + }); +}); diff --git a/expo-app/sources/sync/profileUtils.ts b/expo-app/sources/sync/profileUtils.ts index d90a98a93..51d7d4486 100644 --- a/expo-app/sources/sync/profileUtils.ts +++ b/expo-app/sources/sync/profileUtils.ts @@ -1,4 +1,94 @@ import { AIBackendProfile } from './settings'; +import { AGENT_IDS, getAgentCore, type AgentId } from '@/agents/catalog'; +import { isProfileCompatibleWithAgent } from './settings'; + +export type ProfilePrimaryCli = AgentId | 'multi' | 'none'; + +export type BuiltInProfileId = + | 'anthropic' + | 'deepseek' + | 'zai' + | 'codex' + | 'openai' + | 'azure-openai' + | 'gemini' + | 'gemini-api-key' + | 'gemini-vertex'; + +export type BuiltInProfileNameKey = + | 'profiles.builtInNames.anthropic' + | 'profiles.builtInNames.deepseek' + | 'profiles.builtInNames.zai' + | 'profiles.builtInNames.codex' + | 'profiles.builtInNames.openai' + | 'profiles.builtInNames.azureOpenai' + | 'profiles.builtInNames.gemini' + | 'profiles.builtInNames.geminiApiKey' + | 'profiles.builtInNames.geminiVertex'; + +const ALLOWED_PROFILE_CLIS = new Set<string>(AGENT_IDS as readonly string[]); + +export function getProfileSupportedAgentIds(profile: AIBackendProfile | null | undefined): AgentId[] { + if (!profile) return []; + return Object.entries(profile.compatibility ?? {}) + .filter(([, isSupported]) => isSupported) + .map(([cli]) => cli) + .filter((cli): cli is AgentId => ALLOWED_PROFILE_CLIS.has(cli)); +} + +export function getProfileCompatibleAgentIds( + profile: Pick<AIBackendProfile, 'compatibility' | 'isBuiltIn'> | null | undefined, + agentIds: readonly AgentId[], +): AgentId[] { + if (!profile) return []; + return agentIds.filter((agentId) => isProfileCompatibleWithAgent(profile, agentId)); +} + +export function isProfileCompatibleWithAnyAgent( + profile: Pick<AIBackendProfile, 'compatibility' | 'isBuiltIn'> | null | undefined, + agentIds: readonly AgentId[], +): boolean { + return getProfileCompatibleAgentIds(profile, agentIds).length > 0; +} + +export function getProfilePrimaryCli(profile: AIBackendProfile | null | undefined): ProfilePrimaryCli { + if (!profile) return 'none'; + const supported = getProfileSupportedAgentIds(profile); + + if (supported.length === 0) return 'none'; + if (supported.length === 1) return supported[0]; + return 'multi'; +} + +export function getBuiltInProfileNameKey(id: string): BuiltInProfileNameKey | null { + switch (id as BuiltInProfileId) { + case 'anthropic': + return 'profiles.builtInNames.anthropic'; + case 'deepseek': + return 'profiles.builtInNames.deepseek'; + case 'zai': + return 'profiles.builtInNames.zai'; + case 'codex': + return 'profiles.builtInNames.codex'; + case 'openai': + return 'profiles.builtInNames.openai'; + case 'azure-openai': + return 'profiles.builtInNames.azureOpenai'; + case 'gemini': + return 'profiles.builtInNames.gemini'; + case 'gemini-api-key': + return 'profiles.builtInNames.geminiApiKey'; + case 'gemini-vertex': + return 'profiles.builtInNames.geminiVertex'; + default: + return null; + } +} + +export function resolveProfileById(id: string, customProfiles: AIBackendProfile[]): AIBackendProfile | null { + const custom = customProfiles.find((p) => p.id === id); + return custom ?? getBuiltInProfile(id); +} /** * Documentation and expected values for built-in profiles. @@ -24,10 +114,24 @@ export const getBuiltInProfileDocumentation = (id: string): ProfileDocumentation switch (id) { case 'anthropic': return { - description: 'Official Anthropic Claude API - uses your default Anthropic credentials', + description: 'Official Anthropic backend (Claude Code). Requires being logged in on the selected machine.', + environmentVariables: [], + shellConfigExample: `# No additional environment variables needed. +# Make sure you are logged in to Claude Code on the target machine: +# 1) Run: claude +# 2) Then run: /login +# +# If you want to use an API key instead of CLI login, set: +# export ANTHROPIC_AUTH_TOKEN="sk-..."`, + }; + case 'codex': + return { + setupGuideUrl: 'https://developers.openai.com/codex/get-started', + description: 'Codex CLI using machine-local login (recommended). No API key env vars required.', environmentVariables: [], - shellConfigExample: `# No additional environment variables needed -# Uses ANTHROPIC_AUTH_TOKEN from your login session`, + shellConfigExample: `# No additional environment variables needed. +# Make sure you are logged in to Codex on the target machine: +# 1) Run: codex login`, }; case 'deepseek': return { @@ -179,38 +283,89 @@ export OPENAI_SMALL_FAST_MODEL="gpt-5-codex-low"`, case 'azure-openai': return { setupGuideUrl: 'https://learn.microsoft.com/en-us/azure/ai-services/openai/', - description: 'Azure OpenAI Service for enterprise-grade AI with enhanced security and compliance', + description: 'Azure OpenAI for Codex (configure your provider/base URL in ~/.codex/config.toml or ~/.codex/config.json).', environmentVariables: [ - { - name: 'AZURE_OPENAI_ENDPOINT', - expectedValue: 'https://YOUR_RESOURCE.openai.azure.com', - description: 'Your Azure OpenAI endpoint URL', - isSecret: false, - }, { name: 'AZURE_OPENAI_API_KEY', - expectedValue: '', + expectedValue: 'your-azure-key', description: 'Your Azure OpenAI API key', isSecret: true, }, { name: 'AZURE_OPENAI_API_VERSION', expectedValue: '2024-02-15-preview', - description: 'Azure OpenAI API version', + description: 'Azure OpenAI API version (optional)', isSecret: false, }, + ], + shellConfigExample: `# Add to ~/.zshrc or ~/.bashrc: +export AZURE_OPENAI_API_KEY="YOUR_AZURE_API_KEY" +export AZURE_OPENAI_API_VERSION="2024-02-15-preview" + +# Then configure Codex provider/base URL in ~/.codex/config.toml or ~/.codex/config.json.`, + }; + case 'gemini': + return { + setupGuideUrl: 'https://github.com/google-gemini/gemini-cli', + description: 'Gemini CLI using machine-local login (recommended). No API key env vars required.', + environmentVariables: [], + shellConfigExample: `# No additional environment variables needed. +# Make sure you are logged in to Gemini CLI on the target machine: +# 1) Run: gemini auth`, + }; + case 'gemini-api-key': + return { + setupGuideUrl: 'https://github.com/google-gemini/gemini-cli', + description: 'Gemini CLI using an API key via environment variables.', + environmentVariables: [ { - name: 'AZURE_OPENAI_DEPLOYMENT_NAME', - expectedValue: 'gpt-5-codex', - description: 'Your deployment name for the model', + name: 'GEMINI_API_KEY', + expectedValue: '...', + description: 'Your Gemini API key', + isSecret: true, + }, + { + name: 'GEMINI_MODEL', + expectedValue: 'gemini-2.5-pro', + description: 'Default model (optional)', isSecret: false, }, ], shellConfigExample: `# Add to ~/.zshrc or ~/.bashrc: -export AZURE_OPENAI_ENDPOINT="https://YOUR_RESOURCE.openai.azure.com" -export AZURE_OPENAI_API_KEY="YOUR_AZURE_API_KEY" -export AZURE_OPENAI_API_VERSION="2024-02-15-preview" -export AZURE_OPENAI_DEPLOYMENT_NAME="gpt-5-codex"`, +export GEMINI_API_KEY="YOUR_GEMINI_API_KEY" +export GEMINI_MODEL="gemini-2.5-pro"`, + }; + case 'gemini-vertex': + return { + setupGuideUrl: 'https://github.com/google-gemini/gemini-cli', + description: 'Gemini CLI using Vertex AI (Application Default Credentials).', + environmentVariables: [ + { + name: 'GOOGLE_GENAI_USE_VERTEXAI', + expectedValue: '1', + description: 'Enable Vertex AI backend', + isSecret: false, + }, + { + name: 'GOOGLE_CLOUD_PROJECT', + expectedValue: 'your-gcp-project-id', + description: 'Google Cloud project ID', + isSecret: false, + }, + { + name: 'GOOGLE_CLOUD_LOCATION', + expectedValue: 'us-central1', + description: 'Google Cloud location/region', + isSecret: false, + }, + ], + shellConfigExample: `# Add to ~/.zshrc or ~/.bashrc: +export GOOGLE_GENAI_USE_VERTEXAI="1" +export GOOGLE_CLOUD_PROJECT="YOUR_GCP_PROJECT_ID" +export GOOGLE_CLOUD_LOCATION="us-central1" + +# Make sure ADC is configured on the target machine (one option): +# gcloud auth application-default login`, }; default: return null; @@ -242,10 +397,12 @@ export const getBuiltInProfile = (id: string): AIBackendProfile | null => { return { id: 'anthropic', name: 'Anthropic (Default)', - anthropicConfig: {}, + authMode: 'machineLogin', + requiresMachineLogin: getAgentCore('claude').cli.machineLoginKey, environmentVariables: [], - defaultPermissionMode: 'default', + defaultPermissionModeByAgent: { claude: 'default' }, compatibility: { claude: true, codex: false, gemini: false }, + envVarRequirements: [], isBuiltIn: true, createdAt: Date.now(), updatedAt: Date.now(), @@ -256,11 +413,11 @@ export const getBuiltInProfile = (id: string): AIBackendProfile | null => { // Launch daemon with: DEEPSEEK_AUTH_TOKEN=sk-... DEEPSEEK_BASE_URL=https://api.deepseek.com/anthropic // Uses ${VAR:-default} format for fallback values (bash parameter expansion) // Secrets use ${VAR} without fallback for security - // NOTE: anthropicConfig left empty so environmentVariables aren't overridden (getProfileEnvironmentVariables priority) + // NOTE: Profiles are env-var based; environmentVariables are the single source of truth. return { id: 'deepseek', name: 'DeepSeek (Reasoner)', - anthropicConfig: {}, + envVarRequirements: [{ name: 'DEEPSEEK_AUTH_TOKEN', kind: 'secret', required: true }], environmentVariables: [ { name: 'ANTHROPIC_BASE_URL', value: '${DEEPSEEK_BASE_URL:-https://api.deepseek.com/anthropic}' }, { name: 'ANTHROPIC_AUTH_TOKEN', value: '${DEEPSEEK_AUTH_TOKEN}' }, // Secret - no fallback @@ -269,7 +426,7 @@ export const getBuiltInProfile = (id: string): AIBackendProfile | null => { { name: 'ANTHROPIC_SMALL_FAST_MODEL', value: '${DEEPSEEK_SMALL_FAST_MODEL:-deepseek-chat}' }, { name: 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC', value: '${DEEPSEEK_CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC:-1}' }, ], - defaultPermissionMode: 'default', + defaultPermissionModeByAgent: { claude: 'default' }, compatibility: { claude: true, codex: false, gemini: false }, isBuiltIn: true, createdAt: Date.now(), @@ -282,11 +439,11 @@ export const getBuiltInProfile = (id: string): AIBackendProfile | null => { // Model mappings: Z_AI_OPUS_MODEL=GLM-4.6, Z_AI_SONNET_MODEL=GLM-4.6, Z_AI_HAIKU_MODEL=GLM-4.5-Air // Uses ${VAR:-default} format for fallback values (bash parameter expansion) // Secrets use ${VAR} without fallback for security - // NOTE: anthropicConfig left empty so environmentVariables aren't overridden + // NOTE: Profiles are env-var based; environmentVariables are the single source of truth. return { id: 'zai', name: 'Z.AI (GLM-4.6)', - anthropicConfig: {}, + envVarRequirements: [{ name: 'Z_AI_AUTH_TOKEN', kind: 'secret', required: true }], environmentVariables: [ { name: 'ANTHROPIC_BASE_URL', value: '${Z_AI_BASE_URL:-https://api.z.ai/api/anthropic}' }, { name: 'ANTHROPIC_AUTH_TOKEN', value: '${Z_AI_AUTH_TOKEN}' }, // Secret - no fallback @@ -296,18 +453,33 @@ export const getBuiltInProfile = (id: string): AIBackendProfile | null => { { name: 'ANTHROPIC_DEFAULT_SONNET_MODEL', value: '${Z_AI_SONNET_MODEL:-GLM-4.6}' }, { name: 'ANTHROPIC_DEFAULT_HAIKU_MODEL', value: '${Z_AI_HAIKU_MODEL:-GLM-4.5-Air}' }, ], - defaultPermissionMode: 'default', + defaultPermissionModeByAgent: { claude: 'default' }, compatibility: { claude: true, codex: false, gemini: false }, isBuiltIn: true, createdAt: Date.now(), updatedAt: Date.now(), version: '1.0.0', }; + case 'codex': + return { + id: 'codex', + name: 'Codex (Default)', + authMode: 'machineLogin', + requiresMachineLogin: getAgentCore('codex').cli.machineLoginKey, + environmentVariables: [], + defaultPermissionModeByAgent: { codex: 'default' }, + compatibility: { claude: false, codex: true, gemini: false }, + envVarRequirements: [], + isBuiltIn: true, + createdAt: Date.now(), + updatedAt: Date.now(), + version: '1.0.0', + }; case 'openai': return { id: 'openai', name: 'OpenAI (GPT-5)', - openaiConfig: {}, + envVarRequirements: [{ name: 'OPENAI_API_KEY', kind: 'secret', required: true }], environmentVariables: [ { name: 'OPENAI_BASE_URL', value: 'https://api.openai.com/v1' }, { name: 'OPENAI_MODEL', value: 'gpt-5-codex-high' }, @@ -316,6 +488,7 @@ export const getBuiltInProfile = (id: string): AIBackendProfile | null => { { name: 'API_TIMEOUT_MS', value: '600000' }, { name: 'CODEX_SMALL_FAST_MODEL', value: 'gpt-5-codex-low' }, ], + defaultPermissionModeByAgent: { codex: 'default' }, compatibility: { claude: false, codex: true, gemini: false }, isBuiltIn: true, createdAt: Date.now(), @@ -326,19 +499,66 @@ export const getBuiltInProfile = (id: string): AIBackendProfile | null => { return { id: 'azure-openai', name: 'Azure OpenAI', - azureOpenAIConfig: {}, + envVarRequirements: [{ name: 'AZURE_OPENAI_API_KEY', kind: 'secret', required: true }], environmentVariables: [ { name: 'AZURE_OPENAI_API_VERSION', value: '2024-02-15-preview' }, - { name: 'AZURE_OPENAI_DEPLOYMENT_NAME', value: 'gpt-5-codex' }, { name: 'OPENAI_API_TIMEOUT_MS', value: '600000' }, { name: 'API_TIMEOUT_MS', value: '600000' }, ], + defaultPermissionModeByAgent: { codex: 'default' }, compatibility: { claude: false, codex: true, gemini: false }, isBuiltIn: true, createdAt: Date.now(), updatedAt: Date.now(), version: '1.0.0', }; + case 'gemini': + return { + id: 'gemini', + name: 'Gemini (Default)', + authMode: 'machineLogin', + requiresMachineLogin: getAgentCore('gemini').cli.machineLoginKey, + environmentVariables: [], + defaultPermissionModeByAgent: { gemini: 'default' }, + compatibility: { claude: false, codex: false, gemini: true }, + envVarRequirements: [], + isBuiltIn: true, + createdAt: Date.now(), + updatedAt: Date.now(), + version: '1.0.0', + }; + case 'gemini-api-key': + return { + id: 'gemini-api-key', + name: 'Gemini (API key)', + envVarRequirements: [{ name: 'GEMINI_API_KEY', kind: 'secret', required: true }], + environmentVariables: [{ name: 'GEMINI_MODEL', value: 'gemini-2.5-pro' }], + defaultPermissionModeByAgent: { gemini: 'default' }, + compatibility: { claude: false, codex: false, gemini: true }, + isBuiltIn: true, + createdAt: Date.now(), + updatedAt: Date.now(), + version: '1.0.0', + }; + case 'gemini-vertex': + return { + id: 'gemini-vertex', + name: 'Gemini (Vertex AI)', + envVarRequirements: [ + { name: 'GOOGLE_CLOUD_PROJECT', kind: 'config', required: true }, + { name: 'GOOGLE_CLOUD_LOCATION', kind: 'config', required: true }, + ], + environmentVariables: [ + { name: 'GOOGLE_GENAI_USE_VERTEXAI', value: '1' }, + { name: 'GEMINI_MODEL', value: 'gemini-2.5-pro' }, + ], + defaultPermissionModeByAgent: { gemini: 'default' }, + compatibility: { claude: false, codex: false, gemini: true }, + isBuiltIn: true, + createdAt: Date.now(), + updatedAt: Date.now(), + version: '1.0.0', + }; default: return null; } @@ -364,6 +584,11 @@ export const DEFAULT_PROFILES = [ name: 'Z.AI (GLM-4.6)', isBuiltIn: true, }, + { + id: 'codex', + name: 'Codex (Default)', + isBuiltIn: true, + }, { id: 'openai', name: 'OpenAI (GPT-5)', @@ -373,5 +598,20 @@ export const DEFAULT_PROFILES = [ id: 'azure-openai', name: 'Azure OpenAI', isBuiltIn: true, - } + }, + { + id: 'gemini', + name: 'Gemini (Default)', + isBuiltIn: true, + }, + { + id: 'gemini-api-key', + name: 'Gemini (API key)', + isBuiltIn: true, + }, + { + id: 'gemini-vertex', + name: 'Gemini (Vertex AI)', + isBuiltIn: true, + }, ]; diff --git a/expo-app/sources/sync/readStateV1.test.ts b/expo-app/sources/sync/readStateV1.test.ts new file mode 100644 index 000000000..71728d08d --- /dev/null +++ b/expo-app/sources/sync/readStateV1.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; + +import { computeNextReadStateV1 } from './readStateV1'; + +describe('computeNextReadStateV1', () => { + it('does not change state when existing marker already covers current activity', () => { + expect(computeNextReadStateV1({ + prev: { v: 1, sessionSeq: 10, pendingActivityAt: 20, updatedAt: 100 }, + sessionSeq: 10, + pendingActivityAt: 20, + now: 200, + })).toEqual({ + didChange: false, + next: { v: 1, sessionSeq: 10, pendingActivityAt: 20, updatedAt: 100 }, + }); + }); + + it('advances markers when activity increases', () => { + expect(computeNextReadStateV1({ + prev: { v: 1, sessionSeq: 10, pendingActivityAt: 20, updatedAt: 100 }, + sessionSeq: 11, + pendingActivityAt: 25, + now: 200, + })).toEqual({ + didChange: true, + next: { v: 1, sessionSeq: 11, pendingActivityAt: 25, updatedAt: 200 }, + }); + }); + + it('repairs invalid markers when previous sessionSeq exceeds current sessionSeq', () => { + expect(computeNextReadStateV1({ + prev: { v: 1, sessionSeq: 50_000, pendingActivityAt: 20, updatedAt: 100 }, + sessionSeq: 11, + pendingActivityAt: 20, + now: 200, + })).toEqual({ + didChange: true, + next: { v: 1, sessionSeq: 11, pendingActivityAt: 20, updatedAt: 200 }, + }); + }); +}); + diff --git a/expo-app/sources/sync/readStateV1.ts b/expo-app/sources/sync/readStateV1.ts new file mode 100644 index 000000000..b4ec6267b --- /dev/null +++ b/expo-app/sources/sync/readStateV1.ts @@ -0,0 +1,46 @@ +export type ReadStateV1 = { + v: 1; + sessionSeq: number; + pendingActivityAt: number; + updatedAt: number; +}; + +export function computeNextReadStateV1(params: { + prev: ReadStateV1 | undefined; + sessionSeq: number; + pendingActivityAt: number; + now: number; +}): { didChange: boolean; next: ReadStateV1 } { + const sessionSeq = params.sessionSeq ?? 0; + const pendingActivityAt = params.pendingActivityAt ?? 0; + + const prev = params.prev; + if (!prev) { + return { + didChange: true, + next: { v: 1, sessionSeq, pendingActivityAt, updatedAt: params.now }, + }; + } + + const needsSeqRepair = prev.sessionSeq > sessionSeq; + const nextSessionSeq = needsSeqRepair + ? sessionSeq + : Math.max(prev.sessionSeq, sessionSeq); + + const nextPendingActivityAt = Math.max(prev.pendingActivityAt, pendingActivityAt); + + if (!needsSeqRepair && nextSessionSeq === prev.sessionSeq && nextPendingActivityAt === prev.pendingActivityAt) { + return { didChange: false, next: prev }; + } + + return { + didChange: true, + next: { + v: 1, + sessionSeq: nextSessionSeq, + pendingActivityAt: nextPendingActivityAt, + updatedAt: params.now, + }, + }; +} + diff --git a/expo-app/sources/sync/realtimeSessionSeq.test.ts b/expo-app/sources/sync/realtimeSessionSeq.test.ts new file mode 100644 index 000000000..f1c5063bc --- /dev/null +++ b/expo-app/sources/sync/realtimeSessionSeq.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; + +import { computeNextSessionSeqFromUpdate } from './realtimeSessionSeq'; + +describe('computeNextSessionSeqFromUpdate', () => { + it('keeps the session seq unchanged for update-session updates', () => { + expect(computeNextSessionSeqFromUpdate({ + currentSessionSeq: 10, + updateType: 'update-session', + containerSeq: 9_999, + messageSeq: 123, + })).toBe(10); + }); + + it('uses the message seq (not the container seq) for new-message updates', () => { + expect(computeNextSessionSeqFromUpdate({ + currentSessionSeq: 10, + updateType: 'new-message', + containerSeq: 9_999, + messageSeq: 11, + })).toBe(11); + }); + + it('never decreases the session seq', () => { + expect(computeNextSessionSeqFromUpdate({ + currentSessionSeq: 10, + updateType: 'new-message', + containerSeq: 9_999, + messageSeq: 9, + })).toBe(10); + }); +}); + diff --git a/expo-app/sources/sync/realtimeSessionSeq.ts b/expo-app/sources/sync/realtimeSessionSeq.ts new file mode 100644 index 000000000..cf767dd64 --- /dev/null +++ b/expo-app/sources/sync/realtimeSessionSeq.ts @@ -0,0 +1,21 @@ +type UpdateType = 'new-message' | 'update-session'; + +export function computeNextSessionSeqFromUpdate(params: { + currentSessionSeq: number; + updateType: UpdateType; + containerSeq: number; + messageSeq: number | undefined; +}): number { + const { currentSessionSeq, updateType, containerSeq: _containerSeq, messageSeq } = params; + + if (updateType === 'update-session') { + return currentSessionSeq; + } + + const candidate = messageSeq; + if (typeof candidate !== 'number') { + return currentSessionSeq; + } + + return Math.max(currentSessionSeq, candidate); +} diff --git a/expo-app/sources/sync/reducer/helpers/arrays.ts b/expo-app/sources/sync/reducer/helpers/arrays.ts new file mode 100644 index 000000000..3ff4f95a9 --- /dev/null +++ b/expo-app/sources/sync/reducer/helpers/arrays.ts @@ -0,0 +1,20 @@ +export function isEmptyArray(v: unknown): v is [] { + return Array.isArray(v) && v.length === 0; +} + +export function equalOptionalStringArrays(a: unknown, b: unknown): boolean { + // Treat `undefined` / `null` / `[]` as equivalent “empty”. + if (a == null || isEmptyArray(a)) { + return b == null || isEmptyArray(b); + } + if (b == null || isEmptyArray(b)) { + return a == null || isEmptyArray(a); + } + if (!Array.isArray(a) || !Array.isArray(b)) return false; + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) return false; + } + return true; +} + diff --git a/expo-app/sources/sync/reducer/helpers/streamingToolResult.ts b/expo-app/sources/sync/reducer/helpers/streamingToolResult.ts new file mode 100644 index 000000000..6247a6c4a --- /dev/null +++ b/expo-app/sources/sync/reducer/helpers/streamingToolResult.ts @@ -0,0 +1,47 @@ +export function coerceStreamingToolResultChunk( + value: unknown +): { stdoutChunk?: string; stderrChunk?: string } | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const obj = value as Record<string, unknown>; + const streamFlag = obj._stream === true; + const stdoutChunk = typeof obj.stdoutChunk === 'string' ? obj.stdoutChunk : undefined; + const stderrChunk = typeof obj.stderrChunk === 'string' ? obj.stderrChunk : undefined; + if (!streamFlag && !stdoutChunk && !stderrChunk) return null; + if (!stdoutChunk && !stderrChunk) return null; + return { stdoutChunk, stderrChunk }; +} + +export function mergeStreamingChunkIntoResult( + existing: unknown, + chunk: { stdoutChunk?: string; stderrChunk?: string } +): Record<string, unknown> { + const base: Record<string, unknown> = + existing && typeof existing === 'object' && !Array.isArray(existing) + ? { ...(existing as Record<string, unknown>) } + : {}; + if (typeof chunk.stdoutChunk === 'string') { + const prev = typeof base.stdout === 'string' ? base.stdout : ''; + base.stdout = prev + chunk.stdoutChunk; + } + if (typeof chunk.stderrChunk === 'string') { + const prev = typeof base.stderr === 'string' ? base.stderr : ''; + base.stderr = prev + chunk.stderrChunk; + } + return base; +} + +export function mergeExistingStdStreamsIntoFinalResultIfMissing( + existing: unknown, + next: unknown +): unknown { + if (!existing || typeof existing !== 'object' || Array.isArray(existing)) return next; + if (!next || typeof next !== 'object' || Array.isArray(next)) return next; + + const prev = existing as Record<string, unknown>; + const out = { ...(next as Record<string, unknown>) }; + + if (typeof out.stdout !== 'string' && typeof prev.stdout === 'string') out.stdout = prev.stdout; + if (typeof out.stderr !== 'string' && typeof prev.stderr === 'string') out.stderr = prev.stderr; + return out; +} + diff --git a/expo-app/sources/sync/reducer/helpers/thinkingText.ts b/expo-app/sources/sync/reducer/helpers/thinkingText.ts new file mode 100644 index 000000000..32d38c212 --- /dev/null +++ b/expo-app/sources/sync/reducer/helpers/thinkingText.ts @@ -0,0 +1,19 @@ +export function normalizeThinkingChunk(chunk: string): string { + const match = chunk.match(/^\*\*[^*]+\*\*\n([\s\S]*)$/); + const body = match ? match[1] : chunk; + // Some ACP providers stream thinking as word-per-line deltas (often `"\n"`-terminated). + // Preserve paragraph breaks, but collapse single newlines into spaces for readability. + return body + .replace(/\r\n/g, '\n') + .replace(/\n+/g, (m) => (m.length >= 2 ? '\n\n' : ' ')); +} + +export function unwrapThinkingText(text: string): string { + const match = text.match(/^\*Thinking\.\.\.\*\n\n\*([\s\S]*)\*$/); + return match ? match[1] : text; +} + +export function wrapThinkingText(body: string): string { + return `*Thinking...*\n\n*${body}*`; +} + diff --git a/expo-app/sources/sync/reducer/phase0-skipping.spec.ts b/expo-app/sources/sync/reducer/phase0-skipping.spec.ts index 5e005ab59..198db9e02 100644 --- a/expo-app/sources/sync/reducer/phase0-skipping.spec.ts +++ b/expo-app/sources/sync/reducer/phase0-skipping.spec.ts @@ -93,12 +93,6 @@ describe('Phase 0 permission skipping issue', () => { // Process messages and AgentState together (simulates opening chat) const result = reducer(state, toolMessages, agentState); - // Log what happened (for debugging) - console.log('Result messages:', result.messages.length); - console.log('Permission mappings:', { - toolIdToMessageId: Array.from(state.toolIdToMessageId.entries()) - }); - // Find the tool messages in the result const webFetchTool = result.messages.find(m => m.kind === 'tool-call' && m.tool?.name === 'WebFetch'); const writeTool = result.messages.find(m => m.kind === 'tool-call' && m.tool?.name === 'Write'); @@ -203,4 +197,65 @@ describe('Phase 0 permission skipping issue', () => { expect(toolAfterPermission?.tool?.permission?.id).toBe('tool1'); expect(toolAfterPermission?.tool?.permission?.status).toBe('approved'); }); -}); \ No newline at end of file + + it('should not skip a newer pending request when a completed request with the same id exists', () => { + const state = createReducer(); + + const agentState: AgentState = { + requests: { + // Newer pending request (e.g. agent re-prompts with same id) + 'perm1': { + tool: 'execute', + arguments: { command: ['bash', '-lc', 'echo hello'] }, + createdAt: 2000 + } + }, + completedRequests: { + // Older completed entry for the same id + 'perm1': { + tool: 'execute', + arguments: { command: ['bash', '-lc', 'echo hello'] }, + status: 'approved', + createdAt: 900, + completedAt: 1500 + } + } + }; + + const result = reducer(state, [], agentState); + + const tool = result.messages.find(m => m.kind === 'tool-call' && m.tool?.permission?.id === 'perm1'); + expect(tool).toBeDefined(); + expect(tool?.kind).toBe('tool-call'); + if (tool?.kind === 'tool-call') { + // New pending should take precedence over older completed + expect(tool.tool?.permission?.status).toBe('pending'); + expect(tool.tool?.state).toBe('running'); + } + }); + + it('supports allowTools as a legacy alias for allowedTools in completed requests', () => { + const state = createReducer(); + + const agentState: AgentState = { + requests: {}, + completedRequests: { + 'tool1': { + tool: 'Bash', + arguments: { command: 'echo hello' }, + status: 'approved', + createdAt: 900, + completedAt: 950, + allowTools: ['Bash(echo hello)'] + } as any + } + }; + + reducer(state, [], agentState); + const msg = Array.from(state.messages.values()).find(m => m.tool?.permission?.id === 'tool1'); + expect(msg).toBeDefined(); + expect(msg?.tool?.permission?.status).toBe('approved'); + // Should have been mapped into permission.allowedTools for UI code paths. + expect((msg?.tool?.permission as any)?.allowedTools).toEqual(['Bash(echo hello)']); + }); +}); diff --git a/expo-app/sources/sync/reducer/phases/agentStatePermissions.ts b/expo-app/sources/sync/reducer/phases/agentStatePermissions.ts new file mode 100644 index 000000000..3b6a133f7 --- /dev/null +++ b/expo-app/sources/sync/reducer/phases/agentStatePermissions.ts @@ -0,0 +1,302 @@ +import { compareToolCalls } from '../../../utils/toolComparison'; +import type { AgentState } from '../../storageTypes'; +import type { ToolCall } from '../../typesMessage'; +import { equalOptionalStringArrays } from '../helpers/arrays'; +import type { ReducerState } from '../reducer'; + +export function runAgentStatePermissionsPhase(params: Readonly<{ + state: ReducerState; + agentState?: AgentState | null; + incomingToolIds: Set<string>; + changed: Set<string>; + allocateId: () => string; + enableLogging: boolean; +}>): void { + const { state, agentState, incomingToolIds, changed, allocateId, enableLogging } = params; + + // + // Phase 0: Process AgentState permissions + // + + const getCompletedAllowedTools = (completed: any): string[] | undefined => { + const list = completed?.allowedTools ?? completed?.allowTools; + return Array.isArray(list) ? list : undefined; + }; + + if (enableLogging) { + console.log(`[REDUCER] Phase 0: Processing AgentState`); + } + if (agentState) { + // Track permission ids where a newer pending request should override an older completed entry. + const pendingOverridesCompleted = new Set<string>(); + + // Process pending permission requests + if (agentState.requests) { + for (const [permId, request] of Object.entries(agentState.requests)) { + // If this permission is also in completedRequests, prefer the newer one by timestamp. + // Some agents can re-prompt with the same permission id (same toolCallId) even after + // a previous approval was recorded; in that case we must surface the new pending request. + const existingCompleted = agentState.completedRequests?.[permId]; + if (existingCompleted) { + const pendingCreatedAt = request.createdAt ?? 0; + const completedAt = existingCompleted.completedAt ?? existingCompleted.createdAt ?? 0; + const isNewerPending = pendingCreatedAt > completedAt; + if (!isNewerPending) { + continue; + } + pendingOverridesCompleted.add(permId); + } + + // Check if we already have a message for this permission ID + const existingMessageId = state.toolIdToMessageId.get(permId); + if (existingMessageId) { + // Update existing tool message with permission info and latest arguments + const message = state.messages.get(existingMessageId); + if (message?.tool) { + if (enableLogging) { + console.log(`[REDUCER] Updating existing tool ${permId} with permission`); + } + let hasChanged = false; + + // Update input only when it actually changed (keeps reducer idempotent). + // This still allows late-arriving fields (e.g. proposedExecpolicyAmendment) + // to update the existing permission message. + const inputUnchanged = compareToolCalls( + { name: request.tool, arguments: message.tool.input }, + { name: request.tool, arguments: request.arguments } + ); + if (!inputUnchanged) { + message.tool.input = request.arguments; + hasChanged = true; + } + if (!message.tool.permission) { + message.tool.permission = { + id: permId, + status: 'pending' + }; + hasChanged = true; + } + if (hasChanged) { + changed.add(existingMessageId); + } + } + } else { + if (enableLogging) { + console.log(`[REDUCER] Creating new message for permission ${permId}`); + } + + // Create a new tool message for the permission request + let mid = allocateId(); + let toolCall: ToolCall = { + name: request.tool, + state: 'running' as const, + input: request.arguments, + createdAt: request.createdAt || Date.now(), + startedAt: null, + completedAt: null, + description: null, + result: undefined, + permission: { + id: permId, + status: 'pending' + } + }; + + state.messages.set(mid, { + id: mid, + realID: null, + role: 'agent', + createdAt: request.createdAt || Date.now(), + text: null, + tool: toolCall, + event: null, + }); + + // Store by permission ID (which will match tool ID) + state.toolIdToMessageId.set(permId, mid); + + changed.add(mid); + } + + // Store permission details for quick lookup + state.permissions.set(permId, { + tool: request.tool, + arguments: request.arguments, + createdAt: request.createdAt || Date.now(), + status: 'pending' + }); + } + } + + // Process completed permission requests + if (agentState.completedRequests) { + for (const [permId, completed] of Object.entries(agentState.completedRequests)) { + // If we have a newer pending request for this id, do not let the older completed entry win. + if (pendingOverridesCompleted.has(permId)) { + continue; + } + // Check if we have a message for this permission ID + const messageId = state.toolIdToMessageId.get(permId); + if (messageId) { + const message = state.messages.get(messageId); + if (message?.tool) { + // Skip if tool has already started actual execution with approval + if (message.tool.startedAt && message.tool.permission?.status === 'approved') { + continue; + } + + // Skip if permission already has date (came from tool result - preferred over agentState) + if (message.tool.permission?.date) { + continue; + } + + // Check if we need to update ANY field + const needsUpdate = + message.tool.permission?.status !== completed.status || + message.tool.permission?.reason !== completed.reason || + message.tool.permission?.mode !== completed.mode || + !equalOptionalStringArrays(message.tool.permission?.allowedTools, getCompletedAllowedTools(completed)) || + message.tool.permission?.decision !== completed.decision; + + if (!needsUpdate) { + continue; + } + + let hasChanged = false; + + // Update permission status + if (!message.tool.permission) { + message.tool.permission = { + id: permId, + status: completed.status, + mode: completed.mode || undefined, + allowedTools: getCompletedAllowedTools(completed), + decision: completed.decision || undefined, + reason: completed.reason || undefined + }; + hasChanged = true; + } else { + // Update all fields + message.tool.permission.status = completed.status; + message.tool.permission.mode = completed.mode || undefined; + message.tool.permission.allowedTools = getCompletedAllowedTools(completed); + message.tool.permission.decision = completed.decision || undefined; + if (completed.reason) { + message.tool.permission.reason = completed.reason; + } + hasChanged = true; + } + + // Update tool state based on permission status + if (completed.status === 'approved') { + if (message.tool.state !== 'completed' && message.tool.state !== 'error' && message.tool.state !== 'running') { + message.tool.state = 'running'; + hasChanged = true; + } + } else { + // denied or canceled + if (message.tool.state !== 'error' && message.tool.state !== 'completed') { + message.tool.state = 'error'; + message.tool.completedAt = completed.completedAt || Date.now(); + if (!message.tool.result && completed.reason) { + message.tool.result = { error: completed.reason }; + } + hasChanged = true; + } + } + + // Update stored permission + state.permissions.set(permId, { + tool: completed.tool, + arguments: completed.arguments, + createdAt: completed.createdAt || Date.now(), + completedAt: completed.completedAt || undefined, + status: completed.status, + reason: completed.reason || undefined, + mode: completed.mode || undefined, + allowedTools: getCompletedAllowedTools(completed), + decision: completed.decision || undefined + }); + + if (hasChanged) { + changed.add(messageId); + } + } + } else { + // No existing message - check if tool ID is in incoming messages + if (incomingToolIds.has(permId)) { + if (enableLogging) { + console.log(`[REDUCER] Storing permission ${permId} for incoming tool`); + } + // Store permission for when tool arrives in Phase 2 + state.permissions.set(permId, { + tool: completed.tool, + arguments: completed.arguments, + createdAt: completed.createdAt || Date.now(), + completedAt: completed.completedAt || undefined, + status: completed.status, + reason: completed.reason || undefined + }); + continue; + } + + // Skip if already processed as pending + if (agentState.requests && agentState.requests[permId]) { + continue; + } + + // Create a new message for completed permission without tool + let mid = allocateId(); + let toolCall: ToolCall = { + name: completed.tool, + state: completed.status === 'approved' ? 'completed' : 'error', + input: completed.arguments, + createdAt: completed.createdAt || Date.now(), + startedAt: null, + completedAt: completed.completedAt || Date.now(), + description: null, + result: completed.status === 'approved' + ? 'Approved' + : (completed.reason ? { error: completed.reason } : undefined), + permission: { + id: permId, + status: completed.status, + reason: completed.reason || undefined, + mode: completed.mode || undefined, + allowedTools: getCompletedAllowedTools(completed), + decision: completed.decision || undefined + } + }; + + state.messages.set(mid, { + id: mid, + realID: null, + role: 'agent', + createdAt: completed.createdAt || Date.now(), + text: null, + tool: toolCall, + event: null, + }); + + state.toolIdToMessageId.set(permId, mid); + + // Store permission details + state.permissions.set(permId, { + tool: completed.tool, + arguments: completed.arguments, + createdAt: completed.createdAt || Date.now(), + completedAt: completed.completedAt || undefined, + status: completed.status, + reason: completed.reason || undefined, + mode: completed.mode || undefined, + allowedTools: getCompletedAllowedTools(completed), + decision: completed.decision || undefined + }); + + changed.add(mid); + } + } + } + } +} + diff --git a/expo-app/sources/sync/reducer/phases/messageToEventConversion.ts b/expo-app/sources/sync/reducer/phases/messageToEventConversion.ts new file mode 100644 index 000000000..cd9ef84b4 --- /dev/null +++ b/expo-app/sources/sync/reducer/phases/messageToEventConversion.ts @@ -0,0 +1,142 @@ +import type { AgentEvent, NormalizedMessage } from '../../typesRaw'; +import type { ReducerState } from '../reducer'; +import { parseMessageAsEvent } from '../messageToEvent'; + +export function runMessageToEventConversion({ + state, + nonSidechainMessages, + changed, + allocateId, + enableLogging, +}: { + state: ReducerState; + nonSidechainMessages: NormalizedMessage[]; + changed: Set<string>; + allocateId: () => string; + enableLogging: boolean; +}): { + nonSidechainMessages: NormalizedMessage[]; + incomingToolIds: Set<string>; + hasReadyEvent: boolean; +} { + // + // Phase 0.5: Message-to-Event Conversion + // Convert certain messages to events before normal processing + // + + if (enableLogging) { + console.log(`[REDUCER] Phase 0.5: Message-to-Event Conversion`); + } + + const messagesToProcess: NormalizedMessage[] = []; + const convertedEvents: { message: NormalizedMessage; event: AgentEvent }[] = []; + let hasReadyEvent = false; + + for (const msg of nonSidechainMessages) { + // Check if we've already processed this message + if (msg.role === 'user' && msg.localId && state.localIds.has(msg.localId)) { + continue; + } + if (state.messageIds.has(msg.id)) { + continue; + } + + // Filter out ready events completely - they should not create any message + if (msg.role === 'event' && msg.content.type === 'ready') { + // Mark as processed to prevent duplication but don't add to messages + state.messageIds.set(msg.id, msg.id); + hasReadyEvent = true; + continue; + } + + // Handle context reset events - reset state and let the message be shown + if ( + msg.role === 'event' && + msg.content.type === 'message' && + msg.content.message === 'Context was reset' + ) { + // Reset todos to empty array and reset usage to zero + state.latestTodos = { + todos: [], + timestamp: msg.createdAt, // Use message timestamp, not current time + }; + state.latestUsage = { + inputTokens: 0, + outputTokens: 0, + cacheCreation: 0, + cacheRead: 0, + contextSize: 0, + timestamp: msg.createdAt, // Use message timestamp to avoid blocking older usage data + }; + // Don't continue - let the event be processed normally to create a message + } + + // Handle compaction completed events - reset context but keep todos + if ( + msg.role === 'event' && + msg.content.type === 'message' && + msg.content.message === 'Compaction completed' + ) { + // Reset usage/context to zero but keep todos unchanged + state.latestUsage = { + inputTokens: 0, + outputTokens: 0, + cacheCreation: 0, + cacheRead: 0, + contextSize: 0, + timestamp: msg.createdAt, // Use message timestamp to avoid blocking older usage data + }; + // Don't continue - let the event be processed normally to create a message + } + + // Try to parse message as event + const event = parseMessageAsEvent(msg); + if (event) { + if (enableLogging) { + console.log(`[REDUCER] Converting message ${msg.id} to event:`, event); + } + convertedEvents.push({ message: msg, event }); + // Mark as processed to prevent duplication + state.messageIds.set(msg.id, msg.id); + if (msg.role === 'user' && msg.localId) { + state.localIds.set(msg.localId, msg.id); + } + } else { + messagesToProcess.push(msg); + } + } + + // Process converted events immediately + for (const { message, event } of convertedEvents) { + const mid = allocateId(); + state.messages.set(mid, { + id: mid, + realID: message.id, + role: 'agent', + createdAt: message.createdAt, + event: event, + tool: null, + text: null, + meta: message.meta, + }); + changed.add(mid); + } + + // Update nonSidechainMessages to only include messages that weren't converted + nonSidechainMessages = messagesToProcess; + + // Build a set of incoming tool IDs for quick lookup + const incomingToolIds = new Set<string>(); + for (const msg of nonSidechainMessages) { + if (msg.role === 'agent') { + for (const c of msg.content) { + if (c.type === 'tool-call') { + incomingToolIds.add(c.id); + } + } + } + } + + return { nonSidechainMessages, incomingToolIds, hasReadyEvent }; +} + diff --git a/expo-app/sources/sync/reducer/phases/modeSwitchEvents.ts b/expo-app/sources/sync/reducer/phases/modeSwitchEvents.ts new file mode 100644 index 000000000..06c5819ff --- /dev/null +++ b/expo-app/sources/sync/reducer/phases/modeSwitchEvents.ts @@ -0,0 +1,33 @@ +import type { TracedMessage } from '../reducerTracer'; +import type { ReducerState } from '../reducer'; + +export function runModeSwitchEventsPhase(params: Readonly<{ + state: ReducerState; + nonSidechainMessages: TracedMessage[]; + changed: Set<string>; + allocateId: () => string; +}>): void { + const { state, nonSidechainMessages, changed, allocateId } = params; + + // + // Phase 5: Process mode-switch messages + // + + for (let msg of nonSidechainMessages) { + if (msg.role === 'event') { + let mid = allocateId(); + state.messages.set(mid, { + id: mid, + realID: msg.id, + role: 'agent', + createdAt: msg.createdAt, + event: msg.content, + tool: null, + text: null, + meta: msg.meta, + }); + changed.add(mid); + } + } +} + diff --git a/expo-app/sources/sync/reducer/phases/sidechains.ts b/expo-app/sources/sync/reducer/phases/sidechains.ts new file mode 100644 index 000000000..f9943bd87 --- /dev/null +++ b/expo-app/sources/sync/reducer/phases/sidechains.ts @@ -0,0 +1,246 @@ +import type { ToolCall } from '../../typesMessage'; +import type { TracedMessage } from '../reducerTracer'; +import type { ReducerMessage, ReducerState } from '../reducer'; +import { coerceStreamingToolResultChunk, mergeExistingStdStreamsIntoFinalResultIfMissing, mergeStreamingChunkIntoResult } from '../helpers/streamingToolResult'; +import { normalizeThinkingChunk, unwrapThinkingText, wrapThinkingText } from '../helpers/thinkingText'; + +export function runSidechainsPhase(params: Readonly<{ + state: ReducerState; + sidechainMessages: TracedMessage[]; + changed: Set<string>; + allocateId: () => string; +}>): void { + const { state, sidechainMessages, changed, allocateId } = params; + + // + // Phase 4: Process sidechains and store them in state + // + + // For each sidechain message, store it in the state and mark the Task as changed + for (const msg of sidechainMessages) { + if (!msg.sidechainId) continue; + + // Skip if we already processed this message + if (state.messageIds.has(msg.id)) continue; + + // Mark as processed + state.messageIds.set(msg.id, msg.id); + + // Get or create the sidechain array for this Task + const existingSidechain = state.sidechains.get(msg.sidechainId) || []; + + // Process and add new sidechain messages + if (msg.role === 'agent' && msg.content[0]?.type === 'sidechain') { + // This is the sidechain root - create a user message + let mid = allocateId(); + let userMsg: ReducerMessage = { + id: mid, + realID: msg.id, + role: 'user', + createdAt: msg.createdAt, + text: msg.content[0].prompt, + tool: null, + event: null, + meta: msg.meta, + }; + state.messages.set(mid, userMsg); + existingSidechain.push(userMsg); + } else if (msg.role === 'agent') { + // Process agent content in sidechain + for (let c of msg.content) { + if (c.type === 'text') { + let mid = allocateId(); + let textMsg: ReducerMessage = { + id: mid, + realID: msg.id, + role: 'agent', + createdAt: msg.createdAt, + text: c.text, + isThinking: false, + tool: null, + event: null, + meta: msg.meta, + }; + state.messages.set(mid, textMsg); + existingSidechain.push(textMsg); + } else if (c.type === 'thinking') { + const chunk = typeof c.thinking === 'string' ? normalizeThinkingChunk(c.thinking) : ''; + if (!chunk.trim()) { + continue; + } + + const last = existingSidechain[existingSidechain.length - 1]; + if (last && last.role === 'agent' && last.isThinking && typeof last.text === 'string') { + const merged = unwrapThinkingText(last.text) + chunk; + last.text = wrapThinkingText(merged); + changed.add(last.id); + } else { + let mid = allocateId(); + let textMsg: ReducerMessage = { + id: mid, + realID: msg.id, + role: 'agent', + createdAt: msg.createdAt, + text: wrapThinkingText(chunk), + isThinking: true, + tool: null, + event: null, + meta: msg.meta, + }; + state.messages.set(mid, textMsg); + existingSidechain.push(textMsg); + } + } else if (c.type === 'tool-call') { + // Check if there's already a permission message for this tool + const existingPermissionMessageId = state.toolIdToMessageId.get(c.id); + + let mid = allocateId(); + let toolCall: ToolCall = { + name: c.name, + state: 'running' as const, + input: c.input, + createdAt: msg.createdAt, + startedAt: null, + completedAt: null, + description: c.description, + result: undefined + }; + + // If there's a permission message, copy its permission info + if (existingPermissionMessageId) { + const permissionMessage = state.messages.get(existingPermissionMessageId); + if (permissionMessage?.tool?.permission) { + toolCall.permission = { ...permissionMessage.tool.permission }; + // Update the permission message to show it's running + if (permissionMessage.tool.state !== 'completed' && permissionMessage.tool.state !== 'error') { + permissionMessage.tool.state = 'running'; + permissionMessage.tool.startedAt = msg.createdAt; + permissionMessage.tool.description = c.description; + changed.add(existingPermissionMessageId); + } + } + } + + let toolMsg: ReducerMessage = { + id: mid, + realID: msg.id, + role: 'agent', + createdAt: msg.createdAt, + text: null, + tool: toolCall, + event: null, + meta: msg.meta, + }; + state.messages.set(mid, toolMsg); + existingSidechain.push(toolMsg); + + // Map sidechain tool separately to avoid overwriting permission mapping + state.sidechainToolIdToMessageId.set(c.id, mid); + } else if (c.type === 'tool-result') { + // Process tool result in sidechain - update BOTH messages + + // Update the sidechain tool message + let sidechainMessageId = state.sidechainToolIdToMessageId.get(c.tool_use_id); + if (sidechainMessageId) { + let sidechainMessage = state.messages.get(sidechainMessageId); + if (sidechainMessage && sidechainMessage.tool && sidechainMessage.tool.state === 'running') { + const streamChunk = coerceStreamingToolResultChunk(c.content); + if (streamChunk) { + sidechainMessage.tool.result = mergeStreamingChunkIntoResult(sidechainMessage.tool.result, streamChunk); + } else { + sidechainMessage.tool.state = c.is_error ? 'error' : 'completed'; + sidechainMessage.tool.result = mergeExistingStdStreamsIntoFinalResultIfMissing(sidechainMessage.tool.result, c.content); + sidechainMessage.tool.completedAt = msg.createdAt; + } + + // Update permission data if provided by backend + if (c.permissions) { + // Merge with existing permission to preserve decision field from agentState + if (sidechainMessage.tool.permission) { + const existingDecision = sidechainMessage.tool.permission.decision; + sidechainMessage.tool.permission = { + ...sidechainMessage.tool.permission, + id: c.tool_use_id, + status: c.permissions.result === 'approved' ? 'approved' : 'denied', + date: c.permissions.date, + mode: c.permissions.mode, + allowedTools: c.permissions.allowedTools, + decision: c.permissions.decision || existingDecision + }; + } else { + sidechainMessage.tool.permission = { + id: c.tool_use_id, + status: c.permissions.result === 'approved' ? 'approved' : 'denied', + date: c.permissions.date, + mode: c.permissions.mode, + allowedTools: c.permissions.allowedTools, + decision: c.permissions.decision + }; + } + } + + changed.add(sidechainMessageId); + } + } + + // Also update the main permission message if it exists + let permissionMessageId = state.toolIdToMessageId.get(c.tool_use_id); + if (permissionMessageId) { + let permissionMessage = state.messages.get(permissionMessageId); + if (permissionMessage && permissionMessage.tool && permissionMessage.tool.state === 'running') { + const streamChunk = coerceStreamingToolResultChunk(c.content); + if (streamChunk) { + permissionMessage.tool.result = mergeStreamingChunkIntoResult(permissionMessage.tool.result, streamChunk); + } else { + permissionMessage.tool.state = c.is_error ? 'error' : 'completed'; + permissionMessage.tool.result = mergeExistingStdStreamsIntoFinalResultIfMissing(permissionMessage.tool.result, c.content); + permissionMessage.tool.completedAt = msg.createdAt; + } + + // Update permission data if provided by backend + if (c.permissions) { + // Merge with existing permission to preserve decision field from agentState + if (permissionMessage.tool.permission) { + const existingDecision = permissionMessage.tool.permission.decision; + permissionMessage.tool.permission = { + ...permissionMessage.tool.permission, + id: c.tool_use_id, + status: c.permissions.result === 'approved' ? 'approved' : 'denied', + date: c.permissions.date, + mode: c.permissions.mode, + allowedTools: c.permissions.allowedTools, + decision: c.permissions.decision || existingDecision + }; + } else { + permissionMessage.tool.permission = { + id: c.tool_use_id, + status: c.permissions.result === 'approved' ? 'approved' : 'denied', + date: c.permissions.date, + mode: c.permissions.mode, + allowedTools: c.permissions.allowedTools, + decision: c.permissions.decision + }; + } + } + + changed.add(permissionMessageId); + } + } + } + } + } + + // Update the sidechain in state + state.sidechains.set(msg.sidechainId, existingSidechain); + + // Find the Task tool message that owns this sidechain and mark it as changed + // msg.sidechainId is the realID of the Task message + for (const [internalId, message] of state.messages) { + if (message.realID === msg.sidechainId && message.tool) { + changed.add(internalId); + break; + } + } + } +} + diff --git a/expo-app/sources/sync/reducer/phases/toolCalls.ts b/expo-app/sources/sync/reducer/phases/toolCalls.ts new file mode 100644 index 000000000..6822ede13 --- /dev/null +++ b/expo-app/sources/sync/reducer/phases/toolCalls.ts @@ -0,0 +1,198 @@ +import type { TracedMessage } from '../reducerTracer'; +import type { ToolCall } from '../../typesMessage'; +import { compareToolCalls } from '../../../utils/toolComparison'; +import type { ReducerState } from '../reducer'; + +export function runToolCallsPhase(params: Readonly<{ + state: ReducerState; + nonSidechainMessages: TracedMessage[]; + changed: Set<string>; + allocateId: () => string; + enableLogging: boolean; + isPermissionRequestToolCall: (toolId: string, input: unknown) => boolean; +}>): void { + const { + state, + nonSidechainMessages, + changed, + allocateId, + enableLogging, + isPermissionRequestToolCall, + } = params; + + // + // Phase 2: Process non-sidechain tool calls + // + + if (enableLogging) { + console.log(`[REDUCER] Phase 2: Processing tool calls`); + } + for (let msg of nonSidechainMessages) { + if (msg.role === 'agent') { + for (let c of msg.content) { + if (c.type === 'tool-call') { + // Direct lookup by tool ID (since permission ID = tool ID now) + const existingMessageId = state.toolIdToMessageId.get(c.id); + + if (existingMessageId) { + if (enableLogging) { + console.log(`[REDUCER] Found existing message for tool ${c.id}`); + } + // Update existing message with tool execution details + const message = state.messages.get(existingMessageId); + if (message?.tool) { + message.realID = msg.id; + message.tool.description = c.description; + message.tool.startedAt = msg.createdAt; + + // Merge updated tool input (ACP providers can send late-arriving titles, locations, + // or rawInput in subsequent tool_call updates). + const incomingInput = c.input; + if (incomingInput !== undefined) { + const existingInput = message.tool.input; + const existingObj = existingInput && typeof existingInput === 'object' && !Array.isArray(existingInput) + ? (existingInput as Record<string, unknown>) + : null; + const incomingObj = incomingInput && typeof incomingInput === 'object' && !Array.isArray(incomingInput) + ? (incomingInput as Record<string, unknown>) + : null; + + const merged = + existingObj && incomingObj + ? (() => { + // Preserve existing fields (permission args are authoritative), but allow + // ACP metadata (_acp) to update over time. + const base = { ...incomingObj, ...existingObj }; + const existingAcp = existingObj._acp && typeof existingObj._acp === 'object' && !Array.isArray(existingObj._acp) + ? (existingObj._acp as Record<string, unknown>) + : null; + const incomingAcp = incomingObj._acp && typeof incomingObj._acp === 'object' && !Array.isArray(incomingObj._acp) + ? (incomingObj._acp as Record<string, unknown>) + : null; + if (incomingAcp) { + base._acp = { ...(existingAcp ?? {}), ...incomingAcp }; + } + return base; + })() + : incomingInput; + + const inputUnchanged = compareToolCalls( + { name: c.name, arguments: existingInput }, + { name: c.name, arguments: merged } + ); + if (!inputUnchanged) { + message.tool.input = merged; + } + } + + if (!message.tool.permission && isPermissionRequestToolCall(c.id, message.tool.input)) { + message.tool.permission = { id: c.id, status: 'pending' }; + message.tool.startedAt = null; + } + + // If permission was approved and shown as completed (no tool), now it's running + if (message.tool.permission?.status === 'approved' && message.tool.state === 'completed') { + message.tool.state = 'running'; + message.tool.completedAt = null; + message.tool.result = undefined; + } + changed.add(existingMessageId); + + // Track TodoWrite tool inputs when updating existing messages + if (message.tool.name === 'TodoWrite' && message.tool.state === 'running' && message.tool.input?.todos) { + // Only update if this is newer than existing todos + if (!state.latestTodos || message.tool.createdAt > state.latestTodos.timestamp) { + state.latestTodos = { + todos: message.tool.input.todos, + timestamp: message.tool.createdAt + }; + } + } + } + } else { + if (enableLogging) { + console.log(`[REDUCER] Creating new message for tool ${c.id}`); + } + // Check if there's a stored permission for this tool + const permission = state.permissions.get(c.id); + + let toolCall: ToolCall = { + name: c.name, + state: 'running' as const, + input: permission ? permission.arguments : c.input, // Use permission args if available + createdAt: permission ? permission.createdAt : msg.createdAt, // Use permission timestamp if available + startedAt: msg.createdAt, + completedAt: null, + description: c.description, + result: undefined, + }; + + // Add permission info if found + if (permission) { + if (enableLogging) { + console.log(`[REDUCER] Found stored permission for tool ${c.id}`); + } + toolCall.permission = { + id: c.id, + status: permission.status, + reason: permission.reason, + mode: permission.mode, + allowedTools: permission.allowedTools, + decision: permission.decision + }; + + // Update state based on permission status + if (permission.status !== 'approved') { + toolCall.state = 'error'; + toolCall.completedAt = permission.completedAt || msg.createdAt; + if (permission.reason) { + toolCall.result = { error: permission.reason }; + } + } + } + + // Some providers persist pending permission requests as tool-call messages (without AgentState). + // Treat those tool-call inputs as pending permissions so the UI can render approval controls. + if (!permission && isPermissionRequestToolCall(c.id, c.input)) { + toolCall.startedAt = null; + toolCall.permission = { id: c.id, status: 'pending' }; + state.permissions.set(c.id, { + tool: c.name, + arguments: c.input, + createdAt: msg.createdAt, + status: 'pending', + }); + } + + let mid = allocateId(); + state.messages.set(mid, { + id: mid, + realID: msg.id, + role: 'agent', + createdAt: msg.createdAt, + text: null, + tool: toolCall, + event: null, + meta: msg.meta, + }); + + state.toolIdToMessageId.set(c.id, mid); + changed.add(mid); + + // Track TodoWrite tool inputs + if (toolCall.name === 'TodoWrite' && toolCall.state === 'running' && toolCall.input?.todos) { + // Only update if this is newer than existing todos + if (!state.latestTodos || toolCall.createdAt > state.latestTodos.timestamp) { + state.latestTodos = { + todos: toolCall.input.todos, + timestamp: toolCall.createdAt + }; + } + } + } + } + } + } + } +} + diff --git a/expo-app/sources/sync/reducer/phases/toolResults.ts b/expo-app/sources/sync/reducer/phases/toolResults.ts new file mode 100644 index 000000000..126ed6591 --- /dev/null +++ b/expo-app/sources/sync/reducer/phases/toolResults.ts @@ -0,0 +1,80 @@ +import type { TracedMessage } from '../reducerTracer'; +import type { ReducerState } from '../reducer'; +import { coerceStreamingToolResultChunk, mergeExistingStdStreamsIntoFinalResultIfMissing, mergeStreamingChunkIntoResult } from '../helpers/streamingToolResult'; + +export function runToolResultsPhase(params: Readonly<{ + state: ReducerState; + nonSidechainMessages: TracedMessage[]; + changed: Set<string>; +}>): void { + const { state, nonSidechainMessages, changed } = params; + + // + // Phase 3: Process non-sidechain tool results + // + + for (let msg of nonSidechainMessages) { + if (msg.role === 'agent') { + for (let c of msg.content) { + if (c.type === 'tool-result') { + // Find the message containing this tool + let messageId = state.toolIdToMessageId.get(c.tool_use_id); + if (!messageId) { + continue; + } + + let message = state.messages.get(messageId); + if (!message || !message.tool) { + continue; + } + + if (message.tool.state !== 'running') { + continue; + } + + const streamChunk = coerceStreamingToolResultChunk(c.content); + if (streamChunk) { + message.tool.result = mergeStreamingChunkIntoResult(message.tool.result, streamChunk); + changed.add(messageId); + continue; + } + + // Update tool state and result + message.tool.state = c.is_error ? 'error' : 'completed'; + message.tool.result = mergeExistingStdStreamsIntoFinalResultIfMissing(message.tool.result, c.content); + message.tool.completedAt = msg.createdAt; + + // Update permission data if provided by backend + if (c.permissions) { + // Merge with existing permission to preserve decision field from agentState + if (message.tool.permission) { + // Preserve existing decision if not provided in tool result + const existingDecision = message.tool.permission.decision; + message.tool.permission = { + ...message.tool.permission, + id: c.tool_use_id, + status: c.permissions.result === 'approved' ? 'approved' : 'denied', + date: c.permissions.date, + mode: c.permissions.mode, + allowedTools: c.permissions.allowedTools, + decision: c.permissions.decision || existingDecision + }; + } else { + message.tool.permission = { + id: c.tool_use_id, + status: c.permissions.result === 'approved' ? 'approved' : 'denied', + date: c.permissions.date, + mode: c.permissions.mode, + allowedTools: c.permissions.allowedTools, + decision: c.permissions.decision + }; + } + } + + changed.add(messageId); + } + } + } + } +} + diff --git a/expo-app/sources/sync/reducer/phases/userAndText.ts b/expo-app/sources/sync/reducer/phases/userAndText.ts new file mode 100644 index 000000000..6ed5610df --- /dev/null +++ b/expo-app/sources/sync/reducer/phases/userAndText.ts @@ -0,0 +1,145 @@ +import type { TracedMessage } from '../reducerTracer'; +import type { UsageData } from '../../typesRaw'; +import type { ReducerState } from '../reducer'; +import { normalizeThinkingChunk, unwrapThinkingText, wrapThinkingText } from '../helpers/thinkingText'; + +export function runUserAndTextPhase(params: Readonly<{ + state: ReducerState; + nonSidechainMessages: TracedMessage[]; + changed: Set<string>; + allocateId: () => string; + processUsageData: (state: ReducerState, usage: UsageData, timestamp: number) => void; + lastMainThinkingMessageId: string | null; + lastMainThinkingCreatedAt: number | null; +}>): Readonly<{ + lastMainThinkingMessageId: string | null; + lastMainThinkingCreatedAt: number | null; +}> { + const { + state, + nonSidechainMessages, + changed, + allocateId, + processUsageData, + } = params; + + let lastMainThinkingMessageId = params.lastMainThinkingMessageId; + let lastMainThinkingCreatedAt = params.lastMainThinkingCreatedAt; + + // + // Phase 1: Process non-sidechain user messages and text messages + // + + for (let msg of nonSidechainMessages) { + if (msg.role === 'user') { + // Check if we've seen this localId before + if (msg.localId && state.localIds.has(msg.localId)) { + continue; + } + // Check if we've seen this message ID before + if (state.messageIds.has(msg.id)) { + continue; + } + + // Create a new message + let mid = allocateId(); + state.messages.set(mid, { + id: mid, + realID: msg.id, + role: 'user', + createdAt: msg.createdAt, + text: msg.content.text, + tool: null, + event: null, + meta: msg.meta, + }); + + // Track both localId and messageId + if (msg.localId) { + state.localIds.set(msg.localId, mid); + } + state.messageIds.set(msg.id, mid); + + changed.add(mid); + lastMainThinkingMessageId = null; + lastMainThinkingCreatedAt = null; + } else if (msg.role === 'agent') { + // Check if we've seen this agent message before + if (state.messageIds.has(msg.id)) { + continue; + } + + // Mark this message as seen + state.messageIds.set(msg.id, msg.id); + + // Process usage data if present + if (msg.usage) { + processUsageData(state, msg.usage, msg.createdAt); + } + + // Process text and thinking content (tool calls handled in Phase 2) + for (let c of msg.content) { + if (c.type === 'text') { + let mid = allocateId(); + state.messages.set(mid, { + id: mid, + realID: msg.id, + role: 'agent', + createdAt: msg.createdAt, + text: c.text, + isThinking: false, + tool: null, + event: null, + meta: msg.meta, + }); + changed.add(mid); + lastMainThinkingMessageId = null; + lastMainThinkingCreatedAt = null; + } else if (c.type === 'thinking') { + const chunk = typeof c.thinking === 'string' ? normalizeThinkingChunk(c.thinking) : ''; + if (!chunk.trim()) { + continue; + } + + const prevThinkingId = lastMainThinkingMessageId; + const canAppendToPrevious = + prevThinkingId + && lastMainThinkingCreatedAt !== null + && msg.createdAt - lastMainThinkingCreatedAt < 120_000 + && (() => { + const prev = state.messages.get(prevThinkingId); + return prev?.role === 'agent' && prev.isThinking && typeof prev.text === 'string'; + })(); + + if (canAppendToPrevious) { + const prev = prevThinkingId ? state.messages.get(prevThinkingId) : null; + if (prev && typeof prev.text === 'string') { + const merged = unwrapThinkingText(prev.text) + chunk; + prev.text = wrapThinkingText(merged); + changed.add(prevThinkingId!); + } + } else { + let mid = allocateId(); + state.messages.set(mid, { + id: mid, + realID: msg.id, + role: 'agent', + createdAt: msg.createdAt, + text: wrapThinkingText(chunk), + isThinking: true, + tool: null, + event: null, + meta: msg.meta, + }); + changed.add(mid); + lastMainThinkingMessageId = mid; + lastMainThinkingCreatedAt = msg.createdAt; + } + } + } + } + } + + return { lastMainThinkingMessageId, lastMainThinkingCreatedAt }; +} + diff --git a/expo-app/sources/sync/reducer/reducer.spec.ts b/expo-app/sources/sync/reducer/reducer.spec.ts index 96e391610..2d0246e8a 100644 --- a/expo-app/sources/sync/reducer/reducer.spec.ts +++ b/expo-app/sources/sync/reducer/reducer.spec.ts @@ -377,6 +377,48 @@ describe('reducer', () => { }); describe('AgentState permissions', () => { + it('should treat permission-request tool-call inputs as pending permissions (no AgentState required)', () => { + const state = createReducer(); + + const messages: NormalizedMessage[] = [ + { + id: 'perm-msg-1', + localId: null, + createdAt: 1000, + role: 'agent', + isSidechain: false, + content: [{ + type: 'tool-call', + id: 'write_file-123', + name: 'write', + input: { + permissionId: 'write_file-123', + toolCall: { + toolCallId: 'write_file-123', + status: 'pending', + title: 'Writing to .tmp/example.txt', + content: [{ path: 'example.txt', type: 'diff', oldText: '', newText: 'hello' }], + locations: [{ path: '/Users/example/.tmp/example.txt' }], + }, + }, + description: 'write', + uuid: 'perm-msg-1', + parentUUID: null, + }], + }, + ]; + + const result = reducer(state, messages); + expect(result.messages).toHaveLength(1); + + const msg = result.messages[0]; + expect(msg.kind).toBe('tool-call'); + if (msg.kind !== 'tool-call') return; + + expect(msg.tool.permission).toEqual({ id: 'write_file-123', status: 'pending' }); + expect(msg.tool.startedAt).toBeNull(); + }); + it('should create tool messages for pending permission requests', () => { const state = createReducer(); const agentState: AgentState = { @@ -680,8 +722,14 @@ describe('reducer', () => { expect(state.toolIdToMessageId.has('tool-completed')).toBe(true); // Second call with same AgentState - should not create duplicates + const sizeBefore = state.messages.size; + const idsBefore = new Set(Array.from(state.messages.keys())); const result2 = reducer(state, [], agentState); - expect(result2.messages).toHaveLength(0); // No new messages + // Reducer may return updated existing messages, but must not add duplicates. + expect(state.messages.size).toBe(sizeBefore); + for (const msg of result2.messages) { + expect(idsBefore.has(msg.id)).toBe(true); + } // Verify the mappings still exist and haven't changed expect(state.toolIdToMessageId.size).toBe(2); @@ -1306,6 +1354,115 @@ describe('reducer', () => { } }); + it('should treat streaming tool results as incremental output without completing', () => { + const state = createReducer(); + + const toolCallMessages: NormalizedMessage[] = [ + { + id: 'msg-1', + localId: null, + createdAt: 1000, + role: 'agent', + content: [{ + type: 'tool-call', + id: 'tool-1', + name: 'Bash', + input: { command: 'echo hello' }, + description: null, + uuid: 'tool-uuid-1', + parentUUID: null + }], + isSidechain: false + } + ]; + + const result1 = reducer(state, toolCallMessages); + expect(result1.messages).toHaveLength(1); + expect(result1.messages[0].kind).toBe('tool-call'); + if (result1.messages[0].kind === 'tool-call') { + expect(result1.messages[0].tool.state).toBe('running'); + expect(result1.messages[0].tool.completedAt).toBeNull(); + } + + const streamChunk1: NormalizedMessage[] = [ + { + id: 'msg-2', + localId: null, + createdAt: 1100, + role: 'agent', + content: [{ + type: 'tool-result', + tool_use_id: 'tool-1', + content: { _stream: true, _terminal: true, stdoutChunk: 'hello\\n' }, + is_error: false, + uuid: 'result-uuid-1', + parentUUID: null + }], + isSidechain: false + } + ]; + + const result2 = reducer(state, streamChunk1); + expect(result2.messages).toHaveLength(1); + if (result2.messages[0].kind === 'tool-call') { + expect(result2.messages[0].tool.state).toBe('running'); + expect(result2.messages[0].tool.completedAt).toBeNull(); + expect(result2.messages[0].tool.result).toEqual({ stdout: 'hello\\n' }); + } + + const streamChunk2: NormalizedMessage[] = [ + { + id: 'msg-3', + localId: null, + createdAt: 1150, + role: 'agent', + content: [{ + type: 'tool-result', + tool_use_id: 'tool-1', + content: { _stream: true, stdoutChunk: 'world\\n' }, + is_error: false, + uuid: 'result-uuid-2', + parentUUID: null + }], + isSidechain: false + } + ]; + + const result3 = reducer(state, streamChunk2); + expect(result3.messages).toHaveLength(1); + if (result3.messages[0].kind === 'tool-call') { + expect(result3.messages[0].tool.state).toBe('running'); + expect(result3.messages[0].tool.completedAt).toBeNull(); + expect(result3.messages[0].tool.result).toEqual({ stdout: 'hello\\nworld\\n' }); + } + + const finalResult: NormalizedMessage[] = [ + { + id: 'msg-4', + localId: null, + createdAt: 1200, + role: 'agent', + content: [{ + type: 'tool-result', + tool_use_id: 'tool-1', + content: { exitCode: 0 }, + is_error: false, + uuid: 'result-uuid-3', + parentUUID: null + }], + isSidechain: false + } + ]; + + const result4 = reducer(state, finalResult); + expect(result4.messages).toHaveLength(1); + if (result4.messages[0].kind === 'tool-call') { + expect(result4.messages[0].tool.state).toBe('completed'); + expect(result4.messages[0].tool.completedAt).toBe(1200); + expect(result4.messages[0].tool.result).toEqual({ exitCode: 0, stdout: 'hello\\nworld\\n' }); + } + }); + it('should handle interleaved messages from multiple sources correctly', () => { const state = createReducer(); @@ -1717,9 +1874,10 @@ describe('reducer', () => { expect(state.messages.size).toBe(1); // Process again with same state - should not create duplicate + const sizeBefore = state.messages.size; const result2 = reducer(state, [], agentState); - expect(result2.messages).toHaveLength(0); // No new messages - expect(state.messages.size).toBe(1); // Still only one message + // Reducer may return updated existing messages, but must not add duplicates. + expect(state.messages.size).toBe(sizeBefore); // Still only one message // Verify the message has correct permission status const message = state.messages.get(pendingMessageId!); @@ -2833,4 +2991,4 @@ describe('reducer', () => { expect(toolMsgId).toBe(permMsgId); // Same message - properly matched! }); }); -}); \ No newline at end of file +}); diff --git a/expo-app/sources/sync/reducer/reducer.ts b/expo-app/sources/sync/reducer/reducer.ts index bc99e5ffd..bdb8e957b 100644 --- a/expo-app/sources/sync/reducer/reducer.ts +++ b/expo-app/sources/sync/reducer/reducer.ts @@ -115,9 +115,60 @@ import { AgentEvent, NormalizedMessage, UsageData } from "../typesRaw"; import { createTracer, traceMessages, TracerState } from "./reducerTracer"; import { AgentState } from "../storageTypes"; import { MessageMeta } from "../typesMessageMeta"; -import { parseMessageAsEvent } from "./messageToEvent"; +import { compareToolCalls } from "../../utils/toolComparison"; +import { runMessageToEventConversion } from "./phases/messageToEventConversion"; +import { runAgentStatePermissionsPhase } from "./phases/agentStatePermissions"; +import { runUserAndTextPhase } from "./phases/userAndText"; +import { runToolCallsPhase } from "./phases/toolCalls"; +import { runToolResultsPhase } from "./phases/toolResults"; +import { runSidechainsPhase } from "./phases/sidechains"; +import { runModeSwitchEventsPhase } from "./phases/modeSwitchEvents"; +import { equalOptionalStringArrays } from "./helpers/arrays"; +import { coerceStreamingToolResultChunk, mergeExistingStdStreamsIntoFinalResultIfMissing, mergeStreamingChunkIntoResult } from "./helpers/streamingToolResult"; +import { normalizeThinkingChunk, unwrapThinkingText, wrapThinkingText } from "./helpers/thinkingText"; + +function asRecord(value: unknown): Record<string, unknown> | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + return value as Record<string, unknown>; +} + +function firstString(value: unknown): string | null { + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null; +} + +function extractPermissionRequestId(input: unknown): string | null { + const obj = asRecord(input); + if (!obj) return null; + + const direct = + firstString(obj.permissionId) ?? + firstString(obj.toolCallId) ?? + null; + if (direct) return direct; + + const toolCall = asRecord(obj.toolCall); + if (!toolCall) return null; + + return ( + firstString(toolCall.permissionId) ?? + firstString(toolCall.toolCallId) ?? + null + ); +} + +function isPermissionRequestToolCall(toolId: string, input: unknown): boolean { + const extracted = extractPermissionRequestId(input); + if (!extracted || extracted !== toolId) return false; -type ReducerMessage = { + const obj = asRecord(input); + const toolCall = obj ? asRecord(obj.toolCall) : null; + const status = firstString(toolCall?.status) ?? firstString(obj?.status) ?? null; + + // Only treat as a permission request when it looks pending. + return status === 'pending' || toolCall !== null; +} + +export type ReducerMessage = { id: string; realID: string | null; createdAt: number; @@ -138,7 +189,9 @@ type StoredPermission = { reason?: string; mode?: string; allowedTools?: string[]; - decision?: 'approved' | 'approved_for_session' | 'denied' | 'abort'; + // Backward-compatible field name used by some clients/agents. + allowTools?: string[]; + decision?: 'approved' | 'approved_for_session' | 'approved_execpolicy_amendment' | 'denied' | 'abort'; }; export type ReducerState = { @@ -217,829 +270,95 @@ export function reducer(state: ReducerState, messages: NormalizedMessage[], agen let changed: Set<string> = new Set(); let hasReadyEvent = false; - // First, trace all messages to identify sidechains - const tracedMessages = traceMessages(state.tracerState, messages); - - // Separate sidechain and non-sidechain messages - let nonSidechainMessages = tracedMessages.filter(msg => !msg.sidechainId); - const sidechainMessages = tracedMessages.filter(msg => msg.sidechainId); - - // - // Phase 0.5: Message-to-Event Conversion - // Convert certain messages to events before normal processing - // - - if (ENABLE_LOGGING) { - console.log(`[REDUCER] Phase 0.5: Message-to-Event Conversion`); - } - - const messagesToProcess: NormalizedMessage[] = []; - const convertedEvents: { message: NormalizedMessage, event: AgentEvent }[] = []; - - for (const msg of nonSidechainMessages) { - // Check if we've already processed this message - if (msg.role === 'user' && msg.localId && state.localIds.has(msg.localId)) { - continue; - } - if (state.messageIds.has(msg.id)) { - continue; - } - - // Filter out ready events completely - they should not create any message - if (msg.role === 'event' && msg.content.type === 'ready') { - // Mark as processed to prevent duplication but don't add to messages - state.messageIds.set(msg.id, msg.id); - hasReadyEvent = true; - continue; - } - - // Handle context reset events - reset state and let the message be shown - if (msg.role === 'event' && msg.content.type === 'message' && msg.content.message === 'Context was reset') { - // Reset todos to empty array and reset usage to zero - state.latestTodos = { - todos: [], - timestamp: msg.createdAt // Use message timestamp, not current time - }; - state.latestUsage = { - inputTokens: 0, - outputTokens: 0, - cacheCreation: 0, - cacheRead: 0, - contextSize: 0, - timestamp: msg.createdAt // Use message timestamp to avoid blocking older usage data - }; - // Don't continue - let the event be processed normally to create a message - } - - // Handle compaction completed events - reset context but keep todos - if (msg.role === 'event' && msg.content.type === 'message' && msg.content.message === 'Compaction completed') { - // Reset usage/context to zero but keep todos unchanged - state.latestUsage = { - inputTokens: 0, - outputTokens: 0, - cacheCreation: 0, - cacheRead: 0, - contextSize: 0, - timestamp: msg.createdAt // Use message timestamp to avoid blocking older usage data - }; - // Don't continue - let the event be processed normally to create a message - } - - // Try to parse message as event - const event = parseMessageAsEvent(msg); - if (event) { - if (ENABLE_LOGGING) { - console.log(`[REDUCER] Converting message ${msg.id} to event:`, event); - } - convertedEvents.push({ message: msg, event }); - // Mark as processed to prevent duplication - state.messageIds.set(msg.id, msg.id); - if (msg.role === 'user' && msg.localId) { - state.localIds.set(msg.localId, msg.id); - } - } else { - messagesToProcess.push(msg); - } - } - - // Process converted events immediately - for (const { message, event } of convertedEvents) { - const mid = allocateId(); - state.messages.set(mid, { - id: mid, - realID: message.id, - role: 'agent', - createdAt: message.createdAt, - event: event, - tool: null, - text: null, - meta: message.meta, - }); - changed.add(mid); + const sidechainMessageIds = new Set<string>(); + for (const chain of state.sidechains.values()) { + for (const m of chain) sidechainMessageIds.add(m.id); } - // Update nonSidechainMessages to only include messages that weren't converted - nonSidechainMessages = messagesToProcess; - - // Build a set of incoming tool IDs for quick lookup - const incomingToolIds = new Set<string>(); - for (let msg of nonSidechainMessages) { - if (msg.role === 'agent') { - for (let c of msg.content) { - if (c.type === 'tool-call') { - incomingToolIds.add(c.id); - } - } + let lastMainThinkingMessageId: string | null = null; + let lastMainThinkingCreatedAt: number | null = null; + for (const [mid, m] of state.messages) { + if (sidechainMessageIds.has(mid)) continue; + if (m.role !== 'agent' || !m.isThinking || typeof m.text !== 'string') continue; + if (lastMainThinkingCreatedAt === null || m.createdAt > lastMainThinkingCreatedAt) { + lastMainThinkingMessageId = mid; + lastMainThinkingCreatedAt = m.createdAt; } } - // - // Phase 0: Process AgentState permissions - // - - if (ENABLE_LOGGING) { - console.log(`[REDUCER] Phase 0: Processing AgentState`); - } - if (agentState) { - // Process pending permission requests - if (agentState.requests) { - for (const [permId, request] of Object.entries(agentState.requests)) { - // Skip if this permission is also in completedRequests (completed takes precedence) - if (agentState.completedRequests && agentState.completedRequests[permId]) { - continue; - } - - // Check if we already have a message for this permission ID - const existingMessageId = state.toolIdToMessageId.get(permId); - if (existingMessageId) { - // Update existing tool message with permission info - const message = state.messages.get(existingMessageId); - if (message?.tool && !message.tool.permission) { - if (ENABLE_LOGGING) { - console.log(`[REDUCER] Updating existing tool ${permId} with permission`); - } - message.tool.permission = { - id: permId, - status: 'pending' - }; - changed.add(existingMessageId); - } - } else { - if (ENABLE_LOGGING) { - console.log(`[REDUCER] Creating new message for permission ${permId}`); - } - - // Create a new tool message for the permission request - let mid = allocateId(); - let toolCall: ToolCall = { - name: request.tool, - state: 'running' as const, - input: request.arguments, - createdAt: request.createdAt || Date.now(), - startedAt: null, - completedAt: null, - description: null, - result: undefined, - permission: { - id: permId, - status: 'pending' - } - }; - - state.messages.set(mid, { - id: mid, - realID: null, - role: 'agent', - createdAt: request.createdAt || Date.now(), - text: null, - tool: toolCall, - event: null, - }); - - // Store by permission ID (which will match tool ID) - state.toolIdToMessageId.set(permId, mid); - - changed.add(mid); - } - - // Store permission details for quick lookup - state.permissions.set(permId, { - tool: request.tool, - arguments: request.arguments, - createdAt: request.createdAt || Date.now(), - status: 'pending' - }); - } - } - - // Process completed permission requests - if (agentState.completedRequests) { - for (const [permId, completed] of Object.entries(agentState.completedRequests)) { - // Check if we have a message for this permission ID - const messageId = state.toolIdToMessageId.get(permId); - if (messageId) { - const message = state.messages.get(messageId); - if (message?.tool) { - // Skip if tool has already started actual execution with approval - if (message.tool.startedAt && message.tool.permission?.status === 'approved') { - continue; - } - - // Skip if permission already has date (came from tool result - preferred over agentState) - if (message.tool.permission?.date) { - continue; - } - - // Check if we need to update ANY field - const needsUpdate = - message.tool.permission?.status !== completed.status || - message.tool.permission?.reason !== completed.reason || - message.tool.permission?.mode !== completed.mode || - message.tool.permission?.allowedTools !== completed.allowedTools || - message.tool.permission?.decision !== completed.decision; - - if (!needsUpdate) { - continue; - } - - let hasChanged = false; - - // Update permission status - if (!message.tool.permission) { - message.tool.permission = { - id: permId, - status: completed.status, - mode: completed.mode || undefined, - allowedTools: completed.allowedTools || undefined, - decision: completed.decision || undefined, - reason: completed.reason || undefined - }; - hasChanged = true; - } else { - // Update all fields - message.tool.permission.status = completed.status; - message.tool.permission.mode = completed.mode || undefined; - message.tool.permission.allowedTools = completed.allowedTools || undefined; - message.tool.permission.decision = completed.decision || undefined; - if (completed.reason) { - message.tool.permission.reason = completed.reason; - } - hasChanged = true; - } - - // Update tool state based on permission status - if (completed.status === 'approved') { - if (message.tool.state !== 'completed' && message.tool.state !== 'error' && message.tool.state !== 'running') { - message.tool.state = 'running'; - hasChanged = true; - } - } else { - // denied or canceled - if (message.tool.state !== 'error' && message.tool.state !== 'completed') { - message.tool.state = 'error'; - message.tool.completedAt = completed.completedAt || Date.now(); - if (!message.tool.result && completed.reason) { - message.tool.result = { error: completed.reason }; - } - hasChanged = true; - } - } - - // Update stored permission - state.permissions.set(permId, { - tool: completed.tool, - arguments: completed.arguments, - createdAt: completed.createdAt || Date.now(), - completedAt: completed.completedAt || undefined, - status: completed.status, - reason: completed.reason || undefined, - mode: completed.mode || undefined, - allowedTools: completed.allowedTools || undefined, - decision: completed.decision || undefined - }); - if (hasChanged) { - changed.add(messageId); - } - } - } else { - // No existing message - check if tool ID is in incoming messages - if (incomingToolIds.has(permId)) { - if (ENABLE_LOGGING) { - console.log(`[REDUCER] Storing permission ${permId} for incoming tool`); - } - // Store permission for when tool arrives in Phase 2 - state.permissions.set(permId, { - tool: completed.tool, - arguments: completed.arguments, - createdAt: completed.createdAt || Date.now(), - completedAt: completed.completedAt || undefined, - status: completed.status, - reason: completed.reason || undefined - }); - continue; - } - - // Skip if already processed as pending - if (agentState.requests && agentState.requests[permId]) { - continue; - } - - // Create a new message for completed permission without tool - let mid = allocateId(); - let toolCall: ToolCall = { - name: completed.tool, - state: completed.status === 'approved' ? 'completed' : 'error', - input: completed.arguments, - createdAt: completed.createdAt || Date.now(), - startedAt: null, - completedAt: completed.completedAt || Date.now(), - description: null, - result: completed.status === 'approved' - ? 'Approved' - : (completed.reason ? { error: completed.reason } : undefined), - permission: { - id: permId, - status: completed.status, - reason: completed.reason || undefined, - mode: completed.mode || undefined, - allowedTools: completed.allowedTools || undefined, - decision: completed.decision || undefined - } - }; - - state.messages.set(mid, { - id: mid, - realID: null, - role: 'agent', - createdAt: completed.createdAt || Date.now(), - text: null, - tool: toolCall, - event: null, - }); - - state.toolIdToMessageId.set(permId, mid); - - // Store permission details - state.permissions.set(permId, { - tool: completed.tool, - arguments: completed.arguments, - createdAt: completed.createdAt || Date.now(), - completedAt: completed.completedAt || undefined, - status: completed.status, - reason: completed.reason || undefined, - mode: completed.mode || undefined, - allowedTools: completed.allowedTools || undefined, - decision: completed.decision || undefined - }); - - changed.add(mid); - } - } - } - } - - // - // Phase 1: Process non-sidechain user messages and text messages - // - - for (let msg of nonSidechainMessages) { - if (msg.role === 'user') { - // Check if we've seen this localId before - if (msg.localId && state.localIds.has(msg.localId)) { - continue; - } - // Check if we've seen this message ID before - if (state.messageIds.has(msg.id)) { - continue; - } - - // Create a new message - let mid = allocateId(); - state.messages.set(mid, { - id: mid, - realID: msg.id, - role: 'user', - createdAt: msg.createdAt, - text: msg.content.text, - tool: null, - event: null, - meta: msg.meta, - }); - - // Track both localId and messageId - if (msg.localId) { - state.localIds.set(msg.localId, mid); - } - state.messageIds.set(msg.id, mid); - - changed.add(mid); - } else if (msg.role === 'agent') { - // Check if we've seen this agent message before - if (state.messageIds.has(msg.id)) { - continue; - } - - // Mark this message as seen - state.messageIds.set(msg.id, msg.id); - - // Process usage data if present - if (msg.usage) { - processUsageData(state, msg.usage, msg.createdAt); - } - - // Process text and thinking content (tool calls handled in Phase 2) - for (let c of msg.content) { - if (c.type === 'text' || c.type === 'thinking') { - let mid = allocateId(); - const isThinking = c.type === 'thinking'; - state.messages.set(mid, { - id: mid, - realID: msg.id, - role: 'agent', - createdAt: msg.createdAt, - text: isThinking ? `*Thinking...*\n\n*${c.thinking}*` : c.text, - isThinking, - tool: null, - event: null, - meta: msg.meta, - }); - changed.add(mid); - } - } - } - } - - // - // Phase 2: Process non-sidechain tool calls - // - - if (ENABLE_LOGGING) { - console.log(`[REDUCER] Phase 2: Processing tool calls`); - } - for (let msg of nonSidechainMessages) { - if (msg.role === 'agent') { - for (let c of msg.content) { - if (c.type === 'tool-call') { - // Direct lookup by tool ID (since permission ID = tool ID now) - const existingMessageId = state.toolIdToMessageId.get(c.id); - - if (existingMessageId) { - if (ENABLE_LOGGING) { - console.log(`[REDUCER] Found existing message for tool ${c.id}`); - } - // Update existing message with tool execution details - const message = state.messages.get(existingMessageId); - if (message?.tool) { - message.realID = msg.id; - message.tool.description = c.description; - message.tool.startedAt = msg.createdAt; - // If permission was approved and shown as completed (no tool), now it's running - if (message.tool.permission?.status === 'approved' && message.tool.state === 'completed') { - message.tool.state = 'running'; - message.tool.completedAt = null; - message.tool.result = undefined; - } - changed.add(existingMessageId); - - // Track TodoWrite tool inputs when updating existing messages - if (message.tool.name === 'TodoWrite' && message.tool.state === 'running' && message.tool.input?.todos) { - // Only update if this is newer than existing todos - if (!state.latestTodos || message.tool.createdAt > state.latestTodos.timestamp) { - state.latestTodos = { - todos: message.tool.input.todos, - timestamp: message.tool.createdAt - }; - } - } - } - } else { - if (ENABLE_LOGGING) { - console.log(`[REDUCER] Creating new message for tool ${c.id}`); - } - // Check if there's a stored permission for this tool - const permission = state.permissions.get(c.id); - - let toolCall: ToolCall = { - name: c.name, - state: 'running' as const, - input: permission ? permission.arguments : c.input, // Use permission args if available - createdAt: permission ? permission.createdAt : msg.createdAt, // Use permission timestamp if available - startedAt: msg.createdAt, - completedAt: null, - description: c.description, - result: undefined, - }; - - // Add permission info if found - if (permission) { - if (ENABLE_LOGGING) { - console.log(`[REDUCER] Found stored permission for tool ${c.id}`); - } - toolCall.permission = { - id: c.id, - status: permission.status, - reason: permission.reason, - mode: permission.mode, - allowedTools: permission.allowedTools, - decision: permission.decision - }; - - // Update state based on permission status - if (permission.status !== 'approved') { - toolCall.state = 'error'; - toolCall.completedAt = permission.completedAt || msg.createdAt; - if (permission.reason) { - toolCall.result = { error: permission.reason }; - } - } - } - - let mid = allocateId(); - state.messages.set(mid, { - id: mid, - realID: msg.id, - role: 'agent', - createdAt: msg.createdAt, - text: null, - tool: toolCall, - event: null, - meta: msg.meta, - }); - - state.toolIdToMessageId.set(c.id, mid); - changed.add(mid); + // First, trace all messages to identify sidechains + const tracedMessages = traceMessages(state.tracerState, messages); - // Track TodoWrite tool inputs - if (toolCall.name === 'TodoWrite' && toolCall.state === 'running' && toolCall.input?.todos) { - // Only update if this is newer than existing todos - if (!state.latestTodos || toolCall.createdAt > state.latestTodos.timestamp) { - state.latestTodos = { - todos: toolCall.input.todos, - timestamp: toolCall.createdAt - }; - } - } - } - } - } - } - } + // Separate sidechain and non-sidechain messages + let nonSidechainMessages = tracedMessages.filter(msg => !msg.sidechainId); + const sidechainMessages = tracedMessages.filter(msg => msg.sidechainId); // - // Phase 3: Process non-sidechain tool results - // - - for (let msg of nonSidechainMessages) { - if (msg.role === 'agent') { - for (let c of msg.content) { - if (c.type === 'tool-result') { - // Find the message containing this tool - let messageId = state.toolIdToMessageId.get(c.tool_use_id); - if (!messageId) { - continue; - } - - let message = state.messages.get(messageId); - if (!message || !message.tool) { - continue; - } - - if (message.tool.state !== 'running') { - continue; - } - - // Update tool state and result - message.tool.state = c.is_error ? 'error' : 'completed'; - message.tool.result = c.content; - message.tool.completedAt = msg.createdAt; - - // Update permission data if provided by backend - if (c.permissions) { - // Merge with existing permission to preserve decision field from agentState - if (message.tool.permission) { - // Preserve existing decision if not provided in tool result - const existingDecision = message.tool.permission.decision; - message.tool.permission = { - ...message.tool.permission, - id: c.tool_use_id, - status: c.permissions.result === 'approved' ? 'approved' : 'denied', - date: c.permissions.date, - mode: c.permissions.mode, - allowedTools: c.permissions.allowedTools, - decision: c.permissions.decision || existingDecision - }; - } else { - message.tool.permission = { - id: c.tool_use_id, - status: c.permissions.result === 'approved' ? 'approved' : 'denied', - date: c.permissions.date, - mode: c.permissions.mode, - allowedTools: c.permissions.allowedTools, - decision: c.permissions.decision - }; - } - } - - changed.add(messageId); - } - } - } - } + const conversion = runMessageToEventConversion({ + state, + nonSidechainMessages, + changed, + allocateId, + enableLogging: ENABLE_LOGGING, + }); + nonSidechainMessages = conversion.nonSidechainMessages; + const incomingToolIds = conversion.incomingToolIds; + hasReadyEvent = hasReadyEvent || conversion.hasReadyEvent; + + runAgentStatePermissionsPhase({ + state, + agentState, + incomingToolIds, + changed, + allocateId, + enableLogging: ENABLE_LOGGING, + }); + + const phase1 = runUserAndTextPhase({ + state, + nonSidechainMessages, + changed, + allocateId, + processUsageData, + lastMainThinkingMessageId, + lastMainThinkingCreatedAt, + }); + lastMainThinkingMessageId = phase1.lastMainThinkingMessageId; + lastMainThinkingCreatedAt = phase1.lastMainThinkingCreatedAt; + + runToolCallsPhase({ + state, + nonSidechainMessages, + changed, + allocateId, + enableLogging: ENABLE_LOGGING, + isPermissionRequestToolCall, + }); + + runToolResultsPhase({ + state, + nonSidechainMessages, + changed, + }); // // Phase 4: Process sidechains and store them in state // - // For each sidechain message, store it in the state and mark the Task as changed - for (const msg of sidechainMessages) { - if (!msg.sidechainId) continue; - - // Skip if we already processed this message - if (state.messageIds.has(msg.id)) continue; - - // Mark as processed - state.messageIds.set(msg.id, msg.id); - - // Get or create the sidechain array for this Task - const existingSidechain = state.sidechains.get(msg.sidechainId) || []; - - // Process and add new sidechain messages - if (msg.role === 'agent' && msg.content[0]?.type === 'sidechain') { - // This is the sidechain root - create a user message - let mid = allocateId(); - let userMsg: ReducerMessage = { - id: mid, - realID: msg.id, - role: 'user', - createdAt: msg.createdAt, - text: msg.content[0].prompt, - tool: null, - event: null, - meta: msg.meta, - }; - state.messages.set(mid, userMsg); - existingSidechain.push(userMsg); - } else if (msg.role === 'agent') { - // Process agent content in sidechain - for (let c of msg.content) { - if (c.type === 'text' || c.type === 'thinking') { - let mid = allocateId(); - const isThinking = c.type === 'thinking'; - let textMsg: ReducerMessage = { - id: mid, - realID: msg.id, - role: 'agent', - createdAt: msg.createdAt, - text: isThinking ? `*Thinking...*\n\n*${c.thinking}*` : c.text, - isThinking, - tool: null, - event: null, - meta: msg.meta, - }; - state.messages.set(mid, textMsg); - existingSidechain.push(textMsg); - } else if (c.type === 'tool-call') { - // Check if there's already a permission message for this tool - const existingPermissionMessageId = state.toolIdToMessageId.get(c.id); - - let mid = allocateId(); - let toolCall: ToolCall = { - name: c.name, - state: 'running' as const, - input: c.input, - createdAt: msg.createdAt, - startedAt: null, - completedAt: null, - description: c.description, - result: undefined - }; - - // If there's a permission message, copy its permission info - if (existingPermissionMessageId) { - const permissionMessage = state.messages.get(existingPermissionMessageId); - if (permissionMessage?.tool?.permission) { - toolCall.permission = { ...permissionMessage.tool.permission }; - // Update the permission message to show it's running - if (permissionMessage.tool.state !== 'completed' && permissionMessage.tool.state !== 'error') { - permissionMessage.tool.state = 'running'; - permissionMessage.tool.startedAt = msg.createdAt; - permissionMessage.tool.description = c.description; - changed.add(existingPermissionMessageId); - } - } - } + runSidechainsPhase({ + state, + sidechainMessages, + changed, + allocateId, + }); - let toolMsg: ReducerMessage = { - id: mid, - realID: msg.id, - role: 'agent', - createdAt: msg.createdAt, - text: null, - tool: toolCall, - event: null, - meta: msg.meta, - }; - state.messages.set(mid, toolMsg); - existingSidechain.push(toolMsg); - - // Map sidechain tool separately to avoid overwriting permission mapping - state.sidechainToolIdToMessageId.set(c.id, mid); - } else if (c.type === 'tool-result') { - // Process tool result in sidechain - update BOTH messages - - // Update the sidechain tool message - let sidechainMessageId = state.sidechainToolIdToMessageId.get(c.tool_use_id); - if (sidechainMessageId) { - let sidechainMessage = state.messages.get(sidechainMessageId); - if (sidechainMessage && sidechainMessage.tool && sidechainMessage.tool.state === 'running') { - sidechainMessage.tool.state = c.is_error ? 'error' : 'completed'; - sidechainMessage.tool.result = c.content; - sidechainMessage.tool.completedAt = msg.createdAt; - - // Update permission data if provided by backend - if (c.permissions) { - // Merge with existing permission to preserve decision field from agentState - if (sidechainMessage.tool.permission) { - const existingDecision = sidechainMessage.tool.permission.decision; - sidechainMessage.tool.permission = { - ...sidechainMessage.tool.permission, - id: c.tool_use_id, - status: c.permissions.result === 'approved' ? 'approved' : 'denied', - date: c.permissions.date, - mode: c.permissions.mode, - allowedTools: c.permissions.allowedTools, - decision: c.permissions.decision || existingDecision - }; - } else { - sidechainMessage.tool.permission = { - id: c.tool_use_id, - status: c.permissions.result === 'approved' ? 'approved' : 'denied', - date: c.permissions.date, - mode: c.permissions.mode, - allowedTools: c.permissions.allowedTools, - decision: c.permissions.decision - }; - } - } - } - } - - // Also update the main permission message if it exists - let permissionMessageId = state.toolIdToMessageId.get(c.tool_use_id); - if (permissionMessageId) { - let permissionMessage = state.messages.get(permissionMessageId); - if (permissionMessage && permissionMessage.tool && permissionMessage.tool.state === 'running') { - permissionMessage.tool.state = c.is_error ? 'error' : 'completed'; - permissionMessage.tool.result = c.content; - permissionMessage.tool.completedAt = msg.createdAt; - - // Update permission data if provided by backend - if (c.permissions) { - // Merge with existing permission to preserve decision field from agentState - if (permissionMessage.tool.permission) { - const existingDecision = permissionMessage.tool.permission.decision; - permissionMessage.tool.permission = { - ...permissionMessage.tool.permission, - id: c.tool_use_id, - status: c.permissions.result === 'approved' ? 'approved' : 'denied', - date: c.permissions.date, - mode: c.permissions.mode, - allowedTools: c.permissions.allowedTools, - decision: c.permissions.decision || existingDecision - }; - } else { - permissionMessage.tool.permission = { - id: c.tool_use_id, - status: c.permissions.result === 'approved' ? 'approved' : 'denied', - date: c.permissions.date, - mode: c.permissions.mode, - allowedTools: c.permissions.allowedTools, - decision: c.permissions.decision - }; - } - } - - changed.add(permissionMessageId); - } - } - } - } - } - - // Update the sidechain in state - state.sidechains.set(msg.sidechainId, existingSidechain); - - // Find the Task tool message that owns this sidechain and mark it as changed - // msg.sidechainId is the realID of the Task message - for (const [internalId, message] of state.messages) { - if (message.realID === msg.sidechainId && message.tool) { - changed.add(internalId); - break; - } - } - } - - // - // Phase 5: Process mode-switch messages - // - - for (let msg of nonSidechainMessages) { - if (msg.role === 'event') { - let mid = allocateId(); - state.messages.set(mid, { - id: mid, - realID: msg.id, - role: 'agent', - createdAt: msg.createdAt, - event: msg.content, - tool: null, - text: null, - meta: msg.meta, - }); - changed.add(mid); - } - } + runModeSwitchEventsPhase({ + state, + nonSidechainMessages, + changed, + allocateId, + }); // // Collect changed messages (only root-level messages) @@ -1153,4 +472,4 @@ function convertReducerMessageToMessage(reducerMsg: ReducerMessage, state: Reduc } return null; -} \ No newline at end of file +} diff --git a/expo-app/sources/sync/resumeSessionBase.test.ts b/expo-app/sources/sync/resumeSessionBase.test.ts new file mode 100644 index 000000000..8910d4806 --- /dev/null +++ b/expo-app/sources/sync/resumeSessionBase.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; + +import { buildResumeSessionBaseOptionsFromSession } from './resumeSessionBase'; + +describe('buildResumeSessionBaseOptionsFromSession', () => { + it('returns null when session metadata is missing', () => { + expect(buildResumeSessionBaseOptionsFromSession({ + sessionId: 's1', + session: { metadata: null } as any, + resumeCapabilityOptions: {}, + })).toBeNull(); + }); + + it('returns null when vendor resume is not allowed', () => { + expect(buildResumeSessionBaseOptionsFromSession({ + sessionId: 's1', + session: { metadata: { machineId: 'm1', path: '/tmp', flavor: 'openai', codexSessionId: 'x1' } } as any, + resumeCapabilityOptions: {}, // codex not enabled + })).toBeNull(); + }); + + it('returns base options when vendor resume is allowed and present', () => { + expect(buildResumeSessionBaseOptionsFromSession({ + sessionId: 's1', + session: { metadata: { machineId: 'm1', path: '/tmp', flavor: 'openai', codexSessionId: 'x1' } } as any, + resumeCapabilityOptions: { allowExperimentalResumeByAgentId: { codex: true } }, + })).toEqual({ + sessionId: 's1', + machineId: 'm1', + directory: '/tmp', + agent: 'codex', + resume: 'x1', + }); + }); + + it('passes through permission mode overrides', () => { + expect(buildResumeSessionBaseOptionsFromSession({ + sessionId: 's1', + session: { metadata: { machineId: 'm1', path: '/tmp', flavor: 'claude', claudeSessionId: 'c1' } } as any, + resumeCapabilityOptions: {}, + permissionOverride: { permissionMode: 'plan', permissionModeUpdatedAt: 123 }, + })).toEqual({ + sessionId: 's1', + machineId: 'm1', + directory: '/tmp', + agent: 'claude', + resume: 'c1', + permissionMode: 'plan', + permissionModeUpdatedAt: 123, + }); + }); +}); diff --git a/expo-app/sources/sync/resumeSessionBase.ts b/expo-app/sources/sync/resumeSessionBase.ts new file mode 100644 index 000000000..1055f8ef5 --- /dev/null +++ b/expo-app/sources/sync/resumeSessionBase.ts @@ -0,0 +1,43 @@ +import type { Session } from './storageTypes'; +import type { ResumeSessionOptions } from './ops'; +import type { ResumeCapabilityOptions } from '@/agents/resumeCapabilities'; +import { canAgentResume, getAgentVendorResumeId } from '@/agents/resumeCapabilities'; +import { getAgentCore, resolveAgentIdFromFlavor } from '@/agents/catalog'; +import type { PermissionModeOverrideForSpawn } from '@/sync/permissionModeOverride'; + +export type ResumeSessionBaseOptions = Omit< + ResumeSessionOptions, + 'sessionEncryptionKeyBase64' | 'sessionEncryptionVariant' +>; + +export function buildResumeSessionBaseOptionsFromSession(opts: { + sessionId: string; + session: Session; + resumeCapabilityOptions: ResumeCapabilityOptions; + permissionOverride?: PermissionModeOverrideForSpawn | null; +}): ResumeSessionBaseOptions | null { + const { sessionId, session, resumeCapabilityOptions, permissionOverride } = opts; + + const machineId = session.metadata?.machineId; + const directory = session.metadata?.path; + const flavor = session.metadata?.flavor; + if (!machineId || !directory || !flavor) return null; + + const agentId = resolveAgentIdFromFlavor(flavor); + if (!agentId) return null; + + // Note: vendor resume IDs can be missing even for otherwise-resumable sessions. + // Wake/resume still needs to work (e.g. pending-queue wake) and should attach the vendor id only when present. + if (!canAgentResume(flavor, resumeCapabilityOptions)) return null; + + const resume = getAgentVendorResumeId(session.metadata, agentId, resumeCapabilityOptions); + + return { + sessionId, + machineId, + directory, + agent: getAgentCore(agentId).cli.spawnAgent, + ...(resume ? { resume } : {}), + ...(permissionOverride ? permissionOverride : {}), + }; +} diff --git a/expo-app/sources/sync/resumeSessionPayload.test.ts b/expo-app/sources/sync/resumeSessionPayload.test.ts new file mode 100644 index 000000000..34d52c0d9 --- /dev/null +++ b/expo-app/sources/sync/resumeSessionPayload.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, test } from 'vitest'; + +import { buildResumeHappySessionRpcParams } from './resumeSessionPayload'; + +describe('buildResumeHappySessionRpcParams', () => { + test('builds typed params for resume-session', () => { + expect(buildResumeHappySessionRpcParams({ + sessionId: 's1', + directory: '/tmp', + agent: 'claude', + sessionEncryptionKeyBase64: 'abc', + sessionEncryptionVariant: 'dataKey', + })).toEqual({ + type: 'resume-session', + sessionId: 's1', + directory: '/tmp', + agent: 'claude', + sessionEncryptionKeyBase64: 'abc', + sessionEncryptionVariant: 'dataKey', + }); + }); +}); + diff --git a/expo-app/sources/sync/resumeSessionPayload.ts b/expo-app/sources/sync/resumeSessionPayload.ts new file mode 100644 index 000000000..c931157a9 --- /dev/null +++ b/expo-app/sources/sync/resumeSessionPayload.ts @@ -0,0 +1,41 @@ +import { z } from 'zod'; +import { AGENT_IDS, type AgentId } from '@/agents/catalog'; +import { isPermissionMode, type PermissionMode } from '@/sync/permissionTypes'; + +export type ResumeHappySessionRpcParams = { + type: 'resume-session'; + sessionId: string; + directory: string; + agent: AgentId; + resume?: string; + sessionEncryptionKeyBase64: string; + sessionEncryptionVariant: 'dataKey'; + permissionMode?: PermissionMode; + permissionModeUpdatedAt?: number; + experimentalCodexResume?: boolean; + experimentalCodexAcp?: boolean; +}; + +const ResumeHappySessionRpcParamsSchema = z.object({ + type: z.literal('resume-session'), + sessionId: z.string().min(1), + directory: z.string().min(1), + agent: z.enum(AGENT_IDS), + resume: z.string().min(1).optional(), + sessionEncryptionKeyBase64: z.string().min(1), + sessionEncryptionVariant: z.literal('dataKey'), + permissionMode: z.string().refine((value) => isPermissionMode(value)).optional(), + permissionModeUpdatedAt: z.number().optional(), + experimentalCodexResume: z.boolean().optional(), + experimentalCodexAcp: z.boolean().optional(), +}); + +export function buildResumeHappySessionRpcParams(input: Omit<ResumeHappySessionRpcParams, 'type'>): ResumeHappySessionRpcParams { + const params: ResumeHappySessionRpcParams = { + type: 'resume-session', + ...input, + }; + // Validate shape early to avoid accidentally sending secrets in wrong fields. + ResumeHappySessionRpcParamsSchema.parse(params); + return params; +} diff --git a/expo-app/sources/sync/rpcErrors.test.ts b/expo-app/sources/sync/rpcErrors.test.ts new file mode 100644 index 000000000..9ce9f0792 --- /dev/null +++ b/expo-app/sources/sync/rpcErrors.test.ts @@ -0,0 +1,26 @@ +import { describe, it, expect } from 'vitest'; +import { createRpcCallError, isRpcMethodNotAvailableError } from './rpcErrors'; +import { RPC_ERROR_CODES } from '@happy/protocol/rpc'; + +describe('rpcErrors', () => { + it('creates an Error with rpcErrorCode when provided', () => { + const err = createRpcCallError({ error: 'RPC method not available', errorCode: RPC_ERROR_CODES.METHOD_NOT_AVAILABLE }); + expect(err.message).toBe('RPC method not available'); + expect((err as any).rpcErrorCode).toBe('RPC_METHOD_NOT_AVAILABLE'); + }); + + it('creates an Error without rpcErrorCode when missing', () => { + const err = createRpcCallError({ error: 'boom' }); + expect(err.message).toBe('boom'); + expect((err as any).rpcErrorCode).toBeUndefined(); + }); + + it('detects RPC method unavailable by explicit errorCode', () => { + expect(isRpcMethodNotAvailableError({ rpcErrorCode: RPC_ERROR_CODES.METHOD_NOT_AVAILABLE, message: 'anything' })).toBe(true); + }); + + it('detects RPC method unavailable by legacy message (case-insensitive)', () => { + expect(isRpcMethodNotAvailableError({ message: 'RPC method not available' })).toBe(true); + expect(isRpcMethodNotAvailableError({ message: 'rpc METHOD NOT available ' })).toBe(true); + }); +}); diff --git a/expo-app/sources/sync/rpcErrors.ts b/expo-app/sources/sync/rpcErrors.ts new file mode 100644 index 000000000..90242cc30 --- /dev/null +++ b/expo-app/sources/sync/rpcErrors.ts @@ -0,0 +1,27 @@ +import type { RpcErrorCode } from '@happy/protocol/rpc'; +import { RPC_ERROR_CODES } from '@happy/protocol/rpc'; + +export type { RpcErrorCode }; + +/** + * Create a regular Error instance that also carries a structured RPC error code. + * + * Notes: + * - Backward compatibility: older servers/clients only expose a message string. + * - Newer clients should prefer `rpcErrorCode` when available. + */ +export function createRpcCallError(opts: { error: string; errorCode?: string | null | undefined }): Error { + const err = new Error(opts.error); + if (opts.errorCode && typeof opts.errorCode === 'string') { + (err as any).rpcErrorCode = opts.errorCode; + } + return err; +} + +export function isRpcMethodNotAvailableError(err: { rpcErrorCode?: unknown; message?: unknown }): boolean { + if (err.rpcErrorCode === RPC_ERROR_CODES.METHOD_NOT_AVAILABLE) { + return true; + } + const msg = typeof err.message === 'string' ? err.message.trim().toLowerCase() : ''; + return msg === 'rpc method not available'; +} diff --git a/expo-app/sources/sync/secretBindings.test.ts b/expo-app/sources/sync/secretBindings.test.ts new file mode 100644 index 000000000..0275d199e --- /dev/null +++ b/expo-app/sources/sync/secretBindings.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; + +import { settingsParse } from '@/sync/settings'; +import { pruneSecretBindings } from '@/sync/secretBindings'; + +describe('pruneSecretBindings', () => { + it('drops bindings for unknown profiles, unknown secrets, and non-required env names; normalizes env var name casing', () => { + const base = settingsParse({}); + + const settings = { + ...base, + profiles: [ + { + id: 'custom-1', + name: 'Custom', + environmentVariables: [], + compatibility: { claude: true, codex: true, gemini: true }, + envVarRequirements: [{ name: 'OPENAI_API_KEY', kind: 'secret', required: true }], + isBuiltIn: false, + createdAt: 0, + updatedAt: 0, + version: '1.0.0', + }, + ], + secrets: [ + { id: 's1', name: 'S1', kind: 'apiKey', encryptedValue: { _isSecretValue: true, encryptedValue: { t: 'enc-v1', c: 'Zm9v' } }, createdAt: 0, updatedAt: 0 }, + ], + secretBindingsByProfileId: { + // Unknown profile -> drop + 'missing-profile': { OPENAI_API_KEY: 's1' }, + // Known profile: + 'custom-1': { + // Normalized to uppercase and kept + openai_api_key: 's1', + // Env var not declared as secret requirement -> drop + OTHER_SECRET: 's1', + // Unknown secret id -> drop + OPENAI_API_KEY: 'missing-secret', + // Invalid env name -> drop + 'not valid': 's1', + }, + }, + }; + + const pruned = pruneSecretBindings(settings as any); + expect(pruned.secretBindingsByProfileId).toEqual({ + 'custom-1': { + OPENAI_API_KEY: 's1', + }, + }); + }); +}); + diff --git a/expo-app/sources/sync/secretBindings.ts b/expo-app/sources/sync/secretBindings.ts new file mode 100644 index 000000000..68045914f --- /dev/null +++ b/expo-app/sources/sync/secretBindings.ts @@ -0,0 +1,103 @@ +import type { Settings } from '@/sync/settings'; +import { getBuiltInProfile } from '@/sync/profileUtils'; + +function normalizeEnvVarName(input: string): string | null { + const trimmed = input.trim(); + if (!trimmed) return null; + const upper = trimmed.toUpperCase(); + if (!/^[A-Z_][A-Z0-9_]*$/.test(upper)) return null; + return upper; +} + +function getAllowedSecretEnvVarNamesByProfileId(settings: Settings): Record<string, Set<string>> { + const out: Record<string, Set<string>> = {}; + + for (const p of settings.profiles) { + const names = new Set( + (p.envVarRequirements ?? []) + .filter((r) => (r.kind ?? 'secret') === 'secret') + .map((r) => normalizeEnvVarName(r.name)) + .filter((n): n is string => typeof n === 'string' && n.length > 0), + ); + out[p.id] = names; + } + + // Include built-in profiles too (bindings are allowed for built-ins). + // We only consider built-ins that we know about; unknown profile ids are pruned. + const seen = new Set(Object.keys(out)); + for (const profileId of Object.keys(settings.secretBindingsByProfileId ?? {})) { + if (seen.has(profileId)) continue; + const builtIn = getBuiltInProfile(profileId); + if (!builtIn) continue; + const names = new Set( + (builtIn.envVarRequirements ?? []) + .filter((r) => (r.kind ?? 'secret') === 'secret') + .map((r) => normalizeEnvVarName(r.name)) + .filter((n): n is string => typeof n === 'string' && n.length > 0), + ); + out[profileId] = names; + } + + return out; +} + +/** + * Remove dangling/invalid secret bindings. + * + * Invariants: + * - No bindings for unknown profile ids (custom or built-in). + * - No bindings for env var names that are not declared as a secret requirement on that profile. + * - No bindings referencing deleted secrets. + * - Env var names are normalized to uppercase. + */ +export function pruneSecretBindings(settings: Settings): Settings { + const bindings = settings.secretBindingsByProfileId ?? {}; + if (Object.keys(bindings).length === 0) return settings; + + const secretIds = new Set((settings.secrets ?? []).map((s) => s.id)); + const allowedByProfileId = getAllowedSecretEnvVarNamesByProfileId(settings); + + let changed = false; + const next: Record<string, Record<string, string>> = {}; + + for (const [profileId, byEnv] of Object.entries(bindings)) { + const allowed = allowedByProfileId[profileId]; + if (!allowed) { + changed = true; + continue; + } + + let nextByEnv: Record<string, string> | null = null; + for (const [rawEnvName, secretId] of Object.entries(byEnv ?? {})) { + const envName = typeof rawEnvName === 'string' ? normalizeEnvVarName(rawEnvName) : null; + if (!envName) { + changed = true; + continue; + } + if (!allowed.has(envName)) { + changed = true; + continue; + } + if (typeof secretId !== 'string' || !secretIds.has(secretId)) { + changed = true; + continue; + } + if (!nextByEnv) nextByEnv = {}; + nextByEnv[envName] = secretId; + } + + if (!nextByEnv || Object.keys(nextByEnv).length === 0) { + if (Object.keys(byEnv ?? {}).length > 0) changed = true; + continue; + } + + next[profileId] = nextByEnv; + } + + if (!changed) return settings; + return { + ...settings, + secretBindingsByProfileId: next, + }; +} + diff --git a/expo-app/sources/sync/secretSettings.test.ts b/expo-app/sources/sync/secretSettings.test.ts new file mode 100644 index 000000000..fc0363229 --- /dev/null +++ b/expo-app/sources/sync/secretSettings.test.ts @@ -0,0 +1,44 @@ +import { beforeAll, describe, expect, it } from 'vitest'; + +import sodium from '@/encryption/libsodium.lib'; +import { decryptSecretValue, sealSecretsDeep } from './secretSettings'; + +describe('secretSettings', () => { + beforeAll(async () => { + await sodium.ready; + }); + + it('sealSecretsDeep encrypts SecretString.value into SecretString.encryptedValue and drops SecretString.value', () => { + const key = new Uint8Array(32).fill(7); + const delta = { + secrets: [ + { id: 'k1', name: 'Key', encryptedValue: { _isSecretValue: true, value: 'sk-test' } }, + ], + }; + + const sealed = sealSecretsDeep(delta, key); + const item: any = (sealed as any).secrets[0]; + expect(item.encryptedValue?.value).toBeUndefined(); + expect(item.encryptedValue?.encryptedValue?.t).toBe('enc-v1'); + expect(typeof item.encryptedValue?.encryptedValue?.c).toBe('string'); + expect(item.encryptedValue.encryptedValue.c.length).toBeGreaterThan(0); + }); + + it('sealSecretsDeep does not encrypt objects without secret marker', () => { + const key = new Uint8Array(32).fill(7); + const delta = { value: 'not-a-secret', encryptedValue: undefined }; + // Without `_isSecretValue: true`, we must not seal it (avoids false positives across the app). + const sealed = sealSecretsDeep(delta, key); + expect((sealed as any).value).toBe('not-a-secret'); + }); + + it('decryptSecretValue returns plaintext if value is present (does not mutate input)', () => { + const key = new Uint8Array(32).fill(7); + const input: any = { _isSecretValue: true, value: 'sk-plain', encryptedValue: undefined }; + const out = decryptSecretValue(input, key); + expect(out).toBe('sk-plain'); + expect(input.value).toBe('sk-plain'); + expect(input.encryptedValue).toBeUndefined(); + }); +}); + diff --git a/expo-app/sources/sync/secretSettings.ts b/expo-app/sources/sync/secretSettings.ts new file mode 100644 index 000000000..2e6be29bb --- /dev/null +++ b/expo-app/sources/sync/secretSettings.ts @@ -0,0 +1,157 @@ +import * as z from 'zod'; + +import { encodeBase64, decodeBase64 } from '@/encryption/base64'; +import sodium from '@/encryption/libsodium.lib'; +import { deriveKey } from '@/encryption/deriveKey'; +import { getRandomBytes } from '@/platform/cryptoRandom'; +// Note: this module must remain safe for vitest/node (no react-native import). + +/** + * Field-level secret encryption for settings. + * + * Goal: even after decrypting the outer settings blob, sensitive values can remain encrypted-at-rest + * in MMKV / JSON and only be decrypted just-in-time when needed. + * + * This is intentionally generic so we can reuse it for future secret settings. + */ + +export const EncryptedStringSchema = z.object({ + t: z.literal('enc-v1'), + c: z.string().min(1), // base64 payload (includes nonce) +}); + +export type EncryptedString = z.infer<typeof EncryptedStringSchema>; + +// Standard secret container (plaintext input + encrypted-at-rest ciphertext). +// This is the ONLY supported secret shape for settings going forward. +export const SecretStringSchema = z.object({ + _isSecretValue: z.literal(true), + value: z.string().min(1).optional(), + encryptedValue: EncryptedStringSchema.optional(), +}); + +export type SecretString = z.infer<typeof SecretStringSchema>; + +const SETTINGS_SECRETS_USAGE = 'Happy Settings Secrets'; +const SETTINGS_SECRETS_PATH = ['settings', 'secrets', 'v1'] as const; + +export async function deriveSettingsSecretsKey(masterSecret: Uint8Array): Promise<Uint8Array> { + return await deriveKey(masterSecret, SETTINGS_SECRETS_USAGE, [...SETTINGS_SECRETS_PATH]); +} + +export function encryptSecretString(value: string, key: Uint8Array): EncryptedString { + const nonce = getRandomBytes(sodium.crypto_secretbox_NONCEBYTES); + const message = new TextEncoder().encode(value); + const encrypted = sodium.crypto_secretbox_easy(message, nonce, key); + const combined = new Uint8Array(nonce.length + encrypted.length); + combined.set(nonce, 0); + combined.set(encrypted, nonce.length); + return { t: 'enc-v1', c: encodeBase64(combined, 'base64') }; +} + +export function decryptSecretString(valueEnc: EncryptedString, key: Uint8Array): string | null { + try { + const combined = decodeBase64(valueEnc.c, 'base64'); + if (combined.length < sodium.crypto_secretbox_NONCEBYTES) return null; + const nonce = combined.slice(0, sodium.crypto_secretbox_NONCEBYTES); + const boxed = combined.slice(sodium.crypto_secretbox_NONCEBYTES); + const opened = sodium.crypto_secretbox_open_easy(boxed, nonce, key); + if (!opened) return null; + return new TextDecoder().decode(opened); + } catch { + return null; + } +} + +/** + * Secret settings registry + * + * Add new encrypted-at-rest settings by extending this registry. Aim: "single-line addition" + * for new secret fields, and centralized sealing/decryption rules. + */ + +// We intentionally do NOT maintain a per-setting registry. All secrets follow one convention. + +/** + * Generic helper for "secret string in settings" objects that may carry either: + * - plaintext `value` (input/legacy; must never be persisted), or + * - encrypted-at-rest `encryptedValue` (preferred persisted form). + */ +export function decryptSecretValue( + input: SecretString | null | undefined, + key: Uint8Array | null +): string | null { + if (!input) return null; + const plaintext = typeof input.value === 'string' ? input.value.trim() : ''; + if (plaintext) return plaintext; + if (!key) return null; + if (!input.encryptedValue) return null; + return decryptSecretString(input.encryptedValue, key); +} + +function isPlainObject(value: unknown): value is Record<string, unknown> { + if (!value || typeof value !== 'object') return false; + if (Array.isArray(value)) return false; + const proto = Object.getPrototypeOf(value); + return proto === Object.prototype || proto === null; +} + +/** + * Seal plaintext secrets in an arbitrary object graph. + * + * Contract: + * - Any object with `_isSecretValue: true` is treated as a secret container (`SecretStringSchema`). + * - If it also contains a non-empty plaintext `value`, we encrypt it into `encryptedValue` and delete `value`. + * + * This is intentionally schema-independent so it works for any future secret fields as long as + * they follow the same `{ value, encryptedValue }` convention. + */ +export function sealSecretsDeep<T>(input: T, key: Uint8Array | null): T { + if (!key) return input; + + if (Array.isArray(input)) { + // Fast path: avoid allocating a new array unless at least one element changes. + let out: any[] | null = null; + for (let i = 0; i < input.length; i++) { + const item = (input as any)[i]; + const sealed = sealSecretsDeep(item, key); + if (out) { + out[i] = sealed; + continue; + } + if (sealed !== item) { + // First change: allocate and copy prefix. + out = new Array(input.length); + for (let j = 0; j < i; j++) out[j] = (input as any)[j]; + out[i] = sealed; + } + } + return (out ? out : input) as any; + } + + if (!isPlainObject(input)) return input; + + // If this object is a secret container, seal it. + if ((input as any)._isSecretValue === true) { + const value = typeof (input as any).value === 'string' ? String((input as any).value).trim() : ''; + if (value.length > 0) { + const encryptedValue = encryptSecretString(value, key); + const { value: _dropped, ...rest } = input as any; + return { ...rest, encryptedValue } as any; + } + // No plaintext present; nothing to do. + return input as any; + } + + // Otherwise recurse through keys. + let out: any = input; + for (const [k, v] of Object.entries(input)) { + const sealedChild = sealSecretsDeep(v, key); + if (sealedChild !== v) { + if (out === input) out = { ...(input as any) }; + out[k] = sealedChild; + } + } + return out; +} + diff --git a/expo-app/sources/sync/serverConfig.ts b/expo-app/sources/sync/serverConfig.ts index fedea04df..b52f452d0 100644 --- a/expo-app/sources/sync/serverConfig.ts +++ b/expo-app/sources/sync/serverConfig.ts @@ -1,7 +1,10 @@ import { MMKV } from 'react-native-mmkv'; +import { readStorageScopeFromEnv, scopedStorageId } from '@/utils/storageScope'; // Separate MMKV instance for server config that persists across logouts -const serverConfigStorage = new MMKV({ id: 'server-config' }); +const isWebRuntime = typeof window !== 'undefined' && typeof document !== 'undefined'; +const serverConfigScope = isWebRuntime ? null : readStorageScopeFromEnv(); +const serverConfigStorage = new MMKV({ id: scopedStorageId('server-config', serverConfigScope) }); const SERVER_KEY = 'custom-server-url'; const DEFAULT_SERVER_URL = 'https://api.cluster-fluster.com'; diff --git a/expo-app/sources/sync/sessionListViewData.test.ts b/expo-app/sources/sync/sessionListViewData.test.ts new file mode 100644 index 000000000..98e1cd219 --- /dev/null +++ b/expo-app/sources/sync/sessionListViewData.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from 'vitest'; +import type { Machine, Session } from './storageTypes'; +import { buildSessionListViewData } from './sessionListViewData'; + +function makeSession(partial: Partial<Session> & Pick<Session, 'id'>): Session { + const active = partial.active ?? false; + const createdAt = partial.createdAt ?? 0; + const activeAt = partial.activeAt ?? createdAt; + const updatedAt = partial.updatedAt ?? createdAt; + return { + id: partial.id, + seq: partial.seq ?? 0, + createdAt, + updatedAt, + active, + activeAt, + metadata: partial.metadata ?? null, + metadataVersion: partial.metadataVersion ?? 0, + agentState: partial.agentState ?? null, + agentStateVersion: partial.agentStateVersion ?? 0, + thinking: partial.thinking ?? false, + thinkingAt: partial.thinkingAt ?? 0, + presence: active ? 'online' : activeAt, + todos: partial.todos, + draft: partial.draft, + permissionMode: partial.permissionMode ?? null, + permissionModeUpdatedAt: partial.permissionModeUpdatedAt ?? null, + modelMode: partial.modelMode ?? null, + latestUsage: partial.latestUsage ?? null, + }; +} + +function makeMachine(partial: Partial<Machine> & Pick<Machine, 'id'>): Machine { + const createdAt = partial.createdAt ?? 0; + const active = partial.active ?? false; + const activeAt = partial.activeAt ?? createdAt; + return { + id: partial.id, + seq: partial.seq ?? 0, + createdAt, + updatedAt: partial.updatedAt ?? createdAt, + active, + activeAt, + metadata: partial.metadata ?? null, + metadataVersion: partial.metadataVersion ?? 0, + daemonState: partial.daemonState ?? null, + daemonStateVersion: partial.daemonStateVersion ?? 0, + }; +} + +describe('buildSessionListViewData', () => { + it('groups inactive sessions by machine+path when enabled', () => { + const machineA = makeMachine({ id: 'm1', metadata: { host: 'm1', platform: 'darwin', happyCliVersion: '0.0.0', happyHomeDir: '/h', homeDir: '/home/u' } }); + const machineB = makeMachine({ id: 'm2', metadata: { host: 'm2', platform: 'darwin', happyCliVersion: '0.0.0', happyHomeDir: '/h', homeDir: '/home/u' } }); + + const sessions: Record<string, Session> = { + active: makeSession({ + id: 'active', + active: true, + createdAt: 1, + updatedAt: 50, + metadata: { machineId: 'm1', path: '/home/u/repoA', homeDir: '/home/u', host: 'm1', version: '0.0.0', flavor: 'claude' }, + }), + a1: makeSession({ + id: 'a1', + createdAt: 2, + updatedAt: 100, + metadata: { machineId: 'm1', path: '/home/u/repoA', homeDir: '/home/u', host: 'm1', version: '0.0.0', flavor: 'claude' }, + }), + a2: makeSession({ + id: 'a2', + createdAt: 3, + updatedAt: 200, + metadata: { machineId: 'm1', path: '/home/u/repoA', homeDir: '/home/u', host: 'm1', version: '0.0.0', flavor: 'claude' }, + }), + b1: makeSession({ + id: 'b1', + createdAt: 4, + updatedAt: 150, + metadata: { machineId: 'm2', path: '/home/u/repoB', homeDir: '/home/u', host: 'm2', version: '0.0.0', flavor: 'claude' }, + }), + }; + + const machines: Record<string, Machine> = { + [machineA.id]: machineA, + [machineB.id]: machineB, + }; + + const data = buildSessionListViewData(sessions, machines, { groupInactiveSessionsByProject: true }); + + const summary = data.map((item) => { + switch (item.type) { + case 'active-sessions': + return `active:${item.sessions.map((s) => s.id).join(',')}`; + case 'project-group': + return `group:${item.machine.id}:${item.displayPath}`; + case 'session': + return `session:${item.session.id}:${item.variant ?? 'default'}`; + case 'header': + return `header:${item.title}`; + } + }); + + expect(summary).toEqual([ + 'active:active', + 'group:m1:~/repoA', + 'session:a2:no-path', + 'session:a1:no-path', + 'group:m2:~/repoB', + 'session:b1:no-path', + ]); + }); + + it('does not treat /home/userfoo as inside /home/user', () => { + const machine = makeMachine({ id: 'm1', metadata: { host: 'm1', platform: 'darwin', happyCliVersion: '0.0.0', happyHomeDir: '/h', homeDir: '/home/user' } }); + + const sessions: Record<string, Session> = { + s1: makeSession({ + id: 's1', + createdAt: 1, + updatedAt: 2, + metadata: { machineId: 'm1', path: '/home/userfoo/repo', homeDir: '/home/user', host: 'm1', version: '0.0.0', flavor: 'claude' }, + }), + }; + + const data = buildSessionListViewData(sessions, { [machine.id]: machine }, { groupInactiveSessionsByProject: true }); + const group = data.find((i) => i.type === 'project-group') as any; + expect(group?.displayPath).toBe('/home/userfoo/repo'); + }); +}); diff --git a/expo-app/sources/sync/sessionListViewData.ts b/expo-app/sources/sync/sessionListViewData.ts new file mode 100644 index 000000000..37915a2b2 --- /dev/null +++ b/expo-app/sources/sync/sessionListViewData.ts @@ -0,0 +1,187 @@ +import type { Machine, Session } from './storageTypes'; + +export type SessionListViewItem = + | { type: 'header'; title: string } + | { type: 'active-sessions'; sessions: Session[] } + | { type: 'project-group'; displayPath: string; machine: Machine } + | { type: 'session'; session: Session; variant?: 'default' | 'no-path' }; + +export interface BuildSessionListViewDataOptions { + groupInactiveSessionsByProject: boolean; +} + +function isSessionActive(session: { active: boolean }): boolean { + return session.active; +} + +function formatPathRelativeToHome(path: string, homeDir?: string | null): string { + if (!homeDir) return path; + + const normalizedHome = homeDir.endsWith('/') ? homeDir.slice(0, -1) : homeDir; + const isInHome = path === normalizedHome || path.startsWith(`${normalizedHome}/`); + if (!isInHome) { + return path; + } + + const relativePath = path.slice(normalizedHome.length); + return relativePath ? `~${relativePath}` : '~'; +} + +function makeUnknownMachine(id: string): Machine { + return { + id, + seq: 0, + createdAt: 0, + updatedAt: 0, + active: false, + activeAt: 0, + metadata: null, + metadataVersion: 0, + daemonState: null, + daemonStateVersion: 0, + }; +} + +export function buildSessionListViewData( + sessions: Record<string, Session>, + machines: Record<string, Machine>, + options: BuildSessionListViewDataOptions +): SessionListViewItem[] { + const activeSessions: Session[] = []; + const inactiveSessions: Session[] = []; + + Object.values(sessions).forEach((session) => { + if (isSessionActive(session)) { + activeSessions.push(session); + } else { + inactiveSessions.push(session); + } + }); + + activeSessions.sort((a, b) => b.updatedAt - a.updatedAt); + inactiveSessions.sort((a, b) => b.updatedAt - a.updatedAt); + + const listData: SessionListViewItem[] = []; + + if (activeSessions.length > 0) { + listData.push({ type: 'active-sessions', sessions: activeSessions }); + } + + if (options.groupInactiveSessionsByProject && inactiveSessions.length > 0) { + type ProjectGroup = { + key: string; + displayPath: string; + machine: Machine; + latestUpdatedAt: number; + sessions: Session[]; + }; + + const groups = new Map<string, ProjectGroup>(); + + for (const session of inactiveSessions) { + const machineId = session.metadata?.machineId || 'unknown'; + const path = session.metadata?.path || ''; + const key = `${machineId}:${path}`; + + const existing = groups.get(key); + if (!existing) { + groups.set(key, { + key, + displayPath: path ? formatPathRelativeToHome(path, session.metadata?.homeDir) : '', + machine: machines[machineId] ?? makeUnknownMachine(machineId), + latestUpdatedAt: session.updatedAt, + sessions: [session], + }); + } else { + existing.sessions.push(session); + existing.latestUpdatedAt = Math.max(existing.latestUpdatedAt, session.updatedAt); + } + } + + const sortedGroups = Array.from(groups.values()).sort((a, b) => { + if (b.latestUpdatedAt !== a.latestUpdatedAt) return b.latestUpdatedAt - a.latestUpdatedAt; + if (a.displayPath !== b.displayPath) return a.displayPath.localeCompare(b.displayPath); + return a.key.localeCompare(b.key); + }); + + for (const group of sortedGroups) { + group.sessions.sort((a, b) => b.updatedAt - a.updatedAt); + + const hasGroupHeader = Boolean(group.displayPath); + if (hasGroupHeader) { + listData.push({ type: 'project-group', displayPath: group.displayPath, machine: group.machine }); + } + + const variant: 'default' | 'no-path' = hasGroupHeader ? 'no-path' : 'default'; + group.sessions.forEach((session) => { + listData.push({ type: 'session', session, variant }); + }); + } + + return listData; + } + + // Group inactive sessions by date + const now = new Date(); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const yesterday = new Date(today.getTime() - 24 * 60 * 60 * 1000); + + let currentDateGroup: Session[] = []; + let currentDateString: string | null = null; + + for (const session of inactiveSessions) { + const sessionDate = new Date(session.updatedAt); + const dateString = sessionDate.toDateString(); + + if (currentDateString !== dateString) { + if (currentDateGroup.length > 0 && currentDateString) { + const groupDate = new Date(currentDateString); + const sessionDateOnly = new Date(groupDate.getFullYear(), groupDate.getMonth(), groupDate.getDate()); + + let headerTitle: string; + if (sessionDateOnly.getTime() === today.getTime()) { + headerTitle = 'Today'; + } else if (sessionDateOnly.getTime() === yesterday.getTime()) { + headerTitle = 'Yesterday'; + } else { + const diffTime = today.getTime() - sessionDateOnly.getTime(); + const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24)); + headerTitle = `${diffDays} days ago`; + } + + listData.push({ type: 'header', title: headerTitle }); + currentDateGroup.forEach((sess) => { + listData.push({ type: 'session', session: sess }); + }); + } + + currentDateString = dateString; + currentDateGroup = [session]; + } else { + currentDateGroup.push(session); + } + } + + if (currentDateGroup.length > 0 && currentDateString) { + const groupDate = new Date(currentDateString); + const sessionDateOnly = new Date(groupDate.getFullYear(), groupDate.getMonth(), groupDate.getDate()); + + let headerTitle: string; + if (sessionDateOnly.getTime() === today.getTime()) { + headerTitle = 'Today'; + } else if (sessionDateOnly.getTime() === yesterday.getTime()) { + headerTitle = 'Yesterday'; + } else { + const diffTime = today.getTime() - sessionDateOnly.getTime(); + const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24)); + headerTitle = `${diffDays} days ago`; + } + + listData.push({ type: 'header', title: headerTitle }); + currentDateGroup.forEach((sess) => { + listData.push({ type: 'session', session: sess }); + }); + } + + return listData; +} diff --git a/expo-app/sources/sync/settings.spec.ts b/expo-app/sources/sync/settings.spec.ts index 4f36ce46f..188ce5d99 100644 --- a/expo-app/sources/sync/settings.spec.ts +++ b/expo-app/sources/sync/settings.spec.ts @@ -3,6 +3,11 @@ import { settingsParse, applySettings, settingsDefaults, type Settings, AIBacken import { getBuiltInProfile } from './profileUtils'; describe('settings', () => { + const makeSettings = (overrides: Partial<Settings> = {}): Settings => ({ + ...settingsDefaults, + ...overrides, + }); + describe('settingsParse', () => { it('should return defaults when given invalid input', () => { expect(settingsParse(null)).toEqual(settingsDefaults); @@ -89,148 +94,122 @@ describe('settings', () => { } }); }); + + it('should default per-experiment toggles to true when experiments is true (migration)', () => { + const parsed = settingsParse({ + experiments: true, + // Note: per-experiment keys intentionally omitted (older clients) + } as any); + + expect((parsed as any).expUsageReporting).toBe(true); + expect((parsed as any).expFileViewer).toBe(true); + expect((parsed as any).expShowThinkingMessages).toBe(true); + expect((parsed as any).expSessionType).toBe(true); + expect((parsed as any).expZen).toBe(true); + expect((parsed as any).expVoiceAuthFlow).toBe(true); + expect((parsed as any).expInboxFriends).toBe(true); + }); + + it('should default per-experiment toggles to false when experiments is false (migration)', () => { + const parsed = settingsParse({ + experiments: false, + // Note: per-experiment keys intentionally omitted (older clients) + } as any); + + expect((parsed as any).expUsageReporting).toBe(false); + expect((parsed as any).expFileViewer).toBe(false); + expect((parsed as any).expShowThinkingMessages).toBe(false); + expect((parsed as any).expSessionType).toBe(false); + expect((parsed as any).expZen).toBe(false); + expect((parsed as any).expVoiceAuthFlow).toBe(false); + expect((parsed as any).expInboxFriends).toBe(false); + }); + + it('defaults per-agent new-session permission modes', () => { + const parsed = settingsParse({} as any); + expect((parsed as any).sessionDefaultPermissionModeByAgent?.claude).toBe('default'); + expect((parsed as any).sessionDefaultPermissionModeByAgent?.codex).toBe('default'); + expect((parsed as any).sessionDefaultPermissionModeByAgent?.gemini).toBe('default'); + }); + + it('migrates legacy lastUsedPermissionMode into per-agent defaults when missing', () => { + const parsed = settingsParse({ + lastUsedAgent: 'claude', + lastUsedPermissionMode: 'plan', + } as any); + expect((parsed as any).sessionDefaultPermissionModeByAgent?.claude).toBe('plan'); + expect((parsed as any).sessionDefaultPermissionModeByAgent?.codex).toBe('safe-yolo'); + expect((parsed as any).sessionDefaultPermissionModeByAgent?.gemini).toBe('safe-yolo'); + }); + + it('should preserve explicit per-experiment toggles when present (no forced override)', () => { + const parsed = settingsParse({ + experiments: true, + expUsageReporting: true, + expFileViewer: false, + expShowThinkingMessages: true, + expSessionType: false, + expZen: true, + expVoiceAuthFlow: false, + expInboxFriends: false, + } as any); + + expect((parsed as any).expUsageReporting).toBe(true); + expect((parsed as any).expFileViewer).toBe(false); + expect((parsed as any).expShowThinkingMessages).toBe(true); + expect((parsed as any).expSessionType).toBe(false); + expect((parsed as any).expZen).toBe(true); + expect((parsed as any).expVoiceAuthFlow).toBe(false); + expect((parsed as any).expInboxFriends).toBe(false); + }); + + it('should keep valid secrets when one secret entry is invalid', () => { + const validSecret = { + id: 'secret-1', + name: 'My Secret', + kind: 'apiKey', + encryptedValue: { _isSecretValue: true, value: 'abc' }, + createdAt: 1, + updatedAt: 1, + }; + const invalidSecret = { + id: '', + name: '', + kind: 'apiKey', + encryptedValue: { _isSecretValue: true, value: 'def' }, + createdAt: 2, + updatedAt: 2, + }; + const parsed = settingsParse({ + viewInline: true, + secrets: [validSecret, invalidSecret], + } as any); + + expect(parsed.viewInline).toBe(true); + expect(parsed.secrets).toEqual([validSecret]); + }); }); describe('applySettings', () => { it('should apply delta to existing settings', () => { - const currentSettings: Settings = { - schemaVersion: 1, - viewInline: false, - expandTodos: true, - showLineNumbers: true, - showLineNumbersInToolViews: false, - wrapLinesInDiffs: false, - analyticsOptOut: false, - inferenceOpenAIKey: null, - experiments: false, - useEnhancedSessionWizard: false, - alwaysShowContextSize: false, - agentInputEnterToSend: true, - avatarStyle: 'gradient', - showFlavorIcons: false, - compactSessionView: false, - hideInactiveSessions: false, - reviewPromptAnswered: false, - reviewPromptLikedApp: null, - voiceAssistantLanguage: null, - preferredLanguage: null, - recentMachinePaths: [], - lastUsedAgent: null, - lastUsedPermissionMode: null, - lastUsedModelMode: null, - profiles: [], - lastUsedProfile: null, - favoriteDirectories: [], - favoriteMachines: [], - dismissedCLIWarnings: { perMachine: {}, global: {} }, - }; - const delta: Partial<Settings> = { - viewInline: true - }; + const currentSettings = makeSettings({ schemaVersion: 1, avatarStyle: 'gradient' }); + const delta: Partial<Settings> = { viewInline: true }; expect(applySettings(currentSettings, delta)).toEqual({ + ...currentSettings, schemaVersion: 1, // Preserved from currentSettings viewInline: true, - expandTodos: true, - showLineNumbers: true, - showLineNumbersInToolViews: false, - wrapLinesInDiffs: false, - analyticsOptOut: false, - inferenceOpenAIKey: null, - experiments: false, - useEnhancedSessionWizard: false, - alwaysShowContextSize: false, - agentInputEnterToSend: true, - avatarStyle: 'gradient', // This should be preserved from currentSettings - showFlavorIcons: false, - compactSessionView: false, - hideInactiveSessions: false, - reviewPromptAnswered: false, - reviewPromptLikedApp: null, - voiceAssistantLanguage: null, - preferredLanguage: null, - recentMachinePaths: [], - lastUsedAgent: null, - lastUsedPermissionMode: null, - lastUsedModelMode: null, - profiles: [], - lastUsedProfile: null, - favoriteDirectories: [], - favoriteMachines: [], - dismissedCLIWarnings: { perMachine: {}, global: {} }, }); }); it('should merge with defaults', () => { - const currentSettings: Settings = { - schemaVersion: 1, - viewInline: true, - expandTodos: true, - showLineNumbers: true, - showLineNumbersInToolViews: false, - wrapLinesInDiffs: false, - analyticsOptOut: false, - inferenceOpenAIKey: null, - experiments: false, - useEnhancedSessionWizard: false, - alwaysShowContextSize: false, - agentInputEnterToSend: true, - avatarStyle: 'gradient', - showFlavorIcons: false, - compactSessionView: false, - hideInactiveSessions: false, - reviewPromptAnswered: false, - reviewPromptLikedApp: null, - voiceAssistantLanguage: null, - preferredLanguage: null, - recentMachinePaths: [], - lastUsedAgent: null, - lastUsedPermissionMode: null, - lastUsedModelMode: null, - profiles: [], - lastUsedProfile: null, - favoriteDirectories: [], - favoriteMachines: [], - dismissedCLIWarnings: { perMachine: {}, global: {} }, - }; + const currentSettings = makeSettings({ schemaVersion: 1, avatarStyle: 'gradient', viewInline: true }); const delta: Partial<Settings> = {}; expect(applySettings(currentSettings, delta)).toEqual(currentSettings); }); it('should override existing values with delta', () => { - const currentSettings: Settings = { - schemaVersion: 1, - viewInline: true, - expandTodos: true, - showLineNumbers: true, - showLineNumbersInToolViews: false, - wrapLinesInDiffs: false, - analyticsOptOut: false, - inferenceOpenAIKey: null, - experiments: false, - useEnhancedSessionWizard: false, - alwaysShowContextSize: false, - agentInputEnterToSend: true, - avatarStyle: 'gradient', - showFlavorIcons: false, - compactSessionView: false, - hideInactiveSessions: false, - reviewPromptAnswered: false, - reviewPromptLikedApp: null, - voiceAssistantLanguage: null, - preferredLanguage: null, - recentMachinePaths: [], - lastUsedAgent: null, - lastUsedPermissionMode: null, - lastUsedModelMode: null, - profiles: [], - lastUsedProfile: null, - favoriteDirectories: [], - favoriteMachines: [], - dismissedCLIWarnings: { perMachine: {}, global: {} }, - }; - const delta: Partial<Settings> = { - viewInline: false - }; + const currentSettings = makeSettings({ schemaVersion: 1, avatarStyle: 'gradient', viewInline: true }); + const delta: Partial<Settings> = { viewInline: false }; expect(applySettings(currentSettings, delta)).toEqual({ ...currentSettings, viewInline: false @@ -238,37 +217,7 @@ describe('settings', () => { }); it('should handle empty delta', () => { - const currentSettings: Settings = { - schemaVersion: 1, - viewInline: true, - expandTodos: true, - showLineNumbers: true, - showLineNumbersInToolViews: false, - wrapLinesInDiffs: false, - analyticsOptOut: false, - inferenceOpenAIKey: null, - experiments: false, - useEnhancedSessionWizard: false, - alwaysShowContextSize: false, - agentInputEnterToSend: true, - avatarStyle: 'gradient', - showFlavorIcons: false, - compactSessionView: false, - hideInactiveSessions: false, - reviewPromptAnswered: false, - reviewPromptLikedApp: null, - voiceAssistantLanguage: null, - preferredLanguage: null, - recentMachinePaths: [], - lastUsedAgent: null, - lastUsedPermissionMode: null, - lastUsedModelMode: null, - profiles: [], - lastUsedProfile: null, - favoriteDirectories: [], - favoriteMachines: [], - dismissedCLIWarnings: { perMachine: {}, global: {} }, - }; + const currentSettings = makeSettings({ schemaVersion: 1, avatarStyle: 'gradient', viewInline: true }); expect(applySettings(currentSettings, {})).toEqual(currentSettings); }); @@ -288,37 +237,7 @@ describe('settings', () => { }); it('should handle extra fields in delta', () => { - const currentSettings: Settings = { - schemaVersion: 1, - viewInline: true, - expandTodos: true, - showLineNumbers: true, - showLineNumbersInToolViews: false, - wrapLinesInDiffs: false, - analyticsOptOut: false, - inferenceOpenAIKey: null, - experiments: false, - useEnhancedSessionWizard: false, - alwaysShowContextSize: false, - agentInputEnterToSend: true, - avatarStyle: 'gradient', - showFlavorIcons: false, - compactSessionView: false, - hideInactiveSessions: false, - reviewPromptAnswered: false, - reviewPromptLikedApp: null, - voiceAssistantLanguage: null, - preferredLanguage: null, - recentMachinePaths: [], - lastUsedAgent: null, - lastUsedPermissionMode: null, - lastUsedModelMode: null, - profiles: [], - lastUsedProfile: null, - favoriteDirectories: [], - favoriteMachines: [], - dismissedCLIWarnings: { perMachine: {}, global: {} }, - }; + const currentSettings = makeSettings({ schemaVersion: 1, avatarStyle: 'gradient', viewInline: true }); const delta: any = { viewInline: false, newField: 'new value' @@ -350,37 +269,18 @@ describe('settings', () => { describe('settingsDefaults', () => { it('should have correct default values', () => { - expect(settingsDefaults).toEqual({ - schemaVersion: 2, - viewInline: false, - expandTodos: true, - showLineNumbers: true, - showLineNumbersInToolViews: false, - wrapLinesInDiffs: false, - analyticsOptOut: false, - inferenceOpenAIKey: null, - experiments: false, - alwaysShowContextSize: false, - avatarStyle: 'brutalist', - showFlavorIcons: false, - compactSessionView: false, - agentInputEnterToSend: true, - hideInactiveSessions: false, - reviewPromptAnswered: false, - reviewPromptLikedApp: null, - voiceAssistantLanguage: null, - preferredLanguage: null, - recentMachinePaths: [], - lastUsedAgent: null, - lastUsedPermissionMode: null, - lastUsedModelMode: null, - profiles: [], - lastUsedProfile: null, - favoriteDirectories: ['~/src', '~/Desktop', '~/Documents'], - favoriteMachines: [], - dismissedCLIWarnings: { perMachine: {}, global: {} }, - useEnhancedSessionWizard: false, + expect(settingsDefaults.schemaVersion).toBe(2); + expect(settingsDefaults.experiments).toBe(false); + expect(settingsDefaults.experimentalAgents).toEqual({}); + expect(settingsDefaults.sessionDefaultPermissionModeByAgent).toMatchObject({ + claude: 'default', + codex: 'default', + gemini: 'default', }); + expect((settingsDefaults as any).expGemini).toBeUndefined(); + expect((settingsDefaults as any).sessionDefaultPermissionModeClaude).toBeUndefined(); + expect((settingsDefaults as any).sessionDefaultPermissionModeCodex).toBeUndefined(); + expect((settingsDefaults as any).sessionDefaultPermissionModeGemini).toBeUndefined(); }); it('should be a valid Settings object', () => { @@ -389,6 +289,17 @@ describe('settings', () => { }); }); + describe('profiles', () => { + it('accepts the built-in profiles schema', () => { + const profile = getBuiltInProfile('anthropic'); + expect(profile).toBeTruthy(); + const parsed = AIBackendProfileSchema.safeParse(profile); + expect(parsed.success).toBe(true); + }); + }); + + // Keep the remainder of the file intact; avoid pinning full defaults objects in tests. + describe('forward/backward compatibility', () => { it('should handle settings from older version (missing new fields)', () => { const oldVersionSettings = {}; @@ -495,6 +406,30 @@ describe('settings', () => { expect(() => AIBackendProfileSchema.parse(profile)).not.toThrow(); }); + it('validates built-in Codex profile', () => { + const profile = getBuiltInProfile('codex'); + expect(profile).not.toBeNull(); + expect(() => AIBackendProfileSchema.parse(profile)).not.toThrow(); + }); + + it('validates built-in Gemini profile', () => { + const profile = getBuiltInProfile('gemini'); + expect(profile).not.toBeNull(); + expect(() => AIBackendProfileSchema.parse(profile)).not.toThrow(); + }); + + it('validates built-in Gemini API key profile', () => { + const profile = getBuiltInProfile('gemini-api-key'); + expect(profile).not.toBeNull(); + expect(() => AIBackendProfileSchema.parse(profile)).not.toThrow(); + }); + + it('validates built-in Gemini Vertex profile', () => { + const profile = getBuiltInProfile('gemini-vertex'); + expect(profile).not.toBeNull(); + expect(() => AIBackendProfileSchema.parse(profile)).not.toThrow(); + }); + it('accepts all 7 permission modes', () => { const modes = ['default', 'acceptEdits', 'bypassPermissions', 'plan', 'read-only', 'safe-yolo', 'yolo']; modes.forEach(mode => { @@ -542,6 +477,88 @@ describe('settings', () => { }; expect(() => AIBackendProfileSchema.parse(invalidProfile)).toThrow(); }); + + it('accepts profiles with multiple required secret env vars', () => { + const profile = { + id: crypto.randomUUID(), + name: 'Test Profile', + envVarRequirements: [ + { name: 'OPENAI_API_KEY', kind: 'secret', required: true }, + { name: 'ANTHROPIC_AUTH_TOKEN', kind: 'secret', required: true }, + ], + compatibility: { claude: true, codex: true, gemini: true }, + }; + expect(() => AIBackendProfileSchema.parse(profile)).not.toThrow(); + }); + + it('accepts machine-login profiles that also declare secret requirements', () => { + const profile = { + id: crypto.randomUUID(), + name: 'Test Profile', + authMode: 'machineLogin', + requiresMachineLogin: 'claude-code', + envVarRequirements: [{ name: 'OPENAI_API_KEY', kind: 'secret', required: true }], + compatibility: { claude: true, codex: true, gemini: true }, + }; + expect(() => AIBackendProfileSchema.parse(profile)).not.toThrow(); + }); + + it('rejects requiresMachineLogin when authMode is not machineLogin', () => { + const invalidProfile = { + id: crypto.randomUUID(), + name: 'Test Profile', + authMode: undefined, + requiresMachineLogin: 'claude-code', + envVarRequirements: [], + compatibility: { claude: true, codex: true, gemini: true }, + }; + expect(() => AIBackendProfileSchema.parse(invalidProfile)).toThrow(); + }); + }); + + describe('SavedSecret validation', () => { + it('accepts valid secrets entries in settingsParse', () => { + const now = Date.now(); + const parsed = settingsParse({ + secrets: [ + { id: 'k1', name: 'My Secret', kind: 'apiKey', encryptedValue: { _isSecretValue: true, value: 'sk-test' }, createdAt: now, updatedAt: now }, + ], + }); + expect(parsed.secrets.length).toBe(1); + expect(parsed.secrets[0]?.name).toBe('My Secret'); + // settingsParse should tolerate plaintext values (legacy/input form), + // but the runtime should seal them before persisting. + expect(parsed.secrets[0]?.encryptedValue?.value).toBe('sk-test'); + }); + + it('drops invalid secrets entries (missing value)', () => { + const parsed = settingsParse({ + secrets: [ + { id: 'k1', name: 'Missing value', kind: 'apiKey', encryptedValue: { _isSecretValue: true } }, + ], + } as any); + // settingsParse validates per-field, so invalid field should fall back to default. + expect(parsed.secrets).toEqual([]); + }); + + it('accepts encrypted-at-rest secrets entries (SecretString.encryptedValue)', () => { + const now = Date.now(); + const parsed = settingsParse({ + secrets: [ + { id: 'k1', name: 'My Secret', kind: 'apiKey', encryptedValue: { _isSecretValue: true, encryptedValue: { t: 'enc-v1', c: 'Zm9v' } }, createdAt: now, updatedAt: now }, + ], + } as any); + expect(parsed.secrets.length).toBe(1); + expect(parsed.secrets[0]?.name).toBe('My Secret'); + expect(parsed.secrets[0]?.encryptedValue?.encryptedValue?.t).toBe('enc-v1'); + }); + }); + + describe('secretBindingsByProfileId', () => { + it('defaults to an empty object', () => { + const parsed = settingsParse({}); + expect(parsed.secretBindingsByProfileId).toEqual({}); + }); }); describe('version-mismatch scenario (bug fix)', () => { @@ -560,9 +577,10 @@ describe('settings', () => { { id: 'server-profile', name: 'Server Profile', - anthropicConfig: {}, environmentVariables: [], + defaultPermissionModeByAgent: {}, compatibility: { claude: true, codex: true, gemini: true }, + envVarRequirements: [], isBuiltIn: false, createdAt: Date.now(), updatedAt: Date.now(), @@ -578,9 +596,10 @@ describe('settings', () => { { id: 'local-profile', name: 'Local Profile', - anthropicConfig: {}, environmentVariables: [], + defaultPermissionModeByAgent: {}, compatibility: { claude: true, codex: true, gemini: true }, + envVarRequirements: [], isBuiltIn: false, createdAt: Date.now(), updatedAt: Date.now(), @@ -680,9 +699,10 @@ describe('settings', () => { profiles: [{ id: 'test-profile', name: 'Test', - anthropicConfig: {}, environmentVariables: [], + defaultPermissionModeByAgent: {}, compatibility: { claude: true, codex: true, gemini: true }, + envVarRequirements: [], isBuiltIn: false, createdAt: Date.now(), updatedAt: Date.now(), @@ -713,7 +733,6 @@ describe('settings', () => { profiles: [{ id: 'device-b-profile', name: 'Device B Profile', - anthropicConfig: {}, environmentVariables: [], compatibility: { claude: true, codex: true }, isBuiltIn: false, @@ -825,17 +844,17 @@ describe('settings', () => { profiles: [{ id: 'server-profile-1', name: 'Server Profile', - anthropicConfig: {}, environmentVariables: [], compatibility: { claude: true, codex: true }, + envVarRequirements: [], isBuiltIn: false, createdAt: 1000, updatedAt: 1000, version: '1.0.0', }], dismissedCLIWarnings: { - perMachine: { 'machine-1': ['warning-1'] }, - global: ['global-warning'] + perMachine: { 'machine-1': { claude: true } }, + global: { codex: true } } }); @@ -844,9 +863,10 @@ describe('settings', () => { profiles: [{ id: 'local-profile-1', name: 'Local Profile', - anthropicConfig: {}, environmentVariables: [], + defaultPermissionModeByAgent: {}, compatibility: { claude: true, codex: true, gemini: true }, + envVarRequirements: [], isBuiltIn: false, createdAt: 2000, updatedAt: 2000, diff --git a/expo-app/sources/sync/settings.ts b/expo-app/sources/sync/settings.ts index 5746c863d..7ab6a58b7 100644 --- a/expo-app/sources/sync/settings.ts +++ b/expo-app/sources/sync/settings.ts @@ -1,95 +1,48 @@ import * as z from 'zod'; +import { dbgSettings, isSettingsSyncDebugEnabled } from './debugSettings'; +import { SecretStringSchema } from './secretSettings'; +import { pruneSecretBindings } from './secretBindings'; +import { PERMISSION_MODES } from '@/constants/PermissionModes'; +import type { AgentType } from './modelOptions'; +import { mapPermissionModeAcrossAgents } from './permissionMapping'; +import type { PermissionMode } from './permissionTypes'; +import { isPermissionMode, normalizePermissionModeForGroup } from './permissionTypes'; +import { AGENT_IDS, DEFAULT_AGENT_ID, getAgentCore, isAgentId, type AgentId } from '@/agents/catalog'; // // Configuration Profile Schema (for environment variable profiles) // -// Environment variable schemas for different AI providers -// Note: baseUrl fields accept either valid URLs or ${VAR} or ${VAR:-default} template strings -const AnthropicConfigSchema = z.object({ - baseUrl: z.string().refine( - (val) => { - if (!val) return true; // Optional - // Allow ${VAR} and ${VAR:-default} template strings - if (/^\$\{[A-Z_][A-Z0-9_]*(:-[^}]*)?\}$/.test(val)) return true; - // Otherwise validate as URL - try { - new URL(val); - return true; - } catch { - return false; - } - }, - { message: 'Must be a valid URL or ${VAR} or ${VAR:-default} template string' } - ).optional(), - authToken: z.string().optional(), - model: z.string().optional(), -}); - -const OpenAIConfigSchema = z.object({ - apiKey: z.string().optional(), - baseUrl: z.string().refine( - (val) => { - if (!val) return true; - // Allow ${VAR} and ${VAR:-default} template strings - if (/^\$\{[A-Z_][A-Z0-9_]*(:-[^}]*)?\}$/.test(val)) return true; - try { - new URL(val); - return true; - } catch { - return false; - } - }, - { message: 'Must be a valid URL or ${VAR} or ${VAR:-default} template string' } - ).optional(), - model: z.string().optional(), -}); - -const AzureOpenAIConfigSchema = z.object({ - apiKey: z.string().optional(), - endpoint: z.string().refine( - (val) => { - if (!val) return true; - // Allow ${VAR} and ${VAR:-default} template strings - if (/^\$\{[A-Z_][A-Z0-9_]*(:-[^}]*)?\}$/.test(val)) return true; - try { - new URL(val); - return true; - } catch { - return false; - } - }, - { message: 'Must be a valid URL or ${VAR} or ${VAR:-default} template string' } - ).optional(), - apiVersion: z.string().optional(), - deploymentName: z.string().optional(), -}); - -const TogetherAIConfigSchema = z.object({ - apiKey: z.string().optional(), - model: z.string().optional(), -}); - -// Tmux configuration schema -const TmuxConfigSchema = z.object({ - sessionName: z.string().optional(), - tmpDir: z.string().optional(), - updateEnvironment: z.boolean().optional(), -}); - // Environment variables schema with validation const EnvironmentVariableSchema = z.object({ name: z.string().regex(/^[A-Z_][A-Z0-9_]*$/, 'Invalid environment variable name'), value: z.string(), + // User override: + // - true: force secret handling in UI (and hint daemon) + // - false: force non-secret handling in UI (unless daemon enforces) + // - undefined: auto classification + isSecret: z.boolean().optional(), }); -// Profile compatibility schema -const ProfileCompatibilitySchema = z.object({ - claude: z.boolean().default(true), - codex: z.boolean().default(true), - gemini: z.boolean().default(true), +const RequiredEnvVarKindSchema = z.enum(['secret', 'config']); + +const EnvVarRequirementSchema = z.object({ + name: z.string().regex(/^[A-Z_][A-Z0-9_]*$/, 'Invalid environment variable name'), + kind: RequiredEnvVarKindSchema.default('secret'), + // Required=true blocks session creation when unsatisfied. + // Required=false is “optional” (still useful for vault binding, but does not block). + required: z.boolean().default(true), }); +const RequiresMachineLoginSchema = z.string().min(1); + +// Profile compatibility schema +const ProfileCompatibilitySchema = z.record(z.string(), z.boolean()).default({}); + +const DEFAULT_SESSION_PERMISSION_MODE_BY_AGENT: Record<AgentId, PermissionMode> = Object.fromEntries( + AGENT_IDS.map((id) => [id, 'default']), +) as any; + export const AIBackendProfileSchema = z.object({ // Accept both UUIDs (user profiles) and simple strings (built-in profiles like 'anthropic') // The isBuiltIn field distinguishes profile types @@ -97,32 +50,36 @@ export const AIBackendProfileSchema = z.object({ name: z.string().min(1).max(100), description: z.string().max(500).optional(), - // Agent-specific configurations - anthropicConfig: AnthropicConfigSchema.optional(), - openaiConfig: OpenAIConfigSchema.optional(), - azureOpenAIConfig: AzureOpenAIConfigSchema.optional(), - togetherAIConfig: TogetherAIConfigSchema.optional(), - - // Tmux configuration - tmuxConfig: TmuxConfigSchema.optional(), - - // Startup bash script (executed before spawning session) - startupBashScript: z.string().optional(), - // Environment variables (validated) environmentVariables: z.array(EnvironmentVariableSchema).default([]), // Default session type for this profile defaultSessionType: z.enum(['simple', 'worktree']).optional(), - // Default permission mode for this profile - defaultPermissionMode: z.enum(['default', 'acceptEdits', 'bypassPermissions', 'plan', 'read-only', 'safe-yolo', 'yolo']).optional(), + // Legacy default permission mode for this profile (kept for backwards compatibility). + defaultPermissionMode: z.enum(PERMISSION_MODES).optional(), + + // Per-agent default permission mode overrides for new sessions when this profile is selected. + // When unset, the account-level per-agent defaults apply. + defaultPermissionModeByAgent: z.record(z.string(), z.enum(PERMISSION_MODES)).default({}), // Default model mode for this profile defaultModelMode: z.string().optional(), // Compatibility metadata - compatibility: ProfileCompatibilitySchema.default({ claude: true, codex: true, gemini: true }), + compatibility: ProfileCompatibilitySchema.default({}), + + // Authentication / requirements metadata (used by UI gating) + // - machineLogin: profile relies on a machine-local CLI login cache + authMode: z.enum(['machineLogin']).optional(), + + // For machine-login profiles, specify which CLI must be logged in on the target machine. + // This is used for UX copy and for optional login-status detection. + requiresMachineLogin: RequiresMachineLoginSchema.optional(), + + // Explicit environment variable requirements for this profile at runtime. + // Secret requirements are satisfied by machine env, vault binding, or “enter once”. + envVarRequirements: z.array(EnvVarRequirementSchema).default([]), // Built-in profile indicator isBuiltIn: z.boolean().default(false), @@ -131,15 +88,89 @@ export const AIBackendProfileSchema = z.object({ createdAt: z.number().default(() => Date.now()), updatedAt: z.number().default(() => Date.now()), version: z.string().default('1.0.0'), -}); +}) + // NOTE: Zod v4 marks `superRefine` as deprecated in favor of `.check(...)`. + // We use chained `.refine(...)` here to preserve per-field error paths/messages. + .refine((profile) => { + return !(profile.requiresMachineLogin && profile.authMode !== 'machineLogin'); + }, { + path: ['requiresMachineLogin'], + message: 'requiresMachineLogin may only be set when authMode=machineLogin', + }); export type AIBackendProfile = z.infer<typeof AIBackendProfileSchema>; +// +// Session / tmux settings +// + +const SessionTmuxMachineOverrideSchema = z.object({ + useTmux: z.boolean(), + sessionName: z.string(), + isolated: z.boolean(), + tmpDir: z.string().nullable(), +}); + +export const SavedSecretSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1).max(100), + kind: z.enum(['apiKey', 'token', 'password', 'other']).default('apiKey'), + // Secret-at-rest container: + // - plaintext is set via `encryptedValue.value` (input only; must not be persisted) + // - ciphertext persists in `encryptedValue.encryptedValue` + encryptedValue: SecretStringSchema, + createdAt: z.number().default(() => Date.now()), + updatedAt: z.number().default(() => Date.now()), +}).refine((key) => { + const hasValue = typeof key.encryptedValue.value === 'string' && key.encryptedValue.value.trim().length > 0; + const hasEnc = Boolean(key.encryptedValue.encryptedValue && typeof key.encryptedValue.encryptedValue.c === 'string' && key.encryptedValue.encryptedValue.c.length > 0); + return hasValue || hasEnc; +}, { + path: ['encryptedValue'], + message: 'Secret must include a value or encrypted value', +}); + +export type SavedSecret = z.infer<typeof SavedSecretSchema>; + // Helper functions for profile validation and compatibility -export function validateProfileForAgent(profile: AIBackendProfile, agent: 'claude' | 'codex' | 'gemini'): boolean { - return profile.compatibility[agent]; +export function isProfileCompatibleWithAgent( + profile: Pick<AIBackendProfile, 'compatibility' | 'isBuiltIn'>, + agentId: AgentId, +): boolean { + const explicit = profile.compatibility?.[agentId]; + if (typeof explicit === 'boolean') return explicit; + return profile.isBuiltIn ? false : true; } +function mergeEnvironmentVariables( + existing: unknown, + additions: Record<string, string | undefined> +): Array<{ name: string; value: string }> { + const map = new Map<string, string>(); + + if (Array.isArray(existing)) { + for (const entry of existing) { + if (!entry || typeof entry !== 'object') continue; + const name = (entry as any).name; + const value = (entry as any).value; + if (typeof name !== 'string' || typeof value !== 'string') continue; + map.set(name, value); + } + } + + for (const [name, value] of Object.entries(additions)) { + if (typeof value !== 'string') continue; + if (!map.has(name)) { + map.set(name, value); + } + } + + return Array.from(map.entries()).map(([name, value]) => ({ name, value })); +} + +// NOTE: We intentionally do NOT support legacy provider config objects (e.g. `openaiConfig`). +// Profiles must use `environmentVariables` + `envVarRequirements` only. + /** * Converts a profile into environment variables for session spawning. * @@ -157,8 +188,8 @@ export function validateProfileForAgent(profile: AIBackendProfile, agent: 'claud * Sent: ANTHROPIC_AUTH_TOKEN=${Z_AI_AUTH_TOKEN} (literal string with placeholder) * * 4. DAEMON EXPANDS ${VAR} from its process.env when spawning session: - * - Tmux mode: Shell expands via `export ANTHROPIC_AUTH_TOKEN="${Z_AI_AUTH_TOKEN}";` before launching - * - Non-tmux mode: Node.js spawn with env: { ...process.env, ...profileEnvVars } (shell expansion in child) + * - Tmux mode: daemon interpolates ${VAR} / ${VAR:-default} / ${VAR:=default} in env values before launching (shells do not expand placeholders inside env values automatically) + * - Non-tmux mode: daemon interpolates ${VAR} / ${VAR:-default} / ${VAR:=default} in env values before calling spawn() (Node does not expand placeholders) * * 5. SESSION RECEIVES actual expanded values: * ANTHROPIC_AUTH_TOKEN=sk-real-key (expanded from daemon's Z_AI_AUTH_TOKEN, not literal ${Z_AI_AUTH_TOKEN}) @@ -172,7 +203,7 @@ export function validateProfileForAgent(profile: AIBackendProfile, agent: 'claud * - Each session uses its selected backend for its entire lifetime (no mid-session switching) * - Keep secrets in shell environment, not in GUI/profile storage * - * PRIORITY ORDER when spawning (daemon/run.ts): + * PRIORITY ORDER when spawning: * Final env = { ...daemon.process.env, ...expandedProfileVars, ...authVars } * authVars override profile, profile overrides daemon.process.env */ @@ -184,45 +215,6 @@ export function getProfileEnvironmentVariables(profile: AIBackendProfile): Recor envVars[envVar.name] = envVar.value; }); - // Add Anthropic config - if (profile.anthropicConfig) { - if (profile.anthropicConfig.baseUrl) envVars.ANTHROPIC_BASE_URL = profile.anthropicConfig.baseUrl; - if (profile.anthropicConfig.authToken) envVars.ANTHROPIC_AUTH_TOKEN = profile.anthropicConfig.authToken; - if (profile.anthropicConfig.model) envVars.ANTHROPIC_MODEL = profile.anthropicConfig.model; - } - - // Add OpenAI config - if (profile.openaiConfig) { - if (profile.openaiConfig.apiKey) envVars.OPENAI_API_KEY = profile.openaiConfig.apiKey; - if (profile.openaiConfig.baseUrl) envVars.OPENAI_BASE_URL = profile.openaiConfig.baseUrl; - if (profile.openaiConfig.model) envVars.OPENAI_MODEL = profile.openaiConfig.model; - } - - // Add Azure OpenAI config - if (profile.azureOpenAIConfig) { - if (profile.azureOpenAIConfig.apiKey) envVars.AZURE_OPENAI_API_KEY = profile.azureOpenAIConfig.apiKey; - if (profile.azureOpenAIConfig.endpoint) envVars.AZURE_OPENAI_ENDPOINT = profile.azureOpenAIConfig.endpoint; - if (profile.azureOpenAIConfig.apiVersion) envVars.AZURE_OPENAI_API_VERSION = profile.azureOpenAIConfig.apiVersion; - if (profile.azureOpenAIConfig.deploymentName) envVars.AZURE_OPENAI_DEPLOYMENT_NAME = profile.azureOpenAIConfig.deploymentName; - } - - // Add Together AI config - if (profile.togetherAIConfig) { - if (profile.togetherAIConfig.apiKey) envVars.TOGETHER_API_KEY = profile.togetherAIConfig.apiKey; - if (profile.togetherAIConfig.model) envVars.TOGETHER_MODEL = profile.togetherAIConfig.model; - } - - // Add Tmux config - if (profile.tmuxConfig) { - // Empty string means "use current/most recent session", so include it - if (profile.tmuxConfig.sessionName !== undefined) envVars.TMUX_SESSION_NAME = profile.tmuxConfig.sessionName; - // Empty string may be valid for tmpDir to use tmux defaults - if (profile.tmuxConfig.tmpDir !== undefined) envVars.TMUX_TMPDIR = profile.tmuxConfig.tmpDir; - if (profile.tmuxConfig.updateEnvironment !== undefined) { - envVars.TMUX_UPDATE_ENVIRONMENT = profile.tmuxConfig.updateEnvironment.toString(); - } - } - return envVars; } @@ -249,6 +241,8 @@ export function isProfileVersionCompatible(profileVersion: string, requiredVersi // // Current schema version for backward compatibility +// NOTE: This schemaVersion is for the Happy app's settings blob (synced via the server). +// happy-cli maintains its own local settings schemaVersion separately. export const SUPPORTED_SCHEMA_VERSION = 2; export const SettingsSchema = z.object({ @@ -263,13 +257,48 @@ export const SettingsSchema = z.object({ wrapLinesInDiffs: z.boolean().describe('Whether to wrap long lines in diff views'), analyticsOptOut: z.boolean().describe('Whether to opt out of anonymous analytics'), experiments: z.boolean().describe('Whether to enable experimental features'), + // Per-agent experimental gating (still subject to `experiments` master switch). + // Unknown keys are supported to avoid schema churn when adding new agents. + experimentalAgents: z.record(z.string(), z.boolean()).default({}).describe('Per-agent experimental toggles'), + // Per-experiment toggles (gated by `experiments` master switch in UI/usage) + expUsageReporting: z.boolean().describe('Experimental: enable usage reporting UI'), + expFileViewer: z.boolean().describe('Experimental: enable session file viewer'), + expShowThinkingMessages: z.boolean().describe('Experimental: show assistant thinking messages'), + expSessionType: z.boolean().describe('Experimental: show session type selector (simple vs worktree)'), + expZen: z.boolean().describe('Experimental: enable Zen navigation/experience'), + expVoiceAuthFlow: z.boolean().describe('Experimental: enable authenticated voice token flow'), + expInboxFriends: z.boolean().describe('Experimental: enable inbox/friends UI + related UX'), + // Intentionally NOT auto-enabled when `experiments` is enabled; this toggles extra local installation + security surface area. + expCodexResume: z.boolean().describe('Experimental: enable Codex vendor-resume and resume-codex installer UI'), + // Experimental configuration for the Codex resume installer (used only when expCodexResume is enabled). + codexResumeInstallSpec: z.string().describe('Codex resume installer spec (npm/git/file); empty uses daemon default'), + // Experimental: route Codex through ACP (codex-acp) instead of MCP. + expCodexAcp: z.boolean().describe('Experimental: enable Codex ACP backend (requires codex-acp install)'), + // Experimental configuration for the Codex ACP installer (used only when expCodexAcp is enabled). + codexAcpInstallSpec: z.string().describe('Codex ACP installer spec (npm/git/file); empty uses daemon default'), + useProfiles: z.boolean().describe('Whether to enable AI backend profiles feature'), useEnhancedSessionWizard: z.boolean().describe('A/B test flag: Use enhanced profile-based session wizard UI'), + // Default permission modes for new sessions (account-level; per agent). + // Values are normalized per-agent when used in UI/session creation. + sessionDefaultPermissionModeByAgent: z.record(z.string(), z.enum(PERMISSION_MODES)).default(DEFAULT_SESSION_PERMISSION_MODE_BY_AGENT).describe('Default permission mode per agent for new sessions'), + sessionUseTmux: z.boolean().describe('Whether new sessions should start in tmux by default'), + sessionTmuxSessionName: z.string().describe('Default tmux session name for new sessions'), + sessionTmuxIsolated: z.boolean().describe('Whether to use an isolated tmux server for new sessions'), + sessionTmuxTmpDir: z.string().nullable().describe('Optional TMUX_TMPDIR override for isolated tmux server'), + sessionTmuxByMachineId: z.record(z.string(), SessionTmuxMachineOverrideSchema).default({}).describe('Per-machine overrides for tmux session spawning'), + // Legacy combined toggle (kept for backward compatibility; see settingsParse migration) + usePickerSearch: z.boolean().describe('Whether to show search in machine/path picker UIs (legacy combined toggle)'), + useMachinePickerSearch: z.boolean().describe('Whether to show search in machine picker UIs'), + usePathPickerSearch: z.boolean().describe('Whether to show search in path picker UIs'), alwaysShowContextSize: z.boolean().describe('Always show context size in agent input'), agentInputEnterToSend: z.boolean().describe('Whether pressing Enter submits/sends in the agent input (web)'), + agentInputActionBarLayout: z.enum(['auto', 'wrap', 'scroll', 'collapsed']).describe('Agent input action bar layout'), + agentInputChipDensity: z.enum(['auto', 'labels', 'icons']).describe('Agent input action chip density'), avatarStyle: z.string().describe('Avatar display style'), showFlavorIcons: z.boolean().describe('Whether to show AI provider icons in avatars'), compactSessionView: z.boolean().describe('Whether to use compact view for active sessions'), hideInactiveSessions: z.boolean().describe('Hide inactive sessions in the main list'), + groupInactiveSessionsByProject: z.boolean().describe('Group inactive sessions by project in the main list'), reviewPromptAnswered: z.boolean().describe('Whether the review prompt has been answered'), reviewPromptLikedApp: z.boolean().nullish().describe('Whether user liked the app when asked'), voiceAssistantLanguage: z.string().nullable().describe('Preferred language for voice assistant (null for auto-detect)'), @@ -281,25 +310,22 @@ export const SettingsSchema = z.object({ lastUsedAgent: z.string().nullable().describe('Last selected agent type for new sessions'), lastUsedPermissionMode: z.string().nullable().describe('Last selected permission mode for new sessions'), lastUsedModelMode: z.string().nullable().describe('Last selected model mode for new sessions'), + sessionMessageSendMode: z.enum(['agent_queue', 'interrupt', 'server_pending']).describe('How the app submits messages while an agent is running'), // Profile management settings profiles: z.array(AIBackendProfileSchema).describe('User-defined profiles for AI backend and environment variables'), lastUsedProfile: z.string().nullable().describe('Last selected profile for new sessions'), + secrets: z.array(SavedSecretSchema).default([]).describe('Saved secrets (encrypted settings). Values are never re-displayed in UI.'), + secretBindingsByProfileId: z.record(z.string(), z.record(z.string(), z.string())).default({}).describe('Default saved secret ID per profile and env var name'), // Favorite directories for quick path selection favoriteDirectories: z.array(z.string()).describe('User-defined favorite directories for quick access in path selection'), // Favorite machines for quick machine selection favoriteMachines: z.array(z.string()).describe('User-defined favorite machines (machine IDs) for quick access in machine selection'), + // Favorite profiles for quick profile selection (built-in or custom profile IDs) + favoriteProfiles: z.array(z.string()).describe('User-defined favorite profiles (profile IDs) for quick access in profile selection'), // Dismissed CLI warning banners (supports both per-machine and global dismissal) dismissedCLIWarnings: z.object({ - perMachine: z.record(z.string(), z.object({ - claude: z.boolean().optional(), - codex: z.boolean().optional(), - gemini: z.boolean().optional(), - })).default({}), - global: z.object({ - claude: z.boolean().optional(), - codex: z.boolean().optional(), - gemini: z.boolean().optional(), - }).default({}), + perMachine: z.record(z.string(), z.record(z.string(), z.boolean()).default({})).default({}), + global: z.record(z.string(), z.boolean()).default({}), }).default({ perMachine: {}, global: {} }).describe('Tracks which CLI installation warnings user has dismissed (per-machine or globally)'), }); @@ -332,13 +358,38 @@ export const settingsDefaults: Settings = { wrapLinesInDiffs: false, analyticsOptOut: false, experiments: false, + experimentalAgents: {}, + expUsageReporting: false, + expFileViewer: false, + expShowThinkingMessages: false, + expSessionType: false, + expZen: false, + expVoiceAuthFlow: false, + expInboxFriends: false, + expCodexResume: false, + codexResumeInstallSpec: '', + expCodexAcp: false, + codexAcpInstallSpec: '', + useProfiles: false, + sessionDefaultPermissionModeByAgent: DEFAULT_SESSION_PERMISSION_MODE_BY_AGENT, + sessionUseTmux: false, + sessionTmuxSessionName: 'happy', + sessionTmuxIsolated: true, + sessionTmuxTmpDir: null, + sessionTmuxByMachineId: {}, useEnhancedSessionWizard: false, + usePickerSearch: false, + useMachinePickerSearch: false, + usePathPickerSearch: false, alwaysShowContextSize: false, agentInputEnterToSend: true, + agentInputActionBarLayout: 'auto', + agentInputChipDensity: 'auto', avatarStyle: 'brutalist', showFlavorIcons: false, compactSessionView: false, hideInactiveSessions: false, + groupInactiveSessionsByProject: false, reviewPromptAnswered: false, reviewPromptLikedApp: null, voiceAssistantLanguage: null, @@ -347,13 +398,18 @@ export const settingsDefaults: Settings = { lastUsedAgent: null, lastUsedPermissionMode: null, lastUsedModelMode: null, + sessionMessageSendMode: 'agent_queue', // Profile management defaults profiles: [], lastUsedProfile: null, - // Default favorite directories (real common directories on Unix-like systems) - favoriteDirectories: ['~/src', '~/Desktop', '~/Documents'], + secrets: [], + secretBindingsByProfileId: {}, + // Favorite directories (empty by default) + favoriteDirectories: [], // Favorite machines (empty by default) favoriteMachines: [], + // Favorite profiles (empty by default) + favoriteProfiles: [], // Dismissed CLI warnings (empty by default) dismissedCLIWarnings: { perMachine: {}, global: {} }, }; @@ -369,28 +425,183 @@ export function settingsParse(settings: unknown): Settings { return { ...settingsDefaults }; } - const parsed = SettingsSchemaPartial.safeParse(settings); - if (!parsed.success) { - // For invalid settings, preserve unknown fields but use defaults for known fields - const unknownFields = { ...(settings as any) }; - // Remove all known schema fields from unknownFields - const knownFields = Object.keys(SettingsSchema.shape); - knownFields.forEach(key => delete unknownFields[key]); - return { ...settingsDefaults, ...unknownFields }; - } + const isDev = typeof __DEV__ !== 'undefined' && __DEV__; + const debug = isSettingsSyncDebugEnabled(); + + // IMPORTANT: be tolerant of partially-invalid settings objects. + // A single invalid field (e.g. one malformed profile) must not reset all other known settings to defaults. + const input = settings as Record<string, unknown>; + const result: any = { ...settingsDefaults }; + + // Parse known fields individually to avoid whole-object failure. + (Object.keys(SettingsSchema.shape) as Array<keyof typeof SettingsSchema.shape>).forEach((key) => { + if (!Object.prototype.hasOwnProperty.call(input, key)) return; + + // Special-case profiles: validate per profile entry, keep valid ones. + if (key === 'profiles') { + const profilesValue = input[key]; + if (Array.isArray(profilesValue)) { + const parsedProfiles: AIBackendProfile[] = []; + for (const rawProfile of profilesValue) { + const parsedProfile = AIBackendProfileSchema.safeParse(rawProfile); + if (parsedProfile.success) { + parsedProfiles.push(parsedProfile.data); + } else if (isDev) { + console.warn('[settingsParse] Dropping invalid profile entry', parsedProfile.error.issues); + } + } + result.profiles = parsedProfiles; + } + return; + } + + // Special-case secrets: validate per secret entry, keep valid ones. + if (key === 'secrets') { + const secretsValue = input[key]; + if (Array.isArray(secretsValue)) { + const parsedSecrets: SavedSecret[] = []; + for (const rawSecret of secretsValue) { + const parsedSecret = SavedSecretSchema.safeParse(rawSecret); + if (parsedSecret.success) { + parsedSecrets.push(parsedSecret.data); + } else if (isDev || debug) { + console.warn('[settingsParse] Dropping invalid secret entry', parsedSecret.error.issues); + } + } + result.secrets = parsedSecrets; + } + return; + } + + const schema = SettingsSchema.shape[key]; + const parsedField = schema.safeParse(input[key]); + if (parsedField.success) { + result[key] = parsedField.data; + } else if (isDev || debug) { + console.warn(`[settingsParse] Invalid settings field "${String(key)}" - using default`, parsedField.error.issues); + if (debug) { + dbgSettings('settingsParse: invalid field', { + key: String(key), + issues: parsedField.error.issues.map((i) => ({ + path: i.path, + code: i.code, + message: i.message, + })), + }); + } + } + }); // Migration: Convert old 'zh' language code to 'zh-Hans' - if (parsed.data.preferredLanguage === 'zh') { - console.log('[Settings Migration] Converting language code from "zh" to "zh-Hans"'); - parsed.data.preferredLanguage = 'zh-Hans'; + if (result.preferredLanguage === 'zh') { + result.preferredLanguage = 'zh-Hans'; + } + + // Migration: Convert legacy combined picker-search toggle into per-picker toggles. + // Only apply if new fields were not present in persisted settings. + const hasMachineSearch = 'useMachinePickerSearch' in input; + const hasPathSearch = 'usePathPickerSearch' in input; + if (!hasMachineSearch && !hasPathSearch) { + const legacy = SettingsSchema.shape.usePickerSearch.safeParse(input.usePickerSearch); + if (legacy.success && legacy.data === true) { + result.useMachinePickerSearch = true; + result.usePathPickerSearch = true; + } + } + + // Migration: Rename terminal/message send settings to session-prefixed names. + // These settings have not been deployed broadly, but we still migrate to avoid breaking local dev devices. + if (!('sessionUseTmux' in input) && 'terminalUseTmux' in input) { + const parsed = z.boolean().safeParse((input as any).terminalUseTmux); + if (parsed.success) result.sessionUseTmux = parsed.data; + } + if (!('sessionTmuxSessionName' in input) && 'terminalTmuxSessionName' in input) { + const parsed = z.string().safeParse((input as any).terminalTmuxSessionName); + if (parsed.success) result.sessionTmuxSessionName = parsed.data; + } + if (!('sessionTmuxIsolated' in input) && 'terminalTmuxIsolated' in input) { + const parsed = z.boolean().safeParse((input as any).terminalTmuxIsolated); + if (parsed.success) result.sessionTmuxIsolated = parsed.data; + } + if (!('sessionTmuxTmpDir' in input) && 'terminalTmuxTmpDir' in input) { + const parsed = z.string().nullable().safeParse((input as any).terminalTmuxTmpDir); + if (parsed.success) result.sessionTmuxTmpDir = parsed.data; + } + if (!('sessionTmuxByMachineId' in input) && 'terminalTmuxByMachineId' in input) { + const parsed = z.record(z.string(), SessionTmuxMachineOverrideSchema).safeParse((input as any).terminalTmuxByMachineId); + if (parsed.success) result.sessionTmuxByMachineId = parsed.data; + } + if (!('sessionMessageSendMode' in input) && 'messageSendMode' in input) { + const parsed = z.enum(['agent_queue', 'interrupt', 'server_pending'] as const).safeParse((input as any).messageSendMode); + if (parsed.success) result.sessionMessageSendMode = parsed.data; + } + + // Migration: introduce per-agent default permission modes for new sessions. + // + // Sources (in priority order): + // 1) New field: `sessionDefaultPermissionModeByAgent` + // 2) Legacy: `lastUsedPermissionMode` + `lastUsedAgent` (seed defaults to preserve user intent) + const hasPerAgentPermissionDefaults = ('sessionDefaultPermissionModeByAgent' in input); + if (!hasPerAgentPermissionDefaults) { + const byAgent: Record<string, PermissionMode> = { ...(result.sessionDefaultPermissionModeByAgent as any) }; + const rawMode = (input as any).lastUsedPermissionMode; + const rawAgent = (input as any).lastUsedAgent; + if (isPermissionMode(rawMode)) { + const from: AgentType = isAgentId(rawAgent) ? rawAgent : DEFAULT_AGENT_ID; + for (const to of AGENT_IDS) { + const mapped = mapPermissionModeAcrossAgents(rawMode as PermissionMode, from, to); + const group = getAgentCore(to).permissions.modeGroup; + byAgent[to] = normalizePermissionModeForGroup(mapped, group); + } + } + + result.sessionDefaultPermissionModeByAgent = byAgent as any; } - // Merge defaults, parsed settings, and preserve unknown fields - const unknownFields = { ...(settings as any) }; - // Remove known fields from unknownFields to preserve only the unknown ones - Object.keys(parsed.data).forEach(key => delete unknownFields[key]); + // Migration: Introduce per-experiment toggles. + // If persisted settings only had `experiments` (older clients), default ALL experiment toggles + // to match the master switch so existing users keep the same behavior. + const experimentKeys = [ + 'expUsageReporting', + 'expFileViewer', + 'expShowThinkingMessages', + 'expSessionType', + 'expZen', + 'expVoiceAuthFlow', + 'expInboxFriends', + ] as const; + const hasAnyExperimentKey = + experimentKeys.some((k) => k in input) || + ('experimentalAgents' in input); + if (!hasAnyExperimentKey) { + const enableAll = result.experiments === true; + for (const key of experimentKeys) { + result[key] = enableAll; + } + } + + const DROPPED_KEYS = new Set([ + // Removed in favor of `defaultPermissionModeByAgent`. + 'defaultPermissionModeClaude', + 'defaultPermissionModeCodex', + 'defaultPermissionModeGemini', + ]); + + // Preserve unknown fields (forward compatibility). + for (const [key, value] of Object.entries(input)) { + if (key === '__proto__') continue; + if (DROPPED_KEYS.has(key)) continue; + if (!Object.prototype.hasOwnProperty.call(SettingsSchema.shape, key)) { + Object.defineProperty(result, key, { + value, + enumerable: true, + configurable: true, + writable: true, + }); + } + } - return { ...settingsDefaults, ...parsed.data, ...unknownFields }; + return pruneSecretBindings(result as Settings); } // @@ -409,5 +620,5 @@ export function applySettings(settings: Settings, delta: Partial<Settings>): Set } }); - return result; + return pruneSecretBindings(result as Settings); } diff --git a/expo-app/sources/sync/sharingTypes.ts b/expo-app/sources/sync/sharingTypes.ts new file mode 100644 index 000000000..5305cce5f --- /dev/null +++ b/expo-app/sources/sync/sharingTypes.ts @@ -0,0 +1,409 @@ +import { z } from "zod"; + +// +// Session Sharing Types +// + +/** + * Access level for session sharing + * + * @remarks + * Defines the permission level a user has when accessing a shared session: + * - `view`: Read-only access to session messages and metadata + * - `edit`: Can send messages but cannot manage sharing settings + * - `admin`: Full access including sharing management + */ +export type ShareAccessLevel = 'view' | 'edit' | 'admin'; + +/** + * User profile information included in share responses + * + * @remarks + * This is a subset of the full user profile, containing only the information + * necessary for displaying who has access to a session. + */ +export interface ShareUserProfile { + /** Unique user identifier */ + id: string; + /** User's unique username */ + username: string | null; + /** User's first name, if set */ + firstName: string | null; + /** User's last name, if set */ + lastName: string | null; + /** URL to user's avatar image, if set */ + avatar: string | null; +} + +/** + * Session share (direct user-to-user sharing) + * + * @remarks + * Represents a direct share of a session between two users. The session owner + * can share with specific users who must be friends. Each share has an access + * level that determines what the shared user can do. + * + * The `encryptedDataKey` is only present when the current user is the recipient + * of the share, allowing them to decrypt the session data. + */ +export interface SessionShare { + /** Unique identifier for this share */ + id: string; + /** ID of the session being shared */ + sessionId: string; + /** User who receives access to the session */ + sharedWithUser: ShareUserProfile; + /** User who created the share (optional, only in some contexts) */ + sharedBy?: ShareUserProfile; + /** Access level granted to the shared user */ + accessLevel: ShareAccessLevel; + /** + * Session data encryption key, encrypted with the recipient's public key + * + * @remarks + * Base64 encoded. Only present when accessing as the shared user. + * Used to decrypt the session's messages and data. + */ + encryptedDataKey?: string; + /** Timestamp when the share was created (milliseconds since epoch) */ + createdAt: number; + /** Timestamp when the share was last updated (milliseconds since epoch) */ + updatedAt: number; +} + +/** + * Public session share (link-based sharing) + * + * @remarks + * Represents a public link that allows anyone with the token to access a session. + * Public shares are always read-only for security reasons. They can have optional + * expiration dates and usage limits. + * + * When `isConsentRequired` is true, users must explicitly consent to logging of + * their IP address and user agent before accessing the session. + */ +export interface PublicSessionShare { + /** Unique identifier for this public share */ + id: string; + /** ID of the session being shared (optional in some contexts) */ + sessionId?: string; + /** + * Random token used in the public URL + * + * @remarks + * Public-share tokens are stored hashed on the server and cannot be recovered. + * The server returns the token only at creation/rotation time. + */ + token: string | null; + /** + * Expiration timestamp (milliseconds since epoch), or null if never expires + * + * @remarks + * After this time, the link will no longer be accessible. + */ + expiresAt: number | null; + /** + * Maximum number of times the link can be accessed, or null for unlimited + * + * @remarks + * Once `useCount` reaches this value, the link becomes inaccessible. + */ + maxUses: number | null; + /** Number of times the link has been accessed */ + useCount: number; + /** + * Whether users must consent to access logging + * + * @remarks + * If true, the user must explicitly consent before their IP address and + * user agent are logged. If false, access is not logged. + */ + isConsentRequired: boolean; + /** Timestamp when the share was created (milliseconds since epoch) */ + createdAt: number; + /** Timestamp when the share was last updated (milliseconds since epoch) */ + updatedAt: number; +} + +/** + * Access log entry for public shares + * + * @remarks + * Records when and by whom a public share was accessed. IP address and user + * agent are only logged if the user gave consent or consent was not required. + */ +export interface PublicShareAccessLog { + /** Unique identifier for this log entry */ + id: string; + /** + * User who accessed the share, if authenticated + * + * @remarks + * Null if the user accessed anonymously without authentication. + */ + user: ShareUserProfile | null; + /** Timestamp of access (milliseconds since epoch) */ + accessedAt: number; + /** + * IP address of the accessor + * + * @remarks + * Only logged if user gave consent (when `isConsentRequired` is true) + * or if consent was not required. + */ + ipAddress: string | null; + /** + * User agent string of the accessor's browser + * + * @remarks + * Only logged if user gave consent (when `isConsentRequired` is true) + * or if consent was not required. + */ + userAgent: string | null; +} + +/** + * Blocked user for public shares + * + * @remarks + * Represents a user who has been blocked from accessing a specific public share. + * Even if they have the token, blocked users will receive a 404 error. + */ +export interface PublicShareBlockedUser { + /** Unique identifier for this block entry */ + id: string; + /** User who is blocked */ + user: ShareUserProfile; + /** Optional reason for blocking (displayed to owner) */ + reason: string | null; + /** Timestamp when user was blocked (milliseconds since epoch) */ + blockedAt: number; +} + +// +// API Request/Response Types +// + +/** + * Request to create or update a session share + * + * @remarks + * Used when sharing a session with a specific user. The user must be a friend + * of the session owner. The server will handle encryption of the data key with + * the recipient's public key. + */ +export interface CreateSessionShareRequest { + /** ID of the user to share with */ + userId: string; + /** Access level to grant */ + accessLevel: ShareAccessLevel; + /** Base64 encoded (v0 + box bundle) */ + encryptedDataKey: string; +} + +/** Response containing a single session share */ +export interface SessionShareResponse { + /** The created or updated share */ + share: SessionShare; +} + +/** Response containing multiple session shares */ +export interface SessionSharesResponse { + /** List of shares for a session */ + shares: SessionShare[]; +} + +/** + * Request to create or update a public share + * + * @remarks + * Creates a public link for a session. The link can optionally have an + * expiration date, usage limit, and consent requirement for access logging. + */ +export interface CreatePublicShareRequest { + /** + * Session data encryption key, encrypted for public access + * + * @remarks + * Base64 encoded. Typically encrypted with a key derived from the token. + */ + encryptedDataKey: string; + /** + * Optional expiration timestamp (milliseconds since epoch) + * + * @remarks + * After this time, the link will no longer be accessible. + */ + expiresAt?: number; + /** + * Optional maximum number of accesses + * + * @remarks + * Once this limit is reached, the link becomes inaccessible. + */ + maxUses?: number; + /** + * Whether to require user consent for access logging + * + * @remarks + * If true, users must explicitly consent before their IP and user agent + * are logged. Defaults to false. + */ + isConsentRequired?: boolean; +} + +/** Response containing a public share */ +export interface PublicShareResponse { + /** The created, updated, or retrieved public share */ + publicShare: PublicSessionShare; +} + +/** + * Response when accessing a session via public share + * + * @remarks + * Returns the session data and encrypted key needed to decrypt it. + * Public shares always have view-only access. + */ +export interface AccessPublicShareResponse { + /** Session information */ + session: { + /** Session ID */ + id: string; + /** Session sequence number */ + seq: number; + /** Creation timestamp (milliseconds since epoch) */ + createdAt: number; + /** Last update timestamp (milliseconds since epoch) */ + updatedAt: number; + /** Whether session is active */ + active: boolean; + /** Last activity timestamp (milliseconds since epoch) */ + activeAt: number; + /** Session metadata */ + metadata: any; + /** Metadata version number */ + metadataVersion: number; + /** Agent state */ + agentState: any; + /** Agent state version number */ + agentStateVersion: number; + }; + /** Access level (always 'view' for public shares) */ + accessLevel: 'view'; + /** Encrypted data key for decrypting session (base64) */ + encryptedDataKey: string; + /** Session owner profile */ + owner: ShareUserProfile; + /** Whether consent is required (echoed) */ + isConsentRequired: boolean; +} + +/** Response containing access logs for a public share */ +export interface PublicShareAccessLogsResponse { + /** List of access log entries */ + logs: PublicShareAccessLog[]; +} + +/** Response containing blocked users for a public share */ +export interface PublicShareBlockedUsersResponse { + /** List of blocked users */ + blockedUsers: PublicShareBlockedUser[]; +} + +/** + * Request to block a user from a public share + * + * @remarks + * Prevents a specific user from accessing a public share, even if they + * have the token. Useful for dealing with abuse. + */ +export interface BlockPublicShareUserRequest { + /** ID of the user to block */ + userId: string; + /** + * Optional reason for blocking + * + * @remarks + * This is only visible to the session owner and helps track why + * users were blocked. + */ + reason?: string; +} + +// +// Error Types +// + +/** + * Base error class for session sharing operations + * + * @remarks + * All session sharing errors extend from this class for easy error handling. + */ +export class SessionSharingError extends Error { + constructor(message: string) { + super(message); + this.name = 'SessionSharingError'; + } +} + +/** + * Error thrown when a requested share does not exist + * + * @remarks + * This can occur when trying to access, update, or delete a share that + * has already been deleted or never existed. + */ +export class ShareNotFoundError extends SessionSharingError { + constructor() { + super('Share not found'); + this.name = 'ShareNotFoundError'; + } +} + +/** + * Error thrown when a public share token is invalid or expired + * + * @remarks + * This can occur if: + * - The token doesn't exist + * - The share has expired (past `expiresAt`) + * - The maximum uses have been reached + * - The current user is blocked + */ +export class PublicShareNotFoundError extends SessionSharingError { + constructor() { + super('Public share not found or expired'); + this.name = 'PublicShareNotFoundError'; + } +} + +/** + * Error thrown when accessing a public share that requires consent + * + * @remarks + * When `isConsentRequired` is true, users must explicitly consent to + * access logging by passing `consent=true` in the request. This error + * indicates the consent parameter was missing or false. + */ +export class ConsentRequiredError extends SessionSharingError { + constructor() { + super('Consent required for access'); + this.name = 'ConsentRequiredError'; + } +} + +/** + * Error thrown when a public share has reached its maximum usage limit + * + * @remarks + * When a public share has a `maxUses` limit and that limit has been + * reached, further access attempts will fail with this error. + */ +export class MaxUsesReachedError extends SessionSharingError { + constructor() { + super('Maximum uses reached'); + this.name = 'MaxUsesReachedError'; + } +} diff --git a/expo-app/sources/sync/spawnSessionPayload.ts b/expo-app/sources/sync/spawnSessionPayload.ts new file mode 100644 index 000000000..314b2713a --- /dev/null +++ b/expo-app/sources/sync/spawnSessionPayload.ts @@ -0,0 +1,79 @@ +import type { TerminalSpawnOptions } from './terminalSettings'; +import type { AgentId } from '@/agents/catalog'; +import type { PermissionMode } from '@/sync/permissionTypes'; + +// Options for spawning a session +export interface SpawnSessionOptions { + machineId: string; + directory: string; + approvedNewDirectoryCreation?: boolean; + token?: string; + agent?: AgentId; + // Session-scoped profile identity (non-secret). Empty string means "no profile". + profileId?: string; + // Environment variables from AI backend profile + // Accepts any environment variables - daemon will pass them to the agent process + // Common variables include: + // - ANTHROPIC_BASE_URL, ANTHROPIC_AUTH_TOKEN, ANTHROPIC_MODEL, ANTHROPIC_SMALL_FAST_MODEL + // - OPENAI_API_KEY, OPENAI_BASE_URL, OPENAI_MODEL, OPENAI_API_TIMEOUT_MS + // - AZURE_OPENAI_API_KEY, AZURE_OPENAI_ENDPOINT, AZURE_OPENAI_API_VERSION, AZURE_OPENAI_DEPLOYMENT_NAME + // - TOGETHER_API_KEY, TOGETHER_MODEL + // - API_TIMEOUT_MS, CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC + // - Custom variables (DEEPSEEK_*, Z_AI_*, etc.) + environmentVariables?: Record<string, string>; + resume?: string; + permissionMode?: PermissionMode; + permissionModeUpdatedAt?: number; + /** + * Experimental: allow Codex vendor resume. + * Only relevant when agent === 'codex' and resume is set. + */ + experimentalCodexResume?: boolean; + /** + * Experimental: route Codex through ACP (codex-acp). + * When enabled, Codex sessions use ACP instead of MCP. + */ + experimentalCodexAcp?: boolean; + terminal?: TerminalSpawnOptions | null; +} + +export type SpawnHappySessionRpcParams = { + type: 'spawn-in-directory' + directory: string + approvedNewDirectoryCreation?: boolean + token?: string + agent?: AgentId + profileId?: string + environmentVariables?: Record<string, string> + resume?: string + permissionMode?: PermissionMode + permissionModeUpdatedAt?: number + experimentalCodexResume?: boolean + experimentalCodexAcp?: boolean + terminal?: TerminalSpawnOptions +}; + +export function buildSpawnHappySessionRpcParams(options: SpawnSessionOptions): SpawnHappySessionRpcParams { + const { directory, approvedNewDirectoryCreation = false, token, agent, environmentVariables, profileId, resume, permissionMode, permissionModeUpdatedAt, experimentalCodexResume, experimentalCodexAcp, terminal } = options; + + const params: SpawnHappySessionRpcParams = { + type: 'spawn-in-directory', + directory, + approvedNewDirectoryCreation, + token, + agent, + profileId, + environmentVariables, + resume, + permissionMode, + permissionModeUpdatedAt, + experimentalCodexResume, + experimentalCodexAcp, + }; + + if (terminal) { + params.terminal = terminal; + } + + return params; +} diff --git a/expo-app/sources/sync/storage.ts b/expo-app/sources/sync/storage.ts index 48e7ab771..67308db56 100644 --- a/expo-app/sources/sync/storage.ts +++ b/expo-app/sources/sync/storage.ts @@ -1,66 +1,36 @@ import { create } from "zustand"; -import { useShallow } from 'zustand/react/shallow' -import { Session, Machine, GitStatus } from "./storageTypes"; -import { createReducer, reducer, ReducerState } from "./reducer/reducer"; -import { Message } from "./typesMessage"; -import { NormalizedMessage } from "./typesRaw"; -import { isMachineOnline } from '@/utils/machineUtils'; -import { applySettings, Settings } from "./settings"; -import { LocalSettings, applyLocalSettings } from "./localSettings"; -import { Purchases, customerInfoToPurchases } from "./purchases"; -import { TodoState } from "../-zen/model/ops"; -import { Profile } from "./profile"; -import { UserProfile, RelationshipUpdatedEvent } from "./friendTypes"; -import { loadSettings, loadLocalSettings, saveLocalSettings, saveSettings, loadPurchases, savePurchases, loadProfile, saveProfile, loadSessionDrafts, saveSessionDrafts, loadSessionPermissionModes, saveSessionPermissionModes } from "./persistence"; -import type { PermissionMode } from '@/components/PermissionModeSelector'; +import type { DiscardedPendingMessage, GitStatus, Machine, PendingMessage, Session } from "./storageTypes"; +import type { Settings } from "./settings"; +import type { LocalSettings } from "./localSettings"; +import type { Purchases } from "./purchases"; +import type { TodoState } from "../-zen/model/ops"; +import type { Profile } from "./profile"; +import type { RelationshipUpdatedEvent, UserProfile } from "./friendTypes"; import type { CustomerInfo } from './revenueCat/types'; -import React from "react"; -import { sync } from "./sync"; -import { getCurrentRealtimeSessionId, getVoiceSession } from '@/realtime/RealtimeSession'; -import { isMutableTool } from "@/components/tools/knownTools"; -import { projectManager } from "./projectManager"; -import { DecryptedArtifact } from "./artifactTypes"; -import { FeedItem } from "./feedTypes"; - -// Debounce timer for realtimeMode changes -let realtimeModeDebounceTimer: ReturnType<typeof setTimeout> | null = null; -const REALTIME_MODE_DEBOUNCE_MS = 150; - -/** - * Centralized session online state resolver - * Returns either "online" (string) or a timestamp (number) for last seen - */ -function resolveSessionOnlineState(session: { active: boolean; activeAt: number }): "online" | number { - // Session is online if the active flag is true - return session.active ? "online" : session.activeAt; -} - -/** - * Checks if a session should be shown in the active sessions group - */ -function isSessionActive(session: { active: boolean; activeAt: number }): boolean { - // Use the active flag directly, no timeout checks - return session.active; -} +import type { DecryptedArtifact } from "./artifactTypes"; +import type { FeedItem } from "./feedTypes"; +import type { SessionListViewItem } from './sessionListViewData'; +import type { NormalizedMessage } from "./typesRaw"; +import { createArtifactsDomain } from './store/domains/artifacts'; +import { createFeedDomain } from './store/domains/feed'; +import { createFriendsDomain } from './store/domains/friends'; +import { createMachinesDomain } from './store/domains/machines'; +import { createMessagesDomain, type SessionMessages } from './store/domains/messages'; +import { createProfileDomain } from './store/domains/profile'; +import { createPendingDomain, type SessionPending } from './store/domains/pending'; +import { createRealtimeDomain, type NativeUpdateStatus, type RealtimeMode, type RealtimeStatus, type SocketStatus, type SyncError } from './store/domains/realtime'; +import { createSettingsDomain } from './store/domains/settings'; +import { createSessionsDomain } from './store/domains/sessions'; +import { createTodosDomain } from './store/domains/todos'; // Known entitlement IDs export type KnownEntitlements = 'pro'; -interface SessionMessages { - messages: Message[]; - messagesMap: Record<string, Message>; - reducerState: ReducerState; - isLoaded: boolean; -} +type SessionModelMode = NonNullable<Session['modelMode']>; // Machine type is now imported from storageTypes - represents persisted machine data -// Unified list item type for SessionsList component -export type SessionListViewItem = - | { type: 'header'; title: string } - | { type: 'active-sessions'; sessions: Session[] } - | { type: 'project-group'; displayPath: string; machine: Machine } - | { type: 'session'; session: Session; variant?: 'default' | 'no-path' }; +export type { SessionListViewItem } from './sessionListViewData'; // Legacy type for backward compatibility - to be removed export type SessionListItem = string | Session; @@ -75,6 +45,7 @@ interface StorageState { sessionsData: SessionListItem[] | null; // Legacy - to be removed sessionListViewData: SessionListViewItem[] | null; sessionMessages: Record<string, SessionMessages>; + sessionPending: Record<string, SessionPending>; sessionGitStatus: Record<string, GitStatus | null>; machines: Record<string, Machine>; artifacts: Record<string, DecryptedArtifact>; // New artifacts storage @@ -86,38 +57,56 @@ interface StorageState { feedHasMore: boolean; feedLoaded: boolean; // True after initial feed fetch friendsLoaded: boolean; // True after initial friends fetch - realtimeStatus: 'disconnected' | 'connecting' | 'connected' | 'error'; - realtimeMode: 'idle' | 'speaking'; - socketStatus: 'disconnected' | 'connecting' | 'connected' | 'error'; + realtimeStatus: RealtimeStatus; + realtimeMode: RealtimeMode; + socketStatus: SocketStatus; socketLastConnectedAt: number | null; socketLastDisconnectedAt: number | null; + socketLastError: string | null; + socketLastErrorAt: number | null; + syncError: SyncError; + lastSyncAt: number | null; isDataReady: boolean; - nativeUpdateStatus: { available: boolean; updateUrl?: string } | null; + nativeUpdateStatus: NativeUpdateStatus; todoState: TodoState | null; todosLoaded: boolean; + sessionLastViewed: Record<string, number>; applySessions: (sessions: (Omit<Session, 'presence'> & { presence?: "online" | number })[]) => void; applyMachines: (machines: Machine[], replace?: boolean) => void; applyLoaded: () => void; applyReady: () => void; applyMessages: (sessionId: string, messages: NormalizedMessage[]) => { changed: string[], hasReadyEvent: boolean }; applyMessagesLoaded: (sessionId: string) => void; + applyPendingLoaded: (sessionId: string) => void; + applyPendingMessages: (sessionId: string, messages: PendingMessage[]) => void; + applyDiscardedPendingMessages: (sessionId: string, messages: DiscardedPendingMessage[]) => void; + upsertPendingMessage: (sessionId: string, message: PendingMessage) => void; + removePendingMessage: (sessionId: string, pendingId: string) => void; applySettings: (settings: Settings, version: number) => void; + replaceSettings: (settings: Settings, version: number) => void; applySettingsLocal: (settings: Partial<Settings>) => void; applyLocalSettings: (settings: Partial<LocalSettings>) => void; applyPurchases: (customerInfo: CustomerInfo) => void; applyProfile: (profile: Profile) => void; applyTodos: (todoState: TodoState) => void; applyGitStatus: (sessionId: string, status: GitStatus | null) => void; - applyNativeUpdateStatus: (status: { available: boolean; updateUrl?: string } | null) => void; + applyNativeUpdateStatus: (status: NativeUpdateStatus) => void; isMutableToolCall: (sessionId: string, callId: string) => boolean; - setRealtimeStatus: (status: 'disconnected' | 'connecting' | 'connected' | 'error') => void; - setRealtimeMode: (mode: 'idle' | 'speaking', immediate?: boolean) => void; + setRealtimeStatus: (status: RealtimeStatus) => void; + setRealtimeMode: (mode: RealtimeMode, immediate?: boolean) => void; clearRealtimeModeDebounce: () => void; - setSocketStatus: (status: 'disconnected' | 'connecting' | 'connected' | 'error') => void; + setSocketStatus: (status: SocketStatus) => void; + setSocketError: (message: string | null) => void; + setSyncError: (error: StorageState['syncError']) => void; + clearSyncError: () => void; + setLastSyncAt: (ts: number) => void; getActiveSessions: () => Session[]; updateSessionDraft: (sessionId: string, draft: string | null) => void; + markSessionOptimisticThinking: (sessionId: string) => void; + clearSessionOptimisticThinking: (sessionId: string) => void; + markSessionViewed: (sessionId: string) => void; updateSessionPermissionMode: (sessionId: string, mode: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' | 'read-only' | 'safe-yolo' | 'yolo') => void; - updateSessionModelMode: (sessionId: string, mode: 'default' | 'gemini-2.5-pro' | 'gemini-2.5-flash' | 'gemini-2.5-flash-lite') => void; + updateSessionModelMode: (sessionId: string, mode: SessionModelMode) => void; // Artifact methods applyArtifacts: (artifacts: DecryptedArtifact[]) => void; addArtifact: (artifact: DecryptedArtifact) => void; @@ -147,1151 +136,36 @@ interface StorageState { clearFeed: () => void; } -// Helper function to build unified list view data from sessions and machines -function buildSessionListViewData( - sessions: Record<string, Session> -): SessionListViewItem[] { - // Separate active and inactive sessions - const activeSessions: Session[] = []; - const inactiveSessions: Session[] = []; - - Object.values(sessions).forEach(session => { - if (isSessionActive(session)) { - activeSessions.push(session); - } else { - inactiveSessions.push(session); - } - }); - - // Sort sessions by updated date (newest first) - activeSessions.sort((a, b) => b.updatedAt - a.updatedAt); - inactiveSessions.sort((a, b) => b.updatedAt - a.updatedAt); - - // Build unified list view data - const listData: SessionListViewItem[] = []; - - // Add active sessions as a single item at the top (if any) - if (activeSessions.length > 0) { - listData.push({ type: 'active-sessions', sessions: activeSessions }); - } - - // Group inactive sessions by date - const now = new Date(); - const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); - const yesterday = new Date(today.getTime() - 24 * 60 * 60 * 1000); - - let currentDateGroup: Session[] = []; - let currentDateString: string | null = null; - - for (const session of inactiveSessions) { - const sessionDate = new Date(session.updatedAt); - const dateString = sessionDate.toDateString(); - - if (currentDateString !== dateString) { - // Process previous group - if (currentDateGroup.length > 0 && currentDateString) { - const groupDate = new Date(currentDateString); - const sessionDateOnly = new Date(groupDate.getFullYear(), groupDate.getMonth(), groupDate.getDate()); - - let headerTitle: string; - if (sessionDateOnly.getTime() === today.getTime()) { - headerTitle = 'Today'; - } else if (sessionDateOnly.getTime() === yesterday.getTime()) { - headerTitle = 'Yesterday'; - } else { - const diffTime = today.getTime() - sessionDateOnly.getTime(); - const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24)); - headerTitle = `${diffDays} days ago`; - } - - listData.push({ type: 'header', title: headerTitle }); - currentDateGroup.forEach(sess => { - listData.push({ type: 'session', session: sess }); - }); - } - - // Start new group - currentDateString = dateString; - currentDateGroup = [session]; - } else { - currentDateGroup.push(session); - } - } - - // Process final group - if (currentDateGroup.length > 0 && currentDateString) { - const groupDate = new Date(currentDateString); - const sessionDateOnly = new Date(groupDate.getFullYear(), groupDate.getMonth(), groupDate.getDate()); - - let headerTitle: string; - if (sessionDateOnly.getTime() === today.getTime()) { - headerTitle = 'Today'; - } else if (sessionDateOnly.getTime() === yesterday.getTime()) { - headerTitle = 'Yesterday'; - } else { - const diffTime = today.getTime() - sessionDateOnly.getTime(); - const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24)); - headerTitle = `${diffDays} days ago`; - } - - listData.push({ type: 'header', title: headerTitle }); - currentDateGroup.forEach(sess => { - listData.push({ type: 'session', session: sess }); - }); - } - - return listData; -} - export const storage = create<StorageState>()((set, get) => { - let { settings, version } = loadSettings(); - let localSettings = loadLocalSettings(); - let purchases = loadPurchases(); - let profile = loadProfile(); - let sessionDrafts = loadSessionDrafts(); - let sessionPermissionModes = loadSessionPermissionModes(); - return { - settings, - settingsVersion: version, - localSettings, - purchases, - profile, - sessions: {}, - machines: {}, - artifacts: {}, // Initialize artifacts - friends: {}, // Initialize relationships cache - users: {}, // Initialize global user cache - feedItems: [], // Initialize feed items list - feedHead: null, - feedTail: null, - feedHasMore: false, - feedLoaded: false, // Initialize as false - friendsLoaded: false, // Initialize as false - todoState: null, // Initialize todo state - todosLoaded: false, // Initialize todos loaded state - sessionsData: null, // Legacy - to be removed - sessionListViewData: null, - sessionMessages: {}, - sessionGitStatus: {}, - realtimeStatus: 'disconnected', - realtimeMode: 'idle', - socketStatus: 'disconnected', - socketLastConnectedAt: null, - socketLastDisconnectedAt: null, - isDataReady: false, - nativeUpdateStatus: null, - isMutableToolCall: (sessionId: string, callId: string) => { - const sessionMessages = get().sessionMessages[sessionId]; - if (!sessionMessages) { - return true; - } - const toolCall = sessionMessages.reducerState.toolIdToMessageId.get(callId); - if (!toolCall) { - return true; - } - const toolCallMessage = sessionMessages.messagesMap[toolCall]; - if (!toolCallMessage || toolCallMessage.kind !== 'tool-call') { - return true; - } - return toolCallMessage.tool?.name ? isMutableTool(toolCallMessage.tool?.name) : true; - }, - getActiveSessions: () => { - const state = get(); - return Object.values(state.sessions).filter(s => s.active); - }, - applySessions: (sessions: (Omit<Session, 'presence'> & { presence?: "online" | number })[]) => set((state) => { - // Load drafts and permission modes if sessions are empty (initial load) - const savedDrafts = Object.keys(state.sessions).length === 0 ? sessionDrafts : {}; - const savedPermissionModes = Object.keys(state.sessions).length === 0 ? sessionPermissionModes : {}; - - // Merge new sessions with existing ones - const mergedSessions: Record<string, Session> = { ...state.sessions }; - - // Update sessions with calculated presence using centralized resolver - sessions.forEach(session => { - // Use centralized resolver for consistent state management - const presence = resolveSessionOnlineState(session); - - // Preserve existing draft and permission mode if they exist, or load from saved data - const existingDraft = state.sessions[session.id]?.draft; - const savedDraft = savedDrafts[session.id]; - const existingPermissionMode = state.sessions[session.id]?.permissionMode; - const savedPermissionMode = savedPermissionModes[session.id]; - mergedSessions[session.id] = { - ...session, - presence, - draft: existingDraft || savedDraft || session.draft || null, - permissionMode: existingPermissionMode || savedPermissionMode || session.permissionMode || 'default' - }; - }); - - // Build active set from all sessions (including existing ones) - const activeSet = new Set<string>(); - Object.values(mergedSessions).forEach(session => { - if (isSessionActive(session)) { - activeSet.add(session.id); - } - }); - - // Separate active and inactive sessions - const activeSessions: Session[] = []; - const inactiveSessions: Session[] = []; - - // Process all sessions from merged set - Object.values(mergedSessions).forEach(session => { - if (activeSet.has(session.id)) { - activeSessions.push(session); - } else { - inactiveSessions.push(session); - } - }); - - // Sort both arrays by creation date for stable ordering - activeSessions.sort((a, b) => b.createdAt - a.createdAt); - inactiveSessions.sort((a, b) => b.createdAt - a.createdAt); - - // Build flat list data for FlashList - const listData: SessionListItem[] = []; - - if (activeSessions.length > 0) { - listData.push('online'); - listData.push(...activeSessions); - } - - // Legacy sessionsData - to be removed - // Machines are now integrated into sessionListViewData - - if (inactiveSessions.length > 0) { - listData.push('offline'); - listData.push(...inactiveSessions); - } - - // console.log(`📊 Storage: applySessions called with ${sessions.length} sessions, active: ${activeSessions.length}, inactive: ${inactiveSessions.length}`); - - // Process AgentState updates for sessions that already have messages loaded - const updatedSessionMessages = { ...state.sessionMessages }; - - sessions.forEach(session => { - const oldSession = state.sessions[session.id]; - const newSession = mergedSessions[session.id]; - - // Check if sessionMessages exists AND agentStateVersion is newer - const existingSessionMessages = updatedSessionMessages[session.id]; - if (existingSessionMessages && newSession.agentState && - (!oldSession || newSession.agentStateVersion > (oldSession.agentStateVersion || 0))) { - - // Check for NEW permission requests before processing - const currentRealtimeSessionId = getCurrentRealtimeSessionId(); - const voiceSession = getVoiceSession(); - - // console.log('[REALTIME DEBUG] Permission check:', { - // currentRealtimeSessionId, - // sessionId: session.id, - // match: currentRealtimeSessionId === session.id, - // hasVoiceSession: !!voiceSession, - // oldRequests: Object.keys(oldSession?.agentState?.requests || {}), - // newRequests: Object.keys(newSession.agentState?.requests || {}) - // }); - - if (currentRealtimeSessionId === session.id && voiceSession) { - const oldRequests = oldSession?.agentState?.requests || {}; - const newRequests = newSession.agentState?.requests || {}; - - // Find NEW permission requests only - for (const [requestId, request] of Object.entries(newRequests)) { - if (!oldRequests[requestId]) { - // This is a NEW permission request - const toolName = request.tool; - // console.log('[REALTIME DEBUG] Sending permission notification for:', toolName); - voiceSession.sendTextMessage( - `Claude is requesting permission to use the ${toolName} tool` - ); - } - } - } - - // Process new AgentState through reducer - const reducerResult = reducer(existingSessionMessages.reducerState, [], newSession.agentState); - const processedMessages = reducerResult.messages; - - // Always update the session messages, even if no new messages were created - // This ensures the reducer state is updated with the new AgentState - const mergedMessagesMap = { ...existingSessionMessages.messagesMap }; - processedMessages.forEach(message => { - mergedMessagesMap[message.id] = message; - }); - - const messagesArray = Object.values(mergedMessagesMap) - .sort((a, b) => b.createdAt - a.createdAt); - - updatedSessionMessages[session.id] = { - messages: messagesArray, - messagesMap: mergedMessagesMap, - reducerState: existingSessionMessages.reducerState, // The reducer modifies state in-place, so this has the updates - isLoaded: existingSessionMessages.isLoaded - }; - - // IMPORTANT: Copy latestUsage from reducerState to Session for immediate availability - if (existingSessionMessages.reducerState.latestUsage) { - mergedSessions[session.id] = { - ...mergedSessions[session.id], - latestUsage: { ...existingSessionMessages.reducerState.latestUsage } - }; - } - } - }); - - // Build new unified list view data - const sessionListViewData = buildSessionListViewData( - mergedSessions - ); - - // Update project manager with current sessions and machines - const machineMetadataMap = new Map<string, any>(); - Object.values(state.machines).forEach(machine => { - if (machine.metadata) { - machineMetadataMap.set(machine.id, machine.metadata); - } - }); - projectManager.updateSessions(Object.values(mergedSessions), machineMetadataMap); - - return { - ...state, - sessions: mergedSessions, - sessionsData: listData, // Legacy - to be removed - sessionListViewData, - sessionMessages: updatedSessionMessages - }; - }), - applyLoaded: () => set((state) => { - const result = { - ...state, - sessionsData: [] - }; - return result; - }), - applyReady: () => set((state) => ({ - ...state, - isDataReady: true - })), - applyMessages: (sessionId: string, messages: NormalizedMessage[]) => { - let changed = new Set<string>(); - let hasReadyEvent = false; - set((state) => { - - // Resolve session messages state - const existingSession = state.sessionMessages[sessionId] || { - messages: [], - messagesMap: {}, - reducerState: createReducer(), - isLoaded: false - }; - - // Get the session's agentState if available - const session = state.sessions[sessionId]; - const agentState = session?.agentState; - - // Messages are already normalized, no need to process them again - const normalizedMessages = messages; - - // Run reducer with agentState - const reducerResult = reducer(existingSession.reducerState, normalizedMessages, agentState); - const processedMessages = reducerResult.messages; - for (let message of processedMessages) { - changed.add(message.id); - } - if (reducerResult.hasReadyEvent) { - hasReadyEvent = true; - } - - // Merge messages - const mergedMessagesMap = { ...existingSession.messagesMap }; - processedMessages.forEach(message => { - mergedMessagesMap[message.id] = message; - }); - - // Convert to array and sort by createdAt - const messagesArray = Object.values(mergedMessagesMap) - .sort((a, b) => b.createdAt - a.createdAt); - - // Update session with todos and latestUsage - // IMPORTANT: We extract latestUsage from the mutable reducerState and copy it to the Session object - // This ensures latestUsage is available immediately on load, even before messages are fully loaded - let updatedSessions = state.sessions; - const needsUpdate = (reducerResult.todos !== undefined || existingSession.reducerState.latestUsage) && session; - - if (needsUpdate) { - updatedSessions = { - ...state.sessions, - [sessionId]: { - ...session, - ...(reducerResult.todos !== undefined && { todos: reducerResult.todos }), - // Copy latestUsage from reducerState to make it immediately available - latestUsage: existingSession.reducerState.latestUsage ? { - ...existingSession.reducerState.latestUsage - } : session.latestUsage - } - }; - } - - return { - ...state, - sessions: updatedSessions, - sessionMessages: { - ...state.sessionMessages, - [sessionId]: { - ...existingSession, - messages: messagesArray, - messagesMap: mergedMessagesMap, - reducerState: existingSession.reducerState, // Explicitly include the mutated reducer state - isLoaded: true - } - } - }; - }); - - return { changed: Array.from(changed), hasReadyEvent }; - }, - applyMessagesLoaded: (sessionId: string) => set((state) => { - const existingSession = state.sessionMessages[sessionId]; - let result: StorageState; - - if (!existingSession) { - // First time loading - check for AgentState - const session = state.sessions[sessionId]; - const agentState = session?.agentState; - - // Create new reducer state - const reducerState = createReducer(); - - // Process AgentState if it exists - let messages: Message[] = []; - let messagesMap: Record<string, Message> = {}; - - if (agentState) { - // Process AgentState through reducer to get initial permission messages - const reducerResult = reducer(reducerState, [], agentState); - const processedMessages = reducerResult.messages; - - processedMessages.forEach(message => { - messagesMap[message.id] = message; - }); - - messages = Object.values(messagesMap) - .sort((a, b) => b.createdAt - a.createdAt); - } - - // Extract latestUsage from reducerState if available and update session - let updatedSessions = state.sessions; - if (session && reducerState.latestUsage) { - updatedSessions = { - ...state.sessions, - [sessionId]: { - ...session, - latestUsage: { ...reducerState.latestUsage } - } - }; - } - - result = { - ...state, - sessions: updatedSessions, - sessionMessages: { - ...state.sessionMessages, - [sessionId]: { - reducerState, - messages, - messagesMap, - isLoaded: true - } satisfies SessionMessages - } - }; - } else { - result = { - ...state, - sessionMessages: { - ...state.sessionMessages, - [sessionId]: { - ...existingSession, - isLoaded: true - } satisfies SessionMessages - } - }; - } - - return result; - }), - applySettingsLocal: (settings: Partial<Settings>) => set((state) => { - saveSettings(applySettings(state.settings, settings), state.settingsVersion ?? 0); - return { - ...state, - settings: applySettings(state.settings, settings) - }; - }), - applySettings: (settings: Settings, version: number) => set((state) => { - if (state.settingsVersion === null || state.settingsVersion < version) { - saveSettings(settings, version); - return { - ...state, - settings, - settingsVersion: version - }; - } else { - return state; - } - }), - applyLocalSettings: (delta: Partial<LocalSettings>) => set((state) => { - const updatedLocalSettings = applyLocalSettings(state.localSettings, delta); - saveLocalSettings(updatedLocalSettings); - return { - ...state, - localSettings: updatedLocalSettings - }; - }), - applyPurchases: (customerInfo: CustomerInfo) => set((state) => { - // Transform CustomerInfo to our Purchases format - const purchases = customerInfoToPurchases(customerInfo); - - // Always save and update - no need for version checks - savePurchases(purchases); - return { - ...state, - purchases - }; - }), - applyProfile: (profile: Profile) => set((state) => { - // Always save and update profile - saveProfile(profile); - return { - ...state, - profile - }; - }), - applyTodos: (todoState: TodoState) => set((state) => { - return { - ...state, - todoState, - todosLoaded: true - }; - }), - applyGitStatus: (sessionId: string, status: GitStatus | null) => set((state) => { - // Update project git status as well - projectManager.updateSessionProjectGitStatus(sessionId, status); - - return { - ...state, - sessionGitStatus: { - ...state.sessionGitStatus, - [sessionId]: status - } - }; - }), - applyNativeUpdateStatus: (status: { available: boolean; updateUrl?: string } | null) => set((state) => ({ - ...state, - nativeUpdateStatus: status - })), - setRealtimeStatus: (status: 'disconnected' | 'connecting' | 'connected' | 'error') => set((state) => ({ - ...state, - realtimeStatus: status - })), - setRealtimeMode: (mode: 'idle' | 'speaking', immediate?: boolean) => { - if (immediate) { - // Clear any pending debounce and set immediately - if (realtimeModeDebounceTimer) { - clearTimeout(realtimeModeDebounceTimer); - realtimeModeDebounceTimer = null; - } - set((state) => ({ ...state, realtimeMode: mode })); - } else { - // Debounce mode changes to avoid flickering - if (realtimeModeDebounceTimer) { - clearTimeout(realtimeModeDebounceTimer); - } - realtimeModeDebounceTimer = setTimeout(() => { - realtimeModeDebounceTimer = null; - set((state) => ({ ...state, realtimeMode: mode })); - }, REALTIME_MODE_DEBOUNCE_MS); - } - }, - clearRealtimeModeDebounce: () => { - if (realtimeModeDebounceTimer) { - clearTimeout(realtimeModeDebounceTimer); - realtimeModeDebounceTimer = null; - } - }, - setSocketStatus: (status: 'disconnected' | 'connecting' | 'connected' | 'error') => set((state) => { - const now = Date.now(); - const updates: Partial<StorageState> = { - socketStatus: status - }; - - // Update timestamp based on status - if (status === 'connected') { - updates.socketLastConnectedAt = now; - } else if (status === 'disconnected' || status === 'error') { - updates.socketLastDisconnectedAt = now; - } - - return { - ...state, - ...updates - }; - }), - updateSessionDraft: (sessionId: string, draft: string | null) => set((state) => { - const session = state.sessions[sessionId]; - if (!session) return state; - - // Don't store empty strings, convert to null - const normalizedDraft = draft?.trim() ? draft : null; - - // Collect all drafts for persistence - const allDrafts: Record<string, string> = {}; - Object.entries(state.sessions).forEach(([id, sess]) => { - if (id === sessionId) { - if (normalizedDraft) { - allDrafts[id] = normalizedDraft; - } - } else if (sess.draft) { - allDrafts[id] = sess.draft; - } - }); - - // Persist drafts - saveSessionDrafts(allDrafts); - - const updatedSessions = { - ...state.sessions, - [sessionId]: { - ...session, - draft: normalizedDraft - } - }; - - // Rebuild sessionListViewData to update the UI immediately - const sessionListViewData = buildSessionListViewData( - updatedSessions - ); - - return { - ...state, - sessions: updatedSessions, - sessionListViewData - }; - }), - updateSessionPermissionMode: (sessionId: string, mode: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' | 'read-only' | 'safe-yolo' | 'yolo') => set((state) => { - const session = state.sessions[sessionId]; - if (!session) return state; - - // Update the session with the new permission mode - const updatedSessions = { - ...state.sessions, - [sessionId]: { - ...session, - permissionMode: mode - } - }; - - // Collect all permission modes for persistence - const allModes: Record<string, PermissionMode> = {}; - Object.entries(updatedSessions).forEach(([id, sess]) => { - if (sess.permissionMode && sess.permissionMode !== 'default') { - allModes[id] = sess.permissionMode; - } - }); - - // Persist permission modes (only non-default values to save space) - saveSessionPermissionModes(allModes); - - // No need to rebuild sessionListViewData since permission mode doesn't affect the list display - return { - ...state, - sessions: updatedSessions - }; - }), - updateSessionModelMode: (sessionId: string, mode: 'default' | 'gemini-2.5-pro' | 'gemini-2.5-flash' | 'gemini-2.5-flash-lite') => set((state) => { - const session = state.sessions[sessionId]; - if (!session) return state; - - // Update the session with the new model mode - const updatedSessions = { - ...state.sessions, - [sessionId]: { - ...session, - modelMode: mode - } - }; - - // No need to rebuild sessionListViewData since model mode doesn't affect the list display - return { - ...state, - sessions: updatedSessions - }; - }), - // Project management methods - getProjects: () => projectManager.getProjects(), - getProject: (projectId: string) => projectManager.getProject(projectId), - getProjectForSession: (sessionId: string) => projectManager.getProjectForSession(sessionId), - getProjectSessions: (projectId: string) => projectManager.getProjectSessions(projectId), - // Project git status methods - getProjectGitStatus: (projectId: string) => projectManager.getProjectGitStatus(projectId), - getSessionProjectGitStatus: (sessionId: string) => projectManager.getSessionProjectGitStatus(sessionId), - updateSessionProjectGitStatus: (sessionId: string, status: GitStatus | null) => { - projectManager.updateSessionProjectGitStatus(sessionId, status); - // Trigger a state update to notify hooks - set((state) => ({ ...state })); - }, - applyMachines: (machines: Machine[], replace: boolean = false) => set((state) => { - // Either replace all machines or merge updates - let mergedMachines: Record<string, Machine>; - - if (replace) { - // Replace entire machine state (used by fetchMachines) - mergedMachines = {}; - machines.forEach(machine => { - mergedMachines[machine.id] = machine; - }); - } else { - // Merge individual updates (used by update-machine) - mergedMachines = { ...state.machines }; - machines.forEach(machine => { - mergedMachines[machine.id] = machine; - }); - } + const settingsDomain = createSettingsDomain<StorageState>({ set, get }); + const profileDomain = createProfileDomain<StorageState>({ set, get }); + const todosDomain = createTodosDomain<StorageState>({ set, get }); + const machinesDomain = createMachinesDomain<StorageState>({ set, get }); + const sessionsDomain = createSessionsDomain<StorageState>({ set, get }); + const pendingDomain = createPendingDomain<StorageState>({ set, get }); + const messagesDomain = createMessagesDomain<StorageState>({ set, get }); + const realtimeDomain = createRealtimeDomain<StorageState>({ set, get }); + const artifactsDomain = createArtifactsDomain<StorageState>({ set, get }); + const friendsDomain = createFriendsDomain<StorageState>({ set, get }); + const feedDomain = createFeedDomain<StorageState>({ set, get }); - // Rebuild sessionListViewData to reflect machine changes - const sessionListViewData = buildSessionListViewData( - state.sessions - ); - - return { - ...state, - machines: mergedMachines, - sessionListViewData - }; - }), - // Artifact methods - applyArtifacts: (artifacts: DecryptedArtifact[]) => set((state) => { - console.log(`🗂️ Storage.applyArtifacts: Applying ${artifacts.length} artifacts`); - const mergedArtifacts = { ...state.artifacts }; - artifacts.forEach(artifact => { - mergedArtifacts[artifact.id] = artifact; - }); - console.log(`🗂️ Storage.applyArtifacts: Total artifacts after merge: ${Object.keys(mergedArtifacts).length}`); - - return { - ...state, - artifacts: mergedArtifacts - }; - }), - addArtifact: (artifact: DecryptedArtifact) => set((state) => { - const updatedArtifacts = { - ...state.artifacts, - [artifact.id]: artifact - }; - - return { - ...state, - artifacts: updatedArtifacts - }; - }), - updateArtifact: (artifact: DecryptedArtifact) => set((state) => { - const updatedArtifacts = { - ...state.artifacts, - [artifact.id]: artifact - }; - - return { - ...state, - artifacts: updatedArtifacts - }; - }), - deleteArtifact: (artifactId: string) => set((state) => { - const { [artifactId]: _, ...remainingArtifacts } = state.artifacts; - - return { - ...state, - artifacts: remainingArtifacts - }; - }), - deleteSession: (sessionId: string) => set((state) => { - // Remove session from sessions - const { [sessionId]: deletedSession, ...remainingSessions } = state.sessions; - - // Remove session messages if they exist - const { [sessionId]: deletedMessages, ...remainingSessionMessages } = state.sessionMessages; - - // Remove session git status if it exists - const { [sessionId]: deletedGitStatus, ...remainingGitStatus } = state.sessionGitStatus; - - // Clear drafts and permission modes from persistent storage - const drafts = loadSessionDrafts(); - delete drafts[sessionId]; - saveSessionDrafts(drafts); - - const modes = loadSessionPermissionModes(); - delete modes[sessionId]; - saveSessionPermissionModes(modes); - - // Rebuild sessionListViewData without the deleted session - const sessionListViewData = buildSessionListViewData(remainingSessions); - - return { - ...state, - sessions: remainingSessions, - sessionMessages: remainingSessionMessages, - sessionGitStatus: remainingGitStatus, - sessionListViewData - }; - }), - // Friend management methods - applyFriends: (friends: UserProfile[]) => set((state) => { - const mergedFriends = { ...state.friends }; - friends.forEach(friend => { - mergedFriends[friend.id] = friend; - }); - return { - ...state, - friends: mergedFriends, - friendsLoaded: true // Mark as loaded after first fetch - }; - }), - applyRelationshipUpdate: (event: RelationshipUpdatedEvent) => set((state) => { - const { fromUserId, toUserId, status, action, fromUser, toUser } = event; - const currentUserId = state.profile.id; - - // Update friends cache - const updatedFriends = { ...state.friends }; - - // Determine which user profile to update based on perspective - const otherUserId = fromUserId === currentUserId ? toUserId : fromUserId; - const otherUser = fromUserId === currentUserId ? toUser : fromUser; - - if (action === 'deleted' || status === 'none') { - // Remove from friends if deleted or status is none - delete updatedFriends[otherUserId]; - } else if (otherUser) { - // Update or add the user profile with current status - updatedFriends[otherUserId] = otherUser; - } - - return { - ...state, - friends: updatedFriends - }; - }), - getFriend: (userId: string) => { - return get().friends[userId]; - }, - getAcceptedFriends: () => { - const friends = get().friends; - return Object.values(friends).filter(friend => friend.status === 'friend'); - }, - // User cache methods - applyUsers: (users: Record<string, UserProfile | null>) => set((state) => ({ - ...state, - users: { ...state.users, ...users } - })), - getUser: (userId: string) => { - return get().users[userId]; // Returns UserProfile | null | undefined - }, - assumeUsers: async (userIds: string[]) => { - // This will be implemented in sync.ts as it needs access to credentials - // Just a placeholder here for the interface - const { sync } = await import('./sync'); - return sync.assumeUsers(userIds); - }, - // Feed methods - applyFeedItems: (items: FeedItem[]) => set((state) => { - // Always mark feed as loaded even if empty - if (items.length === 0) { - return { - ...state, - feedLoaded: true // Mark as loaded even when empty - }; - } - - // Create a map of existing items for quick lookup - const existingMap = new Map<string, FeedItem>(); - state.feedItems.forEach(item => { - existingMap.set(item.id, item); - }); - - // Process new items - const updatedItems = [...state.feedItems]; - let head = state.feedHead; - let tail = state.feedTail; - - items.forEach(newItem => { - // Remove items with same repeatKey if it exists - if (newItem.repeatKey) { - const indexToRemove = updatedItems.findIndex(item => - item.repeatKey === newItem.repeatKey - ); - if (indexToRemove !== -1) { - updatedItems.splice(indexToRemove, 1); - } - } - - // Add new item if it doesn't exist - if (!existingMap.has(newItem.id)) { - updatedItems.push(newItem); - } - - // Update head/tail cursors - if (!head || newItem.counter > parseInt(head.substring(2), 10)) { - head = newItem.cursor; - } - if (!tail || newItem.counter < parseInt(tail.substring(2), 10)) { - tail = newItem.cursor; - } - }); - - // Sort by counter (desc - newest first) - updatedItems.sort((a, b) => b.counter - a.counter); - - return { - ...state, - feedItems: updatedItems, - feedHead: head, - feedTail: tail, - feedLoaded: true // Mark as loaded after first fetch - }; - }), - clearFeed: () => set((state) => ({ - ...state, - feedItems: [], - feedHead: null, - feedTail: null, - feedHasMore: false, - feedLoaded: false, // Reset loading flag - friendsLoaded: false // Reset loading flag - })), + return { + ...settingsDomain, + ...profileDomain, + ...sessionsDomain, + ...machinesDomain, + ...artifactsDomain, + ...friendsDomain, + ...feedDomain, + ...todosDomain, + ...pendingDomain, + ...messagesDomain, + ...realtimeDomain, } }); -export function useSessions() { - return storage(useShallow((state) => state.isDataReady ? state.sessionsData : null)); +export function getStorage() { + return storage; } -export function useSession(id: string): Session | null { - return storage(useShallow((state) => state.sessions[id] ?? null)); -} - -const emptyArray: unknown[] = []; - -export function useSessionMessages(sessionId: string): { messages: Message[], isLoaded: boolean } { - return storage(useShallow((state) => { - const session = state.sessionMessages[sessionId]; - return { - messages: session?.messages ?? emptyArray, - isLoaded: session?.isLoaded ?? false - }; - })); -} - -export function useMessage(sessionId: string, messageId: string): Message | null { - return storage(useShallow((state) => { - const session = state.sessionMessages[sessionId]; - return session?.messagesMap[messageId] ?? null; - })); -} - -export function useSessionUsage(sessionId: string) { - return storage(useShallow((state) => { - const session = state.sessionMessages[sessionId]; - return session?.reducerState?.latestUsage ?? null; - })); -} - -export function useSettings(): Settings { - return storage(useShallow((state) => state.settings)); -} - -export function useSettingMutable<K extends keyof Settings>(name: K): [Settings[K], (value: Settings[K]) => void] { - const setValue = React.useCallback((value: Settings[K]) => { - sync.applySettings({ [name]: value }); - }, [name]); - const value = useSetting(name); - return [value, setValue]; -} - -export function useSetting<K extends keyof Settings>(name: K): Settings[K] { - return storage(useShallow((state) => state.settings[name])); -} - -export function useLocalSettings(): LocalSettings { - return storage(useShallow((state) => state.localSettings)); -} - -export function useAllMachines(): Machine[] { - return storage(useShallow((state) => { - if (!state.isDataReady) return []; - return (Object.values(state.machines).sort((a, b) => b.createdAt - a.createdAt)).filter((v) => v.active); - })); -} - -export function useMachine(machineId: string): Machine | null { - return storage(useShallow((state) => state.machines[machineId] ?? null)); -} - -export function useSessionListViewData(): SessionListViewItem[] | null { - return storage((state) => state.isDataReady ? state.sessionListViewData : null); -} - -export function useAllSessions(): Session[] { - return storage(useShallow((state) => { - if (!state.isDataReady) return []; - return Object.values(state.sessions).sort((a, b) => b.updatedAt - a.updatedAt); - })); -} - -export function useLocalSettingMutable<K extends keyof LocalSettings>(name: K): [LocalSettings[K], (value: LocalSettings[K]) => void] { - const setValue = React.useCallback((value: LocalSettings[K]) => { - storage.getState().applyLocalSettings({ [name]: value }); - }, [name]); - const value = useLocalSetting(name); - return [value, setValue]; -} - -// Project management hooks -export function useProjects() { - return storage(useShallow((state) => state.getProjects())); -} - -export function useProject(projectId: string | null) { - return storage(useShallow((state) => projectId ? state.getProject(projectId) : null)); -} - -export function useProjectForSession(sessionId: string | null) { - return storage(useShallow((state) => sessionId ? state.getProjectForSession(sessionId) : null)); -} - -export function useProjectSessions(projectId: string | null) { - return storage(useShallow((state) => projectId ? state.getProjectSessions(projectId) : [])); -} - -export function useProjectGitStatus(projectId: string | null) { - return storage(useShallow((state) => projectId ? state.getProjectGitStatus(projectId) : null)); -} - -export function useSessionProjectGitStatus(sessionId: string | null) { - return storage(useShallow((state) => sessionId ? state.getSessionProjectGitStatus(sessionId) : null)); -} - -export function useLocalSetting<K extends keyof LocalSettings>(name: K): LocalSettings[K] { - return storage(useShallow((state) => state.localSettings[name])); -} - -// Artifact hooks -export function useArtifacts(): DecryptedArtifact[] { - return storage(useShallow((state) => { - if (!state.isDataReady) return []; - // Filter out draft artifacts from the main list - return Object.values(state.artifacts) - .filter(artifact => !artifact.draft) - .sort((a, b) => b.updatedAt - a.updatedAt); - })); -} - -export function useAllArtifacts(): DecryptedArtifact[] { - return storage(useShallow((state) => { - if (!state.isDataReady) return []; - // Return all artifacts including drafts - return Object.values(state.artifacts).sort((a, b) => b.updatedAt - a.updatedAt); - })); -} - -export function useDraftArtifacts(): DecryptedArtifact[] { - return storage(useShallow((state) => { - if (!state.isDataReady) return []; - // Return only draft artifacts - return Object.values(state.artifacts) - .filter(artifact => artifact.draft === true) - .sort((a, b) => b.updatedAt - a.updatedAt); - })); -} - -export function useArtifact(artifactId: string): DecryptedArtifact | null { - return storage(useShallow((state) => state.artifacts[artifactId] ?? null)); -} - -export function useArtifactsCount(): number { - return storage(useShallow((state) => { - // Count only non-draft artifacts - return Object.values(state.artifacts).filter(a => !a.draft).length; - })); -} - -export function useEntitlement(id: KnownEntitlements): boolean { - return storage(useShallow((state) => state.purchases.entitlements[id] ?? false)); -} - -export function useRealtimeStatus(): 'disconnected' | 'connecting' | 'connected' | 'error' { - return storage(useShallow((state) => state.realtimeStatus)); -} - -export function useRealtimeMode(): 'idle' | 'speaking' { - return storage(useShallow((state) => state.realtimeMode)); -} - -export function useSocketStatus() { - return storage(useShallow((state) => ({ - status: state.socketStatus, - lastConnectedAt: state.socketLastConnectedAt, - lastDisconnectedAt: state.socketLastDisconnectedAt - }))); -} - -export function useSessionGitStatus(sessionId: string): GitStatus | null { - return storage(useShallow((state) => state.sessionGitStatus[sessionId] ?? null)); -} - -export function useIsDataReady(): boolean { - return storage(useShallow((state) => state.isDataReady)); -} - -export function useProfile() { - return storage(useShallow((state) => state.profile)); -} - -export function useFriends() { - return storage(useShallow((state) => state.friends)); -} - -export function useFriendRequests() { - return storage(useShallow((state) => { - // Filter friends to get pending requests (where status is 'pending') - return Object.values(state.friends).filter(friend => friend.status === 'pending'); - })); -} - -export function useAcceptedFriends() { - return storage(useShallow((state) => { - return Object.values(state.friends).filter(friend => friend.status === 'friend'); - })); -} - -export function useFeedItems() { - return storage(useShallow((state) => state.feedItems)); -} -export function useFeedLoaded() { - return storage((state) => state.feedLoaded); -} -export function useFriendsLoaded() { - return storage((state) => state.friendsLoaded); -} - -export function useFriend(userId: string | undefined) { - return storage(useShallow((state) => userId ? state.friends[userId] : undefined)); -} - -export function useUser(userId: string | undefined) { - return storage(useShallow((state) => userId ? state.users[userId] : undefined)); -} - -export function useRequestedFriends() { - return storage(useShallow((state) => { - // Filter friends to get sent requests (where status is 'requested') - return Object.values(state.friends).filter(friend => friend.status === 'requested'); - })); -} +export * from './store/hooks'; diff --git a/expo-app/sources/sync/storageTypes.discardedCommitted.test.ts b/expo-app/sources/sync/storageTypes.discardedCommitted.test.ts new file mode 100644 index 000000000..ccce479f9 --- /dev/null +++ b/expo-app/sources/sync/storageTypes.discardedCommitted.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest'; +import { MetadataSchema } from './storageTypes'; + +describe('MetadataSchema (discarded committed messages)', () => { + it('preserves discardedCommittedMessageLocalIds', () => { + const parsed = MetadataSchema.parse({ + path: '/tmp', + host: 'localhost', + discardedCommittedMessageLocalIds: ['local-1'], + }); + + expect(parsed.discardedCommittedMessageLocalIds).toEqual(['local-1']); + }); +}); + diff --git a/expo-app/sources/sync/storageTypes.terminal.test.ts b/expo-app/sources/sync/storageTypes.terminal.test.ts new file mode 100644 index 000000000..412c49f3e --- /dev/null +++ b/expo-app/sources/sync/storageTypes.terminal.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect } from 'vitest'; + +import { MetadataSchema } from './storageTypes'; + +describe('MetadataSchema', () => { + it('should preserve terminal metadata when present', () => { + const parsed = MetadataSchema.parse({ + path: '/tmp', + host: 'host', + terminal: { + mode: 'tmux', + requested: 'tmux', + tmux: { + target: 'happy:win-1', + tmpDir: '/tmp/happy-tmux', + }, + }, + } as any); + + expect((parsed as any).terminal).toEqual({ + mode: 'tmux', + requested: 'tmux', + tmux: { + target: 'happy:win-1', + tmpDir: '/tmp/happy-tmux', + }, + }); + }); + + it('should preserve Auggie vendor session metadata when present', () => { + const parsed = MetadataSchema.parse({ + path: '/tmp', + host: 'host', + auggieSessionId: 'auggie-session-1', + auggieAllowIndexing: true, + } as any); + + expect((parsed as any).auggieSessionId).toBe('auggie-session-1'); + expect((parsed as any).auggieAllowIndexing).toBe(true); + }); +}); diff --git a/expo-app/sources/sync/storageTypes.ts b/expo-app/sources/sync/storageTypes.ts index 82fedb5c1..c7c7bc3e0 100644 --- a/expo-app/sources/sync/storageTypes.ts +++ b/expo-app/sources/sync/storageTypes.ts @@ -1,4 +1,7 @@ import { z } from "zod"; +import { PERMISSION_MODES } from "@/constants/PermissionModes"; +import type { PermissionMode } from "@/constants/PermissionModes"; +import type { ModelMode } from "@/sync/permissionTypes"; // // Agent states @@ -10,18 +13,82 @@ export const MetadataSchema = z.object({ version: z.string().optional(), name: z.string().optional(), os: z.string().optional(), + profileId: z.string().nullable().optional(), // Session-scoped profile identity (non-secret) summary: z.object({ text: z.string(), updatedAt: z.number() }).optional(), machineId: z.string().optional(), claudeSessionId: z.string().optional(), // Claude Code session ID + codexSessionId: z.string().optional(), // Codex session/conversation ID (uuid) + geminiSessionId: z.string().optional(), // Gemini ACP session ID (opaque) + opencodeSessionId: z.string().optional(), // OpenCode ACP session ID (opaque) + auggieSessionId: z.string().optional(), // Auggie ACP session ID (opaque) + auggieAllowIndexing: z.boolean().optional(), // Auggie indexing enablement (spawn-time) tools: z.array(z.string()).optional(), slashCommands: z.array(z.string()).optional(), + slashCommandDetails: z.array(z.object({ + command: z.string(), + description: z.string().optional(), + })).optional(), + acpHistoryImportV1: z.object({ + v: z.literal(1), + provider: z.string(), + remoteSessionId: z.string(), + importedAt: z.number(), + lastImportedFingerprint: z.string().optional(), + }).optional(), homeDir: z.string().optional(), // User's home directory on the machine happyHomeDir: z.string().optional(), // Happy configuration directory hostPid: z.number().optional(), // Process ID of the session - flavor: z.string().nullish() // Session flavor/variant identifier + terminal: z.object({ + mode: z.enum(['plain', 'tmux']), + requested: z.enum(['plain', 'tmux']).optional(), + fallbackReason: z.string().optional(), + tmux: z.object({ + target: z.string(), + tmpDir: z.string().optional(), + }).optional(), + }).optional(), + flavor: z.string().nullish(), // Session flavor/variant identifier + // Published by happy-cli so the app can seed permission state even before there are messages. + permissionMode: z.enum(PERMISSION_MODES).optional(), + permissionModeUpdatedAt: z.number().optional(), + messageQueueV1: z.object({ + v: z.literal(1), + queue: z.array(z.object({ + localId: z.string(), + message: z.string(), + createdAt: z.number(), + updatedAt: z.number(), + })), + inFlight: z.object({ + localId: z.string(), + message: z.string(), + createdAt: z.number(), + updatedAt: z.number(), + claimedAt: z.number(), + }).nullable().optional(), + }).optional(), + messageQueueV1Discarded: z.array(z.object({ + localId: z.string(), + message: z.string(), + createdAt: z.number(), + updatedAt: z.number(), + discardedAt: z.number(), + discardedReason: z.enum(['switch_to_local', 'manual']), + })).optional(), + /** + * Local-only markers for committed transcript messages that should be treated as discarded + * (e.g. when the user switches to terminal control and abandons unprocessed remote messages). + */ + discardedCommittedMessageLocalIds: z.array(z.string()).optional(), + readStateV1: z.object({ + v: z.literal(1), + sessionSeq: z.number(), + pendingActivityAt: z.number(), + updatedAt: z.number(), + }).optional(), }); export type Metadata = z.infer<typeof MetadataSchema>; @@ -42,9 +109,16 @@ export const AgentStateSchema = z.object({ reason: z.string().nullish(), mode: z.string().nullish(), allowedTools: z.array(z.string()).nullish(), - decision: z.enum(['approved', 'approved_for_session', 'denied', 'abort']).nullish() - })).nullish() -}); + decision: z.enum(['approved', 'approved_for_session', 'approved_execpolicy_amendment', 'denied', 'abort']).nullish() + })).nullish(), + /** + * Optional agent capabilities negotiated via agentState. + * This must be permissive for backward/forward compatibility across agent versions. + */ + capabilities: z.object({ + askUserQuestionAnswersInPermission: z.boolean().optional(), + }).nullish(), +}).passthrough(); export type AgentState = z.infer<typeof AgentStateSchema>; @@ -62,6 +136,7 @@ export interface Session { thinking: boolean, thinkingAt: number, presence: "online" | number, // "online" when active, timestamp when last seen + optimisticThinkingAt?: number | null; // Local-only timestamp used for immediate "processing" UI feedback after submit todos?: Array<{ content: string; status: 'pending' | 'in_progress' | 'completed'; @@ -69,8 +144,9 @@ export interface Session { id: string; }>; draft?: string | null; // Local draft message, not synced to server - permissionMode?: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' | 'read-only' | 'safe-yolo' | 'yolo' | null; // Local permission mode, not synced to server - modelMode?: 'default' | 'gemini-2.5-pro' | 'gemini-2.5-flash' | 'gemini-2.5-flash-lite' | null; // Local model mode, not synced to server + permissionMode?: PermissionMode | null; // Local permission mode, not synced to server + permissionModeUpdatedAt?: number | null; // Local timestamp to coordinate inferred (from last message) vs user-selected mode, not synced to server + modelMode?: ModelMode | null; // Local model mode, not synced to server // IMPORTANT: latestUsage is extracted from reducerState.latestUsage after message processing. // We store it directly on Session to ensure it's available immediately on load. // Do NOT store reducerState itself on Session - it's mutable and should only exist in SessionMessages. @@ -82,6 +158,31 @@ export interface Session { contextSize: number; timestamp: number; } | null; + // Sharing-related fields + owner?: string; // User ID of the session owner (for shared sessions) + ownerProfile?: { + id: string; + username: string | null; + firstName: string | null; + lastName: string | null; + avatar: string | null; + }; // Owner profile information (for shared sessions) + accessLevel?: 'view' | 'edit' | 'admin'; // Access level for shared sessions +} + +export interface PendingMessage { + id: string; + localId: string | null; + createdAt: number; + updatedAt: number; + text: string; + displayText?: string; + rawRecord: any; +} + +export interface DiscardedPendingMessage extends PendingMessage { + discardedAt: number; + discardedReason: 'switch_to_local' | 'manual'; } export interface DecryptedMessage { @@ -153,4 +254,4 @@ export interface GitStatus { aheadCount?: number; // Commits ahead of upstream behindCount?: number; // Commits behind upstream stashCount?: number; // Number of stash entries -} \ No newline at end of file +} diff --git a/expo-app/sources/sync/store/domains/_shared.ts b/expo-app/sources/sync/store/domains/_shared.ts new file mode 100644 index 000000000..10021f5fa --- /dev/null +++ b/expo-app/sources/sync/store/domains/_shared.ts @@ -0,0 +1,6 @@ +export type StoreSet<S> = { + (partial: S | Partial<S> | ((state: S) => S | Partial<S>), replace?: false): void; + (state: S | ((state: S) => S), replace: true): void; +}; + +export type StoreGet<S> = () => S; diff --git a/expo-app/sources/sync/store/domains/artifacts.ts b/expo-app/sources/sync/store/domains/artifacts.ts new file mode 100644 index 000000000..db5e2d024 --- /dev/null +++ b/expo-app/sources/sync/store/domains/artifacts.ts @@ -0,0 +1,67 @@ +import type { DecryptedArtifact } from '../../artifactTypes'; +import type { StoreGet, StoreSet } from './_shared'; + +export type ArtifactsDomain = { + artifacts: Record<string, DecryptedArtifact>; + applyArtifacts: (artifacts: DecryptedArtifact[]) => void; + addArtifact: (artifact: DecryptedArtifact) => void; + updateArtifact: (artifact: DecryptedArtifact) => void; + deleteArtifact: (artifactId: string) => void; +}; + +export function createArtifactsDomain<S extends ArtifactsDomain>({ + set, +}: { + set: StoreSet<S>; + get: StoreGet<S>; +}): ArtifactsDomain { + return { + artifacts: {}, + applyArtifacts: (artifacts) => + set((state) => { + const mergedArtifacts = { ...state.artifacts }; + artifacts.forEach((artifact) => { + mergedArtifacts[artifact.id] = artifact; + }); + + return { + ...state, + artifacts: mergedArtifacts, + }; + }), + addArtifact: (artifact) => + set((state) => { + const updatedArtifacts = { + ...state.artifacts, + [artifact.id]: artifact, + }; + + return { + ...state, + artifacts: updatedArtifacts, + }; + }), + updateArtifact: (artifact) => + set((state) => { + const updatedArtifacts = { + ...state.artifacts, + [artifact.id]: artifact, + }; + + return { + ...state, + artifacts: updatedArtifacts, + }; + }), + deleteArtifact: (artifactId) => + set((state) => { + const { [artifactId]: _, ...remainingArtifacts } = state.artifacts; + + return { + ...state, + artifacts: remainingArtifacts, + }; + }), + }; +} + diff --git a/expo-app/sources/sync/store/domains/feed.ts b/expo-app/sources/sync/store/domains/feed.ts new file mode 100644 index 000000000..95ff327cf --- /dev/null +++ b/expo-app/sources/sync/store/domains/feed.ts @@ -0,0 +1,93 @@ +import type { FeedItem } from '../../feedTypes'; +import type { StoreGet, StoreSet } from './_shared'; + +export type FeedDomain = { + feedItems: FeedItem[]; + feedHead: string | null; + feedTail: string | null; + feedHasMore: boolean; + feedLoaded: boolean; + applyFeedItems: (items: FeedItem[]) => void; + clearFeed: () => void; +}; + +export function createFeedDomain<S extends FeedDomain & { friendsLoaded: boolean }>({ + set, +}: { + set: StoreSet<S>; + get: StoreGet<S>; +}): FeedDomain { + return { + feedItems: [], + feedHead: null, + feedTail: null, + feedHasMore: false, + feedLoaded: false, + applyFeedItems: (items) => + set((state) => { + // Always mark feed as loaded even if empty + if (items.length === 0) { + return { + ...state, + feedLoaded: true, // Mark as loaded even when empty + }; + } + + // Create a map of existing items for quick lookup + const existingMap = new Map<string, FeedItem>(); + state.feedItems.forEach((item) => { + existingMap.set(item.id, item); + }); + + // Process new items + const updatedItems = [...state.feedItems]; + let head = state.feedHead; + let tail = state.feedTail; + + items.forEach((newItem) => { + // Remove items with same repeatKey if it exists + if (newItem.repeatKey) { + const indexToRemove = updatedItems.findIndex((item) => item.repeatKey === newItem.repeatKey); + if (indexToRemove !== -1) { + updatedItems.splice(indexToRemove, 1); + } + } + + // Add new item if it doesn't exist + if (!existingMap.has(newItem.id)) { + updatedItems.push(newItem); + } + + // Update head/tail cursors + if (!head || newItem.counter > parseInt(head.substring(2), 10)) { + head = newItem.cursor; + } + if (!tail || newItem.counter < parseInt(tail.substring(2), 10)) { + tail = newItem.cursor; + } + }); + + // Sort by counter (desc - newest first) + updatedItems.sort((a, b) => b.counter - a.counter); + + return { + ...state, + feedItems: updatedItems, + feedHead: head, + feedTail: tail, + feedLoaded: true, // Mark as loaded after first fetch + }; + }), + clearFeed: () => + set((state) => ({ + ...state, + feedItems: [], + feedHead: null, + feedTail: null, + feedHasMore: false, + feedLoaded: false, // Reset loading flag + friendsLoaded: false, // Reset loading flag + })), + }; +} + diff --git a/expo-app/sources/sync/store/domains/friends.ts b/expo-app/sources/sync/store/domains/friends.ts new file mode 100644 index 000000000..0a0fd4c2e --- /dev/null +++ b/expo-app/sources/sync/store/domains/friends.ts @@ -0,0 +1,80 @@ +import type { RelationshipUpdatedEvent, UserProfile } from '../../friendTypes'; +import type { StoreGet, StoreSet } from './_shared'; + +export type FriendsDomain = { + friends: Record<string, UserProfile>; + users: Record<string, UserProfile | null>; + friendsLoaded: boolean; + applyFriends: (friends: UserProfile[]) => void; + applyRelationshipUpdate: (event: RelationshipUpdatedEvent) => void; + getFriend: (userId: string) => UserProfile | undefined; + getAcceptedFriends: () => UserProfile[]; + applyUsers: (users: Record<string, UserProfile | null>) => void; + getUser: (userId: string) => UserProfile | null | undefined; + assumeUsers: (userIds: string[]) => Promise<void>; +}; + +export function createFriendsDomain< + S extends FriendsDomain & { profile: { id: string } }, +>({ set, get }: { set: StoreSet<S>; get: StoreGet<S> }): FriendsDomain { + return { + friends: {}, + users: {}, + friendsLoaded: false, + applyFriends: (friends) => + set((state) => { + const mergedFriends = { ...state.friends }; + friends.forEach((friend) => { + mergedFriends[friend.id] = friend; + }); + return { + ...state, + friends: mergedFriends, + friendsLoaded: true, // Mark as loaded after first fetch + }; + }), + applyRelationshipUpdate: (event) => + set((state) => { + const { fromUserId, toUserId, status, action, fromUser, toUser } = event; + const currentUserId = state.profile.id; + + // Update friends cache + const updatedFriends = { ...state.friends }; + + // Determine which user profile to update based on perspective + const otherUserId = fromUserId === currentUserId ? toUserId : fromUserId; + const otherUser = fromUserId === currentUserId ? toUser : fromUser; + + if (action === 'deleted' || status === 'none') { + // Remove from friends if deleted or status is none + delete updatedFriends[otherUserId]; + } else if (otherUser) { + // Update or add the user profile with current status + updatedFriends[otherUserId] = otherUser; + } + + return { + ...state, + friends: updatedFriends, + }; + }), + getFriend: (userId) => get().friends[userId], + getAcceptedFriends: () => { + const friends = get().friends; + return Object.values(friends).filter((friend) => friend.status === 'friend'); + }, + applyUsers: (users) => + set((state) => ({ + ...state, + users: { ...state.users, ...users }, + })), + getUser: (userId) => get().users[userId], // Returns UserProfile | null | undefined + assumeUsers: async (userIds) => { + // This will be implemented in sync.ts as it needs access to credentials + // Just a placeholder here for the interface + const { sync } = await import('../../sync'); + return sync.assumeUsers(userIds); + }, + }; +} + diff --git a/expo-app/sources/sync/store/domains/machines.ts b/expo-app/sources/sync/store/domains/machines.ts new file mode 100644 index 000000000..3bfda1747 --- /dev/null +++ b/expo-app/sources/sync/store/domains/machines.ts @@ -0,0 +1,55 @@ +import type { Machine, Session } from '../../storageTypes'; +import type { Settings } from '../../settings'; +import type { SessionListViewItem } from '../../sessionListViewData'; +import { buildSessionListViewData } from '../../sessionListViewData'; + +import type { StoreGet, StoreSet } from './_shared'; + +export type MachinesDomain = { + machines: Record<string, Machine>; + applyMachines: (machines: Machine[], replace?: boolean) => void; +}; + +type MachinesDomainDependencies = Readonly<{ + sessions: Record<string, Session>; + settings: Settings; + sessionListViewData: SessionListViewItem[] | null; +}>; + +export function createMachinesDomain<S extends MachinesDomain & MachinesDomainDependencies>({ + set, +}: { + set: StoreSet<S>; + get: StoreGet<S>; +}): MachinesDomain { + return { + machines: {}, + applyMachines: (machines, replace = false) => + set((state) => { + let mergedMachines: Record<string, Machine>; + + if (replace) { + mergedMachines = {}; + machines.forEach((machine) => { + mergedMachines[machine.id] = machine; + }); + } else { + mergedMachines = { ...state.machines }; + machines.forEach((machine) => { + mergedMachines[machine.id] = machine; + }); + } + + const sessionListViewData = buildSessionListViewData(state.sessions, mergedMachines, { + groupInactiveSessionsByProject: state.settings.groupInactiveSessionsByProject, + }); + + return { + ...state, + machines: mergedMachines, + sessionListViewData, + }; + }), + }; +} + diff --git a/expo-app/sources/sync/store/domains/messages.ts b/expo-app/sources/sync/store/domains/messages.ts new file mode 100644 index 000000000..e6acfdb7a --- /dev/null +++ b/expo-app/sources/sync/store/domains/messages.ts @@ -0,0 +1,266 @@ +import { PERMISSION_MODES } from '@/constants/PermissionModes'; +import type { PermissionMode } from '@/sync/permissionTypes'; +import { isMutableTool } from '@/components/tools/knownTools'; + +import { createReducer, reducer, type ReducerState } from '../../reducer/reducer'; +import type { Message } from '../../typesMessage'; +import type { NormalizedMessage } from '../../typesRaw'; +import type { Session } from '../../storageTypes'; + +import { persistSessionPermissionData } from './sessions'; +import type { SessionPending } from './pending'; +import type { StoreGet, StoreSet } from './_shared'; + +export type SessionMessages = { + messages: Message[]; + messagesMap: Record<string, Message>; + reducerState: ReducerState; + isLoaded: boolean; +}; + +export type MessagesDomain = { + sessionMessages: Record<string, SessionMessages>; + isMutableToolCall: (sessionId: string, callId: string) => boolean; + applyMessages: (sessionId: string, messages: NormalizedMessage[]) => { changed: string[]; hasReadyEvent: boolean }; + applyMessagesLoaded: (sessionId: string) => void; +}; + +type MessagesDomainDependencies = { + sessions: Record<string, Session>; + sessionPending: Record<string, SessionPending>; +}; + +export function createMessagesDomain<S extends MessagesDomain & MessagesDomainDependencies>({ + set, + get, +}: { + set: StoreSet<S>; + get: StoreGet<S>; +}): MessagesDomain { + return { + sessionMessages: {}, + isMutableToolCall: (sessionId: string, callId: string) => { + const sessionMessages = get().sessionMessages[sessionId]; + if (!sessionMessages) { + return true; + } + const toolCall = sessionMessages.reducerState.toolIdToMessageId.get(callId); + if (!toolCall) { + return true; + } + const toolCallMessage = sessionMessages.messagesMap[toolCall]; + if (!toolCallMessage || toolCallMessage.kind !== 'tool-call') { + return true; + } + return toolCallMessage.tool?.name ? isMutableTool(toolCallMessage.tool?.name) : true; + }, + applyMessages: (sessionId: string, messages: NormalizedMessage[]) => { + let changed = new Set<string>(); + let hasReadyEvent = false; + set((state) => { + // Resolve session messages state + const existingSession = state.sessionMessages[sessionId] || { + messages: [], + messagesMap: {}, + reducerState: createReducer(), + isLoaded: false + }; + + // Get the session's agentState if available + const session = state.sessions[sessionId]; + const agentState = session?.agentState; + + // Messages are already normalized, no need to process them again + const normalizedMessages = messages; + + // Run reducer with agentState + const reducerResult = reducer(existingSession.reducerState, normalizedMessages, agentState); + const processedMessages = reducerResult.messages; + for (let message of processedMessages) { + changed.add(message.id); + } + if (reducerResult.hasReadyEvent) { + hasReadyEvent = true; + } + + // Merge messages + const mergedMessagesMap = { ...existingSession.messagesMap }; + processedMessages.forEach(message => { + mergedMessagesMap[message.id] = message; + }); + + // Convert to array and sort by createdAt + const messagesArray = Object.values(mergedMessagesMap) + .sort((a, b) => b.createdAt - a.createdAt); + + // Infer session permission mode from the most recent user message meta. + // This makes permission mode "follow" the session across devices/machines without adding server fields. + // Local user changes should win until the next user message is sent (tracked by permissionModeUpdatedAt). + let inferredPermissionMode: PermissionMode | null = null; + let inferredPermissionModeAt: number | null = null; + for (const message of messagesArray) { + if (message.kind !== 'user-text') continue; + const rawMode = message.meta?.permissionMode; + if (!rawMode || !PERMISSION_MODES.includes(rawMode as any)) continue; + const mode = rawMode as PermissionMode; + inferredPermissionMode = mode; + inferredPermissionModeAt = message.createdAt; + break; + } + + // Clear server-pending items once we see the corresponding user message in the transcript. + // We key this off localId, which is preserved when a pending item is materialized into a SessionMessage. + let updatedSessionPending = state.sessionPending; + const pendingState = state.sessionPending[sessionId]; + if (pendingState && pendingState.messages.length > 0) { + const localIdsToClear = new Set<string>(); + for (const m of processedMessages) { + if (m.kind === 'user-text' && m.localId) { + localIdsToClear.add(m.localId); + } + } + if (localIdsToClear.size > 0) { + const filtered = pendingState.messages.filter((p) => !p.localId || !localIdsToClear.has(p.localId)); + if (filtered.length !== pendingState.messages.length) { + updatedSessionPending = { + ...state.sessionPending, + [sessionId]: { + ...pendingState, + messages: filtered + } + }; + } + } + } + + // Update session with todos and latestUsage + // IMPORTANT: We extract latestUsage from the mutable reducerState and copy it to the Session object + // This ensures latestUsage is available immediately on load, even before messages are fully loaded + let updatedSessions = state.sessions; + const needsUpdate = (reducerResult.todos !== undefined || existingSession.reducerState.latestUsage) && session; + + const canInferPermissionMode = Boolean( + session && + inferredPermissionMode && + inferredPermissionModeAt && + // NOTE: inferredPermissionModeAt comes from message.createdAt (server timestamp for remote messages, + // and best-effort server-aligned timestamp for locally-created optimistic messages). + // permissionModeUpdatedAt is stamped using nowServerMs() for clock-safe ordering across devices. + inferredPermissionModeAt > (session.permissionModeUpdatedAt ?? 0) + ); + + const shouldWritePermissionMode = + canInferPermissionMode && + (session!.permissionMode ?? 'default') !== inferredPermissionMode; + + if (needsUpdate || shouldWritePermissionMode) { + updatedSessions = { + ...state.sessions, + [sessionId]: { + ...session, + ...(reducerResult.todos !== undefined && { todos: reducerResult.todos }), + // Copy latestUsage from reducerState to make it immediately available + latestUsage: existingSession.reducerState.latestUsage ? { + ...existingSession.reducerState.latestUsage + } : session.latestUsage, + ...(shouldWritePermissionMode && { + permissionMode: inferredPermissionMode, + permissionModeUpdatedAt: inferredPermissionModeAt + }) + } + }; + + // Persist permission modes (only non-default values to save space) + // Note: this includes modes inferred from session messages so they load instantly on app restart. + if (shouldWritePermissionMode) { + persistSessionPermissionData(updatedSessions); + } + } + + return { + ...state, + sessions: updatedSessions, + sessionMessages: { + ...state.sessionMessages, + [sessionId]: { + ...existingSession, + messages: messagesArray, + messagesMap: mergedMessagesMap, + reducerState: existingSession.reducerState, // Explicitly include the mutated reducer state + isLoaded: true + } + }, + sessionPending: updatedSessionPending + }; + }); + + return { changed: Array.from(changed), hasReadyEvent }; + }, + applyMessagesLoaded: (sessionId: string) => set((state) => { + const existingSession = state.sessionMessages[sessionId]; + + if (!existingSession) { + // First time loading - check for AgentState + const session = state.sessions[sessionId]; + const agentState = session?.agentState; + + // Create new reducer state + const reducerState = createReducer(); + + // Process AgentState if it exists + let messages: Message[] = []; + let messagesMap: Record<string, Message> = {}; + + if (agentState) { + // Process AgentState through reducer to get initial permission messages + const reducerResult = reducer(reducerState, [], agentState); + const processedMessages = reducerResult.messages; + + processedMessages.forEach(message => { + messagesMap[message.id] = message; + }); + + messages = Object.values(messagesMap) + .sort((a, b) => b.createdAt - a.createdAt); + } + + // Extract latestUsage from reducerState if available and update session + let updatedSessions = state.sessions; + if (session && reducerState.latestUsage) { + updatedSessions = { + ...state.sessions, + [sessionId]: { + ...session, + latestUsage: { ...reducerState.latestUsage } + } + }; + } + + return { + ...state, + sessions: updatedSessions, + sessionMessages: { + ...state.sessionMessages, + [sessionId]: { + reducerState, + messages, + messagesMap, + isLoaded: true + } satisfies SessionMessages + } + }; + } + + return { + ...state, + sessionMessages: { + ...state.sessionMessages, + [sessionId]: { + ...existingSession, + isLoaded: true + } satisfies SessionMessages + } + }; + }), + }; +} diff --git a/expo-app/sources/sync/store/domains/pending.ts b/expo-app/sources/sync/store/domains/pending.ts new file mode 100644 index 000000000..5c4cca9ae --- /dev/null +++ b/expo-app/sources/sync/store/domains/pending.ts @@ -0,0 +1,99 @@ +import type { DiscardedPendingMessage, PendingMessage } from '../../storageTypes'; + +import type { StoreGet, StoreSet } from './_shared'; + +export type SessionPending = { + messages: PendingMessage[]; + discarded: DiscardedPendingMessage[]; + isLoaded: boolean; +}; + +export type PendingDomain = { + sessionPending: Record<string, SessionPending>; + applyPendingLoaded: (sessionId: string) => void; + applyPendingMessages: (sessionId: string, messages: PendingMessage[]) => void; + applyDiscardedPendingMessages: (sessionId: string, messages: DiscardedPendingMessage[]) => void; + upsertPendingMessage: (sessionId: string, message: PendingMessage) => void; + removePendingMessage: (sessionId: string, pendingId: string) => void; +}; + +export function createPendingDomain<S extends PendingDomain>({ + set, + get: _get, +}: { + set: StoreSet<S>; + get: StoreGet<S>; +}): PendingDomain { + return { + sessionPending: {}, + applyPendingLoaded: (sessionId: string) => set((state) => { + const existing = state.sessionPending[sessionId]; + return { + ...state, + sessionPending: { + ...state.sessionPending, + [sessionId]: { + messages: existing?.messages ?? [], + discarded: existing?.discarded ?? [], + isLoaded: true + } + } + }; + }), + applyPendingMessages: (sessionId: string, messages: PendingMessage[]) => set((state) => ({ + ...state, + sessionPending: { + ...state.sessionPending, + [sessionId]: { + messages, + discarded: state.sessionPending[sessionId]?.discarded ?? [], + isLoaded: true + } + } + })), + applyDiscardedPendingMessages: (sessionId: string, messages: DiscardedPendingMessage[]) => set((state) => ({ + ...state, + sessionPending: { + ...state.sessionPending, + [sessionId]: { + messages: state.sessionPending[sessionId]?.messages ?? [], + discarded: messages, + isLoaded: state.sessionPending[sessionId]?.isLoaded ?? false, + }, + }, + })), + upsertPendingMessage: (sessionId: string, message: PendingMessage) => set((state) => { + const existing = state.sessionPending[sessionId] ?? { messages: [], discarded: [], isLoaded: false }; + const idx = existing.messages.findIndex((m) => m.id === message.id); + const next = idx >= 0 + ? [...existing.messages.slice(0, idx), message, ...existing.messages.slice(idx + 1)] + : [...existing.messages, message].sort((a, b) => a.createdAt - b.createdAt); + return { + ...state, + sessionPending: { + ...state.sessionPending, + [sessionId]: { + messages: next, + discarded: existing.discarded, + isLoaded: existing.isLoaded + } + } + }; + }), + removePendingMessage: (sessionId: string, pendingId: string) => set((state) => { + const existing = state.sessionPending[sessionId]; + if (!existing) return state; + return { + ...state, + sessionPending: { + ...state.sessionPending, + [sessionId]: { + ...existing, + messages: existing.messages.filter((m) => m.id !== pendingId) + } + } + }; + }), + }; +} + diff --git a/expo-app/sources/sync/store/domains/profile.ts b/expo-app/sources/sync/store/domains/profile.ts new file mode 100644 index 000000000..5443eee75 --- /dev/null +++ b/expo-app/sources/sync/store/domains/profile.ts @@ -0,0 +1,31 @@ +import type { Profile } from '../../profile'; +import { loadProfile, saveProfile } from '../../persistence'; + +import type { StoreGet, StoreSet } from './_shared'; + +export type ProfileDomain = { + profile: Profile; + applyProfile: (profile: Profile) => void; +}; + +export function createProfileDomain<S extends ProfileDomain>({ + set, +}: { + set: StoreSet<S>; + get: StoreGet<S>; +}): ProfileDomain { + const profile = loadProfile(); + + return { + profile, + applyProfile: (nextProfile) => + set((state) => { + saveProfile(nextProfile); + return { + ...state, + profile: nextProfile, + }; + }), + }; +} + diff --git a/expo-app/sources/sync/store/domains/realtime.ts b/expo-app/sources/sync/store/domains/realtime.ts new file mode 100644 index 000000000..58f94101d --- /dev/null +++ b/expo-app/sources/sync/store/domains/realtime.ts @@ -0,0 +1,135 @@ +import type { StoreGet, StoreSet } from './_shared'; + +export type RealtimeStatus = 'disconnected' | 'connecting' | 'connected' | 'error'; +export type RealtimeMode = 'idle' | 'speaking'; +export type SocketStatus = 'disconnected' | 'connecting' | 'connected' | 'error'; + +export type SyncError = { + message: string; + retryable: boolean; + kind: 'auth' | 'config' | 'network' | 'server' | 'unknown'; + at: number; + failuresCount?: number; + nextRetryAt?: number; +} | null; + +export type NativeUpdateStatus = { available: boolean; updateUrl?: string } | null; + +export type RealtimeDomain = { + realtimeStatus: RealtimeStatus; + realtimeMode: RealtimeMode; + socketStatus: SocketStatus; + socketLastConnectedAt: number | null; + socketLastDisconnectedAt: number | null; + socketLastError: string | null; + socketLastErrorAt: number | null; + syncError: SyncError; + lastSyncAt: number | null; + nativeUpdateStatus: NativeUpdateStatus; + applyNativeUpdateStatus: (status: NativeUpdateStatus) => void; + setRealtimeStatus: (status: RealtimeStatus) => void; + setRealtimeMode: (mode: RealtimeMode, immediate?: boolean) => void; + clearRealtimeModeDebounce: () => void; + setSocketStatus: (status: SocketStatus) => void; + setSocketError: (message: string | null) => void; + setSyncError: (error: SyncError) => void; + clearSyncError: () => void; + setLastSyncAt: (ts: number) => void; +}; + +export function createRealtimeDomain<S extends RealtimeDomain>({ + set, +}: { + set: StoreSet<S>; + get: StoreGet<S>; +}): RealtimeDomain { + // Debounce timer for realtimeMode changes + let realtimeModeDebounceTimer: ReturnType<typeof setTimeout> | null = null; + const REALTIME_MODE_DEBOUNCE_MS = 150; + + return { + realtimeStatus: 'disconnected', + realtimeMode: 'idle', + socketStatus: 'disconnected', + socketLastConnectedAt: null, + socketLastDisconnectedAt: null, + socketLastError: null, + socketLastErrorAt: null, + syncError: null, + lastSyncAt: null, + nativeUpdateStatus: null, + applyNativeUpdateStatus: (status) => + set((state) => ({ + ...state, + nativeUpdateStatus: status, + })), + setRealtimeStatus: (status) => + set((state) => ({ + ...state, + realtimeStatus: status, + })), + setRealtimeMode: (mode, immediate) => { + if (immediate) { + // Clear any pending debounce and set immediately + if (realtimeModeDebounceTimer) { + clearTimeout(realtimeModeDebounceTimer); + realtimeModeDebounceTimer = null; + } + set((state) => ({ ...state, realtimeMode: mode })); + } else { + // Debounce mode changes to avoid flickering + if (realtimeModeDebounceTimer) { + clearTimeout(realtimeModeDebounceTimer); + } + realtimeModeDebounceTimer = setTimeout(() => { + realtimeModeDebounceTimer = null; + set((state) => ({ ...state, realtimeMode: mode })); + }, REALTIME_MODE_DEBOUNCE_MS); + } + }, + clearRealtimeModeDebounce: () => { + if (realtimeModeDebounceTimer) { + clearTimeout(realtimeModeDebounceTimer); + realtimeModeDebounceTimer = null; + } + }, + setSocketStatus: (status) => + set((state) => { + const now = Date.now(); + const updates: Partial<RealtimeDomain> = { socketStatus: status }; + + // Update timestamp based on status + if (status === 'connected') { + updates.socketLastConnectedAt = now; + updates.socketLastError = null; + updates.socketLastErrorAt = null; + } else if (status === 'disconnected' || status === 'error') { + updates.socketLastDisconnectedAt = now; + } + + return { + ...state, + ...updates, + }; + }), + setSocketError: (message) => + set((state) => { + if (!message) { + return { + ...state, + socketLastError: null, + socketLastErrorAt: null, + }; + } + return { + ...state, + socketLastError: message, + socketLastErrorAt: Date.now(), + }; + }), + setSyncError: (error) => set((state) => ({ ...state, syncError: error })), + clearSyncError: () => set((state) => ({ ...state, syncError: null })), + setLastSyncAt: (ts) => set((state) => ({ ...state, lastSyncAt: ts })), + }; +} + diff --git a/expo-app/sources/sync/store/domains/sessions.ts b/expo-app/sources/sync/store/domains/sessions.ts new file mode 100644 index 000000000..7de000879 --- /dev/null +++ b/expo-app/sources/sync/store/domains/sessions.ts @@ -0,0 +1,612 @@ +import type { GitStatus, Machine, Session } from '../../storageTypes'; +import { createReducer, reducer } from '../../reducer/reducer'; +import type { NormalizedMessage } from '../../typesRaw'; +import { buildSessionListViewData, type SessionListViewItem } from '../../sessionListViewData'; +import { nowServerMs } from '../../time'; +import { loadSessionDrafts, loadSessionLastViewed, loadSessionModelModes, loadSessionPermissionModeUpdatedAts, loadSessionPermissionModes, saveSessionDrafts, saveSessionLastViewed, saveSessionModelModes, saveSessionPermissionModeUpdatedAts, saveSessionPermissionModes } from '../../persistence'; +import { projectManager } from '../../projectManager'; +import { getCurrentRealtimeSessionId, getVoiceSession } from '@/realtime/RealtimeSession'; +import type { PermissionMode } from '@/sync/permissionTypes'; + +import type { StoreGet, StoreSet } from './_shared'; +import type { SessionMessages } from './messages'; + +type SessionModelMode = NonNullable<Session['modelMode']>; + +export type SessionsDomain = { + sessions: Record<string, Session>; + sessionsData: (string | Session)[] | null; + sessionListViewData: SessionListViewItem[] | null; + sessionGitStatus: Record<string, GitStatus | null>; + sessionLastViewed: Record<string, number>; + isDataReady: boolean; + + getActiveSessions: () => Session[]; + applySessions: (sessions: (Omit<Session, 'presence'> & { presence?: 'online' | number })[]) => void; + applyLoaded: () => void; + applyReady: () => void; + + applyGitStatus: (sessionId: string, status: GitStatus | null) => void; + updateSessionDraft: (sessionId: string, draft: string | null) => void; + markSessionOptimisticThinking: (sessionId: string) => void; + clearSessionOptimisticThinking: (sessionId: string) => void; + markSessionViewed: (sessionId: string) => void; + updateSessionPermissionMode: (sessionId: string, mode: PermissionMode) => void; + updateSessionModelMode: (sessionId: string, mode: SessionModelMode) => void; + + getProjects: () => import('../../projectManager').Project[]; + getProject: (projectId: string) => import('../../projectManager').Project | null; + getProjectForSession: (sessionId: string) => import('../../projectManager').Project | null; + getProjectSessions: (projectId: string) => string[]; + + getProjectGitStatus: (projectId: string) => GitStatus | null; + getSessionProjectGitStatus: (sessionId: string) => GitStatus | null; + updateSessionProjectGitStatus: (sessionId: string, status: GitStatus | null) => void; + + deleteSession: (sessionId: string) => void; +}; + +type SessionsDomainDependencies = { + machines: Record<string, Machine>; + sessionMessages: Record<string, SessionMessages>; + settings: { groupInactiveSessionsByProject: boolean }; +}; + +function extractSessionPermissionData(sessions: Record<string, Session>): { + modes: Record<string, PermissionMode>; + updatedAts: Record<string, number>; +} { + const modes: Record<string, PermissionMode> = {}; + const updatedAts: Record<string, number> = {}; + + Object.entries(sessions).forEach(([id, sess]) => { + if (sess.permissionMode && sess.permissionMode !== 'default') { + modes[id] = sess.permissionMode; + } + if (typeof sess.permissionModeUpdatedAt === 'number') { + updatedAts[id] = sess.permissionModeUpdatedAt; + } + }); + + return { modes, updatedAts }; +} + +export function persistSessionPermissionData(sessions: Record<string, Session>): { + modes: Record<string, PermissionMode>; + updatedAts: Record<string, number>; +} | null { + const { modes, updatedAts } = extractSessionPermissionData(sessions); + + try { + saveSessionPermissionModes(modes); + saveSessionPermissionModeUpdatedAts(updatedAts); + return { modes, updatedAts }; + } catch (e) { + console.error('Failed to persist session permission data:', e); + return null; + } +} + +// UI-only "optimistic processing" marker. +// Cleared via timers so components don't need to poll time. +const OPTIMISTIC_SESSION_THINKING_TIMEOUT_MS = 15_000; +const optimisticThinkingTimeoutBySessionId = new Map<string, ReturnType<typeof setTimeout>>(); + +/** + * Centralized session online state resolver + * Returns either "online" (string) or a timestamp (number) for last seen + */ +function resolveSessionOnlineState(session: { active: boolean; activeAt: number }): "online" | number { + // Session is online if the active flag is true + return session.active ? "online" : session.activeAt; +} + +/** + * Checks if a session should be shown in the active sessions group + */ +function isSessionActive(session: { active: boolean; activeAt: number }): boolean { + // Use the active flag directly, no timeout checks + return session.active; +} + +export function createSessionsDomain<S extends SessionsDomain & SessionsDomainDependencies>({ + set, + get, +}: { + set: StoreSet<S>; + get: StoreGet<S>; +}): SessionsDomain { + let sessionDrafts = loadSessionDrafts(); + let sessionPermissionModes = loadSessionPermissionModes(); + let sessionModelModes = loadSessionModelModes(); + let sessionPermissionModeUpdatedAts = loadSessionPermissionModeUpdatedAts(); + let sessionLastViewed = loadSessionLastViewed(); + + return { + sessions: {}, + sessionsData: null, // Legacy - to be removed + sessionListViewData: null, + sessionGitStatus: {}, + sessionLastViewed, + isDataReady: false, + getActiveSessions: () => { + const state = get(); + return Object.values(state.sessions).filter(s => s.active); + }, + applySessions: (sessions: (Omit<Session, 'presence'> & { presence?: "online" | number })[]) => set((state) => { + // Load drafts and permission modes if sessions are empty (initial load) + const savedDrafts = Object.keys(state.sessions).length === 0 ? sessionDrafts : {}; + const savedPermissionModes = Object.keys(state.sessions).length === 0 ? sessionPermissionModes : {}; + const savedModelModes = Object.keys(state.sessions).length === 0 ? sessionModelModes : {}; + const savedPermissionModeUpdatedAts = Object.keys(state.sessions).length === 0 ? sessionPermissionModeUpdatedAts : {}; + + // Merge new sessions with existing ones + const mergedSessions: Record<string, Session> = { ...state.sessions }; + + // Update sessions with calculated presence using centralized resolver + sessions.forEach(session => { + // Use centralized resolver for consistent state management + const presence = resolveSessionOnlineState(session); + + // Preserve existing draft and permission mode if they exist, or load from saved data + const existingDraft = state.sessions[session.id]?.draft; + const savedDraft = savedDrafts[session.id]; + const existingPermissionMode = state.sessions[session.id]?.permissionMode; + const savedPermissionMode = savedPermissionModes[session.id]; + const existingModelMode = state.sessions[session.id]?.modelMode; + const savedModelMode = savedModelModes[session.id]; + const existingPermissionModeUpdatedAt = state.sessions[session.id]?.permissionModeUpdatedAt; + const savedPermissionModeUpdatedAt = savedPermissionModeUpdatedAts[session.id]; + const existingOptimisticThinkingAt = state.sessions[session.id]?.optimisticThinkingAt ?? null; + + // CLI may publish a session permission mode in encrypted metadata for local-only starts. + // This is a fallback signal for when there are no app-sent user messages carrying meta.permissionMode yet. + const metadataPermissionMode = session.metadata?.permissionMode ?? null; + const metadataPermissionModeUpdatedAt = session.metadata?.permissionModeUpdatedAt ?? null; + + let mergedPermissionMode = + existingPermissionMode || + savedPermissionMode || + session.permissionMode || + 'default'; + + let mergedPermissionModeUpdatedAt = + existingPermissionModeUpdatedAt ?? + savedPermissionModeUpdatedAt ?? + null; + + if (metadataPermissionMode && typeof metadataPermissionModeUpdatedAt === 'number') { + const localUpdatedAt = mergedPermissionModeUpdatedAt ?? 0; + if (metadataPermissionModeUpdatedAt > localUpdatedAt) { + mergedPermissionMode = metadataPermissionMode; + mergedPermissionModeUpdatedAt = metadataPermissionModeUpdatedAt; + } + } + + mergedSessions[session.id] = { + ...session, + presence, + draft: existingDraft || savedDraft || session.draft || null, + optimisticThinkingAt: session.thinking === true ? null : existingOptimisticThinkingAt, + permissionMode: mergedPermissionMode, + // Preserve local coordination timestamp (not synced to server) + permissionModeUpdatedAt: mergedPermissionModeUpdatedAt, + modelMode: existingModelMode || savedModelMode || session.modelMode || 'default', + }; + }); + + // Build active set from all sessions (including existing ones) + const activeSet = new Set<string>(); + Object.values(mergedSessions).forEach(session => { + if (isSessionActive(session)) { + activeSet.add(session.id); + } + }); + + // Separate active and inactive sessions + const activeSessions: Session[] = []; + const inactiveSessions: Session[] = []; + + // Process all sessions from merged set + Object.values(mergedSessions).forEach(session => { + if (activeSet.has(session.id)) { + activeSessions.push(session); + } else { + inactiveSessions.push(session); + } + }); + + // Sort both arrays by creation date for stable ordering + activeSessions.sort((a, b) => b.createdAt - a.createdAt); + inactiveSessions.sort((a, b) => b.createdAt - a.createdAt); + + // Build flat list data for FlashList + const listData: (string | Session)[] = []; + + if (activeSessions.length > 0) { + listData.push('online'); + listData.push(...activeSessions); + } + + // Legacy sessionsData - to be removed + // Machines are now integrated into sessionListViewData + + if (inactiveSessions.length > 0) { + listData.push('offline'); + listData.push(...inactiveSessions); + } + + // Process AgentState updates for sessions that already have messages loaded + const updatedSessionMessages = { ...state.sessionMessages }; + + sessions.forEach(session => { + const oldSession = state.sessions[session.id]; + const newSession = mergedSessions[session.id]; + + // Check if sessionMessages exists AND agentStateVersion is newer + const existingSessionMessages = updatedSessionMessages[session.id]; + if (existingSessionMessages && newSession.agentState && + (!oldSession || newSession.agentStateVersion > (oldSession.agentStateVersion || 0))) { + + // Check for NEW permission requests before processing + const currentRealtimeSessionId = getCurrentRealtimeSessionId(); + const voiceSession = getVoiceSession(); + + if (currentRealtimeSessionId === session.id && voiceSession) { + const oldRequests = oldSession?.agentState?.requests || {}; + const newRequests = newSession.agentState?.requests || {}; + + // Find NEW permission requests only + for (const [requestId, request] of Object.entries(newRequests)) { + if (!oldRequests[requestId]) { + // This is a NEW permission request + const toolName = request.tool; + voiceSession.sendTextMessage( + `Claude is requesting permission to use the ${toolName} tool` + ); + } + } + } + + // Process new AgentState through reducer + const reducerResult = reducer(existingSessionMessages.reducerState, [], newSession.agentState); + const processedMessages = reducerResult.messages; + + // Always update the session messages, even if no new messages were created + // This ensures the reducer state is updated with the new AgentState + const mergedMessagesMap = { ...existingSessionMessages.messagesMap }; + processedMessages.forEach(message => { + mergedMessagesMap[message.id] = message; + }); + + const messagesArray = Object.values(mergedMessagesMap) + .sort((a, b) => b.createdAt - a.createdAt); + + updatedSessionMessages[session.id] = { + messages: messagesArray, + messagesMap: mergedMessagesMap, + reducerState: existingSessionMessages.reducerState, // The reducer modifies state in-place, so this has the updates + isLoaded: existingSessionMessages.isLoaded + }; + + // IMPORTANT: Copy latestUsage from reducerState to Session for immediate availability + if (existingSessionMessages.reducerState.latestUsage) { + mergedSessions[session.id] = { + ...mergedSessions[session.id], + latestUsage: { ...existingSessionMessages.reducerState.latestUsage } + }; + } + } + }); + + // Build new unified list view data + const sessionListViewData = buildSessionListViewData( + mergedSessions, + state.machines, + { groupInactiveSessionsByProject: state.settings.groupInactiveSessionsByProject } + ); + + // Update project manager with current sessions and machines + const machineMetadataMap = new Map<string, any>(); + Object.values(state.machines).forEach(machine => { + if (machine.metadata) { + machineMetadataMap.set(machine.id, machine.metadata); + } + }); + projectManager.updateSessions(Object.values(mergedSessions), machineMetadataMap); + + return { + ...state, + sessions: mergedSessions, + sessionsData: listData, // Legacy - to be removed + sessionListViewData, + sessionMessages: updatedSessionMessages + }; + }), + applyLoaded: () => set((state) => { + const result = { + ...state, + sessionsData: [] + }; + return result; + }), + applyReady: () => set((state) => ({ + ...state, + isDataReady: true + })), + applyGitStatus: (sessionId: string, status: GitStatus | null) => set((state) => { + // Update project git status as well + projectManager.updateSessionProjectGitStatus(sessionId, status); + + return { + ...state, + sessionGitStatus: { + ...state.sessionGitStatus, + [sessionId]: status + } + }; + }), + updateSessionDraft: (sessionId: string, draft: string | null) => set((state) => { + const session = state.sessions[sessionId]; + if (!session) return state; + + // Don't store empty strings, convert to null + const normalizedDraft = draft?.trim() ? draft : null; + + // Collect all drafts for persistence + const allDrafts: Record<string, string> = {}; + Object.entries(state.sessions).forEach(([id, sess]) => { + if (id === sessionId) { + if (normalizedDraft) { + allDrafts[id] = normalizedDraft; + } + } else if (sess.draft) { + allDrafts[id] = sess.draft; + } + }); + + // Persist drafts + saveSessionDrafts(allDrafts); + + const updatedSessions = { + ...state.sessions, + [sessionId]: { + ...session, + draft: normalizedDraft + } + }; + + // Rebuild sessionListViewData to update the UI immediately + const sessionListViewData = buildSessionListViewData( + updatedSessions, + state.machines, + { groupInactiveSessionsByProject: state.settings.groupInactiveSessionsByProject } + ); + + return { + ...state, + sessions: updatedSessions, + sessionListViewData + }; + }), + markSessionOptimisticThinking: (sessionId: string) => set((state) => { + const session = state.sessions[sessionId]; + if (!session) return state; + + const nextSessions = { + ...state.sessions, + [sessionId]: { + ...session, + optimisticThinkingAt: Date.now(), + }, + }; + const sessionListViewData = buildSessionListViewData( + nextSessions, + state.machines, + { groupInactiveSessionsByProject: state.settings.groupInactiveSessionsByProject } + ); + + const existingTimeout = optimisticThinkingTimeoutBySessionId.get(sessionId); + if (existingTimeout) { + clearTimeout(existingTimeout); + } + const timeout = setTimeout(() => { + optimisticThinkingTimeoutBySessionId.delete(sessionId); + set((s) => { + const current = s.sessions[sessionId]; + if (!current) return s; + if (!current.optimisticThinkingAt) return s; + + const next = { + ...s.sessions, + [sessionId]: { + ...current, + optimisticThinkingAt: null, + }, + }; + return { + ...s, + sessions: next, + sessionListViewData: buildSessionListViewData( + next, + s.machines, + { groupInactiveSessionsByProject: s.settings.groupInactiveSessionsByProject } + ), + }; + }); + }, OPTIMISTIC_SESSION_THINKING_TIMEOUT_MS); + optimisticThinkingTimeoutBySessionId.set(sessionId, timeout); + + return { + ...state, + sessions: nextSessions, + sessionListViewData, + }; + }), + clearSessionOptimisticThinking: (sessionId: string) => set((state) => { + const session = state.sessions[sessionId]; + if (!session) return state; + if (!session.optimisticThinkingAt) return state; + + const existingTimeout = optimisticThinkingTimeoutBySessionId.get(sessionId); + if (existingTimeout) { + clearTimeout(existingTimeout); + optimisticThinkingTimeoutBySessionId.delete(sessionId); + } + + const nextSessions = { + ...state.sessions, + [sessionId]: { + ...session, + optimisticThinkingAt: null, + }, + }; + + return { + ...state, + sessions: nextSessions, + sessionListViewData: buildSessionListViewData( + nextSessions, + state.machines, + { groupInactiveSessionsByProject: state.settings.groupInactiveSessionsByProject } + ), + }; + }), + markSessionViewed: (sessionId: string) => { + const now = Date.now(); + sessionLastViewed[sessionId] = now; + saveSessionLastViewed(sessionLastViewed); + set((state) => ({ + ...state, + sessionLastViewed: { ...sessionLastViewed } + })); + }, + updateSessionPermissionMode: (sessionId: string, mode: PermissionMode) => set((state) => { + const session = state.sessions[sessionId]; + if (!session) return state; + + const now = nowServerMs(); + + // Update the session with the new permission mode + const updatedSessions = { + ...state.sessions, + [sessionId]: { + ...session, + permissionMode: mode, + // Mark as locally updated so older message-based inference cannot override this selection. + // Newer user messages (from any device) will still take over. + permissionModeUpdatedAt: now + } + }; + + const persisted = persistSessionPermissionData(updatedSessions); + if (persisted) { + sessionPermissionModes = persisted.modes; + sessionPermissionModeUpdatedAts = persisted.updatedAts; + } + + // No need to rebuild sessionListViewData since permission mode doesn't affect the list display + return { + ...state, + sessions: updatedSessions + }; + }), + updateSessionModelMode: (sessionId: string, mode: SessionModelMode) => set((state) => { + const session = state.sessions[sessionId]; + if (!session) return state; + + // Update the session with the new model mode + const updatedSessions = { + ...state.sessions, + [sessionId]: { + ...session, + modelMode: mode + } + }; + + // Collect all model modes for persistence (only non-default values to save space) + const allModes: Record<string, SessionModelMode> = {}; + Object.entries(updatedSessions).forEach(([id, sess]) => { + if (sess.modelMode && sess.modelMode !== 'default') { + allModes[id] = sess.modelMode; + } + }); + + saveSessionModelModes(allModes); + + // No need to rebuild sessionListViewData since model mode doesn't affect the list display + return { + ...state, + sessions: updatedSessions + }; + }), + // Project management methods + getProjects: () => projectManager.getProjects(), + getProject: (projectId: string) => projectManager.getProject(projectId), + getProjectForSession: (sessionId: string) => projectManager.getProjectForSession(sessionId), + getProjectSessions: (projectId: string) => projectManager.getProjectSessions(projectId), + // Project git status methods + getProjectGitStatus: (projectId: string) => projectManager.getProjectGitStatus(projectId), + getSessionProjectGitStatus: (sessionId: string) => projectManager.getSessionProjectGitStatus(sessionId), + updateSessionProjectGitStatus: (sessionId: string, status: GitStatus | null) => { + projectManager.updateSessionProjectGitStatus(sessionId, status); + // Trigger a state update to notify hooks + set((state) => ({ ...state })); + }, + deleteSession: (sessionId: string) => set((state) => { + const optimisticTimeout = optimisticThinkingTimeoutBySessionId.get(sessionId); + if (optimisticTimeout) { + clearTimeout(optimisticTimeout); + optimisticThinkingTimeoutBySessionId.delete(sessionId); + } + + // Remove session from sessions + const { [sessionId]: deletedSession, ...remainingSessions } = state.sessions; + + // Remove session messages if they exist + const { [sessionId]: deletedMessages, ...remainingSessionMessages } = state.sessionMessages; + + // Remove session git status if it exists + const { [sessionId]: deletedGitStatus, ...remainingGitStatus } = state.sessionGitStatus; + + // Clear drafts and permission modes from persistent storage + const drafts = loadSessionDrafts(); + delete drafts[sessionId]; + saveSessionDrafts(drafts); + + const modes = loadSessionPermissionModes(); + delete modes[sessionId]; + saveSessionPermissionModes(modes); + sessionPermissionModes = modes; + + const updatedAts = loadSessionPermissionModeUpdatedAts(); + delete updatedAts[sessionId]; + saveSessionPermissionModeUpdatedAts(updatedAts); + sessionPermissionModeUpdatedAts = updatedAts; + + const modelModes = loadSessionModelModes(); + delete modelModes[sessionId]; + saveSessionModelModes(modelModes); + sessionModelModes = modelModes; + + delete sessionLastViewed[sessionId]; + saveSessionLastViewed(sessionLastViewed); + + // Rebuild sessionListViewData without the deleted session + const sessionListViewData = buildSessionListViewData( + remainingSessions, + state.machines, + { groupInactiveSessionsByProject: state.settings.groupInactiveSessionsByProject } + ); + + return { + ...state, + sessions: remainingSessions, + sessionMessages: remainingSessionMessages, + sessionGitStatus: remainingGitStatus, + sessionLastViewed: { ...sessionLastViewed }, + sessionListViewData + }; + }), + }; +} diff --git a/expo-app/sources/sync/store/domains/settings.ts b/expo-app/sources/sync/store/domains/settings.ts new file mode 100644 index 000000000..6cae085a7 --- /dev/null +++ b/expo-app/sources/sync/store/domains/settings.ts @@ -0,0 +1,132 @@ +import type { CustomerInfo } from '../../revenueCat/types'; +import type { Machine, Session } from '../../storageTypes'; +import type { SessionListViewItem } from '../../sessionListViewData'; +import { buildSessionListViewData } from '../../sessionListViewData'; +import { applyLocalSettings, type LocalSettings } from '../../localSettings'; +import { customerInfoToPurchases, type Purchases } from '../../purchases'; +import { applySettings, type Settings } from '../../settings'; +import { loadLocalSettings, loadPurchases, loadSettings, saveLocalSettings, savePurchases, saveSettings } from '../../persistence'; + +import type { StoreGet, StoreSet } from './_shared'; + +export type SettingsDomain = { + settings: Settings; + settingsVersion: number | null; + localSettings: LocalSettings; + purchases: Purchases; + applySettingsLocal: (delta: Partial<Settings>) => void; + applySettings: (settings: Settings, version: number) => void; + replaceSettings: (settings: Settings, version: number) => void; + applyLocalSettings: (delta: Partial<LocalSettings>) => void; + applyPurchases: (customerInfo: CustomerInfo) => void; +}; + +type SettingsDomainDependencies = Readonly<{ + sessions: Record<string, Session>; + machines: Record<string, Machine>; + sessionListViewData: SessionListViewItem[] | null; +}>; + +export function createSettingsDomain<S extends SettingsDomain & SettingsDomainDependencies>({ + set, +}: { + set: StoreSet<S>; + get: StoreGet<S>; +}): SettingsDomain { + const { settings, version } = loadSettings(); + const localSettings = loadLocalSettings(); + const purchases = loadPurchases(); + + return { + settings, + settingsVersion: version, + localSettings, + purchases, + applySettingsLocal: (delta) => + set((state) => { + const newSettings = applySettings(state.settings, delta); + saveSettings(newSettings, state.settingsVersion ?? 0); + + const shouldRebuildSessionListViewData = + Object.prototype.hasOwnProperty.call(delta, 'groupInactiveSessionsByProject') && + delta.groupInactiveSessionsByProject !== state.settings.groupInactiveSessionsByProject; + + if (shouldRebuildSessionListViewData) { + const sessionListViewData = buildSessionListViewData(state.sessions, state.machines, { + groupInactiveSessionsByProject: newSettings.groupInactiveSessionsByProject, + }); + return { + ...state, + settings: newSettings, + sessionListViewData, + }; + } + return { + ...state, + settings: newSettings, + }; + }), + applySettings: (nextSettings, nextVersion) => + set((state) => { + if (state.settingsVersion == null || state.settingsVersion < nextVersion) { + saveSettings(nextSettings, nextVersion); + + const shouldRebuildSessionListViewData = + nextSettings.groupInactiveSessionsByProject !== state.settings.groupInactiveSessionsByProject; + + const sessionListViewData = shouldRebuildSessionListViewData + ? buildSessionListViewData(state.sessions, state.machines, { + groupInactiveSessionsByProject: nextSettings.groupInactiveSessionsByProject, + }) + : state.sessionListViewData; + + return { + ...state, + settings: nextSettings, + settingsVersion: nextVersion, + sessionListViewData, + }; + } + return state; + }), + replaceSettings: (nextSettings, nextVersion) => + set((state) => { + saveSettings(nextSettings, nextVersion); + + const shouldRebuildSessionListViewData = + nextSettings.groupInactiveSessionsByProject !== state.settings.groupInactiveSessionsByProject; + + const sessionListViewData = shouldRebuildSessionListViewData + ? buildSessionListViewData(state.sessions, state.machines, { + groupInactiveSessionsByProject: nextSettings.groupInactiveSessionsByProject, + }) + : state.sessionListViewData; + + return { + ...state, + settings: nextSettings, + settingsVersion: nextVersion, + sessionListViewData, + }; + }), + applyLocalSettings: (delta) => + set((state) => { + const updatedLocalSettings = applyLocalSettings(state.localSettings, delta); + saveLocalSettings(updatedLocalSettings); + return { + ...state, + localSettings: updatedLocalSettings, + }; + }), + applyPurchases: (customerInfo) => + set((state) => { + const nextPurchases = customerInfoToPurchases(customerInfo); + savePurchases(nextPurchases); + return { + ...state, + purchases: nextPurchases, + }; + }), + }; +} + diff --git a/expo-app/sources/sync/store/domains/todos.ts b/expo-app/sources/sync/store/domains/todos.ts new file mode 100644 index 000000000..2e6b97c6f --- /dev/null +++ b/expo-app/sources/sync/store/domains/todos.ts @@ -0,0 +1,28 @@ +import type { TodoState } from '../../../-zen/model/ops'; + +import type { StoreGet, StoreSet } from './_shared'; + +export type TodosDomain = { + todoState: TodoState | null; + todosLoaded: boolean; + applyTodos: (todoState: TodoState) => void; +}; + +export function createTodosDomain<S extends TodosDomain>({ + set, +}: { + set: StoreSet<S>; + get: StoreGet<S>; +}): TodosDomain { + return { + todoState: null, + todosLoaded: false, + applyTodos: (todoState) => + set((state) => ({ + ...state, + todoState, + todosLoaded: true, + })), + }; +} + diff --git a/expo-app/sources/sync/store/hooks.ts b/expo-app/sources/sync/store/hooks.ts new file mode 100644 index 000000000..2a0797c6a --- /dev/null +++ b/expo-app/sources/sync/store/hooks.ts @@ -0,0 +1,331 @@ +import React from 'react'; +import { useShallow } from 'zustand/react/shallow'; + +import type { + DiscardedPendingMessage, + GitStatus, + Machine, + PendingMessage, + Session, +} from '../storageTypes'; +import type { DecryptedArtifact } from '../artifactTypes'; +import type { LocalSettings } from '../localSettings'; +import type { Message } from '../typesMessage'; +import type { Settings } from '../settings'; +import type { SessionListViewItem } from '../sessionListViewData'; +import { computeHasUnreadActivity, computePendingActivityAt } from '../unread'; +import { sync } from '../sync'; + +import { getStorage } from '../storage'; +import type { KnownEntitlements } from '../storage'; + +export function useSessions() { + return getStorage()(useShallow((state) => (state.isDataReady ? state.sessionsData : null))); +} + +export function useSession(id: string): Session | null { + return getStorage()(useShallow((state) => state.sessions[id] ?? null)); +} + +const emptyArray: unknown[] = []; + +export function useSessionMessages( + sessionId: string +): { messages: Message[]; isLoaded: boolean } { + return getStorage()( + useShallow((state) => { + const session = state.sessionMessages[sessionId]; + return { + messages: session?.messages ?? emptyArray, + isLoaded: session?.isLoaded ?? false, + }; + }) + ); +} + +export function useHasUnreadMessages(sessionId: string): boolean { + return getStorage()((state) => { + const session = state.sessions[sessionId]; + if (!session) return false; + const pendingActivityAt = computePendingActivityAt(session.metadata); + const readState = session.metadata?.readStateV1; + return computeHasUnreadActivity({ + sessionSeq: session.seq ?? 0, + pendingActivityAt, + lastViewedSessionSeq: readState?.sessionSeq, + lastViewedPendingActivityAt: readState?.pendingActivityAt, + }); + }); +} + +export function useSessionPendingMessages( + sessionId: string +): { messages: PendingMessage[]; discarded: DiscardedPendingMessage[]; isLoaded: boolean } { + return getStorage()( + useShallow((state) => { + const pending = state.sessionPending[sessionId]; + return { + messages: pending?.messages ?? emptyArray, + discarded: pending?.discarded ?? emptyArray, + isLoaded: pending?.isLoaded ?? false, + }; + }) + ); +} + +export function useMessage(sessionId: string, messageId: string): Message | null { + return getStorage()( + useShallow((state) => { + const session = state.sessionMessages[sessionId]; + return session?.messagesMap[messageId] ?? null; + }) + ); +} + +export function useSessionUsage(sessionId: string) { + return getStorage()( + useShallow((state) => { + const session = state.sessionMessages[sessionId]; + return session?.reducerState?.latestUsage ?? null; + }) + ); +} + +export function useSettings(): Settings { + return getStorage()(useShallow((state) => state.settings)); +} + +export function useSettingMutable<K extends keyof Settings>( + name: K +): [Settings[K], (value: Settings[K]) => void] { + const setValue = React.useCallback( + (value: Settings[K]) => { + sync.applySettings({ [name]: value }); + }, + [name] + ); + const value = useSetting(name); + return [value, setValue]; +} + +export function useSetting<K extends keyof Settings>(name: K): Settings[K] { + return getStorage()(useShallow((state) => state.settings[name])); +} + +export function useLocalSettings(): LocalSettings { + return getStorage()(useShallow((state) => state.localSettings)); +} + +export function useAllMachines(): Machine[] { + return getStorage()( + useShallow((state) => { + if (!state.isDataReady) return []; + return Object.values(state.machines) + .sort((a, b) => b.createdAt - a.createdAt) + .filter((v) => v.active); + }) + ); +} + +export function useMachine(machineId: string): Machine | null { + return getStorage()(useShallow((state) => state.machines[machineId] ?? null)); +} + +export function useSessionListViewData(): SessionListViewItem[] | null { + return getStorage()((state) => (state.isDataReady ? state.sessionListViewData : null)); +} + +export function useAllSessions(): Session[] { + return getStorage()( + useShallow((state) => { + if (!state.isDataReady) return []; + return Object.values(state.sessions).sort((a, b) => b.updatedAt - a.updatedAt); + }) + ); +} + +export function useLocalSettingMutable<K extends keyof LocalSettings>( + name: K +): [LocalSettings[K], (value: LocalSettings[K]) => void] { + const setValue = React.useCallback( + (value: LocalSettings[K]) => { + getStorage().getState().applyLocalSettings({ [name]: value }); + }, + [name] + ); + const value = useLocalSetting(name); + return [value, setValue]; +} + +// Project management hooks +export function useProjects() { + return getStorage()(useShallow((state) => state.getProjects())); +} + +export function useProject(projectId: string | null) { + return getStorage()(useShallow((state) => (projectId ? state.getProject(projectId) : null))); +} + +export function useProjectForSession(sessionId: string | null) { + return getStorage()( + useShallow((state) => (sessionId ? state.getProjectForSession(sessionId) : null)) + ); +} + +export function useProjectSessions(projectId: string | null) { + return getStorage()(useShallow((state) => (projectId ? state.getProjectSessions(projectId) : []))); +} + +export function useProjectGitStatus(projectId: string | null) { + return getStorage()(useShallow((state) => (projectId ? state.getProjectGitStatus(projectId) : null))); +} + +export function useSessionProjectGitStatus(sessionId: string | null) { + return getStorage()( + useShallow((state) => (sessionId ? state.getSessionProjectGitStatus(sessionId) : null)) + ); +} + +export function useLocalSetting<K extends keyof LocalSettings>(name: K): LocalSettings[K] { + return getStorage()(useShallow((state) => state.localSettings[name])); +} + +// Artifact hooks +export function useArtifacts(): DecryptedArtifact[] { + return getStorage()( + useShallow((state) => { + if (!state.isDataReady) return []; + // Filter out draft artifacts from the main list + return Object.values(state.artifacts) + .filter((artifact) => !artifact.draft) + .sort((a, b) => b.updatedAt - a.updatedAt); + }) + ); +} + +export function useAllArtifacts(): DecryptedArtifact[] { + return getStorage()( + useShallow((state) => { + if (!state.isDataReady) return []; + // Return all artifacts including drafts + return Object.values(state.artifacts).sort((a, b) => b.updatedAt - a.updatedAt); + }) + ); +} + +export function useDraftArtifacts(): DecryptedArtifact[] { + return getStorage()( + useShallow((state) => { + if (!state.isDataReady) return []; + // Return only draft artifacts + return Object.values(state.artifacts) + .filter((artifact) => artifact.draft === true) + .sort((a, b) => b.updatedAt - a.updatedAt); + }) + ); +} + +export function useArtifact(artifactId: string): DecryptedArtifact | null { + return getStorage()(useShallow((state) => state.artifacts[artifactId] ?? null)); +} + +export function useArtifactsCount(): number { + return getStorage()( + useShallow((state) => { + // Count only non-draft artifacts + return Object.values(state.artifacts).filter((a) => !a.draft).length; + }) + ); +} + +export function useEntitlement(id: KnownEntitlements): boolean { + return getStorage()(useShallow((state) => state.purchases.entitlements[id] ?? false)); +} + +export function useRealtimeStatus(): 'disconnected' | 'connecting' | 'connected' | 'error' { + return getStorage()(useShallow((state) => state.realtimeStatus)); +} + +export function useRealtimeMode(): 'idle' | 'speaking' { + return getStorage()(useShallow((state) => state.realtimeMode)); +} + +export function useSocketStatus() { + return getStorage()( + useShallow((state) => ({ + status: state.socketStatus, + lastConnectedAt: state.socketLastConnectedAt, + lastDisconnectedAt: state.socketLastDisconnectedAt, + lastError: state.socketLastError, + lastErrorAt: state.socketLastErrorAt, + })) + ); +} + +export function useSyncError() { + return getStorage()(useShallow((state) => state.syncError)); +} + +export function useLastSyncAt() { + return getStorage()(useShallow((state) => state.lastSyncAt)); +} + +export function useSessionGitStatus(sessionId: string): GitStatus | null { + return getStorage()(useShallow((state) => state.sessionGitStatus[sessionId] ?? null)); +} + +export function useIsDataReady(): boolean { + return getStorage()(useShallow((state) => state.isDataReady)); +} + +export function useProfile() { + return getStorage()(useShallow((state) => state.profile)); +} + +export function useFriends() { + return getStorage()(useShallow((state) => state.friends)); +} + +export function useFriendRequests() { + return getStorage()( + useShallow((state) => { + // Filter friends to get pending requests (where status is 'pending') + return Object.values(state.friends).filter((friend) => friend.status === 'pending'); + }) + ); +} + +export function useAcceptedFriends() { + return getStorage()( + useShallow((state) => { + return Object.values(state.friends).filter((friend) => friend.status === 'friend'); + }) + ); +} + +export function useFeedItems() { + return getStorage()(useShallow((state) => state.feedItems)); +} +export function useFeedLoaded() { + return getStorage()((state) => state.feedLoaded); +} +export function useFriendsLoaded() { + return getStorage()((state) => state.friendsLoaded); +} + +export function useFriend(userId: string | undefined) { + return getStorage()(useShallow((state) => (userId ? state.friends[userId] : undefined))); +} + +export function useUser(userId: string | undefined) { + return getStorage()(useShallow((state) => (userId ? state.users[userId] : undefined))); +} + +export function useRequestedFriends() { + return getStorage()( + useShallow((state) => { + // Filter friends to get sent requests (where status is 'requested') + return Object.values(state.friends).filter((friend) => friend.status === 'requested'); + }) + ); +} diff --git a/expo-app/sources/sync/submitMode.test.ts b/expo-app/sources/sync/submitMode.test.ts new file mode 100644 index 000000000..8d912b06a --- /dev/null +++ b/expo-app/sources/sync/submitMode.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; + +import { chooseSubmitMode } from './submitMode'; + +describe('chooseSubmitMode', () => { + it('preserves interrupt mode', () => { + expect(chooseSubmitMode({ + configuredMode: 'interrupt', + session: { metadata: {} } as any, + })).toBe('interrupt'); + }); + + it('preserves explicit server_pending mode', () => { + expect(chooseSubmitMode({ + configuredMode: 'server_pending', + session: { metadata: {} } as any, + })).toBe('server_pending'); + }); + + it('prefers server_pending while controlledByUser when queue is supported', () => { + expect(chooseSubmitMode({ + configuredMode: 'agent_queue', + session: { + agentState: { controlledByUser: true }, + metadata: { messageQueueV1: { v: 1, queue: [] } }, + } as any, + })).toBe('server_pending'); + }); + + it('prefers server_pending while thinking when queue is supported', () => { + expect(chooseSubmitMode({ + configuredMode: 'agent_queue', + session: { + thinking: true, + metadata: { messageQueueV1: { v: 1, queue: [] } }, + } as any, + })).toBe('server_pending'); + }); + + it('prefers server_pending when the session is offline but queue is supported', () => { + expect(chooseSubmitMode({ + configuredMode: 'agent_queue', + session: { + presence: 0, + agentStateVersion: 0, + metadata: { messageQueueV1: { v: 1, queue: [] } }, + } as any, + })).toBe('server_pending'); + }); + + it('prefers server_pending when the agent is not ready but queue is supported', () => { + expect(chooseSubmitMode({ + configuredMode: 'agent_queue', + session: { + presence: 'online', + agentStateVersion: 0, + metadata: { messageQueueV1: { v: 1, queue: [] } }, + } as any, + })).toBe('server_pending'); + }); + + it('keeps agent_queue if queue is not supported', () => { + expect(chooseSubmitMode({ + configuredMode: 'agent_queue', + session: { + thinking: true, + metadata: {}, + } as any, + })).toBe('agent_queue'); + }); +}); diff --git a/expo-app/sources/sync/submitMode.ts b/expo-app/sources/sync/submitMode.ts new file mode 100644 index 000000000..df917ee9b --- /dev/null +++ b/expo-app/sources/sync/submitMode.ts @@ -0,0 +1,31 @@ +import type { Session } from './storageTypes'; + +export type MessageSendMode = 'agent_queue' | 'interrupt' | 'server_pending'; + +export function chooseSubmitMode(opts: { + configuredMode: MessageSendMode; + session: Session | null; +}): MessageSendMode { + const mode = opts.configuredMode; + if (mode !== 'agent_queue') return mode; + + const session = opts.session; + const supportsQueue = Boolean(session?.metadata?.messageQueueV1); + if (!supportsQueue) return mode; + + const controlledByUser = Boolean(session?.agentState?.controlledByUser); + const isBusy = Boolean(session?.thinking); + const isOnline = session?.presence === 'online'; + const agentReady = Boolean(session && session.agentStateVersion > 0); + + // Prefer the metadata-backed queue when: + // - terminal has control (can't safely inject into local stdin), + // - the agent is busy (user may want to edit/remove before processing), + // - the agent is not ready yet (direct sends can be missed because the agent does not replay backlog), or + // - the machine is offline (queue gives reliable eventual processing once it reconnects). + if (controlledByUser || isBusy || !isOnline || !agentReady) { + return 'server_pending'; + } + + return mode; +} diff --git a/expo-app/sources/sync/suggestionCommands.ts b/expo-app/sources/sync/suggestionCommands.ts index b2ac1c715..db9670c40 100644 --- a/expo-app/sources/sync/suggestionCommands.ts +++ b/expo-app/sources/sync/suggestionCommands.ts @@ -87,19 +87,33 @@ function getCommandsFromSession(sessionId: string): CommandItem[] { const commands: CommandItem[] = [...DEFAULT_COMMANDS]; - // Add commands from metadata.slashCommands (filter with ignore list) + // Prefer richer metadata when available + const details = (session.metadata as any).slashCommandDetails as Array<{ command?: unknown; description?: unknown }> | undefined; + if (Array.isArray(details) && details.length > 0) { + for (const d of details) { + const cmd = typeof d.command === 'string' ? d.command : null; + if (!cmd) continue; + if (IGNORED_COMMANDS.includes(cmd)) continue; + if (commands.find(c => c.command === cmd)) continue; + commands.push({ + command: cmd, + description: typeof d.description === 'string' && d.description.trim().length > 0 + ? d.description + : COMMAND_DESCRIPTIONS[cmd] + }); + } + return commands; + } + + // Fallback: commands from metadata.slashCommands (filter with ignore list) if (session.metadata.slashCommands) { for (const cmd of session.metadata.slashCommands) { - // Skip if in ignore list if (IGNORED_COMMANDS.includes(cmd)) continue; - - // Check if it's already in default commands - if (!commands.find(c => c.command === cmd)) { - commands.push({ - command: cmd, - description: COMMAND_DESCRIPTIONS[cmd] // Optional description - }); - } + if (commands.find(c => c.command === cmd)) continue; + commands.push({ + command: cmd, + description: COMMAND_DESCRIPTIONS[cmd] + }); } } @@ -145,4 +159,4 @@ export async function searchCommands( // Get all available commands for a session export function getAllCommands(sessionId: string): CommandItem[] { return getCommandsFromSession(sessionId); -} \ No newline at end of file +} diff --git a/expo-app/sources/sync/sync.ts b/expo-app/sources/sync/sync.ts index 5393a3651..d76a91926 100644 --- a/expo-app/sources/sync/sync.ts +++ b/expo-app/sources/sync/sync.ts @@ -4,23 +4,21 @@ import { AuthCredentials } from '@/auth/tokenStorage'; import { Encryption } from '@/sync/encryption/encryption'; import { decodeBase64, encodeBase64 } from '@/encryption/base64'; import { storage } from './storage'; -import { ApiEphemeralUpdateSchema, ApiMessage, ApiUpdateContainerSchema } from './apiTypes'; +import { ApiMessage } from './apiTypes'; import type { ApiEphemeralActivityUpdate } from './apiTypes'; -import { Session, Machine } from './storageTypes'; +import { Session, Machine, type Metadata } from './storageTypes'; import { InvalidateSync } from '@/utils/sync'; import { ActivityUpdateAccumulator } from './reducer/activityUpdateAccumulator'; -import { randomUUID } from 'expo-crypto'; -import * as Notifications from 'expo-notifications'; -import { registerPushToken } from './apiPush'; +import { randomUUID } from '@/platform/randomUUID'; import { Platform, AppState } from 'react-native'; import { isRunningOnMac } from '@/utils/platform'; import { NormalizedMessage, normalizeRawMessage, RawRecord } from './typesRaw'; import { applySettings, Settings, settingsDefaults, settingsParse, SUPPORTED_SCHEMA_VERSION } from './settings'; -import { Profile, profileParse } from './profile'; +import { Profile } from './profile'; import { loadPendingSettings, savePendingSettings } from './persistence'; import { initializeTracking, tracking } from '@/track'; import { parseToken } from '@/utils/parseToken'; -import { RevenueCat, LogLevel, PaywallResult } from './revenueCat'; +import { RevenueCat } from './revenueCat'; import { trackPaywallPresented, trackPaywallPurchased, trackPaywallCancelled, trackPaywallRestored, trackPaywallError } from '@/track'; import { getServerUrl } from './serverConfig'; import { config } from '@/config'; @@ -31,14 +29,59 @@ import { voiceHooks } from '@/realtime/hooks/voiceHooks'; import { Message } from './typesMessage'; import { EncryptionCache } from './encryption/encryptionCache'; import { systemPrompt } from './prompt/systemPrompt'; -import { fetchArtifact, fetchArtifacts, createArtifact, updateArtifact } from './apiArtifacts'; -import { DecryptedArtifact, Artifact, ArtifactCreateRequest, ArtifactUpdateRequest } from './artifactTypes'; -import { ArtifactEncryption } from './encryption/artifactEncryption'; +import { nowServerMs } from './time'; +import { getAgentCore, resolveAgentIdFromFlavor } from '@/agents/catalog'; +import { computePendingActivityAt } from './unread'; +import { computeNextReadStateV1 } from './readStateV1'; +import { updateSessionMetadataWithRetry as updateSessionMetadataWithRetryRpc, type UpdateMetadataAck } from './updateSessionMetadataWithRetry'; +import type { DecryptedArtifact } from './artifactTypes'; import { getFriendsList, getUserProfile } from './apiFriends'; -import { fetchFeed } from './apiFeed'; import { FeedItem } from './feedTypes'; import { UserProfile } from './friendTypes'; -import { initializeTodoSync } from '../-zen/model/ops'; +import { buildOutgoingMessageMeta } from './messageMeta'; +import { HappyError } from '@/utils/errors'; +import { dbgSettings, isSettingsSyncDebugEnabled, summarizeSettings, summarizeSettingsDelta } from './debugSettings'; +import { deriveSettingsSecretsKey, decryptSecretValue, encryptSecretString, sealSecretsDeep } from './secretSettings'; +import { didControlReturnToMobile } from './controlledByUserTransitions'; +import { chooseSubmitMode } from './submitMode'; +import type { SavedSecret } from './settings'; +import { scheduleDebouncedPendingSettingsFlush } from './engine/pendingSettings'; +import { applySettingsLocalDelta, syncSettings as syncSettingsEngine } from './engine/settings'; +import { getOfferings as getOfferingsEngine, presentPaywall as presentPaywallEngine, purchaseProduct as purchaseProductEngine, syncPurchases as syncPurchasesEngine } from './engine/purchases'; +import { + createArtifactViaApi, + fetchAndApplyArtifactsList, + fetchArtifactWithBodyFromApi, + handleDeleteArtifactSocketUpdate, + handleNewArtifactSocketUpdate, + handleUpdateArtifactSocketUpdate, + updateArtifactViaApi, +} from './engine/artifacts'; +import { fetchAndApplyFeed, handleNewFeedPostUpdate, handleRelationshipUpdatedSocketUpdate, handleTodoKvBatchUpdate } from './engine/feed'; +import { fetchAndApplyProfile, handleUpdateAccountSocketUpdate, registerPushTokenIfAvailable } from './engine/account'; +import { buildMachineFromMachineActivityEphemeralUpdate, buildUpdatedMachineFromSocketUpdate, fetchAndApplyMachines } from './engine/machines'; +import { applyTodoSocketUpdates as applyTodoSocketUpdatesEngine, fetchTodos as fetchTodosEngine } from './engine/todos'; +import { + buildUpdatedSessionFromSocketUpdate, + fetchAndApplySessions, + fetchAndApplyMessages, + fetchAndApplyPendingMessages as fetchAndApplyPendingMessagesEngine, + handleDeleteSessionSocketUpdate, + handleNewMessageSocketUpdate, + enqueuePendingMessage as enqueuePendingMessageEngine, + updatePendingMessage as updatePendingMessageEngine, + deletePendingMessage as deletePendingMessageEngine, + discardPendingMessage as discardPendingMessageEngine, + restoreDiscardedPendingMessage as restoreDiscardedPendingMessageEngine, + deleteDiscardedPendingMessage as deleteDiscardedPendingMessageEngine, + repairInvalidReadStateV1 as repairInvalidReadStateV1Engine, +} from './engine/sessions'; +import { + flushActivityUpdates as flushActivityUpdatesEngine, + handleEphemeralSocketUpdate, + handleSocketReconnected, + handleSocketUpdate, +} from './engine/socket'; class Sync { // Spawned agents (especially in spawn mode) can take noticeable time to connect. @@ -55,6 +98,8 @@ class Sync { private sessionDataKeys = new Map<string, Uint8Array>(); // Store session data encryption keys internally private machineDataKeys = new Map<string, Uint8Array>(); // Store machine data encryption keys internally private artifactDataKeys = new Map<string, Uint8Array>(); // Store artifact data encryption keys internally + private readStateV1RepairAttempted = new Set<string>(); + private readStateV1RepairInFlight = new Set<string>(); private settingsSync: InvalidateSync; private profileSync: InvalidateSync; private purchasesSync: InvalidateSync; @@ -68,18 +113,44 @@ class Sync { private todosSync: InvalidateSync; private activityAccumulator: ActivityUpdateAccumulator; private pendingSettings: Partial<Settings> = loadPendingSettings(); + private pendingSettingsFlushTimer: ReturnType<typeof setTimeout> | null = null; + private pendingSettingsDirty = false; revenueCatInitialized = false; + private settingsSecretsKey: Uint8Array | null = null; // Generic locking mechanism private recalculationLockCount = 0; private lastRecalculationTime = 0; + private machinesRefreshInFlight: Promise<void> | null = null; + private lastMachinesRefreshAt = 0; constructor() { - this.sessionsSync = new InvalidateSync(this.fetchSessions); - this.settingsSync = new InvalidateSync(this.syncSettings); - this.profileSync = new InvalidateSync(this.fetchProfile); - this.purchasesSync = new InvalidateSync(this.syncPurchases); - this.machinesSync = new InvalidateSync(this.fetchMachines); + dbgSettings('Sync.constructor: loaded pendingSettings', { + pendingKeys: Object.keys(this.pendingSettings).sort(), + }); + const onSuccess = () => { + storage.getState().clearSyncError(); + storage.getState().setLastSyncAt(Date.now()); + }; + const onError = (e: any) => { + const message = e instanceof Error ? e.message : String(e); + const retryable = !(e instanceof HappyError && e.canTryAgain === false); + const kind: 'auth' | 'config' | 'network' | 'server' | 'unknown' = + e instanceof HappyError && e.kind ? e.kind : 'unknown'; + storage.getState().setSyncError({ message, retryable, kind, at: Date.now() }); + }; + + const onRetry = (info: { failuresCount: number; nextDelayMs: number; nextRetryAt: number }) => { + const ex = storage.getState().syncError; + if (!ex) return; + storage.getState().setSyncError({ ...ex, failuresCount: info.failuresCount, nextRetryAt: info.nextRetryAt }); + }; + + this.sessionsSync = new InvalidateSync(this.fetchSessions, { onError, onSuccess, onRetry }); + this.settingsSync = new InvalidateSync(this.syncSettings, { onError, onSuccess, onRetry }); + this.profileSync = new InvalidateSync(this.fetchProfile, { onError, onSuccess, onRetry }); + this.purchasesSync = new InvalidateSync(this.syncPurchases, { onError, onSuccess, onRetry }); + this.machinesSync = new InvalidateSync(this.fetchMachines, { onError, onSuccess, onRetry }); this.nativeUpdateSync = new InvalidateSync(this.fetchNativeUpdate); this.artifactsSync = new InvalidateSync(this.fetchArtifactsList); this.friendsSync = new InvalidateSync(this.fetchFriends); @@ -114,15 +185,62 @@ class Sync { this.todosSync.invalidate(); } else { log.log(`📱 App state changed to: ${nextAppState}`); + // Reliability: ensure we persist any pending settings immediately when backgrounding. + // This avoids losing last-second settings changes if the OS suspends the app. + try { + if (this.pendingSettingsFlushTimer) { + clearTimeout(this.pendingSettingsFlushTimer); + this.pendingSettingsFlushTimer = null; + } + savePendingSettings(this.pendingSettings); + } catch { + // ignore + } } }); } + private schedulePendingSettingsFlush = () => { + scheduleDebouncedPendingSettingsFlush({ + getTimer: () => this.pendingSettingsFlushTimer, + setTimer: (timer) => { + this.pendingSettingsFlushTimer = timer; + }, + markDirty: () => { + this.pendingSettingsDirty = true; + }, + consumeDirty: () => { + if (!this.pendingSettingsDirty) { + return false; + } + this.pendingSettingsDirty = false; + return true; + }, + flush: () => { + // Persist pending settings for crash/restart safety. + savePendingSettings(this.pendingSettings); + // Trigger server sync (can be retried later). + this.settingsSync.invalidate(); + }, + delayMs: 900, + }); + }; + async create(credentials: AuthCredentials, encryption: Encryption) { this.credentials = credentials; this.encryption = encryption; this.anonID = encryption.anonID; this.serverID = parseToken(credentials.token); + // Derive a stable per-account key for field-level secret settings. + // This is separate from the outer settings blob encryption. + try { + const secretKey = decodeBase64(credentials.secret, 'base64url'); + if (secretKey.length === 32) { + this.settingsSecretsKey = await deriveSettingsSecretsKey(secretKey); + } + } catch { + this.settingsSecretsKey = null; + } await this.#init(); // Await settings sync to have fresh settings @@ -142,9 +260,36 @@ class Sync { this.encryption = encryption; this.anonID = encryption.anonID; this.serverID = parseToken(credentials.token); + try { + const secretKey = decodeBase64(credentials.secret, 'base64url'); + if (secretKey.length === 32) { + this.settingsSecretsKey = await deriveSettingsSecretsKey(secretKey); + } + } catch { + this.settingsSecretsKey = null; + } await this.#init(); } + /** + * Encrypt a secret value into an encrypted-at-rest container. + * Used for transient persistence (e.g. local drafts) where plaintext must never be stored. + */ + public encryptSecretValue(value: string): import('./secretSettings').SecretString | null { + const v = typeof value === 'string' ? value.trim() : ''; + if (!v) return null; + if (!this.settingsSecretsKey) return null; + return { _isSecretValue: true, encryptedValue: encryptSecretString(v, this.settingsSecretsKey) }; + } + + /** + * Generic secret-string decryption helper for settings-like objects. + * Prefer this over adding per-field helpers unless a field needs special handling. + */ + public decryptSecretValue(input: import('./secretSettings').SecretString | null | undefined): string | null { + return decryptSecretValue(input, this.settingsSecretsKey); + } + async #init() { // Subscribe to updates @@ -208,10 +353,12 @@ class Sync { async sendMessage(sessionId: string, text: string, displayText?: string) { + storage.getState().markSessionOptimisticThinking(sessionId); // Get encryption const encryption = this.encryption.getSessionEncryption(sessionId); if (!encryption) { // Should never happen + storage.getState().clearSessionOptimisticThinking(sessionId); console.error(`Session ${sessionId} not found`); return; } @@ -219,214 +366,288 @@ class Sync { // Get session data from storage const session = storage.getState().sessions[sessionId]; if (!session) { + storage.getState().clearSessionOptimisticThinking(sessionId); console.error(`Session ${sessionId} not found in storage`); return; } - // Read permission mode from session state - const permissionMode = session.permissionMode || 'default'; - - // Read model mode - for Gemini, default to gemini-2.5-pro if not set - const flavor = session.metadata?.flavor; - const isGemini = flavor === 'gemini'; - const modelMode = session.modelMode || (isGemini ? 'gemini-2.5-pro' : 'default'); - - // Generate local ID - const localId = randomUUID(); - - // Determine sentFrom based on platform - let sentFrom: string; - if (Platform.OS === 'web') { - sentFrom = 'web'; - } else if (Platform.OS === 'android') { - sentFrom = 'android'; - } else if (Platform.OS === 'ios') { - // Check if running on Mac (Catalyst or Designed for iPad on Mac) - if (isRunningOnMac()) { - sentFrom = 'mac'; + try { + // Read permission mode from session state + const permissionMode = session.permissionMode || 'default'; + + // Read model mode - default is agent-specific (Gemini needs an explicit default) + const flavor = session.metadata?.flavor; + const agentId = resolveAgentIdFromFlavor(flavor); + const modelMode = session.modelMode || (agentId ? getAgentCore(agentId).model.defaultMode : 'default'); + + // Generate local ID + const localId = randomUUID(); + + // Determine sentFrom based on platform + let sentFrom: string; + if (Platform.OS === 'web') { + sentFrom = 'web'; + } else if (Platform.OS === 'android') { + sentFrom = 'android'; + } else if (Platform.OS === 'ios') { + // Check if running on Mac (Catalyst or Designed for iPad on Mac) + if (isRunningOnMac()) { + sentFrom = 'mac'; + } else { + sentFrom = 'ios'; + } } else { - sentFrom = 'ios'; + sentFrom = 'web'; // fallback } - } else { - sentFrom = 'web'; // fallback - } - // Model settings - for Gemini, we pass the selected model; for others, CLI handles it - let model: string | null = null; - if (isGemini && modelMode !== 'default') { - // For Gemini ACP, pass the selected model to CLI - model = modelMode; - } - const fallbackModel: string | null = null; - - // Create user message content with metadata - const content: RawRecord = { - role: 'user', - content: { - type: 'text', - text - }, - meta: { - sentFrom, - permissionMode: permissionMode || 'default', - model, - fallbackModel, - appendSystemPrompt: systemPrompt, - ...(displayText && { displayText }) // Add displayText if provided + const model = agentId && getAgentCore(agentId).model.supportsSelection && modelMode !== 'default' ? modelMode : undefined; + // Create user message content with metadata + const content: RawRecord = { + role: 'user', + content: { + type: 'text', + text + }, + meta: buildOutgoingMessageMeta({ + sentFrom, + permissionMode: permissionMode || 'default', + model, + appendSystemPrompt: systemPrompt, + displayText, + }) + }; + const encryptedRawRecord = await encryption.encryptRawRecord(content); + + // Add to messages - normalize the raw record + const createdAt = nowServerMs(); + const normalizedMessage = normalizeRawMessage(localId, localId, createdAt, content); + if (normalizedMessage) { + this.applyMessages(sessionId, [normalizedMessage]); } - }; - const encryptedRawRecord = await encryption.encryptRawRecord(content); - // Add to messages - normalize the raw record - const createdAt = Date.now(); - const normalizedMessage = normalizeRawMessage(localId, localId, createdAt, content); - if (normalizedMessage) { - this.applyMessages(sessionId, [normalizedMessage]); - } + const ready = await this.waitForAgentReady(sessionId); + if (!ready) { + log.log(`Session ${sessionId} not ready after timeout, sending anyway`); + } - const ready = await this.waitForAgentReady(sessionId); - if (!ready) { - log.log(`Session ${sessionId} not ready after timeout, sending anyway`); + // Send message with optional permission mode and source identifier + apiSocket.send('message', { + sid: sessionId, + message: encryptedRawRecord, + localId, + sentFrom, + permissionMode: permissionMode || 'default' + }); + } catch (e) { + storage.getState().clearSessionOptimisticThinking(sessionId); + throw e; } + } - // Send message with optional permission mode and source identifier - apiSocket.send('message', { - sid: sessionId, - message: encryptedRawRecord, - localId, - sentFrom, - permissionMode: permissionMode || 'default' + async abortSession(sessionId: string): Promise<void> { + await apiSocket.sessionRPC(sessionId, 'abort', { + reason: `The user doesn't want to proceed with this tool use. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). STOP what you are doing and wait for the user to tell you how to proceed.` }); } - applySettings = (delta: Partial<Settings>) => { - storage.getState().applySettingsLocal(delta); + async submitMessage(sessionId: string, text: string, displayText?: string): Promise<void> { + const configuredMode = storage.getState().settings.sessionMessageSendMode; + const session = storage.getState().sessions[sessionId] ?? null; + const mode = chooseSubmitMode({ configuredMode, session }); - // Save pending settings - this.pendingSettings = { ...this.pendingSettings, ...delta }; - savePendingSettings(this.pendingSettings); + if (mode === 'interrupt') { + try { await this.abortSession(sessionId); } catch { } + await this.sendMessage(sessionId, text, displayText); + return; + } + if (mode === 'server_pending') { + await this.enqueuePendingMessage(sessionId, text, displayText); + return; + } + await this.sendMessage(sessionId, text, displayText); + } - // Sync PostHog opt-out state if it was changed - if (tracking && 'analyticsOptOut' in delta) { - const currentSettings = storage.getState().settings; - if (currentSettings.analyticsOptOut) { - tracking.optOut(); - } else { - tracking.optIn(); - } + private async updateSessionMetadataWithRetry(sessionId: string, updater: (metadata: Metadata) => Metadata): Promise<void> { + const encryption = this.encryption.getSessionEncryption(sessionId); + if (!encryption) { + throw new Error(`Session ${sessionId} not found`); } - // Invalidate settings sync - this.settingsSync.invalidate(); + await updateSessionMetadataWithRetryRpc<Metadata>({ + sessionId, + getSession: () => { + const s = storage.getState().sessions[sessionId]; + if (!s?.metadata) return null; + return { metadataVersion: s.metadataVersion, metadata: s.metadata }; + }, + refreshSessions: async () => { + await this.refreshSessions(); + }, + encryptMetadata: async (metadata) => encryption.encryptMetadata(metadata), + decryptMetadata: async (version, encrypted) => encryption.decryptMetadata(version, encrypted), + emitUpdateMetadata: async (payload) => apiSocket.emitWithAck<UpdateMetadataAck>('update-metadata', payload), + applySessionMetadata: ({ metadataVersion, metadata }) => { + const currentSession = storage.getState().sessions[sessionId]; + if (!currentSession) return; + this.applySessions([{ + ...currentSession, + metadata, + metadataVersion, + }]); + }, + updater, + maxAttempts: 8, + }); } - refreshPurchases = () => { - this.purchasesSync.invalidate(); + private repairInvalidReadStateV1 = async (params: { sessionId: string; sessionSeqUpperBound: number }): Promise<void> => { + await repairInvalidReadStateV1Engine({ + sessionId: params.sessionId, + sessionSeqUpperBound: params.sessionSeqUpperBound, + attempted: this.readStateV1RepairAttempted, + inFlight: this.readStateV1RepairInFlight, + getSession: (sessionId) => storage.getState().sessions[sessionId], + updateSessionMetadataWithRetry: (sessionId, updater) => this.updateSessionMetadataWithRetry(sessionId, updater), + now: nowServerMs, + }); } - refreshProfile = async () => { - await this.profileSync.invalidateAndAwait(); + async markSessionViewed(sessionId: string, opts?: { sessionSeq?: number; pendingActivityAt?: number }): Promise<void> { + const session = storage.getState().sessions[sessionId]; + if (!session?.metadata) return; + + const sessionSeq = opts?.sessionSeq ?? session.seq ?? 0; + const pendingActivityAt = opts?.pendingActivityAt ?? computePendingActivityAt(session.metadata); + const existing = session.metadata.readStateV1; + const existingSeq = existing?.sessionSeq ?? 0; + const needsRepair = existingSeq > sessionSeq; + + const early = computeNextReadStateV1({ + prev: existing, + sessionSeq, + pendingActivityAt, + now: nowServerMs(), + }); + if (!needsRepair && !early.didChange) return; + + await this.updateSessionMetadataWithRetry(sessionId, (metadata) => { + const result = computeNextReadStateV1({ + prev: metadata.readStateV1, + sessionSeq, + pendingActivityAt, + now: nowServerMs(), + }); + if (!result.didChange) return metadata; + return { ...metadata, readStateV1: result.next }; + }); } - purchaseProduct = async (productId: string): Promise<{ success: boolean; error?: string }> => { - try { - // Check if RevenueCat is initialized - if (!this.revenueCatInitialized) { - return { success: false, error: 'RevenueCat not initialized' }; - } + async fetchPendingMessages(sessionId: string): Promise<void> { + await fetchAndApplyPendingMessagesEngine({ sessionId, encryption: this.encryption }); + } - // Fetch the product - const products = await RevenueCat.getProducts([productId]); - if (products.length === 0) { - return { success: false, error: `Product '${productId}' not found` }; - } + async enqueuePendingMessage(sessionId: string, text: string, displayText?: string): Promise<void> { + await enqueuePendingMessageEngine({ + sessionId, + text, + displayText, + encryption: this.encryption, + updateSessionMetadataWithRetry: (id, updater) => this.updateSessionMetadataWithRetry(id, updater), + }); + } - // Purchase the product - const product = products[0]; - const { customerInfo } = await RevenueCat.purchaseStoreProduct(product); + async updatePendingMessage(sessionId: string, pendingId: string, text: string): Promise<void> { + await updatePendingMessageEngine({ + sessionId, + pendingId, + text, + encryption: this.encryption, + updateSessionMetadataWithRetry: (id, updater) => this.updateSessionMetadataWithRetry(id, updater), + }); + } - // Update local purchases data - storage.getState().applyPurchases(customerInfo); + async deletePendingMessage(sessionId: string, pendingId: string): Promise<void> { + await deletePendingMessageEngine({ + sessionId, + pendingId, + updateSessionMetadataWithRetry: (id, updater) => this.updateSessionMetadataWithRetry(id, updater), + }); + } - return { success: true }; - } catch (error: any) { - // Check if user cancelled - if (error.userCancelled) { - return { success: false, error: 'Purchase cancelled' }; - } + async discardPendingMessage( + sessionId: string, + pendingId: string, + opts?: { reason?: 'switch_to_local' | 'manual' } + ): Promise<void> { + await discardPendingMessageEngine({ + sessionId, + pendingId, + opts, + encryption: this.encryption, + updateSessionMetadataWithRetry: (id, updater) => this.updateSessionMetadataWithRetry(id, updater), + }); + } - // Return the error message - return { success: false, error: error.message || 'Purchase failed' }; - } + async restoreDiscardedPendingMessage(sessionId: string, pendingId: string): Promise<void> { + await restoreDiscardedPendingMessageEngine({ + sessionId, + pendingId, + encryption: this.encryption, + updateSessionMetadataWithRetry: (id, updater) => this.updateSessionMetadataWithRetry(id, updater), + }); } - getOfferings = async (): Promise<{ success: boolean; offerings?: any; error?: string }> => { - try { - // Check if RevenueCat is initialized - if (!this.revenueCatInitialized) { - return { success: false, error: 'RevenueCat not initialized' }; - } + async deleteDiscardedPendingMessage(sessionId: string, pendingId: string): Promise<void> { + await deleteDiscardedPendingMessageEngine({ + sessionId, + pendingId, + encryption: this.encryption, + updateSessionMetadataWithRetry: (id, updater) => this.updateSessionMetadataWithRetry(id, updater), + }); + } - // Fetch offerings - const offerings = await RevenueCat.getOfferings(); + applySettings = (delta: Partial<Settings>) => { + applySettingsLocalDelta({ + delta, + settingsSecretsKey: this.settingsSecretsKey, + getPendingSettings: () => this.pendingSettings, + setPendingSettings: (next) => { + this.pendingSettings = next; + }, + schedulePendingSettingsFlush: () => this.schedulePendingSettingsFlush(), + }); + } - // Return the offerings data - return { - success: true, - offerings: { - current: offerings.current, - all: offerings.all - } - }; - } catch (error: any) { - return { success: false, error: error.message || 'Failed to fetch offerings' }; - } + refreshPurchases = () => { + this.purchasesSync.invalidate(); } - presentPaywall = async (): Promise<{ success: boolean; purchased?: boolean; error?: string }> => { - try { - // Check if RevenueCat is initialized - if (!this.revenueCatInitialized) { - const error = 'RevenueCat not initialized'; - trackPaywallError(error); - return { success: false, error }; - } + refreshProfile = async () => { + await this.profileSync.invalidateAndAwait(); + } - // Track paywall presentation - trackPaywallPresented(); - - // Present the paywall - const result = await RevenueCat.presentPaywall(); - - // Handle the result - switch (result) { - case PaywallResult.PURCHASED: - trackPaywallPurchased(); - // Refresh customer info after purchase - await this.syncPurchases(); - return { success: true, purchased: true }; - case PaywallResult.RESTORED: - trackPaywallRestored(); - // Refresh customer info after restore - await this.syncPurchases(); - return { success: true, purchased: true }; - case PaywallResult.CANCELLED: - trackPaywallCancelled(); - return { success: true, purchased: false }; - case PaywallResult.NOT_PRESENTED: - // Don't track error for NOT_PRESENTED as it's a platform limitation - return { success: false, error: 'Paywall not available on this platform' }; - case PaywallResult.ERROR: - default: - const errorMsg = 'Failed to present paywall'; - trackPaywallError(errorMsg); - return { success: false, error: errorMsg }; - } - } catch (error: any) { - const errorMessage = error.message || 'Failed to present paywall'; - trackPaywallError(errorMessage); - return { success: false, error: errorMessage }; - } + purchaseProduct = async (productId: string): Promise<{ success: boolean; error?: string }> => { + return await purchaseProductEngine({ + revenueCatInitialized: this.revenueCatInitialized, + productId, + applyPurchases: (customerInfo) => storage.getState().applyPurchases(customerInfo), + }); + } + + getOfferings = async (): Promise<{ success: boolean; offerings?: any; error?: string }> => { + return await getOfferingsEngine({ revenueCatInitialized: this.revenueCatInitialized }); + } + + presentPaywall = async (): Promise<{ success: boolean; purchased?: boolean; error?: string }> => { + return await presentPaywallEngine({ + revenueCatInitialized: this.revenueCatInitialized, + trackPaywallPresented, + trackPaywallPurchased, + trackPaywallCancelled, + trackPaywallRestored, + trackPaywallError, + syncPurchases: () => this.syncPurchases(), + }); } async assumeUsers(userIds: string[]): Promise<void> { @@ -469,87 +690,87 @@ class Sync { private fetchSessions = async () => { if (!this.credentials) return; - - const API_ENDPOINT = getServerUrl(); - const response = await fetch(`${API_ENDPOINT}/v1/sessions`, { - headers: { - 'Authorization': `Bearer ${this.credentials.token}`, - 'Content-Type': 'application/json' - } + await fetchAndApplySessions({ + credentials: this.credentials, + encryption: this.encryption, + sessionDataKeys: this.sessionDataKeys, + applySessions: (sessions) => this.applySessions(sessions), + repairInvalidReadStateV1: (params) => this.repairInvalidReadStateV1(params), + log, }); + } - if (!response.ok) { - throw new Error(`Failed to fetch sessions: ${response.status}`); - } + /** + * Export the per-session data key for UI-assisted resume (dataKey mode only). + * Returns null when the session uses legacy encryption or the key is unavailable. + */ + public getSessionEncryptionKeyBase64ForResume(sessionId: string): string | null { + const key = this.sessionDataKeys.get(sessionId); + if (!key) return null; + return encodeBase64(key, 'base64'); + } - const data = await response.json(); - const sessions = data.sessions as Array<{ - id: string; - tag: string; - seq: number; - metadata: string; - metadataVersion: number; - agentState: string | null; - agentStateVersion: number; - dataEncryptionKey: string | null; - active: boolean; - activeAt: number; - createdAt: number; - updatedAt: number; - lastMessage: ApiMessage | null; - }>; - - // Initialize all session encryptions first - const sessionKeys = new Map<string, Uint8Array | null>(); - for (const session of sessions) { - if (session.dataEncryptionKey) { - let decrypted = await this.encryption.decryptEncryptionKey(session.dataEncryptionKey); - if (!decrypted) { - console.error(`Failed to decrypt data encryption key for session ${session.id}`); - continue; - } - sessionKeys.set(session.id, decrypted); - } else { - sessionKeys.set(session.id, null); - } - } - await this.encryption.initializeSessions(sessionKeys); - - // Decrypt sessions - let decryptedSessions: (Omit<Session, 'presence'> & { presence?: "online" | number })[] = []; - for (const session of sessions) { - // Get session encryption (should always exist after initialization) - const sessionEncryption = this.encryption.getSessionEncryption(session.id); - if (!sessionEncryption) { - console.error(`Session encryption not found for ${session.id} - this should never happen`); - continue; - } + /** + * Get the decrypted per-session data encryption key (DEK) if available. + * + * @remarks + * This is intentionally in-memory only; it returns null if the session key + * hasn't been fetched/decrypted yet. + */ + public getSessionDataKey(sessionId: string): Uint8Array | null { + const key = this.sessionDataKeys.get(sessionId); + if (!key) return null; + // Defensive copy (callers should treat keys as immutable). + return new Uint8Array(key); + } - // Decrypt metadata using session-specific encryption - let metadata = await sessionEncryption.decryptMetadata(session.metadataVersion, session.metadata); + public refreshMachines = async () => { + return this.fetchMachines(); + } + + public retryNow = () => { + try { + storage.getState().clearSyncError(); + apiSocket.disconnect(); + apiSocket.connect(); + } catch { + // ignore + } + this.sessionsSync.invalidate(); + this.settingsSync.invalidate(); + this.profileSync.invalidate(); + this.machinesSync.invalidate(); + this.purchasesSync.invalidate(); + this.artifactsSync.invalidate(); + this.friendsSync.invalidate(); + this.friendRequestsSync.invalidate(); + this.feedSync.invalidate(); + this.todosSync.invalidate(); + } - // Decrypt agent state using session-specific encryption - let agentState = await sessionEncryption.decryptAgentState(session.agentStateVersion, session.agentState); + public refreshMachinesThrottled = async (params?: { staleMs?: number; force?: boolean }) => { + if (!this.credentials) return; + const staleMs = params?.staleMs ?? 30_000; + const force = params?.force ?? false; + const now = Date.now(); - // Put it all together - const processedSession = { - ...session, - thinking: false, - thinkingAt: 0, - metadata, - agentState - }; - decryptedSessions.push(processedSession); + if (!force && (now - this.lastMachinesRefreshAt) < staleMs) { + return; } - // Apply to storage - this.applySessions(decryptedSessions); - log.log(`📥 fetchSessions completed - processed ${decryptedSessions.length} sessions`); + if (this.machinesRefreshInFlight) { + return this.machinesRefreshInFlight; + } - } + this.machinesRefreshInFlight = this.fetchMachines() + .then(() => { + this.lastMachinesRefreshAt = Date.now(); + }) + .finally(() => { + this.machinesRefreshInFlight = null; + }); - public refreshMachines = async () => { - return this.fetchMachines(); + return this.machinesRefreshInFlight; } public refreshSessions = async () => { @@ -562,115 +783,23 @@ class Sync { // Artifact methods public fetchArtifactsList = async (): Promise<void> => { - log.log('📦 fetchArtifactsList: Starting artifact sync'); - if (!this.credentials) { - log.log('📦 fetchArtifactsList: No credentials, skipping'); - return; - } - - try { - log.log('📦 fetchArtifactsList: Fetching artifacts from server'); - const artifacts = await fetchArtifacts(this.credentials); - log.log(`📦 fetchArtifactsList: Received ${artifacts.length} artifacts from server`); - const decryptedArtifacts: DecryptedArtifact[] = []; - - for (const artifact of artifacts) { - try { - // Decrypt the data encryption key - const decryptedKey = await this.encryption.decryptEncryptionKey(artifact.dataEncryptionKey); - if (!decryptedKey) { - console.error(`Failed to decrypt key for artifact ${artifact.id}`); - continue; - } - - // Store the decrypted key in memory - this.artifactDataKeys.set(artifact.id, decryptedKey); - - // Create artifact encryption instance - const artifactEncryption = new ArtifactEncryption(decryptedKey); - - // Decrypt header - const header = await artifactEncryption.decryptHeader(artifact.header); - - decryptedArtifacts.push({ - id: artifact.id, - title: header?.title || null, - sessions: header?.sessions, // Include sessions from header - draft: header?.draft, // Include draft flag from header - body: undefined, // Body not loaded in list - headerVersion: artifact.headerVersion, - bodyVersion: artifact.bodyVersion, - seq: artifact.seq, - createdAt: artifact.createdAt, - updatedAt: artifact.updatedAt, - isDecrypted: !!header, - }); - } catch (err) { - console.error(`Failed to decrypt artifact ${artifact.id}:`, err); - // Add with decryption failed flag - decryptedArtifacts.push({ - id: artifact.id, - title: null, - body: undefined, - headerVersion: artifact.headerVersion, - seq: artifact.seq, - createdAt: artifact.createdAt, - updatedAt: artifact.updatedAt, - isDecrypted: false, - }); - } - } - - log.log(`📦 fetchArtifactsList: Successfully decrypted ${decryptedArtifacts.length} artifacts`); - storage.getState().applyArtifacts(decryptedArtifacts); - log.log('📦 fetchArtifactsList: Artifacts applied to storage'); - } catch (error) { - log.log(`📦 fetchArtifactsList: Error fetching artifacts: ${error}`); - console.error('Failed to fetch artifacts:', error); - throw error; - } + await fetchAndApplyArtifactsList({ + credentials: this.credentials, + encryption: this.encryption, + artifactDataKeys: this.artifactDataKeys, + applyArtifacts: (artifacts) => storage.getState().applyArtifacts(artifacts), + }); } public async fetchArtifactWithBody(artifactId: string): Promise<DecryptedArtifact | null> { if (!this.credentials) return null; - try { - const artifact = await fetchArtifact(this.credentials, artifactId); - - // Decrypt the data encryption key - const decryptedKey = await this.encryption.decryptEncryptionKey(artifact.dataEncryptionKey); - if (!decryptedKey) { - console.error(`Failed to decrypt key for artifact ${artifactId}`); - return null; - } - - // Store the decrypted key in memory - this.artifactDataKeys.set(artifact.id, decryptedKey); - - // Create artifact encryption instance - const artifactEncryption = new ArtifactEncryption(decryptedKey); - - // Decrypt header and body - const header = await artifactEncryption.decryptHeader(artifact.header); - const body = artifact.body ? await artifactEncryption.decryptBody(artifact.body) : null; - - return { - id: artifact.id, - title: header?.title || null, - sessions: header?.sessions, // Include sessions from header - draft: header?.draft, // Include draft flag from header - body: body?.body || null, - headerVersion: artifact.headerVersion, - bodyVersion: artifact.bodyVersion, - seq: artifact.seq, - createdAt: artifact.createdAt, - updatedAt: artifact.updatedAt, - isDecrypted: !!header, - }; - } catch (error) { - console.error(`Failed to fetch artifact ${artifactId}:`, error); - return null; - } + return await fetchArtifactWithBodyFromApi({ + credentials: this.credentials, + artifactId, + encryption: this.encryption, + artifactDataKeys: this.artifactDataKeys, + }); } public async createArtifact( @@ -683,59 +812,16 @@ class Sync { throw new Error('Not authenticated'); } - try { - // Generate unique artifact ID - const artifactId = this.encryption.generateId(); - - // Generate data encryption key - const dataEncryptionKey = ArtifactEncryption.generateDataEncryptionKey(); - - // Store the decrypted key in memory - this.artifactDataKeys.set(artifactId, dataEncryptionKey); - - // Encrypt the data encryption key with user's key - const encryptedKey = await this.encryption.encryptEncryptionKey(dataEncryptionKey); - - // Create artifact encryption instance - const artifactEncryption = new ArtifactEncryption(dataEncryptionKey); - - // Encrypt header and body - const encryptedHeader = await artifactEncryption.encryptHeader({ title, sessions, draft }); - const encryptedBody = await artifactEncryption.encryptBody({ body }); - - // Create the request - const request: ArtifactCreateRequest = { - id: artifactId, - header: encryptedHeader, - body: encryptedBody, - dataEncryptionKey: encodeBase64(encryptedKey, 'base64'), - }; - - // Send to server - const artifact = await createArtifact(this.credentials, request); - - // Add to local storage - const decryptedArtifact: DecryptedArtifact = { - id: artifact.id, - title, - sessions, - draft, - body, - headerVersion: artifact.headerVersion, - bodyVersion: artifact.bodyVersion, - seq: artifact.seq, - createdAt: artifact.createdAt, - updatedAt: artifact.updatedAt, - isDecrypted: true, - }; - - storage.getState().addArtifact(decryptedArtifact); - - return artifactId; - } catch (error) { - console.error('Failed to create artifact:', error); - throw error; - } + return await createArtifactViaApi({ + credentials: this.credentials, + title, + body, + sessions, + draft, + encryption: this.encryption, + artifactDataKeys: this.artifactDataKeys, + addArtifact: (artifact) => storage.getState().addArtifact(artifact), + }); } public async updateArtifact( @@ -749,204 +835,29 @@ class Sync { throw new Error('Not authenticated'); } - try { - // Get current artifact to get versions and encryption key - const currentArtifact = storage.getState().artifacts[artifactId]; - if (!currentArtifact) { - throw new Error('Artifact not found'); - } - - // Get the data encryption key from memory or fetch it - let dataEncryptionKey = this.artifactDataKeys.get(artifactId); - - // Fetch full artifact if we don't have version info or encryption key - let headerVersion = currentArtifact.headerVersion; - let bodyVersion = currentArtifact.bodyVersion; - - if (headerVersion === undefined || bodyVersion === undefined || !dataEncryptionKey) { - const fullArtifact = await fetchArtifact(this.credentials, artifactId); - headerVersion = fullArtifact.headerVersion; - bodyVersion = fullArtifact.bodyVersion; - - // Decrypt and store the data encryption key if we don't have it - if (!dataEncryptionKey) { - const decryptedKey = await this.encryption.decryptEncryptionKey(fullArtifact.dataEncryptionKey); - if (!decryptedKey) { - throw new Error('Failed to decrypt encryption key'); - } - this.artifactDataKeys.set(artifactId, decryptedKey); - dataEncryptionKey = decryptedKey; - } - } - - // Create artifact encryption instance - const artifactEncryption = new ArtifactEncryption(dataEncryptionKey); - - // Prepare update request - const updateRequest: ArtifactUpdateRequest = {}; - - // Check if header needs updating (title, sessions, or draft changed) - if (title !== currentArtifact.title || - JSON.stringify(sessions) !== JSON.stringify(currentArtifact.sessions) || - draft !== currentArtifact.draft) { - const encryptedHeader = await artifactEncryption.encryptHeader({ - title, - sessions, - draft - }); - updateRequest.header = encryptedHeader; - updateRequest.expectedHeaderVersion = headerVersion; - } - - // Only update body if it changed - if (body !== currentArtifact.body) { - const encryptedBody = await artifactEncryption.encryptBody({ body }); - updateRequest.body = encryptedBody; - updateRequest.expectedBodyVersion = bodyVersion; - } - - // Skip if no changes - if (Object.keys(updateRequest).length === 0) { - return; - } - - // Send update to server - const response = await updateArtifact(this.credentials, artifactId, updateRequest); - - if (!response.success) { - // Handle version mismatch - if (response.error === 'version-mismatch') { - throw new Error('Artifact was modified by another client. Please refresh and try again.'); - } - throw new Error('Failed to update artifact'); - } - - // Update local storage - const updatedArtifact: DecryptedArtifact = { - ...currentArtifact, - title, - sessions, - draft, - body, - headerVersion: response.headerVersion !== undefined ? response.headerVersion : headerVersion, - bodyVersion: response.bodyVersion !== undefined ? response.bodyVersion : bodyVersion, - updatedAt: Date.now(), - }; - - storage.getState().updateArtifact(updatedArtifact); - } catch (error) { - console.error('Failed to update artifact:', error); - throw error; - } + await updateArtifactViaApi({ + credentials: this.credentials, + artifactId, + title, + body, + sessions, + draft, + encryption: this.encryption, + artifactDataKeys: this.artifactDataKeys, + getArtifact: (id) => storage.getState().artifacts[id], + updateArtifact: (artifact) => storage.getState().updateArtifact(artifact), + }); } private fetchMachines = async () => { if (!this.credentials) return; - console.log('📊 Sync: Fetching machines...'); - const API_ENDPOINT = getServerUrl(); - const response = await fetch(`${API_ENDPOINT}/v1/machines`, { - headers: { - 'Authorization': `Bearer ${this.credentials.token}`, - 'Content-Type': 'application/json' - } + await fetchAndApplyMachines({ + credentials: this.credentials, + encryption: this.encryption, + machineDataKeys: this.machineDataKeys, + applyMachines: (machines, replace) => storage.getState().applyMachines(machines, replace), }); - - if (!response.ok) { - console.error(`Failed to fetch machines: ${response.status}`); - return; - } - - const data = await response.json(); - console.log(`📊 Sync: Fetched ${Array.isArray(data) ? data.length : 0} machines from server`); - const machines = data as Array<{ - id: string; - metadata: string; - metadataVersion: number; - daemonState?: string | null; - daemonStateVersion?: number; - dataEncryptionKey?: string | null; // Add support for per-machine encryption keys - seq: number; - active: boolean; - activeAt: number; // Changed from lastActiveAt - createdAt: number; - updatedAt: number; - }>; - - // First, collect and decrypt encryption keys for all machines - const machineKeysMap = new Map<string, Uint8Array | null>(); - for (const machine of machines) { - if (machine.dataEncryptionKey) { - const decryptedKey = await this.encryption.decryptEncryptionKey(machine.dataEncryptionKey); - if (!decryptedKey) { - console.error(`Failed to decrypt data encryption key for machine ${machine.id}`); - continue; - } - machineKeysMap.set(machine.id, decryptedKey); - this.machineDataKeys.set(machine.id, decryptedKey); - } else { - machineKeysMap.set(machine.id, null); - } - } - - // Initialize machine encryptions - await this.encryption.initializeMachines(machineKeysMap); - - // Process all machines first, then update state once - const decryptedMachines: Machine[] = []; - - for (const machine of machines) { - // Get machine-specific encryption (might exist from previous initialization) - const machineEncryption = this.encryption.getMachineEncryption(machine.id); - if (!machineEncryption) { - console.error(`Machine encryption not found for ${machine.id} - this should never happen`); - continue; - } - - try { - - // Use machine-specific encryption (which handles fallback internally) - const metadata = machine.metadata - ? await machineEncryption.decryptMetadata(machine.metadataVersion, machine.metadata) - : null; - - const daemonState = machine.daemonState - ? await machineEncryption.decryptDaemonState(machine.daemonStateVersion || 0, machine.daemonState) - : null; - - decryptedMachines.push({ - id: machine.id, - seq: machine.seq, - createdAt: machine.createdAt, - updatedAt: machine.updatedAt, - active: machine.active, - activeAt: machine.activeAt, - metadata, - metadataVersion: machine.metadataVersion, - daemonState, - daemonStateVersion: machine.daemonStateVersion || 0 - }); - } catch (error) { - console.error(`Failed to decrypt machine ${machine.id}:`, error); - // Still add the machine with null metadata - decryptedMachines.push({ - id: machine.id, - seq: machine.seq, - createdAt: machine.createdAt, - updatedAt: machine.updatedAt, - active: machine.active, - activeAt: machine.activeAt, - metadata: null, - metadataVersion: machine.metadataVersion, - daemonState: null, - daemonStateVersion: 0 - }); - } - } - - // Replace entire machine state with fetched machines - storage.getState().applyMachines(decryptedMachines, true); - log.log(`🖥️ fetchMachines completed - processed ${decryptedMachines.length} machines`); } private fetchFriends = async () => { @@ -971,314 +882,50 @@ class Sync { private fetchTodos = async () => { if (!this.credentials) return; - - try { - log.log('📝 Fetching todos...'); - await initializeTodoSync(this.credentials); - log.log('📝 Todos loaded'); - } catch (error) { - log.log('📝 Failed to fetch todos:'); - } + await fetchTodosEngine({ credentials: this.credentials }); } private applyTodoSocketUpdates = async (changes: any[]) => { if (!this.credentials || !this.encryption) return; - - const currentState = storage.getState(); - const todoState = currentState.todoState; - if (!todoState) { - // No todo state yet, just refetch - this.todosSync.invalidate(); - return; - } - - const { todos, undoneOrder, doneOrder, versions } = todoState; - let updatedTodos = { ...todos }; - let updatedVersions = { ...versions }; - let indexUpdated = false; - let newUndoneOrder = undoneOrder; - let newDoneOrder = doneOrder; - - // Process each change - for (const change of changes) { - try { - const key = change.key; - const version = change.version; - - // Update version tracking - updatedVersions[key] = version; - - if (change.value === null) { - // Item was deleted - if (key.startsWith('todo.') && key !== 'todo.index') { - const todoId = key.substring(5); // Remove 'todo.' prefix - delete updatedTodos[todoId]; - newUndoneOrder = newUndoneOrder.filter(id => id !== todoId); - newDoneOrder = newDoneOrder.filter(id => id !== todoId); - } - } else { - // Item was added or updated - const decrypted = await this.encryption.decryptRaw(change.value); - - if (key === 'todo.index') { - // Update the index - const index = decrypted as any; - newUndoneOrder = index.undoneOrder || []; - newDoneOrder = index.completedOrder || []; // Map completedOrder to doneOrder - indexUpdated = true; - } else if (key.startsWith('todo.')) { - // Update a todo item - const todoId = key.substring(5); - if (todoId && todoId !== 'index') { - updatedTodos[todoId] = decrypted as any; - } - } - } - } catch (error) { - console.error(`Failed to process todo change for key ${change.key}:`, error); - } - } - - // Apply the updated state - storage.getState().applyTodos({ - todos: updatedTodos, - undoneOrder: newUndoneOrder, - doneOrder: newDoneOrder, - versions: updatedVersions + await applyTodoSocketUpdatesEngine({ + changes, + encryption: this.encryption, + invalidateTodosSync: () => this.todosSync.invalidate(), }); - - log.log('📝 Applied todo socket updates successfully'); } private fetchFeed = async () => { if (!this.credentials) return; - - try { - log.log('📰 Fetching feed...'); - const state = storage.getState(); - const existingItems = state.feedItems; - const head = state.feedHead; - - // Load feed items - if we have a head, load newer items - let allItems: FeedItem[] = []; - let hasMore = true; - let cursor = head ? { after: head } : undefined; - let loadedCount = 0; - const maxItems = 500; - - // Keep loading until we reach known items or hit max limit - while (hasMore && loadedCount < maxItems) { - const response = await fetchFeed(this.credentials, { - limit: 100, - ...cursor - }); - - // Check if we reached known items - const foundKnown = response.items.some(item => - existingItems.some(existing => existing.id === item.id) - ); - - allItems.push(...response.items); - loadedCount += response.items.length; - hasMore = response.hasMore && !foundKnown; - - // Update cursor for next page - if (response.items.length > 0) { - const lastItem = response.items[response.items.length - 1]; - cursor = { after: lastItem.cursor }; - } - } - - // If this is initial load (no head), also load older items - if (!head && allItems.length < 100) { - const response = await fetchFeed(this.credentials, { - limit: 100 - }); - allItems.push(...response.items); - } - - // Collect user IDs from friend-related feed items - const userIds = new Set<string>(); - allItems.forEach(item => { - if (item.body && (item.body.kind === 'friend_request' || item.body.kind === 'friend_accepted')) { - userIds.add(item.body.uid); - } - }); - - // Fetch missing users - if (userIds.size > 0) { - await this.assumeUsers(Array.from(userIds)); - } - - // Filter out items where user is not found (404) - const users = storage.getState().users; - const compatibleItems = allItems.filter(item => { - // Keep text items - if (item.body.kind === 'text') return true; - - // For friend-related items, check if user exists and is not null (404) - if (item.body.kind === 'friend_request' || item.body.kind === 'friend_accepted') { - const userProfile = users[item.body.uid]; - // Keep item only if user exists and is not null - return userProfile !== null && userProfile !== undefined; - } - - return true; - }); - - // Apply only compatible items to storage - storage.getState().applyFeedItems(compatibleItems); - log.log(`📰 fetchFeed completed - loaded ${compatibleItems.length} compatible items (${allItems.length - compatibleItems.length} filtered)`); - } catch (error) { - console.error('Failed to fetch feed:', error); - } + await fetchAndApplyFeed({ + credentials: this.credentials, + getFeedItems: () => storage.getState().feedItems, + getFeedHead: () => storage.getState().feedHead, + assumeUsers: (userIds) => this.assumeUsers(userIds), + getUsers: () => storage.getState().users, + applyFeedItems: (items) => storage.getState().applyFeedItems(items), + log, + }); } private syncSettings = async () => { if (!this.credentials) return; - - const API_ENDPOINT = getServerUrl(); - const maxRetries = 3; - let retryCount = 0; - - // Apply pending settings - if (Object.keys(this.pendingSettings).length > 0) { - - while (retryCount < maxRetries) { - let version = storage.getState().settingsVersion; - let settings = applySettings(storage.getState().settings, this.pendingSettings); - const response = await fetch(`${API_ENDPOINT}/v1/account/settings`, { - method: 'POST', - body: JSON.stringify({ - settings: await this.encryption.encryptRaw(settings), - expectedVersion: version ?? 0 - }), - headers: { - 'Authorization': `Bearer ${this.credentials.token}`, - 'Content-Type': 'application/json' - } - }); - const data = await response.json() as { - success: false, - error: string, - currentVersion: number, - currentSettings: string | null - } | { - success: true - }; - if (data.success) { - this.pendingSettings = {}; - savePendingSettings({}); - break; - } - if (data.error === 'version-mismatch') { - // Parse server settings - const serverSettings = data.currentSettings - ? settingsParse(await this.encryption.decryptRaw(data.currentSettings)) - : { ...settingsDefaults }; - - // Merge: server base + our pending changes (our changes win) - const mergedSettings = applySettings(serverSettings, this.pendingSettings); - - // Update local storage with merged result at server's version - storage.getState().applySettings(mergedSettings, data.currentVersion); - - // Sync tracking state with merged settings - if (tracking) { - mergedSettings.analyticsOptOut ? tracking.optOut() : tracking.optIn(); - } - - // Log and retry - console.log('settings version-mismatch, retrying', { - serverVersion: data.currentVersion, - retry: retryCount + 1, - pendingKeys: Object.keys(this.pendingSettings) - }); - retryCount++; - continue; - } else { - throw new Error(`Failed to sync settings: ${data.error}`); - } - } - } - - // If exhausted retries, throw to trigger outer backoff delay - if (retryCount >= maxRetries) { - throw new Error(`Settings sync failed after ${maxRetries} retries due to version conflicts`); - } - - // Run request - const response = await fetch(`${API_ENDPOINT}/v1/account/settings`, { - headers: { - 'Authorization': `Bearer ${this.credentials.token}`, - 'Content-Type': 'application/json' - } + await syncSettingsEngine({ + credentials: this.credentials, + encryption: this.encryption, + pendingSettings: this.pendingSettings, + clearPendingSettings: () => { + this.pendingSettings = {}; + savePendingSettings({}); + }, }); - if (!response.ok) { - throw new Error(`Failed to fetch settings: ${response.status}`); - } - const data = await response.json() as { - settings: string | null, - settingsVersion: number - }; - - // Parse response - let parsedSettings: Settings; - if (data.settings) { - parsedSettings = settingsParse(await this.encryption.decryptRaw(data.settings)); - } else { - parsedSettings = { ...settingsDefaults }; - } - - // Log - console.log('settings', JSON.stringify({ - settings: parsedSettings, - version: data.settingsVersion - })); - - // Apply settings to storage - storage.getState().applySettings(parsedSettings, data.settingsVersion); - - // Sync PostHog opt-out state with settings - if (tracking) { - if (parsedSettings.analyticsOptOut) { - tracking.optOut(); - } else { - tracking.optIn(); - } - } } private fetchProfile = async () => { if (!this.credentials) return; - - const API_ENDPOINT = getServerUrl(); - const response = await fetch(`${API_ENDPOINT}/v1/account/profile`, { - headers: { - 'Authorization': `Bearer ${this.credentials.token}`, - 'Content-Type': 'application/json' - } + await fetchAndApplyProfile({ + credentials: this.credentials, + applyProfile: (profile) => storage.getState().applyProfile(profile), }); - - if (!response.ok) { - throw new Error(`Failed to fetch profile: ${response.status}`); - } - - const data = await response.json(); - const parsedProfile = profileParse(data); - - // Log profile data for debugging - console.log('profile', JSON.stringify({ - id: parsedProfile.id, - timestamp: parsedProfile.timestamp, - firstName: parsedProfile.firstName, - lastName: parsedProfile.lastName, - hasAvatar: !!parsedProfile.avatar, - hasGitHub: !!parsedProfile.github - })); - - // Apply profile to storage - storage.getState().applyProfile(parsedProfile); } private fetchNativeUpdate = async () => { @@ -1314,12 +961,11 @@ class Sync { }); if (!response.ok) { - console.log(`[fetchNativeUpdate] Request failed: ${response.status}`); + log.log(`[fetchNativeUpdate] Request failed: ${response.status}`); return; } const data = await response.json(); - console.log('[fetchNativeUpdate] Data:', data); // Apply update status to storage if (data.update_required && data.update_url) { @@ -1333,157 +979,37 @@ class Sync { }); } } catch (error) { - console.log('[fetchNativeUpdate] Error:', error); + console.error('[fetchNativeUpdate] Error:', error); storage.getState().applyNativeUpdateStatus(null); } } private syncPurchases = async () => { - try { - // Initialize RevenueCat if not already done - if (!this.revenueCatInitialized) { - // Get the appropriate API key based on platform - let apiKey: string | undefined; - - if (Platform.OS === 'ios') { - apiKey = config.revenueCatAppleKey; - } else if (Platform.OS === 'android') { - apiKey = config.revenueCatGoogleKey; - } else if (Platform.OS === 'web') { - apiKey = config.revenueCatStripeKey; - } - - if (!apiKey) { - console.log(`RevenueCat: No API key found for platform ${Platform.OS}`); - return; - } - - // Configure RevenueCat - if (__DEV__) { - RevenueCat.setLogLevel(LogLevel.DEBUG); - } - - // Initialize with the public ID as user ID - RevenueCat.configure({ - apiKey, - appUserID: this.serverID, // In server this is a CUID, which we can assume is globaly unique even between servers - useAmazon: false, - }); - - this.revenueCatInitialized = true; - console.log('RevenueCat initialized successfully'); - } - - // Sync purchases - await RevenueCat.syncPurchases(); - - // Fetch customer info - const customerInfo = await RevenueCat.getCustomerInfo(); - - // Apply to storage (storage handles the transformation) - storage.getState().applyPurchases(customerInfo); - - } catch (error) { - console.error('Failed to sync purchases:', error); - // Don't throw - purchases are optional - } + await syncPurchasesEngine({ + serverID: this.serverID, + revenueCatInitialized: this.revenueCatInitialized, + setRevenueCatInitialized: (next) => { + this.revenueCatInitialized = next; + }, + applyPurchases: (customerInfo) => storage.getState().applyPurchases(customerInfo), + }); } private fetchMessages = async (sessionId: string) => { - log.log(`💬 fetchMessages starting for session ${sessionId} - acquiring lock`); - - // Get encryption - may not be ready yet if session was just created - // Throwing an error triggers backoff retry in InvalidateSync - const encryption = this.encryption.getSessionEncryption(sessionId); - if (!encryption) { - log.log(`💬 fetchMessages: Session encryption not ready for ${sessionId}, will retry`); - throw new Error(`Session encryption not ready for ${sessionId}`); - } - - // Request - const response = await apiSocket.request(`/v1/sessions/${sessionId}/messages`); - const data = await response.json(); - - // Collect existing messages - let eixstingMessages = this.sessionReceivedMessages.get(sessionId); - if (!eixstingMessages) { - eixstingMessages = new Set<string>(); - this.sessionReceivedMessages.set(sessionId, eixstingMessages); - } - - // Decrypt and normalize messages - let start = Date.now(); - let normalizedMessages: NormalizedMessage[] = []; - - // Filter out existing messages and prepare for batch decryption - const messagesToDecrypt: ApiMessage[] = []; - for (const msg of [...data.messages as ApiMessage[]].reverse()) { - if (!eixstingMessages.has(msg.id)) { - messagesToDecrypt.push(msg); - } - } - - // Batch decrypt all messages at once - const decryptedMessages = await encryption.decryptMessages(messagesToDecrypt); - - // Process decrypted messages - for (let i = 0; i < decryptedMessages.length; i++) { - const decrypted = decryptedMessages[i]; - if (decrypted) { - eixstingMessages.add(decrypted.id); - // Normalize the decrypted message - let normalized = normalizeRawMessage(decrypted.id, decrypted.localId, decrypted.createdAt, decrypted.content); - if (normalized) { - normalizedMessages.push(normalized); - } - } - } - console.log('Batch decrypted and normalized messages in', Date.now() - start, 'ms'); - console.log('normalizedMessages', JSON.stringify(normalizedMessages)); - // console.log('messages', JSON.stringify(normalizedMessages)); - - // Apply to storage - this.applyMessages(sessionId, normalizedMessages); - storage.getState().applyMessagesLoaded(sessionId); - log.log(`💬 fetchMessages completed for session ${sessionId} - processed ${normalizedMessages.length} messages`); + await fetchAndApplyMessages({ + sessionId, + getSessionEncryption: (id) => this.encryption.getSessionEncryption(id), + request: (path) => apiSocket.request(path), + sessionReceivedMessages: this.sessionReceivedMessages, + applyMessages: (sid, messages) => this.applyMessages(sid, messages), + markMessagesLoaded: (sid) => storage.getState().applyMessagesLoaded(sid), + log, + }); } private registerPushToken = async () => { log.log('registerPushToken'); - // Only register on mobile platforms - if (Platform.OS === 'web') { - return; - } - - // Request permission - const { status: existingStatus } = await Notifications.getPermissionsAsync(); - let finalStatus = existingStatus; - log.log('existingStatus: ' + JSON.stringify(existingStatus)); - - if (existingStatus !== 'granted') { - const { status } = await Notifications.requestPermissionsAsync(); - finalStatus = status; - } - log.log('finalStatus: ' + JSON.stringify(finalStatus)); - - if (finalStatus !== 'granted') { - console.log('Failed to get push token for push notification!'); - return; - } - - // Get push token - const projectId = Constants?.expoConfig?.extra?.eas?.projectId ?? Constants?.easConfig?.projectId; - - const tokenData = await Notifications.getExpoPushTokenAsync({ projectId }); - log.log('tokenData: ' + JSON.stringify(tokenData)); - - // Register with server - try { - await registerPushToken(this.credentials, tokenData.data); - log.log('Push token registered successfully'); - } catch (error) { - log.log('Failed to register push token: ' + JSON.stringify(error)); - } + await registerPushTokenIfAvailable({ credentials: this.credentials, log }); } private subscribeToUpdates = () => { @@ -1493,519 +1019,55 @@ class Sync { // Subscribe to connection state changes apiSocket.onReconnected(() => { - log.log('🔌 Socket reconnected'); - this.sessionsSync.invalidate(); - this.machinesSync.invalidate(); - log.log('🔌 Socket reconnected: Invalidating artifacts sync'); - this.artifactsSync.invalidate(); - this.friendsSync.invalidate(); - this.friendRequestsSync.invalidate(); - this.feedSync.invalidate(); - const sessionsData = storage.getState().sessionsData; - if (sessionsData) { - for (const item of sessionsData) { - if (typeof item !== 'string') { - this.messagesSync.get(item.id)?.invalidate(); - // Also invalidate git status on reconnection - gitStatusSync.invalidate(item.id); - } - } - } + handleSocketReconnected({ + log, + invalidateSessions: () => this.sessionsSync.invalidate(), + invalidateMachines: () => this.machinesSync.invalidate(), + invalidateArtifacts: () => this.artifactsSync.invalidate(), + invalidateFriends: () => this.friendsSync.invalidate(), + invalidateFriendRequests: () => this.friendRequestsSync.invalidate(), + invalidateFeed: () => this.feedSync.invalidate(), + getSessionsData: () => storage.getState().sessionsData, + invalidateMessagesForSession: (sessionId) => this.messagesSync.get(sessionId)?.invalidate(), + invalidateGitStatusForSession: (sessionId) => gitStatusSync.invalidate(sessionId), + }); }); } private handleUpdate = async (update: unknown) => { - console.log('🔄 Sync: handleUpdate called with:', JSON.stringify(update).substring(0, 300)); - const validatedUpdate = ApiUpdateContainerSchema.safeParse(update); - if (!validatedUpdate.success) { - console.log('❌ Sync: Invalid update received:', validatedUpdate.error); - console.error('❌ Sync: Invalid update data:', update); - return; - } - const updateData = validatedUpdate.data; - console.log(`🔄 Sync: Validated update type: ${updateData.body.t}`); - - if (updateData.body.t === 'new-message') { - - // Get encryption - const encryption = this.encryption.getSessionEncryption(updateData.body.sid); - if (!encryption) { // Should never happen - console.error(`Session ${updateData.body.sid} not found`); - this.fetchSessions(); // Just fetch sessions again - return; - } - - // Decrypt message - let lastMessage: NormalizedMessage | null = null; - if (updateData.body.message) { - const decrypted = await encryption.decryptMessage(updateData.body.message); - if (decrypted) { - lastMessage = normalizeRawMessage(decrypted.id, decrypted.localId, decrypted.createdAt, decrypted.content); - - // Check for task lifecycle events to update thinking state - // This ensures UI updates even if volatile activity updates are lost - const rawContent = decrypted.content as { role?: string; content?: { type?: string; data?: { type?: string } } } | null; - const contentType = rawContent?.content?.type; - const dataType = rawContent?.content?.data?.type; - - // Debug logging to trace lifecycle events - if (dataType === 'task_complete' || dataType === 'turn_aborted' || dataType === 'task_started') { - console.log(`🔄 [Sync] Lifecycle event detected: contentType=${contentType}, dataType=${dataType}`); - } - - const isTaskComplete = - ((contentType === 'acp' || contentType === 'codex') && - (dataType === 'task_complete' || dataType === 'turn_aborted')); - - const isTaskStarted = - ((contentType === 'acp' || contentType === 'codex') && dataType === 'task_started'); - - if (isTaskComplete || isTaskStarted) { - console.log(`🔄 [Sync] Updating thinking state: isTaskComplete=${isTaskComplete}, isTaskStarted=${isTaskStarted}`); - } - - // Update session - const session = storage.getState().sessions[updateData.body.sid]; - if (session) { - this.applySessions([{ - ...session, - updatedAt: updateData.createdAt, - seq: updateData.seq, - // Update thinking state based on task lifecycle events - ...(isTaskComplete ? { thinking: false } : {}), - ...(isTaskStarted ? { thinking: true } : {}) - }]) - } else { - // Fetch sessions again if we don't have this session - this.fetchSessions(); - } - - // Update messages - if (lastMessage) { - console.log('🔄 Sync: Applying message:', JSON.stringify(lastMessage)); - this.applyMessages(updateData.body.sid, [lastMessage]); - let hasMutableTool = false; - if (lastMessage.role === 'agent' && lastMessage.content[0] && lastMessage.content[0].type === 'tool-result') { - hasMutableTool = storage.getState().isMutableToolCall(updateData.body.sid, lastMessage.content[0].tool_use_id); - } - if (hasMutableTool) { - gitStatusSync.invalidate(updateData.body.sid); - } - } - } - } - - // Ping session - this.onSessionVisible(updateData.body.sid); - - } else if (updateData.body.t === 'new-session') { - log.log('🆕 New session update received'); - this.sessionsSync.invalidate(); - } else if (updateData.body.t === 'delete-session') { - log.log('🗑️ Delete session update received'); - const sessionId = updateData.body.sid; - - // Remove session from storage - storage.getState().deleteSession(sessionId); - - // Remove encryption keys from memory - this.encryption.removeSessionEncryption(sessionId); - - // Remove from project manager - projectManager.removeSession(sessionId); - - // Clear any cached git status - gitStatusSync.clearForSession(sessionId); - - log.log(`🗑️ Session ${sessionId} deleted from local storage`); - } else if (updateData.body.t === 'update-session') { - const session = storage.getState().sessions[updateData.body.id]; - if (session) { - // Get session encryption - const sessionEncryption = this.encryption.getSessionEncryption(updateData.body.id); - if (!sessionEncryption) { - console.error(`Session encryption not found for ${updateData.body.id} - this should never happen`); - return; - } - - const agentState = updateData.body.agentState && sessionEncryption - ? await sessionEncryption.decryptAgentState(updateData.body.agentState.version, updateData.body.agentState.value) - : session.agentState; - const metadata = updateData.body.metadata && sessionEncryption - ? await sessionEncryption.decryptMetadata(updateData.body.metadata.version, updateData.body.metadata.value) - : session.metadata; - - this.applySessions([{ - ...session, - agentState, - agentStateVersion: updateData.body.agentState - ? updateData.body.agentState.version - : session.agentStateVersion, - metadata, - metadataVersion: updateData.body.metadata - ? updateData.body.metadata.version - : session.metadataVersion, - updatedAt: updateData.createdAt, - seq: updateData.seq - }]); - - // Invalidate git status when agent state changes (files may have been modified) - if (updateData.body.agentState) { - gitStatusSync.invalidate(updateData.body.id); - - // Check for new permission requests and notify voice assistant - if (agentState?.requests && Object.keys(agentState.requests).length > 0) { - const requestIds = Object.keys(agentState.requests); - const firstRequest = agentState.requests[requestIds[0]]; - const toolName = firstRequest?.tool; - voiceHooks.onPermissionRequested(updateData.body.id, requestIds[0], toolName, firstRequest?.arguments); - } - - // Re-fetch messages when control returns to mobile (local -> remote mode switch) - // This catches up on any messages that were exchanged while desktop had control - const wasControlledByUser = session.agentState?.controlledByUser; - const isNowControlledByUser = agentState?.controlledByUser; - if (!wasControlledByUser && isNowControlledByUser) { - log.log(`🔄 Control returned to mobile for session ${updateData.body.id}, re-fetching messages`); - this.onSessionVisible(updateData.body.id); - } - } - } - } else if (updateData.body.t === 'update-account') { - const accountUpdate = updateData.body; - const currentProfile = storage.getState().profile; - - // Build updated profile with new data - const updatedProfile: Profile = { - ...currentProfile, - firstName: accountUpdate.firstName !== undefined ? accountUpdate.firstName : currentProfile.firstName, - lastName: accountUpdate.lastName !== undefined ? accountUpdate.lastName : currentProfile.lastName, - avatar: accountUpdate.avatar !== undefined ? accountUpdate.avatar : currentProfile.avatar, - github: accountUpdate.github !== undefined ? accountUpdate.github : currentProfile.github, - timestamp: updateData.createdAt // Update timestamp to latest - }; - - // Apply the updated profile to storage - storage.getState().applyProfile(updatedProfile); - - // Handle settings updates (new for profile sync) - if (accountUpdate.settings?.value) { - try { - const decryptedSettings = await this.encryption.decryptRaw(accountUpdate.settings.value); - const parsedSettings = settingsParse(decryptedSettings); - - // Version compatibility check - const settingsSchemaVersion = parsedSettings.schemaVersion ?? 1; - if (settingsSchemaVersion > SUPPORTED_SCHEMA_VERSION) { - console.warn( - `⚠️ Received settings schema v${settingsSchemaVersion}, ` + - `we support v${SUPPORTED_SCHEMA_VERSION}. Update app for full functionality.` - ); - } - - storage.getState().applySettings(parsedSettings, accountUpdate.settings.version); - log.log(`📋 Settings synced from server (schema v${settingsSchemaVersion}, version ${accountUpdate.settings.version})`); - } catch (error) { - console.error('❌ Failed to process settings update:', error); - // Don't crash on settings sync errors, just log - } - } - } else if (updateData.body.t === 'update-machine') { - const machineUpdate = updateData.body; - const machineId = machineUpdate.machineId; // Changed from .id to .machineId - const machine = storage.getState().machines[machineId]; - - // Create or update machine with all required fields - const updatedMachine: Machine = { - id: machineId, - seq: updateData.seq, - createdAt: machine?.createdAt ?? updateData.createdAt, - updatedAt: updateData.createdAt, - active: machineUpdate.active ?? true, - activeAt: machineUpdate.activeAt ?? updateData.createdAt, - metadata: machine?.metadata ?? null, - metadataVersion: machine?.metadataVersion ?? 0, - daemonState: machine?.daemonState ?? null, - daemonStateVersion: machine?.daemonStateVersion ?? 0 - }; - - // Get machine-specific encryption (might not exist if machine wasn't initialized) - const machineEncryption = this.encryption.getMachineEncryption(machineId); - if (!machineEncryption) { - console.error(`Machine encryption not found for ${machineId} - cannot decrypt updates`); - return; - } - - // If metadata is provided, decrypt and update it - const metadataUpdate = machineUpdate.metadata; - if (metadataUpdate) { - try { - const metadata = await machineEncryption.decryptMetadata(metadataUpdate.version, metadataUpdate.value); - updatedMachine.metadata = metadata; - updatedMachine.metadataVersion = metadataUpdate.version; - } catch (error) { - console.error(`Failed to decrypt machine metadata for ${machineId}:`, error); - } - } - - // If daemonState is provided, decrypt and update it - const daemonStateUpdate = machineUpdate.daemonState; - if (daemonStateUpdate) { - try { - const daemonState = await machineEncryption.decryptDaemonState(daemonStateUpdate.version, daemonStateUpdate.value); - updatedMachine.daemonState = daemonState; - updatedMachine.daemonStateVersion = daemonStateUpdate.version; - } catch (error) { - console.error(`Failed to decrypt machine daemonState for ${machineId}:`, error); - } - } - - // Update storage using applyMachines which rebuilds sessionListViewData - storage.getState().applyMachines([updatedMachine]); - } else if (updateData.body.t === 'relationship-updated') { - log.log('👥 Received relationship-updated update'); - const relationshipUpdate = updateData.body; - - // Apply the relationship update to storage - storage.getState().applyRelationshipUpdate({ - fromUserId: relationshipUpdate.fromUserId, - toUserId: relationshipUpdate.toUserId, - status: relationshipUpdate.status, - action: relationshipUpdate.action, - fromUser: relationshipUpdate.fromUser, - toUser: relationshipUpdate.toUser, - timestamp: relationshipUpdate.timestamp - }); - - // Invalidate friends data to refresh with latest changes - this.friendsSync.invalidate(); - this.friendRequestsSync.invalidate(); - this.feedSync.invalidate(); - } else if (updateData.body.t === 'new-artifact') { - log.log('📦 Received new-artifact update'); - const artifactUpdate = updateData.body; - const artifactId = artifactUpdate.artifactId; - - try { - // Decrypt the data encryption key - const decryptedKey = await this.encryption.decryptEncryptionKey(artifactUpdate.dataEncryptionKey); - if (!decryptedKey) { - console.error(`Failed to decrypt key for new artifact ${artifactId}`); - return; - } - - // Store the decrypted key in memory - this.artifactDataKeys.set(artifactId, decryptedKey); - - // Create artifact encryption instance - const artifactEncryption = new ArtifactEncryption(decryptedKey); - - // Decrypt header - const header = await artifactEncryption.decryptHeader(artifactUpdate.header); - - // Decrypt body if provided - let decryptedBody: string | null | undefined = undefined; - if (artifactUpdate.body && artifactUpdate.bodyVersion !== undefined) { - const body = await artifactEncryption.decryptBody(artifactUpdate.body); - decryptedBody = body?.body || null; - } - - // Add to storage - const decryptedArtifact: DecryptedArtifact = { - id: artifactId, - title: header?.title || null, - body: decryptedBody, - headerVersion: artifactUpdate.headerVersion, - bodyVersion: artifactUpdate.bodyVersion, - seq: artifactUpdate.seq, - createdAt: artifactUpdate.createdAt, - updatedAt: artifactUpdate.updatedAt, - isDecrypted: !!header, - }; - - storage.getState().addArtifact(decryptedArtifact); - log.log(`📦 Added new artifact ${artifactId} to storage`); - } catch (error) { - console.error(`Failed to process new artifact ${artifactId}:`, error); - } - } else if (updateData.body.t === 'update-artifact') { - log.log('📦 Received update-artifact update'); - const artifactUpdate = updateData.body; - const artifactId = artifactUpdate.artifactId; - - // Get existing artifact - const existingArtifact = storage.getState().artifacts[artifactId]; - if (!existingArtifact) { - console.error(`Artifact ${artifactId} not found in storage`); - // Fetch all artifacts to sync - this.artifactsSync.invalidate(); - return; - } - - try { - // Get the data encryption key from memory - let dataEncryptionKey = this.artifactDataKeys.get(artifactId); - if (!dataEncryptionKey) { - console.error(`Encryption key not found for artifact ${artifactId}, fetching artifacts`); - this.artifactsSync.invalidate(); - return; - } - - // Create artifact encryption instance - const artifactEncryption = new ArtifactEncryption(dataEncryptionKey); - - // Update artifact with new data - const updatedArtifact: DecryptedArtifact = { - ...existingArtifact, - seq: updateData.seq, - updatedAt: updateData.createdAt, - }; - - // Decrypt and update header if provided - if (artifactUpdate.header) { - const header = await artifactEncryption.decryptHeader(artifactUpdate.header.value); - updatedArtifact.title = header?.title || null; - updatedArtifact.sessions = header?.sessions; - updatedArtifact.draft = header?.draft; - updatedArtifact.headerVersion = artifactUpdate.header.version; - } - - // Decrypt and update body if provided - if (artifactUpdate.body) { - const body = await artifactEncryption.decryptBody(artifactUpdate.body.value); - updatedArtifact.body = body?.body || null; - updatedArtifact.bodyVersion = artifactUpdate.body.version; - } - - storage.getState().updateArtifact(updatedArtifact); - log.log(`📦 Updated artifact ${artifactId} in storage`); - } catch (error) { - console.error(`Failed to process artifact update ${artifactId}:`, error); - } - } else if (updateData.body.t === 'delete-artifact') { - log.log('📦 Received delete-artifact update'); - const artifactUpdate = updateData.body; - const artifactId = artifactUpdate.artifactId; - - // Remove from storage - storage.getState().deleteArtifact(artifactId); - - // Remove encryption key from memory - this.artifactDataKeys.delete(artifactId); - } else if (updateData.body.t === 'new-feed-post') { - log.log('📰 Received new-feed-post update'); - const feedUpdate = updateData.body; - - // Convert to FeedItem with counter from cursor - const feedItem: FeedItem = { - id: feedUpdate.id, - body: feedUpdate.body, - cursor: feedUpdate.cursor, - createdAt: feedUpdate.createdAt, - repeatKey: feedUpdate.repeatKey, - counter: parseInt(feedUpdate.cursor.substring(2), 10) - }; - - // Check if we need to fetch user for friend-related items - if (feedItem.body && (feedItem.body.kind === 'friend_request' || feedItem.body.kind === 'friend_accepted')) { - await this.assumeUsers([feedItem.body.uid]); - - // Check if user fetch failed (404) - don't store item if user not found - const users = storage.getState().users; - const userProfile = users[feedItem.body.uid]; - if (userProfile === null || userProfile === undefined) { - // User was not found or 404, don't store this item - log.log(`📰 Skipping feed item ${feedItem.id} - user ${feedItem.body.uid} not found`); - return; - } - } - - // Apply to storage (will handle repeatKey replacement) - storage.getState().applyFeedItems([feedItem]); - } else if (updateData.body.t === 'kv-batch-update') { - log.log('📝 Received kv-batch-update'); - const kvUpdate = updateData.body; - - // Process KV changes for todos - if (kvUpdate.changes && Array.isArray(kvUpdate.changes)) { - const todoChanges = kvUpdate.changes.filter(change => - change.key && change.key.startsWith('todo.') - ); - - if (todoChanges.length > 0) { - log.log(`📝 Processing ${todoChanges.length} todo KV changes from socket`); - - // Apply the changes directly to avoid unnecessary refetch - try { - await this.applyTodoSocketUpdates(todoChanges); - } catch (error) { - console.error('Failed to apply todo socket updates:', error); - // Fallback to refetch on error - this.todosSync.invalidate(); - } - } - } - } + await handleSocketUpdate({ + update, + encryption: this.encryption, + artifactDataKeys: this.artifactDataKeys, + applySessions: (sessions) => this.applySessions(sessions), + fetchSessions: () => { + void this.fetchSessions(); + }, + applyMessages: (sessionId, messages) => this.applyMessages(sessionId, messages), + onSessionVisible: (sessionId) => this.onSessionVisible(sessionId), + assumeUsers: (userIds) => this.assumeUsers(userIds), + applyTodoSocketUpdates: (changes) => this.applyTodoSocketUpdates(changes), + invalidateSessions: () => this.sessionsSync.invalidate(), + invalidateArtifacts: () => this.artifactsSync.invalidate(), + invalidateFriends: () => this.friendsSync.invalidate(), + invalidateFriendRequests: () => this.friendRequestsSync.invalidate(), + invalidateFeed: () => this.feedSync.invalidate(), + invalidateTodos: () => this.todosSync.invalidate(), + log, + }); } private flushActivityUpdates = (updates: Map<string, ApiEphemeralActivityUpdate>) => { - // log.log(`🔄 Flushing activity updates for ${updates.size} sessions - acquiring lock`); - - - const sessions: Session[] = []; - - for (const [sessionId, update] of updates) { - const session = storage.getState().sessions[sessionId]; - if (session) { - sessions.push({ - ...session, - active: update.active, - activeAt: update.activeAt, - thinking: update.thinking ?? false, - thinkingAt: update.activeAt // Always use activeAt for consistency - }); - } - } - - if (sessions.length > 0) { - // console.log('flushing activity updates ' + sessions.length); - this.applySessions(sessions); - // log.log(`🔄 Activity updates flushed - updated ${sessions.length} sessions`); - } + flushActivityUpdatesEngine({ updates, applySessions: (sessions) => this.applySessions(sessions) }); } private handleEphemeralUpdate = (update: unknown) => { - const validatedUpdate = ApiEphemeralUpdateSchema.safeParse(update); - if (!validatedUpdate.success) { - console.log('Invalid ephemeral update received:', validatedUpdate.error); - console.error('Invalid ephemeral update received:', update); - return; - } else { - // console.log('Ephemeral update received:', update); - } - const updateData = validatedUpdate.data; - - // Process activity updates through smart debounce accumulator - if (updateData.type === 'activity') { - // console.log('adding activity update ' + updateData.id); - this.activityAccumulator.addUpdate(updateData); - } - - // Handle machine activity updates - if (updateData.type === 'machine-activity') { - // Update machine's active status and lastActiveAt - const machine = storage.getState().machines[updateData.id]; - if (machine) { - const updatedMachine: Machine = { - ...machine, - active: updateData.active, - activeAt: updateData.activeAt - }; - storage.getState().applyMachines([updatedMachine]); - } - } - - // daemon-status ephemeral updates are deprecated, machine status is handled via machine-activity + handleEphemeralSocketUpdate({ + update, + addActivityUpdate: (ephemeralUpdate) => { + this.activityAccumulator.addUpdate(ephemeralUpdate); + }, + }); } // @@ -2133,6 +1195,23 @@ async function syncInit(credentials: AuthCredentials, restore: boolean) { apiSocket.onStatusChange((status) => { storage.getState().setSocketStatus(status); }); + apiSocket.onError((error) => { + if (!error) { + storage.getState().setSocketError(null); + return; + } + const msg = error.message || 'Connection error'; + storage.getState().setSocketError(msg); + + // Prefer explicit status if provided by the socket error (depends on server implementation). + const status = (error as any)?.data?.status; + const statusNum = typeof status === 'number' ? status : null; + const kind: 'auth' | 'config' | 'network' | 'server' | 'unknown' = + statusNum === 401 || statusNum === 403 ? 'auth' : 'unknown'; + const retryable = kind !== 'auth'; + + storage.getState().setSyncError({ message: msg, retryable, kind, at: Date.now() }); + }); // Initialize sessions engine if (restore) { diff --git a/expo-app/sources/sync/terminalSettings.spec.ts b/expo-app/sources/sync/terminalSettings.spec.ts new file mode 100644 index 000000000..9d68309a5 --- /dev/null +++ b/expo-app/sources/sync/terminalSettings.spec.ts @@ -0,0 +1,74 @@ +import { describe, it, expect } from 'vitest'; + +import { settingsDefaults } from './settings'; +import { resolveTerminalSpawnOptions } from './terminalSettings'; + +describe('resolveTerminalSpawnOptions', () => { + it('returns null when tmux is disabled', () => { + const settings: any = { + ...settingsDefaults, + sessionUseTmux: false, + }; + expect(resolveTerminalSpawnOptions({ settings, machineId: 'm1' })).toBeNull(); + }); + + it('returns tmux spawn options when enabled', () => { + const settings: any = { + ...settingsDefaults, + sessionUseTmux: true, + sessionTmuxSessionName: 'happy', + sessionTmuxIsolated: true, + sessionTmuxTmpDir: null, + sessionTmuxByMachineId: {}, + }; + + expect(resolveTerminalSpawnOptions({ settings, machineId: 'm1' })).toEqual({ + mode: 'tmux', + tmux: { + sessionName: 'happy', + isolated: true, + tmpDir: null, + }, + }); + }); + + it('allows blank session name to use current/most recent tmux session', () => { + const settings: any = { + ...settingsDefaults, + sessionUseTmux: true, + sessionTmuxSessionName: ' ', + sessionTmuxIsolated: true, + sessionTmuxTmpDir: null, + sessionTmuxByMachineId: {}, + }; + + expect(resolveTerminalSpawnOptions({ settings, machineId: 'm1' })?.tmux?.sessionName).toBe(''); + }); + + it('supports per-machine overrides when enabled', () => { + const settings: any = { + ...settingsDefaults, + sessionUseTmux: true, + sessionTmuxSessionName: 'happy', + sessionTmuxIsolated: true, + sessionTmuxTmpDir: null, + sessionTmuxByMachineId: { + m1: { + useTmux: true, + sessionName: 'dev', + isolated: false, + tmpDir: '/tmp/tmux', + }, + }, + }; + + expect(resolveTerminalSpawnOptions({ settings, machineId: 'm1' })).toEqual({ + mode: 'tmux', + tmux: { + sessionName: 'dev', + isolated: false, + tmpDir: '/tmp/tmux', + }, + }); + }); +}); diff --git a/expo-app/sources/sync/terminalSettings.ts b/expo-app/sources/sync/terminalSettings.ts new file mode 100644 index 000000000..b61877e1f --- /dev/null +++ b/expo-app/sources/sync/terminalSettings.ts @@ -0,0 +1,53 @@ +import type { Settings } from './settings'; + +export type TerminalSpawnOptions = { + mode: 'tmux'; + tmux: { + sessionName: string; + isolated: boolean; + tmpDir: string | null; + }; +}; + +function normalizeTmuxSessionName(value: unknown): string | null { + if (typeof value !== 'string') return null; + return value.trim(); +} + +function normalizeOptionalString(value: unknown): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +export function resolveTerminalSpawnOptions(params: { + settings: Settings; + machineId: string | null; +}): TerminalSpawnOptions | null { + const { settings, machineId } = params; + + const override = machineId ? settings.sessionTmuxByMachineId?.[machineId] : undefined; + + const useTmux = override ? override.useTmux : settings.sessionUseTmux; + if (!useTmux) return null; + + // NOTE: empty string means "use current/most recent tmux session". + const sessionName = (override ? normalizeTmuxSessionName(override.sessionName) : null) + ?? normalizeTmuxSessionName(settings.sessionTmuxSessionName) + ?? 'happy'; + + const isolated = override ? override.isolated : settings.sessionTmuxIsolated; + + const tmpDir = (override ? normalizeOptionalString(override.tmpDir) : null) + ?? normalizeOptionalString(settings.sessionTmuxTmpDir) + ?? null; + + return { + mode: 'tmux', + tmux: { + sessionName, + isolated, + tmpDir, + }, + }; +} diff --git a/expo-app/sources/sync/time.ts b/expo-app/sources/sync/time.ts new file mode 100644 index 000000000..e9a146bb0 --- /dev/null +++ b/expo-app/sources/sync/time.ts @@ -0,0 +1,17 @@ +let serverTimeOffsetMs = 0; + +export function observeServerTimestamp(serverTimestampMs: number | null | undefined) { + if (typeof serverTimestampMs !== 'number' || !Number.isFinite(serverTimestampMs)) { + return; + } + serverTimeOffsetMs = serverTimestampMs - Date.now(); +} + +/** + * Best-effort server-aligned "now" for clock-safe ordering across devices. + * Falls back to Date.now() until we observe at least one server timestamp. + */ +export function nowServerMs(): number { + return Date.now() + serverTimeOffsetMs; +} + diff --git a/expo-app/sources/sync/typesMessage.ts b/expo-app/sources/sync/typesMessage.ts index d7bd2d8ad..e474ff684 100644 --- a/expo-app/sources/sync/typesMessage.ts +++ b/expo-app/sources/sync/typesMessage.ts @@ -16,7 +16,7 @@ export type ToolCall = { reason?: string; mode?: string; allowedTools?: string[]; - decision?: 'approved' | 'approved_for_session' | 'denied' | 'abort'; + decision?: 'approved' | 'approved_for_session' | 'approved_execpolicy_amendment' | 'denied' | 'abort'; date?: number; }; } @@ -60,4 +60,4 @@ export type ToolCallMessage = { meta?: MessageMeta; } -export type Message = UserTextMessage | AgentTextMessage | ToolCallMessage | ModeSwitchMessage; \ No newline at end of file +export type Message = UserTextMessage | AgentTextMessage | ToolCallMessage | ModeSwitchMessage; diff --git a/expo-app/sources/sync/typesMessageMeta.ts b/expo-app/sources/sync/typesMessageMeta.ts index cbfd4f29a..f8d697993 100644 --- a/expo-app/sources/sync/typesMessageMeta.ts +++ b/expo-app/sources/sync/typesMessageMeta.ts @@ -1,9 +1,10 @@ import { z } from 'zod'; +import { PERMISSION_MODES } from '@/constants/PermissionModes'; // Shared message metadata schema export const MessageMetaSchema = z.object({ sentFrom: z.string().optional(), // Source identifier - permissionMode: z.enum(['default', 'acceptEdits', 'bypassPermissions', 'plan', 'read-only', 'safe-yolo', 'yolo']).optional(), // Permission mode for this message + permissionMode: z.enum(PERMISSION_MODES).optional(), // Permission mode for this message model: z.string().nullable().optional(), // Model name for this message (null = reset) fallbackModel: z.string().nullable().optional(), // Fallback model for this message (null = reset) customSystemPrompt: z.string().nullable().optional(), // Custom system prompt for this message (null = reset) diff --git a/expo-app/sources/sync/typesRaw.spec.ts b/expo-app/sources/sync/typesRaw.spec.ts index 29178a25d..c46d4b1dc 100644 --- a/expo-app/sources/sync/typesRaw.spec.ts +++ b/expo-app/sources/sync/typesRaw.spec.ts @@ -470,6 +470,32 @@ describe('Zod Transform - WOLOG Content Normalization', () => { } } }); + + it('accepts Codex token_count messages via codex schema path (so they are not dropped)', () => { + const codexMessage = { + role: 'agent', + content: { + type: 'codex', + data: { + type: 'token_count', + input_tokens: 1, + output_tokens: 2, + total_tokens: 3, + id: 'codex-id-3', + }, + }, + }; + + const result = RawRecordSchema.safeParse(codexMessage); + + expect(result.success).toBe(true); + if (result.success) { + const content = result.data.content; + if (content.type === 'codex') { + expect(content.data.type).toBe('token_count'); + } + } + }); }); describe('Handles unexpected data formats gracefully', () => { @@ -703,6 +729,34 @@ describe('Zod Transform - WOLOG Content Normalization', () => { } }); + it('accepts usage.service_tier null (does not drop message)', () => { + const message = { + role: 'agent', + content: { + type: 'output', + data: { + type: 'assistant', + message: { + role: 'assistant', + model: 'claude-sonnet-4-5-20250929', + content: [{ type: 'text', text: 'Hello' }], + usage: { + input_tokens: 1, + output_tokens: 1, + service_tier: null, + }, + }, + uuid: 'real-assistant-uuid', + parentUuid: null, + }, + }, + meta: { sentFrom: 'cli' }, + }; + + const result = RawRecordSchema.safeParse(message); + expect(result.success).toBe(true); + }); + it('handles real user message with tool_result', () => { const realMessage = { role: 'agent', @@ -1489,4 +1543,193 @@ describe('Zod Transform - WOLOG Content Normalization', () => { } }); }); + + describe('ACP tool call normalization', () => { + it('parses ACP tool-call input when input is a JSON string', () => { + const raw = { + role: 'agent' as const, + content: { + type: 'acp' as const, + provider: 'codex' as const, + data: { + type: 'tool-call' as const, + callId: 'call_1', + name: 'execute', + input: JSON.stringify({ command: ['/bin/zsh', '-lc', 'echo hi'], cwd: '/tmp' }), + id: 'acp-msg-tool-call', + }, + }, + }; + + const normalized = normalizeRawMessage('msg-acp-tool-call', null, Date.now(), raw); + expect(normalized?.role).toBe('agent'); + if (normalized && normalized.role === 'agent') { + const item = normalized.content[0]; + expect(item.type).toBe('tool-call'); + if (item.type === 'tool-call') { + expect(item.name).toBe('execute'); + expect(item.input).toEqual({ command: ['/bin/zsh', '-lc', 'echo hi'], cwd: '/tmp' }); + } + } + }); + }); + + describe('ACP tool result normalization', () => { + it('preserves ACP tool-result output arrays for rich renderers', () => { + const raw = { + role: 'agent' as const, + content: { + type: 'acp' as const, + provider: 'gemini' as const, + data: { + type: 'tool-result' as const, + callId: 'call_abc123', + output: [{ type: 'text', text: 'hello' }], + id: 'acp-msg-1', + }, + }, + }; + + const normalized = normalizeRawMessage('msg-1', null, Date.now(), raw); + expect(normalized?.role).toBe('agent'); + if (normalized && normalized.role === 'agent') { + const item = normalized.content[0]; + expect(item.type).toBe('tool-result'); + if (item.type === 'tool-result') { + expect(item.content).toEqual([{ type: 'text', text: 'hello' }]); + } + } + }); + + it('parses ACP tool-result output when output is a JSON string', () => { + const raw = { + role: 'agent' as const, + content: { + type: 'acp' as const, + provider: 'codex' as const, + data: { + type: 'tool-result' as const, + callId: 'call_1', + output: JSON.stringify({ stdout: 'hi\n', stderr: '' }), + id: 'acp-msg-tool-result', + }, + }, + }; + + const normalized = normalizeRawMessage('msg-acp-tool-result', null, Date.now(), raw); + expect(normalized?.role).toBe('agent'); + if (normalized && normalized.role === 'agent') { + const item = normalized.content[0]; + expect(item.type).toBe('tool-result'); + if (item.type === 'tool-result') { + expect(item.tool_use_id).toBe('call_1'); + expect(item.content).toEqual({ stdout: 'hi\n', stderr: '' }); + } + } + }); + + it('preserves ACP tool-call-result output arrays for rich renderers', () => { + const raw = { + role: 'agent' as const, + content: { + type: 'acp' as const, + provider: 'gemini' as const, + data: { + type: 'tool-call-result' as const, + callId: 'call_abc123', + output: [{ type: 'text', text: 'hello' }], + id: 'acp-msg-2', + }, + }, + }; + + const normalized = normalizeRawMessage('msg-2', null, Date.now(), raw); + expect(normalized?.role).toBe('agent'); + if (normalized && normalized.role === 'agent') { + const item = normalized.content[0]; + expect(item.type).toBe('tool-result'); + if (item.type === 'tool-result') { + expect(item.content).toEqual([{ type: 'text', text: 'hello' }]); + } + } + }); + + it('normalizes ACP tool-result string output to text', () => { + const raw = { + role: 'agent' as const, + content: { + type: 'acp' as const, + provider: 'gemini' as const, + data: { + type: 'tool-result' as const, + callId: 'call_abc123', + output: 'direct string', + id: 'acp-msg-3', + }, + }, + }; + + const normalized = normalizeRawMessage('msg-3', null, Date.now(), raw); + expect(normalized?.role).toBe('agent'); + if (normalized && normalized.role === 'agent') { + const item = normalized.content[0]; + expect(item.type).toBe('tool-result'); + if (item.type === 'tool-result') { + expect(item.content).toBe('direct string'); + } + } + }); + + it('preserves ACP tool-result object output for rich renderers', () => { + const raw = { + role: 'agent' as const, + content: { + type: 'acp' as const, + provider: 'gemini' as const, + data: { + type: 'tool-result' as const, + callId: 'call_abc123', + output: { key: 'value' }, + id: 'acp-msg-4', + }, + }, + }; + + const normalized = normalizeRawMessage('msg-4', null, Date.now(), raw); + expect(normalized?.role).toBe('agent'); + if (normalized && normalized.role === 'agent') { + const item = normalized.content[0]; + expect(item.type).toBe('tool-result'); + if (item.type === 'tool-result') { + expect(item.content).toEqual({ key: 'value' }); + } + } + }); + + it('preserves ACP tool-result null output', () => { + const raw = { + role: 'agent' as const, + content: { + type: 'acp' as const, + provider: 'gemini' as const, + data: { + type: 'tool-result' as const, + callId: 'call_abc123', + output: null, + id: 'acp-msg-5', + }, + }, + }; + + const normalized = normalizeRawMessage('msg-5', null, Date.now(), raw); + expect(normalized?.role).toBe('agent'); + if (normalized && normalized.role === 'agent') { + const item = normalized.content[0]; + expect(item.type).toBe('tool-result'); + if (item.type === 'tool-result') { + expect(item.content).toBeNull(); + } + } + }); + }); }); diff --git a/expo-app/sources/sync/typesRaw.ts b/expo-app/sources/sync/typesRaw.ts index aa7b2ed82..2f8e30ee5 100644 --- a/expo-app/sources/sync/typesRaw.ts +++ b/expo-app/sources/sync/typesRaw.ts @@ -1,818 +1,2 @@ -import * as z from 'zod'; -import { MessageMetaSchema, MessageMeta } from './typesMessageMeta'; - -// -// Raw types -// - -// Usage data type from Claude API -const usageDataSchema = z.object({ - input_tokens: z.number(), - cache_creation_input_tokens: z.number().optional(), - cache_read_input_tokens: z.number().optional(), - output_tokens: z.number(), - service_tier: z.string().optional(), -}); - -export type UsageData = z.infer<typeof usageDataSchema>; - -const agentEventSchema = z.discriminatedUnion('type', [z.object({ - type: z.literal('switch'), - mode: z.enum(['local', 'remote']) -}), z.object({ - type: z.literal('message'), - message: z.string(), -}), z.object({ - type: z.literal('limit-reached'), - endsAt: z.number(), -}), z.object({ - type: z.literal('ready'), -})]); -export type AgentEvent = z.infer<typeof agentEventSchema>; - -const rawTextContentSchema = z.object({ - type: z.literal('text'), - text: z.string(), -}).passthrough(); // ROBUST: Accept unknown fields for future API compatibility -export type RawTextContent = z.infer<typeof rawTextContentSchema>; - -const rawToolUseContentSchema = z.object({ - type: z.literal('tool_use'), - id: z.string(), - name: z.string(), - input: z.any(), -}).passthrough(); // ROBUST: Accept unknown fields preserved by transform -export type RawToolUseContent = z.infer<typeof rawToolUseContentSchema>; - -const rawToolResultContentSchema = z.object({ - type: z.literal('tool_result'), - tool_use_id: z.string(), - content: z.union([z.array(z.object({ type: z.literal('text'), text: z.string() })), z.string()]), - is_error: z.boolean().optional(), - permissions: z.object({ - date: z.number(), - result: z.enum(['approved', 'denied']), - mode: z.enum(['default', 'acceptEdits', 'bypassPermissions', 'plan', 'read-only', 'safe-yolo', 'yolo']).optional(), - allowedTools: z.array(z.string()).optional(), - decision: z.enum(['approved', 'approved_for_session', 'denied', 'abort']).optional(), - }).optional(), -}).passthrough(); // ROBUST: Accept unknown fields for future API compatibility -export type RawToolResultContent = z.infer<typeof rawToolResultContentSchema>; - -/** - * Extended thinking content from Claude API - * Contains model's reasoning process before generating the final response - * Uses .passthrough() to preserve signature and other unknown fields - */ -const rawThinkingContentSchema = z.object({ - type: z.literal('thinking'), - thinking: z.string(), -}).passthrough(); // ROBUST: Accept signature and future fields -export type RawThinkingContent = z.infer<typeof rawThinkingContentSchema>; - -// ============================================================================ -// WOLOG: Type-Safe Content Normalization via Zod Transform -// ============================================================================ -// Accepts both hyphenated (Codex/Gemini) and underscore (Claude) formats -// Transforms all to canonical underscore format during validation -// Full type safety - no `unknown` types -// Source: Part D of the Expo Mobile Testing & Package Manager Agnostic System plan -// ============================================================================ - -/** - * Hyphenated tool-call format from Codex/Gemini agents - * Transforms to canonical tool_use format during validation - * Uses .passthrough() to preserve unknown fields for future API compatibility - */ -const rawHyphenatedToolCallSchema = z.object({ - type: z.literal('tool-call'), - callId: z.string(), - id: z.string().optional(), // Some messages have both - name: z.string(), - input: z.any(), -}).passthrough(); // ROBUST: Accept and preserve unknown fields -type RawHyphenatedToolCall = z.infer<typeof rawHyphenatedToolCallSchema>; - -/** - * Hyphenated tool-call-result format from Codex/Gemini agents - * Transforms to canonical tool_result format during validation - * Uses .passthrough() to preserve unknown fields for future API compatibility - */ -const rawHyphenatedToolResultSchema = z.object({ - type: z.literal('tool-call-result'), - callId: z.string(), - tool_use_id: z.string().optional(), // Some messages have both - output: z.any(), - content: z.any().optional(), // Some messages have both - is_error: z.boolean().optional(), -}).passthrough(); // ROBUST: Accept and preserve unknown fields -type RawHyphenatedToolResult = z.infer<typeof rawHyphenatedToolResultSchema>; - -/** - * Input schema accepting ALL formats (both hyphenated and canonical) - * Including Claude's extended thinking content type - */ -const rawAgentContentInputSchema = z.discriminatedUnion('type', [ - rawTextContentSchema, // type: 'text' (canonical) - rawToolUseContentSchema, // type: 'tool_use' (canonical) - rawToolResultContentSchema, // type: 'tool_result' (canonical) - rawThinkingContentSchema, // type: 'thinking' (canonical) - rawHyphenatedToolCallSchema, // type: 'tool-call' (hyphenated) - rawHyphenatedToolResultSchema, // type: 'tool-call-result' (hyphenated) -]); -type RawAgentContentInput = z.infer<typeof rawAgentContentInputSchema>; - -/** - * Type-safe transform: Hyphenated tool-call → Canonical tool_use - * ROBUST: Unknown fields preserved via object spread and .passthrough() - */ -function normalizeToToolUse(input: RawHyphenatedToolCall) { - // Spread preserves all fields from input (passthrough fields included) - return { - ...input, - type: 'tool_use' as const, - id: input.callId, // Codex uses callId, canonical uses id - }; -} - -/** - * Type-safe transform: Hyphenated tool-call-result → Canonical tool_result - * ROBUST: Unknown fields preserved via object spread and .passthrough() - */ -function normalizeToToolResult(input: RawHyphenatedToolResult) { - // Spread preserves all fields from input (passthrough fields included) - return { - ...input, - type: 'tool_result' as const, - tool_use_id: input.callId, // Codex uses callId, canonical uses tool_use_id - content: input.output ?? input.content ?? '', // Codex uses output, canonical uses content - is_error: input.is_error ?? false, - }; -} - -/** - * Schema that accepts both hyphenated and canonical formats. - * Normalization happens via .preprocess() at root level to avoid Zod v4 "unmergable intersection" issue. - * See: https://github.com/colinhacks/zod/discussions/2100 - * - * Accepts: 'text' | 'tool_use' | 'tool_result' | 'thinking' | 'tool-call' | 'tool-call-result' - * All types validated by their respective schemas with .passthrough() for unknown fields - */ -const rawAgentContentSchema = z.union([ - rawTextContentSchema, - rawToolUseContentSchema, - rawToolResultContentSchema, - rawThinkingContentSchema, - rawHyphenatedToolCallSchema, - rawHyphenatedToolResultSchema, -]); -export type RawAgentContent = z.infer<typeof rawAgentContentSchema>; - -const rawAgentRecordSchema = z.discriminatedUnion('type', [z.object({ - type: z.literal('output'), - data: z.intersection(z.discriminatedUnion('type', [ - z.object({ type: z.literal('system') }), - z.object({ type: z.literal('result') }), - z.object({ type: z.literal('summary'), summary: z.string() }), - z.object({ type: z.literal('assistant'), message: z.object({ role: z.literal('assistant'), model: z.string(), content: z.array(rawAgentContentSchema), usage: usageDataSchema.optional() }), parent_tool_use_id: z.string().nullable().optional() }), - z.object({ type: z.literal('user'), message: z.object({ role: z.literal('user'), content: z.union([z.string(), z.array(rawAgentContentSchema)]) }), parent_tool_use_id: z.string().nullable().optional(), toolUseResult: z.any().nullable().optional() }), - ]), z.object({ - isSidechain: z.boolean().nullish(), - isCompactSummary: z.boolean().nullish(), - isMeta: z.boolean().nullish(), - uuid: z.string().nullish(), - parentUuid: z.string().nullish(), - }).passthrough()), // ROBUST: Accept CLI metadata fields (userType, cwd, sessionId, version, gitBranch, slug, requestId, timestamp) -}), z.object({ - type: z.literal('event'), - id: z.string(), - data: agentEventSchema -}), z.object({ - type: z.literal('codex'), - data: z.discriminatedUnion('type', [ - z.object({ type: z.literal('reasoning'), message: z.string() }), - z.object({ type: z.literal('message'), message: z.string() }), - z.object({ - type: z.literal('tool-call'), - callId: z.string(), - input: z.any(), - name: z.string(), - id: z.string() - }), - z.object({ - type: z.literal('tool-call-result'), - callId: z.string(), - output: z.any(), - id: z.string() - }) - ]) -}), z.object({ - // ACP (Agent Communication Protocol) - unified format for all agent providers - type: z.literal('acp'), - provider: z.enum(['gemini', 'codex', 'claude', 'opencode']), - data: z.discriminatedUnion('type', [ - // Core message types - z.object({ type: z.literal('reasoning'), message: z.string() }), - z.object({ type: z.literal('message'), message: z.string() }), - z.object({ type: z.literal('thinking'), text: z.string() }), - // Tool interactions - z.object({ - type: z.literal('tool-call'), - callId: z.string(), - input: z.any(), - name: z.string(), - id: z.string() - }), - z.object({ - type: z.literal('tool-result'), - callId: z.string(), - output: z.any(), - id: z.string(), - isError: z.boolean().optional() - }), - // Hyphenated tool-call-result (for backwards compatibility with CLI) - z.object({ - type: z.literal('tool-call-result'), - callId: z.string(), - output: z.any(), - id: z.string() - }), - // File operations - z.object({ - type: z.literal('file-edit'), - description: z.string(), - filePath: z.string(), - diff: z.string().optional(), - oldContent: z.string().optional(), - newContent: z.string().optional(), - id: z.string() - }), - // Terminal/command output - z.object({ - type: z.literal('terminal-output'), - data: z.string(), - callId: z.string() - }), - // Task lifecycle events - z.object({ type: z.literal('task_started'), id: z.string() }), - z.object({ type: z.literal('task_complete'), id: z.string() }), - z.object({ type: z.literal('turn_aborted'), id: z.string() }), - // Permissions - z.object({ - type: z.literal('permission-request'), - permissionId: z.string(), - toolName: z.string(), - description: z.string(), - options: z.any().optional() - }), - // Usage/metrics - z.object({ type: z.literal('token_count') }).passthrough() - ]) -})]); - -/** - * Preprocessor: Normalizes hyphenated content types to canonical before validation - * This avoids Zod v4's "unmergable intersection" issue with transforms inside complex schemas - * See: https://github.com/colinhacks/zod/discussions/2100 - */ -function preprocessMessageContent(data: any): any { - if (!data || typeof data !== 'object') return data; - - // Helper: normalize a single content item - const normalizeContent = (item: any): any => { - if (!item || typeof item !== 'object') return item; - - if (item.type === 'tool-call') { - return normalizeToToolUse(item); - } - if (item.type === 'tool-call-result') { - return normalizeToToolResult(item); - } - return item; - }; - - // Normalize assistant message content - if (data.role === 'agent' && data.content?.type === 'output' && data.content?.data?.message?.content) { - if (Array.isArray(data.content.data.message.content)) { - data.content.data.message.content = data.content.data.message.content.map(normalizeContent); - } - } - - // Normalize user message content - if (data.role === 'agent' && data.content?.type === 'output' && data.content?.data?.type === 'user' && Array.isArray(data.content.data.message?.content)) { - data.content.data.message.content = data.content.data.message.content.map(normalizeContent); - } - - return data; -} - -const rawRecordSchema = z.preprocess( - preprocessMessageContent, - z.discriminatedUnion('role', [ - z.object({ - role: z.literal('agent'), - content: rawAgentRecordSchema, - meta: MessageMetaSchema.optional() - }), - z.object({ - role: z.literal('user'), - content: z.object({ - type: z.literal('text'), - text: z.string() - }), - meta: MessageMetaSchema.optional() - }) - ]) -); - -export type RawRecord = z.infer<typeof rawRecordSchema>; - -// Export schemas for validation -export const RawRecordSchema = rawRecordSchema; - - -// -// Normalized types -// - -type NormalizedAgentContent = - { - type: 'text'; - text: string; - uuid: string; - parentUUID: string | null; - } | { - type: 'thinking'; - thinking: string; - uuid: string; - parentUUID: string | null; - } | { - type: 'tool-call'; - id: string; - name: string; - input: any; - description: string | null; - uuid: string; - parentUUID: string | null; - } | { - type: 'tool-result' - tool_use_id: string; - content: any; - is_error: boolean; - uuid: string; - parentUUID: string | null; - permissions?: { - date: number; - result: 'approved' | 'denied'; - mode?: string; - allowedTools?: string[]; - decision?: 'approved' | 'approved_for_session' | 'denied' | 'abort'; - }; - } | { - type: 'summary', - summary: string; - } | { - type: 'sidechain' - uuid: string; - prompt: string - }; - -export type NormalizedMessage = ({ - role: 'user' - content: { - type: 'text'; - text: string; - } -} | { - role: 'agent' - content: NormalizedAgentContent[] -} | { - role: 'event' - content: AgentEvent -}) & { - id: string, - localId: string | null, - createdAt: number, - isSidechain: boolean, - meta?: MessageMeta, - usage?: UsageData, -}; - -export function normalizeRawMessage(id: string, localId: string | null, createdAt: number, raw: RawRecord): NormalizedMessage | null { - // Zod transform handles normalization during validation - let parsed = rawRecordSchema.safeParse(raw); - if (!parsed.success) { - console.error('=== VALIDATION ERROR ==='); - console.error('Zod issues:', JSON.stringify(parsed.error.issues, null, 2)); - console.error('Raw message:', JSON.stringify(raw, null, 2)); - console.error('=== END ERROR ==='); - return null; - } - raw = parsed.data; - if (raw.role === 'user') { - return { - id, - localId, - createdAt, - role: 'user', - content: raw.content, - isSidechain: false, - meta: raw.meta, - }; - } - if (raw.role === 'agent') { - if (raw.content.type === 'output') { - - // Skip Meta messages - if (raw.content.data.isMeta) { - return null; - } - - // Skip compact summary messages - if (raw.content.data.isCompactSummary) { - return null; - } - - // Handle Assistant messages (including sidechains) - if (raw.content.data.type === 'assistant') { - if (!raw.content.data.uuid) { - return null; - } - let content: NormalizedAgentContent[] = []; - for (let c of raw.content.data.message.content) { - if (c.type === 'text') { - content.push({ - ...c, // WOLOG: Preserve all fields including unknown ones - uuid: raw.content.data.uuid, - parentUUID: raw.content.data.parentUuid ?? null - } as NormalizedAgentContent); - } else if (c.type === 'thinking') { - content.push({ - ...c, // WOLOG: Preserve all fields including unknown ones (signature, etc.) - uuid: raw.content.data.uuid, - parentUUID: raw.content.data.parentUuid ?? null - } as NormalizedAgentContent); - } else if (c.type === 'tool_use') { - let description: string | null = null; - if (typeof c.input === 'object' && c.input !== null && 'description' in c.input && typeof c.input.description === 'string') { - description = c.input.description; - } - content.push({ - ...c, // WOLOG: Preserve all fields including unknown ones - type: 'tool-call', - description, - uuid: raw.content.data.uuid, - parentUUID: raw.content.data.parentUuid ?? null - } as NormalizedAgentContent); - } - } - return { - id, - localId, - createdAt, - role: 'agent', - isSidechain: raw.content.data.isSidechain ?? false, - content, - meta: raw.meta, - usage: raw.content.data.message.usage - }; - } else if (raw.content.data.type === 'user') { - if (!raw.content.data.uuid) { - return null; - } - - // Handle sidechain user messages - if (raw.content.data.isSidechain && raw.content.data.message && typeof raw.content.data.message.content === 'string') { - // Return as a special agent message with sidechain content - return { - id, - localId, - createdAt, - role: 'agent', - isSidechain: true, - content: [{ - type: 'sidechain', - uuid: raw.content.data.uuid, - prompt: raw.content.data.message.content - }] - }; - } - - // Handle regular user messages - if (raw.content.data.message && typeof raw.content.data.message.content === 'string') { - return { - id, - localId, - createdAt, - role: 'user', - isSidechain: false, - content: { - type: 'text', - text: raw.content.data.message.content - } - }; - } - - // Handle tool results - let content: NormalizedAgentContent[] = []; - if (typeof raw.content.data.message.content === 'string') { - content.push({ - type: 'text', - text: raw.content.data.message.content, - uuid: raw.content.data.uuid, - parentUUID: raw.content.data.parentUuid ?? null - }); - } else { - for (let c of raw.content.data.message.content) { - if (c.type === 'tool_result') { - content.push({ - ...c, // WOLOG: Preserve all fields including unknown ones - type: 'tool-result', - content: raw.content.data.toolUseResult ? raw.content.data.toolUseResult : (typeof c.content === 'string' ? c.content : c.content[0].text), - is_error: c.is_error || false, - uuid: raw.content.data.uuid, - parentUUID: raw.content.data.parentUuid ?? null, - permissions: c.permissions ? { - date: c.permissions.date, - result: c.permissions.result, - mode: c.permissions.mode, - allowedTools: c.permissions.allowedTools, - decision: c.permissions.decision - } : undefined - } as NormalizedAgentContent); - } - } - } - return { - id, - localId, - createdAt, - role: 'agent', - isSidechain: raw.content.data.isSidechain ?? false, - content, - meta: raw.meta - }; - } - } - if (raw.content.type === 'event') { - return { - id, - localId, - createdAt, - role: 'event', - content: raw.content.data, - isSidechain: false, - }; - } - if (raw.content.type === 'codex') { - if (raw.content.data.type === 'message') { - // Cast codex messages to agent text messages - return { - id, - localId, - createdAt, - role: 'agent', - isSidechain: false, - content: [{ - type: 'text', - text: raw.content.data.message, - uuid: id, - parentUUID: null - }], - meta: raw.meta - }; - } - if (raw.content.data.type === 'reasoning') { - // Cast codex messages to agent text messages - return { - id, - localId, - createdAt, - role: 'agent', - isSidechain: false, - content: [{ - type: 'text', - text: raw.content.data.message, - uuid: id, - parentUUID: null - }], - meta: raw.meta - } satisfies NormalizedMessage; - } - if (raw.content.data.type === 'tool-call') { - // Cast tool calls to agent tool-call messages - return { - id, - localId, - createdAt, - role: 'agent', - isSidechain: false, - content: [{ - type: 'tool-call', - id: raw.content.data.callId, - name: raw.content.data.name || 'unknown', - input: raw.content.data.input, - description: null, - uuid: raw.content.data.id, - parentUUID: null - }], - meta: raw.meta - } satisfies NormalizedMessage; - } - if (raw.content.data.type === 'tool-call-result') { - // Cast tool call results to agent tool-result messages - return { - id, - localId, - createdAt, - role: 'agent', - isSidechain: false, - content: [{ - type: 'tool-result', - tool_use_id: raw.content.data.callId, - content: raw.content.data.output, - is_error: false, - uuid: raw.content.data.id, - parentUUID: null - }], - meta: raw.meta - } satisfies NormalizedMessage; - } - } - // ACP (Agent Communication Protocol) - unified format for all agent providers - if (raw.content.type === 'acp') { - if (raw.content.data.type === 'message') { - return { - id, - localId, - createdAt, - role: 'agent', - isSidechain: false, - content: [{ - type: 'text', - text: raw.content.data.message, - uuid: id, - parentUUID: null - }], - meta: raw.meta - } satisfies NormalizedMessage; - } - if (raw.content.data.type === 'reasoning') { - return { - id, - localId, - createdAt, - role: 'agent', - isSidechain: false, - content: [{ - type: 'text', - text: raw.content.data.message, - uuid: id, - parentUUID: null - }], - meta: raw.meta - } satisfies NormalizedMessage; - } - if (raw.content.data.type === 'tool-call') { - return { - id, - localId, - createdAt, - role: 'agent', - isSidechain: false, - content: [{ - type: 'tool-call', - id: raw.content.data.callId, - name: raw.content.data.name || 'unknown', - input: raw.content.data.input, - description: null, - uuid: raw.content.data.id, - parentUUID: null - }], - meta: raw.meta - } satisfies NormalizedMessage; - } - if (raw.content.data.type === 'tool-result') { - return { - id, - localId, - createdAt, - role: 'agent', - isSidechain: false, - content: [{ - type: 'tool-result', - tool_use_id: raw.content.data.callId, - content: raw.content.data.output, - is_error: raw.content.data.isError ?? false, - uuid: raw.content.data.id, - parentUUID: null - }], - meta: raw.meta - } satisfies NormalizedMessage; - } - // Handle hyphenated tool-call-result (backwards compatibility) - if (raw.content.data.type === 'tool-call-result') { - return { - id, - localId, - createdAt, - role: 'agent', - isSidechain: false, - content: [{ - type: 'tool-result', - tool_use_id: raw.content.data.callId, - content: raw.content.data.output, - is_error: false, - uuid: raw.content.data.id, - parentUUID: null - }], - meta: raw.meta - } satisfies NormalizedMessage; - } - if (raw.content.data.type === 'thinking') { - return { - id, - localId, - createdAt, - role: 'agent', - isSidechain: false, - content: [{ - type: 'thinking', - thinking: raw.content.data.text, - uuid: id, - parentUUID: null - }], - meta: raw.meta - } satisfies NormalizedMessage; - } - if (raw.content.data.type === 'file-edit') { - // Map file-edit to tool-call for UI rendering - return { - id, - localId, - createdAt, - role: 'agent', - isSidechain: false, - content: [{ - type: 'tool-call', - id: raw.content.data.id, - name: 'file-edit', - input: { - filePath: raw.content.data.filePath, - description: raw.content.data.description, - diff: raw.content.data.diff, - oldContent: raw.content.data.oldContent, - newContent: raw.content.data.newContent - }, - description: raw.content.data.description, - uuid: raw.content.data.id, - parentUUID: null - }], - meta: raw.meta - } satisfies NormalizedMessage; - } - if (raw.content.data.type === 'terminal-output') { - // Map terminal-output to tool-result - return { - id, - localId, - createdAt, - role: 'agent', - isSidechain: false, - content: [{ - type: 'tool-result', - tool_use_id: raw.content.data.callId, - content: raw.content.data.data, - is_error: false, - uuid: id, - parentUUID: null - }], - meta: raw.meta - } satisfies NormalizedMessage; - } - if (raw.content.data.type === 'permission-request') { - // Map permission-request to tool-call for UI to show permission dialog - return { - id, - localId, - createdAt, - role: 'agent', - isSidechain: false, - content: [{ - type: 'tool-call', - id: raw.content.data.permissionId, - name: raw.content.data.toolName, - input: raw.content.data.options ?? {}, - description: raw.content.data.description, - uuid: id, - parentUUID: null - }], - meta: raw.meta - } satisfies NormalizedMessage; - } - // Task lifecycle events (task_started, task_complete, turn_aborted) and token_count - // are status/metrics - skip normalization, they don't need UI rendering - } - } - return null; -} \ No newline at end of file +export * from './typesRaw/schemas'; +export * from './typesRaw/normalize'; diff --git a/expo-app/sources/sync/typesRaw/normalize.ts b/expo-app/sources/sync/typesRaw/normalize.ts new file mode 100644 index 000000000..14a9b8554 --- /dev/null +++ b/expo-app/sources/sync/typesRaw/normalize.ts @@ -0,0 +1,567 @@ +import type { MessageMeta } from '../typesMessageMeta'; +import { rawRecordSchema, type AgentEvent, type RawRecord, type UsageData } from './schemas'; + +// Normalized types +// + +type NormalizedAgentContent = + { + type: 'text'; + text: string; + uuid: string; + parentUUID: string | null; + } | { + type: 'thinking'; + thinking: string; + uuid: string; + parentUUID: string | null; + } | { + type: 'tool-call'; + id: string; + name: string; + input: any; + description: string | null; + uuid: string; + parentUUID: string | null; + } | { + type: 'tool-result' + tool_use_id: string; + content: any; + is_error: boolean; + uuid: string; + parentUUID: string | null; + permissions?: { + date: number; + result: 'approved' | 'denied'; + mode?: string; + allowedTools?: string[]; + decision?: 'approved' | 'approved_for_session' | 'approved_execpolicy_amendment' | 'denied' | 'abort'; + }; + } | { + type: 'summary', + summary: string; + } | { + type: 'sidechain' + uuid: string; + prompt: string + }; + +export type NormalizedMessage = ({ + role: 'user' + content: { + type: 'text'; + text: string; + } +} | { + role: 'agent' + content: NormalizedAgentContent[] +} | { + role: 'event' + content: AgentEvent +}) & { + id: string, + localId: string | null, + createdAt: number, + isSidechain: boolean, + meta?: MessageMeta, + usage?: UsageData, +}; + +export function normalizeRawMessage(id: string, localId: string | null, createdAt: number, raw: RawRecord): NormalizedMessage | null { + // Zod transform handles normalization during validation + let parsed = rawRecordSchema.safeParse(raw); + if (!parsed.success) { + // Never log full raw messages in production: tool outputs and user text may contain secrets. + // Keep enough context for debugging in dev builds only. + console.error(`[typesRaw] Message validation failed (id=${id})`); + if (__DEV__) { + const contentType = (raw as any)?.content?.type; + const dataType = (raw as any)?.content?.data?.type; + const provider = (raw as any)?.content?.provider; + const toolName = + contentType === 'codex' + ? (raw as any)?.content?.data?.name + : contentType === 'acp' + ? (raw as any)?.content?.data?.name + : null; + const callId = + contentType === 'codex' + ? (raw as any)?.content?.data?.callId + : contentType === 'acp' + ? (raw as any)?.content?.data?.callId + : null; + + console.error('Zod issues:', JSON.stringify(parsed.error.issues, null, 2)); + console.error('Raw summary:', { + role: raw?.role, + contentType, + dataType, + provider, + toolName: typeof toolName === 'string' ? toolName : undefined, + callId: typeof callId === 'string' ? callId : undefined, + }); + } + return null; + } + raw = parsed.data; + + const toolResultContentToText = (content: unknown): string => { + if (content === null || content === undefined) return ''; + if (typeof content === 'string') return content; + + // Claude sometimes sends tool_result.content as [{ type: 'text', text: '...' }] + if (Array.isArray(content)) { + const maybeTextBlocks = content as Array<{ type?: unknown; text?: unknown }>; + const isTextBlocks = maybeTextBlocks.every((b) => b && typeof b === 'object' && b.type === 'text' && typeof b.text === 'string'); + if (isTextBlocks) { + return maybeTextBlocks.map((b) => b.text as string).join(''); + } + + try { + return JSON.stringify(content); + } catch { + return String(content); + } + } + + try { + return JSON.stringify(content); + } catch { + return String(content); + } + }; + + const maybeParseJsonString = (value: unknown): unknown => { + if (typeof value !== 'string') return value; + const trimmed = value.trim(); + if (!trimmed) return value; + const first = trimmed[0]; + if (first !== '{' && first !== '[') return value; + try { + return JSON.parse(trimmed) as unknown; + } catch { + return value; + } + }; + + if (raw.role === 'user') { + return { + id, + localId, + createdAt, + role: 'user', + content: raw.content, + isSidechain: false, + meta: raw.meta, + }; + } + if (raw.role === 'agent') { + if (raw.content.type === 'output') { + + // Skip Meta messages + if (raw.content.data.isMeta) { + return null; + } + + // Skip compact summary messages + if (raw.content.data.isCompactSummary) { + return null; + } + + // Handle Assistant messages (including sidechains) + if (raw.content.data.type === 'assistant') { + if (!raw.content.data.uuid) { + return null; + } + let content: NormalizedAgentContent[] = []; + for (let c of raw.content.data.message.content) { + if (c.type === 'text') { + content.push({ + ...c, // WOLOG: Preserve all fields including unknown ones + uuid: raw.content.data.uuid, + parentUUID: raw.content.data.parentUuid ?? null + } as NormalizedAgentContent); + } else if (c.type === 'thinking') { + content.push({ + ...c, // WOLOG: Preserve all fields including unknown ones (signature, etc.) + uuid: raw.content.data.uuid, + parentUUID: raw.content.data.parentUuid ?? null + } as NormalizedAgentContent); + } else if (c.type === 'tool_use') { + let description: string | null = null; + if (typeof c.input === 'object' && c.input !== null && 'description' in c.input && typeof c.input.description === 'string') { + description = c.input.description; + } + content.push({ + ...c, // WOLOG: Preserve all fields including unknown ones + type: 'tool-call', + description, + uuid: raw.content.data.uuid, + parentUUID: raw.content.data.parentUuid ?? null + } as NormalizedAgentContent); + } + } + return { + id, + localId, + createdAt, + role: 'agent', + isSidechain: raw.content.data.isSidechain ?? false, + content, + meta: raw.meta, + usage: raw.content.data.message.usage + }; + } else if (raw.content.data.type === 'user') { + if (!raw.content.data.uuid) { + return null; + } + + // Handle sidechain user messages + if (raw.content.data.isSidechain && raw.content.data.message && typeof raw.content.data.message.content === 'string') { + // Return as a special agent message with sidechain content + return { + id, + localId, + createdAt, + role: 'agent', + isSidechain: true, + content: [{ + type: 'sidechain', + uuid: raw.content.data.uuid, + prompt: raw.content.data.message.content + }] + }; + } + + // Handle regular user messages + if (raw.content.data.message && typeof raw.content.data.message.content === 'string') { + return { + id, + localId, + createdAt, + role: 'user', + isSidechain: false, + content: { + type: 'text', + text: raw.content.data.message.content + } + }; + } + + // Handle tool results + let content: NormalizedAgentContent[] = []; + if (typeof raw.content.data.message.content === 'string') { + content.push({ + type: 'text', + text: raw.content.data.message.content, + uuid: raw.content.data.uuid, + parentUUID: raw.content.data.parentUuid ?? null + }); + } else { + for (let c of raw.content.data.message.content) { + if (c.type === 'tool_result') { + const rawResultContent = raw.content.data.toolUseResult ?? c.content; + content.push({ + ...c, // WOLOG: Preserve all fields including unknown ones + type: 'tool-result', + content: toolResultContentToText(rawResultContent), + is_error: c.is_error || false, + uuid: raw.content.data.uuid, + parentUUID: raw.content.data.parentUuid ?? null, + permissions: c.permissions ? { + date: c.permissions.date, + result: c.permissions.result, + mode: c.permissions.mode, + allowedTools: c.permissions.allowedTools, + decision: c.permissions.decision + } : undefined + } as NormalizedAgentContent); + } + } + } + return { + id, + localId, + createdAt, + role: 'agent', + isSidechain: raw.content.data.isSidechain ?? false, + content, + meta: raw.meta + }; + } + } + if (raw.content.type === 'event') { + return { + id, + localId, + createdAt, + role: 'event', + content: raw.content.data, + isSidechain: false, + }; + } + if (raw.content.type === 'codex') { + if (raw.content.data.type === 'message') { + // Cast codex messages to agent text messages + return { + id, + localId, + createdAt, + role: 'agent', + isSidechain: false, + content: [{ + type: 'text', + text: raw.content.data.message, + uuid: id, + parentUUID: null + }], + meta: raw.meta + }; + } + if (raw.content.data.type === 'reasoning') { + // Cast codex messages to agent text messages + return { + id, + localId, + createdAt, + role: 'agent', + isSidechain: false, + content: [{ + type: 'text', + text: raw.content.data.message, + uuid: id, + parentUUID: null + }], + meta: raw.meta + } satisfies NormalizedMessage; + } + if (raw.content.data.type === 'tool-call') { + // Cast tool calls to agent tool-call messages + return { + id, + localId, + createdAt, + role: 'agent', + isSidechain: false, + content: [{ + type: 'tool-call', + id: raw.content.data.callId, + name: raw.content.data.name || 'unknown', + input: raw.content.data.input, + description: null, + uuid: raw.content.data.id, + parentUUID: null + }], + meta: raw.meta + } satisfies NormalizedMessage; + } + if (raw.content.data.type === 'tool-call-result') { + // Cast tool call results to agent tool-result messages + return { + id, + localId, + createdAt, + role: 'agent', + isSidechain: false, + content: [{ + type: 'tool-result', + tool_use_id: raw.content.data.callId, + content: toolResultContentToText(raw.content.data.output), + is_error: false, + uuid: raw.content.data.id, + parentUUID: null + }], + meta: raw.meta + } satisfies NormalizedMessage; + } + } + // ACP (Agent Communication Protocol) - unified format for all agent providers + if (raw.content.type === 'acp') { + if (raw.content.data.type === 'message') { + return { + id, + localId, + createdAt, + role: 'agent', + isSidechain: false, + content: [{ + type: 'text', + text: raw.content.data.message, + uuid: id, + parentUUID: null + }], + meta: raw.meta + } satisfies NormalizedMessage; + } + if (raw.content.data.type === 'reasoning') { + return { + id, + localId, + createdAt, + role: 'agent', + isSidechain: false, + content: [{ + type: 'text', + text: raw.content.data.message, + uuid: id, + parentUUID: null + }], + meta: raw.meta + } satisfies NormalizedMessage; + } + if (raw.content.data.type === 'tool-call') { + let description: string | null = null; + const parsedInput = maybeParseJsonString(raw.content.data.input); + const inputObj = (parsedInput && typeof parsedInput === 'object' && !Array.isArray(parsedInput)) + ? (parsedInput as Record<string, unknown>) + : null; + const acpMeta = inputObj && inputObj._acp && typeof inputObj._acp === 'object' && !Array.isArray(inputObj._acp) + ? (inputObj._acp as Record<string, unknown>) + : null; + const acpTitle = acpMeta && typeof acpMeta.title === 'string' ? acpMeta.title : null; + const inputDescription = inputObj && typeof inputObj.description === 'string' ? inputObj.description : null; + description = acpTitle ?? inputDescription ?? null; + return { + id, + localId, + createdAt, + role: 'agent', + isSidechain: false, + content: [{ + type: 'tool-call', + id: raw.content.data.callId, + name: raw.content.data.name || 'unknown', + input: parsedInput, + description, + uuid: raw.content.data.id, + parentUUID: null + }], + meta: raw.meta + } satisfies NormalizedMessage; + } + if (raw.content.data.type === 'tool-result') { + const parsedOutput = maybeParseJsonString(raw.content.data.output); + return { + id, + localId, + createdAt, + role: 'agent', + isSidechain: false, + content: [{ + type: 'tool-result', + tool_use_id: raw.content.data.callId, + content: parsedOutput, + is_error: raw.content.data.isError ?? false, + uuid: raw.content.data.id, + parentUUID: null + }], + meta: raw.meta + } satisfies NormalizedMessage; + } + // Handle hyphenated tool-call-result (backwards compatibility) + if (raw.content.data.type === 'tool-call-result') { + const parsedOutput = maybeParseJsonString(raw.content.data.output); + return { + id, + localId, + createdAt, + role: 'agent', + isSidechain: false, + content: [{ + type: 'tool-result', + tool_use_id: raw.content.data.callId, + content: parsedOutput, + is_error: false, + uuid: raw.content.data.id, + parentUUID: null + }], + meta: raw.meta + } satisfies NormalizedMessage; + } + if (raw.content.data.type === 'thinking') { + return { + id, + localId, + createdAt, + role: 'agent', + isSidechain: false, + content: [{ + type: 'thinking', + thinking: raw.content.data.text, + uuid: id, + parentUUID: null + }], + meta: raw.meta + } satisfies NormalizedMessage; + } + if (raw.content.data.type === 'file-edit') { + // Map file-edit to tool-call for UI rendering + return { + id, + localId, + createdAt, + role: 'agent', + isSidechain: false, + content: [{ + type: 'tool-call', + id: raw.content.data.id, + name: 'file-edit', + input: { + filePath: raw.content.data.filePath, + description: raw.content.data.description, + diff: raw.content.data.diff, + oldContent: raw.content.data.oldContent, + newContent: raw.content.data.newContent + }, + description: raw.content.data.description, + uuid: raw.content.data.id, + parentUUID: null + }], + meta: raw.meta + } satisfies NormalizedMessage; + } + if (raw.content.data.type === 'terminal-output') { + // Map terminal-output to tool-result + return { + id, + localId, + createdAt, + role: 'agent', + isSidechain: false, + content: [{ + type: 'tool-result', + tool_use_id: raw.content.data.callId, + content: raw.content.data.data, + is_error: false, + uuid: id, + parentUUID: null + }], + meta: raw.meta + } satisfies NormalizedMessage; + } + if (raw.content.data.type === 'permission-request') { + // Map permission-request to tool-call for UI to show permission dialog + return { + id, + localId, + createdAt, + role: 'agent', + isSidechain: false, + content: [{ + type: 'tool-call', + id: raw.content.data.permissionId, + name: raw.content.data.toolName, + input: raw.content.data.options ?? {}, + description: raw.content.data.description, + uuid: id, + parentUUID: null + }], + meta: raw.meta + } satisfies NormalizedMessage; + } + // Task lifecycle events (task_started, task_complete, turn_aborted) and token_count + // are status/metrics - skip normalization, they don't need UI rendering + } + } + return null; +} diff --git a/expo-app/sources/sync/typesRaw/schemas.ts b/expo-app/sources/sync/typesRaw/schemas.ts new file mode 100644 index 000000000..c4ec83771 --- /dev/null +++ b/expo-app/sources/sync/typesRaw/schemas.ts @@ -0,0 +1,342 @@ +import * as z from 'zod'; +import { MessageMetaSchema, MessageMeta } from '../typesMessageMeta'; +import { PERMISSION_MODES } from '@/constants/PermissionModes'; +import { AGENT_IDS } from '@happy/agents'; + +// +// Raw types +// + +// Usage data type from Claude API +const usageDataSchema = z.object({ + input_tokens: z.number(), + cache_creation_input_tokens: z.number().optional(), + cache_read_input_tokens: z.number().optional(), + output_tokens: z.number(), + // Some upstream error payloads can include `service_tier: null`. + // Treat null as “unknown” so we don't drop the whole message. + service_tier: z.string().nullish(), +}); + +export type UsageData = z.infer<typeof usageDataSchema>; + +const agentEventSchema = z.discriminatedUnion('type', [z.object({ + type: z.literal('switch'), + mode: z.enum(['local', 'remote']) +}), z.object({ + type: z.literal('message'), + message: z.string(), +}), z.object({ + type: z.literal('limit-reached'), + endsAt: z.number(), +}), z.object({ + type: z.literal('ready'), +})]); +export type AgentEvent = z.infer<typeof agentEventSchema>; + +const rawTextContentSchema = z.object({ + type: z.literal('text'), + text: z.string(), +}).passthrough(); // ROBUST: Accept unknown fields for future API compatibility +export type RawTextContent = z.infer<typeof rawTextContentSchema>; + +const rawToolUseContentSchema = z.object({ + type: z.literal('tool_use'), + id: z.string(), + name: z.string(), + input: z.any(), +}).passthrough(); // ROBUST: Accept unknown fields preserved by transform +export type RawToolUseContent = z.infer<typeof rawToolUseContentSchema>; + +const rawToolResultContentSchema = z.object({ + type: z.literal('tool_result'), + tool_use_id: z.string(), + // Tool results can be strings, Claude-style arrays of text blocks, or structured JSON (Codex/Gemini). + // We accept any here and normalize later for display. + content: z.any(), + is_error: z.boolean().optional(), + permissions: z.object({ + date: z.number(), + result: z.enum(['approved', 'denied']), + mode: z.enum(PERMISSION_MODES).optional(), + allowedTools: z.array(z.string()).optional(), + decision: z.enum(['approved', 'approved_for_session', 'approved_execpolicy_amendment', 'denied', 'abort']).optional(), + }).optional(), +}).passthrough(); // ROBUST: Accept unknown fields for future API compatibility +export type RawToolResultContent = z.infer<typeof rawToolResultContentSchema>; + +/** + * Extended thinking content from Claude API + * Contains model's reasoning process before generating the final response + * Uses .passthrough() to preserve signature and other unknown fields + */ +const rawThinkingContentSchema = z.object({ + type: z.literal('thinking'), + thinking: z.string(), +}).passthrough(); // ROBUST: Accept signature and future fields +export type RawThinkingContent = z.infer<typeof rawThinkingContentSchema>; + +// ============================================================================ +// WOLOG: Type-Safe Content Normalization via Zod Transform +// ============================================================================ +// Accepts both hyphenated (Codex/Gemini) and underscore (Claude) formats +// Transforms all to canonical underscore format during validation +// Full type safety - no `unknown` types +// Source: Part D of the Expo Mobile Testing & Package Manager Agnostic System plan +// ============================================================================ + +/** + * Hyphenated tool-call format from Codex/Gemini agents + * Transforms to canonical tool_use format during validation + * Uses .passthrough() to preserve unknown fields for future API compatibility + */ +const rawHyphenatedToolCallSchema = z.object({ + type: z.literal('tool-call'), + callId: z.string(), + id: z.string().optional(), // Some messages have both + name: z.string(), + input: z.any(), +}).passthrough(); // ROBUST: Accept and preserve unknown fields +type RawHyphenatedToolCall = z.infer<typeof rawHyphenatedToolCallSchema>; + +/** + * Hyphenated tool-call-result format from Codex/Gemini agents + * Transforms to canonical tool_result format during validation + * Uses .passthrough() to preserve unknown fields for future API compatibility + */ +const rawHyphenatedToolResultSchema = z.object({ + type: z.literal('tool-call-result'), + callId: z.string(), + tool_use_id: z.string().optional(), // Some messages have both + output: z.any(), + content: z.any().optional(), // Some messages have both + is_error: z.boolean().optional(), +}).passthrough(); // ROBUST: Accept and preserve unknown fields +type RawHyphenatedToolResult = z.infer<typeof rawHyphenatedToolResultSchema>; + +/** + * Input schema accepting ALL formats (both hyphenated and canonical) + * Including Claude's extended thinking content type + */ +const rawAgentContentInputSchema = z.discriminatedUnion('type', [ + rawTextContentSchema, // type: 'text' (canonical) + rawToolUseContentSchema, // type: 'tool_use' (canonical) + rawToolResultContentSchema, // type: 'tool_result' (canonical) + rawThinkingContentSchema, // type: 'thinking' (canonical) + rawHyphenatedToolCallSchema, // type: 'tool-call' (hyphenated) + rawHyphenatedToolResultSchema, // type: 'tool-call-result' (hyphenated) +]); +type RawAgentContentInput = z.infer<typeof rawAgentContentInputSchema>; + +/** + * Type-safe transform: Hyphenated tool-call → Canonical tool_use + * ROBUST: Unknown fields preserved via object spread and .passthrough() + */ +function normalizeToToolUse(input: RawHyphenatedToolCall) { + // Spread preserves all fields from input (passthrough fields included) + return { + ...input, + type: 'tool_use' as const, + id: input.callId, // Codex uses callId, canonical uses id + }; +} + +/** + * Type-safe transform: Hyphenated tool-call-result → Canonical tool_result + * ROBUST: Unknown fields preserved via object spread and .passthrough() + */ +function normalizeToToolResult(input: RawHyphenatedToolResult) { + // Spread preserves all fields from input (passthrough fields included) + return { + ...input, + type: 'tool_result' as const, + tool_use_id: input.callId, // Codex uses callId, canonical uses tool_use_id + content: input.output ?? input.content ?? '', // Codex uses output, canonical uses content + is_error: input.is_error ?? false, + }; +} + +/** + * Schema that accepts both hyphenated and canonical formats. + * Normalization happens via .preprocess() at root level to avoid Zod v4 "unmergable intersection" issue. + * See: https://github.com/colinhacks/zod/discussions/2100 + * + * Accepts: 'text' | 'tool_use' | 'tool_result' | 'thinking' | 'tool-call' | 'tool-call-result' + * All types validated by their respective schemas with .passthrough() for unknown fields + */ +const rawAgentContentSchema = z.union([ + rawTextContentSchema, + rawToolUseContentSchema, + rawToolResultContentSchema, + rawThinkingContentSchema, + rawHyphenatedToolCallSchema, + rawHyphenatedToolResultSchema, +]); +export type RawAgentContent = z.infer<typeof rawAgentContentSchema>; + +const rawAgentRecordSchema = z.discriminatedUnion('type', [z.object({ + type: z.literal('output'), + data: z.intersection(z.discriminatedUnion('type', [ + z.object({ type: z.literal('system') }), + z.object({ type: z.literal('result') }), + z.object({ type: z.literal('summary'), summary: z.string() }), + z.object({ type: z.literal('assistant'), message: z.object({ role: z.literal('assistant'), model: z.string(), content: z.array(rawAgentContentSchema), usage: usageDataSchema.optional() }), parent_tool_use_id: z.string().nullable().optional() }), + z.object({ type: z.literal('user'), message: z.object({ role: z.literal('user'), content: z.union([z.string(), z.array(rawAgentContentSchema)]) }), parent_tool_use_id: z.string().nullable().optional(), toolUseResult: z.any().nullable().optional() }), + ]), z.object({ + isSidechain: z.boolean().nullish(), + isCompactSummary: z.boolean().nullish(), + isMeta: z.boolean().nullish(), + uuid: z.string().nullish(), + parentUuid: z.string().nullish(), + }).passthrough()), // ROBUST: Accept CLI metadata fields (userType, cwd, sessionId, version, gitBranch, slug, requestId, timestamp) +}), z.object({ + type: z.literal('event'), + id: z.string(), + data: agentEventSchema +}), z.object({ + type: z.literal('codex'), + data: z.discriminatedUnion('type', [ + z.object({ type: z.literal('reasoning'), message: z.string() }), + z.object({ type: z.literal('message'), message: z.string() }), + // Usage/metrics (Codex MCP sometimes sends token_count through the codex channel) + z.object({ type: z.literal('token_count') }).passthrough(), + z.object({ + type: z.literal('tool-call'), + callId: z.string(), + input: z.any(), + name: z.string(), + id: z.string() + }), + z.object({ + type: z.literal('tool-call-result'), + callId: z.string(), + output: z.any(), + id: z.string() + }) + ]) +}), z.object({ + // ACP (Agent Communication Protocol) - unified format for all agent providers + type: z.literal('acp'), + provider: z.enum(AGENT_IDS), + data: z.discriminatedUnion('type', [ + // Core message types + z.object({ type: z.literal('reasoning'), message: z.string() }), + z.object({ type: z.literal('message'), message: z.string() }), + z.object({ type: z.literal('thinking'), text: z.string() }), + // Tool interactions + z.object({ + type: z.literal('tool-call'), + callId: z.string(), + input: z.any(), + name: z.string(), + id: z.string() + }), + z.object({ + type: z.literal('tool-result'), + callId: z.string(), + output: z.any(), + id: z.string(), + isError: z.boolean().optional() + }), + // Hyphenated tool-call-result (for backwards compatibility with CLI) + z.object({ + type: z.literal('tool-call-result'), + callId: z.string(), + output: z.any(), + id: z.string() + }), + // File operations + z.object({ + type: z.literal('file-edit'), + description: z.string(), + filePath: z.string(), + diff: z.string().optional(), + oldContent: z.string().optional(), + newContent: z.string().optional(), + id: z.string() + }).passthrough(), + // Terminal/command output + z.object({ + type: z.literal('terminal-output'), + data: z.string(), + callId: z.string() + }).passthrough(), + // Task lifecycle events + z.object({ type: z.literal('task_started'), id: z.string() }), + z.object({ type: z.literal('task_complete'), id: z.string() }), + z.object({ type: z.literal('turn_aborted'), id: z.string() }), + // Permissions + z.object({ + type: z.literal('permission-request'), + permissionId: z.string(), + toolName: z.string(), + description: z.string(), + options: z.any().optional() + }).passthrough(), + // Usage/metrics + z.object({ type: z.literal('token_count') }).passthrough() + ]) +})]); + +/** + * Preprocessor: Normalizes hyphenated content types to canonical before validation + * This avoids Zod v4's "unmergable intersection" issue with transforms inside complex schemas + * See: https://github.com/colinhacks/zod/discussions/2100 + */ +function preprocessMessageContent(data: any): any { + if (!data || typeof data !== 'object') return data; + + // Helper: normalize a single content item + const normalizeContent = (item: any): any => { + if (!item || typeof item !== 'object') return item; + + if (item.type === 'tool-call') { + return normalizeToToolUse(item); + } + if (item.type === 'tool-call-result') { + return normalizeToToolResult(item); + } + return item; + }; + + // Normalize assistant message content + if (data.role === 'agent' && data.content?.type === 'output' && data.content?.data?.message?.content) { + if (Array.isArray(data.content.data.message.content)) { + data.content.data.message.content = data.content.data.message.content.map(normalizeContent); + } + } + + // Normalize user message content + if (data.role === 'agent' && data.content?.type === 'output' && data.content?.data?.type === 'user' && Array.isArray(data.content.data.message?.content)) { + data.content.data.message.content = data.content.data.message.content.map(normalizeContent); + } + + return data; +} + +export const rawRecordSchema = z.preprocess( + preprocessMessageContent, + z.discriminatedUnion('role', [ + z.object({ + role: z.literal('agent'), + content: rawAgentRecordSchema, + meta: MessageMetaSchema.optional() + }), + z.object({ + role: z.literal('user'), + content: z.object({ + type: z.literal('text'), + text: z.string() + }), + meta: MessageMetaSchema.optional() + }) + ]) +); + +export type RawRecord = z.infer<typeof rawRecordSchema>; + +// Export schemas for validation +export const RawRecordSchema = rawRecordSchema; + + +// diff --git a/expo-app/sources/sync/unread.test.ts b/expo-app/sources/sync/unread.test.ts new file mode 100644 index 000000000..0d2e8c38e --- /dev/null +++ b/expo-app/sources/sync/unread.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest'; +import { computeHasUnreadActivity } from './unread'; + +describe('computeHasUnreadActivity', () => { + it('returns false when there is no activity', () => { + expect( + computeHasUnreadActivity({ + sessionSeq: 0, + pendingActivityAt: 0, + lastViewedSessionSeq: undefined, + lastViewedPendingActivityAt: undefined, + }) + ).toBe(false); + }); + + it('treats missing read marker as unread when there is activity', () => { + expect( + computeHasUnreadActivity({ + sessionSeq: 1, + pendingActivityAt: 0, + lastViewedSessionSeq: undefined, + lastViewedPendingActivityAt: undefined, + }) + ).toBe(true); + expect( + computeHasUnreadActivity({ + sessionSeq: 0, + pendingActivityAt: 123, + lastViewedSessionSeq: undefined, + lastViewedPendingActivityAt: undefined, + }) + ).toBe(true); + }); + + it('returns true when sessionSeq advanced beyond marker', () => { + expect( + computeHasUnreadActivity({ + sessionSeq: 11, + pendingActivityAt: 0, + lastViewedSessionSeq: 10, + lastViewedPendingActivityAt: 0, + }) + ).toBe(true); + }); + + it('returns true when pending activity advanced beyond marker', () => { + expect( + computeHasUnreadActivity({ + sessionSeq: 0, + pendingActivityAt: 11, + lastViewedSessionSeq: 0, + lastViewedPendingActivityAt: 10, + }) + ).toBe(true); + }); + + it('returns false when activity is not beyond marker', () => { + expect( + computeHasUnreadActivity({ + sessionSeq: 11, + pendingActivityAt: 11, + lastViewedSessionSeq: 11, + lastViewedPendingActivityAt: 11, + }) + ).toBe(false); + }); +}); diff --git a/expo-app/sources/sync/unread.ts b/expo-app/sources/sync/unread.ts new file mode 100644 index 000000000..6815ad828 --- /dev/null +++ b/expo-app/sources/sync/unread.ts @@ -0,0 +1,52 @@ +import type { Metadata } from './storageTypes'; + +export function computePendingActivityAt(metadata: Metadata | null | undefined): number { + if (!metadata) return 0; + + let latest = 0; + const bump = (v: unknown) => { + if (typeof v !== 'number') return; + if (!Number.isFinite(v)) return; + if (v > latest) latest = v; + }; + + const queue = metadata.messageQueueV1?.queue ?? []; + for (const item of queue) { + bump(item.updatedAt); + bump(item.createdAt); + } + + const inFlight = metadata.messageQueueV1?.inFlight; + if (inFlight) { + bump(inFlight.claimedAt); + bump(inFlight.updatedAt); + bump(inFlight.createdAt); + } + + const discarded = metadata.messageQueueV1Discarded ?? []; + for (const item of discarded) { + bump(item.discardedAt); + bump(item.updatedAt); + bump(item.createdAt); + } + + return latest; +} + +export function computeHasUnreadActivity(params: { + sessionSeq: number; + pendingActivityAt: number; + lastViewedSessionSeq: number | undefined; + lastViewedPendingActivityAt: number | undefined; +}): boolean { + const { sessionSeq, pendingActivityAt, lastViewedSessionSeq, lastViewedPendingActivityAt } = params; + + const hasAnyActivity = sessionSeq > 0 || pendingActivityAt > 0; + const hasMarker = typeof lastViewedSessionSeq === 'number' || typeof lastViewedPendingActivityAt === 'number'; + if (!hasMarker) return hasAnyActivity; + + const viewedSeq = typeof lastViewedSessionSeq === 'number' ? lastViewedSessionSeq : 0; + const viewedPendingAt = typeof lastViewedPendingActivityAt === 'number' ? lastViewedPendingActivityAt : 0; + + return sessionSeq > viewedSeq || pendingActivityAt > viewedPendingAt; +} diff --git a/expo-app/sources/sync/updateSessionMetadataWithRetry.test.ts b/expo-app/sources/sync/updateSessionMetadataWithRetry.test.ts new file mode 100644 index 000000000..0f6d600e8 --- /dev/null +++ b/expo-app/sources/sync/updateSessionMetadataWithRetry.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest'; + +import { updateSessionMetadataWithRetry } from './updateSessionMetadataWithRetry'; + +type Metadata = { + path: string; + host: string; + readStateV1?: { v: 1; sessionSeq: number; pendingActivityAt: number; updatedAt: number }; + messageQueueV1?: { v: 1; queue: any[]; inFlight?: any | null }; +}; + +describe('updateSessionMetadataWithRetry', () => { + it('retries multiple version-mismatches and applies the latest server metadata before succeeding', async () => { + const sessionId = 's1'; + + const sessions: Record<string, { metadataVersion: number; metadata: Metadata }> = { + [sessionId]: { metadataVersion: 1, metadata: { path: '/tmp', host: 'h' } }, + }; + + const decryptMetadata = async (_version: number, encrypted: string): Promise<Metadata | null> => JSON.parse(encrypted); + const encryptMetadata = async (metadata: Metadata): Promise<string> => JSON.stringify(metadata); + + const applySessionMetadata = (next: { metadataVersion: number; metadata: Metadata }) => { + sessions[sessionId] = next; + }; + + const calls: Array<{ expectedVersion: number; metadata: Metadata }> = []; + + const emitUpdateMetadata = async (payload: { sid: string; expectedVersion: number; metadata: string }) => { + const parsed = JSON.parse(payload.metadata) as Metadata; + calls.push({ expectedVersion: payload.expectedVersion, metadata: parsed }); + + if (payload.expectedVersion === 1) { + return { + result: 'version-mismatch' as const, + version: 2, + metadata: JSON.stringify({ path: '/tmp', host: 'h', messageQueueV1: { v: 1, queue: [{ localId: 'a' }], inFlight: null } }), + }; + } + + if (payload.expectedVersion === 2) { + return { + result: 'version-mismatch' as const, + version: 3, + metadata: JSON.stringify({ path: '/tmp', host: 'h', messageQueueV1: { v: 1, queue: [{ localId: 'a' }, { localId: 'b' }], inFlight: null } }), + }; + } + + return { + result: 'success' as const, + version: payload.expectedVersion + 1, + metadata: payload.metadata, + }; + }; + + const refreshSessions = async () => { + // Should not be required when the server provides version+metadata on mismatch. + throw new Error('refreshSessions should not be called'); + }; + + await updateSessionMetadataWithRetry({ + sessionId, + getSession: () => sessions[sessionId] ?? null, + refreshSessions, + encryptMetadata, + decryptMetadata, + emitUpdateMetadata, + applySessionMetadata, + updater: (base) => ({ + ...base, + readStateV1: { v: 1 as const, sessionSeq: 5, pendingActivityAt: 10, updatedAt: 123 }, + }), + maxAttempts: 5, + }); + + expect(calls.map((c) => c.expectedVersion)).toEqual([1, 2, 3]); + expect(sessions[sessionId]?.metadataVersion).toBe(4); + expect(sessions[sessionId]?.metadata.readStateV1?.sessionSeq).toBe(5); + expect(sessions[sessionId]?.metadata.messageQueueV1?.queue.length).toBe(2); + }); +}); diff --git a/expo-app/sources/sync/updateSessionMetadataWithRetry.ts b/expo-app/sources/sync/updateSessionMetadataWithRetry.ts new file mode 100644 index 000000000..02d1e5350 --- /dev/null +++ b/expo-app/sources/sync/updateSessionMetadataWithRetry.ts @@ -0,0 +1,97 @@ +export type UpdateMetadataAck = { + result: 'success' | 'version-mismatch' | 'error'; + version?: number; + metadata?: string; + message?: string; +}; + +export type SessionMetadataSnapshot<M> = { + metadataVersion: number; + metadata: M; +}; + +/** + * Best-effort helper for updating encrypted session metadata over the websocket `update-metadata` RPC. + * + * The server does not merge metadata (it is encrypted), so we must: + * - fetch the latest version on version-mismatch + * - re-apply our updater and retry + * + * This is used for high-frequency metadata writers (message queue, read markers), so it must be resilient + * to repeated version-mismatches during concurrent updates. + */ +export async function updateSessionMetadataWithRetry<M>(params: { + sessionId: string; + getSession: () => SessionMetadataSnapshot<M> | null; + refreshSessions: () => Promise<void>; + encryptMetadata: (metadata: M) => Promise<string>; + decryptMetadata: (version: number, encrypted: string) => Promise<M | null>; + emitUpdateMetadata: (payload: { sid: string; expectedVersion: number; metadata: string }) => Promise<UpdateMetadataAck>; + applySessionMetadata: (next: SessionMetadataSnapshot<M>) => void; + updater: (base: M) => M; + maxAttempts?: number; +}): Promise<void> { + const { + sessionId, + getSession, + refreshSessions, + encryptMetadata, + decryptMetadata, + emitUpdateMetadata, + applySessionMetadata, + updater, + maxAttempts = 6, + } = params; + + for (let attemptIndex = 0; attemptIndex < maxAttempts; attemptIndex++) { + const current = getSession(); + if (!current) { + throw new Error('Session metadata not available'); + } + + const expectedVersion = current.metadataVersion; + const updatedMetadata = updater(current.metadata); + const encryptedMetadata = await encryptMetadata(updatedMetadata); + + const result = await emitUpdateMetadata({ + sid: sessionId, + expectedVersion, + metadata: encryptedMetadata, + }); + + if (result.result === 'success') { + if (typeof result.version === 'number' && typeof result.metadata === 'string') { + const decrypted = await decryptMetadata(result.version, result.metadata); + if (decrypted) { + applySessionMetadata({ metadataVersion: result.version, metadata: decrypted }); + } + } + return; + } + + if (result.result === 'version-mismatch') { + // Prefer the server-provided current version+metadata; it avoids a whole refresh round-trip. + if (typeof result.version === 'number' && typeof result.metadata === 'string') { + const decrypted = await decryptMetadata(result.version, result.metadata); + if (decrypted) { + applySessionMetadata({ metadataVersion: result.version, metadata: decrypted }); + } else { + await refreshSessions(); + } + } else { + await refreshSessions(); + } + + // Short backoff to reduce tight-loop retries during concurrent writers. + if (attemptIndex < maxAttempts - 1) { + await new Promise((r) => setTimeout(r, Math.min(50 * (attemptIndex + 1), 250))); + } + continue; + } + + throw new Error(result.message || 'Failed to update session metadata'); + } + + throw new Error(`Failed to update session metadata after ${maxAttempts} attempts`); +} + diff --git a/expo-app/sources/text/README.md b/expo-app/sources/text/README.md index 09128f3ef..38551135d 100644 --- a/expo-app/sources/text/README.md +++ b/expo-app/sources/text/README.md @@ -82,8 +82,8 @@ t('invalid.key') // Error: Key doesn't exist ## Files Structure -### `_default.ts` -Contains the main translation object with mixed string/function values: +### `translations/en.ts` +Contains the canonical English translation object with mixed string/function values: ```typescript export const en = { @@ -97,6 +97,13 @@ export const en = { } as const; ``` +### `_types.ts` +Contains the TypeScript types derived from the English translation structure. + +This keeps the canonical translation object (`translations/en.ts`) separate from the type-level API: +- `Translations` / `TranslationStructure` are derived from `en` and used to type-check other locales. +- `TranslationKey` / `TranslationParams<K>` are derived from `Translations` (in `index.ts`) to type `t(...)`. + ### `index.ts` Main module with the `t` function and utilities: - `t()` - Main translation function with strict typing @@ -164,7 +171,7 @@ The API stays the same, but you get: ## Adding New Translations -1. **Add to `_default.ts`**: +1. **Add to `translations/en.ts`**: ```typescript // String constant newConstant: 'My New Text', @@ -215,9 +222,9 @@ statusMessage: ({ files, online, syncing }: { ## Future Expansion To add more languages: -1. Create new translation files (e.g., `_spanish.ts`) +1. Create new translation files (e.g., `translations/es.ts`) 2. Update types to include new locales 3. Add locale switching logic 4. All existing type safety is preserved -This implementation provides a solid foundation that can scale while maintaining perfect type safety and developer experience. \ No newline at end of file +This implementation provides a solid foundation that can scale while maintaining perfect type safety and developer experience. diff --git a/expo-app/sources/text/_default.ts b/expo-app/sources/text/_default.ts index 0a94f0590..a06d4c0d1 100644 --- a/expo-app/sources/text/_default.ts +++ b/expo-app/sources/text/_default.ts @@ -34,7 +34,6 @@ export const en = { cancel: 'Cancel', authenticate: 'Authenticate', save: 'Save', - saveAs: 'Save As', error: 'Error', success: 'Success', ok: 'OK', @@ -58,8 +57,9 @@ export const en = { fileViewer: 'File Viewer', loading: 'Loading...', retry: 'Retry', - delete: 'Delete', - optional: 'optional', + share: 'Share', + sharing: 'Sharing', + sharedSessions: 'Shared Sessions', }, profile: { @@ -133,8 +133,6 @@ export const en = { exchangingTokens: 'Exchanging tokens...', usage: 'Usage', usageSubtitle: 'View your API usage and costs', - profiles: 'Profiles', - profilesSubtitle: 'Manage environment variable profiles for sessions', // Dynamic settings messages accountConnected: ({ service }: { service: string }) => `${service} account connected`, @@ -204,9 +202,6 @@ export const en = { markdownCopyV2Subtitle: 'Long press opens copy modal', hideInactiveSessions: 'Hide inactive sessions', hideInactiveSessionsSubtitle: 'Show only active chats in your list', - enhancedSessionWizard: 'Enhanced Session Wizard', - enhancedSessionWizardEnabled: 'Profile-first session launcher active', - enhancedSessionWizardDisabled: 'Using standard session launcher', }, errors: { @@ -234,6 +229,7 @@ export const en = { userNotFound: 'User not found', sessionDeleted: 'Session has been deleted', sessionDeletedDescription: 'This session has been permanently removed', + invalidShareLink: 'Invalid or expired share link', // Error functions with context fieldError: ({ field, reason }: { field: string; reason: string }) => @@ -254,6 +250,12 @@ export const en = { failedToRemoveFriend: 'Failed to remove friend', searchFailed: 'Search failed. Please try again.', failedToSendRequest: 'Failed to send friend request', + cannotShareWithSelf: 'Cannot share with yourself', + canOnlyShareWithFriends: 'Can only share with friends', + shareNotFound: 'Share not found', + publicShareNotFound: 'Public share not found or expired', + consentRequired: 'Consent required for access', + maxUsesReached: 'Maximum uses reached', }, newSession: { @@ -300,6 +302,54 @@ export const en = { session: { inputPlaceholder: 'Type a message ...', + sharing: { + title: 'Session Sharing', + shareWith: 'Share with...', + sharedWith: 'Shared with', + shareSession: 'Share Session', + stopSharing: 'Stop Sharing', + accessLevel: 'Access Level', + publicLink: 'Public Link', + createPublicLink: 'Create Public Link', + deletePublicLink: 'Delete Public Link', + copyLink: 'Copy Link', + linkCopied: 'Link copied!', + viewOnly: 'View Only', + canEdit: 'Can Edit', + canManage: 'Can Manage', + sharedBy: ({ name }: { name: string }) => `Shared by ${name}`, + expiresAt: ({ date }: { date: string }) => `Expires: ${date}`, + maxUses: ({ count, used }: { count: number; used: number }) => `${used} / ${count} uses`, + unlimited: 'Unlimited', + requireConsent: 'Require consent for access logging', + consentRequired: 'This link requires your consent to log access information (IP address and user agent)', + giveConsent: 'I consent to access logging', + shareWithFriends: 'Share with friends only', + friendsOnly: 'Only friends can be added', + noShares: 'No shares yet', + viewOnlyDescription: 'Can view messages and metadata', + canEditDescription: 'Can send messages but cannot manage sharing', + canManageDescription: 'Full access including sharing management', + shareNotFound: 'Share link not found or has been revoked', + shareExpired: 'This share link has expired', + failedToDecrypt: 'Failed to decrypt share information', + consentDescription: 'By accepting, you consent to logging of your access information', + acceptAndView: 'Accept and View', + days7: '7 days', + days30: '30 days', + never: 'Never expires', + uses10: '10 uses', + uses50: '50 uses', + maxUsesLabel: 'Maximum uses', + publicLinkDescription: 'Create a shareable link that anyone can use to access this session', + expiresIn: 'Link expires in', + requireConsentDescription: 'Users must consent before accessing', + linkToken: 'Link Token', + expiresOn: 'Expires on', + usageCount: 'Usage', + usageCountWithMax: ({ used, max }: { used: number; max: number }) => `${used} / ${max} uses`, + usageCountUnlimited: ({ used }: { used: number }) => `${used} uses`, + }, }, commandPalette: { @@ -374,7 +424,9 @@ export const en = { deleteSessionWarning: 'This action cannot be undone. All messages and data associated with this session will be permanently deleted.', failedToDeleteSession: 'Failed to delete session', sessionDeleted: 'Session deleted successfully', - + manageSharing: 'Manage Sharing', + manageSharingSubtitle: 'Share this session with friends or create a public link', + }, components: { @@ -418,25 +470,15 @@ export const en = { badgeSafeYolo: 'Safe YOLO', badgeYolo: 'YOLO', }, - codexModel: { - title: 'CODEX MODEL', - gpt5CodexLow: 'gpt-5-codex low', - gpt5CodexMedium: 'gpt-5-codex medium', - gpt5CodexHigh: 'gpt-5-codex high', - gpt5Minimal: 'GPT-5 Minimal', - gpt5Low: 'GPT-5 Low', - gpt5Medium: 'GPT-5 Medium', - gpt5High: 'GPT-5 High', - }, geminiPermissionMode: { title: 'GEMINI PERMISSION MODE', default: 'Default', - readOnly: 'Read Only', - safeYolo: 'Safe YOLO', - yolo: 'YOLO', - badgeReadOnly: 'Read Only', - badgeSafeYolo: 'Safe YOLO', - badgeYolo: 'YOLO', + acceptEdits: 'Accept Edits', + plan: 'Plan Mode', + bypassPermissions: 'Yolo Mode', + badgeAcceptAllEdits: 'Accept All Edits', + badgeBypassAllPermissions: 'Bypass All Permissions', + badgePlanMode: 'Plan Mode', }, context: { remaining: ({ percent }: { percent: number }) => `${percent}% left`, @@ -759,7 +801,7 @@ export const en = { permissions: { yesAllowAllEdits: 'Yes, allow all edits during this session', yesForTool: "Yes, don't ask again for this tool", - noTellClaude: 'No, and provide feedback', + noTellClaude: 'No, and tell Claude what to do differently', } }, @@ -858,6 +900,41 @@ export const en = { cancelRequestConfirm: ({ name }: { name: string }) => `Cancel your friendship request to ${name}?`, denyRequest: 'Deny friendship', nowFriendsWith: ({ name }: { name: string }) => `You are now friends with ${name}`, + searchFriends: 'Search friends', + noFriendsFound: 'No friends found', + }, + + sessionSharing: { + addShare: 'Add Share', + publicLink: 'Public Link', + publicLinkDescription: 'Create a public link that anyone can use to access this session. You can set an expiration date and usage limit.', + expiresIn: 'Expires in', + days7: '7 days', + days30: '30 days', + never: 'Never', + maxUses: 'Maximum uses', + unlimited: 'Unlimited', + uses10: '10 uses', + uses50: '50 uses', + requireConsent: 'Require consent', + requireConsentDescription: 'Users must accept terms before accessing', + linkToken: 'Link token', + expiresOn: 'Expires on', + usageCount: 'Usage count', + usageCountWithMax: ({ count, max }: { count: number; max: number }) => `${count} / ${max} uses`, + usageCountUnlimited: ({ count }: { count: number }) => `${count} uses`, + directSharing: 'Direct Sharing', + publicLinkActive: 'Public link active', + createPublicLink: 'Create public link', + viewOnlyMode: 'View-only mode', + noEditPermission: 'You don\'t have permission to edit this session', + shareNotFound: 'Share not found', + shareExpired: 'This share link has expired', + failedToDecrypt: 'Failed to decrypt session data', + consentRequired: 'Consent Required', + sharedBy: 'Shared by', + consentDescription: 'This session owner requires your consent before viewing', + acceptAndView: 'Accept and View Session', }, usage: { @@ -880,37 +957,6 @@ export const en = { friendRequestGeneric: 'New friend request', friendAccepted: ({ name }: { name: string }) => `You are now friends with ${name}`, friendAcceptedGeneric: 'Friend request accepted', - }, - - profiles: { - // Profile management feature - title: 'Profiles', - subtitle: 'Manage environment variable profiles for sessions', - noProfile: 'No Profile', - noProfileDescription: 'Use default environment settings', - defaultModel: 'Default Model', - addProfile: 'Add Profile', - profileName: 'Profile Name', - enterName: 'Enter profile name', - baseURL: 'Base URL', - authToken: 'Auth Token', - enterToken: 'Enter auth token', - model: 'Model', - tmuxSession: 'Tmux Session', - enterTmuxSession: 'Enter tmux session name', - tmuxTempDir: 'Tmux Temp Directory', - enterTmuxTempDir: 'Enter temp directory path', - tmuxUpdateEnvironment: 'Update environment automatically', - nameRequired: 'Profile name is required', - deleteConfirm: 'Are you sure you want to delete the profile "{name}"?', - editProfile: 'Edit Profile', - addProfileTitle: 'Add New Profile', - delete: { - title: 'Delete Profile', - message: ({ name }: { name: string }) => `Are you sure you want to delete "${name}"? This action cannot be undone.`, - confirm: 'Delete', - cancel: 'Cancel', - }, } } as const; diff --git a/expo-app/sources/text/_types.ts b/expo-app/sources/text/_types.ts new file mode 100644 index 000000000..435f5471e --- /dev/null +++ b/expo-app/sources/text/_types.ts @@ -0,0 +1,3 @@ +export type { TranslationStructure } from './translations/en'; + +export type Translations = import('./translations/en').TranslationStructure; diff --git a/expo-app/sources/text/deviceLocales.native.ts b/expo-app/sources/text/deviceLocales.native.ts new file mode 100644 index 000000000..4c4a5b417 --- /dev/null +++ b/expo-app/sources/text/deviceLocales.native.ts @@ -0,0 +1,7 @@ +import * as Localization from 'expo-localization'; +import type { DeviceLocale } from './deviceLocales'; + +export function getDeviceLocales(): readonly DeviceLocale[] { + return Localization.getLocales() as readonly DeviceLocale[]; +} + diff --git a/expo-app/sources/text/deviceLocales.ts b/expo-app/sources/text/deviceLocales.ts new file mode 100644 index 000000000..cbd7a7080 --- /dev/null +++ b/expo-app/sources/text/deviceLocales.ts @@ -0,0 +1,49 @@ +export type DeviceLocale = { + languageCode?: string | null; + languageScriptCode?: string | null; +}; + +function parseLocaleTag(tag: string): DeviceLocale | null { + const cleaned = tag.trim(); + if (!cleaned) return null; + + const parts = cleaned.split(/[-_]/g).filter(Boolean); + const languageCode = parts[0]?.toLowerCase() ?? null; + if (!languageCode) return null; + + // BCP-47: language[-script][-region]... + const maybeScript = parts.find((p) => p.length === 4); + const languageScriptCode = maybeScript + ? `${maybeScript[0].toUpperCase()}${maybeScript.slice(1).toLowerCase()}` + : null; + + return { languageCode, languageScriptCode }; +} + +/** + * Cross-platform fallback locale detection. + * + * Expo-native builds should use `deviceLocales.native.ts` (Metro will prefer `.native`). + * In unit tests (Vitest/node), this file avoids importing Expo/React Native packages. + */ +export function getDeviceLocales(): readonly DeviceLocale[] { + const tags: string[] = []; + + if (typeof navigator !== 'undefined') { + const nav = navigator as unknown as { languages?: string[]; language?: string }; + if (Array.isArray(nav.languages)) tags.push(...nav.languages); + if (typeof nav.language === 'string') tags.push(nav.language); + } + + const intlTag = Intl?.DateTimeFormat?.()?.resolvedOptions?.()?.locale; + if (typeof intlTag === 'string') tags.push(intlTag); + + const out: DeviceLocale[] = []; + for (const tag of tags) { + const parsed = parseLocaleTag(tag); + if (parsed) out.push(parsed); + } + + return out; +} + diff --git a/expo-app/sources/text/index.ts b/expo-app/sources/text/index.ts index e627bb855..f548f04f3 100644 --- a/expo-app/sources/text/index.ts +++ b/expo-app/sources/text/index.ts @@ -1,4 +1,5 @@ -import { en, type Translations, type TranslationStructure } from './_default'; +import { en } from './translations/en'; +import type { Translations, TranslationStructure } from './_types'; import { ru } from './translations/ru'; import { pl } from './translations/pl'; import { es } from './translations/es'; @@ -7,9 +8,9 @@ import { pt } from './translations/pt'; import { ca } from './translations/ca'; import { zhHans } from './translations/zh-Hans'; import { ja } from './translations/ja'; -import * as Localization from 'expo-localization'; import { loadSettings } from '@/sync/persistence'; import { type SupportedLanguage, SUPPORTED_LANGUAGES, SUPPORTED_LANGUAGE_CODES, DEFAULT_LANGUAGE } from './_all'; +import { getDeviceLocales } from './deviceLocales'; /** * Extract all possible dot-notation keys from the nested translation object @@ -98,13 +99,11 @@ let found = false; if (settings.settings.preferredLanguage && settings.settings.preferredLanguage in translations) { currentLanguage = settings.settings.preferredLanguage as SupportedLanguage; found = true; - console.log(`[i18n] Using preferred language: ${currentLanguage}`); } // Read from device if (!found) { - let locales = Localization.getLocales(); - console.log(`[i18n] Device locales:`, locales.map(l => l.languageCode)); + let locales = getDeviceLocales(); for (let l of locales) { if (l.languageCode) { // Expo added special handling for Chinese variants using script code https://github.com/expo/expo/pull/34984 @@ -114,35 +113,26 @@ if (!found) { // We only have translations for simplified Chinese right now, but looking for help with traditional Chinese. if (l.languageScriptCode === 'Hans') { chineseVariant = 'zh-Hans'; - // } else if (l.languageScriptCode === 'Hant') { - // chineseVariant = 'zh-Hant'; } - console.log(`[i18n] Chinese script code: ${l.languageScriptCode} -> ${chineseVariant}`); - if (chineseVariant && chineseVariant in translations) { currentLanguage = chineseVariant as SupportedLanguage; - console.log(`[i18n] Using Chinese variant: ${currentLanguage}`); break; } currentLanguage = 'zh-Hans'; - console.log(`[i18n] Falling back to simplified Chinese: zh-Hans`); break; } // Direct match for non-Chinese languages if (l.languageCode in translations) { currentLanguage = l.languageCode as SupportedLanguage; - console.log(`[i18n] Using device locale: ${currentLanguage}`); break; } } } } -console.log(`[i18n] Final language: ${currentLanguage}`); - /** * Main translation function with strict typing * diff --git a/expo-app/sources/text/translations/ca.ts b/expo-app/sources/text/translations/ca.ts index 91a6a5ab6..fb1d12ad7 100644 --- a/expo-app/sources/text/translations/ca.ts +++ b/expo-app/sources/text/translations/ca.ts @@ -1,4 +1,4 @@ -import type { TranslationStructure } from '../_default'; +import type { TranslationStructure } from '../_types'; /** * Catalan plural helper function @@ -31,6 +31,8 @@ export const ca: TranslationStructure = { common: { // Simple string constants + add: 'Afegeix', + actions: 'Accions', cancel: 'Cancel·la', authenticate: 'Autentica', save: 'Desa', @@ -47,7 +49,11 @@ export const ca: TranslationStructure = { yes: 'Sí', no: 'No', discard: 'Descarta', + discardChanges: 'Descarta els canvis', + unsavedChangesWarning: 'Tens canvis sense desar.', + keepEditing: 'Continua editant', version: 'Versió', + details: 'Detalls', copied: 'Copiat', copy: 'Copiar', scanning: 'Escanejant...', @@ -60,6 +66,18 @@ export const ca: TranslationStructure = { retry: 'Torna-ho a provar', delete: 'Elimina', optional: 'Opcional', + noMatches: 'Sense coincidències', + all: 'Tots', + machine: 'màquina', + clearSearch: 'Neteja la cerca', + refresh: 'Actualitza', + }, + + dropdown: { + category: { + general: 'General', + results: 'Resultats', + }, }, profile: { @@ -96,6 +114,16 @@ export const ca: TranslationStructure = { enterSecretKey: 'Introdueix la teva clau secreta', invalidSecretKey: 'Clau secreta no vàlida. Comprova-ho i torna-ho a provar.', enterUrlManually: 'Introdueix l\'URL manualment', + openMachine: 'Obrir màquina', + terminalUrlPlaceholder: 'happy://terminal?...', + restoreQrInstructions: '1. Obre Happy al teu dispositiu mòbil\n2. Ves a Configuració → Compte\n3. Toca "Vincular nou dispositiu"\n4. Escaneja aquest codi QR', + restoreWithSecretKeyInstead: 'Restaura amb clau secreta', + restoreWithSecretKeyDescription: 'Introdueix la teva clau secreta per recuperar l’accés al teu compte.', + secretKeyPlaceholder: 'XXXXX-XXXXX-XXXXX...', + unsupported: { + connectTitle: ({ name }: { name: string }) => `Connecta ${name}`, + runCommandInTerminal: 'Executa l\'ordre següent al terminal:', + }, }, settings: { @@ -136,6 +164,12 @@ export const ca: TranslationStructure = { usageSubtitle: "Veure l'ús de l'API i costos", profiles: 'Perfils', profilesSubtitle: 'Gestiona els perfils d\'entorn i variables', + secrets: 'Secrets', + secretsSubtitle: 'Gestiona els secrets desats (no es tornaran a mostrar després d’introduir-los)', + terminal: 'Terminal', + session: 'Sessió', + sessionSubtitleTmuxEnabled: 'Tmux activat', + sessionSubtitleMessageSendingAndTmux: 'Enviament de missatges i tmux', // Dynamic settings messages accountConnected: ({ service }: { service: string }) => `Compte de ${service} connectat`, @@ -173,11 +207,26 @@ export const ca: TranslationStructure = { wrapLinesInDiffsDescription: 'Ajusta les línies llargues en lloc de desplaçament horitzontal a les vistes de diferències', alwaysShowContextSize: 'Mostra sempre la mida del context', alwaysShowContextSizeDescription: 'Mostra l\'ús del context fins i tot quan no estigui prop del límit', + agentInputActionBarLayout: 'Barra d’accions d’entrada', + agentInputActionBarLayoutDescription: 'Tria com es mostren els xips d’acció sobre el camp d’entrada', + agentInputActionBarLayoutOptions: { + auto: 'Auto', + wrap: 'Ajusta', + scroll: 'Desplaçable', + collapsed: 'Plegat', + }, + agentInputChipDensity: 'Densitat dels xips d’acció', + agentInputChipDensityDescription: 'Tria si els xips d’acció mostren etiquetes o icones', + agentInputChipDensityOptions: { + auto: 'Auto', + labels: 'Etiquetes', + icons: 'Només icones', + }, avatarStyle: 'Estil d\'avatar', avatarStyleDescription: 'Tria l\'aparença de l\'avatar de la sessió', avatarOptions: { pixelated: 'Pixelat', - gradient: 'Gradient', + gradient: 'Degradat', brutalist: 'Brutalista', }, showFlavorIcons: "Mostrar icones de proveïdors d'IA", @@ -193,21 +242,52 @@ export const ca: TranslationStructure = { experimentalFeatures: 'Funcions experimentals', experimentalFeaturesEnabled: 'Funcions experimentals activades', experimentalFeaturesDisabled: 'Utilitzant només funcions estables', - webFeatures: 'Funcions web', - webFeaturesDescription: 'Funcions disponibles només a la versió web de l\'app.', + experimentalOptions: 'Opcions experimentals', + experimentalOptionsDescription: 'Tria quines funcions experimentals estan activades.', + expUsageReporting: 'Informe d’ús', + expUsageReportingSubtitle: 'Activa pantalles d’ús i tokens', + expFileViewer: 'Visor de fitxers', + expFileViewerSubtitle: 'Activa l’entrada al visor de fitxers de la sessió', + expShowThinkingMessages: 'Mostra missatges de pensament', + expShowThinkingMessagesSubtitle: 'Mostra missatges d’estat/pensament de l’assistent al xat', + expSessionType: 'Selector de tipus de sessió', + expSessionTypeSubtitle: 'Mostra el selector de tipus de sessió (simple vs worktree)', + expZen: 'Zen', + expZenSubtitle: 'Activa l’entrada de navegació Zen', + expVoiceAuthFlow: 'Flux d’autenticació de veu', + expVoiceAuthFlowSubtitle: 'Utilitza el flux autenticat de tokens de veu (amb paywall)', + expInboxFriends: 'Safata d’entrada i amics', + expInboxFriendsSubtitle: 'Activa la pestanya de Safata d’entrada i les funcions d’amics', + expCodexResume: 'Reprendre Codex', + expCodexResumeSubtitle: 'Permet reprendre sessions de Codex mitjançant una instal·lació separada (experimental)', + expCodexAcp: 'Codex ACP', + expCodexAcpSubtitle: 'Fer servir Codex via ACP (codex-acp) en lloc de MCP (experimental)', + webFeatures: 'Funcions web', + webFeaturesDescription: 'Funcions disponibles només a la versió web de l\'app.', enterToSend: 'Enter per enviar', enterToSendEnabled: 'Prem Enter per enviar (Maj+Enter per a una nova línia)', enterToSendDisabled: 'Enter insereix una nova línia', commandPalette: 'Paleta de comandes', commandPaletteEnabled: 'Prem ⌘K per obrir', commandPaletteDisabled: 'Accés ràpid a comandes desactivat', - markdownCopyV2: 'Markdown Copy v2', + markdownCopyV2: 'Còpia de Markdown v2', markdownCopyV2Subtitle: 'Pulsació llarga obre modal de còpia', hideInactiveSessions: 'Amaga les sessions inactives', hideInactiveSessionsSubtitle: 'Mostra només els xats actius a la llista', + groupInactiveSessionsByProject: 'Agrupa les sessions inactives per projecte', + groupInactiveSessionsByProjectSubtitle: 'Organitza els xats inactius per projecte', enhancedSessionWizard: 'Assistent de sessió millorat', enhancedSessionWizardEnabled: 'Llançador de sessió amb perfil actiu', enhancedSessionWizardDisabled: 'Usant el llançador de sessió estàndard', + profiles: 'Perfils d\'IA', + profilesEnabled: 'Selecció de perfils activada', + profilesDisabled: 'Selecció de perfils desactivada', + pickerSearch: 'Cerca als selectors', + pickerSearchSubtitle: 'Mostra un camp de cerca als selectors de màquina i camí', + machinePickerSearch: 'Cerca de màquines', + machinePickerSearchSubtitle: 'Mostra un camp de cerca als selectors de màquines', + pathPickerSearch: 'Cerca de camins', + pathPickerSearchSubtitle: 'Mostra un camp de cerca als selectors de camins', }, errors: { @@ -255,11 +335,85 @@ export const ca: TranslationStructure = { failedToRemoveFriend: 'No s\'ha pogut eliminar l\'amic', searchFailed: 'La cerca ha fallat. Si us plau, torna-ho a provar.', failedToSendRequest: 'No s\'ha pogut enviar la sol·licitud d\'amistat', + failedToResumeSession: 'No s’ha pogut reprendre la sessió', + failedToSendMessage: 'No s’ha pogut enviar el missatge', + cannotShareWithSelf: 'No pots compartir amb tu mateix', + canOnlyShareWithFriends: 'Només pots compartir amb amics', + shareNotFound: 'Compartició no trobada', + publicShareNotFound: 'Enllaç públic no trobat o expirat', + consentRequired: 'Es requereix consentiment per a l\'accés', + maxUsesReached: 'S\'ha assolit el màxim d\'usos', + invalidShareLink: 'Enllaç de compartició no vàlid o caducat', + missingPermissionId: 'Falta l’identificador de permís', + codexResumeNotInstalledTitle: 'Codex resume no està instal·lat en aquesta màquina', + codexResumeNotInstalledMessage: + 'Per reprendre una conversa de Codex, instal·la el servidor de represa de Codex a la màquina de destinació (Detalls de la màquina → Represa de Codex).', + codexAcpNotInstalledTitle: 'Codex ACP no està instal·lat en aquesta màquina', + codexAcpNotInstalledMessage: + 'Per fer servir l’experiment de Codex ACP, instal·la codex-acp a la màquina de destinació (Detalls de la màquina → Codex ACP) o desactiva l’experiment.', + }, + + deps: { + installNotSupported: 'Actualitza Happy CLI per instal·lar aquesta dependència.', + installFailed: 'La instal·lació ha fallat', + installed: 'Instal·lat', + installLog: ({ path }: { path: string }) => `Registre d'instal·lació: ${path}`, + installable: { + codexResume: { + title: 'Servidor de represa de Codex', + installSpecTitle: 'Origen d\'instal·lació de Codex resume', + }, + codexAcp: { + title: 'Adaptador ACP de Codex', + installSpecTitle: 'Origen d\'instal·lació de Codex ACP', + }, + installSpecDescription: 'Especificació NPM/Git/fitxer passada a `npm install` (experimental). Deixa-ho buit per usar el valor per defecte del dimoni.', + }, + ui: { + notAvailable: 'No disponible', + notAvailableUpdateCli: 'No disponible (actualitza la CLI)', + errorRefresh: 'Error (actualitzar)', + installed: 'Instal·lat', + installedWithVersion: ({ version }: { version: string }) => `Instal·lat (v${version})`, + installedUpdateAvailable: ({ installedVersion, latestVersion }: { installedVersion: string; latestVersion: string }) => + `Instal·lat (v${installedVersion}) — actualització disponible (v${latestVersion})`, + notInstalled: 'No instal·lat', + latest: 'Darrera', + latestSubtitle: ({ version, tag }: { version: string; tag: string }) => `${version} (etiqueta: ${tag})`, + registryCheck: 'Comprovació del registre', + registryCheckFailed: ({ error }: { error: string }) => `Ha fallat: ${error}`, + installSource: 'Origen d\'instal·lació', + installSourceDefault: '(per defecte)', + installSpecPlaceholder: 'p. ex. file:/ruta/al/paquet o github:propietari/repo#branca', + lastInstallLog: 'Últim registre d\'instal·lació', + installLogTitle: 'Registre d\'instal·lació', + }, }, newSession: { // Used by new-session screen and launch flows title: 'Inicia una nova sessió', + selectAiProfileTitle: 'Selecciona el perfil d’IA', + selectAiProfileDescription: 'Selecciona un perfil d’IA per aplicar variables d’entorn i valors per defecte a la sessió.', + changeProfile: 'Canvia el perfil', + aiBackendSelectedByProfile: 'El backend d’IA el selecciona el teu perfil. Per canviar-lo, selecciona un perfil diferent.', + selectAiBackendTitle: 'Selecciona el backend d’IA', + aiBackendLimitedByProfileAndMachineClis: 'Limitat pel perfil seleccionat i els CLI disponibles en aquesta màquina.', + aiBackendSelectWhichAiRuns: 'Selecciona quina IA executa la sessió.', + aiBackendNotCompatibleWithSelectedProfile: 'No és compatible amb el perfil seleccionat.', + aiBackendCliNotDetectedOnMachine: ({ cli }: { cli: string }) => `No s’ha detectat el CLI de ${cli} en aquesta màquina.`, + selectMachineTitle: 'Selecciona màquina', + selectMachineDescription: 'Tria on s’executa aquesta sessió.', + selectPathTitle: 'Selecciona camí', + selectWorkingDirectoryTitle: 'Selecciona el directori de treball', + selectWorkingDirectoryDescription: 'Tria la carpeta usada per a ordres i context.', + selectPermissionModeTitle: 'Selecciona el mode de permisos', + selectPermissionModeDescription: 'Controla com d’estrictes són les aprovacions.', + selectModelTitle: 'Selecciona el model d’IA', + selectModelDescription: 'Tria el model usat per aquesta sessió.', + selectSessionTypeTitle: 'Selecciona el tipus de sessió', + selectSessionTypeDescription: 'Tria una sessió simple o una lligada a un worktree de Git.', + searchPathsPlaceholder: 'Cerca camins...', noMachinesFound: 'No s\'han trobat màquines. Inicia una sessió de Happy al teu ordinador primer.', allMachinesOffline: 'Totes les màquines estan fora de línia', machineDetails: 'Veure detalls de la màquina →', @@ -275,18 +429,94 @@ export const ca: TranslationStructure = { startNewSessionInFolder: 'Nova sessió aquí', noMachineSelected: 'Si us plau, selecciona una màquina per iniciar la sessió', noPathSelected: 'Si us plau, selecciona un directori per iniciar la sessió', + machinePicker: { + searchPlaceholder: 'Cerca màquines...', + recentTitle: 'Recents', + favoritesTitle: 'Preferits', + allTitle: 'Totes', + emptyMessage: 'No hi ha màquines disponibles', + }, + pathPicker: { + enterPathTitle: 'Introdueix el camí', + enterPathPlaceholder: 'Introdueix un camí...', + customPathTitle: 'Camí personalitzat', + recentTitle: 'Recents', + favoritesTitle: 'Preferits', + suggestedTitle: 'Suggerits', + allTitle: 'Totes', + emptyRecent: 'No hi ha camins recents', + emptyFavorites: 'No hi ha camins preferits', + emptySuggested: 'No hi ha camins suggerits', + emptyAll: 'No hi ha camins', + }, sessionType: { title: 'Tipus de sessió', simple: 'Simple', - worktree: 'Worktree', + worktree: 'Worktree (Git)', comingSoon: 'Properament', }, + profileAvailability: { + requiresAgent: ({ agent }: { agent: string }) => `Requereix ${agent}`, + cliNotDetected: ({ cli }: { cli: string }) => `${cli} CLI no detectat`, + }, + cliBanners: { + cliNotDetectedTitle: ({ cli }: { cli: string }) => `${cli} CLI no detectat`, + dontShowFor: 'No mostris aquest avís per a', + thisMachine: 'aquesta màquina', + anyMachine: 'qualsevol màquina', + installCommand: ({ command }: { command: string }) => `Instal·la: ${command} •`, + installCliIfAvailable: ({ cli }: { cli: string }) => `Instal·la el CLI de ${cli} si està disponible •`, + viewInstallationGuide: 'Veure la guia d’instal·lació →', + viewGeminiDocs: 'Veure la documentació de Gemini →', + }, worktree: { creating: ({ name }: { name: string }) => `Creant worktree '${name}'...`, notGitRepo: 'Els worktrees requereixen un repositori git', failed: ({ error }: { error: string }) => `Error en crear el worktree: ${error}`, success: 'Worktree creat amb èxit', - } + }, + resume: { + title: 'Reprendre sessió', + optional: 'Reprendre: Opcional', + pickerTitle: 'Reprendre sessió', + subtitle: ({ agent }: { agent: string }) => `Enganxa un ID de sessió de ${agent} per reprendre`, + placeholder: ({ agent }: { agent: string }) => `Enganxa l’ID de sessió de ${agent}…`, + paste: 'Enganxa', + save: 'Desa', + clearAndRemove: 'Esborra', + helpText: 'Pots trobar els IDs de sessió a la pantalla d’informació de sessió.', + cannotApplyBody: 'Aquest ID de represa no es pot aplicar ara mateix. Happy iniciarà una sessió nova.', + }, + codexResumeBanner: { + title: 'Codex resume', + updateAvailable: 'Actualització disponible', + systemCodexVersion: ({ version }: { version: string }) => `Codex del sistema: ${version}`, + resumeServerVersion: ({ version }: { version: string }) => `Servidor de Codex resume: ${version}`, + notInstalled: 'no instal·lat', + latestVersion: ({ version }: { version: string }) => `(més recent ${version})`, + registryCheckFailed: ({ error }: { error: string }) => `Ha fallat la comprovació del registre: ${error}`, + install: 'Instal·lar', + update: 'Actualitzar', + reinstall: 'Reinstal·lar', + }, + codexResumeInstallModal: { + installTitle: 'Instal·lar Codex resume?', + updateTitle: 'Actualitzar Codex resume?', + reinstallTitle: 'Reinstal·lar Codex resume?', + description: 'Això instal·la un wrapper experimental del servidor MCP de Codex usat només per a operacions de represa.', + }, + codexAcpBanner: { + title: 'Codex ACP', + install: 'Instal·lar', + update: 'Actualitzar', + reinstall: 'Reinstal·lar', + }, + codexAcpInstallModal: { + installTitle: 'Instal·lar Codex ACP?', + updateTitle: 'Actualitzar Codex ACP?', + reinstallTitle: 'Reinstal·lar Codex ACP?', + description: 'Això instal·la un adaptador ACP experimental al voltant de Codex que admet carregar/reprendre fils.', + }, }, sessionHistory: { @@ -295,16 +525,105 @@ export const ca: TranslationStructure = { empty: 'No s\'han trobat sessions', today: 'Avui', yesterday: 'Ahir', - daysAgo: ({ count }: { count: number }) => `fa ${count} ${count === 1 ? 'dia' : 'dies'}`, + daysAgo: ({ count }: { count: number }) => 'fa ' + count + ' ' + (count === 1 ? 'dia' : 'dies'), viewAll: 'Veure totes les sessions', }, session: { inputPlaceholder: 'Escriu un missatge...', + resuming: 'Reprenent...', + resumeFailed: 'No s’ha pogut reprendre la sessió', + resumeSupportNoteChecking: 'Nota: Happy encara està comprovant si aquesta màquina pot reprendre la sessió del proveïdor.', + resumeSupportNoteUnverified: 'Nota: Happy no ha pogut verificar la compatibilitat de represa en aquesta màquina.', + resumeSupportDetails: { + cliNotDetected: 'No s’ha detectat la CLI a la màquina.', + capabilityProbeFailed: 'Ha fallat la comprovació de capacitats.', + acpProbeFailed: 'Ha fallat la comprovació ACP.', + loadSessionFalse: 'L’agent no admet carregar sessions.', + }, + inactiveResumable: 'Inactiva (es pot reprendre)', + inactiveMachineOffline: 'Inactiva (màquina fora de línia)', + inactiveNotResumable: 'Inactiva', + inactiveNotResumableNoticeTitle: 'Aquesta sessió no es pot reprendre', + inactiveNotResumableNoticeBody: ({ provider }: { provider: string }) => + `Aquesta sessió ha finalitzat i no es pot reprendre perquè ${provider} no admet restaurar el seu context aquí. Inicia una sessió nova per continuar.`, + machineOfflineNoticeTitle: 'La màquina està fora de línia', + machineOfflineNoticeBody: ({ machine }: { machine: string }) => + `“${machine}” està fora de línia, així que Happy encara no pot reprendre aquesta sessió. Torna-la a posar en línia per continuar.`, + machineOfflineCannotResume: 'La màquina està fora de línia. Torna-la a posar en línia per reprendre aquesta sessió.', + + sharing: { + title: 'Compartició', + directSharing: 'Compartició directa', + addShare: 'Comparteix amb un amic', + accessLevel: "Nivell d'accés", + shareWith: 'Comparteix amb', + sharedWith: 'Compartit amb', + noShares: 'No compartit', + viewOnly: 'Només lectura', + viewOnlyDescription: 'Pot veure la sessió però no pot enviar missatges.', + viewOnlyMode: 'Només lectura (sessió compartida)', + noEditPermission: 'Tens accés de només lectura a aquesta sessió.', + canEdit: 'Pot editar', + canEditDescription: 'Pot enviar missatges.', + canManage: 'Pot gestionar', + canManageDescription: 'Pot gestionar la compartició.', + stopSharing: 'Deixa de compartir', + recipientMissingKeys: "Aquest usuari encara no ha registrat claus d'encriptació.", + + publicLink: 'Enllaç públic', + publicLinkActive: "L'enllaç públic està actiu", + publicLinkDescription: 'Crea un enllaç perquè qualsevol pugui veure aquesta sessió.', + createPublicLink: 'Crea un enllaç públic', + regeneratePublicLink: "Regenera l'enllaç públic", + deletePublicLink: "Suprimeix l'enllaç públic", + linkToken: "Token de l'enllaç", + tokenNotRecoverable: 'Token no disponible', + tokenNotRecoverableDescription: "Per seguretat, els tokens d'enllaç públic es desen com a hash i no es poden recuperar. Regenera l'enllaç per crear un token nou.", + + expiresIn: 'Caduca en', + expiresOn: 'Caduca el', + days7: '7 dies', + days30: '30 dies', + never: 'Mai', + + maxUsesLabel: 'Ús màxim', + unlimited: 'Il·limitat', + uses10: '10 usos', + uses50: '50 usos', + usageCount: "Comptador d'usos", + usageCountWithMax: ({ used, max }: { used: number; max: number }) => `${used}/${max} usos`, + usageCountUnlimited: ({ used }: { used: number }) => `${used} usos`, + + requireConsent: 'Requereix consentiment', + requireConsentDescription: "Demana consentiment abans de registrar l'accés.", + consentRequired: 'Consentiment requerit', + consentDescription: "Aquest enllaç requereix el teu consentiment per registrar la teva IP i agent d'usuari.", + acceptAndView: 'Accepta i visualitza', + sharedBy: ({ name }: { name: string }) => `Compartit per ${name}`, + + shareNotFound: "L'enllaç de compartició no existeix o ha caducat", + failedToDecrypt: 'No s’ha pogut desxifrar la sessió', + noMessages: 'Encara no hi ha missatges', + session: 'Sessió', + }, }, commandPalette: { placeholder: 'Escriu una comanda o cerca...', + noCommandsFound: 'No s\'han trobat comandes', + }, + + commandView: { + completedWithNoOutput: '[Ordre completada sense sortida]', + }, + + voiceAssistant: { + connecting: 'Connectant...', + active: 'Assistent de veu actiu', + connectionError: 'Error de connexió', + label: 'Assistent de veu', + tapToEnd: 'Toca per acabar', }, server: { @@ -336,8 +655,18 @@ export const ca: TranslationStructure = { happySessionId: 'ID de la sessió de Happy', claudeCodeSessionId: 'ID de la sessió de Claude Code', claudeCodeSessionIdCopied: 'ID de la sessió de Claude Code copiat al porta-retalls', + aiProfile: 'Perfil d\'IA', aiProvider: 'Proveïdor d\'IA', failedToCopyClaudeCodeSessionId: 'Ha fallat copiar l\'ID de la sessió de Claude Code', + codexSessionId: 'ID de la sessió de Codex', + codexSessionIdCopied: 'ID de la sessió de Codex copiat al porta-retalls', + failedToCopyCodexSessionId: 'Ha fallat copiar l\'ID de la sessió de Codex', + opencodeSessionId: 'ID de la sessió d\'OpenCode', + opencodeSessionIdCopied: 'ID de la sessió d\'OpenCode copiat al porta-retalls', + auggieSessionId: 'ID de la sessió d\'Auggie', + auggieSessionIdCopied: 'ID de la sessió d\'Auggie copiat al porta-retalls', + geminiSessionId: 'ID de la sessió de Gemini', + geminiSessionIdCopied: 'ID de la sessió de Gemini copiat al porta-retalls', metadataCopied: 'Metadades copiades al porta-retalls', failedToCopyMetadata: 'Ha fallat copiar les metadades', failedToKillSession: 'Ha fallat finalitzar la sessió', @@ -347,6 +676,7 @@ export const ca: TranslationStructure = { lastUpdated: 'Última actualització', sequence: 'Seqüència', quickActions: 'Accions ràpides', + copyResumeCommand: 'Copia l’ordre de reprendre', viewMachine: 'Veure la màquina', viewMachineSubtitle: 'Veure detalls de la màquina i sessions', killSessionSubtitle: 'Finalitzar immediatament la sessió', @@ -357,8 +687,14 @@ export const ca: TranslationStructure = { operatingSystem: 'Sistema operatiu', processId: 'ID del procés', happyHome: 'Directori de Happy', + attachFromTerminal: 'Adjunta des del terminal', + tmuxTarget: 'Destí de tmux', + tmuxFallback: 'Fallback de tmux', copyMetadata: 'Copia les metadades', agentState: 'Estat de l\'agent', + rawJsonDevMode: 'JSON en brut (mode desenvolupador)', + sessionStatus: 'Estat de la sessió', + fullSessionObject: 'Objecte complet de la sessió', controlledByUser: 'Controlat per l\'usuari', pendingRequests: 'Sol·licituds pendents', activity: 'Activitat', @@ -375,7 +711,14 @@ export const ca: TranslationStructure = { deleteSessionWarning: 'Aquesta acció no es pot desfer. Tots els missatges i dades associats amb aquesta sessió s\'eliminaran permanentment.', failedToDeleteSession: 'Error en eliminar la sessió', sessionDeleted: 'Sessió eliminada amb èxit', - + manageSharing: 'Gestiona l\'accés', + manageSharingSubtitle: 'Comparteix aquesta sessió amb amics o crea un enllaç públic', + renameSession: 'Canvia el nom de la sessió', + renameSessionSubtitle: 'Canvia el nom de visualització d\'aquesta sessió', + renameSessionPlaceholder: 'Introduïu el nom de la sessió...', + failedToRenameSession: 'Error en canviar el nom de la sessió', + sessionRenamed: 'S\'ha canviat el nom de la sessió correctament', + }, components: { @@ -386,16 +729,57 @@ export const ca: TranslationStructure = { runIt: 'Executa\'l', scanQrCode: 'Escaneja el codi QR', openCamera: 'Obre la càmera', + installCommand: '$ npm i -g happy-coder', + runCommand: '$ happy', + }, + emptyMessages: { + noMessagesYet: 'Encara no hi ha missatges', + created: ({ time }: { time: string }) => `Creat ${time}`, + }, + emptySessionsTablet: { + noActiveSessions: 'No hi ha sessions actives', + startNewSessionDescription: 'Inicia una sessió nova a qualsevol de les teves màquines connectades.', + startNewSessionButton: 'Inicia una sessió nova', + openTerminalToStart: 'Obre un nou terminal a l\'ordinador per iniciar una sessió.', + }, + }, + + zen: { + title: 'Zen', + add: { + placeholder: 'Què s’ha de fer?', + }, + home: { + noTasksYet: 'Encara no hi ha tasques. Toca + per afegir-ne una.', + }, + view: { + workOnTask: 'Treballar en la tasca', + clarify: 'Aclarir', + delete: 'Suprimeix', + linkedSessions: 'Sessions enllaçades', + tapTaskTextToEdit: 'Toca el text de la tasca per editar', }, }, agentInput: { + envVars: { + title: 'Variables d\'entorn', + titleWithCount: ({ count }: { count: number }) => `Variables d'entorn (${count})`, + }, + resumeChip: { + withId: ({ title, id }: { title: string; id: string }) => `${title}: ${id}`, + withIdTruncated: ({ title, prefix, suffix }: { title: string; prefix: string; suffix: string }) => + `${title}: ${prefix}…${suffix}`, + }, permissionMode: { title: 'MODE DE PERMISOS', default: 'Per defecte', acceptEdits: 'Accepta edicions', plan: 'Mode de planificació', bypassPermissions: 'Mode Yolo', + badgeAccept: 'Accepta', + badgePlan: 'Pla', + badgeYolo: 'YOLO', badgeAcceptAllEdits: 'Accepta totes les edicions', badgeBypassAllPermissions: 'Omet tots els permisos', badgePlanMode: 'Mode de planificació', @@ -403,7 +787,13 @@ export const ca: TranslationStructure = { agent: { claude: 'Claude', codex: 'Codex', + opencode: 'OpenCode', gemini: 'Gemini', + auggie: 'Auggie', + }, + auggieIndexingChip: { + on: 'Indexing on', + off: 'Indexing off', }, model: { title: 'MODEL', @@ -412,22 +802,22 @@ export const ca: TranslationStructure = { codexPermissionMode: { title: 'MODE DE PERMISOS CODEX', default: 'Configuració del CLI', - readOnly: 'Read Only Mode', - safeYolo: 'Safe YOLO', + readOnly: 'Mode només lectura', + safeYolo: 'YOLO segur', yolo: 'YOLO', - badgeReadOnly: 'Read Only Mode', - badgeSafeYolo: 'Safe YOLO', + badgeReadOnly: 'Només lectura', + badgeSafeYolo: 'YOLO segur', badgeYolo: 'YOLO', }, codexModel: { - title: 'CODEX MODEL', - gpt5CodexLow: 'gpt-5-codex low', - gpt5CodexMedium: 'gpt-5-codex medium', - gpt5CodexHigh: 'gpt-5-codex high', - gpt5Minimal: 'GPT-5 Minimal', - gpt5Low: 'GPT-5 Low', - gpt5Medium: 'GPT-5 Medium', - gpt5High: 'GPT-5 High', + title: 'MODEL CODEX', + gpt5CodexLow: 'gpt-5-codex baix', + gpt5CodexMedium: 'gpt-5-codex mitjà', + gpt5CodexHigh: 'gpt-5-codex alt', + gpt5Minimal: 'GPT-5 Mínim', + gpt5Low: 'GPT-5 Baix', + gpt5Medium: 'GPT-5 Mitjà', + gpt5High: 'GPT-5 Alt', }, geminiPermissionMode: { title: 'MODE DE PERMISOS GEMINI', @@ -439,6 +829,21 @@ export const ca: TranslationStructure = { badgeSafeYolo: 'YOLO segur', badgeYolo: 'YOLO', }, + geminiModel: { + title: 'MODEL GEMINI', + gemini25Pro: { + label: 'Gemini 2.5 Pro', + description: 'Més capaç', + }, + gemini25Flash: { + label: 'Gemini 2.5 Flash', + description: 'Ràpid i eficient', + }, + gemini25FlashLite: { + label: 'Gemini 2.5 Flash Lite', + description: 'Més ràpid', + }, + }, context: { remaining: ({ percent }: { percent: number }) => `${percent}% restant`, }, @@ -446,6 +851,11 @@ export const ca: TranslationStructure = { fileLabel: 'FITXER', folderLabel: 'CARPETA', }, + actionMenu: { + title: 'ACCIONS', + files: 'Fitxers', + stop: 'Atura', + }, noMachinesAvailable: 'Sense màquines', }, @@ -504,6 +914,10 @@ export const ca: TranslationStructure = { applyChanges: 'Actualitza fitxer', viewDiff: 'Canvis del fitxer actual', question: 'Pregunta', + changeTitle: 'Canvia el títol', + }, + geminiExecute: { + cwd: ({ cwd }: { cwd: string }) => `📁 ${cwd}`, }, desc: { terminalCmd: ({ cmd }: { cmd: string }) => `Terminal(cmd: ${cmd})`, @@ -525,7 +939,19 @@ export const ca: TranslationStructure = { askUserQuestion: { submit: 'Envia resposta', multipleQuestions: ({ count }: { count: number }) => `${count} ${plural({ count, singular: 'pregunta', plural: 'preguntes' })}`, - } + }, + exitPlanMode: { + approve: 'Aprovar el pla', + reject: 'Rebutjar', + requestChanges: 'Demanar canvis', + requestChangesPlaceholder: 'Explica a Claude què vols canviar en aquest pla…', + requestChangesSend: 'Enviar comentaris', + requestChangesEmpty: 'Escriu què vols canviar.', + requestChangesFailed: 'No s\'han pogut demanar canvis. Torna-ho a provar.', + responded: 'Resposta enviada', + approvalMessage: 'Aprovo aquest pla. Si us plau, continua amb la implementació.', + rejectionMessage: 'No aprovo aquest pla. Si us plau, revisa’l o pregunta’m quins canvis voldria.', + }, }, files: { @@ -666,6 +1092,11 @@ export const ca: TranslationStructure = { deviceLinkedSuccessfully: 'Dispositiu enllaçat amb èxit', terminalConnectedSuccessfully: 'Terminal connectat amb èxit', invalidAuthUrl: 'URL d\'autenticació no vàlida', + microphoneAccessRequiredTitle: 'Cal accés al micròfon', + microphoneAccessRequiredRequestPermission: 'Happy necessita accés al micròfon per al xat de veu. Concedeix el permís quan se’t demani.', + microphoneAccessRequiredEnableInSettings: 'Happy necessita accés al micròfon per al xat de veu. Activa l’accés al micròfon a la configuració del dispositiu.', + microphoneAccessRequiredBrowserInstructions: 'Permet l’accés al micròfon a la configuració del navegador. Potser hauràs de fer clic a la icona del cadenat a la barra d’adreces i habilitar el permís del micròfon per a aquest lloc.', + openSettings: 'Obre la configuració', developerMode: 'Mode desenvolupador', developerModeEnabled: 'Mode desenvolupador activat', developerModeDisabled: 'Mode desenvolupador desactivat', @@ -720,6 +1151,15 @@ export const ca: TranslationStructure = { daemon: 'Dimoni', status: 'Estat', stopDaemon: 'Atura el dimoni', + stopDaemonConfirmTitle: 'Aturar el dimoni?', + stopDaemonConfirmBody: 'No podràs iniciar sessions noves en aquesta màquina fins que reiniciïs el dimoni a l’ordinador. Les sessions actuals continuaran actives.', + daemonStoppedTitle: 'Dimoni aturat', + stopDaemonFailed: 'No s’ha pogut aturar el dimoni. Pot ser que no estigui en execució.', + renameTitle: 'Canvia el nom de la màquina', + renameDescription: 'Dona a aquesta màquina un nom personalitzat. Deixa-ho buit per usar el hostname per defecte.', + renamePlaceholder: 'Introdueix el nom de la màquina', + renamedSuccess: 'Màquina reanomenada correctament', + renameFailed: 'No s’ha pogut reanomenar la màquina', lastKnownPid: 'Últim PID conegut', lastKnownHttpPort: 'Últim port HTTP conegut', startedAt: 'Iniciat a', @@ -736,20 +1176,40 @@ export const ca: TranslationStructure = { lastSeen: 'Vist per última vegada', never: 'Mai', metadataVersion: 'Versió de les metadades', + detectedClis: 'CLI detectats', + detectedCliNotDetected: 'No detectat', + detectedCliUnknown: 'Desconegut', + detectedCliNotSupported: 'No compatible (actualitza happy-cli)', untitledSession: 'Sessió sense títol', back: 'Enrere', + notFound: 'Màquina no trobada', + unknownMachine: 'màquina desconeguda', + unknownPath: 'camí desconegut', + tmux: { + overrideTitle: 'Sobreescriu la configuració global de tmux', + overrideEnabledSubtitle: 'La configuració personalitzada de tmux s\'aplica a les noves sessions d\'aquesta màquina.', + overrideDisabledSubtitle: 'Les noves sessions utilitzen la configuració global de tmux.', + notDetectedSubtitle: 'tmux no s\'ha detectat en aquesta màquina.', + notDetectedMessage: 'tmux no s\'ha detectat en aquesta màquina. Instal·la tmux i actualitza la detecció.', + }, }, message: { switchedToMode: ({ mode }: { mode: string }) => `S'ha canviat al mode ${mode}`, + discarded: 'Descartat', unknownEvent: 'Esdeveniment desconegut', usageLimitUntil: ({ time }: { time: string }) => `Límit d'ús assolit fins a ${time}`, unknownTime: 'temps desconegut', }, + chatFooter: { + permissionsTerminalOnly: 'Els permisos només es mostren al terminal. Reinicia o envia un missatge per controlar des de l\'app.', + }, + codex: { // Codex permission dialog buttons permissions: { + yesAlwaysAllowCommand: 'Sí, permet globalment', yesForSession: 'Sí, i no preguntar per aquesta sessió', stopAndExplain: 'Atura, i explica què fer', } @@ -760,6 +1220,9 @@ export const ca: TranslationStructure = { permissions: { yesAllowAllEdits: 'Sí, permet totes les edicions durant aquesta sessió', yesForTool: 'Sí, no tornis a preguntar per aquesta eina', + yesForCommandPrefix: "Sí, no tornis a preguntar per aquest prefix d'ordre", + yesForSubcommand: "Sí, no tornis a preguntar per aquesta subordre", + yesForCommandName: "Sí, no tornis a preguntar per aquesta ordre", noTellClaude: 'No, proporciona comentaris', } }, @@ -773,6 +1236,7 @@ export const ca: TranslationStructure = { textCopied: 'Text copiat al porta-retalls', failedToCopy: 'No s\'ha pogut copiar el text al porta-retalls', noTextToCopy: 'No hi ha text disponible per copiar', + failedToOpen: 'No s\'ha pogut obrir la selecció de text. Torna-ho a provar.', }, markdown: { @@ -792,11 +1256,14 @@ export const ca: TranslationStructure = { edit: 'Edita artefacte', delete: 'Elimina', updateError: 'No s\'ha pogut actualitzar l\'artefacte. Si us plau, torna-ho a provar.', + deleteError: 'No s\'ha pogut eliminar l\'artefacte. Torna-ho a provar.', notFound: 'Artefacte no trobat', discardChanges: 'Descartar els canvis?', discardChangesDescription: 'Tens canvis sense desar. Estàs segur que vols descartar-los?', deleteConfirm: 'Eliminar artefacte?', deleteConfirmDescription: 'Aquest artefacte s\'eliminarà permanentment.', + noContent: 'Sense contingut', + untitled: 'Sense títol', titlePlaceholder: 'Títol de l\'artefacte', bodyPlaceholder: 'Escriu aquí el contingut...', save: 'Desa', @@ -812,6 +1279,8 @@ export const ca: TranslationStructure = { friends: { // Friends feature title: 'Amics', + sharedSessions: 'Sessions compartides', + noSharedSessions: 'Encara no hi ha sessions compartides', manageFriends: 'Gestiona els teus amics i connexions', searchTitle: 'Buscar amics', pendingRequests: 'Sol·licituds d\'amistat', @@ -877,6 +1346,8 @@ export const ca: TranslationStructure = { profiles: { title: 'Perfils', subtitle: 'Gestiona els teus perfils de configuració', + sessionUses: ({ profile }: { profile: string }) => `Aquesta sessió utilitza: ${profile}`, + profilesFixedPerSession: 'Els perfils són fixos per sessió. Per utilitzar un perfil diferent, inicia una sessió nova.', noProfile: 'Cap perfil', noProfileDescription: 'Crea un perfil per gestionar la teva configuració d\'entorn', addProfile: 'Afegeix un perfil', @@ -894,8 +1365,231 @@ export const ca: TranslationStructure = { tmuxTempDir: 'Directori temporal tmux', enterTmuxTempDir: 'Introdueix el directori temporal tmux', tmuxUpdateEnvironment: 'Actualitza l\'entorn tmux', - deleteConfirm: 'Segur que vols eliminar aquest perfil?', + deleteConfirm: ({ name }: { name: string }) => `Segur que vols eliminar el perfil "${name}"?`, nameRequired: 'El nom del perfil és obligatori', + builtIn: 'Integrat', + custom: 'Personalitzat', + builtInSaveAsHint: 'Desar un perfil integrat crea un nou perfil personalitzat.', + builtInNames: { + anthropic: 'Anthropic (Per defecte)', + deepseek: 'DeepSeek (Raonament)', + zai: 'Z.AI (GLM-4.6)', + codex: 'Codex (Default)', + openai: 'OpenAI (GPT-5)', + azureOpenai: 'Azure OpenAI', + gemini: 'Gemini (Default)', + geminiApiKey: 'Gemini (API key)', + geminiVertex: 'Gemini (Vertex AI)', + }, + groups: { + favorites: 'Preferits', + custom: 'Els teus perfils', + builtIn: 'Perfils integrats', + }, + actions: { + viewEnvironmentVariables: 'Variables d\'entorn', + addToFavorites: 'Afegeix als preferits', + removeFromFavorites: 'Treu dels preferits', + editProfile: 'Edita el perfil', + duplicateProfile: 'Duplica el perfil', + deleteProfile: 'Elimina el perfil', + }, + copySuffix: '(Còpia)', + duplicateName: 'Ja existeix un perfil amb aquest nom', + setupInstructions: { + title: 'Instruccions de configuració', + viewOfficialGuide: 'Veure la guia oficial de configuració', + }, + machineLogin: { + title: 'Inici de sessió CLI', + subtitle: 'Aquest perfil depèn d’una memòria cau d’inici de sessió del CLI a la màquina seleccionada.', + status: { + loggedIn: 'Sessió iniciada', + notLoggedIn: 'Sense sessió iniciada', + }, + claudeCode: { + title: 'Claude Code', + instructions: 'Executa `claude` i després escriu `/login` per iniciar sessió.', + warning: 'Nota: definir `ANTHROPIC_AUTH_TOKEN` substitueix l’inici de sessió del CLI.', + }, + codex: { + title: 'Codex', + instructions: 'Executa `codex login` per iniciar sessió.', + }, + geminiCli: { + title: 'Gemini CLI', + instructions: 'Executa `gemini auth` per iniciar sessió.', + }, + }, + requirements: { + secretRequired: 'Secret', + configured: 'Configurada a la màquina', + notConfigured: 'No configurada', + checking: 'Comprovant…', + missingConfigForProfile: ({ env }: { env: string }) => `Aquest perfil requereix que ${env} estigui configurat a la màquina.`, + modalTitle: 'Cal un secret', + modalBody: 'Aquest perfil requereix un secret.\n\nOpcions disponibles:\n• Fer servir l’entorn de la màquina (recomanat)\n• Fer servir un secret desat a la configuració de l’app\n• Introduir un secret només per a aquesta sessió', + sectionTitle: 'Requisits', + sectionSubtitle: 'Aquests camps s’utilitzen per comprovar l’estat i evitar fallades inesperades.', + secretEnvVarPromptDescription: 'Introdueix el nom de la variable d’entorn secreta necessària (p. ex., OPENAI_API_KEY).', + modalHelpWithEnv: ({ env }: { env: string }) => `Aquest perfil necessita ${env}. Tria una opció a continuació.`, + modalHelpGeneric: 'Aquest perfil necessita un secret. Tria una opció a continuació.', + chooseOptionTitle: 'Tria una opció', + machineEnvStatus: { + theMachine: 'la màquina', + checkFor: ({ env }: { env: string }) => `Comprova ${env}`, + checking: ({ env }: { env: string }) => `Comprovant ${env}…`, + found: ({ env, machine }: { env: string; machine: string }) => `${env} trobat a ${machine}`, + notFound: ({ env, machine }: { env: string; machine: string }) => `${env} no trobat a ${machine}`, + }, + machineEnvSubtitle: { + checking: 'Comprovant l’entorn del dimoni…', + found: 'Trobat a l’entorn del dimoni a la màquina.', + notFound: 'Configura-ho a l’entorn del dimoni a la màquina i reinicia el dimoni.', + }, + options: { + none: { + title: 'Cap', + subtitle: 'No requereix secret ni inici de sessió per CLI.', + }, + machineLogin: { + subtitle: 'Requereix haver iniciat sessió via un CLI a la màquina de destinació.', + longSubtitle: 'Requereix haver iniciat sessió via el CLI del backend d’IA escollit a la màquina de destinació.', + }, + useMachineEnvironment: { + title: 'Fer servir l’entorn de la màquina', + subtitleWithEnv: ({ env }: { env: string }) => `Fer servir ${env} de l’entorn del dimoni.`, + subtitleGeneric: 'Fer servir el secret de l’entorn del dimoni.', + }, + useSavedSecret: { + title: 'Fer servir un secret desat', + subtitle: 'Selecciona (o afegeix) un secret desat a l’app.', + }, + enterOnce: { + title: 'Introduir un secret', + subtitle: 'Enganxa un secret només per a aquesta sessió (no es desarà).', + }, + }, + secretEnvVar: { + title: 'Variable d’entorn del secret', + subtitle: 'Introdueix el nom de la variable d’entorn que aquest proveïdor espera per al secret (p. ex., OPENAI_API_KEY).', + label: 'Nom de la variable d’entorn', + }, + sections: { + machineEnvironment: 'Entorn de la màquina', + useOnceTitle: 'Fer servir una vegada', + useOnceLabel: 'Introdueix un secret', + useOnceFooter: 'Enganxa un secret només per a aquesta sessió. No es desarà.', + }, + actions: { + useMachineEnvironment: { + subtitle: 'Comença amb la clau que ja és present a la màquina.', + }, + useOnceButton: 'Fer servir una vegada (només sessió)', + }, + }, + defaultSessionType: 'Tipus de sessió predeterminat', + defaultPermissionMode: { + title: 'Mode de permisos predeterminat', + descriptions: { + default: 'Demana permisos', + acceptEdits: 'Aprova edicions automàticament', + plan: 'Planifica abans d\'executar', + bypassPermissions: 'Salta tots els permisos', + }, + }, + aiBackend: { + title: 'Backend d\'IA', + selectAtLeastOneError: 'Selecciona com a mínim un backend d\'IA.', + claudeSubtitle: 'CLI de Claude', + codexSubtitle: 'CLI de Codex', + opencodeSubtitle: 'CLI d\'OpenCode', + geminiSubtitleExperimental: 'CLI de Gemini (experimental)', + auggieSubtitle: 'CLI d\'Auggie', + }, + tmux: { + title: 'Tmux', + spawnSessionsTitle: 'Inicia sessions a Tmux', + spawnSessionsEnabledSubtitle: 'Les sessions s\'inicien en noves finestres de tmux.', + spawnSessionsDisabledSubtitle: 'Les sessions s\'inicien en un shell normal (sense integració amb tmux)', + isolatedServerTitle: 'Servidor tmux aïllat', + isolatedServerEnabledSubtitle: 'Inicia sessions en un servidor tmux aïllat (recomanat).', + isolatedServerDisabledSubtitle: 'Inicia sessions al servidor tmux predeterminat.', + sessionNamePlaceholder: 'Buit = sessió actual/més recent', + tempDirPlaceholder: 'Deixa-ho buit per generar automàticament', + }, + previewMachine: { + title: 'Previsualitza màquina', + itemTitle: 'Màquina de previsualització per a variables d\'entorn', + selectMachine: 'Selecciona màquina', + resolveSubtitle: 'S\'usa només per previsualitzar els valors resolts a continuació (no canvia el que es desa).', + selectSubtitle: 'Selecciona una màquina per previsualitzar els valors resolts a continuació.', + }, + environmentVariables: { + title: 'Variables d\'entorn', + addVariable: 'Afegeix variable', + namePlaceholder: 'Nom de variable (p. ex., MY_CUSTOM_VAR)', + valuePlaceholder: 'Valor (p. ex., my-value o ${MY_VAR})', + validation: { + nameRequired: 'Introdueix un nom de variable.', + invalidNameFormat: 'Els noms de variable han de ser lletres majúscules, números i guions baixos, i no poden començar amb un número.', + duplicateName: 'Aquesta variable ja existeix.', + }, + card: { + valueLabel: 'Valor:', + fallbackValueLabel: 'Valor de reserva:', + valueInputPlaceholder: 'Valor', + defaultValueInputPlaceholder: 'Valor per defecte', + fallbackDisabledForVault: 'Els valors de reserva estan desactivats quan s\'utilitza el magatzem de secrets.', + secretNotRetrieved: 'Valor secret - no es recupera per seguretat', + secretToggleLabel: 'Amaga el valor a la UI', + secretToggleSubtitle: 'Amaga el valor a la UI i evita obtenir-lo de la màquina per a la previsualització.', + secretToggleEnforcedByDaemon: 'Imposat pel dimoni', + secretToggleEnforcedByVault: 'Imposat pel cofre de secrets', + secretToggleResetToAuto: 'Restablir a automàtic', + requirementRequiredLabel: 'Obligatori', + requirementRequiredSubtitle: 'Bloqueja la creació de la sessió si falta la variable.', + requirementUseVaultLabel: 'Utilitza el magatzem de secrets', + requirementUseVaultSubtitle: 'Utilitza un secret desat (sense valors de reserva).', + defaultSecretLabel: 'Secret per defecte', + overridingDefault: ({ expectedValue }: { expectedValue: string }) => + `S'està substituint el valor predeterminat documentat: ${expectedValue}`, + useMachineEnvToggle: 'Utilitza el valor de l\'entorn de la màquina', + resolvedOnSessionStart: 'Es resol quan la sessió s\'inicia a la màquina seleccionada.', + sourceVariableLabel: 'Variable d\'origen', + sourceVariablePlaceholder: 'Nom de variable d\'origen (p. ex., Z_AI_MODEL)', + checkingMachine: ({ machine }: { machine: string }) => `Comprovant ${machine}...`, + emptyOnMachine: ({ machine }: { machine: string }) => `Buit a ${machine}`, + emptyOnMachineUsingFallback: ({ machine }: { machine: string }) => `Buit a ${machine} (utilitzant reserva)`, + notFoundOnMachine: ({ machine }: { machine: string }) => `No trobat a ${machine}`, + notFoundOnMachineUsingFallback: ({ machine }: { machine: string }) => `No trobat a ${machine} (utilitzant reserva)`, + valueFoundOnMachine: ({ machine }: { machine: string }) => `Valor trobat a ${machine}`, + differsFromDocumented: ({ expectedValue }: { expectedValue: string }) => + `Difiereix del valor documentat: ${expectedValue}`, + }, + preview: { + secretValueHidden: ({ value }: { value: string }) => `${value} - ocult per seguretat`, + hiddenValue: '***ocult***', + emptyValue: '(buit)', + sessionWillReceive: ({ name, value }: { name: string; value: string }) => + `La sessió rebrà: ${name} = ${value}`, + }, + previewModal: { + titleWithProfile: ({ profileName }: { profileName: string }) => `Variables d'entorn · ${profileName}`, + descriptionPrefix: 'Aquestes variables d\'entorn s\'envien en iniciar la sessió. Els valors es resolen usant el dimoni a', + descriptionFallbackMachine: 'la màquina seleccionada', + descriptionSuffix: '.', + emptyMessage: 'No hi ha variables d\'entorn configurades per a aquest perfil.', + checkingSuffix: '(comprovant…)', + detail: { + fixed: 'Fix', + machine: 'Màquina', + checking: 'Comprovant', + fallback: 'Reserva', + missing: 'Falta', + }, + }, + }, delete: { title: 'Eliminar Perfil', message: ({ name }: { name: string }) => `Estàs segur que vols eliminar "${name}"? Aquesta acció no es pot desfer.`, @@ -904,6 +1598,49 @@ export const ca: TranslationStructure = { }, }, + secrets: { + addTitle: 'Nou secret', + savedTitle: 'Secrets desats', + badgeReady: 'Secret', + badgeRequired: 'Cal un secret', + missingForProfile: ({ env }: { env: string | null }) => + `Falta el secret (${env ?? 'secret'}). Configura’l a la màquina o selecciona/introdueix un secret.`, + defaultForProfileTitle: 'Secret predeterminat', + defineDefaultForProfileTitle: 'Defineix el secret predeterminat per a aquest perfil', + addSubtitle: 'Afegeix un secret desat', + noneTitle: 'Cap', + noneSubtitle: 'Fes servir l’entorn de la màquina o introdueix un secret per a aquesta sessió', + emptyTitle: 'No hi ha secrets desats', + emptySubtitle: 'Afegeix-ne un per utilitzar perfils amb secret sense configurar variables d’entorn a la màquina.', + savedHiddenSubtitle: 'Desada (valor ocult)', + defaultLabel: 'Per defecte', + fields: { + name: 'Nom', + value: 'Valor', + }, + placeholders: { + nameExample: 'p. ex., Work OpenAI', + }, + validation: { + nameRequired: 'El nom és obligatori.', + valueRequired: 'El valor és obligatori.', + }, + actions: { + replace: 'Substitueix', + replaceValue: 'Substitueix el valor', + setDefault: 'Estableix com a per defecte', + unsetDefault: 'Treu com a per defecte', + }, + prompts: { + renameTitle: 'Reanomena el secret', + renameDescription: 'Actualitza el nom descriptiu d’aquest secret.', + replaceValueTitle: 'Substitueix el valor del secret', + replaceValueDescription: 'Enganxa el nou valor del secret. No es tornarà a mostrar després de desar-lo.', + deleteTitle: 'Elimina el secret', + deleteConfirm: ({ name }: { name: string }) => `Vols eliminar “${name}”? Aquesta acció no es pot desfer.`, + }, + }, + feed: { // Feed notifications for friend requests and acceptances friendRequestFrom: ({ name }: { name: string }) => `${name} t'ha enviat una sol·licitud d'amistat`, diff --git a/expo-app/sources/text/translations/en.ts b/expo-app/sources/text/translations/en.ts index 7bddc729b..a81ceadb7 100644 --- a/expo-app/sources/text/translations/en.ts +++ b/expo-app/sources/text/translations/en.ts @@ -1,5 +1,3 @@ -import type { TranslationStructure } from '../_default'; - /** * English plural helper function * English has 2 plural forms: singular, plural @@ -14,10 +12,10 @@ function plural({ count, singular, plural }: { count: number; singular: string; * ENGLISH TRANSLATIONS - DEDICATED FILE * * This file represents the new translation architecture where each language - * has its own dedicated file instead of being embedded in _default.ts. + * has its own dedicated file instead of being embedded in _types.ts. * * STRUCTURE CHANGE: - * - Previously: All languages in _default.ts as objects + * - Previously: All languages in a single default file * - Now: Separate files for each language (en.ts, ru.ts, pl.ts, es.ts, etc.) * - Benefit: Better maintainability, smaller files, easier language management * @@ -29,7 +27,7 @@ function plural({ count, singular, plural }: { count: number; singular: string; * - Type safety enforced by TranslationStructure interface * - New translation keys must be added to ALL language files */ -export const en: TranslationStructure = { +export const en = { tabs: { // Tab navigation labels inbox: 'Inbox', @@ -46,6 +44,8 @@ export const en: TranslationStructure = { common: { // Simple string constants + add: 'Add', + actions: 'Actions', cancel: 'Cancel', authenticate: 'Authenticate', save: 'Save', @@ -62,7 +62,11 @@ export const en: TranslationStructure = { yes: 'Yes', no: 'No', discard: 'Discard', + discardChanges: 'Discard changes', + unsavedChangesWarning: 'You have unsaved changes.', + keepEditing: 'Keep editing', version: 'Version', + details: 'Details', copy: 'Copy', copied: 'Copied', scanning: 'Scanning...', @@ -75,6 +79,18 @@ export const en: TranslationStructure = { retry: 'Retry', delete: 'Delete', optional: 'optional', + noMatches: 'No matches', + all: 'All', + machine: 'machine', + clearSearch: 'Clear search', + refresh: 'Refresh', + }, + + dropdown: { + category: { + general: 'General', + results: 'Results', + }, }, profile: { @@ -111,6 +127,16 @@ export const en: TranslationStructure = { enterSecretKey: 'Please enter a secret key', invalidSecretKey: 'Invalid secret key. Please check and try again.', enterUrlManually: 'Enter URL manually', + openMachine: 'Open machine', + terminalUrlPlaceholder: 'happy://terminal?...', + restoreQrInstructions: '1. Open Happy on your mobile device\n2. Go to Settings → Account\n3. Tap "Link New Device"\n4. Scan this QR code', + restoreWithSecretKeyInstead: 'Restore with Secret Key Instead', + restoreWithSecretKeyDescription: 'Enter your secret key to restore access to your account.', + secretKeyPlaceholder: 'XXXXX-XXXXX-XXXXX...', + unsupported: { + connectTitle: ({ name }: { name: string }) => `Connect ${name}`, + runCommandInTerminal: 'Run the following command in your terminal:', + }, }, settings: { @@ -151,6 +177,12 @@ export const en: TranslationStructure = { usageSubtitle: 'View your API usage and costs', profiles: 'Profiles', profilesSubtitle: 'Manage environment variable profiles for sessions', + secrets: 'Secrets', + secretsSubtitle: 'Manage saved secrets (never shown again after entry)', + terminal: 'Terminal', + session: 'Session', + sessionSubtitleTmuxEnabled: 'Tmux enabled', + sessionSubtitleMessageSendingAndTmux: 'Message sending and tmux', // Dynamic settings messages accountConnected: ({ service }: { service: string }) => `${service} account connected`, @@ -188,6 +220,21 @@ export const en: TranslationStructure = { wrapLinesInDiffsDescription: 'Wrap long lines instead of horizontal scrolling in diff views', alwaysShowContextSize: 'Always Show Context Size', alwaysShowContextSizeDescription: 'Display context usage even when not near limit', + agentInputActionBarLayout: 'Input Action Bar', + agentInputActionBarLayoutDescription: 'Choose how action chips are displayed above the input', + agentInputActionBarLayoutOptions: { + auto: 'Auto', + wrap: 'Wrap', + scroll: 'Scrollable', + collapsed: 'Collapsed', + }, + agentInputChipDensity: 'Action Chip Density', + agentInputChipDensityDescription: 'Choose whether action chips show labels or icons', + agentInputChipDensityOptions: { + auto: 'Auto', + labels: 'Labels', + icons: 'Icons only', + }, avatarStyle: 'Avatar Style', avatarStyleDescription: 'Choose session avatar appearance', avatarOptions: { @@ -208,11 +255,31 @@ export const en: TranslationStructure = { experimentalFeatures: 'Experimental Features', experimentalFeaturesEnabled: 'Experimental features enabled', experimentalFeaturesDisabled: 'Using stable features only', - webFeatures: 'Web Features', - webFeaturesDescription: 'Features available only in the web version of the app.', + experimentalOptions: 'Experimental options', + experimentalOptionsDescription: 'Choose which experimental features are enabled.', + expUsageReporting: 'Usage reporting', + expUsageReportingSubtitle: 'Enable usage and token reporting screens', + expFileViewer: 'File viewer', + expFileViewerSubtitle: 'Enable the session file viewer entrypoint', + expShowThinkingMessages: 'Show thinking messages', + expShowThinkingMessagesSubtitle: 'Show assistant thinking/status messages in chat', + expSessionType: 'Session type selector', + expSessionTypeSubtitle: 'Show the session type selector (simple vs worktree)', + expZen: 'Zen', + expZenSubtitle: 'Enable the Zen navigation entry', + expVoiceAuthFlow: 'Voice auth flow', + expVoiceAuthFlowSubtitle: 'Use authenticated voice token flow (paywall-aware)', + expInboxFriends: 'Inbox & Friends', + expInboxFriendsSubtitle: 'Enable the Inbox tab and Friends features', + expCodexResume: 'Codex resume', + expCodexResumeSubtitle: 'Enable Codex session resume using a separate Codex install (experimental)', + expCodexAcp: 'Codex ACP', + expCodexAcpSubtitle: 'Use Codex via ACP (codex-acp) instead of MCP (experimental)', + webFeatures: 'Web Features', + webFeaturesDescription: 'Features available only in the web version of the app.', enterToSend: 'Enter to Send', - enterToSendEnabled: 'Press Enter to send messages', - enterToSendDisabled: 'Press ⌘+Enter to send messages', + enterToSendEnabled: 'Press Enter to send (Shift+Enter for a new line)', + enterToSendDisabled: 'Enter inserts a new line', commandPalette: 'Command Palette', commandPaletteEnabled: 'Press ⌘K to open', commandPaletteDisabled: 'Quick command access disabled', @@ -220,9 +287,20 @@ export const en: TranslationStructure = { markdownCopyV2Subtitle: 'Long press opens copy modal', hideInactiveSessions: 'Hide inactive sessions', hideInactiveSessionsSubtitle: 'Show only active chats in your list', + groupInactiveSessionsByProject: 'Group inactive sessions by project', + groupInactiveSessionsByProjectSubtitle: 'Organize inactive chats under each project', enhancedSessionWizard: 'Enhanced Session Wizard', enhancedSessionWizardEnabled: 'Profile-first session launcher active', enhancedSessionWizardDisabled: 'Using standard session launcher', + profiles: 'AI Profiles', + profilesEnabled: 'Profile selection enabled', + profilesDisabled: 'Profile selection disabled', + pickerSearch: 'Picker Search', + pickerSearchSubtitle: 'Show a search field in machine and path pickers', + machinePickerSearch: 'Machine search', + machinePickerSearchSubtitle: 'Show a search field in machine pickers', + pathPickerSearch: 'Path search', + pathPickerSearchSubtitle: 'Show a search field in path pickers', }, errors: { @@ -248,6 +326,7 @@ export const en: TranslationStructure = { webViewLoadFailed: 'Failed to load authentication page', failedToLoadProfile: 'Failed to load user profile', userNotFound: 'User not found', + invalidShareLink: 'Invalid or expired share link', sessionDeleted: 'Session has been deleted', sessionDeletedDescription: 'This session has been permanently removed', @@ -270,11 +349,84 @@ export const en: TranslationStructure = { failedToRemoveFriend: 'Failed to remove friend', searchFailed: 'Search failed. Please try again.', failedToSendRequest: 'Failed to send friend request', + failedToResumeSession: 'Failed to resume session', + failedToSendMessage: 'Failed to send message', + cannotShareWithSelf: 'Cannot share with yourself', + canOnlyShareWithFriends: 'Can only share with friends', + shareNotFound: 'Share not found', + publicShareNotFound: 'Public share not found or expired', + consentRequired: 'Consent required for access', + maxUsesReached: 'Maximum uses reached', + missingPermissionId: 'Missing permission request id', + codexResumeNotInstalledTitle: 'Codex resume is not installed on this machine', + codexResumeNotInstalledMessage: + 'To resume a Codex conversation, install the Codex resume server on the target machine (Machine Details → Codex resume).', + codexAcpNotInstalledTitle: 'Codex ACP is not installed on this machine', + codexAcpNotInstalledMessage: + 'To use the Codex ACP experiment, install codex-acp on the target machine (Machine Details → Codex ACP) or disable the experiment.', + }, + + deps: { + installNotSupported: 'Update Happy CLI to install this dependency.', + installFailed: 'Install failed', + installed: 'Installed', + installLog: ({ path }: { path: string }) => `Install log: ${path}`, + installable: { + codexResume: { + title: 'Codex resume server', + installSpecTitle: 'Codex resume install source', + }, + codexAcp: { + title: 'Codex ACP adapter', + installSpecTitle: 'Codex ACP install source', + }, + installSpecDescription: 'NPM/Git/file spec passed to `npm install` (experimental). Leave empty to use daemon default.', + }, + ui: { + notAvailable: 'Not available', + notAvailableUpdateCli: 'Not available (update CLI)', + errorRefresh: 'Error (refresh)', + installed: 'Installed', + installedWithVersion: ({ version }: { version: string }) => `Installed (v${version})`, + installedUpdateAvailable: ({ installedVersion, latestVersion }: { installedVersion: string; latestVersion: string }) => + `Installed (v${installedVersion}) — update available (v${latestVersion})`, + notInstalled: 'Not installed', + latest: 'Latest', + latestSubtitle: ({ version, tag }: { version: string; tag: string }) => `${version} (tag: ${tag})`, + registryCheck: 'Registry check', + registryCheckFailed: ({ error }: { error: string }) => `Failed: ${error}`, + installSource: 'Install source', + installSourceDefault: '(default)', + installSpecPlaceholder: 'e.g. file:/path/to/pkg or github:owner/repo#branch', + lastInstallLog: 'Last install log', + installLogTitle: 'Install log', + }, }, newSession: { // Used by new-session screen and launch flows title: 'Start New Session', + selectAiProfileTitle: 'Select AI Profile', + selectAiProfileDescription: 'Select an AI profile to apply environment variables and defaults to your session.', + changeProfile: 'Change Profile', + aiBackendSelectedByProfile: 'AI backend is selected by your profile. To change it, select a different profile.', + selectAiBackendTitle: 'Select AI Backend', + aiBackendLimitedByProfileAndMachineClis: 'Limited by your selected profile and available CLIs on this machine.', + aiBackendSelectWhichAiRuns: 'Select which AI runs your session.', + aiBackendNotCompatibleWithSelectedProfile: 'Not compatible with the selected profile.', + aiBackendCliNotDetectedOnMachine: ({ cli }: { cli: string }) => `${cli} CLI not detected on this machine.`, + selectMachineTitle: 'Select Machine', + selectMachineDescription: 'Choose where this session runs.', + selectPathTitle: 'Select Path', + selectWorkingDirectoryTitle: 'Select Working Directory', + selectWorkingDirectoryDescription: 'Pick the folder used for commands and context.', + selectPermissionModeTitle: 'Select Permission Mode', + selectPermissionModeDescription: 'Control how strictly actions require approval.', + selectModelTitle: 'Select AI Model', + selectModelDescription: 'Choose the model used by this session.', + selectSessionTypeTitle: 'Select Session Type', + selectSessionTypeDescription: 'Choose a simple session or one tied to a Git worktree.', + searchPathsPlaceholder: 'Search paths...', noMachinesFound: 'No machines found. Start a Happy session on your computer first.', allMachinesOffline: 'All machines appear offline', machineDetails: 'View machine details →', @@ -290,18 +442,94 @@ export const en: TranslationStructure = { notConnectedToServer: 'Not connected to server. Check your internet connection.', noMachineSelected: 'Please select a machine to start the session', noPathSelected: 'Please select a directory to start the session in', + machinePicker: { + searchPlaceholder: 'Search machines...', + recentTitle: 'Recent', + favoritesTitle: 'Favorites', + allTitle: 'All', + emptyMessage: 'No machines available', + }, + pathPicker: { + enterPathTitle: 'Enter Path', + enterPathPlaceholder: 'Enter a path...', + customPathTitle: 'Custom Path', + recentTitle: 'Recent', + favoritesTitle: 'Favorites', + suggestedTitle: 'Suggested', + allTitle: 'All', + emptyRecent: 'No recent paths', + emptyFavorites: 'No favorite paths', + emptySuggested: 'No suggested paths', + emptyAll: 'No paths', + }, sessionType: { title: 'Session Type', simple: 'Simple', worktree: 'Worktree', comingSoon: 'Coming soon', }, + profileAvailability: { + requiresAgent: ({ agent }: { agent: string }) => `Requires ${agent}`, + cliNotDetected: ({ cli }: { cli: string }) => `${cli} CLI not detected`, + }, + cliBanners: { + cliNotDetectedTitle: ({ cli }: { cli: string }) => `${cli} CLI Not Detected`, + dontShowFor: "Don't show this popup for", + thisMachine: 'this machine', + anyMachine: 'any machine', + installCommand: ({ command }: { command: string }) => `Install: ${command} •`, + installCliIfAvailable: ({ cli }: { cli: string }) => `Install ${cli} CLI if available •`, + viewInstallationGuide: 'View Installation Guide →', + viewGeminiDocs: 'View Gemini Docs →', + }, worktree: { creating: ({ name }: { name: string }) => `Creating worktree '${name}'...`, notGitRepo: 'Worktrees require a git repository', failed: ({ error }: { error: string }) => `Failed to create worktree: ${error}`, success: 'Worktree created successfully', - } + }, + resume: { + title: 'Resume session', + optional: 'Resume: Optional', + pickerTitle: 'Resume session', + subtitle: ({ agent }: { agent: string }) => `Paste a ${agent} session ID to resume`, + placeholder: ({ agent }: { agent: string }) => `Paste ${agent} session ID…`, + paste: 'Paste', + save: 'Save', + clearAndRemove: 'Clear', + helpText: 'You can find session IDs in the Session Info screen.', + cannotApplyBody: 'This resume ID can’t be applied right now. Happy will start a new session instead.', + }, + codexResumeBanner: { + title: 'Codex resume', + updateAvailable: 'Update available', + systemCodexVersion: ({ version }: { version: string }) => `System codex: ${version}`, + resumeServerVersion: ({ version }: { version: string }) => `Codex resume server: ${version}`, + notInstalled: 'not installed', + latestVersion: ({ version }: { version: string }) => `(latest ${version})`, + registryCheckFailed: ({ error }: { error: string }) => `Registry check failed: ${error}`, + install: 'Install', + update: 'Update', + reinstall: 'Reinstall', + }, + codexResumeInstallModal: { + installTitle: 'Install Codex resume?', + updateTitle: 'Update Codex resume?', + reinstallTitle: 'Reinstall Codex resume?', + description: 'This installs an experimental Codex MCP server wrapper used only for resume operations.', + }, + codexAcpBanner: { + title: 'Codex ACP', + install: 'Install', + update: 'Update', + reinstall: 'Reinstall', + }, + codexAcpInstallModal: { + installTitle: 'Install Codex ACP?', + updateTitle: 'Update Codex ACP?', + reinstallTitle: 'Reinstall Codex ACP?', + description: 'This installs an experimental ACP adapter around Codex that supports loading/resuming threads.', + }, }, sessionHistory: { @@ -315,11 +543,100 @@ export const en: TranslationStructure = { }, session: { - inputPlaceholder: 'Type a message ...', + inputPlaceholder: 'What would you like to work on?', + resuming: 'Resuming...', + resumeFailed: 'Failed to resume session', + resumeSupportNoteChecking: 'Note: Happy is still checking whether this machine can resume the provider session.', + resumeSupportNoteUnverified: 'Note: Happy couldn’t verify resume support for this machine.', + resumeSupportDetails: { + cliNotDetected: 'CLI not detected on the machine.', + capabilityProbeFailed: 'Capability probe failed.', + acpProbeFailed: 'ACP probe failed.', + loadSessionFalse: 'Agent does not support loading sessions.', + }, + inactiveResumable: 'Inactive (resumable)', + inactiveMachineOffline: 'Inactive (machine offline)', + inactiveNotResumable: 'Inactive', + inactiveNotResumableNoticeTitle: 'This session can’t be resumed', + inactiveNotResumableNoticeBody: ({ provider }: { provider: string }) => + `This session ended and can’t be resumed because ${provider} doesn’t support restoring its context here. Start a new session to continue.`, + machineOfflineNoticeTitle: 'Machine is offline', + machineOfflineNoticeBody: ({ machine }: { machine: string }) => + `“${machine}” is offline, so Happy can’t resume this session yet. Bring it online to continue.`, + machineOfflineCannotResume: 'Machine is offline. Bring it online to resume this session.', + + sharing: { + title: 'Sharing', + directSharing: 'Direct sharing', + addShare: 'Share with a friend', + accessLevel: 'Access level', + shareWith: 'Share with', + sharedWith: 'Shared with', + noShares: 'Not shared', + viewOnly: 'View only', + viewOnlyDescription: 'Can view the session but can’t send messages.', + viewOnlyMode: 'View-only (shared session)', + noEditPermission: 'You have read-only access to this session.', + canEdit: 'Can edit', + canEditDescription: 'Can send messages.', + canManage: 'Can manage', + canManageDescription: 'Can manage sharing settings.', + stopSharing: 'Stop sharing', + recipientMissingKeys: 'This user hasn’t registered encryption keys yet.', + + publicLink: 'Public link', + publicLinkActive: 'Public link is active', + publicLinkDescription: 'Create a link that lets anyone view this session.', + createPublicLink: 'Create public link', + regeneratePublicLink: 'Regenerate public link', + deletePublicLink: 'Delete public link', + linkToken: 'Link token', + tokenNotRecoverable: 'Token not available', + tokenNotRecoverableDescription: 'For security reasons, public-link tokens are stored hashed and can’t be recovered. Regenerate the link to create a new token.', + + expiresIn: 'Expires in', + expiresOn: 'Expires on', + days7: '7 days', + days30: '30 days', + never: 'Never', + + maxUsesLabel: 'Maximum uses', + unlimited: 'Unlimited', + uses10: '10 uses', + uses50: '50 uses', + usageCount: 'Usage count', + usageCountWithMax: ({ used, max }: { used: number; max: number }) => `${used}/${max} uses`, + usageCountUnlimited: ({ used }: { used: number }) => `${used} uses`, + + requireConsent: 'Require consent', + requireConsentDescription: 'Ask viewers to consent before their access is logged.', + consentRequired: 'Consent required', + consentDescription: 'This link requires your consent to log your IP address and user agent.', + acceptAndView: 'Accept and view', + sharedBy: ({ name }: { name: string }) => `Shared by ${name}`, + + shareNotFound: 'Share link not found or expired', + failedToDecrypt: 'Failed to decrypt the session', + noMessages: 'No messages yet', + session: 'Session', + }, }, commandPalette: { placeholder: 'Type a command or search...', + noCommandsFound: 'No commands found', + }, + + commandView: { + completedWithNoOutput: '[Command completed with no output]', + }, + + voiceAssistant: { + connecting: 'Connecting...', + active: 'Voice Assistant Active', + connectionError: 'Connection Error', + label: 'Voice Assistant', + tapToEnd: 'Tap to end', }, server: { @@ -351,8 +668,18 @@ export const en: TranslationStructure = { happySessionId: 'Happy Session ID', claudeCodeSessionId: 'Claude Code Session ID', claudeCodeSessionIdCopied: 'Claude Code Session ID copied to clipboard', + aiProfile: 'AI Profile', aiProvider: 'AI Provider', failedToCopyClaudeCodeSessionId: 'Failed to copy Claude Code Session ID', + codexSessionId: 'Codex Session ID', + codexSessionIdCopied: 'Codex Session ID copied to clipboard', + failedToCopyCodexSessionId: 'Failed to copy Codex Session ID', + opencodeSessionId: 'OpenCode Session ID', + opencodeSessionIdCopied: 'OpenCode Session ID copied to clipboard', + auggieSessionId: 'Auggie Session ID', + auggieSessionIdCopied: 'Auggie Session ID copied to clipboard', + geminiSessionId: 'Gemini Session ID', + geminiSessionIdCopied: 'Gemini Session ID copied to clipboard', metadataCopied: 'Metadata copied to clipboard', failedToCopyMetadata: 'Failed to copy metadata', failedToKillSession: 'Failed to kill session', @@ -362,8 +689,11 @@ export const en: TranslationStructure = { lastUpdated: 'Last Updated', sequence: 'Sequence', quickActions: 'Quick Actions', + copyResumeCommand: 'Copy resume command', viewMachine: 'View Machine', viewMachineSubtitle: 'View machine details and sessions', + manageSharing: 'Manage sharing', + manageSharingSubtitle: 'Share this session with others', killSessionSubtitle: 'Immediately terminate the session', archiveSessionSubtitle: 'Archive this session and stop it', metadata: 'Metadata', @@ -372,8 +702,14 @@ export const en: TranslationStructure = { operatingSystem: 'Operating System', processId: 'Process ID', happyHome: 'Happy Home', + attachFromTerminal: 'Attach from terminal', + tmuxTarget: 'Tmux target', + tmuxFallback: 'Tmux fallback', copyMetadata: 'Copy Metadata', agentState: 'Agent State', + rawJsonDevMode: 'Raw JSON (Dev Mode)', + sessionStatus: 'Session Status', + fullSessionObject: 'Full Session Object', controlledByUser: 'Controlled by User', pendingRequests: 'Pending Requests', activity: 'Activity', @@ -390,6 +726,11 @@ export const en: TranslationStructure = { deleteSessionWarning: 'This action cannot be undone. All messages and data associated with this session will be permanently deleted.', failedToDeleteSession: 'Failed to delete session', sessionDeleted: 'Session deleted successfully', + renameSession: 'Rename Session', + renameSessionSubtitle: 'Change the display name for this session', + renameSessionPlaceholder: 'Enter session name...', + failedToRenameSession: 'Failed to rename session', + sessionRenamed: 'Session renamed successfully', }, @@ -401,16 +742,57 @@ export const en: TranslationStructure = { runIt: 'Run it', scanQrCode: 'Scan the QR code', openCamera: 'Open Camera', + installCommand: '$ npm i -g happy-coder', + runCommand: '$ happy', + }, + emptyMessages: { + noMessagesYet: 'No messages yet', + created: ({ time }: { time: string }) => `Created ${time}`, + }, + emptySessionsTablet: { + noActiveSessions: 'No active sessions', + startNewSessionDescription: 'Start a new session on any of your connected machines.', + startNewSessionButton: 'Start New Session', + openTerminalToStart: 'Open a new terminal on your computer to start session.', + }, + }, + + zen: { + title: 'Zen', + add: { + placeholder: 'What needs to be done?', + }, + home: { + noTasksYet: 'No tasks yet. Tap + to add one.', + }, + view: { + workOnTask: 'Work on task', + clarify: 'Clarify', + delete: 'Delete', + linkedSessions: 'Linked Sessions', + tapTaskTextToEdit: 'Tap the task text to edit', }, }, agentInput: { + envVars: { + title: 'Env Vars', + titleWithCount: ({ count }: { count: number }) => `Env Vars (${count})`, + }, + resumeChip: { + withId: ({ title, id }: { title: string; id: string }) => `${title}: ${id}`, + withIdTruncated: ({ title, prefix, suffix }: { title: string; prefix: string; suffix: string }) => + `${title}: ${prefix}…${suffix}`, + }, permissionMode: { title: 'PERMISSION MODE', default: 'Default', acceptEdits: 'Accept Edits', plan: 'Plan Mode', bypassPermissions: 'Yolo Mode', + badgeAccept: 'Accept', + badgePlan: 'Plan', + badgeYolo: 'YOLO', badgeAcceptAllEdits: 'Accept All Edits', badgeBypassAllPermissions: 'Bypass All Permissions', badgePlanMode: 'Plan Mode', @@ -418,7 +800,13 @@ export const en: TranslationStructure = { agent: { claude: 'Claude', codex: 'Codex', + opencode: 'OpenCode', gemini: 'Gemini', + auggie: 'Auggie', + }, + auggieIndexingChip: { + on: 'Indexing on', + off: 'Indexing off', }, model: { title: 'MODEL', @@ -430,7 +818,7 @@ export const en: TranslationStructure = { readOnly: 'Read Only Mode', safeYolo: 'Safe YOLO', yolo: 'YOLO', - badgeReadOnly: 'Read Only Mode', + badgeReadOnly: 'Read Only', badgeSafeYolo: 'Safe YOLO', badgeYolo: 'YOLO', }, @@ -454,6 +842,21 @@ export const en: TranslationStructure = { badgeSafeYolo: 'Safe YOLO', badgeYolo: 'YOLO', }, + geminiModel: { + title: 'GEMINI MODEL', + gemini25Pro: { + label: 'Gemini 2.5 Pro', + description: 'Most capable', + }, + gemini25Flash: { + label: 'Gemini 2.5 Flash', + description: 'Fast & efficient', + }, + gemini25FlashLite: { + label: 'Gemini 2.5 Flash Lite', + description: 'Fastest', + }, + }, context: { remaining: ({ percent }: { percent: number }) => `${percent}% left`, }, @@ -461,6 +864,11 @@ export const en: TranslationStructure = { fileLabel: 'FILE', folderLabel: 'FOLDER', }, + actionMenu: { + title: 'ACTIONS', + files: 'Files', + stop: 'Stop', + }, noMachinesAvailable: 'No machines', }, @@ -519,11 +927,27 @@ export const en: TranslationStructure = { applyChanges: 'Update file', viewDiff: 'Current file changes', question: 'Question', + changeTitle: 'Change Title', + }, + geminiExecute: { + cwd: ({ cwd }: { cwd: string }) => `📁 ${cwd}`, }, askUserQuestion: { submit: 'Submit Answer', multipleQuestions: ({ count }: { count: number }) => `${count} questions`, }, + exitPlanMode: { + approve: 'Approve Plan', + reject: 'Reject', + requestChanges: 'Request changes', + requestChangesPlaceholder: 'Tell Claude what you want to change in this plan…', + requestChangesSend: 'Send feedback', + requestChangesEmpty: 'Please write what you want to change.', + requestChangesFailed: 'Failed to request changes. Please try again.', + responded: 'Response sent', + approvalMessage: 'I approve this plan. Please proceed with the implementation.', + rejectionMessage: 'I do not approve this plan. Please revise it or ask me what changes I would like.', + }, desc: { terminalCmd: ({ cmd }: { cmd: string }) => `Terminal(cmd: ${cmd})`, searchPattern: ({ pattern }: { pattern: string }) => `Search(pattern: ${pattern})`, @@ -681,6 +1105,11 @@ export const en: TranslationStructure = { deviceLinkedSuccessfully: 'Device linked successfully', terminalConnectedSuccessfully: 'Terminal connected successfully', invalidAuthUrl: 'Invalid authentication URL', + microphoneAccessRequiredTitle: 'Microphone Access Required', + microphoneAccessRequiredRequestPermission: 'Happy needs access to your microphone for voice chat. Please grant permission when prompted.', + microphoneAccessRequiredEnableInSettings: 'Happy needs access to your microphone for voice chat. Please enable microphone access in your device settings.', + microphoneAccessRequiredBrowserInstructions: 'Please allow microphone access in your browser settings. You may need to click the lock icon in the address bar and enable microphone permission for this site.', + openSettings: 'Open Settings', developerMode: 'Developer Mode', developerModeEnabled: 'Developer mode enabled', developerModeDisabled: 'Developer mode disabled', @@ -735,6 +1164,15 @@ export const en: TranslationStructure = { daemon: 'Daemon', status: 'Status', stopDaemon: 'Stop Daemon', + stopDaemonConfirmTitle: 'Stop Daemon?', + stopDaemonConfirmBody: 'You will not be able to spawn new sessions on this machine until you restart the daemon on your computer again. Your current sessions will stay alive.', + daemonStoppedTitle: 'Daemon Stopped', + stopDaemonFailed: 'Failed to stop daemon. It may not be running.', + renameTitle: 'Rename Machine', + renameDescription: 'Give this machine a custom name. Leave empty to use the default hostname.', + renamePlaceholder: 'Enter machine name', + renamedSuccess: 'Machine renamed successfully', + renameFailed: 'Failed to rename machine', lastKnownPid: 'Last Known PID', lastKnownHttpPort: 'Last Known HTTP Port', startedAt: 'Started At', @@ -751,20 +1189,40 @@ export const en: TranslationStructure = { lastSeen: 'Last Seen', never: 'Never', metadataVersion: 'Metadata Version', + detectedClis: 'Detected CLIs', + detectedCliNotDetected: 'Not detected', + detectedCliUnknown: 'Unknown', + detectedCliNotSupported: 'Not supported (update happy-cli)', untitledSession: 'Untitled Session', back: 'Back', + notFound: 'Machine not found', + unknownMachine: 'unknown machine', + unknownPath: 'unknown path', + tmux: { + overrideTitle: 'Override global tmux settings', + overrideEnabledSubtitle: 'Custom tmux settings apply to new sessions on this machine.', + overrideDisabledSubtitle: 'New sessions use the global tmux settings.', + notDetectedSubtitle: 'tmux is not detected on this machine.', + notDetectedMessage: 'tmux is not detected on this machine. Install tmux and refresh detection.', + }, }, message: { switchedToMode: ({ mode }: { mode: string }) => `Switched to ${mode} mode`, + discarded: 'Discarded', unknownEvent: 'Unknown event', usageLimitUntil: ({ time }: { time: string }) => `Usage limit reached until ${time}`, unknownTime: 'unknown time', }, + chatFooter: { + permissionsTerminalOnly: 'Permissions are shown in the terminal only. Reset or send a message to control from the app.', + }, + codex: { // Codex permission dialog buttons permissions: { + yesAlwaysAllowCommand: 'Yes, always allow globally', yesForSession: "Yes, and don't ask for a session", stopAndExplain: 'Stop, and explain what to do', } @@ -775,6 +1233,9 @@ export const en: TranslationStructure = { permissions: { yesAllowAllEdits: 'Yes, allow all edits during this session', yesForTool: "Yes, don't ask again for this tool", + yesForCommandPrefix: "Yes, don't ask again for this command prefix", + yesForSubcommand: "Yes, don't ask again for this subcommand", + yesForCommandName: "Yes, don't ask again for this command", noTellClaude: 'No, and provide feedback', } }, @@ -788,6 +1249,7 @@ export const en: TranslationStructure = { textCopied: 'Text copied to clipboard', failedToCopy: 'Failed to copy text to clipboard', noTextToCopy: 'No text available to copy', + failedToOpen: 'Failed to open text selection. Please try again.', }, markdown: { @@ -808,11 +1270,14 @@ export const en: TranslationStructure = { edit: 'Edit Artifact', delete: 'Delete', updateError: 'Failed to update artifact. Please try again.', + deleteError: 'Failed to delete artifact. Please try again.', notFound: 'Artifact not found', discardChanges: 'Discard changes?', discardChangesDescription: 'You have unsaved changes. Are you sure you want to discard them?', deleteConfirm: 'Delete artifact?', deleteConfirmDescription: 'This action cannot be undone', + noContent: 'No content', + untitled: 'Untitled', titleLabel: 'TITLE', titlePlaceholder: 'Enter a title for your artifact', bodyLabel: 'CONTENT', @@ -874,6 +1339,8 @@ export const en: TranslationStructure = { cancelRequestConfirm: ({ name }: { name: string }) => `Cancel your friendship request to ${name}?`, denyRequest: 'Deny friendship', nowFriendsWith: ({ name }: { name: string }) => `You are now friends with ${name}`, + sharedSessions: 'Shared sessions', + noSharedSessions: 'No shared sessions yet', }, usage: { @@ -898,12 +1365,57 @@ export const en: TranslationStructure = { friendAcceptedGeneric: 'Friend request accepted', }, + secrets: { + addTitle: 'New secret', + savedTitle: 'Saved secrets', + badgeReady: 'Secrets', + badgeRequired: 'Secret required', + missingForProfile: ({ env }: { env: string | null }) => + `Missing secret (${env ?? 'secret'}). Configure it on the machine or select/enter a secret.`, + defaultForProfileTitle: 'Default secret', + defineDefaultForProfileTitle: 'Define default secret for this profile', + addSubtitle: 'Add a saved secret', + noneTitle: 'None', + noneSubtitle: 'Use machine environment or enter a secret for this session', + emptyTitle: 'No saved keys', + emptySubtitle: 'Add one to use secret-required profiles without setting machine env vars.', + savedHiddenSubtitle: 'Saved (value hidden)', + defaultLabel: 'Default', + fields: { + name: 'Name', + value: 'Value', + }, + placeholders: { + nameExample: 'e.g. Work OpenAI', + }, + validation: { + nameRequired: 'Name is required.', + valueRequired: 'Value is required.', + }, + actions: { + replace: 'Replace', + replaceValue: 'Replace value', + setDefault: 'Set as default', + unsetDefault: 'Unset default', + }, + prompts: { + renameTitle: 'Rename secret', + renameDescription: 'Update the friendly name for this key.', + replaceValueTitle: 'Replace secret value', + replaceValueDescription: 'Paste the new secret value. This value will not be shown again after saving.', + deleteTitle: 'Delete secret', + deleteConfirm: ({ name }: { name: string }) => `Delete “${name}”? This cannot be undone.`, + }, + }, + profiles: { // Profile management feature title: 'Profiles', subtitle: 'Manage environment variable profiles for sessions', - noProfile: 'No Profile', - noProfileDescription: 'Use default environment settings', + sessionUses: ({ profile }: { profile: string }) => `This session uses: ${profile}`, + profilesFixedPerSession: 'Profiles are fixed per session. To use a different profile, start a new session.', + noProfile: 'Default Environment', + noProfileDescription: 'Use the machine environment without profile variables', defaultModel: 'Default Model', addProfile: 'Add Profile', profileName: 'Profile Name', @@ -918,9 +1430,232 @@ export const en: TranslationStructure = { enterTmuxTempDir: 'Enter temp directory path', tmuxUpdateEnvironment: 'Update environment automatically', nameRequired: 'Profile name is required', - deleteConfirm: 'Are you sure you want to delete the profile "{name}"?', + deleteConfirm: ({ name }: { name: string }) => `Are you sure you want to delete the profile "${name}"?`, editProfile: 'Edit Profile', addProfileTitle: 'Add New Profile', + builtIn: 'Built-in', + custom: 'Custom', + builtInSaveAsHint: 'Saving a built-in profile creates a new custom profile.', + builtInNames: { + anthropic: 'Anthropic (Default)', + deepseek: 'DeepSeek (Reasoner)', + zai: 'Z.AI (GLM-4.6)', + codex: 'Codex (Default)', + openai: 'OpenAI (GPT-5)', + azureOpenai: 'Azure OpenAI', + gemini: 'Gemini (Default)', + geminiApiKey: 'Gemini (API key)', + geminiVertex: 'Gemini (Vertex AI)', + }, + groups: { + favorites: 'Favorites', + custom: 'Your Profiles', + builtIn: 'Built-in Profiles', + }, + actions: { + viewEnvironmentVariables: 'Environment Variables', + addToFavorites: 'Add to favorites', + removeFromFavorites: 'Remove from favorites', + editProfile: 'Edit profile', + duplicateProfile: 'Duplicate profile', + deleteProfile: 'Delete profile', + }, + copySuffix: '(Copy)', + duplicateName: 'A profile with this name already exists', + setupInstructions: { + title: 'Setup Instructions', + viewOfficialGuide: 'View Official Setup Guide', + }, + machineLogin: { + title: 'CLI login', + subtitle: 'This profile relies on a CLI login cache on the selected machine.', + status: { + loggedIn: 'Logged in', + notLoggedIn: 'Not logged in', + }, + claudeCode: { + title: 'Claude Code', + instructions: 'Run `claude`, then type `/login` to sign in.', + warning: 'Note: setting `ANTHROPIC_AUTH_TOKEN` overrides CLI login.', + }, + codex: { + title: 'Codex', + instructions: 'Run `codex login` to sign in.', + }, + geminiCli: { + title: 'Gemini CLI', + instructions: 'Run `gemini auth` to sign in.', + }, + }, + requirements: { + secretRequired: 'Secret', + configured: 'Configured on machine', + notConfigured: 'Not configured', + checking: 'Checking…', + missingConfigForProfile: ({ env }: { env: string }) => `This profile requires ${env} to be configured on the machine.`, + modalTitle: 'Secret required', + modalBody: 'This profile requires a secret.\n\nSupported options:\n• Use machine environment (recommended)\n• Use saved secret from app settings\n• Enter a secret for this session only', + sectionTitle: 'Requirements', + sectionSubtitle: 'These fields are used to preflight readiness and to avoid surprise failures.', + secretEnvVarPromptDescription: 'Enter the required secret environment variable name (e.g. OPENAI_API_KEY).', + modalHelpWithEnv: ({ env }: { env: string }) => `This profile needs ${env}. Choose one option below.`, + modalHelpGeneric: 'This profile needs a secret. Choose one option below.', + chooseOptionTitle: 'Choose an option', + machineEnvStatus: { + theMachine: 'the machine', + checkFor: ({ env }: { env: string }) => `Check for ${env}`, + checking: ({ env }: { env: string }) => `Checking ${env}…`, + found: ({ env, machine }: { env: string; machine: string }) => `${env} found on ${machine}`, + notFound: ({ env, machine }: { env: string; machine: string }) => `${env} not found on ${machine}`, + }, + machineEnvSubtitle: { + checking: 'Checking daemon environment…', + found: 'Found in the daemon environment on the machine.', + notFound: 'Set it in the daemon environment on the machine and restart the daemon.', + }, + options: { + none: { + title: 'None', + subtitle: 'Does not require a secret or CLI login.', + }, + machineLogin: { + subtitle: 'Requires the CLI to be logged in on the machine.', + longSubtitle: 'Requires being logged in via the CLI for the AI backend you choose on the target machine.', + }, + useMachineEnvironment: { + title: 'Use machine environment', + subtitleWithEnv: ({ env }: { env: string }) => `Use ${env} from the daemon environment.`, + subtitleGeneric: 'Use the secret from the daemon environment.', + }, + useSavedSecret: { + title: 'Use a saved secret', + subtitle: 'Select (or add) a saved secret in the app.', + }, + enterOnce: { + title: 'Enter a secret', + subtitle: 'Paste a secret for this session only (won’t be saved).', + }, + }, + secretEnvVar: { + title: 'Secret environment variable', + subtitle: 'Enter the env var name this provider expects for its secret (e.g. OPENAI_API_KEY).', + label: 'Environment variable name', + }, + sections: { + machineEnvironment: 'Machine environment', + useOnceTitle: 'Use once', + useOnceLabel: 'Enter a secret', + useOnceFooter: 'Paste a secret for this session only. It won’t be saved.', + }, + actions: { + useMachineEnvironment: { + subtitle: 'Start with the key already present on the machine.', + }, + useOnceButton: 'Use once (session only)', + }, + }, + defaultSessionType: 'Default Session Type', + defaultPermissionMode: { + title: 'Default Permission Mode', + descriptions: { + default: 'Ask for permissions', + acceptEdits: 'Auto-approve edits', + plan: 'Plan before executing', + bypassPermissions: 'Skip all permissions', + }, + }, + aiBackend: { + title: 'AI Backend', + selectAtLeastOneError: 'Select at least one AI backend.', + claudeSubtitle: 'Claude CLI', + codexSubtitle: 'Codex CLI', + opencodeSubtitle: 'OpenCode CLI', + geminiSubtitleExperimental: 'Gemini CLI (experimental)', + auggieSubtitle: 'Auggie CLI', + }, + tmux: { + title: 'Tmux', + spawnSessionsTitle: 'Spawn Sessions in Tmux', + spawnSessionsEnabledSubtitle: 'Sessions spawn in new tmux windows.', + spawnSessionsDisabledSubtitle: 'Sessions spawn in regular shell (no tmux integration)', + isolatedServerTitle: 'Isolated tmux server', + isolatedServerEnabledSubtitle: 'Start sessions in an isolated tmux server (recommended).', + isolatedServerDisabledSubtitle: 'Start sessions in your default tmux server.', + sessionNamePlaceholder: 'Empty = current/most recent session', + tempDirPlaceholder: 'Leave blank to auto-generate', + }, + previewMachine: { + title: 'Preview Machine', + itemTitle: 'Preview machine for environment variables preview', + selectMachine: 'Select machine', + resolveSubtitle: 'Used only to preview the resolved values below (does not change what is saved).', + selectSubtitle: 'Select a machine to preview the resolved values below.', + }, + environmentVariables: { + title: 'Environment Variables', + addVariable: 'Add Variable', + namePlaceholder: 'Variable name (e.g., MY_CUSTOM_VAR)', + valuePlaceholder: 'Value (e.g., my-value or ${MY_VAR})', + validation: { + nameRequired: 'Enter a variable name.', + invalidNameFormat: 'Variable names must be uppercase letters, numbers, and underscores, and cannot start with a number.', + duplicateName: 'That variable already exists.', + }, + card: { + valueLabel: 'Value:', + fallbackValueLabel: 'Fallback value:', + valueInputPlaceholder: 'Value', + defaultValueInputPlaceholder: 'Default value', + fallbackDisabledForVault: 'Fallbacks are disabled when using the secret vault.', + secretNotRetrieved: 'Secret value - not retrieved for security', + secretToggleLabel: 'Hide value in UI', + secretToggleSubtitle: 'Hide the value in the UI and avoid fetching it from the machine for preview.', + secretToggleEnforcedByDaemon: 'Enforced by daemon', + secretToggleEnforcedByVault: 'Enforced by secret vault', + secretToggleResetToAuto: 'Reset to auto', + requirementRequiredLabel: 'Required', + requirementRequiredSubtitle: 'Block session creation when this variable is missing.', + requirementUseVaultLabel: 'Use secret vault', + requirementUseVaultSubtitle: 'Use a saved secret for this variable (no fallback values).', + defaultSecretLabel: 'Default secret', + overridingDefault: ({ expectedValue }: { expectedValue: string }) => + `Overriding documented default: ${expectedValue}`, + useMachineEnvToggle: 'Use value from machine environment', + resolvedOnSessionStart: 'Resolved when the session starts on the selected machine.', + sourceVariableLabel: 'Source variable', + sourceVariablePlaceholder: 'Source variable name (e.g., Z_AI_MODEL)', + checkingMachine: ({ machine }: { machine: string }) => `Checking ${machine}...`, + emptyOnMachine: ({ machine }: { machine: string }) => `Empty on ${machine}`, + emptyOnMachineUsingFallback: ({ machine }: { machine: string }) => `Empty on ${machine} (using fallback)`, + notFoundOnMachine: ({ machine }: { machine: string }) => `Not found on ${machine}`, + notFoundOnMachineUsingFallback: ({ machine }: { machine: string }) => `Not found on ${machine} (using fallback)`, + valueFoundOnMachine: ({ machine }: { machine: string }) => `Value found on ${machine}`, + differsFromDocumented: ({ expectedValue }: { expectedValue: string }) => + `Differs from documented value: ${expectedValue}`, + }, + preview: { + secretValueHidden: ({ value }: { value: string }) => `${value} - hidden for security`, + hiddenValue: '***hidden***', + emptyValue: '(empty)', + sessionWillReceive: ({ name, value }: { name: string; value: string }) => + `Session will receive: ${name} = ${value}`, + }, + previewModal: { + titleWithProfile: ({ profileName }: { profileName: string }) => `Env Vars · ${profileName}`, + descriptionPrefix: 'These environment variables are sent when starting the session. Values are resolved using the daemon on', + descriptionFallbackMachine: 'the selected machine', + descriptionSuffix: '.', + emptyMessage: 'No environment variables are set for this profile.', + checkingSuffix: '(checking…)', + detail: { + fixed: 'Fixed', + machine: 'Machine', + checking: 'Checking', + fallback: 'Fallback', + missing: 'Missing', + }, + }, + }, delete: { title: 'Delete Profile', message: ({ name }: { name: string }) => `Are you sure you want to delete "${name}"? This action cannot be undone.`, @@ -928,6 +1663,8 @@ export const en: TranslationStructure = { cancel: 'Cancel', }, } -} as const; +}; + +export type TranslationStructure = typeof en; -export type TranslationsEn = typeof en; \ No newline at end of file +export type TranslationsEn = typeof en; diff --git a/expo-app/sources/text/translations/es.ts b/expo-app/sources/text/translations/es.ts index 34d760939..67b6765df 100644 --- a/expo-app/sources/text/translations/es.ts +++ b/expo-app/sources/text/translations/es.ts @@ -1,4 +1,4 @@ -import type { TranslationStructure } from '../_default'; +import type { TranslationStructure } from '../_types'; /** * Spanish plural helper function @@ -31,6 +31,8 @@ export const es: TranslationStructure = { common: { // Simple string constants + add: 'Añadir', + actions: 'Acciones', cancel: 'Cancelar', authenticate: 'Autenticar', save: 'Guardar', @@ -47,7 +49,11 @@ export const es: TranslationStructure = { yes: 'Sí', no: 'No', discard: 'Descartar', + discardChanges: 'Descartar cambios', + unsavedChangesWarning: 'Tienes cambios sin guardar.', + keepEditing: 'Seguir editando', version: 'Versión', + details: 'Detalles', copied: 'Copiado', copy: 'Copiar', scanning: 'Escaneando...', @@ -60,6 +66,18 @@ export const es: TranslationStructure = { retry: 'Reintentar', delete: 'Eliminar', optional: 'opcional', + noMatches: 'Sin coincidencias', + all: 'Todo', + machine: 'máquina', + clearSearch: 'Limpiar búsqueda', + refresh: 'Actualizar', + }, + + dropdown: { + category: { + general: 'General', + results: 'Resultados', + }, }, profile: { @@ -96,6 +114,16 @@ export const es: TranslationStructure = { enterSecretKey: 'Ingresa tu clave secreta', invalidSecretKey: 'Clave secreta inválida. Verifica e intenta de nuevo.', enterUrlManually: 'Ingresar URL manualmente', + openMachine: 'Abrir máquina', + terminalUrlPlaceholder: 'happy://terminal?...', + restoreQrInstructions: '1. Abre Happy en tu dispositivo móvil\n2. Ve a Configuración → Cuenta\n3. Toca "Vincular nuevo dispositivo"\n4. Escanea este código QR', + restoreWithSecretKeyInstead: 'Restaurar con clave secreta', + restoreWithSecretKeyDescription: 'Ingresa tu clave secreta para recuperar el acceso a tu cuenta.', + secretKeyPlaceholder: 'XXXXX-XXXXX-XXXXX...', + unsupported: { + connectTitle: ({ name }: { name: string }) => `Conectar ${name}`, + runCommandInTerminal: 'Ejecuta el siguiente comando en tu terminal:', + }, }, settings: { @@ -136,6 +164,12 @@ export const es: TranslationStructure = { usageSubtitle: 'Ver tu uso de API y costos', profiles: 'Perfiles', profilesSubtitle: 'Gestionar perfiles de variables de entorno para sesiones', + secrets: 'Secretos', + secretsSubtitle: 'Gestiona los secretos guardados (no se vuelven a mostrar después de ingresarlos)', + terminal: 'Terminal', + session: 'Sesión', + sessionSubtitleTmuxEnabled: 'Tmux activado', + sessionSubtitleMessageSendingAndTmux: 'Envío de mensajes y tmux', // Dynamic settings messages accountConnected: ({ service }: { service: string }) => `Cuenta de ${service} conectada`, @@ -173,6 +207,21 @@ export const es: TranslationStructure = { wrapLinesInDiffsDescription: 'Ajustar líneas largas en lugar de desplazamiento horizontal en vistas de diferencias', alwaysShowContextSize: 'Mostrar siempre tamaño del contexto', alwaysShowContextSizeDescription: 'Mostrar uso del contexto incluso cuando no esté cerca del límite', + agentInputActionBarLayout: 'Barra de acciones de entrada', + agentInputActionBarLayoutDescription: 'Elige cómo se muestran los chips de acción encima del campo de entrada', + agentInputActionBarLayoutOptions: { + auto: 'Auto', + wrap: 'Ajustar', + scroll: 'Desplazable', + collapsed: 'Contraído', + }, + agentInputChipDensity: 'Densidad de chips de acción', + agentInputChipDensityDescription: 'Elige si los chips de acción muestran etiquetas o íconos', + agentInputChipDensityOptions: { + auto: 'Auto', + labels: 'Etiquetas', + icons: 'Solo íconos', + }, avatarStyle: 'Estilo de avatar', avatarStyleDescription: 'Elige la apariencia del avatar de sesión', avatarOptions: { @@ -193,6 +242,26 @@ export const es: TranslationStructure = { experimentalFeatures: 'Características experimentales', experimentalFeaturesEnabled: 'Características experimentales habilitadas', experimentalFeaturesDisabled: 'Usando solo características estables', + experimentalOptions: 'Opciones experimentales', + experimentalOptionsDescription: 'Elige qué funciones experimentales están activadas.', + expUsageReporting: 'Usage reporting', + expUsageReportingSubtitle: 'Habilitar pantallas de uso y reporte de tokens', + expFileViewer: 'File viewer', + expFileViewerSubtitle: 'Habilitar el punto de entrada del visor de archivos de la sesión', + expShowThinkingMessages: 'Show thinking messages', + expShowThinkingMessagesSubtitle: 'Mostrar mensajes de pensamiento/estado del asistente en el chat', + expSessionType: 'Session type selector', + expSessionTypeSubtitle: 'Mostrar el selector de tipo de sesión (simple vs worktree)', + expZen: 'Zen', + expZenSubtitle: 'Habilitar la entrada de navegación Zen', + expVoiceAuthFlow: 'Voice auth flow', + expVoiceAuthFlowSubtitle: 'Usar flujo autenticado de token de voz (con paywall)', + expInboxFriends: 'Bandeja de entrada y amigos', + expInboxFriendsSubtitle: 'Habilitar la pestaña de Bandeja de entrada y las funciones de amigos', + expCodexResume: 'Codex resume', + expCodexResumeSubtitle: 'Habilitar la reanudación de sesiones de Codex usando una instalación separada de Codex (experimental)', + expCodexAcp: 'Codex ACP', + expCodexAcpSubtitle: 'Usar Codex mediante ACP (codex-acp) en lugar de MCP (experimental)', webFeatures: 'Características web', webFeaturesDescription: 'Características disponibles solo en la versión web de la aplicación.', enterToSend: 'Enter para enviar', @@ -201,13 +270,24 @@ export const es: TranslationStructure = { commandPalette: 'Paleta de comandos', commandPaletteEnabled: 'Presione ⌘K para abrir', commandPaletteDisabled: 'Acceso rápido a comandos deshabilitado', - markdownCopyV2: 'Markdown Copy v2', + markdownCopyV2: 'Copia de Markdown v2', markdownCopyV2Subtitle: 'Pulsación larga abre modal de copiado', hideInactiveSessions: 'Ocultar sesiones inactivas', hideInactiveSessionsSubtitle: 'Muestra solo los chats activos en tu lista', + groupInactiveSessionsByProject: 'Agrupar sesiones inactivas por proyecto', + groupInactiveSessionsByProjectSubtitle: 'Organiza los chats inactivos por proyecto', enhancedSessionWizard: 'Asistente de sesión mejorado', enhancedSessionWizardEnabled: 'Lanzador de sesión con perfil activo', enhancedSessionWizardDisabled: 'Usando el lanzador de sesión estándar', + profiles: 'Perfiles de IA', + profilesEnabled: 'Selección de perfiles habilitada', + profilesDisabled: 'Selección de perfiles deshabilitada', + pickerSearch: 'Búsqueda en selectores', + pickerSearchSubtitle: 'Mostrar un campo de búsqueda en los selectores de máquina y ruta', + machinePickerSearch: 'Búsqueda de máquinas', + machinePickerSearchSubtitle: 'Mostrar un campo de búsqueda en los selectores de máquinas', + pathPickerSearch: 'Búsqueda de rutas', + pathPickerSearchSubtitle: 'Mostrar un campo de búsqueda en los selectores de rutas', }, errors: { @@ -255,11 +335,85 @@ export const es: TranslationStructure = { failedToRemoveFriend: 'No se pudo eliminar al amigo', searchFailed: 'La búsqueda falló. Por favor, intenta de nuevo.', failedToSendRequest: 'No se pudo enviar la solicitud de amistad', + failedToResumeSession: 'No se pudo reanudar la sesión', + failedToSendMessage: 'No se pudo enviar el mensaje', + cannotShareWithSelf: 'No puedes compartir contigo mismo', + canOnlyShareWithFriends: 'Solo puedes compartir con amigos', + shareNotFound: 'Compartido no encontrado', + publicShareNotFound: 'Enlace público no encontrado o expirado', + consentRequired: 'Se requiere consentimiento para acceder', + maxUsesReached: 'Se alcanzó el máximo de usos', + invalidShareLink: 'Enlace de compartir inválido o expirado', + missingPermissionId: 'Falta el id de permiso', + codexResumeNotInstalledTitle: 'Codex resume no está instalado en esta máquina', + codexResumeNotInstalledMessage: + 'Para reanudar una conversación de Codex, instala el servidor de reanudación de Codex en la máquina de destino (Detalles de la máquina → Reanudación de Codex).', + codexAcpNotInstalledTitle: 'Codex ACP no está instalado en esta máquina', + codexAcpNotInstalledMessage: + 'Para usar el experimento de Codex ACP, instala codex-acp en la máquina de destino (Detalles de la máquina → Codex ACP) o desactiva el experimento.', + }, + + deps: { + installNotSupported: 'Actualiza Happy CLI para instalar esta dependencia.', + installFailed: 'Instalación fallida', + installed: 'Instalado', + installLog: ({ path }: { path: string }) => `Registro de instalación: ${path}`, + installable: { + codexResume: { + title: 'Servidor de reanudación de Codex', + installSpecTitle: 'Fuente de instalación de Codex resume', + }, + codexAcp: { + title: 'Adaptador ACP de Codex', + installSpecTitle: 'Fuente de instalación de Codex ACP', + }, + installSpecDescription: 'Especificación de NPM/Git/archivo pasada a `npm install` (experimental). Déjalo vacío para usar el valor predeterminado del daemon.', + }, + ui: { + notAvailable: 'No disponible', + notAvailableUpdateCli: 'No disponible (actualiza la CLI)', + errorRefresh: 'Error (actualizar)', + installed: 'Instalado', + installedWithVersion: ({ version }: { version: string }) => `Instalado (v${version})`, + installedUpdateAvailable: ({ installedVersion, latestVersion }: { installedVersion: string; latestVersion: string }) => + `Instalado (v${installedVersion}) — actualización disponible (v${latestVersion})`, + notInstalled: 'No instalado', + latest: 'Última', + latestSubtitle: ({ version, tag }: { version: string; tag: string }) => `${version} (etiqueta: ${tag})`, + registryCheck: 'Comprobación del registro', + registryCheckFailed: ({ error }: { error: string }) => `Falló: ${error}`, + installSource: 'Origen de instalación', + installSourceDefault: '(predeterminado)', + installSpecPlaceholder: 'p. ej. file:/ruta/al/paquete o github:propietario/repo#rama', + lastInstallLog: 'Último registro de instalación', + installLogTitle: 'Registro de instalación', + }, }, newSession: { // Used by new-session screen and launch flows title: 'Iniciar nueva sesión', + selectAiProfileTitle: 'Seleccionar perfil de IA', + selectAiProfileDescription: 'Selecciona un perfil de IA para aplicar variables de entorno y valores predeterminados a tu sesión.', + changeProfile: 'Cambiar perfil', + aiBackendSelectedByProfile: 'El backend de IA lo selecciona tu perfil. Para cambiarlo, selecciona un perfil diferente.', + selectAiBackendTitle: 'Seleccionar backend de IA', + aiBackendLimitedByProfileAndMachineClis: 'Limitado por tu perfil seleccionado y los CLI disponibles en esta máquina.', + aiBackendSelectWhichAiRuns: 'Selecciona qué IA ejecuta tu sesión.', + aiBackendNotCompatibleWithSelectedProfile: 'No es compatible con el perfil seleccionado.', + aiBackendCliNotDetectedOnMachine: ({ cli }: { cli: string }) => `No se detectó el CLI de ${cli} en esta máquina.`, + selectMachineTitle: 'Seleccionar máquina', + selectMachineDescription: 'Elige dónde se ejecuta esta sesión.', + selectPathTitle: 'Seleccionar ruta', + selectWorkingDirectoryTitle: 'Seleccionar directorio de trabajo', + selectWorkingDirectoryDescription: 'Elige la carpeta usada para comandos y contexto.', + selectPermissionModeTitle: 'Seleccionar modo de permisos', + selectPermissionModeDescription: 'Controla qué tan estrictamente las acciones requieren aprobación.', + selectModelTitle: 'Seleccionar modelo de IA', + selectModelDescription: 'Elige el modelo usado por esta sesión.', + selectSessionTypeTitle: 'Seleccionar tipo de sesión', + selectSessionTypeDescription: 'Elige una sesión simple o una vinculada a un worktree de Git.', + searchPathsPlaceholder: 'Buscar rutas...', noMachinesFound: 'No se encontraron máquinas. Inicia una sesión de Happy en tu computadora primero.', allMachinesOffline: 'Todas las máquinas están desconectadas', machineDetails: 'Ver detalles de la máquina →', @@ -275,18 +429,94 @@ export const es: TranslationStructure = { startNewSessionInFolder: 'Nueva sesión aquí', noMachineSelected: 'Por favor, selecciona una máquina para iniciar la sesión', noPathSelected: 'Por favor, selecciona un directorio para iniciar la sesión', + machinePicker: { + searchPlaceholder: 'Buscar máquinas...', + recentTitle: 'Recientes', + favoritesTitle: 'Favoritos', + allTitle: 'Todas', + emptyMessage: 'No hay máquinas disponibles', + }, + pathPicker: { + enterPathTitle: 'Ingresar ruta', + enterPathPlaceholder: 'Ingresa una ruta...', + customPathTitle: 'Ruta personalizada', + recentTitle: 'Recientes', + favoritesTitle: 'Favoritos', + suggestedTitle: 'Sugeridas', + allTitle: 'Todas', + emptyRecent: 'No hay rutas recientes', + emptyFavorites: 'No hay rutas favoritas', + emptySuggested: 'No hay rutas sugeridas', + emptyAll: 'No hay rutas', + }, sessionType: { title: 'Tipo de sesión', simple: 'Simple', worktree: 'Worktree', comingSoon: 'Próximamente', }, + profileAvailability: { + requiresAgent: ({ agent }: { agent: string }) => `Requiere ${agent}`, + cliNotDetected: ({ cli }: { cli: string }) => `${cli} CLI no detectado`, + }, + cliBanners: { + cliNotDetectedTitle: ({ cli }: { cli: string }) => `${cli} CLI no detectado`, + dontShowFor: 'No mostrar este aviso para', + thisMachine: 'esta máquina', + anyMachine: 'cualquier máquina', + installCommand: ({ command }: { command: string }) => `Instalar: ${command} •`, + installCliIfAvailable: ({ cli }: { cli: string }) => `Instala ${cli} CLI si está disponible •`, + viewInstallationGuide: 'Ver guía de instalación →', + viewGeminiDocs: 'Ver documentación de Gemini →', + }, worktree: { creating: ({ name }: { name: string }) => `Creando worktree '${name}'...`, notGitRepo: 'Los worktrees requieren un repositorio git', failed: ({ error }: { error: string }) => `Error al crear worktree: ${error}`, success: 'Worktree creado exitosamente', - } + }, + resume: { + title: 'Reanudar sesión', + optional: 'Reanudar: Opcional', + pickerTitle: 'Reanudar sesión', + subtitle: ({ agent }: { agent: string }) => `Pega un ID de sesión de ${agent} para reanudar`, + placeholder: ({ agent }: { agent: string }) => `Pega el ID de sesión de ${agent}…`, + paste: 'Pegar', + save: 'Guardar', + clearAndRemove: 'Borrar', + helpText: 'Puedes encontrar los IDs de sesión en la pantalla de información de sesión.', + cannotApplyBody: 'Este ID de reanudación no se puede aplicar ahora. Happy iniciará una nueva sesión en su lugar.', + }, + codexResumeBanner: { + title: 'Codex resume', + updateAvailable: 'Actualización disponible', + systemCodexVersion: ({ version }: { version: string }) => `Codex del sistema: ${version}`, + resumeServerVersion: ({ version }: { version: string }) => `Servidor de Codex resume: ${version}`, + notInstalled: 'no instalado', + latestVersion: ({ version }: { version: string }) => `(última ${version})`, + registryCheckFailed: ({ error }: { error: string }) => `La comprobación del registro falló: ${error}`, + install: 'Instalar', + update: 'Actualizar', + reinstall: 'Reinstalar', + }, + codexResumeInstallModal: { + installTitle: '¿Instalar Codex resume?', + updateTitle: '¿Actualizar Codex resume?', + reinstallTitle: '¿Reinstalar Codex resume?', + description: 'Esto instala un wrapper experimental de servidor MCP de Codex usado solo para operaciones de reanudación.', + }, + codexAcpBanner: { + title: 'Codex ACP', + install: 'Instalar', + update: 'Actualizar', + reinstall: 'Reinstalar', + }, + codexAcpInstallModal: { + installTitle: '¿Instalar Codex ACP?', + updateTitle: '¿Actualizar Codex ACP?', + reinstallTitle: '¿Reinstalar Codex ACP?', + description: 'Esto instala un adaptador ACP experimental alrededor de Codex que admite cargar/reanudar hilos.', + }, }, sessionHistory: { @@ -301,10 +531,99 @@ export const es: TranslationStructure = { session: { inputPlaceholder: 'Escriba un mensaje ...', + resuming: 'Reanudando...', + resumeFailed: 'No se pudo reanudar la sesión', + resumeSupportNoteChecking: 'Nota: Happy todavía está comprobando si esta máquina puede reanudar la sesión del proveedor.', + resumeSupportNoteUnverified: 'Nota: Happy no pudo verificar la compatibilidad de reanudación para esta máquina.', + resumeSupportDetails: { + cliNotDetected: 'No se detectó la CLI en la máquina.', + capabilityProbeFailed: 'Falló la comprobación de capacidades.', + acpProbeFailed: 'Falló la comprobación ACP.', + loadSessionFalse: 'El agente no admite cargar sesiones.', + }, + inactiveResumable: 'Inactiva (reanudable)', + inactiveMachineOffline: 'Inactiva (máquina sin conexión)', + inactiveNotResumable: 'Inactiva', + inactiveNotResumableNoticeTitle: 'Esta sesión no se puede reanudar', + inactiveNotResumableNoticeBody: ({ provider }: { provider: string }) => + `Esta sesión terminó y no se puede reanudar porque ${provider} no admite restaurar su contexto aquí. Inicia una nueva sesión para continuar.`, + machineOfflineNoticeTitle: 'La máquina está sin conexión', + machineOfflineNoticeBody: ({ machine }: { machine: string }) => + `“${machine}” está sin conexión, así que Happy no puede reanudar esta sesión todavía. Vuelve a conectarla para continuar.`, + machineOfflineCannotResume: 'La máquina está sin conexión. Vuelve a conectarla para reanudar esta sesión.', + + sharing: { + title: 'Compartir', + directSharing: 'Compartir directamente', + addShare: 'Compartir con un amigo', + accessLevel: 'Nivel de acceso', + shareWith: 'Compartir con', + sharedWith: 'Compartido con', + noShares: 'No compartido', + viewOnly: 'Solo ver', + viewOnlyDescription: 'Puede ver la sesión, pero no enviar mensajes.', + viewOnlyMode: 'Solo ver (sesión compartida)', + noEditPermission: 'Tienes acceso de solo lectura a esta sesión.', + canEdit: 'Puede editar', + canEditDescription: 'Puede enviar mensajes.', + canManage: 'Puede administrar', + canManageDescription: 'Puede administrar la configuración de uso compartido.', + stopSharing: 'Dejar de compartir', + recipientMissingKeys: 'Este usuario aún no ha registrado claves de cifrado.', + + publicLink: 'Enlace público', + publicLinkActive: 'El enlace público está activo', + publicLinkDescription: 'Crea un enlace para que cualquiera pueda ver esta sesión.', + createPublicLink: 'Crear enlace público', + regeneratePublicLink: 'Regenerar enlace público', + deletePublicLink: 'Eliminar enlace público', + linkToken: 'Token del enlace', + tokenNotRecoverable: 'Token no disponible', + tokenNotRecoverableDescription: 'Por seguridad, los tokens de enlace público se almacenan con hash y no se pueden recuperar. Regenera el enlace para crear un nuevo token.', + + expiresIn: 'Expira en', + expiresOn: 'Expira el', + days7: '7 días', + days30: '30 días', + never: 'Nunca', + + maxUsesLabel: 'Usos máximos', + unlimited: 'Ilimitado', + uses10: '10 usos', + uses50: '50 usos', + usageCount: 'Número de usos', + usageCountWithMax: ({ used, max }: { used: number; max: number }) => `${used}/${max} usos`, + usageCountUnlimited: ({ used }: { used: number }) => `${used} usos`, + + requireConsent: 'Requerir consentimiento', + requireConsentDescription: 'Pide consentimiento antes de registrar el acceso.', + consentRequired: 'Se requiere consentimiento', + consentDescription: 'Este enlace requiere tu consentimiento para registrar tu IP y agente de usuario.', + acceptAndView: 'Aceptar y ver', + sharedBy: ({ name }: { name: string }) => `Compartido por ${name}`, + + shareNotFound: 'El enlace compartido no existe o ha caducado', + failedToDecrypt: 'No se pudo descifrar la sesión', + noMessages: 'Aún no hay mensajes', + session: 'Sesión', + }, }, commandPalette: { placeholder: 'Escriba un comando o busque...', + noCommandsFound: 'No se encontraron comandos', + }, + + commandView: { + completedWithNoOutput: '[Comando completado sin salida]', + }, + + voiceAssistant: { + connecting: 'Conectando...', + active: 'Asistente de voz activo', + connectionError: 'Error de conexión', + label: 'Asistente de voz', + tapToEnd: 'Toca para finalizar', }, server: { @@ -336,8 +655,18 @@ export const es: TranslationStructure = { happySessionId: 'ID de sesión de Happy', claudeCodeSessionId: 'ID de sesión de Claude Code', claudeCodeSessionIdCopied: 'ID de sesión de Claude Code copiado al portapapeles', + aiProfile: 'Perfil de IA', aiProvider: 'Proveedor de IA', failedToCopyClaudeCodeSessionId: 'Falló al copiar ID de sesión de Claude Code', + codexSessionId: 'ID de sesión de Codex', + codexSessionIdCopied: 'ID de sesión de Codex copiado al portapapeles', + failedToCopyCodexSessionId: 'Falló al copiar ID de sesión de Codex', + opencodeSessionId: 'ID de sesión de OpenCode', + opencodeSessionIdCopied: 'ID de sesión de OpenCode copiado al portapapeles', + auggieSessionId: 'ID de sesión de Auggie', + auggieSessionIdCopied: 'ID de sesión de Auggie copiado al portapapeles', + geminiSessionId: 'ID de sesión de Gemini', + geminiSessionIdCopied: 'ID de sesión de Gemini copiado al portapapeles', metadataCopied: 'Metadatos copiados al portapapeles', failedToCopyMetadata: 'Falló al copiar metadatos', failedToKillSession: 'Falló al terminar sesión', @@ -347,6 +676,7 @@ export const es: TranslationStructure = { lastUpdated: 'Última actualización', sequence: 'Secuencia', quickActions: 'Acciones rápidas', + copyResumeCommand: 'Copiar comando de reanudación', viewMachine: 'Ver máquina', viewMachineSubtitle: 'Ver detalles de máquina y sesiones', killSessionSubtitle: 'Terminar inmediatamente la sesión', @@ -357,8 +687,14 @@ export const es: TranslationStructure = { operatingSystem: 'Sistema operativo', processId: 'ID del proceso', happyHome: 'Directorio de Happy', + attachFromTerminal: 'Adjuntar desde la terminal', + tmuxTarget: 'Destino de tmux', + tmuxFallback: 'Fallback de tmux', copyMetadata: 'Copiar metadatos', agentState: 'Estado del agente', + rawJsonDevMode: 'JSON sin procesar (modo desarrollador)', + sessionStatus: 'Estado de la sesión', + fullSessionObject: 'Objeto de sesión completo', controlledByUser: 'Controlado por el usuario', pendingRequests: 'Solicitudes pendientes', activity: 'Actividad', @@ -375,7 +711,14 @@ export const es: TranslationStructure = { deleteSessionWarning: 'Esta acción no se puede deshacer. Todos los mensajes y datos asociados con esta sesión se eliminarán permanentemente.', failedToDeleteSession: 'Error al eliminar la sesión', sessionDeleted: 'Sesión eliminada exitosamente', - + manageSharing: 'Gestionar acceso', + manageSharingSubtitle: 'Comparte esta sesión con amigos o crea un enlace público', + renameSession: 'Renombrar Sesión', + renameSessionSubtitle: 'Cambiar el nombre de visualización de esta sesión', + renameSessionPlaceholder: 'Introduce el nombre de la sesión...', + failedToRenameSession: 'Error al renombrar la sesión', + sessionRenamed: 'Sesión renombrada exitosamente', + }, components: { @@ -386,16 +729,57 @@ export const es: TranslationStructure = { runIt: 'Ejecútelo', scanQrCode: 'Escanee el código QR', openCamera: 'Abrir cámara', + installCommand: '$ npm i -g happy-coder', + runCommand: '$ happy', + }, + emptyMessages: { + noMessagesYet: 'Aún no hay mensajes', + created: ({ time }: { time: string }) => `Creado ${time}`, + }, + emptySessionsTablet: { + noActiveSessions: 'No hay sesiones activas', + startNewSessionDescription: 'Inicia una nueva sesión en cualquiera de tus máquinas conectadas.', + startNewSessionButton: 'Iniciar nueva sesión', + openTerminalToStart: 'Abre un nuevo terminal en tu computadora para iniciar una sesión.', + }, + }, + + zen: { + title: 'Zen', + add: { + placeholder: '¿Qué hay que hacer?', + }, + home: { + noTasksYet: 'Aún no hay tareas. Toca + para añadir una.', + }, + view: { + workOnTask: 'Trabajar en la tarea', + clarify: 'Aclarar', + delete: 'Eliminar', + linkedSessions: 'Sesiones vinculadas', + tapTaskTextToEdit: 'Toca el texto de la tarea para editar', }, }, agentInput: { + envVars: { + title: 'Variables de entorno', + titleWithCount: ({ count }: { count: number }) => `Variables de entorno (${count})`, + }, + resumeChip: { + withId: ({ title, id }: { title: string; id: string }) => `${title}: ${id}`, + withIdTruncated: ({ title, prefix, suffix }: { title: string; prefix: string; suffix: string }) => + `${title}: ${prefix}…${suffix}`, + }, permissionMode: { title: 'MODO DE PERMISOS', default: 'Por defecto', acceptEdits: 'Aceptar ediciones', plan: 'Modo de planificación', bypassPermissions: 'Modo Yolo', + badgeAccept: 'Aceptar', + badgePlan: 'Plan', + badgeYolo: 'YOLO', badgeAcceptAllEdits: 'Aceptar todas las ediciones', badgeBypassAllPermissions: 'Omitir todos los permisos', badgePlanMode: 'Modo de planificación', @@ -403,7 +787,13 @@ export const es: TranslationStructure = { agent: { claude: 'Claude', codex: 'Codex', + opencode: 'OpenCode', gemini: 'Gemini', + auggie: 'Auggie', + }, + auggieIndexingChip: { + on: 'Indexing on', + off: 'Indexing off', }, model: { title: 'MODELO', @@ -412,22 +802,22 @@ export const es: TranslationStructure = { codexPermissionMode: { title: 'MODO DE PERMISOS CODEX', default: 'Configuración del CLI', - readOnly: 'Read Only Mode', - safeYolo: 'Safe YOLO', + readOnly: 'Modo de solo lectura', + safeYolo: 'YOLO seguro', yolo: 'YOLO', - badgeReadOnly: 'Read Only Mode', - badgeSafeYolo: 'Safe YOLO', + badgeReadOnly: 'Solo lectura', + badgeSafeYolo: 'YOLO seguro', badgeYolo: 'YOLO', }, codexModel: { - title: 'CODEX MODEL', - gpt5CodexLow: 'gpt-5-codex low', - gpt5CodexMedium: 'gpt-5-codex medium', - gpt5CodexHigh: 'gpt-5-codex high', - gpt5Minimal: 'GPT-5 Minimal', - gpt5Low: 'GPT-5 Low', - gpt5Medium: 'GPT-5 Medium', - gpt5High: 'GPT-5 High', + title: 'MODELO CODEX', + gpt5CodexLow: 'gpt-5-codex bajo', + gpt5CodexMedium: 'gpt-5-codex medio', + gpt5CodexHigh: 'gpt-5-codex alto', + gpt5Minimal: 'GPT-5 Mínimo', + gpt5Low: 'GPT-5 Bajo', + gpt5Medium: 'GPT-5 Medio', + gpt5High: 'GPT-5 Alto', }, geminiPermissionMode: { title: 'MODO DE PERMISOS GEMINI', @@ -439,6 +829,21 @@ export const es: TranslationStructure = { badgeSafeYolo: 'YOLO seguro', badgeYolo: 'YOLO', }, + geminiModel: { + title: 'MODELO GEMINI', + gemini25Pro: { + label: 'Gemini 2.5 Pro', + description: 'Más capaz', + }, + gemini25Flash: { + label: 'Gemini 2.5 Flash', + description: 'Rápido y eficiente', + }, + gemini25FlashLite: { + label: 'Gemini 2.5 Flash Lite', + description: 'Más rápido', + }, + }, context: { remaining: ({ percent }: { percent: number }) => `${percent}% restante`, }, @@ -446,6 +851,11 @@ export const es: TranslationStructure = { fileLabel: 'ARCHIVO', folderLabel: 'CARPETA', }, + actionMenu: { + title: 'ACCIONES', + files: 'Archivos', + stop: 'Detener', + }, noMachinesAvailable: 'Sin máquinas', }, @@ -504,6 +914,10 @@ export const es: TranslationStructure = { applyChanges: 'Actualizar archivo', viewDiff: 'Cambios del archivo actual', question: 'Pregunta', + changeTitle: 'Cambiar título', + }, + geminiExecute: { + cwd: ({ cwd }: { cwd: string }) => `📁 ${cwd}`, }, desc: { terminalCmd: ({ cmd }: { cmd: string }) => `Terminal(cmd: ${cmd})`, @@ -525,7 +939,19 @@ export const es: TranslationStructure = { askUserQuestion: { submit: 'Enviar respuesta', multipleQuestions: ({ count }: { count: number }) => `${count} ${plural({ count, singular: 'pregunta', plural: 'preguntas' })}`, - } + }, + exitPlanMode: { + approve: 'Aprobar plan', + reject: 'Rechazar', + requestChanges: 'Solicitar cambios', + requestChangesPlaceholder: 'Dile a Claude qué quieres cambiar de este plan…', + requestChangesSend: 'Enviar comentarios', + requestChangesEmpty: 'Escribe qué quieres cambiar.', + requestChangesFailed: 'No se pudieron solicitar cambios. Inténtalo de nuevo.', + responded: 'Respuesta enviada', + approvalMessage: 'Apruebo este plan. Por favor, continúa con la implementación.', + rejectionMessage: 'No apruebo este plan. Por favor, revísalo o pregúntame qué cambios me gustaría.', + }, }, files: { @@ -666,6 +1092,11 @@ export const es: TranslationStructure = { deviceLinkedSuccessfully: 'Dispositivo vinculado exitosamente', terminalConnectedSuccessfully: 'Terminal conectado exitosamente', invalidAuthUrl: 'URL de autenticación inválida', + microphoneAccessRequiredTitle: 'Se requiere acceso al micrófono', + microphoneAccessRequiredRequestPermission: 'Happy necesita acceso a tu micrófono para el chat de voz. Concede el permiso cuando se te solicite.', + microphoneAccessRequiredEnableInSettings: 'Happy necesita acceso a tu micrófono para el chat de voz. Activa el acceso al micrófono en la configuración de tu dispositivo.', + microphoneAccessRequiredBrowserInstructions: 'Permite el acceso al micrófono en la configuración del navegador. Puede que debas hacer clic en el icono de candado en la barra de direcciones y habilitar el permiso del micrófono para este sitio.', + openSettings: 'Abrir configuración', developerMode: 'Modo desarrollador', developerModeEnabled: 'Modo desarrollador habilitado', developerModeDisabled: 'Modo desarrollador deshabilitado', @@ -717,9 +1148,18 @@ export const es: TranslationStructure = { offlineUnableToSpawn: 'El lanzador está deshabilitado mientras la máquina está desconectada', offlineHelp: '• Asegúrate de que tu computadora esté en línea\n• Ejecuta `happy daemon status` para diagnosticar\n• ¿Estás usando la última versión del CLI? Actualiza con `npm install -g happy-coder@latest`', launchNewSessionInDirectory: 'Iniciar nueva sesión en directorio', - daemon: 'Daemon', + daemon: 'Demonio', status: 'Estado', stopDaemon: 'Detener daemon', + stopDaemonConfirmTitle: '¿Detener daemon?', + stopDaemonConfirmBody: 'No podrás crear nuevas sesiones en esta máquina hasta que reinicies el daemon en tu computadora. Tus sesiones actuales seguirán activas.', + daemonStoppedTitle: 'Daemon detenido', + stopDaemonFailed: 'No se pudo detener el daemon. Puede que no esté en ejecución.', + renameTitle: 'Renombrar máquina', + renameDescription: 'Dale a esta máquina un nombre personalizado. Déjalo vacío para usar el hostname predeterminado.', + renamePlaceholder: 'Ingresa el nombre de la máquina', + renamedSuccess: 'Máquina renombrada correctamente', + renameFailed: 'No se pudo renombrar la máquina', lastKnownPid: 'Último PID conocido', lastKnownHttpPort: 'Último puerto HTTP conocido', startedAt: 'Iniciado en', @@ -736,20 +1176,40 @@ export const es: TranslationStructure = { lastSeen: 'Visto por última vez', never: 'Nunca', metadataVersion: 'Versión de metadatos', + detectedClis: 'CLI detectados', + detectedCliNotDetected: 'No detectado', + detectedCliUnknown: 'Desconocido', + detectedCliNotSupported: 'No compatible (actualiza happy-cli)', untitledSession: 'Sesión sin título', back: 'Atrás', + notFound: 'Máquina no encontrada', + unknownMachine: 'máquina desconocida', + unknownPath: 'ruta desconocida', + tmux: { + overrideTitle: 'Sobrescribir la configuración global de tmux', + overrideEnabledSubtitle: 'La configuración personalizada de tmux se aplica a las nuevas sesiones en esta máquina.', + overrideDisabledSubtitle: 'Las nuevas sesiones usan la configuración global de tmux.', + notDetectedSubtitle: 'tmux no se detecta en esta máquina.', + notDetectedMessage: 'tmux no se detecta en esta máquina. Instala tmux y actualiza la detección.', + }, }, message: { switchedToMode: ({ mode }: { mode: string }) => `Cambiado al modo ${mode}`, + discarded: 'Descartado', unknownEvent: 'Evento desconocido', usageLimitUntil: ({ time }: { time: string }) => `Límite de uso alcanzado hasta ${time}`, unknownTime: 'tiempo desconocido', }, + chatFooter: { + permissionsTerminalOnly: 'Los permisos se muestran solo en el terminal. Restablece o envía un mensaje para controlar desde la app.', + }, + codex: { // Codex permission dialog buttons permissions: { + yesAlwaysAllowCommand: 'Sí, permitir globalmente', yesForSession: 'Sí, y no preguntar por esta sesión', stopAndExplain: 'Detener, y explicar qué hacer', } @@ -760,6 +1220,9 @@ export const es: TranslationStructure = { permissions: { yesAllowAllEdits: 'Sí, permitir todas las ediciones durante esta sesión', yesForTool: 'Sí, no volver a preguntar para esta herramienta', + yesForCommandPrefix: 'Sí, no volver a preguntar para este prefijo de comando', + yesForSubcommand: 'Sí, no volver a preguntar para este subcomando', + yesForCommandName: 'Sí, no volver a preguntar para este comando', noTellClaude: 'No, proporcionar comentarios', } }, @@ -773,6 +1236,7 @@ export const es: TranslationStructure = { textCopied: 'Texto copiado al portapapeles', failedToCopy: 'Error al copiar el texto al portapapeles', noTextToCopy: 'No hay texto disponible para copiar', + failedToOpen: 'No se pudo abrir la selección de texto. Intenta de nuevo.', }, markdown: { @@ -793,11 +1257,14 @@ export const es: TranslationStructure = { edit: 'Editar artefacto', delete: 'Eliminar', updateError: 'No se pudo actualizar el artefacto. Por favor, intenta de nuevo.', + deleteError: 'No se pudo eliminar el artefacto. Intenta de nuevo.', notFound: 'Artefacto no encontrado', discardChanges: '¿Descartar cambios?', discardChangesDescription: 'Tienes cambios sin guardar. ¿Estás seguro de que quieres descartarlos?', deleteConfirm: '¿Eliminar artefacto?', deleteConfirmDescription: 'Esta acción no se puede deshacer', + noContent: 'Sin contenido', + untitled: 'Sin título', titleLabel: 'TÍTULO', titlePlaceholder: 'Ingresa un título para tu artefacto', bodyLabel: 'CONTENIDO', @@ -813,6 +1280,8 @@ export const es: TranslationStructure = { friends: { // Friends feature title: 'Amigos', + sharedSessions: 'Sesiones compartidas', + noSharedSessions: 'Aún no hay sesiones compartidas', manageFriends: 'Administra tus amigos y conexiones', searchTitle: 'Buscar amigos', pendingRequests: 'Solicitudes de amistad', @@ -883,10 +1352,55 @@ export const es: TranslationStructure = { friendAcceptedGeneric: 'Solicitud de amistad aceptada', }, + secrets: { + addTitle: 'Nuevo secreto', + savedTitle: 'Secretos guardados', + badgeReady: 'Secreto', + badgeRequired: 'Se requiere secreto', + missingForProfile: ({ env }: { env: string | null }) => + `Falta el secreto (${env ?? 'secreto'}). Configúralo en la máquina o selecciona/introduce un secreto.`, + defaultForProfileTitle: 'Secreto predeterminado', + defineDefaultForProfileTitle: 'Definir secreto predeterminado para este perfil', + addSubtitle: 'Agregar un secreto guardado', + noneTitle: 'Ninguna', + noneSubtitle: 'Usa el entorno de la máquina o ingresa un secreto para esta sesión', + emptyTitle: 'No hay secretos guardados', + emptySubtitle: 'Agrega uno para usar perfiles con secreto sin configurar variables de entorno en la máquina.', + savedHiddenSubtitle: 'Guardada (valor oculto)', + defaultLabel: 'Predeterminada', + fields: { + name: 'Nombre', + value: 'Valor', + }, + placeholders: { + nameExample: 'p. ej., Work OpenAI', + }, + validation: { + nameRequired: 'El nombre es obligatorio.', + valueRequired: 'El valor es obligatorio.', + }, + actions: { + replace: 'Reemplazar', + replaceValue: 'Reemplazar valor', + setDefault: 'Establecer como predeterminada', + unsetDefault: 'Quitar como predeterminada', + }, + prompts: { + renameTitle: 'Renombrar secreto', + renameDescription: 'Actualiza el nombre descriptivo de este secreto.', + replaceValueTitle: 'Reemplazar valor del secreto', + replaceValueDescription: 'Pega el nuevo valor del secreto. Este valor no se mostrará de nuevo después de guardarlo.', + deleteTitle: 'Eliminar secreto', + deleteConfirm: ({ name }: { name: string }) => `¿Eliminar “${name}”? Esto no se puede deshacer.`, + }, + }, + profiles: { // Profile management feature title: 'Perfiles', subtitle: 'Gestionar perfiles de variables de entorno para sesiones', + sessionUses: ({ profile }: { profile: string }) => `Esta sesión usa: ${profile}`, + profilesFixedPerSession: 'Los perfiles son fijos por sesión. Para usar un perfil diferente, inicia una nueva sesión.', noProfile: 'Sin Perfil', noProfileDescription: 'Usar configuración de entorno predeterminada', defaultModel: 'Modelo Predeterminado', @@ -903,9 +1417,232 @@ export const es: TranslationStructure = { enterTmuxTempDir: 'Ingrese la ruta del directorio temporal', tmuxUpdateEnvironment: 'Actualizar entorno automáticamente', nameRequired: 'El nombre del perfil es requerido', - deleteConfirm: '¿Estás seguro de que quieres eliminar el perfil "{name}"?', + deleteConfirm: ({ name }: { name: string }) => `¿Estás seguro de que quieres eliminar el perfil "${name}"?`, editProfile: 'Editar Perfil', addProfileTitle: 'Agregar Nuevo Perfil', + builtIn: 'Integrado', + custom: 'Personalizado', + builtInSaveAsHint: 'Guardar un perfil integrado crea un nuevo perfil personalizado.', + builtInNames: { + anthropic: 'Anthropic (Predeterminado)', + deepseek: 'DeepSeek (Razonamiento)', + zai: 'Z.AI (GLM-4.6)', + codex: 'Codex (Predeterminado)', + openai: 'OpenAI (GPT-5)', + azureOpenai: 'Azure OpenAI', + gemini: 'Gemini (Predeterminado)', + geminiApiKey: 'Gemini (clave API)', + geminiVertex: 'Gemini (Vertex AI)', + }, + groups: { + favorites: 'Favoritos', + custom: 'Tus perfiles', + builtIn: 'Perfiles integrados', + }, + actions: { + viewEnvironmentVariables: 'Variables de entorno', + addToFavorites: 'Agregar a favoritos', + removeFromFavorites: 'Quitar de favoritos', + editProfile: 'Editar perfil', + duplicateProfile: 'Duplicar perfil', + deleteProfile: 'Eliminar perfil', + }, + copySuffix: '(Copia)', + duplicateName: 'Ya existe un perfil con este nombre', + setupInstructions: { + title: 'Instrucciones de configuración', + viewOfficialGuide: 'Ver la guía oficial de configuración', + }, + machineLogin: { + title: 'Se requiere iniciar sesión en la máquina', + subtitle: 'Este perfil depende de una caché de inicio de sesión del CLI en la máquina seleccionada.', + status: { + loggedIn: 'Sesión iniciada', + notLoggedIn: 'No has iniciado sesión', + }, + claudeCode: { + title: 'Claude Code', + instructions: 'Ejecuta `claude` y luego escribe `/login` para iniciar sesión.', + warning: 'Nota: establecer `ANTHROPIC_AUTH_TOKEN` sobrescribe el inicio de sesión del CLI.', + }, + codex: { + title: 'Codex', + instructions: 'Ejecuta `codex login` para iniciar sesión.', + }, + geminiCli: { + title: 'Gemini CLI', + instructions: 'Ejecuta `gemini auth` para iniciar sesión.', + }, + }, + requirements: { + secretRequired: 'Secreto', + configured: 'Configurada en la máquina', + notConfigured: 'No configurada', + checking: 'Comprobando…', + missingConfigForProfile: ({ env }: { env: string }) => `Este perfil requiere que ${env} esté configurado en la máquina.`, + modalTitle: 'Se requiere secreto', + modalBody: 'Este perfil requiere un secreto.\n\nOpciones disponibles:\n• Usar entorno de la máquina (recomendado)\n• Usar un secreto guardado en la configuración de la app\n• Ingresar un secreto solo para esta sesión', + sectionTitle: 'Requisitos', + sectionSubtitle: 'Estos campos se usan para comprobar el estado y evitar fallos inesperados.', + secretEnvVarPromptDescription: 'Ingresa el nombre de la variable de entorno secreta requerida (p. ej., OPENAI_API_KEY).', + modalHelpWithEnv: ({ env }: { env: string }) => `Este perfil necesita ${env}. Elige una opción abajo.`, + modalHelpGeneric: 'Este perfil necesita un secreto. Elige una opción abajo.', + chooseOptionTitle: 'Elige una opción', + machineEnvStatus: { + theMachine: 'la máquina', + checkFor: ({ env }: { env: string }) => `Comprobar ${env}`, + checking: ({ env }: { env: string }) => `Comprobando ${env}…`, + found: ({ env, machine }: { env: string; machine: string }) => `${env} encontrado en ${machine}`, + notFound: ({ env, machine }: { env: string; machine: string }) => `${env} no encontrado en ${machine}`, + }, + machineEnvSubtitle: { + checking: 'Comprobando el entorno del daemon…', + found: 'Encontrado en el entorno del daemon en la máquina.', + notFound: 'Configúralo en el entorno del daemon en la máquina y reinicia el daemon.', + }, + options: { + none: { + title: 'Ninguna', + subtitle: 'No requiere secreto ni inicio de sesión por CLI.', + }, + machineLogin: { + subtitle: 'Requiere iniciar sesión mediante un CLI en la máquina de destino.', + longSubtitle: 'Requiere haber iniciado sesión mediante el CLI para el backend de IA que elijas en la máquina de destino.', + }, + useMachineEnvironment: { + title: 'Usar entorno de la máquina', + subtitleWithEnv: ({ env }: { env: string }) => `Usar ${env} del entorno del daemon.`, + subtitleGeneric: 'Usar el secreto del entorno del daemon.', + }, + useSavedSecret: { + title: 'Usar un secreto guardado', + subtitle: 'Selecciona (o agrega) un secreto guardado en la app.', + }, + enterOnce: { + title: 'Ingresar un secreto', + subtitle: 'Pega un secreto solo para esta sesión (no se guardará).', + }, + }, + secretEnvVar: { + title: 'Variable de entorno del secreto', + subtitle: 'Ingresa el nombre de la variable de entorno que este proveedor espera para su secreto (p. ej., OPENAI_API_KEY).', + label: 'Nombre de la variable de entorno', + }, + sections: { + machineEnvironment: 'Entorno de la máquina', + useOnceTitle: 'Usar una vez', + useOnceLabel: 'Ingresa un secreto', + useOnceFooter: 'Pega un secreto solo para esta sesión. No se guardará.', + }, + actions: { + useMachineEnvironment: { + subtitle: 'Comenzar con la clave ya presente en la máquina.', + }, + useOnceButton: 'Usar una vez (solo sesión)', + }, + }, + defaultSessionType: 'Tipo de sesión predeterminado', + defaultPermissionMode: { + title: 'Modo de permisos predeterminado', + descriptions: { + default: 'Pedir permisos', + acceptEdits: 'Aprobar ediciones automáticamente', + plan: 'Planificar antes de ejecutar', + bypassPermissions: 'Omitir todos los permisos', + }, + }, + aiBackend: { + title: 'Backend de IA', + selectAtLeastOneError: 'Selecciona al menos un backend de IA.', + claudeSubtitle: 'CLI de Claude', + codexSubtitle: 'CLI de Codex', + opencodeSubtitle: 'CLI de OpenCode', + geminiSubtitleExperimental: 'CLI de Gemini (experimental)', + auggieSubtitle: 'CLI de Auggie', + }, + tmux: { + title: 'Tmux', + spawnSessionsTitle: 'Iniciar sesiones en Tmux', + spawnSessionsEnabledSubtitle: 'Las sesiones se abren en nuevas ventanas de tmux.', + spawnSessionsDisabledSubtitle: 'Las sesiones se abren en una shell normal (sin integración con tmux)', + isolatedServerTitle: 'Servidor tmux aislado', + isolatedServerEnabledSubtitle: 'Inicia sesiones en un servidor tmux aislado (recomendado).', + isolatedServerDisabledSubtitle: 'Inicia sesiones en tu servidor tmux predeterminado.', + sessionNamePlaceholder: 'Vacío = sesión actual/más reciente', + tempDirPlaceholder: 'Dejar vacío para generar automáticamente', + }, + previewMachine: { + title: 'Vista previa de la máquina', + itemTitle: 'Máquina de vista previa para variables de entorno', + selectMachine: 'Seleccionar máquina', + resolveSubtitle: 'Se usa solo para previsualizar los valores resueltos abajo (no cambia lo que se guarda).', + selectSubtitle: 'Selecciona una máquina para previsualizar los valores resueltos abajo.', + }, + environmentVariables: { + title: 'Variables de entorno', + addVariable: 'Añadir variable', + namePlaceholder: 'Nombre de variable (p. ej., MY_CUSTOM_VAR)', + valuePlaceholder: 'Valor (p. ej., mi-valor o ${MY_VAR})', + validation: { + nameRequired: 'Introduce un nombre de variable.', + invalidNameFormat: 'Los nombres de variables deben ser letras mayúsculas, números y guiones bajos, y no pueden empezar por un número.', + duplicateName: 'Esa variable ya existe.', + }, + card: { + valueLabel: 'Valor:', + fallbackValueLabel: 'Valor de respaldo:', + valueInputPlaceholder: 'Valor', + defaultValueInputPlaceholder: 'Valor predeterminado', + fallbackDisabledForVault: 'Los valores de respaldo están deshabilitados al usar el almacén de secretos.', + secretNotRetrieved: 'Valor secreto: no se recupera por seguridad', + secretToggleLabel: 'Ocultar el valor en la UI', + secretToggleSubtitle: 'Oculta el valor en la UI y evita obtenerlo de la máquina para la vista previa.', + secretToggleEnforcedByDaemon: 'Impuesto por el daemon', + secretToggleEnforcedByVault: 'Impuesto por el almacén de secretos', + secretToggleResetToAuto: 'Restablecer a automático', + requirementRequiredLabel: 'Obligatorio', + requirementRequiredSubtitle: 'Bloquea la creación de la sesión si falta la variable.', + requirementUseVaultLabel: 'Usar almacén de secretos', + requirementUseVaultSubtitle: 'Usar un secreto guardado (sin valores de respaldo).', + defaultSecretLabel: 'Secreto predeterminado', + overridingDefault: ({ expectedValue }: { expectedValue: string }) => + `Sobrescribiendo el valor documentado: ${expectedValue}`, + useMachineEnvToggle: 'Usar valor del entorno de la máquina', + resolvedOnSessionStart: 'Se resuelve al iniciar la sesión en la máquina seleccionada.', + sourceVariableLabel: 'Variable de origen', + sourceVariablePlaceholder: 'Nombre de variable de origen (p. ej., Z_AI_MODEL)', + checkingMachine: ({ machine }: { machine: string }) => `Verificando ${machine}...`, + emptyOnMachine: ({ machine }: { machine: string }) => `Vacío en ${machine}`, + emptyOnMachineUsingFallback: ({ machine }: { machine: string }) => `Vacío en ${machine} (usando respaldo)`, + notFoundOnMachine: ({ machine }: { machine: string }) => `No encontrado en ${machine}`, + notFoundOnMachineUsingFallback: ({ machine }: { machine: string }) => `No encontrado en ${machine} (usando respaldo)`, + valueFoundOnMachine: ({ machine }: { machine: string }) => `Valor encontrado en ${machine}`, + differsFromDocumented: ({ expectedValue }: { expectedValue: string }) => + `Difiere del valor documentado: ${expectedValue}`, + }, + preview: { + secretValueHidden: ({ value }: { value: string }) => `${value} - oculto por seguridad`, + hiddenValue: '***oculto***', + emptyValue: '(vacío)', + sessionWillReceive: ({ name, value }: { name: string; value: string }) => + `La sesión recibirá: ${name} = ${value}`, + }, + previewModal: { + titleWithProfile: ({ profileName }: { profileName: string }) => `Vars de entorno · ${profileName}`, + descriptionPrefix: 'Estas variables de entorno se envían al iniciar la sesión. Los valores se resuelven usando el daemon en', + descriptionFallbackMachine: 'la máquina seleccionada', + descriptionSuffix: '.', + emptyMessage: 'No hay variables de entorno configuradas para este perfil.', + checkingSuffix: '(verificando…)', + detail: { + fixed: 'Fijo', + machine: 'Máquina', + checking: 'Verificando', + fallback: 'Respaldo', + missing: 'Falta', + }, + }, + }, delete: { title: 'Eliminar Perfil', message: ({ name }: { name: string }) => `¿Estás seguro de que quieres eliminar "${name}"? Esta acción no se puede deshacer.`, diff --git a/expo-app/sources/text/translations/it.ts b/expo-app/sources/text/translations/it.ts index 0f3d4cf2a..54060e29b 100644 --- a/expo-app/sources/text/translations/it.ts +++ b/expo-app/sources/text/translations/it.ts @@ -1,4 +1,4 @@ -import type { TranslationStructure } from '../_default'; +import type { TranslationStructure } from '../_types'; /** * Italian plural helper function @@ -31,6 +31,8 @@ export const it: TranslationStructure = { common: { // Simple string constants + add: 'Aggiungi', + actions: 'Azioni', cancel: 'Annulla', authenticate: 'Autentica', save: 'Salva', @@ -46,7 +48,11 @@ export const it: TranslationStructure = { yes: 'Sì', no: 'No', discard: 'Scarta', + discardChanges: 'Scarta modifiche', + unsavedChangesWarning: 'Hai modifiche non salvate.', + keepEditing: 'Continua a modificare', version: 'Versione', + details: 'Dettagli', copied: 'Copiato', copy: 'Copia', scanning: 'Scansione...', @@ -59,9 +65,21 @@ export const it: TranslationStructure = { retry: 'Riprova', delete: 'Elimina', optional: 'opzionale', + noMatches: 'Nessuna corrispondenza', + all: 'Tutti', + machine: 'macchina', + clearSearch: 'Cancella ricerca', + refresh: 'Aggiorna', saveAs: 'Salva con nome', }, + dropdown: { + category: { + general: 'Generale', + results: 'Risultati', + }, + }, + profile: { userProfile: 'Profilo utente', details: 'Dettagli', @@ -74,6 +92,8 @@ export const it: TranslationStructure = { profiles: { title: 'Profili', subtitle: 'Gestisci i profili delle variabili ambiente per le sessioni', + sessionUses: ({ profile }: { profile: string }) => `Questa sessione usa: ${profile}`, + profilesFixedPerSession: 'I profili sono fissi per sessione. Per usare un profilo diverso, avvia una nuova sessione.', noProfile: 'Nessun profilo', noProfileDescription: 'Usa le impostazioni ambiente predefinite', defaultModel: 'Modello predefinito', @@ -90,9 +110,232 @@ export const it: TranslationStructure = { enterTmuxTempDir: 'Inserisci percorso directory temporanea', tmuxUpdateEnvironment: 'Aggiorna ambiente automaticamente', nameRequired: 'Il nome del profilo è obbligatorio', - deleteConfirm: 'Sei sicuro di voler eliminare il profilo "{name}"?', + deleteConfirm: ({ name }: { name: string }) => `Sei sicuro di voler eliminare il profilo "${name}"?`, editProfile: 'Modifica profilo', addProfileTitle: 'Aggiungi nuovo profilo', + builtIn: 'Integrato', + custom: 'Personalizzato', + builtInSaveAsHint: 'Salvare un profilo integrato crea un nuovo profilo personalizzato.', + builtInNames: { + anthropic: 'Anthropic (Predefinito)', + deepseek: 'DeepSeek (Ragionamento)', + zai: 'Z.AI (GLM-4.6)', + codex: 'Codex (Predefinito)', + openai: 'OpenAI (GPT-5)', + azureOpenai: 'Azure OpenAI', + gemini: 'Gemini (Predefinito)', + geminiApiKey: 'Gemini (API key)', + geminiVertex: 'Gemini (Vertex AI)', + }, + groups: { + favorites: 'Preferiti', + custom: 'I tuoi profili', + builtIn: 'Profili integrati', + }, + actions: { + viewEnvironmentVariables: 'Variabili ambiente', + addToFavorites: 'Aggiungi ai preferiti', + removeFromFavorites: 'Rimuovi dai preferiti', + editProfile: 'Modifica profilo', + duplicateProfile: 'Duplica profilo', + deleteProfile: 'Elimina profilo', + }, + copySuffix: '(Copia)', + duplicateName: 'Esiste già un profilo con questo nome', + setupInstructions: { + title: 'Istruzioni di configurazione', + viewOfficialGuide: 'Visualizza la guida ufficiale di configurazione', + }, + machineLogin: { + title: 'Login richiesto sulla macchina', + subtitle: 'Questo profilo si basa su una cache di login del CLI sulla macchina selezionata.', + status: { + loggedIn: 'Accesso effettuato', + notLoggedIn: 'Accesso non effettuato', + }, + claudeCode: { + title: 'Claude Code', + instructions: 'Esegui `claude`, poi digita `/login` per accedere.', + warning: 'Nota: impostare `ANTHROPIC_AUTH_TOKEN` sostituisce il login del CLI.', + }, + codex: { + title: 'Codex', + instructions: 'Esegui `codex login` per accedere.', + }, + geminiCli: { + title: 'Gemini CLI', + instructions: 'Esegui `gemini auth` per accedere.', + }, + }, + requirements: { + secretRequired: 'Segreto', + configured: 'Configurata sulla macchina', + notConfigured: 'Non configurata', + checking: 'Verifica…', + missingConfigForProfile: ({ env }: { env: string }) => `Questo profilo richiede la configurazione di ${env} sulla macchina.`, + modalTitle: 'Segreto richiesto', + modalBody: 'Questo profilo richiede un segreto.\n\nOpzioni supportate:\n• Usa ambiente della macchina (consigliato)\n• Usa un segreto salvato nelle impostazioni dell’app\n• Inserisci un segreto solo per questa sessione', + sectionTitle: 'Requisiti', + sectionSubtitle: 'Questi campi servono per verificare lo stato e evitare fallimenti inattesi.', + secretEnvVarPromptDescription: 'Inserisci il nome della variabile d’ambiente segreta richiesta (es. OPENAI_API_KEY).', + modalHelpWithEnv: ({ env }: { env: string }) => `Questo profilo richiede ${env}. Scegli un’opzione qui sotto.`, + modalHelpGeneric: 'Questo profilo richiede un segreto. Scegli un’opzione qui sotto.', + chooseOptionTitle: 'Scegli un’opzione', + machineEnvStatus: { + theMachine: 'la macchina', + checkFor: ({ env }: { env: string }) => `Controlla ${env}`, + checking: ({ env }: { env: string }) => `Verifica ${env}…`, + found: ({ env, machine }: { env: string; machine: string }) => `${env} trovato su ${machine}`, + notFound: ({ env, machine }: { env: string; machine: string }) => `${env} non trovato su ${machine}`, + }, + machineEnvSubtitle: { + checking: 'Verifica ambiente del daemon…', + found: 'Trovato nell’ambiente del daemon sulla macchina.', + notFound: 'Impostalo nell’ambiente del daemon sulla macchina e riavvia il daemon.', + }, + options: { + none: { + title: 'Nessuno', + subtitle: 'Non richiede segreto né login CLI.', + }, + machineLogin: { + subtitle: 'Richiede essere autenticati tramite un CLI sulla macchina di destinazione.', + longSubtitle: 'Richiede essere autenticati tramite il CLI per il backend IA scelto sulla macchina di destinazione.', + }, + useMachineEnvironment: { + title: 'Usa ambiente della macchina', + subtitleWithEnv: ({ env }: { env: string }) => `Usa ${env} dall’ambiente del daemon.`, + subtitleGeneric: 'Usa il segreto dall’ambiente del daemon.', + }, + useSavedSecret: { + title: 'Usa un segreto salvato', + subtitle: 'Seleziona (o aggiungi) un segreto salvato nell’app.', + }, + enterOnce: { + title: 'Inserisci un segreto', + subtitle: 'Incolla un segreto solo per questa sessione (non verrà salvato).', + }, + }, + secretEnvVar: { + title: 'Variabile d’ambiente del segreto', + subtitle: 'Inserisci il nome della variabile d’ambiente che questo provider si aspetta per il segreto (es. OPENAI_API_KEY).', + label: 'Nome variabile d’ambiente', + }, + sections: { + machineEnvironment: 'Ambiente della macchina', + useOnceTitle: 'Usa una volta', + useOnceLabel: 'Inserisci un segreto', + useOnceFooter: 'Incolla un segreto solo per questa sessione. Non verrà salvato.', + }, + actions: { + useMachineEnvironment: { + subtitle: 'Inizia con la chiave già presente sulla macchina.', + }, + useOnceButton: 'Usa una volta (solo sessione)', + }, + }, + defaultSessionType: 'Tipo di sessione predefinito', + defaultPermissionMode: { + title: 'Modalità di permesso predefinita', + descriptions: { + default: 'Chiedi permessi', + acceptEdits: 'Approva automaticamente le modifiche', + plan: 'Pianifica prima di eseguire', + bypassPermissions: 'Salta tutti i permessi', + }, + }, + aiBackend: { + title: 'Backend IA', + selectAtLeastOneError: 'Seleziona almeno un backend IA.', + claudeSubtitle: 'Claude CLI', + codexSubtitle: 'Codex CLI', + opencodeSubtitle: 'OpenCode CLI', + geminiSubtitleExperimental: 'Gemini CLI (sperimentale)', + auggieSubtitle: 'Auggie CLI', + }, + tmux: { + title: 'Tmux', + spawnSessionsTitle: 'Avvia sessioni in Tmux', + spawnSessionsEnabledSubtitle: 'Le sessioni vengono avviate in nuove finestre di tmux.', + spawnSessionsDisabledSubtitle: 'Le sessioni vengono avviate in una shell normale (senza integrazione tmux)', + isolatedServerTitle: 'Server tmux isolato', + isolatedServerEnabledSubtitle: 'Avvia le sessioni in un server tmux isolato (consigliato).', + isolatedServerDisabledSubtitle: 'Avvia le sessioni nel server tmux predefinito.', + sessionNamePlaceholder: 'Vuoto = sessione corrente/più recente', + tempDirPlaceholder: 'Lascia vuoto per generare automaticamente', + }, + previewMachine: { + title: 'Anteprima macchina', + itemTitle: 'Macchina di anteprima per variabili d\'ambiente', + selectMachine: 'Seleziona macchina', + resolveSubtitle: 'Usata solo per l\'anteprima dei valori risolti sotto (non cambia ciò che viene salvato).', + selectSubtitle: 'Seleziona una macchina per l\'anteprima dei valori risolti sotto.', + }, + environmentVariables: { + title: 'Variabili ambiente', + addVariable: 'Aggiungi variabile', + namePlaceholder: 'Nome variabile (es., MY_CUSTOM_VAR)', + valuePlaceholder: 'Valore (es., my-value o ${MY_VAR})', + validation: { + nameRequired: 'Inserisci un nome variabile.', + invalidNameFormat: 'I nomi delle variabili devono usare lettere maiuscole, numeri e underscore e non possono iniziare con un numero.', + duplicateName: 'Questa variabile esiste già.', + }, + card: { + valueLabel: 'Valore:', + fallbackValueLabel: 'Valore di fallback:', + valueInputPlaceholder: 'Valore', + defaultValueInputPlaceholder: 'Valore predefinito', + fallbackDisabledForVault: 'I fallback sono disabilitati quando usi il vault dei segreti.', + secretNotRetrieved: 'Valore segreto - non recuperato per sicurezza', + secretToggleLabel: 'Nascondi il valore nella UI', + secretToggleSubtitle: 'Nasconde il valore nella UI ed evita di recuperarlo dalla macchina per l\'anteprima.', + secretToggleEnforcedByDaemon: 'Imposto dal daemon', + secretToggleEnforcedByVault: 'Imposto dal vault dei segreti', + secretToggleResetToAuto: 'Ripristina su automatico', + requirementRequiredLabel: 'Obbligatorio', + requirementRequiredSubtitle: 'Blocca la creazione della sessione quando la variabile manca.', + requirementUseVaultLabel: 'Usa vault dei segreti', + requirementUseVaultSubtitle: 'Usa un segreto salvato (senza valori di fallback).', + defaultSecretLabel: 'Segreto predefinito', + overridingDefault: ({ expectedValue }: { expectedValue: string }) => + `Sostituzione del valore predefinito documentato: ${expectedValue}`, + useMachineEnvToggle: 'Usa valore dall\'ambiente della macchina', + resolvedOnSessionStart: 'Risolto quando la sessione viene avviata sulla macchina selezionata.', + sourceVariableLabel: 'Variabile sorgente', + sourceVariablePlaceholder: 'Nome variabile sorgente (es., Z_AI_MODEL)', + checkingMachine: ({ machine }: { machine: string }) => `Verifica ${machine}...`, + emptyOnMachine: ({ machine }: { machine: string }) => `Vuoto su ${machine}`, + emptyOnMachineUsingFallback: ({ machine }: { machine: string }) => `Vuoto su ${machine} (uso fallback)`, + notFoundOnMachine: ({ machine }: { machine: string }) => `Non trovato su ${machine}`, + notFoundOnMachineUsingFallback: ({ machine }: { machine: string }) => `Non trovato su ${machine} (uso fallback)`, + valueFoundOnMachine: ({ machine }: { machine: string }) => `Valore trovato su ${machine}`, + differsFromDocumented: ({ expectedValue }: { expectedValue: string }) => + `Diverso dal valore documentato: ${expectedValue}`, + }, + preview: { + secretValueHidden: ({ value }: { value: string }) => `${value} - nascosto per sicurezza`, + hiddenValue: '***nascosto***', + emptyValue: '(vuoto)', + sessionWillReceive: ({ name, value }: { name: string; value: string }) => + `La sessione riceverà: ${name} = ${value}`, + }, + previewModal: { + titleWithProfile: ({ profileName }: { profileName: string }) => `Variabili ambiente · ${profileName}`, + descriptionPrefix: 'Queste variabili ambiente vengono inviate all\'avvio della sessione. I valori vengono risolti dal daemon su', + descriptionFallbackMachine: 'la macchina selezionata', + descriptionSuffix: '.', + emptyMessage: 'Nessuna variabile ambiente è impostata per questo profilo.', + checkingSuffix: '(verifica…)', + detail: { + fixed: 'Fisso', + machine: 'Macchina', + checking: 'Verifica', + fallback: 'Alternativa', + missing: 'Mancante', + }, + }, + }, delete: { title: 'Elimina profilo', message: ({ name }: { name: string }) => `Sei sicuro di voler eliminare "${name}"? Questa azione non può essere annullata.`, @@ -125,6 +368,16 @@ export const it: TranslationStructure = { enterSecretKey: 'Inserisci la chiave segreta', invalidSecretKey: 'Chiave segreta non valida. Controlla e riprova.', enterUrlManually: 'Inserisci URL manualmente', + openMachine: 'Apri macchina', + terminalUrlPlaceholder: 'happy://terminal?...', + restoreQrInstructions: '1. Apri Happy sul tuo dispositivo mobile\n2. Vai su Impostazioni → Account\n3. Tocca "Collega nuovo dispositivo"\n4. Scansiona questo codice QR', + restoreWithSecretKeyInstead: 'Ripristina con chiave segreta', + restoreWithSecretKeyDescription: 'Inserisci la chiave segreta per ripristinare l’accesso al tuo account.', + secretKeyPlaceholder: 'XXXXX-XXXXX-XXXXX...', + unsupported: { + connectTitle: ({ name }: { name: string }) => `Connetti ${name}`, + runCommandInTerminal: 'Esegui il seguente comando nel terminale:', + }, }, settings: { @@ -165,6 +418,12 @@ export const it: TranslationStructure = { usageSubtitle: 'Vedi il tuo utilizzo API e i costi', profiles: 'Profili', profilesSubtitle: 'Gestisci i profili delle variabili ambiente per le sessioni', + secrets: 'Segreti', + secretsSubtitle: 'Gestisci i segreti salvati (non verranno più mostrati dopo l’inserimento)', + terminal: 'Terminale', + session: 'Sessione', + sessionSubtitleTmuxEnabled: 'Tmux abilitato', + sessionSubtitleMessageSendingAndTmux: 'Invio messaggi e tmux', // Dynamic settings messages accountConnected: ({ service }: { service: string }) => `Account ${service} collegato`, @@ -202,6 +461,21 @@ export const it: TranslationStructure = { wrapLinesInDiffsDescription: 'A capo delle righe lunghe invece dello scorrimento orizzontale nelle viste diff', alwaysShowContextSize: 'Mostra sempre dimensione contesto', alwaysShowContextSizeDescription: 'Mostra l\'uso del contesto anche quando non è vicino al limite', + agentInputActionBarLayout: 'Barra azioni di input', + agentInputActionBarLayoutDescription: 'Scegli come vengono mostrati i chip azione sopra il campo di input', + agentInputActionBarLayoutOptions: { + auto: 'Auto', + wrap: 'A capo', + scroll: 'Scorrevole', + collapsed: 'Compresso', + }, + agentInputChipDensity: 'Densità dei chip azione', + agentInputChipDensityDescription: 'Scegli se i chip azione mostrano etichette o icone', + agentInputChipDensityOptions: { + auto: 'Auto', + labels: 'Etichette', + icons: 'Solo icone', + }, avatarStyle: 'Stile avatar', avatarStyleDescription: 'Scegli l\'aspetto dell\'avatar di sessione', avatarOptions: { @@ -222,6 +496,26 @@ export const it: TranslationStructure = { experimentalFeatures: 'Funzionalità sperimentali', experimentalFeaturesEnabled: 'Funzionalità sperimentali abilitate', experimentalFeaturesDisabled: 'Usando solo funzionalità stabili', + experimentalOptions: 'Opzioni sperimentali', + experimentalOptionsDescription: 'Scegli quali funzionalità sperimentali sono abilitate.', + expUsageReporting: 'Usage reporting', + expUsageReportingSubtitle: 'Enable usage and token reporting screens', + expFileViewer: 'File viewer', + expFileViewerSubtitle: 'Enable the session file viewer entrypoint', + expShowThinkingMessages: 'Show thinking messages', + expShowThinkingMessagesSubtitle: 'Show assistant thinking/status messages in chat', + expSessionType: 'Session type selector', + expSessionTypeSubtitle: 'Show the session type selector (simple vs worktree)', + expZen: 'Zen', + expZenSubtitle: 'Enable the Zen navigation entry', + expVoiceAuthFlow: 'Voice auth flow', + expVoiceAuthFlowSubtitle: 'Use authenticated voice token flow (paywall-aware)', + expInboxFriends: 'Posta in arrivo e amici', + expInboxFriendsSubtitle: 'Abilita la scheda Posta in arrivo e le funzionalità Amici', + expCodexResume: 'Riprendi Codex', + expCodexResumeSubtitle: 'Abilita la ripresa delle sessioni Codex usando un\'installazione separata (sperimentale)', + expCodexAcp: 'Codex ACP', + expCodexAcpSubtitle: 'Usa Codex tramite ACP (codex-acp) invece di MCP (sperimentale)', webFeatures: 'Funzionalità web', webFeaturesDescription: 'Funzionalità disponibili solo nella versione web dell\'app.', enterToSend: 'Invio con Enter', @@ -230,13 +524,24 @@ export const it: TranslationStructure = { commandPalette: 'Palette comandi', commandPaletteEnabled: 'Premi ⌘K per aprire', commandPaletteDisabled: 'Accesso rapido ai comandi disabilitato', - markdownCopyV2: 'Markdown Copy v2', + markdownCopyV2: 'Copia Markdown v2', markdownCopyV2Subtitle: 'Pressione lunga apre la finestra di copia', hideInactiveSessions: 'Nascondi sessioni inattive', hideInactiveSessionsSubtitle: 'Mostra solo le chat attive nella tua lista', + groupInactiveSessionsByProject: 'Raggruppa sessioni inattive per progetto', + groupInactiveSessionsByProjectSubtitle: 'Organizza le chat inattive per progetto', enhancedSessionWizard: 'Wizard sessione avanzato', enhancedSessionWizardEnabled: 'Avvio sessioni con profili attivo', enhancedSessionWizardDisabled: 'Usando avvio sessioni standard', + profiles: 'Profili IA', + profilesEnabled: 'Selezione profili abilitata', + profilesDisabled: 'Selezione profili disabilitata', + pickerSearch: 'Ricerca nei selettori', + pickerSearchSubtitle: 'Mostra un campo di ricerca nei selettori di macchina e percorso', + machinePickerSearch: 'Ricerca macchine', + machinePickerSearchSubtitle: 'Mostra un campo di ricerca nei selettori di macchine', + pathPickerSearch: 'Ricerca percorsi', + pathPickerSearchSubtitle: 'Mostra un campo di ricerca nei selettori di percorsi', }, errors: { @@ -284,11 +589,85 @@ export const it: TranslationStructure = { failedToRemoveFriend: 'Impossibile rimuovere l\'amico', searchFailed: 'Ricerca non riuscita. Riprova.', failedToSendRequest: 'Impossibile inviare la richiesta di amicizia', + failedToResumeSession: 'Impossibile riprendere la sessione', + failedToSendMessage: 'Impossibile inviare il messaggio', + cannotShareWithSelf: 'Non puoi condividere con te stesso', + canOnlyShareWithFriends: 'Puoi condividere solo con amici', + shareNotFound: 'Condivisione non trovata', + publicShareNotFound: 'Link pubblico non trovato o scaduto', + consentRequired: 'Consenso richiesto per l\'accesso', + maxUsesReached: 'Numero massimo di utilizzi raggiunto', + invalidShareLink: 'Link di condivisione non valido o scaduto', + missingPermissionId: 'Manca l\'ID del permesso', + codexResumeNotInstalledTitle: 'Codex resume non è installato su questa macchina', + codexResumeNotInstalledMessage: + 'Per riprendere una conversazione di Codex, installa il server di ripresa di Codex sulla macchina di destinazione (Dettagli macchina → Ripresa Codex).', + codexAcpNotInstalledTitle: 'Codex ACP non è installato su questa macchina', + codexAcpNotInstalledMessage: + 'Per usare l\'esperimento Codex ACP, installa codex-acp sulla macchina di destinazione (Dettagli macchina → Codex ACP) o disattiva l\'esperimento.', + }, + + deps: { + installNotSupported: 'Aggiorna Happy CLI per installare questa dipendenza.', + installFailed: 'Installazione non riuscita', + installed: 'Installato', + installLog: ({ path }: { path: string }) => `Log di installazione: ${path}`, + installable: { + codexResume: { + title: 'Server di ripresa Codex', + installSpecTitle: 'Origine installazione Codex resume', + }, + codexAcp: { + title: 'Adattatore Codex ACP', + installSpecTitle: 'Origine installazione Codex ACP', + }, + installSpecDescription: 'Spec NPM/Git/file passato a `npm install` (sperimentale). Lascia vuoto per usare il valore predefinito del demone.', + }, + ui: { + notAvailable: 'Non disponibile', + notAvailableUpdateCli: 'Non disponibile (aggiorna CLI)', + errorRefresh: 'Errore (aggiorna)', + installed: 'Installato', + installedWithVersion: ({ version }: { version: string }) => `Installato (v${version})`, + installedUpdateAvailable: ({ installedVersion, latestVersion }: { installedVersion: string; latestVersion: string }) => + `Installato (v${installedVersion}) — aggiornamento disponibile (v${latestVersion})`, + notInstalled: 'Non installato', + latest: 'Ultimo', + latestSubtitle: ({ version, tag }: { version: string; tag: string }) => `${version} (tag: ${tag})`, + registryCheck: 'Controllo registro', + registryCheckFailed: ({ error }: { error: string }) => `Non riuscito: ${error}`, + installSource: 'Origine installazione', + installSourceDefault: '(predefinito)', + installSpecPlaceholder: 'es. file:/percorso/al/pkg o github:proprietario/repo#branch', + lastInstallLog: 'Ultimo log di installazione', + installLogTitle: 'Log di installazione', + }, }, newSession: { // Used by new-session screen and launch flows title: 'Avvia nuova sessione', + selectAiProfileTitle: 'Seleziona profilo IA', + selectAiProfileDescription: 'Seleziona un profilo IA per applicare variabili d’ambiente e valori predefiniti alla sessione.', + changeProfile: 'Cambia profilo', + aiBackendSelectedByProfile: 'Il backend IA è determinato dal profilo. Per cambiarlo, seleziona un profilo diverso.', + selectAiBackendTitle: 'Seleziona backend IA', + aiBackendLimitedByProfileAndMachineClis: 'Limitato dal profilo selezionato e dalle CLI disponibili su questa macchina.', + aiBackendSelectWhichAiRuns: 'Seleziona quale IA esegue la sessione.', + aiBackendNotCompatibleWithSelectedProfile: 'Non compatibile con il profilo selezionato.', + aiBackendCliNotDetectedOnMachine: ({ cli }: { cli: string }) => `CLI di ${cli} non rilevata su questa macchina.`, + selectMachineTitle: 'Seleziona macchina', + selectMachineDescription: 'Scegli dove viene eseguita questa sessione.', + selectPathTitle: 'Seleziona percorso', + selectWorkingDirectoryTitle: 'Seleziona directory di lavoro', + selectWorkingDirectoryDescription: 'Scegli la cartella usata per comandi e contesto.', + selectPermissionModeTitle: 'Seleziona modalità di permessi', + selectPermissionModeDescription: 'Controlla quanto rigidamente le azioni richiedono approvazione.', + selectModelTitle: 'Seleziona modello IA', + selectModelDescription: 'Scegli il modello usato da questa sessione.', + selectSessionTypeTitle: 'Seleziona tipo di sessione', + selectSessionTypeDescription: 'Scegli una sessione semplice o una collegata a una worktree Git.', + searchPathsPlaceholder: 'Cerca percorsi...', noMachinesFound: 'Nessuna macchina trovata. Avvia prima una sessione Happy sul tuo computer.', allMachinesOffline: 'Tutte le macchine sembrano offline', machineDetails: 'Visualizza dettagli macchina →', @@ -304,18 +683,94 @@ export const it: TranslationStructure = { notConnectedToServer: 'Non connesso al server. Controlla la tua connessione Internet.', noMachineSelected: 'Seleziona una macchina per avviare la sessione', noPathSelected: 'Seleziona una directory in cui avviare la sessione', + machinePicker: { + searchPlaceholder: 'Cerca macchine...', + recentTitle: 'Recenti', + favoritesTitle: 'Preferiti', + allTitle: 'Tutte', + emptyMessage: 'Nessuna macchina disponibile', + }, + pathPicker: { + enterPathTitle: 'Inserisci percorso', + enterPathPlaceholder: 'Inserisci un percorso...', + customPathTitle: 'Percorso personalizzato', + recentTitle: 'Recenti', + favoritesTitle: 'Preferiti', + suggestedTitle: 'Suggeriti', + allTitle: 'Tutte', + emptyRecent: 'Nessun percorso recente', + emptyFavorites: 'Nessun percorso preferito', + emptySuggested: 'Nessun percorso suggerito', + emptyAll: 'Nessun percorso', + }, sessionType: { title: 'Tipo di sessione', simple: 'Semplice', - worktree: 'Worktree', + worktree: 'Worktree (Git)', comingSoon: 'In arrivo', }, + profileAvailability: { + requiresAgent: ({ agent }: { agent: string }) => `Richiede ${agent}`, + cliNotDetected: ({ cli }: { cli: string }) => `CLI di ${cli} non rilevata`, + }, + cliBanners: { + cliNotDetectedTitle: ({ cli }: { cli: string }) => `CLI di ${cli} non rilevata`, + dontShowFor: 'Non mostrare questo avviso per', + thisMachine: 'questa macchina', + anyMachine: 'qualsiasi macchina', + installCommand: ({ command }: { command: string }) => `Installa: ${command} •`, + installCliIfAvailable: ({ cli }: { cli: string }) => `Installa la CLI di ${cli} se disponibile •`, + viewInstallationGuide: 'Vedi guida di installazione →', + viewGeminiDocs: 'Vedi documentazione Gemini →', + }, worktree: { creating: ({ name }: { name: string }) => `Creazione worktree '${name}'...`, notGitRepo: 'Le worktree richiedono un repository git', failed: ({ error }: { error: string }) => `Impossibile creare la worktree: ${error}`, success: 'Worktree creata con successo', - } + }, + resume: { + title: 'Riprendi sessione', + optional: 'Riprendi: Opzionale', + pickerTitle: 'Riprendi sessione', + subtitle: ({ agent }: { agent: string }) => `Incolla un ID sessione ${agent} per riprendere`, + placeholder: ({ agent }: { agent: string }) => `Incolla ID sessione ${agent}…`, + paste: 'Incolla', + save: 'Salva', + clearAndRemove: 'Cancella', + helpText: 'Puoi trovare gli ID sessione nella schermata Info sessione.', + cannotApplyBody: 'Questo ID di ripresa non può essere applicato ora. Happy avvierà invece una nuova sessione.', + }, + codexResumeBanner: { + title: 'Codex resume', + updateAvailable: 'Aggiornamento disponibile', + systemCodexVersion: ({ version }: { version: string }) => `Codex di sistema: ${version}`, + resumeServerVersion: ({ version }: { version: string }) => `Server Codex resume: ${version}`, + notInstalled: 'non installato', + latestVersion: ({ version }: { version: string }) => `(più recente ${version})`, + registryCheckFailed: ({ error }: { error: string }) => `Controllo del registro non riuscito: ${error}`, + install: 'Installa', + update: 'Aggiorna', + reinstall: 'Reinstalla', + }, + codexResumeInstallModal: { + installTitle: 'Installare Codex resume?', + updateTitle: 'Aggiornare Codex resume?', + reinstallTitle: 'Reinstallare Codex resume?', + description: 'Questo installa un wrapper sperimentale del server MCP di Codex usato solo per operazioni di ripresa.', + }, + codexAcpBanner: { + title: 'Codex ACP', + install: 'Installa', + update: 'Aggiorna', + reinstall: 'Reinstalla', + }, + codexAcpInstallModal: { + installTitle: 'Installare Codex ACP?', + updateTitle: 'Aggiornare Codex ACP?', + reinstallTitle: 'Reinstallare Codex ACP?', + description: 'Questo installa un adattatore ACP sperimentale per Codex che supporta il caricamento/la ripresa dei thread.', + }, }, sessionHistory: { @@ -330,10 +785,99 @@ export const it: TranslationStructure = { session: { inputPlaceholder: 'Scrivi un messaggio ...', + resuming: 'Ripresa in corso...', + resumeFailed: 'Impossibile riprendere la sessione', + resumeSupportNoteChecking: 'Nota: Happy sta ancora verificando se questa macchina può riprendere la sessione del provider.', + resumeSupportNoteUnverified: 'Nota: Happy non è riuscito a verificare il supporto alla ripresa su questa macchina.', + resumeSupportDetails: { + cliNotDetected: 'CLI non rilevata sulla macchina.', + capabilityProbeFailed: 'Verifica delle capacità non riuscita.', + acpProbeFailed: 'Verifica ACP non riuscita.', + loadSessionFalse: 'L’agente non supporta il caricamento delle sessioni.', + }, + inactiveResumable: 'Inattiva (riprendibile)', + inactiveMachineOffline: 'Inattiva (macchina offline)', + inactiveNotResumable: 'Inattiva', + inactiveNotResumableNoticeTitle: 'Questa sessione non può essere ripresa', + inactiveNotResumableNoticeBody: ({ provider }: { provider: string }) => + `Questa sessione è terminata e non può essere ripresa perché ${provider} non supporta il ripristino del contesto qui. Avvia una nuova sessione per continuare.`, + machineOfflineNoticeTitle: 'La macchina è offline', + machineOfflineNoticeBody: ({ machine }: { machine: string }) => + `“${machine}” è offline, quindi Happy non può ancora riprendere questa sessione. Riporta la macchina online per continuare.`, + machineOfflineCannotResume: 'La macchina è offline. Riportala online per riprendere questa sessione.', + + sharing: { + title: 'Condivisione', + directSharing: 'Condivisione diretta', + addShare: 'Condividi con un amico', + accessLevel: 'Livello di accesso', + shareWith: 'Condividi con', + sharedWith: 'Condiviso con', + noShares: 'Non condiviso', + viewOnly: 'Solo visualizzazione', + viewOnlyDescription: 'Può vedere la sessione ma non inviare messaggi.', + viewOnlyMode: 'Solo visualizzazione (sessione condivisa)', + noEditPermission: 'Hai accesso in sola lettura a questa sessione.', + canEdit: 'Può modificare', + canEditDescription: 'Può inviare messaggi.', + canManage: 'Può gestire', + canManageDescription: 'Può gestire la condivisione.', + stopSharing: 'Interrompi condivisione', + recipientMissingKeys: 'Questo utente non ha ancora registrato le chiavi di crittografia.', + + publicLink: 'Link pubblico', + publicLinkActive: 'Link pubblico attivo', + publicLinkDescription: 'Crea un link per permettere a chiunque di visualizzare questa sessione.', + createPublicLink: 'Crea link pubblico', + regeneratePublicLink: 'Rigenera link pubblico', + deletePublicLink: 'Elimina link pubblico', + linkToken: 'Token del link', + tokenNotRecoverable: 'Token non disponibile', + tokenNotRecoverableDescription: 'Per motivi di sicurezza, i token dei link pubblici vengono salvati come hash e non possono essere recuperati. Rigenera il link per creare un nuovo token.', + + expiresIn: 'Scade tra', + expiresOn: 'Scade il', + days7: '7 giorni', + days30: '30 giorni', + never: 'Mai', + + maxUsesLabel: 'Utilizzi massimi', + unlimited: 'Illimitato', + uses10: '10 utilizzi', + uses50: '50 utilizzi', + usageCount: 'Conteggio utilizzi', + usageCountWithMax: ({ used, max }: { used: number; max: number }) => `${used}/${max} utilizzi`, + usageCountUnlimited: ({ used }: { used: number }) => `${used} utilizzi`, + + requireConsent: 'Richiedi consenso', + requireConsentDescription: "Chiedi il consenso prima di registrare l'accesso.", + consentRequired: 'Consenso richiesto', + consentDescription: 'Questo link richiede il tuo consenso per registrare IP e user agent.', + acceptAndView: 'Accetta e visualizza', + sharedBy: ({ name }: { name: string }) => `Condiviso da ${name}`, + + shareNotFound: 'Link di condivisione non trovato o scaduto', + failedToDecrypt: 'Impossibile decifrare la sessione', + noMessages: 'Nessun messaggio', + session: 'Sessione', + }, }, commandPalette: { placeholder: 'Digita un comando o cerca...', + noCommandsFound: 'Nessun comando trovato', + }, + + commandView: { + completedWithNoOutput: '[Comando completato senza output]', + }, + + voiceAssistant: { + connecting: 'Connessione...', + active: 'Assistente vocale attivo', + connectionError: 'Errore di connessione', + label: 'Assistente vocale', + tapToEnd: 'Tocca per terminare', }, server: { @@ -365,8 +909,18 @@ export const it: TranslationStructure = { happySessionId: 'ID sessione Happy', claudeCodeSessionId: 'ID sessione Claude Code', claudeCodeSessionIdCopied: 'ID sessione Claude Code copiato negli appunti', + aiProfile: 'Profilo IA', aiProvider: 'Provider IA', failedToCopyClaudeCodeSessionId: 'Impossibile copiare l\'ID sessione Claude Code', + codexSessionId: 'ID sessione Codex', + codexSessionIdCopied: 'ID sessione Codex copiato negli appunti', + failedToCopyCodexSessionId: 'Impossibile copiare l\'ID sessione Codex', + opencodeSessionId: 'ID sessione OpenCode', + opencodeSessionIdCopied: 'ID sessione OpenCode copiato negli appunti', + auggieSessionId: 'ID sessione Auggie', + auggieSessionIdCopied: 'ID sessione Auggie copiato negli appunti', + geminiSessionId: 'ID sessione Gemini', + geminiSessionIdCopied: 'ID sessione Gemini copiato negli appunti', metadataCopied: 'Metadati copiati negli appunti', failedToCopyMetadata: 'Impossibile copiare i metadati', failedToKillSession: 'Impossibile terminare la sessione', @@ -376,6 +930,7 @@ export const it: TranslationStructure = { lastUpdated: 'Ultimo aggiornamento', sequence: 'Sequenza', quickActions: 'Azioni rapide', + copyResumeCommand: 'Copia comando di ripresa', viewMachine: 'Visualizza macchina', viewMachineSubtitle: 'Visualizza dettagli e sessioni della macchina', killSessionSubtitle: 'Termina immediatamente la sessione', @@ -385,9 +940,15 @@ export const it: TranslationStructure = { path: 'Percorso', operatingSystem: 'Sistema operativo', processId: 'ID processo', - happyHome: 'Happy Home', + happyHome: 'Home di Happy', + attachFromTerminal: 'Collega dal terminale', + tmuxTarget: 'Destinazione tmux', + tmuxFallback: 'Fallback tmux', copyMetadata: 'Copia metadati', agentState: 'Stato agente', + rawJsonDevMode: 'JSON grezzo (modalità sviluppatore)', + sessionStatus: 'Stato sessione', + fullSessionObject: 'Oggetto sessione completo', controlledByUser: 'Controllato dall\'utente', pendingRequests: 'Richieste in sospeso', activity: 'Attività', @@ -404,7 +965,13 @@ export const it: TranslationStructure = { deleteSessionWarning: 'Questa azione non può essere annullata. Tutti i messaggi e i dati associati a questa sessione verranno eliminati definitivamente.', failedToDeleteSession: 'Impossibile eliminare la sessione', sessionDeleted: 'Sessione eliminata con successo', - + manageSharing: 'Gestisci condivisione', + manageSharingSubtitle: 'Condividi questa sessione con amici o crea un link pubblico', + renameSession: 'Rinomina sessione', + renameSessionSubtitle: 'Cambia il nome visualizzato di questa sessione', + renameSessionPlaceholder: 'Inserisci nome sessione...', + failedToRenameSession: 'Impossibile rinominare la sessione', + sessionRenamed: 'Sessione rinominata con successo', }, components: { @@ -415,16 +982,57 @@ export const it: TranslationStructure = { runIt: 'Avviala', scanQrCode: 'Scansiona il codice QR', openCamera: 'Apri fotocamera', + installCommand: '$ npm i -g happy-coder', + runCommand: '$ happy', + }, + emptyMessages: { + noMessagesYet: 'Ancora nessun messaggio', + created: ({ time }: { time: string }) => `Creato ${time}`, + }, + emptySessionsTablet: { + noActiveSessions: 'Nessuna sessione attiva', + startNewSessionDescription: 'Avvia una nuova sessione su una delle tue macchine collegate.', + startNewSessionButton: 'Avvia nuova sessione', + openTerminalToStart: 'Apri un nuovo terminale sul computer per avviare una sessione.', + }, + }, + + zen: { + title: 'Zen', + add: { + placeholder: 'Cosa bisogna fare?', + }, + home: { + noTasksYet: 'Ancora nessuna attività. Tocca + per aggiungerne una.', + }, + view: { + workOnTask: 'Lavora sul compito', + clarify: 'Chiarisci', + delete: 'Elimina', + linkedSessions: 'Sessioni collegate', + tapTaskTextToEdit: 'Tocca il testo del compito per modificarlo', }, }, agentInput: { + envVars: { + title: 'Var env', + titleWithCount: ({ count }: { count: number }) => `Var env (${count})`, + }, + resumeChip: { + withId: ({ title, id }: { title: string; id: string }) => `${title}: ${id}`, + withIdTruncated: ({ title, prefix, suffix }: { title: string; prefix: string; suffix: string }) => + `${title}: ${prefix}…${suffix}`, + }, permissionMode: { title: 'MODALITÀ PERMESSI', default: 'Predefinito', acceptEdits: 'Accetta modifiche', plan: 'Modalità piano', bypassPermissions: 'Modalità YOLO', + badgeAccept: 'Accetta', + badgePlan: 'Piano', + badgeYolo: 'YOLO', badgeAcceptAllEdits: 'Accetta tutte le modifiche', badgeBypassAllPermissions: 'Bypassa tutti i permessi', badgePlanMode: 'Modalità piano', @@ -432,7 +1040,13 @@ export const it: TranslationStructure = { agent: { claude: 'Claude', codex: 'Codex', + opencode: 'OpenCode', gemini: 'Gemini', + auggie: 'Auggie', + }, + auggieIndexingChip: { + on: 'Indexing on', + off: 'Indexing off', }, model: { title: 'MODELLO', @@ -461,13 +1075,28 @@ export const it: TranslationStructure = { geminiPermissionMode: { title: 'MODALITÀ PERMESSI GEMINI', default: 'Predefinito', - readOnly: 'Solo lettura', + readOnly: 'Modalità sola lettura', safeYolo: 'YOLO sicuro', yolo: 'YOLO', - badgeReadOnly: 'Solo lettura', + badgeReadOnly: 'Modalità sola lettura', badgeSafeYolo: 'YOLO sicuro', badgeYolo: 'YOLO', }, + geminiModel: { + title: 'MODELLO GEMINI', + gemini25Pro: { + label: 'Gemini 2.5 Pro', + description: 'Il più potente', + }, + gemini25Flash: { + label: 'Gemini 2.5 Flash', + description: 'Veloce ed efficiente', + }, + gemini25FlashLite: { + label: 'Gemini 2.5 Flash Lite', + description: 'Il più veloce', + }, + }, context: { remaining: ({ percent }: { percent: number }) => `${percent}% restante`, }, @@ -475,6 +1104,11 @@ export const it: TranslationStructure = { fileLabel: 'FILE', folderLabel: 'CARTELLA', }, + actionMenu: { + title: 'AZIONI', + files: 'File', + stop: 'Ferma', + }, noMachinesAvailable: 'Nessuna macchina', }, @@ -513,6 +1147,18 @@ export const it: TranslationStructure = { submit: 'Invia risposta', multipleQuestions: ({ count }: { count: number }) => `${count} ${plural({ count, singular: 'domanda', plural: 'domande' })}`, }, + exitPlanMode: { + approve: 'Approva piano', + reject: 'Rifiuta', + requestChanges: 'Richiedi modifiche', + requestChangesPlaceholder: 'Spiega a Claude cosa vuoi cambiare in questo piano…', + requestChangesSend: 'Invia feedback', + requestChangesEmpty: 'Scrivi cosa vuoi cambiare.', + requestChangesFailed: 'Impossibile inviare la richiesta di modifiche. Riprova.', + responded: 'Risposta inviata', + approvalMessage: 'Approvo questo piano. Procedi con l’implementazione.', + rejectionMessage: 'Non approvo questo piano. Rivedilo o chiedimi quali modifiche desidero.', + }, multiEdit: { editNumber: ({ index, total }: { index: number; total: number }) => `Modifica ${index} di ${total}`, replaceAll: 'Sostituisci tutto', @@ -537,6 +1183,10 @@ export const it: TranslationStructure = { applyChanges: 'Aggiorna file', viewDiff: 'Modifiche file attuali', question: 'Domanda', + changeTitle: 'Cambia titolo', + }, + geminiExecute: { + cwd: ({ cwd }: { cwd: string }) => `📁 ${cwd}`, }, desc: { terminalCmd: ({ cmd }: { cmd: string }) => `Terminale(cmd: ${cmd})`, @@ -575,7 +1225,7 @@ export const it: TranslationStructure = { loadingFile: ({ fileName }: { fileName: string }) => `Caricamento ${fileName}...`, binaryFile: 'File binario', cannotDisplayBinary: 'Impossibile mostrare il contenuto del file binario', - diff: 'Diff', + diff: 'Differenze', file: 'File', fileEmpty: 'File vuoto', noChanges: 'Nessuna modifica da mostrare', @@ -695,6 +1345,11 @@ export const it: TranslationStructure = { deviceLinkedSuccessfully: 'Dispositivo collegato con successo', terminalConnectedSuccessfully: 'Terminale collegato con successo', invalidAuthUrl: 'URL di autenticazione non valido', + microphoneAccessRequiredTitle: 'Accesso al microfono richiesto', + microphoneAccessRequiredRequestPermission: 'Happy ha bisogno dell’accesso al microfono per la chat vocale. Concedi il permesso quando richiesto.', + microphoneAccessRequiredEnableInSettings: 'Happy ha bisogno dell’accesso al microfono per la chat vocale. Abilita l’accesso al microfono nelle impostazioni del dispositivo.', + microphoneAccessRequiredBrowserInstructions: 'Consenti l’accesso al microfono nelle impostazioni del browser. Potrebbe essere necessario fare clic sull’icona del lucchetto nella barra degli indirizzi e abilitare il permesso del microfono per questo sito.', + openSettings: 'Apri impostazioni', developerMode: 'Modalità sviluppatore', developerModeEnabled: 'Modalità sviluppatore attivata', developerModeDisabled: 'Modalità sviluppatore disattivata', @@ -746,9 +1401,18 @@ export const it: TranslationStructure = { launchNewSessionInDirectory: 'Avvia nuova sessione nella directory', offlineUnableToSpawn: 'Avvio disabilitato quando la macchina è offline', offlineHelp: '• Assicurati che il tuo computer sia online\n• Esegui `happy daemon status` per diagnosticare\n• Stai usando l\'ultima versione della CLI? Aggiorna con `npm install -g happy-coder@latest`', - daemon: 'Daemon', + daemon: 'Demone', status: 'Stato', stopDaemon: 'Arresta daemon', + stopDaemonConfirmTitle: 'Arrestare il daemon?', + stopDaemonConfirmBody: 'Non potrai avviare nuove sessioni su questa macchina finché non riavvii il daemon sul computer. Le sessioni correnti resteranno attive.', + daemonStoppedTitle: 'Daemon arrestato', + stopDaemonFailed: 'Impossibile arrestare il daemon. Potrebbe non essere in esecuzione.', + renameTitle: 'Rinomina macchina', + renameDescription: 'Assegna a questa macchina un nome personalizzato. Lascia vuoto per usare l’hostname predefinito.', + renamePlaceholder: 'Inserisci nome macchina', + renamedSuccess: 'Macchina rinominata correttamente', + renameFailed: 'Impossibile rinominare la macchina', lastKnownPid: 'Ultimo PID noto', lastKnownHttpPort: 'Ultima porta HTTP nota', startedAt: 'Avviato alle', @@ -765,20 +1429,40 @@ export const it: TranslationStructure = { lastSeen: 'Ultimo accesso', never: 'Mai', metadataVersion: 'Versione metadati', + detectedClis: 'CLI rilevate', + detectedCliNotDetected: 'Non rilevata', + detectedCliUnknown: 'Sconosciuta', + detectedCliNotSupported: 'Non supportata (aggiorna happy-cli)', untitledSession: 'Sessione senza titolo', back: 'Indietro', + notFound: 'Macchina non trovata', + unknownMachine: 'macchina sconosciuta', + unknownPath: 'percorso sconosciuto', + tmux: { + overrideTitle: 'Sovrascrivi le impostazioni tmux globali', + overrideEnabledSubtitle: 'Le impostazioni tmux personalizzate si applicano alle nuove sessioni su questa macchina.', + overrideDisabledSubtitle: 'Le nuove sessioni usano le impostazioni tmux globali.', + notDetectedSubtitle: 'tmux non è rilevato su questa macchina.', + notDetectedMessage: 'tmux non è rilevato su questa macchina. Installa tmux e aggiorna il rilevamento.', + }, }, message: { switchedToMode: ({ mode }: { mode: string }) => `Passato alla modalità ${mode}`, + discarded: 'Scartato', unknownEvent: 'Evento sconosciuto', usageLimitUntil: ({ time }: { time: string }) => `Limite di utilizzo raggiunto fino a ${time}`, unknownTime: 'ora sconosciuta', }, + chatFooter: { + permissionsTerminalOnly: 'I permessi vengono mostrati solo nel terminale. Reimposta o invia un messaggio per controllare dall’app.', + }, + codex: { // Codex permission dialog buttons permissions: { + yesAlwaysAllowCommand: 'Sì, consenti sempre globalmente', yesForSession: 'Sì, e non chiedere per una sessione', stopAndExplain: 'Fermati e spiega cosa devo fare', } @@ -789,6 +1473,9 @@ export const it: TranslationStructure = { permissions: { yesAllowAllEdits: 'Sì, consenti tutte le modifiche durante questa sessione', yesForTool: 'Sì, non chiedere più per questo strumento', + yesForCommandPrefix: 'Sì, non chiedere più per questo prefisso di comando', + yesForSubcommand: 'Sì, non chiedere più per questo sottocomando', + yesForCommandName: 'Sì, non chiedere più per questo comando', noTellClaude: 'No, fornisci feedback', } }, @@ -802,6 +1489,7 @@ export const it: TranslationStructure = { textCopied: 'Testo copiato negli appunti', failedToCopy: 'Impossibile copiare il testo negli appunti', noTextToCopy: 'Nessun testo disponibile da copiare', + failedToOpen: 'Impossibile aprire la selezione del testo. Riprova.', }, markdown: { @@ -822,11 +1510,14 @@ export const it: TranslationStructure = { edit: 'Modifica artefatto', delete: 'Elimina', updateError: 'Impossibile aggiornare l\'artefatto. Riprova.', + deleteError: 'Impossibile eliminare l’artefatto. Riprova.', notFound: 'Artefatto non trovato', discardChanges: 'Scartare le modifiche?', discardChangesDescription: 'Hai modifiche non salvate. Sei sicuro di volerle scartare?', deleteConfirm: 'Eliminare artefatto?', deleteConfirmDescription: 'Questa azione non può essere annullata', + noContent: 'Nessun contenuto', + untitled: 'Senza titolo', titleLabel: 'TITOLO', titlePlaceholder: 'Inserisci un titolo per il tuo artefatto', bodyLabel: 'CONTENUTO', @@ -842,6 +1533,8 @@ export const it: TranslationStructure = { friends: { // Friends feature title: 'Amici', + sharedSessions: 'Sessioni condivise', + noSharedSessions: 'Nessuna sessione condivisa', manageFriends: 'Gestisci i tuoi amici e le connessioni', searchTitle: 'Trova amici', pendingRequests: 'Richieste di amicizia', @@ -904,6 +1597,49 @@ export const it: TranslationStructure = { noData: 'Nessun dato di utilizzo disponibile', }, + secrets: { + addTitle: 'Nuovo segreto', + savedTitle: 'Segreti salvati', + badgeReady: 'Segreto', + badgeRequired: 'Segreto richiesto', + missingForProfile: ({ env }: { env: string | null }) => + `Segreto mancante (${env ?? 'segreto'}). Configuralo sulla macchina oppure seleziona/inserisci un segreto.`, + defaultForProfileTitle: 'Segreto predefinito', + defineDefaultForProfileTitle: 'Definisci segreto predefinito per questo profilo', + addSubtitle: 'Aggiungi un segreto salvato', + noneTitle: 'Nessuna', + noneSubtitle: 'Usa l’ambiente della macchina o inserisci un segreto per questa sessione', + emptyTitle: 'Nessun segreto salvato', + emptySubtitle: 'Aggiungine uno per usare profili con segreto senza impostare variabili d’ambiente sulla macchina.', + savedHiddenSubtitle: 'Salvata (valore nascosto)', + defaultLabel: 'Predefinita', + fields: { + name: 'Nome', + value: 'Valore', + }, + placeholders: { + nameExample: 'es. Work OpenAI', + }, + validation: { + nameRequired: 'Il nome è obbligatorio.', + valueRequired: 'Il valore è obbligatorio.', + }, + actions: { + replace: 'Sostituisci', + replaceValue: 'Sostituisci valore', + setDefault: 'Imposta come predefinita', + unsetDefault: 'Rimuovi predefinita', + }, + prompts: { + renameTitle: 'Rinomina segreto', + renameDescription: 'Aggiorna il nome descrittivo di questo segreto.', + replaceValueTitle: 'Sostituisci valore del segreto', + replaceValueDescription: 'Incolla il nuovo valore del segreto. Questo valore non verrà mostrato di nuovo dopo il salvataggio.', + deleteTitle: 'Elimina segreto', + deleteConfirm: ({ name }: { name: string }) => `Eliminare “${name}”? Questa azione non può essere annullata.`, + }, + }, + feed: { // Feed notifications for friend requests and acceptances friendRequestFrom: ({ name }: { name: string }) => `${name} ti ha inviato una richiesta di amicizia`, diff --git a/expo-app/sources/text/translations/ja.ts b/expo-app/sources/text/translations/ja.ts index fe92bf8d8..a275fe251 100644 --- a/expo-app/sources/text/translations/ja.ts +++ b/expo-app/sources/text/translations/ja.ts @@ -5,17 +5,7 @@ * - Functions with typed object parameters for dynamic text */ -import { TranslationStructure } from "../_default"; - -/** - * Japanese plural helper function - * Japanese doesn't have grammatical plurals, so this just returns the appropriate form - * @param options - Object containing count, singular, and plural forms - * @returns The appropriate form based on count - */ -function plural({ count, singular, plural }: { count: number; singular: string; plural: string }): string { - return count === 1 ? singular : plural; -} +import type { TranslationStructure } from '../_types'; export const ja: TranslationStructure = { tabs: { @@ -34,6 +24,8 @@ export const ja: TranslationStructure = { common: { // Simple string constants + add: '追加', + actions: '操作', cancel: 'キャンセル', authenticate: '認証', save: '保存', @@ -49,7 +41,11 @@ export const ja: TranslationStructure = { yes: 'はい', no: 'いいえ', discard: '破棄', + discardChanges: '変更を破棄', + unsavedChangesWarning: '未保存の変更があります。', + keepEditing: '編集を続ける', version: 'バージョン', + details: '詳細', copied: 'コピーしました', copy: 'コピー', scanning: 'スキャン中...', @@ -62,9 +58,21 @@ export const ja: TranslationStructure = { retry: '再試行', delete: '削除', optional: '任意', + noMatches: '一致するものがありません', + all: 'すべて', + machine: 'マシン', + clearSearch: '検索をクリア', + refresh: '更新', saveAs: '名前を付けて保存', }, + dropdown: { + category: { + general: '一般', + results: '結果', + }, + }, + profile: { userProfile: 'ユーザープロフィール', details: '詳細', @@ -77,6 +85,8 @@ export const ja: TranslationStructure = { profiles: { title: 'プロファイル', subtitle: 'セッション用の環境変数プロファイルを管理', + sessionUses: ({ profile }: { profile: string }) => `このセッションは次を使用しています: ${profile}`, + profilesFixedPerSession: 'プロファイルはセッションごとに固定です。別のプロファイルを使うには新しいセッションを開始してください。', noProfile: 'プロファイルなし', noProfileDescription: 'デフォルトの環境設定を使用', defaultModel: 'デフォルトモデル', @@ -93,9 +103,232 @@ export const ja: TranslationStructure = { enterTmuxTempDir: '一時ディレクトリのパスを入力', tmuxUpdateEnvironment: '環境を自動更新', nameRequired: 'プロファイル名は必須です', - deleteConfirm: 'プロファイル「{name}」を削除してもよろしいですか?', + deleteConfirm: ({ name }: { name: string }) => `プロファイル「${name}」を削除してもよろしいですか?`, editProfile: 'プロファイルを編集', addProfileTitle: '新しいプロファイルを追加', + builtIn: '組み込み', + custom: 'カスタム', + builtInSaveAsHint: '組み込みプロファイルを保存すると、新しいカスタムプロファイルが作成されます。', + builtInNames: { + anthropic: 'Anthropic(デフォルト)', + deepseek: 'DeepSeek(推論)', + zai: 'Z.AI (GLM-4.6)', + codex: 'Codex (Default)', + openai: 'OpenAI (GPT-5)', + azureOpenai: 'Azure OpenAI', + gemini: 'Gemini (Default)', + geminiApiKey: 'Gemini (API key)', + geminiVertex: 'Gemini (Vertex AI)', + }, + groups: { + favorites: 'お気に入り', + custom: 'あなたのプロファイル', + builtIn: '組み込みプロファイル', + }, + actions: { + viewEnvironmentVariables: '環境変数', + addToFavorites: 'お気に入りに追加', + removeFromFavorites: 'お気に入りから削除', + editProfile: 'プロファイルを編集', + duplicateProfile: 'プロファイルを複製', + deleteProfile: 'プロファイルを削除', + }, + copySuffix: '(コピー)', + duplicateName: '同じ名前のプロファイルが既に存在します', + setupInstructions: { + title: 'セットアップ手順', + viewOfficialGuide: '公式セットアップガイドを表示', + }, + machineLogin: { + title: 'マシンでのログインが必要', + subtitle: 'このプロファイルは、選択したマシン上の CLI ログインキャッシュに依存します。', + status: { + loggedIn: 'ログイン済み', + notLoggedIn: '未ログイン', + }, + claudeCode: { + title: 'Claude Code', + instructions: '`claude` を実行し、`/login` と入力してログインしてください。', + warning: '注意: `ANTHROPIC_AUTH_TOKEN` を設定すると CLI ログインを上書きします。', + }, + codex: { + title: 'Codex', + instructions: '`codex login` を実行してログインしてください。', + }, + geminiCli: { + title: 'Gemini CLI', + instructions: '`gemini auth` を実行してログインしてください。', + }, + }, + requirements: { + secretRequired: 'シークレット', + configured: 'マシンで設定済み', + notConfigured: '未設定', + checking: '確認中…', + missingConfigForProfile: ({ env }: { env: string }) => `このプロファイルを使用するには、マシンで ${env} を設定する必要があります。`, + modalTitle: 'シークレットが必要です', + modalBody: 'このプロファイルにはシークレットが必要です。\n\n利用可能な選択肢:\n• マシン環境を使用(推奨)\n• アプリ設定の保存済みシークレットを使用\n• このセッションのみシークレットを入力', + sectionTitle: '要件', + sectionSubtitle: 'これらの項目は事前チェックのために使用され、予期しない失敗を避けます。', + secretEnvVarPromptDescription: '必要な秘密環境変数名を入力してください(例: OPENAI_API_KEY)。', + modalHelpWithEnv: ({ env }: { env: string }) => `このプロファイルには${env}が必要です。以下から1つ選択してください。`, + modalHelpGeneric: 'このプロファイルにはシークレットが必要です。以下から1つ選択してください。', + chooseOptionTitle: '選択してください', + machineEnvStatus: { + theMachine: 'マシン', + checkFor: ({ env }: { env: string }) => `${env} を確認`, + checking: ({ env }: { env: string }) => `${env} を確認中…`, + found: ({ env, machine }: { env: string; machine: string }) => `${machine}で${env}が見つかりました`, + notFound: ({ env, machine }: { env: string; machine: string }) => `${machine}で${env}が見つかりません`, + }, + machineEnvSubtitle: { + checking: 'デーモン環境を確認中…', + found: 'マシン上のデーモン環境で見つかりました。', + notFound: 'マシン上のデーモン環境に設定して、デーモンを再起動してください。', + }, + options: { + none: { + title: 'なし', + subtitle: 'シークレットもCLIログインも不要です。', + }, + machineLogin: { + subtitle: 'ターゲットマシンでCLIからログインしている必要があります。', + longSubtitle: 'ターゲットマシンで選択したAIバックエンドのCLIにログインしている必要があります。', + }, + useMachineEnvironment: { + title: 'マシン環境を使用', + subtitleWithEnv: ({ env }: { env: string }) => `デーモン環境から${env}を使用します。`, + subtitleGeneric: 'デーモン環境からシークレットを使用します。', + }, + useSavedSecret: { + title: '保存済みシークレットを使用', + subtitle: 'アプリ内の保存済みシークレットを選択(または追加)します。', + }, + enterOnce: { + title: 'シークレットを入力', + subtitle: 'このセッションのみシークレットを貼り付けます(保存されません)。', + }, + }, + secretEnvVar: { + title: 'シークレットの環境変数', + subtitle: 'このプロバイダがシークレットに期待する環境変数名を入力してください(例: OPENAI_API_KEY)。', + label: '環境変数名', + }, + sections: { + machineEnvironment: 'マシン環境', + useOnceTitle: '一度だけ使用', + useOnceLabel: 'シークレットを入力', + useOnceFooter: 'このセッションのみシークレットを貼り付けます。保存されません。', + }, + actions: { + useMachineEnvironment: { + subtitle: 'マシンに既にあるキーを使用して開始します。', + }, + useOnceButton: '一度だけ使用(セッションのみ)', + }, + }, + defaultSessionType: 'デフォルトのセッションタイプ', + defaultPermissionMode: { + title: 'デフォルトの権限モード', + descriptions: { + default: '権限を要求する', + acceptEdits: '編集を自動承認', + plan: '実行前に計画', + bypassPermissions: 'すべての権限をスキップ', + }, + }, + aiBackend: { + title: 'AIバックエンド', + selectAtLeastOneError: '少なくとも1つのAIバックエンドを選択してください。', + claudeSubtitle: 'Claude コマンドライン', + codexSubtitle: 'Codex コマンドライン', + opencodeSubtitle: 'OpenCode コマンドライン', + geminiSubtitleExperimental: 'Gemini コマンドライン(実験)', + auggieSubtitle: 'Auggie CLI', + }, + tmux: { + title: 'Tmux', + spawnSessionsTitle: 'Tmuxでセッションを起動', + spawnSessionsEnabledSubtitle: 'セッションは新しいtmuxウィンドウで起動します。', + spawnSessionsDisabledSubtitle: 'セッションは通常のシェルで起動します(tmux連携なし)', + isolatedServerTitle: '分離された tmux サーバー', + isolatedServerEnabledSubtitle: '分離された tmux サーバーでセッションを開始します(推奨)。', + isolatedServerDisabledSubtitle: 'デフォルトの tmux サーバーでセッションを開始します。', + sessionNamePlaceholder: '空 = 現在/最近のセッション', + tempDirPlaceholder: '空欄で自動生成', + }, + previewMachine: { + title: 'マシンをプレビュー', + itemTitle: '環境変数のプレビュー用マシン', + selectMachine: 'マシンを選択', + resolveSubtitle: '下の解決後の値をプレビューするためだけに使用します(保存内容は変わりません)。', + selectSubtitle: '下の解決後の値をプレビューするマシンを選択してください。', + }, + environmentVariables: { + title: '環境変数', + addVariable: '変数を追加', + namePlaceholder: '変数名(例: MY_CUSTOM_VAR)', + valuePlaceholder: '値(例: my-value または ${MY_VAR})', + validation: { + nameRequired: '変数名を入力してください。', + invalidNameFormat: '変数名は大文字、数字、アンダースコアのみで、数字から始めることはできません。', + duplicateName: 'その変数は既に存在します。', + }, + card: { + valueLabel: '値:', + fallbackValueLabel: 'フォールバック値:', + valueInputPlaceholder: '値', + defaultValueInputPlaceholder: 'デフォルト値', + fallbackDisabledForVault: 'シークレット保管庫を使用している場合、フォールバックは無効になります。', + secretNotRetrieved: 'シークレット値 — セキュリティのため取得しません', + secretToggleLabel: 'UIで値を隠す', + secretToggleSubtitle: 'UIで値を非表示にし、プレビューのためにマシンから取得しません。', + secretToggleEnforcedByDaemon: 'デーモンで強制', + secretToggleEnforcedByVault: 'シークレット保管庫で強制', + secretToggleResetToAuto: '自動に戻す', + requirementRequiredLabel: '必須', + requirementRequiredSubtitle: '変数が不足している場合、セッション作成をブロックします。', + requirementUseVaultLabel: 'シークレット保管庫を使用', + requirementUseVaultSubtitle: '保存済みシークレットを使用(フォールバックなし)。', + defaultSecretLabel: 'デフォルトのシークレット', + overridingDefault: ({ expectedValue }: { expectedValue: string }) => + `ドキュメントのデフォルト値を上書き: ${expectedValue}`, + useMachineEnvToggle: 'マシン環境から値を使用', + resolvedOnSessionStart: '選択したマシンでセッション開始時に解決されます。', + sourceVariableLabel: '参照元変数', + sourceVariablePlaceholder: '参照元変数名(例: Z_AI_MODEL)', + checkingMachine: ({ machine }: { machine: string }) => `${machine} を確認中...`, + emptyOnMachine: ({ machine }: { machine: string }) => `${machine} では空です`, + emptyOnMachineUsingFallback: ({ machine }: { machine: string }) => `${machine} では空です(フォールバック使用)`, + notFoundOnMachine: ({ machine }: { machine: string }) => `${machine} で見つかりません`, + notFoundOnMachineUsingFallback: ({ machine }: { machine: string }) => `${machine} で見つかりません(フォールバック使用)`, + valueFoundOnMachine: ({ machine }: { machine: string }) => `${machine} で値を確認`, + differsFromDocumented: ({ expectedValue }: { expectedValue: string }) => + `ドキュメント値と異なります: ${expectedValue}`, + }, + preview: { + secretValueHidden: ({ value }: { value: string }) => `${value} - セキュリティのため非表示`, + hiddenValue: '***非表示***', + emptyValue: '(空)', + sessionWillReceive: ({ name, value }: { name: string; value: string }) => + `セッションに渡される値: ${name} = ${value}`, + }, + previewModal: { + titleWithProfile: ({ profileName }: { profileName: string }) => `環境変数 · ${profileName}`, + descriptionPrefix: 'これらの環境変数はセッション開始時に送信されます。値はデーモンが', + descriptionFallbackMachine: '選択したマシン', + descriptionSuffix: 'で解決します。', + emptyMessage: 'このプロファイルには環境変数が設定されていません。', + checkingSuffix: '(確認中…)', + detail: { + fixed: '固定', + machine: 'マシン', + checking: '確認中', + fallback: 'フォールバック', + missing: '未設定', + }, + }, + }, delete: { title: 'プロファイルを削除', message: ({ name }: { name: string }) => `「${name}」を削除してもよろしいですか?この操作は元に戻せません。`, @@ -128,6 +361,16 @@ export const ja: TranslationStructure = { enterSecretKey: 'シークレットキーを入力してください', invalidSecretKey: 'シークレットキーが無効です。確認して再試行してください。', enterUrlManually: 'URLを手動で入力', + openMachine: 'マシンを開く', + terminalUrlPlaceholder: 'happy://terminal?...', + restoreQrInstructions: '1. モバイル端末で Happy を開く\n2. 設定 → アカウント に移動\n3. 「新しいデバイスをリンク」をタップ\n4. この QR コードをスキャン', + restoreWithSecretKeyInstead: '秘密鍵で復元する', + restoreWithSecretKeyDescription: 'アカウントへのアクセスを復元するには秘密鍵を入力してください。', + secretKeyPlaceholder: 'XXXXX-XXXXX-XXXXX...', + unsupported: { + connectTitle: ({ name }: { name: string }) => `${name} を接続`, + runCommandInTerminal: 'ターミナルで次のコマンドを実行してください:', + }, }, settings: { @@ -168,6 +411,12 @@ export const ja: TranslationStructure = { usageSubtitle: 'API使用量とコストを確認', profiles: 'プロファイル', profilesSubtitle: 'セッション用の環境変数プロファイルを管理', + secrets: 'シークレット', + secretsSubtitle: '保存したシークレットを管理(入力後は再表示されません)', + terminal: 'ターミナル', + session: 'セッション', + sessionSubtitleTmuxEnabled: 'Tmux 有効', + sessionSubtitleMessageSendingAndTmux: 'メッセージ送信と tmux', // Dynamic settings messages accountConnected: ({ service }: { service: string }) => `${service}アカウントが接続されました`, @@ -205,6 +454,21 @@ export const ja: TranslationStructure = { wrapLinesInDiffsDescription: '差分表示で水平スクロールの代わりに長い行を折り返す', alwaysShowContextSize: '常にコンテキストサイズを表示', alwaysShowContextSizeDescription: '上限に近づいていなくてもコンテキスト使用量を表示', + agentInputActionBarLayout: '入力アクションバー', + agentInputActionBarLayoutDescription: '入力欄の上に表示するアクションチップの表示方法を選択します', + agentInputActionBarLayoutOptions: { + auto: '自動', + wrap: '折り返し', + scroll: 'スクロール', + collapsed: '折りたたみ', + }, + agentInputChipDensity: 'アクションチップ密度', + agentInputChipDensityDescription: 'アクションチップをラベル表示にするかアイコン表示にするか選択します', + agentInputChipDensityOptions: { + auto: '自動', + labels: 'ラベル', + icons: 'アイコンのみ', + }, avatarStyle: 'アバタースタイル', avatarStyleDescription: 'セッションアバターの外観を選択', avatarOptions: { @@ -225,8 +489,28 @@ export const ja: TranslationStructure = { experimentalFeatures: '実験的機能', experimentalFeaturesEnabled: '実験的機能が有効です', experimentalFeaturesDisabled: '安定版機能のみを使用', - webFeatures: 'Web機能', - webFeaturesDescription: 'Webバージョンでのみ利用可能な機能。', + experimentalOptions: '実験オプション', + experimentalOptionsDescription: '有効にする実験的機能を選択します。', + expUsageReporting: 'Usage reporting', + expUsageReportingSubtitle: 'Enable usage and token reporting screens', + expFileViewer: 'File viewer', + expFileViewerSubtitle: 'Enable the session file viewer entrypoint', + expShowThinkingMessages: 'Show thinking messages', + expShowThinkingMessagesSubtitle: 'Show assistant thinking/status messages in chat', + expSessionType: 'Session type selector', + expSessionTypeSubtitle: 'Show the session type selector (simple vs worktree)', + expZen: 'Zen', + expZenSubtitle: 'Enable the Zen navigation entry', + expVoiceAuthFlow: 'Voice auth flow', + expVoiceAuthFlowSubtitle: 'Use authenticated voice token flow (paywall-aware)', + expInboxFriends: '受信箱と友だち', + expInboxFriendsSubtitle: '受信箱タブと友だち機能を有効化', + expCodexResume: 'Codexの再開', + expCodexResumeSubtitle: '再開操作専用のCodexを別途インストールして使用(実験的)', + expCodexAcp: 'Codex ACP', + expCodexAcpSubtitle: 'MCPの代わりにACP(codex-acp)経由でCodexを使用(実験的)', + webFeatures: 'Web機能', + webFeaturesDescription: 'Webバージョンでのみ利用可能な機能。', enterToSend: 'Enterで送信', enterToSendEnabled: 'Enterで送信(Shift+Enterで改行)', enterToSendDisabled: 'Enterで改行', @@ -237,9 +521,20 @@ export const ja: TranslationStructure = { markdownCopyV2Subtitle: '長押しでコピーモーダルを開く', hideInactiveSessions: '非アクティブセッションを非表示', hideInactiveSessionsSubtitle: 'アクティブなチャットのみをリストに表示', + groupInactiveSessionsByProject: '非アクティブセッションをプロジェクト別にグループ化', + groupInactiveSessionsByProjectSubtitle: '非アクティブなチャットをプロジェクトごとに整理', enhancedSessionWizard: '拡張セッションウィザード', enhancedSessionWizardEnabled: 'プロファイル優先セッションランチャーが有効', enhancedSessionWizardDisabled: '標準セッションランチャーを使用', + profiles: 'AIプロファイル', + profilesEnabled: 'プロファイル選択を有効化', + profilesDisabled: 'プロファイル選択を無効化', + pickerSearch: 'ピッカー検索', + pickerSearchSubtitle: 'マシンとパスのピッカーに検索欄を表示', + machinePickerSearch: 'マシン検索', + machinePickerSearchSubtitle: 'マシンピッカーに検索欄を表示', + pathPickerSearch: 'パス検索', + pathPickerSearchSubtitle: 'パスピッカーに検索欄を表示', }, errors: { @@ -287,11 +582,85 @@ export const ja: TranslationStructure = { failedToRemoveFriend: '友達の削除に失敗しました', searchFailed: '検索に失敗しました。再試行してください。', failedToSendRequest: '友達リクエストの送信に失敗しました', + failedToResumeSession: 'セッションの再開に失敗しました', + failedToSendMessage: 'メッセージの送信に失敗しました', + cannotShareWithSelf: '自分自身とは共有できません', + canOnlyShareWithFriends: '友達とのみ共有できます', + shareNotFound: '共有が見つかりません', + publicShareNotFound: '公開共有が見つからないか期限切れです', + consentRequired: 'アクセスには同意が必要です', + maxUsesReached: '最大使用回数に達しました', + invalidShareLink: '無効または期限切れの共有リンク', + missingPermissionId: '権限リクエストIDがありません', + codexResumeNotInstalledTitle: 'このマシンには Codex resume がインストールされていません', + codexResumeNotInstalledMessage: + 'Codex の会話を再開するには、対象のマシンに Codex resume サーバーをインストールしてください(マシン詳細 → Codex resume)。', + codexAcpNotInstalledTitle: 'このマシンには Codex ACP がインストールされていません', + codexAcpNotInstalledMessage: + 'Codex ACP の実験機能を使うには、対象のマシンに codex-acp をインストールしてください(マシン詳細 → Codex ACP)。または実験機能を無効にしてください。', + }, + + deps: { + installNotSupported: 'この依存関係をインストールするには Happy CLI を更新してください。', + installFailed: 'インストールに失敗しました', + installed: 'インストールしました', + installLog: ({ path }: { path: string }) => `インストールログ: ${path}`, + installable: { + codexResume: { + title: 'Codex 再開サーバー', + installSpecTitle: 'Codex resume のインストール元', + }, + codexAcp: { + title: 'Codex ACP アダプター', + installSpecTitle: 'Codex ACP のインストール元', + }, + installSpecDescription: '(実験的)`npm install` に渡す NPM/Git/ファイル指定。空欄の場合はデーモンの既定を使用します。', + }, + ui: { + notAvailable: '利用できません', + notAvailableUpdateCli: '利用できません(CLI を更新してください)', + errorRefresh: 'エラー(更新)', + installed: 'インストール済み', + installedWithVersion: ({ version }: { version: string }) => `インストール済み(v${version})`, + installedUpdateAvailable: ({ installedVersion, latestVersion }: { installedVersion: string; latestVersion: string }) => + `インストール済み(v${installedVersion})— 更新あり(v${latestVersion})`, + notInstalled: '未インストール', + latest: '最新', + latestSubtitle: ({ version, tag }: { version: string; tag: string }) => `${version}(タグ: ${tag})`, + registryCheck: 'レジストリ確認', + registryCheckFailed: ({ error }: { error: string }) => `失敗: ${error}`, + installSource: 'インストール元', + installSourceDefault: '(既定)', + installSpecPlaceholder: '例: file:/path/to/pkg または github:owner/repo#branch', + lastInstallLog: '前回のインストールログ', + installLogTitle: 'インストールログ', + }, }, newSession: { // Used by new-session screen and launch flows title: '新しいセッションを開始', + selectAiProfileTitle: 'AIプロファイルを選択', + selectAiProfileDescription: '環境変数とデフォルト設定をセッションに適用するため、AIプロファイルを選択してください。', + changeProfile: 'プロファイルを変更', + aiBackendSelectedByProfile: 'AIバックエンドはプロファイルで選択されています。変更するには別のプロファイルを選択してください。', + selectAiBackendTitle: 'AIバックエンドを選択', + aiBackendLimitedByProfileAndMachineClis: '選択したプロファイルと、このマシンで利用可能なCLIによって制限されます。', + aiBackendSelectWhichAiRuns: 'セッションで実行するAIを選択してください。', + aiBackendNotCompatibleWithSelectedProfile: '選択したプロファイルと互換性がありません。', + aiBackendCliNotDetectedOnMachine: ({ cli }: { cli: string }) => `このマシンで${cli} CLIが検出されませんでした。`, + selectMachineTitle: 'マシンを選択', + selectMachineDescription: 'このセッションを実行する場所を選択します。', + selectPathTitle: 'パスを選択', + selectWorkingDirectoryTitle: '作業ディレクトリを選択', + selectWorkingDirectoryDescription: 'コマンドとコンテキストに使用するフォルダを選択してください。', + selectPermissionModeTitle: '権限モードを選択', + selectPermissionModeDescription: '操作にどの程度承認が必要かを設定します。', + selectModelTitle: 'AIモデルを選択', + selectModelDescription: 'このセッションで使用するモデルを選択してください。', + selectSessionTypeTitle: 'セッションタイプを選択', + selectSessionTypeDescription: 'シンプルなセッション、またはGitのワークツリーに紐づくセッションを選択してください。', + searchPathsPlaceholder: 'パスを検索...', noMachinesFound: 'マシンが見つかりません。まずコンピューターでHappyセッションを起動してください。', allMachinesOffline: 'すべてのマシンがオフラインです', machineDetails: 'マシンの詳細を表示 →', @@ -307,18 +676,94 @@ export const ja: TranslationStructure = { notConnectedToServer: 'サーバーに接続されていません。インターネット接続を確認してください。', noMachineSelected: 'セッションを開始するマシンを選択してください', noPathSelected: 'セッションを開始するディレクトリを選択してください', + machinePicker: { + searchPlaceholder: 'マシンを検索...', + recentTitle: '最近', + favoritesTitle: 'お気に入り', + allTitle: 'すべて', + emptyMessage: '利用可能なマシンがありません', + }, + pathPicker: { + enterPathTitle: 'パスを入力', + enterPathPlaceholder: 'パスを入力...', + customPathTitle: 'カスタムパス', + recentTitle: '最近', + favoritesTitle: 'お気に入り', + suggestedTitle: 'おすすめ', + allTitle: 'すべて', + emptyRecent: '最近のパスはありません', + emptyFavorites: 'お気に入りのパスはありません', + emptySuggested: 'おすすめのパスはありません', + emptyAll: 'パスがありません', + }, sessionType: { title: 'セッションタイプ', simple: 'シンプル', worktree: 'ワークツリー', comingSoon: '近日公開', }, + profileAvailability: { + requiresAgent: ({ agent }: { agent: string }) => `${agent} が必要`, + cliNotDetected: ({ cli }: { cli: string }) => `${cli} CLI が検出されません`, + }, + cliBanners: { + cliNotDetectedTitle: ({ cli }: { cli: string }) => `${cli} CLI が検出されません`, + dontShowFor: 'このポップアップを表示しない:', + thisMachine: 'このマシン', + anyMachine: 'すべてのマシン', + installCommand: ({ command }: { command: string }) => `インストール: ${command} •`, + installCliIfAvailable: ({ cli }: { cli: string }) => `${cli} CLI が利用可能ならインストール •`, + viewInstallationGuide: 'インストールガイドを見る →', + viewGeminiDocs: 'Geminiドキュメントを見る →', + }, worktree: { creating: ({ name }: { name: string }) => `ワークツリー '${name}' を作成中...`, notGitRepo: 'ワークツリーにはGitリポジトリが必要です', failed: ({ error }: { error: string }) => `ワークツリーの作成に失敗しました: ${error}`, success: 'ワークツリーが正常に作成されました', - } + }, + resume: { + title: 'セッションを再開', + optional: '再開: 任意', + pickerTitle: 'セッションを再開', + subtitle: ({ agent }: { agent: string }) => `再開する${agent}セッションIDを貼り付けてください`, + placeholder: ({ agent }: { agent: string }) => `${agent}セッションIDを貼り付け…`, + paste: '貼り付け', + save: '保存', + clearAndRemove: 'クリア', + helpText: 'セッションIDは「セッション情報」画面で確認できます。', + cannotApplyBody: 'この再開IDは現在適用できません。代わりに新しいセッションを開始します。', + }, + codexResumeBanner: { + title: 'Codex resume', + updateAvailable: '更新があります', + systemCodexVersion: ({ version }: { version: string }) => `システム Codex: ${version}`, + resumeServerVersion: ({ version }: { version: string }) => `Codex resume サーバー: ${version}`, + notInstalled: '未インストール', + latestVersion: ({ version }: { version: string }) => `(最新 ${version})`, + registryCheckFailed: ({ error }: { error: string }) => `レジストリの確認に失敗しました: ${error}`, + install: 'インストール', + update: '更新', + reinstall: '再インストール', + }, + codexResumeInstallModal: { + installTitle: 'Codex resume をインストールしますか?', + updateTitle: 'Codex resume を更新しますか?', + reinstallTitle: 'Codex resume を再インストールしますか?', + description: 'これは再開操作にのみ使用する、実験的な Codex MCP サーバーラッパーをインストールします。', + }, + codexAcpBanner: { + title: 'Codex ACP', + install: 'インストール', + update: '更新', + reinstall: '再インストール', + }, + codexAcpInstallModal: { + installTitle: 'Codex ACP をインストールしますか?', + updateTitle: 'Codex ACP を更新しますか?', + reinstallTitle: 'Codex ACP を再インストールしますか?', + description: 'これはスレッドの読み込み/再開に対応した、Codex 向けの実験的な ACP アダプターをインストールします。', + }, }, sessionHistory: { @@ -333,10 +778,99 @@ export const ja: TranslationStructure = { session: { inputPlaceholder: 'メッセージを入力...', + resuming: '再開中...', + resumeFailed: 'セッションの再開に失敗しました', + resumeSupportNoteChecking: '注: Happy はこのマシンでプロバイダーのセッションを再開できるか確認中です。', + resumeSupportNoteUnverified: '注: Happy はこのマシンでの再開サポートを確認できませんでした。', + resumeSupportDetails: { + cliNotDetected: 'このマシンで CLI が検出されませんでした。', + capabilityProbeFailed: '機能の確認に失敗しました。', + acpProbeFailed: 'ACP の確認に失敗しました。', + loadSessionFalse: 'エージェントはセッションの読み込みをサポートしていません。', + }, + inactiveResumable: '非アクティブ(再開可能)', + inactiveMachineOffline: '非アクティブ(マシンがオフライン)', + inactiveNotResumable: '非アクティブ', + inactiveNotResumableNoticeTitle: 'このセッションは再開できません', + inactiveNotResumableNoticeBody: ({ provider }: { provider: string }) => + `このセッションは終了しており、${provider} がここでコンテキストの復元をサポートしていないため再開できません。続けるには新しいセッションを開始してください。`, + machineOfflineNoticeTitle: 'マシンがオフラインです', + machineOfflineNoticeBody: ({ machine }: { machine: string }) => + `“${machine}” がオフラインのため、Happy はまだこのセッションを再開できません。オンラインに戻して続行してください。`, + machineOfflineCannotResume: 'マシンがオフラインです。オンラインに戻してこのセッションを再開してください。', + sharing: { + title: '共有', + directSharing: '直接共有', + addShare: '友達と共有', + accessLevel: 'アクセスレベル', + shareWith: '共有先', + sharedWith: '共有中', + noShares: '未共有', + viewOnly: '閲覧のみ', + viewOnlyDescription: '閲覧できますが、メッセージは送信できません。', + viewOnlyMode: '閲覧のみ(共有セッション)', + noEditPermission: 'このセッションは閲覧専用です。', + canEdit: '編集可能', + canEditDescription: 'メッセージを送信できます。', + canManage: '管理可能', + canManageDescription: '共有設定を管理できます。', + stopSharing: '共有を停止', + recipientMissingKeys: 'このユーザーはまだ暗号化キーを登録していません。', + + publicLink: '公開リンク', + publicLinkActive: '公開リンクが有効です', + publicLinkDescription: '誰でもこのセッションを閲覧できるリンクを作成します。', + createPublicLink: '公開リンクを作成', + regeneratePublicLink: '公開リンクを再生成', + deletePublicLink: '公開リンクを削除', + linkToken: 'リンクトークン', + tokenNotRecoverable: 'トークンは利用できません', + tokenNotRecoverableDescription: + 'セキュリティ上の理由により、公開リンクのトークンはハッシュ化して保存され復元できません。新しいトークンが必要な場合はリンクを再生成してください。', + + expiresIn: '有効期限', + expiresOn: '有効期限', + days7: '7日間', + days30: '30日間', + never: '無期限', + + maxUsesLabel: '最大使用回数', + unlimited: '無制限', + uses10: '10回使用', + uses50: '50回使用', + usageCount: '使用回数', + usageCountWithMax: ({ used, max }: { used: number; max: number }) => `${used}/${max} 回使用`, + usageCountUnlimited: ({ used }: { used: number }) => `${used} 回使用`, + + requireConsent: '同意を要求', + requireConsentDescription: 'アクセスを記録する前に同意を求めます。', + consentRequired: '同意が必要です', + consentDescription: 'このリンクでは、IP アドレスとユーザーエージェントを記録するために同意が必要です。', + acceptAndView: '同意して表示', + sharedBy: ({ name }: { name: string }) => `${name}さんが共有`, + + shareNotFound: '共有リンクが見つからないか、期限切れです', + failedToDecrypt: 'セッションの復号に失敗しました', + noMessages: 'まだメッセージがありません', + session: 'セッション', + }, }, commandPalette: { placeholder: 'コマンドを入力または検索...', + noCommandsFound: 'コマンドが見つかりません', + }, + + commandView: { + completedWithNoOutput: '[出力なしでコマンドが完了しました]', + }, + + voiceAssistant: { + connecting: '接続中...', + active: '音声アシスタントが有効です', + connectionError: '接続エラー', + label: '音声アシスタント', + tapToEnd: 'タップして終了', }, server: { @@ -363,13 +897,23 @@ export const ja: TranslationStructure = { killSessionConfirm: 'このセッションを終了してもよろしいですか?', archiveSession: 'セッションをアーカイブ', archiveSessionConfirm: 'このセッションをアーカイブしてもよろしいですか?', - happySessionIdCopied: 'Happy Session IDがクリップボードにコピーされました', - failedToCopySessionId: 'Happy Session IDのコピーに失敗しました', - happySessionId: 'Happy Session ID', - claudeCodeSessionId: 'Claude Code Session ID', - claudeCodeSessionIdCopied: 'Claude Code Session IDがクリップボードにコピーされました', + happySessionIdCopied: 'Happy セッション ID をクリップボードにコピーしました', + failedToCopySessionId: 'Happy セッション ID のコピーに失敗しました', + happySessionId: 'Happy セッション ID', + claudeCodeSessionId: 'Claude Code セッション ID', + claudeCodeSessionIdCopied: 'Claude Code セッション ID をクリップボードにコピーしました', + aiProfile: 'AIプロファイル', aiProvider: 'AIプロバイダー', - failedToCopyClaudeCodeSessionId: 'Claude Code Session IDのコピーに失敗しました', + failedToCopyClaudeCodeSessionId: 'Claude Code セッション ID のコピーに失敗しました', + codexSessionId: 'Codex セッション ID', + codexSessionIdCopied: 'Codex セッション ID をクリップボードにコピーしました', + failedToCopyCodexSessionId: 'Codex セッション ID のコピーに失敗しました', + opencodeSessionId: 'OpenCode セッション ID', + opencodeSessionIdCopied: 'OpenCode セッション ID をクリップボードにコピーしました', + auggieSessionId: 'Auggie セッション ID', + auggieSessionIdCopied: 'Auggie セッション ID をクリップボードにコピーしました', + geminiSessionId: 'Gemini セッション ID', + geminiSessionIdCopied: 'Gemini セッション ID をクリップボードにコピーしました', metadataCopied: 'メタデータがクリップボードにコピーされました', failedToCopyMetadata: 'メタデータのコピーに失敗しました', failedToKillSession: 'セッションの終了に失敗しました', @@ -379,6 +923,7 @@ export const ja: TranslationStructure = { lastUpdated: '最終更新', sequence: 'シーケンス', quickActions: 'クイックアクション', + copyResumeCommand: '再開コマンドをコピー', viewMachine: 'マシンを表示', viewMachineSubtitle: 'マシンの詳細とセッションを表示', killSessionSubtitle: 'セッションを即座に終了', @@ -388,9 +933,15 @@ export const ja: TranslationStructure = { path: 'パス', operatingSystem: 'オペレーティングシステム', processId: 'プロセスID', - happyHome: 'Happy Home', + happyHome: 'Happy のホーム', + attachFromTerminal: 'ターミナルからアタッチ', + tmuxTarget: 'tmux ターゲット', + tmuxFallback: 'tmux フォールバック', copyMetadata: 'メタデータをコピー', agentState: 'エージェント状態', + rawJsonDevMode: '生JSON(開発者モード)', + sessionStatus: 'セッションステータス', + fullSessionObject: 'セッションオブジェクト全体', controlledByUser: 'ユーザーによる制御', pendingRequests: '保留中のリクエスト', activity: 'アクティビティ', @@ -407,6 +958,13 @@ export const ja: TranslationStructure = { deleteSessionWarning: 'この操作は取り消せません。このセッションに関連するすべてのメッセージとデータが完全に削除されます。', failedToDeleteSession: 'セッションの削除に失敗しました', sessionDeleted: 'セッションが正常に削除されました', + manageSharing: '共有を管理', + manageSharingSubtitle: '友達とセッションを共有するか、公開リンクを作成', + renameSession: 'セッション名を変更', + renameSessionSubtitle: 'このセッションの表示名を変更します', + renameSessionPlaceholder: 'セッション名を入力...', + failedToRenameSession: 'セッション名の変更に失敗しました', + sessionRenamed: 'セッション名を変更しました', }, @@ -418,16 +976,57 @@ export const ja: TranslationStructure = { runIt: '実行する', scanQrCode: 'QRコードをスキャン', openCamera: 'カメラを開く', + installCommand: '$ npm i -g happy-coder', + runCommand: '$ happy', + }, + emptyMessages: { + noMessagesYet: 'まだメッセージはありません', + created: ({ time }: { time: string }) => `作成 ${time}`, + }, + emptySessionsTablet: { + noActiveSessions: 'アクティブなセッションはありません', + startNewSessionDescription: '接続済みのどのマシンでも新しいセッションを開始できます。', + startNewSessionButton: '新しいセッションを開始', + openTerminalToStart: 'セッションを開始するには、コンピュータで新しいターミナルを開いてください。', + }, + }, + + zen: { + title: 'Zen', + add: { + placeholder: 'やることは?', + }, + home: { + noTasksYet: 'まだタスクはありません。+ をタップして追加します。', + }, + view: { + workOnTask: 'タスクに取り組む', + clarify: '明確化', + delete: '削除', + linkedSessions: 'リンクされたセッション', + tapTaskTextToEdit: 'タスクのテキストをタップして編集', }, }, agentInput: { + envVars: { + title: '環境変数', + titleWithCount: ({ count }: { count: number }) => `環境変数 (${count})`, + }, + resumeChip: { + withId: ({ title, id }: { title: string; id: string }) => `${title}: ${id}`, + withIdTruncated: ({ title, prefix, suffix }: { title: string; prefix: string; suffix: string }) => + `${title}: ${prefix}…${suffix}`, + }, permissionMode: { title: '権限モード', default: 'デフォルト', acceptEdits: '編集を許可', plan: 'プランモード', bypassPermissions: 'Yoloモード', + badgeAccept: '許可', + badgePlan: 'プラン', + badgeYolo: 'YOLO', badgeAcceptAllEdits: 'すべての編集を許可', badgeBypassAllPermissions: 'すべての権限をバイパス', badgePlanMode: 'プランモード', @@ -435,7 +1034,13 @@ export const ja: TranslationStructure = { agent: { claude: 'Claude', codex: 'Codex', + opencode: 'OpenCode', gemini: 'Gemini', + auggie: 'Auggie', + }, + auggieIndexingChip: { + on: 'Indexing on', + off: 'Indexing off', }, model: { title: 'モデル', @@ -464,13 +1069,28 @@ export const ja: TranslationStructure = { geminiPermissionMode: { title: 'GEMINI権限モード', default: 'デフォルト', - readOnly: '読み取り専用', - safeYolo: '安全YOLO', + readOnly: '読み取り専用モード', + safeYolo: 'セーフYOLO', yolo: 'YOLO', - badgeReadOnly: '読み取り専用', - badgeSafeYolo: '安全YOLO', + badgeReadOnly: '読み取り専用モード', + badgeSafeYolo: 'セーフYOLO', badgeYolo: 'YOLO', }, + geminiModel: { + title: 'GEMINIモデル', + gemini25Pro: { + label: 'Gemini 2.5 Pro', + description: '最高性能', + }, + gemini25Flash: { + label: 'Gemini 2.5 Flash', + description: '高速・効率的', + }, + gemini25FlashLite: { + label: 'Gemini 2.5 Flash Lite', + description: '最速', + }, + }, context: { remaining: ({ percent }: { percent: number }) => `残り ${percent}%`, }, @@ -478,6 +1098,11 @@ export const ja: TranslationStructure = { fileLabel: 'ファイル', folderLabel: 'フォルダ', }, + actionMenu: { + title: '操作', + files: 'ファイル', + stop: '停止', + }, noMachinesAvailable: 'マシンなし', }, @@ -516,6 +1141,18 @@ export const ja: TranslationStructure = { submit: '回答を送信', multipleQuestions: ({ count }: { count: number }) => `${count}件の質問`, }, + exitPlanMode: { + approve: 'プランを承認', + reject: '拒否', + requestChanges: '変更を依頼', + requestChangesPlaceholder: 'このプランで変更したい点をClaudeに伝えてください…', + requestChangesSend: 'フィードバックを送信', + requestChangesEmpty: '変更したい内容を入力してください。', + requestChangesFailed: '変更の依頼に失敗しました。もう一度お試しください。', + responded: '送信しました', + approvalMessage: 'このプランを承認します。実装を進めてください。', + rejectionMessage: 'このプランを承認しません。修正するか、希望する変更点を確認してください。', + }, multiEdit: { editNumber: ({ index, total }: { index: number; total: number }) => `編集 ${index}/${total}`, replaceAll: 'すべて置換', @@ -540,6 +1177,10 @@ export const ja: TranslationStructure = { applyChanges: 'ファイルを更新', viewDiff: '現在のファイル変更', question: '質問', + changeTitle: 'タイトルを変更', + }, + geminiExecute: { + cwd: ({ cwd }: { cwd: string }) => `📁 ${cwd}`, }, desc: { terminalCmd: ({ cmd }: { cmd: string }) => `ターミナル(cmd: ${cmd})`, @@ -562,7 +1203,7 @@ export const ja: TranslationStructure = { files: { searchPlaceholder: 'ファイルを検索...', - detachedHead: 'detached HEAD', + detachedHead: '切り離された HEAD', summary: ({ staged, unstaged }: { staged: number; unstaged: number }) => `ステージ済み ${staged} • 未ステージ ${unstaged}`, notRepo: 'Gitリポジトリではありません', notUnderGit: 'このディレクトリはGitバージョン管理下にありません', @@ -698,6 +1339,11 @@ export const ja: TranslationStructure = { deviceLinkedSuccessfully: 'デバイスが正常にリンクされました', terminalConnectedSuccessfully: 'ターミナルが正常に接続されました', invalidAuthUrl: '無効な認証URL', + microphoneAccessRequiredTitle: 'マイクへのアクセスが必要です', + microphoneAccessRequiredRequestPermission: 'Happy は音声チャットのためにマイクへのアクセスが必要です。求められたら許可してください。', + microphoneAccessRequiredEnableInSettings: 'Happy は音声チャットのためにマイクへのアクセスが必要です。端末の設定でマイクのアクセスを有効にしてください。', + microphoneAccessRequiredBrowserInstructions: 'ブラウザの設定でマイクへのアクセスを許可してください。アドレスバーの鍵アイコンをクリックし、このサイトのマイク権限を有効にする必要がある場合があります。', + openSettings: '設定を開く', developerMode: '開発者モード', developerModeEnabled: '開発者モードが有効になりました', developerModeDisabled: '開発者モードが無効になりました', @@ -752,6 +1398,15 @@ export const ja: TranslationStructure = { daemon: 'デーモン', status: 'ステータス', stopDaemon: 'デーモンを停止', + stopDaemonConfirmTitle: 'デーモンを停止しますか?', + stopDaemonConfirmBody: 'このマシンではデーモンを再起動するまで新しいセッションを作成できません。現在のセッションは継続します。', + daemonStoppedTitle: 'デーモンを停止しました', + stopDaemonFailed: 'デーモンを停止できませんでした。実行されていない可能性があります。', + renameTitle: 'マシン名を変更', + renameDescription: 'このマシンにカスタム名を設定します。空欄の場合はデフォルトのホスト名を使用します。', + renamePlaceholder: 'マシン名を入力', + renamedSuccess: 'マシン名を変更しました', + renameFailed: 'マシン名の変更に失敗しました', lastKnownPid: '最後に確認されたPID', lastKnownHttpPort: '最後に確認されたHTTPポート', startedAt: '開始時刻', @@ -768,20 +1423,40 @@ export const ja: TranslationStructure = { lastSeen: '最終確認', never: 'なし', metadataVersion: 'メタデータバージョン', + detectedClis: '検出されたCLI', + detectedCliNotDetected: '未検出', + detectedCliUnknown: '不明', + detectedCliNotSupported: '未対応(happy-cliを更新してください)', untitledSession: '無題のセッション', back: '戻る', + notFound: 'マシンが見つかりません', + unknownMachine: '不明なマシン', + unknownPath: '不明なパス', + tmux: { + overrideTitle: 'グローバル tmux 設定を上書き', + overrideEnabledSubtitle: 'このマシンの新しいセッションにカスタム tmux 設定が適用されます。', + overrideDisabledSubtitle: '新しいセッションはグローバル tmux 設定を使用します。', + notDetectedSubtitle: 'このマシンで tmux が検出されません。', + notDetectedMessage: 'このマシンで tmux が検出されません。tmux をインストールして検出を更新してください。', + }, }, message: { switchedToMode: ({ mode }: { mode: string }) => `${mode}モードに切り替えました`, + discarded: '破棄済み', unknownEvent: '不明なイベント', usageLimitUntil: ({ time }: { time: string }) => `${time}まで使用制限中`, unknownTime: '不明な時間', }, + chatFooter: { + permissionsTerminalOnly: '権限はターミナルにのみ表示されます。リセットするかメッセージを送信して、アプリから制御してください。', + }, + codex: { // Codex permission dialog buttons permissions: { + yesAlwaysAllowCommand: 'はい、グローバルに常に許可', yesForSession: "はい、このセッションでは確認しない", stopAndExplain: '停止して、何をすべきか説明', } @@ -792,6 +1467,9 @@ export const ja: TranslationStructure = { permissions: { yesAllowAllEdits: 'はい、このセッション中のすべての編集を許可', yesForTool: "はい、このツールについては確認しない", + yesForCommandPrefix: 'はい、このコマンドプレフィックスについては確認しない', + yesForSubcommand: 'はい、このサブコマンドについては確認しない', + yesForCommandName: 'はい、このコマンドについては確認しない', noTellClaude: 'いいえ、フィードバックを提供', } }, @@ -805,6 +1483,7 @@ export const ja: TranslationStructure = { textCopied: 'テキストがクリップボードにコピーされました', failedToCopy: 'テキストのクリップボードへのコピーに失敗しました', noTextToCopy: 'コピーできるテキストがありません', + failedToOpen: 'テキスト選択を開けませんでした。もう一度お試しください。', }, markdown: { @@ -825,11 +1504,14 @@ export const ja: TranslationStructure = { edit: 'アーティファクトを編集', delete: '削除', updateError: 'アーティファクトの更新に失敗しました。再試行してください。', + deleteError: 'アーティファクトを削除できませんでした。もう一度お試しください。', notFound: 'アーティファクトが見つかりません', discardChanges: '変更を破棄しますか?', discardChangesDescription: '保存されていない変更があります。破棄してもよろしいですか?', deleteConfirm: 'アーティファクトを削除しますか?', deleteConfirmDescription: 'この操作は取り消せません', + noContent: '内容がありません', + untitled: '無題', titleLabel: 'タイトル', titlePlaceholder: 'アーティファクトのタイトルを入力', bodyLabel: 'コンテンツ', @@ -846,6 +1528,8 @@ export const ja: TranslationStructure = { // Friends feature title: '友達', manageFriends: '友達とつながりを管理', + sharedSessions: '共有セッション', + noSharedSessions: '共有セッションはまだありません', searchTitle: '友達を探す', pendingRequests: '友達リクエスト', myFriends: 'マイフレンド', @@ -907,6 +1591,49 @@ export const ja: TranslationStructure = { noData: '使用データがありません', }, + secrets: { + addTitle: '新しいシークレット', + savedTitle: '保存済みシークレット', + badgeReady: 'シークレット', + badgeRequired: 'シークレットが必要', + missingForProfile: ({ env }: { env: string | null }) => + `シークレットがありません(${env ?? 'シークレット'})。マシンで設定するか、シークレットを選択/入力してください。`, + defaultForProfileTitle: 'デフォルトのシークレット', + defineDefaultForProfileTitle: 'このプロフィールのデフォルトシークレットを設定', + addSubtitle: '保存済みシークレットを追加', + noneTitle: 'なし', + noneSubtitle: 'マシン環境を使用するか、このセッション用にシークレットを入力してください', + emptyTitle: '保存済みシークレットがありません', + emptySubtitle: 'マシンの環境変数を設定せずにシークレットが必要なプロファイルを使うには、追加してください。', + savedHiddenSubtitle: '保存済み(値は非表示)', + defaultLabel: 'デフォルト', + fields: { + name: '名前', + value: '値', + }, + placeholders: { + nameExample: '例: Work OpenAI', + }, + validation: { + nameRequired: '名前は必須です。', + valueRequired: '値は必須です。', + }, + actions: { + replace: '置き換え', + replaceValue: '値を置き換え', + setDefault: 'デフォルトに設定', + unsetDefault: 'デフォルト解除', + }, + prompts: { + renameTitle: 'シークレット名を変更', + renameDescription: 'このシークレットの表示名を更新します。', + replaceValueTitle: 'シークレットの値を置き換え', + replaceValueDescription: '新しいシークレットの値を貼り付けてください。保存後は再表示されません。', + deleteTitle: 'シークレットを削除', + deleteConfirm: ({ name }: { name: string }) => `「${name}」を削除しますか?元に戻せません。`, + }, + }, + feed: { // Feed notifications for friend requests and acceptances friendRequestFrom: ({ name }: { name: string }) => `${name}さんから友達リクエストが届きました`, diff --git a/expo-app/sources/text/translations/pl.ts b/expo-app/sources/text/translations/pl.ts index 09c62f576..dfdaf01c9 100644 --- a/expo-app/sources/text/translations/pl.ts +++ b/expo-app/sources/text/translations/pl.ts @@ -1,4 +1,4 @@ -import type { TranslationStructure } from '../_default'; +import type { TranslationStructure } from '../_types'; /** * Polish plural helper function @@ -42,6 +42,8 @@ export const pl: TranslationStructure = { common: { // Simple string constants + add: 'Dodaj', + actions: 'Akcje', cancel: 'Anuluj', authenticate: 'Uwierzytelnij', save: 'Zapisz', @@ -58,7 +60,11 @@ export const pl: TranslationStructure = { yes: 'Tak', no: 'Nie', discard: 'Odrzuć', + discardChanges: 'Odrzuć zmiany', + unsavedChangesWarning: 'Masz niezapisane zmiany.', + keepEditing: 'Kontynuuj edycję', version: 'Wersja', + details: 'Szczegóły', copied: 'Skopiowano', copy: 'Kopiuj', scanning: 'Skanowanie...', @@ -71,6 +77,18 @@ export const pl: TranslationStructure = { retry: 'Ponów', delete: 'Usuń', optional: 'opcjonalnie', + noMatches: 'Brak dopasowań', + all: 'Wszystko', + machine: 'maszyna', + clearSearch: 'Wyczyść wyszukiwanie', + refresh: 'Odśwież', + }, + + dropdown: { + category: { + general: 'Ogólne', + results: 'Wyniki', + }, }, profile: { @@ -88,8 +106,8 @@ export const pl: TranslationStructure = { connecting: 'łączenie', disconnected: 'rozłączono', error: 'błąd', - online: 'online', - offline: 'offline', + online: 'w sieci', + offline: 'poza siecią', lastSeen: ({ time }: { time: string }) => `ostatnio widziano ${time}`, permissionRequired: 'wymagane uprawnienie', activeNow: 'Aktywny teraz', @@ -107,6 +125,16 @@ export const pl: TranslationStructure = { enterSecretKey: 'Proszę wprowadzić klucz tajny', invalidSecretKey: 'Nieprawidłowy klucz tajny. Sprawdź i spróbuj ponownie.', enterUrlManually: 'Wprowadź URL ręcznie', + openMachine: 'Otwórz maszynę', + terminalUrlPlaceholder: 'happy://terminal?...', + restoreQrInstructions: '1. Otwórz Happy na urządzeniu mobilnym\n2. Przejdź do Ustawienia → Konto\n3. Dotknij „Połącz nowe urządzenie”\n4. Zeskanuj ten kod QR', + restoreWithSecretKeyInstead: 'Przywróć za pomocą klucza tajnego', + restoreWithSecretKeyDescription: 'Wpisz swój klucz tajny, aby odzyskać dostęp do konta.', + secretKeyPlaceholder: 'XXXXX-XXXXX-XXXXX...', + unsupported: { + connectTitle: ({ name }: { name: string }) => `Połącz ${name}`, + runCommandInTerminal: 'Uruchom poniższe polecenie w terminalu:', + }, }, settings: { @@ -147,11 +175,17 @@ export const pl: TranslationStructure = { usageSubtitle: 'Zobacz użycie API i koszty', profiles: 'Profile', profilesSubtitle: 'Zarządzaj profilami zmiennych środowiskowych dla sesji', + secrets: 'Sekrety', + secretsSubtitle: 'Zarządzaj zapisanymi sekretami (po wpisaniu nie będą ponownie pokazywane)', + terminal: 'Terminal', + session: 'Sesja', + sessionSubtitleTmuxEnabled: 'Tmux włączony', + sessionSubtitleMessageSendingAndTmux: 'Wysyłanie wiadomości i tmux', // Dynamic settings messages accountConnected: ({ service }: { service: string }) => `Konto ${service} połączone`, machineStatus: ({ name, status }: { name: string; status: 'online' | 'offline' }) => - `${name} jest ${status === 'online' ? 'online' : 'offline'}`, + `${name} jest ${status === 'online' ? 'w sieci' : 'poza siecią'}`, featureToggled: ({ feature, enabled }: { feature: string; enabled: boolean }) => `${feature} ${enabled ? 'włączona' : 'wyłączona'}`, }, @@ -184,6 +218,21 @@ export const pl: TranslationStructure = { wrapLinesInDiffsDescription: 'Zawijaj długie linie zamiast przewijania poziomego w widokach różnic', alwaysShowContextSize: 'Zawsze pokazuj rozmiar kontekstu', alwaysShowContextSizeDescription: 'Wyświetlaj użycie kontekstu nawet gdy nie jest blisko limitu', + agentInputActionBarLayout: 'Pasek akcji pola wpisywania', + agentInputActionBarLayoutDescription: 'Wybierz, jak wyświetlać chipy akcji nad polem wpisywania', + agentInputActionBarLayoutOptions: { + auto: 'Automatycznie', + wrap: 'Zawijanie', + scroll: 'Przewijany', + collapsed: 'Zwinięty', + }, + agentInputChipDensity: 'Gęstość chipów akcji', + agentInputChipDensityDescription: 'Wybierz, czy chipy akcji pokazują etykiety czy ikony', + agentInputChipDensityOptions: { + auto: 'Automatycznie', + labels: 'Etykiety', + icons: 'Tylko ikony', + }, avatarStyle: 'Styl awatara', avatarStyleDescription: 'Wybierz wygląd awatara sesji', avatarOptions: { @@ -204,21 +253,52 @@ export const pl: TranslationStructure = { experimentalFeatures: 'Funkcje eksperymentalne', experimentalFeaturesEnabled: 'Funkcje eksperymentalne włączone', experimentalFeaturesDisabled: 'Używane tylko stabilne funkcje', - webFeatures: 'Funkcje webowe', - webFeaturesDescription: 'Funkcje dostępne tylko w wersji webowej aplikacji.', + experimentalOptions: 'Opcje eksperymentalne', + experimentalOptionsDescription: 'Wybierz, które funkcje eksperymentalne są włączone.', + expUsageReporting: 'Usage reporting', + expUsageReportingSubtitle: 'Enable usage and token reporting screens', + expFileViewer: 'File viewer', + expFileViewerSubtitle: 'Enable the session file viewer entrypoint', + expShowThinkingMessages: 'Show thinking messages', + expShowThinkingMessagesSubtitle: 'Show assistant thinking/status messages in chat', + expSessionType: 'Session type selector', + expSessionTypeSubtitle: 'Show the session type selector (simple vs worktree)', + expZen: 'Zen', + expZenSubtitle: 'Enable the Zen navigation entry', + expVoiceAuthFlow: 'Voice auth flow', + expVoiceAuthFlowSubtitle: 'Use authenticated voice token flow (paywall-aware)', + expInboxFriends: 'Skrzynka odbiorcza i znajomi', + expInboxFriendsSubtitle: 'Włącz kartę Skrzynka odbiorcza oraz funkcje znajomych', + expCodexResume: 'Wznawianie Codex', + expCodexResumeSubtitle: 'Umożliwia wznawianie sesji Codex przy użyciu osobnej instalacji (eksperymentalne)', + expCodexAcp: 'Codex ACP', + expCodexAcpSubtitle: 'Użyj Codex przez ACP (codex-acp) zamiast MCP (eksperymentalne)', + webFeatures: 'Funkcje webowe', + webFeaturesDescription: 'Funkcje dostępne tylko w wersji webowej aplikacji.', enterToSend: 'Enter aby wysłać', enterToSendEnabled: 'Naciśnij Enter, aby wysłać (Shift+Enter dla nowej linii)', enterToSendDisabled: 'Enter wstawia nową linię', commandPalette: 'Paleta poleceń', commandPaletteEnabled: 'Naciśnij ⌘K, aby otworzyć', commandPaletteDisabled: 'Szybki dostęp do poleceń wyłączony', - markdownCopyV2: 'Markdown Copy v2', + markdownCopyV2: 'Kopiowanie Markdown v2', markdownCopyV2Subtitle: 'Długie naciśnięcie otwiera modal kopiowania', hideInactiveSessions: 'Ukryj nieaktywne sesje', hideInactiveSessionsSubtitle: 'Wyświetlaj tylko aktywne czaty na liście', + groupInactiveSessionsByProject: 'Grupuj nieaktywne sesje według projektu', + groupInactiveSessionsByProjectSubtitle: 'Porządkuj nieaktywne czaty według projektu', enhancedSessionWizard: 'Ulepszony kreator sesji', enhancedSessionWizardEnabled: 'Aktywny launcher z profilem', enhancedSessionWizardDisabled: 'Używanie standardowego launchera sesji', + profiles: 'Profile AI', + profilesEnabled: 'Wybór profili włączony', + profilesDisabled: 'Wybór profili wyłączony', + pickerSearch: 'Wyszukiwanie w selektorach', + pickerSearchSubtitle: 'Pokaż pole wyszukiwania w selektorach maszyn i ścieżek', + machinePickerSearch: 'Wyszukiwanie maszyn', + machinePickerSearchSubtitle: 'Pokaż pole wyszukiwania w selektorach maszyn', + pathPickerSearch: 'Wyszukiwanie ścieżek', + pathPickerSearchSubtitle: 'Pokaż pole wyszukiwania w selektorach ścieżek', }, errors: { @@ -266,13 +346,87 @@ export const pl: TranslationStructure = { failedToRemoveFriend: 'Nie udało się usunąć przyjaciela', searchFailed: 'Wyszukiwanie nie powiodło się. Spróbuj ponownie.', failedToSendRequest: 'Nie udało się wysłać zaproszenia do znajomych', + failedToResumeSession: 'Nie udało się wznowić sesji', + failedToSendMessage: 'Nie udało się wysłać wiadomości', + cannotShareWithSelf: 'Nie możesz udostępnić sobie', + canOnlyShareWithFriends: 'Można udostępniać tylko znajomym', + shareNotFound: 'Udostępnienie nie zostało znalezione', + publicShareNotFound: 'Publiczne udostępnienie nie zostało znalezione lub wygasło', + consentRequired: 'Wymagana zgoda na dostęp', + maxUsesReached: 'Osiągnięto maksymalną liczbę użyć', + invalidShareLink: 'Nieprawidłowy lub wygasły link do udostępnienia', + missingPermissionId: 'Brak identyfikatora prośby o uprawnienie', + codexResumeNotInstalledTitle: 'Codex resume nie jest zainstalowane na tej maszynie', + codexResumeNotInstalledMessage: + 'Aby wznowić rozmowę Codex, zainstaluj serwer wznawiania Codex na maszynie docelowej (Szczegóły maszyny → Wznawianie Codex).', + codexAcpNotInstalledTitle: 'Codex ACP nie jest zainstalowane na tej maszynie', + codexAcpNotInstalledMessage: + 'Aby użyć eksperymentu Codex ACP, zainstaluj codex-acp na maszynie docelowej (Szczegóły maszyny → Codex ACP) lub wyłącz eksperyment.', + }, + + deps: { + installNotSupported: 'Zaktualizuj Happy CLI, aby zainstalować tę zależność.', + installFailed: 'Instalacja nie powiodła się', + installed: 'Zainstalowano', + installLog: ({ path }: { path: string }) => `Log instalacji: ${path}`, + installable: { + codexResume: { + title: 'Serwer wznawiania Codex', + installSpecTitle: 'Źródło instalacji Codex resume', + }, + codexAcp: { + title: 'Adapter Codex ACP', + installSpecTitle: 'Źródło instalacji Codex ACP', + }, + installSpecDescription: 'Specyfikacja NPM/Git/file przekazywana do `npm install` (eksperymentalne). Pozostaw puste, aby użyć domyślnej wartości demona.', + }, + ui: { + notAvailable: 'Niedostępne', + notAvailableUpdateCli: 'Niedostępne (zaktualizuj CLI)', + errorRefresh: 'Błąd (odśwież)', + installed: 'Zainstalowano', + installedWithVersion: ({ version }: { version: string }) => `Zainstalowano (v${version})`, + installedUpdateAvailable: ({ installedVersion, latestVersion }: { installedVersion: string; latestVersion: string }) => + `Zainstalowano (v${installedVersion}) — dostępna aktualizacja (v${latestVersion})`, + notInstalled: 'Nie zainstalowano', + latest: 'Najnowsza', + latestSubtitle: ({ version, tag }: { version: string; tag: string }) => `${version} (tag: ${tag})`, + registryCheck: 'Sprawdzenie rejestru', + registryCheckFailed: ({ error }: { error: string }) => `Niepowodzenie: ${error}`, + installSource: 'Źródło instalacji', + installSourceDefault: '(domyślne)', + installSpecPlaceholder: 'np. file:/ścieżka/do/pakietu lub github:właściciel/repo#gałąź', + lastInstallLog: 'Ostatni log instalacji', + installLogTitle: 'Log instalacji', + }, }, newSession: { // Used by new-session screen and launch flows title: 'Rozpocznij nową sesję', + selectAiProfileTitle: 'Wybierz profil AI', + selectAiProfileDescription: 'Wybierz profil AI, aby zastosować zmienne środowiskowe i domyślne ustawienia do sesji.', + changeProfile: 'Zmień profil', + aiBackendSelectedByProfile: 'Backend AI jest wybierany przez profil. Aby go zmienić, wybierz inny profil.', + selectAiBackendTitle: 'Wybierz backend AI', + aiBackendLimitedByProfileAndMachineClis: 'Ograniczone przez wybrany profil i dostępne CLI na tej maszynie.', + aiBackendSelectWhichAiRuns: 'Wybierz, które AI uruchamia Twoją sesję.', + aiBackendNotCompatibleWithSelectedProfile: 'Niezgodne z wybranym profilem.', + aiBackendCliNotDetectedOnMachine: ({ cli }: { cli: string }) => `Nie wykryto CLI ${cli} na tej maszynie.`, + selectMachineTitle: 'Wybierz maszynę', + selectMachineDescription: 'Wybierz, gdzie ta sesja działa.', + selectPathTitle: 'Wybierz ścieżkę', + selectWorkingDirectoryTitle: 'Wybierz katalog roboczy', + selectWorkingDirectoryDescription: 'Wybierz folder używany dla poleceń i kontekstu.', + selectPermissionModeTitle: 'Wybierz tryb uprawnień', + selectPermissionModeDescription: 'Określ, jak ściśle akcje wymagają zatwierdzenia.', + selectModelTitle: 'Wybierz model AI', + selectModelDescription: 'Wybierz model używany przez tę sesję.', + selectSessionTypeTitle: 'Wybierz typ sesji', + selectSessionTypeDescription: 'Wybierz sesję prostą lub powiązaną z Git worktree.', + searchPathsPlaceholder: 'Szukaj ścieżek...', noMachinesFound: 'Nie znaleziono maszyn. Najpierw uruchom sesję Happy na swoim komputerze.', - allMachinesOffline: 'Wszystkie maszyny są offline', + allMachinesOffline: 'Wszystkie maszyny są poza siecią', machineDetails: 'Zobacz szczegóły maszyny →', directoryDoesNotExist: 'Katalog nie został znaleziony', createDirectoryConfirm: ({ directory }: { directory: string }) => `Katalog ${directory} nie istnieje. Czy chcesz go utworzyć?`, @@ -286,18 +440,94 @@ export const pl: TranslationStructure = { startNewSessionInFolder: 'Nowa sesja tutaj', noMachineSelected: 'Proszę wybrać maszynę do rozpoczęcia sesji', noPathSelected: 'Proszę wybrać katalog do rozpoczęcia sesji', + machinePicker: { + searchPlaceholder: 'Szukaj maszyn...', + recentTitle: 'Ostatnie', + favoritesTitle: 'Ulubione', + allTitle: 'Wszystkie', + emptyMessage: 'Brak dostępnych maszyn', + }, + pathPicker: { + enterPathTitle: 'Wpisz ścieżkę', + enterPathPlaceholder: 'Wpisz ścieżkę...', + customPathTitle: 'Niestandardowa ścieżka', + recentTitle: 'Ostatnie', + favoritesTitle: 'Ulubione', + suggestedTitle: 'Sugerowane', + allTitle: 'Wszystkie', + emptyRecent: 'Brak ostatnich ścieżek', + emptyFavorites: 'Brak ulubionych ścieżek', + emptySuggested: 'Brak sugerowanych ścieżek', + emptyAll: 'Brak ścieżek', + }, sessionType: { title: 'Typ sesji', simple: 'Prosta', - worktree: 'Worktree', + worktree: 'Drzewo robocze', comingSoon: 'Wkrótce dostępne', }, + profileAvailability: { + requiresAgent: ({ agent }: { agent: string }) => `Wymaga ${agent}`, + cliNotDetected: ({ cli }: { cli: string }) => `Nie wykryto CLI ${cli}`, + }, + cliBanners: { + cliNotDetectedTitle: ({ cli }: { cli: string }) => `Nie wykryto CLI ${cli}`, + dontShowFor: 'Nie pokazuj tego komunikatu dla', + thisMachine: 'tej maszyny', + anyMachine: 'dowolnej maszyny', + installCommand: ({ command }: { command: string }) => `Zainstaluj: ${command} •`, + installCliIfAvailable: ({ cli }: { cli: string }) => `Zainstaluj CLI ${cli}, jeśli jest dostępne •`, + viewInstallationGuide: 'Zobacz instrukcję instalacji →', + viewGeminiDocs: 'Zobacz dokumentację Gemini →', + }, worktree: { creating: ({ name }: { name: string }) => `Tworzenie worktree '${name}'...`, notGitRepo: 'Worktree wymaga repozytorium git', failed: ({ error }: { error: string }) => `Nie udało się utworzyć worktree: ${error}`, success: 'Worktree został utworzony pomyślnie', - } + }, + resume: { + title: 'Wznów sesję', + optional: 'Wznów: Opcjonalnie', + pickerTitle: 'Wznów sesję', + subtitle: ({ agent }: { agent: string }) => `Wklej ID sesji ${agent}, aby wznowić`, + placeholder: ({ agent }: { agent: string }) => `Wklej ID sesji ${agent}…`, + paste: 'Wklej', + save: 'Zapisz', + clearAndRemove: 'Wyczyść', + helpText: 'ID sesji znajdziesz na ekranie informacji o sesji.', + cannotApplyBody: 'Nie można teraz zastosować tego ID wznowienia. Happy uruchomi zamiast tego nową sesję.', + }, + codexResumeBanner: { + title: 'Codex resume', + updateAvailable: 'Dostępna aktualizacja', + systemCodexVersion: ({ version }: { version: string }) => `Systemowy Codex: ${version}`, + resumeServerVersion: ({ version }: { version: string }) => `Serwer Codex resume: ${version}`, + notInstalled: 'nie zainstalowano', + latestVersion: ({ version }: { version: string }) => `(najnowsza ${version})`, + registryCheckFailed: ({ error }: { error: string }) => `Sprawdzenie rejestru nie powiodło się: ${error}`, + install: 'Zainstaluj', + update: 'Zaktualizuj', + reinstall: 'Zainstaluj ponownie', + }, + codexResumeInstallModal: { + installTitle: 'Zainstalować Codex resume?', + updateTitle: 'Zaktualizować Codex resume?', + reinstallTitle: 'Zainstalować ponownie Codex resume?', + description: 'To instaluje eksperymentalny wrapper serwera MCP Codex używany wyłącznie do operacji wznawiania.', + }, + codexAcpBanner: { + title: 'Codex ACP', + install: 'Zainstaluj', + update: 'Zaktualizuj', + reinstall: 'Zainstaluj ponownie', + }, + codexAcpInstallModal: { + installTitle: 'Zainstalować Codex ACP?', + updateTitle: 'Zaktualizować Codex ACP?', + reinstallTitle: 'Zainstalować ponownie Codex ACP?', + description: 'To instaluje eksperymentalny adapter ACP dla Codex, który obsługuje ładowanie/wznawianie wątków.', + }, }, sessionHistory: { @@ -312,10 +542,99 @@ export const pl: TranslationStructure = { session: { inputPlaceholder: 'Wpisz wiadomość...', + resuming: 'Wznawianie...', + resumeFailed: 'Nie udało się wznowić sesji', + resumeSupportNoteChecking: 'Uwaga: Happy wciąż sprawdza, czy ta maszyna może wznowić sesję dostawcy.', + resumeSupportNoteUnverified: 'Uwaga: Happy nie mógł zweryfikować obsługi wznawiania na tej maszynie.', + resumeSupportDetails: { + cliNotDetected: 'Nie wykryto CLI na maszynie.', + capabilityProbeFailed: 'Nie udało się sprawdzić możliwości.', + acpProbeFailed: 'Nie udało się sprawdzić ACP.', + loadSessionFalse: 'Agent nie obsługuje ładowania sesji.', + }, + inactiveResumable: 'Nieaktywna (można wznowić)', + inactiveMachineOffline: 'Nieaktywna (maszyna offline)', + inactiveNotResumable: 'Nieaktywna', + inactiveNotResumableNoticeTitle: 'Nie można wznowić tej sesji', + inactiveNotResumableNoticeBody: ({ provider }: { provider: string }) => + `Ta sesja została zakończona i nie można jej wznowić, ponieważ ${provider} nie obsługuje przywracania kontekstu tutaj. Rozpocznij nową sesję, aby kontynuować.`, + machineOfflineNoticeTitle: 'Maszyna jest offline', + machineOfflineNoticeBody: ({ machine }: { machine: string }) => + `“${machine}” jest offline, więc Happy nie może jeszcze wznowić tej sesji. Przywróć maszynę online, aby kontynuować.`, + machineOfflineCannotResume: 'Maszyna jest offline. Przywróć ją online, aby wznowić tę sesję.', + + sharing: { + title: 'Udostępnianie', + directSharing: 'Udostępnianie bezpośrednie', + addShare: 'Udostępnij znajomemu', + accessLevel: 'Poziom dostępu', + shareWith: 'Udostępnij', + sharedWith: 'Udostępniono', + noShares: 'Nieudostępnione', + viewOnly: 'Tylko podgląd', + viewOnlyDescription: 'Może przeglądać sesję, ale nie może wysyłać wiadomości.', + viewOnlyMode: 'Tylko podgląd (sesja udostępniona)', + noEditPermission: 'Masz dostęp tylko do odczytu do tej sesji.', + canEdit: 'Może edytować', + canEditDescription: 'Może wysyłać wiadomości.', + canManage: 'Może zarządzać', + canManageDescription: 'Może zarządzać udostępnianiem.', + stopSharing: 'Zatrzymaj udostępnianie', + recipientMissingKeys: 'Ten użytkownik nie zarejestrował jeszcze kluczy szyfrowania.', + + publicLink: 'Link publiczny', + publicLinkActive: 'Link publiczny jest aktywny', + publicLinkDescription: 'Utwórz link, aby każdy mógł zobaczyć tę sesję.', + createPublicLink: 'Utwórz link publiczny', + regeneratePublicLink: 'Wygeneruj nowy link publiczny', + deletePublicLink: 'Usuń link publiczny', + linkToken: 'Token linku', + tokenNotRecoverable: 'Token niedostępny', + tokenNotRecoverableDescription: 'Ze względów bezpieczeństwa tokeny linków publicznych są przechowywane jako hash i nie można ich odzyskać. Wygeneruj nowy link, aby utworzyć nowy token.', + + expiresIn: 'Wygasa za', + expiresOn: 'Wygasa', + days7: '7 dni', + days30: '30 dni', + never: 'Nigdy', + + maxUsesLabel: 'Maksymalna liczba użyć', + unlimited: 'Bez limitu', + uses10: '10 użyć', + uses50: '50 użyć', + usageCount: 'Liczba użyć', + usageCountWithMax: ({ used, max }: { used: number; max: number }) => `${used}/${max} użyć`, + usageCountUnlimited: ({ used }: { used: number }) => `${used} użyć`, + + requireConsent: 'Wymagaj zgody', + requireConsentDescription: 'Poproś o zgodę przed rejestrowaniem dostępu.', + consentRequired: 'Wymagana zgoda', + consentDescription: 'Ten link wymaga Twojej zgody na zapisanie adresu IP i user agenta.', + acceptAndView: 'Akceptuj i wyświetl', + sharedBy: ({ name }: { name: string }) => `Udostępnione przez ${name}`, + + shareNotFound: 'Link udostępniania nie istnieje lub wygasł', + failedToDecrypt: 'Nie udało się odszyfrować sesji', + noMessages: 'Brak wiadomości', + session: 'Sesja', + }, }, commandPalette: { placeholder: 'Wpisz polecenie lub wyszukaj...', + noCommandsFound: 'Nie znaleziono poleceń', + }, + + commandView: { + completedWithNoOutput: '[Polecenie zakończone bez danych wyjściowych]', + }, + + voiceAssistant: { + connecting: 'Łączenie...', + active: 'Asystent głosowy aktywny', + connectionError: 'Błąd połączenia', + label: 'Asystent głosowy', + tapToEnd: 'Dotknij, aby zakończyć', }, server: { @@ -347,8 +666,18 @@ export const pl: TranslationStructure = { happySessionId: 'ID sesji Happy', claudeCodeSessionId: 'ID sesji Claude Code', claudeCodeSessionIdCopied: 'ID sesji Claude Code skopiowane do schowka', + aiProfile: 'Profil AI', aiProvider: 'Dostawca AI', failedToCopyClaudeCodeSessionId: 'Nie udało się skopiować ID sesji Claude Code', + codexSessionId: 'ID sesji Codex', + codexSessionIdCopied: 'ID sesji Codex skopiowane do schowka', + failedToCopyCodexSessionId: 'Nie udało się skopiować ID sesji Codex', + opencodeSessionId: 'ID sesji OpenCode', + opencodeSessionIdCopied: 'ID sesji OpenCode skopiowane do schowka', + auggieSessionId: 'ID sesji Auggie', + auggieSessionIdCopied: 'ID sesji Auggie skopiowane do schowka', + geminiSessionId: 'ID sesji Gemini', + geminiSessionIdCopied: 'ID sesji Gemini skopiowane do schowka', metadataCopied: 'Metadane skopiowane do schowka', failedToCopyMetadata: 'Nie udało się skopiować metadanych', failedToKillSession: 'Nie udało się zakończyć sesji', @@ -358,6 +687,7 @@ export const pl: TranslationStructure = { lastUpdated: 'Ostatnia aktualizacja', sequence: 'Sekwencja', quickActions: 'Szybkie akcje', + copyResumeCommand: 'Kopiuj komendę wznowienia', viewMachine: 'Zobacz maszynę', viewMachineSubtitle: 'Zobacz szczegóły maszyny i sesje', killSessionSubtitle: 'Natychmiastowo zakończ sesję', @@ -368,8 +698,14 @@ export const pl: TranslationStructure = { operatingSystem: 'System operacyjny', processId: 'ID procesu', happyHome: 'Katalog domowy Happy', + attachFromTerminal: 'Dołącz z terminala', + tmuxTarget: 'Cel tmux', + tmuxFallback: 'Fallback tmux', copyMetadata: 'Kopiuj metadane', agentState: 'Stan agenta', + rawJsonDevMode: 'Surowy JSON (tryb deweloperski)', + sessionStatus: 'Status sesji', + fullSessionObject: 'Pełny obiekt sesji', controlledByUser: 'Kontrolowany przez użytkownika', pendingRequests: 'Oczekujące żądania', activity: 'Aktywność', @@ -386,6 +722,13 @@ export const pl: TranslationStructure = { deleteSessionWarning: 'Ta operacja jest nieodwracalna. Wszystkie wiadomości i dane powiązane z tą sesją zostaną trwale usunięte.', failedToDeleteSession: 'Nie udało się usunąć sesji', sessionDeleted: 'Sesja została pomyślnie usunięta', + manageSharing: 'Zarządzanie udostępnianiem', + manageSharingSubtitle: 'Udostępnij tę sesję znajomym lub utwórz publiczny link', + renameSession: 'Zmień nazwę sesji', + renameSessionSubtitle: 'Zmień wyświetlaną nazwę tej sesji', + renameSessionPlaceholder: 'Wprowadź nazwę sesji...', + failedToRenameSession: 'Nie udało się zmienić nazwy sesji', + sessionRenamed: 'Pomyślnie zmieniono nazwę sesji', }, components: { @@ -396,16 +739,57 @@ export const pl: TranslationStructure = { runIt: 'Uruchom je', scanQrCode: 'Zeskanuj kod QR', openCamera: 'Otwórz kamerę', + installCommand: '$ npm i -g happy-coder', + runCommand: '$ happy', + }, + emptyMessages: { + noMessagesYet: 'Brak wiadomości', + created: ({ time }: { time: string }) => `Utworzono ${time}`, + }, + emptySessionsTablet: { + noActiveSessions: 'Brak aktywnych sesji', + startNewSessionDescription: 'Rozpocznij nową sesję na dowolnej z połączonych maszyn.', + startNewSessionButton: 'Rozpocznij nową sesję', + openTerminalToStart: 'Otwórz nowy terminal na komputerze, aby rozpocząć sesję.', + }, + }, + + zen: { + title: 'Zen', + add: { + placeholder: 'Co trzeba zrobić?', + }, + home: { + noTasksYet: 'Brak zadań. Stuknij +, aby dodać.', + }, + view: { + workOnTask: 'Pracuj nad zadaniem', + clarify: 'Doprecyzuj', + delete: 'Usuń', + linkedSessions: 'Powiązane sesje', + tapTaskTextToEdit: 'Stuknij tekst zadania, aby edytować', }, }, agentInput: { + envVars: { + title: 'Zmienne środowiskowe', + titleWithCount: ({ count }: { count: number }) => `Zmienne środowiskowe (${count})`, + }, + resumeChip: { + withId: ({ title, id }: { title: string; id: string }) => `${title}: ${id}`, + withIdTruncated: ({ title, prefix, suffix }: { title: string; prefix: string; suffix: string }) => + `${title}: ${prefix}…${suffix}`, + }, permissionMode: { title: 'TRYB UPRAWNIEŃ', default: 'Domyślny', acceptEdits: 'Akceptuj edycje', plan: 'Tryb planowania', bypassPermissions: 'Tryb YOLO', + badgeAccept: 'Akceptuj', + badgePlan: 'Plan', + badgeYolo: 'YOLO', badgeAcceptAllEdits: 'Akceptuj wszystkie edycje', badgeBypassAllPermissions: 'Omiń wszystkie uprawnienia', badgePlanMode: 'Tryb planowania', @@ -413,7 +797,13 @@ export const pl: TranslationStructure = { agent: { claude: 'Claude', codex: 'Codex', + opencode: 'OpenCode', gemini: 'Gemini', + auggie: 'Auggie', + }, + auggieIndexingChip: { + on: 'Indexing on', + off: 'Indexing off', }, model: { title: 'MODEL', @@ -422,39 +812,59 @@ export const pl: TranslationStructure = { codexPermissionMode: { title: 'TRYB UPRAWNIEŃ CODEX', default: 'Ustawienia CLI', - readOnly: 'Read Only Mode', - safeYolo: 'Safe YOLO', + readOnly: 'Tryb tylko do odczytu', + safeYolo: 'Bezpieczne YOLO', yolo: 'YOLO', - badgeReadOnly: 'Read Only Mode', - badgeSafeYolo: 'Safe YOLO', + badgeReadOnly: 'Tylko do odczytu', + badgeSafeYolo: 'Bezpieczne YOLO', badgeYolo: 'YOLO', }, codexModel: { - title: 'CODEX MODEL', - gpt5CodexLow: 'gpt-5-codex low', - gpt5CodexMedium: 'gpt-5-codex medium', - gpt5CodexHigh: 'gpt-5-codex high', - gpt5Minimal: 'GPT-5 Minimal', - gpt5Low: 'GPT-5 Low', - gpt5Medium: 'GPT-5 Medium', - gpt5High: 'GPT-5 High', + title: 'MODEL CODEX', + gpt5CodexLow: 'gpt-5-codex niski', + gpt5CodexMedium: 'gpt-5-codex średni', + gpt5CodexHigh: 'gpt-5-codex wysoki', + gpt5Minimal: 'GPT-5 Minimalny', + gpt5Low: 'GPT-5 Niski', + gpt5Medium: 'GPT-5 Średni', + gpt5High: 'GPT-5 Wysoki', }, geminiPermissionMode: { title: 'TRYB UPRAWNIEŃ GEMINI', default: 'Domyślny', readOnly: 'Tylko do odczytu', - safeYolo: 'Bezpieczny YOLO', + safeYolo: 'Bezpieczne YOLO', yolo: 'YOLO', badgeReadOnly: 'Tylko do odczytu', - badgeSafeYolo: 'Bezpieczny YOLO', + badgeSafeYolo: 'Bezpieczne YOLO', badgeYolo: 'YOLO', }, + geminiModel: { + title: 'MODEL GEMINI', + gemini25Pro: { + label: 'Gemini 2.5 Pro', + description: 'Najbardziej zaawansowany', + }, + gemini25Flash: { + label: 'Gemini 2.5 Flash', + description: 'Szybki i wydajny', + }, + gemini25FlashLite: { + label: 'Gemini 2.5 Flash Lite', + description: 'Najszybszy', + }, + }, context: { remaining: ({ percent }: { percent: number }) => `Pozostało ${percent}%`, }, suggestion: { fileLabel: 'PLIK', - folderLabel: 'FOLDER', + folderLabel: 'KATALOG', + }, + actionMenu: { + title: 'AKCJE', + files: 'Pliki', + stop: 'Zatrzymaj', }, noMachinesAvailable: 'Brak maszyn', }, @@ -514,6 +924,10 @@ export const pl: TranslationStructure = { applyChanges: 'Zaktualizuj plik', viewDiff: 'Bieżące zmiany pliku', question: 'Pytanie', + changeTitle: 'Zmień tytuł', + }, + geminiExecute: { + cwd: ({ cwd }: { cwd: string }) => `📁 ${cwd}`, }, desc: { terminalCmd: ({ cmd }: { cmd: string }) => `Terminal(cmd: ${cmd})`, @@ -535,7 +949,19 @@ export const pl: TranslationStructure = { askUserQuestion: { submit: 'Wyślij odpowiedź', multipleQuestions: ({ count }: { count: number }) => `${count} ${plural({ count, one: 'pytanie', few: 'pytania', many: 'pytań' })}`, - } + }, + exitPlanMode: { + approve: 'Zatwierdź plan', + reject: 'Odrzuć', + requestChanges: 'Poproś o zmiany', + requestChangesPlaceholder: 'Napisz Claude, co chcesz zmienić w tym planie…', + requestChangesSend: 'Wyślij uwagi', + requestChangesEmpty: 'Wpisz, co chcesz zmienić.', + requestChangesFailed: 'Nie udało się poprosić o zmiany. Spróbuj ponownie.', + responded: 'Odpowiedź wysłana', + approvalMessage: 'Zatwierdzam ten plan. Proszę kontynuować implementację.', + rejectionMessage: 'Nie zatwierdzam tego planu. Proszę go poprawić lub zapytać mnie, jakie zmiany chciałbym wprowadzić.', + }, }, files: { @@ -676,6 +1102,11 @@ export const pl: TranslationStructure = { deviceLinkedSuccessfully: 'Urządzenie połączone pomyślnie', terminalConnectedSuccessfully: 'Terminal połączony pomyślnie', invalidAuthUrl: 'Nieprawidłowy URL uwierzytelnienia', + microphoneAccessRequiredTitle: 'Wymagany dostęp do mikrofonu', + microphoneAccessRequiredRequestPermission: 'Happy potrzebuje dostępu do mikrofonu do czatu głosowego. Udziel zgody, gdy pojawi się prośba.', + microphoneAccessRequiredEnableInSettings: 'Happy potrzebuje dostępu do mikrofonu do czatu głosowego. Włącz dostęp do mikrofonu w ustawieniach urządzenia.', + microphoneAccessRequiredBrowserInstructions: 'Zezwól na dostęp do mikrofonu w ustawieniach przeglądarki. Być może musisz kliknąć ikonę kłódki na pasku adresu i włączyć uprawnienie mikrofonu dla tej witryny.', + openSettings: 'Otwórz ustawienia', developerMode: 'Tryb deweloperski', developerModeEnabled: 'Tryb deweloperski włączony', developerModeDisabled: 'Tryb deweloperski wyłączony', @@ -727,9 +1158,18 @@ export const pl: TranslationStructure = { offlineUnableToSpawn: 'Launcher wyłączony, gdy maszyna jest offline', offlineHelp: '• Upewnij się, że komputer jest online\n• Uruchom `happy daemon status`, aby zdiagnozować\n• Czy używasz najnowszej wersji CLI? Zaktualizuj poleceniem `npm install -g happy-coder@latest`', launchNewSessionInDirectory: 'Uruchom nową sesję w katalogu', - daemon: 'Daemon', + daemon: 'Demon', status: 'Status', stopDaemon: 'Zatrzymaj daemon', + stopDaemonConfirmTitle: 'Zatrzymać daemon?', + stopDaemonConfirmBody: 'Nie będziesz mógł tworzyć nowych sesji na tej maszynie, dopóki nie uruchomisz ponownie daemona na komputerze. Obecne sesje pozostaną aktywne.', + daemonStoppedTitle: 'Daemon zatrzymany', + stopDaemonFailed: 'Nie udało się zatrzymać daemona. Może nie działa.', + renameTitle: 'Zmień nazwę maszyny', + renameDescription: 'Nadaj tej maszynie własną nazwę. Pozostaw puste, aby użyć domyślnej nazwy hosta.', + renamePlaceholder: 'Wpisz nazwę maszyny', + renamedSuccess: 'Nazwa maszyny została zmieniona', + renameFailed: 'Nie udało się zmienić nazwy maszyny', lastKnownPid: 'Ostatni znany PID', lastKnownHttpPort: 'Ostatni znany port HTTP', startedAt: 'Uruchomiony o', @@ -746,20 +1186,40 @@ export const pl: TranslationStructure = { lastSeen: 'Ostatnio widziana', never: 'Nigdy', metadataVersion: 'Wersja metadanych', + detectedClis: 'Wykryte CLI', + detectedCliNotDetected: 'Nie wykryto', + detectedCliUnknown: 'Nieznane', + detectedCliNotSupported: 'Nieobsługiwane (zaktualizuj happy-cli)', untitledSession: 'Sesja bez nazwy', back: 'Wstecz', + notFound: 'Nie znaleziono maszyny', + unknownMachine: 'nieznana maszyna', + unknownPath: 'nieznana ścieżka', + tmux: { + overrideTitle: 'Zastąp globalne ustawienia tmux', + overrideEnabledSubtitle: 'Niestandardowe ustawienia tmux dotyczą nowych sesji na tej maszynie.', + overrideDisabledSubtitle: 'Nowe sesje używają globalnych ustawień tmux.', + notDetectedSubtitle: 'tmux nie został wykryty na tej maszynie.', + notDetectedMessage: 'tmux nie został wykryty na tej maszynie. Zainstaluj tmux i odśwież wykrywanie.', + }, }, message: { switchedToMode: ({ mode }: { mode: string }) => `Przełączono na tryb ${mode}`, + discarded: 'Odrzucono', unknownEvent: 'Nieznane zdarzenie', usageLimitUntil: ({ time }: { time: string }) => `Osiągnięto limit użycia do ${time}`, unknownTime: 'nieznany czas', }, + chatFooter: { + permissionsTerminalOnly: 'Uprawnienia są widoczne tylko w terminalu. Zresetuj lub wyślij wiadomość, aby sterować z aplikacji.', + }, + codex: { // Codex permission dialog buttons permissions: { + yesAlwaysAllowCommand: 'Tak, zezwól globalnie', yesForSession: 'Tak, i nie pytaj dla tej sesji', stopAndExplain: 'Zatrzymaj i wyjaśnij, co zrobić', } @@ -770,6 +1230,9 @@ export const pl: TranslationStructure = { permissions: { yesAllowAllEdits: 'Tak, zezwól na wszystkie edycje podczas tej sesji', yesForTool: 'Tak, nie pytaj ponownie dla tego narzędzia', + yesForCommandPrefix: 'Tak, nie pytaj ponownie dla tego prefiksu polecenia', + yesForSubcommand: 'Tak, nie pytaj ponownie dla tego podpolecenia', + yesForCommandName: 'Tak, nie pytaj ponownie dla tego polecenia', noTellClaude: 'Nie, przekaż opinię', } }, @@ -783,6 +1246,7 @@ export const pl: TranslationStructure = { textCopied: 'Tekst skopiowany do schowka', failedToCopy: 'Nie udało się skopiować tekstu do schowka', noTextToCopy: 'Brak tekstu do skopiowania', + failedToOpen: 'Nie udało się otworzyć wyboru tekstu. Spróbuj ponownie.', }, markdown: { @@ -816,11 +1280,14 @@ export const pl: TranslationStructure = { edit: 'Edytuj artefakt', delete: 'Usuń', updateError: 'Nie udało się zaktualizować artefaktu. Spróbuj ponownie.', + deleteError: 'Nie udało się usunąć artefaktu. Spróbuj ponownie.', notFound: 'Artefakt nie został znaleziony', discardChanges: 'Odrzucić zmiany?', discardChangesDescription: 'Masz niezapisane zmiany. Czy na pewno chcesz je odrzucić?', deleteConfirm: 'Usunąć artefakt?', deleteConfirmDescription: 'Tej operacji nie można cofnąć', + noContent: 'Brak treści', + untitled: 'Bez tytułu', titleLabel: 'TYTUŁ', titlePlaceholder: 'Wprowadź tytuł dla swojego artefaktu', bodyLabel: 'TREŚĆ', @@ -836,6 +1303,8 @@ export const pl: TranslationStructure = { friends: { // Friends feature title: 'Przyjaciele', + sharedSessions: 'Udostępnione sesje', + noSharedSessions: 'Brak udostępnionych sesji', manageFriends: 'Zarządzaj swoimi przyjaciółmi i połączeniami', searchTitle: 'Znajdź przyjaciół', pendingRequests: 'Zaproszenia do znajomych', @@ -906,10 +1375,55 @@ export const pl: TranslationStructure = { friendAcceptedGeneric: 'Zaproszenie do znajomych zaakceptowane', }, + secrets: { + addTitle: 'Nowy sekret', + savedTitle: 'Zapisane sekrety', + badgeReady: 'Sekret', + badgeRequired: 'Wymagany sekret', + missingForProfile: ({ env }: { env: string | null }) => + `Brak sekretu (${env ?? 'sekret'}). Skonfiguruj go na maszynie lub wybierz/wpisz sekret.`, + defaultForProfileTitle: 'Domyślny sekret', + defineDefaultForProfileTitle: 'Ustaw domyślny sekret dla tego profilu', + addSubtitle: 'Dodaj zapisany sekret', + noneTitle: 'Brak', + noneSubtitle: 'Użyj środowiska maszyny lub wpisz sekret dla tej sesji', + emptyTitle: 'Brak zapisanych sekretów', + emptySubtitle: 'Dodaj jeden, aby używać profili z sekretem bez ustawiania zmiennych środowiskowych na maszynie.', + savedHiddenSubtitle: 'Zapisany (wartość ukryta)', + defaultLabel: 'Domyślny', + fields: { + name: 'Nazwa', + value: 'Wartość', + }, + placeholders: { + nameExample: 'np. Work OpenAI', + }, + validation: { + nameRequired: 'Nazwa jest wymagana.', + valueRequired: 'Wartość jest wymagana.', + }, + actions: { + replace: 'Zastąp', + replaceValue: 'Zastąp wartość', + setDefault: 'Ustaw jako domyślny', + unsetDefault: 'Usuń domyślny', + }, + prompts: { + renameTitle: 'Zmień nazwę sekretu', + renameDescription: 'Zaktualizuj przyjazną nazwę dla tego sekretu.', + replaceValueTitle: 'Zastąp wartość sekretu', + replaceValueDescription: 'Wklej nową wartość sekretu. Ta wartość nie będzie ponownie wyświetlana po zapisaniu.', + deleteTitle: 'Usuń sekret', + deleteConfirm: ({ name }: { name: string }) => `Usunąć “${name}”? Tej czynności nie można cofnąć.`, + }, + }, + profiles: { // Profile management feature title: 'Profile', subtitle: 'Zarządzaj profilami zmiennych środowiskowych dla sesji', + sessionUses: ({ profile }: { profile: string }) => `Ta sesja używa: ${profile}`, + profilesFixedPerSession: 'Profile są stałe dla sesji. Aby użyć innego profilu, rozpocznij nową sesję.', noProfile: 'Brak Profilu', noProfileDescription: 'Użyj domyślnych ustawień środowiska', defaultModel: 'Domyślny Model', @@ -926,9 +1440,232 @@ export const pl: TranslationStructure = { enterTmuxTempDir: 'Wprowadź ścieżkę do katalogu tymczasowego', tmuxUpdateEnvironment: 'Aktualizuj środowisko automatycznie', nameRequired: 'Nazwa profilu jest wymagana', - deleteConfirm: 'Czy na pewno chcesz usunąć profil "{name}"?', + deleteConfirm: ({ name }: { name: string }) => `Czy na pewno chcesz usunąć profil "${name}"?`, editProfile: 'Edytuj Profil', addProfileTitle: 'Dodaj Nowy Profil', + builtIn: 'Wbudowane', + custom: 'Niestandardowe', + builtInSaveAsHint: 'Zapisanie wbudowanego profilu tworzy nowy profil niestandardowy.', + builtInNames: { + anthropic: 'Anthropic (Domyślny)', + deepseek: 'DeepSeek (Reasoner)', + zai: 'Z.AI (GLM-4.6)', + codex: 'Codex (Default)', + openai: 'OpenAI (GPT-5)', + azureOpenai: 'Azure OpenAI', + gemini: 'Gemini (Default)', + geminiApiKey: 'Gemini (API key)', + geminiVertex: 'Gemini (Vertex AI)', + }, + groups: { + favorites: 'Ulubione', + custom: 'Twoje profile', + builtIn: 'Profile wbudowane', + }, + actions: { + viewEnvironmentVariables: 'Zmienne środowiskowe', + addToFavorites: 'Dodaj do ulubionych', + removeFromFavorites: 'Usuń z ulubionych', + editProfile: 'Edytuj profil', + duplicateProfile: 'Duplikuj profil', + deleteProfile: 'Usuń profil', + }, + copySuffix: '(Kopia)', + duplicateName: 'Profil o tej nazwie już istnieje', + setupInstructions: { + title: 'Instrukcje konfiguracji', + viewOfficialGuide: 'Zobacz oficjalny przewodnik konfiguracji', + }, + machineLogin: { + title: 'Wymagane logowanie na maszynie', + subtitle: 'Ten profil korzysta z pamięci podręcznej logowania CLI na wybranej maszynie.', + status: { + loggedIn: 'Zalogowano', + notLoggedIn: 'Nie zalogowano', + }, + claudeCode: { + title: 'Claude Code', + instructions: 'Uruchom `claude`, a następnie wpisz `/login`, aby się zalogować.', + warning: 'Uwaga: ustawienie `ANTHROPIC_AUTH_TOKEN` zastępuje logowanie CLI.', + }, + codex: { + title: 'Codex', + instructions: 'Uruchom `codex login`, aby się zalogować.', + }, + geminiCli: { + title: 'Gemini CLI', + instructions: 'Uruchom `gemini auth`, aby się zalogować.', + }, + }, + requirements: { + secretRequired: 'Sekret', + configured: 'Skonfigurowano na maszynie', + notConfigured: 'Nie skonfigurowano', + checking: 'Sprawdzanie…', + missingConfigForProfile: ({ env }: { env: string }) => `Ten profil wymaga skonfigurowania ${env} na maszynie.`, + modalTitle: 'Wymagany sekret', + modalBody: 'Ten profil wymaga sekretu.\n\nDostępne opcje:\n• Użyj środowiska maszyny (zalecane)\n• Użyj zapisanego sekretu z ustawień aplikacji\n• Wpisz sekret tylko dla tej sesji', + sectionTitle: 'Wymagania', + sectionSubtitle: 'Te pola służą do wstępnej weryfikacji i aby uniknąć niespodziewanych błędów.', + secretEnvVarPromptDescription: 'Wpisz nazwę wymaganej tajnej zmiennej środowiskowej (np. OPENAI_API_KEY).', + modalHelpWithEnv: ({ env }: { env: string }) => `Ten profil wymaga ${env}. Wybierz jedną z opcji poniżej.`, + modalHelpGeneric: 'Ten profil wymaga sekretu. Wybierz jedną z opcji poniżej.', + chooseOptionTitle: 'Wybierz opcję', + machineEnvStatus: { + theMachine: 'maszynie', + checkFor: ({ env }: { env: string }) => `Sprawdź ${env}`, + checking: ({ env }: { env: string }) => `Sprawdzanie ${env}…`, + found: ({ env, machine }: { env: string; machine: string }) => `${env} znaleziono na ${machine}`, + notFound: ({ env, machine }: { env: string; machine: string }) => `${env} nie znaleziono na ${machine}`, + }, + machineEnvSubtitle: { + checking: 'Sprawdzanie środowiska daemona…', + found: 'Znaleziono w środowisku daemona na maszynie.', + notFound: 'Ustaw w środowisku daemona na maszynie i uruchom ponownie daemona.', + }, + options: { + none: { + title: 'Brak', + subtitle: 'Nie wymaga sekretu ani logowania CLI.', + }, + machineLogin: { + subtitle: 'Wymaga zalogowania przez CLI na maszynie docelowej.', + longSubtitle: 'Wymaga zalogowania w CLI dla wybranego backendu AI na maszynie docelowej.', + }, + useMachineEnvironment: { + title: 'Użyj środowiska maszyny', + subtitleWithEnv: ({ env }: { env: string }) => `Użyj ${env} ze środowiska daemona.`, + subtitleGeneric: 'Użyj sekretu ze środowiska daemona.', + }, + useSavedSecret: { + title: 'Użyj zapisanego sekretu', + subtitle: 'Wybierz (lub dodaj) zapisany sekret w aplikacji.', + }, + enterOnce: { + title: 'Wpisz sekret', + subtitle: 'Wklej sekret tylko dla tej sesji (nie zostanie zapisany).', + }, + }, + secretEnvVar: { + title: 'Zmienna środowiskowa sekretu', + subtitle: 'Wpisz nazwę zmiennej środowiskowej, której ten dostawca oczekuje dla sekretu (np. OPENAI_API_KEY).', + label: 'Nazwa zmiennej środowiskowej', + }, + sections: { + machineEnvironment: 'Środowisko maszyny', + useOnceTitle: 'Użyj raz', + useOnceLabel: 'Wprowadź sekret', + useOnceFooter: 'Wklej sekret tylko dla tej sesji. Nie zostanie zapisany.', + }, + actions: { + useMachineEnvironment: { + subtitle: 'Rozpocznij z kluczem już obecnym na maszynie.', + }, + useOnceButton: 'Użyj raz (tylko sesja)', + }, + }, + defaultSessionType: 'Domyślny typ sesji', + defaultPermissionMode: { + title: 'Domyślny tryb uprawnień', + descriptions: { + default: 'Pytaj o uprawnienia', + acceptEdits: 'Automatycznie zatwierdzaj edycje', + plan: 'Zaplanuj przed wykonaniem', + bypassPermissions: 'Pomiń wszystkie uprawnienia', + }, + }, + aiBackend: { + title: 'Backend AI', + selectAtLeastOneError: 'Wybierz co najmniej jeden backend AI.', + claudeSubtitle: 'CLI Claude', + codexSubtitle: 'CLI Codex', + opencodeSubtitle: 'CLI OpenCode', + geminiSubtitleExperimental: 'CLI Gemini (eksperymentalne)', + auggieSubtitle: 'Auggie CLI', + }, + tmux: { + title: 'Tmux', + spawnSessionsTitle: 'Uruchamiaj sesje w Tmux', + spawnSessionsEnabledSubtitle: 'Sesje uruchamiają się w nowych oknach tmux.', + spawnSessionsDisabledSubtitle: 'Sesje uruchamiają się w zwykłej powłoce (bez integracji z tmux)', + isolatedServerTitle: 'Izolowany serwer tmux', + isolatedServerEnabledSubtitle: 'Uruchamiaj sesje w izolowanym serwerze tmux (zalecane).', + isolatedServerDisabledSubtitle: 'Uruchamiaj sesje w domyślnym serwerze tmux.', + sessionNamePlaceholder: 'Puste = bieżąca/najnowsza sesja', + tempDirPlaceholder: 'Pozostaw puste, aby wygenerować automatycznie', + }, + previewMachine: { + title: 'Podgląd maszyny', + itemTitle: 'Maszyna podglądu dla zmiennych środowiskowych', + selectMachine: 'Wybierz maszynę', + resolveSubtitle: 'Służy tylko do podglądu rozwiązanych wartości poniżej (nie zmienia tego, co zostanie zapisane).', + selectSubtitle: 'Wybierz maszynę, aby podejrzeć rozwiązane wartości poniżej.', + }, + environmentVariables: { + title: 'Zmienne środowiskowe', + addVariable: 'Dodaj zmienną', + namePlaceholder: 'Nazwa zmiennej (np. MY_CUSTOM_VAR)', + valuePlaceholder: 'Wartość (np. my-value lub ${MY_VAR})', + validation: { + nameRequired: 'Wprowadź nazwę zmiennej.', + invalidNameFormat: 'Nazwy zmiennych muszą zawierać wielkie litery, cyfry i podkreślenia oraz nie mogą zaczynać się od cyfry.', + duplicateName: 'Taka zmienna już istnieje.', + }, + card: { + valueLabel: 'Wartość:', + fallbackValueLabel: 'Wartość fallback:', + valueInputPlaceholder: 'Wartość', + defaultValueInputPlaceholder: 'Wartość domyślna', + fallbackDisabledForVault: 'Fallback jest wyłączony podczas używania sejfu sekretów.', + secretNotRetrieved: 'Wartość sekretna - nie jest pobierana ze względów bezpieczeństwa', + secretToggleLabel: 'Ukryj wartość w UI', + secretToggleSubtitle: 'Ukrywa wartość w UI i nie pobiera jej z maszyny na potrzeby podglądu.', + secretToggleEnforcedByDaemon: 'Wymuszone przez daemon', + secretToggleEnforcedByVault: 'Wymuszone przez sejf sekretów', + secretToggleResetToAuto: 'Przywróć automatyczne', + requirementRequiredLabel: 'Wymagane', + requirementRequiredSubtitle: 'Blokuje tworzenie sesji, jeśli zmienna jest brakująca.', + requirementUseVaultLabel: 'Użyj sejfu sekretów', + requirementUseVaultSubtitle: 'Użyj zapisanego sekretu (bez wartości fallback).', + defaultSecretLabel: 'Domyślny sekret', + overridingDefault: ({ expectedValue }: { expectedValue: string }) => + `Nadpisywanie udokumentowanej wartości domyślnej: ${expectedValue}`, + useMachineEnvToggle: 'Użyj wartości ze środowiska maszyny', + resolvedOnSessionStart: 'Rozwiązywane podczas uruchamiania sesji na wybranej maszynie.', + sourceVariableLabel: 'Zmienna źródłowa', + sourceVariablePlaceholder: 'Nazwa zmiennej źródłowej (np. Z_AI_MODEL)', + checkingMachine: ({ machine }: { machine: string }) => `Sprawdzanie ${machine}...`, + emptyOnMachine: ({ machine }: { machine: string }) => `Pusto na ${machine}`, + emptyOnMachineUsingFallback: ({ machine }: { machine: string }) => `Pusto na ${machine} (używam fallback)`, + notFoundOnMachine: ({ machine }: { machine: string }) => `Nie znaleziono na ${machine}`, + notFoundOnMachineUsingFallback: ({ machine }: { machine: string }) => `Nie znaleziono na ${machine} (używam fallback)`, + valueFoundOnMachine: ({ machine }: { machine: string }) => `Znaleziono wartość na ${machine}`, + differsFromDocumented: ({ expectedValue }: { expectedValue: string }) => + `Różni się od udokumentowanej wartości: ${expectedValue}`, + }, + preview: { + secretValueHidden: ({ value }: { value: string }) => `${value} - ukryte ze względów bezpieczeństwa`, + hiddenValue: '***ukryte***', + emptyValue: '(puste)', + sessionWillReceive: ({ name, value }: { name: string; value: string }) => + `Sesja otrzyma: ${name} = ${value}`, + }, + previewModal: { + titleWithProfile: ({ profileName }: { profileName: string }) => `Zmienne środowiskowe · ${profileName}`, + descriptionPrefix: 'Te zmienne środowiskowe są wysyłane podczas uruchamiania sesji. Wartości są rozwiązywane przez daemon na', + descriptionFallbackMachine: 'wybranej maszynie', + descriptionSuffix: '.', + emptyMessage: 'Dla tego profilu nie ustawiono zmiennych środowiskowych.', + checkingSuffix: '(sprawdzanie…)', + detail: { + fixed: 'Stała', + machine: 'Maszyna', + checking: 'Sprawdzanie', + fallback: 'Wartość zapasowa', + missing: 'Brak', + }, + }, + }, delete: { title: 'Usuń Profil', message: ({ name }: { name: string }) => `Czy na pewno chcesz usunąć "${name}"? Tej czynności nie można cofnąć.`, diff --git a/expo-app/sources/text/translations/pt.ts b/expo-app/sources/text/translations/pt.ts index 93f134ecf..f5ef289b0 100644 --- a/expo-app/sources/text/translations/pt.ts +++ b/expo-app/sources/text/translations/pt.ts @@ -1,4 +1,4 @@ -import type { TranslationStructure } from '../_default'; +import type { TranslationStructure } from '../_types'; /** * Portuguese plural helper function @@ -31,6 +31,8 @@ export const pt: TranslationStructure = { common: { // Simple string constants + add: 'Adicionar', + actions: 'Ações', cancel: 'Cancelar', authenticate: 'Autenticar', save: 'Salvar', @@ -47,7 +49,11 @@ export const pt: TranslationStructure = { yes: 'Sim', no: 'Não', discard: 'Descartar', + discardChanges: 'Descartar alterações', + unsavedChangesWarning: 'Você tem alterações não salvas.', + keepEditing: 'Continuar editando', version: 'Versão', + details: 'Detalhes', copied: 'Copiado', copy: 'Copiar', scanning: 'Escaneando...', @@ -60,6 +66,18 @@ export const pt: TranslationStructure = { retry: 'Tentar novamente', delete: 'Excluir', optional: 'Opcional', + noMatches: 'Nenhuma correspondência', + all: 'Todos', + machine: 'máquina', + clearSearch: 'Limpar pesquisa', + refresh: 'Atualizar', + }, + + dropdown: { + category: { + general: 'Geral', + results: 'Resultados', + }, }, profile: { @@ -96,6 +114,16 @@ export const pt: TranslationStructure = { enterSecretKey: 'Por favor, insira uma chave secreta', invalidSecretKey: 'Chave secreta inválida. Verifique e tente novamente.', enterUrlManually: 'Inserir URL manualmente', + openMachine: 'Abrir máquina', + terminalUrlPlaceholder: 'happy://terminal?...', + restoreQrInstructions: '1. Abra o Happy no seu dispositivo móvel\n2. Vá em Configurações → Conta\n3. Toque em "Vincular novo dispositivo"\n4. Escaneie este código QR', + restoreWithSecretKeyInstead: 'Restaurar com chave secreta', + restoreWithSecretKeyDescription: 'Digite sua chave secreta para recuperar o acesso à sua conta.', + secretKeyPlaceholder: 'XXXXX-XXXXX-XXXXX...', + unsupported: { + connectTitle: ({ name }: { name: string }) => `Conectar ${name}`, + runCommandInTerminal: 'Execute o seguinte comando no terminal:', + }, }, settings: { @@ -136,6 +164,12 @@ export const pt: TranslationStructure = { usageSubtitle: 'Visualizar uso da API e custos', profiles: 'Perfis', profilesSubtitle: 'Gerenciar perfis de ambiente e variáveis', + secrets: 'Segredos', + secretsSubtitle: 'Gerencie os segredos salvos (não serão exibidos novamente após o envio)', + terminal: 'Terminal', + session: 'Sessão', + sessionSubtitleTmuxEnabled: 'Tmux ativado', + sessionSubtitleMessageSendingAndTmux: 'Envio de mensagens e tmux', // Dynamic settings messages accountConnected: ({ service }: { service: string }) => `Conta ${service} conectada`, @@ -173,6 +207,21 @@ export const pt: TranslationStructure = { wrapLinesInDiffsDescription: 'Quebrar linhas longas ao invés de rolagem horizontal nas visualizações de diffs', alwaysShowContextSize: 'Sempre mostrar tamanho do contexto', alwaysShowContextSizeDescription: 'Exibir uso do contexto mesmo quando não estiver próximo do limite', + agentInputActionBarLayout: 'Barra de ações do input', + agentInputActionBarLayoutDescription: 'Escolha como os chips de ação são exibidos acima do campo de entrada', + agentInputActionBarLayoutOptions: { + auto: 'Auto', + wrap: 'Quebrar linha', + scroll: 'Rolável', + collapsed: 'Recolhido', + }, + agentInputChipDensity: 'Densidade dos chips de ação', + agentInputChipDensityDescription: 'Escolha se os chips de ação exibem rótulos ou ícones', + agentInputChipDensityOptions: { + auto: 'Auto', + labels: 'Rótulos', + icons: 'Somente ícones', + }, avatarStyle: 'Estilo do avatar', avatarStyleDescription: 'Escolha a aparência do avatar da sessão', avatarOptions: { @@ -193,21 +242,52 @@ export const pt: TranslationStructure = { experimentalFeatures: 'Recursos experimentais', experimentalFeaturesEnabled: 'Recursos experimentais ativados', experimentalFeaturesDisabled: 'Usando apenas recursos estáveis', - webFeatures: 'Recursos web', - webFeaturesDescription: 'Recursos disponíveis apenas na versão web do aplicativo.', + experimentalOptions: 'Opções experimentais', + experimentalOptionsDescription: 'Escolha quais recursos experimentais estão ativados.', + expUsageReporting: 'Usage reporting', + expUsageReportingSubtitle: 'Enable usage and token reporting screens', + expFileViewer: 'File viewer', + expFileViewerSubtitle: 'Enable the session file viewer entrypoint', + expShowThinkingMessages: 'Show thinking messages', + expShowThinkingMessagesSubtitle: 'Show assistant thinking/status messages in chat', + expSessionType: 'Session type selector', + expSessionTypeSubtitle: 'Show the session type selector (simple vs worktree)', + expZen: 'Zen', + expZenSubtitle: 'Enable the Zen navigation entry', + expVoiceAuthFlow: 'Voice auth flow', + expVoiceAuthFlowSubtitle: 'Use authenticated voice token flow (paywall-aware)', + expInboxFriends: 'Caixa de entrada e amigos', + expInboxFriendsSubtitle: 'Ativar a aba Caixa de entrada e os recursos de amigos', + expCodexResume: 'Retomar Codex', + expCodexResumeSubtitle: 'Permite retomar sessões do Codex usando uma instalação separada (experimental)', + expCodexAcp: 'Codex ACP', + expCodexAcpSubtitle: 'Usar Codex via ACP (codex-acp) em vez de MCP (experimental)', + webFeatures: 'Recursos web', + webFeaturesDescription: 'Recursos disponíveis apenas na versão web do aplicativo.', enterToSend: 'Enter para enviar', enterToSendEnabled: 'Pressione Enter para enviar (Shift+Enter para nova linha)', enterToSendDisabled: 'Enter insere uma nova linha', commandPalette: 'Paleta de comandos', commandPaletteEnabled: 'Pressione ⌘K para abrir', commandPaletteDisabled: 'Acesso rápido a comandos desativado', - markdownCopyV2: 'Markdown Copy v2', + markdownCopyV2: 'Cópia de Markdown v2', markdownCopyV2Subtitle: 'Pressione e segure para abrir modal de cópia', hideInactiveSessions: 'Ocultar sessões inativas', hideInactiveSessionsSubtitle: 'Mostre apenas os chats ativos na sua lista', + groupInactiveSessionsByProject: 'Agrupar sessões inativas por projeto', + groupInactiveSessionsByProjectSubtitle: 'Organize os chats inativos por projeto', enhancedSessionWizard: 'Assistente de sessão aprimorado', enhancedSessionWizardEnabled: 'Lançador de sessão com perfil ativo', enhancedSessionWizardDisabled: 'Usando o lançador de sessão padrão', + profiles: 'Perfis de IA', + profilesEnabled: 'Seleção de perfis ativada', + profilesDisabled: 'Seleção de perfis desativada', + pickerSearch: 'Busca nos seletores', + pickerSearchSubtitle: 'Mostrar um campo de busca nos seletores de máquina e caminho', + machinePickerSearch: 'Busca de máquinas', + machinePickerSearchSubtitle: 'Mostrar um campo de busca nos seletores de máquinas', + pathPickerSearch: 'Busca de caminhos', + pathPickerSearchSubtitle: 'Mostrar um campo de busca nos seletores de caminhos', }, errors: { @@ -255,11 +335,85 @@ export const pt: TranslationStructure = { failedToRemoveFriend: 'Falha ao remover amigo', searchFailed: 'A busca falhou. Por favor, tente novamente.', failedToSendRequest: 'Falha ao enviar solicitação de amizade', + failedToResumeSession: 'Falha ao retomar a sessão', + failedToSendMessage: 'Falha ao enviar a mensagem', + cannotShareWithSelf: 'Não é possível compartilhar consigo mesmo', + canOnlyShareWithFriends: 'Só é possível compartilhar com amigos', + shareNotFound: 'Compartilhamento não encontrado', + publicShareNotFound: 'Link público não encontrado ou expirado', + consentRequired: 'Consentimento necessário para acesso', + maxUsesReached: 'Máximo de usos atingido', + invalidShareLink: 'Link de compartilhamento inválido ou expirado', + missingPermissionId: 'Falta o id de permissão', + codexResumeNotInstalledTitle: 'O Codex resume não está instalado nesta máquina', + codexResumeNotInstalledMessage: + 'Para retomar uma conversa do Codex, instale o servidor de retomada do Codex na máquina de destino (Detalhes da máquina → Retomada do Codex).', + codexAcpNotInstalledTitle: 'O Codex ACP não está instalado nesta máquina', + codexAcpNotInstalledMessage: + 'Para usar o experimento Codex ACP, instale o codex-acp na máquina de destino (Detalhes da máquina → Codex ACP) ou desative o experimento.', + }, + + deps: { + installNotSupported: 'Atualize o Happy CLI para instalar esta dependência.', + installFailed: 'Falha na instalação', + installed: 'Instalado', + installLog: ({ path }: { path: string }) => `Log de instalação: ${path}`, + installable: { + codexResume: { + title: 'Servidor de retomada do Codex', + installSpecTitle: 'Fonte de instalação do Codex resume', + }, + codexAcp: { + title: 'Adaptador Codex ACP', + installSpecTitle: 'Fonte de instalação do Codex ACP', + }, + installSpecDescription: 'Especificação NPM/Git/arquivo passada para `npm install` (experimental). Deixe em branco para usar o padrão do daemon.', + }, + ui: { + notAvailable: 'Indisponível', + notAvailableUpdateCli: 'Indisponível (atualize o CLI)', + errorRefresh: 'Erro (atualizar)', + installed: 'Instalado', + installedWithVersion: ({ version }: { version: string }) => `Instalado (v${version})`, + installedUpdateAvailable: ({ installedVersion, latestVersion }: { installedVersion: string; latestVersion: string }) => + `Instalado (v${installedVersion}) — atualização disponível (v${latestVersion})`, + notInstalled: 'Não instalado', + latest: 'Última', + latestSubtitle: ({ version, tag }: { version: string; tag: string }) => `${version} (tag: ${tag})`, + registryCheck: 'Verificação do registro', + registryCheckFailed: ({ error }: { error: string }) => `Falhou: ${error}`, + installSource: 'Fonte de instalação', + installSourceDefault: '(padrão)', + installSpecPlaceholder: 'ex.: file:/caminho/para/pkg ou github:owner/repo#branch', + lastInstallLog: 'Último log de instalação', + installLogTitle: 'Log de instalação', + }, }, newSession: { // Used by new-session screen and launch flows title: 'Iniciar nova sessão', + selectAiProfileTitle: 'Selecionar perfil de IA', + selectAiProfileDescription: 'Selecione um perfil de IA para aplicar variáveis de ambiente e padrões à sua sessão.', + changeProfile: 'Trocar perfil', + aiBackendSelectedByProfile: 'O backend de IA é selecionado pelo seu perfil. Para alterar, selecione um perfil diferente.', + selectAiBackendTitle: 'Selecionar backend de IA', + aiBackendLimitedByProfileAndMachineClis: 'Limitado pelo perfil selecionado e pelos CLIs disponíveis nesta máquina.', + aiBackendSelectWhichAiRuns: 'Selecione qual IA roda sua sessão.', + aiBackendNotCompatibleWithSelectedProfile: 'Não compatível com o perfil selecionado.', + aiBackendCliNotDetectedOnMachine: ({ cli }: { cli: string }) => `CLI do ${cli} não detectado nesta máquina.`, + selectMachineTitle: 'Selecionar máquina', + selectMachineDescription: 'Escolha onde esta sessão será executada.', + selectPathTitle: 'Selecionar caminho', + selectWorkingDirectoryTitle: 'Selecionar diretório de trabalho', + selectWorkingDirectoryDescription: 'Escolha a pasta usada para comandos e contexto.', + selectPermissionModeTitle: 'Selecionar modo de permissões', + selectPermissionModeDescription: 'Controle o quão estritamente as ações exigem aprovação.', + selectModelTitle: 'Selecionar modelo de IA', + selectModelDescription: 'Escolha o modelo usado por esta sessão.', + selectSessionTypeTitle: 'Selecionar tipo de sessão', + selectSessionTypeDescription: 'Escolha uma sessão simples ou uma vinculada a um worktree do Git.', + searchPathsPlaceholder: 'Pesquisar caminhos...', noMachinesFound: 'Nenhuma máquina encontrada. Inicie uma sessão Happy no seu computador primeiro.', allMachinesOffline: 'Todas as máquinas estão offline', machineDetails: 'Ver detalhes da máquina →', @@ -275,18 +429,94 @@ export const pt: TranslationStructure = { startNewSessionInFolder: 'Nova sessão aqui', noMachineSelected: 'Por favor, selecione uma máquina para iniciar a sessão', noPathSelected: 'Por favor, selecione um diretório para iniciar a sessão', + machinePicker: { + searchPlaceholder: 'Pesquisar máquinas...', + recentTitle: 'Recentes', + favoritesTitle: 'Favoritos', + allTitle: 'Todas', + emptyMessage: 'Nenhuma máquina disponível', + }, + pathPicker: { + enterPathTitle: 'Inserir caminho', + enterPathPlaceholder: 'Insira um caminho...', + customPathTitle: 'Caminho personalizado', + recentTitle: 'Recentes', + favoritesTitle: 'Favoritos', + suggestedTitle: 'Sugeridos', + allTitle: 'Todas', + emptyRecent: 'Nenhum caminho recente', + emptyFavorites: 'Nenhum caminho favorito', + emptySuggested: 'Nenhum caminho sugerido', + emptyAll: 'Nenhum caminho', + }, sessionType: { title: 'Tipo de sessão', simple: 'Simples', - worktree: 'Worktree', + worktree: 'Árvore de trabalho', comingSoon: 'Em breve', }, + profileAvailability: { + requiresAgent: ({ agent }: { agent: string }) => `Requer ${agent}`, + cliNotDetected: ({ cli }: { cli: string }) => `CLI do ${cli} não detectado`, + }, + cliBanners: { + cliNotDetectedTitle: ({ cli }: { cli: string }) => `CLI do ${cli} não detectado`, + dontShowFor: 'Não mostrar este aviso para', + thisMachine: 'esta máquina', + anyMachine: 'qualquer máquina', + installCommand: ({ command }: { command: string }) => `Instalar: ${command} •`, + installCliIfAvailable: ({ cli }: { cli: string }) => `Instale o CLI do ${cli} se disponível •`, + viewInstallationGuide: 'Ver guia de instalação →', + viewGeminiDocs: 'Ver docs do Gemini →', + }, worktree: { creating: ({ name }: { name: string }) => `Criando worktree '${name}'...`, notGitRepo: 'Worktrees requerem um repositório git', failed: ({ error }: { error: string }) => `Falha ao criar worktree: ${error}`, success: 'Worktree criado com sucesso', - } + }, + resume: { + title: 'Retomar sessão', + optional: 'Retomar: Opcional', + pickerTitle: 'Retomar sessão', + subtitle: ({ agent }: { agent: string }) => `Cole um ID de sessão do ${agent} para retomar`, + placeholder: ({ agent }: { agent: string }) => `Cole o ID de sessão do ${agent}…`, + paste: 'Colar', + save: 'Salvar', + clearAndRemove: 'Limpar', + helpText: 'Você pode encontrar os IDs de sessão na tela de informações da sessão.', + cannotApplyBody: 'Este ID de retomada não pode ser aplicado agora. O Happy iniciará uma nova sessão em vez disso.', + }, + codexResumeBanner: { + title: 'Codex resume', + updateAvailable: 'Atualização disponível', + systemCodexVersion: ({ version }: { version: string }) => `Codex do sistema: ${version}`, + resumeServerVersion: ({ version }: { version: string }) => `Servidor do Codex resume: ${version}`, + notInstalled: 'não instalado', + latestVersion: ({ version }: { version: string }) => `(mais recente ${version})`, + registryCheckFailed: ({ error }: { error: string }) => `Falha na verificação do registro: ${error}`, + install: 'Instalar', + update: 'Atualizar', + reinstall: 'Reinstalar', + }, + codexResumeInstallModal: { + installTitle: 'Instalar Codex resume?', + updateTitle: 'Atualizar Codex resume?', + reinstallTitle: 'Reinstalar Codex resume?', + description: 'Isso instala um wrapper experimental de servidor MCP do Codex usado apenas para operações de retomada.', + }, + codexAcpBanner: { + title: 'Codex ACP', + install: 'Instalar', + update: 'Atualizar', + reinstall: 'Reinstalar', + }, + codexAcpInstallModal: { + installTitle: 'Instalar Codex ACP?', + updateTitle: 'Atualizar Codex ACP?', + reinstallTitle: 'Reinstalar Codex ACP?', + description: 'Isso instala um adaptador ACP experimental em torno do Codex que oferece suporte a carregar/retomar threads.', + }, }, sessionHistory: { @@ -301,10 +531,99 @@ export const pt: TranslationStructure = { session: { inputPlaceholder: 'Digite uma mensagem ...', + resuming: 'Retomando...', + resumeFailed: 'Falha ao retomar a sessão', + resumeSupportNoteChecking: 'Nota: o Happy ainda está verificando se esta máquina pode retomar a sessão do provedor.', + resumeSupportNoteUnverified: 'Nota: o Happy não conseguiu verificar o suporte de retomada para esta máquina.', + resumeSupportDetails: { + cliNotDetected: 'CLI não detectado na máquina.', + capabilityProbeFailed: 'Falha na verificação de capacidades.', + acpProbeFailed: 'Falha na verificação ACP.', + loadSessionFalse: 'O agente não oferece suporte para carregar sessões.', + }, + inactiveResumable: 'Inativa (retomável)', + inactiveMachineOffline: 'Inativa (máquina offline)', + inactiveNotResumable: 'Inativa', + inactiveNotResumableNoticeTitle: 'Esta sessão não pode ser retomada', + inactiveNotResumableNoticeBody: ({ provider }: { provider: string }) => + `Esta sessão terminou e não pode ser retomada porque ${provider} não oferece suporte para restaurar o contexto aqui. Inicie uma nova sessão para continuar.`, + machineOfflineNoticeTitle: 'A máquina está offline', + machineOfflineNoticeBody: ({ machine }: { machine: string }) => + `“${machine}” está offline, então o Happy ainda não consegue retomar esta sessão. Traga a máquina de volta online para continuar.`, + machineOfflineCannotResume: 'A máquina está offline. Traga-a de volta online para retomar esta sessão.', + + sharing: { + title: 'Compartilhamento', + directSharing: 'Compartilhamento direto', + addShare: 'Compartilhar com um amigo', + accessLevel: 'Nível de acesso', + shareWith: 'Compartilhar com', + sharedWith: 'Compartilhado com', + noShares: 'Não compartilhado', + viewOnly: 'Somente visualizar', + viewOnlyDescription: 'Pode ver a sessão, mas não enviar mensagens.', + viewOnlyMode: 'Somente visualização (sessão compartilhada)', + noEditPermission: 'Você tem acesso somente leitura a esta sessão.', + canEdit: 'Pode editar', + canEditDescription: 'Pode enviar mensagens.', + canManage: 'Pode gerenciar', + canManageDescription: 'Pode gerenciar o compartilhamento.', + stopSharing: 'Parar de compartilhar', + recipientMissingKeys: 'Este usuário ainda não registrou chaves de criptografia.', + + publicLink: 'Link público', + publicLinkActive: 'Link público ativo', + publicLinkDescription: 'Crie um link para que qualquer pessoa possa ver esta sessão.', + createPublicLink: 'Criar link público', + regeneratePublicLink: 'Regenerar link público', + deletePublicLink: 'Excluir link público', + linkToken: 'Token do link', + tokenNotRecoverable: 'Token indisponível', + tokenNotRecoverableDescription: 'Por segurança, tokens de link público são armazenados como hash e não podem ser recuperados. Regere o link para criar um novo token.', + + expiresIn: 'Expira em', + expiresOn: 'Expira em', + days7: '7 dias', + days30: '30 dias', + never: 'Nunca', + + maxUsesLabel: 'Máximo de usos', + unlimited: 'Ilimitado', + uses10: '10 usos', + uses50: '50 usos', + usageCount: 'Contagem de usos', + usageCountWithMax: ({ used, max }: { used: number; max: number }) => `${used}/${max} usos`, + usageCountUnlimited: ({ used }: { used: number }) => `${used} usos`, + + requireConsent: 'Exigir consentimento', + requireConsentDescription: 'Peça consentimento antes de registrar o acesso.', + consentRequired: 'Consentimento exigido', + consentDescription: 'Este link exige seu consentimento para registrar seu IP e agente de usuário.', + acceptAndView: 'Aceitar e visualizar', + sharedBy: ({ name }: { name: string }) => `Compartilhado por ${name}`, + + shareNotFound: 'Link de compartilhamento não encontrado ou expirado', + failedToDecrypt: 'Falha ao descriptografar a sessão', + noMessages: 'Ainda não há mensagens', + session: 'Sessão', + }, }, commandPalette: { placeholder: 'Digite um comando ou pesquise...', + noCommandsFound: 'Nenhum comando encontrado', + }, + + commandView: { + completedWithNoOutput: '[Comando concluído sem saída]', + }, + + voiceAssistant: { + connecting: 'Conectando...', + active: 'Assistente de voz ativo', + connectionError: 'Erro de conexão', + label: 'Assistente de voz', + tapToEnd: 'Toque para encerrar', }, server: { @@ -336,8 +655,18 @@ export const pt: TranslationStructure = { happySessionId: 'ID da sessão Happy', claudeCodeSessionId: 'ID da sessão Claude Code', claudeCodeSessionIdCopied: 'ID da sessão Claude Code copiado para a área de transferência', + aiProfile: 'Perfil de IA', aiProvider: 'Provedor de IA', failedToCopyClaudeCodeSessionId: 'Falha ao copiar ID da sessão Claude Code', + codexSessionId: 'ID da sessão Codex', + codexSessionIdCopied: 'ID da sessão Codex copiado para a área de transferência', + failedToCopyCodexSessionId: 'Falha ao copiar ID da sessão Codex', + opencodeSessionId: 'ID da sessão OpenCode', + opencodeSessionIdCopied: 'ID da sessão OpenCode copiado para a área de transferência', + auggieSessionId: 'ID da sessão Auggie', + auggieSessionIdCopied: 'ID da sessão Auggie copiado para a área de transferência', + geminiSessionId: 'ID da sessão Gemini', + geminiSessionIdCopied: 'ID da sessão Gemini copiado para a área de transferência', metadataCopied: 'Metadados copiados para a área de transferência', failedToCopyMetadata: 'Falha ao copiar metadados', failedToKillSession: 'Falha ao encerrar sessão', @@ -347,6 +676,7 @@ export const pt: TranslationStructure = { lastUpdated: 'Última atualização', sequence: 'Sequência', quickActions: 'Ações rápidas', + copyResumeCommand: 'Copiar comando de retomada', viewMachine: 'Ver máquina', viewMachineSubtitle: 'Ver detalhes da máquina e sessões', killSessionSubtitle: 'Encerrar imediatamente a sessão', @@ -357,8 +687,14 @@ export const pt: TranslationStructure = { operatingSystem: 'Sistema operacional', processId: 'ID do processo', happyHome: 'Diretório Happy', + attachFromTerminal: 'Anexar pelo terminal', + tmuxTarget: 'Alvo do tmux', + tmuxFallback: 'Fallback do tmux', copyMetadata: 'Copiar metadados', agentState: 'Estado do agente', + rawJsonDevMode: 'JSON bruto (modo dev)', + sessionStatus: 'Status da sessão', + fullSessionObject: 'Objeto completo da sessão', controlledByUser: 'Controlado pelo usuário', pendingRequests: 'Solicitações pendentes', activity: 'Atividade', @@ -375,7 +711,14 @@ export const pt: TranslationStructure = { deleteSessionWarning: 'Esta ação não pode ser desfeita. Todas as mensagens e dados associados a esta sessão serão excluídos permanentemente.', failedToDeleteSession: 'Falha ao excluir sessão', sessionDeleted: 'Sessão excluída com sucesso', - + manageSharing: 'Gerenciar compartilhamento', + manageSharingSubtitle: 'Compartilhe esta sessão com amigos ou crie um link público', + renameSession: 'Renomear Sessão', + renameSessionSubtitle: 'Alterar o nome de exibição desta sessão', + renameSessionPlaceholder: 'Digite o nome da sessão...', + failedToRenameSession: 'Falha ao renomear sessão', + sessionRenamed: 'Sessão renomeada com sucesso', + }, components: { @@ -386,16 +729,57 @@ export const pt: TranslationStructure = { runIt: 'Execute', scanQrCode: 'Escaneie o código QR', openCamera: 'Abrir câmera', + installCommand: '$ npm i -g happy-coder', + runCommand: '$ happy', + }, + emptyMessages: { + noMessagesYet: 'Nenhuma mensagem ainda', + created: ({ time }: { time: string }) => `Criado ${time}`, + }, + emptySessionsTablet: { + noActiveSessions: 'Nenhuma sessão ativa', + startNewSessionDescription: 'Inicie uma nova sessão em qualquer uma das suas máquinas conectadas.', + startNewSessionButton: 'Iniciar nova sessão', + openTerminalToStart: 'Abra um novo terminal no computador para iniciar uma sessão.', + }, + }, + + zen: { + title: 'Zen', + add: { + placeholder: 'O que precisa ser feito?', + }, + home: { + noTasksYet: 'Ainda não há tarefas. Toque em + para adicionar.', + }, + view: { + workOnTask: 'Trabalhar na tarefa', + clarify: 'Esclarecer', + delete: 'Excluir', + linkedSessions: 'Sessões vinculadas', + tapTaskTextToEdit: 'Toque no texto da tarefa para editar', }, }, agentInput: { + envVars: { + title: 'Vars env', + titleWithCount: ({ count }: { count: number }) => `Vars env (${count})`, + }, + resumeChip: { + withId: ({ title, id }: { title: string; id: string }) => `${title}: ${id}`, + withIdTruncated: ({ title, prefix, suffix }: { title: string; prefix: string; suffix: string }) => + `${title}: ${prefix}…${suffix}`, + }, permissionMode: { title: 'MODO DE PERMISSÃO', default: 'Padrão', acceptEdits: 'Aceitar edições', plan: 'Modo de planejamento', bypassPermissions: 'Modo Yolo', + badgeAccept: 'Aceitar', + badgePlan: 'Plano', + badgeYolo: 'YOLO', badgeAcceptAllEdits: 'Aceitar todas as edições', badgeBypassAllPermissions: 'Ignorar todas as permissões', badgePlanMode: 'Modo de planejamento', @@ -403,7 +787,13 @@ export const pt: TranslationStructure = { agent: { claude: 'Claude', codex: 'Codex', + opencode: 'OpenCode', gemini: 'Gemini', + auggie: 'Auggie', + }, + auggieIndexingChip: { + on: 'Indexing on', + off: 'Indexing off', }, model: { title: 'MODELO', @@ -412,22 +802,22 @@ export const pt: TranslationStructure = { codexPermissionMode: { title: 'MODO DE PERMISSÃO CODEX', default: 'Configurações do CLI', - readOnly: 'Read Only Mode', - safeYolo: 'Safe YOLO', + readOnly: 'Modo somente leitura', + safeYolo: 'YOLO seguro', yolo: 'YOLO', - badgeReadOnly: 'Read Only Mode', - badgeSafeYolo: 'Safe YOLO', + badgeReadOnly: 'Somente leitura', + badgeSafeYolo: 'YOLO seguro', badgeYolo: 'YOLO', }, codexModel: { - title: 'CODEX MODEL', - gpt5CodexLow: 'gpt-5-codex low', - gpt5CodexMedium: 'gpt-5-codex medium', - gpt5CodexHigh: 'gpt-5-codex high', - gpt5Minimal: 'GPT-5 Minimal', - gpt5Low: 'GPT-5 Low', - gpt5Medium: 'GPT-5 Medium', - gpt5High: 'GPT-5 High', + title: 'MODELO CODEX', + gpt5CodexLow: 'gpt-5-codex baixo', + gpt5CodexMedium: 'gpt-5-codex médio', + gpt5CodexHigh: 'gpt-5-codex alto', + gpt5Minimal: 'GPT-5 Mínimo', + gpt5Low: 'GPT-5 Baixo', + gpt5Medium: 'GPT-5 Médio', + gpt5High: 'GPT-5 Alto', }, geminiPermissionMode: { title: 'MODO DE PERMISSÃO GEMINI', @@ -439,6 +829,21 @@ export const pt: TranslationStructure = { badgeSafeYolo: 'YOLO seguro', badgeYolo: 'YOLO', }, + geminiModel: { + title: 'MODELO GEMINI', + gemini25Pro: { + label: 'Gemini 2.5 Pro', + description: 'Mais capaz', + }, + gemini25Flash: { + label: 'Gemini 2.5 Flash', + description: 'Rápido e eficiente', + }, + gemini25FlashLite: { + label: 'Gemini 2.5 Flash Lite', + description: 'Mais rápido', + }, + }, context: { remaining: ({ percent }: { percent: number }) => `${percent}% restante`, }, @@ -446,6 +851,11 @@ export const pt: TranslationStructure = { fileLabel: 'ARQUIVO', folderLabel: 'PASTA', }, + actionMenu: { + title: 'AÇÕES', + files: 'Arquivos', + stop: 'Parar', + }, noMachinesAvailable: 'Sem máquinas', }, @@ -504,6 +914,10 @@ export const pt: TranslationStructure = { applyChanges: 'Atualizar arquivo', viewDiff: 'Alterações do arquivo atual', question: 'Pergunta', + changeTitle: 'Alterar título', + }, + geminiExecute: { + cwd: ({ cwd }: { cwd: string }) => `📁 ${cwd}`, }, desc: { terminalCmd: ({ cmd }: { cmd: string }) => `Terminal(cmd: ${cmd})`, @@ -525,7 +939,19 @@ export const pt: TranslationStructure = { askUserQuestion: { submit: 'Enviar resposta', multipleQuestions: ({ count }: { count: number }) => `${count} ${plural({ count, singular: 'pergunta', plural: 'perguntas' })}`, - } + }, + exitPlanMode: { + approve: 'Aprovar plano', + reject: 'Rejeitar', + requestChanges: 'Solicitar alterações', + requestChangesPlaceholder: 'Diga ao Claude o que você quer mudar neste plano…', + requestChangesSend: 'Enviar feedback', + requestChangesEmpty: 'Escreva o que você quer mudar.', + requestChangesFailed: 'Falha ao solicitar alterações. Tente novamente.', + responded: 'Resposta enviada', + approvalMessage: 'Aprovo este plano. Por favor, prossiga com a implementação.', + rejectionMessage: 'Não aprovo este plano. Por favor, revise-o ou pergunte quais alterações eu gostaria.', + }, }, files: { @@ -546,7 +972,7 @@ export const pt: TranslationStructure = { loadingFile: ({ fileName }: { fileName: string }) => `Carregando ${fileName}...`, binaryFile: 'Arquivo binário', cannotDisplayBinary: 'Não é possível exibir o conteúdo do arquivo binário', - diff: 'Diff', + diff: 'Diferenças', file: 'Arquivo', fileEmpty: 'Arquivo está vazio', noChanges: 'Nenhuma alteração para exibir', @@ -666,6 +1092,11 @@ export const pt: TranslationStructure = { deviceLinkedSuccessfully: 'Dispositivo vinculado com sucesso', terminalConnectedSuccessfully: 'Terminal conectado com sucesso', invalidAuthUrl: 'URL de autenticação inválida', + microphoneAccessRequiredTitle: 'É necessário acesso ao microfone', + microphoneAccessRequiredRequestPermission: 'O Happy precisa de acesso ao seu microfone para o chat por voz. Conceda a permissão quando solicitado.', + microphoneAccessRequiredEnableInSettings: 'O Happy precisa de acesso ao seu microfone para o chat por voz. Ative o acesso ao microfone nas configurações do seu dispositivo.', + microphoneAccessRequiredBrowserInstructions: 'Permita o acesso ao microfone nas configurações do navegador. Talvez seja necessário clicar no ícone de cadeado na barra de endereços e habilitar a permissão do microfone para este site.', + openSettings: 'Abrir configurações', developerMode: 'Modo desenvolvedor', developerModeEnabled: 'Modo desenvolvedor ativado', developerModeDisabled: 'Modo desenvolvedor desativado', @@ -720,6 +1151,15 @@ export const pt: TranslationStructure = { daemon: 'Daemon', status: 'Status', stopDaemon: 'Parar daemon', + stopDaemonConfirmTitle: 'Parar daemon?', + stopDaemonConfirmBody: 'Você não poderá iniciar novas sessões nesta máquina até reiniciar o daemon no seu computador. Suas sessões atuais continuarão ativas.', + daemonStoppedTitle: 'Daemon parado', + stopDaemonFailed: 'Falha ao parar o daemon. Talvez ele não esteja em execução.', + renameTitle: 'Renomear máquina', + renameDescription: 'Dê a esta máquina um nome personalizado. Deixe em branco para usar o hostname padrão.', + renamePlaceholder: 'Digite o nome da máquina', + renamedSuccess: 'Máquina renomeada com sucesso', + renameFailed: 'Falha ao renomear a máquina', lastKnownPid: 'Último PID conhecido', lastKnownHttpPort: 'Última porta HTTP conhecida', startedAt: 'Iniciado em', @@ -736,20 +1176,40 @@ export const pt: TranslationStructure = { lastSeen: 'Visto pela última vez', never: 'Nunca', metadataVersion: 'Versão dos metadados', + detectedClis: 'CLIs detectados', + detectedCliNotDetected: 'Não detectado', + detectedCliUnknown: 'Desconhecido', + detectedCliNotSupported: 'Não suportado (atualize o happy-cli)', untitledSession: 'Sessão sem título', back: 'Voltar', + notFound: 'Máquina não encontrada', + unknownMachine: 'máquina desconhecida', + unknownPath: 'caminho desconhecido', + tmux: { + overrideTitle: 'Substituir configurações globais do tmux', + overrideEnabledSubtitle: 'As configurações personalizadas do tmux se aplicam a novas sessões nesta máquina.', + overrideDisabledSubtitle: 'Novas sessões usam as configurações globais do tmux.', + notDetectedSubtitle: 'tmux não foi detectado nesta máquina.', + notDetectedMessage: 'tmux não foi detectado nesta máquina. Instale o tmux e atualize a detecção.', + }, }, message: { switchedToMode: ({ mode }: { mode: string }) => `Mudou para o modo ${mode}`, + discarded: 'Descartado', unknownEvent: 'Evento desconhecido', usageLimitUntil: ({ time }: { time: string }) => `Limite de uso atingido até ${time}`, unknownTime: 'horário desconhecido', }, + chatFooter: { + permissionsTerminalOnly: 'As permissões são mostradas apenas no terminal. Redefina ou envie uma mensagem para controlar pelo app.', + }, + codex: { // Codex permission dialog buttons permissions: { + yesAlwaysAllowCommand: 'Sim, permitir globalmente', yesForSession: 'Sim, e não perguntar para esta sessão', stopAndExplain: 'Parar, e explicar o que fazer', } @@ -760,6 +1220,9 @@ export const pt: TranslationStructure = { permissions: { yesAllowAllEdits: 'Sim, permitir todas as edições durante esta sessão', yesForTool: 'Sim, não perguntar novamente para esta ferramenta', + yesForCommandPrefix: 'Sim, não perguntar novamente para este prefixo de comando', + yesForSubcommand: 'Sim, não perguntar novamente para este subcomando', + yesForCommandName: 'Sim, não perguntar novamente para este comando', noTellClaude: 'Não, fornecer feedback', } }, @@ -773,6 +1236,7 @@ export const pt: TranslationStructure = { textCopied: 'Texto copiado para a área de transferência', failedToCopy: 'Falha ao copiar o texto para a área de transferência', noTextToCopy: 'Nenhum texto disponível para copiar', + failedToOpen: 'Falha ao abrir a seleção de texto. Tente novamente.', }, markdown: { @@ -792,11 +1256,14 @@ export const pt: TranslationStructure = { edit: 'Editar artefato', delete: 'Excluir', updateError: 'Falha ao atualizar artefato. Por favor, tente novamente.', + deleteError: 'Falha ao excluir o artefato. Tente novamente.', notFound: 'Artefato não encontrado', discardChanges: 'Descartar alterações?', discardChangesDescription: 'Você tem alterações não salvas. Tem certeza de que deseja descartá-las?', deleteConfirm: 'Excluir artefato?', deleteConfirmDescription: 'Este artefato será excluído permanentemente.', + noContent: 'Sem conteúdo', + untitled: 'Sem título', titlePlaceholder: 'Título do artefato', bodyPlaceholder: 'Digite o conteúdo aqui...', save: 'Salvar', @@ -812,6 +1279,8 @@ export const pt: TranslationStructure = { friends: { // Friends feature title: 'Amigos', + sharedSessions: 'Sessões compartilhadas', + noSharedSessions: 'Ainda não há sessões compartilhadas', manageFriends: 'Gerencie seus amigos e conexões', searchTitle: 'Buscar amigos', pendingRequests: 'Solicitações de amizade', @@ -877,6 +1346,8 @@ export const pt: TranslationStructure = { profiles: { title: 'Perfis', subtitle: 'Gerencie seus perfis de configuração', + sessionUses: ({ profile }: { profile: string }) => `Esta sessão usa: ${profile}`, + profilesFixedPerSession: 'Os perfis são fixos por sessão. Para usar um perfil diferente, inicie uma nova sessão.', noProfile: 'Nenhum perfil', noProfileDescription: 'Crie um perfil para gerenciar sua configuração de ambiente', addProfile: 'Adicionar perfil', @@ -894,8 +1365,231 @@ export const pt: TranslationStructure = { tmuxTempDir: 'Diretório temporário tmux', enterTmuxTempDir: 'Digite o diretório temporário tmux', tmuxUpdateEnvironment: 'Atualizar ambiente tmux', - deleteConfirm: 'Tem certeza de que deseja excluir este perfil?', + deleteConfirm: ({ name }: { name: string }) => `Tem certeza de que deseja excluir o perfil "${name}"?`, nameRequired: 'O nome do perfil é obrigatório', + builtIn: 'Integrado', + custom: 'Personalizado', + builtInSaveAsHint: 'Salvar um perfil integrado cria um novo perfil personalizado.', + builtInNames: { + anthropic: 'Anthropic (Padrão)', + deepseek: 'DeepSeek (Raciocínio)', + zai: 'Z.AI (GLM-4.6)', + codex: 'Codex (Padrão)', + openai: 'OpenAI (GPT-5)', + azureOpenai: 'Azure OpenAI', + gemini: 'Gemini (Padrão)', + geminiApiKey: 'Gemini (Chave de API)', + geminiVertex: 'Gemini (Vertex AI)', + }, + groups: { + favorites: 'Favoritos', + custom: 'Seus perfis', + builtIn: 'Perfis integrados', + }, + actions: { + viewEnvironmentVariables: 'Variáveis de ambiente', + addToFavorites: 'Adicionar aos favoritos', + removeFromFavorites: 'Remover dos favoritos', + editProfile: 'Editar perfil', + duplicateProfile: 'Duplicar perfil', + deleteProfile: 'Excluir perfil', + }, + copySuffix: '(Cópia)', + duplicateName: 'Já existe um perfil com este nome', + setupInstructions: { + title: 'Instruções de configuração', + viewOfficialGuide: 'Ver guia oficial de configuração', + }, + machineLogin: { + title: 'Login necessário na máquina', + subtitle: 'Este perfil depende do cache de login do CLI na máquina selecionada.', + status: { + loggedIn: 'Logado', + notLoggedIn: 'Não logado', + }, + claudeCode: { + title: 'Claude Code', + instructions: 'Execute `claude` e depois digite `/login` para entrar.', + warning: 'Obs.: definir `ANTHROPIC_AUTH_TOKEN` substitui o login do CLI.', + }, + codex: { + title: 'Codex', + instructions: 'Execute `codex login` para entrar.', + }, + geminiCli: { + title: 'Gemini CLI', + instructions: 'Execute `gemini auth` para entrar.', + }, + }, + requirements: { + secretRequired: 'Segredo', + configured: 'Configurada na máquina', + notConfigured: 'Não configurada', + checking: 'Verificando…', + missingConfigForProfile: ({ env }: { env: string }) => `Este perfil requer que ${env} esteja configurado na máquina.`, + modalTitle: 'Segredo necessário', + modalBody: 'Este perfil requer um segredo.\n\nOpções disponíveis:\n• Usar ambiente da máquina (recomendado)\n• Usar um segredo salvo nas configurações do app\n• Inserir um segredo apenas para esta sessão', + sectionTitle: 'Requisitos', + sectionSubtitle: 'Estes campos são usados para checar a prontidão e evitar falhas inesperadas.', + secretEnvVarPromptDescription: 'Digite o nome da variável de ambiente secreta necessária (ex.: OPENAI_API_KEY).', + modalHelpWithEnv: ({ env }: { env: string }) => `Este perfil precisa de ${env}. Escolha uma opção abaixo.`, + modalHelpGeneric: 'Este perfil precisa de um segredo. Escolha uma opção abaixo.', + chooseOptionTitle: 'Escolha uma opção', + machineEnvStatus: { + theMachine: 'a máquina', + checkFor: ({ env }: { env: string }) => `Verificar ${env}`, + checking: ({ env }: { env: string }) => `Verificando ${env}…`, + found: ({ env, machine }: { env: string; machine: string }) => `${env} encontrado em ${machine}`, + notFound: ({ env, machine }: { env: string; machine: string }) => `${env} não encontrado em ${machine}`, + }, + machineEnvSubtitle: { + checking: 'Verificando ambiente do daemon…', + found: 'Encontrado no ambiente do daemon na máquina.', + notFound: 'Defina no ambiente do daemon na máquina e reinicie o daemon.', + }, + options: { + none: { + title: 'Nenhum', + subtitle: 'Não requer segredo nem login via CLI.', + }, + machineLogin: { + subtitle: 'Requer estar logado via um CLI na máquina de destino.', + longSubtitle: 'Requer estar logado via o CLI do backend de IA escolhido na máquina de destino.', + }, + useMachineEnvironment: { + title: 'Usar ambiente da máquina', + subtitleWithEnv: ({ env }: { env: string }) => `Usar ${env} do ambiente do daemon.`, + subtitleGeneric: 'Usar o segredo do ambiente do daemon.', + }, + useSavedSecret: { + title: 'Usar um segredo salvo', + subtitle: 'Selecione (ou adicione) um segredo salvo no app.', + }, + enterOnce: { + title: 'Inserir um segredo', + subtitle: 'Cole um segredo apenas para esta sessão (não será salvo).', + }, + }, + secretEnvVar: { + title: 'Variável de ambiente do segredo', + subtitle: 'Digite o nome da variável de ambiente que este provedor espera para o segredo (ex.: OPENAI_API_KEY).', + label: 'Nome da variável de ambiente', + }, + sections: { + machineEnvironment: 'Ambiente da máquina', + useOnceTitle: 'Usar uma vez', + useOnceLabel: 'Insira um segredo', + useOnceFooter: 'Cole um segredo apenas para esta sessão. Ele não será salvo.', + }, + actions: { + useMachineEnvironment: { + subtitle: 'Começar com a chave já presente na máquina.', + }, + useOnceButton: 'Usar uma vez (apenas sessão)', + }, + }, + defaultSessionType: 'Tipo de sessão padrão', + defaultPermissionMode: { + title: 'Modo de permissão padrão', + descriptions: { + default: 'Solicitar permissões', + acceptEdits: 'Aprovar edições automaticamente', + plan: 'Planejar antes de executar', + bypassPermissions: 'Ignorar todas as permissões', + }, + }, + aiBackend: { + title: 'Backend de IA', + selectAtLeastOneError: 'Selecione pelo menos um backend de IA.', + claudeSubtitle: 'CLI do Claude', + codexSubtitle: 'CLI do Codex', + opencodeSubtitle: 'CLI do OpenCode', + geminiSubtitleExperimental: 'CLI do Gemini (experimental)', + auggieSubtitle: 'CLI do Auggie', + }, + tmux: { + title: 'Tmux', + spawnSessionsTitle: 'Iniciar sessões no Tmux', + spawnSessionsEnabledSubtitle: 'As sessões são iniciadas em novas janelas do tmux.', + spawnSessionsDisabledSubtitle: 'As sessões são iniciadas no shell comum (sem integração com tmux)', + isolatedServerTitle: 'Servidor tmux isolado', + isolatedServerEnabledSubtitle: 'Inicie sessões em um servidor tmux isolado (recomendado).', + isolatedServerDisabledSubtitle: 'Inicie sessões no seu servidor tmux padrão.', + sessionNamePlaceholder: 'Vazio = sessão atual/mais recente', + tempDirPlaceholder: 'Deixe em branco para gerar automaticamente', + }, + previewMachine: { + title: 'Pré-visualizar máquina', + itemTitle: 'Máquina de pré-visualização para variáveis de ambiente', + selectMachine: 'Selecionar máquina', + resolveSubtitle: 'Usada apenas para pré-visualizar os valores resolvidos abaixo (não altera o que é salvo).', + selectSubtitle: 'Selecione uma máquina para pré-visualizar os valores resolvidos abaixo.', + }, + environmentVariables: { + title: 'Variáveis de ambiente', + addVariable: 'Adicionar variável', + namePlaceholder: 'Nome da variável (e.g., MY_CUSTOM_VAR)', + valuePlaceholder: 'Valor (e.g., my-value ou ${MY_VAR})', + validation: { + nameRequired: 'Digite um nome de variável.', + invalidNameFormat: 'Os nomes das variáveis devem conter letras maiúsculas, números e sublinhados, e não podem começar com um número.', + duplicateName: 'Essa variável já existe.', + }, + card: { + valueLabel: 'Valor:', + fallbackValueLabel: 'Valor de fallback:', + valueInputPlaceholder: 'Valor', + defaultValueInputPlaceholder: 'Valor padrão', + fallbackDisabledForVault: 'Fallbacks ficam desativados ao usar o cofre de segredos.', + secretNotRetrieved: 'Valor secreto - não é recuperado por segurança', + secretToggleLabel: 'Ocultar valor na UI', + secretToggleSubtitle: 'Oculta o valor na interface e evita buscá-lo da máquina para pré-visualização.', + secretToggleEnforcedByDaemon: 'Imposto pelo daemon', + secretToggleEnforcedByVault: 'Imposto pelo cofre de segredos', + secretToggleResetToAuto: 'Redefinir para automático', + requirementRequiredLabel: 'Obrigatório', + requirementRequiredSubtitle: 'Bloqueia a criação da sessão quando a variável está ausente.', + requirementUseVaultLabel: 'Usar cofre de segredos', + requirementUseVaultSubtitle: 'Usar um segredo salvo (sem valores de fallback).', + defaultSecretLabel: 'Segredo padrão', + overridingDefault: ({ expectedValue }: { expectedValue: string }) => + `Substituindo o valor padrão documentado: ${expectedValue}`, + useMachineEnvToggle: 'Usar valor do ambiente da máquina', + resolvedOnSessionStart: 'Resolvido quando a sessão começa na máquina selecionada.', + sourceVariableLabel: 'Variável de origem', + sourceVariablePlaceholder: 'Nome da variável de origem (e.g., Z_AI_MODEL)', + checkingMachine: ({ machine }: { machine: string }) => `Verificando ${machine}...`, + emptyOnMachine: ({ machine }: { machine: string }) => `Vazio em ${machine}`, + emptyOnMachineUsingFallback: ({ machine }: { machine: string }) => `Vazio em ${machine} (usando fallback)`, + notFoundOnMachine: ({ machine }: { machine: string }) => `Não encontrado em ${machine}`, + notFoundOnMachineUsingFallback: ({ machine }: { machine: string }) => `Não encontrado em ${machine} (usando fallback)`, + valueFoundOnMachine: ({ machine }: { machine: string }) => `Valor encontrado em ${machine}`, + differsFromDocumented: ({ expectedValue }: { expectedValue: string }) => + `Diferente do valor documentado: ${expectedValue}`, + }, + preview: { + secretValueHidden: ({ value }: { value: string }) => `${value} - oculto por segurança`, + hiddenValue: '***oculto***', + emptyValue: '(vazio)', + sessionWillReceive: ({ name, value }: { name: string; value: string }) => + `A sessão receberá: ${name} = ${value}`, + }, + previewModal: { + titleWithProfile: ({ profileName }: { profileName: string }) => `Vars de ambiente · ${profileName}`, + descriptionPrefix: 'Estas variáveis de ambiente são enviadas ao iniciar a sessão. Os valores são resolvidos usando o daemon em', + descriptionFallbackMachine: 'a máquina selecionada', + descriptionSuffix: '.', + emptyMessage: 'Nenhuma variável de ambiente está definida para este perfil.', + checkingSuffix: '(verificando…)', + detail: { + fixed: 'Fixo', + machine: 'Máquina', + checking: 'Verificando', + fallback: 'Alternativa', + missing: 'Ausente', + }, + }, + }, delete: { title: 'Excluir Perfil', message: ({ name }: { name: string }) => `Tem certeza de que deseja excluir "${name}"? Esta ação não pode ser desfeita.`, @@ -904,6 +1598,49 @@ export const pt: TranslationStructure = { }, }, + secrets: { + addTitle: 'Novo segredo', + savedTitle: 'Segredos salvos', + badgeReady: 'Segredo', + badgeRequired: 'Segredo necessário', + missingForProfile: ({ env }: { env: string | null }) => + `Falta o segredo (${env ?? 'segredo'}). Configure na máquina ou selecione/insira um segredo.`, + defaultForProfileTitle: 'Segredo padrão', + defineDefaultForProfileTitle: 'Definir segredo padrão para este perfil', + addSubtitle: 'Adicionar um segredo salvo', + noneTitle: 'Nenhuma', + noneSubtitle: 'Use o ambiente da máquina ou insira um segredo para esta sessão', + emptyTitle: 'Nenhum segredo salvo', + emptySubtitle: 'Adicione um para usar perfis com segredo sem configurar variáveis de ambiente na máquina.', + savedHiddenSubtitle: 'Salva (valor oculto)', + defaultLabel: 'Padrão', + fields: { + name: 'Nome', + value: 'Valor', + }, + placeholders: { + nameExample: 'ex.: Work OpenAI', + }, + validation: { + nameRequired: 'Nome é obrigatório.', + valueRequired: 'Valor é obrigatório.', + }, + actions: { + replace: 'Substituir', + replaceValue: 'Substituir valor', + setDefault: 'Definir como padrão', + unsetDefault: 'Remover padrão', + }, + prompts: { + renameTitle: 'Renomear segredo', + renameDescription: 'Atualize o nome amigável deste segredo.', + replaceValueTitle: 'Substituir valor do segredo', + replaceValueDescription: 'Cole o novo valor do segredo. Este valor não será mostrado novamente após salvar.', + deleteTitle: 'Excluir segredo', + deleteConfirm: ({ name }: { name: string }) => `Excluir “${name}”? Esta ação não pode ser desfeita.`, + }, + }, + feed: { // Feed notifications for friend requests and acceptances friendRequestFrom: ({ name }: { name: string }) => `${name} enviou-lhe um pedido de amizade`, diff --git a/expo-app/sources/text/translations/ru.ts b/expo-app/sources/text/translations/ru.ts index aa533ea82..9c9e92a8c 100644 --- a/expo-app/sources/text/translations/ru.ts +++ b/expo-app/sources/text/translations/ru.ts @@ -1,4 +1,4 @@ -import type { TranslationStructure } from '../_default'; +import type { TranslationStructure } from '../_types'; /** * Russian plural helper function @@ -42,6 +42,8 @@ export const ru: TranslationStructure = { common: { // Simple string constants + add: 'Добавить', + actions: 'Действия', cancel: 'Отмена', authenticate: 'Авторизация', save: 'Сохранить', @@ -58,7 +60,11 @@ export const ru: TranslationStructure = { yes: 'Да', no: 'Нет', discard: 'Отменить', + discardChanges: 'Отменить изменения', + unsavedChangesWarning: 'У вас есть несохранённые изменения.', + keepEditing: 'Продолжить редактирование', version: 'Версия', + details: 'Детали', copied: 'Скопировано', copy: 'Копировать', scanning: 'Сканирование...', @@ -71,6 +77,18 @@ export const ru: TranslationStructure = { retry: 'Повторить', delete: 'Удалить', optional: 'необязательно', + noMatches: 'Нет совпадений', + all: 'Все', + machine: 'машина', + clearSearch: 'Очистить поиск', + refresh: 'Обновить', + }, + + dropdown: { + category: { + general: 'Общее', + results: 'Результаты', + }, }, connect: { @@ -78,6 +96,16 @@ export const ru: TranslationStructure = { enterSecretKey: 'Пожалуйста, введите секретный ключ', invalidSecretKey: 'Неверный секретный ключ. Проверьте и попробуйте снова.', enterUrlManually: 'Ввести URL вручную', + openMachine: 'Открыть машину', + terminalUrlPlaceholder: 'happy://terminal?...', + restoreQrInstructions: '1. Откройте Happy на мобильном устройстве\n2. Перейдите в Настройки → Аккаунт\n3. Нажмите «Подключить новое устройство»\n4. Отсканируйте этот QR-код', + restoreWithSecretKeyInstead: 'Восстановить по секретному ключу', + restoreWithSecretKeyDescription: 'Введите секретный ключ, чтобы восстановить доступ к аккаунту.', + secretKeyPlaceholder: 'XXXXX-XXXXX-XXXXX...', + unsupported: { + connectTitle: ({ name }: { name: string }) => `Подключить ${name}`, + runCommandInTerminal: 'Выполните следующую команду в терминале:', + }, }, settings: { @@ -118,11 +146,17 @@ export const ru: TranslationStructure = { usageSubtitle: 'Просмотр использования API и затрат', profiles: 'Профили', profilesSubtitle: 'Управление профилями переменных окружения для сессий', + secrets: 'Секреты', + secretsSubtitle: 'Управление сохранёнными секретами (после ввода больше не показываются)', + terminal: 'Терминал', + session: 'Сессия', + sessionSubtitleTmuxEnabled: 'Tmux включён', + sessionSubtitleMessageSendingAndTmux: 'Отправка сообщений и tmux', // Dynamic settings messages accountConnected: ({ service }: { service: string }) => `Аккаунт ${service} подключен`, machineStatus: ({ name, status }: { name: string; status: 'online' | 'offline' }) => - `${name} ${status === 'online' ? 'online' : 'offline'}`, + `${name} ${status === 'online' ? 'в сети' : 'не в сети'}`, featureToggled: ({ feature, enabled }: { feature: string; enabled: boolean }) => `${feature} ${enabled ? 'включена' : 'отключена'}`, }, @@ -155,6 +189,21 @@ export const ru: TranslationStructure = { wrapLinesInDiffsDescription: 'Переносить длинные строки вместо горизонтальной прокрутки в представлениях различий', alwaysShowContextSize: 'Всегда показывать размер контекста', alwaysShowContextSizeDescription: 'Отображать использование контекста даже когда не близко к лимиту', + agentInputActionBarLayout: 'Панель действий ввода', + agentInputActionBarLayoutDescription: 'Выберите, как отображаются действия над полем ввода', + agentInputActionBarLayoutOptions: { + auto: 'Авто', + wrap: 'Перенос', + scroll: 'Прокрутка', + collapsed: 'Свернуто', + }, + agentInputChipDensity: 'Плотность чипов действий', + agentInputChipDensityDescription: 'Выберите, показывать ли чипы действий с подписями или только значками', + agentInputChipDensityOptions: { + auto: 'Авто', + labels: 'Подписи', + icons: 'Только значки', + }, avatarStyle: 'Стиль аватара', avatarStyleDescription: 'Выберите внешний вид аватара сессии', avatarOptions: { @@ -175,21 +224,52 @@ export const ru: TranslationStructure = { experimentalFeatures: 'Экспериментальные функции', experimentalFeaturesEnabled: 'Экспериментальные функции включены', experimentalFeaturesDisabled: 'Используются только стабильные функции', - webFeatures: 'Веб-функции', - webFeaturesDescription: 'Функции, доступные только в веб-версии приложения.', + experimentalOptions: 'Экспериментальные опции', + experimentalOptionsDescription: 'Выберите, какие экспериментальные функции включены.', + expUsageReporting: 'Usage reporting', + expUsageReportingSubtitle: 'Enable usage and token reporting screens', + expFileViewer: 'File viewer', + expFileViewerSubtitle: 'Enable the session file viewer entrypoint', + expShowThinkingMessages: 'Show thinking messages', + expShowThinkingMessagesSubtitle: 'Show assistant thinking/status messages in chat', + expSessionType: 'Session type selector', + expSessionTypeSubtitle: 'Show the session type selector (simple vs worktree)', + expZen: 'Zen', + expZenSubtitle: 'Enable the Zen navigation entry', + expVoiceAuthFlow: 'Voice auth flow', + expVoiceAuthFlowSubtitle: 'Use authenticated voice token flow (paywall-aware)', + expInboxFriends: 'Входящие и друзья', + expInboxFriendsSubtitle: 'Включить вкладку «Входящие» и функции друзей', + expCodexResume: 'Возобновление Codex', + expCodexResumeSubtitle: 'Разрешить возобновление сессий Codex через отдельную установку (экспериментально)', + expCodexAcp: 'Codex ACP', + expCodexAcpSubtitle: 'Использовать Codex через ACP (codex-acp) вместо MCP (экспериментально)', + webFeatures: 'Веб-функции', + webFeaturesDescription: 'Функции, доступные только в веб-версии приложения.', enterToSend: 'Enter для отправки', enterToSendEnabled: 'Нажмите Enter для отправки (Shift+Enter для новой строки)', enterToSendDisabled: 'Enter вставляет новую строку', - commandPalette: 'Command Palette', + commandPalette: 'Палитра команд', commandPaletteEnabled: 'Нажмите ⌘K для открытия', commandPaletteDisabled: 'Быстрый доступ к командам отключён', - markdownCopyV2: 'Markdown Copy v2', + markdownCopyV2: 'Копирование Markdown v2', markdownCopyV2Subtitle: 'Долгое нажатие открывает модальное окно копирования', hideInactiveSessions: 'Скрывать неактивные сессии', hideInactiveSessionsSubtitle: 'Показывать в списке только активные чаты', + groupInactiveSessionsByProject: 'Группировать неактивные сессии по проектам', + groupInactiveSessionsByProjectSubtitle: 'Организовать неактивные чаты по проектам', enhancedSessionWizard: 'Улучшенный мастер сессий', enhancedSessionWizardEnabled: 'Лаунчер с профилем активен', enhancedSessionWizardDisabled: 'Используется стандартный лаунчер', + profiles: 'Профили ИИ', + profilesEnabled: 'Выбор профилей включён', + profilesDisabled: 'Выбор профилей отключён', + pickerSearch: 'Поиск в выборе', + pickerSearchSubtitle: 'Показывать поле поиска в выборе машины и пути', + machinePickerSearch: 'Поиск машин', + machinePickerSearchSubtitle: 'Показывать поле поиска при выборе машины', + pathPickerSearch: 'Поиск путей', + pathPickerSearchSubtitle: 'Показывать поле поиска при выборе пути', }, errors: { @@ -237,13 +317,87 @@ export const ru: TranslationStructure = { failedToRemoveFriend: 'Не удалось удалить друга', searchFailed: 'Поиск не удался. Пожалуйста, попробуйте снова.', failedToSendRequest: 'Не удалось отправить запрос в друзья', + failedToResumeSession: 'Не удалось возобновить сессию', + failedToSendMessage: 'Не удалось отправить сообщение', + cannotShareWithSelf: 'Нельзя поделиться с самим собой', + canOnlyShareWithFriends: 'Можно делиться только с друзьями', + shareNotFound: 'Общий доступ не найден', + publicShareNotFound: 'Публичная ссылка не найдена или истекла', + consentRequired: 'Требуется согласие для доступа', + maxUsesReached: 'Достигнут лимит использований', + invalidShareLink: 'Недействительная или просроченная ссылка для обмена', + missingPermissionId: 'Отсутствует идентификатор запроса разрешения', + codexResumeNotInstalledTitle: 'Codex resume не установлен на этой машине', + codexResumeNotInstalledMessage: + 'Чтобы возобновить разговор Codex, установите сервер возобновления Codex на целевой машине (Детали машины → Возобновление Codex).', + codexAcpNotInstalledTitle: 'Codex ACP не установлен на этой машине', + codexAcpNotInstalledMessage: + 'Чтобы использовать эксперимент Codex ACP, установите codex-acp на целевой машине (Детали машины → Codex ACP) или отключите эксперимент.', + }, + + deps: { + installNotSupported: 'Обновите Happy CLI, чтобы установить эту зависимость.', + installFailed: 'Не удалось установить', + installed: 'Установлено', + installLog: ({ path }: { path: string }) => `Лог установки: ${path}`, + installable: { + codexResume: { + title: 'Сервер возобновления Codex', + installSpecTitle: 'Источник установки Codex resume', + }, + codexAcp: { + title: 'Адаптер Codex ACP', + installSpecTitle: 'Источник установки Codex ACP', + }, + installSpecDescription: 'Спецификация NPM/Git/file для `npm install` (экспериментально). Оставьте пустым, чтобы использовать значение демона по умолчанию.', + }, + ui: { + notAvailable: 'Недоступно', + notAvailableUpdateCli: 'Недоступно (обновите CLI)', + errorRefresh: 'Ошибка (обновить)', + installed: 'Установлено', + installedWithVersion: ({ version }: { version: string }) => `Установлено (v${version})`, + installedUpdateAvailable: ({ installedVersion, latestVersion }: { installedVersion: string; latestVersion: string }) => + `Установлено (v${installedVersion}) — доступно обновление (v${latestVersion})`, + notInstalled: 'Не установлено', + latest: 'Последняя', + latestSubtitle: ({ version, tag }: { version: string; tag: string }) => `${version} (tag: ${tag})`, + registryCheck: 'Проверка реестра', + registryCheckFailed: ({ error }: { error: string }) => `Ошибка: ${error}`, + installSource: 'Источник установки', + installSourceDefault: '(по умолчанию)', + installSpecPlaceholder: 'например, file:/path/to/pkg или github:owner/repo#branch', + lastInstallLog: 'Последний лог установки', + installLogTitle: 'Лог установки', + }, }, newSession: { // Used by new-session screen and launch flows title: 'Начать новую сессию', + selectAiProfileTitle: 'Выбрать профиль ИИ', + selectAiProfileDescription: 'Выберите профиль ИИ, чтобы применить переменные окружения и настройки по умолчанию к вашей сессии.', + changeProfile: 'Сменить профиль', + aiBackendSelectedByProfile: 'Бэкенд ИИ выбирается вашим профилем. Чтобы изменить его, выберите другой профиль.', + selectAiBackendTitle: 'Выбрать бэкенд ИИ', + aiBackendLimitedByProfileAndMachineClis: 'Ограничено выбранным профилем и доступными CLI на этой машине.', + aiBackendSelectWhichAiRuns: 'Выберите, какой ИИ будет работать в вашей сессии.', + aiBackendNotCompatibleWithSelectedProfile: 'Несовместимо с выбранным профилем.', + aiBackendCliNotDetectedOnMachine: ({ cli }: { cli: string }) => `${cli} CLI не обнаружен на этой машине.`, + selectMachineTitle: 'Выбрать машину', + selectMachineDescription: 'Выберите, где будет выполняться эта сессия.', + selectPathTitle: 'Выбрать путь', + selectWorkingDirectoryTitle: 'Выбрать рабочую директорию', + selectWorkingDirectoryDescription: 'Выберите папку, используемую для команд и контекста.', + selectPermissionModeTitle: 'Выбрать режим разрешений', + selectPermissionModeDescription: 'Настройте, насколько строго действия требуют подтверждения.', + selectModelTitle: 'Выбрать модель ИИ', + selectModelDescription: 'Выберите модель, используемую этой сессией.', + selectSessionTypeTitle: 'Выбрать тип сессии', + selectSessionTypeDescription: 'Выберите простую сессию или сессию, привязанную к Git worktree.', + searchPathsPlaceholder: 'Поиск путей...', noMachinesFound: 'Машины не найдены. Сначала запустите сессию Happy на вашем компьютере.', - allMachinesOffline: 'Все машины находятся offline', + allMachinesOffline: 'Все машины не в сети', machineDetails: 'Посмотреть детали машины →', directoryDoesNotExist: 'Директория не найдена', createDirectoryConfirm: ({ directory }: { directory: string }) => `Директория ${directory} не существует. Хотите создать её?`, @@ -257,18 +411,94 @@ export const ru: TranslationStructure = { startNewSessionInFolder: 'Новая сессия здесь', noMachineSelected: 'Пожалуйста, выберите машину для запуска сессии', noPathSelected: 'Пожалуйста, выберите директорию для запуска сессии', + machinePicker: { + searchPlaceholder: 'Поиск машин...', + recentTitle: 'Недавние', + favoritesTitle: 'Избранное', + allTitle: 'Все', + emptyMessage: 'Нет доступных машин', + }, + pathPicker: { + enterPathTitle: 'Введите путь', + enterPathPlaceholder: 'Введите путь...', + customPathTitle: 'Пользовательский путь', + recentTitle: 'Недавние', + favoritesTitle: 'Избранное', + suggestedTitle: 'Рекомендуемые', + allTitle: 'Все', + emptyRecent: 'Нет недавних путей', + emptyFavorites: 'Нет избранных путей', + emptySuggested: 'Нет рекомендуемых путей', + emptyAll: 'Нет путей', + }, sessionType: { title: 'Тип сессии', simple: 'Простая', - worktree: 'Worktree', + worktree: 'Рабочее дерево', comingSoon: 'Скоро будет доступно', }, + profileAvailability: { + requiresAgent: ({ agent }: { agent: string }) => `Требуется ${agent}`, + cliNotDetected: ({ cli }: { cli: string }) => `${cli} CLI не обнаружен`, + }, + cliBanners: { + cliNotDetectedTitle: ({ cli }: { cli: string }) => `${cli} CLI не обнаружен`, + dontShowFor: 'Не показывать это предупреждение для', + thisMachine: 'этой машины', + anyMachine: 'любой машины', + installCommand: ({ command }: { command: string }) => `Установить: ${command} •`, + installCliIfAvailable: ({ cli }: { cli: string }) => `Установите ${cli} CLI, если доступно •`, + viewInstallationGuide: 'Открыть руководство по установке →', + viewGeminiDocs: 'Открыть документацию Gemini →', + }, worktree: { creating: ({ name }: { name: string }) => `Создание worktree '${name}'...`, notGitRepo: 'Worktree требует наличия git репозитория', failed: ({ error }: { error: string }) => `Не удалось создать worktree: ${error}`, success: 'Worktree успешно создан', - } + }, + resume: { + title: 'Продолжить сессию', + optional: 'Продолжить: необязательно', + pickerTitle: 'Продолжить сессию', + subtitle: ({ agent }: { agent: string }) => `Вставьте ID сессии ${agent} для продолжения`, + placeholder: ({ agent }: { agent: string }) => `Вставьте ID сессии ${agent}…`, + paste: 'Вставить', + save: 'Сохранить', + clearAndRemove: 'Очистить', + helpText: 'ID сессии можно найти на экране информации о сессии.', + cannotApplyBody: 'Этот ID возобновления сейчас нельзя применить. Happy вместо этого начнёт новую сессию.', + }, + codexResumeBanner: { + title: 'Codex resume', + updateAvailable: 'Доступно обновление', + systemCodexVersion: ({ version }: { version: string }) => `Системный Codex: ${version}`, + resumeServerVersion: ({ version }: { version: string }) => `Сервер Codex resume: ${version}`, + notInstalled: 'не установлен', + latestVersion: ({ version }: { version: string }) => `(последняя ${version})`, + registryCheckFailed: ({ error }: { error: string }) => `Проверка реестра не удалась: ${error}`, + install: 'Установить', + update: 'Обновить', + reinstall: 'Переустановить', + }, + codexResumeInstallModal: { + installTitle: 'Установить Codex resume?', + updateTitle: 'Обновить Codex resume?', + reinstallTitle: 'Переустановить Codex resume?', + description: 'Это установит экспериментальный wrapper MCP-сервера Codex, используемый только для операций возобновления.', + }, + codexAcpBanner: { + title: 'Codex ACP', + install: 'Установить', + update: 'Обновить', + reinstall: 'Переустановить', + }, + codexAcpInstallModal: { + installTitle: 'Установить Codex ACP?', + updateTitle: 'Обновить Codex ACP?', + reinstallTitle: 'Переустановить Codex ACP?', + description: 'Это установит экспериментальный ACP-адаптер для Codex, который поддерживает загрузку/возобновление тредов.', + }, }, sessionHistory: { @@ -310,8 +540,18 @@ export const ru: TranslationStructure = { happySessionId: 'ID сессии Happy', claudeCodeSessionId: 'ID сессии Claude Code', claudeCodeSessionIdCopied: 'ID сессии Claude Code скопирован в буфер обмена', + aiProfile: 'Профиль ИИ', aiProvider: 'Поставщик ИИ', failedToCopyClaudeCodeSessionId: 'Не удалось скопировать ID сессии Claude Code', + codexSessionId: 'ID сессии Codex', + codexSessionIdCopied: 'ID сессии Codex скопирован в буфер обмена', + failedToCopyCodexSessionId: 'Не удалось скопировать ID сессии Codex', + opencodeSessionId: 'ID сессии OpenCode', + opencodeSessionIdCopied: 'ID сессии OpenCode скопирован в буфер обмена', + geminiSessionId: 'ID сессии Gemini', + geminiSessionIdCopied: 'ID сессии Gemini скопирован в буфер обмена', + auggieSessionId: 'ID сессии Auggie', + auggieSessionIdCopied: 'ID сессии Auggie скопирован в буфер обмена', metadataCopied: 'Метаданные скопированы в буфер обмена', failedToCopyMetadata: 'Не удалось скопировать метаданные', failedToKillSession: 'Не удалось завершить сессию', @@ -321,6 +561,7 @@ export const ru: TranslationStructure = { lastUpdated: 'Последнее обновление', sequence: 'Последовательность', quickActions: 'Быстрые действия', + copyResumeCommand: 'Скопировать команду возобновления', viewMachine: 'Посмотреть машину', viewMachineSubtitle: 'Посмотреть детали машины и сессии', killSessionSubtitle: 'Немедленно завершить сессию', @@ -331,8 +572,14 @@ export const ru: TranslationStructure = { operatingSystem: 'Операционная система', processId: 'ID процесса', happyHome: 'Домашний каталог Happy', + attachFromTerminal: 'Подключиться из терминала', + tmuxTarget: 'Цель tmux', + tmuxFallback: 'Fallback tmux', copyMetadata: 'Копировать метаданные', agentState: 'Состояние агента', + rawJsonDevMode: 'Сырой JSON (режим разработчика)', + sessionStatus: 'Статус сессии', + fullSessionObject: 'Полный объект сессии', controlledByUser: 'Управляется пользователем', pendingRequests: 'Ожидающие запросы', activity: 'Активность', @@ -349,6 +596,13 @@ export const ru: TranslationStructure = { deleteSessionWarning: 'Это действие нельзя отменить. Все сообщения и данные, связанные с этой сессией, будут удалены навсегда.', failedToDeleteSession: 'Не удалось удалить сессию', sessionDeleted: 'Сессия успешно удалена', + manageSharing: 'Управление доступом', + manageSharingSubtitle: 'Поделиться сессией с друзьями или создать публичную ссылку', + renameSession: 'Переименовать сессию', + renameSessionSubtitle: 'Изменить отображаемое имя сессии', + renameSessionPlaceholder: 'Введите название сессии...', + failedToRenameSession: 'Не удалось переименовать сессию', + sessionRenamed: 'Сессия успешно переименована', }, components: { @@ -359,6 +613,35 @@ export const ru: TranslationStructure = { runIt: 'Запустите его', scanQrCode: 'Отсканируйте QR-код', openCamera: 'Открыть камеру', + installCommand: '$ npm i -g happy-coder', + runCommand: '$ happy', + }, + emptyMessages: { + noMessagesYet: 'Сообщений пока нет', + created: ({ time }: { time: string }) => `Создано ${time}`, + }, + emptySessionsTablet: { + noActiveSessions: 'Нет активных сессий', + startNewSessionDescription: 'Запустите новую сессию на любой из подключённых машин.', + startNewSessionButton: 'Новая сессия', + openTerminalToStart: 'Откройте новый терминал на компьютере, чтобы начать сессию.', + }, + }, + + zen: { + title: 'Zen', + add: { + placeholder: 'Что нужно сделать?', + }, + home: { + noTasksYet: 'Пока нет задач. Нажмите +, чтобы добавить.', + }, + view: { + workOnTask: 'Работать над задачей', + clarify: 'Уточнить', + delete: 'Удалить', + linkedSessions: 'Связанные сессии', + tapTaskTextToEdit: 'Нажмите на текст задачи, чтобы отредактировать', }, }, @@ -377,8 +660,8 @@ export const ru: TranslationStructure = { connecting: 'подключение', disconnected: 'отключено', error: 'ошибка', - online: 'online', - offline: 'offline', + online: 'в сети', + offline: 'не в сети', lastSeen: ({ time }: { time: string }) => `в сети ${time}`, permissionRequired: 'требуется разрешение', activeNow: 'Активен сейчас', @@ -393,19 +676,120 @@ export const ru: TranslationStructure = { session: { inputPlaceholder: 'Введите сообщение...', + resuming: 'Возобновление...', + resumeFailed: 'Не удалось возобновить сессию', + resumeSupportNoteChecking: 'Примечание: Happy всё ещё проверяет, может ли эта машина возобновить сессию провайдера.', + resumeSupportNoteUnverified: 'Примечание: Happy не смог проверить поддержку возобновления на этой машине.', + resumeSupportDetails: { + cliNotDetected: 'CLI не обнаружен на машине.', + capabilityProbeFailed: 'Не удалось проверить возможности.', + acpProbeFailed: 'Не удалось выполнить ACP-проверку.', + loadSessionFalse: 'Агент не поддерживает загрузку сессий.', + }, + inactiveResumable: 'Неактивна (можно возобновить)', + inactiveMachineOffline: 'Неактивна (машина не в сети)', + inactiveNotResumable: 'Неактивна', + inactiveNotResumableNoticeTitle: 'Эту сессию нельзя возобновить', + inactiveNotResumableNoticeBody: ({ provider }: { provider: string }) => + `Эта сессия завершена и не может быть возобновлена, потому что ${provider} не поддерживает восстановление контекста здесь. Начните новую сессию, чтобы продолжить.`, + machineOfflineNoticeTitle: 'Машина не в сети', + machineOfflineNoticeBody: ({ machine }: { machine: string }) => + `“${machine}” не в сети, поэтому Happy пока не может возобновить эту сессию. Подключите машину, чтобы продолжить.`, + machineOfflineCannotResume: 'Машина не в сети. Подключите её, чтобы возобновить эту сессию.', + sharing: { + title: 'Общий доступ', + directSharing: 'Прямой доступ', + addShare: 'Поделиться с другом', + accessLevel: 'Уровень доступа', + shareWith: 'Поделиться с', + sharedWith: 'Доступ предоставлен', + noShares: 'Не поделено', + viewOnly: 'Только просмотр', + viewOnlyDescription: 'Можно просматривать, но нельзя отправлять сообщения.', + viewOnlyMode: 'Только просмотр (общая сессия)', + noEditPermission: 'У вас доступ только для чтения к этой сессии.', + canEdit: 'Можно редактировать', + canEditDescription: 'Можно отправлять сообщения.', + canManage: 'Можно управлять', + canManageDescription: 'Можно управлять настройками общего доступа.', + stopSharing: 'Прекратить доступ', + recipientMissingKeys: 'Этот пользователь ещё не зарегистрировал ключи шифрования.', + + publicLink: 'Публичная ссылка', + publicLinkActive: 'Публичная ссылка активна', + publicLinkDescription: 'Создайте ссылку, по которой любой сможет просмотреть эту сессию.', + createPublicLink: 'Создать публичную ссылку', + regeneratePublicLink: 'Пересоздать публичную ссылку', + deletePublicLink: 'Удалить публичную ссылку', + linkToken: 'Токен ссылки', + tokenNotRecoverable: 'Токен недоступен', + tokenNotRecoverableDescription: + 'По соображениям безопасности токены публичных ссылок хранятся в виде хеша и не могут быть восстановлены. Пересоздайте ссылку, чтобы создать новый токен.', + + expiresIn: 'Истекает через', + expiresOn: 'Истекает', + days7: '7 дней', + days30: '30 дней', + never: 'Никогда', + + maxUsesLabel: 'Максимум использований', + unlimited: 'Без ограничений', + uses10: '10 использований', + uses50: '50 использований', + usageCount: 'Количество использований', + usageCountWithMax: ({ used, max }: { used: number; max: number }) => `${used}/${max} использований`, + usageCountUnlimited: ({ used }: { used: number }) => `${used} использований`, + + requireConsent: 'Требовать согласие', + requireConsentDescription: 'Запрашивать согласие перед тем, как логировать доступ.', + consentRequired: 'Требуется согласие', + consentDescription: 'Эта ссылка требует вашего согласия на запись IP-адреса и user agent.', + acceptAndView: 'Принять и просмотреть', + sharedBy: ({ name }: { name: string }) => `Поделился ${name}`, + + shareNotFound: 'Ссылка не найдена или истекла', + failedToDecrypt: 'Не удалось расшифровать сессию', + noMessages: 'Сообщений пока нет', + session: 'Сессия', + }, }, commandPalette: { placeholder: 'Введите команду или поиск...', + noCommandsFound: 'Команды не найдены', + }, + + commandView: { + completedWithNoOutput: '[Команда завершена без вывода]', + }, + + voiceAssistant: { + connecting: 'Подключение...', + active: 'Голосовой ассистент активен', + connectionError: 'Ошибка соединения', + label: 'Голосовой ассистент', + tapToEnd: 'Нажмите, чтобы завершить', }, agentInput: { + envVars: { + title: 'Переменные окружения', + titleWithCount: ({ count }: { count: number }) => `Переменные окружения (${count})`, + }, + resumeChip: { + withId: ({ title, id }: { title: string; id: string }) => `${title}: ${id}`, + withIdTruncated: ({ title, prefix, suffix }: { title: string; prefix: string; suffix: string }) => + `${title}: ${prefix}…${suffix}`, + }, permissionMode: { title: 'РЕЖИМ РАЗРЕШЕНИЙ', default: 'По умолчанию', acceptEdits: 'Принимать правки', plan: 'Режим планирования', bypassPermissions: 'YOLO режим', + badgeAccept: 'Принять', + badgePlan: 'План', + badgeYolo: 'YOLO', badgeAcceptAllEdits: 'Принимать все правки', badgeBypassAllPermissions: 'Обход всех разрешений', badgePlanMode: 'Режим планирования', @@ -413,7 +797,13 @@ export const ru: TranslationStructure = { agent: { claude: 'Claude', codex: 'Codex', + opencode: 'OpenCode', gemini: 'Gemini', + auggie: 'Auggie', + }, + auggieIndexingChip: { + on: 'Индексация включена', + off: 'Индексация выключена', }, model: { title: 'МОДЕЛЬ', @@ -422,22 +812,22 @@ export const ru: TranslationStructure = { codexPermissionMode: { title: 'РЕЖИМ РАЗРЕШЕНИЙ CODEX', default: 'Настройки CLI', - readOnly: 'Read Only Mode', - safeYolo: 'Safe YOLO', + readOnly: 'Только чтение', + safeYolo: 'Безопасный YOLO', yolo: 'YOLO', badgeReadOnly: 'Только чтение', - badgeSafeYolo: 'Safe YOLO', + badgeSafeYolo: 'Безопасный YOLO', badgeYolo: 'YOLO', }, codexModel: { - title: 'CODEX MODEL', - gpt5CodexLow: 'gpt-5-codex low', - gpt5CodexMedium: 'gpt-5-codex medium', - gpt5CodexHigh: 'gpt-5-codex high', - gpt5Minimal: 'GPT-5 Minimal', - gpt5Low: 'GPT-5 Low', - gpt5Medium: 'GPT-5 Medium', - gpt5High: 'GPT-5 High', + title: 'МОДЕЛЬ CODEX', + gpt5CodexLow: 'gpt-5-codex низкий', + gpt5CodexMedium: 'gpt-5-codex средний', + gpt5CodexHigh: 'gpt-5-codex высокий', + gpt5Minimal: 'GPT-5 Минимальный', + gpt5Low: 'GPT-5 Низкий', + gpt5Medium: 'GPT-5 Средний', + gpt5High: 'GPT-5 Высокий', }, geminiPermissionMode: { title: 'РЕЖИМ РАЗРЕШЕНИЙ', @@ -449,6 +839,21 @@ export const ru: TranslationStructure = { badgeSafeYolo: 'Безопасный YOLO', badgeYolo: 'YOLO', }, + geminiModel: { + title: 'МОДЕЛЬ GEMINI', + gemini25Pro: { + label: 'Gemini 2.5 Pro', + description: 'Самая мощная', + }, + gemini25Flash: { + label: 'Gemini 2.5 Flash', + description: 'Быстро и эффективно', + }, + gemini25FlashLite: { + label: 'Gemini 2.5 Flash Lite', + description: 'Самая быстрая', + }, + }, context: { remaining: ({ percent }: { percent: number }) => `Осталось ${percent}%`, }, @@ -456,6 +861,11 @@ export const ru: TranslationStructure = { fileLabel: 'ФАЙЛ', folderLabel: 'ПАПКА', }, + actionMenu: { + title: 'ДЕЙСТВИЯ', + files: 'Файлы', + stop: 'Остановить', + }, noMachinesAvailable: 'Нет машин', }, @@ -514,6 +924,10 @@ export const ru: TranslationStructure = { applyChanges: 'Обновить файл', viewDiff: 'Текущие изменения файла', question: 'Вопрос', + changeTitle: 'Изменить заголовок', + }, + geminiExecute: { + cwd: ({ cwd }: { cwd: string }) => `📁 ${cwd}`, }, desc: { terminalCmd: ({ cmd }: { cmd: string }) => `Терминал(команда: ${cmd})`, @@ -535,7 +949,19 @@ export const ru: TranslationStructure = { askUserQuestion: { submit: 'Отправить ответ', multipleQuestions: ({ count }: { count: number }) => `${count} ${plural({ count, one: 'вопрос', few: 'вопроса', many: 'вопросов' })}`, - } + }, + exitPlanMode: { + approve: 'Одобрить план', + reject: 'Отклонить', + requestChanges: 'Попросить изменения', + requestChangesPlaceholder: 'Напишите Claude, что вы хотите изменить в этом плане…', + requestChangesSend: 'Отправить комментарий', + requestChangesEmpty: 'Пожалуйста, напишите, что вы хотите изменить.', + requestChangesFailed: 'Не удалось отправить запрос на изменения. Попробуйте снова.', + responded: 'Ответ отправлен', + approvalMessage: 'Я одобряю этот план. Пожалуйста, продолжайте реализацию.', + rejectionMessage: 'Я не одобряю этот план. Пожалуйста, переработайте его или спросите, какие изменения я хочу.', + }, }, files: { @@ -664,6 +1090,11 @@ export const ru: TranslationStructure = { deviceLinkedSuccessfully: 'Устройство успешно связано', terminalConnectedSuccessfully: 'Терминал успешно подключен', invalidAuthUrl: 'Неверный URL авторизации', + microphoneAccessRequiredTitle: 'Требуется доступ к микрофону', + microphoneAccessRequiredRequestPermission: 'Happy нужен доступ к микрофону для голосового чата. Разрешите доступ, когда появится запрос.', + microphoneAccessRequiredEnableInSettings: 'Happy нужен доступ к микрофону для голосового чата. Включите доступ к микрофону в настройках устройства.', + microphoneAccessRequiredBrowserInstructions: 'Разрешите доступ к микрофону в настройках браузера. Возможно, нужно нажать на значок замка в адресной строке и включить разрешение микрофона для этого сайта.', + openSettings: 'Открыть настройки', developerMode: 'Режим разработчика', developerModeEnabled: 'Режим разработчика включен', developerModeDisabled: 'Режим разработчика отключен', @@ -712,12 +1143,21 @@ export const ru: TranslationStructure = { }, machine: { - offlineUnableToSpawn: 'Запуск отключен: машина offline', - offlineHelp: '• Убедитесь, что компьютер online\n• Выполните `happy daemon status` для диагностики\n• Используете последнюю версию CLI? Обновите командой `npm install -g happy-coder@latest`', + offlineUnableToSpawn: 'Запуск отключён: машина офлайн', + offlineHelp: '• Убедитесь, что компьютер онлайн\n• Выполните `happy daemon status` для диагностики\n• Используете последнюю версию CLI? Обновите командой `npm install -g happy-coder@latest`', launchNewSessionInDirectory: 'Запустить новую сессию в папке', - daemon: 'Daemon', + daemon: 'Демон', status: 'Статус', stopDaemon: 'Остановить daemon', + stopDaemonConfirmTitle: 'Остановить демон?', + stopDaemonConfirmBody: 'Вы не сможете создавать новые сессии на этой машине, пока не перезапустите демон на компьютере. Текущие сессии останутся активными.', + daemonStoppedTitle: 'Демон остановлен', + stopDaemonFailed: 'Не удалось остановить демон. Возможно, он не запущен.', + renameTitle: 'Переименовать машину', + renameDescription: 'Дайте этой машине имя. Оставьте пустым, чтобы использовать hostname по умолчанию.', + renamePlaceholder: 'Введите имя машины', + renamedSuccess: 'Машина успешно переименована', + renameFailed: 'Не удалось переименовать машину', lastKnownPid: 'Последний известный PID', lastKnownHttpPort: 'Последний известный HTTP порт', startedAt: 'Запущен в', @@ -734,20 +1174,40 @@ export const ru: TranslationStructure = { lastSeen: 'Последняя активность', never: 'Никогда', metadataVersion: 'Версия метаданных', + detectedClis: 'Обнаруженные CLI', + detectedCliNotDetected: 'Не обнаружено', + detectedCliUnknown: 'Неизвестно', + detectedCliNotSupported: 'Не поддерживается (обновите happy-cli)', untitledSession: 'Безымянная сессия', back: 'Назад', + notFound: 'Машина не найдена', + unknownMachine: 'неизвестная машина', + unknownPath: 'неизвестный путь', + tmux: { + overrideTitle: 'Переопределить глобальные настройки tmux', + overrideEnabledSubtitle: 'Пользовательские настройки tmux применяются к новым сессиям на этой машине.', + overrideDisabledSubtitle: 'Новые сессии используют глобальные настройки tmux.', + notDetectedSubtitle: 'tmux не обнаружен на этой машине.', + notDetectedMessage: 'tmux не обнаружен на этой машине. Установите tmux и обновите обнаружение.', + }, }, message: { switchedToMode: ({ mode }: { mode: string }) => `Переключено в режим ${mode}`, + discarded: 'Отброшено', unknownEvent: 'Неизвестное событие', usageLimitUntil: ({ time }: { time: string }) => `Лимит использования достигнут до ${time}`, unknownTime: 'неизвестное время', }, + chatFooter: { + permissionsTerminalOnly: 'Разрешения отображаются только в терминале. Сбросьте их или отправьте сообщение, чтобы управлять из приложения.', + }, + codex: { // Codex permission dialog buttons permissions: { + yesAlwaysAllowCommand: 'Да, разрешить глобально', yesForSession: 'Да, и не спрашивать для этой сессии', stopAndExplain: 'Остановить и объяснить, что делать', } @@ -758,6 +1218,9 @@ export const ru: TranslationStructure = { permissions: { yesAllowAllEdits: 'Да, разрешить все правки в этой сессии', yesForTool: 'Да, больше не спрашивать для этого инструмента', + yesForCommandPrefix: 'Да, больше не спрашивать для этого префикса команды', + yesForSubcommand: 'Да, больше не спрашивать для этой подкоманды', + yesForCommandName: 'Да, больше не спрашивать для этой команды', noTellClaude: 'Нет, дать обратную связь', } }, @@ -783,6 +1246,7 @@ export const ru: TranslationStructure = { textCopied: 'Текст скопирован в буфер обмена', failedToCopy: 'Не удалось скопировать текст в буфер обмена', noTextToCopy: 'Нет текста для копирования', + failedToOpen: 'Не удалось открыть выбор текста. Пожалуйста, попробуйте снова.', }, markdown: { @@ -815,11 +1279,14 @@ export const ru: TranslationStructure = { edit: 'Редактировать артефакт', delete: 'Удалить', updateError: 'Не удалось обновить артефакт. Пожалуйста, попробуйте еще раз.', + deleteError: 'Не удалось удалить артефакт. Пожалуйста, попробуйте снова.', notFound: 'Артефакт не найден', discardChanges: 'Отменить изменения?', discardChangesDescription: 'У вас есть несохраненные изменения. Вы уверены, что хотите их отменить?', deleteConfirm: 'Удалить артефакт?', deleteConfirmDescription: 'Это действие нельзя отменить', + noContent: 'Нет содержимого', + untitled: 'Без названия', titleLabel: 'ЗАГОЛОВОК', titlePlaceholder: 'Введите заголовок для вашего артефакта', bodyLabel: 'СОДЕРЖИМОЕ', @@ -836,6 +1303,8 @@ export const ru: TranslationStructure = { // Friends feature title: 'Друзья', manageFriends: 'Управляйте своими друзьями и связями', + sharedSessions: 'Общие сессии', + noSharedSessions: 'Пока нет общих сессий', searchTitle: 'Найти друзей', pendingRequests: 'Запросы в друзья', myFriends: 'Мои друзья', @@ -905,10 +1374,55 @@ export const ru: TranslationStructure = { friendAcceptedGeneric: 'Запрос в друзья принят', }, + secrets: { + addTitle: 'Новый секрет', + savedTitle: 'Сохранённые секреты', + badgeReady: 'Секреты', + badgeRequired: 'Требуется секрет', + missingForProfile: ({ env }: { env: string | null }) => + `Не хватает секрета (${env ?? 'секрет'}). Настройте его на машине или выберите/введите секрет.`, + defaultForProfileTitle: 'Секрет по умолчанию', + defineDefaultForProfileTitle: 'Установить секрет по умолчанию для этого профиля', + addSubtitle: 'Добавить сохранённый секрет', + noneTitle: 'Нет', + noneSubtitle: 'Используйте окружение машины или введите секрет для этой сессии', + emptyTitle: 'Нет сохранённых ключей', + emptySubtitle: 'Добавьте секрет, чтобы использовать профили с требованием секрета без переменных окружения на машине.', + savedHiddenSubtitle: 'Сохранён (значение скрыто)', + defaultLabel: 'По умолчанию', + fields: { + name: 'Имя', + value: 'Значение', + }, + placeholders: { + nameExample: 'например, Work OpenAI', + }, + validation: { + nameRequired: 'Имя обязательно.', + valueRequired: 'Значение обязательно.', + }, + actions: { + replace: 'Заменить', + replaceValue: 'Заменить значение', + setDefault: 'Сделать по умолчанию', + unsetDefault: 'Убрать по умолчанию', + }, + prompts: { + renameTitle: 'Переименовать секрет', + renameDescription: 'Обновите понятное имя для этого ключа.', + replaceValueTitle: 'Заменить значение секрета', + replaceValueDescription: 'Вставьте новое значение секрета. После сохранения оно больше не будет показано.', + deleteTitle: 'Удалить секрет', + deleteConfirm: ({ name }: { name: string }) => `Удалить «${name}»? Это нельзя отменить.`, + }, + }, + profiles: { // Profile management feature title: 'Профили', subtitle: 'Управление профилями переменных окружения для сессий', + sessionUses: ({ profile }: { profile: string }) => `Эта сессия использует: ${profile}`, + profilesFixedPerSession: 'Профили фиксированы для каждой сессии. Чтобы использовать другой профиль, начните новую сессию.', noProfile: 'Без Профиля', noProfileDescription: 'Использовать настройки окружения по умолчанию', defaultModel: 'Модель по Умолчанию', @@ -925,9 +1439,234 @@ export const ru: TranslationStructure = { enterTmuxTempDir: 'Введите путь к временному каталогу', tmuxUpdateEnvironment: 'Обновлять окружение автоматически', nameRequired: 'Имя профиля обязательно', - deleteConfirm: 'Вы уверены, что хотите удалить профиль "{name}"?', + deleteConfirm: ({ name }: { name: string }) => `Вы уверены, что хотите удалить профиль "${name}"?`, editProfile: 'Редактировать Профиль', addProfileTitle: 'Добавить Новый Профиль', + builtIn: 'Встроенный', + custom: 'Пользовательский', + builtInSaveAsHint: 'Сохранение встроенного профиля создаёт новый пользовательский профиль.', + builtInNames: { + anthropic: 'Anthropic (по умолчанию)', + deepseek: 'DeepSeek (Рассуждение)', + zai: 'Z.AI (GLM-4.6)', + codex: 'Codex (по умолчанию)', + openai: 'OpenAI (GPT-5)', + azureOpenai: 'Azure OpenAI', + gemini: 'Gemini (по умолчанию)', + geminiApiKey: 'Gemini (API key)', + geminiVertex: 'Gemini (Vertex AI)', + }, + groups: { + favorites: 'Избранное', + custom: 'Ваши профили', + builtIn: 'Встроенные профили', + }, + actions: { + viewEnvironmentVariables: 'Переменные окружения', + addToFavorites: 'Добавить в избранное', + removeFromFavorites: 'Убрать из избранного', + editProfile: 'Редактировать профиль', + duplicateProfile: 'Дублировать профиль', + deleteProfile: 'Удалить профиль', + }, + copySuffix: '(Копия)', + duplicateName: 'Профиль с таким названием уже существует', + setupInstructions: { + title: 'Инструкции по настройке', + viewOfficialGuide: 'Открыть официальное руководство', + }, + machineLogin: { + title: 'Требуется вход на машине', + subtitle: 'Этот профиль использует кэш входа CLI на выбранной машине.', + status: { + loggedIn: 'Вход выполнен', + notLoggedIn: 'Вход не выполнен', + }, + claudeCode: { + title: 'Claude Code', + instructions: 'Запустите `claude`, затем введите `/login`, чтобы войти.', + warning: 'Примечание: установка `ANTHROPIC_AUTH_TOKEN` переопределяет вход через CLI.', + }, + codex: { + title: 'Codex', + instructions: 'Выполните `codex login`, чтобы войти.', + }, + geminiCli: { + title: 'Gemini CLI', + instructions: 'Выполните `gemini auth`, чтобы войти.', + }, + }, + requirements: { + secretRequired: 'Секрет', + configured: 'Настроен на машине', + notConfigured: 'Не настроен', + checking: 'Проверка…', + missingConfigForProfile: ({ env }: { env: string }) => `Этот профиль требует настройки ${env} на машине.`, + modalTitle: 'Требуется секрет', + modalBody: 'Для этого профиля требуется секрет.\n\nДоступные варианты:\n• Использовать окружение машины (рекомендуется)\n• Использовать сохранённый секрет из настроек приложения\n• Ввести секрет только для этой сессии', + sectionTitle: 'Требования', + sectionSubtitle: 'Эти поля используются для предварительной проверки готовности и чтобы избежать неожиданных ошибок.', + secretEnvVarPromptDescription: 'Введите имя обязательной секретной переменной окружения (например, OPENAI_API_KEY).', + modalHelpWithEnv: ({ env }: { env: string }) => `Для этого профиля требуется ${env}. Выберите один вариант ниже.`, + modalHelpGeneric: 'Для этого профиля требуется секрет. Выберите один вариант ниже.', + chooseOptionTitle: 'Выберите вариант', + machineEnvStatus: { + theMachine: 'машине', + checkFor: ({ env }: { env: string }) => `Проверить ${env}`, + checking: ({ env }: { env: string }) => `Проверяем ${env}…`, + found: ({ env, machine }: { env: string; machine: string }) => `${env} найден на ${machine}`, + notFound: ({ env, machine }: { env: string; machine: string }) => `${env} не найден на ${machine}`, + }, + machineEnvSubtitle: { + checking: 'Проверяем окружение демона…', + found: 'Найдено в окружении демона на машине.', + notFound: 'Укажите значение в окружении демона на машине и перезапустите демон.', + }, + options: { + none: { + title: 'Нет', + subtitle: 'Не требует секрета или входа через CLI.', + }, + machineLogin: { + subtitle: 'Требуется вход через CLI на целевой машине.', + longSubtitle: 'Требуется быть авторизованным через CLI для выбранного бэкенда ИИ на целевой машине.', + }, + useMachineEnvironment: { + title: 'Использовать окружение машины', + subtitleWithEnv: ({ env }: { env: string }) => `Использовать ${env} из окружения демона.`, + subtitleGeneric: 'Использовать секрет из окружения демона.', + }, + useSavedSecret: { + title: 'Использовать сохранённый секрет', + subtitle: 'Выберите (или добавьте) сохранённый секрет в приложении.', + }, + enterOnce: { + title: 'Ввести секрет', + subtitle: 'Вставьте секрет только для этой сессии (он не будет сохранён).', + }, + }, + secretEnvVar: { + title: 'Переменная окружения для секрета', + subtitle: 'Введите имя переменной окружения, которую этот провайдер ожидает для секрета (например, OPENAI_API_KEY).', + label: 'Имя переменной окружения', + }, + sections: { + machineEnvironment: 'Окружение машины', + useOnceTitle: 'Использовать один раз', + useOnceLabel: 'Введите секрет', + useOnceFooter: 'Вставьте секрет только для этой сессии. Он не будет сохранён.', + }, + actions: { + useMachineEnvironment: { + subtitle: 'Использовать секрет, который уже есть на машине.', + }, + useOnceButton: 'Использовать один раз (только для сессии)', + }, + }, + defaultSessionType: 'Тип сессии по умолчанию', + defaultPermissionMode: { + title: 'Режим разрешений по умолчанию', + descriptions: { + default: 'Запрашивать разрешения', + acceptEdits: 'Авто-одобрять правки', + plan: 'Планировать перед выполнением', + bypassPermissions: 'Пропускать все разрешения', + }, + }, + aiBackend: { + title: 'Бекенд ИИ', + selectAtLeastOneError: 'Выберите хотя бы один бекенд ИИ.', + claudeSubtitle: 'CLI Claude', + codexSubtitle: 'CLI Codex', + opencodeSubtitle: 'CLI OpenCode', + geminiSubtitleExperimental: 'Gemini CLI (экспериментально)', + auggieSubtitle: 'Auggie CLI', + }, + tmux: { + title: 'Tmux', + spawnSessionsTitle: 'Запускать сессии в Tmux', + spawnSessionsEnabledSubtitle: 'Сессии запускаются в новых окнах tmux.', + spawnSessionsDisabledSubtitle: 'Сессии запускаются в обычной оболочке (без интеграции с tmux)', + isolatedServerTitle: 'Изолированный сервер tmux', + isolatedServerEnabledSubtitle: 'Запускать сессии в изолированном сервере tmux (рекомендуется).', + isolatedServerDisabledSubtitle: 'Запускать сессии в вашем tmux-сервере по умолчанию.', + sessionNamePlaceholder: 'Пусто = текущая/последняя сессия', + tempDirPlaceholder: 'Оставьте пустым для автогенерации', + }, + previewMachine: { + title: 'Предпросмотр машины', + itemTitle: 'Машина предпросмотра для переменных окружения', + selectMachine: 'Выбрать машину', + resolveSubtitle: 'Используется только для предпросмотра вычисленных значений ниже (не меняет то, что сохраняется).', + selectSubtitle: 'Выберите машину, чтобы просмотреть вычисленные значения ниже.', + }, + environmentVariables: { + title: 'Переменные окружения', + addVariable: 'Добавить переменную', + namePlaceholder: 'Имя переменной (например, MY_CUSTOM_VAR)', + valuePlaceholder: 'Значение (например, my-value или ${MY_VAR})', + validation: { + nameRequired: 'Введите имя переменной.', + invalidNameFormat: 'Имена переменных должны содержать заглавные буквы, цифры и подчёркивания и не могут начинаться с цифры.', + duplicateName: 'Такая переменная уже существует.', + }, + card: { + valueLabel: 'Значение:', + fallbackValueLabel: 'Значение по умолчанию:', + valueInputPlaceholder: 'Значение', + defaultValueInputPlaceholder: 'Значение по умолчанию', + fallbackDisabledForVault: 'Fallback отключён при использовании хранилища секретов.', + secretNotRetrieved: 'Секретное значение — не извлекается из соображений безопасности', + secretToggleLabel: 'Скрыть значение в UI', + secretToggleSubtitle: 'Скрывает значение в UI и не извлекает его с машины для предварительного просмотра.', + secretToggleEnforcedByDaemon: 'Принудительно демоном', + secretToggleEnforcedByVault: 'Принудительно хранилищем секретов', + secretToggleResetToAuto: 'Сбросить на авто', + requirementRequiredLabel: 'Обязательно', + requirementRequiredSubtitle: 'Блокирует создание сессии, если переменная отсутствует.', + requirementUseVaultLabel: 'Использовать хранилище секретов', + requirementUseVaultSubtitle: 'Использовать сохранённый секрет (без fallback-значений).', + defaultSecretLabel: 'Секрет по умолчанию', + overridingDefault: ({ expectedValue }: { expectedValue: string }) => + `Переопределение документированного значения: ${expectedValue}`, + useMachineEnvToggle: 'Использовать значение из окружения машины', + resolvedOnSessionStart: 'Разрешается при запуске сессии на выбранной машине.', + sourceVariableLabel: 'Переменная-источник', + sourceVariablePlaceholder: 'Имя переменной-источника (например, Z_AI_MODEL)', + checkingMachine: ({ machine }: { machine: string }) => `Проверка ${machine}...`, + emptyOnMachine: ({ machine }: { machine: string }) => `Пусто на ${machine}`, + emptyOnMachineUsingFallback: ({ machine }: { machine: string }) => + `Пусто на ${machine} (используется значение по умолчанию)`, + notFoundOnMachine: ({ machine }: { machine: string }) => `Не найдено на ${machine}`, + notFoundOnMachineUsingFallback: ({ machine }: { machine: string }) => + `Не найдено на ${machine} (используется значение по умолчанию)`, + valueFoundOnMachine: ({ machine }: { machine: string }) => `Значение найдено на ${machine}`, + differsFromDocumented: ({ expectedValue }: { expectedValue: string }) => + `Отличается от документированного значения: ${expectedValue}`, + }, + preview: { + secretValueHidden: ({ value }: { value: string }) => `${value} — скрыто из соображений безопасности`, + hiddenValue: '***скрыто***', + emptyValue: '(пусто)', + sessionWillReceive: ({ name, value }: { name: string; value: string }) => + `Сессия получит: ${name} = ${value}`, + }, + previewModal: { + titleWithProfile: ({ profileName }: { profileName: string }) => `Переменные окружения · ${profileName}`, + descriptionPrefix: 'Эти переменные окружения отправляются при запуске сессии. Значения разрешаются демоном на', + descriptionFallbackMachine: 'выбранной машине', + descriptionSuffix: '.', + emptyMessage: 'Для этого профиля не заданы переменные окружения.', + checkingSuffix: '(проверка…)', + detail: { + fixed: 'Фиксированное', + machine: 'Машина', + checking: 'Проверка', + fallback: 'По умолчанию', + missing: 'Отсутствует', + }, + }, + }, delete: { title: 'Удалить Профиль', message: ({ name }: { name: string }) => `Вы уверены, что хотите удалить "${name}"? Это действие нельзя отменить.`, diff --git a/expo-app/sources/text/translations/zh-Hans.ts b/expo-app/sources/text/translations/zh-Hans.ts index 0f005f143..3cca5998e 100644 --- a/expo-app/sources/text/translations/zh-Hans.ts +++ b/expo-app/sources/text/translations/zh-Hans.ts @@ -5,7 +5,7 @@ * - Functions with typed object parameters for dynamic text */ -import { TranslationStructure } from "../_default"; +import type { TranslationStructure } from '../_types'; /** * Chinese plural helper function @@ -33,6 +33,8 @@ export const zhHans: TranslationStructure = { common: { // Simple string constants + add: '添加', + actions: '操作', cancel: '取消', authenticate: '认证', save: '保存', @@ -49,7 +51,11 @@ export const zhHans: TranslationStructure = { yes: '是', no: '否', discard: '放弃', + discardChanges: '放弃更改', + unsavedChangesWarning: '你有未保存的更改。', + keepEditing: '继续编辑', version: '版本', + details: '详情', copied: '已复制', copy: '复制', scanning: '扫描中...', @@ -62,6 +68,18 @@ export const zhHans: TranslationStructure = { retry: '重试', delete: '删除', optional: '可选的', + noMatches: '无匹配结果', + all: '全部', + machine: '机器', + clearSearch: '清除搜索', + refresh: '刷新', + }, + + dropdown: { + category: { + general: '常规', + results: '结果', + }, }, profile: { @@ -98,6 +116,16 @@ export const zhHans: TranslationStructure = { enterSecretKey: '请输入密钥', invalidSecretKey: '无效的密钥,请检查后重试。', enterUrlManually: '手动输入 URL', + openMachine: '打开机器', + terminalUrlPlaceholder: 'happy://terminal?...', + restoreQrInstructions: '1. 在你的手机上打开 Happy\n2. 前往 设置 → 账户\n3. 点击“链接新设备”\n4. 扫描此二维码', + restoreWithSecretKeyInstead: '改用密钥恢复', + restoreWithSecretKeyDescription: '输入你的密钥以恢复账户访问权限。', + secretKeyPlaceholder: 'XXXXX-XXXXX-XXXXX...', + unsupported: { + connectTitle: ({ name }: { name: string }) => `连接 ${name}`, + runCommandInTerminal: '在终端中运行以下命令:', + }, }, settings: { @@ -138,6 +166,12 @@ export const zhHans: TranslationStructure = { usageSubtitle: '查看 API 使用情况和费用', profiles: '配置文件', profilesSubtitle: '管理环境配置文件和变量', + secrets: '机密', + secretsSubtitle: '管理已保存的机密(输入后将不再显示)', + terminal: '终端', + session: '会话', + sessionSubtitleTmuxEnabled: '已启用 Tmux', + sessionSubtitleMessageSendingAndTmux: '消息发送与 tmux', // Dynamic settings messages accountConnected: ({ service }: { service: string }) => `已连接 ${service} 账户`, @@ -175,6 +209,21 @@ export const zhHans: TranslationStructure = { wrapLinesInDiffsDescription: '在差异视图中换行显示长行而不是水平滚动', alwaysShowContextSize: '始终显示上下文大小', alwaysShowContextSizeDescription: '即使未接近限制时也显示上下文使用情况', + agentInputActionBarLayout: '输入操作栏', + agentInputActionBarLayoutDescription: '选择在输入框上方如何显示操作标签', + agentInputActionBarLayoutOptions: { + auto: '自动', + wrap: '换行', + scroll: '可滚动', + collapsed: '折叠', + }, + agentInputChipDensity: '操作标签密度', + agentInputChipDensityDescription: '选择操作标签显示文字还是图标', + agentInputChipDensityOptions: { + auto: '自动', + labels: '文字', + icons: '仅图标', + }, avatarStyle: '头像风格', avatarStyleDescription: '选择会话头像外观', avatarOptions: { @@ -195,8 +244,28 @@ export const zhHans: TranslationStructure = { experimentalFeatures: '实验功能', experimentalFeaturesEnabled: '实验功能已启用', experimentalFeaturesDisabled: '仅使用稳定功能', - webFeatures: 'Web 功能', - webFeaturesDescription: '仅在应用的 Web 版本中可用的功能。', + experimentalOptions: '实验选项', + experimentalOptionsDescription: '选择启用哪些实验功能。', + expUsageReporting: 'Usage reporting', + expUsageReportingSubtitle: 'Enable usage and token reporting screens', + expFileViewer: 'File viewer', + expFileViewerSubtitle: 'Enable the session file viewer entrypoint', + expShowThinkingMessages: 'Show thinking messages', + expShowThinkingMessagesSubtitle: 'Show assistant thinking/status messages in chat', + expSessionType: 'Session type selector', + expSessionTypeSubtitle: 'Show the session type selector (simple vs worktree)', + expZen: 'Zen', + expZenSubtitle: 'Enable the Zen navigation entry', + expVoiceAuthFlow: 'Voice auth flow', + expVoiceAuthFlowSubtitle: 'Use authenticated voice token flow (paywall-aware)', + expInboxFriends: '收件箱与好友', + expInboxFriendsSubtitle: '启用收件箱标签页和好友功能', + expCodexResume: '恢复 Codex', + expCodexResumeSubtitle: '启用使用单独安装的 Codex 来恢复会话(实验性)', + expCodexAcp: 'Codex ACP', + expCodexAcpSubtitle: '使用 ACP(codex-acp)来运行 Codex,而不是 MCP(实验性)', + webFeatures: 'Web 功能', + webFeaturesDescription: '仅在应用的 Web 版本中可用的功能。', enterToSend: '回车发送', enterToSendEnabled: '按回车发送(Shift+回车换行)', enterToSendDisabled: '回车换行', @@ -207,9 +276,20 @@ export const zhHans: TranslationStructure = { markdownCopyV2Subtitle: '长按打开复制模态框', hideInactiveSessions: '隐藏非活跃会话', hideInactiveSessionsSubtitle: '仅在列表中显示活跃的聊天', + groupInactiveSessionsByProject: '按项目分组非活跃会话', + groupInactiveSessionsByProjectSubtitle: '按项目整理非活跃聊天', enhancedSessionWizard: '增强会话向导', enhancedSessionWizardEnabled: '配置文件优先启动器已激活', enhancedSessionWizardDisabled: '使用标准会话启动器', + profiles: 'AI 配置文件', + profilesEnabled: '已启用配置文件选择', + profilesDisabled: '已禁用配置文件选择', + pickerSearch: '选择器搜索', + pickerSearchSubtitle: '在设备和路径选择器中显示搜索框', + machinePickerSearch: '设备搜索', + machinePickerSearchSubtitle: '在设备选择器中显示搜索框', + pathPickerSearch: '路径搜索', + pathPickerSearchSubtitle: '在路径选择器中显示搜索框', }, errors: { @@ -257,11 +337,85 @@ export const zhHans: TranslationStructure = { failedToRemoveFriend: '删除好友失败', searchFailed: '搜索失败。请重试。', failedToSendRequest: '发送好友请求失败', + failedToResumeSession: '恢复会话失败', + failedToSendMessage: '发送消息失败', + cannotShareWithSelf: '不能与自己分享', + canOnlyShareWithFriends: '只能与好友分享', + shareNotFound: '未找到分享', + publicShareNotFound: '公开分享未找到或已过期', + consentRequired: '需要同意才能访问', + maxUsesReached: '已达到最大使用次数', + invalidShareLink: '无效或已过期的共享链接', + missingPermissionId: '缺少权限请求 ID', + codexResumeNotInstalledTitle: '此机器未安装 Codex resume', + codexResumeNotInstalledMessage: + '要恢复 Codex 对话,请在目标机器上安装 Codex resume 服务器(机器详情 → Codex resume)。', + codexAcpNotInstalledTitle: '此机器未安装 Codex ACP', + codexAcpNotInstalledMessage: + '要使用 Codex ACP 实验功能,请在目标机器上安装 codex-acp(机器详情 → Codex ACP),或关闭实验开关。', + }, + + deps: { + installNotSupported: '请更新 Happy CLI 以安装此依赖项。', + installFailed: '安装失败', + installed: '已安装', + installLog: ({ path }: { path: string }) => `安装日志:${path}`, + installable: { + codexResume: { + title: 'Codex 恢复服务器', + installSpecTitle: 'Codex resume 安装来源', + }, + codexAcp: { + title: 'Codex ACP 适配器', + installSpecTitle: 'Codex ACP 安装来源', + }, + installSpecDescription: '传给 `npm install` 的 NPM/Git/文件规格(实验性)。留空则使用守护进程默认值。', + }, + ui: { + notAvailable: '不可用', + notAvailableUpdateCli: '不可用(请更新 CLI)', + errorRefresh: '错误(刷新)', + installed: '已安装', + installedWithVersion: ({ version }: { version: string }) => `已安装(v${version})`, + installedUpdateAvailable: ({ installedVersion, latestVersion }: { installedVersion: string; latestVersion: string }) => + `已安装(v${installedVersion})— 有更新(v${latestVersion})`, + notInstalled: '未安装', + latest: '最新', + latestSubtitle: ({ version, tag }: { version: string; tag: string }) => `${version}(标签:${tag})`, + registryCheck: '注册表检查', + registryCheckFailed: ({ error }: { error: string }) => `失败:${error}`, + installSource: '安装来源', + installSourceDefault: '(默认)', + installSpecPlaceholder: '例如 file:/path/to/pkg 或 github:owner/repo#branch', + lastInstallLog: '上次安装日志', + installLogTitle: '安装日志', + }, }, newSession: { // Used by new-session screen and launch flows title: '启动新会话', + selectAiProfileTitle: '选择 AI 配置', + selectAiProfileDescription: '选择一个 AI 配置,以将环境变量和默认值应用到会话。', + changeProfile: '更改配置', + aiBackendSelectedByProfile: 'AI 后端由所选配置决定。如需更改,请选择其他配置。', + selectAiBackendTitle: '选择 AI 后端', + aiBackendLimitedByProfileAndMachineClis: '受所选配置和此设备上可用的 CLI 限制。', + aiBackendSelectWhichAiRuns: '选择由哪个 AI 运行会话。', + aiBackendNotCompatibleWithSelectedProfile: '与所选配置不兼容。', + aiBackendCliNotDetectedOnMachine: ({ cli }: { cli: string }) => `此设备未检测到 ${cli} CLI。`, + selectMachineTitle: '选择设备', + selectMachineDescription: '选择此会话运行的位置。', + selectPathTitle: '选择路径', + selectWorkingDirectoryTitle: '选择工作目录', + selectWorkingDirectoryDescription: '选择用于命令和上下文的文件夹。', + selectPermissionModeTitle: '选择权限模式', + selectPermissionModeDescription: '控制操作需要批准的严格程度。', + selectModelTitle: '选择 AI 模型', + selectModelDescription: '选择此会话使用的模型。', + selectSessionTypeTitle: '选择会话类型', + selectSessionTypeDescription: '选择简单会话或与 Git worktree 关联的会话。', + searchPathsPlaceholder: '搜索路径...', noMachinesFound: '未找到设备。请先在您的计算机上启动 Happy 会话。', allMachinesOffline: '所有设备似乎都已离线', machineDetails: '查看设备详情 →', @@ -277,18 +431,94 @@ export const zhHans: TranslationStructure = { notConnectedToServer: '未连接到服务器。请检查您的网络连接。', noMachineSelected: '请选择一台设备以启动会话', noPathSelected: '请选择一个目录以启动会话', + machinePicker: { + searchPlaceholder: '搜索设备...', + recentTitle: '最近', + favoritesTitle: '收藏', + allTitle: '全部', + emptyMessage: '没有可用设备', + }, + pathPicker: { + enterPathTitle: '输入路径', + enterPathPlaceholder: '输入路径...', + customPathTitle: '自定义路径', + recentTitle: '最近', + favoritesTitle: '收藏', + suggestedTitle: '推荐', + allTitle: '全部', + emptyRecent: '没有最近的路径', + emptyFavorites: '没有收藏的路径', + emptySuggested: '没有推荐的路径', + emptyAll: '没有路径', + }, sessionType: { title: '会话类型', simple: '简单', - worktree: 'Worktree', + worktree: 'Worktree(Git)', comingSoon: '即将推出', }, + profileAvailability: { + requiresAgent: ({ agent }: { agent: string }) => `需要 ${agent}`, + cliNotDetected: ({ cli }: { cli: string }) => `未检测到 ${cli} CLI`, + }, + cliBanners: { + cliNotDetectedTitle: ({ cli }: { cli: string }) => `${cli} CLI 未检测到`, + dontShowFor: '不再显示此提示:', + thisMachine: '此设备', + anyMachine: '所有设备', + installCommand: ({ command }: { command: string }) => `安装:${command} •`, + installCliIfAvailable: ({ cli }: { cli: string }) => `如可用请安装 ${cli} CLI •`, + viewInstallationGuide: '查看安装指南 →', + viewGeminiDocs: '查看 Gemini 文档 →', + }, worktree: { creating: ({ name }: { name: string }) => `正在创建 worktree '${name}'...`, notGitRepo: 'Worktree 需要 git 仓库', failed: ({ error }: { error: string }) => `创建 worktree 失败:${error}`, success: 'Worktree 创建成功', - } + }, + resume: { + title: '恢复会话', + optional: '恢复:可选', + pickerTitle: '恢复会话', + subtitle: ({ agent }: { agent: string }) => `粘贴 ${agent} 会话 ID 以恢复`, + placeholder: ({ agent }: { agent: string }) => `粘贴 ${agent} 会话 ID…`, + paste: '粘贴', + save: '保存', + clearAndRemove: '清除', + helpText: '你可以在“会话信息”页面找到会话 ID。', + cannotApplyBody: '此恢复 ID 当前无法应用。Happy 将改为启动一个新会话。', + }, + codexResumeBanner: { + title: 'Codex resume', + updateAvailable: '有可用更新', + systemCodexVersion: ({ version }: { version: string }) => `系统 codex:${version}`, + resumeServerVersion: ({ version }: { version: string }) => `Codex resume 服务器:${version}`, + notInstalled: '未安装', + latestVersion: ({ version }: { version: string }) => `(最新 ${version})`, + registryCheckFailed: ({ error }: { error: string }) => `注册表检查失败:${error}`, + install: '安装', + update: '更新', + reinstall: '重新安装', + }, + codexResumeInstallModal: { + installTitle: '安装 Codex resume?', + updateTitle: '更新 Codex resume?', + reinstallTitle: '重新安装 Codex resume?', + description: '这将安装一个仅用于恢复操作的实验性 Codex MCP 服务器封装。', + }, + codexAcpBanner: { + title: 'Codex ACP', + install: '安装', + update: '更新', + reinstall: '重新安装', + }, + codexAcpInstallModal: { + installTitle: '安装 Codex ACP?', + updateTitle: '更新 Codex ACP?', + reinstallTitle: '重新安装 Codex ACP?', + description: '这将安装一个围绕 Codex 的实验性 ACP 适配器,用于加载/恢复线程。', + }, }, sessionHistory: { @@ -303,10 +533,98 @@ export const zhHans: TranslationStructure = { session: { inputPlaceholder: '输入消息...', + resuming: '正在恢复...', + resumeFailed: '恢复会话失败', + resumeSupportNoteChecking: '注意:Happy 仍在检查此机器是否可以恢复提供方会话。', + resumeSupportNoteUnverified: '注意:Happy 无法验证此机器的恢复支持情况。', + resumeSupportDetails: { + cliNotDetected: '未在机器上检测到 CLI。', + capabilityProbeFailed: '能力检查失败。', + acpProbeFailed: 'ACP 检查失败。', + loadSessionFalse: '代理不支持加载会话。', + }, + inactiveResumable: '未激活(可恢复)', + inactiveMachineOffline: '未激活(机器离线)', + inactiveNotResumable: '未激活', + inactiveNotResumableNoticeTitle: '此会话无法恢复', + inactiveNotResumableNoticeBody: ({ provider }: { provider: string }) => + `此会话已结束,且由于 ${provider} 不支持在此处恢复其上下文,因此无法恢复。请开始新会话以继续。`, + machineOfflineNoticeTitle: '机器离线', + machineOfflineNoticeBody: ({ machine }: { machine: string }) => + `“${machine}” 处于离线状态,因此 Happy 目前无法恢复此会话。请将机器恢复在线后继续。`, + machineOfflineCannotResume: '机器离线。请将其恢复在线后再恢复此会话。', + sharing: { + title: '共享', + directSharing: '直接共享', + addShare: '与好友共享', + accessLevel: '访问级别', + shareWith: '共享给', + sharedWith: '已共享给', + noShares: '未共享', + viewOnly: '仅查看', + viewOnlyDescription: '可查看会话,但无法发送消息。', + viewOnlyMode: '仅查看(共享会话)', + noEditPermission: '您对此会话只有只读访问权限。', + canEdit: '可编辑', + canEditDescription: '可发送消息。', + canManage: '可管理', + canManageDescription: '可管理共享设置。', + stopSharing: '停止分享', + recipientMissingKeys: '此用户尚未注册加密密钥。', + + publicLink: '公开链接', + publicLinkActive: '公开链接已启用', + publicLinkDescription: '创建一个任何人都可以查看此会话的链接。', + createPublicLink: '创建公开链接', + regeneratePublicLink: '重新生成公开链接', + deletePublicLink: '删除公开链接', + linkToken: '链接令牌', + tokenNotRecoverable: '令牌不可用', + tokenNotRecoverableDescription: '出于安全原因,公开链接令牌以哈希形式存储,无法恢复。请重新生成链接以创建新令牌。', + + expiresIn: '有效期', + expiresOn: '到期日期', + days7: '7 天', + days30: '30 天', + never: '永不过期', + + maxUsesLabel: '最大使用次数', + unlimited: '无限制', + uses10: '10 次使用', + uses50: '50 次使用', + usageCount: '使用次数', + usageCountWithMax: ({ used, max }: { used: number; max: number }) => `${used}/${max} 次使用`, + usageCountUnlimited: ({ used }: { used: number }) => `${used} 次使用`, + + requireConsent: '需要同意', + requireConsentDescription: '在记录访问前请求同意。', + consentRequired: '需要同意', + consentDescription: '此链接需要您同意记录您的 IP 地址和用户代理。', + acceptAndView: '同意并查看', + sharedBy: ({ name }: { name: string }) => `由 ${name} 分享`, + + shareNotFound: '共享链接不存在或已过期', + failedToDecrypt: '无法解密会话', + noMessages: '暂无消息', + session: '会话', + }, }, commandPalette: { placeholder: '输入命令或搜索...', + noCommandsFound: '未找到命令', + }, + + commandView: { + completedWithNoOutput: '[命令完成且无输出]', + }, + + voiceAssistant: { + connecting: '连接中...', + active: '语音助手已启用', + connectionError: '连接错误', + label: '语音助手', + tapToEnd: '点击结束', }, server: { @@ -338,8 +656,18 @@ export const zhHans: TranslationStructure = { happySessionId: 'Happy 会话 ID', claudeCodeSessionId: 'Claude Code 会话 ID', claudeCodeSessionIdCopied: 'Claude Code 会话 ID 已复制到剪贴板', + aiProfile: 'AI 配置文件', aiProvider: 'AI 提供商', failedToCopyClaudeCodeSessionId: '复制 Claude Code 会话 ID 失败', + codexSessionId: 'Codex 会话 ID', + codexSessionIdCopied: 'Codex 会话 ID 已复制到剪贴板', + failedToCopyCodexSessionId: '复制 Codex 会话 ID 失败', + opencodeSessionId: 'OpenCode 会话 ID', + opencodeSessionIdCopied: 'OpenCode 会话 ID 已复制到剪贴板', + geminiSessionId: 'Gemini 会话 ID', + geminiSessionIdCopied: 'Gemini 会话 ID 已复制到剪贴板', + auggieSessionId: 'Auggie 会话 ID', + auggieSessionIdCopied: 'Auggie 会话 ID 已复制到剪贴板', metadataCopied: '元数据已复制到剪贴板', failedToCopyMetadata: '复制元数据失败', failedToKillSession: '终止会话失败', @@ -349,6 +677,7 @@ export const zhHans: TranslationStructure = { lastUpdated: '最后更新', sequence: '序列', quickActions: '快速操作', + copyResumeCommand: '复制恢复命令', viewMachine: '查看设备', viewMachineSubtitle: '查看设备详情和会话', killSessionSubtitle: '立即终止会话', @@ -359,8 +688,14 @@ export const zhHans: TranslationStructure = { operatingSystem: '操作系统', processId: '进程 ID', happyHome: 'Happy 主目录', + attachFromTerminal: '从终端附加', + tmuxTarget: 'tmux 目标', + tmuxFallback: 'tmux 回退', copyMetadata: '复制元数据', agentState: 'Agent 状态', + rawJsonDevMode: '原始 JSON(开发者模式)', + sessionStatus: '会话状态', + fullSessionObject: '完整会话对象', controlledByUser: '用户控制', pendingRequests: '待处理请求', activity: '活动', @@ -377,7 +712,14 @@ export const zhHans: TranslationStructure = { deleteSessionWarning: '此操作无法撤销。与此会话相关的所有消息和数据将被永久删除。', failedToDeleteSession: '删除会话失败', sessionDeleted: '会话删除成功', - + manageSharing: '管理共享', + manageSharingSubtitle: '与好友共享此会话或创建公开链接', + renameSession: '重命名会话', + renameSessionSubtitle: '更改此会话的显示名称', + renameSessionPlaceholder: '输入会话名称...', + failedToRenameSession: '重命名会话失败', + sessionRenamed: '会话重命名成功', + }, components: { @@ -388,16 +730,57 @@ export const zhHans: TranslationStructure = { runIt: '运行它', scanQrCode: '扫描二维码', openCamera: '打开相机', + installCommand: '$ npm i -g happy-coder', + runCommand: '$ happy', + }, + emptyMessages: { + noMessagesYet: '暂无消息', + created: ({ time }: { time: string }) => `创建于 ${time}`, + }, + emptySessionsTablet: { + noActiveSessions: '没有活动会话', + startNewSessionDescription: '在任意已连接设备上开始新的会话。', + startNewSessionButton: '开始新会话', + openTerminalToStart: '在电脑上打开新的终端以开始会话。', + }, + }, + + zen: { + title: 'Zen', + add: { + placeholder: '需要做什么?', + }, + home: { + noTasksYet: '还没有任务。点按 + 添加一个。', + }, + view: { + workOnTask: '处理任务', + clarify: '澄清', + delete: '删除', + linkedSessions: '已关联的会话', + tapTaskTextToEdit: '点击任务文本以编辑', }, }, agentInput: { + envVars: { + title: '环境变量', + titleWithCount: ({ count }: { count: number }) => `环境变量 (${count})`, + }, + resumeChip: { + withId: ({ title, id }: { title: string; id: string }) => `${title}: ${id}`, + withIdTruncated: ({ title, prefix, suffix }: { title: string; prefix: string; suffix: string }) => + `${title}: ${prefix}…${suffix}`, + }, permissionMode: { title: '权限模式', default: '默认', acceptEdits: '接受编辑', plan: '计划模式', bypassPermissions: 'Yolo 模式', + badgeAccept: '接受', + badgePlan: '计划', + badgeYolo: 'YOLO', badgeAcceptAllEdits: '接受所有编辑', badgeBypassAllPermissions: '绕过所有权限', badgePlanMode: '计划模式', @@ -405,7 +788,13 @@ export const zhHans: TranslationStructure = { agent: { claude: 'Claude', codex: 'Codex', + opencode: 'OpenCode', gemini: 'Gemini', + auggie: 'Auggie', + }, + auggieIndexingChip: { + on: '已开启索引', + off: '已关闭索引', }, model: { title: '模型', @@ -414,22 +803,22 @@ export const zhHans: TranslationStructure = { codexPermissionMode: { title: 'CODEX 权限模式', default: 'CLI 设置', - readOnly: 'Read Only Mode', - safeYolo: 'Safe YOLO', + readOnly: '只读模式', + safeYolo: '安全 YOLO', yolo: 'YOLO', - badgeReadOnly: 'Read Only Mode', - badgeSafeYolo: 'Safe YOLO', + badgeReadOnly: '只读', + badgeSafeYolo: '安全 YOLO', badgeYolo: 'YOLO', }, codexModel: { - title: 'CODEX MODEL', - gpt5CodexLow: 'gpt-5-codex low', - gpt5CodexMedium: 'gpt-5-codex medium', - gpt5CodexHigh: 'gpt-5-codex high', - gpt5Minimal: 'GPT-5 Minimal', - gpt5Low: 'GPT-5 Low', - gpt5Medium: 'GPT-5 Medium', - gpt5High: 'GPT-5 High', + title: 'CODEX 模型', + gpt5CodexLow: 'gpt-5-codex 低', + gpt5CodexMedium: 'gpt-5-codex 中', + gpt5CodexHigh: 'gpt-5-codex 高', + gpt5Minimal: 'GPT-5 最小', + gpt5Low: 'GPT-5 低', + gpt5Medium: 'GPT-5 中', + gpt5High: 'GPT-5 高', }, geminiPermissionMode: { title: 'GEMINI 权限模式', @@ -441,6 +830,21 @@ export const zhHans: TranslationStructure = { badgeSafeYolo: '安全 YOLO', badgeYolo: 'YOLO', }, + geminiModel: { + title: 'GEMINI 模型', + gemini25Pro: { + label: 'Gemini 2.5 Pro', + description: '最强能力', + }, + gemini25Flash: { + label: 'Gemini 2.5 Flash', + description: '快速且高效', + }, + gemini25FlashLite: { + label: 'Gemini 2.5 Flash Lite', + description: '最快', + }, + }, context: { remaining: ({ percent }: { percent: number }) => `剩余 ${percent}%`, }, @@ -448,6 +852,11 @@ export const zhHans: TranslationStructure = { fileLabel: '文件', folderLabel: '文件夹', }, + actionMenu: { + title: '操作', + files: '文件', + stop: '停止', + }, noMachinesAvailable: '无设备', }, @@ -506,6 +915,10 @@ export const zhHans: TranslationStructure = { applyChanges: '更新文件', viewDiff: '当前文件更改', question: '问题', + changeTitle: '更改标题', + }, + geminiExecute: { + cwd: ({ cwd }: { cwd: string }) => `📁 ${cwd}`, }, desc: { terminalCmd: ({ cmd }: { cmd: string }) => `终端(命令: ${cmd})`, @@ -527,7 +940,19 @@ export const zhHans: TranslationStructure = { askUserQuestion: { submit: '提交答案', multipleQuestions: ({ count }: { count: number }) => `${count} 个问题`, - } + }, + exitPlanMode: { + approve: '批准计划', + reject: '拒绝', + requestChanges: '请求修改', + requestChangesPlaceholder: '告诉 Claude 你希望如何修改这个计划…', + requestChangesSend: '发送反馈', + requestChangesEmpty: '请填写你希望修改的内容。', + requestChangesFailed: '请求修改失败,请重试。', + responded: '已发送回复', + approvalMessage: '我批准这个计划。请继续实现。', + rejectionMessage: '我不批准这个计划。请修改它,或问我希望做哪些更改。', + }, }, files: { @@ -668,6 +1093,11 @@ export const zhHans: TranslationStructure = { deviceLinkedSuccessfully: '设备链接成功', terminalConnectedSuccessfully: '终端连接成功', invalidAuthUrl: '无效的认证 URL', + microphoneAccessRequiredTitle: '需要麦克风权限', + microphoneAccessRequiredRequestPermission: 'Happy 需要访问你的麦克风用于语音聊天。出现提示时请授予权限。', + microphoneAccessRequiredEnableInSettings: 'Happy 需要访问你的麦克风用于语音聊天。请在设备设置中启用麦克风权限。', + microphoneAccessRequiredBrowserInstructions: '请在浏览器设置中允许麦克风访问。你可能需要点击地址栏中的锁形图标,并为此网站启用麦克风权限。', + openSettings: '打开设置', developerMode: '开发者模式', developerModeEnabled: '开发者模式已启用', developerModeDisabled: '开发者模式已禁用', @@ -722,6 +1152,15 @@ export const zhHans: TranslationStructure = { daemon: '守护进程', status: '状态', stopDaemon: '停止守护进程', + stopDaemonConfirmTitle: '停止守护进程?', + stopDaemonConfirmBody: '在您重新启动电脑上的守护进程之前,您将无法在此设备上创建新会话。当前会话将保持运行。', + daemonStoppedTitle: '守护进程已停止', + stopDaemonFailed: '停止守护进程失败。它可能未在运行。', + renameTitle: '重命名设备', + renameDescription: '为此设备设置自定义名称。留空则使用默认主机名。', + renamePlaceholder: '输入设备名称', + renamedSuccess: '设备重命名成功', + renameFailed: '设备重命名失败', lastKnownPid: '最后已知 PID', lastKnownHttpPort: '最后已知 HTTP 端口', startedAt: '启动时间', @@ -738,20 +1177,40 @@ export const zhHans: TranslationStructure = { lastSeen: '最后活跃', never: '从未', metadataVersion: '元数据版本', + detectedClis: '已检测到的 CLI', + detectedCliNotDetected: '未检测到', + detectedCliUnknown: '未知', + detectedCliNotSupported: '不支持(请更新 happy-cli)', untitledSession: '无标题会话', back: '返回', + notFound: '未找到设备', + unknownMachine: '未知设备', + unknownPath: '未知路径', + tmux: { + overrideTitle: '覆盖全局 tmux 设置', + overrideEnabledSubtitle: '自定义 tmux 设置将应用于此设备上的新会话。', + overrideDisabledSubtitle: '新会话使用全局 tmux 设置。', + notDetectedSubtitle: '此设备未检测到 tmux。', + notDetectedMessage: '此设备未检测到 tmux。请安装 tmux 并刷新检测。', + }, }, message: { switchedToMode: ({ mode }: { mode: string }) => `已切换到 ${mode} 模式`, + discarded: '已丢弃', unknownEvent: '未知事件', usageLimitUntil: ({ time }: { time: string }) => `使用限制到 ${time}`, unknownTime: '未知时间', }, + chatFooter: { + permissionsTerminalOnly: '权限仅在终端中显示。重置或发送消息即可从应用中控制。', + }, + codex: { // Codex permission dialog buttons permissions: { + yesAlwaysAllowCommand: '是,全局永久允许', yesForSession: '是,并且本次会话不再询问', stopAndExplain: '停止,并说明该做什么', } @@ -762,6 +1221,9 @@ export const zhHans: TranslationStructure = { permissions: { yesAllowAllEdits: '是,允许本次会话的所有编辑', yesForTool: '是,不再询问此工具', + yesForCommandPrefix: '是,不再询问此命令前缀', + yesForSubcommand: '是,不再询问此子命令', + yesForCommandName: '是,不再询问此命令', noTellClaude: '否,提供反馈', } }, @@ -775,6 +1237,7 @@ export const zhHans: TranslationStructure = { textCopied: '文本已复制到剪贴板', failedToCopy: '复制文本到剪贴板失败', noTextToCopy: '没有可复制的文本', + failedToOpen: '无法打开文本选择。请重试。', }, markdown: { @@ -794,11 +1257,14 @@ export const zhHans: TranslationStructure = { edit: '编辑工件', delete: '删除', updateError: '更新工件失败。请重试。', + deleteError: '删除工件失败。请重试。', notFound: '未找到工件', discardChanges: '放弃更改?', discardChangesDescription: '您有未保存的更改。确定要放弃它们吗?', deleteConfirm: '删除工件?', deleteConfirmDescription: '此工件将被永久删除。', + noContent: '无内容', + untitled: '未命名', titlePlaceholder: '工件标题', bodyPlaceholder: '在此输入内容...', save: '保存', @@ -815,6 +1281,8 @@ export const zhHans: TranslationStructure = { // Friends feature title: '好友', manageFriends: '管理您的好友和连接', + sharedSessions: '共享会话', + noSharedSessions: '暂无共享会话', searchTitle: '查找好友', pendingRequests: '好友请求', myFriends: '我的好友', @@ -879,6 +1347,8 @@ export const zhHans: TranslationStructure = { profiles: { title: '配置文件', subtitle: '管理您的配置文件', + sessionUses: ({ profile }: { profile: string }) => `此会话使用:${profile}`, + profilesFixedPerSession: '配置文件在每个会话中是固定的。要使用不同的配置文件,请启动新会话。', noProfile: '无配置文件', noProfileDescription: '创建配置文件以管理您的环境设置', addProfile: '添加配置文件', @@ -896,8 +1366,231 @@ export const zhHans: TranslationStructure = { tmuxTempDir: 'tmux 临时目录', enterTmuxTempDir: '输入 tmux 临时目录', tmuxUpdateEnvironment: '更新 tmux 环境', - deleteConfirm: '确定要删除此配置文件吗?', + deleteConfirm: ({ name }: { name: string }) => `确定要删除配置文件“${name}”吗?`, nameRequired: '配置文件名称为必填项', + builtIn: '内置', + custom: '自定义', + builtInSaveAsHint: '保存内置配置文件会创建一个新的自定义配置文件。', + builtInNames: { + anthropic: 'Anthropic(默认)', + deepseek: 'DeepSeek(推理)', + zai: 'Z.AI (GLM-4.6)', + codex: 'Codex(默认)', + openai: 'OpenAI (GPT-5)', + azureOpenai: 'Azure OpenAI', + gemini: 'Gemini(默认)', + geminiApiKey: 'Gemini(API 密钥)', + geminiVertex: 'Gemini (Vertex AI)', + }, + groups: { + favorites: '收藏', + custom: '你的配置文件', + builtIn: '内置配置文件', + }, + actions: { + viewEnvironmentVariables: '环境变量', + addToFavorites: '添加到收藏', + removeFromFavorites: '从收藏中移除', + editProfile: '编辑配置文件', + duplicateProfile: '复制配置文件', + deleteProfile: '删除配置文件', + }, + copySuffix: '(副本)', + duplicateName: '已存在同名配置文件', + setupInstructions: { + title: '设置说明', + viewOfficialGuide: '查看官方设置指南', + }, + machineLogin: { + title: '需要在设备上登录', + subtitle: '此配置文件依赖所选设备上的 CLI 登录缓存。', + status: { + loggedIn: '已登录', + notLoggedIn: '未登录', + }, + claudeCode: { + title: 'Claude Code', + instructions: '运行 `claude`,然后输入 `/login` 登录。', + warning: '注意:设置 `ANTHROPIC_AUTH_TOKEN` 会覆盖 CLI 登录。', + }, + codex: { + title: 'Codex', + instructions: '运行 `codex login` 登录。', + }, + geminiCli: { + title: 'Gemini CLI', + instructions: '运行 `gemini auth` 登录。', + }, + }, + requirements: { + secretRequired: '机密', + configured: '已在设备上配置', + notConfigured: '未配置', + checking: '检查中…', + missingConfigForProfile: ({ env }: { env: string }) => `此配置文件要求在本机配置 ${env}。`, + modalTitle: '需要机密', + modalBody: '此配置需要机密。\n\n支持的选项:\n• 使用设备环境(推荐)\n• 使用应用设置中保存的机密\n• 仅为本次会话输入机密', + sectionTitle: '要求', + sectionSubtitle: '这些字段用于预检查就绪状态,避免意外失败。', + secretEnvVarPromptDescription: '输入所需的秘密环境变量名称(例如 OPENAI_API_KEY)。', + modalHelpWithEnv: ({ env }: { env: string }) => `此配置需要 ${env}。请选择下面的一个选项。`, + modalHelpGeneric: '此配置需要机密。请选择下面的一个选项。', + chooseOptionTitle: '选择一个选项', + machineEnvStatus: { + theMachine: '设备', + checkFor: ({ env }: { env: string }) => `检查 ${env}`, + checking: ({ env }: { env: string }) => `正在检查 ${env}…`, + found: ({ env, machine }: { env: string; machine: string }) => `在${machine}上找到 ${env}`, + notFound: ({ env, machine }: { env: string; machine: string }) => `在${machine}上未找到 ${env}`, + }, + machineEnvSubtitle: { + checking: '正在检查守护进程环境…', + found: '已在设备的守护进程环境中找到。', + notFound: '请在设备的守护进程环境中设置它并重启守护进程。', + }, + options: { + none: { + title: '无', + subtitle: '不需要机密或 CLI 登录。', + }, + machineLogin: { + subtitle: '需要在目标设备上通过 CLI 登录。', + longSubtitle: '需要在目标设备上登录到所选 AI 后端的 CLI。', + }, + useMachineEnvironment: { + title: '使用设备环境', + subtitleWithEnv: ({ env }: { env: string }) => `从守护进程环境中使用 ${env}。`, + subtitleGeneric: '从守护进程环境中使用机密。', + }, + useSavedSecret: { + title: '使用已保存的机密', + subtitle: '在应用中选择(或添加)一个已保存的机密。', + }, + enterOnce: { + title: '输入机密', + subtitle: '仅为本次会话粘贴机密(不会保存)。', + }, + }, + secretEnvVar: { + title: '机密环境变量', + subtitle: '输入此提供方期望的机密环境变量名(例如 OPENAI_API_KEY)。', + label: '环境变量名', + }, + sections: { + machineEnvironment: '设备环境', + useOnceTitle: '仅使用一次', + useOnceLabel: '输入机密', + useOnceFooter: '仅为本次会话粘贴机密。不会保存。', + }, + actions: { + useMachineEnvironment: { + subtitle: '使用设备上已存在的密钥开始。', + }, + useOnceButton: '仅使用一次(仅本次会话)', + }, + }, + defaultSessionType: '默认会话类型', + defaultPermissionMode: { + title: '默认权限模式', + descriptions: { + default: '询问权限', + acceptEdits: '自动批准编辑', + plan: '执行前先规划', + bypassPermissions: '跳过所有权限', + }, + }, + aiBackend: { + title: 'AI 后端', + selectAtLeastOneError: '至少选择一个 AI 后端。', + claudeSubtitle: 'Claude 命令行', + codexSubtitle: 'Codex 命令行', + opencodeSubtitle: 'OpenCode 命令行', + geminiSubtitleExperimental: 'Gemini 命令行(实验)', + auggieSubtitle: 'Auggie 命令行', + }, + tmux: { + title: 'tmux', + spawnSessionsTitle: '在 tmux 中启动会话', + spawnSessionsEnabledSubtitle: '会话将在新的 tmux 窗口中启动。', + spawnSessionsDisabledSubtitle: '会话将在普通 shell 中启动(无 tmux 集成)', + isolatedServerTitle: '隔离的 tmux 服务器', + isolatedServerEnabledSubtitle: '在隔离的 tmux 服务器中启动会话(推荐)。', + isolatedServerDisabledSubtitle: '在默认的 tmux 服务器中启动会话。', + sessionNamePlaceholder: '留空 = 当前/最近会话', + tempDirPlaceholder: '留空以自动生成', + }, + previewMachine: { + title: '预览设备', + itemTitle: '用于环境变量预览的设备', + selectMachine: '选择设备', + resolveSubtitle: '仅用于预览下面解析后的值(不会改变已保存的内容)。', + selectSubtitle: '选择设备以预览下面解析后的值。', + }, + environmentVariables: { + title: '环境变量', + addVariable: '添加变量', + namePlaceholder: '变量名(例如 MY_CUSTOM_VAR)', + valuePlaceholder: '值(例如 my-value 或 ${MY_VAR})', + validation: { + nameRequired: '请输入变量名。', + invalidNameFormat: '变量名必须由大写字母、数字和下划线组成,且不能以数字开头。', + duplicateName: '该变量已存在。', + }, + card: { + valueLabel: '值:', + fallbackValueLabel: '备用值:', + valueInputPlaceholder: '值', + defaultValueInputPlaceholder: '默认值', + fallbackDisabledForVault: '使用机密保管库时,备用值会被禁用。', + secretNotRetrieved: '秘密值——出于安全原因不会读取', + secretToggleLabel: '在 UI 中隐藏值', + secretToggleSubtitle: '在 UI 中隐藏该值,并避免为预览从机器获取它。', + secretToggleEnforcedByDaemon: '由守护进程强制', + secretToggleEnforcedByVault: '由机密保管库强制', + secretToggleResetToAuto: '重置为自动', + requirementRequiredLabel: '必需', + requirementRequiredSubtitle: '当变量缺失时,阻止创建会话。', + requirementUseVaultLabel: '使用机密保管库', + requirementUseVaultSubtitle: '使用已保存的机密(不允许备用值)。', + defaultSecretLabel: '默认机密', + overridingDefault: ({ expectedValue }: { expectedValue: string }) => + `正在覆盖文档默认值:${expectedValue}`, + useMachineEnvToggle: '使用设备环境中的值', + resolvedOnSessionStart: '会话在所选设备上启动时解析。', + sourceVariableLabel: '来源变量', + sourceVariablePlaceholder: '来源变量名(例如 Z_AI_MODEL)', + checkingMachine: ({ machine }: { machine: string }) => `正在检查 ${machine}...`, + emptyOnMachine: ({ machine }: { machine: string }) => `${machine} 上为空`, + emptyOnMachineUsingFallback: ({ machine }: { machine: string }) => `${machine} 上为空(使用备用值)`, + notFoundOnMachine: ({ machine }: { machine: string }) => `在 ${machine} 上未找到`, + notFoundOnMachineUsingFallback: ({ machine }: { machine: string }) => `在 ${machine} 上未找到(使用备用值)`, + valueFoundOnMachine: ({ machine }: { machine: string }) => `在 ${machine} 上找到值`, + differsFromDocumented: ({ expectedValue }: { expectedValue: string }) => + `与文档值不同:${expectedValue}`, + }, + preview: { + secretValueHidden: ({ value }: { value: string }) => `${value} - 出于安全已隐藏`, + hiddenValue: '***已隐藏***', + emptyValue: '(空)', + sessionWillReceive: ({ name, value }: { name: string; value: string }) => + `会话将收到:${name} = ${value}`, + }, + previewModal: { + titleWithProfile: ({ profileName }: { profileName: string }) => `环境变量 · ${profileName}`, + descriptionPrefix: '这些环境变量会在启动会话时发送。值会通过守护进程解析于', + descriptionFallbackMachine: '所选设备', + descriptionSuffix: '。', + emptyMessage: '该配置文件未设置环境变量。', + checkingSuffix: '(检查中…)', + detail: { + fixed: '固定', + machine: '设备', + checking: '检查中', + fallback: '备用', + missing: '缺失', + }, + }, + }, delete: { title: '删除配置', message: ({ name }: { name: string }) => `确定要删除"${name}"吗?此操作无法撤销。`, @@ -906,6 +1599,49 @@ export const zhHans: TranslationStructure = { }, }, + secrets: { + addTitle: '新的机密', + savedTitle: '已保存的机密', + badgeReady: '机密', + badgeRequired: '需要机密', + missingForProfile: ({ env }: { env: string | null }) => + `缺少机密(${env ?? '机密'})。请在设备上配置,或选择/输入一个机密。`, + defaultForProfileTitle: '默认机密', + defineDefaultForProfileTitle: '为此配置文件设置默认机密', + addSubtitle: '添加已保存的机密', + noneTitle: '无', + noneSubtitle: '使用设备环境,或为本次会话输入机密', + emptyTitle: '没有已保存的机密', + emptySubtitle: '添加一个,以在不设置设备环境变量的情况下使用需要机密的配置。', + savedHiddenSubtitle: '已保存(值已隐藏)', + defaultLabel: '默认', + fields: { + name: '名称', + value: '值', + }, + placeholders: { + nameExample: '例如:Work OpenAI', + }, + validation: { + nameRequired: '名称为必填项。', + valueRequired: '值为必填项。', + }, + actions: { + replace: '替换', + replaceValue: '替换值', + setDefault: '设为默认', + unsetDefault: '取消默认', + }, + prompts: { + renameTitle: '重命名机密', + renameDescription: '更新此机密的友好名称。', + replaceValueTitle: '替换机密值', + replaceValueDescription: '粘贴新的机密值。保存后将不会再次显示。', + deleteTitle: '删除机密', + deleteConfirm: ({ name }: { name: string }) => `删除“${name}”?此操作无法撤销。`, + }, + }, + feed: { // Feed notifications for friend requests and acceptances friendRequestFrom: ({ name }: { name: string }) => `${name} 向您发送了好友请求`, diff --git a/expo-app/sources/theme.css b/expo-app/sources/theme.css index 7e241b5ae..82c1136b8 100644 --- a/expo-app/sources/theme.css +++ b/expo-app/sources/theme.css @@ -33,6 +33,23 @@ scrollbar-color: var(--colors-divider) var(--colors-surface-high); } +/* Expo Router (web) modal sizing + - Expo Router uses a Vaul/Radix drawer for `presentation: 'modal'` on web. + - Default sizing is a bit short on large screens; override via attribute selectors + so we don't rely on hashed classnames. */ +@media (min-width: 700px) { + [data-vaul-drawer][data-vaul-drawer-direction="bottom"] [data-presentation="modal"] { + height: min(820px, calc(100vh - 96px)) !important; + max-height: min(820px, calc(100vh - 96px)) !important; + min-height: min(820px, calc(100vh - 96px)) !important; + } + + html[data-happy-route="new"] [data-vaul-drawer][data-vaul-drawer-direction="bottom"] [data-presentation="modal"] { + height: auto !important; + min-height: 0 !important; + } +} + /* Ensure scrollbars are visible on hover for macOS */ ::-webkit-scrollbar:horizontal { height: 12px; @@ -40,4 +57,4 @@ ::-webkit-scrollbar:vertical { width: 12px; -} \ No newline at end of file +} diff --git a/expo-app/sources/theme.ts b/expo-app/sources/theme.ts index c612581e3..080399f01 100644 --- a/expo-app/sources/theme.ts +++ b/expo-app/sources/theme.ts @@ -49,7 +49,7 @@ export const lightTheme = { surface: '#ffffff', surfaceRipple: 'rgba(0, 0, 0, 0.08)', surfacePressed: '#f0f0f2', - surfaceSelected: Platform.select({ ios: '#C6C6C8', default: '#eaeaea' }), + surfaceSelected: Platform.select({ ios: '#eaeaea', default: '#eaeaea' }), surfacePressedOverlay: Platform.select({ ios: '#D1D1D6', default: 'transparent' }), surfaceHigh: '#F8F8F8', surfaceHighest: '#f0f0f0', @@ -64,7 +64,7 @@ export const lightTheme = { // groupped: { - background: Platform.select({ ios: '#F2F2F7', default: '#F5F5F5' }), + background: Platform.select({ ios: '#F5F5F5', default: '#F5F5F5' }), chevron: Platform.select({ ios: '#C7C7CC', default: '#49454F' }), sectionTitle: Platform.select({ ios: '#8E8E93', default: '#49454F' }), }, diff --git a/expo-app/sources/unistyles.ts b/expo-app/sources/unistyles.ts index 7a3a2a8e5..5f34013bd 100644 --- a/expo-app/sources/unistyles.ts +++ b/expo-app/sources/unistyles.ts @@ -1,12 +1,9 @@ -import { StyleSheet, UnistylesRuntime } from 'react-native-unistyles'; -import { darkTheme, lightTheme } from './theme'; -import { loadThemePreference } from './sync/persistence'; import { Appearance } from 'react-native'; import * as SystemUI from 'expo-system-ui'; +import { StyleSheet, UnistylesRuntime } from 'react-native-unistyles'; -// -// Theme -// +import { darkTheme, lightTheme } from './theme'; +import { loadThemePreference } from './sync/persistence'; const appThemes = { light: lightTheme, @@ -19,13 +16,18 @@ const breakpoints = { md: 500, lg: 800, xl: 1200 - // use as many breakpoints as you need }; -// Load theme preference from storage +type AppThemes = typeof appThemes; +type AppBreakpoints = typeof breakpoints; + +declare module 'react-native-unistyles' { + export interface UnistylesThemes extends AppThemes { } + export interface UnistylesBreakpoints extends AppBreakpoints { } +} + const themePreference = loadThemePreference(); -// Determine initial theme and adaptive settings const getInitialTheme = (): 'light' | 'dark' => { if (themePreference === 'adaptive') { const systemTheme = Appearance.getColorScheme(); @@ -36,47 +38,37 @@ const getInitialTheme = (): 'light' | 'dark' => { const settings = themePreference === 'adaptive' ? { - // When adaptive, let Unistyles handle theme switching automatically adaptiveThemes: true, - CSSVars: true, // Enable CSS variables for web + CSSVars: true, } : { - // When fixed theme, set the initial theme explicitly initialTheme: getInitialTheme(), - CSSVars: true, // Enable CSS variables for web + CSSVars: true, }; -// -// Bootstrap -// - -type AppThemes = typeof appThemes -type AppBreakpoints = typeof breakpoints - -declare module 'react-native-unistyles' { - export interface UnistylesThemes extends AppThemes { } - export interface UnistylesBreakpoints extends AppBreakpoints { } -} - StyleSheet.configure({ settings, breakpoints, themes: appThemes, -}) +}); -// Set initial root view background color based on theme const setRootBackgroundColor = () => { if (themePreference === 'adaptive') { const systemTheme = Appearance.getColorScheme(); - const color = systemTheme === 'dark' ? appThemes.dark.colors.groupped.background : appThemes.light.colors.groupped.background; - UnistylesRuntime.setRootViewBackgroundColor(color); - SystemUI.setBackgroundColorAsync(color); - } else { - const color = themePreference === 'dark' ? appThemes.dark.colors.groupped.background : appThemes.light.colors.groupped.background; + const color = systemTheme === 'dark' + ? appThemes.dark.colors.groupped.background + : appThemes.light.colors.groupped.background; UnistylesRuntime.setRootViewBackgroundColor(color); - SystemUI.setBackgroundColorAsync(color); + void SystemUI.setBackgroundColorAsync(color); + return; } + + const color = themePreference === 'dark' + ? appThemes.dark.colors.groupped.background + : appThemes.light.colors.groupped.background; + UnistylesRuntime.setRootViewBackgroundColor(color); + void SystemUI.setBackgroundColorAsync(color); }; -// Set initial background color -setRootBackgroundColor(); \ No newline at end of file +setRootBackgroundColor(); + diff --git a/expo-app/sources/utils/errors.test.ts b/expo-app/sources/utils/errors.test.ts new file mode 100644 index 000000000..606645300 --- /dev/null +++ b/expo-app/sources/utils/errors.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from 'vitest'; +import { HappyError } from './errors'; + +describe('HappyError', () => { + it('uses a stable error name for debugging', () => { + const error = new HappyError('boom', true); + expect(error.name).toBe('HappyError'); + }); +}); + diff --git a/expo-app/sources/utils/errors.ts b/expo-app/sources/utils/errors.ts index 26ca128b6..ffd577a35 100644 --- a/expo-app/sources/utils/errors.ts +++ b/expo-app/sources/utils/errors.ts @@ -1,10 +1,18 @@ export class HappyError extends Error { readonly canTryAgain: boolean; + readonly status?: number; + readonly kind?: 'auth' | 'config' | 'network' | 'server' | 'unknown'; - constructor(message: string, canTryAgain: boolean) { + constructor( + message: string, + canTryAgain: boolean, + opts?: { status?: number; kind?: 'auth' | 'config' | 'network' | 'server' | 'unknown' } + ) { super(message); this.canTryAgain = canTryAgain; - this.name = 'RetryableError'; + this.status = opts?.status; + this.kind = opts?.kind; + this.name = 'HappyError'; Object.setPrototypeOf(this, HappyError.prototype); } -} \ No newline at end of file +} diff --git a/expo-app/sources/utils/microphonePermissions.ts b/expo-app/sources/utils/microphonePermissions.ts index 13a1f7004..d42e8b393 100644 --- a/expo-app/sources/utils/microphonePermissions.ts +++ b/expo-app/sources/utils/microphonePermissions.ts @@ -1,6 +1,7 @@ import { Platform, Linking } from 'react-native'; import { Modal } from '@/modal'; import { AudioModule } from 'expo-audio'; +import { t } from '@/text'; export interface MicrophonePermissionResult { granted: boolean; @@ -82,23 +83,23 @@ export async function checkMicrophonePermission(): Promise<MicrophonePermissionR * Show appropriate error message when permission is denied */ export function showMicrophonePermissionDeniedAlert(canAskAgain: boolean = false) { - const title = 'Microphone Access Required'; + const title = t('modals.microphoneAccessRequiredTitle'); const message = canAskAgain - ? 'Happy needs access to your microphone for voice chat. Please grant permission when prompted.' - : 'Happy needs access to your microphone for voice chat. Please enable microphone access in your device settings.'; + ? t('modals.microphoneAccessRequiredRequestPermission') + : t('modals.microphoneAccessRequiredEnableInSettings'); if (Platform.OS === 'web') { // Web: Show browser-specific instructions Modal.alert( title, - 'Please allow microphone access in your browser settings. You may need to click the lock icon in the address bar and enable microphone permission for this site.', - [{ text: 'OK' }] + t('modals.microphoneAccessRequiredBrowserInstructions'), + [{ text: t('common.ok') }] ); } else { Modal.alert(title, message, [ - { text: 'Cancel', style: 'cancel' }, + { text: t('common.cancel'), style: 'cancel' }, { - text: 'Open Settings', + text: t('modals.openSettings'), onPress: () => { // Opens app settings on iOS/Android Linking.openSettings(); diff --git a/expo-app/sources/utils/oauth.ts b/expo-app/sources/utils/oauth.ts index 1c07d4f5f..0a0da5271 100644 --- a/expo-app/sources/utils/oauth.ts +++ b/expo-app/sources/utils/oauth.ts @@ -1,5 +1,5 @@ -import { getRandomBytes } from 'expo-crypto'; -import * as Crypto from 'expo-crypto'; +import { getRandomBytes } from '@/platform/cryptoRandom'; +import { digest } from '@/platform/digest'; // OAuth Configuration for Claude.ai export const CLAUDE_OAUTH_CONFIG = { @@ -44,11 +44,8 @@ export async function generatePKCE(): Promise<PKCECodes> { const verifier = base64urlEncode(verifierBytes); // Generate code challenge (SHA256 of verifier, base64url encoded) - const challengeBytes = await Crypto.digest( - Crypto.CryptoDigestAlgorithm.SHA256, - new TextEncoder().encode(verifier) - ); - const challenge = base64urlEncode(new Uint8Array(challengeBytes)); + const challengeBytes = await digest('SHA-256', new TextEncoder().encode(verifier)); + const challenge = base64urlEncode(challengeBytes); return { verifier, challenge }; } diff --git a/expo-app/sources/utils/profiles/envVarTemplate.test.ts b/expo-app/sources/utils/profiles/envVarTemplate.test.ts new file mode 100644 index 000000000..52ca30646 --- /dev/null +++ b/expo-app/sources/utils/profiles/envVarTemplate.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; +import { formatEnvVarTemplate, parseEnvVarTemplate } from './envVarTemplate'; + +describe('envVarTemplate', () => { + it('preserves := operator during parse/format round-trip', () => { + const input = '${FOO:=bar}'; + const parsed = parseEnvVarTemplate(input); + expect(parsed).toEqual({ sourceVar: 'FOO', operator: ':=', fallback: 'bar' }); + expect(formatEnvVarTemplate(parsed!)).toBe(input); + }); + + it('preserves :- operator during parse/format round-trip', () => { + const input = '${FOO:-bar}'; + const parsed = parseEnvVarTemplate(input); + expect(parsed).toEqual({ sourceVar: 'FOO', operator: ':-', fallback: 'bar' }); + expect(formatEnvVarTemplate(parsed!)).toBe(input); + }); + + it('round-trips templates without a fallback', () => { + const input = '${FOO}'; + const parsed = parseEnvVarTemplate(input); + expect(parsed).toEqual({ sourceVar: 'FOO', operator: null, fallback: '' }); + expect(formatEnvVarTemplate(parsed!)).toBe(input); + }); + + it('formats an empty fallback when operator is explicitly provided', () => { + expect(formatEnvVarTemplate({ sourceVar: 'FOO', operator: ':=', fallback: '' })).toBe('${FOO:=}'); + expect(formatEnvVarTemplate({ sourceVar: 'FOO', operator: ':-', fallback: '' })).toBe('${FOO:-}'); + }); +}); + diff --git a/expo-app/sources/utils/profiles/envVarTemplate.ts b/expo-app/sources/utils/profiles/envVarTemplate.ts new file mode 100644 index 000000000..493ca41eb --- /dev/null +++ b/expo-app/sources/utils/profiles/envVarTemplate.ts @@ -0,0 +1,40 @@ +export type EnvVarTemplateOperator = ':-' | ':='; + +export type EnvVarTemplate = Readonly<{ + sourceVar: string; + fallback: string; + operator: EnvVarTemplateOperator | null; +}>; + +export function parseEnvVarTemplate(value: string): EnvVarTemplate | null { + const withFallback = value.match(/^\$\{([A-Z_][A-Z0-9_]*)(:-|:=)(.*)\}$/); + if (withFallback) { + return { + sourceVar: withFallback[1], + operator: withFallback[2] as EnvVarTemplateOperator, + fallback: withFallback[3], + }; + } + + const noFallback = value.match(/^\$\{([A-Z_][A-Z0-9_]*)\}$/); + if (noFallback) { + return { + sourceVar: noFallback[1], + operator: null, + fallback: '', + }; + } + + return null; +} + +export function formatEnvVarTemplate(params: { + sourceVar: string; + fallback: string; + operator?: EnvVarTemplateOperator | null; +}): string { + const operator: EnvVarTemplateOperator | null = params.operator ?? (params.fallback !== '' ? ':-' : null); + const suffix = operator ? `${operator}${params.fallback}` : ''; + return `\${${params.sourceVar}${suffix}}`; +} + diff --git a/expo-app/sources/utils/profiles/profileConfigRequirements.test.ts b/expo-app/sources/utils/profiles/profileConfigRequirements.test.ts new file mode 100644 index 000000000..5e1bf8e6f --- /dev/null +++ b/expo-app/sources/utils/profiles/profileConfigRequirements.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; + +import type { AIBackendProfile } from '@/sync/settings'; +import { getMissingRequiredConfigEnvVarNames } from '@/utils/profiles/profileConfigRequirements'; + +function makeProfile(reqs: AIBackendProfile['envVarRequirements']): AIBackendProfile { + return { + id: 'p1', + name: 'Profile', + isBuiltIn: false, + environmentVariables: [], + compatibility: { claude: true, codex: true, gemini: true }, + envVarRequirements: reqs ?? [], + createdAt: 0, + updatedAt: 0, + version: '1.0.0', + } as any; +} + +describe('getMissingRequiredConfigEnvVarNames', () => { + it('returns [] when profile has no config requirements', () => { + const profile = makeProfile([{ name: 'OPENAI_API_KEY', kind: 'secret', required: true }]); + expect(getMissingRequiredConfigEnvVarNames(profile, { OPENAI_API_KEY: false })).toEqual([]); + }); + + it('returns missing required config env vars when not set in machine env', () => { + const profile = makeProfile([ + { name: 'AZURE_OPENAI_ENDPOINT', kind: 'config', required: true }, + { name: 'AZURE_OPENAI_API_KEY', kind: 'secret', required: true }, + { name: 'OPTIONAL_CFG', kind: 'config', required: false }, + ]); + + expect(getMissingRequiredConfigEnvVarNames(profile, { AZURE_OPENAI_ENDPOINT: false })).toEqual(['AZURE_OPENAI_ENDPOINT']); + }); + + it('treats true as configured and ignores null/undefined', () => { + const profile = makeProfile([{ name: 'CFG', kind: 'config', required: true }]); + expect(getMissingRequiredConfigEnvVarNames(profile, { CFG: true })).toEqual([]); + expect(getMissingRequiredConfigEnvVarNames(profile, { CFG: null })).toEqual(['CFG']); + expect(getMissingRequiredConfigEnvVarNames(profile, {})).toEqual(['CFG']); + }); +}); + diff --git a/expo-app/sources/utils/profiles/profileConfigRequirements.ts b/expo-app/sources/utils/profiles/profileConfigRequirements.ts new file mode 100644 index 000000000..affdc12f7 --- /dev/null +++ b/expo-app/sources/utils/profiles/profileConfigRequirements.ts @@ -0,0 +1,15 @@ +import type { AIBackendProfile } from '@/sync/settings'; + +export function getMissingRequiredConfigEnvVarNames( + profile: AIBackendProfile | null | undefined, + machineEnvReadyByName: Record<string, boolean | null | undefined> | null | undefined, +): string[] { + if (!profile) return []; + const reqs = profile.envVarRequirements ?? []; + return reqs + .filter((r) => (r.kind ?? 'secret') === 'config' && r.required === true) + .map((r) => r.name) + .filter((name): name is string => typeof name === 'string' && name.length > 0) + .filter((name) => machineEnvReadyByName?.[name] !== true); +} + diff --git a/expo-app/sources/utils/secrets/secretRequirementApply.test.ts b/expo-app/sources/utils/secrets/secretRequirementApply.test.ts new file mode 100644 index 000000000..595e41cfa --- /dev/null +++ b/expo-app/sources/utils/secrets/secretRequirementApply.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; +import { applySecretRequirementResult } from './secretRequirementApply'; + +describe('applySecretRequirementResult', () => { + it('sets machine env choice and clears session-only value', () => { + const out = applySecretRequirementResult({ + profileId: 'p1', + result: { action: 'useMachine', envVarName: 'OPENAI_API_KEY' }, + selectedSecretIdByProfileIdByEnvVarName: { p1: { OPENAI_API_KEY: null } }, + sessionOnlySecretValueByProfileIdByEnvVarName: { p1: { OPENAI_API_KEY: 'abc' } }, + secretBindingsByProfileId: { p1: { OPENAI_API_KEY: 's0' } }, + }); + + expect(out.nextSelectedSecretIdByProfileIdByEnvVarName.p1?.OPENAI_API_KEY).toBe(''); + expect(out.nextSessionOnlySecretValueByProfileIdByEnvVarName.p1?.OPENAI_API_KEY).toBeNull(); + expect(out.nextSecretBindingsByProfileId.p1?.OPENAI_API_KEY).toBe('s0'); + }); + + it('stores session-only secret value and marks selection as machine-env preferred', () => { + const out = applySecretRequirementResult({ + profileId: 'p1', + result: { action: 'enterOnce', envVarName: 'OPENAI_API_KEY', value: 'sk-test' }, + selectedSecretIdByProfileIdByEnvVarName: { p1: {} }, + sessionOnlySecretValueByProfileIdByEnvVarName: {}, + secretBindingsByProfileId: {}, + }); + + expect(out.nextSelectedSecretIdByProfileIdByEnvVarName.p1?.OPENAI_API_KEY).toBe(''); + expect(out.nextSessionOnlySecretValueByProfileIdByEnvVarName.p1?.OPENAI_API_KEY).toBe('sk-test'); + }); + + it('selects a saved secret without changing defaults when setDefault=false', () => { + const out = applySecretRequirementResult({ + profileId: 'p1', + result: { action: 'selectSaved', envVarName: 'OPENAI_API_KEY', secretId: 's1', setDefault: false }, + selectedSecretIdByProfileIdByEnvVarName: { p1: { OPENAI_API_KEY: '' } }, + sessionOnlySecretValueByProfileIdByEnvVarName: { p1: { OPENAI_API_KEY: 'abc' } }, + secretBindingsByProfileId: { p1: { OPENAI_API_KEY: 's0' } }, + }); + + expect(out.nextSelectedSecretIdByProfileIdByEnvVarName.p1?.OPENAI_API_KEY).toBe('s1'); + expect(out.nextSessionOnlySecretValueByProfileIdByEnvVarName.p1?.OPENAI_API_KEY).toBeNull(); + expect(out.nextSecretBindingsByProfileId.p1?.OPENAI_API_KEY).toBe('s0'); + }); + + it('selects a saved secret and updates defaults when setDefault=true', () => { + const out = applySecretRequirementResult({ + profileId: 'p1', + result: { action: 'selectSaved', envVarName: 'OPENAI_API_KEY', secretId: 's1', setDefault: true }, + selectedSecretIdByProfileIdByEnvVarName: { p1: {} }, + sessionOnlySecretValueByProfileIdByEnvVarName: { p1: {} }, + secretBindingsByProfileId: { p1: { OPENAI_API_KEY: 's0' } }, + }); + + expect(out.nextSelectedSecretIdByProfileIdByEnvVarName.p1?.OPENAI_API_KEY).toBe('s1'); + expect(out.nextSecretBindingsByProfileId.p1?.OPENAI_API_KEY).toBe('s1'); + }); +}); + diff --git a/expo-app/sources/utils/secrets/secretRequirementApply.ts b/expo-app/sources/utils/secrets/secretRequirementApply.ts new file mode 100644 index 000000000..5ad1a57d4 --- /dev/null +++ b/expo-app/sources/utils/secrets/secretRequirementApply.ts @@ -0,0 +1,70 @@ +import type { SecretRequirementModalResult } from '@/components/secrets/requirements'; + +export type SecretChoiceByProfileIdByEnvVarName = Record<string, Record<string, string | null>>; + +export type SecretBindingsByProfileId = Record<string, Record<string, string>>; + +export type ApplySecretRequirementResultInput = Readonly<{ + profileId: string; + result: SecretRequirementModalResult; + selectedSecretIdByProfileIdByEnvVarName: SecretChoiceByProfileIdByEnvVarName; + sessionOnlySecretValueByProfileIdByEnvVarName: SecretChoiceByProfileIdByEnvVarName; + secretBindingsByProfileId: SecretBindingsByProfileId; +}>; + +export type ApplySecretRequirementResultOutput = Readonly<{ + nextSelectedSecretIdByProfileIdByEnvVarName: SecretChoiceByProfileIdByEnvVarName; + nextSessionOnlySecretValueByProfileIdByEnvVarName: SecretChoiceByProfileIdByEnvVarName; + nextSecretBindingsByProfileId: SecretBindingsByProfileId; +}>; + +export function applySecretRequirementResult( + input: ApplySecretRequirementResultInput, +): ApplySecretRequirementResultOutput { + const { profileId, result } = input; + + const nextSelected: SecretChoiceByProfileIdByEnvVarName = { ...input.selectedSecretIdByProfileIdByEnvVarName }; + const nextSessionOnly: SecretChoiceByProfileIdByEnvVarName = { ...input.sessionOnlySecretValueByProfileIdByEnvVarName }; + let nextBindings: SecretBindingsByProfileId = input.secretBindingsByProfileId; + + const ensureProfileMap = (map: SecretChoiceByProfileIdByEnvVarName) => { + const existing = map[profileId] ?? {}; + const copy = { ...existing }; + map[profileId] = copy; + return copy; + }; + + if (result.action === 'useMachine') { + const selected = ensureProfileMap(nextSelected); + selected[result.envVarName] = ''; + + const sessionOnly = ensureProfileMap(nextSessionOnly); + sessionOnly[result.envVarName] = null; + } else if (result.action === 'enterOnce') { + const selected = ensureProfileMap(nextSelected); + selected[result.envVarName] = ''; + + const sessionOnly = ensureProfileMap(nextSessionOnly); + sessionOnly[result.envVarName] = result.value; + } else if (result.action === 'selectSaved') { + const selected = ensureProfileMap(nextSelected); + selected[result.envVarName] = result.secretId; + + const sessionOnly = ensureProfileMap(nextSessionOnly); + sessionOnly[result.envVarName] = null; + + if (result.setDefault) { + nextBindings = { ...nextBindings }; + nextBindings[profileId] = { + ...(nextBindings[profileId] ?? {}), + [result.envVarName]: result.secretId, + }; + } + } + + return { + nextSelectedSecretIdByProfileIdByEnvVarName: nextSelected, + nextSessionOnlySecretValueByProfileIdByEnvVarName: nextSessionOnly, + nextSecretBindingsByProfileId: nextBindings, + }; +} diff --git a/expo-app/sources/utils/secrets/secretRequirementPromptEligibility.test.ts b/expo-app/sources/utils/secrets/secretRequirementPromptEligibility.test.ts new file mode 100644 index 000000000..e160511b0 --- /dev/null +++ b/expo-app/sources/utils/secrets/secretRequirementPromptEligibility.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest'; +import { shouldAutoPromptSecretRequirement } from './secretRequirementPromptEligibility'; + +describe('shouldAutoPromptSecretRequirement', () => { + it('does not require a selected machine (still enforces saved/once secrets)', () => { + const decision = shouldAutoPromptSecretRequirement({ + useProfiles: true, + selectedProfileId: 'p1', + shouldShowSecretSection: true, + isModalOpen: false, + machineEnvPresenceIsLoading: false, + selectedMachineId: null, + }); + + expect(decision).toBe(true); + }); +}); + diff --git a/expo-app/sources/utils/secrets/secretRequirementPromptEligibility.ts b/expo-app/sources/utils/secrets/secretRequirementPromptEligibility.ts new file mode 100644 index 000000000..80853c442 --- /dev/null +++ b/expo-app/sources/utils/secrets/secretRequirementPromptEligibility.ts @@ -0,0 +1,31 @@ +export type SecretRequirementAutoPromptEligibilityParams = Readonly<{ + useProfiles: boolean; + selectedProfileId: string | null; + shouldShowSecretSection: boolean; + isModalOpen: boolean; + machineEnvPresenceIsLoading: boolean; + /** + * Used for prompt-key generation. Not required for eligibility; can be null when no machine is selected. + */ + selectedMachineId: string | null; +}>; + +/** + * Gate for auto-opening the Secret Requirement UI. + * + * IMPORTANT: + * We intentionally do NOT require `selectedMachineId` here: + * if there is no machine selected, users must still satisfy secrets via a saved secret or session-only value. + */ +export function shouldAutoPromptSecretRequirement(params: SecretRequirementAutoPromptEligibilityParams): boolean { + if (!params.useProfiles) return false; + if (!params.selectedProfileId) return false; + if (!params.shouldShowSecretSection) return false; + if (params.isModalOpen) return false; + + // When a machine IS selected, wait for env presence to settle so we don't spuriously prompt. + if (params.selectedMachineId && params.machineEnvPresenceIsLoading) return false; + + return true; +} + diff --git a/expo-app/sources/utils/secrets/secretSatisfaction.test.ts b/expo-app/sources/utils/secrets/secretSatisfaction.test.ts new file mode 100644 index 000000000..068d3a20f --- /dev/null +++ b/expo-app/sources/utils/secrets/secretSatisfaction.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest'; + +import type { AIBackendProfile, SavedSecret } from '@/sync/settings'; +import { getSecretSatisfaction } from '@/utils/secrets/secretSatisfaction'; + +function makeProfile(reqs: AIBackendProfile['envVarRequirements']): AIBackendProfile { + return { + id: 'p1', + name: 'Profile', + isBuiltIn: false, + environmentVariables: [], + compatibility: { claude: true, codex: true, gemini: true }, + envVarRequirements: reqs ?? [], + createdAt: 0, + updatedAt: 0, + version: '1.0.0', + } as any; +} + +describe('getSecretSatisfaction', () => { + const secrets: SavedSecret[] = [ + { id: 's1', name: 'S1', kind: 'apiKey', encryptedValue: { _isSecretValue: true, encryptedValue: { t: 'enc-v1', c: 'Zm9v' } }, createdAt: 0, updatedAt: 0 } as any, + { id: 's2', name: 'S2', kind: 'apiKey', encryptedValue: { _isSecretValue: true, encryptedValue: { t: 'enc-v1', c: 'YmFy' } }, createdAt: 0, updatedAt: 0 } as any, + ]; + + it('treats profiles with no secret requirements as satisfied', () => { + const profile = makeProfile([{ name: 'FOO', kind: 'config', required: true }]); + const res = getSecretSatisfaction({ profile, secrets }); + expect(res.hasSecretRequirements).toBe(false); + expect(res.isSatisfied).toBe(true); + expect(res.items).toEqual([]); + }); + + it('evaluates multiple required secrets independently and gates on required-only', () => { + const profile = makeProfile([ + { name: 'A', kind: 'secret', required: true }, + { name: 'B', kind: 'secret', required: true }, + ]); + + const res = getSecretSatisfaction({ + profile, + secrets, + machineEnvReadyByName: { A: true, B: false }, + }); + + expect(res.hasSecretRequirements).toBe(true); + expect(res.isSatisfied).toBe(false); + expect(res.items.map((i) => [i.envVarName, i.isSatisfied, i.satisfiedBy])).toEqual([ + ['A', true, 'machineEnv'], + ['B', false, 'none'], + ]); + }); + + it('prefers sessionOnly over selected/remembered/default/machine per env var', () => { + const profile = makeProfile([{ name: 'A', kind: 'secret', required: true }]); + const res = getSecretSatisfaction({ + profile, + secrets, + defaultBindings: { A: 's1' }, + selectedSecretIds: { A: 's2' }, + sessionOnlyValues: { A: 'sk-live' }, + machineEnvReadyByName: { A: true }, + }); + expect(res.isSatisfied).toBe(true); + expect(res.items[0]?.satisfiedBy).toBe('sessionOnly'); + }); + + it('when selectedSecretIds[env] is empty string, only machine env (or sessionOnly) can satisfy', () => { + const profile = makeProfile([{ name: 'A', kind: 'secret', required: true }]); + const res = getSecretSatisfaction({ + profile, + secrets, + defaultBindings: { A: 's1' }, + selectedSecretIds: { A: '' }, // prefer machine env + machineEnvReadyByName: { A: false }, + }); + expect(res.isSatisfied).toBe(false); + expect(res.items[0]?.satisfiedBy).toBe('none'); + }); +}); + diff --git a/expo-app/sources/utils/secrets/secretSatisfaction.ts b/expo-app/sources/utils/secrets/secretSatisfaction.ts new file mode 100644 index 000000000..1929789be --- /dev/null +++ b/expo-app/sources/utils/secrets/secretSatisfaction.ts @@ -0,0 +1,160 @@ +import type { AIBackendProfile, SavedSecret } from '@/sync/settings'; + +export type SecretSatisfactionSource = + | 'none' + | 'machineEnv' + | 'sessionOnly' + | 'selectedSaved' + | 'rememberedSaved' + | 'defaultSaved'; + +export type SecretSatisfactionItem = Readonly<{ + envVarName: string; + required: boolean; + isSatisfied: boolean; + satisfiedBy: SecretSatisfactionSource; + savedSecretId: string | null; +}>; + +export type SecretSatisfactionResult = Readonly<{ + hasSecretRequirements: boolean; + items: SecretSatisfactionItem[]; + /** + * True when all required secret requirements are satisfied. + */ + isSatisfied: boolean; +}>; + +export type SecretSatisfactionParams = Readonly<{ + profile: AIBackendProfile | null; + secrets: SavedSecret[]; + /** + * Per-profile default bindings from settings: envVarName -> savedSecretId. + */ + defaultBindings?: Record<string, string> | null; + /** + * Explicit per-run selection (e.g. New Session UI state): envVarName -> savedSecretId (or '' for “prefer machine env”). + */ + selectedSecretIds?: Record<string, string | null | undefined> | null; + /** + * Remembered per-screen selection (optional): envVarName -> savedSecretId. + */ + rememberedSecretIds?: Record<string, string | null | undefined> | null; + /** + * Session-only secrets (never persisted): envVarName -> plaintext. + */ + sessionOnlyValues?: Record<string, string | null | undefined> | null; + /** + * Whether the machine environment provides envVarName: envVarName -> true/false/unknown. + */ + machineEnvReadyByName?: Record<string, boolean | null | undefined> | null; +}>; + +function normalizeId(id: string | null | undefined): string | null { + if (typeof id !== 'string') return null; + const trimmed = id.trim(); + if (!trimmed) return null; + return trimmed; +} + +function hasSavedSecret(secrets: SavedSecret[], id: string | null): boolean { + if (!id) return false; + return secrets.some((k) => k.id === id); +} + +function getSecretRequirements(profile: AIBackendProfile): Array<{ envVarName: string; required: boolean }> { + const reqs = profile.envVarRequirements ?? []; + return reqs + .filter((r) => (r.kind ?? 'secret') === 'secret') + .map((r) => ({ envVarName: r.name, required: r.required === true })); +} + +/** + * Centralized secret satisfaction logic (multi-secret). + * + * Precedence per env var (highest -> lowest): + * - sessionOnlyValues[env] + * - selectedSecretIds[env] (explicit per-run saved key selection) + * - rememberedSecretIds[env] (per-screen remembered selection) + * - defaultBindings[env] (profile default saved key) + * - machineEnvReadyByName[env] (daemon env provides required var) + * + * Special case: + * - If selectedSecretIds[env] === '' (empty string), treat as “prefer machine env”: + * do NOT count remembered/default saved secrets as satisfying; only machine env or sessionOnly. + */ +export function getSecretSatisfaction(params: SecretSatisfactionParams): SecretSatisfactionResult { + const profile = params.profile; + if (!profile) { + return { + hasSecretRequirements: false, + items: [], + isSatisfied: true, + }; + } + + const requirements = getSecretRequirements(profile); + if (requirements.length === 0) { + return { + hasSecretRequirements: false, + items: [], + isSatisfied: true, + }; + } + + const secrets = params.secrets ?? []; + const defaultBindings = params.defaultBindings ?? null; + const selectedSecretIds = params.selectedSecretIds ?? null; + const rememberedSecretIds = params.rememberedSecretIds ?? null; + const sessionOnlyValues = params.sessionOnlyValues ?? null; + const machineEnvReadyByName = params.machineEnvReadyByName ?? null; + + const items: SecretSatisfactionItem[] = requirements.map(({ envVarName, required }) => { + const machineEnvReady = machineEnvReadyByName?.[envVarName]; + const sessionOnly = typeof sessionOnlyValues?.[envVarName] === 'string' + ? String(sessionOnlyValues?.[envVarName]).trim() + : ''; + const selectedRaw = selectedSecretIds?.[envVarName]; + const selectedId = normalizeId(selectedRaw === '' ? null : (selectedRaw ?? null)); + const preferMachineEnv = selectedRaw === ''; + const rememberedId = normalizeId(rememberedSecretIds?.[envVarName] ?? null); + const defaultId = normalizeId(defaultBindings?.[envVarName] ?? null); + + if (sessionOnly.length > 0) { + return { envVarName, required, isSatisfied: true, satisfiedBy: 'sessionOnly', savedSecretId: null }; + } + + if (hasSavedSecret(secrets, selectedId)) { + return { envVarName, required, isSatisfied: true, satisfiedBy: 'selectedSaved', savedSecretId: selectedId! }; + } + + if (preferMachineEnv) { + if (machineEnvReady === true) { + return { envVarName, required, isSatisfied: true, satisfiedBy: 'machineEnv', savedSecretId: null }; + } + return { envVarName, required, isSatisfied: false, satisfiedBy: 'none', savedSecretId: null }; + } + + if (hasSavedSecret(secrets, rememberedId)) { + return { envVarName, required, isSatisfied: true, satisfiedBy: 'rememberedSaved', savedSecretId: rememberedId! }; + } + + if (hasSavedSecret(secrets, defaultId)) { + return { envVarName, required, isSatisfied: true, satisfiedBy: 'defaultSaved', savedSecretId: defaultId! }; + } + + if (machineEnvReady === true) { + return { envVarName, required, isSatisfied: true, satisfiedBy: 'machineEnv', savedSecretId: null }; + } + + return { envVarName, required, isSatisfied: false, satisfiedBy: 'none', savedSecretId: null }; + }); + + const isSatisfied = items.filter((i) => i.required).every((i) => i.isSatisfied); + return { + hasSecretRequirements: true, + items, + isSatisfied, + }; +} + diff --git a/expo-app/sources/utils/sessionUtils.test.ts b/expo-app/sources/utils/sessionUtils.test.ts new file mode 100644 index 000000000..b0cad062b --- /dev/null +++ b/expo-app/sources/utils/sessionUtils.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { Session } from '@/sync/storageTypes'; + +vi.mock('@/text', () => { + return { + t: (key: string) => key, + }; +}); + +function createBaseSession(overrides: Partial<Session> = {}): Session { + return { + id: 's1', + seq: 1, + createdAt: 0, + updatedAt: 0, + active: true, + activeAt: 0, + metadata: null, + metadataVersion: 0, + agentState: null, + agentStateVersion: 0, + thinking: false, + thinkingAt: 0, + presence: 'online', + ...overrides, + }; +} + +describe('getSessionStatus', () => { + it('returns disconnected when presence is not online', async () => { + const { getSessionStatus } = await import('./sessionUtils'); + const session = createBaseSession({ presence: 123 }); + const status = getSessionStatus(session, 1_000, 0); + expect(status.state).toBe('disconnected'); + expect(status.isConnected).toBe(false); + expect(status.shouldShowStatus).toBe(true); + }); + + it('returns permission_required when the agent has pending requests', async () => { + const { getSessionStatus } = await import('./sessionUtils'); + const session = createBaseSession({ + agentState: { + controlledByUser: null, + requests: { + req1: { tool: 'tool', arguments: {}, createdAt: null }, + }, + completedRequests: null, + }, + }); + const status = getSessionStatus(session, 1_000, 0); + expect(status.state).toBe('permission_required'); + expect(status.isConnected).toBe(true); + expect(status.shouldShowStatus).toBe(true); + }); + + it('returns thinking when session.thinking is true', async () => { + const { getSessionStatus } = await import('./sessionUtils'); + const session = createBaseSession({ thinking: true }); + const status = getSessionStatus(session, 1_000, 0); + expect(status.state).toBe('thinking'); + expect(status.isConnected).toBe(true); + expect(status.shouldShowStatus).toBe(true); + expect(status.isPulsing).toBe(true); + }); + + it('returns thinking when optimisticThinkingAt is recent', async () => { + const { getSessionStatus } = await import('./sessionUtils'); + const now = 1_000_000; + const session = createBaseSession({ optimisticThinkingAt: now - 1_000 }); + const status = getSessionStatus(session, now, 0); + expect(status.state).toBe('thinking'); + }); + + it('does not treat stale optimisticThinkingAt as thinking', async () => { + const { getSessionStatus, OPTIMISTIC_SESSION_THINKING_TIMEOUT_MS } = await import('./sessionUtils'); + const now = 1_000_000; + const session = createBaseSession({ optimisticThinkingAt: now - OPTIMISTIC_SESSION_THINKING_TIMEOUT_MS - 1 }); + const status = getSessionStatus(session, now, 0); + expect(status.state).toBe('waiting'); + }); +}); diff --git a/expo-app/sources/utils/sessionUtils.ts b/expo-app/sources/utils/sessionUtils.ts index 752d2010e..bc5f38b2a 100644 --- a/expo-app/sources/utils/sessionUtils.ts +++ b/expo-app/sources/utils/sessionUtils.ts @@ -14,17 +14,26 @@ export interface SessionStatus { isPulsing?: boolean; } +export const OPTIMISTIC_SESSION_THINKING_TIMEOUT_MS = 15_000; + /** * Get the current state of a session based on presence and thinking status. * Uses centralized session state from storage.ts */ -export function useSessionStatus(session: Session): SessionStatus { +export function getSessionStatus(session: Session, nowMs: number = Date.now(), vibingIndex?: number): SessionStatus { const isOnline = session.presence === "online"; const hasPermissions = (session.agentState?.requests && Object.keys(session.agentState.requests).length > 0 ? true : false); - const vibingMessage = React.useMemo(() => { - return vibingMessages[Math.floor(Math.random() * vibingMessages.length)].toLowerCase() + '…'; - }, [isOnline, hasPermissions, session.thinking]); + const optimisticThinkingAt = session.optimisticThinkingAt ?? null; + const isOptimisticThinking = typeof optimisticThinkingAt === 'number' && nowMs - optimisticThinkingAt < OPTIMISTIC_SESSION_THINKING_TIMEOUT_MS; + const isThinking = session.thinking === true || isOptimisticThinking; + + const vibingMessage = (() => { + const idx = typeof vibingIndex === 'number' + ? vibingIndex + : Math.floor(Math.random() * vibingMessages.length); + return vibingMessages[idx % vibingMessages.length].toLowerCase() + '…'; + })(); if (!isOnline) { return { @@ -50,7 +59,7 @@ export function useSessionStatus(session: Session): SessionStatus { }; } - if (session.thinking === true) { + if (isThinking) { return { state: 'thinking', isConnected: true, @@ -72,6 +81,25 @@ export function useSessionStatus(session: Session): SessionStatus { }; } +/** + * Hook wrapper around `getSessionStatus` that keeps vibing text stable while the session is thinking. + */ +export function useSessionStatus(session: Session): SessionStatus { + const isOnline = session.presence === "online"; + const hasPermissions = (session.agentState?.requests && Object.keys(session.agentState.requests).length > 0 ? true : false); + + const now = Date.now(); + const optimisticThinkingAt = session.optimisticThinkingAt ?? null; + const isOptimisticThinking = typeof optimisticThinkingAt === 'number' && now - optimisticThinkingAt < OPTIMISTIC_SESSION_THINKING_TIMEOUT_MS; + const isThinking = session.thinking === true || isOptimisticThinking; + + const vibingIndex = React.useMemo(() => { + return Math.floor(Math.random() * vibingMessages.length); + }, [isOnline, hasPermissions, isThinking]); + + return getSessionStatus(session, now, vibingIndex); +} + /** * Extracts a display name from a session's metadata path. * Returns the last segment of the path, or 'unknown' if no path is available. @@ -217,4 +245,4 @@ export function formatLastSeen(activeAt: number, isActive: boolean = false): str } } -const vibingMessages = ["Accomplishing", "Actioning", "Actualizing", "Baking", "Booping", "Brewing", "Calculating", "Cerebrating", "Channelling", "Churning", "Clauding", "Coalescing", "Cogitating", "Computing", "Combobulating", "Concocting", "Conjuring", "Considering", "Contemplating", "Cooking", "Crafting", "Creating", "Crunching", "Deciphering", "Deliberating", "Determining", "Discombobulating", "Divining", "Doing", "Effecting", "Elucidating", "Enchanting", "Envisioning", "Finagling", "Flibbertigibbeting", "Forging", "Forming", "Frolicking", "Generating", "Germinating", "Hatching", "Herding", "Honking", "Ideating", "Imagining", "Incubating", "Inferring", "Manifesting", "Marinating", "Meandering", "Moseying", "Mulling", "Mustering", "Musing", "Noodling", "Percolating", "Perusing", "Philosophising", "Pontificating", "Pondering", "Processing", "Puttering", "Puzzling", "Reticulating", "Ruminating", "Scheming", "Schlepping", "Shimmying", "Simmering", "Smooshing", "Spelunking", "Spinning", "Stewing", "Sussing", "Synthesizing", "Thinking", "Tinkering", "Transmuting", "Unfurling", "Unravelling", "Vibing", "Wandering", "Whirring", "Wibbling", "Wizarding", "Working", "Wrangling"]; \ No newline at end of file +const vibingMessages = ["Accomplishing", "Actioning", "Actualizing", "Baking", "Booping", "Brewing", "Calculating", "Cerebrating", "Channelling", "Churning", "Clauding", "Coalescing", "Cogitating", "Computing", "Combobulating", "Concocting", "Conjuring", "Considering", "Contemplating", "Cooking", "Crafting", "Creating", "Crunching", "Deciphering", "Deliberating", "Determining", "Discombobulating", "Divining", "Doing", "Effecting", "Elucidating", "Enchanting", "Envisioning", "Finagling", "Flibbertigibbeting", "Forging", "Forming", "Frolicking", "Generating", "Germinating", "Hatching", "Herding", "Honking", "Ideating", "Imagining", "Incubating", "Inferring", "Manifesting", "Marinating", "Meandering", "Moseying", "Mulling", "Mustering", "Musing", "Noodling", "Percolating", "Perusing", "Philosophising", "Pontificating", "Pondering", "Processing", "Puttering", "Puzzling", "Reticulating", "Ruminating", "Scheming", "Schlepping", "Shimmying", "Simmering", "Smooshing", "Spelunking", "Spinning", "Stewing", "Sussing", "Synthesizing", "Thinking", "Tinkering", "Transmuting", "Unfurling", "Unravelling", "Vibing", "Wandering", "Whirring", "Wibbling", "Wizarding", "Working", "Wrangling"]; diff --git a/expo-app/sources/utils/sessions/discardedCommittedMessages.test.ts b/expo-app/sources/utils/sessions/discardedCommittedMessages.test.ts new file mode 100644 index 000000000..394c1a174 --- /dev/null +++ b/expo-app/sources/utils/sessions/discardedCommittedMessages.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; +import { isCommittedMessageDiscarded } from './discardedCommittedMessages'; + +describe('isCommittedMessageDiscarded', () => { + it('returns false when metadata is missing', () => { + expect(isCommittedMessageDiscarded(null, 'x')).toBe(false); + }); + + it('returns false when localId is missing', () => { + expect(isCommittedMessageDiscarded({} as any, null)).toBe(false); + }); + + it('returns true when localId is included in discardedCommittedMessageLocalIds', () => { + expect(isCommittedMessageDiscarded({ discardedCommittedMessageLocalIds: ['a'] } as any, 'a')).toBe(true); + }); + + it('returns false when localId is not included in discardedCommittedMessageLocalIds', () => { + expect(isCommittedMessageDiscarded({ discardedCommittedMessageLocalIds: ['a'] } as any, 'b')).toBe(false); + }); +}); + diff --git a/expo-app/sources/utils/sessions/discardedCommittedMessages.ts b/expo-app/sources/utils/sessions/discardedCommittedMessages.ts new file mode 100644 index 000000000..63cc57e99 --- /dev/null +++ b/expo-app/sources/utils/sessions/discardedCommittedMessages.ts @@ -0,0 +1,8 @@ +import type { Metadata } from '@/sync/storageTypes'; + +export function isCommittedMessageDiscarded(metadata: Metadata | null, localId: string | null): boolean { + if (!metadata) return false; + if (!localId) return false; + const list = metadata.discardedCommittedMessageLocalIds; + return Array.isArray(list) && list.includes(localId); +} diff --git a/expo-app/sources/utils/sessions/recentMachines.ts b/expo-app/sources/utils/sessions/recentMachines.ts new file mode 100644 index 000000000..9c098d641 --- /dev/null +++ b/expo-app/sources/utils/sessions/recentMachines.ts @@ -0,0 +1,31 @@ +import type { Machine } from '@/sync/storageTypes'; +import type { Session } from '@/sync/storageTypes'; + +export function getRecentMachinesFromSessions(params: { + machines: Machine[]; + sessions: Array<Session | string> | null | undefined; +}): Machine[] { + if (!params.sessions || params.machines.length === 0) return []; + + const byId = new Map(params.machines.map((m) => [m.id, m] as const)); + const seen = new Set<string>(); + const machinesWithTimestamp: Array<{ machine: Machine; timestamp: number }> = []; + + params.sessions.forEach((item) => { + if (typeof item === 'string') return; + const machineId = item.metadata?.machineId; + if (!machineId || seen.has(machineId)) return; + const machine = byId.get(machineId); + if (!machine) return; + seen.add(machineId); + machinesWithTimestamp.push({ + machine, + timestamp: item.updatedAt || item.createdAt, + }); + }); + + return machinesWithTimestamp + .sort((a, b) => b.timestamp - a.timestamp) + .map((item) => item.machine); +} + diff --git a/expo-app/sources/utils/sessions/recentPaths.ts b/expo-app/sources/utils/sessions/recentPaths.ts new file mode 100644 index 000000000..09eaa93d8 --- /dev/null +++ b/expo-app/sources/utils/sessions/recentPaths.ts @@ -0,0 +1,45 @@ +import type { Session } from '@/sync/storageTypes'; + +export function getRecentPathsForMachine(params: { + machineId: string; + recentMachinePaths: Array<{ machineId: string; path: string }>; + sessions: Array<Session | string> | null | undefined; +}): string[] { + const paths: string[] = []; + const pathSet = new Set<string>(); + + // First, add paths from recentMachinePaths (most recent first by storage order) + for (const entry of params.recentMachinePaths) { + if (entry.machineId === params.machineId && !pathSet.has(entry.path)) { + paths.push(entry.path); + pathSet.add(entry.path); + } + } + + // Then add paths from sessions if we need more + if (params.sessions) { + const pathsWithTimestamps: Array<{ path: string; timestamp: number }> = []; + + params.sessions.forEach((item) => { + if (typeof item === 'string') return; + const session = item; + if (session.metadata?.machineId === params.machineId && session.metadata?.path) { + const path = session.metadata.path; + if (!pathSet.has(path)) { + pathSet.add(path); + pathsWithTimestamps.push({ + path, + timestamp: session.updatedAt || session.createdAt, + }); + } + } + }); + + pathsWithTimestamps + .sort((a, b) => b.timestamp - a.timestamp) + .forEach((item) => paths.push(item.path)); + } + + return paths; +} + diff --git a/expo-app/sources/utils/sessions/terminalSessionDetails.test.ts b/expo-app/sources/utils/sessions/terminalSessionDetails.test.ts new file mode 100644 index 000000000..3328609ee --- /dev/null +++ b/expo-app/sources/utils/sessions/terminalSessionDetails.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect } from 'vitest'; + +import { getAttachCommandForSession, getTmuxFallbackReason, getTmuxTargetForSession } from './terminalSessionDetails'; + +describe('terminalSessionDetails', () => { + it('returns an attach command when tmux target exists', () => { + expect(getAttachCommandForSession({ + sessionId: 's1', + terminal: { + mode: 'tmux', + tmux: { target: 'happy:win-1' }, + }, + } as any)).toBe('happy attach s1'); + }); + + it('returns null attach command when terminal is not tmux', () => { + expect(getAttachCommandForSession({ + sessionId: 's1', + terminal: { + mode: 'plain', + requested: 'tmux', + }, + } as any)).toBeNull(); + }); + + it('returns tmux target when present', () => { + expect(getTmuxTargetForSession({ + mode: 'tmux', + tmux: { target: 'happy:win-1', tmpDir: '/tmp' }, + } as any)).toBe('happy:win-1'); + }); + + it('returns tmux fallback reason when present', () => { + expect(getTmuxFallbackReason({ + mode: 'plain', + requested: 'tmux', + fallbackReason: 'tmux not found', + } as any)).toBe('tmux not found'); + }); +}); + diff --git a/expo-app/sources/utils/sessions/terminalSessionDetails.ts b/expo-app/sources/utils/sessions/terminalSessionDetails.ts new file mode 100644 index 000000000..c32dd473e --- /dev/null +++ b/expo-app/sources/utils/sessions/terminalSessionDetails.ts @@ -0,0 +1,26 @@ +import type { Metadata } from '@/sync/storageTypes'; + +export function getAttachCommandForSession(params: { + sessionId: string; + terminal: Metadata['terminal'] | null | undefined; +}): string | null { + const { sessionId, terminal } = params; + if (!terminal) return null; + if (terminal.mode !== 'tmux') return null; + if (!terminal.tmux?.target) return null; + return `happy attach ${sessionId}`; +} + +export function getTmuxTargetForSession(terminal: Metadata['terminal'] | null | undefined): string | null { + if (!terminal) return null; + if (terminal.mode !== 'tmux') return null; + return terminal.tmux?.target ?? null; +} + +export function getTmuxFallbackReason(terminal: Metadata['terminal'] | null | undefined): string | null { + if (!terminal) return null; + if (terminal.mode !== 'plain') return null; + if (terminal.requested !== 'tmux') return null; + return terminal.fallbackReason ?? null; +} + diff --git a/expo-app/sources/utils/storageScope.test.ts b/expo-app/sources/utils/storageScope.test.ts new file mode 100644 index 000000000..5436c31e1 --- /dev/null +++ b/expo-app/sources/utils/storageScope.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; + +import { + EXPO_PUBLIC_STORAGE_SCOPE_ENV_VAR, + normalizeStorageScope, + readStorageScopeFromEnv, + scopedStorageId, +} from './storageScope'; + +describe('storageScope', () => { + describe('normalizeStorageScope', () => { + it('returns null for non-strings and empty strings', () => { + expect(normalizeStorageScope(undefined)).toBeNull(); + expect(normalizeStorageScope(null)).toBeNull(); + expect(normalizeStorageScope(123)).toBeNull(); + expect(normalizeStorageScope('')).toBeNull(); + expect(normalizeStorageScope(' ')).toBeNull(); + }); + + it('sanitizes unsafe characters and clamps length', () => { + expect(normalizeStorageScope(' pr272-107 ')).toBe('pr272-107'); + expect(normalizeStorageScope('a/b:c')).toBe('a_b_c'); + expect(normalizeStorageScope('a__b')).toBe('a_b'); + + const long = 'x'.repeat(100); + expect(normalizeStorageScope(long)?.length).toBe(64); + }); + }); + + describe('readStorageScopeFromEnv', () => { + it('reads from EXPO_PUBLIC_HAPPY_STORAGE_SCOPE', () => { + expect(readStorageScopeFromEnv({ [EXPO_PUBLIC_STORAGE_SCOPE_ENV_VAR]: 'stack-1' })).toBe('stack-1'); + expect(readStorageScopeFromEnv({ [EXPO_PUBLIC_STORAGE_SCOPE_ENV_VAR]: ' ' })).toBeNull(); + }); + }); + + describe('scopedStorageId', () => { + it('returns baseId when scope is null', () => { + expect(scopedStorageId('auth_credentials', null)).toBe('auth_credentials'); + }); + + it('namespaces when scope is present', () => { + expect(scopedStorageId('auth_credentials', 'stack-1')).toBe('auth_credentials__stack-1'); + }); + }); +}); diff --git a/expo-app/sources/utils/storageScope.ts b/expo-app/sources/utils/storageScope.ts new file mode 100644 index 000000000..8bfebde1a --- /dev/null +++ b/expo-app/sources/utils/storageScope.ts @@ -0,0 +1,32 @@ +export const EXPO_PUBLIC_STORAGE_SCOPE_ENV_VAR = 'EXPO_PUBLIC_HAPPY_STORAGE_SCOPE'; + +/** + * Returns a sanitized storage scope suitable for identifiers/keys, or null. + * + * Notes: + * - This is intentionally conservative (stable, URL/key friendly). + * - If unset/empty, callers should behave exactly as they did before (no scoping). + */ +export function normalizeStorageScope(value: unknown): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + if (!trimmed) return null; + + // Keep only safe characters to avoid backend/storage quirks (keychain, MMKV id, etc.) + // Replace everything else with '_' for stability. + const sanitized = trimmed.replace(/[^a-zA-Z0-9._-]/g, '_'); + const collapsed = sanitized.replace(/_+/g, '_'); + const clamped = collapsed.slice(0, 64); + return clamped || null; +} + +export function readStorageScopeFromEnv( + env: Record<string, string | undefined> = process.env, +): string | null { + return normalizeStorageScope(env[EXPO_PUBLIC_STORAGE_SCOPE_ENV_VAR]); +} + +export function scopedStorageId(baseId: string, scope: string | null): string { + // Must be compatible with all underlying stores (SecureStore keys are especially strict). + return scope ? `${baseId}__${scope}` : baseId; +} diff --git a/expo-app/sources/utils/sync.ts b/expo-app/sources/utils/sync.ts index 731487840..6a8f85527 100644 --- a/expo-app/sources/utils/sync.ts +++ b/expo-app/sources/utils/sync.ts @@ -1,4 +1,4 @@ -import { backoff } from "@/utils/time"; +import { createBackoff } from "@/utils/time"; export class InvalidateSync { private _invalidated = false; @@ -6,9 +6,30 @@ export class InvalidateSync { private _stopped = false; private _command: () => Promise<void>; private _pendings: (() => void)[] = []; - - constructor(command: () => Promise<void>) { + private _onError?: (e: any) => void; + private _onSuccess?: () => void; + private _onRetry?: (info: { failuresCount: number; nextDelayMs: number; nextRetryAt: number }) => void; + private _backoff!: ReturnType<typeof createBackoff>; + + constructor( + command: () => Promise<void>, + opts?: { + onError?: (e: any) => void; + onSuccess?: () => void; + onRetry?: (info: { failuresCount: number; nextDelayMs: number; nextRetryAt: number }) => void; + } + ) { this._command = command; + this._onError = opts?.onError; + this._onSuccess = opts?.onSuccess; + this._onRetry = opts?.onRetry; + this._backoff = createBackoff({ + maxFailureCount: Number.POSITIVE_INFINITY, + onError: (e) => console.warn(e), + onRetry: (_e, failuresCount, nextDelayMs) => { + this._onRetry?.({ failuresCount, nextDelayMs, nextRetryAt: Date.now() + nextDelayMs }); + } + }); } invalidate() { @@ -62,12 +83,20 @@ export class InvalidateSync { private _doSync = async () => { - await backoff(async () => { - if (this._stopped) { - return; - } - await this._command(); - }); + try { + await this._backoff(async () => { + if (this._stopped) { + return; + } + await this._command(); + }); + this._onSuccess?.(); + } catch (e) { + // Non-retryable errors (e.g. auth/config) should not brick the sync queue. + // We treat this as a "give up for now" and allow future invalidations to retry. + this._onError?.(e); + console.warn(e); + } if (this._stopped) { this._notifyPendings(); return; @@ -145,12 +174,19 @@ export class ValueSync<T> { const value = this._latestValue!; this._hasValue = false; - await backoff(async () => { - if (this._stopped) { - return; - } - await this._command(value); - }); + try { + const backoffForever = createBackoff({ maxFailureCount: Number.POSITIVE_INFINITY, onError: (e) => console.warn(e) }); + await backoffForever(async () => { + if (this._stopped) { + return; + } + await this._command(value); + }); + } catch (e) { + // Non-retryable errors should stop this processing loop, but not deadlock awaiters. + console.warn(e); + break; + } if (this._stopped) { this._notifyPendings(); @@ -161,4 +197,4 @@ export class ValueSync<T> { this._processing = false; this._notifyPendings(); } -} \ No newline at end of file +} diff --git a/expo-app/sources/utils/tempDataStore.ts b/expo-app/sources/utils/tempDataStore.ts index d120daf31..6bc6ff72e 100644 --- a/expo-app/sources/utils/tempDataStore.ts +++ b/expo-app/sources/utils/tempDataStore.ts @@ -1,4 +1,5 @@ -import { randomUUID } from 'expo-crypto'; +import { randomUUID } from '@/platform/randomUUID'; +import type { AgentId } from '@/agents/catalog'; export interface TempDataEntry { data: any; @@ -9,8 +10,9 @@ export interface NewSessionData { prompt?: string; machineId?: string; path?: string; - agentType?: 'claude' | 'codex' | 'gemini'; + agentType?: AgentId; sessionType?: 'simple' | 'worktree'; + resumeSessionId?: string; taskId?: string; taskTitle?: string; } @@ -70,4 +72,4 @@ export function peekTempData<T = any>(key: string): T | null { */ export function clearTempData(): void { tempDataMap.clear(); -} \ No newline at end of file +} diff --git a/expo-app/sources/utils/time.test.ts b/expo-app/sources/utils/time.test.ts new file mode 100644 index 000000000..69c2e3e10 --- /dev/null +++ b/expo-app/sources/utils/time.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it, vi } from 'vitest'; +import { linearBackoffDelay } from './time'; + +describe('linearBackoffDelay', () => { + it('clamps to the configured min/max range', () => { + const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(1); + try { + expect(linearBackoffDelay(0, 250, 1000, 8)).toBe(250); + expect(linearBackoffDelay(8, 250, 1000, 8)).toBe(1000); + expect(linearBackoffDelay(50, 250, 1000, 8)).toBe(1000); + } finally { + randomSpy.mockRestore(); + } + }); +}); + diff --git a/expo-app/sources/utils/time.ts b/expo-app/sources/utils/time.ts index 1feedf57b..4d70023cf 100644 --- a/expo-app/sources/utils/time.ts +++ b/expo-app/sources/utils/time.ts @@ -2,9 +2,13 @@ export async function delay(ms: number) { return new Promise(resolve => setTimeout(resolve, ms)); } -export function exponentialBackoffDelay(currentFailureCount: number, minDelay: number, maxDelay: number, maxFailureCount: number) { - let maxDelayRet = minDelay + ((maxDelay - minDelay) / maxFailureCount) * Math.max(currentFailureCount, maxFailureCount); - return Math.round(Math.random() * maxDelayRet); +export function linearBackoffDelay(currentFailureCount: number, minDelay: number, maxDelay: number, maxFailureCount: number) { + // Linearly ramp the delay as failures increase, capped at maxDelay, then apply jitter. + const safeMaxFailureCount = Number.isFinite(maxFailureCount) ? Math.max(maxFailureCount, 1) : 50; + const clampedFailureCount = Math.min(Math.max(currentFailureCount, 0), safeMaxFailureCount); + const maxDelayRet = minDelay + ((maxDelay - minDelay) / safeMaxFailureCount) * clampedFailureCount; + const jittered = Math.random() * maxDelayRet; + return Math.max(minDelay, Math.round(jittered)); } export type BackoffFunc = <T>(callback: () => Promise<T>) => Promise<T>; @@ -12,6 +16,8 @@ export type BackoffFunc = <T>(callback: () => Promise<T>) => Promise<T>; export function createBackoff( opts?: { onError?: (e: any, failuresCount: number) => void, + onRetry?: (e: any, failuresCount: number, nextDelayMs: number) => void, + shouldRetry?: (e: any, failuresCount: number) => boolean, minDelay?: number, maxDelay?: number, maxFailureCount?: number @@ -20,22 +26,46 @@ export function createBackoff( let currentFailureCount = 0; const minDelay = opts && opts.minDelay !== undefined ? opts.minDelay : 250; const maxDelay = opts && opts.maxDelay !== undefined ? opts.maxDelay : 1000; - const maxFailureCount = opts && opts.maxFailureCount !== undefined ? opts.maxFailureCount : 50; + // Maximum number of failures we tolerate before giving up. + const maxFailureCount = opts && opts.maxFailureCount !== undefined ? opts.maxFailureCount : 8; + const shouldRetry = opts && opts.shouldRetry + ? opts.shouldRetry + : (e: any) => { + // Default: do not retry explicitly non-retryable errors. + // Duck-typed to avoid coupling this util to higher-level error classes. + if (e && typeof e === 'object') { + if ((e as any).retryable === false) { + return false; + } + if (typeof (e as any).canTryAgain === 'boolean' && (e as any).canTryAgain === false) { + return false; + } + } + return true; + }; while (true) { try { return await callback(); } catch (e) { - if (currentFailureCount < maxFailureCount) { - currentFailureCount++; + currentFailureCount++; + if (!shouldRetry(e, currentFailureCount)) { + throw e; + } + if (currentFailureCount >= maxFailureCount) { + throw e; } if (opts && opts.onError) { opts.onError(e, currentFailureCount); } - let waitForRequest = exponentialBackoffDelay(currentFailureCount, minDelay, maxDelay, maxFailureCount); + let waitForRequest = linearBackoffDelay(currentFailureCount, minDelay, maxDelay, maxFailureCount); + if (opts && opts.onRetry) { + opts.onRetry(e, currentFailureCount, waitForRequest); + } await delay(waitForRequest); } } }; } -export let backoff = createBackoff({ onError: (e) => { console.warn(e); } }); \ No newline at end of file +export let backoff = createBackoff({ onError: (e) => { console.warn(e); } }); +export let backoffForever = createBackoff({ onError: (e) => { console.warn(e); }, maxFailureCount: Number.POSITIVE_INFINITY }); diff --git a/expo-app/sources/utils/ui/clipboard.test.ts b/expo-app/sources/utils/ui/clipboard.test.ts new file mode 100644 index 000000000..851341311 --- /dev/null +++ b/expo-app/sources/utils/ui/clipboard.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it, vi } from 'vitest'; + +describe('getClipboardStringTrimmedSafe', () => { + it('returns trimmed clipboard contents', async () => { + vi.resetModules(); + vi.doMock('expo-clipboard', () => { + return { + getStringAsync: vi.fn(async () => ' hello '), + }; + }); + + const { getClipboardStringTrimmedSafe } = await import('./clipboard'); + await expect(getClipboardStringTrimmedSafe()).resolves.toBe('hello'); + }); + + it('returns empty string when clipboard read throws', async () => { + vi.resetModules(); + vi.doMock('expo-clipboard', () => { + return { + getStringAsync: vi.fn(async () => { + throw new Error('clipboard failed'); + }), + }; + }); + + const { getClipboardStringTrimmedSafe } = await import('./clipboard'); + await expect(getClipboardStringTrimmedSafe()).resolves.toBe(''); + }); +}); + diff --git a/expo-app/sources/utils/ui/clipboard.ts b/expo-app/sources/utils/ui/clipboard.ts new file mode 100644 index 000000000..94621287c --- /dev/null +++ b/expo-app/sources/utils/ui/clipboard.ts @@ -0,0 +1,10 @@ +import * as Clipboard from 'expo-clipboard'; + +export async function getClipboardStringTrimmedSafe(): Promise<string> { + try { + return (await Clipboard.getStringAsync()).trim(); + } catch { + return ''; + } +} + diff --git a/expo-app/sources/utils/ui/ignoreNextRowPress.test.ts b/expo-app/sources/utils/ui/ignoreNextRowPress.test.ts new file mode 100644 index 000000000..807780c5b --- /dev/null +++ b/expo-app/sources/utils/ui/ignoreNextRowPress.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it, vi } from 'vitest'; +import { ignoreNextRowPress } from './ignoreNextRowPress'; + +describe('ignoreNextRowPress', () => { + it('resets the ignore flag on the next tick', () => { + vi.useFakeTimers(); + try { + const ref = { current: false }; + + ignoreNextRowPress(ref); + expect(ref.current).toBe(true); + + vi.runAllTimers(); + expect(ref.current).toBe(false); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/expo-app/sources/utils/ui/ignoreNextRowPress.ts b/expo-app/sources/utils/ui/ignoreNextRowPress.ts new file mode 100644 index 000000000..55c95e473 --- /dev/null +++ b/expo-app/sources/utils/ui/ignoreNextRowPress.ts @@ -0,0 +1,7 @@ +export function ignoreNextRowPress(ref: { current: boolean }): void { + ref.current = true; + setTimeout(() => { + ref.current = false; + }, 0); +} + diff --git a/expo-app/sources/utils/ui/promptUnsavedChangesAlert.test.ts b/expo-app/sources/utils/ui/promptUnsavedChangesAlert.test.ts new file mode 100644 index 000000000..33a26372d --- /dev/null +++ b/expo-app/sources/utils/ui/promptUnsavedChangesAlert.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; +import type { AlertButton } from '@/modal/types'; +import { promptUnsavedChangesAlert } from '@/utils/ui/promptUnsavedChangesAlert'; + +const basePromptOptions = { + title: 'Discard changes', + message: 'You have unsaved changes.', + discardText: 'Discard', + saveText: 'Save', + keepEditingText: 'Keep editing', +} as const; + +function createPromptHarness() { + let lastButtons: AlertButton[] | undefined; + + const alert = (_title: string, _message?: string, buttons?: AlertButton[]) => { + lastButtons = buttons; + }; + + const promise = promptUnsavedChangesAlert(alert, basePromptOptions); + + function press(text: string) { + const button = lastButtons?.find((b) => b.text === text); + expect(button).toBeDefined(); + button?.onPress?.(); + } + + return { promise, press }; +} + +describe('promptUnsavedChangesAlert', () => { + it('resolves to save when the Save button is pressed', async () => { + const { promise, press } = createPromptHarness(); + + press('Save'); + + await expect(promise).resolves.toBe('save'); + }); + + it('resolves to discard when the Discard button is pressed', async () => { + const { promise, press } = createPromptHarness(); + + press('Discard'); + + await expect(promise).resolves.toBe('discard'); + }); + + it('resolves to keepEditing when the Keep editing button is pressed', async () => { + const { promise, press } = createPromptHarness(); + + press('Keep editing'); + + await expect(promise).resolves.toBe('keepEditing'); + }); +}); diff --git a/expo-app/sources/utils/ui/promptUnsavedChangesAlert.ts b/expo-app/sources/utils/ui/promptUnsavedChangesAlert.ts new file mode 100644 index 000000000..867580f3a --- /dev/null +++ b/expo-app/sources/utils/ui/promptUnsavedChangesAlert.ts @@ -0,0 +1,35 @@ +import type { AlertButton } from '@/modal/types'; + +export type UnsavedChangesDecision = 'discard' | 'save' | 'keepEditing'; + +export function promptUnsavedChangesAlert( + alert: (title: string, message?: string, buttons?: AlertButton[]) => void, + params: { + title: string; + message: string; + discardText: string; + saveText: string; + keepEditingText: string; + }, +): Promise<UnsavedChangesDecision> { + return new Promise((resolve) => { + alert(params.title, params.message, [ + { + text: params.discardText, + style: 'destructive', + onPress: () => resolve('discard'), + }, + { + text: params.saveText, + style: 'default', + onPress: () => resolve('save'), + }, + { + text: params.keepEditingText, + style: 'cancel', + onPress: () => resolve('keepEditing'), + }, + ]); + }); +} + diff --git a/expo-app/sources/utils/web/radixCjs.ts b/expo-app/sources/utils/web/radixCjs.ts new file mode 100644 index 000000000..3a51b3bb9 --- /dev/null +++ b/expo-app/sources/utils/web/radixCjs.ts @@ -0,0 +1,10 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ + +export function requireRadixDialog() { + return require('@radix-ui/react-dialog') as typeof import('@radix-ui/react-dialog'); +} + +export function requireRadixDismissableLayer() { + return require('@radix-ui/react-dismissable-layer') as typeof import('@radix-ui/react-dismissable-layer'); +} + diff --git a/expo-app/sources/utils/web/reactDomCjs.ts b/expo-app/sources/utils/web/reactDomCjs.ts new file mode 100644 index 000000000..ae26d4bf5 --- /dev/null +++ b/expo-app/sources/utils/web/reactDomCjs.ts @@ -0,0 +1,8 @@ +export function requireReactDOM(): any { + // IMPORTANT: + // Use `require` so this module can be imported in cross-platform code without pulling `react-dom` + // into native bundles. Callers should only invoke this on web. + // eslint-disable-next-line @typescript-eslint/no-var-requires + return require('react-dom'); +} + diff --git a/expo-app/sources/utils/web/reactNativeScreensCjs.ts b/expo-app/sources/utils/web/reactNativeScreensCjs.ts new file mode 100644 index 000000000..f7257f34f --- /dev/null +++ b/expo-app/sources/utils/web/reactNativeScreensCjs.ts @@ -0,0 +1,8 @@ +export function requireReactNativeScreens(): any { + // IMPORTANT: + // Use `require` so this module can be imported in cross-platform code without pulling + // react-native-screens into non-native bundles. Callers should only invoke this on native. + // eslint-disable-next-line @typescript-eslint/no-var-requires + return require('react-native-screens'); +} + diff --git a/expo-app/tools/postinstall.mjs b/expo-app/tools/postinstall.mjs new file mode 100644 index 000000000..f475b5046 --- /dev/null +++ b/expo-app/tools/postinstall.mjs @@ -0,0 +1,95 @@ +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; +import url from 'node:url'; + +// Yarn workspaces can execute this script via a symlinked path (e.g. repoRoot/node_modules/happy/...). +// Resolve symlinks so repoRootDir/expoAppDir are computed from the real filesystem location. +const toolsDir = path.dirname(fs.realpathSync(url.fileURLToPath(import.meta.url))); +const expoAppDir = path.resolve(toolsDir, '..'); +const repoRootDir = path.resolve(expoAppDir, '..'); +const patchDir = path.resolve(expoAppDir, 'patches'); +const patchDirFromRepoRoot = path.relative(repoRootDir, patchDir); +const patchDirFromExpoApp = path.relative(expoAppDir, patchDir); +const repoRootNodeModulesDir = path.resolve(repoRootDir, 'node_modules'); +const expoAppNodeModulesDir = path.resolve(expoAppDir, 'node_modules'); + +const patchPackageCliCandidatePaths = [ + path.resolve(expoAppDir, 'node_modules', 'patch-package', 'dist', 'index.js'), + path.resolve(repoRootDir, 'node_modules', 'patch-package', 'dist', 'index.js'), +]; + +const patchPackageCliPath = patchPackageCliCandidatePaths.find((candidatePath) => + fs.existsSync(candidatePath), +); + +if (!patchPackageCliPath) { + console.error( + `Could not find patch-package CLI at:\n${patchPackageCliCandidatePaths + .map((p) => `- ${p}`) + .join('\n')}`, + ); + process.exit(1); +} + +function run(command, args, options) { + const result = spawnSync(command, args, { stdio: 'inherit', ...options }); + if (result.status !== 0) { + process.exit(result.status ?? 1); + } +} + +// Note: this repo uses Yarn workspaces, so some dependencies are hoisted to the repo root. +// patch-package only patches packages present in the current working directory's +// node_modules, so we run it from the repo root but keep patch files in expo-app/patches. +if (fs.existsSync(repoRootNodeModulesDir)) { + run(process.execPath, [patchPackageCliPath, '--patch-dir', patchDirFromRepoRoot], { + cwd: repoRootDir, + }); +} + +// Some dependencies are not hoisted (e.g. expo-router) and are installed under expo-app/node_modules. +// Run patch-package again scoped to expo-app to apply those patches. +if (fs.existsSync(expoAppNodeModulesDir)) { + run(process.execPath, [patchPackageCliPath, '--patch-dir', patchDirFromExpoApp], { + cwd: expoAppDir, + }); +} + +const expoRouterWebModalCandidatePaths = [ + path.resolve(repoRootDir, 'node_modules', 'expo-router', 'build', 'layouts', '_web-modal.js'), + path.resolve(expoAppDir, 'node_modules', 'expo-router', 'build', 'layouts', '_web-modal.js'), +]; + +const existingExpoRouterWebModalPaths = expoRouterWebModalCandidatePaths.filter((candidatePath) => + fs.existsSync(candidatePath), +); + +if (existingExpoRouterWebModalPaths.length === 0) { + console.error( + `Could not find expo-router _web-modal.js at:\n${expoRouterWebModalCandidatePaths + .map((p) => `- ${p}`) + .join('\n')}`, + ); + process.exit(1); +} + +const unpatchedPaths = []; +for (const filePath of existingExpoRouterWebModalPaths) { + const contents = fs.readFileSync(filePath, 'utf8'); + if (!contents.includes('ExperimentalModalStack')) { + unpatchedPaths.push(filePath); + } +} + +if (unpatchedPaths.length > 0) { + console.error( + `expo-router web modals patch does not appear to be applied to:\n${unpatchedPaths + .map((p) => `- ${p}`) + .join('\n')}`, + ); + process.exit(1); +} + +run('npx', ['setup-skia-web', 'public'], { cwd: expoAppDir }); diff --git a/expo-app/vitest.config.ts b/expo-app/vitest.config.ts index 1836de229..08cd2cf5e 100644 --- a/expo-app/vitest.config.ts +++ b/expo-app/vitest.config.ts @@ -2,9 +2,16 @@ import { defineConfig } from 'vitest/config' import { resolve } from 'node:path' export default defineConfig({ + define: { + __DEV__: false, + }, test: { + // Ensure per-file module isolation so test-local `vi.mock(...)` does not leak + // across unrelated test files (especially important for our React Native stubs). + isolate: true, globals: false, environment: 'node', + setupFiles: [resolve('./sources/dev/vitestSetup.ts')], include: ['sources/**/*.{spec,test}.ts'], coverage: { provider: 'v8', @@ -19,8 +26,23 @@ export default defineConfig({ }, }, resolve: { - alias: { - '@': resolve('./sources'), - }, + // IMPORTANT: keep `@` after more specific `@/...` aliases (Vite resolves aliases in-order). + alias: [ + // Vitest runs in node; avoid parsing React Native's Flow entrypoint. + { find: 'react-native', replacement: resolve('./sources/dev/reactNativeStub.ts') }, + // Expo packages commonly depend on `expo-modules-core`, whose exports point to TS sources that import `react-native`. + // In node/Vitest we stub the minimal surface needed by our tests. + { find: 'expo-modules-core', replacement: resolve('./sources/dev/expoModulesCoreStub.ts') }, + // `expo-localization` depends on Expo modules that don't exist in Vitest's node env. + { find: 'expo-localization', replacement: resolve('./sources/dev/expoLocalizationStub.ts') }, + // Use libsodium-wrappers in tests instead of the RN native binding. + { find: '@more-tech/react-native-libsodium', replacement: 'libsodium-wrappers' }, + // Use node-safe platform adapters in tests (avoid static expo-crypto imports). + { find: '@/platform/cryptoRandom', replacement: resolve('./sources/platform/cryptoRandom.node.ts') }, + { find: '@/platform/hmacSha512', replacement: resolve('./sources/platform/hmacSha512.node.ts') }, + { find: '@/platform/randomUUID', replacement: resolve('./sources/platform/randomUUID.node.ts') }, + { find: '@/platform/digest', replacement: resolve('./sources/platform/digest.node.ts') }, + { find: '@', replacement: resolve('./sources') }, + ], }, -}) \ No newline at end of file +}) diff --git a/expo-app/yarn.lock b/expo-app/yarn.lock index ce5b12ad1..5391b0490 100644 --- a/expo-app/yarn.lock +++ b/expo-app/yarn.lock @@ -3109,6 +3109,13 @@ dependencies: "@types/react" "*" +"@types/react-test-renderer@^19.1.0": + version "19.1.0" + resolved "https://registry.yarnpkg.com/@types/react-test-renderer/-/react-test-renderer-19.1.0.tgz#1d0af8f2e1b5931e245b8b5b234d1502b854dc10" + integrity sha512-XD0WZrHqjNrxA/MaR9O22w/RNidWR9YZmBdRGI7wcnWGrv/3dA8wKCJ8m63Sn+tLJhcjmuhOi629N66W6kgWzQ== + dependencies: + "@types/react" "*" + "@types/react@*": version "19.1.8" resolved "https://registry.yarnpkg.com/@types/react/-/react-19.1.8.tgz#ff8395f2afb764597265ced15f8dddb0720ae1c3" @@ -8444,7 +8451,7 @@ react-is@^18.0.0: resolved "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz" integrity sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg== -react-is@^19.0.0, react-is@^19.1.0: +react-is@^19.1.0: version "19.1.0" resolved "https://registry.npmjs.org/react-is/-/react-is-19.1.0.tgz" integrity sha512-Oe56aUPnkHyyDxxkvqtd7KkdQP5uIUfHxd5XTb3wE9d/kRnZLmKbDB0GWk919tdQ+mxxPtG6EAs6RMT6i1qtHg== @@ -8746,13 +8753,13 @@ react-syntax-highlighter@^15.6.1: prismjs "^1.27.0" refractor "^3.6.0" -react-test-renderer@19.0.0: - version "19.0.0" - resolved "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-19.0.0.tgz" - integrity sha512-oX5u9rOQlHzqrE/64CNr0HB0uWxkCQmZNSfozlYvwE71TLVgeZxVf0IjouGEr1v7r1kcDifdAJBeOhdhxsG/DA== +react-test-renderer@19.1.0: + version "19.1.0" + resolved "https://registry.yarnpkg.com/react-test-renderer/-/react-test-renderer-19.1.0.tgz#89e1baa9e45a6da064b9760f92251d5b8e1f34ab" + integrity sha512-jXkSl3CpvPYEF+p/eGDLB4sPoDX8pKkYvRl9+rR8HxLY0X04vW7hCm1/0zHoUSjPZ3bDa+wXWNTDVIw/R8aDVw== dependencies: - react-is "^19.0.0" - scheduler "^0.25.0" + react-is "^19.1.0" + scheduler "^0.26.0" react-textarea-autosize@^8.5.9: version "8.5.9" diff --git a/package.json b/package.json index 90480ef9b..bb63b53e8 100644 --- a/package.json +++ b/package.json @@ -1,19 +1,28 @@ { "name": "monorepo", "private": true, + "scripts": { + "build:packages": "yarn workspace @happy/agents build && yarn workspace @happy/protocol build", + "ci:act": "bash scripts/ci/run-act-tests.sh", + "test": "yarn --cwd expo-app test && yarn --cwd cli test && yarn --cwd server test", + "typecheck": "yarn --cwd expo-app typecheck && yarn --cwd cli typecheck && yarn --cwd server build" + }, "workspaces": { "packages": [ "expo-app", - "hello-world" + "packages/*" ], "nohoist": [ - "**/react", - "**/react-dom", - "**/react-native", - "**/react-native/**", - "**/react-native-edge-to-edge/**", - "**/react-native-incall-manager/**" - ] + "**/react", + "**/react-dom", + "**/react-native", + "**/react-native/**", + "**/react-native-edge-to-edge/**", + "**/react-native-incall-manager/**" + ] + }, + "resolutions": { + "expo-router": "6.0.22" }, "packageManager": "yarn@1.22.22" } diff --git a/packages/agents/README.md b/packages/agents/README.md new file mode 100644 index 000000000..0109d5ef2 --- /dev/null +++ b/packages/agents/README.md @@ -0,0 +1,4 @@ +# @happy/agents + +Internal workspace package for shared agent identifiers/configuration. + diff --git a/packages/agents/package.json b/packages/agents/package.json new file mode 100644 index 000000000..9217069ef --- /dev/null +++ b/packages/agents/package.json @@ -0,0 +1,27 @@ +{ + "name": "@happy/agents", + "version": "0.0.0", + "private": true, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": [ + "dist", + "package.json", + "README.md" + ], + "scripts": { + "build": "tsc -p tsconfig.json", + "postinstall": "yarn -s build", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "devDependencies": { + "typescript": "^5.9.2" + } +} diff --git a/packages/agents/src/index.ts b/packages/agents/src/index.ts new file mode 100644 index 000000000..da8fdd979 --- /dev/null +++ b/packages/agents/src/index.ts @@ -0,0 +1,13 @@ +export const HAPPY_AGENTS_PACKAGE = '@happy/agents'; + +export { + AGENT_IDS, + type AgentCore, + type AgentId, + type CloudConnectTargetStatus, + type CloudVendorKey, + type ResumeRuntimeGate, + type VendorResumeIdField, + type VendorResumeSupportLevel, +} from './types.js'; +export { AGENTS_CORE, DEFAULT_AGENT_ID } from './manifest.js'; diff --git a/packages/agents/src/manifest.ts b/packages/agents/src/manifest.ts new file mode 100644 index 000000000..a4e0f7d90 --- /dev/null +++ b/packages/agents/src/manifest.ts @@ -0,0 +1,46 @@ +import type { AgentCore, AgentId } from './types.js'; + +export const DEFAULT_AGENT_ID: AgentId = 'claude'; + +export const AGENTS_CORE = { + claude: { + id: 'claude', + cliSubcommand: 'claude', + detectKey: 'claude', + flavorAliases: [], + cloudConnect: { vendorKey: 'anthropic', status: 'experimental' }, + resume: { vendorResume: 'supported', vendorResumeIdField: null, runtimeGate: null }, + }, + codex: { + id: 'codex', + cliSubcommand: 'codex', + detectKey: 'codex', + flavorAliases: ['codex-acp', 'codex-mcp'], + cloudConnect: { vendorKey: 'openai', status: 'experimental' }, + resume: { vendorResume: 'experimental', vendorResumeIdField: 'codexSessionId', runtimeGate: null }, + }, + opencode: { + id: 'opencode', + cliSubcommand: 'opencode', + detectKey: 'opencode', + flavorAliases: [], + cloudConnect: null, + resume: { vendorResume: 'supported', vendorResumeIdField: 'opencodeSessionId', runtimeGate: 'acpLoadSession' }, + }, + gemini: { + id: 'gemini', + cliSubcommand: 'gemini', + detectKey: 'gemini', + flavorAliases: [], + cloudConnect: { vendorKey: 'gemini', status: 'wired' }, + resume: { vendorResume: 'supported', vendorResumeIdField: 'geminiSessionId', runtimeGate: 'acpLoadSession' }, + }, + auggie: { + id: 'auggie', + cliSubcommand: 'auggie', + detectKey: 'auggie', + flavorAliases: [], + cloudConnect: null, + resume: { vendorResume: 'supported', vendorResumeIdField: 'auggieSessionId', runtimeGate: 'acpLoadSession' }, + }, +} as const satisfies Record<AgentId, AgentCore>; diff --git a/packages/agents/src/types.ts b/packages/agents/src/types.ts new file mode 100644 index 000000000..c017df9fc --- /dev/null +++ b/packages/agents/src/types.ts @@ -0,0 +1,58 @@ +export const AGENT_IDS = ['claude', 'codex', 'opencode', 'gemini', 'auggie'] as const; +export type AgentId = (typeof AGENT_IDS)[number]; + +export type VendorResumeSupportLevel = 'supported' | 'unsupported' | 'experimental'; +export type ResumeRuntimeGate = 'acpLoadSession' | null; + +export type VendorResumeIdField = 'codexSessionId' | 'geminiSessionId' | 'opencodeSessionId' | 'auggieSessionId'; + +export type CloudVendorKey = 'openai' | 'anthropic' | 'gemini'; +export type CloudConnectTargetStatus = 'wired' | 'experimental'; + +export type AgentCore = Readonly<{ + id: AgentId; + /** + * CLI subcommand used to spawn/select the agent. + * For now this matches the canonical id. + */ + cliSubcommand: AgentId; + /** + * CLI binary name used for local detection (e.g. `command -v <detectKey>`). + * For now this matches the canonical id. + */ + detectKey: AgentId; + /** + * Optional alternative flavors that should resolve to this agent id. + * + * This is intended for internal variants (e.g. `codex-acp`) and UI legacy + * strings; the canonical id should remain the primary persisted value. + */ + flavorAliases?: ReadonlyArray<string>; + /** + * Optional cloud-connect config for this agent. + * + * When present, the CLI/app may offer a `happy connect <agentId>` flow. + */ + cloudConnect?: Readonly<{ vendorKey: CloudVendorKey; status: CloudConnectTargetStatus }> | null; + resume: Readonly<{ + /** + * Whether vendor-resume is supported in principle. + * + * - supported: generally supported and expected to work + * - experimental: supported but intentionally gated/opt-in + * - unsupported: not available at all + */ + vendorResume: VendorResumeSupportLevel; + /** + * Optional metadata field name used to persist the vendor resume id. + * + * This lets UI + CLI agree on which metadata key to read/write without + * duplicating strings. + */ + vendorResumeIdField?: VendorResumeIdField | null; + /** + * Optional runtime gate used by apps to enable resume dynamically per machine. + */ + runtimeGate: ResumeRuntimeGate; + }>; +}>; diff --git a/packages/agents/tsconfig.json b/packages/agents/tsconfig.json new file mode 100644 index 000000000..0811cf86e --- /dev/null +++ b/packages/agents/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022"], + "rootDir": "src", + "outDir": "dist", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "strict": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/protocol/README.md b/packages/protocol/README.md new file mode 100644 index 000000000..080534aeb --- /dev/null +++ b/packages/protocol/README.md @@ -0,0 +1,7 @@ +# @happy/protocol + +Shared cross-package contracts between Happy CLI and Happy Expo app. + +This package is intentionally small and should only contain stable protocol-level +types/constants that both sides need (e.g. RPC result shapes, error codes). + diff --git a/packages/protocol/package.json b/packages/protocol/package.json new file mode 100644 index 000000000..18e01e292 --- /dev/null +++ b/packages/protocol/package.json @@ -0,0 +1,50 @@ +{ + "name": "@happy/protocol", + "version": "0.0.0", + "private": true, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./spawnSession": { + "types": "./dist/spawnSession.d.ts", + "default": "./dist/spawnSession.js" + }, + "./rpc": { + "types": "./dist/rpc.d.ts", + "default": "./dist/rpc.js" + }, + "./checklists": { + "types": "./dist/checklists.d.ts", + "default": "./dist/checklists.js" + }, + "./capabilities": { + "types": "./dist/capabilities.d.ts", + "default": "./dist/capabilities.js" + }, + "./socketRpc": { + "types": "./dist/socketRpc.d.ts", + "default": "./dist/socketRpc.js" + } + }, + "files": [ + "dist", + "package.json", + "README.md" + ], + "scripts": { + "build": "tsc -p tsconfig.json", + "postinstall": "yarn --cwd ../agents -s build && yarn -s build", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "@happy/agents": "link:../agents" + }, + "devDependencies": { + "typescript": "^5.9.2" + } +} diff --git a/packages/protocol/src/capabilities.ts b/packages/protocol/src/capabilities.ts new file mode 100644 index 000000000..441767026 --- /dev/null +++ b/packages/protocol/src/capabilities.ts @@ -0,0 +1,49 @@ +export type CapabilityKind = 'cli' | 'tool' | 'dep'; + +// Capability IDs are namespaced strings returned by the daemon. +// Keep this flexible so new capabilities (including new `cli.<agent>` ids) do not require UI code changes. +export type CapabilityId = `cli.${string}` | `tool.${string}` | `dep.${string}`; + +export type CapabilityDetectRequest = { + id: CapabilityId; + params?: Record<string, unknown>; +}; + +export type CapabilityDescriptor = { + id: CapabilityId; + kind: CapabilityKind; + title?: string; + methods?: Record<string, { title?: string }>; +}; + +export type CapabilitiesDescribeResponse = { + protocolVersion: 1; + capabilities: CapabilityDescriptor[]; + checklists: Record<string, CapabilityDetectRequest[]>; +}; + +export type CapabilityDetectResult = + | { ok: true; checkedAt: number; data: unknown } + | { ok: false; checkedAt: number; error: { message: string; code?: string } }; + +export type CapabilitiesDetectResponse = { + protocolVersion: 1; + results: Partial<Record<CapabilityId, CapabilityDetectResult>>; +}; + +export type CapabilitiesDetectRequest = { + checklistId?: string; + requests?: CapabilityDetectRequest[]; + overrides?: Partial<Record<CapabilityId, { params?: Record<string, unknown> }>>; +}; + +export type CapabilitiesInvokeRequest = { + id: CapabilityId; + method: string; + params?: Record<string, unknown>; +}; + +export type CapabilitiesInvokeResponse = + | { ok: true; result: unknown } + | { ok: false; error: { message: string; code?: string }; logPath?: string }; + diff --git a/packages/protocol/src/checklists.ts b/packages/protocol/src/checklists.ts new file mode 100644 index 000000000..0a96864b8 --- /dev/null +++ b/packages/protocol/src/checklists.ts @@ -0,0 +1,12 @@ +import type { AgentId } from '@happy/agents'; + +export const CHECKLIST_IDS = { + NEW_SESSION: 'new-session', + MACHINE_DETAILS: 'machine-details', +} as const; + +export type ChecklistId = (typeof CHECKLIST_IDS)[keyof typeof CHECKLIST_IDS] | `resume.${AgentId}`; + +export function resumeChecklistId(agentId: AgentId): `resume.${AgentId}` { + return `resume.${agentId}`; +} diff --git a/packages/protocol/src/index.ts b/packages/protocol/src/index.ts new file mode 100644 index 000000000..f5c650848 --- /dev/null +++ b/packages/protocol/src/index.ts @@ -0,0 +1,25 @@ +export const HAPPY_PROTOCOL_PACKAGE = '@happy/protocol'; + +export { SPAWN_SESSION_ERROR_CODES, type SpawnSessionErrorCode, type SpawnSessionResult } from './spawnSession.js'; +export { + RPC_ERROR_CODES, + RPC_ERROR_MESSAGES, + RPC_METHODS, + isRpcMethodNotFoundResult, + type RpcErrorCode, + type RpcMethod, +} from './rpc.js'; +export { CHECKLIST_IDS, resumeChecklistId, type ChecklistId } from './checklists.js'; +export { SOCKET_RPC_EVENTS, type SocketRpcEvent } from './socketRpc.js'; +export { + type CapabilitiesDescribeResponse, + type CapabilitiesDetectRequest, + type CapabilitiesDetectResponse, + type CapabilitiesInvokeRequest, + type CapabilitiesInvokeResponse, + type CapabilityDescriptor, + type CapabilityDetectRequest, + type CapabilityDetectResult, + type CapabilityId, + type CapabilityKind, +} from './capabilities.js'; diff --git a/packages/protocol/src/rpc.ts b/packages/protocol/src/rpc.ts new file mode 100644 index 000000000..4b3d83e87 --- /dev/null +++ b/packages/protocol/src/rpc.ts @@ -0,0 +1,37 @@ +export const RPC_METHODS = { + SPAWN_HAPPY_SESSION: 'spawn-happy-session', + STOP_SESSION: 'stop-session', + STOP_DAEMON: 'stop-daemon', + BASH: 'bash', + PREVIEW_ENV: 'preview-env', + READ_FILE: 'readFile', + WRITE_FILE: 'writeFile', + LIST_DIRECTORY: 'listDirectory', + GET_DIRECTORY_TREE: 'getDirectoryTree', + RIPGREP: 'ripgrep', + DIFFTASTIC: 'difftastic', + KILL_SESSION: 'killSession', + CAPABILITIES_DESCRIBE: 'capabilities.describe', + CAPABILITIES_DETECT: 'capabilities.detect', + CAPABILITIES_INVOKE: 'capabilities.invoke', +} as const; + +export type RpcMethod = (typeof RPC_METHODS)[keyof typeof RPC_METHODS]; + +export const RPC_ERROR_CODES = { + METHOD_NOT_AVAILABLE: 'RPC_METHOD_NOT_AVAILABLE', + METHOD_NOT_FOUND: 'RPC_METHOD_NOT_FOUND', +} as const; + +export type RpcErrorCode = (typeof RPC_ERROR_CODES)[keyof typeof RPC_ERROR_CODES]; + +export const RPC_ERROR_MESSAGES = { + METHOD_NOT_FOUND: 'Method not found', +} as const; + +export function isRpcMethodNotFoundResult(value: unknown): value is { error: string; errorCode?: string } { + if (!value || typeof value !== 'object') return false; + const maybe = value as any; + if (maybe.errorCode === RPC_ERROR_CODES.METHOD_NOT_FOUND) return true; + return maybe.error === RPC_ERROR_MESSAGES.METHOD_NOT_FOUND; +} diff --git a/packages/protocol/src/socketRpc.ts b/packages/protocol/src/socketRpc.ts new file mode 100644 index 000000000..0658dfce3 --- /dev/null +++ b/packages/protocol/src/socketRpc.ts @@ -0,0 +1,12 @@ +export const SOCKET_RPC_EVENTS = { + REGISTER: 'rpc-register', + REGISTERED: 'rpc-registered', + UNREGISTER: 'rpc-unregister', + UNREGISTERED: 'rpc-unregistered', + ERROR: 'rpc-error', + CALL: 'rpc-call', + REQUEST: 'rpc-request', +} as const; + +export type SocketRpcEvent = (typeof SOCKET_RPC_EVENTS)[keyof typeof SOCKET_RPC_EVENTS]; + diff --git a/packages/protocol/src/spawnSession.ts b/packages/protocol/src/spawnSession.ts new file mode 100644 index 000000000..6bbba629c --- /dev/null +++ b/packages/protocol/src/spawnSession.ts @@ -0,0 +1,23 @@ +export const SPAWN_SESSION_ERROR_CODES = { + INVALID_REQUEST: 'INVALID_REQUEST', + INVALID_ENVIRONMENT_VARIABLES: 'INVALID_ENVIRONMENT_VARIABLES', + AUTH_ENV_UNEXPANDED: 'AUTH_ENV_UNEXPANDED', + RESUME_NOT_SUPPORTED: 'RESUME_NOT_SUPPORTED', + RESUME_MISSING_ENCRYPTION_KEY: 'RESUME_MISSING_ENCRYPTION_KEY', + RESUME_UNSUPPORTED_ENCRYPTION_VARIANT: 'RESUME_UNSUPPORTED_ENCRYPTION_VARIANT', + DIRECTORY_CREATE_FAILED: 'DIRECTORY_CREATE_FAILED', + SPAWN_VALIDATION_FAILED: 'SPAWN_VALIDATION_FAILED', + SPAWN_NO_PID: 'SPAWN_NO_PID', + CHILD_EXITED_BEFORE_WEBHOOK: 'CHILD_EXITED_BEFORE_WEBHOOK', + SESSION_WEBHOOK_TIMEOUT: 'SESSION_WEBHOOK_TIMEOUT', + SPAWN_FAILED: 'SPAWN_FAILED', + UNEXPECTED: 'UNEXPECTED', +} as const; + +export type SpawnSessionErrorCode = (typeof SPAWN_SESSION_ERROR_CODES)[keyof typeof SPAWN_SESSION_ERROR_CODES]; + +export type SpawnSessionResult = + | { type: 'success'; sessionId?: string } + | { type: 'requestToApproveDirectoryCreation'; directory: string } + | { type: 'error'; errorCode: SpawnSessionErrorCode; errorMessage: string }; + diff --git a/packages/protocol/tsconfig.json b/packages/protocol/tsconfig.json new file mode 100644 index 000000000..ab4d2b8e4 --- /dev/null +++ b/packages/protocol/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022"], + "rootDir": "src", + "outDir": "dist", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "strict": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +} + diff --git a/scripts/ci/run-act-tests.sh b/scripts/ci/run-act-tests.sh new file mode 100644 index 000000000..c718f23b6 --- /dev/null +++ b/scripts/ci/run-act-tests.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" +cd "$REPO_ROOT" + +WORKFLOW_PATH="${ACT_WORKFLOW_PATH:-.github/workflows/tests.yml}" +ARCH="${ACT_ARCH:-linux/amd64}" +LOG_DIR="${ACT_LOG_DIR:-/tmp}" + +usage() { + cat <<'EOF' +Run the GitHub Actions test workflow locally using `act`. + +Usage: + bash scripts/ci/run-act-tests.sh # run all jobs + bash scripts/ci/run-act-tests.sh <job>... # run specific job(s) + +Jobs: + expo-app + server + cli + cli-daemon-e2e + +Env overrides: + ACT_WORKFLOW_PATH (default: .github/workflows/tests.yml) + ACT_ARCH (default: linux/amd64) + ACT_LOG_DIR (default: /tmp) +EOF +} + +if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then + usage + exit 0 +fi + +if ! command -v act >/dev/null 2>&1; then + echo "Error: \`act\` is not installed or not on PATH." >&2 + exit 1 +fi + +if ! command -v docker >/dev/null 2>&1; then + echo "Error: \`docker\` is not installed or not on PATH." >&2 + exit 1 +fi + +if ! docker info >/dev/null 2>&1; then + echo "Error: Docker does not appear to be running (docker info failed)." >&2 + exit 1 +fi + +if [[ ! -f "$WORKFLOW_PATH" ]]; then + echo "Error: workflow not found at \`$WORKFLOW_PATH\`." >&2 + exit 1 +fi + +mkdir -p "$LOG_DIR" + +DEFAULT_JOBS=(expo-app server cli cli-daemon-e2e) +JOBS=("$@") +if [[ ${#JOBS[@]} -eq 0 ]]; then + JOBS=("${DEFAULT_JOBS[@]}") +fi + +RUN_ID="$(date +%Y%m%d-%H%M%S)" + +echo "Using workflow: $WORKFLOW_PATH" +echo "Using arch: $ARCH" +echo "Log dir: $LOG_DIR" +echo "Jobs: ${JOBS[*]}" +echo + +for job in "${JOBS[@]}"; do + log_file="$LOG_DIR/act-${job}-${RUN_ID}.log" + echo "==> Running act job: $job" + echo " Log: $log_file" + echo + act --container-architecture "$ARCH" -W "$WORKFLOW_PATH" -j "$job" | tee "$log_file" + echo +done diff --git a/server/.gitignore b/server/.gitignore index c75f9856c..e45f29371 100644 --- a/server/.gitignore +++ b/server/.gitignore @@ -10,3 +10,5 @@ dist .logs/ .claude/ + +generated/ diff --git a/server/README.md b/server/README.md index bef4da684..30ca2c599 100644 --- a/server/README.md +++ b/server/README.md @@ -9,12 +9,13 @@ Happy Server is the synchronization backbone for secure Claude Code clients. It ## Features - 🔐 **Zero Knowledge** - The server stores encrypted data but has no ability to decrypt it -- 🎯 **Minimal Surface** - Only essential features for secure sync, nothing more +- 🎯 **Minimal Surface** - Only essential features for secure sync, nothing more - 🕵️ **Privacy First** - No analytics, no tracking, no data mining - 📖 **Open Source** - Transparent implementation you can audit and self-host - 🔑 **Cryptographic Auth** - No passwords stored, only public key signatures - ⚡ **Real-time Sync** - WebSocket-based synchronization across all your devices - 📱 **Multi-device** - Seamless session management across phones, tablets, and computers +- 🤝 **Session Sharing** - Collaborate on conversations with granular access control - 🔔 **Push Notifications** - Notify when Claude Code finishes tasks or needs permissions (encrypted, we can't see the content) - 🌐 **Distributed Ready** - Built to scale horizontally when needed @@ -22,12 +23,178 @@ Happy Server is the synchronization backbone for secure Claude Code clients. It Your Claude Code clients generate encryption keys locally and use Happy Server as a secure relay. Messages are end-to-end encrypted before leaving your device. The server's job is simple: store encrypted blobs and sync them between your devices in real-time. +### Session Sharing + +Happy Server supports secure collaboration through two sharing methods: + +**Direct Sharing**: Share sessions with specific users by username, with three access levels: +- **View**: Read-only access to messages +- **Edit**: Can send messages but cannot manage sharing +- **Admin**: Full access including sharing management + +**Public Links**: Generate shareable URLs for broader access: +- Always read-only for security +- Optional expiration dates and usage limits +- Consent-based access logging (IP/UA only logged with explicit consent) + +All sharing maintains end-to-end encryption - encrypted data keys are distributed to authorized users, and the server never sees unencrypted content. + ## Hosting **You don't need to self-host!** Our free cloud Happy Server at `happy-api.slopus.com` is just as secure as running your own. Since all data is end-to-end encrypted before it reaches our servers, we literally cannot read your messages even if we wanted to. The encryption happens on your device, and only you have the keys. That said, Happy Server is open source and self-hostable if you prefer running your own infrastructure. The security model is identical whether you use our servers or your own. +## Server flavors + +Happy Server supports two flavors that share the same API + internal logic. The only difference is which infrastructure backends are used for storage. + +- **full** (default, recommended for production): Postgres + Redis + S3/Minio-compatible public file storage. +- **light** (recommended for self-hosting/testing): SQLite + local public file storage served by the server under `GET /files/*`. + +### Choosing a flavor + +- **full**: run `yarn start` (uses `sources/main.ts` → `startServer('full')`) +- **light**: run `yarn start:light` (uses `sources/main.light.ts` → `startServer('light')`) + +For local development, `yarn dev:light` is the easiest entrypoint for the light flavor (it creates the local dirs and runs `prisma migrate deploy` for the SQLite database before starting). + +### Local development + +#### Prerequisites + +- Node.js + Yarn +- Docker (required only for the full flavor local deps) + +#### Full flavor (Postgres + Redis + S3/Minio) + +This repo includes convenience scripts to start Postgres/Redis/Minio via Docker and then run migrations. + +```bash +yarn install + +# Start dependencies +yarn db +yarn redis +yarn s3 +yarn s3:init + +# Apply migrations (uses `.env.dev`) +yarn migrate + +# Start the server (loads `.env.dev`) +PORT=3005 yarn dev +``` + +Verify: + +```bash +curl http://127.0.0.1:3005/health +``` + +Notes: + +- If port `3005` is already in use, choose another: `PORT=3007 ...`. +- `yarn dev` does **not** kill anything by default. You can force-kills whatever is listening on the port by using: `PORT=3005 yarn dev -- --kill-port` (or `yarn dev:kill-port`). +- `yarn start` is production-style (it expects env vars already set in your environment). +- Minio cleanup: `yarn s3:down`. + +#### Light flavor (SQLite + local files) + +*The light flavor does not require Docker.* It uses a local SQLite database file and serves public files from disk under `GET /files/*`. + +```bash +yarn install + +# Runs `prisma migrate deploy` for SQLite before starting +PORT=3005 yarn dev:light +``` + +Verify: + +```bash +curl http://127.0.0.1:3005/health +``` + +Notes: + +- `yarn dev:light` runs `prisma migrate deploy` against the SQLite database (using the checked-in migration history under `prisma/sqlite/migrations/*`). +- If you are upgrading an existing light DB that was created before SQLite migrations existed, run `yarn migrate:light:resolve-baseline` once (after making a backup). +- If you want a clean slate for local dev/testing, delete the light data dir (default: `~/.happy/server-light`) or point the light flavor at a fresh dir via `HAPPY_SERVER_LIGHT_DATA_DIR=/tmp/happy-server-light`. + +### Prisma schema (full vs light) + +- `prisma/schema.prisma` is the **source of truth** (the full flavor uses it directly). +- `prisma/sqlite/schema.prisma` is **auto-generated** from `schema.prisma` (do not edit). +- Regenerate with `yarn schema:sync` (or verify with `yarn schema:sync:check`). + +Migrations directories are flavor-specific: + +- **full (Postgres)** migrations: `prisma/migrations/*` +- **light (SQLite)** migrations: `prisma/sqlite/migrations/*` + +Practical safety notes for the light flavor: + +- The light flavor uses Prisma Migrate (`migrate deploy`) to apply a deterministic, reviewable migration history. +- Avoid destructive migrations for user data. Prefer an expand/contract approach (add + backfill + switch code) over drops. +- Treat renames as potentially dangerous: if you only want to rename the Prisma Client API, prefer `@map` / `@@map` instead of renaming the underlying DB objects. +- Review generated SQL carefully for the light flavor. SQLite has limited `ALTER TABLE` support, so some changes are implemented via table redefinition (create new table → copy data → drop old table). +- Before upgrading a long-lived self-hosted light instance, back up the SQLite file (copy `~/.happy/server-light/happy-server-light.sqlite`) so you can roll back if needed. + +The full (Postgres) flavor uses migrations: + +- Dev migrations: `yarn migrate` / `yarn migrate:reset` (uses `.env.dev`) + - Applies/creates migrations under `prisma/migrations/*` + +The light (SQLite) flavor uses migrations as well: + +- Apply checked-in migrations (recommended for self-hosting upgrades): `yarn migrate:light:deploy` + - Applies migrations under `prisma/sqlite/migrations/*` +- Create a new SQLite migration from schema changes (writes to `prisma/sqlite/migrations/*`): `yarn migrate:light:new -- --name <name>` + - Uses an isolated temp SQLite file so it never touches a user's real light database. + - For non-trivial changes (renames, type changes, making a column required, adding uniques), you may need to edit the generated `migration.sql` or use an expand/contract sequence instead of a single-step migration. +- If you are upgrading an existing light database that was created before SQLite migrations existed, run the one-time baselining command (after making a backup): `yarn migrate:light:resolve-baseline` +- `yarn db:push:light` is for fast local prototyping only. Prefer migrations for anything you want users to upgrade without surprises. + +### Schema changes (developer workflow) + +When you change the data model, you must update both migration histories: + +1. Edit `prisma/schema.prisma` +2. Regenerate the SQLite schema and commit the result: + - `yarn schema:sync` +3. Create/update the **full (Postgres)** migration: + - `yarn migrate --name <name>` (writes to `prisma/migrations/*`) +4. Create/update the **light (SQLite)** migration: + - `yarn migrate:light:new -- --name <name>` (writes to `prisma/sqlite/migrations/*`) +5. Validate: + - `yarn test` + - Smoke test both flavors (`yarn dev` and `yarn dev:light`) + +No-data-loss guidelines: + +- Prefer “expand/contract”: add new columns/tables, backfill, switch code, and only remove old fields in a major version (or never). +- Be careful with renames. If you only need to rename the Prisma Client API, prefer `@map` / `@@map`. + +Light defaults (when env vars are missing): + +- data dir: `~/.happy/server-light` +- sqlite db: `~/.happy/server-light/happy-server-light.sqlite` +- public files: `~/.happy/server-light/files/*` +- `HANDY_MASTER_SECRET` is generated (once) and persisted to `~/.happy/server-light/handy-master-secret.txt` + +### Serve UI (optional, any flavor) + +You can serve a prebuilt web UI bundle (static directory) from the server process. This is opt-in and does not affect the full flavor unless enabled. + +- `HAPPY_SERVER_UI_DIR=/absolute/path/to/ui-build` +- `HAPPY_SERVER_UI_PREFIX=/` (default) or `/ui` + +Notes: + +- If `HAPPY_SERVER_UI_PREFIX=/`, the server serves the UI at `/` and uses an SPA fallback for unknown `GET` routes (it does **not** fallback for API paths like `/v1/*` or `/files/*`). +- If `HAPPY_SERVER_UI_PREFIX=/ui`, the UI is served under `/ui` and the server keeps its default `/` route. + ## License -MIT - Use it, modify it, deploy it anywhere. \ No newline at end of file +MIT - Use it, modify it, deploy it anywhere. diff --git a/server/package.json b/server/package.json index a2a4ffc5b..44a48cc6e 100644 --- a/server/package.json +++ b/server/package.json @@ -7,19 +7,33 @@ "private": true, "type": "module", "scripts": { + "build:shared": "node ./scripts/buildSharedDeps.mjs", + "prebuild": "yarn -s build:shared", + "pretest": "yarn -s build:shared", "build": "tsc --noEmit", + "typecheck": "yarn -s build", "start": "tsx ./sources/main.ts", - "dev": "lsof -ti tcp:3005 | xargs kill -9 && tsx --env-file=.env --env-file=.env.dev ./sources/main.ts", + "start:light": "tsx ./sources/main.light.ts", + "dev": "tsx ./scripts/dev.full.ts", + "dev:kill-port": "yarn dev -- --kill-port", + "dev:light": "tsx ./scripts/dev.light.ts", "test": "vitest run", "migrate": "dotenv -e .env.dev -- prisma migrate dev", "migrate:reset": "dotenv -e .env.dev -- prisma migrate reset", + "migrate:light:deploy": "tsx ./scripts/migrate.light.deploy.ts", + "migrate:light:resolve-baseline": "tsx ./scripts/migrate.light.resolveBaseline.ts", + "migrate:light:new": "tsx ./scripts/migrate.light.new.ts", + "schema:sync": "tsx ./scripts/schemaSync.ts", + "schema:sync:check": "tsx ./scripts/schemaSync.ts --check", "generate": "prisma generate", - "postinstall": "prisma generate", - "db": "docker run -d -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=handy -v $(pwd)/.pgdata:/var/lib/postgresql/data -p 5432:5432 postgres", + "generate:light": "yarn -s schema:sync --quiet && prisma generate --schema prisma/sqlite/schema.prisma", + "db:push:light": "yarn -s schema:sync --quiet && prisma db push --schema prisma/sqlite/schema.prisma", + "postinstall": "yarn -s schema:sync --quiet && prisma generate && prisma generate --schema prisma/sqlite/schema.prisma && yarn -s build:shared", + "db": "docker run -d -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=handy -v $(pwd)/.pgdata:/var/lib/postgresql/data -p 5432:5432 postgres:17", "redis": "docker run -d -p 6379:6379 redis", "s3": "docker run -d --name minio -p 9000:9000 -p 9001:9001 -e MINIO_ROOT_USER=minioadmin -e MINIO_ROOT_PASSWORD=minioadmin -v $(pwd)/.minio/data:/data minio/minio server /data --console-address :9001", "s3:down": "docker rm -f minio || true", - "s3:init": "dotenv -e .env.dev -- docker run --rm --network container:minio --entrypoint /bin/sh minio/mc -c \"mc alias set local http://localhost:9000 $S3_ACCESS_KEY $S3_SECRET_KEY && mc mb -p local/$S3_BUCKET || true && mc anonymous set download local/$S3_BUCKET\"" + "s3:init": "dotenv -e .env.dev -- sh -c 'docker run --rm --network container:minio --entrypoint /bin/sh -e S3_ACCESS_KEY -e S3_SECRET_KEY -e S3_BUCKET minio/mc -c \"mc alias set local http://localhost:9000 \\$S3_ACCESS_KEY \\$S3_SECRET_KEY && mc mb -p local/\\$S3_BUCKET || true && mc anonymous set download local/\\$S3_BUCKET\"'" }, "devDependencies": { "@types/chalk": "^2.2.0", @@ -33,9 +47,12 @@ "yaml": "^2.4.2" }, "dependencies": { + "@happy/agents": "link:../packages/agents", + "@happy/protocol": "link:../packages/protocol", "@date-fns/tz": "^1.2.0", "@fastify/bearer-auth": "^10.1.1", "@fastify/cors": "^10.0.1", + "@fastify/rate-limit": "^10.3.0", "@prisma/client": "^6.11.1", "@socket.io/redis-streams-adapter": "^0.2.2", "@types/jsonwebtoken": "^9.0.10", diff --git a/server/prisma/migrations/20260109044634_add_session_sharing/migration.sql b/server/prisma/migrations/20260109044634_add_session_sharing/migration.sql new file mode 100644 index 000000000..ed0d85c74 --- /dev/null +++ b/server/prisma/migrations/20260109044634_add_session_sharing/migration.sql @@ -0,0 +1,152 @@ +-- CreateEnum +CREATE TYPE "ShareAccessLevel" AS ENUM ('view', 'edit', 'admin'); + +-- CreateTable +CREATE TABLE "SessionShare" ( + "id" TEXT NOT NULL, + "sessionId" TEXT NOT NULL, + "sharedByUserId" TEXT NOT NULL, + "sharedWithUserId" TEXT NOT NULL, + "accessLevel" "ShareAccessLevel" NOT NULL DEFAULT 'view', + "encryptedDataKey" BYTEA NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "SessionShare_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "SessionShareAccessLog" ( + "id" TEXT NOT NULL, + "sessionShareId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "accessedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "ipAddress" TEXT, + "userAgent" TEXT, + + CONSTRAINT "SessionShareAccessLog_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "PublicSessionShare" ( + "id" TEXT NOT NULL, + "sessionId" TEXT NOT NULL, + "createdByUserId" TEXT NOT NULL, + "token" TEXT NOT NULL, + "accessLevel" "ShareAccessLevel" NOT NULL DEFAULT 'view', + "encryptedDataKey" BYTEA NOT NULL, + "expiresAt" TIMESTAMP(3), + "maxUses" INTEGER, + "useCount" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "PublicSessionShare_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "PublicShareAccessLog" ( + "id" TEXT NOT NULL, + "publicShareId" TEXT NOT NULL, + "userId" TEXT, + "accessedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "ipAddress" TEXT, + "userAgent" TEXT, + + CONSTRAINT "PublicShareAccessLog_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "PublicShareBlockedUser" ( + "id" TEXT NOT NULL, + "publicShareId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "blockedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "reason" TEXT, + + CONSTRAINT "PublicShareBlockedUser_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "SessionShare_sharedWithUserId_idx" ON "SessionShare"("sharedWithUserId"); + +-- CreateIndex +CREATE INDEX "SessionShare_sharedByUserId_idx" ON "SessionShare"("sharedByUserId"); + +-- CreateIndex +CREATE INDEX "SessionShare_sessionId_idx" ON "SessionShare"("sessionId"); + +-- CreateIndex +CREATE UNIQUE INDEX "SessionShare_sessionId_sharedWithUserId_key" ON "SessionShare"("sessionId", "sharedWithUserId"); + +-- CreateIndex +CREATE INDEX "SessionShareAccessLog_sessionShareId_idx" ON "SessionShareAccessLog"("sessionShareId"); + +-- CreateIndex +CREATE INDEX "SessionShareAccessLog_userId_idx" ON "SessionShareAccessLog"("userId"); + +-- CreateIndex +CREATE INDEX "SessionShareAccessLog_accessedAt_idx" ON "SessionShareAccessLog"("accessedAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "PublicSessionShare_sessionId_key" ON "PublicSessionShare"("sessionId"); + +-- CreateIndex +CREATE UNIQUE INDEX "PublicSessionShare_token_key" ON "PublicSessionShare"("token"); + +-- CreateIndex +CREATE INDEX "PublicSessionShare_token_idx" ON "PublicSessionShare"("token"); + +-- CreateIndex +CREATE INDEX "PublicSessionShare_sessionId_idx" ON "PublicSessionShare"("sessionId"); + +-- CreateIndex +CREATE INDEX "PublicShareAccessLog_publicShareId_idx" ON "PublicShareAccessLog"("publicShareId"); + +-- CreateIndex +CREATE INDEX "PublicShareAccessLog_userId_idx" ON "PublicShareAccessLog"("userId"); + +-- CreateIndex +CREATE INDEX "PublicShareAccessLog_accessedAt_idx" ON "PublicShareAccessLog"("accessedAt"); + +-- CreateIndex +CREATE INDEX "PublicShareBlockedUser_publicShareId_idx" ON "PublicShareBlockedUser"("publicShareId"); + +-- CreateIndex +CREATE INDEX "PublicShareBlockedUser_userId_idx" ON "PublicShareBlockedUser"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "PublicShareBlockedUser_publicShareId_userId_key" ON "PublicShareBlockedUser"("publicShareId", "userId"); + +-- AddForeignKey +ALTER TABLE "SessionShare" ADD CONSTRAINT "SessionShare_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "Session"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "SessionShare" ADD CONSTRAINT "SessionShare_sharedByUserId_fkey" FOREIGN KEY ("sharedByUserId") REFERENCES "Account"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "SessionShare" ADD CONSTRAINT "SessionShare_sharedWithUserId_fkey" FOREIGN KEY ("sharedWithUserId") REFERENCES "Account"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "SessionShareAccessLog" ADD CONSTRAINT "SessionShareAccessLog_sessionShareId_fkey" FOREIGN KEY ("sessionShareId") REFERENCES "SessionShare"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "SessionShareAccessLog" ADD CONSTRAINT "SessionShareAccessLog_userId_fkey" FOREIGN KEY ("userId") REFERENCES "Account"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PublicSessionShare" ADD CONSTRAINT "PublicSessionShare_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "Session"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PublicSessionShare" ADD CONSTRAINT "PublicSessionShare_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "Account"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PublicShareAccessLog" ADD CONSTRAINT "PublicShareAccessLog_publicShareId_fkey" FOREIGN KEY ("publicShareId") REFERENCES "PublicSessionShare"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PublicShareAccessLog" ADD CONSTRAINT "PublicShareAccessLog_userId_fkey" FOREIGN KEY ("userId") REFERENCES "Account"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PublicShareBlockedUser" ADD CONSTRAINT "PublicShareBlockedUser_publicShareId_fkey" FOREIGN KEY ("publicShareId") REFERENCES "PublicSessionShare"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PublicShareBlockedUser" ADD CONSTRAINT "PublicShareBlockedUser_userId_fkey" FOREIGN KEY ("userId") REFERENCES "Account"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/server/prisma/migrations/20260109050001_remove_public_share_access_level/migration.sql b/server/prisma/migrations/20260109050001_remove_public_share_access_level/migration.sql new file mode 100644 index 000000000..d86a8f4f8 --- /dev/null +++ b/server/prisma/migrations/20260109050001_remove_public_share_access_level/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - You are about to drop the column `accessLevel` on the `PublicSessionShare` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "PublicSessionShare" DROP COLUMN "accessLevel"; diff --git a/server/prisma/migrations/20260109051716_add_log_access_to_public_share/migration.sql b/server/prisma/migrations/20260109051716_add_log_access_to_public_share/migration.sql new file mode 100644 index 000000000..abea51d18 --- /dev/null +++ b/server/prisma/migrations/20260109051716_add_log_access_to_public_share/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "PublicSessionShare" ADD COLUMN "logAccess" BOOLEAN NOT NULL DEFAULT false; diff --git a/server/prisma/migrations/20260109052146_rename_log_access_to_is_consent_required/migration.sql b/server/prisma/migrations/20260109052146_rename_log_access_to_is_consent_required/migration.sql new file mode 100644 index 000000000..610100332 --- /dev/null +++ b/server/prisma/migrations/20260109052146_rename_log_access_to_is_consent_required/migration.sql @@ -0,0 +1,9 @@ +/* + Warnings: + + - You are about to drop the column `logAccess` on the `PublicSessionShare` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "PublicSessionShare" DROP COLUMN "logAccess", +ADD COLUMN "isConsentRequired" BOOLEAN NOT NULL DEFAULT false; diff --git a/server/prisma/migrations/20260126120000_add_content_keys_and_public_share_token_hash/migration.sql b/server/prisma/migrations/20260126120000_add_content_keys_and_public_share_token_hash/migration.sql new file mode 100644 index 000000000..b735ad6c9 --- /dev/null +++ b/server/prisma/migrations/20260126120000_add_content_keys_and_public_share_token_hash/migration.sql @@ -0,0 +1,26 @@ +-- This feature set has not been deployed yet, so we can safely reset public-share rows +-- while switching from storing plaintext bearer tokens to storing token hashes. + +-- AlterTable +ALTER TABLE "Account" +ADD COLUMN "contentPublicKey" BYTEA, +ADD COLUMN "contentPublicKeySig" BYTEA; + +-- Reset public-share data (token is a bearer secret) +DELETE FROM "PublicShareAccessLog"; +DELETE FROM "PublicShareBlockedUser"; +DELETE FROM "PublicSessionShare"; + +-- Drop legacy token indexes before dropping the column +DROP INDEX IF EXISTS "PublicSessionShare_token_key"; +DROP INDEX IF EXISTS "PublicSessionShare_token_idx"; + +-- AlterTable +ALTER TABLE "PublicSessionShare" +DROP COLUMN "token", +ADD COLUMN "tokenHash" BYTEA NOT NULL; + +-- CreateIndex +CREATE UNIQUE INDEX "PublicSessionShare_tokenHash_key" ON "PublicSessionShare"("tokenHash"); +CREATE INDEX "PublicSessionShare_tokenHash_idx" ON "PublicSessionShare"("tokenHash"); + diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 634930581..9edbe06ab 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -22,6 +22,10 @@ datasource db { model Account { id String @id @default(cuid()) publicKey String @unique + /// X25519 (NaCl box) public key for encrypting session DEKs to this account + contentPublicKey Bytes? + /// Ed25519 signature binding contentPublicKey to publicKey + contentPublicKeySig Bytes? seq Int @default(0) feedSeq BigInt @default(0) createdAt DateTime @default(now()) @@ -38,20 +42,26 @@ model Account { /// [ImageRef] avatar Json? - Session Session[] - AccountPushToken AccountPushToken[] - TerminalAuthRequest TerminalAuthRequest[] - AccountAuthRequest AccountAuthRequest[] - UsageReport UsageReport[] - Machine Machine[] - UploadedFile UploadedFile[] - ServiceAccountToken ServiceAccountToken[] - RelationshipsFrom UserRelationship[] @relation("RelationshipsFrom") - RelationshipsTo UserRelationship[] @relation("RelationshipsTo") - Artifact Artifact[] - AccessKey AccessKey[] - UserFeedItem UserFeedItem[] - UserKVStore UserKVStore[] + Session Session[] + AccountPushToken AccountPushToken[] + TerminalAuthRequest TerminalAuthRequest[] + AccountAuthRequest AccountAuthRequest[] + UsageReport UsageReport[] + Machine Machine[] + UploadedFile UploadedFile[] + ServiceAccountToken ServiceAccountToken[] + RelationshipsFrom UserRelationship[] @relation("RelationshipsFrom") + RelationshipsTo UserRelationship[] @relation("RelationshipsTo") + Artifact Artifact[] + AccessKey AccessKey[] + UserFeedItem UserFeedItem[] + UserKVStore UserKVStore[] + SharedBySessions SessionShare[] @relation("SharedBySessions") + SharedWithSessions SessionShare[] @relation("SharedWithSessions") + SessionShareAccessLogs SessionShareAccessLog[] @relation("SessionShareAccessLogs") + PublicSessionShares PublicSessionShare[] @relation("PublicSessionShares") + PublicShareAccessLogs PublicShareAccessLog[] @relation("PublicShareAccessLogs") + PublicShareBlockedUsers PublicShareBlockedUser[] @relation("PublicShareBlockedUsers") } model TerminalAuthRequest { @@ -91,23 +101,25 @@ model AccountPushToken { // model Session { - id String @id @default(cuid()) + id String @id @default(cuid()) tag String accountId String - account Account @relation(fields: [accountId], references: [id]) + account Account @relation(fields: [accountId], references: [id]) metadata String - metadataVersion Int @default(0) + metadataVersion Int @default(0) agentState String? - agentStateVersion Int @default(0) + agentStateVersion Int @default(0) dataEncryptionKey Bytes? - seq Int @default(0) - active Boolean @default(true) - lastActiveAt DateTime @default(now()) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + seq Int @default(0) + active Boolean @default(true) + lastActiveAt DateTime @default(now()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt messages SessionMessage[] usageReports UsageReport[] accessKeys AccessKey[] + shares SessionShare[] + publicShare PublicSessionShare? @@unique([accountId, tag]) @@index([accountId, updatedAt(sort: Desc)]) @@ -361,3 +373,115 @@ model UserKVStore { @@unique([accountId, key]) @@index([accountId]) } + +// +// Session Sharing +// + +/// Access level for session sharing +enum ShareAccessLevel { + /// Read-only access - can view session content but cannot interact + view + /// Edit access - can send messages and approve tool execution + edit + /// Admin access - can manage sharing settings and archive session + admin +} + +/// Direct session share between users (friend-to-friend sharing) +model SessionShare { + id String @id @default(cuid()) + sessionId String + session Session @relation(fields: [sessionId], references: [id], onDelete: Cascade) + sharedByUserId String + sharedByUser Account @relation("SharedBySessions", fields: [sharedByUserId], references: [id]) + sharedWithUserId String + sharedWithUser Account @relation("SharedWithSessions", fields: [sharedWithUserId], references: [id]) + accessLevel ShareAccessLevel @default(view) + /// NaCl Box encrypted dataEncryptionKey for the recipient + encryptedDataKey Bytes + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + accessLogs SessionShareAccessLog[] + + @@unique([sessionId, sharedWithUserId]) + @@index([sharedWithUserId]) + @@index([sharedByUserId]) + @@index([sessionId]) +} + +/// Access log for direct session shares +model SessionShareAccessLog { + id String @id @default(cuid()) + sessionShareId String + sessionShare SessionShare @relation(fields: [sessionShareId], references: [id], onDelete: Cascade) + userId String + user Account @relation("SessionShareAccessLogs", fields: [userId], references: [id]) + accessedAt DateTime @default(now()) + ipAddress String? + userAgent String? + + @@index([sessionShareId]) + @@index([userId]) + @@index([accessedAt]) +} + +/// Public session share via shareable link (always view-only for security) +model PublicSessionShare { + id String @id @default(cuid()) + sessionId String @unique + session Session @relation(fields: [sessionId], references: [id], onDelete: Cascade) + createdByUserId String + createdByUser Account @relation("PublicSessionShares", fields: [createdByUserId], references: [id]) + /// sha256(token) (32 bytes) + tokenHash Bytes @unique + /// Encrypted dataEncryptionKey for public access + encryptedDataKey Bytes + /// Optional expiration time (null = no expiration) + expiresAt DateTime? + /// Maximum number of uses (null = unlimited) + maxUses Int? + /// Current use count + useCount Int @default(0) + /// Whether user consent is required to view (enables detailed access logging) + isConsentRequired Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + accessLogs PublicShareAccessLog[] + blockedUsers PublicShareBlockedUser[] + + @@index([tokenHash]) + @@index([sessionId]) +} + +/// Access log for public session shares +model PublicShareAccessLog { + id String @id @default(cuid()) + publicShareId String + publicShare PublicSessionShare @relation(fields: [publicShareId], references: [id], onDelete: Cascade) + /// User ID if authenticated, null for anonymous access + userId String? + user Account? @relation("PublicShareAccessLogs", fields: [userId], references: [id]) + accessedAt DateTime @default(now()) + ipAddress String? + userAgent String? + + @@index([publicShareId]) + @@index([userId]) + @@index([accessedAt]) +} + +/// Blocked users for public session shares +model PublicShareBlockedUser { + id String @id @default(cuid()) + publicShareId String + publicShare PublicSessionShare @relation(fields: [publicShareId], references: [id], onDelete: Cascade) + userId String + user Account @relation("PublicShareBlockedUsers", fields: [userId], references: [id]) + blockedAt DateTime @default(now()) + reason String? + + @@unique([publicShareId, userId]) + @@index([publicShareId]) + @@index([userId]) +} diff --git a/server/prisma/sqlite/migrations/20260122190000_baseline/migration.sql b/server/prisma/sqlite/migrations/20260122190000_baseline/migration.sql new file mode 100644 index 000000000..c50b079c9 --- /dev/null +++ b/server/prisma/sqlite/migrations/20260122190000_baseline/migration.sql @@ -0,0 +1,345 @@ +-- CreateTable +CREATE TABLE "Account" ( + "id" TEXT NOT NULL PRIMARY KEY, + "publicKey" TEXT NOT NULL, + "seq" INTEGER NOT NULL DEFAULT 0, + "feedSeq" BIGINT NOT NULL DEFAULT 0, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + "settings" TEXT, + "settingsVersion" INTEGER NOT NULL DEFAULT 0, + "githubUserId" TEXT, + "firstName" TEXT, + "lastName" TEXT, + "username" TEXT, + "avatar" JSONB, + CONSTRAINT "Account_githubUserId_fkey" FOREIGN KEY ("githubUserId") REFERENCES "GithubUser" ("id") ON DELETE SET NULL ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "TerminalAuthRequest" ( + "id" TEXT NOT NULL PRIMARY KEY, + "publicKey" TEXT NOT NULL, + "supportsV2" BOOLEAN NOT NULL DEFAULT false, + "response" TEXT, + "responseAccountId" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "TerminalAuthRequest_responseAccountId_fkey" FOREIGN KEY ("responseAccountId") REFERENCES "Account" ("id") ON DELETE SET NULL ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "AccountAuthRequest" ( + "id" TEXT NOT NULL PRIMARY KEY, + "publicKey" TEXT NOT NULL, + "response" TEXT, + "responseAccountId" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "AccountAuthRequest_responseAccountId_fkey" FOREIGN KEY ("responseAccountId") REFERENCES "Account" ("id") ON DELETE SET NULL ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "AccountPushToken" ( + "id" TEXT NOT NULL PRIMARY KEY, + "accountId" TEXT NOT NULL, + "token" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "AccountPushToken_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "Account" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "Session" ( + "id" TEXT NOT NULL PRIMARY KEY, + "tag" TEXT NOT NULL, + "accountId" TEXT NOT NULL, + "metadata" TEXT NOT NULL, + "metadataVersion" INTEGER NOT NULL DEFAULT 0, + "agentState" TEXT, + "agentStateVersion" INTEGER NOT NULL DEFAULT 0, + "dataEncryptionKey" BLOB, + "seq" INTEGER NOT NULL DEFAULT 0, + "active" BOOLEAN NOT NULL DEFAULT true, + "lastActiveAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "Session_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "Account" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "SessionMessage" ( + "id" TEXT NOT NULL PRIMARY KEY, + "sessionId" TEXT NOT NULL, + "localId" TEXT, + "seq" INTEGER NOT NULL, + "content" JSONB NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "SessionMessage_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "Session" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "GithubUser" ( + "id" TEXT NOT NULL PRIMARY KEY, + "profile" JSONB NOT NULL, + "token" BLOB, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); + +-- CreateTable +CREATE TABLE "GithubOrganization" ( + "id" TEXT NOT NULL PRIMARY KEY, + "profile" JSONB NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); + +-- CreateTable +CREATE TABLE "GlobalLock" ( + "key" TEXT NOT NULL PRIMARY KEY, + "value" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + "expiresAt" DATETIME NOT NULL +); + +-- CreateTable +CREATE TABLE "RepeatKey" ( + "key" TEXT NOT NULL PRIMARY KEY, + "value" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expiresAt" DATETIME NOT NULL +); + +-- CreateTable +CREATE TABLE "SimpleCache" ( + "key" TEXT NOT NULL PRIMARY KEY, + "value" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); + +-- CreateTable +CREATE TABLE "UsageReport" ( + "id" TEXT NOT NULL PRIMARY KEY, + "key" TEXT NOT NULL, + "accountId" TEXT NOT NULL, + "sessionId" TEXT, + "data" JSONB NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "UsageReport_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "Account" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT "UsageReport_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "Session" ("id") ON DELETE SET NULL ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "Machine" ( + "id" TEXT NOT NULL PRIMARY KEY, + "accountId" TEXT NOT NULL, + "metadata" TEXT NOT NULL, + "metadataVersion" INTEGER NOT NULL DEFAULT 0, + "daemonState" TEXT, + "daemonStateVersion" INTEGER NOT NULL DEFAULT 0, + "dataEncryptionKey" BLOB, + "seq" INTEGER NOT NULL DEFAULT 0, + "active" BOOLEAN NOT NULL DEFAULT true, + "lastActiveAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "Machine_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "Account" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "UploadedFile" ( + "id" TEXT NOT NULL PRIMARY KEY, + "accountId" TEXT NOT NULL, + "path" TEXT NOT NULL, + "width" INTEGER, + "height" INTEGER, + "thumbhash" TEXT, + "reuseKey" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "UploadedFile_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "Account" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "ServiceAccountToken" ( + "id" TEXT NOT NULL PRIMARY KEY, + "accountId" TEXT NOT NULL, + "vendor" TEXT NOT NULL, + "token" BLOB NOT NULL, + "metadata" JSONB, + "lastUsedAt" DATETIME, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "ServiceAccountToken_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "Account" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "Artifact" ( + "id" TEXT NOT NULL PRIMARY KEY, + "accountId" TEXT NOT NULL, + "header" BLOB NOT NULL, + "headerVersion" INTEGER NOT NULL DEFAULT 0, + "body" BLOB NOT NULL, + "bodyVersion" INTEGER NOT NULL DEFAULT 0, + "dataEncryptionKey" BLOB NOT NULL, + "seq" INTEGER NOT NULL DEFAULT 0, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "Artifact_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "Account" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "AccessKey" ( + "id" TEXT NOT NULL PRIMARY KEY, + "accountId" TEXT NOT NULL, + "machineId" TEXT NOT NULL, + "sessionId" TEXT NOT NULL, + "data" TEXT NOT NULL, + "dataVersion" INTEGER NOT NULL DEFAULT 0, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "AccessKey_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "Account" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT "AccessKey_accountId_machineId_fkey" FOREIGN KEY ("accountId", "machineId") REFERENCES "Machine" ("accountId", "id") ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT "AccessKey_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "Session" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "UserRelationship" ( + "fromUserId" TEXT NOT NULL, + "toUserId" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'pending', + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + "acceptedAt" DATETIME, + "lastNotifiedAt" DATETIME, + + PRIMARY KEY ("fromUserId", "toUserId"), + CONSTRAINT "UserRelationship_fromUserId_fkey" FOREIGN KEY ("fromUserId") REFERENCES "Account" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "UserRelationship_toUserId_fkey" FOREIGN KEY ("toUserId") REFERENCES "Account" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "UserFeedItem" ( + "id" TEXT NOT NULL PRIMARY KEY, + "userId" TEXT NOT NULL, + "counter" BIGINT NOT NULL, + "repeatKey" TEXT, + "body" JSONB NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "UserFeedItem_userId_fkey" FOREIGN KEY ("userId") REFERENCES "Account" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "UserKVStore" ( + "id" TEXT NOT NULL PRIMARY KEY, + "accountId" TEXT NOT NULL, + "key" TEXT NOT NULL, + "value" BLOB, + "version" INTEGER NOT NULL DEFAULT 0, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "UserKVStore_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "Account" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateIndex +CREATE UNIQUE INDEX "Account_publicKey_key" ON "Account"("publicKey"); + +-- CreateIndex +CREATE UNIQUE INDEX "Account_githubUserId_key" ON "Account"("githubUserId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Account_username_key" ON "Account"("username"); + +-- CreateIndex +CREATE UNIQUE INDEX "TerminalAuthRequest_publicKey_key" ON "TerminalAuthRequest"("publicKey"); + +-- CreateIndex +CREATE UNIQUE INDEX "AccountAuthRequest_publicKey_key" ON "AccountAuthRequest"("publicKey"); + +-- CreateIndex +CREATE UNIQUE INDEX "AccountPushToken_accountId_token_key" ON "AccountPushToken"("accountId", "token"); + +-- CreateIndex +CREATE INDEX "Session_accountId_updatedAt_idx" ON "Session"("accountId", "updatedAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "Session_accountId_tag_key" ON "Session"("accountId", "tag"); + +-- CreateIndex +CREATE INDEX "SessionMessage_sessionId_seq_idx" ON "SessionMessage"("sessionId", "seq"); + +-- CreateIndex +CREATE UNIQUE INDEX "SessionMessage_sessionId_localId_key" ON "SessionMessage"("sessionId", "localId"); + +-- CreateIndex +CREATE INDEX "UsageReport_accountId_idx" ON "UsageReport"("accountId"); + +-- CreateIndex +CREATE INDEX "UsageReport_sessionId_idx" ON "UsageReport"("sessionId"); + +-- CreateIndex +CREATE UNIQUE INDEX "UsageReport_accountId_sessionId_key_key" ON "UsageReport"("accountId", "sessionId", "key"); + +-- CreateIndex +CREATE INDEX "Machine_accountId_idx" ON "Machine"("accountId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Machine_accountId_id_key" ON "Machine"("accountId", "id"); + +-- CreateIndex +CREATE INDEX "UploadedFile_accountId_idx" ON "UploadedFile"("accountId"); + +-- CreateIndex +CREATE UNIQUE INDEX "UploadedFile_accountId_path_key" ON "UploadedFile"("accountId", "path"); + +-- CreateIndex +CREATE INDEX "ServiceAccountToken_accountId_idx" ON "ServiceAccountToken"("accountId"); + +-- CreateIndex +CREATE UNIQUE INDEX "ServiceAccountToken_accountId_vendor_key" ON "ServiceAccountToken"("accountId", "vendor"); + +-- CreateIndex +CREATE INDEX "Artifact_accountId_idx" ON "Artifact"("accountId"); + +-- CreateIndex +CREATE INDEX "Artifact_accountId_updatedAt_idx" ON "Artifact"("accountId", "updatedAt"); + +-- CreateIndex +CREATE INDEX "AccessKey_accountId_idx" ON "AccessKey"("accountId"); + +-- CreateIndex +CREATE INDEX "AccessKey_sessionId_idx" ON "AccessKey"("sessionId"); + +-- CreateIndex +CREATE INDEX "AccessKey_machineId_idx" ON "AccessKey"("machineId"); + +-- CreateIndex +CREATE UNIQUE INDEX "AccessKey_accountId_machineId_sessionId_key" ON "AccessKey"("accountId", "machineId", "sessionId"); + +-- CreateIndex +CREATE INDEX "UserRelationship_toUserId_status_idx" ON "UserRelationship"("toUserId", "status"); + +-- CreateIndex +CREATE INDEX "UserRelationship_fromUserId_status_idx" ON "UserRelationship"("fromUserId", "status"); + +-- CreateIndex +CREATE INDEX "UserFeedItem_userId_counter_idx" ON "UserFeedItem"("userId", "counter"); + +-- CreateIndex +CREATE UNIQUE INDEX "UserFeedItem_userId_counter_key" ON "UserFeedItem"("userId", "counter"); + +-- CreateIndex +CREATE UNIQUE INDEX "UserFeedItem_userId_repeatKey_key" ON "UserFeedItem"("userId", "repeatKey"); + +-- CreateIndex +CREATE INDEX "UserKVStore_accountId_idx" ON "UserKVStore"("accountId"); + +-- CreateIndex +CREATE UNIQUE INDEX "UserKVStore_accountId_key_key" ON "UserKVStore"("accountId", "key"); + diff --git a/server/prisma/sqlite/migrations/20260127210500_add_session_sharing/migration.sql b/server/prisma/sqlite/migrations/20260127210500_add_session_sharing/migration.sql new file mode 100644 index 000000000..d028f1e8e --- /dev/null +++ b/server/prisma/sqlite/migrations/20260127210500_add_session_sharing/migration.sql @@ -0,0 +1,122 @@ +-- AlterTable +ALTER TABLE "Account" ADD COLUMN "contentPublicKey" BLOB; +ALTER TABLE "Account" ADD COLUMN "contentPublicKeySig" BLOB; + +-- CreateTable +CREATE TABLE "SessionShare" ( + "id" TEXT NOT NULL PRIMARY KEY, + "sessionId" TEXT NOT NULL, + "sharedByUserId" TEXT NOT NULL, + "sharedWithUserId" TEXT NOT NULL, + "accessLevel" TEXT NOT NULL DEFAULT 'view', + "encryptedDataKey" BLOB NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "SessionShare_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "Session" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "SessionShare_sharedByUserId_fkey" FOREIGN KEY ("sharedByUserId") REFERENCES "Account" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT "SessionShare_sharedWithUserId_fkey" FOREIGN KEY ("sharedWithUserId") REFERENCES "Account" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "SessionShareAccessLog" ( + "id" TEXT NOT NULL PRIMARY KEY, + "sessionShareId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "accessedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "ipAddress" TEXT, + "userAgent" TEXT, + CONSTRAINT "SessionShareAccessLog_sessionShareId_fkey" FOREIGN KEY ("sessionShareId") REFERENCES "SessionShare" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "SessionShareAccessLog_userId_fkey" FOREIGN KEY ("userId") REFERENCES "Account" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "PublicSessionShare" ( + "id" TEXT NOT NULL PRIMARY KEY, + "sessionId" TEXT NOT NULL, + "createdByUserId" TEXT NOT NULL, + "tokenHash" BLOB NOT NULL, + "encryptedDataKey" BLOB NOT NULL, + "expiresAt" DATETIME, + "maxUses" INTEGER, + "useCount" INTEGER NOT NULL DEFAULT 0, + "isConsentRequired" BOOLEAN NOT NULL DEFAULT false, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "PublicSessionShare_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "Session" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "PublicSessionShare_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "Account" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "PublicShareAccessLog" ( + "id" TEXT NOT NULL PRIMARY KEY, + "publicShareId" TEXT NOT NULL, + "userId" TEXT, + "accessedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "ipAddress" TEXT, + "userAgent" TEXT, + CONSTRAINT "PublicShareAccessLog_publicShareId_fkey" FOREIGN KEY ("publicShareId") REFERENCES "PublicSessionShare" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "PublicShareAccessLog_userId_fkey" FOREIGN KEY ("userId") REFERENCES "Account" ("id") ON DELETE SET NULL ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "PublicShareBlockedUser" ( + "id" TEXT NOT NULL PRIMARY KEY, + "publicShareId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "blockedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "reason" TEXT, + CONSTRAINT "PublicShareBlockedUser_publicShareId_fkey" FOREIGN KEY ("publicShareId") REFERENCES "PublicSessionShare" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "PublicShareBlockedUser_userId_fkey" FOREIGN KEY ("userId") REFERENCES "Account" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); + +-- CreateIndex +CREATE INDEX "SessionShare_sharedWithUserId_idx" ON "SessionShare"("sharedWithUserId"); + +-- CreateIndex +CREATE INDEX "SessionShare_sharedByUserId_idx" ON "SessionShare"("sharedByUserId"); + +-- CreateIndex +CREATE INDEX "SessionShare_sessionId_idx" ON "SessionShare"("sessionId"); + +-- CreateIndex +CREATE UNIQUE INDEX "SessionShare_sessionId_sharedWithUserId_key" ON "SessionShare"("sessionId", "sharedWithUserId"); + +-- CreateIndex +CREATE INDEX "SessionShareAccessLog_sessionShareId_idx" ON "SessionShareAccessLog"("sessionShareId"); + +-- CreateIndex +CREATE INDEX "SessionShareAccessLog_userId_idx" ON "SessionShareAccessLog"("userId"); + +-- CreateIndex +CREATE INDEX "SessionShareAccessLog_accessedAt_idx" ON "SessionShareAccessLog"("accessedAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "PublicSessionShare_sessionId_key" ON "PublicSessionShare"("sessionId"); + +-- CreateIndex +CREATE UNIQUE INDEX "PublicSessionShare_tokenHash_key" ON "PublicSessionShare"("tokenHash"); + +-- CreateIndex +CREATE INDEX "PublicSessionShare_tokenHash_idx" ON "PublicSessionShare"("tokenHash"); + +-- CreateIndex +CREATE INDEX "PublicSessionShare_sessionId_idx" ON "PublicSessionShare"("sessionId"); + +-- CreateIndex +CREATE INDEX "PublicShareAccessLog_publicShareId_idx" ON "PublicShareAccessLog"("publicShareId"); + +-- CreateIndex +CREATE INDEX "PublicShareAccessLog_userId_idx" ON "PublicShareAccessLog"("userId"); + +-- CreateIndex +CREATE INDEX "PublicShareAccessLog_accessedAt_idx" ON "PublicShareAccessLog"("accessedAt"); + +-- CreateIndex +CREATE INDEX "PublicShareBlockedUser_publicShareId_idx" ON "PublicShareBlockedUser"("publicShareId"); + +-- CreateIndex +CREATE INDEX "PublicShareBlockedUser_userId_idx" ON "PublicShareBlockedUser"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "PublicShareBlockedUser_publicShareId_userId_key" ON "PublicShareBlockedUser"("publicShareId", "userId"); + diff --git a/server/prisma/sqlite/migrations/migration_lock.toml b/server/prisma/sqlite/migrations/migration_lock.toml new file mode 100644 index 000000000..6fcf33daf --- /dev/null +++ b/server/prisma/sqlite/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (i.e. Git) +provider = "sqlite" diff --git a/server/prisma/sqlite/schema.prisma b/server/prisma/sqlite/schema.prisma new file mode 100644 index 000000000..ea2dc5ee8 --- /dev/null +++ b/server/prisma/sqlite/schema.prisma @@ -0,0 +1,488 @@ +// AUTO-GENERATED FILE - DO NOT EDIT. +// Source: prisma/schema.prisma +// Regenerate: yarn schema:sync + +// This is your Prisma schema file, +// learn more about it in the docs: https://pris.ly/d/prisma-schema + +generator client { + provider = "prisma-client-js" + previewFeatures = ["metrics"] + output = "../../generated/sqlite-client" +} + +datasource db { + provider = "sqlite" + url = env("DATABASE_URL") +} + +// +// Account +// + +model Account { + id String @id @default(cuid()) + publicKey String @unique + /// X25519 (NaCl box) public key for encrypting session DEKs to this account + contentPublicKey Bytes? + /// Ed25519 signature binding contentPublicKey to publicKey + contentPublicKeySig Bytes? + seq Int @default(0) + feedSeq BigInt @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + settings String? + settingsVersion Int @default(0) + githubUserId String? @unique + githubUser GithubUser? @relation(fields: [githubUserId], references: [id]) + + // Profile + firstName String? + lastName String? + username String? @unique + /// [ImageRef] + avatar Json? + + Session Session[] + AccountPushToken AccountPushToken[] + TerminalAuthRequest TerminalAuthRequest[] + AccountAuthRequest AccountAuthRequest[] + UsageReport UsageReport[] + Machine Machine[] + UploadedFile UploadedFile[] + ServiceAccountToken ServiceAccountToken[] + RelationshipsFrom UserRelationship[] @relation("RelationshipsFrom") + RelationshipsTo UserRelationship[] @relation("RelationshipsTo") + Artifact Artifact[] + AccessKey AccessKey[] + UserFeedItem UserFeedItem[] + UserKVStore UserKVStore[] + SharedBySessions SessionShare[] @relation("SharedBySessions") + SharedWithSessions SessionShare[] @relation("SharedWithSessions") + SessionShareAccessLogs SessionShareAccessLog[] @relation("SessionShareAccessLogs") + PublicSessionShares PublicSessionShare[] @relation("PublicSessionShares") + PublicShareAccessLogs PublicShareAccessLog[] @relation("PublicShareAccessLogs") + PublicShareBlockedUsers PublicShareBlockedUser[] @relation("PublicShareBlockedUsers") +} + +model TerminalAuthRequest { + id String @id @default(cuid()) + publicKey String @unique + supportsV2 Boolean @default(false) + response String? + responseAccountId String? + responseAccount Account? @relation(fields: [responseAccountId], references: [id]) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model AccountAuthRequest { + id String @id @default(cuid()) + publicKey String @unique + response String? + responseAccountId String? + responseAccount Account? @relation(fields: [responseAccountId], references: [id]) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model AccountPushToken { + id String @id @default(cuid()) + accountId String + account Account @relation(fields: [accountId], references: [id]) + token String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([accountId, token]) +} + +// +// Sessions +// + +model Session { + id String @id @default(cuid()) + tag String + accountId String + account Account @relation(fields: [accountId], references: [id]) + metadata String + metadataVersion Int @default(0) + agentState String? + agentStateVersion Int @default(0) + dataEncryptionKey Bytes? + seq Int @default(0) + active Boolean @default(true) + lastActiveAt DateTime @default(now()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + messages SessionMessage[] + usageReports UsageReport[] + accessKeys AccessKey[] + shares SessionShare[] + publicShare PublicSessionShare? + + @@unique([accountId, tag]) + @@index([accountId, updatedAt]) +} + +model SessionMessage { + id String @id @default(cuid()) + sessionId String + session Session @relation(fields: [sessionId], references: [id]) + localId String? + seq Int + /// [SessionMessageContent] + content Json + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([sessionId, localId]) + @@index([sessionId, seq]) +} + +// +// Github +// + +model GithubUser { + id String @id + /// [GitHubProfile] + profile Json + token Bytes? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + Account Account[] +} + +model GithubOrganization { + id String @id + /// [GitHubOrg] + profile Json + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +// +// Utility +// + +model GlobalLock { + key String @id @default(cuid()) + value String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + expiresAt DateTime +} + +model RepeatKey { + key String @id + value String + createdAt DateTime @default(now()) + expiresAt DateTime +} + +model SimpleCache { + key String @id + value String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +// +// Usage Reporting +// + +model UsageReport { + id String @id @default(cuid()) + key String + accountId String + account Account @relation(fields: [accountId], references: [id]) + sessionId String? + session Session? @relation(fields: [sessionId], references: [id]) + /// [UsageReportData] + data Json + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([accountId, sessionId, key]) + @@index([accountId]) + @@index([sessionId]) +} + +// +// Machines +// + +model Machine { + id String @id + accountId String + account Account @relation(fields: [accountId], references: [id]) + metadata String // Encrypted - contains static machine info + metadataVersion Int @default(0) + daemonState String? // Encrypted - contains dynamic daemon state + daemonStateVersion Int @default(0) + dataEncryptionKey Bytes? + seq Int @default(0) + active Boolean @default(true) + lastActiveAt DateTime @default(now()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + accessKeys AccessKey[] + + @@unique([accountId, id]) + @@index([accountId]) +} + +model UploadedFile { + id String @id @default(cuid()) + accountId String + account Account @relation(fields: [accountId], references: [id]) + path String + width Int? + height Int? + thumbhash String? + reuseKey String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([accountId, path]) + @@index([accountId]) +} + +model ServiceAccountToken { + id String @id @default(cuid()) + accountId String + account Account @relation(fields: [accountId], references: [id], onDelete: Cascade) + vendor String + token Bytes // Encrypted token + metadata Json? // Optional vendor metadata + lastUsedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([accountId, vendor]) + @@index([accountId]) +} + +// +// Artifacts +// + +model Artifact { + id String @id // UUID provided by client + accountId String + account Account @relation(fields: [accountId], references: [id]) + header Bytes // Encrypted header (can contain JSON) + headerVersion Int @default(0) + body Bytes // Encrypted body + bodyVersion Int @default(0) + dataEncryptionKey Bytes // Encryption key for this artifact + seq Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([accountId]) + @@index([accountId, updatedAt]) +} + +// +// Access Keys +// + +model AccessKey { + id String @id @default(cuid()) + accountId String + account Account @relation(fields: [accountId], references: [id]) + machineId String + machine Machine @relation(fields: [accountId, machineId], references: [accountId, id]) + sessionId String + session Session @relation(fields: [sessionId], references: [id]) + data String // Encrypted data + dataVersion Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([accountId, machineId, sessionId]) + @@index([accountId]) + @@index([sessionId]) + @@index([machineId]) +} + +// +// Social Network - Relationships +// + +enum RelationshipStatus { + none + requested + pending + friend + rejected +} + +model UserRelationship { + fromUserId String + fromUser Account @relation("RelationshipsFrom", fields: [fromUserId], references: [id], onDelete: Cascade) + toUserId String + toUser Account @relation("RelationshipsTo", fields: [toUserId], references: [id], onDelete: Cascade) + status RelationshipStatus @default(pending) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + acceptedAt DateTime? + lastNotifiedAt DateTime? + + @@id([fromUserId, toUserId]) + @@index([toUserId, status]) + @@index([fromUserId, status]) +} + +// +// Feed +// + +model UserFeedItem { + id String @id @default(cuid()) + userId String + user Account @relation(fields: [userId], references: [id], onDelete: Cascade) + counter BigInt + repeatKey String? + /// [FeedBody] + body Json + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([userId, counter]) + @@unique([userId, repeatKey]) + @@index([userId, counter]) +} + +// +// Key-Value Storage +// + +model UserKVStore { + id String @id @default(cuid()) + accountId String + account Account @relation(fields: [accountId], references: [id], onDelete: Cascade) + key String // Unencrypted for indexing + value Bytes? // Encrypted value, null when "deleted" + version Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([accountId, key]) + @@index([accountId]) +} + +// +// Session Sharing +// + +/// Access level for session sharing +enum ShareAccessLevel { + /// Read-only access - can view session content but cannot interact + view + /// Edit access - can send messages and approve tool execution + edit + /// Admin access - can manage sharing settings and archive session + admin +} + +/// Direct session share between users (friend-to-friend sharing) +model SessionShare { + id String @id @default(cuid()) + sessionId String + session Session @relation(fields: [sessionId], references: [id], onDelete: Cascade) + sharedByUserId String + sharedByUser Account @relation("SharedBySessions", fields: [sharedByUserId], references: [id]) + sharedWithUserId String + sharedWithUser Account @relation("SharedWithSessions", fields: [sharedWithUserId], references: [id]) + accessLevel ShareAccessLevel @default(view) + /// NaCl Box encrypted dataEncryptionKey for the recipient + encryptedDataKey Bytes + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + accessLogs SessionShareAccessLog[] + + @@unique([sessionId, sharedWithUserId]) + @@index([sharedWithUserId]) + @@index([sharedByUserId]) + @@index([sessionId]) +} + +/// Access log for direct session shares +model SessionShareAccessLog { + id String @id @default(cuid()) + sessionShareId String + sessionShare SessionShare @relation(fields: [sessionShareId], references: [id], onDelete: Cascade) + userId String + user Account @relation("SessionShareAccessLogs", fields: [userId], references: [id]) + accessedAt DateTime @default(now()) + ipAddress String? + userAgent String? + + @@index([sessionShareId]) + @@index([userId]) + @@index([accessedAt]) +} + +/// Public session share via shareable link (always view-only for security) +model PublicSessionShare { + id String @id @default(cuid()) + sessionId String @unique + session Session @relation(fields: [sessionId], references: [id], onDelete: Cascade) + createdByUserId String + createdByUser Account @relation("PublicSessionShares", fields: [createdByUserId], references: [id]) + /// sha256(token) (32 bytes) + tokenHash Bytes @unique + /// Encrypted dataEncryptionKey for public access + encryptedDataKey Bytes + /// Optional expiration time (null = no expiration) + expiresAt DateTime? + /// Maximum number of uses (null = unlimited) + maxUses Int? + /// Current use count + useCount Int @default(0) + /// Whether user consent is required to view (enables detailed access logging) + isConsentRequired Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + accessLogs PublicShareAccessLog[] + blockedUsers PublicShareBlockedUser[] + + @@index([tokenHash]) + @@index([sessionId]) +} + +/// Access log for public session shares +model PublicShareAccessLog { + id String @id @default(cuid()) + publicShareId String + publicShare PublicSessionShare @relation(fields: [publicShareId], references: [id], onDelete: Cascade) + /// User ID if authenticated, null for anonymous access + userId String? + user Account? @relation("PublicShareAccessLogs", fields: [userId], references: [id]) + accessedAt DateTime @default(now()) + ipAddress String? + userAgent String? + + @@index([publicShareId]) + @@index([userId]) + @@index([accessedAt]) +} + +/// Blocked users for public session shares +model PublicShareBlockedUser { + id String @id @default(cuid()) + publicShareId String + publicShare PublicSessionShare @relation(fields: [publicShareId], references: [id], onDelete: Cascade) + userId String + user Account @relation("PublicShareBlockedUsers", fields: [userId], references: [id]) + blockedAt DateTime @default(now()) + reason String? + + @@unique([publicShareId, userId]) + @@index([publicShareId]) + @@index([userId]) +} diff --git a/server/scripts/buildSharedDeps.mjs b/server/scripts/buildSharedDeps.mjs new file mode 100644 index 000000000..731a9f9f7 --- /dev/null +++ b/server/scripts/buildSharedDeps.mjs @@ -0,0 +1,40 @@ +import { execFileSync } from 'node:child_process'; +import { existsSync, mkdirSync, rmSync, symlinkSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(__dirname, '..', '..'); + +const tscBin = resolve(repoRoot, 'server', 'node_modules', '.bin', process.platform === 'win32' ? 'tsc.cmd' : 'tsc'); + +function runTsc(tsconfigPath) { + execFileSync(tscBin, ['-p', tsconfigPath], { stdio: 'inherit' }); +} + +function ensureSymlink({ linkPath, targetPath }) { + try { + rmSync(linkPath, { recursive: true, force: true }); + } catch { + // ignore + } + mkdirSync(resolve(linkPath, '..'), { recursive: true }); + symlinkSync(targetPath, linkPath, process.platform === 'win32' ? 'junction' : 'dir'); +} + +// Ensure @happy/agents is resolvable from the protocol workspace. +ensureSymlink({ + linkPath: resolve(repoRoot, 'packages', 'protocol', 'node_modules', '@happy', 'agents'), + targetPath: resolve(repoRoot, 'packages', 'agents'), +}); + +// Build shared packages (dist/ is the runtime contract). +runTsc(resolve(repoRoot, 'packages', 'agents', 'tsconfig.json')); +runTsc(resolve(repoRoot, 'packages', 'protocol', 'tsconfig.json')); + +// Sanity check: ensure protocol dist entry exists. +const protocolDist = resolve(repoRoot, 'packages', 'protocol', 'dist', 'index.js'); +if (!existsSync(protocolDist)) { + throw new Error(`Expected @happy/protocol build output missing: ${protocolDist}`); +} + diff --git a/server/scripts/dev.full.ts b/server/scripts/dev.full.ts new file mode 100644 index 000000000..50f0aa77a --- /dev/null +++ b/server/scripts/dev.full.ts @@ -0,0 +1,21 @@ +import dotenv from "dotenv"; +import { execSync } from "node:child_process"; +import { parseDevFullArgs } from "./dev.fullArgs"; + +const args = parseDevFullArgs(process.argv.slice(2), process.env); + +dotenv.config({ path: '.env.dev' }); +process.env.PORT = String(args.port); + +if (args.killPort) { + try { + execSync( + `sh -c 'pids="$(lsof -ti tcp:${args.port} 2>/dev/null || true)"; if [ -n "$pids" ]; then kill -9 $pids; fi'`, + { stdio: 'inherit' } + ); + } catch { + // ignore: nothing to kill / lsof missing / permission issues + } +} + +await import('../sources/main'); diff --git a/server/scripts/dev.fullArgs.spec.ts b/server/scripts/dev.fullArgs.spec.ts new file mode 100644 index 000000000..8bdda3cbb --- /dev/null +++ b/server/scripts/dev.fullArgs.spec.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { parseDevFullArgs } from "./dev.fullArgs"; + +describe('parseDevFullArgs', () => { + it('defaults to port 3005', () => { + expect(parseDevFullArgs([], {} as any)).toEqual({ port: 3005, killPort: false }); + }); + + it('reads PORT from env', () => { + expect(parseDevFullArgs([], { PORT: '3007' } as any)).toEqual({ port: 3007, killPort: false }); + }); + + it('supports --port 3007', () => { + expect(parseDevFullArgs(['--port', '3007'], {} as any)).toEqual({ port: 3007, killPort: false }); + }); + + it('supports --port=3007', () => { + expect(parseDevFullArgs(['--port=3007'], {} as any)).toEqual({ port: 3007, killPort: false }); + }); + + it('supports --kill-port', () => { + expect(parseDevFullArgs(['--kill-port'], {} as any)).toEqual({ port: 3005, killPort: true }); + }); +}); + diff --git a/server/scripts/dev.fullArgs.ts b/server/scripts/dev.fullArgs.ts new file mode 100644 index 000000000..3a46d847f --- /dev/null +++ b/server/scripts/dev.fullArgs.ts @@ -0,0 +1,47 @@ +export type DevFullArgs = { + port: number; + killPort: boolean; +}; + +function parsePort(raw: string): number { + const n = Number.parseInt(raw, 10); + if (!Number.isFinite(n) || n <= 0 || n > 65535) { + throw new Error(`Invalid port: ${raw}`); + } + return n; +} + +export function parseDevFullArgs(argv: string[], env: NodeJS.ProcessEnv = process.env): DevFullArgs { + let port: number | null = env.PORT ? parsePort(env.PORT) : null; + let killPort = false; + + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + + if (a === '--kill-port') { + killPort = true; + continue; + } + + if (a === '--port') { + const next = argv[i + 1]; + if (!next) { + throw new Error(`Missing value for --port`); + } + port = parsePort(next); + i++; + continue; + } + + if (a.startsWith('--port=')) { + port = parsePort(a.slice('--port='.length)); + continue; + } + } + + return { + port: port ?? 3005, + killPort, + }; +} + diff --git a/server/scripts/dev.light.ts b/server/scripts/dev.light.ts new file mode 100644 index 000000000..60e144087 --- /dev/null +++ b/server/scripts/dev.light.ts @@ -0,0 +1,44 @@ +import { spawn } from 'node:child_process'; +import { mkdir } from 'node:fs/promises'; +import { applyLightDefaultEnv } from '@/flavors/light/env'; +import { buildLightDevPlan } from './dev.lightPlan'; + +function run(cmd: string, args: string[], env: NodeJS.ProcessEnv): Promise<void> { + return new Promise((resolve, reject) => { + const child = spawn(cmd, args, { + env: env as Record<string, string>, + stdio: 'inherit', + shell: false, + }); + child.on('error', reject); + child.on('exit', (code) => { + if (code === 0) resolve(); + else reject(new Error(`${cmd} exited with code ${code}`)); + }); + }); +} + +async function main() { + const env: NodeJS.ProcessEnv = { ...process.env }; + applyLightDefaultEnv(env); + + const dataDir = env.HAPPY_SERVER_LIGHT_DATA_DIR!; + const filesDir = env.HAPPY_SERVER_LIGHT_FILES_DIR!; + const plan = buildLightDevPlan(); + + // Ensure dirs exist so SQLite can create the DB file. + await mkdir(dataDir, { recursive: true }); + await mkdir(filesDir, { recursive: true }); + + // Ensure sqlite schema is present, then apply migrations (idempotent). + await run('yarn', ['-s', 'schema:sync', '--quiet'], env); + await run('yarn', plan.prismaDeployArgs, env); + + // Run the light flavor. + await run('yarn', plan.startLightArgs, env); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/server/scripts/dev.lightPlan.spec.ts b/server/scripts/dev.lightPlan.spec.ts new file mode 100644 index 000000000..5ae5d3860 --- /dev/null +++ b/server/scripts/dev.lightPlan.spec.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from "vitest"; +import { buildLightDevPlan } from "./dev.lightPlan"; + +describe('buildLightDevPlan', () => { + it('uses prisma migrate deploy with the sqlite schema path', () => { + const plan = buildLightDevPlan(); + expect(plan.prismaSchemaPath).toBe('prisma/sqlite/schema.prisma'); + expect(plan.prismaDeployArgs).toEqual(['-s', 'prisma', 'migrate', 'deploy', '--schema', 'prisma/sqlite/schema.prisma']); + }); +}); + diff --git a/server/scripts/dev.lightPlan.ts b/server/scripts/dev.lightPlan.ts new file mode 100644 index 000000000..1599cc657 --- /dev/null +++ b/server/scripts/dev.lightPlan.ts @@ -0,0 +1,15 @@ +export type LightDevPlan = { + prismaSchemaPath: string; + prismaDeployArgs: string[]; + startLightArgs: string[]; +}; + +export function buildLightDevPlan(): LightDevPlan { + const prismaSchemaPath = 'prisma/sqlite/schema.prisma'; + return { + prismaSchemaPath, + prismaDeployArgs: ['-s', 'prisma', 'migrate', 'deploy', '--schema', prismaSchemaPath], + startLightArgs: ['-s', 'start:light'], + }; +} + diff --git a/server/scripts/migrate.light.deploy.ts b/server/scripts/migrate.light.deploy.ts new file mode 100644 index 000000000..e3a24ddcc --- /dev/null +++ b/server/scripts/migrate.light.deploy.ts @@ -0,0 +1,35 @@ +import { spawn } from 'node:child_process'; +import { mkdir } from 'node:fs/promises'; +import { applyLightDefaultEnv } from '@/flavors/light/env'; +import { requireLightDataDir } from './migrate.light.deployPlan'; + +function run(cmd: string, args: string[], env: NodeJS.ProcessEnv): Promise<void> { + return new Promise((resolve, reject) => { + const child = spawn(cmd, args, { + env: env as Record<string, string>, + stdio: 'inherit', + shell: false, + }); + child.on('error', reject); + child.on('exit', (code) => { + if (code === 0) resolve(); + else reject(new Error(`${cmd} exited with code ${code}`)); + }); + }); +} + +async function main() { + const env: NodeJS.ProcessEnv = { ...process.env }; + applyLightDefaultEnv(env); + + const dataDir = requireLightDataDir(env); + await mkdir(dataDir, { recursive: true }); + + await run('yarn', ['-s', 'schema:sync', '--quiet'], env); + await run('yarn', ['-s', 'prisma', 'migrate', 'deploy', '--schema', 'prisma/sqlite/schema.prisma'], env); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/server/scripts/migrate.light.deployPlan.spec.ts b/server/scripts/migrate.light.deployPlan.spec.ts new file mode 100644 index 000000000..ccbd0ae12 --- /dev/null +++ b/server/scripts/migrate.light.deployPlan.spec.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; +import { buildLightMigrateDeployPlan, requireLightDataDir } from './migrate.light.deployPlan'; + +describe('requireLightDataDir', () => { + it('throws when HAPPY_SERVER_LIGHT_DATA_DIR is missing', () => { + expect(() => requireLightDataDir({})).toThrow(/HAPPY_SERVER_LIGHT_DATA_DIR/); + }); + + it('throws when HAPPY_SERVER_LIGHT_DATA_DIR is empty', () => { + expect(() => requireLightDataDir({ HAPPY_SERVER_LIGHT_DATA_DIR: ' ' })).toThrow(/HAPPY_SERVER_LIGHT_DATA_DIR/); + }); + + it('returns a trimmed HAPPY_SERVER_LIGHT_DATA_DIR', () => { + expect(requireLightDataDir({ HAPPY_SERVER_LIGHT_DATA_DIR: ' /tmp/happy ' })).toBe('/tmp/happy'); + }); +}); + +describe('buildLightMigrateDeployPlan', () => { + it('throws when HAPPY_SERVER_LIGHT_DATA_DIR is missing', () => { + expect(() => buildLightMigrateDeployPlan({})).toThrow(/HAPPY_SERVER_LIGHT_DATA_DIR/); + }); + + it('returns the expected schema and migrate args for sqlite', () => { + const plan = buildLightMigrateDeployPlan({ HAPPY_SERVER_LIGHT_DATA_DIR: '/tmp/happy' }); + expect(plan.dataDir).toBe('/tmp/happy'); + expect(plan.prismaSchemaPath).toBe('prisma/sqlite/schema.prisma'); + expect(plan.schemaGenerateArgs).toEqual(['-s', 'schema:sqlite', '--quiet']); + expect(plan.prismaDeployArgs).toEqual([ + '-s', + 'prisma', + 'migrate', + 'deploy', + '--schema', + 'prisma/sqlite/schema.prisma', + ]); + }); +}); diff --git a/server/scripts/migrate.light.deployPlan.ts b/server/scripts/migrate.light.deployPlan.ts new file mode 100644 index 000000000..3c6e66578 --- /dev/null +++ b/server/scripts/migrate.light.deployPlan.ts @@ -0,0 +1,25 @@ +export type LightMigrateDeployPlan = { + dataDir: string; + prismaSchemaPath: string; + schemaGenerateArgs: string[]; + prismaDeployArgs: string[]; +}; + +export function requireLightDataDir(env: NodeJS.ProcessEnv): string { + const raw = env.HAPPY_SERVER_LIGHT_DATA_DIR; + if (typeof raw !== 'string' || raw.trim() === '') { + throw new Error('Missing HAPPY_SERVER_LIGHT_DATA_DIR (set it or ensure applyLightDefaultEnv sets it)'); + } + return raw.trim(); +} + +export function buildLightMigrateDeployPlan(env: NodeJS.ProcessEnv): LightMigrateDeployPlan { + const dataDir = requireLightDataDir(env); + const prismaSchemaPath = 'prisma/sqlite/schema.prisma'; + return { + dataDir, + prismaSchemaPath, + schemaGenerateArgs: ['-s', 'schema:sqlite', '--quiet'], + prismaDeployArgs: ['-s', 'prisma', 'migrate', 'deploy', '--schema', prismaSchemaPath], + }; +} diff --git a/server/scripts/migrate.light.new.ts b/server/scripts/migrate.light.new.ts new file mode 100644 index 000000000..db85587d8 --- /dev/null +++ b/server/scripts/migrate.light.new.ts @@ -0,0 +1,80 @@ +import { spawn } from 'node:child_process'; +import tmp from 'tmp'; + +function run(cmd: string, args: string[], env: NodeJS.ProcessEnv): Promise<void> { + return new Promise((resolve, reject) => { + const child = spawn(cmd, args, { + env: env as Record<string, string>, + stdio: 'inherit', + shell: false, + }); + child.on('error', reject); + child.on('exit', (code) => { + if (code === 0) resolve(); + else reject(new Error(`${cmd} exited with code ${code}`)); + }); + }); +} + +function parseNameArg(argv: string[]): { name: string | null; passthrough: string[] } { + const passthrough: string[] = []; + let name: string | null = null; + + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === '--name') { + const next = argv[i + 1]; + if (!next) { + throw new Error('Missing value for --name'); + } + name = next; + i++; + continue; + } + if (a.startsWith('--name=')) { + name = a.slice('--name='.length); + continue; + } + passthrough.push(a); + } + + return { name, passthrough }; +} + +async function main() { + const { name, passthrough } = parseNameArg(process.argv.slice(2)); + if (!name || !name.trim()) { + throw new Error('Missing --name. Example: yarn migrate:light:new -- --name add_my_table'); + } + + const env: NodeJS.ProcessEnv = { ...process.env }; + + // Use an isolated temp DB file so creating migrations never touches a user's real light DB. + const dbFile = tmp.fileSync({ prefix: 'happy-server-light-migrate-', postfix: '.sqlite' }).name; + env.DATABASE_URL = `file:${dbFile}`; + + await run('yarn', ['-s', 'schema:sync', '--quiet'], env); + await run( + 'yarn', + [ + '-s', + 'prisma', + 'migrate', + 'dev', + '--schema', + 'prisma/sqlite/schema.prisma', + '--name', + name, + '--create-only', + '--skip-generate', + '--skip-seed', + ...passthrough, + ], + env + ); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/server/scripts/migrate.light.resolveBaseline.ts b/server/scripts/migrate.light.resolveBaseline.ts new file mode 100644 index 000000000..fc2fc5069 --- /dev/null +++ b/server/scripts/migrate.light.resolveBaseline.ts @@ -0,0 +1,53 @@ +import { spawn } from 'node:child_process'; +import { mkdir, readdir } from 'node:fs/promises'; +import { applyLightDefaultEnv } from '@/flavors/light/env'; + +function run(cmd: string, args: string[], env: NodeJS.ProcessEnv): Promise<void> { + return new Promise((resolve, reject) => { + const child = spawn(cmd, args, { + env: env as Record<string, string>, + stdio: 'inherit', + shell: false, + }); + child.on('error', reject); + child.on('exit', (code) => { + if (code === 0) resolve(); + else reject(new Error(`${cmd} exited with code ${code}`)); + }); + }); +} + +async function findBaselineMigrationDir(): Promise<string> { + const entries = await readdir('prisma/sqlite/migrations', { withFileTypes: true }); + const dirs = entries + .filter((e) => e.isDirectory()) + .map((e) => e.name) + .sort(); + const first = dirs[0]; + if (!first) { + throw new Error('No prisma/sqlite/migrations/* directories found to use as a baseline.'); + } + return first; +} + +async function main() { + const env: NodeJS.ProcessEnv = { ...process.env }; + applyLightDefaultEnv(env); + + const dataDir = env.HAPPY_SERVER_LIGHT_DATA_DIR!; + await mkdir(dataDir, { recursive: true }); + + await run('yarn', ['-s', 'schema:sync', '--quiet'], env); + + const baseline = await findBaselineMigrationDir(); + await run( + 'yarn', + ['-s', 'prisma', 'migrate', 'resolve', '--schema', 'prisma/sqlite/schema.prisma', '--applied', baseline], + env + ); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/server/scripts/schemaSync.spec.ts b/server/scripts/schemaSync.spec.ts new file mode 100644 index 000000000..5af640096 --- /dev/null +++ b/server/scripts/schemaSync.spec.ts @@ -0,0 +1,31 @@ +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { generateEnumsTsFromPostgres, generateSqliteSchemaFromPostgres, normalizeSchemaText } from './schemaSync'; + +describe('generateSqliteSchemaFromPostgres', () => { + it('converts the schema header blocks for sqlite', async () => { + const master = await readFile(join(process.cwd(), 'prisma/schema.prisma'), 'utf-8'); + const generated = generateSqliteSchemaFromPostgres(master); + expect(generated).toContain('provider = "sqlite"'); + expect(generated).toContain('output = "../../generated/sqlite-client"'); + expect(generated).not.toContain('generator json'); + expect(generated).not.toMatch(/sort\s*:\s*(Asc|Desc)/); + }); + + it('keeps prisma/sqlite/schema.prisma in sync with prisma/schema.prisma', async () => { + const master = await readFile(join(process.cwd(), 'prisma/schema.prisma'), 'utf-8'); + const existing = await readFile(join(process.cwd(), 'prisma/sqlite/schema.prisma'), 'utf-8'); + const generated = generateSqliteSchemaFromPostgres(master); + expect(normalizeSchemaText(existing)).toBe(normalizeSchemaText(generated)); + }); +}); + +describe('generateEnumsTsFromPostgres', () => { + it('keeps sources/storage/enums.generated.ts in sync with prisma/schema.prisma', async () => { + const master = await readFile(join(process.cwd(), 'prisma/schema.prisma'), 'utf-8'); + const existing = await readFile(join(process.cwd(), 'sources/storage/enums.generated.ts'), 'utf-8'); + const generated = generateEnumsTsFromPostgres(master); + expect(existing.replace(/\r\n/g, '\n').trimEnd()).toBe(generated.replace(/\r\n/g, '\n').trimEnd()); + }); +}); diff --git a/server/scripts/schemaSync.ts b/server/scripts/schemaSync.ts new file mode 100644 index 000000000..0bdcac654 --- /dev/null +++ b/server/scripts/schemaSync.ts @@ -0,0 +1,193 @@ +import { readFile, writeFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { mkdir } from 'node:fs/promises'; + +export function normalizeSchemaText(input: string): string { + return input.replace(/\r\n/g, '\n').trimEnd() + '\n'; +} + +function normalizeGeneratedTs(input: string): string { + return input.replace(/\r\n/g, '\n').trimEnd() + '\n'; +} + +type EnumDef = { name: string; values: string[] }; + +function parseEnums(schemaText: string): EnumDef[] { + const text = schemaText.replace(/\r\n/g, '\n'); + const out: EnumDef[] = []; + const enumRe = /^\s*enum\s+([A-Za-z_][A-Za-z0-9_]*)\s*\{([\s\S]*?)^\s*\}\s*$/gm; + let m: RegExpExecArray | null; + while ((m = enumRe.exec(text))) { + const name = m[1]!; + const body = m[2] ?? ''; + const values = body + .split('\n') + .map((l) => l.trim()) + .filter((l) => l && !l.startsWith('//')) + // Each enum member is an identifier, optionally with attributes like @map(...) + .map((l) => l.split(/\s+/)[0]) + .filter(Boolean); + out.push({ name, values }); + } + return out; +} + +export function generateEnumsTsFromPostgres(postgresSchema: string): string { + const enums = parseEnums(postgresSchema); + if (enums.length === 0) { + throw new Error('Failed to find any enum blocks in prisma/schema.prisma'); + } + + const header = [ + '// AUTO-GENERATED FILE - DO NOT EDIT.', + '// Source: prisma/schema.prisma', + '// Regenerate: yarn schema:sync', + '', + ].join('\n'); + + const chunks: string[] = [header]; + for (const e of enums) { + chunks.push(`export const ${e.name} = {`); + for (const v of e.values) { + const key = /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(v) ? v : JSON.stringify(v); + chunks.push(` ${key}: "${v}",`); + } + chunks.push('} as const;'); + chunks.push(''); + chunks.push(`export type ${e.name} = (typeof ${e.name})[keyof typeof ${e.name}];`); + chunks.push(''); + } + + return normalizeGeneratedTs(chunks.join('\n')); +} + +export function generateSqliteSchemaFromPostgres(postgresSchema: string): string { + const schema = postgresSchema.replace(/\r\n/g, '\n'); + + const datasource = /(^|\n)\s*datasource\s+db\s*{[\s\S]*?\n}\s*\n/m; + const match = schema.match(datasource); + if (!match || match.index == null) { + throw new Error('Failed to find `datasource db { ... }` block in prisma/schema.prisma'); + } + + const bodyStart = match.index + match[0].length; + const rawBody = schema.slice(bodyStart); + + const body = normalizeSchemaText(rawBody) + .replace(/^\s+/, '') + .replace(/(\w+)\(\s*sort\s*:\s*\w+\s*\)/g, '$1'); + + const header = [ + '// AUTO-GENERATED FILE - DO NOT EDIT.', + '// Source: prisma/schema.prisma', + '// Regenerate: yarn schema:sync', + '', + '// This is your Prisma schema file,', + '// learn more about it in the docs: https://pris.ly/d/prisma-schema', + ].join('\n'); + + const generatorClient = [ + 'generator client {', + ' provider = "prisma-client-js"', + ' previewFeatures = ["metrics"]', + ' output = "../../generated/sqlite-client"', + '}', + ].join('\n'); + + const datasourceDb = [ + 'datasource db {', + ' provider = "sqlite"', + ' url = env("DATABASE_URL")', + '}', + ].join('\n'); + + return normalizeSchemaText([header, '', generatorClient, '', datasourceDb, '', body].join('\n')); +} + +function resolveRepoRoot(): string { + const __filename = fileURLToPath(import.meta.url); + const __dirname = dirname(__filename); + return join(__dirname, '..'); +} + +async function writeIfChanged(path: string, next: string, normalize: (s: string) => string): Promise<boolean> { + let existing = ''; + try { + existing = await readFile(path, 'utf-8'); + } catch { + // ignore + } + if (normalize(existing) === normalize(next)) { + return false; + } + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, next, 'utf-8'); + return true; +} + +async function main(args: string[]): Promise<void> { + const check = args.includes('--check'); + const quiet = args.includes('--quiet'); + + const root = resolveRepoRoot(); + const masterPath = join(root, 'prisma', 'schema.prisma'); + const sqlitePath = join(root, 'prisma', 'sqlite', 'schema.prisma'); + const enumsTsPath = join(root, 'sources', 'storage', 'enums.generated.ts'); + + const master = await readFile(masterPath, 'utf-8'); + const generated = generateSqliteSchemaFromPostgres(master); + const enumsTs = generateEnumsTsFromPostgres(master); + + if (check) { + let existing = ''; + try { + existing = await readFile(sqlitePath, 'utf-8'); + } catch { + // ignore + } + if (normalizeSchemaText(existing) !== normalizeSchemaText(generated)) { + console.error('[schema] prisma/sqlite/schema.prisma is out of date.'); + console.error('[schema] Run: yarn schema:sync'); + process.exit(1); + } + + let existingEnums = ''; + try { + existingEnums = await readFile(enumsTsPath, 'utf-8'); + } catch { + // ignore + } + if (normalizeGeneratedTs(existingEnums) !== normalizeGeneratedTs(enumsTs)) { + console.error('[schema] sources/storage/enums.generated.ts is out of date.'); + console.error('[schema] Run: yarn schema:sync'); + process.exit(1); + } + + if (!quiet) { + console.log('[schema] prisma/sqlite/schema.prisma is up to date.'); + console.log('[schema] sources/storage/enums.generated.ts is up to date.'); + } + return; + } + + if (!quiet) { + const wroteSchema = await writeIfChanged(sqlitePath, generated, normalizeSchemaText); + const wroteEnums = await writeIfChanged(enumsTsPath, enumsTs, normalizeGeneratedTs); + if (wroteSchema) console.log('[schema] Wrote prisma/sqlite/schema.prisma'); + if (wroteEnums) console.log('[schema] Wrote sources/storage/enums.generated.ts'); + if (!wroteSchema && !wroteEnums) console.log('[schema] No changes.'); + } else { + await writeIfChanged(sqlitePath, generated, normalizeSchemaText); + await writeIfChanged(enumsTsPath, enumsTs, normalizeGeneratedTs); + } +} + +const isMain = import.meta.url === pathToFileURL(process.argv[1] || '').href; +if (isMain) { + // eslint-disable-next-line no-void + void main(process.argv.slice(2)).catch((err) => { + console.error(err); + process.exit(1); + }); +} diff --git a/server/sources/app/api/api.ts b/server/sources/app/api/api.ts index e6db9e84d..353e7e8b9 100644 --- a/server/sources/app/api/api.ts +++ b/server/sources/app/api/api.ts @@ -18,9 +18,13 @@ import { accessKeysRoutes } from "./routes/accessKeysRoutes"; import { enableMonitoring } from "./utils/enableMonitoring"; import { enableErrorHandlers } from "./utils/enableErrorHandlers"; import { enableAuthentication } from "./utils/enableAuthentication"; +import { enableOptionalStatics } from "./utils/enableOptionalStatics"; import { userRoutes } from "./routes/userRoutes"; import { feedRoutes } from "./routes/feedRoutes"; import { kvRoutes } from "./routes/kvRoutes"; +import { shareRoutes } from "./routes/shareRoutes"; +import { publicShareRoutes } from "./routes/publicShareRoutes"; +import { featuresRoutes } from "./routes/featuresRoutes"; export async function startApi() { @@ -37,10 +41,12 @@ export async function startApi() { allowedHeaders: '*', methods: ['GET', 'POST', 'DELETE'] }); - app.get('/', function (request, reply) { - reply.send('Welcome to Happy Server!'); + app.register(import('@fastify/rate-limit'), { + global: false // Only apply to routes with explicit config }); + enableOptionalStatics(app); + // Create typed provider app.setValidatorCompiler(validatorCompiler); app.setSerializerCompiler(serializerCompiler); @@ -62,10 +68,13 @@ export async function startApi() { accessKeysRoutes(typed); devRoutes(typed); versionRoutes(typed); + featuresRoutes(typed); voiceRoutes(typed); userRoutes(typed); feedRoutes(typed); kvRoutes(typed); + shareRoutes(typed); + publicShareRoutes(typed); // Start HTTP const port = process.env.PORT ? parseInt(process.env.PORT, 10) : 3005; @@ -79,4 +88,4 @@ export async function startApi() { // End log('API ready on port http://localhost:' + port); -} \ No newline at end of file +} diff --git a/server/sources/app/api/routes/authRoutes.ts b/server/sources/app/api/routes/authRoutes.ts index 12d24cb36..267a578b4 100644 --- a/server/sources/app/api/routes/authRoutes.ts +++ b/server/sources/app/api/routes/authRoutes.ts @@ -11,7 +11,18 @@ export function authRoutes(app: Fastify) { body: z.object({ publicKey: z.string(), challenge: z.string(), - signature: z.string() + signature: z.string(), + contentPublicKey: z.string().optional(), + contentPublicKeySig: z.string().optional() + }).superRefine((value, ctx) => { + const hasContentKey = typeof value.contentPublicKey === 'string'; + const hasContentSig = typeof value.contentPublicKeySig === 'string'; + if (hasContentKey !== hasContentSig) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'contentPublicKey and contentPublicKeySig must be provided together' + }); + } }) } }, async (request, reply) => { @@ -19,17 +30,57 @@ export function authRoutes(app: Fastify) { const publicKey = privacyKit.decodeBase64(request.body.publicKey); const challenge = privacyKit.decodeBase64(request.body.challenge); const signature = privacyKit.decodeBase64(request.body.signature); + if (publicKey.length !== tweetnacl.sign.publicKeyLength) { + return reply.code(401).send({ error: 'Invalid public key' }); + } + if (signature.length !== tweetnacl.sign.signatureLength) { + return reply.code(401).send({ error: 'Invalid signature' }); + } const isValid = tweetnacl.sign.detached.verify(challenge, signature, publicKey); if (!isValid) { return reply.code(401).send({ error: 'Invalid signature' }); } + let contentPublicKey: Uint8Array | null = null; + let contentPublicKeySig: Uint8Array | null = null; + if (request.body.contentPublicKey && request.body.contentPublicKeySig) { + try { + contentPublicKey = privacyKit.decodeBase64(request.body.contentPublicKey); + contentPublicKeySig = privacyKit.decodeBase64(request.body.contentPublicKeySig); + } catch { + return reply.code(400).send({ error: 'Invalid content key encoding' }); + } + if (contentPublicKey.length !== tweetnacl.box.publicKeyLength) { + return reply.code(400).send({ error: 'Invalid contentPublicKey' }); + } + if (contentPublicKeySig.length !== tweetnacl.sign.signatureLength) { + return reply.code(400).send({ error: 'Invalid contentPublicKeySig' }); + } + + const binding = Buffer.concat([ + Buffer.from('Happy content key v1\u0000', 'utf8'), + Buffer.from(contentPublicKey) + ]); + const isContentKeyValid = tweetnacl.sign.detached.verify(binding, contentPublicKeySig, publicKey); + if (!isContentKeyValid) { + return reply.code(400).send({ error: 'Invalid contentPublicKeySig' }); + } + } + // Create or update user in database const publicKeyHex = privacyKit.encodeHex(publicKey); const user = await db.account.upsert({ where: { publicKey: publicKeyHex }, - update: { updatedAt: new Date() }, - create: { publicKey: publicKeyHex } + update: { + updatedAt: new Date(), + ...(contentPublicKey ? { contentPublicKey: new Uint8Array(contentPublicKey) } : {}), + ...(contentPublicKeySig ? { contentPublicKeySig: new Uint8Array(contentPublicKeySig) } : {}), + }, + create: { + publicKey: publicKeyHex, + ...(contentPublicKey ? { contentPublicKey: new Uint8Array(contentPublicKey) } : {}), + ...(contentPublicKeySig ? { contentPublicKeySig: new Uint8Array(contentPublicKeySig) } : {}), + } }); return reply.send({ @@ -241,4 +292,4 @@ export function authRoutes(app: Fastify) { return reply.send({ success: true }); }); -} \ No newline at end of file +} diff --git a/server/sources/app/api/routes/featuresRoutes.ts b/server/sources/app/api/routes/featuresRoutes.ts new file mode 100644 index 000000000..c2a873ab8 --- /dev/null +++ b/server/sources/app/api/routes/featuresRoutes.ts @@ -0,0 +1,31 @@ +import { z } from 'zod'; +import { type Fastify } from '../types'; + +export function featuresRoutes(app: Fastify) { + app.get( + '/v1/features', + { + schema: { + response: { + 200: z.object({ + features: z.object({ + sessionSharing: z.boolean(), + publicSharing: z.boolean(), + contentKeys: z.boolean(), + }), + }), + }, + }, + }, + async (_request, reply) => { + return reply.send({ + features: { + sessionSharing: true, + publicSharing: true, + contentKeys: true, + }, + }); + } + ); +} + diff --git a/server/sources/app/api/routes/machinesRoutes.ts b/server/sources/app/api/routes/machinesRoutes.ts index 27758a59e..f972ff38a 100644 --- a/server/sources/app/api/routes/machinesRoutes.ts +++ b/server/sources/app/api/routes/machinesRoutes.ts @@ -1,7 +1,7 @@ import { eventRouter } from "@/app/events/eventRouter"; import { Fastify } from "../types"; import { z } from "zod"; -import { db } from "@/storage/db"; +import { db, isPrismaErrorCode } from "@/storage/db"; import { log } from "@/utils/log"; import { randomKeyNaked } from "@/utils/randomKeyNaked"; import { allocateUserSeq } from "@/storage/seq"; @@ -51,20 +51,49 @@ export function machinesRoutes(app: Fastify) { // Create new machine log({ module: 'machines', machineId: id, userId }, 'Creating new machine'); - const newMachine = await db.machine.create({ - data: { - id, - accountId: userId, - metadata, - metadataVersion: 1, - daemonState: daemonState || null, - daemonStateVersion: daemonState ? 1 : 0, - dataEncryptionKey: dataEncryptionKey ? new Uint8Array(Buffer.from(dataEncryptionKey, 'base64')) : undefined, - // Default to offline - in case the user does not start daemon - active: false, - // lastActiveAt and activeAt defaults to now() in schema + let newMachine; + try { + newMachine = await db.machine.create({ + data: { + id, + accountId: userId, + metadata, + metadataVersion: 1, + daemonState: daemonState || null, + daemonStateVersion: daemonState ? 1 : 0, + dataEncryptionKey: dataEncryptionKey ? new Uint8Array(Buffer.from(dataEncryptionKey, 'base64')) : undefined, + // Default to offline - in case the user does not start daemon + active: false, + // lastActiveAt and activeAt defaults to now() in schema + } + }); + } catch (e) { + // Concurrency safety: multiple clients may race to create the same machine (e.g. daemon + session spawns). + // If we lost the race, fetch the winner row and return it instead of surfacing a 500. + if (isPrismaErrorCode(e, 'P2002')) { + const existing = await db.machine.findFirst({ + where: { accountId: userId, id } + }); + if (existing) { + log({ module: 'machines', machineId: id, userId }, 'Machine created concurrently; returning existing machine'); + return reply.send({ + machine: { + id: existing.id, + metadata: existing.metadata, + metadataVersion: existing.metadataVersion, + daemonState: existing.daemonState, + daemonStateVersion: existing.daemonStateVersion, + dataEncryptionKey: existing.dataEncryptionKey ? Buffer.from(existing.dataEncryptionKey).toString('base64') : null, + active: existing.active, + activeAt: existing.lastActiveAt.getTime(), + createdAt: existing.createdAt.getTime(), + updatedAt: existing.updatedAt.getTime() + } + }); + } } - }); + throw e; + } // Emit both new-machine and update-machine events for backward compatibility const updSeq1 = await allocateUserSeq(userId); @@ -174,4 +203,4 @@ export function machinesRoutes(app: Fastify) { }; }); -} \ No newline at end of file +} diff --git a/server/sources/app/api/routes/publicShareRoutes.ts b/server/sources/app/api/routes/publicShareRoutes.ts new file mode 100644 index 000000000..6bfcd620d --- /dev/null +++ b/server/sources/app/api/routes/publicShareRoutes.ts @@ -0,0 +1,698 @@ +import { type Fastify } from "../types"; +import { db } from "@/storage/db"; +import { z } from "zod"; +import { isSessionOwner } from "@/app/share/accessControl"; +import { randomKeyNaked } from "@/utils/randomKeyNaked"; +import { logPublicShareAccess, getIpAddress, getUserAgent } from "@/app/share/accessLogger"; +import { PROFILE_SELECT, toShareUserProfile } from "@/app/share/types"; +import { eventRouter, buildPublicShareCreatedUpdate, buildPublicShareUpdatedUpdate, buildPublicShareDeletedUpdate } from "@/app/events/eventRouter"; +import { allocateUserSeq } from "@/storage/seq"; +import { createHash } from "crypto"; + +/** + * Public session sharing API routes + * + * Public shares are always view-only for security + */ +export function publicShareRoutes(app: Fastify) { + + /** + * Create or update public share for a session + */ + app.post('/v1/sessions/:sessionId/public-share', { + preHandler: app.authenticate, + config: { + rateLimit: { + max: 10, + timeWindow: '1 minute' + } + }, + schema: { + params: z.object({ + sessionId: z.string() + }), + body: z.object({ + token: z.string().optional(), // client-generated token (required when creating or rotating) + encryptedDataKey: z.string().optional(), // base64 encoded (required when creating or rotating) + expiresAt: z.number().optional(), // timestamp + maxUses: z.number().int().positive().optional(), + isConsentRequired: z.boolean().optional() // require consent for detailed logging + }) + } + }, async (request, reply) => { + const userId = request.userId; + const { sessionId } = request.params; + const { token, encryptedDataKey, expiresAt, maxUses, isConsentRequired } = request.body; + + // Only owner can create public shares + if (!await isSessionOwner(userId, sessionId)) { + return reply.code(403).send({ error: 'Forbidden' }); + } + + // Check if public share already exists + const existing = await db.publicSessionShare.findUnique({ + where: { sessionId } + }); + + let publicShare; + const isUpdate = !!existing; + + if (existing) { + const shouldRotateToken = typeof token === 'string' && token.length > 0; + if (shouldRotateToken && !encryptedDataKey) { + return reply.code(400).send({ error: 'encryptedDataKey required when rotating token' }); + } + const nextTokenHash = shouldRotateToken ? createHash('sha256').update(token!, 'utf8').digest() : null; + + // Update existing share (token is stored as a hash only; token itself is not persisted) + publicShare = await db.publicSessionShare.update({ + where: { sessionId }, + data: { + ...(nextTokenHash ? { tokenHash: nextTokenHash } : {}), + ...(encryptedDataKey ? { encryptedDataKey: new Uint8Array(Buffer.from(encryptedDataKey, 'base64')) } : {}), + expiresAt: expiresAt ? new Date(expiresAt) : null, + maxUses: maxUses ?? null, + isConsentRequired: isConsentRequired ?? false, + ...(nextTokenHash ? { useCount: 0 } : {}), + } + }); + } else { + if (!token) { + return reply.code(400).send({ error: 'token required' }); + } + if (!encryptedDataKey) { + return reply.code(400).send({ error: 'encryptedDataKey required' }); + } + const tokenHash = createHash('sha256').update(token, 'utf8').digest(); + + // Create new share with client-provided token + publicShare = await db.publicSessionShare.create({ + data: { + sessionId, + createdByUserId: userId, + tokenHash, + encryptedDataKey: new Uint8Array(Buffer.from(encryptedDataKey, 'base64')), + expiresAt: expiresAt ? new Date(expiresAt) : null, + maxUses: maxUses ?? null, + isConsentRequired: isConsentRequired ?? false + } + }); + } + + // Emit real-time update to session owner + const updateSeq = await allocateUserSeq(userId); + const updatePayload = isUpdate + ? buildPublicShareUpdatedUpdate(publicShare, updateSeq, randomKeyNaked(12)) + : buildPublicShareCreatedUpdate({ ...publicShare, token: token! }, updateSeq, randomKeyNaked(12)); + + eventRouter.emitUpdate({ + userId: userId, + payload: updatePayload, + recipientFilter: { type: 'all-interested-in-session', sessionId } + }); + + return reply.send({ + publicShare: { + id: publicShare.id, + token: token ?? null, + expiresAt: publicShare.expiresAt?.getTime() ?? null, + maxUses: publicShare.maxUses, + useCount: publicShare.useCount, + isConsentRequired: publicShare.isConsentRequired, + createdAt: publicShare.createdAt.getTime(), + updatedAt: publicShare.updatedAt.getTime() + } + }); + }); + + /** + * Get public share info for a session + */ + app.get('/v1/sessions/:sessionId/public-share', { + preHandler: app.authenticate, + schema: { + params: z.object({ + sessionId: z.string() + }) + } + }, async (request, reply) => { + const userId = request.userId; + const { sessionId } = request.params; + + // Only owner can view public share settings + if (!await isSessionOwner(userId, sessionId)) { + return reply.code(403).send({ error: 'Forbidden' }); + } + + const publicShare = await db.publicSessionShare.findUnique({ + where: { sessionId } + }); + + if (!publicShare) { + return reply.send({ publicShare: null }); + } + + return reply.send({ + publicShare: { + id: publicShare.id, + token: null, + expiresAt: publicShare.expiresAt?.getTime() ?? null, + maxUses: publicShare.maxUses, + useCount: publicShare.useCount, + isConsentRequired: publicShare.isConsentRequired, + createdAt: publicShare.createdAt.getTime(), + updatedAt: publicShare.updatedAt.getTime() + } + }); + }); + + /** + * Delete public share (disable public link) + */ + app.delete('/v1/sessions/:sessionId/public-share', { + preHandler: app.authenticate, + schema: { + params: z.object({ + sessionId: z.string() + }) + } + }, async (request, reply) => { + const userId = request.userId; + const { sessionId } = request.params; + + // Only owner can delete public share + if (!await isSessionOwner(userId, sessionId)) { + return reply.code(403).send({ error: 'Forbidden' }); + } + + // Use transaction to ensure consistent state + const deleted = await db.$transaction(async (tx) => { + // Check if share exists + const existing = await tx.publicSessionShare.findUnique({ + where: { sessionId } + }); + + if (!existing) { + return false; + } + + // Delete public share + await tx.publicSessionShare.delete({ + where: { sessionId } + }); + + return true; + }); + + // Emit real-time update to session owner (outside transaction) + if (deleted) { + const updateSeq = await allocateUserSeq(userId); + const updatePayload = buildPublicShareDeletedUpdate( + sessionId, + updateSeq, + randomKeyNaked(12) + ); + + eventRouter.emitUpdate({ + userId: userId, + payload: updatePayload, + recipientFilter: { type: 'all-interested-in-session', sessionId } + }); + } + + return reply.send({ success: true }); + }); + + /** + * Access session via public share token (no auth required) + * + * If isConsentRequired is true, client must pass consent=true query param + */ + app.get('/v1/public-share/:token', { + config: { + rateLimit: { + max: 10, + timeWindow: '1 minute' + } + }, + schema: { + params: z.object({ + token: z.string() + }), + querystring: z.object({ + consent: z.coerce.boolean().optional() + }).optional() + } + }, async (request, reply) => { + const { token } = request.params; + const { consent } = request.query || {}; + const tokenHash = createHash('sha256').update(token, 'utf8').digest(); + + // Try to get user ID if authenticated + let userId: string | null = null; + if (request.headers.authorization) { + try { + await app.authenticate(request, reply); + userId = request.userId; + } catch { + // Not authenticated, continue as anonymous + } + } + + // Use transaction to atomically check limits and increment use count + const result = await db.$transaction(async (tx) => { + // Check access and get full public share data + const publicShare = await tx.publicSessionShare.findUnique({ + where: { tokenHash }, + select: { + id: true, + sessionId: true, + expiresAt: true, + maxUses: true, + useCount: true, + isConsentRequired: true, + encryptedDataKey: true, + blockedUsers: userId ? { + where: { userId }, + select: { id: true } + } : undefined + } + }); + + if (!publicShare) { + return { error: 'Public share not found or expired' }; + } + + // Check if expired + if (publicShare.expiresAt && publicShare.expiresAt < new Date()) { + return { error: 'Public share not found or expired' }; + } + + // Check if max uses exceeded (before incrementing) + if (publicShare.maxUses && publicShare.useCount >= publicShare.maxUses) { + return { error: 'Public share not found or expired' }; + } + + // Check if user is blocked + if (userId && publicShare.blockedUsers && publicShare.blockedUsers.length > 0) { + return { error: 'Public share not found or expired' }; + } + + // Check consent requirement + if (publicShare.isConsentRequired && !consent) { + return { + error: 'Consent required', + requiresConsent: true, + publicShareId: publicShare.id, + sessionId: publicShare.sessionId + }; + } + + // Increment use count atomically + await tx.publicSessionShare.update({ + where: { id: publicShare.id }, + data: { useCount: { increment: 1 } } + }); + + return { + success: true, + publicShareId: publicShare.id, + sessionId: publicShare.sessionId, + isConsentRequired: publicShare.isConsentRequired, + encryptedDataKey: publicShare.encryptedDataKey + }; + }); + + // Handle errors from transaction + if ('error' in result) { + if (result.requiresConsent) { + // Get owner info even when consent is required + const session = await db.session.findUnique({ + where: { id: result.sessionId }, + select: { + account: { + select: PROFILE_SELECT + } + } + }); + + return reply.code(403).send({ + error: result.error, + requiresConsent: true, + sessionId: result.sessionId, + owner: session?.account ? toShareUserProfile(session.account) : null + }); + } + return reply.code(404).send({ error: result.error }); + } + + // Log access (only log IP/UA if consent was given) + const ipAddress = result.isConsentRequired ? getIpAddress(request.headers) : undefined; + const userAgent = result.isConsentRequired ? getUserAgent(request.headers) : undefined; + await logPublicShareAccess(result.publicShareId, userId, ipAddress, userAgent); + + // Get session info with owner profile + const session = await db.session.findUnique({ + where: { id: result.sessionId }, + select: { + id: true, + seq: true, + createdAt: true, + updatedAt: true, + metadata: true, + metadataVersion: true, + agentState: true, + agentStateVersion: true, + active: true, + lastActiveAt: true, + account: { + select: PROFILE_SELECT + } + } + }); + + if (!session) { + return reply.code(404).send({ error: 'Session not found' }); + } + + return reply.send({ + session: { + id: session.id, + seq: session.seq, + createdAt: session.createdAt.getTime(), + updatedAt: session.updatedAt.getTime(), + active: session.active, + activeAt: session.lastActiveAt.getTime(), + metadata: session.metadata, + metadataVersion: session.metadataVersion, + agentState: session.agentState, + agentStateVersion: session.agentStateVersion + }, + owner: toShareUserProfile(session.account), + accessLevel: 'view', + encryptedDataKey: Buffer.from(result.encryptedDataKey).toString('base64'), + isConsentRequired: result.isConsentRequired + }); + }); + + /** + * Get messages for a public share token (no auth required, read-only) + * + * NOTE: Does not increment useCount (useCount is incremented on /v1/public-share/:token). + */ + app.get('/v1/public-share/:token/messages', { + config: { + rateLimit: { + max: 20, + timeWindow: '1 minute' + } + }, + schema: { + params: z.object({ + token: z.string() + }), + querystring: z.object({ + consent: z.coerce.boolean().optional() + }).optional() + } + }, async (request, reply) => { + const { token } = request.params; + const { consent } = request.query || {}; + const tokenHash = createHash('sha256').update(token, 'utf8').digest(); + + // Try to get user ID if authenticated + let userId: string | null = null; + if (request.headers.authorization) { + try { + await app.authenticate(request, reply); + userId = request.userId; + } catch { + // Not authenticated, continue as anonymous + } + } + + const publicShare = await db.publicSessionShare.findUnique({ + where: { tokenHash }, + select: { + id: true, + sessionId: true, + expiresAt: true, + maxUses: true, + useCount: true, + isConsentRequired: true, + blockedUsers: userId ? { + where: { userId }, + select: { id: true } + } : undefined + } + }); + + if (!publicShare) { + return reply.code(404).send({ error: 'Public share not found or expired' }); + } + + // Check if expired + if (publicShare.expiresAt && publicShare.expiresAt < new Date()) { + return reply.code(404).send({ error: 'Public share not found or expired' }); + } + + // Check if max uses exceeded + if (publicShare.maxUses && publicShare.useCount >= publicShare.maxUses) { + return reply.code(404).send({ error: 'Public share not found or expired' }); + } + + // Check if user is blocked + if (userId && publicShare.blockedUsers && publicShare.blockedUsers.length > 0) { + return reply.code(404).send({ error: 'Public share not found or expired' }); + } + + // Check consent requirement + if (publicShare.isConsentRequired && !consent) { + const session = await db.session.findUnique({ + where: { id: publicShare.sessionId }, + select: { + account: { + select: PROFILE_SELECT + } + } + }); + + return reply.code(403).send({ + error: 'Consent required', + requiresConsent: true, + sessionId: publicShare.sessionId, + owner: session?.account ? toShareUserProfile(session.account) : null + }); + } + + const messages = await db.sessionMessage.findMany({ + where: { sessionId: publicShare.sessionId }, + orderBy: { createdAt: 'desc' }, + take: 150, + select: { + id: true, + seq: true, + localId: true, + content: true, + createdAt: true, + updatedAt: true + } + }); + + return reply.send({ + messages: messages.map((v) => ({ + id: v.id, + seq: v.seq, + content: v.content, + localId: v.localId, + createdAt: v.createdAt.getTime(), + updatedAt: v.updatedAt.getTime() + })) + }); + }); + + /** + * Get blocked users for public share + */ + app.get('/v1/sessions/:sessionId/public-share/blocked-users', { + preHandler: app.authenticate, + schema: { + params: z.object({ + sessionId: z.string() + }) + } + }, async (request, reply) => { + const userId = request.userId; + const { sessionId } = request.params; + + // Only owner can view blocked users + if (!await isSessionOwner(userId, sessionId)) { + return reply.code(403).send({ error: 'Forbidden' }); + } + + const publicShare = await db.publicSessionShare.findUnique({ + where: { sessionId }, + select: { id: true } + }); + + if (!publicShare) { + return reply.code(404).send({ error: 'Public share not found' }); + } + + const blockedUsers = await db.publicShareBlockedUser.findMany({ + where: { publicShareId: publicShare.id }, + include: { + user: { + select: PROFILE_SELECT + } + }, + orderBy: { blockedAt: 'desc' } + }); + + return reply.send({ + blockedUsers: blockedUsers.map(bu => ({ + id: bu.id, + user: toShareUserProfile(bu.user), + reason: bu.reason, + blockedAt: bu.blockedAt.getTime() + })) + }); + }); + + /** + * Block user from public share + */ + app.post('/v1/sessions/:sessionId/public-share/blocked-users', { + preHandler: app.authenticate, + schema: { + params: z.object({ + sessionId: z.string() + }), + body: z.object({ + userId: z.string(), + reason: z.string().optional() + }) + } + }, async (request, reply) => { + const ownerId = request.userId; + const { sessionId } = request.params; + const { userId, reason } = request.body; + + // Only owner can block users + if (!await isSessionOwner(ownerId, sessionId)) { + return reply.code(403).send({ error: 'Forbidden' }); + } + + const publicShare = await db.publicSessionShare.findUnique({ + where: { sessionId }, + select: { id: true } + }); + + if (!publicShare) { + return reply.code(404).send({ error: 'Public share not found' }); + } + + const blockedUser = await db.publicShareBlockedUser.create({ + data: { + publicShareId: publicShare.id, + userId, + reason: reason ?? null + }, + include: { + user: { + select: PROFILE_SELECT + } + } + }); + + return reply.send({ + blockedUser: { + id: blockedUser.id, + user: toShareUserProfile(blockedUser.user), + reason: blockedUser.reason, + blockedAt: blockedUser.blockedAt.getTime() + } + }); + }); + + /** + * Unblock user from public share + */ + app.delete('/v1/sessions/:sessionId/public-share/blocked-users/:blockedUserId', { + preHandler: app.authenticate, + schema: { + params: z.object({ + sessionId: z.string(), + blockedUserId: z.string() + }) + } + }, async (request, reply) => { + const userId = request.userId; + const { sessionId, blockedUserId } = request.params; + + // Only owner can unblock users + if (!await isSessionOwner(userId, sessionId)) { + return reply.code(403).send({ error: 'Forbidden' }); + } + + await db.publicShareBlockedUser.delete({ + where: { id: blockedUserId } + }); + + return reply.send({ success: true }); + }); + + /** + * Get access logs for public share + */ + app.get('/v1/sessions/:sessionId/public-share/access-logs', { + preHandler: app.authenticate, + schema: { + params: z.object({ + sessionId: z.string() + }), + querystring: z.object({ + limit: z.coerce.number().int().min(1).max(100).default(50) + }).optional() + } + }, async (request, reply) => { + const userId = request.userId; + const { sessionId } = request.params; + const limit = request.query?.limit || 50; + + // Only owner can view access logs + if (!await isSessionOwner(userId, sessionId)) { + return reply.code(403).send({ error: 'Forbidden' }); + } + + const publicShare = await db.publicSessionShare.findUnique({ + where: { sessionId }, + select: { id: true } + }); + + if (!publicShare) { + return reply.code(404).send({ error: 'Public share not found' }); + } + + const logs = await db.publicShareAccessLog.findMany({ + where: { publicShareId: publicShare.id }, + include: { + user: { + select: PROFILE_SELECT + } + }, + orderBy: { accessedAt: 'desc' }, + take: limit + }); + + return reply.send({ + logs: logs.map(log => ({ + id: log.id, + user: log.user ? toShareUserProfile(log.user) : null, + accessedAt: log.accessedAt.getTime(), + ipAddress: log.ipAddress, + userAgent: log.userAgent + })) + }); + }); +} diff --git a/server/sources/app/api/routes/sessionRoutes.ts b/server/sources/app/api/routes/sessionRoutes.ts index e4f9e7a99..398415525 100644 --- a/server/sources/app/api/routes/sessionRoutes.ts +++ b/server/sources/app/api/routes/sessionRoutes.ts @@ -2,11 +2,13 @@ import { eventRouter, buildNewSessionUpdate } from "@/app/events/eventRouter"; import { type Fastify } from "../types"; import { db } from "@/storage/db"; import { z } from "zod"; -import { Prisma } from "@prisma/client"; +import type { Prisma } from "@prisma/client"; import { log } from "@/utils/log"; import { randomKeyNaked } from "@/utils/randomKeyNaked"; import { allocateUserSeq } from "@/storage/seq"; import { sessionDelete } from "@/app/session/sessionDelete"; +import { checkSessionAccess } from "@/app/share/accessControl"; +import { PROFILE_SELECT, toShareUserProfile } from "@/app/share/types"; export function sessionRoutes(app: Fastify) { @@ -16,58 +18,93 @@ export function sessionRoutes(app: Fastify) { }, async (request, reply) => { const userId = request.userId; - const sessions = await db.session.findMany({ - where: { accountId: userId }, - orderBy: { updatedAt: 'desc' }, - take: 150, - select: { - id: true, - seq: true, - createdAt: true, - updatedAt: true, - metadata: true, - metadataVersion: true, - agentState: true, - agentStateVersion: true, - dataEncryptionKey: true, - active: true, - lastActiveAt: true, - // messages: { - // orderBy: { seq: 'desc' }, - // take: 1, - // select: { - // id: true, - // seq: true, - // content: true, - // localId: true, - // createdAt: true - // } - // } - } - }); - - return reply.send({ - sessions: sessions.map((v) => { - // const lastMessage = v.messages[0]; - const sessionUpdatedAt = v.updatedAt.getTime(); - // const lastMessageCreatedAt = lastMessage ? lastMessage.createdAt.getTime() : 0; + const [ownedSessions, shares] = await Promise.all([ + db.session.findMany({ + where: { accountId: userId }, + orderBy: { updatedAt: 'desc' }, + take: 150, + select: { + id: true, + seq: true, + createdAt: true, + updatedAt: true, + metadata: true, + metadataVersion: true, + agentState: true, + agentStateVersion: true, + dataEncryptionKey: true, + active: true, + lastActiveAt: true, + } + }), + db.sessionShare.findMany({ + where: { sharedWithUserId: userId }, + orderBy: { session: { updatedAt: 'desc' } }, + take: 150, + select: { + accessLevel: true, + encryptedDataKey: true, + sharedByUserId: true, + sharedByUser: { select: PROFILE_SELECT }, + session: { + select: { + id: true, + seq: true, + createdAt: true, + updatedAt: true, + metadata: true, + metadataVersion: true, + agentState: true, + agentStateVersion: true, + active: true, + lastActiveAt: true, + } + } + } + }), + ]); + const sessions = [ + ...ownedSessions.map((v) => ({ + id: v.id, + seq: v.seq, + createdAt: v.createdAt.getTime(), + updatedAt: v.updatedAt.getTime(), + active: v.active, + activeAt: v.lastActiveAt.getTime(), + metadata: v.metadata, + metadataVersion: v.metadataVersion, + agentState: v.agentState, + agentStateVersion: v.agentStateVersion, + dataEncryptionKey: v.dataEncryptionKey ? Buffer.from(v.dataEncryptionKey).toString('base64') : null, + lastMessage: null, + })), + ...shares.map((share) => { + const v = share.session; return { id: v.id, seq: v.seq, createdAt: v.createdAt.getTime(), - updatedAt: sessionUpdatedAt, + updatedAt: v.updatedAt.getTime(), active: v.active, activeAt: v.lastActiveAt.getTime(), metadata: v.metadata, metadataVersion: v.metadataVersion, agentState: v.agentState, agentStateVersion: v.agentStateVersion, - dataEncryptionKey: v.dataEncryptionKey ? Buffer.from(v.dataEncryptionKey).toString('base64') : null, - lastMessage: null + // Important: for shared sessions, return the recipient-wrapped DEK. + dataEncryptionKey: Buffer.from(share.encryptedDataKey).toString('base64'), + lastMessage: null, + owner: share.sharedByUserId, + ownerProfile: toShareUserProfile(share.sharedByUser), + accessLevel: share.accessLevel, }; - }) - }); + }), + ] + .sort((a, b) => b.updatedAt - a.updatedAt) + .slice(0, 150); + + return reply.send({ sessions }); }); // V2 Sessions API - Active sessions only @@ -316,15 +353,8 @@ export function sessionRoutes(app: Fastify) { const userId = request.userId; const { sessionId } = request.params; - // Verify session belongs to user - const session = await db.session.findFirst({ - where: { - id: sessionId, - accountId: userId - } - }); - - if (!session) { + const access = await checkSessionAccess(userId, sessionId); + if (!access) { return reply.code(404).send({ error: 'Session not found' }); } @@ -374,4 +404,4 @@ export function sessionRoutes(app: Fastify) { return reply.send({ success: true }); }); -} \ No newline at end of file +} diff --git a/server/sources/app/api/routes/shareRoutes.ts b/server/sources/app/api/routes/shareRoutes.ts new file mode 100644 index 000000000..39d37bc98 --- /dev/null +++ b/server/sources/app/api/routes/shareRoutes.ts @@ -0,0 +1,308 @@ +import { type Fastify } from "../types"; +import { db } from "@/storage/db"; +import { z } from "zod"; +import { canManageSharing, areFriends } from "@/app/share/accessControl"; +import { ShareAccessLevel } from "@prisma/client"; +import { PROFILE_SELECT, toShareUserProfile } from "@/app/share/types"; +import { eventRouter, buildSessionSharedUpdate, buildSessionShareUpdatedUpdate, buildSessionShareRevokedUpdate } from "@/app/events/eventRouter"; +import { allocateUserSeq } from "@/storage/seq"; +import { randomKeyNaked } from "@/utils/randomKeyNaked"; + +function parseEncryptedDataKeyV0(encryptedDataKeyB64: string): Uint8Array { + let bytes: Uint8Array; + try { + bytes = new Uint8Array(Buffer.from(encryptedDataKeyB64, 'base64')); + } catch { + throw new Error('Invalid base64'); + } + // version (1) + ephemeral pk (32) + nonce (24) + mac (16) = 73 minimum + if (bytes.length < 1 + 32 + 24 + 16) { + throw new Error('encryptedDataKey too short'); + } + if (bytes[0] !== 0) { + throw new Error('Unsupported encryptedDataKey version'); + } + return bytes; +} + +/** + * Session sharing API routes + */ +export function shareRoutes(app: Fastify) { + + /** + * Get all shares for a session (owner/admin only) + */ + app.get('/v1/sessions/:sessionId/shares', { + preHandler: app.authenticate, + schema: { + params: z.object({ + sessionId: z.string() + }) + } + }, async (request, reply) => { + const userId = request.userId; + const { sessionId } = request.params; + + // Only owner or admin can view shares + if (!await canManageSharing(userId, sessionId)) { + return reply.code(403).send({ error: 'Forbidden' }); + } + + const shares = await db.sessionShare.findMany({ + where: { sessionId }, + include: { + sharedWithUser: { + select: PROFILE_SELECT + } + }, + orderBy: { createdAt: 'desc' } + }); + + return reply.send({ + shares: shares.map(share => ({ + id: share.id, + sharedWithUser: toShareUserProfile(share.sharedWithUser), + accessLevel: share.accessLevel, + createdAt: share.createdAt.getTime(), + updatedAt: share.updatedAt.getTime() + })) + }); + }); + + /** + * Share session with a user + */ + app.post('/v1/sessions/:sessionId/shares', { + preHandler: app.authenticate, + config: { + rateLimit: { + max: 20, + timeWindow: '1 minute' + } + }, + schema: { + params: z.object({ + sessionId: z.string() + }), + body: z.object({ + userId: z.string(), + accessLevel: z.enum(['view', 'edit', 'admin']), + encryptedDataKey: z.string(), + }) + } + }, async (request, reply) => { + const ownerId = request.userId; + const { sessionId } = request.params; + const { userId, accessLevel, encryptedDataKey } = request.body; + + const session = await db.session.findUnique({ + where: { id: sessionId }, + select: { id: true } + }); + if (!session) { + return reply.code(404).send({ error: 'Session not found' }); + } + + // Only owner or admin can create shares + if (!await canManageSharing(ownerId, sessionId)) { + return reply.code(403).send({ error: 'Forbidden' }); + } + + // Cannot share with yourself + if (userId === ownerId) { + return reply.code(400).send({ error: 'Cannot share with yourself' }); + } + + // Verify target user exists and get their public key + const targetUser = await db.account.findUnique({ + where: { id: userId }, + select: { id: true } + }); + + if (!targetUser) { + return reply.code(404).send({ error: 'User not found' }); + } + + // Check if users are friends + if (!await areFriends(ownerId, userId)) { + return reply.code(403).send({ error: 'Can only share with friends' }); + } + + let encryptedDataKeyBytes: Uint8Array; + try { + encryptedDataKeyBytes = parseEncryptedDataKeyV0(encryptedDataKey); + } catch (error) { + return reply.code(400).send({ error: 'Invalid encryptedDataKey' }); + } + + // Create or update share + const share = await db.sessionShare.upsert({ + where: { + sessionId_sharedWithUserId: { + sessionId, + sharedWithUserId: userId + } + }, + create: { + sessionId, + sharedByUserId: ownerId, + sharedWithUserId: userId, + accessLevel: accessLevel as ShareAccessLevel, + encryptedDataKey: encryptedDataKeyBytes + }, + update: { + accessLevel: accessLevel as ShareAccessLevel, + encryptedDataKey: encryptedDataKeyBytes + }, + include: { + sharedWithUser: { + select: PROFILE_SELECT + }, + sharedByUser: { + select: PROFILE_SELECT + } + } + }); + + // Emit real-time update to shared user + const updateSeq = await allocateUserSeq(userId); + const updatePayload = buildSessionSharedUpdate(share, updateSeq, randomKeyNaked(12)); + eventRouter.emitUpdate({ + userId: userId, + payload: updatePayload, + recipientFilter: { type: 'all-user-authenticated-connections' } + }); + + return reply.send({ + share: { + id: share.id, + sharedWithUser: toShareUserProfile(share.sharedWithUser), + accessLevel: share.accessLevel, + createdAt: share.createdAt.getTime(), + updatedAt: share.updatedAt.getTime() + } + }); + }); + + /** + * Update share access level + */ + app.patch('/v1/sessions/:sessionId/shares/:shareId', { + preHandler: app.authenticate, + schema: { + params: z.object({ + sessionId: z.string(), + shareId: z.string() + }), + body: z.object({ + accessLevel: z.enum(['view', 'edit', 'admin']) + }) + } + }, async (request, reply) => { + const userId = request.userId; + const { sessionId, shareId } = request.params; + const { accessLevel } = request.body; + + // Only owner or admin can update shares + if (!await canManageSharing(userId, sessionId)) { + return reply.code(403).send({ error: 'Forbidden' }); + } + + const share = await db.sessionShare.update({ + where: { id: shareId, sessionId }, + data: { accessLevel: accessLevel as ShareAccessLevel }, + include: { + sharedWithUser: { + select: PROFILE_SELECT + } + } + }); + + // Emit real-time update to shared user + const updateSeq = await allocateUserSeq(share.sharedWithUserId); + const updatePayload = buildSessionShareUpdatedUpdate( + share.id, + share.sessionId, + share.accessLevel, + share.updatedAt, + updateSeq, + randomKeyNaked(12) + ); + eventRouter.emitUpdate({ + userId: share.sharedWithUserId, + payload: updatePayload, + recipientFilter: { type: 'all-user-authenticated-connections' } + }); + + return reply.send({ + share: { + id: share.id, + sharedWithUser: toShareUserProfile(share.sharedWithUser), + accessLevel: share.accessLevel, + createdAt: share.createdAt.getTime(), + updatedAt: share.updatedAt.getTime() + } + }); + }); + + /** + * Delete share (revoke access) + */ + app.delete('/v1/sessions/:sessionId/shares/:shareId', { + preHandler: app.authenticate, + schema: { + params: z.object({ + sessionId: z.string(), + shareId: z.string() + }) + } + }, async (request, reply) => { + const userId = request.userId; + const { sessionId, shareId } = request.params; + + // Only owner or admin can delete shares + if (!await canManageSharing(userId, sessionId)) { + return reply.code(403).send({ error: 'Forbidden' }); + } + + // Use transaction to ensure consistent state + const result = await db.$transaction(async (tx) => { + // Get share before deleting + const share = await tx.sessionShare.findUnique({ + where: { id: shareId, sessionId } + }); + + if (!share) { + return { error: 'Share not found' }; + } + + // Delete share + await tx.sessionShare.delete({ + where: { id: shareId, sessionId } + }); + + return { share }; + }); + + if ('error' in result) { + return reply.code(404).send({ error: result.error }); + } + + // Emit real-time update to shared user (outside transaction) + const updateSeq = await allocateUserSeq(result.share.sharedWithUserId); + const updatePayload = buildSessionShareRevokedUpdate( + result.share.id, + result.share.sessionId, + updateSeq, + randomKeyNaked(12) + ); + eventRouter.emitUpdate({ + userId: result.share.sharedWithUserId, + payload: updatePayload, + recipientFilter: { type: 'all-user-authenticated-connections' } + }); + + return reply.send({ success: true }); + }); +} diff --git a/server/sources/app/api/routes/userRoutes.ts b/server/sources/app/api/routes/userRoutes.ts index 9da423938..5f366df58 100644 --- a/server/sources/app/api/routes/userRoutes.ts +++ b/server/sources/app/api/routes/userRoutes.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { Fastify } from "../types"; import { db } from "@/storage/db"; -import { RelationshipStatus } from "@prisma/client"; +import { RelationshipStatus, type RelationshipStatus as RelationshipStatusType } from "@/storage/prisma"; import { friendAdd } from "@/app/social/friendAdd"; import { Context } from "@/context"; import { friendRemove } from "@/app/social/friendRemove"; @@ -50,7 +50,7 @@ export async function userRoutes(app: Fastify) { toUserId: id } }); - const status: RelationshipStatus = relationship?.status || RelationshipStatus.none; + const status: RelationshipStatusType = relationship?.status || RelationshipStatus.none; // Build user profile return reply.send({ @@ -74,13 +74,15 @@ export async function userRoutes(app: Fastify) { }, async (request, reply) => { const { query } = request.query; + const username = + process.env.HAPPY_SERVER_FLAVOR === 'light' + ? { startsWith: query } + : { startsWith: query, mode: 'insensitive' as const }; + // Search for users by username, first 10 matches const users = await db.account.findMany({ where: { - username: { - startsWith: query, - mode: 'insensitive' - } + username }, include: { githubUser: true @@ -99,7 +101,7 @@ export async function userRoutes(app: Fastify) { toUserId: user.id } }); - const status: RelationshipStatus = relationship?.status || RelationshipStatus.none; + const status: RelationshipStatusType = relationship?.status || RelationshipStatus.none; return buildUserProfile(user, status); })); @@ -179,5 +181,8 @@ const UserProfileSchema = z.object({ }).nullable(), username: z.string(), bio: z.string().nullable(), - status: RelationshipStatusSchema -}); \ No newline at end of file + status: RelationshipStatusSchema, + publicKey: z.string(), + contentPublicKey: z.string().nullable(), + contentPublicKeySig: z.string().nullable(), +}); diff --git a/server/sources/app/api/socket/rpcHandler.spec.ts b/server/sources/app/api/socket/rpcHandler.spec.ts new file mode 100644 index 000000000..64c268ea7 --- /dev/null +++ b/server/sources/app/api/socket/rpcHandler.spec.ts @@ -0,0 +1,46 @@ +import { describe, it, expect, vi } from 'vitest'; +import { rpcHandler } from './rpcHandler'; +import { RPC_ERROR_CODES } from '@happy/protocol/rpc'; +import { SOCKET_RPC_EVENTS } from '@happy/protocol/socketRpc'; + +class FakeSocket { + public connected = true; + public id = 'fake-socket'; + public handlers = new Map<string, any>(); + public emit = vi.fn(); + + on(event: string, handler: any) { + this.handlers.set(event, handler); + } + + timeout() { + return { + emitWithAck: async () => { + throw new Error('not implemented'); + }, + }; + } +} + +describe('rpcHandler', () => { + it('returns an explicit errorCode when the RPC method is not available', async () => { + const socket = new FakeSocket(); + const rpcListeners = new Map<string, any>(); + + rpcHandler('user-1', socket as any, rpcListeners as any); + + const handler = socket.handlers.get(SOCKET_RPC_EVENTS.CALL); + expect(typeof handler).toBe('function'); + + const callback = vi.fn(); + await handler({ method: 'missing-method', params: {} }, callback); + + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ + ok: false, + error: 'RPC method not available', + errorCode: RPC_ERROR_CODES.METHOD_NOT_AVAILABLE, + }), + ); + }); +}); diff --git a/server/sources/app/api/socket/rpcHandler.ts b/server/sources/app/api/socket/rpcHandler.ts index 9892bcea3..c33f409cc 100644 --- a/server/sources/app/api/socket/rpcHandler.ts +++ b/server/sources/app/api/socket/rpcHandler.ts @@ -1,16 +1,18 @@ import { eventRouter } from "@/app/events/eventRouter"; import { log } from "@/utils/log"; import { Socket } from "socket.io"; +import { RPC_ERROR_CODES } from "@happy/protocol/rpc"; +import { SOCKET_RPC_EVENTS } from "@happy/protocol/socketRpc"; export function rpcHandler(userId: string, socket: Socket, rpcListeners: Map<string, Socket>) { // RPC register - Register this socket as a listener for an RPC method - socket.on('rpc-register', async (data: any) => { + socket.on(SOCKET_RPC_EVENTS.REGISTER, async (data: any) => { try { const { method } = data; if (!method || typeof method !== 'string') { - socket.emit('rpc-error', { type: 'register', error: 'Invalid method name' }); + socket.emit(SOCKET_RPC_EVENTS.ERROR, { type: 'register', error: 'Invalid method name' }); return; } @@ -23,22 +25,22 @@ export function rpcHandler(userId: string, socket: Socket, rpcListeners: Map<str // Register this socket as the listener for this method rpcListeners.set(method, socket); - socket.emit('rpc-registered', { method }); + socket.emit(SOCKET_RPC_EVENTS.REGISTERED, { method }); // log({ module: 'websocket-rpc' }, `RPC method registered: ${method} on socket ${socket.id} (user: ${userId})`); // log({ module: 'websocket-rpc' }, `Active RPC methods for user ${userId}: ${Array.from(rpcListeners.keys()).join(', ')}`); } catch (error) { log({ module: 'websocket', level: 'error' }, `Error in rpc-register: ${error}`); - socket.emit('rpc-error', { type: 'register', error: 'Internal error' }); + socket.emit(SOCKET_RPC_EVENTS.ERROR, { type: 'register', error: 'Internal error' }); } }); // RPC unregister - Remove this socket as a listener for an RPC method - socket.on('rpc-unregister', async (data: any) => { + socket.on(SOCKET_RPC_EVENTS.UNREGISTER, async (data: any) => { try { const { method } = data; if (!method || typeof method !== 'string') { - socket.emit('rpc-error', { type: 'unregister', error: 'Invalid method name' }); + socket.emit(SOCKET_RPC_EVENTS.ERROR, { type: 'unregister', error: 'Invalid method name' }); return; } @@ -56,15 +58,15 @@ export function rpcHandler(userId: string, socket: Socket, rpcListeners: Map<str // log({ module: 'websocket-rpc' }, `RPC unregister ignored: ${method} not registered on socket ${socket.id}`); } - socket.emit('rpc-unregistered', { method }); + socket.emit(SOCKET_RPC_EVENTS.UNREGISTERED, { method }); } catch (error) { log({ module: 'websocket', level: 'error' }, `Error in rpc-unregister: ${error}`); - socket.emit('rpc-error', { type: 'unregister', error: 'Internal error' }); + socket.emit(SOCKET_RPC_EVENTS.ERROR, { type: 'unregister', error: 'Internal error' }); } }); // RPC call - Call an RPC method on another socket of the same user - socket.on('rpc-call', async (data: any, callback: (response: any) => void) => { + socket.on(SOCKET_RPC_EVENTS.CALL, async (data: any, callback: (response: any) => void) => { try { const { method, params } = data; @@ -84,7 +86,10 @@ export function rpcHandler(userId: string, socket: Socket, rpcListeners: Map<str if (callback) { callback({ ok: false, - error: 'RPC method not available' + error: 'RPC method not available', + // Backward compatible: older clients rely on the error string. + // Newer clients should prefer this structured code. + errorCode: RPC_ERROR_CODES.METHOD_NOT_AVAILABLE }); } return; @@ -108,7 +113,7 @@ export function rpcHandler(userId: string, socket: Socket, rpcListeners: Map<str // Forward the RPC request to the target socket using emitWithAck try { - const response = await targetSocket.timeout(30000).emitWithAck('rpc-request', { + const response = await targetSocket.timeout(30000).emitWithAck(SOCKET_RPC_EVENTS.REQUEST, { method, params }); @@ -167,4 +172,4 @@ export function rpcHandler(userId: string, socket: Socket, rpcListeners: Map<str // log({ module: 'websocket-rpc' }, `All RPC listeners removed for user ${userId}`); } }); -} \ No newline at end of file +} diff --git a/server/sources/app/api/socket/sessionUpdateHandler.ts b/server/sources/app/api/socket/sessionUpdateHandler.ts index 4b5cfb16e..5fd616469 100644 --- a/server/sources/app/api/socket/sessionUpdateHandler.ts +++ b/server/sources/app/api/socket/sessionUpdateHandler.ts @@ -1,6 +1,7 @@ import { sessionAliveEventsCounter, websocketEventsCounter } from "@/app/monitoring/metrics2"; import { activityCache } from "@/app/presence/sessionCache"; import { buildNewMessageUpdate, buildSessionActivityEphemeral, buildUpdateSessionUpdate, ClientConnection, eventRouter } from "@/app/events/eventRouter"; +import { checkSessionAccess, requireAccessLevel } from "@/app/share/accessControl"; import { db } from "@/storage/db"; import { allocateSessionSeq, allocateUserSeq } from "@/storage/seq"; import { AsyncLock } from "@/utils/lock"; @@ -9,6 +10,48 @@ import { randomKeyNaked } from "@/utils/randomKeyNaked"; import { Socket } from "socket.io"; export function sessionUpdateHandler(userId: string, socket: Socket, connection: ClientConnection) { + const getSessionParticipantUserIds = async (sessionId: string): Promise<string[]> => { + const session = await db.session.findUnique({ + where: { id: sessionId }, + select: { + accountId: true, + shares: { + select: { + sharedWithUserId: true + } + } + } + }); + if (!session) { + return []; + } + const ids = new Set<string>(); + ids.add(session.accountId); + for (const share of session.shares) { + ids.add(share.sharedWithUserId); + } + return Array.from(ids); + }; + + const emitUpdateToSessionParticipants = async (params: { + sessionId: string; + senderUserId: string; + skipSenderConnection?: ClientConnection; + buildPayload: (updateSeq: number, updateId: string) => any; + }): Promise<void> => { + const participantUserIds = await getSessionParticipantUserIds(params.sessionId); + await Promise.all(participantUserIds.map(async (participantUserId) => { + const updSeq = await allocateUserSeq(participantUserId); + const payload = params.buildPayload(updSeq, randomKeyNaked(12)); + eventRouter.emitUpdate({ + userId: participantUserId, + payload, + recipientFilter: { type: 'all-interested-in-session', sessionId: params.sessionId }, + skipSenderConnection: participantUserId === params.senderUserId ? params.skipSenderConnection : undefined + }); + })); + }; + socket.on('update-metadata', async (data: any, callback: (response: any) => void) => { try { const { sid, metadata, expectedVersion } = data; @@ -22,10 +65,20 @@ export function sessionUpdateHandler(userId: string, socket: Socket, connection: } // Resolve session + const access = await checkSessionAccess(userId, sid); + if (!access || !requireAccessLevel(access, 'edit')) { + if (callback) { + callback({ result: 'forbidden' }); + } + return; + } const session = await db.session.findUnique({ - where: { id: sid, accountId: userId } + where: { id: sid } }); if (!session) { + if (callback) { + callback({ result: 'error' }); + } return; } @@ -49,16 +102,15 @@ export function sessionUpdateHandler(userId: string, socket: Socket, connection: } // Generate session metadata update - const updSeq = await allocateUserSeq(userId); const metadataUpdate = { value: metadata, version: expectedVersion + 1 }; - const updatePayload = buildUpdateSessionUpdate(sid, updSeq, randomKeyNaked(12), metadataUpdate); - eventRouter.emitUpdate({ - userId, - payload: updatePayload, - recipientFilter: { type: 'all-interested-in-session', sessionId: sid } + await emitUpdateToSessionParticipants({ + sessionId: sid, + senderUserId: userId, + skipSenderConnection: connection, + buildPayload: (updSeq, updId) => buildUpdateSessionUpdate(sid, updSeq, updId, metadataUpdate) }); // Send success response with new version via callback @@ -84,15 +136,17 @@ export function sessionUpdateHandler(userId: string, socket: Socket, connection: } // Resolve session + const access = await checkSessionAccess(userId, sid); + if (!access || !requireAccessLevel(access, 'edit')) { + callback({ result: 'forbidden' }); + return; + } const session = await db.session.findUnique({ - where: { - id: sid, - accountId: userId - } + where: { id: sid } }); if (!session) { callback({ result: 'error' }); - return null; + return; } // Check version @@ -115,16 +169,15 @@ export function sessionUpdateHandler(userId: string, socket: Socket, connection: } // Generate session agent state update - const updSeq = await allocateUserSeq(userId); const agentStateUpdate = { value: agentState, version: expectedVersion + 1 }; - const updatePayload = buildUpdateSessionUpdate(sid, updSeq, randomKeyNaked(12), undefined, agentStateUpdate); - eventRouter.emitUpdate({ - userId, - payload: updatePayload, - recipientFilter: { type: 'all-interested-in-session', sessionId: sid } + await emitUpdateToSessionParticipants({ + sessionId: sid, + senderUserId: userId, + skipSenderConnection: connection, + buildPayload: (updSeq, updId) => buildUpdateSessionUpdate(sid, updSeq, updId, undefined, agentStateUpdate) }); // Send success response with new version via callback @@ -168,7 +221,7 @@ export function sessionUpdateHandler(userId: string, socket: Socket, connection: } // Queue database update (will only update if time difference is significant) - activityCache.queueSessionUpdate(sid, t); + activityCache.queueSessionUpdate(sid, userId, t); // Emit session activity update const sessionActivity = buildSessionActivityEphemeral(sid, true, t, thinking || false); @@ -192,8 +245,12 @@ export function sessionUpdateHandler(userId: string, socket: Socket, connection: log({ module: 'websocket' }, `Received message from socket ${socket.id}: sessionId=${sid}, messageLength=${message.length} bytes, connectionType=${connection.connectionType}, connectionSessionId=${connection.connectionType === 'session-scoped' ? connection.sessionId : 'N/A'}`); // Resolve session + const access = await checkSessionAccess(userId, sid); + if (!access || !requireAccessLevel(access, 'edit')) { + return; + } const session = await db.session.findUnique({ - where: { id: sid, accountId: userId } + where: { id: sid } }); if (!session) { return; @@ -207,7 +264,6 @@ export function sessionUpdateHandler(userId: string, socket: Socket, connection: }; // Resolve seq - const updSeq = await allocateUserSeq(userId); const msgSeq = await allocateSessionSeq(sid); // Check if message already exists @@ -231,12 +287,11 @@ export function sessionUpdateHandler(userId: string, socket: Socket, connection: }); // Emit new message update to relevant clients - const updatePayload = buildNewMessageUpdate(msg, sid, updSeq, randomKeyNaked(12)); - eventRouter.emitUpdate({ - userId, - payload: updatePayload, - recipientFilter: { type: 'all-interested-in-session', sessionId: sid }, - skipSenderConnection: connection + await emitUpdateToSessionParticipants({ + sessionId: sid, + senderUserId: userId, + skipSenderConnection: connection, + buildPayload: (updSeq, updId) => buildNewMessageUpdate(msg, sid, updSeq, updId) }); } catch (error) { log({ module: 'websocket', level: 'error' }, `Error in message handler: ${error}`); @@ -287,4 +342,4 @@ export function sessionUpdateHandler(userId: string, socket: Socket, connection: } }); -} \ No newline at end of file +} diff --git a/server/sources/app/api/uiConfig.spec.ts b/server/sources/app/api/uiConfig.spec.ts new file mode 100644 index 000000000..4438fd985 --- /dev/null +++ b/server/sources/app/api/uiConfig.spec.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; +import { resolveUiConfig } from './uiConfig'; + +describe('resolveUiConfig', () => { + it('returns null dir when UI is not configured', () => { + const cfg = resolveUiConfig({}); + expect(cfg.dir).toBeNull(); + }); + + it('uses HAPPY_SERVER_UI_DIR and defaults prefix to /', () => { + const cfg = resolveUiConfig({ HAPPY_SERVER_UI_DIR: '/tmp/ui' }); + expect(cfg.dir).toBe('/tmp/ui'); + expect(cfg.mountRoot).toBe(true); + expect(cfg.prefix).toBe('/'); + }); + + it('normalizes a non-root prefix by stripping trailing slash', () => { + const cfg = resolveUiConfig({ HAPPY_SERVER_UI_DIR: '/tmp/ui', HAPPY_SERVER_UI_PREFIX: '/ui/' }); + expect(cfg.mountRoot).toBe(false); + expect(cfg.prefix).toBe('/ui'); + }); + + it('supports legacy HAPPY_SERVER_LIGHT_UI_* env vars', () => { + const cfg = resolveUiConfig({ HAPPY_SERVER_LIGHT_UI_DIR: '/tmp/ui', HAPPY_SERVER_LIGHT_UI_PREFIX: '/ui' }); + expect(cfg.dir).toBe('/tmp/ui'); + expect(cfg.mountRoot).toBe(false); + expect(cfg.prefix).toBe('/ui'); + }); +}); + diff --git a/server/sources/app/api/uiConfig.ts b/server/sources/app/api/uiConfig.ts new file mode 100644 index 000000000..182158325 --- /dev/null +++ b/server/sources/app/api/uiConfig.ts @@ -0,0 +1,26 @@ +export type UiConfig = { + dir: string | null; + /** + * UI mount prefix for route registration (no trailing slash). + * - "/" means "mounted at root" + * - "/ui" means "mounted under /ui" + */ + prefix: string; + mountRoot: boolean; +}; + +export function resolveUiConfig(env: NodeJS.ProcessEnv = process.env): UiConfig { + const dirRaw = env.HAPPY_SERVER_UI_DIR ?? env.HAPPY_SERVER_LIGHT_UI_DIR; + const dir = typeof dirRaw === 'string' && dirRaw.trim() ? dirRaw.trim() : null; + + const prefixRaw = env.HAPPY_SERVER_UI_PREFIX ?? env.HAPPY_SERVER_LIGHT_UI_PREFIX; + const prefixNormalized = typeof prefixRaw === 'string' && prefixRaw.trim() ? prefixRaw.trim() : '/'; + const mountRoot = prefixNormalized === '/' || prefixNormalized === ''; + const prefix = mountRoot + ? '/' + : prefixNormalized.endsWith('/') + ? prefixNormalized.slice(0, -1) + : prefixNormalized; + + return { dir, prefix, mountRoot }; +} diff --git a/server/sources/app/api/utils/enableErrorHandlers.spec.ts b/server/sources/app/api/utils/enableErrorHandlers.spec.ts new file mode 100644 index 000000000..b58451d96 --- /dev/null +++ b/server/sources/app/api/utils/enableErrorHandlers.spec.ts @@ -0,0 +1,33 @@ +import Fastify from 'fastify'; +import { describe, expect, it } from 'vitest'; +import { mkdtemp } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { enableErrorHandlers } from './enableErrorHandlers'; + +describe('enableErrorHandlers', () => { + it('responds 404 when UI index.html is missing (instead of 500)', async () => { + const dir = await mkdtemp(join(tmpdir(), 'happy-ui-missing-')); + + const prevUiDir = process.env.HAPPY_SERVER_UI_DIR; + const prevUiPrefix = process.env.HAPPY_SERVER_UI_PREFIX; + process.env.HAPPY_SERVER_UI_DIR = dir; + process.env.HAPPY_SERVER_UI_PREFIX = '/'; + + try { + const app = Fastify(); + enableErrorHandlers(app as any); + await app.ready(); + + const res = await app.inject({ method: 'GET', url: '/' }); + expect(res.statusCode).toBe(404); + } finally { + if (typeof prevUiDir === 'string') process.env.HAPPY_SERVER_UI_DIR = prevUiDir; + else delete process.env.HAPPY_SERVER_UI_DIR; + + if (typeof prevUiPrefix === 'string') process.env.HAPPY_SERVER_UI_PREFIX = prevUiPrefix; + else delete process.env.HAPPY_SERVER_UI_PREFIX; + } + }); +}); + diff --git a/server/sources/app/api/utils/enableErrorHandlers.ts b/server/sources/app/api/utils/enableErrorHandlers.ts index cf941f499..f3d3827fa 100644 --- a/server/sources/app/api/utils/enableErrorHandlers.ts +++ b/server/sources/app/api/utils/enableErrorHandlers.ts @@ -1,5 +1,8 @@ import { log } from "@/utils/log"; import { Fastify } from "../types"; +import { readFile, stat } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { resolveUiConfig } from "@/app/api/uiConfig"; export function enableErrorHandlers(app: Fastify) { // Global error handler @@ -42,10 +45,71 @@ export function enableErrorHandlers(app: Fastify) { } }); - // Catch-all route for debugging 404s - app.setNotFoundHandler((request, reply) => { + const ui = resolveUiConfig(process.env); + const uiDirRaw = ui.dir ?? ''; + const uiMountedAtRoot = ui.mountRoot; + + let cachedIndexHtml: { html: string; mtimeMs: number } | null = null; + const rootDir = uiDirRaw ? resolve(uiDirRaw) : ''; + + async function serveSpaIndex(reply: any): Promise<any> { + if (!uiDirRaw) { + reply.header('cache-control', 'no-cache'); + return reply.code(404).send({ error: 'Not found' }); + } + + const indexPath = join(rootDir, 'index.html'); + try { + const st = await stat(indexPath); + const mtimeMs = typeof st.mtimeMs === 'number' ? st.mtimeMs : st.mtime.getTime(); + if (!cachedIndexHtml || cachedIndexHtml.mtimeMs !== mtimeMs) { + cachedIndexHtml = { + html: (await readFile(indexPath, 'utf-8')) + '\n<!-- Welcome to Happy Server! -->\n', + mtimeMs, + }; + } + } catch (err: any) { + if (err?.code === 'ENOENT' || err?.code === 'ENOTDIR') { + reply.header('cache-control', 'no-cache'); + return reply.code(404).send({ error: 'Not found' }); + } + throw err; + } + reply.header('content-type', 'text/html; charset=utf-8'); + reply.header('cache-control', 'no-cache'); + return reply.send(cachedIndexHtml.html); + } + + // Catch-all route: in UI-root mode, SPA fallback for unknown GET routes. + // Otherwise keep strict 404 with extra logging. + app.setNotFoundHandler(async (request, reply) => { + const url = request.url || ''; + + if (uiDirRaw && uiMountedAtRoot && request.method === 'GET') { + // Don't SPA-fallback for API and asset paths. + if ( + url.startsWith('/v1/') || + url === '/v1' || + url.startsWith('/files/') || + url === '/files' || + url.startsWith('/_expo/') || + url.startsWith('/assets/') || + url.startsWith('/.well-known/') || + url === '/favicon.ico' || + url === '/favicon-active.ico' || + url === '/canvaskit.wasm' || + url === '/metadata.json' || + url === '/health' || + url.startsWith('/metrics') + ) { + // Fall through to 404 logging below + } else { + return await serveSpaIndex(reply); + } + } + log({ module: '404-handler' }, `404 - Method: ${request.method}, Path: ${request.url}, Headers: ${JSON.stringify(request.headers)}`); - reply.code(404).send({ error: 'Not found', path: request.url, method: request.method }); + return reply.code(404).send({ error: 'Not found', path: request.url, method: request.method }); }); // Error hook for additional logging @@ -85,4 +149,4 @@ export function enableErrorHandlers(app: Fastify) { } }; }); -} \ No newline at end of file +} diff --git a/server/sources/app/api/utils/enableOptionalStatics.ts b/server/sources/app/api/utils/enableOptionalStatics.ts new file mode 100644 index 000000000..1431d6ded --- /dev/null +++ b/server/sources/app/api/utils/enableOptionalStatics.ts @@ -0,0 +1,23 @@ +import type { FastifyInstance } from "fastify"; +import { resolveUiConfig } from "@/app/api/uiConfig"; +import { enableServeUi } from "./enableServeUi"; +import { enablePublicFiles } from "./enablePublicFiles"; + +type AnyFastifyInstance = FastifyInstance<any, any, any, any, any>; + +export function enableOptionalStatics(app: AnyFastifyInstance) { + // Optional: serve a prebuilt web UI bundle (static directory). + const ui = resolveUiConfig(process.env); + const { dir: uiDir, mountRoot } = ui; + if (!uiDir || !mountRoot) { + app.get('/', function (_request, reply) { + reply.send('Welcome to Happy Server!'); + }); + } + + enableServeUi(app, ui); + + // Local file serving for the light flavor (avatars/images/etc). + // Enabled only when the selected files backend supports public reads. + enablePublicFiles(app); +} diff --git a/server/sources/app/api/utils/enablePublicFiles.ts b/server/sources/app/api/utils/enablePublicFiles.ts new file mode 100644 index 000000000..cef4c9d4d --- /dev/null +++ b/server/sources/app/api/utils/enablePublicFiles.ts @@ -0,0 +1,39 @@ +import type { FastifyInstance } from "fastify"; +import { extname } from "node:path"; +import { hasPublicFileRead, readPublicFile } from "@/storage/files"; +import { normalizePublicPath } from "@/flavors/light/files"; + +type AnyFastifyInstance = FastifyInstance<any, any, any, any, any>; + +export function enablePublicFiles(app: AnyFastifyInstance) { + if (!hasPublicFileRead()) { + return; + } + + app.get('/files/*', async (request, reply) => { + try { + const raw = (request.params as { '*': string | undefined })['*'] || ''; + const decoded = decodeURIComponent(raw); + const path = normalizePublicPath(decoded); + const bytes = await readPublicFile(path); + + const ext = extname(path).toLowerCase(); + if (ext === '.png') { + reply.header('content-type', 'image/png'); + } else if (ext === '.jpg' || ext === '.jpeg') { + reply.header('content-type', 'image/jpeg'); + } else if (ext === '.webp') { + reply.header('content-type', 'image/webp'); + } else if (ext === '.gif') { + reply.header('content-type', 'image/gif'); + } else { + reply.header('content-type', 'application/octet-stream'); + } + + reply.header('cache-control', 'public, max-age=31536000, immutable'); + return reply.send(Buffer.from(bytes)); + } catch { + return reply.code(404).send({ error: 'Not found' }); + } + }); +} diff --git a/server/sources/app/api/utils/enableServeUi.spec.ts b/server/sources/app/api/utils/enableServeUi.spec.ts new file mode 100644 index 000000000..5ed33b239 --- /dev/null +++ b/server/sources/app/api/utils/enableServeUi.spec.ts @@ -0,0 +1,20 @@ +import Fastify from 'fastify'; +import { describe, expect, it } from 'vitest'; +import { mkdtemp } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { enableServeUi } from './enableServeUi'; + +describe('enableServeUi', () => { + it('responds 404 when index.html is missing (instead of throwing)', async () => { + const dir = await mkdtemp(join(tmpdir(), 'happy-ui-missing-')); + const app = Fastify(); + + enableServeUi(app as any, { dir, prefix: '/', mountRoot: true }); + await app.ready(); + + const res = await app.inject({ method: 'GET', url: '/' }); + expect(res.statusCode).toBe(404); + expect(res.headers['cache-control']).toBe('no-cache'); + }); +}); diff --git a/server/sources/app/api/utils/enableServeUi.ts b/server/sources/app/api/utils/enableServeUi.ts new file mode 100644 index 000000000..203b0ee3e --- /dev/null +++ b/server/sources/app/api/utils/enableServeUi.ts @@ -0,0 +1,192 @@ +import type { FastifyInstance } from "fastify"; +import type { UiConfig } from "@/app/api/uiConfig"; +import { extname, resolve, sep } from "node:path"; +import { readFile, stat } from "node:fs/promises"; +import { warn } from "@/utils/log"; + +type AnyFastifyInstance = FastifyInstance<any, any, any, any, any>; + +export function enableServeUi(app: AnyFastifyInstance, ui: UiConfig) { + const uiDir = ui.dir; + if (!uiDir) { + return; + } + + const root = resolve(uiDir); + + async function sendUiFile(relPath: string, reply: any) { + const candidate = resolve(root, relPath); + if (!(candidate === root || candidate.startsWith(root + sep))) { + return reply.code(404).send({ error: 'Not found' }); + } + + const bytes = await readFile(candidate); + const ext = extname(candidate).toLowerCase(); + + if (ext === '.html') { + reply.header('content-type', 'text/html; charset=utf-8'); + reply.header('cache-control', 'no-cache'); + } else if (ext === '.js') { + reply.header('content-type', 'text/javascript; charset=utf-8'); + reply.header('cache-control', 'public, max-age=31536000, immutable'); + } else if (ext === '.css') { + reply.header('content-type', 'text/css; charset=utf-8'); + reply.header('cache-control', 'public, max-age=31536000, immutable'); + } else if (ext === '.json') { + reply.header('content-type', 'application/json; charset=utf-8'); + reply.header('cache-control', 'public, max-age=31536000, immutable'); + } else if (ext === '.svg') { + reply.header('content-type', 'image/svg+xml'); + reply.header('cache-control', 'public, max-age=31536000, immutable'); + } else if (ext === '.ico') { + reply.header('content-type', 'image/x-icon'); + reply.header('cache-control', 'public, max-age=31536000, immutable'); + } else if (ext === '.wasm') { + reply.header('content-type', 'application/wasm'); + reply.header('cache-control', 'public, max-age=31536000, immutable'); + } else if (ext === '.ttf') { + reply.header('content-type', 'font/ttf'); + reply.header('cache-control', 'public, max-age=31536000, immutable'); + } else if (ext === '.woff') { + reply.header('content-type', 'font/woff'); + reply.header('cache-control', 'public, max-age=31536000, immutable'); + } else if (ext === '.woff2') { + reply.header('content-type', 'font/woff2'); + reply.header('cache-control', 'public, max-age=31536000, immutable'); + } else if (ext === '.png') { + reply.header('content-type', 'image/png'); + reply.header('cache-control', 'public, max-age=31536000, immutable'); + } else if (ext === '.jpg' || ext === '.jpeg') { + reply.header('content-type', 'image/jpeg'); + reply.header('cache-control', 'public, max-age=31536000, immutable'); + } else if (ext === '.webp') { + reply.header('content-type', 'image/webp'); + reply.header('cache-control', 'public, max-age=31536000, immutable'); + } else if (ext === '.gif') { + reply.header('content-type', 'image/gif'); + reply.header('cache-control', 'public, max-age=31536000, immutable'); + } else { + reply.header('content-type', 'application/octet-stream'); + reply.header('cache-control', 'public, max-age=31536000, immutable'); + } + + return reply.send(Buffer.from(bytes)); + } + + async function sendIndexHtml(reply: any) { + const indexPath = resolve(root, 'index.html'); + let html: string; + try { + html = (await readFile(indexPath, 'utf-8')) + '\n<!-- Welcome to Happy Server! -->\n'; + } catch (err) { + warn({ err, indexPath }, 'UI index.html not found (check UI build dir configuration)'); + reply.header('cache-control', 'no-cache'); + return reply.code(404).send({ error: 'Not found' }); + } + reply.header('content-type', 'text/html; charset=utf-8'); + reply.header('cache-control', 'no-cache'); + return reply.send(html); + } + + if (ui.mountRoot) { + app.get('/', async (_request, reply) => await sendIndexHtml(reply)); + app.get('/ui', async (_request, reply) => reply.redirect('/', 302)); + app.get('/ui/', async (_request, reply) => reply.redirect('/', 302)); + app.get('/ui/*', async (request, reply) => { + const raw = (request.params as { '*': string | undefined })['*'] || ''; + const decoded = decodeURIComponent(raw).replace(/^\/+/, ''); + return reply.redirect(`/${decoded}`, 302); + }); + } else { + const prefix = ui.prefix; + app.get(prefix, async (_request, reply) => reply.redirect(`${prefix}/`, 302)); + app.get(`${prefix}/*`, async (request, reply) => { + try { + const raw = (request.params as { '*': string | undefined })['*'] || ''; + const decoded = decodeURIComponent(raw); + const rel = decoded.replace(/^\/+/, ''); + + const candidate = resolve(root, rel || 'index.html'); + if (!(candidate === root || candidate.startsWith(root + sep))) { + return reply.code(404).send({ error: 'Not found' }); + } + + let filePath = candidate; + try { + const st = await stat(filePath); + if (st.isDirectory()) { + filePath = resolve(root, 'index.html'); + } + } catch { + filePath = resolve(root, 'index.html'); + } + + const relPath = filePath.slice(root.length + 1); + if (relPath === 'index.html') { + return await sendIndexHtml(reply); + } + return await sendUiFile(relPath, reply); + } catch { + return reply.code(404).send({ error: 'Not found' }); + } + }); + } + + // Expo export (metro) emits absolute URLs like `/_expo/...` and `/favicon.ico` even when served from a subpath. + // To keep `/ui` working without rewriting builds, also serve these static assets from the root. + app.get('/_expo/*', async (request, reply) => { + try { + const raw = (request.params as { '*': string | undefined })['*'] || ''; + const decoded = decodeURIComponent(raw).replace(/^\/+/, ''); + return await sendUiFile(`_expo/${decoded}`, reply); + } catch { + return reply.code(404).send({ error: 'Not found' }); + } + }); + app.get('/assets/*', async (request, reply) => { + try { + const raw = (request.params as { '*': string | undefined })['*'] || ''; + const decoded = decodeURIComponent(raw).replace(/^\/+/, ''); + return await sendUiFile(`assets/${decoded}`, reply); + } catch { + return reply.code(404).send({ error: 'Not found' }); + } + }); + app.get('/.well-known/*', async (request, reply) => { + try { + const raw = (request.params as { '*': string | undefined })['*'] || ''; + const decoded = decodeURIComponent(raw).replace(/^\/+/, ''); + return await sendUiFile(`.well-known/${decoded}`, reply); + } catch { + return reply.code(404).send({ error: 'Not found' }); + } + }); + app.get('/favicon.ico', async (_request, reply) => { + try { + return await sendUiFile('favicon.ico', reply); + } catch { + return reply.code(404).send({ error: 'Not found' }); + } + }); + app.get('/favicon-active.ico', async (_request, reply) => { + try { + return await sendUiFile('favicon-active.ico', reply); + } catch { + return reply.code(404).send({ error: 'Not found' }); + } + }); + app.get('/canvaskit.wasm', async (_request, reply) => { + try { + return await sendUiFile('canvaskit.wasm', reply); + } catch { + return reply.code(404).send({ error: 'Not found' }); + } + }); + app.get('/metadata.json', async (_request, reply) => { + try { + return await sendUiFile('metadata.json', reply); + } catch { + return reply.code(404).send({ error: 'Not found' }); + } + }); +} diff --git a/server/sources/app/events/eventRouter.ts b/server/sources/app/events/eventRouter.ts index 6ba61fe8f..2971b291e 100644 --- a/server/sources/app/events/eventRouter.ts +++ b/server/sources/app/events/eventRouter.ts @@ -152,6 +152,50 @@ export type UpdateEvent = { value: string | null; // null indicates deletion version: number; // -1 for deleted keys }>; +} | { + type: 'session-shared'; + sessionId: string; + shareId: string; + sharedBy: { + id: string; + firstName: string | null; + lastName: string | null; + username: string | null; + avatar: any | null; + }; + accessLevel: 'view' | 'edit' | 'admin'; + encryptedDataKey: string; + createdAt: number; +} | { + type: 'session-share-updated'; + sessionId: string; + shareId: string; + accessLevel: 'view' | 'edit' | 'admin'; + updatedAt: number; +} | { + type: 'session-share-revoked'; + sessionId: string; + shareId: string; +} | { + type: 'public-share-created'; + sessionId: string; + publicShareId: string; + token: string; + expiresAt: number | null; + maxUses: number | null; + isConsentRequired: boolean; + createdAt: number; +} | { + type: 'public-share-updated'; + sessionId: string; + publicShareId: string; + expiresAt: number | null; + maxUses: number | null; + isConsentRequired: boolean; + updatedAt: number; +} | { + type: 'public-share-deleted'; + sessionId: string; }; // === EPHEMERAL EVENT TYPES (Transient) === @@ -631,3 +675,139 @@ export function buildKVBatchUpdateUpdate( createdAt: Date.now() }; } + +export function buildSessionSharedUpdate(share: { + id: string; + sessionId: string; + sharedByUser: { + id: string; + firstName: string | null; + lastName: string | null; + username: string | null; + avatar: any | null; + }; + accessLevel: 'view' | 'edit' | 'admin'; + encryptedDataKey: Uint8Array; + createdAt: Date; +}, updateSeq: number, updateId: string): UpdatePayload { + return { + id: updateId, + seq: updateSeq, + body: { + t: 'session-shared', + sessionId: share.sessionId, + shareId: share.id, + sharedBy: share.sharedByUser, + accessLevel: share.accessLevel, + encryptedDataKey: Buffer.from(share.encryptedDataKey).toString('base64'), + createdAt: share.createdAt.getTime() + }, + createdAt: Date.now() + }; +} + +export function buildSessionShareUpdatedUpdate( + shareId: string, + sessionId: string, + accessLevel: 'view' | 'edit' | 'admin', + updatedAt: Date, + updateSeq: number, + updateId: string +): UpdatePayload { + return { + id: updateId, + seq: updateSeq, + body: { + t: 'session-share-updated', + sessionId, + shareId, + accessLevel, + updatedAt: updatedAt.getTime() + }, + createdAt: Date.now() + }; +} + +export function buildSessionShareRevokedUpdate( + shareId: string, + sessionId: string, + updateSeq: number, + updateId: string +): UpdatePayload { + return { + id: updateId, + seq: updateSeq, + body: { + t: 'session-share-revoked', + sessionId, + shareId + }, + createdAt: Date.now() + }; +} + +export function buildPublicShareCreatedUpdate(publicShare: { + id: string; + sessionId: string; + token: string; + expiresAt: Date | null; + maxUses: number | null; + isConsentRequired: boolean; + createdAt: Date; +}, updateSeq: number, updateId: string): UpdatePayload { + return { + id: updateId, + seq: updateSeq, + body: { + t: 'public-share-created', + sessionId: publicShare.sessionId, + publicShareId: publicShare.id, + token: publicShare.token, + expiresAt: publicShare.expiresAt?.getTime() ?? null, + maxUses: publicShare.maxUses, + isConsentRequired: publicShare.isConsentRequired, + createdAt: publicShare.createdAt.getTime() + }, + createdAt: Date.now() + }; +} + +export function buildPublicShareUpdatedUpdate(publicShare: { + id: string; + sessionId: string; + expiresAt: Date | null; + maxUses: number | null; + isConsentRequired: boolean; + updatedAt: Date; +}, updateSeq: number, updateId: string): UpdatePayload { + return { + id: updateId, + seq: updateSeq, + body: { + t: 'public-share-updated', + sessionId: publicShare.sessionId, + publicShareId: publicShare.id, + expiresAt: publicShare.expiresAt?.getTime() ?? null, + maxUses: publicShare.maxUses, + isConsentRequired: publicShare.isConsentRequired, + updatedAt: publicShare.updatedAt.getTime() + }, + createdAt: Date.now() + }; +} + +export function buildPublicShareDeletedUpdate( + sessionId: string, + updateSeq: number, + updateId: string +): UpdatePayload { + return { + id: updateId, + seq: updateSeq, + body: { + t: 'public-share-deleted', + sessionId + }, + createdAt: Date.now() + }; +} diff --git a/server/sources/app/events/sharingEvents.spec.ts b/server/sources/app/events/sharingEvents.spec.ts new file mode 100644 index 000000000..e5c83709d --- /dev/null +++ b/server/sources/app/events/sharingEvents.spec.ts @@ -0,0 +1,189 @@ +import { describe, it, expect } from 'vitest'; +import { + buildSessionSharedUpdate, + buildSessionShareUpdatedUpdate, + buildSessionShareRevokedUpdate, + buildPublicShareCreatedUpdate, + buildPublicShareUpdatedUpdate, + buildPublicShareDeletedUpdate +} from './eventRouter'; + +describe('Sharing Event Builders', () => { + describe('buildSessionSharedUpdate', () => { + it('should build session-shared update event', () => { + const share = { + id: 'share-1', + sessionId: 'session-1', + sharedByUser: { + id: 'user-owner', + firstName: 'John', + lastName: 'Doe', + username: 'johndoe', + avatar: null + }, + accessLevel: 'view' as const, + encryptedDataKey: new Uint8Array([1, 2, 3, 4]), + createdAt: new Date('2025-01-09T12:00:00Z') + }; + + const result = buildSessionSharedUpdate(share, 100, 'update-id-1'); + + expect(result).toMatchObject({ + id: 'update-id-1', + seq: 100, + body: { + t: 'session-shared', + sessionId: 'session-1', + shareId: 'share-1', + sharedBy: share.sharedByUser, + accessLevel: 'view', + encryptedDataKey: expect.any(String), + createdAt: share.createdAt.getTime() + } + }); + expect(result.createdAt).toBeGreaterThan(0); + }); + }); + + describe('buildSessionShareUpdatedUpdate', () => { + it('should build session-share-updated event', () => { + const updatedAt = new Date('2025-01-09T13:00:00Z'); + const result = buildSessionShareUpdatedUpdate( + 'share-1', + 'session-1', + 'edit', + updatedAt, + 101, + 'update-id-2' + ); + + expect(result).toMatchObject({ + id: 'update-id-2', + seq: 101, + body: { + t: 'session-share-updated', + sessionId: 'session-1', + shareId: 'share-1', + accessLevel: 'edit', + updatedAt: updatedAt.getTime() + } + }); + }); + }); + + describe('buildSessionShareRevokedUpdate', () => { + it('should build session-share-revoked event', () => { + const result = buildSessionShareRevokedUpdate( + 'share-1', + 'session-1', + 102, + 'update-id-3' + ); + + expect(result).toMatchObject({ + id: 'update-id-3', + seq: 102, + body: { + t: 'session-share-revoked', + sessionId: 'session-1', + shareId: 'share-1' + } + }); + }); + }); + + describe('buildPublicShareCreatedUpdate', () => { + it('should build public-share-created event with all fields', () => { + const publicShare = { + id: 'public-1', + sessionId: 'session-1', + token: 'abc123', + expiresAt: new Date('2025-02-09T12:00:00Z'), + maxUses: 100, + isConsentRequired: true, + createdAt: new Date('2025-01-09T12:00:00Z') + }; + + const result = buildPublicShareCreatedUpdate(publicShare, 103, 'update-id-4'); + + expect(result).toMatchObject({ + id: 'update-id-4', + seq: 103, + body: { + t: 'public-share-created', + sessionId: 'session-1', + publicShareId: 'public-1', + token: 'abc123', + expiresAt: publicShare.expiresAt.getTime(), + maxUses: 100, + isConsentRequired: true, + createdAt: publicShare.createdAt.getTime() + } + }); + }); + + it('should handle null expiration and max uses', () => { + const publicShare = { + id: 'public-2', + sessionId: 'session-2', + token: 'xyz789', + expiresAt: null, + maxUses: null, + isConsentRequired: false, + createdAt: new Date('2025-01-09T12:00:00Z') + }; + + const result = buildPublicShareCreatedUpdate(publicShare, 104, 'update-id-5'); + + expect(result.body).toMatchObject({ + expiresAt: null, + maxUses: null, + isConsentRequired: false + }); + }); + }); + + describe('buildPublicShareUpdatedUpdate', () => { + it('should build public-share-updated event', () => { + const publicShare = { + id: 'public-1', + sessionId: 'session-1', + expiresAt: new Date('2025-02-10T12:00:00Z'), + maxUses: 200, + isConsentRequired: false, + updatedAt: new Date('2025-01-09T14:00:00Z') + }; + + const result = buildPublicShareUpdatedUpdate(publicShare, 105, 'update-id-6'); + + expect(result).toMatchObject({ + id: 'update-id-6', + seq: 105, + body: { + t: 'public-share-updated', + sessionId: 'session-1', + publicShareId: 'public-1', + expiresAt: publicShare.expiresAt.getTime(), + maxUses: 200, + isConsentRequired: false, + updatedAt: publicShare.updatedAt.getTime() + } + }); + }); + }); + + describe('buildPublicShareDeletedUpdate', () => { + it('should build public-share-deleted event', () => { + const result = buildPublicShareDeletedUpdate('session-1', 106, 'update-id-7'); + + expect(result).toMatchObject({ + id: 'update-id-7', + seq: 106, + body: { + t: 'public-share-deleted', + sessionId: 'session-1' + } + }); + }); + }); +}); diff --git a/server/sources/app/feed/feedGet.ts b/server/sources/app/feed/feedGet.ts index a5fbc04a6..1fa2d4d42 100644 --- a/server/sources/app/feed/feedGet.ts +++ b/server/sources/app/feed/feedGet.ts @@ -1,6 +1,6 @@ import { Context } from "@/context"; import { FeedOptions, FeedResult } from "./types"; -import { Prisma } from "@prisma/client"; +import type { Prisma } from "@prisma/client"; import { Tx } from "@/storage/inTx"; /** @@ -52,4 +52,4 @@ export async function feedGet( })), hasMore }; -} \ No newline at end of file +} diff --git a/server/sources/app/presence/sessionCache.ts b/server/sources/app/presence/sessionCache.ts index c37286019..e1b78c958 100644 --- a/server/sources/app/presence/sessionCache.ts +++ b/server/sources/app/presence/sessionCache.ts @@ -1,12 +1,14 @@ import { db } from "@/storage/db"; import { log } from "@/utils/log"; import { sessionCacheCounter, databaseUpdatesSkippedCounter } from "@/app/monitoring/metrics2"; +import { checkSessionAccess } from "@/app/share/accessControl"; interface SessionCacheEntry { validUntil: number; lastUpdateSent: number; pendingUpdate: number | null; userId: string; + sessionId: string; } interface MachineCacheEntry { @@ -48,10 +50,11 @@ class ActivityCache { async isSessionValid(sessionId: string, userId: string): Promise<boolean> { const now = Date.now(); - const cached = this.sessionCache.get(sessionId); + const cacheKey = `${sessionId}:${userId}`; + const cached = this.sessionCache.get(cacheKey); // Check cache first - if (cached && cached.validUntil > now && cached.userId === userId) { + if (cached && cached.validUntil > now) { sessionCacheCounter.inc({ operation: 'session_validation', result: 'hit' }); return true; } @@ -60,17 +63,16 @@ class ActivityCache { // Cache miss - check database try { - const session = await db.session.findUnique({ - where: { id: sessionId, accountId: userId } - }); + const access = await checkSessionAccess(userId, sessionId); - if (session) { + if (access) { // Cache the result - this.sessionCache.set(sessionId, { + this.sessionCache.set(cacheKey, { validUntil: now + this.CACHE_TTL, - lastUpdateSent: session.lastActiveAt.getTime(), + lastUpdateSent: now, pendingUpdate: null, - userId + userId, + sessionId }); return true; } @@ -123,8 +125,9 @@ class ActivityCache { } } - queueSessionUpdate(sessionId: string, timestamp: number): boolean { - const cached = this.sessionCache.get(sessionId); + queueSessionUpdate(sessionId: string, userId: string, timestamp: number): boolean { + const cacheKey = `${sessionId}:${userId}`; + const cached = this.sessionCache.get(cacheKey); if (!cached) { return false; // Should validate first } @@ -158,13 +161,13 @@ class ActivityCache { } private async flushPendingUpdates(): Promise<void> { - const sessionUpdates: { id: string, timestamp: number }[] = []; + const sessionUpdatesById = new Map<string, number>(); const machineUpdates: { id: string, timestamp: number, userId: string }[] = []; // Collect session updates - for (const [sessionId, entry] of this.sessionCache.entries()) { + for (const entry of this.sessionCache.values()) { if (entry.pendingUpdate) { - sessionUpdates.push({ id: sessionId, timestamp: entry.pendingUpdate }); + sessionUpdatesById.set(entry.sessionId, Math.max(sessionUpdatesById.get(entry.sessionId) ?? 0, entry.pendingUpdate)); entry.lastUpdateSent = entry.pendingUpdate; entry.pendingUpdate = null; } @@ -184,16 +187,16 @@ class ActivityCache { } // Batch update sessions - if (sessionUpdates.length > 0) { + if (sessionUpdatesById.size > 0) { try { - await Promise.all(sessionUpdates.map(update => + await Promise.all(Array.from(sessionUpdatesById.entries()).map(([sessionId, timestamp]) => db.session.update({ - where: { id: update.id }, - data: { lastActiveAt: new Date(update.timestamp), active: true } + where: { id: sessionId }, + data: { lastActiveAt: new Date(timestamp), active: true } }) )); - log({ module: 'session-cache' }, `Flushed ${sessionUpdates.length} session updates`); + log({ module: 'session-cache' }, `Flushed ${sessionUpdatesById.size} session updates`); } catch (error) { log({ module: 'session-cache', level: 'error' }, `Error updating sessions: ${error}`); } @@ -257,4 +260,4 @@ export const activityCache = new ActivityCache(); // Cleanup every 5 minutes setInterval(() => { activityCache.cleanup(); -}, 5 * 60 * 1000); \ No newline at end of file +}, 5 * 60 * 1000); diff --git a/server/sources/app/presence/timeout.ts b/server/sources/app/presence/timeout.ts index 5908e1193..db7e58913 100644 --- a/server/sources/app/presence/timeout.ts +++ b/server/sources/app/presence/timeout.ts @@ -17,16 +17,16 @@ export function startTimeout() { } }); for (const session of sessions) { - const updated = await db.session.updateManyAndReturn({ + const { count } = await db.session.updateMany({ where: { id: session.id, active: true }, data: { active: false } }); - if (updated.length === 0) { + if (count === 0) { continue; } eventRouter.emitEphemeral({ userId: session.accountId, - payload: buildSessionActivityEphemeral(session.id, false, updated[0].lastActiveAt.getTime(), false), + payload: buildSessionActivityEphemeral(session.id, false, session.lastActiveAt.getTime(), false), recipientFilter: { type: 'user-scoped-only' } }); } @@ -41,16 +41,16 @@ export function startTimeout() { } }); for (const machine of machines) { - const updated = await db.machine.updateManyAndReturn({ + const { count } = await db.machine.updateMany({ where: { id: machine.id, active: true }, data: { active: false } }); - if (updated.length === 0) { + if (count === 0) { continue; } eventRouter.emitEphemeral({ userId: machine.accountId, - payload: buildMachineActivityEphemeral(machine.id, false, updated[0].lastActiveAt.getTime()), + payload: buildMachineActivityEphemeral(machine.id, false, machine.lastActiveAt.getTime()), recipientFilter: { type: 'user-scoped-only' } }); } @@ -59,4 +59,4 @@ export function startTimeout() { await delay(1000 * 60, shutdownSignal); } }); -} \ No newline at end of file +} diff --git a/server/sources/app/share/accessControl.spec.ts b/server/sources/app/share/accessControl.spec.ts new file mode 100644 index 000000000..a091dd88c --- /dev/null +++ b/server/sources/app/share/accessControl.spec.ts @@ -0,0 +1,295 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { checkSessionAccess, checkPublicShareAccess, isSessionOwner, canManageSharing, areFriends } from './accessControl'; +import { db } from '@/storage/db'; + +vi.mock('@/storage/db', () => ({ + db: { + session: { + findUnique: vi.fn() + }, + sessionShare: { + findUnique: vi.fn() + }, + publicSessionShare: { + findUnique: vi.fn() + }, + userRelationship: { + findFirst: vi.fn() + } + } +})); + +describe('accessControl', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('checkSessionAccess', () => { + it('should return owner access when user owns the session', async () => { + vi.mocked(db.session.findUnique).mockResolvedValue({ + id: 'session-1', + accountId: 'user-1' + } as any); + + const result = await checkSessionAccess('user-1', 'session-1'); + + expect(result).toEqual({ + userId: 'user-1', + sessionId: 'session-1', + level: 'owner', + isOwner: true + }); + }); + + it('should return null when session does not exist', async () => { + vi.mocked(db.session.findUnique).mockResolvedValue(null); + + const result = await checkSessionAccess('user-1', 'session-1'); + + expect(result).toBeNull(); + }); + + it('should return shared access level when session is shared with user', async () => { + vi.mocked(db.session.findUnique).mockResolvedValue({ + id: 'session-1', + accountId: 'user-owner' + } as any); + + vi.mocked(db.sessionShare.findUnique).mockResolvedValue({ + accessLevel: 'view' + } as any); + + const result = await checkSessionAccess('user-1', 'session-1'); + + expect(result).toEqual({ + userId: 'user-1', + sessionId: 'session-1', + level: 'view', + isOwner: false + }); + }); + + it('should return null when user has no access to session', async () => { + vi.mocked(db.session.findUnique).mockResolvedValue({ + id: 'session-1', + accountId: 'user-owner' + } as any); + + vi.mocked(db.sessionShare.findUnique).mockResolvedValue(null); + + const result = await checkSessionAccess('user-1', 'session-1'); + + expect(result).toBeNull(); + }); + }); + + describe('checkPublicShareAccess', () => { + it('should return access info for valid token', async () => { + const mockShare = { + id: 'public-1', + sessionId: 'session-1', + expiresAt: null, + maxUses: null, + useCount: 5, + blockedUsers: [] + }; + + vi.mocked(db.publicSessionShare.findUnique).mockResolvedValue(mockShare as any); + + const result = await checkPublicShareAccess('valid-token', null); + + expect(result).toEqual({ + sessionId: 'session-1', + publicShareId: 'public-1' + }); + }); + + it('should return null for invalid token', async () => { + vi.mocked(db.publicSessionShare.findUnique).mockResolvedValue(null); + + const result = await checkPublicShareAccess('invalid-token', null); + + expect(result).toBeNull(); + }); + + it('should return null for expired shares', async () => { + const pastDate = new Date(Date.now() - 1000 * 60 * 60); // 1 hour ago + const mockShare = { + id: 'public-1', + sessionId: 'session-1', + expiresAt: pastDate, + maxUses: null, + useCount: 0, + blockedUsers: [] + }; + + vi.mocked(db.publicSessionShare.findUnique).mockResolvedValue(mockShare as any); + + const result = await checkPublicShareAccess('valid-token', null); + + expect(result).toBeNull(); + }); + + it('should return null when max uses reached', async () => { + const mockShare = { + id: 'public-1', + sessionId: 'session-1', + expiresAt: null, + maxUses: 10, + useCount: 10, + blockedUsers: [] + }; + + vi.mocked(db.publicSessionShare.findUnique).mockResolvedValue(mockShare as any); + + const result = await checkPublicShareAccess('valid-token', null); + + expect(result).toBeNull(); + }); + }); + + describe('isSessionOwner', () => { + it('should return true when user owns the session', async () => { + vi.mocked(db.session.findUnique).mockResolvedValue({ + id: 'session-1', + accountId: 'user-1' + } as any); + + const result = await isSessionOwner('user-1', 'session-1'); + + expect(result).toBe(true); + }); + + it('should return false when user does not own the session', async () => { + vi.mocked(db.session.findUnique).mockResolvedValue({ + id: 'session-1', + accountId: 'user-owner' + } as any); + + const result = await isSessionOwner('user-1', 'session-1'); + + expect(result).toBe(false); + }); + + it('should return false when session does not exist', async () => { + vi.mocked(db.session.findUnique).mockResolvedValue(null); + + const result = await isSessionOwner('user-1', 'session-1'); + + expect(result).toBe(false); + }); + }); + + describe('canManageSharing', () => { + it('should return true for session owner', async () => { + vi.mocked(db.session.findUnique).mockResolvedValue({ + id: 'session-1', + accountId: 'user-1' + } as any); + + const result = await canManageSharing('user-1', 'session-1'); + + expect(result).toBe(true); + }); + + it('should return true for admin access level', async () => { + vi.mocked(db.session.findUnique).mockResolvedValue({ + id: 'session-1', + accountId: 'user-owner' + } as any); + + vi.mocked(db.sessionShare.findUnique).mockResolvedValue({ + accessLevel: 'admin' + } as any); + + const result = await canManageSharing('user-1', 'session-1'); + + expect(result).toBe(true); + }); + + it('should return false for view access level', async () => { + vi.mocked(db.session.findUnique).mockResolvedValue({ + id: 'session-1', + accountId: 'user-owner' + } as any); + + vi.mocked(db.sessionShare.findUnique).mockResolvedValue({ + accessLevel: 'view' + } as any); + + const result = await canManageSharing('user-1', 'session-1'); + + expect(result).toBe(false); + }); + + it('should return false for edit access level', async () => { + vi.mocked(db.session.findUnique).mockResolvedValue({ + id: 'session-1', + accountId: 'user-owner' + } as any); + + vi.mocked(db.sessionShare.findUnique).mockResolvedValue({ + accessLevel: 'edit' + } as any); + + const result = await canManageSharing('user-1', 'session-1'); + + expect(result).toBe(false); + }); + + it('should return false when user has no access', async () => { + vi.mocked(db.session.findUnique).mockResolvedValue({ + id: 'session-1', + accountId: 'user-owner' + } as any); + + vi.mocked(db.sessionShare.findUnique).mockResolvedValue(null); + + const result = await canManageSharing('user-1', 'session-1'); + + expect(result).toBe(false); + }); + }); + + describe('areFriends', () => { + it('should return true when users are friends (from->to)', async () => { + vi.mocked(db.userRelationship.findFirst).mockResolvedValue({ + fromUserId: 'user-1', + toUserId: 'user-2', + status: 'friend' + } as any); + + const result = await areFriends('user-1', 'user-2'); + + expect(result).toBe(true); + }); + + it('should return true when users are friends (to->from)', async () => { + vi.mocked(db.userRelationship.findFirst).mockResolvedValue({ + fromUserId: 'user-2', + toUserId: 'user-1', + status: 'friend' + } as any); + + const result = await areFriends('user-1', 'user-2'); + + expect(result).toBe(true); + }); + + it('should return false when users are not friends', async () => { + vi.mocked(db.userRelationship.findFirst).mockResolvedValue(null); + + const result = await areFriends('user-1', 'user-2'); + + expect(result).toBe(false); + }); + + it('should return false when relationship is pending', async () => { + vi.mocked(db.userRelationship.findFirst).mockResolvedValue(null); + + const result = await areFriends('user-1', 'user-2'); + + expect(result).toBe(false); + }); + }); +}); diff --git a/server/sources/app/share/accessControl.ts b/server/sources/app/share/accessControl.ts new file mode 100644 index 000000000..88cd1bdf6 --- /dev/null +++ b/server/sources/app/share/accessControl.ts @@ -0,0 +1,233 @@ +import { db } from "@/storage/db"; +import { ShareAccessLevel } from "@prisma/client"; +import { createHash } from "crypto"; + +/** + * Access level for session sharing (including owner) + */ +export type AccessLevel = ShareAccessLevel | 'owner'; + +/** + * Session access information for a user + */ +export interface SessionAccess { + /** User ID requesting access */ + userId: string; + /** Session ID being accessed */ + sessionId: string; + /** Access level granted to user */ + level: AccessLevel; + /** Whether user is session owner */ + isOwner: boolean; +} + +/** + * Check user's access level for a session + * + * @param userId - User ID requesting access + * @param sessionId - Session ID to check + * @returns Session access info, or null if no access + */ +export async function checkSessionAccess( + userId: string, + sessionId: string +): Promise<SessionAccess | null> { + // First check if user owns the session + const session = await db.session.findUnique({ + where: { id: sessionId }, + select: { accountId: true } + }); + + if (!session) { + return null; + } + + if (session.accountId === userId) { + return { + userId, + sessionId, + level: 'owner', + isOwner: true + }; + } + + // Check if session is shared with user + const share = await db.sessionShare.findUnique({ + where: { + sessionId_sharedWithUserId: { + sessionId, + sharedWithUserId: userId + } + }, + select: { accessLevel: true } + }); + + if (share) { + return { + userId, + sessionId, + level: share.accessLevel, + isOwner: false + }; + } + + return null; +} + +/** + * Check if user has required access level + * + * @param access - User's session access + * @param required - Required access level + * @returns True if user has sufficient access + */ +export function requireAccessLevel( + access: SessionAccess, + required: AccessLevel +): boolean { + const levels: AccessLevel[] = ['view', 'edit', 'admin', 'owner']; + const userLevel = levels.indexOf(access.level); + const requiredLevel = levels.indexOf(required); + return userLevel >= requiredLevel; +} + +/** + * Check if user can view session + * + * @param userId - User ID requesting access + * @param sessionId - Session ID to check + * @returns True if user can view session + */ +export async function canViewSession( + userId: string, + sessionId: string +): Promise<boolean> { + const access = await checkSessionAccess(userId, sessionId); + return access !== null; +} + +/** + * Check if user can send messages to session + * + * @param userId - User ID requesting access + * @param sessionId - Session ID to check + * @returns True if user can send messages + */ +export async function canSendMessages( + userId: string, + sessionId: string +): Promise<boolean> { + const access = await checkSessionAccess(userId, sessionId); + if (!access) return false; + return requireAccessLevel(access, 'edit'); +} + +/** + * Check if user can manage sharing settings + * + * @param userId - User ID requesting access + * @param sessionId - Session ID to check + * @returns True if user can manage sharing + */ +export async function canManageSharing( + userId: string, + sessionId: string +): Promise<boolean> { + const access = await checkSessionAccess(userId, sessionId); + if (!access) return false; + return requireAccessLevel(access, 'admin'); +} + +/** + * Check if user owns the session + * + * @param userId - User ID requesting access + * @param sessionId - Session ID to check + * @returns True if user owns the session + */ +export async function isSessionOwner( + userId: string, + sessionId: string +): Promise<boolean> { + const access = await checkSessionAccess(userId, sessionId); + return access?.isOwner ?? false; +} + +/** + * Check if two users are friends + * + * @param userId1 - First user ID + * @param userId2 - Second user ID + * @returns True if users are friends + */ +export async function areFriends( + userId1: string, + userId2: string +): Promise<boolean> { + const relationship = await db.userRelationship.findFirst({ + where: { + OR: [ + { fromUserId: userId1, toUserId: userId2, status: 'friend' }, + { fromUserId: userId2, toUserId: userId1, status: 'friend' } + ] + } + }); + return relationship !== null; +} + +/** + * Check public share access with blocking and limits + * + * Public shares are always view-only for security + * + * @param token - Public share token + * @param userId - User ID accessing (null for anonymous) + * @returns Public share info if valid, null otherwise + */ +export async function checkPublicShareAccess( + token: string, + userId: string | null +): Promise<{ + sessionId: string; + publicShareId: string; +} | null> { + const tokenHash = createHash('sha256').update(token, 'utf8').digest(); + const publicShare = await db.publicSessionShare.findUnique({ + where: { tokenHash }, + select: { + id: true, + sessionId: true, + expiresAt: true, + maxUses: true, + useCount: true, + blockedUsers: userId ? { + where: { userId }, + select: { id: true } + } : undefined + } + }); + + if (!publicShare) { + return null; + } + + // Check if expired + if (publicShare.expiresAt && publicShare.expiresAt < new Date()) { + return null; + } + + // Check if max uses exceeded + if (publicShare.maxUses && publicShare.useCount >= publicShare.maxUses) { + return null; + } + + // Check if user is blocked + if (userId && publicShare.blockedUsers && publicShare.blockedUsers.length > 0) { + return null; + } + + return { + sessionId: publicShare.sessionId, + publicShareId: publicShare.id + }; +} diff --git a/server/sources/app/share/accessLogger.spec.ts b/server/sources/app/share/accessLogger.spec.ts new file mode 100644 index 000000000..a298ca6f0 --- /dev/null +++ b/server/sources/app/share/accessLogger.spec.ts @@ -0,0 +1,156 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { logSessionShareAccess, logPublicShareAccess, getIpAddress, getUserAgent } from './accessLogger'; +import { db } from '@/storage/db'; + +vi.mock('@/storage/db', () => ({ + db: { + sessionShareAccessLog: { + create: vi.fn() + }, + publicShareAccessLog: { + create: vi.fn() + } + } +})); + +describe('accessLogger', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('logSessionShareAccess', () => { + it('should log access with IP and user agent', async () => { + await logSessionShareAccess('share-1', 'user-1', '192.168.1.1', 'Mozilla/5.0'); + + expect(db.sessionShareAccessLog.create).toHaveBeenCalledWith({ + data: { + sessionShareId: 'share-1', + userId: 'user-1', + ipAddress: '192.168.1.1', + userAgent: 'Mozilla/5.0' + } + }); + }); + + it('should log access without IP and user agent', async () => { + await logSessionShareAccess('share-1', 'user-1'); + + expect(db.sessionShareAccessLog.create).toHaveBeenCalledWith({ + data: { + sessionShareId: 'share-1', + userId: 'user-1', + ipAddress: null, + userAgent: null + } + }); + }); + }); + + describe('logPublicShareAccess', () => { + it('should log access with all fields', async () => { + await logPublicShareAccess('public-1', 'user-1', '192.168.1.1', 'Mozilla/5.0'); + + expect(db.publicShareAccessLog.create).toHaveBeenCalledWith({ + data: { + publicShareId: 'public-1', + userId: 'user-1', + ipAddress: '192.168.1.1', + userAgent: 'Mozilla/5.0' + } + }); + }); + + it('should log anonymous access', async () => { + await logPublicShareAccess('public-1', null); + + expect(db.publicShareAccessLog.create).toHaveBeenCalledWith({ + data: { + publicShareId: 'public-1', + userId: null, + ipAddress: null, + userAgent: null + } + }); + }); + + it('should log access with consent (IP and UA present)', async () => { + await logPublicShareAccess('public-1', null, '10.0.0.1', 'Chrome/100.0'); + + expect(db.publicShareAccessLog.create).toHaveBeenCalledWith({ + data: { + publicShareId: 'public-1', + userId: null, + ipAddress: '10.0.0.1', + userAgent: 'Chrome/100.0' + } + }); + }); + }); + + describe('getIpAddress', () => { + it('should extract IP from x-forwarded-for header', () => { + const headers = { 'x-forwarded-for': '203.0.113.1, 198.51.100.1' }; + const result = getIpAddress(headers); + expect(result).toBe('203.0.113.1'); + }); + + it('should handle x-forwarded-for as array', () => { + const headers = { 'x-forwarded-for': ['203.0.113.1, 198.51.100.1'] }; + const result = getIpAddress(headers); + expect(result).toBe('203.0.113.1'); + }); + + it('should extract IP from x-real-ip header', () => { + const headers = { 'x-real-ip': '203.0.113.5' }; + const result = getIpAddress(headers); + expect(result).toBe('203.0.113.5'); + }); + + it('should prefer x-forwarded-for over x-real-ip', () => { + const headers = { + 'x-forwarded-for': '203.0.113.1', + 'x-real-ip': '203.0.113.5' + }; + const result = getIpAddress(headers); + expect(result).toBe('203.0.113.1'); + }); + + it('should return undefined when no IP headers present', () => { + const headers = {}; + const result = getIpAddress(headers); + expect(result).toBeUndefined(); + }); + + it('should trim whitespace from IP address', () => { + const headers = { 'x-forwarded-for': ' 203.0.113.1 , 198.51.100.1' }; + const result = getIpAddress(headers); + expect(result).toBe('203.0.113.1'); + }); + }); + + describe('getUserAgent', () => { + it('should extract user agent from header', () => { + const headers = { 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' }; + const result = getUserAgent(headers); + expect(result).toBe('Mozilla/5.0 (Windows NT 10.0; Win64; x64)'); + }); + + it('should handle user agent as array', () => { + const headers = { 'user-agent': ['Mozilla/5.0'] }; + const result = getUserAgent(headers); + expect(result).toBe('Mozilla/5.0'); + }); + + it('should return undefined when no user agent header', () => { + const headers = {}; + const result = getUserAgent(headers); + expect(result).toBeUndefined(); + }); + + it('should return undefined for empty user agent', () => { + const headers = { 'user-agent': '' }; + const result = getUserAgent(headers); + expect(result).toBeUndefined(); + }); + }); +}); diff --git a/server/sources/app/share/accessLogger.ts b/server/sources/app/share/accessLogger.ts new file mode 100644 index 000000000..58c72ddc2 --- /dev/null +++ b/server/sources/app/share/accessLogger.ts @@ -0,0 +1,83 @@ +import { db } from "@/storage/db"; + +/** + * Log access to a direct session share + * + * @param sessionShareId - Session share ID + * @param userId - User ID who accessed + * @param ipAddress - IP address (optional) + * @param userAgent - User agent (optional) + */ +export async function logSessionShareAccess( + sessionShareId: string, + userId: string, + ipAddress?: string, + userAgent?: string +): Promise<void> { + await db.sessionShareAccessLog.create({ + data: { + sessionShareId, + userId, + ipAddress: ipAddress ?? null, + userAgent: userAgent ?? null + } + }); +} + +/** + * Log access to a public session share + * + * @param publicShareId - Public share ID + * @param userId - User ID who accessed (null for anonymous) + * @param ipAddress - IP address (optional) + * @param userAgent - User agent (optional) + */ +export async function logPublicShareAccess( + publicShareId: string, + userId: string | null, + ipAddress?: string, + userAgent?: string +): Promise<void> { + await db.publicShareAccessLog.create({ + data: { + publicShareId, + userId: userId ?? null, + ipAddress: ipAddress ?? null, + userAgent: userAgent ?? null + } + }); +} + +/** + * Get IP address from request + * + * @param headers - Request headers + * @returns IP address or undefined + */ +export function getIpAddress(headers: Record<string, string | string[] | undefined>): string | undefined { + // Check common headers for IP address + const forwardedFor = headers['x-forwarded-for']; + if (forwardedFor) { + const ip = Array.isArray(forwardedFor) ? forwardedFor[0] : forwardedFor; + return ip.split(',')[0].trim(); + } + + const realIp = headers['x-real-ip']; + if (realIp) { + return Array.isArray(realIp) ? realIp[0] : realIp; + } + + return undefined; +} + +/** + * Get user agent from request + * + * @param headers - Request headers + * @returns User agent or undefined + */ +export function getUserAgent(headers: Record<string, string | string[] | undefined>): string | undefined { + const userAgent = headers['user-agent']; + if (!userAgent) return undefined; + return Array.isArray(userAgent) ? userAgent[0] : userAgent; +} diff --git a/server/sources/app/share/encryptDataKey.ts b/server/sources/app/share/encryptDataKey.ts new file mode 100644 index 000000000..00b9016ab --- /dev/null +++ b/server/sources/app/share/encryptDataKey.ts @@ -0,0 +1,35 @@ +/** + * Encryption utilities for session sharing + */ + +import nacl from 'tweetnacl'; + +/** + * Encrypt a session data key with a recipient's public key + * + * Uses X25519-XSalsa20-Poly1305 encryption with ephemeral keys + */ +export function encryptDataKeyForRecipient( + dataKey: Uint8Array, + recipientPublicKey: Uint8Array +): Uint8Array { + const ephemeralKeyPair = nacl.box.keyPair(); + const nonce = nacl.randomBytes(nacl.box.nonceLength); + + const encrypted = nacl.box( + dataKey, + nonce, + recipientPublicKey, + ephemeralKeyPair.secretKey + ); + + // Bundle: ephemeral public key (32) + nonce (24) + encrypted data + const bundle = new Uint8Array( + ephemeralKeyPair.publicKey.length + nonce.length + encrypted.length + ); + bundle.set(ephemeralKeyPair.publicKey, 0); + bundle.set(nonce, ephemeralKeyPair.publicKey.length); + bundle.set(encrypted, ephemeralKeyPair.publicKey.length + nonce.length); + + return bundle; +} diff --git a/server/sources/app/share/types.ts b/server/sources/app/share/types.ts new file mode 100644 index 000000000..877e2bb8d --- /dev/null +++ b/server/sources/app/share/types.ts @@ -0,0 +1,42 @@ +import { getPublicUrl } from "@/storage/files"; + +/** + * Common select for user profile information + */ +export const PROFILE_SELECT = { + id: true, + firstName: true, + lastName: true, + username: true, + avatar: true +} as const; + +/** + * User profile type (inferred from PROFILE_SELECT) + */ +export type UserProfile = { + id: string; + firstName: string | null; + lastName: string | null; + username: string | null; + avatar: string | null; +}; + +export function toShareUserProfile(profile: { + id: string; + firstName: string | null; + lastName: string | null; + username: string | null; + avatar: any | null; +}): UserProfile { + const avatarJson = profile.avatar as any | null; + const avatarPath = avatarJson && typeof avatarJson === 'object' ? avatarJson.path : null; + const avatarUrl = typeof avatarPath === 'string' ? getPublicUrl(avatarPath) : null; + return { + id: profile.id, + firstName: profile.firstName, + lastName: profile.lastName, + username: profile.username, + avatar: avatarUrl + }; +} diff --git a/server/sources/app/social/friendAdd.ts b/server/sources/app/social/friendAdd.ts index 5746ea5e7..2a2444c19 100644 --- a/server/sources/app/social/friendAdd.ts +++ b/server/sources/app/social/friendAdd.ts @@ -1,10 +1,10 @@ import { Context } from "@/context"; import { buildUserProfile, UserProfile } from "./type"; import { inTx } from "@/storage/inTx"; -import { RelationshipStatus } from "@prisma/client"; import { relationshipSet } from "./relationshipSet"; import { relationshipGet } from "./relationshipGet"; import { sendFriendRequestNotification, sendFriendshipEstablishedNotification } from "./friendNotification"; +import { RelationshipStatus } from "@/storage/prisma"; /** * Add a friend or accept a friend request. @@ -75,4 +75,4 @@ export async function friendAdd(ctx: Context, uid: string): Promise<UserProfile // Do not change anything and return the target user profile return buildUserProfile(targetUser, currentUserRelationship); }); -} \ No newline at end of file +} diff --git a/server/sources/app/social/friendList.ts b/server/sources/app/social/friendList.ts index 34cb70cf5..3c46ace6f 100644 --- a/server/sources/app/social/friendList.ts +++ b/server/sources/app/social/friendList.ts @@ -1,7 +1,7 @@ import { Context } from "@/context"; import { buildUserProfile, UserProfile } from "./type"; import { db } from "@/storage/db"; -import { RelationshipStatus } from "@prisma/client"; +import { RelationshipStatus } from "@/storage/prisma"; export async function friendList(ctx: Context): Promise<UserProfile[]> { // Query all relationships where current user is fromUserId with friend, pending, or requested status @@ -28,4 +28,4 @@ export async function friendList(ctx: Context): Promise<UserProfile[]> { } return profiles; -} \ No newline at end of file +} diff --git a/server/sources/app/social/friendNotification.spec.ts b/server/sources/app/social/friendNotification.spec.ts index e13782b08..54c3c5337 100644 --- a/server/sources/app/social/friendNotification.spec.ts +++ b/server/sources/app/social/friendNotification.spec.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi } from "vitest"; -import { RelationshipStatus } from "@prisma/client"; +import { RelationshipStatus } from "@/storage/prisma"; // Mock the dependencies that require environment variables vi.mock("@/storage/files", () => ({ @@ -58,4 +58,4 @@ describe("friendNotification", () => { expect(result).toBe(true); }); }); -}); \ No newline at end of file +}); diff --git a/server/sources/app/social/friendNotification.ts b/server/sources/app/social/friendNotification.ts index ce0dd3339..533d541db 100644 --- a/server/sources/app/social/friendNotification.ts +++ b/server/sources/app/social/friendNotification.ts @@ -1,4 +1,4 @@ -import { Prisma, RelationshipStatus } from "@prisma/client"; +import { RelationshipStatus, type RelationshipStatus as RelationshipStatusType, type TransactionClient } from "@/storage/prisma"; import { feedPost } from "@/app/feed/feedPost"; import { Context } from "@/context"; import { afterTx } from "@/storage/inTx"; @@ -12,7 +12,7 @@ import { afterTx } from "@/storage/inTx"; */ export function shouldSendNotification( lastNotifiedAt: Date | null, - status: RelationshipStatus + status: RelationshipStatusType ): boolean { // Don't send notifications for rejected relationships if (status === RelationshipStatus.rejected) { @@ -34,7 +34,7 @@ export function shouldSendNotification( * This creates a feed item for the receiver about the incoming friend request. */ export async function sendFriendRequestNotification( - tx: Prisma.TransactionClient, + tx: TransactionClient, receiverUserId: string, senderUserId: string ): Promise<void> { @@ -86,7 +86,7 @@ export async function sendFriendRequestNotification( * This creates feed items for both users about the new friendship. */ export async function sendFriendshipEstablishedNotification( - tx: Prisma.TransactionClient, + tx: TransactionClient, user1Id: string, user2Id: string ): Promise<void> { @@ -167,4 +167,4 @@ export async function sendFriendshipEstablishedNotification( } }); } -} \ No newline at end of file +} diff --git a/server/sources/app/social/friendRemove.ts b/server/sources/app/social/friendRemove.ts index 0063d8cae..564816447 100644 --- a/server/sources/app/social/friendRemove.ts +++ b/server/sources/app/social/friendRemove.ts @@ -1,9 +1,9 @@ import { Context } from "@/context"; import { buildUserProfile, UserProfile } from "./type"; import { inTx } from "@/storage/inTx"; -import { RelationshipStatus } from "@prisma/client"; import { relationshipSet } from "./relationshipSet"; import { relationshipGet } from "./relationshipGet"; +import { RelationshipStatus } from "@/storage/prisma"; export async function friendRemove(ctx: Context, uid: string): Promise<UserProfile | null> { return await inTx(async (tx) => { @@ -50,4 +50,4 @@ export async function friendRemove(ctx: Context, uid: string): Promise<UserProfi // Return the target user profile with status none return buildUserProfile(targetUser, currentUserRelationship); }); -} \ No newline at end of file +} diff --git a/server/sources/app/social/relationshipGet.ts b/server/sources/app/social/relationshipGet.ts index a58462e78..42053817c 100644 --- a/server/sources/app/social/relationshipGet.ts +++ b/server/sources/app/social/relationshipGet.ts @@ -1,7 +1,6 @@ -import { Prisma, PrismaClient } from "@prisma/client"; -import { RelationshipStatus } from "@prisma/client"; +import { RelationshipStatus, type PrismaClientType, type TransactionClient, type RelationshipStatus as RelationshipStatusType } from "@/storage/prisma"; -export async function relationshipGet(tx: Prisma.TransactionClient | PrismaClient, from: string, to: string): Promise<RelationshipStatus> { +export async function relationshipGet(tx: TransactionClient | PrismaClientType, from: string, to: string): Promise<RelationshipStatusType> { const relationship = await tx.userRelationship.findFirst({ where: { fromUserId: from, @@ -9,4 +8,4 @@ export async function relationshipGet(tx: Prisma.TransactionClient | PrismaClien } }); return relationship?.status || RelationshipStatus.none; -} \ No newline at end of file +} diff --git a/server/sources/app/social/relationshipSet.ts b/server/sources/app/social/relationshipSet.ts index 783fbda2c..3abcf26e9 100644 --- a/server/sources/app/social/relationshipSet.ts +++ b/server/sources/app/social/relationshipSet.ts @@ -1,7 +1,6 @@ -import { Prisma } from "@prisma/client"; -import { RelationshipStatus } from "@prisma/client"; +import { RelationshipStatus, type RelationshipStatus as RelationshipStatusType, type TransactionClient } from "@/storage/prisma"; -export async function relationshipSet(tx: Prisma.TransactionClient, from: string, to: string, status: RelationshipStatus, lastNotifiedAt?: Date) { +export async function relationshipSet(tx: TransactionClient, from: string, to: string, status: RelationshipStatusType, lastNotifiedAt?: Date) { // Get existing relationship to preserve lastNotifiedAt const existing = await tx.userRelationship.findUnique({ where: { @@ -57,4 +56,4 @@ export async function relationshipSet(tx: Prisma.TransactionClient, from: string } }); } -} \ No newline at end of file +} diff --git a/server/sources/app/social/type.ts b/server/sources/app/social/type.ts index deb5edba8..c46f4e678 100644 --- a/server/sources/app/social/type.ts +++ b/server/sources/app/social/type.ts @@ -1,6 +1,7 @@ import { getPublicUrl, ImageRef } from "@/storage/files"; -import { RelationshipStatus } from "@prisma/client"; +import type { RelationshipStatus } from "@/storage/prisma"; import { GitHubProfile } from "../api/types"; +import * as privacyKit from "privacy-kit"; export type UserProfile = { id: string; @@ -16,6 +17,9 @@ export type UserProfile = { username: string; bio: string | null; status: RelationshipStatus; + publicKey: string; + contentPublicKey: string | null; + contentPublicKeySig: string | null; } export function buildUserProfile( @@ -26,6 +30,9 @@ export function buildUserProfile( username: string | null; avatar: ImageRef | null; githubUser: { profile: GitHubProfile } | null; + publicKey: string; + contentPublicKey: Uint8Array | null; + contentPublicKeySig: Uint8Array | null; }, status: RelationshipStatus ): UserProfile { @@ -51,6 +58,9 @@ export function buildUserProfile( avatar, username: account.username || githubProfile?.login || '', bio: githubProfile?.bio || null, - status + status, + publicKey: account.publicKey, + contentPublicKey: account.contentPublicKey ? privacyKit.encodeBase64(account.contentPublicKey) : null, + contentPublicKeySig: account.contentPublicKeySig ? privacyKit.encodeBase64(account.contentPublicKeySig) : null, }; -} \ No newline at end of file +} diff --git a/server/sources/context.ts b/server/sources/context.ts index 7e2ab2fc8..f73ea79fb 100644 --- a/server/sources/context.ts +++ b/server/sources/context.ts @@ -1,5 +1,3 @@ -import { Prisma, PrismaClient } from "@prisma/client"; - export class Context { static create(uid: string) { @@ -11,4 +9,4 @@ export class Context { private constructor(uid: string) { this.uid = uid; } -} \ No newline at end of file +} diff --git a/server/sources/flavors/light/env.spec.ts b/server/sources/flavors/light/env.spec.ts new file mode 100644 index 000000000..165100890 --- /dev/null +++ b/server/sources/flavors/light/env.spec.ts @@ -0,0 +1,73 @@ +import { mkdtemp, rm, readFile, stat } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { applyLightDefaultEnv, ensureHandyMasterSecret } from './env'; + +describe('light env helpers', () => { + it('applyLightDefaultEnv fills defaults without overriding explicit values', () => { + const env: NodeJS.ProcessEnv = { + PORT: '4000', + DATABASE_URL: 'file:/custom.sqlite', + PUBLIC_URL: 'http://example.com/', + HAPPY_SERVER_LIGHT_DATA_DIR: '/custom/data', + HAPPY_SERVER_LIGHT_FILES_DIR: '/custom/files', + }; + + applyLightDefaultEnv(env, { homedir: '/home/ignored' }); + + expect(env.HAPPY_SERVER_LIGHT_DATA_DIR).toBe('/custom/data'); + expect(env.HAPPY_SERVER_LIGHT_FILES_DIR).toBe('/custom/files'); + expect(env.DATABASE_URL).toBe('file:/custom.sqlite'); + expect(env.PUBLIC_URL).toBe('http://example.com'); + }); + + it('applyLightDefaultEnv derives defaults from homedir and PORT when missing', () => { + const env: NodeJS.ProcessEnv = { PORT: '4000' }; + applyLightDefaultEnv(env, { homedir: '/home/test' }); + + expect(env.HAPPY_SERVER_LIGHT_DATA_DIR).toBe('/home/test/.happy/server-light'); + expect(env.HAPPY_SERVER_LIGHT_FILES_DIR).toBe('/home/test/.happy/server-light/files'); + expect(env.DATABASE_URL).toBe('file:///home/test/.happy/server-light/happy-server-light.sqlite'); + expect(env.PUBLIC_URL).toBe('http://localhost:4000'); + }); + + it('applyLightDefaultEnv falls back to default port when PORT is invalid', () => { + const env: NodeJS.ProcessEnv = { PORT: 'oops' }; + applyLightDefaultEnv(env, { homedir: '/home/test' }); + expect(env.PUBLIC_URL).toBe('http://localhost:3005'); + }); + + it('ensureHandyMasterSecret persists a generated secret and reuses it', async () => { + const dir = await mkdtemp(join(tmpdir(), 'happy-server-light-')); + try { + const env: NodeJS.ProcessEnv = { HAPPY_SERVER_LIGHT_DATA_DIR: dir }; + await ensureHandyMasterSecret(env, { dataDir: dir }); + expect(typeof env.HANDY_MASTER_SECRET).toBe('string'); + const first = env.HANDY_MASTER_SECRET as string; + expect(first.length).toBeGreaterThan(0); + + // New env should pick up persisted value. + const env2: NodeJS.ProcessEnv = { HAPPY_SERVER_LIGHT_DATA_DIR: dir }; + await ensureHandyMasterSecret(env2, { dataDir: dir }); + expect(env2.HANDY_MASTER_SECRET).toBe(first); + + const onDisk = (await readFile(join(dir, 'handy-master-secret.txt'), 'utf-8')).trim(); + expect(onDisk).toBe(first); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + it('ensureHandyMasterSecret ensures the data directory exists even when secret is already set', async () => { + const base = await mkdtemp(join(tmpdir(), 'happy-server-light-')); + const dir = join(base, 'data'); + try { + const env: NodeJS.ProcessEnv = { HAPPY_SERVER_LIGHT_DATA_DIR: dir, HANDY_MASTER_SECRET: 'pre-set' }; + await ensureHandyMasterSecret(env, { dataDir: dir }); + expect((await stat(dir)).isDirectory()).toBe(true); + } finally { + await rm(base, { recursive: true, force: true }); + } + }); +}); diff --git a/server/sources/flavors/light/env.ts b/server/sources/flavors/light/env.ts new file mode 100644 index 000000000..d699a7a8e --- /dev/null +++ b/server/sources/flavors/light/env.ts @@ -0,0 +1,93 @@ +import { randomBytes } from 'node:crypto'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { homedir as defaultHomedir } from 'node:os'; +import { pathToFileURL } from 'node:url'; + +export type LightEnv = NodeJS.ProcessEnv; + +export function resolveLightDataDir(env: LightEnv, opts?: { homedir?: string }): string { + const fromEnv = env.HAPPY_SERVER_LIGHT_DATA_DIR?.trim(); + if (fromEnv) { + return fromEnv; + } + const home = opts?.homedir ?? defaultHomedir(); + return join(home, '.happy', 'server-light'); +} + +export function resolveLightFilesDir(env: LightEnv, dataDir: string): string { + const fromEnv = env.HAPPY_SERVER_LIGHT_FILES_DIR?.trim(); + if (fromEnv) { + return fromEnv; + } + return join(dataDir, 'files'); +} + +export function resolveLightDatabaseUrl(env: LightEnv, dataDir: string): string { + const fromEnv = env.DATABASE_URL?.trim(); + if (fromEnv) { + return fromEnv; + } + const dbPath = join(dataDir, 'happy-server-light.sqlite'); + return pathToFileURL(dbPath).toString(); +} + +export function resolveLightPublicUrl(env: LightEnv): string { + const fromEnv = env.PUBLIC_URL?.trim(); + if (fromEnv) { + return fromEnv.replace(/\/+$/, ''); + } + const parsed = env.PORT ? parseInt(env.PORT, 10) : NaN; + const port = Number.isInteger(parsed) && parsed > 0 && parsed <= 65535 ? parsed : 3005; + return `http://localhost:${port}`; +} + +export function applyLightDefaultEnv(env: LightEnv, opts?: { homedir?: string }): void { + const dataDir = resolveLightDataDir(env, opts); + const filesDir = resolveLightFilesDir(env, dataDir); + + env.HAPPY_SERVER_LIGHT_DATA_DIR = dataDir; + env.HAPPY_SERVER_LIGHT_FILES_DIR = filesDir; + + env.DATABASE_URL = resolveLightDatabaseUrl(env, dataDir); + env.PUBLIC_URL = resolveLightPublicUrl(env); +} + +export async function ensureHandyMasterSecret(env: LightEnv, opts?: { dataDir?: string; homedir?: string }): Promise<void> { + const dataDir = opts?.dataDir ?? resolveLightDataDir(env, { homedir: opts?.homedir }); + await mkdir(dataDir, { recursive: true }); + + if (env.HANDY_MASTER_SECRET && env.HANDY_MASTER_SECRET.trim()) { + return; + } + const secretPath = join(dataDir, 'handy-master-secret.txt'); + + try { + const existing = (await readFile(secretPath, 'utf-8')).trim(); + if (existing) { + env.HANDY_MASTER_SECRET = existing; + return; + } + } catch { + // ignore - will create below + } + + await mkdir(dirname(secretPath), { recursive: true }); + const generated = randomBytes(32).toString('base64url'); + try { + await writeFile(secretPath, generated, { encoding: 'utf-8', mode: 0o600, flag: 'wx' }); + env.HANDY_MASTER_SECRET = generated; + return; + } catch (err: any) { + if (err?.code !== 'EEXIST') { + throw err; + } + } + + // Another process likely created the file while we were racing to initialize it. + const existing = (await readFile(secretPath, 'utf-8')).trim(); + if (!existing) { + throw new Error(`handy-master-secret.txt exists but is empty: ${secretPath}`); + } + env.HANDY_MASTER_SECRET = existing; +} diff --git a/server/sources/flavors/light/files.spec.ts b/server/sources/flavors/light/files.spec.ts new file mode 100644 index 000000000..32b1bb6e4 --- /dev/null +++ b/server/sources/flavors/light/files.spec.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import { getLightPublicUrl, normalizePublicPath } from './files'; + +describe('normalizePublicPath', () => { + it('rejects path traversal and absolute paths', () => { + expect(() => normalizePublicPath('../x')).toThrow(); + expect(() => normalizePublicPath('a/../x')).toThrow(); + expect(() => normalizePublicPath('..\\x')).toThrow(); + expect(() => normalizePublicPath('/x')).toThrow(); + expect(() => normalizePublicPath('\\x')).toThrow(); + expect(() => normalizePublicPath('C:\\x')).toThrow(); + expect(() => normalizePublicPath('C:/x')).toThrow(); + }); + + it('returns a normalized relative path', () => { + expect(normalizePublicPath('foo//bar')).toBe('foo/bar'); + expect(normalizePublicPath('foo/./bar')).toBe('foo/bar'); + expect(normalizePublicPath('foo\\bar\\baz.txt')).toBe('foo/bar/baz.txt'); + }); +}); + +describe('getLightPublicUrl', () => { + it('encodes each path segment (so # and ? are not treated as URL fragment/query)', () => { + const env = { PUBLIC_URL: 'http://localhost:3005' } as NodeJS.ProcessEnv; + const url = getLightPublicUrl(env, 'foo/bar baz#qux?zap'); + expect(url).toBe('http://localhost:3005/files/foo/bar%20baz%23qux%3Fzap'); + }); +}); diff --git a/server/sources/flavors/light/files.ts b/server/sources/flavors/light/files.ts new file mode 100644 index 000000000..80365aee9 --- /dev/null +++ b/server/sources/flavors/light/files.ts @@ -0,0 +1,71 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { dirname, join, posix } from 'node:path'; +import { homedir } from 'node:os'; +import { resolveLightPublicUrl } from './env'; + +/** + * Lightweight file storage for happy-server "light" flavor. + * + * In production (full flavor), happy-server uses S3/Minio for public files. + * In light flavor, we store files on disk and serve them via `GET /files/*`. + */ + +export function resolveLightPublicFilesDir(env: NodeJS.ProcessEnv): string { + return env.HAPPY_SERVER_LIGHT_FILES_DIR?.trim() + ? env.HAPPY_SERVER_LIGHT_FILES_DIR.trim() + : join(homedir(), '.happy', 'server-light', 'files'); +} + +export async function ensureLightFilesDir(env: NodeJS.ProcessEnv): Promise<void> { + await mkdir(resolveLightPublicFilesDir(env), { recursive: true }); +} + +export function getLightPublicBaseUrl(env: NodeJS.ProcessEnv): string { + return resolveLightPublicUrl(env); +} + +export function normalizePublicPath(path: string): string { + if (path.includes('\0')) { + throw new Error('Invalid path'); + } + + const raw = path.replace(/\\/g, '/'); + const rawParts = raw.split('/').filter(Boolean); + if (raw.startsWith('/')) { + throw new Error('Invalid path'); + } + if (rawParts.some((part) => part === '..')) { + throw new Error('Invalid path'); + } + const normalized = posix.normalize(raw).replace(/^\/+/, ''); + const parts = normalized.split('/').filter(Boolean); + if (parts.some((part: string) => part === '..')) { + throw new Error('Invalid path'); + } + if (normalized.includes(':') || normalized.startsWith('/')) { + throw new Error('Invalid path'); + } + if (parts.length === 0) { + throw new Error('Invalid path'); + } + return parts.join('/'); +} + +export function getLightPublicUrl(env: NodeJS.ProcessEnv, path: string): string { + const safe = normalizePublicPath(path); + const encoded = safe.split('/').map(encodeURIComponent).join('/'); + return `${getLightPublicBaseUrl(env)}/files/${encoded}`; +} + +export async function writeLightPublicFile(env: NodeJS.ProcessEnv, path: string, data: Uint8Array): Promise<void> { + const safe = normalizePublicPath(path); + const abs = join(resolveLightPublicFilesDir(env), safe); + await mkdir(dirname(abs), { recursive: true }); + await writeFile(abs, data); +} + +export async function readLightPublicFile(env: NodeJS.ProcessEnv, path: string): Promise<Uint8Array> { + const safe = normalizePublicPath(path); + const abs = join(resolveLightPublicFilesDir(env), safe); + return await readFile(abs); +} diff --git a/server/sources/main.light.ts b/server/sources/main.light.ts new file mode 100644 index 000000000..37b2b720a --- /dev/null +++ b/server/sources/main.light.ts @@ -0,0 +1,11 @@ +import 'dotenv/config'; + +import { startServer } from '@/startServer'; +import { registerProcessHandlers } from '@/utils/processHandlers'; + +registerProcessHandlers(); + +startServer('light').catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/server/sources/main.ts b/server/sources/main.ts index 31315a78c..60644fde1 100644 --- a/server/sources/main.ts +++ b/server/sources/main.ts @@ -1,110 +1,11 @@ -import { startApi } from "@/app/api/api"; -import { log } from "@/utils/log"; -import { awaitShutdown, onShutdown } from "@/utils/shutdown"; -import { db } from './storage/db'; -import { startTimeout } from "./app/presence/timeout"; -import { redis } from "./storage/redis"; -import { startMetricsServer } from "@/app/monitoring/metrics"; -import { activityCache } from "@/app/presence/sessionCache"; -import { auth } from "./app/auth/auth"; -import { startDatabaseMetricsUpdater } from "@/app/monitoring/metrics2"; -import { initEncrypt } from "./modules/encrypt"; -import { initGithub } from "./modules/github"; -import { loadFiles } from "./storage/files"; +import { startServer } from '@/startServer'; +import { registerProcessHandlers } from '@/utils/processHandlers'; -async function main() { +registerProcessHandlers(); - // Storage - await db.$connect(); - onShutdown('db', async () => { - await db.$disconnect(); - }); - onShutdown('activity-cache', async () => { - activityCache.shutdown(); - }); - await redis.ping(); - - // Initialize auth module - await initEncrypt(); - await initGithub(); - await loadFiles(); - await auth.init(); - - // - // Start - // - - await startApi(); - await startMetricsServer(); - startDatabaseMetricsUpdater(); - startTimeout(); - - // - // Ready - // - - log('Ready'); - await awaitShutdown(); - log('Shutting down...'); -} - -// Process-level error handling -process.on('uncaughtException', (error) => { - log({ - module: 'process-error', - level: 'error', - stack: error.stack, - name: error.name - }, `Uncaught Exception: ${error.message}`); - - console.error('Uncaught Exception:', error); - process.exit(1); -}); - -process.on('unhandledRejection', (reason, promise) => { - const errorMsg = reason instanceof Error ? reason.message : String(reason); - const errorStack = reason instanceof Error ? reason.stack : undefined; - - log({ - module: 'process-error', - level: 'error', - stack: errorStack, - reason: String(reason) - }, `Unhandled Rejection: ${errorMsg}`); - - console.error('Unhandled Rejection at:', promise, 'reason:', reason); - process.exit(1); -}); - -process.on('warning', (warning) => { - log({ - module: 'process-warning', - level: 'warn', - name: warning.name, - stack: warning.stack - }, `Process Warning: ${warning.message}`); -}); - -// Log when the process is about to exit -process.on('exit', (code) => { - if (code !== 0) { - log({ - module: 'process-exit', - level: 'error', - exitCode: code - }, `Process exiting with code: ${code}`); - } else { - log({ - module: 'process-exit', - level: 'info', - exitCode: code - }, 'Process exiting normally'); - } -}); - -main().catch((e) => { +startServer('full').catch((e) => { console.error(e); process.exit(1); }).then(() => { process.exit(0); -}); \ No newline at end of file +}); diff --git a/server/sources/startServer.ts b/server/sources/startServer.ts new file mode 100644 index 000000000..55f6cc52d --- /dev/null +++ b/server/sources/startServer.ts @@ -0,0 +1,66 @@ +import { startApi } from '@/app/api/api'; +import { startMetricsServer } from '@/app/monitoring/metrics'; +import { startDatabaseMetricsUpdater } from '@/app/monitoring/metrics2'; +import { auth } from '@/app/auth/auth'; +import { activityCache } from '@/app/presence/sessionCache'; +import { startTimeout } from '@/app/presence/timeout'; +import { initEncrypt } from '@/modules/encrypt'; +import { initGithub } from '@/modules/github'; +import { loadFiles, initFilesLocalFromEnv, initFilesS3FromEnv } from '@/storage/files'; +import { db, initDbPostgres, initDbSqlite } from '@/storage/db'; +import { log } from '@/utils/log'; +import { awaitShutdown, onShutdown } from '@/utils/shutdown'; +import { applyLightDefaultEnv, ensureHandyMasterSecret } from '@/flavors/light/env'; + +export type ServerFlavor = 'full' | 'light'; + +export async function startServer(flavor: ServerFlavor): Promise<void> { + process.env.HAPPY_SERVER_FLAVOR = flavor; + + if (flavor === 'light') { + applyLightDefaultEnv(process.env); + await ensureHandyMasterSecret(process.env); + await initDbSqlite(); + initFilesLocalFromEnv(process.env); + } else { + initDbPostgres(); + initFilesS3FromEnv(process.env); + } + + // Storage + await db.$connect(); + onShutdown('db', async () => { + await db.$disconnect(); + }); + onShutdown('activity-cache', async () => { + activityCache.shutdown(); + }); + + if (flavor === 'full') { + const { redis } = await import('./storage/redis'); + await redis.ping(); + } + + // Initialize auth module + await initEncrypt(); + await initGithub(); + await loadFiles(); + await auth.init(); + + // + // Start + // + + await startApi(); + await startMetricsServer(); + startDatabaseMetricsUpdater(); + startTimeout(); + + // + // Ready + // + + log('Ready'); + await awaitShutdown(); + log('Shutting down...'); +} diff --git a/server/sources/storage/db.ts b/server/sources/storage/db.ts index 0a65fe1b5..8e4ed5095 100644 --- a/server/sources/storage/db.ts +++ b/server/sources/storage/db.ts @@ -1,3 +1 @@ -import { PrismaClient } from "@prisma/client"; - -export const db = new PrismaClient(); \ No newline at end of file +export * from "./prisma"; diff --git a/server/sources/storage/enums.generated.ts b/server/sources/storage/enums.generated.ts new file mode 100644 index 000000000..67dceb1f0 --- /dev/null +++ b/server/sources/storage/enums.generated.ts @@ -0,0 +1,21 @@ +// AUTO-GENERATED FILE - DO NOT EDIT. +// Source: prisma/schema.prisma +// Regenerate: yarn schema:sync + +export const RelationshipStatus = { + none: "none", + requested: "requested", + pending: "pending", + friend: "friend", + rejected: "rejected", +} as const; + +export type RelationshipStatus = (typeof RelationshipStatus)[keyof typeof RelationshipStatus]; + +export const ShareAccessLevel = { + view: "view", + edit: "edit", + admin: "admin", +} as const; + +export type ShareAccessLevel = (typeof ShareAccessLevel)[keyof typeof ShareAccessLevel]; diff --git a/server/sources/storage/files.spec.ts b/server/sources/storage/files.spec.ts new file mode 100644 index 000000000..6c19d8929 --- /dev/null +++ b/server/sources/storage/files.spec.ts @@ -0,0 +1,46 @@ +import { describe, expect, it, vi } from 'vitest'; + +describe('storage/files (S3 env parsing)', () => { + it('throws when S3_PORT is set but not a valid integer port', async () => { + vi.resetModules(); + const { initFilesS3FromEnv } = await import('./files'); + + expect(() => + initFilesS3FromEnv({ + S3_HOST: 'example.com', + S3_PORT: 'nope', + S3_BUCKET: 'bucket', + S3_PUBLIC_URL: 'https://cdn.example.com', + S3_ACCESS_KEY: 'access', + S3_SECRET_KEY: 'secret', + } as unknown as NodeJS.ProcessEnv), + ).toThrow(/S3_PORT/i); + }); + + it('throws when the configured bucket does not exist', async () => { + vi.resetModules(); + const bucketExists = vi.fn().mockResolvedValue(false); + + vi.doMock('minio', () => { + return { + Client: vi.fn().mockImplementation(() => ({ + bucketExists, + putObject: vi.fn(), + })), + }; + }); + + const { initFilesS3FromEnv, loadFiles } = await import('./files'); + + initFilesS3FromEnv({ + S3_HOST: 'example.com', + S3_BUCKET: 'bucket', + S3_PUBLIC_URL: 'https://cdn.example.com', + S3_ACCESS_KEY: 'access', + S3_SECRET_KEY: 'secret', + } as unknown as NodeJS.ProcessEnv); + + await expect(loadFiles()).rejects.toThrow(/bucket/i); + }); +}); + diff --git a/server/sources/storage/files.ts b/server/sources/storage/files.ts index 41189a9f1..bc9e2fa13 100644 --- a/server/sources/storage/files.ts +++ b/server/sources/storage/files.ts @@ -1,34 +1,115 @@ import * as Minio from 'minio'; +import { ensureLightFilesDir, getLightPublicUrl, readLightPublicFile, writeLightPublicFile } from '@/flavors/light/files'; -const s3Host = process.env.S3_HOST!; -const s3Port = process.env.S3_PORT ? parseInt(process.env.S3_PORT, 10) : undefined; -const s3UseSSL = process.env.S3_USE_SSL ? process.env.S3_USE_SSL === 'true' : true; +export type ImageRef = { + width: number; + height: number; + thumbhash: string; + path: string; +} -export const s3client = new Minio.Client({ - endPoint: s3Host, - port: s3Port, - useSSL: s3UseSSL, - accessKey: process.env.S3_ACCESS_KEY!, - secretKey: process.env.S3_SECRET_KEY!, -}); +export type PublicFilesBackend = { + init(): Promise<void>; + getPublicUrl(path: string): string; + writePublicFile(path: string, data: Uint8Array): Promise<void>; + readPublicFile?(path: string): Promise<Uint8Array>; +} + +let backend: PublicFilesBackend | null = null; -export const s3bucket = process.env.S3_BUCKET!; +export function initFilesS3FromEnv(env: NodeJS.ProcessEnv = process.env): void { + const s3Host = requiredEnv(env, 'S3_HOST'); + const s3PortRaw = env.S3_PORT?.trim(); + let s3Port: number | undefined; + if (s3PortRaw) { + const parsed = parseInt(s3PortRaw, 10); + if (!Number.isInteger(parsed) || parsed <= 0 || parsed > 65535) { + throw new Error(`Invalid S3_PORT: ${s3PortRaw}`); + } + s3Port = parsed; + } + const s3UseSSL = env.S3_USE_SSL ? env.S3_USE_SSL === 'true' : true; -export const s3host = process.env.S3_HOST! + const s3bucket = requiredEnv(env, 'S3_BUCKET'); + const s3public = requiredEnv(env, 'S3_PUBLIC_URL'); -export const s3public = process.env.S3_PUBLIC_URL!; + const s3client = new Minio.Client({ + endPoint: s3Host, + port: s3Port, + useSSL: s3UseSSL, + accessKey: requiredEnv(env, 'S3_ACCESS_KEY'), + secretKey: requiredEnv(env, 'S3_SECRET_KEY'), + }); -export async function loadFiles() { - await s3client.bucketExists(s3bucket); // Throws if bucket does not exist or is not accessible + backend = { + async init() { + const exists = await s3client.bucketExists(s3bucket); + if (!exists) { + throw new Error(`S3 bucket does not exist: ${s3bucket}`); + } + }, + getPublicUrl(path: string) { + return `${s3public}/${path}`; + }, + async writePublicFile(path: string, data: Uint8Array) { + await s3client.putObject(s3bucket, path, Buffer.from(data)); + }, + }; } -export function getPublicUrl(path: string) { - return `${s3public}/${path}`; +export function initFilesLocalFromEnv(env: NodeJS.ProcessEnv = process.env): void { + backend = { + async init() { + await ensureLightFilesDir(env); + }, + getPublicUrl(path: string) { + return getLightPublicUrl(env, path); + }, + async writePublicFile(path: string, data: Uint8Array) { + await writeLightPublicFile(env, path, data); + }, + async readPublicFile(path: string) { + return await readLightPublicFile(env, path); + } + }; } -export type ImageRef = { - width: number; - height: number; - thumbhash: string; - path: string; +export function hasPublicFileRead(): boolean { + return Boolean(backend && backend.readPublicFile); +} + +export async function loadFiles(): Promise<void> { + if (!backend) { + throw new Error('Files backend not initialized'); + } + await backend.init(); +} + +export function getPublicUrl(path: string): string { + if (!backend) { + throw new Error('Files backend not initialized'); + } + return backend.getPublicUrl(path); +} + +export async function writePublicFile(path: string, data: Uint8Array): Promise<void> { + if (!backend) { + throw new Error('Files backend not initialized'); + } + await backend.writePublicFile(path, data); +} + +export async function readPublicFile(path: string): Promise<Uint8Array> { + if (!backend?.readPublicFile) { + throw new Error('Public file read is not supported'); + } + return await backend.readPublicFile(path); +} + +function requiredEnv(env: NodeJS.ProcessEnv, key: string): string { + const v = env[key]?.trim(); + if (!v) { + throw new Error(`Missing required env var: ${key}`); + } + return v; } diff --git a/server/sources/storage/inTx.ts b/server/sources/storage/inTx.ts index 0f85e7201..9c5a891e4 100644 --- a/server/sources/storage/inTx.ts +++ b/server/sources/storage/inTx.ts @@ -1,8 +1,8 @@ -import { Prisma } from "@prisma/client"; import { delay } from "@/utils/delay"; import { db } from "@/storage/db"; +import { isPrismaErrorCode, type TransactionClient } from "@/storage/prisma"; -export type Tx = Prisma.TransactionClient; +export type Tx = TransactionClient; const symbol = Symbol(); @@ -31,14 +31,12 @@ export async function inTx<T>(fn: (tx: Tx) => Promise<T>): Promise<T> { } return result.result; } catch (e) { - if (e instanceof Prisma.PrismaClientKnownRequestError) { - if (e.code === 'P2034' && counter < 3) { - counter++; - await delay(counter * 100); - continue; - } + if (isPrismaErrorCode(e, "P2034") && counter < 3) { + counter++; + await delay(counter * 100); + continue; } throw e; } } -} \ No newline at end of file +} diff --git a/server/sources/storage/prisma.spec.ts b/server/sources/storage/prisma.spec.ts new file mode 100644 index 000000000..240fc5a19 --- /dev/null +++ b/server/sources/storage/prisma.spec.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { RelationshipStatus, db, isPrismaErrorCode } from "./prisma"; + +function parseEnumValues(schemaText: string, enumName: string): string[] { + const block = schemaText.match(new RegExp(`enum\\s+${enumName}\\s*\\{([\\s\\S]*?)\\}`, "m")); + if (!block?.[1]) { + throw new Error(`enum ${enumName} not found in schema`); + } + return block[1] + .split("\n") + .map((line) => line.trim()) + .filter((line) => line && !line.startsWith("//")) + .map((line) => line.split(/\s+/)[0]) + .filter(Boolean); +} + +describe("storage/prisma", () => { + it("throws a helpful error when db is accessed before initialization", () => { + // `db` is a proxy so simply importing it is fine; accessing properties should fail loudly until initDb* runs. + // Use a regex match to avoid brittle exact-string assertions. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect(() => (db as any).user).toThrow(/not initialized/i); + }); + + it("RelationshipStatus matches prisma/schema.prisma and prisma/sqlite/schema.prisma", () => { + const root = join(process.cwd()); + const fullSchema = readFileSync(join(root, "prisma", "schema.prisma"), "utf-8"); + const sqliteSchema = readFileSync(join(root, "prisma", "sqlite", "schema.prisma"), "utf-8"); + + const fullValues = parseEnumValues(fullSchema, "RelationshipStatus"); + const sqliteValues = parseEnumValues(sqliteSchema, "RelationshipStatus"); + + // sqlite schema is generated from full schema; these must stay identical. + expect(sqliteValues).toEqual(fullValues); + + const exportedValues = Object.values(RelationshipStatus); + expect(exportedValues.sort()).toEqual([...new Set(fullValues)].sort()); + }); + + it("detects Prisma-like error codes without relying on Prisma error classes", () => { + expect(isPrismaErrorCode({ code: "P2034" }, "P2034")).toBe(true); + expect(isPrismaErrorCode({ code: "P2002" }, "P2034")).toBe(false); + expect(isPrismaErrorCode(new Error("no code"), "P2034")).toBe(false); + expect(isPrismaErrorCode(null, "P2034")).toBe(false); + }); +}); diff --git a/server/sources/storage/prisma.ts b/server/sources/storage/prisma.ts new file mode 100644 index 000000000..f0734fc50 --- /dev/null +++ b/server/sources/storage/prisma.ts @@ -0,0 +1,101 @@ +import { Prisma, PrismaClient } from "@prisma/client"; + +export { Prisma }; +export type TransactionClient = Prisma.TransactionClient; +export type PrismaClientType = PrismaClient; + +export * from "./enums.generated"; + +let _db: PrismaClientType | null = null; + +export const db: PrismaClientType = new Proxy({} as PrismaClientType, { + get(_target, prop) { + if (!_db) { + if (prop === Symbol.toStringTag) return "PrismaClient"; + // Avoid accidental `await db` treating it like a thenable. + if (prop === "then") return undefined; + throw new Error("Database client is not initialized. Call initDbPostgres() or initDbSqlite() before using db."); + } + const value = (_db as any)[prop]; + return typeof value === "function" ? value.bind(_db) : value; + }, + set(_target, prop, value) { + if (!_db) { + throw new Error("Database client is not initialized. Call initDbPostgres() or initDbSqlite() before using db."); + } + (_db as any)[prop] = value; + return true; + }, +}) as PrismaClientType; + +export function initDbPostgres(): void { + _db = new PrismaClient(); +} + +export async function initDbSqlite(): Promise<void> { + const clientUrl = new URL("../../generated/sqlite-client/index.js", import.meta.url); + const mod: any = await import(clientUrl.toString()); + const SqlitePrismaClient: any = mod?.PrismaClient ?? mod?.default?.PrismaClient; + if (!SqlitePrismaClient) { + throw new Error("Failed to load sqlite PrismaClient (missing generated/sqlite-client)"); + } + const client = new SqlitePrismaClient() as PrismaClientType; + + // SQLite can throw transient "database is locked" / SQLITE_BUSY under concurrent writes, + // especially in CI where we spawn many sessions in parallel. Add a small retry layer and + // increase busy timeout to make light/sqlite a viable test backend. + const isSqliteBusyError = (err: unknown): boolean => { + const message = err instanceof Error ? err.message : String(err); + return message.includes("SQLITE_BUSY") || message.includes("database is locked"); + }; + + const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + + client.$use(async (params, next) => { + // Only retry writes (reads are generally safe and should fail fast if they fail). + const action = params.action; + const isWrite = + action === "create" || + action === "createMany" || + action === "update" || + action === "updateMany" || + action === "upsert" || + action === "delete" || + action === "deleteMany"; + + if (!isWrite) { + return await next(params); + } + + const maxRetries = 6; + let attempt = 0; + while (true) { + try { + return await next(params); + } catch (e) { + if (!isSqliteBusyError(e) || attempt >= maxRetries) { + throw e; + } + const backoffMs = 25 * Math.pow(2, attempt); + attempt += 1; + await sleep(backoffMs); + } + } + }); + + // These PRAGMAs are applied per connection; Prisma may use a pool, but even setting them once + // on startup helps CI stability. We keep the connection open; shutdown handler will disconnect. + await client.$connect(); + // NOTE: Some PRAGMAs (e.g. `journal_mode`) return results; use `$queryRaw*` to avoid P2010. + await client.$queryRawUnsafe("PRAGMA journal_mode=WAL"); + await client.$queryRawUnsafe("PRAGMA busy_timeout=5000"); + + _db = client; +} + +export function isPrismaErrorCode(err: unknown, code: string): boolean { + if (!err || typeof err !== "object") { + return false; + } + return (err as any).code === code; +} diff --git a/server/sources/storage/processImage.spec.ts b/server/sources/storage/processImage.spec.ts index d6babaaf5..c6e9d1555 100644 --- a/server/sources/storage/processImage.spec.ts +++ b/server/sources/storage/processImage.spec.ts @@ -1,10 +1,30 @@ -import * as fs from 'fs'; import { processImage } from './processImage'; -import { describe, it } from 'vitest'; +import { describe, expect, it } from 'vitest'; +import sharp from 'sharp'; describe('processImage', () => { - it('should resize image', async () => { - let img = fs.readFileSync(__dirname + '/__testdata__/image.jpg'); - let result = await processImage(img); + it('resizes pixel data and returns original dimensions', async () => { + const originalWidth = 200; + const originalHeight = 100; + const targetWidth = 100; + const targetHeight = 50; + + const img = await sharp({ + create: { + width: originalWidth, + height: originalHeight, + channels: 3, + background: { r: 255, g: 0, b: 0 }, + }, + }) + .jpeg() + .toBuffer(); + + const result = await processImage(img); + expect(result.format).toBe('jpeg'); + expect(result.width).toBe(originalWidth); + expect(result.height).toBe(originalHeight); + expect(result.pixels.length).toBe(targetWidth * targetHeight * 4); + expect(result.thumbhash.length).toBeGreaterThan(0); }); -}); \ No newline at end of file +}); diff --git a/server/sources/storage/uploadImage.ts b/server/sources/storage/uploadImage.ts index 3bcaffd44..f6df1f119 100644 --- a/server/sources/storage/uploadImage.ts +++ b/server/sources/storage/uploadImage.ts @@ -1,6 +1,6 @@ import { randomKey } from "@/utils/randomKey"; import { processImage } from "./processImage"; -import { s3bucket, s3client, s3host } from "./files"; +import { writePublicFile } from "./files"; import { db } from "./db"; export async function uploadImage(userId: string, directory: string, prefix: string, url: string, src: Buffer) { @@ -25,11 +25,12 @@ export async function uploadImage(userId: string, directory: string, prefix: str const processed = await processImage(src); const key = randomKey(prefix); let filename = `${key}.${processed.format === 'png' ? 'png' : 'jpg'}`; - await s3client.putObject(s3bucket, 'public/users/' + userId + '/' + directory + '/' + filename, src); + const path = `public/users/${userId}/${directory}/${filename}`; + await writePublicFile(path, src); await db.uploadedFile.create({ data: { accountId: userId, - path: `public/users/${userId}/${directory}/${filename}`, + path, reuseKey: 'image-url:' + url, width: processed.width, height: processed.height, @@ -37,13 +38,9 @@ export async function uploadImage(userId: string, directory: string, prefix: str } }); return { - path: `public/users/${userId}/${directory}/${filename}`, + path, thumbhash: processed.thumbhash, width: processed.width, height: processed.height } } - -export function resolveImageUrl(path: string) { - return `https://${s3host}/${s3bucket}/${path}`; -} \ No newline at end of file diff --git a/server/sources/utils/processHandlers.ts b/server/sources/utils/processHandlers.ts new file mode 100644 index 000000000..2a215529e --- /dev/null +++ b/server/sources/utils/processHandlers.ts @@ -0,0 +1,58 @@ +import { log } from '@/utils/log'; + +export function registerProcessHandlers(): void { + // Process-level error handling + process.on('uncaughtException', (error) => { + log({ + module: 'process-error', + level: 'error', + stack: error.stack, + name: error.name + }, `Uncaught Exception: ${error.message}`); + + console.error('Uncaught Exception:', error); + process.exit(1); + }); + + process.on('unhandledRejection', (reason, promise) => { + const errorMsg = reason instanceof Error ? reason.message : String(reason); + const errorStack = reason instanceof Error ? reason.stack : undefined; + + log({ + module: 'process-error', + level: 'error', + stack: errorStack, + reason: String(reason) + }, `Unhandled Rejection: ${errorMsg}`); + + console.error('Unhandled Rejection at:', promise, 'reason:', reason); + process.exit(1); + }); + + process.on('warning', (warning) => { + log({ + module: 'process-warning', + level: 'warn', + name: warning.name, + stack: warning.stack + }, `Process Warning: ${warning.message}`); + }); + + // Log when the process is about to exit + process.on('exit', (code) => { + if (code !== 0) { + log({ + module: 'process-exit', + level: 'error', + exitCode: code + }, `Process exiting with code: ${code}`); + } else { + log({ + module: 'process-exit', + level: 'info', + exitCode: code + }, 'Process exiting normally'); + } + }); +} + diff --git a/server/tsconfig.json b/server/tsconfig.json index 5fa4afc87..58f53260e 100755 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -5,7 +5,7 @@ /* Basic Options */ // "incremental": true, /* Enable incremental compilation */ "target": "ESNext", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', 'ES2021', or 'ESNEXT'. */ - "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */ + "module": "ESNext", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */ "lib": ["es2018", "esnext.asynciterable"], // "allowJs": true, /* Allow javascript files to be compiled. */ // "checkJs": true, /* Report errors in .js files. */ @@ -44,7 +44,7 @@ // "noPropertyAccessFromIndexSignature": true, /* Require undeclared properties from index signatures to use element accesses. */ /* Module Resolution Options */ - // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ + "moduleResolution": "bundler", // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ diff --git a/server/vitest.config.ts b/server/vitest.config.ts index 11345d16f..f6044fb40 100644 --- a/server/vitest.config.ts +++ b/server/vitest.config.ts @@ -1,11 +1,25 @@ import { defineConfig } from 'vitest/config'; import tsconfigPaths from 'vite-tsconfig-paths'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); export default defineConfig({ test: { globals: true, environment: 'node', include: ['**/*.test.ts', '**/*.spec.ts'], + env: { + S3_HOST: 'localhost', + S3_PORT: '9000', + S3_USE_SSL: 'false', + S3_ACCESS_KEY: 'test', + S3_SECRET_KEY: 'test', + S3_BUCKET: 'test' + } }, - plugins: [tsconfigPaths()] -}); \ No newline at end of file + // Restrict tsconfig resolution to server only. + // Otherwise vite-tsconfig-paths may scan the repo and attempt to parse Expo tsconfigs. + plugins: [tsconfigPaths({ projects: [resolve(__dirname, './tsconfig.json')] })] +}); diff --git a/server/yarn.lock b/server/yarn.lock index c9500db5b..542658727 100644 --- a/server/yarn.lock +++ b/server/yarn.lock @@ -331,6 +331,23 @@ "@fastify/forwarded" "^3.0.0" ipaddr.js "^2.1.0" +"@fastify/rate-limit@^10.3.0": + version "10.3.0" + resolved "https://registry.yarnpkg.com/@fastify/rate-limit/-/rate-limit-10.3.0.tgz#3cf6a56c0e3dd18fc0a56727675d7ba1d9a9bd7b" + integrity sha512-eIGkG9XKQs0nyynatApA3EVrojHOuq4l6fhB4eeCk4PIOeadvOJz9/4w3vGI44Go17uaXOWEcPkaD8kuKm7g6Q== + dependencies: + "@lukeed/ms" "^2.0.2" + fastify-plugin "^5.0.0" + toad-cache "^3.7.0" + +"@happy/agents@link:../packages/agents": + version "0.0.0" + uid "" + +"@happy/protocol@link:../packages/protocol": + version "0.0.0" + uid "" + "@img/sharp-darwin-arm64@0.34.3": version "0.34.3" resolved "https://registry.yarnpkg.com/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.3.tgz#4850c8ace3c1dc13607fa07d43377b1f9aa774da" @@ -489,6 +506,11 @@ "@jridgewell/resolve-uri" "^3.0.3" "@jridgewell/sourcemap-codec" "^1.4.10" +"@lukeed/ms@^2.0.2": + version "2.0.2" + resolved "https://registry.yarnpkg.com/@lukeed/ms/-/ms-2.0.2.tgz#07f09e59a74c52f4d88c6db5c1054e819538e2a8" + integrity sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA== + "@msgpack/msgpack@~2.8.0": version "2.8.0" resolved "https://registry.yarnpkg.com/@msgpack/msgpack/-/msgpack-2.8.0.tgz#4210deb771ee3912964f14a15ddfb5ff877e70b9" diff --git a/yarn.lock b/yarn.lock index 343428a63..ec627ca16 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1493,6 +1493,14 @@ resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.10.tgz#a2a1e3812d14525f725d011a73eceb41fef5bc1c" integrity sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ== +"@happy/agents@link:packages/agents": + version "0.0.0" + +"@happy/protocol@link:packages/protocol": + version "0.0.0" + dependencies: + "@happy/agents" "link:packages/agents" + "@iconify/types@^2.0.0": version "2.0.0" resolved "https://registry.yarnpkg.com/@iconify/types/-/types-2.0.0.tgz#ab0e9ea681d6c8a1214f30cd741fe3a20cc57f57" @@ -2954,6 +2962,13 @@ dependencies: "@types/react" "*" +"@types/react-test-renderer@^19.1.0": + version "19.1.0" + resolved "https://registry.yarnpkg.com/@types/react-test-renderer/-/react-test-renderer-19.1.0.tgz#1d0af8f2e1b5931e245b8b5b234d1502b854dc10" + integrity sha512-XD0WZrHqjNrxA/MaR9O22w/RNidWR9YZmBdRGI7wcnWGrv/3dA8wKCJ8m63Sn+tLJhcjmuhOi629N66W6kgWzQ== + dependencies: + "@types/react" "*" + "@types/react@*": version "19.2.8" resolved "https://registry.yarnpkg.com/@types/react/-/react-19.2.8.tgz#307011c9f5973a6abab8e17d0293f48843627994" @@ -5260,10 +5275,10 @@ expo-print@~15.0.7: resolved "https://registry.yarnpkg.com/expo-print/-/expo-print-15.0.8.tgz#596c9f2302fb68d2db682f6f3e0c6596930b59dc" integrity sha512-4O0Qzm0On5AmJIl9d+BT+ieTipFp658nHI4aX7vKEFPfj3dfQxG6rDJJpca+rrc9c4Ha8ZFYGvxJG5+4lFq2Pw== -expo-router@~6.0.7: - version "6.0.21" - resolved "https://registry.yarnpkg.com/expo-router/-/expo-router-6.0.21.tgz#5920269224c7817f23a55df43ea01d1f7cba9172" - integrity sha512-wjTUjrnWj6gRYjaYl1kYfcRnNE4ZAQ0kz0+sQf6/mzBd/OU6pnOdD7WrdAW3pTTpm52Q8sMoeX98tNQEddg2uA== +expo-router@6.0.22: + version "6.0.22" + resolved "https://registry.yarnpkg.com/expo-router/-/expo-router-6.0.22.tgz#d77b5af4ddfbd742375cca1f23b080c69e69841d" + integrity sha512-6eOwobaVZQRsSQv0IoWwVlPbJru1zbreVsuPFIWwk7HApENStU2MggrceHXJqXjGho+FKeXxUop/gqOFDzpOMg== dependencies: "@expo/metro-runtime" "^6.1.2" "@expo/schema-utils" "^0.1.8" @@ -6623,14 +6638,21 @@ libsodium-wrappers-sumo@^0.7.13: dependencies: libsodium-sumo "^0.7.16" -libsodium-wrappers@^0.7.13, libsodium-wrappers@^0.7.15: +libsodium-wrappers@0.7.15: + version "0.7.15" + resolved "https://registry.yarnpkg.com/libsodium-wrappers/-/libsodium-wrappers-0.7.15.tgz#53f13e483820272a3d55b23be2e34402ac988055" + integrity sha512-E4anqJQwcfiC6+Yrl01C1m8p99wEhLmJSs0VQqST66SbQXXBoaJY0pF4BNjRYa/sOQAxx6lXAaAFIlx+15tXJQ== + dependencies: + libsodium "^0.7.15" + +libsodium-wrappers@^0.7.13: version "0.7.16" resolved "https://registry.yarnpkg.com/libsodium-wrappers/-/libsodium-wrappers-0.7.16.tgz#abaa065e914562695c6c1d66527c8e72bbbaec15" integrity sha512-Gtr/WBx4dKjvRL1pvfwZqu7gO6AfrQ0u9vFL+kXihtHf6NfkROR8pjYWn98MFDI3jN19Ii1ZUfPR9afGiPyfHg== dependencies: libsodium "^0.7.16" -libsodium@^0.7.16: +libsodium@^0.7.15, libsodium@^0.7.16: version "0.7.16" resolved "https://registry.yarnpkg.com/libsodium/-/libsodium-0.7.16.tgz#3d4f9d68ed887bb8bf2e76bb3ba231265eae58a0" integrity sha512-3HrzSPuzm6Yt9aTYCDxYEG8x8/6C0+ag655Y7rhhWZM9PT4NpdnbqlzXhGZlDnkgR6MeSTnOt/VIyHLs9aSf+Q== @@ -7912,7 +7934,7 @@ react-is@^18.0.0: resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.3.1.tgz#e83557dc12eae63a99e003a46388b1dcbb44db7e" integrity sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg== -react-is@^19.0.0, react-is@^19.1.0: +react-is@^19.1.0: version "19.2.3" resolved "https://registry.yarnpkg.com/react-is/-/react-is-19.2.3.tgz#eec2feb69c7fb31f77d0b5c08c10ae1c88886b29" integrity sha512-qJNJfu81ByyabuG7hPFEbXqNcWSU3+eVus+KJs+0ncpGfMyYdvSmxiJxbWR65lYi1I+/0HBcliO029gc4F+PnA== @@ -8203,13 +8225,13 @@ react-syntax-highlighter@^15.6.1: prismjs "^1.30.0" refractor "^3.6.0" -react-test-renderer@19.0.0: - version "19.0.0" - resolved "https://registry.yarnpkg.com/react-test-renderer/-/react-test-renderer-19.0.0.tgz#ca6fa322c58d4bfa34635788fe242a8c3daa4c7d" - integrity sha512-oX5u9rOQlHzqrE/64CNr0HB0uWxkCQmZNSfozlYvwE71TLVgeZxVf0IjouGEr1v7r1kcDifdAJBeOhdhxsG/DA== +react-test-renderer@19.1.0: + version "19.1.0" + resolved "https://registry.yarnpkg.com/react-test-renderer/-/react-test-renderer-19.1.0.tgz#89e1baa9e45a6da064b9760f92251d5b8e1f34ab" + integrity sha512-jXkSl3CpvPYEF+p/eGDLB4sPoDX8pKkYvRl9+rR8HxLY0X04vW7hCm1/0zHoUSjPZ3bDa+wXWNTDVIw/R8aDVw== dependencies: - react-is "^19.0.0" - scheduler "^0.25.0" + react-is "^19.1.0" + scheduler "^0.26.0" react-textarea-autosize@^8.5.9: version "8.5.9" @@ -9332,7 +9354,7 @@ typed-emitter@^2.1.0: optionalDependencies: rxjs "^7.5.2" -typescript@~5.9.2: +typescript@^5.9.2, typescript@~5.9.2: version "5.9.3" resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.9.3.tgz#5b4f59e15310ab17a216f5d6cf53ee476ede670f" integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==