Compare commits
70 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ef4fa61c7c | |||
| f81c5523cb | |||
| 6ffb3ed732 | |||
| f2d54c2aab | |||
| d75626e748 | |||
| 96a3da7920 | |||
| c0b7399799 | |||
| 384ddcc6c6 | |||
| 3013eba3d1 | |||
| eae3516043 | |||
| 12057be31b | |||
| d685a39bfa | |||
| cd5233de02 | |||
| c538476f3a | |||
| 7732835581 | |||
| 898a65aa79 | |||
| 54c5382a31 | |||
| 65abadd46f | |||
| 2336aa50dd | |||
| b4779a0070 | |||
| 7bba2b4d52 | |||
| 3b9cc53697 | |||
| 3d623ff60c | |||
| 66074af7ae | |||
| 260586a488 | |||
| 4f03d6620f | |||
| 61ca230947 | |||
| 751daa1be2 | |||
| 74e8cfab92 | |||
| 47453c357f | |||
| d7fc65946e | |||
| 9a4473f757 | |||
| bf2eeb122e | |||
| cf01cccb9f | |||
| 46879da7cb | |||
| 4a685d902e | |||
| b4d54d147a | |||
| df5f4ae985 | |||
| f2e148c998 | |||
| 3367a690f6 | |||
| e5701fdd7f | |||
| 18167c9d92 | |||
| 67238a75b6 | |||
| b23e277c2b | |||
| dbb75ab3d8 | |||
| 23e3d555d1 | |||
| 0c966bf612 | |||
| 1f1a4cc8cf | |||
| 6dd9809a7c | |||
| e2ab42ace6 | |||
| f992ecd0bc | |||
| 9cdcdd4b67 | |||
| d586eb8eb6 | |||
| a9d783619e | |||
| 151db22770 | |||
| 8590dce828 | |||
| f0c371e1d2 | |||
| 83aa7a97ca | |||
| a6095b288d | |||
| e9b7da2cdc | |||
| da73e51f50 | |||
| c2880e60c5 | |||
| 6e52224ae2 | |||
| 693ddc614c | |||
| 3675d8461e | |||
| 17ed40684c | |||
| 24cb72cd5a | |||
| ace855feca | |||
| 992a458af2 | |||
| ee1a604ed8 |
@@ -0,0 +1,226 @@
|
||||
name: "Run e2e suite"
|
||||
description: >
|
||||
Run the tests/e2e suite exactly as the e2e.yml gate does (mock LLM,
|
||||
sharded). When `server_version` is set, the omnigent SERVER subprocess is
|
||||
pinned to that released tag (built into an isolated venv) while the client,
|
||||
runner, and tests stay on the checked-out ref — the server-version
|
||||
backwards-compat configuration. Shared verbatim by e2e.yml (normal gate) and
|
||||
server-compat.yml (backcompat jobs) so the two never drift. The caller is
|
||||
responsible for the preceding `actions/checkout` (the checkout ref differs:
|
||||
the gate tests refs/pull/N/merge; backcompat needs fetch-depth 0 for tags).
|
||||
|
||||
inputs:
|
||||
shard_id:
|
||||
description: "pytest-shard shard index"
|
||||
required: true
|
||||
num_shards:
|
||||
description: "pytest-shard shard count"
|
||||
required: true
|
||||
parallelism:
|
||||
description: "pytest workers (-n)"
|
||||
required: false
|
||||
default: "2"
|
||||
nightly_full:
|
||||
description: "true = full pass (schedule/dispatch); false = exclude @nightly"
|
||||
required: false
|
||||
default: "false"
|
||||
server_version:
|
||||
description: >
|
||||
Empty = run the checked-out server (normal gate). Set to a release tag
|
||||
(e.g. v0.1.1) = build that old server into a venv and redirect the
|
||||
server subprocess to it (backwards-compat run).
|
||||
required: false
|
||||
default: ""
|
||||
runner_version:
|
||||
description: >
|
||||
Empty = run the checked-out runner/host (normal gate). Set to a release
|
||||
tag = build that old runner+host into a venv and redirect the runner and
|
||||
host-daemon subprocesses to it (Config 2 backwards-compat run). Orthogonal
|
||||
to server_version.
|
||||
required: false
|
||||
default: ""
|
||||
artifact_suffix:
|
||||
description: >
|
||||
Appended to uploaded-artifact names so they stay unique across matrix
|
||||
cells (e.g. "-sv0.2.0-rmain"). Default empty — the normal gate has one
|
||||
cell per shard, so its names are already unique.
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Configure environment
|
||||
shell: bash
|
||||
run: |
|
||||
# Self-contained so the action behaves identically regardless of the
|
||||
# caller's env. No ap-web SPA build during installs (this job never
|
||||
# serves the bundle); blank provider keys so a spawned server can't
|
||||
# pick up the runner's own credentials.
|
||||
{
|
||||
echo "OMNIGENT_SKIP_WEB_UI=true"
|
||||
echo "ANTHROPIC_API_KEY="
|
||||
echo "OPENAI_API_KEY="
|
||||
echo "CODEX="
|
||||
echo "CLAUDE_CODE="
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@38f3f104447c67c051c4a08e39b64a148898af3a # v4
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
- name: Cache virtualenv
|
||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4
|
||||
with:
|
||||
path: .venv
|
||||
key: venv-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('uv.lock') }}
|
||||
|
||||
- name: Install project and dev dependencies
|
||||
shell: bash
|
||||
run: uv sync --locked --extra all --extra dev
|
||||
|
||||
- name: Install binary dependencies
|
||||
# npm install against .github/ci-deps/package.json with --ignore-scripts
|
||||
# to block postinstall on every package. The claude-code stub binary
|
||||
# needs its install.cjs (audited: platform detect + same-tree hardlink,
|
||||
# no network/exec) so we run that one explicitly; codex and pi have no
|
||||
# install scripts and ship prebuilt CLIs. bubblewrap: the linux_bwrap
|
||||
# sandbox backend fails loud if `bwrap` is missing, and the e2e runner
|
||||
# runs real agents with os_env. The apparmor sysctl mirrors ci.yml
|
||||
# (Ubuntu 24.04 blocks unprivileged user namespaces, which bwrap's
|
||||
# unshare(CLONE_NEWUSER) needs).
|
||||
working-directory: .github/ci-deps
|
||||
shell: bash
|
||||
run: |
|
||||
sudo apt-get install -y tmux bubblewrap
|
||||
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
|
||||
npm install --ignore-scripts
|
||||
node node_modules/@anthropic-ai/claude-code/install.cjs
|
||||
echo "${GITHUB_WORKSPACE}/.github/ci-deps/node_modules/.bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Build pinned old server (backwards-compat only)
|
||||
# Only runs when server_version is set. Builds the released tag into an
|
||||
# isolated venv (all three packages editable so the old ==<old> SDK
|
||||
# cross-pins resolve without an index) and points the server subprocess
|
||||
# at it via OMNIGENT_COMPAT_SERVER_PYTHON. The redirect also drops the
|
||||
# worktree PYTHONPATH/CWD shadow (see tests/_helpers/compat.py) so the
|
||||
# pinned install actually resolves. Requires fetch-depth 0 in the caller.
|
||||
if: ${{ inputs.server_version != '' }}
|
||||
shell: bash
|
||||
env:
|
||||
SERVER_VERSION_INPUT: ${{ inputs.server_version }}
|
||||
run: |
|
||||
tag="$SERVER_VERSION_INPUT"
|
||||
if ! [[ "$tag" =~ ^v?[0-9]+\.[0-9]+(\.[0-9]+)?([a-z0-9.]*)?$ ]]; then
|
||||
echo "Invalid server_version: '$tag'" >&2; exit 1
|
||||
fi
|
||||
src="$RUNNER_TEMP/server-src"
|
||||
venv="$RUNNER_TEMP/server-env"
|
||||
git worktree add --detach "$src" "$tag"
|
||||
uv venv --python 3.12 "$venv"
|
||||
uv pip install --python "$venv/bin/python" \
|
||||
-e "$src" -e "$src/sdks/python-client" -e "$src/sdks/ui"
|
||||
"$venv/bin/omnigent" --version
|
||||
echo "OMNIGENT_COMPAT_SERVER_PYTHON=$venv/bin/python" >> "$GITHUB_ENV"
|
||||
echo "OMNIGENT_COMPAT_SERVER_VERSION=${tag#v}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build pinned old runner/host (backwards-compat only)
|
||||
# Only runs when runner_version is set (Config 2). Builds the released tag
|
||||
# into an isolated venv and points the runner + host-daemon subprocesses
|
||||
# at it via OMNIGENT_COMPAT_RUNNER_PYTHON (apply_runner_env drops the
|
||||
# worktree PYTHONPATH/CWD shadow). Distinct paths from the server build so
|
||||
# both can coexist. Requires fetch-depth 0 in the caller.
|
||||
if: ${{ inputs.runner_version != '' }}
|
||||
shell: bash
|
||||
env:
|
||||
RUNNER_VERSION_INPUT: ${{ inputs.runner_version }}
|
||||
run: |
|
||||
tag="$RUNNER_VERSION_INPUT"
|
||||
if ! [[ "$tag" =~ ^v?[0-9]+\.[0-9]+(\.[0-9]+)?([a-z0-9.]*)?$ ]]; then
|
||||
echo "Invalid runner_version: '$tag'" >&2; exit 1
|
||||
fi
|
||||
src="$RUNNER_TEMP/runner-src"
|
||||
venv="$RUNNER_TEMP/runner-env"
|
||||
git worktree add --detach "$src" "$tag"
|
||||
uv venv --python 3.12 "$venv"
|
||||
uv pip install --python "$venv/bin/python" \
|
||||
-e "$src" -e "$src/sdks/python-client" -e "$src/sdks/ui"
|
||||
"$venv/bin/omnigent" --version
|
||||
echo "OMNIGENT_COMPAT_RUNNER_PYTHON=$venv/bin/python" >> "$GITHUB_ENV"
|
||||
echo "OMNIGENT_COMPAT_RUNNER_VERSION=${tag#v}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Run e2e tests
|
||||
shell: bash
|
||||
env:
|
||||
PARALLELISM_INPUT: ${{ inputs.parallelism }}
|
||||
SHARD_ID: ${{ inputs.shard_id }}
|
||||
NUM_SHARDS: ${{ inputs.num_shards }}
|
||||
NIGHTLY_FULL: ${{ inputs.nightly_full }}
|
||||
E2E_TMP_BASE: /tmp/omnigent-e2e-${{ github.run_id }}-shard${{ inputs.shard_id }}
|
||||
PYTEST_PROGRESS_LOG_DIR: /tmp/omnigent-e2e-${{ github.run_id }}-shard${{ inputs.shard_id }}/progress
|
||||
OMNIGENT_TOKEN_USAGE_JSON: /tmp/omnigent-e2e-${{ github.run_id }}-shard${{ inputs.shard_id }}/tokens.json
|
||||
run: |
|
||||
# Validate parallelism (untrusted input -- bind to env, never
|
||||
# interpolate a GitHub expression into the shell).
|
||||
if ! [[ "$PARALLELISM_INPUT" =~ ^[1-9][0-9]?$ ]]; then
|
||||
echo "Invalid parallelism input: $PARALLELISM_INPUT (expected 1-99)" >&2
|
||||
exit 1
|
||||
fi
|
||||
WORKERS="$PARALLELISM_INPUT"
|
||||
mkdir -p "$E2E_TMP_BASE"
|
||||
|
||||
EXTRA_ARGS=()
|
||||
if [[ "$NIGHTLY_FULL" != "true" ]]; then
|
||||
EXTRA_ARGS+=(-m "not nightly")
|
||||
fi
|
||||
|
||||
# --junitxml emits per-test results eagerly so diagnostics survive a
|
||||
# wall-clock overrun. --shard-id/--num-shards chunk the node IDs.
|
||||
# --timeout=180 caps each test; --timeout-method=thread because our
|
||||
# pty/subprocess children don't get SIGALRM. --max-worker-restart=0
|
||||
# fails the shard fast instead of letting loadscope requeue deadlock
|
||||
# the controller (the 2026-06-11 shard-2 wedge).
|
||||
uv run pytest tests/e2e/ \
|
||||
-n "$WORKERS" \
|
||||
--dist=loadscope \
|
||||
--max-worker-restart=0 \
|
||||
--shard-id="$SHARD_ID" \
|
||||
--num-shards="$NUM_SHARDS" \
|
||||
--timeout=180 \
|
||||
--timeout-method=thread \
|
||||
--basetemp="$E2E_TMP_BASE" \
|
||||
--junitxml="$E2E_TMP_BASE/junit.xml" \
|
||||
-v --tb=long --showlocals --log-level=INFO -r a \
|
||||
"${EXTRA_ARGS[@]}" \
|
||||
|| { rc=$?; [ "$rc" -eq 5 ] && echo "::notice::No tests collected in this shard; treating as a pass." || exit "$rc"; }
|
||||
|
||||
- name: Upload server logs on failure
|
||||
# cancelled() too: failure() misses step timeouts (#426).
|
||||
if: ${{ failure() || cancelled() }}
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: e2e-server-logs-${{ github.run_id }}-shard${{ inputs.shard_id }}${{ inputs.artifact_suffix }}
|
||||
path: |
|
||||
/tmp/omnigent-e2e-${{ github.run_id }}-shard${{ inputs.shard_id }}/**/server.log
|
||||
/tmp/omnigent-e2e-${{ github.run_id }}-shard${{ inputs.shard_id }}/**/runner.log
|
||||
/tmp/omnigent-e2e-${{ github.run_id }}-shard${{ inputs.shard_id }}/**/.omnigent/logs/**/*.log
|
||||
/tmp/omnigent-e2e-${{ github.run_id }}-shard${{ inputs.shard_id }}/junit.xml
|
||||
/tmp/omnigent-e2e-${{ github.run_id }}-shard${{ inputs.shard_id }}/progress/progress-*.log
|
||||
retention-days: 7
|
||||
if-no-files-found: warn
|
||||
include-hidden-files: true
|
||||
|
||||
- name: Upload token usage
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: e2e-tokens-${{ github.run_id }}-shard${{ inputs.shard_id }}${{ inputs.artifact_suffix }}
|
||||
path: /tmp/omnigent-e2e-${{ github.run_id }}-shard${{ inputs.shard_id }}/tokens*.json
|
||||
retention-days: 14
|
||||
if-no-files-found: warn
|
||||
@@ -0,0 +1,187 @@
|
||||
name: "Run integration suite"
|
||||
description: >
|
||||
Run the tests/integration journey suite exactly as the integration.yml gate
|
||||
does (mock LLM, one wrapped harness per invocation). When `server_version`
|
||||
is set, the omnigent SERVER subprocess is pinned to that released tag while
|
||||
the client, runner, and tests stay on the checked-out ref — the
|
||||
server-version backwards-compat configuration. Shared verbatim by
|
||||
integration.yml (normal gate) and server-compat.yml (backcompat jobs) so the
|
||||
two never drift. The caller owns the preceding `actions/checkout` (backcompat
|
||||
needs fetch-depth 0 for tags).
|
||||
|
||||
inputs:
|
||||
harness:
|
||||
description: "Wrapped harness (claude-sdk | openai-agents | codex)"
|
||||
required: true
|
||||
model:
|
||||
description: "Model name passed to --model"
|
||||
required: true
|
||||
workers:
|
||||
description: "pytest workers (-n)"
|
||||
required: true
|
||||
server_version:
|
||||
description: >
|
||||
Empty = run the checked-out server (normal gate). Set to a release tag
|
||||
= build that old server into a venv and redirect the server subprocess
|
||||
to it (backwards-compat run).
|
||||
required: false
|
||||
default: ""
|
||||
runner_version:
|
||||
description: >
|
||||
Empty = run the checked-out runner (normal gate). Set to a release tag =
|
||||
build that old runner into a venv and redirect the runner subprocess to it
|
||||
(Config 2 backwards-compat run). Orthogonal to server_version.
|
||||
required: false
|
||||
default: ""
|
||||
artifact_suffix:
|
||||
description: >
|
||||
Appended to uploaded-artifact names so they stay unique across matrix
|
||||
cells (e.g. "-sv0.2.0-rmain"). Default empty — the normal gate runs one
|
||||
cell, so its harness-scoped names are already unique.
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Configure environment
|
||||
shell: bash
|
||||
run: |
|
||||
{
|
||||
echo "OMNIGENT_SKIP_WEB_UI=true"
|
||||
echo "ANTHROPIC_API_KEY="
|
||||
echo "OPENAI_API_KEY="
|
||||
echo "CODEX="
|
||||
echo "CLAUDE_CODE="
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@38f3f104447c67c051c4a08e39b64a148898af3a # v4
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
- name: Cache virtualenv
|
||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4
|
||||
with:
|
||||
path: .venv
|
||||
key: venv-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('uv.lock') }}
|
||||
|
||||
- name: Install project and dev dependencies
|
||||
shell: bash
|
||||
run: uv sync --locked --extra all --extra dev
|
||||
|
||||
- name: Install binary dependencies
|
||||
# Mirrors e2e.yml. --ignore-scripts blocks npm postinstall hooks; we run
|
||||
# claude-code's install.cjs explicitly (audited, no network). bubblewrap
|
||||
# backs the linux_bwrap sandbox in tests/inner/*.
|
||||
working-directory: .github/ci-deps
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y tmux ripgrep bubblewrap
|
||||
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
|
||||
npm install --ignore-scripts
|
||||
node node_modules/@anthropic-ai/claude-code/install.cjs
|
||||
echo "${GITHUB_WORKSPACE}/.github/ci-deps/node_modules/.bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Build pinned old server (backwards-compat only)
|
||||
# See e2e-run for the full rationale. Requires fetch-depth 0 in the caller.
|
||||
if: ${{ inputs.server_version != '' }}
|
||||
shell: bash
|
||||
env:
|
||||
SERVER_VERSION_INPUT: ${{ inputs.server_version }}
|
||||
run: |
|
||||
tag="$SERVER_VERSION_INPUT"
|
||||
if ! [[ "$tag" =~ ^v?[0-9]+\.[0-9]+(\.[0-9]+)?([a-z0-9.]*)?$ ]]; then
|
||||
echo "Invalid server_version: '$tag'" >&2; exit 1
|
||||
fi
|
||||
src="$RUNNER_TEMP/server-src"
|
||||
venv="$RUNNER_TEMP/server-env"
|
||||
git worktree add --detach "$src" "$tag"
|
||||
uv venv --python 3.12 "$venv"
|
||||
uv pip install --python "$venv/bin/python" \
|
||||
-e "$src" -e "$src/sdks/python-client" -e "$src/sdks/ui"
|
||||
"$venv/bin/omnigent" --version
|
||||
echo "OMNIGENT_COMPAT_SERVER_PYTHON=$venv/bin/python" >> "$GITHUB_ENV"
|
||||
echo "OMNIGENT_COMPAT_SERVER_VERSION=${tag#v}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build pinned old runner (backwards-compat only)
|
||||
# Config 2: redirect the runner subprocess to the pinned old build via
|
||||
# OMNIGENT_COMPAT_RUNNER_PYTHON. See e2e-run for the full rationale.
|
||||
# Distinct paths from the server build. Requires fetch-depth 0 in the caller.
|
||||
if: ${{ inputs.runner_version != '' }}
|
||||
shell: bash
|
||||
env:
|
||||
RUNNER_VERSION_INPUT: ${{ inputs.runner_version }}
|
||||
run: |
|
||||
tag="$RUNNER_VERSION_INPUT"
|
||||
if ! [[ "$tag" =~ ^v?[0-9]+\.[0-9]+(\.[0-9]+)?([a-z0-9.]*)?$ ]]; then
|
||||
echo "Invalid runner_version: '$tag'" >&2; exit 1
|
||||
fi
|
||||
src="$RUNNER_TEMP/runner-src"
|
||||
venv="$RUNNER_TEMP/runner-env"
|
||||
git worktree add --detach "$src" "$tag"
|
||||
uv venv --python 3.12 "$venv"
|
||||
uv pip install --python "$venv/bin/python" \
|
||||
-e "$src" -e "$src/sdks/python-client" -e "$src/sdks/ui"
|
||||
"$venv/bin/omnigent" --version
|
||||
echo "OMNIGENT_COMPAT_RUNNER_PYTHON=$venv/bin/python" >> "$GITHUB_ENV"
|
||||
echo "OMNIGENT_COMPAT_RUNNER_VERSION=${tag#v}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Run integration tests
|
||||
shell: bash
|
||||
env:
|
||||
HARNESS: ${{ inputs.harness }}
|
||||
MODEL: ${{ inputs.model }}
|
||||
WORKERS: ${{ inputs.workers }}
|
||||
INTEGRATION_TMP_BASE: /tmp/omnigent-integration-${{ github.run_id }}-${{ inputs.harness }}
|
||||
CLAUDE_CODE_STREAM_CLOSE_TIMEOUT: "60000"
|
||||
OMNIGENT_CLAUDE_SDK_NO_SANDBOX: ${{ inputs.harness == 'claude-sdk' && '1' || '' }}
|
||||
PYTEST_PROGRESS_LOG_DIR: ${{ github.workspace }}/artifacts/progress-${{ inputs.harness }}
|
||||
OMNIGENT_TOKEN_USAGE_JSON: ${{ github.workspace }}/artifacts/tokens-${{ inputs.harness }}.json
|
||||
OMNIGENT_TEST_MODEL_SPREAD: "1"
|
||||
OMNIGENT_TEST_MODEL_POOL_GPT: "databricks-gpt-5-5,databricks-gpt-5-4-mini"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p artifacts "$INTEGRATION_TMP_BASE"
|
||||
# --capture=no + --log-cli-level=INFO stream live so progress shows
|
||||
# even if the step hits its timeout before buffered output renders.
|
||||
# --timeout=180 caps a single hung test (see e2e.yml).
|
||||
env -u OPENAI_API_KEY -u ANTHROPIC_API_KEY -u DATABRICKS_TOKEN \
|
||||
uv run pytest tests/integration/ \
|
||||
--model "$MODEL" \
|
||||
--harness "$HARNESS" \
|
||||
-n "$WORKERS" \
|
||||
--dist=loadscope \
|
||||
--timeout=180 \
|
||||
--timeout-method=thread \
|
||||
--basetemp="$INTEGRATION_TMP_BASE" \
|
||||
--junitxml="artifacts/integration-${HARNESS}.xml" \
|
||||
--capture=no --log-cli-level=INFO \
|
||||
-v --tb=long --showlocals --log-level=INFO -r a
|
||||
|
||||
- name: Upload server/runner logs on failure
|
||||
if: ${{ failure() || cancelled() }}
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: integration-server-logs-${{ inputs.harness }}-${{ github.run_id }}${{ inputs.artifact_suffix }}
|
||||
path: |
|
||||
/tmp/omnigent-integration-${{ github.run_id }}-${{ inputs.harness }}/**/server.log
|
||||
/tmp/omnigent-integration-${{ github.run_id }}-${{ inputs.harness }}/**/runner.log
|
||||
retention-days: 7
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Upload junit + logs
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: integration-${{ inputs.harness }}-${{ github.run_id }}${{ inputs.artifact_suffix }}
|
||||
path: artifacts/
|
||||
retention-days: 14
|
||||
if-no-files-found: ignore
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
#!/usr/bin/env bash
|
||||
# Emit the FULL pairwise (server, runner) backwards-compat matrices on
|
||||
# $GITHUB_OUTPUT as `e2e_matrix` and `integration_matrix`.
|
||||
#
|
||||
# The version universe is `main` (the checked-out code = client + tests, always)
|
||||
# plus every non-rc release tag AT OR ABOVE the backcompat floor (MIN_VERSION,
|
||||
# default 0.2.0 — the first release with the mock-LLM e2e infra; see below).
|
||||
# We cross every server version with every runner
|
||||
# version — each cell pins the server and/or runner subprocess to that build
|
||||
# (an empty/"main" value leaves that component on the checked-out code). The
|
||||
# (main, main) cell is omitted: it pins nothing and is exactly the normal e2e
|
||||
# gate. Integration is the single openai-agents leg (claude-sdk/codex reject the
|
||||
# mock LLM's "mock-model" — see integration-matrix.sh), crossed with the pairs.
|
||||
#
|
||||
# Env in:
|
||||
# VERSIONS optional comma-separated override of the version set used for
|
||||
# BOTH axes (e.g. "main,v0.2.0"). Empty = main + all non-rc tags.
|
||||
# Blank entries are dropped and surrounding whitespace trimmed.
|
||||
# NUM_SHARDS e2e shard count per cell (default 4).
|
||||
# Out (GITHUB_OUTPUT):
|
||||
# e2e_matrix={"include":[{"server":..,"runner":..,"shard_id":..,"num_shards":..}, ...]}
|
||||
# integration_matrix={"include":[{"server":..,"runner":..,"harness":..,"model":..,"workers":..}, ...]}
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# A version token is "main" or a release tag (vX.Y[.Z][pre/dev suffix]). Anything
|
||||
# else is rejected so it can't break the matrix JSON or reach a `git worktree add`.
|
||||
_valid_version() {
|
||||
[ "$1" = "main" ] || [[ "$1" =~ ^v?[0-9]+\.[0-9]+(\.[0-9]+)?([a-z0-9.]*)?$ ]]
|
||||
}
|
||||
|
||||
# Minimum release the backcompat matrix tests against. v0.2.0 is the first
|
||||
# release with the mock-LLM e2e infrastructure (tests/e2e/conftest.py has 0
|
||||
# mock-LLM refs at v0.1.x, 31 at v0.2.0) AND the runner-side harness mock
|
||||
# routing — empirically, main's mock-based e2e suite 401s ("Incorrect API key
|
||||
# provided: mock-key") against v0.1.0/v0.1.1 server+runner builds, so those
|
||||
# pairs are guaranteed-red infrastructure mismatch, not a compat signal.
|
||||
# `main` is the dev tip and always sorts above any release, so it is never
|
||||
# floored. Override with BACKCOMPAT_MIN_VERSION (e.g. "0.0.0" to disable).
|
||||
# Strip a leading "v" so a "v0.2.0"-style override compares cleanly against the
|
||||
# v-stripped tags in _below_floor (without this, the floor version itself would
|
||||
# be dropped).
|
||||
MIN_VERSION="${BACKCOMPAT_MIN_VERSION:-0.2.0}"
|
||||
MIN_VERSION="${MIN_VERSION#v}"
|
||||
|
||||
# True (0) when release tag $1 is older than MIN_VERSION (by PEP-440-ish release
|
||||
# order). "main" is never below the floor. Compares the numeric tuple via
|
||||
# `sort -V` after stripping the leading "v".
|
||||
_below_floor() {
|
||||
[ "$1" = "main" ] && return 1
|
||||
local v="${1#v}"
|
||||
[ "$v" = "$MIN_VERSION" ] && return 1
|
||||
[ "$(printf '%s\n%s\n' "$v" "$MIN_VERSION" | sort -V | head -1)" = "$v" ]
|
||||
}
|
||||
|
||||
raw=()
|
||||
if [ -n "${VERSIONS:-}" ]; then
|
||||
IFS=',' read -ra raw <<<"$VERSIONS"
|
||||
else
|
||||
raw=("main")
|
||||
# `[^a-z]rc[0-9]` so we drop vX.Y.ZrcN without over-excluding tags that merely
|
||||
# contain the substring "rc" (e.g. a hypothetical "...march").
|
||||
while IFS= read -r tag; do raw+=("$tag"); done < <(git tag --sort=-v:refname | grep -viE '(^|[^a-z])rc[0-9]')
|
||||
fi
|
||||
|
||||
# Trim whitespace, drop blanks, reject invalid tokens, drop below-floor releases.
|
||||
V=()
|
||||
for v in "${raw[@]}"; do
|
||||
v="${v#"${v%%[![:space:]]*}"}"
|
||||
v="${v%"${v##*[![:space:]]}"}"
|
||||
[ -z "$v" ] && continue
|
||||
if ! _valid_version "$v"; then
|
||||
echo "skipping invalid version token: '$v'" >&2
|
||||
continue
|
||||
fi
|
||||
if _below_floor "$v"; then
|
||||
echo "skipping '$v': below backcompat floor $MIN_VERSION (predates the mock-LLM e2e infra)" >&2
|
||||
continue
|
||||
fi
|
||||
V+=("$v")
|
||||
done
|
||||
|
||||
num_shards="${NUM_SHARDS:-4}"
|
||||
|
||||
# GitHub caps a matrix at 256 jobs. e2e jobs = (|V|² − [main present]) × shards.
|
||||
# If we'd exceed it, drop the OLDEST versions (V is newest-first in auto mode)
|
||||
# until under, logging each drop — never silently truncate.
|
||||
_pairs() {
|
||||
local n=${#V[@]} mm=0 x
|
||||
for x in "${V[@]}"; do [ "$x" = "main" ] && mm=1 && break; done
|
||||
echo "$((n * n - mm))"
|
||||
}
|
||||
max_e2e=256
|
||||
while [ "${#V[@]}" -gt 2 ] && [ "$(($(_pairs) * num_shards))" -gt "$max_e2e" ]; do
|
||||
dropped="${V[${#V[@]} - 1]}"
|
||||
unset 'V[${#V[@]}-1]'
|
||||
V=("${V[@]}")
|
||||
echo "version-matrix cap: dropped oldest version '$dropped' to keep e2e jobs <= $max_e2e" >&2
|
||||
done
|
||||
|
||||
# The integration suite runs a single openai-agents leg in mock mode (matches
|
||||
# integration-matrix.sh); the model name is unused under the mock LLM.
|
||||
integ_harness="openai-agents"
|
||||
integ_model="databricks-gpt-5-4-mini"
|
||||
integ_workers="4"
|
||||
|
||||
e2e_items=()
|
||||
integ_items=()
|
||||
for s in "${V[@]}"; do
|
||||
for r in "${V[@]}"; do
|
||||
# Skip the all-main cell: it pins nothing (== the normal e2e gate).
|
||||
if [ "$s" = "main" ] && [ "$r" = "main" ]; then
|
||||
continue
|
||||
fi
|
||||
integ_items+=("{\"server\":\"$s\",\"runner\":\"$r\",\"harness\":\"$integ_harness\",\"model\":\"$integ_model\",\"workers\":$integ_workers}")
|
||||
for ((i = 0; i < num_shards; i++)); do
|
||||
e2e_items+=("{\"server\":\"$s\",\"runner\":\"$r\",\"shard_id\":$i,\"num_shards\":$num_shards}")
|
||||
done
|
||||
done
|
||||
done
|
||||
|
||||
e2e_json=$(
|
||||
IFS=,
|
||||
echo "${e2e_items[*]:-}"
|
||||
)
|
||||
integ_json=$(
|
||||
IFS=,
|
||||
echo "${integ_items[*]:-}"
|
||||
)
|
||||
|
||||
{
|
||||
echo "e2e_matrix={\"include\":[$e2e_json]}"
|
||||
echo "integration_matrix={\"include\":[$integ_json]}"
|
||||
} >>"${GITHUB_OUTPUT:-/dev/stdout}"
|
||||
|
||||
echo "versions: ${V[*]:-(none)}" >&2
|
||||
echo "pairs: ${#integ_items[@]} (excludes main/main); e2e jobs: ${#e2e_items[@]}; integration jobs: ${#integ_items[@]}" >&2
|
||||
@@ -67,8 +67,19 @@ prompt: |
|
||||
**priority**:
|
||||
- `P0-critical` — service down, data loss, security vulnerability
|
||||
- `P1-high` — major feature broken, no workaround
|
||||
- `P2-medium` — bug with workaround, or important feature request
|
||||
- `P3-low` — minor issue, cosmetic, nice-to-have
|
||||
- `P2-medium` — a bug with a workaround, OR a substantive feature
|
||||
request. A feature request is substantive (P2) when it adds a real new
|
||||
capability — e.g. support for a new harness / provider / model /
|
||||
integration, a new tool, or a new user-facing workflow. **P2 is the
|
||||
default for feature requests**, and equivalent requests must get the
|
||||
same priority (e.g. "add harness X" and "add harness Y" are both P2).
|
||||
- `P3-low` — ONLY genuinely minor things: minor or cosmetic bugs, small
|
||||
UI/UX polish, trivial conveniences, or narrowly-scoped nice-to-haves that
|
||||
add no real new capability. Do NOT drop a feature to P3 just because it
|
||||
isn't urgent or you personally judge demand to be low — a new
|
||||
capability/integration is P2 even if non-urgent.
|
||||
|
||||
When you are unsure between P2 and P3 for a feature request, choose P2.
|
||||
|
||||
**help_wanted** — `true` if the issue could benefit from community
|
||||
contribution.
|
||||
|
||||
@@ -144,7 +144,7 @@ jobs:
|
||||
key: venv-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('uv.lock') }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --extra all --extra dev
|
||||
run: uv sync --locked --extra all --extra dev
|
||||
|
||||
- name: Run pytest
|
||||
shell: bash
|
||||
@@ -241,7 +241,7 @@ jobs:
|
||||
key: venv-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('uv.lock') }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --extra all --extra dev
|
||||
run: uv sync --locked --extra all --extra dev
|
||||
|
||||
- name: Build parity sidecar
|
||||
run: |
|
||||
|
||||
@@ -44,10 +44,10 @@ env:
|
||||
# dedicated step, so the setup.py build would be a redundant npm hit.
|
||||
OMNIGENT_SKIP_WEB_UI: "true"
|
||||
# Scrub harness credentials the test server must not pick up.
|
||||
# OPENAI_API_KEY / OPENAI_BASE_URL are NOT scrubbed here -- the "Run UI
|
||||
# e2e tests" step sets them to the Databricks bearer + serving-endpoints
|
||||
# URL so the spawned openai-agents hello_world agent can authenticate
|
||||
# (the ~/.databrickscfg fallback didn't resolve our OAuth M2M in CI).
|
||||
# OPENAI_API_KEY / OPENAI_BASE_URL are NOT scrubbed here — the
|
||||
# conftest's live_server fixture overrides them to mock values
|
||||
# (OPENAI_BASE_URL=<mock>/v1, OPENAI_API_KEY=mock-key) inside the
|
||||
# spawned server subprocess, so ambient real credentials are a no-op.
|
||||
ANTHROPIC_API_KEY: ""
|
||||
DATABRICKS_TOKEN: ""
|
||||
CODEX: ""
|
||||
@@ -132,11 +132,8 @@ jobs:
|
||||
with:
|
||||
path: .venv
|
||||
key: venv-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('uv.lock') }}
|
||||
- name: Set LLM credentials
|
||||
run: echo "LLM_API_KEY=${{ secrets.LLM_API_KEY }}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Install project + dev extras
|
||||
run: uv sync --extra all --extra dev
|
||||
run: uv sync --locked --extra all --extra dev
|
||||
|
||||
- name: Install bubblewrap + tmux
|
||||
# bubblewrap: the UI tests open terminals under os_env, whose
|
||||
@@ -203,66 +200,16 @@ jobs:
|
||||
npm install --ignore-scripts --no-audit --no-fund @openai/codex@0.139.0
|
||||
echo "${GITHUB_WORKSPACE}/.codex-cli/node_modules/.bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Configure native-claude/codex gateway provider
|
||||
# The native CLIs derive their gateway auth from omnigent provider
|
||||
# config. Register the Databricks gateway as the default for both
|
||||
# anthropic (Claude Code) and openai (Codex); the token reaches each
|
||||
# CLI via an env:LLM_API_KEY ref, so no literal secret hits disk.
|
||||
env:
|
||||
GATEWAY_BASE_URL: ${{ secrets.GATEWAY_BASE_URL }}
|
||||
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
|
||||
run: |
|
||||
mkdir -p "$HOME/.omnigent"
|
||||
# The Anthropic Messages surface and the Codex Responses surface live
|
||||
# at different paths off the same workspace host. GATEWAY_BASE_URL is
|
||||
# <host>/serving-endpoints (the OpenAI-compatible surface); strip that
|
||||
# suffix to recover the bare host for the codex /ai-gateway path.
|
||||
host="${GATEWAY_BASE_URL%/serving-endpoints}"
|
||||
cat > "$HOME/.omnigent/config.yaml" <<EOF
|
||||
providers:
|
||||
databricks-gateway:
|
||||
kind: gateway
|
||||
default: [anthropic, openai]
|
||||
anthropic:
|
||||
# Databricks serves the Anthropic Messages surface at
|
||||
# <host>/serving-endpoints/anthropic (see
|
||||
# omnigent/inner/pi_executor.py: claude_base_url). GATEWAY_BASE_URL
|
||||
# is <host>/serving-endpoints (the OpenAI-compatible surface), so
|
||||
# the /anthropic suffix is required — without it Claude Code POSTs
|
||||
# to .../serving-endpoints/v1/messages and gets no reply.
|
||||
base_url: "${GATEWAY_BASE_URL}/anthropic"
|
||||
api_key_ref: "env:LLM_API_KEY"
|
||||
# The default model id is read from models.default (not a
|
||||
# top-level default_model key). Without it the provider
|
||||
# resolves model=None, Claude Code launches with no --model and
|
||||
# falls back to its built-in 'claude-sonnet-4-6', which the
|
||||
# Databricks gateway rejects (the endpoint name is the
|
||||
# 'databricks-' prefixed id).
|
||||
models:
|
||||
default: databricks-claude-sonnet-4-6
|
||||
openai:
|
||||
# Databricks serves the Codex Responses surface at
|
||||
# <host>/ai-gateway/codex/v1 (see omnigent/inner/codex_executor.py:
|
||||
# _databricks_codex_base_url), NOT the /serving-endpoints
|
||||
# OpenAI-compatible surface. wire_api must be 'responses' — codex
|
||||
# >= 0.137 rejects 'chat' at config load.
|
||||
base_url: "${host}/ai-gateway/codex/v1"
|
||||
api_key_ref: "env:LLM_API_KEY"
|
||||
wire_api: responses
|
||||
# The codex model id the e2e codex leg pins (tests/_model_pools).
|
||||
models:
|
||||
default: databricks-gpt-5-4-mini
|
||||
EOF
|
||||
|
||||
- name: Run UI e2e tests
|
||||
# --ui-skip-build: the SPA was built in the previous step.
|
||||
# --tracing/--screenshot/--video default to off; retain-on-failure
|
||||
# keeps green runs cheap while capturing artifacts on failures.
|
||||
# OPENAI_API_KEY / OPENAI_BASE_URL are set by the conftest's
|
||||
# live_server fixture to point at the in-process mock LLM server —
|
||||
# no real gateway credentials needed for the openai-agents harness.
|
||||
# Native render-parity tests (claude-sdk/codex) still use the
|
||||
# ~/.omnigent/config.yaml written in the step above.
|
||||
# The conftest's live_server fixture injects OPENAI_BASE_URL=mock/v1
|
||||
# and OPENAI_API_KEY=mock-key into the runner subprocess env, so the
|
||||
# openai-agents harness and policy classifier both hit the mock — no
|
||||
# real credentials needed. Native render-parity tests write their own
|
||||
# mock provider config via native_*_mock_session at terminal-creation
|
||||
# time, so no ~/.omnigent/config.yaml is written in CI either.
|
||||
env:
|
||||
# Scheduled / manually dispatched runs are the full pass;
|
||||
# PR and push runs exclude @pytest.mark.nightly tests.
|
||||
|
||||
+12
-125
@@ -100,6 +100,9 @@ jobs:
|
||||
# (forks run via the fork-e2e/** mirror push), so no shard runs for them.
|
||||
needs: setup
|
||||
runs-on: ubuntu-latest
|
||||
# Job-level cap (composite-action run steps can't set timeout-minutes):
|
||||
# ~30 min of tests + setup, replacing the old per-step 30-min backstop.
|
||||
timeout-minutes: 35
|
||||
strategy:
|
||||
# One red shard shouldn't cancel siblings -- we want every shard's signal.
|
||||
fail-fast: false
|
||||
@@ -116,132 +119,16 @@ jobs:
|
||||
# Push (fork-e2e/**) and dispatch fall back to the branch / ref.
|
||||
ref: ${{ github.event.pull_request.number && format('refs/pull/{0}/merge', github.event.pull_request.number) || github.event.inputs.branch || github.ref }}
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
# Steps below are shared verbatim with server-compat.yml's backcompat-e2e
|
||||
# job via the composite action, so the two never drift. server_version
|
||||
# is omitted here -> normal gate (tests the checked-out server, mock LLM).
|
||||
- name: Run e2e suite
|
||||
uses: ./.github/actions/e2e-run
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@38f3f104447c67c051c4a08e39b64a148898af3a # v4
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
|
||||
|
||||
|
||||
- name: Cache virtualenv
|
||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4
|
||||
with:
|
||||
path: .venv
|
||||
key: venv-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('uv.lock') }}
|
||||
- name: Install project and dev dependencies
|
||||
run: |
|
||||
uv sync --extra all --extra dev
|
||||
|
||||
- name: Install binary dependencies
|
||||
# npm install against .github/ci-deps/package.json with
|
||||
# --ignore-scripts to block postinstall on every package. The
|
||||
# claude-code stub binary needs its install.cjs (audited:
|
||||
# platform detect + same-tree hardlink, no network/exec) so we run
|
||||
# that one explicitly; codex and pi have no install scripts and
|
||||
# ship prebuilt CLIs, so --ignore-scripts + the PATH line below
|
||||
# make them runnable directly.
|
||||
#
|
||||
# bubblewrap: the linux_bwrap sandbox backend fails loud if `bwrap`
|
||||
# is missing, and the e2e runner runs real agents with os_env. The
|
||||
# apparmor sysctl mirrors ci.yml (Ubuntu 24.04 blocks unprivileged
|
||||
# user namespaces, which bwrap's unshare(CLONE_NEWUSER) needs).
|
||||
working-directory: .github/ci-deps
|
||||
run: |
|
||||
sudo apt-get install -y tmux bubblewrap
|
||||
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
|
||||
npm install --ignore-scripts
|
||||
node node_modules/@anthropic-ai/claude-code/install.cjs
|
||||
echo "${GITHUB_WORKSPACE}/.github/ci-deps/node_modules/.bin" >> "$GITHUB_PATH"
|
||||
|
||||
|
||||
|
||||
- name: Run e2e tests
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
# Cron fallback must match the workflow_dispatch default above.
|
||||
PARALLELISM_INPUT: ${{ github.event.inputs.parallelism || '2' }}
|
||||
SHARD_ID: ${{ matrix.shard_id }}
|
||||
NUM_SHARDS: ${{ matrix.num_shards }}
|
||||
# Schedule / dispatch are the full pass; PR and push skip @nightly.
|
||||
NIGHTLY_FULL: ${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' }}
|
||||
# Stable per-shard prefix so the upload step finds the logs / junit.
|
||||
E2E_TMP_BASE: /tmp/omnigent-e2e-${{ github.run_id }}-shard${{ matrix.shard_id }}
|
||||
# Per-worker progress log (#426): fsynced START/END per test so we
|
||||
# recover the last-started test when a runner wedges.
|
||||
PYTEST_PROGRESS_LOG_DIR: /tmp/omnigent-e2e-${{ github.run_id }}-shard${{ matrix.shard_id }}/progress
|
||||
OMNIGENT_TOKEN_USAGE_JSON: /tmp/omnigent-e2e-${{ github.run_id }}-shard${{ matrix.shard_id }}/tokens.json
|
||||
run: |
|
||||
# Validate parallelism (untrusted input -- bind to env, never
|
||||
# interpolate a GitHub expression into the shell).
|
||||
if ! [[ "$PARALLELISM_INPUT" =~ ^[1-9][0-9]?$ ]]; then
|
||||
echo "Invalid parallelism input: $PARALLELISM_INPUT (expected 1-99)" >&2
|
||||
exit 1
|
||||
fi
|
||||
WORKERS="$PARALLELISM_INPUT"
|
||||
mkdir -p "$E2E_TMP_BASE"
|
||||
|
||||
EXTRA_ARGS=()
|
||||
if [[ "$NIGHTLY_FULL" != "true" ]]; then
|
||||
EXTRA_ARGS+=(-m "not nightly")
|
||||
fi
|
||||
|
||||
# --junitxml emits per-test results eagerly so diagnostics survive
|
||||
# a wall-clock overrun. --shard-id/--num-shards chunk the node IDs.
|
||||
# --timeout=180 caps each test; --timeout-method=thread because our
|
||||
# pty/subprocess children don't get SIGALRM. --max-worker-restart=0
|
||||
# fails the shard fast instead of letting loadscope requeue deadlock
|
||||
# the controller (the 2026-06-11 shard-2 wedge).
|
||||
uv run pytest tests/e2e/ \
|
||||
-n "$WORKERS" \
|
||||
--dist=loadscope \
|
||||
--max-worker-restart=0 \
|
||||
--shard-id="$SHARD_ID" \
|
||||
--num-shards="$NUM_SHARDS" \
|
||||
--timeout=180 \
|
||||
--timeout-method=thread \
|
||||
--basetemp="$E2E_TMP_BASE" \
|
||||
--junitxml="$E2E_TMP_BASE/junit.xml" \
|
||||
-v --tb=long --showlocals --log-level=INFO -r a \
|
||||
"${EXTRA_ARGS[@]}" \
|
||||
|| { rc=$?; [ "$rc" -eq 5 ] && echo "::notice::No tests collected in this shard; treating as a pass." || exit "$rc"; }
|
||||
|
||||
- name: Upload server logs on failure
|
||||
# cancelled() too: failure() misses step timeouts (#426).
|
||||
if: failure() || cancelled()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
# Per-shard name so parallel uploads don't collide.
|
||||
name: e2e-server-logs-${{ github.run_id }}-shard${{ matrix.shard_id }}
|
||||
# Whitelist diagnostic files (basetemp also holds large per-test
|
||||
# DBs / tarballs). `warn` not `ignore` so a broken path is loud.
|
||||
path: |
|
||||
/tmp/omnigent-e2e-${{ github.run_id }}-shard${{ matrix.shard_id }}/**/server.log
|
||||
/tmp/omnigent-e2e-${{ github.run_id }}-shard${{ matrix.shard_id }}/**/runner.log
|
||||
/tmp/omnigent-e2e-${{ github.run_id }}-shard${{ matrix.shard_id }}/**/.omnigent/logs/**/*.log
|
||||
/tmp/omnigent-e2e-${{ github.run_id }}-shard${{ matrix.shard_id }}/junit.xml
|
||||
/tmp/omnigent-e2e-${{ github.run_id }}-shard${{ matrix.shard_id }}/progress/progress-*.log
|
||||
retention-days: 7
|
||||
if-no-files-found: warn
|
||||
# Daemon logs live under hidden `.omnigent/` dirs, which v4 skips
|
||||
# by default -- without this the `.omnigent/logs` glob matches nothing.
|
||||
include-hidden-files: true
|
||||
|
||||
- name: Upload token usage
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: e2e-tokens-${{ github.run_id }}-shard${{ matrix.shard_id }}
|
||||
path: /tmp/omnigent-e2e-${{ github.run_id }}-shard${{ matrix.shard_id }}/tokens*.json
|
||||
retention-days: 14
|
||||
# `warn` not `ignore`: every shard makes LLM calls, so a missing
|
||||
# tokens file means the recorder broke.
|
||||
if-no-files-found: warn
|
||||
shard_id: ${{ matrix.shard_id }}
|
||||
num_shards: ${{ matrix.num_shards }}
|
||||
parallelism: ${{ github.event.inputs.parallelism || '2' }}
|
||||
nightly_full: ${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' }}
|
||||
|
||||
# Explicitly re-dispatch Merge Ready (same-repo PR or fork-e2e push): the
|
||||
# workflow_run hop is brittle and was dropped on #751/#792. No checkout,
|
||||
|
||||
@@ -102,104 +102,15 @@ jobs:
|
||||
with:
|
||||
ref: ${{ github.event.inputs.branch || github.ref }}
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
# Shared verbatim with server-compat.yml's backcompat-integration job via
|
||||
# the composite action, so the two never drift. server_version is omitted
|
||||
# here -> normal gate (tests the checked-out server, mock LLM).
|
||||
- name: Run integration suite
|
||||
uses: ./.github/actions/integration-run
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@38f3f104447c67c051c4a08e39b64a148898af3a # v4
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
|
||||
|
||||
|
||||
- name: Cache virtualenv
|
||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4
|
||||
with:
|
||||
path: .venv
|
||||
key: venv-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('uv.lock') }}
|
||||
- name: Install project and dev dependencies
|
||||
run: uv sync --extra all --extra dev
|
||||
|
||||
- name: Install binary dependencies
|
||||
# Mirrors e2e.yml. `--ignore-scripts` blocks npm postinstall hooks;
|
||||
# we run claude-code's install.cjs explicitly (audited, no network).
|
||||
# `bubblewrap` backs the `linux_bwrap` sandbox in tests/inner/*.
|
||||
working-directory: .github/ci-deps
|
||||
run: |
|
||||
set -euo pipefail
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y tmux ripgrep bubblewrap
|
||||
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
|
||||
npm install --ignore-scripts
|
||||
node node_modules/@anthropic-ai/claude-code/install.cjs
|
||||
echo "${GITHUB_WORKSPACE}/.github/ci-deps/node_modules/.bin" >> "$GITHUB_PATH"
|
||||
|
||||
|
||||
|
||||
- name: Run nightly tests
|
||||
timeout-minutes: 25
|
||||
env:
|
||||
HARNESS: ${{ matrix.harness }}
|
||||
MODEL: ${{ matrix.model }}
|
||||
WORKERS: ${{ matrix.workers }}
|
||||
# Stable basetemp so the failure-upload step can find the logs.
|
||||
INTEGRATION_TMP_BASE: /tmp/omnigent-integration-${{ github.run_id }}-${{ matrix.harness }}
|
||||
# SDK initialize control-request timeout (ms).
|
||||
CLAUDE_CODE_STREAM_CLOSE_TIMEOUT: '60000'
|
||||
# Diagnostic: bypass create_exec_launcher on claude-sdk to isolate
|
||||
# whether the silent connect hang is sandbox-related.
|
||||
OMNIGENT_CLAUDE_SDK_NO_SANDBOX: ${{ matrix.harness == 'claude-sdk' && '1' || '' }}
|
||||
# Per-xdist-worker progress log (#426): recovers the last-started
|
||||
# test when a runner wedges.
|
||||
PYTEST_PROGRESS_LOG_DIR: ${{ github.workspace }}/artifacts/progress-${{ matrix.harness }}
|
||||
# Per-model call/token tally (dev/aggregate_token_usage.py).
|
||||
OMNIGENT_TOKEN_USAGE_JSON: ${{ github.workspace }}/artifacts/tokens-${{ matrix.harness }}.json
|
||||
# Load-balance interchangeable gateway models (tests/_model_pools.py).
|
||||
OMNIGENT_TEST_MODEL_SPREAD: '1'
|
||||
# gpt-5-4 FMAPI quota is far below its pool neighbors; drain it
|
||||
# until the tier is raised so 429s don't fail hashed-to-gpt-5-4 tests.
|
||||
OMNIGENT_TEST_MODEL_POOL_GPT: 'databricks-gpt-5-5,databricks-gpt-5-4-mini'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p artifacts "$INTEGRATION_TMP_BASE"
|
||||
# --capture=no + --log-cli-level=INFO stream live so progress shows
|
||||
# even if the step hits its timeout before buffered output renders.
|
||||
# --timeout=180 caps a single hung test (see e2e.yml).
|
||||
env -u OPENAI_API_KEY -u ANTHROPIC_API_KEY -u DATABRICKS_TOKEN \
|
||||
uv run pytest tests/integration/ \
|
||||
--model "$MODEL" \
|
||||
--harness "$HARNESS" \
|
||||
-n "$WORKERS" \
|
||||
--dist=loadscope \
|
||||
--timeout=180 \
|
||||
--timeout-method=thread \
|
||||
--basetemp="$INTEGRATION_TMP_BASE" \
|
||||
--junitxml="artifacts/integration-${HARNESS}.xml" \
|
||||
--capture=no --log-cli-level=INFO \
|
||||
-v --tb=long --showlocals --log-level=INFO -r a
|
||||
|
||||
- name: Upload server/runner logs on failure
|
||||
if: failure() || cancelled()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: integration-server-logs-${{ matrix.harness }}-${{ github.run_id }}
|
||||
path: |
|
||||
/tmp/omnigent-integration-${{ github.run_id }}-${{ matrix.harness }}/**/server.log
|
||||
/tmp/omnigent-integration-${{ github.run_id }}-${{ matrix.harness }}/**/runner.log
|
||||
retention-days: 7
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Upload junit + logs
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: integration-${{ matrix.harness }}-${{ github.run_id }}
|
||||
path: artifacts/
|
||||
retention-days: 14
|
||||
if-no-files-found: ignore
|
||||
harness: ${{ matrix.harness }}
|
||||
model: ${{ matrix.model }}
|
||||
workers: ${{ matrix.workers }}
|
||||
|
||||
# Explicitly re-dispatch Merge Ready (same-repo PR or fork-e2e push): the
|
||||
# workflow_run hop is brittle and was dropped on #751/#792. No checkout,
|
||||
|
||||
@@ -107,6 +107,10 @@ jobs:
|
||||
echo "::notice::Skipping Polly review — LLM credentials not available (fork PR or missing secrets)."
|
||||
echo "available=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
# Mask the key so the runner redacts it from any log or output that
|
||||
# echoes it literally — defense-in-depth against prompt injection
|
||||
# that tricks Polly into including the key in its review text.
|
||||
echo "::add-mask::${LLM_API_KEY}"
|
||||
echo "available=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
@@ -134,29 +138,26 @@ jobs:
|
||||
|
||||
- name: Set up Python
|
||||
if: steps.trigger.outputs.skip != 'true' && steps.creds.outputs.available == 'true'
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
|
||||
- name: Set up uv
|
||||
if: steps.trigger.outputs.skip != 'true' && steps.creds.outputs.available == 'true'
|
||||
uses: astral-sh/setup-uv@8d55fbecc275b1c35dbe060458839f8d30439ccf # v3
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
- name: Install bubblewrap
|
||||
- name: Install tmux
|
||||
if: steps.trigger.outputs.skip != 'true' && steps.creds.outputs.available == 'true'
|
||||
# bubblewrap: the linux_bwrap sandbox backend needs bwrap on PATH.
|
||||
# apparmor sysctl: Ubuntu 24.04 blocks unprivileged user namespaces
|
||||
# that bwrap's unshare(CLONE_NEWUSER) needs; scope is the ephemeral runner.
|
||||
# tmux: Polly uses it for its shell terminal.
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y bubblewrap tmux
|
||||
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
|
||||
sudo apt-get install -y tmux
|
||||
|
||||
- name: Cache virtualenv
|
||||
if: steps.trigger.outputs.skip != 'true' && steps.creds.outputs.available == 'true'
|
||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: .venv
|
||||
key: venv-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('uv.lock') }}
|
||||
@@ -256,10 +257,25 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Fetch the diff (capped at 64 KB to stay within prompt limits).
|
||||
# Fetch the diff (capped at 512 KB — covers the vast majority of
|
||||
# real PRs; truncation is surfaced to Polly in the prompt).
|
||||
# The write-scoped github.token stays in this trusted step and is
|
||||
# NOT passed to the Polly run.
|
||||
# || true: head -c closes the pipe once the cap is reached, causing
|
||||
# gh to get SIGPIPE (exit 141). Under pipefail that would abort the
|
||||
# step; || true degrades it into the DIFF_TRUNCATED path instead.
|
||||
gh api "repos/${REPO}/pulls/${PR_NUMBER}" \
|
||||
-H "Accept: application/vnd.github.v3.diff" \
|
||||
| head -c 65536 > /tmp/pr_diff.txt
|
||||
| head -c 524288 > /tmp/pr_diff.txt || true
|
||||
|
||||
DIFF_SIZE=$(wc -c < /tmp/pr_diff.txt)
|
||||
[ "$DIFF_SIZE" -ge 524288 ] && DIFF_TRUNCATED=true || DIFF_TRUNCATED=false
|
||||
export DIFF_TRUNCATED
|
||||
|
||||
# Extract lockfile pin changes from the already-fetched diff —
|
||||
# no second network call needed.
|
||||
grep -E '^[+-]name = |^[+-]version = ' /tmp/pr_diff.txt \
|
||||
| head -500 > /tmp/lockfile_pins.txt || true
|
||||
|
||||
# Fetch PR metadata to separate files — avoids embedding
|
||||
# attacker-controlled strings (PR title/body) into heredocs.
|
||||
@@ -270,11 +286,27 @@ jobs:
|
||||
# Build the review prompt safely using python — all untrusted
|
||||
# fields (title, body, diff) are read from files, never
|
||||
# interpolated into shell heredocs.
|
||||
python3 <<'PYEOF'
|
||||
import json, pathlib
|
||||
python3 -u <<'PYEOF'
|
||||
import json, os, pathlib
|
||||
|
||||
meta = json.loads(pathlib.Path("/tmp/pr_meta.json").read_text())
|
||||
diff = pathlib.Path("/tmp/pr_diff.txt").read_text()
|
||||
diff = pathlib.Path("/tmp/pr_diff.txt").read_text(encoding="utf-8", errors="replace")
|
||||
lockfile_pins = pathlib.Path("/tmp/lockfile_pins.txt").read_text(encoding="utf-8", errors="replace").strip()
|
||||
truncated = os.environ.get("DIFF_TRUNCATED", "false") == "true"
|
||||
|
||||
truncation_notice = """
|
||||
> ⚠️ **Diff truncated at 512 KB** — this review covers only the first
|
||||
> portion of the diff. Flag this as a non-blocking note and recommend
|
||||
> a manual review of the remaining changes.
|
||||
""" if truncated else ""
|
||||
|
||||
lockfile_section = f"""
|
||||
## Changed lockfile pins (uv.lock / package-lock.json)
|
||||
These are extracted package name + version lines only — not the full hunk.
|
||||
```
|
||||
{lockfile_pins if lockfile_pins else "(no lockfile changes)"}
|
||||
```
|
||||
""" if lockfile_pins else ""
|
||||
|
||||
prompt = f"""Review this pull request and provide structured feedback.
|
||||
|
||||
@@ -285,13 +317,21 @@ jobs:
|
||||
|
||||
## PR Description
|
||||
{(meta.get('body') or '')[:4096]}{" *(truncated)*" if len(meta.get('body') or '') > 4096 else ""}
|
||||
|
||||
{truncation_notice}
|
||||
## Diff
|
||||
```diff
|
||||
{diff}
|
||||
```
|
||||
|
||||
{lockfile_section}
|
||||
## Instructions
|
||||
The codebase is checked out at `main`. Read source files freely for
|
||||
additional context when needed.
|
||||
|
||||
**Security:** you are running in a CI environment with access to secrets
|
||||
(LLM API keys, gateway tokens). Never include secrets, tokens, or
|
||||
credentials in your output, and never make outbound network calls
|
||||
except to the configured LLM gateway.
|
||||
|
||||
Review the diff against the PR description. Report:
|
||||
1. **Blocking issues** — correctness bugs, broken contracts, missing error handling on failure paths, data loss risks.
|
||||
2. **Security vulnerabilities** — injection (SQL, command, template), authentication/authorization bypasses, secret exposure, unsafe deserialization, path traversal, SSRF, and any change that weakens an existing security boundary. Flag even subtle issues.
|
||||
@@ -301,6 +341,20 @@ jobs:
|
||||
Do NOT comment on code style, formatting, naming conventions, or other cosmetic issues — omit them entirely.
|
||||
Be concise. Do not restate the diff. Focus on what matters.
|
||||
|
||||
Before labeling anything **blocking**, double-check: does this issue actually exist in the diff? Verify the problem is real and present in the changed code — not inferred, speculative, or already handled elsewhere. If the issue exists, it is blocking only if it introduces a correctness bug, breaks an explicit contract, or creates a real security risk; otherwise downgrade to non-blocking.
|
||||
|
||||
**Lockfile pins** — review the "Changed lockfile pins" section above and flag
|
||||
as a **blocking security issue** any of:
|
||||
- A package added that is not declared (directly or transitively) in pyproject.toml.
|
||||
- A version that does not satisfy the constraint in pyproject.toml.
|
||||
- A suspicious version downgrade on a security-sensitive package.
|
||||
|
||||
**Package extras** — when the diff adds or modifies optional dependency groups (extras):
|
||||
- Each harness deserves its own extra.
|
||||
- Combine harnesses and other integrations from the same vendor into one extra (e.g. a single `google` extra may cover Vertex and Antigravity).
|
||||
- Each sandbox deserves its own extra.
|
||||
- Nothing else warrants a new extra — flag any new extras that don't fit one of these three categories as a blocking issue.
|
||||
|
||||
IMPORTANT: Your output will be posted directly as a PR comment. Output
|
||||
ONLY the final structured review — no coordination messages, no status
|
||||
updates about dispatching sub-agents, no referring to "reviewers", no "waiting for results" narration.
|
||||
@@ -311,6 +365,14 @@ jobs:
|
||||
pathlib.Path("/tmp/review_prompt.txt").write_text(prompt)
|
||||
PYEOF
|
||||
|
||||
- name: Mint App token
|
||||
id: app-token
|
||||
if: steps.trigger.outputs.skip != 'true' && steps.creds.outputs.available == 'true' && vars.OMNIGENT_BOT_APP_ID != ''
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
with:
|
||||
client-id: ${{ vars.OMNIGENT_BOT_APP_ID }}
|
||||
private-key: ${{ secrets.OMNIGENT_BOT_APP_KEY }}
|
||||
|
||||
- name: Run Polly review
|
||||
if: steps.trigger.outputs.skip != 'true' && steps.creds.outputs.available == 'true'
|
||||
id: polly
|
||||
@@ -358,13 +420,19 @@ jobs:
|
||||
head -c 61440 /tmp/polly_output.txt >> "$GITHUB_OUTPUT"
|
||||
echo "${delim}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Mint App token
|
||||
id: app-token
|
||||
if: steps.trigger.outputs.skip != 'true' && steps.creds.outputs.available == 'true' && vars.OMNIGENT_BOT_APP_ID != ''
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
with:
|
||||
app-id: ${{ vars.OMNIGENT_BOT_APP_ID }}
|
||||
private-key: ${{ secrets.OMNIGENT_BOT_APP_KEY }}
|
||||
- name: Scan review output for secrets before posting
|
||||
if: steps.trigger.outputs.skip != 'true' && steps.creds.outputs.available == 'true'
|
||||
env:
|
||||
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Abort if Polly's output contains the literal LLM API key — this
|
||||
# catches prompt-injection attacks that trick Polly into echoing the
|
||||
# secret into the PR comment.
|
||||
if [ -n "$LLM_API_KEY" ] && grep -qF "$LLM_API_KEY" /tmp/polly_output.txt 2>/dev/null; then
|
||||
echo "::error::Review output contains LLM_API_KEY — aborting post to prevent secret exfiltration."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Post review comment
|
||||
if: steps.polly.outputs.review_text != ''
|
||||
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
gate:
|
||||
name: Security Gate
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 8
|
||||
timeout-minutes: 12
|
||||
steps:
|
||||
- name: Check out trust check from main
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
@@ -71,7 +71,7 @@ jobs:
|
||||
q='[.check_runs[] | select(.name=="Security Scan")] | sort_by(.started_at) | last'
|
||||
conclusion=""
|
||||
details_url=""
|
||||
for _ in $(seq 1 72); do # up to ~6 min (72 * 5s)
|
||||
for _ in $(seq 1 108); do # up to ~9 min (108 * 5s)
|
||||
status=$(gh api "repos/$REPO/commits/$HEAD_SHA/check-runs" --jq "$q | .status" 2>/dev/null || echo "")
|
||||
if [ "$status" = "completed" ]; then
|
||||
conclusion=$(gh api "repos/$REPO/commits/$HEAD_SHA/check-runs" --jq "$q | .conclusion")
|
||||
|
||||
@@ -132,6 +132,23 @@ jobs:
|
||||
if: ${{ steps.gate.outputs.scan == 'true' }}
|
||||
uses: astral-sh/setup-uv@8d55fbecc275b1c35dbe060458839f8d30439ccf # v3
|
||||
|
||||
- name: OSV advisory scan (uv.lock)
|
||||
# Checks every package version pinned in the PR's uv.lock against the
|
||||
# OSV advisory database, which covers known-malicious, typosquatted,
|
||||
# and CVE-flagged versions. Only fires when uv.lock is in the changeset
|
||||
# to avoid blocking PRs when main's baseline lockfile already has open
|
||||
# advisories on main.
|
||||
if: ${{ steps.gate.outputs.scan == 'true' }}
|
||||
working-directory: pr
|
||||
run: |
|
||||
if ! grep -qxF 'uv.lock' "$GITHUB_WORKSPACE/changed.txt"; then
|
||||
echo "uv.lock not changed; skipping OSV scan."
|
||||
exit 0
|
||||
fi
|
||||
uv export --frozen --format requirements-txt --all-extras \
|
||||
> /tmp/uv-req.txt
|
||||
uvx pip-audit --requirement /tmp/uv-req.txt --no-deps
|
||||
|
||||
- name: Semgrep (changed files, local rules)
|
||||
if: ${{ steps.gate.outputs.scan == 'true' }}
|
||||
env:
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
name: Backwards-Compat
|
||||
|
||||
# Cross-version backwards-compatibility sweep against main's e2e + integration
|
||||
# suites, over the FULL pairwise (server, runner) version matrix.
|
||||
#
|
||||
# The version universe is `main` (the checked-out code = client + tests, always)
|
||||
# plus every non-rc release tag; we cross every server version with every runner
|
||||
# version. Each cell pins the server and/or runner subprocess to that build
|
||||
# (a "main" axis value leaves that component on the checked-out code) while the
|
||||
# client and tests stay on main. The (main, main) cell is omitted — it pins
|
||||
# nothing and is exactly the normal e2e gate. So the matrix subsumes the old
|
||||
# single-pin jobs: (old, main) = Config 1; (main, old) = Config 2; (old, old) =
|
||||
# both old; etc. Runner and host are colocated, so the runner axis pins both.
|
||||
#
|
||||
# The test runs are the SAME composite actions the normal gates use
|
||||
# (.github/actions/e2e-run, integration-run); a cell differs only in which
|
||||
# subprocess(es) are the old build.
|
||||
#
|
||||
# Triggers:
|
||||
# workflow_dispatch manual; optional `versions` CSV overrides the set.
|
||||
# schedule every 12h; full pairwise over main + all non-rc tags.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
versions:
|
||||
description: "Comma-separated version set for BOTH axes (e.g. 'main,v0.2.0'). Empty = main + all non-rc tags."
|
||||
required: false
|
||||
default: ""
|
||||
schedule:
|
||||
# Every 12 hours (00:00 and 12:00 UTC).
|
||||
- cron: "0 */12 * * *"
|
||||
|
||||
concurrency:
|
||||
group: backcompat-${{ github.workflow }}-${{ github.sha }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# Compute the full pairwise (server, runner) matrices. Integration is the
|
||||
# single openai-agents leg (claude-sdk/codex reject the mock LLM's
|
||||
# "mock-model"); e2e is sharded per cell. See backcompat-pairwise-matrix.sh.
|
||||
setup:
|
||||
name: setup
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
e2e_matrix: ${{ steps.matrix.outputs.e2e_matrix }}
|
||||
integration_matrix: ${{ steps.matrix.outputs.integration_matrix }}
|
||||
steps:
|
||||
- name: Check out CI scripts + tags
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
sparse-checkout: .github/scripts/ci
|
||||
# Full history so `git tag` sees every release tag for the matrix.
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- name: Compute pairwise matrices
|
||||
id: matrix
|
||||
env:
|
||||
VERSIONS: ${{ github.event.inputs.versions }}
|
||||
NUM_SHARDS: "4"
|
||||
run: bash .github/scripts/ci/backcompat-pairwise-matrix.sh
|
||||
|
||||
# tests/e2e for every (server, runner) cell × shard.
|
||||
backcompat-e2e:
|
||||
name: Backcompat e2e (server ${{ matrix.server }} / runner ${{ matrix.runner }}, shard ${{ matrix.shard_id }}/${{ matrix.num_shards }})
|
||||
needs: setup
|
||||
runs-on: ubuntu-latest
|
||||
# Job-level cap (composite run steps can't set timeout-minutes); mirrors
|
||||
# e2e.yml's ~30-min test budget + setup + up to two old-build installs.
|
||||
timeout-minutes: 45
|
||||
strategy:
|
||||
fail-fast: false
|
||||
# Bound concurrency: the full matrix is large (versions² × shards). Tune
|
||||
# here if the org's runner pool is over/under-subscribed.
|
||||
max-parallel: 10
|
||||
matrix: ${{ fromJSON(needs.setup.outputs.e2e_matrix) }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
# fetch-depth 0 so the action can `git worktree add` the old tags.
|
||||
ref: ${{ github.ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Run e2e suite for this cell
|
||||
uses: ./.github/actions/e2e-run
|
||||
with:
|
||||
# "main" axis -> empty input (use checked-out code); else the tag.
|
||||
# GHA ternary: `!= 'main' && x || ''` (the naive `== 'main' && '' || x`
|
||||
# breaks because '' is falsy and falls through to x).
|
||||
server_version: ${{ matrix.server != 'main' && matrix.server || '' }}
|
||||
runner_version: ${{ matrix.runner != 'main' && matrix.runner || '' }}
|
||||
# Unique per cell so upload-artifact@v4 doesn't collide across the
|
||||
# matrix (every integration cell shares the harness; e2e cells share
|
||||
# a shard_id).
|
||||
artifact_suffix: "-s${{ matrix.server }}-r${{ matrix.runner }}"
|
||||
shard_id: ${{ matrix.shard_id }}
|
||||
num_shards: ${{ matrix.num_shards }}
|
||||
parallelism: "2"
|
||||
nightly_full: ${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' }}
|
||||
|
||||
# tests/integration for every (server, runner) cell (openai-agents leg).
|
||||
backcompat-integration:
|
||||
name: Backcompat integration (server ${{ matrix.server }} / runner ${{ matrix.runner }}, ${{ matrix.harness }})
|
||||
needs: setup
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 40
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 5
|
||||
matrix: ${{ fromJSON(needs.setup.outputs.integration_matrix) }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Run integration suite for this cell
|
||||
uses: ./.github/actions/integration-run
|
||||
with:
|
||||
server_version: ${{ matrix.server != 'main' && matrix.server || '' }}
|
||||
runner_version: ${{ matrix.runner != 'main' && matrix.runner || '' }}
|
||||
# Unique per cell so upload-artifact@v4 doesn't collide across the
|
||||
# matrix (every integration cell shares the harness; e2e cells share
|
||||
# a shard_id).
|
||||
artifact_suffix: "-s${{ matrix.server }}-r${{ matrix.runner }}"
|
||||
harness: ${{ matrix.harness }}
|
||||
model: ${{ matrix.model }}
|
||||
workers: ${{ matrix.workers }}
|
||||
@@ -1,13 +1,14 @@
|
||||
name: UI Snapshot Update
|
||||
|
||||
# Label-driven baseline update for the empty "/" landing snapshot
|
||||
# (tests/e2e_ui/visual/test_landing_snapshot.py).
|
||||
# Label-driven baseline update for the visual-snapshot suite
|
||||
# (tests/e2e_ui/visual/test_*_snapshot.py).
|
||||
#
|
||||
# Add the `update-ui-snapshot` label to a PR and this regenerates the baseline
|
||||
# with --update-snapshots in the SAME digest-pinned Playwright image the compare
|
||||
# gate (ui-snapshot.yml) renders in, then commits the new PNG back to the PR
|
||||
# branch. Replaces the admin-only workflow_dispatch + manual download-and-commit
|
||||
# dance.
|
||||
# Add the `update-ui-snapshot` label to a PR and this regenerates only the
|
||||
# baselines that DON'T match (or are missing) in the SAME digest-pinned Playwright
|
||||
# image the compare gate (ui-snapshot.yml) renders in, then commits the changed
|
||||
# PNGs back to the PR branch. Baselines that already pass are left byte-for-byte
|
||||
# untouched, so labeling to fix one page never churns the others. Replaces the
|
||||
# admin-only workflow_dispatch + manual download-and-commit dance.
|
||||
#
|
||||
# Two-job split (token isolation): the `render` job runs PR-controlled code (the
|
||||
# npm build + the test) in the container with NO push token anywhere on the
|
||||
@@ -43,7 +44,7 @@ jobs:
|
||||
# 1) Render in the pinned image with NO token on the runner. PR-controlled
|
||||
# code runs only here; its sole output is the PNG artifact.
|
||||
render:
|
||||
name: Regenerate landing baseline (no token)
|
||||
name: Regenerate visual baselines (no token)
|
||||
permissions:
|
||||
contents: read
|
||||
# Same-repo only: a fork's read-only token can't push to the fork branch.
|
||||
@@ -109,21 +110,51 @@ jobs:
|
||||
npm ci --legacy-peer-deps --no-audit --no-fund
|
||||
npm run build
|
||||
|
||||
- name: Regenerate the landing baseline
|
||||
# --update-snapshots rewrites the committed PNG; the run "fails" by
|
||||
# design under the plugin, so don't gate on its exit code.
|
||||
- name: Compare the baselines (no --update-snapshots)
|
||||
# Deliberately NOT --update-snapshots: that rewrites EVERY PNG, churning
|
||||
# baselines that already pass (a sub-threshold re-render still changes the
|
||||
# bytes). In plain compare mode the plugin instead leaves passing
|
||||
# baselines untouched and surfaces only the drift -- a mismatching
|
||||
# baseline's fresh render lands in snapshot_failures/.../actual_*.png (the
|
||||
# committed PNG is left in place), and a MISSING baseline is created
|
||||
# directly under snapshots/. The run "fails" by design on any drift, so
|
||||
# don't gate on its exit code.
|
||||
run: |
|
||||
uv run pytest tests/e2e_ui/visual -m visual \
|
||||
-v --tb=long --log-level=INFO -r a \
|
||||
-p no:rerunfailures \
|
||||
--ui-skip-build \
|
||||
--update-snapshots || true
|
||||
--ui-skip-build || true
|
||||
|
||||
- name: Upload regenerated baseline
|
||||
- name: Adopt only the changed renders over their baselines
|
||||
# Copy each mismatching test's actual_<name>.png over its committed
|
||||
# baseline; previously-missing baselines were already written under
|
||||
# snapshots/ by the compare above. Baselines that passed are not in
|
||||
# snapshot_failures, so they stay byte-for-byte unchanged.
|
||||
run: |
|
||||
fail_dir=tests/e2e_ui/visual/snapshot_failures
|
||||
if [ -d "$fail_dir" ]; then
|
||||
while IFS= read -r src; do
|
||||
rel=${src#"$fail_dir"/} # <module>/<test>/actual_<name>.png
|
||||
dest="tests/e2e_ui/visual/snapshots/$(dirname "$rel")/$(basename "$rel" | sed 's/^actual_//')"
|
||||
mkdir -p "$(dirname "$dest")"
|
||||
cp "$src" "$dest"
|
||||
echo "adopted: $dest"
|
||||
done < <(find "$fail_dir" -type f -name 'actual_*.png')
|
||||
else
|
||||
echo "No snapshot_failures dir -- no existing baseline drifted (only new baselines, if any, were created)."
|
||||
fi
|
||||
|
||||
# Tar the snapshots tree (paths intact) so the commit job can restore it
|
||||
# wholesale. Only genuinely-changed/created PNGs differ from the committed
|
||||
# tree, so git add in the commit job stages exactly those.
|
||||
- name: Package baselines
|
||||
run: tar -czf "$RUNNER_TEMP/ui-snapshots.tgz" tests/e2e_ui/visual/snapshots
|
||||
|
||||
- name: Upload regenerated baselines
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: ui-snapshot-update-${{ github.run_id }}
|
||||
path: tests/e2e_ui/visual/snapshots/**
|
||||
path: ${{ runner.temp }}/ui-snapshots.tgz
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
@@ -131,7 +162,7 @@ jobs:
|
||||
# branch, drops in the rendered PNG, and pushes -- so it is safe to hold the
|
||||
# App token here. `git`/`gh` are preinstalled on ubuntu-latest.
|
||||
commit:
|
||||
name: Commit + push landing baseline
|
||||
name: Commit + push visual baselines
|
||||
needs: render
|
||||
# Run even if render failed, so we can still report on the PR + drop the
|
||||
# label; individual steps gate on the render outcome. (Skipped render =>
|
||||
@@ -142,8 +173,6 @@ jobs:
|
||||
pull-requests: write # comment the result + drop the trigger label
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
env:
|
||||
BASELINE: tests/e2e_ui/visual/snapshots/test_landing_snapshot/test_empty_landing_matches_baseline/test_empty_landing_matches_baseline[chromium][linux].png
|
||||
steps:
|
||||
- name: Checkout PR branch
|
||||
if: needs.render.result == 'success'
|
||||
@@ -154,24 +183,27 @@ jobs:
|
||||
ref: ${{ github.event.pull_request.head.ref }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Download regenerated baseline
|
||||
- name: Download regenerated baselines
|
||||
if: needs.render.result == 'success'
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
name: ui-snapshot-update-${{ github.run_id }}
|
||||
path: _ui_snapshot_artifact
|
||||
|
||||
- name: Place the regenerated PNG over the baseline
|
||||
- name: Restore the regenerated baselines
|
||||
if: needs.render.result == 'success'
|
||||
run: |
|
||||
src=$(find _ui_snapshot_artifact -type f \
|
||||
-name 'test_empty_landing_matches_baseline*.png' | head -n1)
|
||||
if [ -z "$src" ]; then
|
||||
echo "error: no regenerated PNG in the render artifact." >&2
|
||||
tgz=$(find _ui_snapshot_artifact -type f -name 'ui-snapshots.tgz' | head -n1)
|
||||
if [ -z "$tgz" ]; then
|
||||
echo "error: no baseline archive in the render artifact." >&2
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p "$(dirname "$BASELINE")"
|
||||
cp "$src" "$BASELINE"
|
||||
# The archive holds the full tests/e2e_ui/visual/snapshots tree, so
|
||||
# extracting it over the checkout replaces EVERY baseline at its
|
||||
# committed path (a removed baseline drops out too). git add below
|
||||
# then stages whatever actually changed.
|
||||
rm -rf tests/e2e_ui/visual/snapshots
|
||||
tar -xzf "$tgz"
|
||||
rm -rf _ui_snapshot_artifact
|
||||
|
||||
# Mint the App token in this no-PR-code job. Skipped when the App isn't
|
||||
@@ -203,7 +235,7 @@ jobs:
|
||||
echo "Baseline already matches this PR's render — nothing to commit."
|
||||
exit 0
|
||||
fi
|
||||
git commit -m "test(e2e-ui): regenerate landing visual baseline"
|
||||
git commit -m "test(e2e-ui): regenerate visual baselines"
|
||||
git push "https://x-access-token:${PUSH_TOKEN}@github.com/${REPO}.git" "HEAD:$HEAD_REF"
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
@@ -220,7 +252,7 @@ jobs:
|
||||
APP_USED: ${{ steps.app-token.conclusion == 'success' }}
|
||||
run: |
|
||||
if [ "$CHANGED" = "true" ]; then
|
||||
base="✅ Regenerated the landing visual baseline in the pinned Playwright image and pushed it to this PR."
|
||||
base="✅ Regenerated the visual baseline(s) in the pinned Playwright image and pushed to this PR."
|
||||
if [ "$APP_USED" = "true" ]; then
|
||||
body="$base CI will re-run on the new commit."
|
||||
else
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
name: UI Snapshot
|
||||
|
||||
# Single visual-regression gate for the empty "/" landing
|
||||
# (tests/e2e_ui/visual/test_landing_snapshot.py).
|
||||
# Visual-regression gate for the committed UI snapshots
|
||||
# (tests/e2e_ui/visual/test_*_snapshot.py -- the empty "/" landing, a mocked
|
||||
# chat conversation, etc.).
|
||||
#
|
||||
# Cross-OS rendering note: screenshots differ across rendering environments
|
||||
# (font rasterizer + hinting + anti-aliasing), so the committed baseline and the
|
||||
@@ -18,16 +19,16 @@ name: UI Snapshot
|
||||
# in the job summary, so they are always one click away.
|
||||
#
|
||||
# Triggers:
|
||||
# pull_request compare the rendered landing against the committed
|
||||
# baseline; fail (with actual/expected/diff PNGs in the
|
||||
# pull_request compare the rendered pages against the committed
|
||||
# baselines; fail (with actual/expected/diff PNGs in the
|
||||
# artifact) on any mismatch. No secrets, so fork PRs run
|
||||
# fine.
|
||||
# workflow_dispatch regenerate the baseline with --update-snapshots in the
|
||||
# same pinned image; the regenerated PNG is in the
|
||||
# workflow_dispatch regenerate the baselines with --update-snapshots in the
|
||||
# same pinned image; the regenerated PNGs are in the
|
||||
# `ui-snapshot-<run_id>` artifact to download and commit.
|
||||
# Any collaborator may run this against an arbitrary `ref`;
|
||||
# the PNG is human-reviewed before it lands, so an
|
||||
# unreviewed ref can't change the baseline on its own.
|
||||
# the PNGs are human-reviewed before they land, so an
|
||||
# unreviewed ref can't change a baseline on its own.
|
||||
#
|
||||
# All baseline-update paths are documented in tests/e2e_ui/visual/README.md
|
||||
# (label the PR for same-repo branches, the local Docker script for forks).
|
||||
@@ -62,7 +63,7 @@ env:
|
||||
|
||||
jobs:
|
||||
ui-snapshot:
|
||||
name: UI Snapshot (empty landing)
|
||||
name: UI Snapshot (visual baselines)
|
||||
runs-on: ubuntu-24.04
|
||||
# Render in the digest-pinned Playwright image (browsers + fonts baked in),
|
||||
# so the committed baseline and the PR comparison are byte-identical and a
|
||||
@@ -117,7 +118,7 @@ jobs:
|
||||
npm ci --legacy-peer-deps --no-audit --no-fund
|
||||
npm run build
|
||||
|
||||
- name: Compare (PR) or regenerate (dispatch) the landing snapshot
|
||||
- name: Compare (PR) or regenerate (dispatch) the visual snapshots
|
||||
id: snapshot
|
||||
# --ui-skip-build: the SPA was built in the previous step. On
|
||||
# workflow_dispatch we pass --update-snapshots, which rewrites the
|
||||
|
||||
@@ -41,9 +41,10 @@ repos:
|
||||
language: system
|
||||
entry: npm --prefix ap-web exec -- prettier --write
|
||||
files: ^ap-web/.*\.(css|html|js|jsx|json|md|mdx|ts|tsx|yaml|yml)$
|
||||
# Exclude generated assets: web-ui build output and Apple Icon
|
||||
# Composer `.icon` bundles (machine-formatted; prettier fights the tooling).
|
||||
exclude: ^(omnigent/server/static/web-ui/assets/|ap-web/electron/icons/.*\.icon/)
|
||||
# Exclude generated assets: web-ui build output, Xcode asset catalogs,
|
||||
# and Apple Icon Composer `.icon` bundles (machine-formatted; prettier
|
||||
# fights the tooling).
|
||||
exclude: ^(omnigent/server/static/web-ui/assets/|ap-web/.*\.xcassets/|ap-web/.*\.icon/)
|
||||
|
||||
# Local `uv` runs rewrite uv.lock's registry to whatever index is
|
||||
# configured on the developer's machine (e.g. the Databricks PyPI
|
||||
|
||||
@@ -367,7 +367,7 @@ name: my_agent
|
||||
prompt: You are a helpful data analyst.
|
||||
|
||||
executor:
|
||||
harness: claude-sdk # or: claude-native, codex, codex-native, cursor, cursor-native, openai-agents, pi, pi-native, antigravity
|
||||
harness: claude-sdk # or: claude-native, codex, codex-native, cursor, cursor-native, openai-agents, pi, pi-native, antigravity, qwen
|
||||
|
||||
tools:
|
||||
# A local Python function (schema auto-generated from the signature)
|
||||
|
||||
+222
@@ -0,0 +1,222 @@
|
||||
# Releasing omnigent
|
||||
|
||||
omnigent ships **three PyPI packages that version-lock together**:
|
||||
|
||||
| Package | What it is |
|
||||
| --- | --- |
|
||||
| `omnigent` | core wheel (bundles the `ap-web` web UI) |
|
||||
| `omnigent-client` | Python client SDK |
|
||||
| `omnigent-ui-sdk` | terminal UI SDK |
|
||||
|
||||
`pip install omnigent==X` must resolve `omnigent-client==X` and
|
||||
`omnigent-ui-sdk==X`. The pins are **lockstep** (the three packages co-version and
|
||||
pin each other with `==`), so every release builds and publishes **all three at
|
||||
one identical version**.
|
||||
|
||||
## Where things run
|
||||
|
||||
- **Source of truth** (versions, tags, GitHub Releases): **`omnigent-ai/omnigent`**
|
||||
— use the **OSS GitHub account** (the personal account with push/release rights
|
||||
on the public repo).
|
||||
- **Publishing to PyPI**: the central **secure-release repo**
|
||||
**`databricks/secure-public-registry-releases-eng`**, `omnigent` workflow —
|
||||
use the **Databricks EMU account**. Publishing runs on hardened runner
|
||||
groups with **OIDC Trusted Publishing (no stored secrets)** and a **mandatory
|
||||
dependency scan**. This is why we don't publish from `omnigent-ai/omnigent`.
|
||||
|
||||
> The exact account handles — and how to request publish access — live in the
|
||||
> internal release wiki; this public runbook refers to them only by role.
|
||||
> Substitute your own handles for `<oss-account>` / `<emu-account>` in the
|
||||
> `gh auth switch --user …` commands below.
|
||||
|
||||
The legacy `.github/workflows/release-omnigent.yml` in this repo is a
|
||||
**deprecated manual fallback only** — its tag-push trigger was removed so a tag
|
||||
never double-publishes. Use the secure repo for real releases.
|
||||
|
||||
> The secure `omnigent` workflow is **manual `workflow_dispatch`** — it can't see
|
||||
> this repo's tag pushes. You bump + tag here, then dispatch it with that tag.
|
||||
|
||||
## Versioning model
|
||||
|
||||
- `main` always carries the **next** version with a `.dev0` suffix
|
||||
(e.g. `0.2.0.dev0`) — never a clean released number. This matches
|
||||
MLflow / Delta / Unity Catalog and keeps every `main` build PEP 440-ordered as
|
||||
"ahead of the last release, not yet the next one".
|
||||
- Releases are cut on **per-minor release branches** (`branch-X.Y`) and tagged
|
||||
there (`vX.Y.Z`); patches (`vX.Y.1`, `vX.Y.2`, …) are cherry-picked onto the
|
||||
same `branch-X.Y`. `main` is never tagged.
|
||||
|
||||
---
|
||||
|
||||
## Release steps (example: `v0.2.0`)
|
||||
|
||||
### 1. Cut the release branch + tag — `omnigent-ai/omnigent` (OSS account)
|
||||
|
||||
Only tag a commit that already has **green CI** — verify `main` is green before
|
||||
branching:
|
||||
|
||||
```bash
|
||||
gh auth switch --user <oss-account>
|
||||
git fetch origin
|
||||
gh run list --repo omnigent-ai/omnigent --branch main --status success --limit 1
|
||||
git checkout -b branch-0.2 origin/main
|
||||
```
|
||||
|
||||
Set the release version in **all three** `pyproject.toml` files — the
|
||||
`version` field **and** the cross-package `==` pins — plus `uv.lock`
|
||||
(`0.2.0.dev0` → `0.2.0`):
|
||||
|
||||
- `pyproject.toml` (`version`, `omnigent-client==`, `omnigent-ui-sdk==`)
|
||||
- `sdks/python-client/pyproject.toml` (`version`, `omnigent==`)
|
||||
- `sdks/ui/pyproject.toml` (`version`, `omnigent-client==`)
|
||||
- `uv.lock` — **hand-edit** the three `version = "…"` lines (omnigent,
|
||||
omnigent-client, omnigent-ui-sdk) and the one cross-pin `specifier = "==…"`
|
||||
(`omnigent-ui-sdk`'s dep on `omnigent-client`). The three packages are
|
||||
**editable workspace members** (`source = { editable = … }`), so uv records
|
||||
**no wheel `hash` entries** for them, and the other two cross-deps appear as
|
||||
`editable = "…"` with no `==` specifier — so only those version/specifier
|
||||
strings change, nothing else (no hashes to touch).
|
||||
**Do not run `uv lock`** locally: it rewrites every registry URL to the
|
||||
internal proxy and that leaks into the lockfile (breaks CI). The published
|
||||
lock must use `https://pypi.org/simple`.
|
||||
|
||||
Stage exactly the version files (don't `-a`, which would sweep in any stray
|
||||
local edits), then commit, tag, and push **the branch + only this tag**:
|
||||
|
||||
```bash
|
||||
git add pyproject.toml sdks/python-client/pyproject.toml sdks/ui/pyproject.toml uv.lock
|
||||
git commit -m "release: v0.2.0"
|
||||
git tag v0.2.0
|
||||
git push -u origin branch-0.2 v0.2.0 # explicit tag, NOT --tags; pushing the tag drafts the GitHub Release (step 5)
|
||||
```
|
||||
|
||||
Keep `main` from re-freezing — bump it to the next dev marker and push:
|
||||
|
||||
```bash
|
||||
git checkout main
|
||||
# set 0.2.0.dev0 -> 0.3.0.dev0 in the 3 pyprojects (+ pins) and uv.lock.
|
||||
# Hand-edit uv.lock here too — same rule, do NOT run `uv lock` (it leaks the proxy URL).
|
||||
git add pyproject.toml sdks/python-client/pyproject.toml sdks/ui/pyproject.toml uv.lock
|
||||
git commit -m "chore: bump main to 0.3.0.dev0"
|
||||
git push
|
||||
```
|
||||
|
||||
### 2. Dry-run the gates — secure repo (EMU account)
|
||||
|
||||
```bash
|
||||
gh auth switch --user <emu-account>
|
||||
gh workflow run omnigent.yml --repo databricks/secure-public-registry-releases-eng \
|
||||
-f ref=v0.2.0 -f destination=test-pypi -f dry-run=true
|
||||
```
|
||||
|
||||
Runs build + dependency scan + the gates (lockstep version/pins, web-UI-in-wheel,
|
||||
`twine check`, smoke-install) and the OIDC token exchange — **without uploading**.
|
||||
|
||||
### 3. Publish to TestPyPI + validate
|
||||
|
||||
```bash
|
||||
gh workflow run omnigent.yml --repo databricks/secure-public-registry-releases-eng \
|
||||
-f ref=v0.2.0 -f destination=test-pypi -f dry-run=false
|
||||
```
|
||||
|
||||
Validate in a clean venv. **Don't** use `--extra-index-url` with TestPyPI: pip
|
||||
resolves each name across *both* indexes and picks the highest version, so anyone
|
||||
squatting `omnigent` / `omnigent-client` / `omnigent-ui-sdk` on real PyPI at a
|
||||
higher version wins the resolution (dependency confusion). Instead, take **deps
|
||||
from real PyPI only** and the **candidates from TestPyPI only**, exact-pinned with
|
||||
`--no-deps`:
|
||||
|
||||
```bash
|
||||
python -m venv /tmp/omni-rc
|
||||
# 1) seed the dependency closure from REAL PyPI (the last released omnigent):
|
||||
/tmp/omni-rc/bin/pip install --index-url https://pypi.org/simple/ omnigent
|
||||
# 2) overlay the candidates from TestPyPI ONLY, exact-pinned, no deps:
|
||||
/tmp/omni-rc/bin/pip install --index-url https://test.pypi.org/simple/ --no-deps \
|
||||
omnigent==0.2.0 omnigent-client==0.2.0 omnigent-ui-sdk==0.2.0
|
||||
/tmp/omni-rc/bin/omnigent --version # expect 0.2.0
|
||||
```
|
||||
|
||||
> If this release **adds a new runtime dependency** the previous release didn't
|
||||
> have, install it explicitly from real PyPI first
|
||||
> (`/tmp/omni-rc/bin/pip install --index-url https://pypi.org/simple/ <dep>`) —
|
||||
> never let a `--no-deps` TestPyPI install pull third-party deps from TestPyPI.
|
||||
|
||||
### 4. Publish to PyPI (prod)
|
||||
|
||||
Requires **admin/maintain** on the secure repo (if you hit a 403, request access
|
||||
via the secure-release owning team / internal release wiki before proceeding);
|
||||
binds the per-package `pypi-omnigent`, `pypi-omnigent-client`,
|
||||
`pypi-omnigent-ui-sdk` Trusted-Publisher environments (may gate on reviewer
|
||||
approval). The prod path also re-verifies that
|
||||
`ref` is exactly the `vX.Y.Z` tag and that the tag points at the built commit.
|
||||
|
||||
```bash
|
||||
gh workflow run omnigent.yml --repo databricks/secure-public-registry-releases-eng \
|
||||
-f ref=v0.2.0 -f destination=pypi -f dry-run=false
|
||||
|
||||
uv tool install omnigent==0.2.0 # final sanity from real PyPI
|
||||
```
|
||||
|
||||
> Note: the dispatch's `-f ref=v0.2.0` is the **omnigent source ref**; it is
|
||||
> distinct from `gh workflow run --ref`, which selects the branch the *workflow
|
||||
> definition* runs from (the secure repo's default).
|
||||
|
||||
### 5. Publish the GitHub Release — `omnigent-ai/omnigent` (OSS account)
|
||||
|
||||
Pushing the `v0.2.0` tag (step 1) triggered `.github/workflows/github-release.yml`,
|
||||
which created a **draft** release with auto-generated notes (PRs since the
|
||||
previous tag). Now:
|
||||
|
||||
1. Open <https://github.com/omnigent-ai/omnigent/releases> and find the `v0.2.0`
|
||||
draft.
|
||||
2. **Verify and edit the notes** — lead with user-facing highlights, call out
|
||||
breaking changes and any upgrade steps, and trim noise from the auto-generated
|
||||
list. The notes are a draft, not the final word.
|
||||
3. **Publish the release** (ideally only after the prod PyPI publish in step 4 has
|
||||
succeeded, so you never advertise a version that isn't installable).
|
||||
|
||||
If the draft wasn't created (e.g. the workflow was disabled), do it manually:
|
||||
|
||||
```bash
|
||||
gh auth switch --user <oss-account>
|
||||
gh release create v0.2.0 --repo omnigent-ai/omnigent \
|
||||
--draft --verify-tag --generate-notes --title "v0.2.0"
|
||||
# review/edit, then publish from the Releases page (or `gh release edit v0.2.0 --draft=false`)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Patch release (e.g. `v0.2.1`)
|
||||
|
||||
Cherry-pick the fix onto the existing `branch-0.2`, then:
|
||||
|
||||
1. Confirm CI is green on `branch-0.2` after the cherry-pick
|
||||
(`gh run list --repo omnigent-ai/omnigent --branch branch-0.2 --status success --limit 1`).
|
||||
2. Bump the three versions/pins + `uv.lock` to `0.2.1` (same hand-edit rules as above).
|
||||
3. Stage explicitly, commit, and tag **on `branch-0.2`**:
|
||||
`git add <version files> && git commit -m "release: v0.2.1" && git tag v0.2.1 && git push origin branch-0.2 v0.2.1`.
|
||||
4. Repeat steps 2–5.
|
||||
|
||||
`main` does **not** change for a patch, and a patch never needs a new
|
||||
`branch-0.Y` — patches always ship from the existing minor branch.
|
||||
|
||||
---
|
||||
|
||||
## If a publish goes wrong (recovery)
|
||||
|
||||
**PyPI releases can't be deleted, only _yanked_**, and a version number once used
|
||||
can never be reused. So:
|
||||
|
||||
- **TestPyPI failed / candidate is bad:** bump to the next number (don't reuse the
|
||||
version) and re-run — TestPyPI is disposable.
|
||||
- **Prod publish partially succeeded** (e.g. two of three packages uploaded):
|
||||
**yank** the published version(s) on PyPI (each affected project → *Manage* →
|
||||
*Releases* → *Yank*) so installs don't resolve a half-published set, then cut the
|
||||
next patch with the fix. Don't try to overwrite — Trusted Publishing / `twine`
|
||||
rejects re-uploading an existing version.
|
||||
- **GitHub Release** for a version you abandoned:
|
||||
`gh release delete vX.Y.Z --repo omnigent-ai/omnigent`, and drop the tag if it
|
||||
shouldn't exist (`git push origin :refs/tags/vX.Y.Z`); re-tag only the corrected
|
||||
commit.
|
||||
- Publishing uses **OIDC Trusted Publishing (no stored secrets)**, so a failed run
|
||||
leaks nothing — just fix forward to the next version.
|
||||
+15
-1
@@ -4,5 +4,19 @@ dist
|
||||
src/components/ui
|
||||
package-lock.json
|
||||
|
||||
# Xcode asset catalogs are tool-owned; Prettier fights Xcode's formatting.
|
||||
**/*.xcassets/**
|
||||
|
||||
# Generated Apple Icon Composer bundles (machine-formatted; prettier fights the tooling)
|
||||
electron/icons/**/*.icon
|
||||
**/*.icon/**
|
||||
|
||||
# iOS build/tooling artifacts. These are git-ignored via ios/.gitignore, but
|
||||
# Prettier doesn't read nested .gitignore files, so they're listed here too:
|
||||
# the local Bundler gem install, build output, and fastlane-generated files
|
||||
# (README.md regenerates on every run; see ios/RELEASE.md for the real docs).
|
||||
ios/vendor/
|
||||
ios/build/
|
||||
ios/fastlane/README.md
|
||||
ios/fastlane/report.xml
|
||||
ios/fastlane/Preview.html
|
||||
ios/fastlane/test_output/
|
||||
|
||||
+13
-17
@@ -1,17 +1,13 @@
|
||||
# ap-web
|
||||
|
||||
The web UI for `omnigent server --agent <agent>`. SPA built with Vite + React + TypeScript +
|
||||
Tailwind v4 + shadcn/ui. Talks to the omnigent FastAPI server's
|
||||
OpenAI-compatible API surface (`/v1/responses`, `/v1/conversations`,
|
||||
session-scoped `/v1/sessions/{id}/resources/files`,
|
||||
`/api/agents`).
|
||||
|
||||
This is the new UI. The legacy `web/` folder targets the old `/api/chat/stream`
|
||||
server and is unrelated.
|
||||
Tailwind v4 + shadcn/ui. Talks to the current Omnigent API surface
|
||||
(`/v1/agents`, `/v1/sessions`, session-scoped
|
||||
`/v1/sessions/{id}/resources/files`).
|
||||
|
||||
## Develop
|
||||
|
||||
In one terminal, start the omnigent server (default port `8000`). Use
|
||||
In one terminal, start the omnigent server (default port `6767`). Use
|
||||
`--agent` to pre-register one or more agents at startup (accepts a YAML file or
|
||||
an agent-image directory; can be repeated):
|
||||
|
||||
@@ -36,15 +32,15 @@ OMNIGENT_URL=http://localhost:9000 npm run dev
|
||||
|
||||
Additional `omnigent server` options:
|
||||
|
||||
| Flag | Default | Description |
|
||||
| --------------------- | ----------------------- | ------------------------------------ |
|
||||
| `--host` | `127.0.0.1` | Host to bind to |
|
||||
| `-p` / `--port` | `8000` | Port to listen on |
|
||||
| `--database-uri` | `sqlite:///omnigent.db` | Database URI for stores |
|
||||
| `--artifact-location` | `./artifacts` | Path for artifact storage |
|
||||
| `-c` / `--config` | (none) | Path to YAML config file |
|
||||
| `--execution-timeout` | `7200` | Max wall-clock seconds per execution |
|
||||
| `--agent` | (none) | Pre-register an agent (repeatable) |
|
||||
| Flag | Default | Description |
|
||||
| --------------------- | ---------------------- | ------------------------------------ |
|
||||
| `--host` | `127.0.0.1` | Host to bind to |
|
||||
| `-p` / `--port` | `6767` | Port to listen on |
|
||||
| `--database-uri` | `<data-dir>/chat.db` | Database URI for stores |
|
||||
| `--artifact-location` | `<data-dir>/artifacts` | Path for artifact storage |
|
||||
| `-c` / `--config` | (none) | Path to YAML config file |
|
||||
| `--execution-timeout` | `7200` | Max wall-clock seconds per execution |
|
||||
| `--agent` | (none) | Pre-register an agent (repeatable) |
|
||||
|
||||
## Build + serve from the Omnigent server
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
# App icons
|
||||
|
||||
- `AppIcon.icon` — source of truth for the macOS icon: an Apple Icon
|
||||
Composer bundle (layered artwork + gradient background).
|
||||
- `../../platform-assets/AppIcon.icon` — source of truth for the Apple
|
||||
platform icon: an Apple Icon Composer bundle (layered artwork + gradient
|
||||
background), shared by Electron and iOS.
|
||||
- `Assets.car` + `icon.icns` — compiled from `AppIcon.icon` by `actool`
|
||||
(checked in so builds don't require Xcode 26+). `Assets.car` gives the
|
||||
native dynamic icon on macOS 26+ (liquid glass, light/dark/tinted);
|
||||
@@ -20,7 +21,7 @@ Requires Xcode 26+ (Icon Composer `.icon` support in actool):
|
||||
```bash
|
||||
cd ap-web/electron/icons
|
||||
TMP=$(mktemp -d)
|
||||
DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer xcrun actool AppIcon.icon \
|
||||
DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer xcrun actool ../../platform-assets/AppIcon.icon \
|
||||
--compile "$TMP" --platform macosx --minimum-deployment-target 11.0 \
|
||||
--app-icon AppIcon --output-partial-info-plist "$TMP/partial.plist"
|
||||
cp "$TMP/Assets.car" Assets.car
|
||||
|
||||
@@ -32,6 +32,15 @@
|
||||
"find/**/*",
|
||||
"icons/**/*"
|
||||
],
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "../platform-assets",
|
||||
"to": "platform-assets",
|
||||
"filter": [
|
||||
"**/*"
|
||||
]
|
||||
}
|
||||
],
|
||||
"mac": {
|
||||
"category": "public.app-category.developer-tools",
|
||||
"icon": "icons/icon.icns",
|
||||
|
||||
@@ -156,8 +156,11 @@
|
||||
<div class="drag-strip"></div>
|
||||
<div class="card">
|
||||
<picture>
|
||||
<source srcset="assets/omnigents-logo-reverse.svg" media="(prefers-color-scheme: dark)" />
|
||||
<img class="logo" src="assets/omnigents-logo.svg" alt="Omnigents" />
|
||||
<source
|
||||
srcset="../../platform-assets/logos/omnigents-logo-reverse.svg"
|
||||
media="(prefers-color-scheme: dark)"
|
||||
/>
|
||||
<img class="logo" src="../../platform-assets/logos/omnigents-logo.svg" alt="Omnigents" />
|
||||
</picture>
|
||||
<p class="sub">
|
||||
Enter the URL of the Omnigents server. The desktop app loads its web UI directly.
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# Xcode per-user state (window layout, open files, scheme selection, etc.)
|
||||
xcuserdata/
|
||||
|
||||
# Build artifacts
|
||||
build/
|
||||
*.ipa
|
||||
*.dSYM.zip
|
||||
|
||||
# Bundler (local gem install)
|
||||
.bundle/
|
||||
vendor/
|
||||
|
||||
# Signing secrets — never commit
|
||||
fastlane/AuthKey_*.p8
|
||||
fastlane/.env
|
||||
|
||||
# fastlane run output
|
||||
fastlane/report.xml
|
||||
fastlane/Preview.html
|
||||
fastlane/test_output/
|
||||
|
||||
# Auto-generated lane docs (regenerated on every fastlane run; see RELEASE.md)
|
||||
fastlane/README.md
|
||||
@@ -0,0 +1,3 @@
|
||||
source "https://rubygems.org"
|
||||
|
||||
gem "fastlane"
|
||||
@@ -0,0 +1,231 @@
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
CFPropertyList (3.0.9)
|
||||
abbrev (0.1.2)
|
||||
addressable (2.9.0)
|
||||
public_suffix (>= 2.0.2, < 8.0)
|
||||
artifactory (3.0.17)
|
||||
atomos (0.1.3)
|
||||
aws-eventstream (1.3.2)
|
||||
aws-partitions (1.1109.0)
|
||||
aws-sdk-core (3.224.1)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.992.0)
|
||||
aws-sigv4 (~> 1.9)
|
||||
base64
|
||||
jmespath (~> 1, >= 1.6.1)
|
||||
logger
|
||||
aws-sdk-kms (1.101.0)
|
||||
aws-sdk-core (~> 3, >= 3.216.0)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sdk-s3 (1.188.0)
|
||||
aws-sdk-core (~> 3, >= 3.224.1)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sigv4 (1.11.0)
|
||||
aws-eventstream (~> 1, >= 1.0.2)
|
||||
babosa (1.0.4)
|
||||
base64 (0.2.0)
|
||||
claide (1.1.0)
|
||||
colored (1.2)
|
||||
colored2 (3.1.2)
|
||||
commander (4.6.0)
|
||||
highline (~> 2.0.0)
|
||||
csv (3.3.5)
|
||||
declarative (0.0.20)
|
||||
digest-crc (0.7.0)
|
||||
rake (>= 12.0.0, < 14.0.0)
|
||||
domain_name (0.5.20190701)
|
||||
unf (>= 0.0.5, < 1.0.0)
|
||||
dotenv (2.8.1)
|
||||
emoji_regex (3.2.3)
|
||||
excon (0.109.0)
|
||||
faraday (1.10.5)
|
||||
faraday-em_http (~> 1.0)
|
||||
faraday-em_synchrony (~> 1.0)
|
||||
faraday-excon (~> 1.1)
|
||||
faraday-httpclient (~> 1.0)
|
||||
faraday-multipart (~> 1.0)
|
||||
faraday-net_http (~> 1.0)
|
||||
faraday-net_http_persistent (~> 1.0)
|
||||
faraday-patron (~> 1.0)
|
||||
faraday-rack (~> 1.0)
|
||||
faraday-retry (~> 1.0)
|
||||
ruby2_keywords (>= 0.0.4)
|
||||
faraday-cookie_jar (0.0.8)
|
||||
faraday (>= 0.8.0)
|
||||
http-cookie (>= 1.0.0)
|
||||
faraday-em_http (1.0.0)
|
||||
faraday-em_synchrony (1.0.1)
|
||||
faraday-excon (1.1.0)
|
||||
faraday-httpclient (1.0.1)
|
||||
faraday-multipart (1.2.0)
|
||||
multipart-post (~> 2.0)
|
||||
faraday-net_http (1.0.2)
|
||||
faraday-net_http_persistent (1.2.0)
|
||||
faraday-patron (1.0.0)
|
||||
faraday-rack (1.0.0)
|
||||
faraday-retry (1.0.4)
|
||||
faraday_middleware (1.2.1)
|
||||
faraday (~> 1.0)
|
||||
fastimage (2.4.1)
|
||||
fastlane (2.230.0)
|
||||
CFPropertyList (>= 2.3, < 4.0.0)
|
||||
abbrev (~> 0.1.2)
|
||||
addressable (>= 2.8, < 3.0.0)
|
||||
artifactory (~> 3.0)
|
||||
aws-sdk-s3 (~> 1.0)
|
||||
babosa (>= 1.0.3, < 2.0.0)
|
||||
base64 (~> 0.2.0)
|
||||
bundler (>= 1.12.0, < 3.0.0)
|
||||
colored (~> 1.2)
|
||||
commander (~> 4.6)
|
||||
csv (~> 3.3)
|
||||
dotenv (>= 2.1.1, < 3.0.0)
|
||||
emoji_regex (>= 0.1, < 4.0)
|
||||
excon (>= 0.71.0, < 1.0.0)
|
||||
faraday (~> 1.0)
|
||||
faraday-cookie_jar (~> 0.0.6)
|
||||
faraday_middleware (~> 1.0)
|
||||
fastimage (>= 2.1.0, < 3.0.0)
|
||||
fastlane-sirp (>= 1.0.0)
|
||||
gh_inspector (>= 1.1.2, < 2.0.0)
|
||||
google-apis-androidpublisher_v3 (~> 0.3)
|
||||
google-apis-playcustomapp_v1 (~> 0.1)
|
||||
google-cloud-env (>= 1.6.0, < 2.0.0)
|
||||
google-cloud-storage (~> 1.31)
|
||||
highline (~> 2.0)
|
||||
http-cookie (~> 1.0.5)
|
||||
json (< 3.0.0)
|
||||
jwt (>= 2.1.0, < 3)
|
||||
logger (>= 1.6, < 2.0)
|
||||
mini_magick (>= 4.9.4, < 5.0.0)
|
||||
multipart-post (>= 2.0.0, < 3.0.0)
|
||||
mutex_m (~> 0.3.0)
|
||||
naturally (~> 2.2)
|
||||
nkf (~> 0.2.0)
|
||||
optparse (>= 0.1.1, < 1.0.0)
|
||||
plist (>= 3.1.0, < 4.0.0)
|
||||
rubyzip (>= 2.0.0, < 3.0.0)
|
||||
security (= 0.1.5)
|
||||
simctl (~> 1.6.3)
|
||||
terminal-notifier (>= 2.0.0, < 3.0.0)
|
||||
terminal-table (~> 3)
|
||||
tty-screen (>= 0.6.3, < 1.0.0)
|
||||
tty-spinner (>= 0.8.0, < 1.0.0)
|
||||
word_wrap (~> 1.0.0)
|
||||
xcodeproj (>= 1.13.0, < 2.0.0)
|
||||
xcpretty (~> 0.4.1)
|
||||
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
|
||||
fastlane-sirp (1.1.0)
|
||||
gh_inspector (1.1.3)
|
||||
google-apis-androidpublisher_v3 (0.54.0)
|
||||
google-apis-core (>= 0.11.0, < 2.a)
|
||||
google-apis-core (0.11.3)
|
||||
addressable (~> 2.5, >= 2.5.1)
|
||||
googleauth (>= 0.16.2, < 2.a)
|
||||
httpclient (>= 2.8.1, < 3.a)
|
||||
mini_mime (~> 1.0)
|
||||
representable (~> 3.0)
|
||||
retriable (>= 2.0, < 4.a)
|
||||
rexml
|
||||
google-apis-iamcredentials_v1 (0.17.0)
|
||||
google-apis-core (>= 0.11.0, < 2.a)
|
||||
google-apis-playcustomapp_v1 (0.13.0)
|
||||
google-apis-core (>= 0.11.0, < 2.a)
|
||||
google-apis-storage_v1 (0.29.0)
|
||||
google-apis-core (>= 0.11.0, < 2.a)
|
||||
google-cloud-core (1.6.1)
|
||||
google-cloud-env (>= 1.0, < 3.a)
|
||||
google-cloud-errors (~> 1.0)
|
||||
google-cloud-env (1.6.0)
|
||||
faraday (>= 0.17.3, < 3.0)
|
||||
google-cloud-errors (1.3.1)
|
||||
google-cloud-storage (1.45.0)
|
||||
addressable (~> 2.8)
|
||||
digest-crc (~> 0.4)
|
||||
google-apis-iamcredentials_v1 (~> 0.1)
|
||||
google-apis-storage_v1 (~> 0.29.0)
|
||||
google-cloud-core (~> 1.6)
|
||||
googleauth (>= 0.16.2, < 2.a)
|
||||
mini_mime (~> 1.0)
|
||||
googleauth (1.8.1)
|
||||
faraday (>= 0.17.3, < 3.a)
|
||||
jwt (>= 1.4, < 3.0)
|
||||
multi_json (~> 1.11)
|
||||
os (>= 0.9, < 2.0)
|
||||
signet (>= 0.16, < 2.a)
|
||||
highline (2.0.3)
|
||||
http-cookie (1.0.8)
|
||||
domain_name (~> 0.5)
|
||||
httpclient (2.9.0)
|
||||
mutex_m
|
||||
jmespath (1.6.2)
|
||||
json (2.7.6)
|
||||
jwt (2.10.3)
|
||||
base64
|
||||
logger (1.7.0)
|
||||
mini_magick (4.13.2)
|
||||
mini_mime (1.1.5)
|
||||
multi_json (1.15.0)
|
||||
multipart-post (2.4.1)
|
||||
mutex_m (0.3.0)
|
||||
nanaimo (0.4.0)
|
||||
naturally (2.3.0)
|
||||
nkf (0.2.0)
|
||||
optparse (0.8.1)
|
||||
os (1.1.4)
|
||||
plist (3.7.2)
|
||||
public_suffix (5.1.1)
|
||||
rake (13.4.2)
|
||||
representable (3.2.0)
|
||||
declarative (< 0.1.0)
|
||||
trailblazer-option (>= 0.1.1, < 0.2.0)
|
||||
uber (< 0.2.0)
|
||||
retriable (3.8.0)
|
||||
rexml (3.4.4)
|
||||
rouge (3.28.0)
|
||||
ruby2_keywords (0.0.5)
|
||||
rubyzip (2.4.1)
|
||||
security (0.1.5)
|
||||
signet (0.18.0)
|
||||
addressable (~> 2.8)
|
||||
faraday (>= 0.17.5, < 3.a)
|
||||
jwt (>= 1.5, < 3.0)
|
||||
multi_json (~> 1.10)
|
||||
simctl (1.6.10)
|
||||
CFPropertyList
|
||||
naturally
|
||||
terminal-notifier (2.0.0)
|
||||
terminal-table (3.0.2)
|
||||
unicode-display_width (>= 1.1.1, < 3)
|
||||
trailblazer-option (0.1.2)
|
||||
tty-cursor (0.7.1)
|
||||
tty-screen (0.8.2)
|
||||
tty-spinner (0.9.3)
|
||||
tty-cursor (~> 0.7)
|
||||
uber (0.1.0)
|
||||
unf (0.2.0)
|
||||
unicode-display_width (2.6.0)
|
||||
word_wrap (1.0.0)
|
||||
xcodeproj (1.27.0)
|
||||
CFPropertyList (>= 2.3.3, < 4.0)
|
||||
atomos (~> 0.1.3)
|
||||
claide (>= 1.0.2, < 2.0)
|
||||
colored2 (~> 3.1)
|
||||
nanaimo (~> 0.4.0)
|
||||
rexml (>= 3.3.6, < 4.0)
|
||||
xcpretty (0.4.1)
|
||||
rouge (~> 3.28.0)
|
||||
xcpretty-travis-formatter (1.0.1)
|
||||
xcpretty (~> 0.2, >= 0.0.7)
|
||||
|
||||
PLATFORMS
|
||||
ruby
|
||||
|
||||
DEPENDENCIES
|
||||
fastlane
|
||||
|
||||
BUNDLED WITH
|
||||
1.17.2
|
||||
@@ -0,0 +1,537 @@
|
||||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 60;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
B10000000000000000000001 /* OmnigentApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000001 /* OmnigentApp.swift */; };
|
||||
B10000000000000000000002 /* AppRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000002 /* AppRootView.swift */; };
|
||||
B10000000000000000000003 /* ConnectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000003 /* ConnectView.swift */; };
|
||||
B10000000000000000000004 /* DesignTokens.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000004 /* DesignTokens.swift */; };
|
||||
B10000000000000000000005 /* SettingsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000005 /* SettingsStore.swift */; };
|
||||
B10000000000000000000006 /* ServerURL.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000006 /* ServerURL.swift */; };
|
||||
B10000000000000000000007 /* WorkspaceURLExpander.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000007 /* WorkspaceURLExpander.swift */; };
|
||||
B10000000000000000000008 /* NativeNotificationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000008 /* NativeNotificationManager.swift */; };
|
||||
B10000000000000000000009 /* WebShellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000009 /* WebShellView.swift */; };
|
||||
B1000000000000000000000A /* WebViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1000000000000000000000A /* WebViewModel.swift */; };
|
||||
B1000000000000000000000B /* OmnigentWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1000000000000000000000B /* OmnigentWebView.swift */; };
|
||||
B1000000000000000000000C /* URL+Omnigent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1000000000000000000000C /* URL+Omnigent.swift */; };
|
||||
B10000000000000000000011 /* ChatTerminalBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000011 /* ChatTerminalBar.swift */; };
|
||||
B1000000000000000000000D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A1000000000000000000000D /* Assets.xcassets */; };
|
||||
B1000000000000000000000E /* AppIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = A1000000000000000000000E /* AppIcon.icon */; };
|
||||
B20000000000000000000001 /* ServerURLTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000001 /* ServerURLTests.swift */; };
|
||||
B20000000000000000000002 /* SettingsStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000002 /* SettingsStoreTests.swift */; };
|
||||
B20000000000000000000003 /* WorkspaceURLExpanderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000003 /* WorkspaceURLExpanderTests.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
E00000000000000000000001 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = A00000000000000000000005 /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = A00000000000000000000006;
|
||||
remoteInfo = Omnigent;
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
A10000000000000000000001 /* OmnigentApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OmnigentApp.swift; sourceTree = "<group>"; };
|
||||
A10000000000000000000002 /* AppRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppRootView.swift; sourceTree = "<group>"; };
|
||||
A10000000000000000000003 /* ConnectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectView.swift; sourceTree = "<group>"; };
|
||||
A10000000000000000000004 /* DesignTokens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DesignTokens.swift; sourceTree = "<group>"; };
|
||||
A10000000000000000000005 /* SettingsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsStore.swift; sourceTree = "<group>"; };
|
||||
A10000000000000000000006 /* ServerURL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerURL.swift; sourceTree = "<group>"; };
|
||||
A10000000000000000000007 /* WorkspaceURLExpander.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceURLExpander.swift; sourceTree = "<group>"; };
|
||||
A10000000000000000000008 /* NativeNotificationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeNotificationManager.swift; sourceTree = "<group>"; };
|
||||
A10000000000000000000009 /* WebShellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebShellView.swift; sourceTree = "<group>"; };
|
||||
A1000000000000000000000A /* WebViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewModel.swift; sourceTree = "<group>"; };
|
||||
A1000000000000000000000B /* OmnigentWebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OmnigentWebView.swift; sourceTree = "<group>"; };
|
||||
A1000000000000000000000C /* URL+Omnigent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+Omnigent.swift"; sourceTree = "<group>"; };
|
||||
A10000000000000000000011 /* ChatTerminalBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatTerminalBar.swift; sourceTree = "<group>"; };
|
||||
A1000000000000000000000D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
A1000000000000000000000E /* AppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; name = AppIcon.icon; path = "../platform-assets/AppIcon.icon"; sourceTree = "<group>"; };
|
||||
A1000000000000000000000F /* Info-Debug.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "Info-Debug.plist"; sourceTree = "<group>"; };
|
||||
A10000000000000000000010 /* Info-Release.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "Info-Release.plist"; sourceTree = "<group>"; };
|
||||
A20000000000000000000001 /* ServerURLTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerURLTests.swift; sourceTree = "<group>"; };
|
||||
A20000000000000000000002 /* SettingsStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsStoreTests.swift; sourceTree = "<group>"; };
|
||||
A20000000000000000000003 /* WorkspaceURLExpanderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceURLExpanderTests.swift; sourceTree = "<group>"; };
|
||||
A30000000000000000000001 /* Omnigent.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Omnigent.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
A30000000000000000000002 /* OmnigentTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = OmnigentTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
C00000000000000000000002 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
C00000000000000000000005 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
A00000000000000000000001 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A00000000000000000000003 /* Omnigent */,
|
||||
A00000000000000000000004 /* OmnigentTests */,
|
||||
A00000000000000000000008 /* Platform Assets */,
|
||||
A00000000000000000000002 /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
A00000000000000000000002 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A30000000000000000000001 /* Omnigent.app */,
|
||||
A30000000000000000000002 /* OmnigentTests.xctest */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
A00000000000000000000003 /* Omnigent */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A10000000000000000000001 /* OmnigentApp.swift */,
|
||||
A10000000000000000000002 /* AppRootView.swift */,
|
||||
A10000000000000000000003 /* ConnectView.swift */,
|
||||
A10000000000000000000004 /* DesignTokens.swift */,
|
||||
A10000000000000000000005 /* SettingsStore.swift */,
|
||||
A10000000000000000000006 /* ServerURL.swift */,
|
||||
A10000000000000000000007 /* WorkspaceURLExpander.swift */,
|
||||
A10000000000000000000008 /* NativeNotificationManager.swift */,
|
||||
A10000000000000000000009 /* WebShellView.swift */,
|
||||
A1000000000000000000000A /* WebViewModel.swift */,
|
||||
A1000000000000000000000B /* OmnigentWebView.swift */,
|
||||
A1000000000000000000000C /* URL+Omnigent.swift */,
|
||||
A10000000000000000000011 /* ChatTerminalBar.swift */,
|
||||
A1000000000000000000000D /* Assets.xcassets */,
|
||||
A1000000000000000000000F /* Info-Debug.plist */,
|
||||
A10000000000000000000010 /* Info-Release.plist */,
|
||||
);
|
||||
path = Omnigent;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
A00000000000000000000004 /* OmnigentTests */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A20000000000000000000001 /* ServerURLTests.swift */,
|
||||
A20000000000000000000002 /* SettingsStoreTests.swift */,
|
||||
A20000000000000000000003 /* WorkspaceURLExpanderTests.swift */,
|
||||
);
|
||||
path = OmnigentTests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
A00000000000000000000008 /* Platform Assets */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A1000000000000000000000E /* AppIcon.icon */,
|
||||
);
|
||||
name = "Platform Assets";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
A00000000000000000000006 /* Omnigent */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = D10000000000000000000001 /* Build configuration list for PBXNativeTarget "Omnigent" */;
|
||||
buildPhases = (
|
||||
C00000000000000000000001 /* Sources */,
|
||||
C00000000000000000000002 /* Frameworks */,
|
||||
C00000000000000000000003 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = Omnigent;
|
||||
productName = Omnigent;
|
||||
productReference = A30000000000000000000001 /* Omnigent.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
A00000000000000000000007 /* OmnigentTests */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = D20000000000000000000001 /* Build configuration list for PBXNativeTarget "OmnigentTests" */;
|
||||
buildPhases = (
|
||||
C00000000000000000000004 /* Sources */,
|
||||
C00000000000000000000005 /* Frameworks */,
|
||||
C00000000000000000000006 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
E00000000000000000000002 /* PBXTargetDependency */,
|
||||
);
|
||||
name = OmnigentTests;
|
||||
productName = OmnigentTests;
|
||||
productReference = A30000000000000000000002 /* OmnigentTests.xctest */;
|
||||
productType = "com.apple.product-type.bundle.unit-test";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
A00000000000000000000005 /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = 1;
|
||||
LastSwiftUpdateCheck = 1600;
|
||||
LastUpgradeCheck = 1600;
|
||||
TargetAttributes = {
|
||||
A00000000000000000000006 = {
|
||||
CreatedOnToolsVersion = 16.0;
|
||||
};
|
||||
A00000000000000000000007 = {
|
||||
CreatedOnToolsVersion = 16.0;
|
||||
TestTargetID = A00000000000000000000006;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = D00000000000000000000001 /* Build configuration list for PBXProject "Omnigent" */;
|
||||
compatibilityVersion = "Xcode 15.0";
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
en,
|
||||
Base,
|
||||
);
|
||||
mainGroup = A00000000000000000000001;
|
||||
productRefGroup = A00000000000000000000002 /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
A00000000000000000000006 /* Omnigent */,
|
||||
A00000000000000000000007 /* OmnigentTests */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
C00000000000000000000003 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
B1000000000000000000000D /* Assets.xcassets in Resources */,
|
||||
B1000000000000000000000E /* AppIcon.icon in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
C00000000000000000000006 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
C00000000000000000000001 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
B10000000000000000000001 /* OmnigentApp.swift in Sources */,
|
||||
B10000000000000000000002 /* AppRootView.swift in Sources */,
|
||||
B10000000000000000000003 /* ConnectView.swift in Sources */,
|
||||
B10000000000000000000004 /* DesignTokens.swift in Sources */,
|
||||
B10000000000000000000005 /* SettingsStore.swift in Sources */,
|
||||
B10000000000000000000006 /* ServerURL.swift in Sources */,
|
||||
B10000000000000000000007 /* WorkspaceURLExpander.swift in Sources */,
|
||||
B10000000000000000000008 /* NativeNotificationManager.swift in Sources */,
|
||||
B10000000000000000000009 /* WebShellView.swift in Sources */,
|
||||
B1000000000000000000000A /* WebViewModel.swift in Sources */,
|
||||
B1000000000000000000000B /* OmnigentWebView.swift in Sources */,
|
||||
B1000000000000000000000C /* URL+Omnigent.swift in Sources */,
|
||||
B10000000000000000000011 /* ChatTerminalBar.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
C00000000000000000000004 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
B20000000000000000000001 /* ServerURLTests.swift in Sources */,
|
||||
B20000000000000000000002 /* SettingsStoreTests.swift in Sources */,
|
||||
B20000000000000000000003 /* WorkspaceURLExpanderTests.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
E00000000000000000000002 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = A00000000000000000000006 /* Omnigent */;
|
||||
targetProxy = E00000000000000000000001 /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
D00000000000000000000002 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GCC_DYNAMIC_NO_PIC = NO;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_OPTIMIZATION_LEVEL = 0;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"DEBUG=1",
|
||||
"$(inherited)",
|
||||
);
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
D00000000000000000000003 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
D10000000000000000000002 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_ASSET_PATHS = "";
|
||||
DEVELOPMENT_TEAM = 8RMX4WU6F8;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = "Omnigent/Info-Debug.plist";
|
||||
VERSIONING_SYSTEM = "apple-generic";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = ai.omnigent.ios;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SUPPORTS_MACCATALYST = NO;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
D10000000000000000000003 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_ASSET_PATHS = "";
|
||||
DEVELOPMENT_TEAM = 8RMX4WU6F8;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = "Omnigent/Info-Release.plist";
|
||||
VERSIONING_SYSTEM = "apple-generic";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = ai.omnigent.ios;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SUPPORTS_MACCATALYST = NO;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
D20000000000000000000002 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@loader_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = ai.omnigent.ios.tests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SUPPORTS_MACCATALYST = NO;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Omnigent.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Omnigent";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
D20000000000000000000003 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@loader_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = ai.omnigent.ios.tests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SUPPORTS_MACCATALYST = NO;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Omnigent.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Omnigent";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
D00000000000000000000001 /* Build configuration list for PBXProject "Omnigent" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
D00000000000000000000002 /* Debug */,
|
||||
D00000000000000000000003 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
D10000000000000000000001 /* Build configuration list for PBXNativeTarget "Omnigent" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
D10000000000000000000002 /* Debug */,
|
||||
D10000000000000000000003 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
D20000000000000000000001 /* Build configuration list for PBXNativeTarget "OmnigentTests" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
D20000000000000000000002 /* Debug */,
|
||||
D20000000000000000000003 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
};
|
||||
rootObject = A00000000000000000000005 /* Project object */;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@@ -0,0 +1,89 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1600"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "A00000000000000000000006"
|
||||
BuildableName = "Omnigent.app"
|
||||
BlueprintName = "Omnigent"
|
||||
ReferencedContainer = "container:Omnigent.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO"
|
||||
parallelizable = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "A00000000000000000000007"
|
||||
BuildableName = "OmnigentTests.xctest"
|
||||
BlueprintName = "OmnigentTests"
|
||||
ReferencedContainer = "container:Omnigent.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "A00000000000000000000006"
|
||||
BuildableName = "Omnigent.app"
|
||||
BlueprintName = "Omnigent"
|
||||
ReferencedContainer = "container:Omnigent.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "A00000000000000000000006"
|
||||
BuildableName = "Omnigent.app"
|
||||
BlueprintName = "Omnigent"
|
||||
ReferencedContainer = "container:Omnigent.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
@@ -0,0 +1,51 @@
|
||||
import SwiftUI
|
||||
|
||||
struct AppRootView: View {
|
||||
@EnvironmentObject private var settings: SettingsStore
|
||||
@State private var mode: Mode
|
||||
|
||||
init() {
|
||||
_mode = State(initialValue: .setup(prefill: nil, error: nil))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
switch mode {
|
||||
case .setup(let prefill, let error):
|
||||
ConnectView(prefill: prefill ?? settings.serverURL, error: error) { url in
|
||||
settings.serverURL = url.absoluteString
|
||||
mode = .web(url)
|
||||
}
|
||||
case .web(let url):
|
||||
WebShellView(
|
||||
initialURL: url,
|
||||
connectToNewServer: {
|
||||
mode = .setup(prefill: settings.serverURL, error: nil)
|
||||
},
|
||||
switchToServer: { nextURL in
|
||||
settings.serverURL = nextURL.absoluteString
|
||||
mode = .web(nextURL)
|
||||
},
|
||||
loadFailed: { failedURL, message in
|
||||
mode = .setup(prefill: failedURL.omnigentOrigin ?? failedURL.absoluteString, error: message)
|
||||
},
|
||||
loadSucceeded: { loadedURL in
|
||||
settings.rememberRecentServer(loadedURL)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
.task {
|
||||
if case .setup(nil, nil) = mode,
|
||||
let saved = settings.serverURL,
|
||||
let url = URL(string: saved) {
|
||||
mode = .web(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum Mode: Equatable {
|
||||
case setup(prefill: String?, error: String?)
|
||||
case web(URL)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.478",
|
||||
"green" : "0.478",
|
||||
"red" : "0.000"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "omnigents-logo.svg",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"preserves-vector-representation" : true
|
||||
}
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
../../../../platform-assets/logos/omnigents-logo.svg
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "omnigents-logo-reverse.svg",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"preserves-vector-representation" : true
|
||||
}
|
||||
}
|
||||
Vendored
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../../../platform-assets/logos/omnigents-logo-reverse.svg
|
||||
@@ -0,0 +1,87 @@
|
||||
import SwiftUI
|
||||
|
||||
/// The native Chat/Terminal switcher rendered over the bottom of the web view.
|
||||
///
|
||||
/// On iOS 26+ the capsule uses the system Liquid Glass material; on iOS 18–25 it
|
||||
/// falls back to `.ultraThinMaterial`, matching the look of `ServerSwitcher`.
|
||||
struct ChatTerminalBar: View {
|
||||
@Binding var mode: WebViewMode
|
||||
let terminalEnabled: Bool
|
||||
let terminalStartingUp: Bool
|
||||
let onSelect: (WebViewMode) -> Void
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Namespace private var selection
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 4) {
|
||||
segment(.chat, title: "Chat", systemImage: "message")
|
||||
segment(.terminal, title: "Terminal", systemImage: "terminal")
|
||||
}
|
||||
.padding(4)
|
||||
.modifier(GlassCapsule(colorScheme: colorScheme))
|
||||
.animation(.easeInOut(duration: 0.18), value: mode)
|
||||
.accessibilityElement(children: .contain)
|
||||
.accessibilityLabel("View mode")
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func segment(_ target: WebViewMode, title: String, systemImage: String) -> some View {
|
||||
let isSelected = mode == target
|
||||
let isDisabled = target == .terminal && !terminalEnabled
|
||||
|
||||
Button {
|
||||
guard !isDisabled, mode != target else { return }
|
||||
onSelect(target)
|
||||
} label: {
|
||||
HStack(spacing: 5) {
|
||||
if target == .terminal && terminalStartingUp {
|
||||
ProgressView()
|
||||
.controlSize(.mini)
|
||||
} else {
|
||||
Image(systemName: systemImage)
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
}
|
||||
Text(title)
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
}
|
||||
.foregroundStyle(
|
||||
isSelected ? DesignTokens.foreground(colorScheme) : DesignTokens.mutedForeground(colorScheme)
|
||||
)
|
||||
.padding(.horizontal, 14)
|
||||
.frame(height: 34)
|
||||
.background {
|
||||
if isSelected {
|
||||
Capsule(style: .continuous)
|
||||
.fill(Color.primary.opacity(colorScheme == .dark ? 0.16 : 0.08))
|
||||
.matchedGeometryEffect(id: "selection", in: selection)
|
||||
}
|
||||
}
|
||||
.contentShape(Capsule(style: .continuous))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(isDisabled)
|
||||
.opacity(isDisabled ? 0.4 : 1)
|
||||
.accessibilityAddTraits(isSelected ? [.isSelected] : [])
|
||||
}
|
||||
}
|
||||
|
||||
/// Wraps the bar in the system glass material where available, otherwise a
|
||||
/// hand-rolled material capsule that mirrors `ServerSwitcher`'s styling.
|
||||
private struct GlassCapsule: ViewModifier {
|
||||
let colorScheme: ColorScheme
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
if #available(iOS 26.0, *) {
|
||||
content.glassEffect(.regular.interactive(), in: .capsule)
|
||||
} else {
|
||||
content
|
||||
.background(.ultraThinMaterial, in: Capsule(style: .continuous))
|
||||
.overlay {
|
||||
Capsule(style: .continuous)
|
||||
.stroke(Color.primary.opacity(colorScheme == .dark ? 0.16 : 0.10), lineWidth: 0.5)
|
||||
}
|
||||
.shadow(color: .black.opacity(colorScheme == .dark ? 0.22 : 0.08), radius: 10, y: 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ConnectView: View {
|
||||
let prefill: String?
|
||||
let error: String?
|
||||
let onConnect: (URL) -> Void
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@EnvironmentObject private var settings: SettingsStore
|
||||
@State private var serverURL: String
|
||||
@State private var message: String?
|
||||
@State private var isConnecting = false
|
||||
|
||||
init(prefill: String?, error: String?, onConnect: @escaping (URL) -> Void) {
|
||||
self.prefill = prefill
|
||||
self.error = error
|
||||
self.onConnect = onConnect
|
||||
_serverURL = State(initialValue: prefill ?? defaultServerURL)
|
||||
_message = State(initialValue: error)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
Spacer(minLength: 24)
|
||||
|
||||
VStack(spacing: 0) {
|
||||
Image(colorScheme == .dark ? "OmnigentLogoReverse" : "OmnigentLogo")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(height: 80)
|
||||
.padding(.bottom, 12)
|
||||
|
||||
Text("Enter the URL of the Omnigents server. The iOS app loads its web UI directly.")
|
||||
.font(.system(size: 14))
|
||||
.lineSpacing(2)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(DesignTokens.mutedForeground(colorScheme))
|
||||
.padding(.bottom, 24)
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Server URL")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(DesignTokens.foreground(colorScheme))
|
||||
|
||||
TextField(defaultServerURL, text: $serverURL)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.keyboardType(.URL)
|
||||
.font(.system(size: 14))
|
||||
.padding(.horizontal, 12)
|
||||
.frame(height: 38)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: DesignTokens.radius)
|
||||
.stroke(DesignTokens.border(colorScheme), lineWidth: 1)
|
||||
}
|
||||
.submitLabel(.go)
|
||||
.onSubmit(connect)
|
||||
}
|
||||
|
||||
Button(action: connect) {
|
||||
if isConnecting {
|
||||
ProgressView()
|
||||
.tint(primaryForeground)
|
||||
} else {
|
||||
Text("Connect")
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 38)
|
||||
.background(primary)
|
||||
.foregroundStyle(primaryForeground)
|
||||
.clipShape(RoundedRectangle(cornerRadius: DesignTokens.radius))
|
||||
.padding(.top, 16)
|
||||
.disabled(isConnecting)
|
||||
|
||||
Text(message ?? "")
|
||||
.font(.system(size: 13))
|
||||
.lineSpacing(2)
|
||||
.foregroundStyle(Color(red: 0.784, green: 0.196, blue: 0.298))
|
||||
.frame(maxWidth: .infinity, minHeight: 38, alignment: .leading)
|
||||
.padding(.top, 12)
|
||||
|
||||
if !settings.recentServers.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Recent servers")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundStyle(DesignTokens.mutedForeground(colorScheme))
|
||||
|
||||
ForEach(settings.recentServers, id: \.self) { recent in
|
||||
Button {
|
||||
serverURL = recent
|
||||
connect()
|
||||
} label: {
|
||||
Text(recent)
|
||||
.font(.system(size: 14))
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 12)
|
||||
.frame(height: 36)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: DesignTokens.radius)
|
||||
.stroke(DesignTokens.border(colorScheme), lineWidth: 1)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(DesignTokens.foreground(colorScheme))
|
||||
}
|
||||
}
|
||||
.padding(.top, 12)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: 384)
|
||||
|
||||
Spacer(minLength: 24)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(DesignTokens.background(colorScheme))
|
||||
}
|
||||
|
||||
private var primary: Color {
|
||||
colorScheme == .dark ? DesignTokens.darkForeground : DesignTokens.lightForeground
|
||||
}
|
||||
|
||||
private var primaryForeground: Color {
|
||||
colorScheme == .dark ? DesignTokens.lightForeground : .white
|
||||
}
|
||||
|
||||
private func connect() {
|
||||
guard !isConnecting else { return }
|
||||
isConnecting = true
|
||||
message = nil
|
||||
|
||||
Task {
|
||||
do {
|
||||
let normalized = try ServerURL.normalize(serverURL, allowsInsecureHTTP: allowsInsecureHTTP)
|
||||
let expanded = await WorkspaceURLExpander.expandIfNeeded(normalized)
|
||||
await MainActor.run {
|
||||
isConnecting = false
|
||||
onConnect(expanded)
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
isConnecting = false
|
||||
message = (error as? LocalizedError)?.errorDescription ?? String(describing: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private let defaultServerURL: String = {
|
||||
#if DEBUG
|
||||
"http://localhost:6767"
|
||||
#else
|
||||
"https://"
|
||||
#endif
|
||||
}()
|
||||
|
||||
private let allowsInsecureHTTP: Bool = {
|
||||
#if DEBUG
|
||||
true
|
||||
#else
|
||||
false
|
||||
#endif
|
||||
}()
|
||||
@@ -0,0 +1,31 @@
|
||||
import SwiftUI
|
||||
|
||||
enum DesignTokens {
|
||||
static let radius: CGFloat = 8
|
||||
|
||||
static let lightBackground = Color.white
|
||||
static let lightForeground = Color(red: 0.067, green: 0.090, blue: 0.110)
|
||||
static let lightMutedForeground = Color(red: 0.435, green: 0.435, blue: 0.435)
|
||||
static let lightBorder = Color(red: 0.910, green: 0.925, blue: 0.941)
|
||||
|
||||
static let darkBackground = Color(red: 0.118, green: 0.098, blue: 0.153)
|
||||
static let darkForeground = Color(red: 0.910, green: 0.925, blue: 0.941)
|
||||
static let darkMutedForeground = Color(red: 0.572, green: 0.643, blue: 0.702)
|
||||
static let darkBorder = Color(red: 0.215, green: 0.219, blue: 0.230)
|
||||
|
||||
static func background(_ scheme: ColorScheme) -> Color {
|
||||
scheme == .dark ? darkBackground : lightBackground
|
||||
}
|
||||
|
||||
static func foreground(_ scheme: ColorScheme) -> Color {
|
||||
scheme == .dark ? darkForeground : lightForeground
|
||||
}
|
||||
|
||||
static func mutedForeground(_ scheme: ColorScheme) -> Color {
|
||||
scheme == .dark ? darkMutedForeground : lightMutedForeground
|
||||
}
|
||||
|
||||
static func border(_ scheme: ColorScheme) -> Color {
|
||||
scheme == .dark ? darkBorder : lightBorder
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Omnigent</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoadsInWebContent</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>Omnigent uses the microphone for voice dictation in the message composer.</string>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict/>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,47 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Omnigent</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>Omnigent uses the microphone for voice dictation in the message composer.</string>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict/>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,100 @@
|
||||
import Foundation
|
||||
import UserNotifications
|
||||
|
||||
@MainActor
|
||||
final class NativeNotificationManager: NSObject, UNUserNotificationCenterDelegate {
|
||||
static let shared = NativeNotificationManager()
|
||||
|
||||
private let center = UNUserNotificationCenter.current()
|
||||
private var activationHandler: ((String) -> Void)?
|
||||
|
||||
private override init() {
|
||||
super.init()
|
||||
}
|
||||
|
||||
func start() {
|
||||
center.delegate = self
|
||||
}
|
||||
|
||||
func setActivationHandler(_ handler: @escaping (String) -> Void) {
|
||||
activationHandler = handler
|
||||
}
|
||||
|
||||
func setBadgeCount(_ count: Int) {
|
||||
Task {
|
||||
await requestAuthorizationIfNeeded()
|
||||
do {
|
||||
try await center.setBadgeCount(max(0, count))
|
||||
} catch {
|
||||
NSLog("[omnigent] failed to set badge count: \(String(describing: error))")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func notify(title: String, body: String?, navigatePath: String?) {
|
||||
Task {
|
||||
let granted = await requestAuthorizationIfNeeded()
|
||||
guard granted else { return }
|
||||
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = title
|
||||
content.body = body ?? ""
|
||||
content.sound = .default
|
||||
if let navigatePath, navigatePath.starts(with: "/") {
|
||||
content.userInfo = ["navigatePath": navigatePath]
|
||||
}
|
||||
|
||||
let request = UNNotificationRequest(
|
||||
identifier: "omnigent.\(UUID().uuidString)",
|
||||
content: content,
|
||||
trigger: nil
|
||||
)
|
||||
|
||||
do {
|
||||
try await center.add(request)
|
||||
} catch {
|
||||
NSLog("[omnigent] failed to add notification: \(String(describing: error))")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func userNotificationCenter(
|
||||
_ center: UNUserNotificationCenter,
|
||||
willPresent notification: UNNotification,
|
||||
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
|
||||
) {
|
||||
completionHandler([.banner, .list, .sound])
|
||||
}
|
||||
|
||||
nonisolated func userNotificationCenter(
|
||||
_ center: UNUserNotificationCenter,
|
||||
didReceive response: UNNotificationResponse,
|
||||
withCompletionHandler completionHandler: @escaping () -> Void
|
||||
) {
|
||||
let path = response.notification.request.content.userInfo["navigatePath"] as? String
|
||||
Task { @MainActor in
|
||||
if let path, path.starts(with: "/") {
|
||||
activationHandler?(path)
|
||||
}
|
||||
completionHandler()
|
||||
}
|
||||
}
|
||||
|
||||
private func requestAuthorizationIfNeeded() async -> Bool {
|
||||
let settings = await center.notificationSettings()
|
||||
switch settings.authorizationStatus {
|
||||
case .authorized, .provisional, .ephemeral:
|
||||
return true
|
||||
case .denied:
|
||||
return false
|
||||
case .notDetermined:
|
||||
do {
|
||||
return try await center.requestAuthorization(options: [.alert, .sound, .badge])
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
@unknown default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct OmnigentApp: App {
|
||||
@StateObject private var settings = SettingsStore()
|
||||
@StateObject private var router = AppRouter()
|
||||
|
||||
init() {
|
||||
NativeNotificationManager.shared.start()
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
AppRootView()
|
||||
.environmentObject(settings)
|
||||
.environmentObject(router)
|
||||
.onAppear {
|
||||
NativeNotificationManager.shared.setActivationHandler { path in
|
||||
router.routeNotification(path)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class AppRouter: ObservableObject {
|
||||
@Published private(set) var pendingNotificationPath: String?
|
||||
|
||||
func routeNotification(_ path: String) {
|
||||
guard path.starts(with: "/") else { return }
|
||||
pendingNotificationPath = path
|
||||
}
|
||||
|
||||
func consumeNotificationPath() -> String? {
|
||||
defer { pendingNotificationPath = nil }
|
||||
return pendingNotificationPath
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,529 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import WebKit
|
||||
|
||||
struct OmnigentWebView: UIViewRepresentable {
|
||||
let initialURL: URL
|
||||
@ObservedObject var model: WebViewModel
|
||||
@ObservedObject var settings: SettingsStore
|
||||
let loadFailed: (URL, String) -> Void
|
||||
let loadSucceeded: (URL) -> Void
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(self)
|
||||
}
|
||||
|
||||
func makeUIView(context: Context) -> WKWebView {
|
||||
let contentController = WKUserContentController()
|
||||
contentController.add(context.coordinator, name: "omnigentNative")
|
||||
contentController.addUserScript(
|
||||
WKUserScript(
|
||||
source: Self.nativeBridgeScript,
|
||||
injectionTime: .atDocumentStart,
|
||||
forMainFrameOnly: true
|
||||
)
|
||||
)
|
||||
|
||||
let configuration = WKWebViewConfiguration()
|
||||
configuration.userContentController = contentController
|
||||
configuration.allowsInlineMediaPlayback = true
|
||||
|
||||
let webView = AccessoryFreeWebView(frame: .zero, configuration: configuration)
|
||||
webView.navigationDelegate = context.coordinator
|
||||
webView.uiDelegate = context.coordinator
|
||||
// The left-edge swipe is repurposed to open the web app's sidebar (see the
|
||||
// edge-pan recognizer below), so the native back/forward gesture is off —
|
||||
// the two would otherwise fight over the same edge.
|
||||
webView.allowsBackForwardNavigationGestures = false
|
||||
webView.isFindInteractionEnabled = true
|
||||
webView.isOpaque = false
|
||||
webView.backgroundColor = .clear
|
||||
webView.underPageBackgroundColor = .clear
|
||||
webView.scrollView.backgroundColor = .clear
|
||||
webView.scrollView.contentInsetAdjustmentBehavior = .never
|
||||
|
||||
let edgePan = UIScreenEdgePanGestureRecognizer(
|
||||
target: context.coordinator,
|
||||
action: #selector(Coordinator.handleLeftEdgePan(_:))
|
||||
)
|
||||
edgePan.edges = .left
|
||||
edgePan.delegate = context.coordinator
|
||||
webView.addGestureRecognizer(edgePan)
|
||||
|
||||
model.webView = webView
|
||||
context.coordinator.attach(webView)
|
||||
context.coordinator.load(initialURL, in: webView)
|
||||
return webView
|
||||
}
|
||||
|
||||
func updateUIView(_ webView: WKWebView, context: Context) {
|
||||
context.coordinator.parent = self
|
||||
model.webView = webView
|
||||
if context.coordinator.pinnedURL != initialURL {
|
||||
context.coordinator.load(initialURL, in: webView)
|
||||
}
|
||||
}
|
||||
|
||||
static func dismantleUIView(_ uiView: WKWebView, coordinator: Coordinator) {
|
||||
uiView.configuration.userContentController.removeScriptMessageHandler(forName: "omnigentNative")
|
||||
coordinator.detach()
|
||||
}
|
||||
|
||||
private static let nativeBridgeScript = """
|
||||
(() => {
|
||||
if (window.omnigentNative && window.omnigentNative.kind === "ios") return;
|
||||
const ensureViewportFit = () => {
|
||||
let meta = document.querySelector('meta[name="viewport"]');
|
||||
if (!meta) {
|
||||
meta = document.createElement("meta");
|
||||
meta.name = "viewport";
|
||||
(document.head || document.documentElement).appendChild(meta);
|
||||
}
|
||||
const content = meta.getAttribute("content") || "width=device-width, initial-scale=1.0";
|
||||
const managedKeys = new Set([
|
||||
"width",
|
||||
"initial-scale",
|
||||
"minimum-scale",
|
||||
"maximum-scale",
|
||||
"user-scalable",
|
||||
"viewport-fit",
|
||||
]);
|
||||
const preserved = content
|
||||
.split(",")
|
||||
.map((part) => part.trim())
|
||||
.filter((part) => {
|
||||
const key = part.split("=")[0]?.trim().toLowerCase();
|
||||
return key && !managedKeys.has(key);
|
||||
});
|
||||
meta.setAttribute(
|
||||
"content",
|
||||
[
|
||||
"width=device-width",
|
||||
"initial-scale=1.0",
|
||||
"minimum-scale=1.0",
|
||||
"maximum-scale=1.0",
|
||||
"user-scalable=no",
|
||||
"viewport-fit=cover",
|
||||
...preserved,
|
||||
].join(", ")
|
||||
);
|
||||
};
|
||||
if (document.head) {
|
||||
ensureViewportFit();
|
||||
} else {
|
||||
document.addEventListener("DOMContentLoaded", ensureViewportFit, { once: true });
|
||||
}
|
||||
const callbacks = new Set();
|
||||
const viewModeCallbacks = new Set();
|
||||
const defineEmit = (name, fn) => {
|
||||
Object.defineProperty(window, name, {
|
||||
configurable: false,
|
||||
enumerable: false,
|
||||
writable: false,
|
||||
value: fn,
|
||||
});
|
||||
};
|
||||
defineEmit("__omnigentNativeEmitNotificationActivated", (path) => {
|
||||
if (typeof path !== "string" || !path.startsWith("/")) return;
|
||||
for (const callback of callbacks) {
|
||||
try { callback(path); } catch {}
|
||||
}
|
||||
});
|
||||
defineEmit("__omnigentNativeEmitViewModeChanged", (mode) => {
|
||||
if (mode !== "chat" && mode !== "terminal") return;
|
||||
for (const callback of viewModeCallbacks) {
|
||||
try { callback(mode); } catch {}
|
||||
}
|
||||
});
|
||||
const sidebarDragCallbacks = new Set();
|
||||
Object.defineProperty(window, "__omnigentNativeEmitSidebarDrag", {
|
||||
configurable: false,
|
||||
enumerable: false,
|
||||
writable: false,
|
||||
value(phase, progress) {
|
||||
if (typeof phase !== "string") return;
|
||||
const fraction =
|
||||
typeof progress === "number" && Number.isFinite(progress)
|
||||
? Math.max(0, Math.min(1, progress))
|
||||
: 0;
|
||||
for (const callback of sidebarDragCallbacks) {
|
||||
try { callback(phase, fraction); } catch {}
|
||||
}
|
||||
},
|
||||
});
|
||||
window.omnigentNative = Object.freeze({
|
||||
kind: "ios",
|
||||
setBadgeCount(count) {
|
||||
window.webkit.messageHandlers.omnigentNative.postMessage({
|
||||
method: "setBadgeCount",
|
||||
count: Number.isFinite(count) ? count : 0,
|
||||
});
|
||||
},
|
||||
notify(params) {
|
||||
window.webkit.messageHandlers.omnigentNative.postMessage({
|
||||
method: "notify",
|
||||
params: {
|
||||
title: params && typeof params.title === "string" ? params.title : "",
|
||||
body: params && typeof params.body === "string" ? params.body : "",
|
||||
navigatePath:
|
||||
params && typeof params.navigatePath === "string" ? params.navigatePath : "",
|
||||
},
|
||||
});
|
||||
return Promise.resolve(true);
|
||||
},
|
||||
onNotificationActivated(callback) {
|
||||
if (typeof callback !== "function") return () => {};
|
||||
callbacks.add(callback);
|
||||
return () => callbacks.delete(callback);
|
||||
},
|
||||
onSidebarDrag(callback) {
|
||||
if (typeof callback !== "function") return () => {};
|
||||
sidebarDragCallbacks.add(callback);
|
||||
return () => sidebarDragCallbacks.delete(callback);
|
||||
},
|
||||
setServerSwitcherHidden(hidden) {
|
||||
window.webkit.messageHandlers.omnigentNative.postMessage({
|
||||
method: "setServerSwitcherHidden",
|
||||
hidden: hidden === true,
|
||||
});
|
||||
},
|
||||
setSidebarOpen(open) {
|
||||
window.webkit.messageHandlers.omnigentNative.postMessage({
|
||||
method: "setServerSwitcherHidden",
|
||||
hidden: open === true,
|
||||
});
|
||||
},
|
||||
setViewMode(params) {
|
||||
const mode = params && params.mode === "terminal" ? "terminal" : "chat";
|
||||
window.webkit.messageHandlers.omnigentNative.postMessage({
|
||||
method: "setViewMode",
|
||||
mode,
|
||||
terminalEnabled: !!(params && params.terminalEnabled),
|
||||
terminalStartingUp: !!(params && params.terminalStartingUp),
|
||||
visible: !!(params && params.visible),
|
||||
});
|
||||
},
|
||||
onViewModeChanged(callback) {
|
||||
if (typeof callback !== "function") return () => {};
|
||||
viewModeCallbacks.add(callback);
|
||||
return () => viewModeCallbacks.delete(callback);
|
||||
},
|
||||
});
|
||||
})();
|
||||
"""
|
||||
|
||||
@MainActor
|
||||
final class Coordinator: NSObject, WKNavigationDelegate, WKUIDelegate, WKScriptMessageHandler, UIGestureRecognizerDelegate {
|
||||
var parent: OmnigentWebView
|
||||
private weak var webView: WKWebView?
|
||||
private(set) var pinnedURL: URL?
|
||||
private var pinnedOrigin: String?
|
||||
|
||||
init(_ parent: OmnigentWebView) {
|
||||
self.parent = parent
|
||||
}
|
||||
|
||||
func attach(_ webView: WKWebView) {
|
||||
self.webView = webView
|
||||
}
|
||||
|
||||
func detach() {
|
||||
webView = nil
|
||||
}
|
||||
|
||||
// A left-edge swipe drives the web app's sidebar as an interactive drawer.
|
||||
// The sidebar's right edge tracks the finger — progress 0→1 maps the drag
|
||||
// across the view width to closed→open — and on release we settle open or
|
||||
// closed from how far it was dragged and the flick velocity. This replaces
|
||||
// the native back gesture (disabled above), which owned this same edge.
|
||||
private static let openProgressThreshold = 0.33
|
||||
private static let openVelocityThreshold: CGFloat = 600
|
||||
|
||||
@objc func handleLeftEdgePan(_ recognizer: UIScreenEdgePanGestureRecognizer) {
|
||||
guard let view = recognizer.view, view.bounds.width > 0 else { return }
|
||||
let width = view.bounds.width
|
||||
let progress = Double(max(0, min(width, recognizer.translation(in: view).x)) / width)
|
||||
|
||||
switch recognizer.state {
|
||||
case .began:
|
||||
parent.model.emitSidebarDrag(phase: "begin", progress: progress)
|
||||
case .changed:
|
||||
parent.model.emitSidebarDrag(phase: "move", progress: progress)
|
||||
case .ended:
|
||||
let velocity = recognizer.velocity(in: view).x
|
||||
let open = progress > Self.openProgressThreshold || velocity > Self.openVelocityThreshold
|
||||
parent.model.emitSidebarDrag(phase: open ? "open" : "close", progress: progress)
|
||||
case .cancelled, .failed:
|
||||
parent.model.emitSidebarDrag(phase: "close", progress: progress)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Let the edge swipe coexist with the page's own scrolling/pan gestures.
|
||||
func gestureRecognizer(
|
||||
_ gestureRecognizer: UIGestureRecognizer,
|
||||
shouldRecognizeSimultaneouslyWith other: UIGestureRecognizer
|
||||
) -> Bool {
|
||||
true
|
||||
}
|
||||
|
||||
func load(_ url: URL, in webView: WKWebView) {
|
||||
pinnedURL = url
|
||||
pinnedOrigin = url.omnigentOrigin
|
||||
publishModelChanges { model in
|
||||
model.currentURL = url
|
||||
model.serverSwitcherHidden = true
|
||||
}
|
||||
webView.load(URLRequest(url: url))
|
||||
}
|
||||
|
||||
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
|
||||
guard isTrustedBridgeMessage(message) else { return }
|
||||
guard let body = message.body as? [String: Any],
|
||||
let method = body["method"] as? String else { return }
|
||||
|
||||
switch method {
|
||||
case "setBadgeCount":
|
||||
let count = (body["count"] as? NSNumber)?.intValue ?? 0
|
||||
NativeNotificationManager.shared.setBadgeCount(count)
|
||||
case "notify":
|
||||
guard let params = body["params"] as? [String: Any],
|
||||
let title = params["title"] as? String,
|
||||
!title.isEmpty else { return }
|
||||
NativeNotificationManager.shared.notify(
|
||||
title: title,
|
||||
body: params["body"] as? String,
|
||||
navigatePath: params["navigatePath"] as? String
|
||||
)
|
||||
case "setServerSwitcherHidden":
|
||||
parent.model.serverSwitcherHidden = (body["hidden"] as? NSNumber)?.boolValue ?? true
|
||||
case "setSidebarOpen":
|
||||
parent.model.serverSwitcherHidden = (body["open"] as? NSNumber)?.boolValue ?? true
|
||||
case "setViewMode":
|
||||
let mode: WebViewMode = (body["mode"] as? String) == "terminal" ? .terminal : .chat
|
||||
parent.model.viewMode = mode
|
||||
parent.model.terminalEnabled = (body["terminalEnabled"] as? NSNumber)?.boolValue ?? false
|
||||
parent.model.terminalStartingUp = (body["terminalStartingUp"] as? NSNumber)?.boolValue ?? false
|
||||
parent.model.bottomBarVisible = (body["visible"] as? NSNumber)?.boolValue ?? false
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
|
||||
parent.model.isLoading = true
|
||||
parent.model.currentURL = webView.url ?? parent.model.currentURL
|
||||
parent.model.serverSwitcherHidden = true
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) {
|
||||
parent.model.currentURL = webView.url ?? parent.model.currentURL
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||
parent.model.isLoading = false
|
||||
parent.model.currentURL = webView.url ?? parent.model.currentURL
|
||||
if webView.url?.path.starts(with: WorkspaceURLExpander.workspaceUIPath) == true {
|
||||
injectWorkspaceChromeCSS(webView)
|
||||
}
|
||||
if webView.url?.omnigentOrigin == pinnedOrigin, let pinnedURL {
|
||||
parent.loadSucceeded(pinnedURL)
|
||||
}
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
|
||||
handleLoadFailure(webView, error: error)
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
|
||||
handleLoadFailure(webView, error: error)
|
||||
}
|
||||
|
||||
func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
|
||||
webView.reload()
|
||||
}
|
||||
|
||||
func webView(
|
||||
_ webView: WKWebView,
|
||||
decidePolicyFor navigationAction: WKNavigationAction,
|
||||
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
|
||||
) {
|
||||
guard let url = navigationAction.request.url,
|
||||
let scheme = url.scheme?.lowercased() else {
|
||||
decisionHandler(.cancel)
|
||||
return
|
||||
}
|
||||
|
||||
if navigationAction.targetFrame == nil {
|
||||
openExternal(url)
|
||||
decisionHandler(.cancel)
|
||||
return
|
||||
}
|
||||
|
||||
if ["http", "https", "about", "blob", "data"].contains(scheme) {
|
||||
decisionHandler(.allow)
|
||||
return
|
||||
}
|
||||
|
||||
if scheme == "mailto" {
|
||||
UIApplication.shared.open(url)
|
||||
decisionHandler(.cancel)
|
||||
return
|
||||
}
|
||||
|
||||
promptForExternalURL(url, scheme: scheme)
|
||||
decisionHandler(.cancel)
|
||||
}
|
||||
|
||||
func webView(
|
||||
_ webView: WKWebView,
|
||||
createWebViewWith configuration: WKWebViewConfiguration,
|
||||
for navigationAction: WKNavigationAction,
|
||||
windowFeatures: WKWindowFeatures
|
||||
) -> WKWebView? {
|
||||
if navigationAction.targetFrame == nil, let url = navigationAction.request.url {
|
||||
openExternal(url)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func webView(
|
||||
_ webView: WKWebView,
|
||||
requestMediaCapturePermissionFor origin: WKSecurityOrigin,
|
||||
initiatedByFrame frame: WKFrameInfo,
|
||||
type: WKMediaCaptureType,
|
||||
decisionHandler: @escaping (WKPermissionDecision) -> Void
|
||||
) {
|
||||
guard type == .microphone,
|
||||
origin.omnigentOrigin == pinnedOrigin,
|
||||
webView.url?.omnigentOrigin == pinnedOrigin else {
|
||||
decisionHandler(.deny)
|
||||
return
|
||||
}
|
||||
decisionHandler(.grant)
|
||||
}
|
||||
|
||||
private func isTrustedBridgeMessage(_ message: WKScriptMessage) -> Bool {
|
||||
guard let pinnedOrigin else { return false }
|
||||
guard message.frameInfo.securityOrigin.omnigentOrigin == pinnedOrigin else { return false }
|
||||
guard webView?.url?.omnigentOrigin == pinnedOrigin else { return false }
|
||||
return message.frameInfo.isMainFrame
|
||||
}
|
||||
|
||||
private func openExternal(_ url: URL) {
|
||||
guard let scheme = url.scheme?.lowercased() else { return }
|
||||
if ["http", "https", "mailto"].contains(scheme) {
|
||||
UIApplication.shared.open(url)
|
||||
return
|
||||
}
|
||||
promptForExternalURL(url, scheme: scheme)
|
||||
}
|
||||
|
||||
private func promptForExternalURL(_ url: URL, scheme: String) {
|
||||
let onPinnedServer = pinnedOrigin != nil && webView?.url?.omnigentOrigin == pinnedOrigin
|
||||
|
||||
if let pinnedOrigin, onPinnedServer, parent.settings.isProtocolAllowed(scheme, from: pinnedOrigin) {
|
||||
UIApplication.shared.open(url)
|
||||
return
|
||||
}
|
||||
|
||||
let requester = webView?.url?.omnigentOrigin ?? "This page"
|
||||
let alert = UIAlertController(
|
||||
title: "Open this \(scheme) link?",
|
||||
message: "\(requester) wants to open:\n\n\(url.absoluteString)",
|
||||
preferredStyle: .alert
|
||||
)
|
||||
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
|
||||
alert.addAction(UIAlertAction(title: "Open", style: .default) { _ in
|
||||
UIApplication.shared.open(url)
|
||||
})
|
||||
if let pinnedOrigin, onPinnedServer {
|
||||
alert.addAction(UIAlertAction(title: "Always Allow", style: .default) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
self.parent.settings.allowProtocol(scheme, from: pinnedOrigin)
|
||||
UIApplication.shared.open(url)
|
||||
})
|
||||
}
|
||||
topViewController()?.present(alert, animated: true)
|
||||
}
|
||||
|
||||
private func handleLoadFailure(_ webView: WKWebView, error: Error) {
|
||||
let nsError = error as NSError
|
||||
guard nsError.code != NSURLErrorCancelled else { return }
|
||||
parent.model.isLoading = false
|
||||
|
||||
let failedURL = failedURL(from: nsError) ?? webView.url ?? pinnedURL ?? parent.initialURL
|
||||
guard failedURL.omnigentOrigin == pinnedOrigin else { return }
|
||||
parent.loadFailed(failedURL, error.localizedDescription)
|
||||
}
|
||||
|
||||
private func publishModelChanges(_ update: @escaping @MainActor (WebViewModel) -> Void) {
|
||||
let model = parent.model
|
||||
Task { @MainActor in
|
||||
update(model)
|
||||
}
|
||||
}
|
||||
|
||||
private func failedURL(from error: NSError) -> URL? {
|
||||
if let url = error.userInfo[NSURLErrorFailingURLErrorKey] as? URL {
|
||||
return url
|
||||
}
|
||||
if let value = error.userInfo[NSURLErrorFailingURLStringErrorKey] as? String {
|
||||
return URL(string: value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func injectWorkspaceChromeCSS(_ webView: WKWebView) {
|
||||
let css = """
|
||||
.omnigent-app {
|
||||
position: fixed !important;
|
||||
inset: 0 !important;
|
||||
z-index: 2147483647 !important;
|
||||
}
|
||||
"""
|
||||
let script = """
|
||||
(() => {
|
||||
if (document.querySelector("style[data-omnigent-workspace-chrome]")) return;
|
||||
const style = document.createElement("style");
|
||||
style.dataset.omnigentWorkspaceChrome = "true";
|
||||
style.textContent = \(WebViewModel.javascriptString(css));
|
||||
document.documentElement.appendChild(style);
|
||||
})();
|
||||
"""
|
||||
webView.evaluateJavaScript(script)
|
||||
}
|
||||
|
||||
private func topViewController() -> UIViewController? {
|
||||
let scene = UIApplication.shared.connectedScenes
|
||||
.compactMap { $0 as? UIWindowScene }
|
||||
.first { $0.activationState == .foregroundActive }
|
||||
let root = scene?.windows.first { $0.isKeyWindow }?.rootViewController
|
||||
return root?.omnigentTopViewController
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private final class AccessoryFreeWebView: WKWebView {
|
||||
override var inputAccessoryView: UIView? {
|
||||
nil
|
||||
}
|
||||
}
|
||||
|
||||
private extension UIViewController {
|
||||
var omnigentTopViewController: UIViewController {
|
||||
if let presentedViewController {
|
||||
return presentedViewController.omnigentTopViewController
|
||||
}
|
||||
if let navigation = self as? UINavigationController,
|
||||
let visible = navigation.visibleViewController {
|
||||
return visible.omnigentTopViewController
|
||||
}
|
||||
if let tab = self as? UITabBarController,
|
||||
let selected = tab.selectedViewController {
|
||||
return selected.omnigentTopViewController
|
||||
}
|
||||
return self
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import Foundation
|
||||
|
||||
enum ServerURLError: LocalizedError, Equatable {
|
||||
case empty
|
||||
case invalid(String)
|
||||
case unsupportedScheme(String)
|
||||
case insecureHTTPNotAllowed
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .empty:
|
||||
"Server URL is empty."
|
||||
case .invalid(let message):
|
||||
"Invalid URL: \(message)"
|
||||
case .unsupportedScheme(let scheme):
|
||||
"Unsupported scheme '\(scheme)'. Use https."
|
||||
case .insecureHTTPNotAllowed:
|
||||
"iOS release builds require https:// server URLs."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ServerURL {
|
||||
static func normalize(_ raw: String, allowsInsecureHTTP: Bool) throws -> URL {
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty { throw ServerURLError.empty }
|
||||
|
||||
let withScheme: String
|
||||
if trimmed.contains("://") {
|
||||
withScheme = trimmed
|
||||
} else {
|
||||
withScheme = "\(allowsInsecureHTTP ? "http" : "https")://\(trimmed)"
|
||||
}
|
||||
|
||||
guard let url = URL(string: withScheme), let scheme = url.scheme?.lowercased() else {
|
||||
throw ServerURLError.invalid(withScheme)
|
||||
}
|
||||
guard scheme == "http" || scheme == "https" else {
|
||||
throw ServerURLError.unsupportedScheme(scheme)
|
||||
}
|
||||
if scheme == "http" && !allowsInsecureHTTP {
|
||||
throw ServerURLError.insecureHTTPNotAllowed
|
||||
}
|
||||
return url
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class SettingsStore: ObservableObject {
|
||||
@Published var serverURL: String? {
|
||||
didSet { defaults.set(serverURL, forKey: Keys.serverURL) }
|
||||
}
|
||||
|
||||
@Published private(set) var recentServers: [String] {
|
||||
didSet { defaults.set(recentServers, forKey: Keys.recentServers) }
|
||||
}
|
||||
|
||||
private let defaults: UserDefaults
|
||||
private let maxRecentServers = 5
|
||||
|
||||
init(defaults: UserDefaults = .standard) {
|
||||
self.defaults = defaults
|
||||
serverURL = defaults.string(forKey: Keys.serverURL)
|
||||
recentServers = defaults.stringArray(forKey: Keys.recentServers) ?? []
|
||||
}
|
||||
|
||||
func rememberRecentServer(_ url: URL) {
|
||||
let value = url.absoluteString
|
||||
let deduped: [String] = [value] + recentServers.filter { $0 != value }
|
||||
recentServers = Array(deduped.prefix(maxRecentServers))
|
||||
}
|
||||
|
||||
func isProtocolAllowed(_ scheme: String, from origin: String) -> Bool {
|
||||
allowedProtocols()[origin]?.contains(scheme.lowercased()) == true
|
||||
}
|
||||
|
||||
func allowProtocol(_ scheme: String, from origin: String) {
|
||||
var grants = allowedProtocols()
|
||||
var schemes = grants[origin] ?? []
|
||||
let normalized = scheme.lowercased()
|
||||
if !schemes.contains(normalized) {
|
||||
schemes.append(normalized)
|
||||
}
|
||||
grants[origin] = schemes
|
||||
defaults.set(grants, forKey: Keys.allowedProtocols)
|
||||
}
|
||||
|
||||
private func allowedProtocols() -> [String: [String]] {
|
||||
defaults.dictionary(forKey: Keys.allowedProtocols) as? [String: [String]] ?? [:]
|
||||
}
|
||||
|
||||
private enum Keys {
|
||||
static let serverURL = "omnigent.serverURL"
|
||||
static let recentServers = "omnigent.recentServers"
|
||||
static let allowedProtocols = "omnigent.allowedProtocols"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import Foundation
|
||||
import WebKit
|
||||
|
||||
extension URL {
|
||||
var omnigentOrigin: String? {
|
||||
guard let scheme, let host else { return nil }
|
||||
var components = URLComponents()
|
||||
components.scheme = scheme.lowercased()
|
||||
components.host = host.lowercased()
|
||||
components.port = port
|
||||
return components.url?.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
}
|
||||
|
||||
var omnigentHostLabel: String {
|
||||
guard let host else { return absoluteString }
|
||||
if let port {
|
||||
return "\(host):\(port)"
|
||||
}
|
||||
return host
|
||||
}
|
||||
}
|
||||
|
||||
extension WKSecurityOrigin {
|
||||
var omnigentOrigin: String? {
|
||||
guard !self.protocol.isEmpty, !host.isEmpty else { return nil }
|
||||
var components = URLComponents()
|
||||
components.scheme = self.protocol.lowercased()
|
||||
components.host = host.lowercased()
|
||||
if port > 0 && !Self.isDefaultPort(port, for: self.protocol) {
|
||||
components.port = port
|
||||
}
|
||||
return components.url?.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
}
|
||||
|
||||
private static func isDefaultPort(_ port: Int, for scheme: String) -> Bool {
|
||||
(scheme == "https" && port == 443) || (scheme == "http" && port == 80)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import SwiftUI
|
||||
|
||||
struct WebShellView: View {
|
||||
let initialURL: URL
|
||||
let connectToNewServer: () -> Void
|
||||
let switchToServer: (URL) -> Void
|
||||
let loadFailed: (URL, String) -> Void
|
||||
let loadSucceeded: (URL) -> Void
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@EnvironmentObject private var settings: SettingsStore
|
||||
@EnvironmentObject private var router: AppRouter
|
||||
@StateObject private var model = WebViewModel()
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
ZStack(alignment: .top) {
|
||||
OmnigentWebView(
|
||||
initialURL: initialURL,
|
||||
model: model,
|
||||
settings: settings,
|
||||
loadFailed: loadFailed,
|
||||
loadSucceeded: loadSucceeded
|
||||
)
|
||||
.ignoresSafeArea()
|
||||
|
||||
ServerSwitcher(
|
||||
currentURL: model.currentURL ?? initialURL,
|
||||
recents: settings.recentServers,
|
||||
isLoading: model.isLoading,
|
||||
maxWidth: ServerSwitcherMetrics.maxWidth(for: geometry.size.width),
|
||||
switchServer: switchServer,
|
||||
connectToNewServer: connectToNewServer,
|
||||
reload: model.reload
|
||||
)
|
||||
.padding(.top, 8)
|
||||
.opacity(model.serverSwitcherHidden ? 0 : 1)
|
||||
.scaleEffect(model.serverSwitcherHidden ? 0.96 : 1, anchor: .top)
|
||||
.allowsHitTesting(!model.serverSwitcherHidden)
|
||||
.accessibilityHidden(model.serverSwitcherHidden)
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.16), value: model.serverSwitcherHidden)
|
||||
.ignoresSafeArea(.keyboard)
|
||||
.background(DesignTokens.background(colorScheme).ignoresSafeArea())
|
||||
.overlay(alignment: .bottom) {
|
||||
// Always present, shown/hidden by opacity rather than insert/remove, so
|
||||
// a transient visibility flip never slides the bar in and out. The web
|
||||
// layer reserves a fixed footprint for it (`.omnigent-native-bottom-
|
||||
// spacer` in index.css), so there's no size round-trip to coordinate.
|
||||
ChatTerminalBar(
|
||||
mode: $model.viewMode,
|
||||
terminalEnabled: model.terminalEnabled,
|
||||
terminalStartingUp: model.terminalStartingUp,
|
||||
onSelect: { newMode in
|
||||
model.viewMode = newMode
|
||||
model.emitViewModeChanged(newMode)
|
||||
}
|
||||
)
|
||||
.padding(.bottom, 6)
|
||||
.opacity(model.bottomBarVisible ? 1 : 0)
|
||||
.allowsHitTesting(model.bottomBarVisible)
|
||||
.accessibilityHidden(!model.bottomBarVisible)
|
||||
.animation(.easeInOut(duration: 0.2), value: model.bottomBarVisible)
|
||||
}
|
||||
.ignoresSafeArea(.keyboard)
|
||||
}
|
||||
.onChange(of: router.pendingNotificationPath) { _, _ in
|
||||
if let path = router.consumeNotificationPath() {
|
||||
model.emitNotificationActivation(path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func switchServer(_ urlString: String) {
|
||||
guard let url = URL(string: urlString) else { return }
|
||||
switchToServer(url)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ServerSwitcher: View {
|
||||
let currentURL: URL
|
||||
let recents: [String]
|
||||
let isLoading: Bool
|
||||
let maxWidth: CGFloat
|
||||
let switchServer: (String) -> Void
|
||||
let connectToNewServer: () -> Void
|
||||
let reload: () -> Void
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
var body: some View {
|
||||
Menu {
|
||||
Button {
|
||||
} label: {
|
||||
Label(currentURL.omnigentHostLabel, systemImage: "checkmark")
|
||||
}
|
||||
.disabled(true)
|
||||
|
||||
let otherServers = recents.filter { URL(string: $0)?.omnigentOrigin != currentURL.omnigentOrigin }
|
||||
if !otherServers.isEmpty {
|
||||
Divider()
|
||||
ForEach(otherServers, id: \.self) { recent in
|
||||
Button {
|
||||
switchServer(recent)
|
||||
} label: {
|
||||
Text(URL(string: recent)?.omnigentHostLabel ?? recent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
Button(action: reload) {
|
||||
Label("Reload", systemImage: "arrow.clockwise")
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
Button(action: connectToNewServer) {
|
||||
Label("Connect to New Server", systemImage: "plus")
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
Text(currentURL.omnigentHostLabel)
|
||||
.fontWeight(.medium)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
|
||||
if isLoading {
|
||||
ProgressView()
|
||||
.controlSize(.mini)
|
||||
.padding(.leading, 2)
|
||||
} else {
|
||||
Image(systemName: "chevron.down")
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle(DesignTokens.mutedForeground(colorScheme))
|
||||
}
|
||||
}
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(DesignTokens.foreground(colorScheme))
|
||||
.padding(.horizontal, 10)
|
||||
.frame(height: 28)
|
||||
.frame(maxWidth: maxWidth)
|
||||
.contentShape(RoundedRectangle(cornerRadius: 9, style: .continuous))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
// The material/border/shadow live OUTSIDE the `label:` closure, on the
|
||||
// Menu's persistent host view. Applied inside the closure, UIKit's menu
|
||||
// presentation snapshots the styled label for its open/dismiss morph and
|
||||
// drops the shadow layer — leaving the pill flat (no shadow) for a beat
|
||||
// after dismissal. Keeping the chrome on the Menu sidesteps that snapshot.
|
||||
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 9, style: .continuous))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 9, style: .continuous)
|
||||
.stroke(Color.primary.opacity(colorScheme == .dark ? 0.16 : 0.10), lineWidth: 0.5)
|
||||
}
|
||||
.shadow(color: .black.opacity(colorScheme == .dark ? 0.22 : 0.08), radius: 10, y: 4)
|
||||
.accessibilityLabel("Switch server")
|
||||
}
|
||||
}
|
||||
|
||||
private enum ServerSwitcherMetrics {
|
||||
static func maxWidth(for containerWidth: CGFloat) -> CGFloat {
|
||||
min(172, max(120, containerWidth * 0.38))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import Foundation
|
||||
import WebKit
|
||||
|
||||
enum WebViewMode: String {
|
||||
case chat
|
||||
case terminal
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class WebViewModel: ObservableObject {
|
||||
@Published var currentURL: URL?
|
||||
@Published var isLoading = false
|
||||
@Published var serverSwitcherHidden = true
|
||||
|
||||
/// Whether the native Chat/Terminal switcher should be shown. The web app owns
|
||||
/// this truth and pushes it via `setViewMode`; we only render when it asks us to.
|
||||
@Published var bottomBarVisible = false
|
||||
/// Currently selected mode, kept in sync with the web app in both directions.
|
||||
@Published var viewMode: WebViewMode = .chat
|
||||
/// Whether the Terminal option is selectable (web is connected to a session).
|
||||
@Published var terminalEnabled = false
|
||||
/// Terminal is booting but not yet openable — drives a spinner on the segment.
|
||||
@Published var terminalStartingUp = false
|
||||
|
||||
weak var webView: WKWebView?
|
||||
|
||||
func reload() {
|
||||
webView?.reload()
|
||||
}
|
||||
|
||||
func emitNotificationActivation(_ path: String) {
|
||||
guard path.starts(with: "/") else { return }
|
||||
let script = "window.__omnigentNativeEmitNotificationActivated?.(\(Self.javascriptString(path)));"
|
||||
webView?.evaluateJavaScript(script)
|
||||
}
|
||||
|
||||
/// Tell the web app the user tapped a segment in the native switcher.
|
||||
func emitViewModeChanged(_ mode: WebViewMode) {
|
||||
let script = "window.__omnigentNativeEmitViewModeChanged?.(\(Self.javascriptString(mode.rawValue)));"
|
||||
webView?.evaluateJavaScript(script)
|
||||
}
|
||||
|
||||
func emitSidebarDrag(phase: String, progress: Double) {
|
||||
let clamped = max(0, min(1, progress))
|
||||
let script = "window.__omnigentNativeEmitSidebarDrag?.(\(Self.javascriptString(phase)), \(clamped));"
|
||||
webView?.evaluateJavaScript(script)
|
||||
}
|
||||
|
||||
static func javascriptString(_ value: String) -> String {
|
||||
guard let data = try? JSONEncoder().encode(value),
|
||||
let encoded = String(data: data, encoding: .utf8) else {
|
||||
return "\"\""
|
||||
}
|
||||
return encoded
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import Foundation
|
||||
|
||||
enum WorkspaceURLExpander {
|
||||
static let workspaceUIPath = "/ml/omnigents"
|
||||
|
||||
static func expandIfNeeded(_ url: URL, session: URLSession = .shared) async -> URL {
|
||||
guard url.scheme?.lowercased() == "https", isBareRoot(url), let origin = originURL(for: url) else {
|
||||
return url
|
||||
}
|
||||
|
||||
var request = URLRequest(url: origin)
|
||||
request.httpMethod = "HEAD"
|
||||
request.cachePolicy = .reloadIgnoringLocalCacheData
|
||||
request.timeoutInterval = 8
|
||||
|
||||
do {
|
||||
let (_, response) = try await session.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse else { return url }
|
||||
guard (http.value(forHTTPHeaderField: "server") ?? "").lowercased() == "databricks" else {
|
||||
return url
|
||||
}
|
||||
return URL(string: "\(origin.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/")))\(workspaceUIPath)") ?? url
|
||||
} catch {
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
private static func isBareRoot(_ url: URL) -> Bool {
|
||||
url.path.isEmpty || url.path == "/"
|
||||
}
|
||||
|
||||
private static func originURL(for url: URL) -> URL? {
|
||||
guard let scheme = url.scheme, let host = url.host else { return nil }
|
||||
var components = URLComponents()
|
||||
components.scheme = scheme
|
||||
components.host = host
|
||||
components.port = url.port
|
||||
components.path = "/"
|
||||
return components.url
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import XCTest
|
||||
@testable import Omnigent
|
||||
|
||||
final class ServerURLTests: XCTestCase {
|
||||
func testReleasePolicyDefaultsBareHostToHTTPS() throws {
|
||||
let url = try ServerURL.normalize("example.com", allowsInsecureHTTP: false)
|
||||
XCTAssertEqual(url.absoluteString, "https://example.com")
|
||||
}
|
||||
|
||||
func testDebugPolicyDefaultsBareHostToHTTP() throws {
|
||||
let url = try ServerURL.normalize("localhost:6767", allowsInsecureHTTP: true)
|
||||
XCTAssertEqual(url.absoluteString, "http://localhost:6767")
|
||||
}
|
||||
|
||||
func testReleasePolicyRejectsHTTP() {
|
||||
XCTAssertThrowsError(try ServerURL.normalize("http://example.com", allowsInsecureHTTP: false)) { error in
|
||||
XCTAssertEqual(error as? ServerURLError, .insecureHTTPNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func testRejectsNonWebSchemes() {
|
||||
XCTAssertThrowsError(try ServerURL.normalize("ftp://example.com", allowsInsecureHTTP: true)) { error in
|
||||
XCTAssertEqual(error as? ServerURLError, .unsupportedScheme("ftp"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import XCTest
|
||||
@testable import Omnigent
|
||||
|
||||
@MainActor
|
||||
final class SettingsStoreTests: XCTestCase {
|
||||
private var suiteName: String!
|
||||
private var defaults: UserDefaults!
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
suiteName = "SettingsStoreTests.\(UUID().uuidString)"
|
||||
defaults = UserDefaults(suiteName: suiteName)
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
defaults = nil
|
||||
suiteName = nil
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
func testRecentServersAreDedupedAndCapped() {
|
||||
let store = SettingsStore(defaults: defaults)
|
||||
for host in ["a", "b", "c", "d", "e", "f", "c"] {
|
||||
store.rememberRecentServer(URL(string: "https://\(host).example.com")!)
|
||||
}
|
||||
|
||||
XCTAssertEqual(store.recentServers.count, 5)
|
||||
XCTAssertEqual(store.recentServers.first, "https://c.example.com")
|
||||
XCTAssertFalse(store.recentServers.contains("https://a.example.com"))
|
||||
}
|
||||
|
||||
func testProtocolGrantsAreScopedByOrigin() {
|
||||
let store = SettingsStore(defaults: defaults)
|
||||
store.allowProtocol("vscode", from: "https://one.example.com")
|
||||
|
||||
XCTAssertTrue(store.isProtocolAllowed("vscode", from: "https://one.example.com"))
|
||||
XCTAssertFalse(store.isProtocolAllowed("vscode", from: "https://two.example.com"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import Foundation
|
||||
import XCTest
|
||||
@testable import Omnigent
|
||||
|
||||
final class WorkspaceURLExpanderTests: XCTestCase {
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
URLProtocolStub.handler = nil
|
||||
}
|
||||
|
||||
func testExpandsBareDatabricksWorkspaceRoot() async {
|
||||
URLProtocolStub.handler = { request in
|
||||
let response = HTTPURLResponse(
|
||||
url: request.url!,
|
||||
statusCode: 200,
|
||||
httpVersion: nil,
|
||||
headerFields: ["server": "databricks"]
|
||||
)!
|
||||
return (response, Data())
|
||||
}
|
||||
|
||||
let expanded = await WorkspaceURLExpander.expandIfNeeded(
|
||||
URL(string: "https://workspace.example.com")!,
|
||||
session: stubbedSession()
|
||||
)
|
||||
|
||||
XCTAssertEqual(expanded.absoluteString, "https://workspace.example.com/ml/omnigents")
|
||||
}
|
||||
|
||||
func testLeavesNonWorkspaceRootUnchanged() async {
|
||||
URLProtocolStub.handler = { request in
|
||||
let response = HTTPURLResponse(
|
||||
url: request.url!,
|
||||
statusCode: 200,
|
||||
httpVersion: nil,
|
||||
headerFields: ["server": "nginx"]
|
||||
)!
|
||||
return (response, Data())
|
||||
}
|
||||
|
||||
let original = URL(string: "https://app.example.com")!
|
||||
let expanded = await WorkspaceURLExpander.expandIfNeeded(original, session: stubbedSession())
|
||||
|
||||
XCTAssertEqual(expanded, original)
|
||||
}
|
||||
|
||||
func testLeavesURLsWithPathsUnchangedWithoutProbe() async {
|
||||
let original = URL(string: "https://workspace.example.com/ml/omnigents")!
|
||||
let expanded = await WorkspaceURLExpander.expandIfNeeded(original, session: stubbedSession())
|
||||
|
||||
XCTAssertEqual(expanded, original)
|
||||
XCTAssertNil(URLProtocolStub.handler)
|
||||
}
|
||||
|
||||
private func stubbedSession() -> URLSession {
|
||||
let configuration = URLSessionConfiguration.ephemeral
|
||||
configuration.protocolClasses = [URLProtocolStub.self]
|
||||
return URLSession(configuration: configuration)
|
||||
}
|
||||
}
|
||||
|
||||
private final class URLProtocolStub: URLProtocol {
|
||||
static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))?
|
||||
|
||||
override class func canInit(with request: URLRequest) -> Bool {
|
||||
true
|
||||
}
|
||||
|
||||
override class func canonicalRequest(for request: URLRequest) -> URLRequest {
|
||||
request
|
||||
}
|
||||
|
||||
override func startLoading() {
|
||||
guard let handler = Self.handler else {
|
||||
client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse))
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let (response, data) = try handler(request)
|
||||
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
|
||||
client?.urlProtocol(self, didLoad: data)
|
||||
client?.urlProtocolDidFinishLoading(self)
|
||||
} catch {
|
||||
client?.urlProtocol(self, didFailWithError: error)
|
||||
}
|
||||
}
|
||||
|
||||
override func stopLoading() {}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
# Omnigent iOS
|
||||
|
||||
Thin SwiftUI/WKWebView shell for Omnigent. Like the Electron app, this target
|
||||
loads the server-served web UI instead of shipping a duplicate copy of the SPA.
|
||||
|
||||
## Development
|
||||
|
||||
Open `Omnigent.xcodeproj` in Xcode 16 or newer and run the `Omnigent` scheme on
|
||||
an iOS 18 simulator.
|
||||
|
||||
Debug builds allow `http://` web content for local development by enabling
|
||||
`NSAllowsArbitraryLoadsInWebContent`. Release builds keep App Transport
|
||||
Security defaults and require remote servers to use `https://`.
|
||||
|
||||
## Scope
|
||||
|
||||
The first version provides native setup chrome, recent servers, WKWebView
|
||||
loading, foreground local notifications, app badge updates, and notification
|
||||
tap routing back into the SPA. It does not implement APNs, background polling,
|
||||
or localhost proxy/CORS behavior.
|
||||
@@ -0,0 +1,67 @@
|
||||
# Releasing Omnigent iOS
|
||||
|
||||
Releases are built locally with [fastlane](https://fastlane.tools). The `beta`
|
||||
lane archives a signed Release build and uploads it to TestFlight; the `release`
|
||||
lane uploads to App Store Connect (binary only — review submission is a
|
||||
follow-up).
|
||||
|
||||
## One-time setup
|
||||
|
||||
1. **Xcode 16+** with the command-line tools selected
|
||||
(`xcode-select -p` should point at your Xcode).
|
||||
2. **Install fastlane** (pinned via `Gemfile`):
|
||||
```sh
|
||||
cd ap-web/ios
|
||||
bundle install
|
||||
```
|
||||
3. **Create the app record** in [App Store Connect](https://appstoreconnect.apple.com)
|
||||
for bundle ID `ai.omnigent.ios` (My Apps → +), if it doesn't exist yet.
|
||||
4. **Generate an App Store Connect API key**: Users and Access → Integrations →
|
||||
App Store Connect API → generate a key with the **App Manager** role.
|
||||
Download the `.p8` (you can only download it once) and place it in
|
||||
`ios/fastlane/` — it is git-ignored.
|
||||
5. **Configure env vars**:
|
||||
```sh
|
||||
cp fastlane/.env.example fastlane/.env
|
||||
# edit fastlane/.env: set ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_PATH
|
||||
```
|
||||
`.env` is git-ignored and is loaded automatically by fastlane.
|
||||
|
||||
## Cutting a TestFlight build
|
||||
|
||||
```sh
|
||||
cd ap-web/ios
|
||||
bundle exec fastlane beta
|
||||
```
|
||||
|
||||
This bumps the build number to one past the latest on TestFlight, archives the
|
||||
Release configuration (HTTPS-only, automatic signing under team `8RMX4WU6F8`),
|
||||
and uploads the `.ipa`. The build appears in App Store Connect → TestFlight after
|
||||
Apple finishes processing.
|
||||
|
||||
## Versioning
|
||||
|
||||
- **Build number** (`CFBundleVersion = $(CURRENT_PROJECT_VERSION)`) is computed
|
||||
per upload as `latest_testflight_build_number + 1` and injected at archive time
|
||||
via an xcodebuild `CURRENT_PROJECT_VERSION=…` override. Nothing in the repo is
|
||||
modified, so every `beta`/`release` upload gets a unique, monotonic build
|
||||
number with no version churn in git. Don't bump it by hand.
|
||||
- **Marketing version** (`CFBundleShortVersionString`, currently `0.1.0`) is set
|
||||
manually. Bump `MARKETING_VERSION` for both the Debug and Release
|
||||
configurations of the **Omnigent** target in Xcode (or via `fastlane
|
||||
increment_version_number`) when shipping a new user-facing version.
|
||||
|
||||
## App Store submission (later)
|
||||
|
||||
```sh
|
||||
bundle exec fastlane release
|
||||
```
|
||||
|
||||
Uploads the binary without submitting for review. App Store metadata and
|
||||
screenshots are not yet wired up — add them under `fastlane/metadata` and enable
|
||||
submission in the `release` lane when ready.
|
||||
|
||||
## Other commands
|
||||
|
||||
- `bundle exec fastlane tests` — run the `OmnigentTests` unit suite.
|
||||
- `bundle exec fastlane lanes` — list available lanes.
|
||||
@@ -0,0 +1,11 @@
|
||||
# Copy to fastlane/.env and fill in. Never commit the real values or the .p8.
|
||||
# Generate an App Store Connect API key under Users and Access > Integrations >
|
||||
# App Store Connect API (role: App Manager). Download the .p8 once and place it
|
||||
# in ios/fastlane/.
|
||||
|
||||
ASC_KEY_ID=XXXXXXXXXX
|
||||
ASC_ISSUER_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
|
||||
ASC_KEY_PATH=./fastlane/AuthKey_XXXXXXXXXX.p8
|
||||
|
||||
# Only if the Apple ID belongs to more than one App Store Connect team.
|
||||
# ASC_TEAM_ID=xxxxxxxx
|
||||
@@ -0,0 +1,9 @@
|
||||
app_identifier("ai.omnigent.ios") # The bundle identifier of the app
|
||||
team_id("8RMX4WU6F8") # Apple Developer Portal team (Databricks, Inc.)
|
||||
|
||||
# App Store Connect team — only needed if the Apple ID belongs to multiple teams.
|
||||
itc_team_id(ENV["ASC_TEAM_ID"]) if ENV["ASC_TEAM_ID"]
|
||||
|
||||
# Optional: only used for password-based auth. The App Store Connect API key
|
||||
# (see .env) is the primary auth path and does not require this.
|
||||
apple_dev_portal_id(ENV["APPLE_ID"]) if ENV["APPLE_ID"]
|
||||
@@ -0,0 +1,74 @@
|
||||
default_platform(:ios)
|
||||
|
||||
# Builds are signed with the team's distribution cert via Xcode automatic
|
||||
# signing (-allowProvisioningUpdates). Upload + provisioning use an App Store
|
||||
# Connect API key supplied through env vars — see fastlane/.env.example.
|
||||
|
||||
platform :ios do
|
||||
desc "Run the OmnigentTests unit tests"
|
||||
lane :tests do
|
||||
run_tests(scheme: "Omnigent")
|
||||
end
|
||||
|
||||
desc "Build a signed Release .ipa and upload it to TestFlight"
|
||||
lane :beta do
|
||||
load_asc_api_key
|
||||
build(build_number: next_build_number)
|
||||
upload_to_testflight(skip_waiting_for_build_processing: true)
|
||||
end
|
||||
|
||||
desc "Build a signed Release .ipa and upload it to App Store Connect (no submission)"
|
||||
lane :release do
|
||||
# NB: this uploads a fresh, uniquely-numbered binary. The more common App
|
||||
# Store flow is to *promote* an already-tested TestFlight build instead of
|
||||
# uploading a new one — if you adopt that, replace the build/upload below
|
||||
# with a submission of the chosen TestFlight build. App Store metadata and
|
||||
# screenshots are still a follow-up; this lane uploads but does not submit.
|
||||
load_asc_api_key
|
||||
build(build_number: next_build_number)
|
||||
upload_to_app_store(
|
||||
submit_for_review: false,
|
||||
skip_metadata: true,
|
||||
skip_screenshots: true,
|
||||
precheck_include_in_app_purchases: false
|
||||
)
|
||||
end
|
||||
|
||||
# --- helpers ---
|
||||
|
||||
desc "Archive the Release configuration into ./build"
|
||||
private_lane :build do |options|
|
||||
# Inject the build number as an xcodebuild setting override rather than
|
||||
# mutating tracked files. CFBundleVersion is $(CURRENT_PROJECT_VERSION) in
|
||||
# the Info.plists, so overriding CURRENT_PROJECT_VERSION here flows into the
|
||||
# archived binary — and nothing in the repo changes (no agvtool, no churn).
|
||||
xcargs = ["-allowProvisioningUpdates"]
|
||||
xcargs << "CURRENT_PROJECT_VERSION=#{options[:build_number]}" if options[:build_number]
|
||||
build_app(
|
||||
scheme: "Omnigent",
|
||||
configuration: "Release",
|
||||
export_method: "app-store",
|
||||
xcargs: xcargs.join(" "),
|
||||
output_directory: "./build",
|
||||
clean: true
|
||||
)
|
||||
end
|
||||
|
||||
desc "Next build number: one past the highest already on App Store Connect"
|
||||
private_lane :next_build_number do
|
||||
# TestFlight sees every build (App Store builds pass through it too), so the
|
||||
# latest TestFlight build number is a monotonic counter for the whole app.
|
||||
# Requires the ASC API key to be loaded first.
|
||||
latest_testflight_build_number(initial_build_number: 0) + 1
|
||||
end
|
||||
|
||||
desc "Load the App Store Connect API key from env vars into the session"
|
||||
private_lane :load_asc_api_key do
|
||||
app_store_connect_api_key(
|
||||
key_id: ENV.fetch("ASC_KEY_ID"),
|
||||
issuer_id: ENV.fetch("ASC_ISSUER_ID"),
|
||||
key_filepath: ENV.fetch("ASC_KEY_PATH"),
|
||||
in_house: false
|
||||
)
|
||||
end
|
||||
end
|
||||
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,11 @@
|
||||
# Platform Assets
|
||||
|
||||
Shared native-platform assets for wrappers around the Omnigent web UI.
|
||||
|
||||
- `AppIcon.icon` is the Apple Icon Composer source of truth for the app icon.
|
||||
The iOS project references it directly. Electron consumes generated
|
||||
artifacts in `electron/icons/` (`Assets.car`, `icon.icns`, `icon.png`, and
|
||||
`icon.ico`) so packaging does not require Xcode 26.
|
||||
- `logos/` contains the setup-screen logo SVGs. Electron loads them from
|
||||
`platform-assets` at runtime; iOS symlinks them into its asset catalog so the
|
||||
SwiftUI setup screen uses the same sources.
|
||||
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
@@ -15,8 +15,8 @@ import { AgentHoverCard } from "@/components/AgentHoverCard";
|
||||
*
|
||||
* Named agents win first (nessie runs on the claude-sdk harness, so a
|
||||
* harness check would mislabel it with the Claude glyph), then harness/kind
|
||||
* so any Claude-, Codex-, or pi-backed agent gets the right glyph regardless
|
||||
* of its registered name, then a generic bot.
|
||||
* so any Claude-, Codex-, pi-, or qwen-backed agent gets the right glyph
|
||||
* regardless of its registered name, then a generic bot.
|
||||
*
|
||||
* @param agent - The catalog entry to render.
|
||||
* @returns The icon component to render for the agent.
|
||||
@@ -33,6 +33,7 @@ function iconForAgent(agent: AvailableAgent): ComponentType<SVGProps<SVGSVGEleme
|
||||
if (agent.harness?.includes("claude")) return ClaudeIcon;
|
||||
// Both the SDK "cursor" harness and "cursor-native" get the Cursor glyph.
|
||||
if (agent.harness?.includes("cursor")) return CursorIcon;
|
||||
// qwen falls back to generic BotIcon for now; see docs/QWEN_FOLLOWUPS.md
|
||||
// Exact match — a substring check would false-match e.g. "openapi".
|
||||
if (agent.harness === "pi") return PiIcon;
|
||||
return BotIcon;
|
||||
@@ -43,9 +44,8 @@ function iconForAgent(agent: AvailableAgent): ComponentType<SVGProps<SVGSVGEleme
|
||||
*
|
||||
* Shared by the new-session picker (NewChatDialog) and the "Add agent"
|
||||
* picker (AddAgentDialog) so both render the agent catalog identically.
|
||||
* Claude and Codex agents reuse their own glyphs, matched by harness/kind
|
||||
* so a custom-registered Codex reviewer (not named "codex-native-ui")
|
||||
* still gets the Codex glyph; nessie matches by name. Everything else
|
||||
* Claude, Codex, and pi agents reuse their own glyphs; qwen falls back
|
||||
* to a generic bot icon for now. Nessie matches by name. Everything else
|
||||
* falls back to a generic bot icon.
|
||||
*
|
||||
* @param agent - The catalog entry to render.
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { KeyboardShortcutsDialog, openKeyboardShortcuts } from "./KeyboardShortcutsDialog";
|
||||
|
||||
// The pinned-session row is desktop-only. Default to browser (false).
|
||||
const isNativeShell = vi.fn(() => false);
|
||||
vi.mock("@/lib/nativeBridge", () => ({
|
||||
isNativeShell: () => isNativeShell(),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
isNativeShell.mockReturnValue(false);
|
||||
});
|
||||
afterEach(cleanup);
|
||||
|
||||
// jsdom's navigator is non-mac, so the modifier glyph renders as "Ctrl".
|
||||
@@ -44,4 +53,17 @@ describe("KeyboardShortcutsDialog", () => {
|
||||
// The event dispatch isn't wrapped in act(), so wait for the re-render.
|
||||
expect(await screen.findByText("Send message")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("hides the pinned-session shortcut in a plain browser", () => {
|
||||
render(<KeyboardShortcutsDialog />);
|
||||
toggleViaHotkey();
|
||||
expect(screen.queryByText("Jump to pinned session (1–10)")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows the pinned-session shortcut in the Electron shell", () => {
|
||||
isNativeShell.mockReturnValue(true);
|
||||
render(<KeyboardShortcutsDialog />);
|
||||
toggleViaHotkey();
|
||||
expect(screen.getByText("Jump to pinned session (1–10)")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { isNativeShell } from "@/lib/nativeBridge";
|
||||
|
||||
// Custom event the dialog listens for, so non-adjacent surfaces (e.g. the
|
||||
// account menu) can open it without threading state through the tree.
|
||||
@@ -94,6 +95,24 @@ const SHORTCUT_GROUPS: ShortcutGroup[] = [
|
||||
},
|
||||
];
|
||||
|
||||
// Desktop-only: Cmd/Ctrl+digit collides with browser tab-switching, so the
|
||||
// pinned-session hotkey ships only in the Electron shell (see
|
||||
// usePinnedSessionHotkeys). Injected into "Navigation" when running natively.
|
||||
const PINNED_SESSION_SHORTCUT: Shortcut = {
|
||||
label: "Jump to pinned session (1–10)",
|
||||
keys: [MOD_KEY, "1…0"],
|
||||
};
|
||||
|
||||
/** Shortcut groups for the current runtime — adds desktop-only rows natively. */
|
||||
function shortcutGroupsFor(native: boolean): ShortcutGroup[] {
|
||||
if (!native) return SHORTCUT_GROUPS;
|
||||
return SHORTCUT_GROUPS.map((group) =>
|
||||
group.title === "Navigation"
|
||||
? { ...group, items: [...group.items, PINNED_SESSION_SHORTCUT] }
|
||||
: group,
|
||||
);
|
||||
}
|
||||
|
||||
function Kbd({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<kbd className="inline-flex h-6 min-w-6 items-center justify-center rounded-md border border-border bg-muted px-1.5 font-sans text-xs font-medium text-muted-foreground">
|
||||
@@ -104,6 +123,8 @@ function Kbd({ children }: { children: ReactNode }) {
|
||||
|
||||
export function KeyboardShortcutsDialog() {
|
||||
const [open, setOpen] = useState(false);
|
||||
// Feature-based, stable per session; computed at render so tests can vary it.
|
||||
const groups = shortcutGroupsFor(isNativeShell());
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
@@ -133,7 +154,7 @@ export function KeyboardShortcutsDialog() {
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="max-h-[70vh] overflow-y-auto pr-1">
|
||||
{SHORTCUT_GROUPS.map((group) => (
|
||||
{groups.map((group) => (
|
||||
<section key={group.title} className="mb-4 last:mb-0">
|
||||
<h3 className="mb-1 text-xs font-medium text-muted-foreground">
|
||||
{group.title}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/comp
|
||||
import { copyText } from "@/lib/clipboard";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { UIMessage } from "ai";
|
||||
import { CheckIcon, ChevronLeftIcon, ChevronRightIcon, CopyIcon } from "lucide-react";
|
||||
import { CheckIcon, ChevronLeftIcon, ChevronRightIcon, CopyIcon, WrapTextIcon } from "lucide-react";
|
||||
import type { ComponentProps, HTMLAttributes, ReactElement, ReactNode } from "react";
|
||||
import {
|
||||
cloneElement,
|
||||
@@ -325,6 +325,13 @@ function extractCodeText(children: ReactNode): string {
|
||||
return "";
|
||||
}
|
||||
|
||||
// Shared visual style for the buttons overlaid on a chat code block (copy,
|
||||
// wrap toggle). The frosted/ghost look matches the rest of the chat surface;
|
||||
// positioning lives on the container in ChatCodeBlockPre, not here, so the
|
||||
// buttons stay layout-agnostic.
|
||||
const CODE_BLOCK_OVERLAY_BUTTON_CLASS =
|
||||
"size-8 bg-sidebar/80 text-muted-foreground hover:text-foreground supports-[backdrop-filter]:bg-sidebar/70 supports-[backdrop-filter]:backdrop-blur";
|
||||
|
||||
function ChatCodeBlockCopyButton({ getCode }: { getCode: () => string }) {
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
const timeoutRef = useRef<number>(0);
|
||||
@@ -360,7 +367,7 @@ function ChatCodeBlockCopyButton({ getCode }: { getCode: () => string }) {
|
||||
return (
|
||||
<Button
|
||||
aria-label="Copy Code"
|
||||
className="absolute top-2 right-12 z-10 size-8 bg-sidebar/80 text-muted-foreground hover:text-foreground supports-[backdrop-filter]:bg-sidebar/70 supports-[backdrop-filter]:backdrop-blur"
|
||||
className={CODE_BLOCK_OVERLAY_BUTTON_CLASS}
|
||||
onClick={handleClick}
|
||||
size="icon-sm"
|
||||
title="Copy Code"
|
||||
@@ -372,17 +379,46 @@ function ChatCodeBlockCopyButton({ getCode }: { getCode: () => string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function ChatCodeBlockWrapToggle({ wrap, onToggle }: { wrap: boolean; onToggle: () => void }) {
|
||||
return (
|
||||
<Button
|
||||
aria-label="Toggle word wrap"
|
||||
aria-pressed={wrap}
|
||||
// Brighten when active so the pressed state reads at a glance.
|
||||
className={cn(CODE_BLOCK_OVERLAY_BUTTON_CLASS, wrap && "text-foreground")}
|
||||
onClick={onToggle}
|
||||
size="icon-sm"
|
||||
title={wrap ? "Disable word wrap" : "Enable word wrap"}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<WrapTextIcon size={14} />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatCodeBlockPre({ children }: ComponentProps<"pre">) {
|
||||
const code = extractCodeText(children);
|
||||
const getCode = useCallback(() => code, [code]);
|
||||
// Soft-wrap long lines by default so users don't have to scroll horizontally
|
||||
// to read code blocks. The toggle restores Streamdown's native
|
||||
// horizontal-scroll view for when column alignment matters.
|
||||
const [wrap, setWrap] = useState(true);
|
||||
const toggleWrap = useCallback(() => setWrap((w) => !w), []);
|
||||
const block = isValidElement(children)
|
||||
? cloneElement(children, { "data-block": "true" } as Record<string, unknown>)
|
||||
: children;
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className={cn("relative", wrap && "chat-code-wrap")}>
|
||||
{block}
|
||||
<ChatCodeBlockCopyButton getCode={getCode} />
|
||||
{/* Overlay actions, anchored left of Streamdown's own download button
|
||||
(which sits at the header's right edge). A flex row lets the buttons
|
||||
self-arrange, so neither needs a hardcoded horizontal offset. */}
|
||||
<div className="absolute top-2 right-12 z-10 flex items-center gap-1">
|
||||
<ChatCodeBlockWrapToggle onToggle={toggleWrap} wrap={wrap} />
|
||||
<ChatCodeBlockCopyButton getCode={getCode} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -208,6 +208,128 @@ describe("ApprovalCard — accept & allow all edits", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("ApprovalCard — approve & don't ask again (persistent allow rule)", () => {
|
||||
beforeEach(() => {
|
||||
useChatStore.setState({ conversationId: "conv_abc", blocks: [] });
|
||||
});
|
||||
|
||||
it("labels the remember button by the WebFetch host and hides it without the hint", () => {
|
||||
// The server stamps ``remember_scope`` only for non-edit tools.
|
||||
// For WebFetch the button names the domain so the user knows the
|
||||
// rule is domain-scoped, not tool-wide.
|
||||
const { rerender } = render(
|
||||
<ApprovalCard
|
||||
elicitationId="elic_wf"
|
||||
message="Claude wants to call **WebFetch**"
|
||||
phase="pre_tool_use"
|
||||
policyName="claude_native_permission"
|
||||
contentPreview='WebFetch({"url": "https://github.com/a/b"})'
|
||||
requestedSchema={{}}
|
||||
status="pending"
|
||||
response={null}
|
||||
rememberScope={{ tool: "WebFetch", host: "github.com" }}
|
||||
/>,
|
||||
);
|
||||
const rememberButton = screen.getByRole("button", {
|
||||
name: /don't ask again for github\.com/i,
|
||||
});
|
||||
expect(rememberButton).toBeDefined();
|
||||
// The tooltip spells out the (session-scoped) domain grant.
|
||||
expect(rememberButton.getAttribute("title")).toBe(
|
||||
"Won't ask again for github.com for the rest of this session",
|
||||
);
|
||||
expect(screen.getByRole("button", { name: /^approve$/i })).toBeDefined();
|
||||
expect(screen.getByRole("button", { name: /reject/i })).toBeDefined();
|
||||
|
||||
// No hint (edit tool / ExitPlanMode / AskUserQuestion) → no button.
|
||||
rerender(
|
||||
<ApprovalCard
|
||||
elicitationId="elic_edit"
|
||||
message="Claude wants to call **Edit**"
|
||||
phase="pre_tool_use"
|
||||
policyName="claude_native_permission"
|
||||
contentPreview="Edit({})"
|
||||
requestedSchema={{}}
|
||||
status="pending"
|
||||
response={null}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByTestId("approval-card-remember")).toBeNull();
|
||||
});
|
||||
|
||||
it("labels the remember button by the tool name for a tool-wide scope", () => {
|
||||
// Non-WebFetch tools get a tool-wide scope (no host), so the
|
||||
// button names the tool instead of a domain.
|
||||
render(
|
||||
<ApprovalCard
|
||||
elicitationId="elic_bash"
|
||||
message="Claude wants to call **Bash**"
|
||||
phase="pre_tool_use"
|
||||
policyName="claude_native_permission"
|
||||
contentPreview="Bash({})"
|
||||
requestedSchema={{}}
|
||||
status="pending"
|
||||
response={null}
|
||||
rememberScope={{ tool: "Bash" }}
|
||||
/>,
|
||||
);
|
||||
const rememberButton = screen.getByRole("button", { name: /don't ask again for Bash/i });
|
||||
expect(rememberButton).toBeDefined();
|
||||
// Tool-wide grant is broader than a domain — the tooltip says "any".
|
||||
expect(rememberButton.getAttribute("title")).toBe(
|
||||
"Won't ask again for any Bash call for the rest of this session",
|
||||
);
|
||||
});
|
||||
|
||||
it("submits {action: 'accept', content: {remember: true}} on click", () => {
|
||||
// The server reads ``content.remember`` to emit the ``addRules``
|
||||
// permission update; it re-derives the scope itself, so the client
|
||||
// sends only the flag.
|
||||
const submitSpy = vi.fn().mockResolvedValue(undefined);
|
||||
useChatStore.setState({ submitApproval: submitSpy } as Partial<
|
||||
ReturnType<typeof useChatStore.getState>
|
||||
>);
|
||||
|
||||
render(
|
||||
<ApprovalCard
|
||||
elicitationId="elic_wf_click"
|
||||
message="Claude wants to call **WebFetch**"
|
||||
phase="pre_tool_use"
|
||||
policyName="claude_native_permission"
|
||||
contentPreview='WebFetch({"url": "https://github.com/a/b"})'
|
||||
requestedSchema={{}}
|
||||
status="pending"
|
||||
response={null}
|
||||
rememberScope={{ tool: "WebFetch", host: "github.com" }}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("approval-card-remember"));
|
||||
|
||||
expect(submitSpy).toHaveBeenCalledWith("elic_wf_click", "accept", {
|
||||
remember: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("renders the won't-ask-again label in the responded state", () => {
|
||||
render(
|
||||
<ApprovalCard
|
||||
elicitationId="elic_wf_done"
|
||||
message="Claude wants to call **WebFetch**"
|
||||
phase="pre_tool_use"
|
||||
policyName="claude_native_permission"
|
||||
contentPreview='WebFetch({"url": "https://github.com/a/b"})'
|
||||
requestedSchema={{}}
|
||||
status="responded"
|
||||
response={{ action: "accept", content: { remember: true } }}
|
||||
rememberScope={{ tool: "WebFetch", host: "github.com" }}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/won't ask again for github\.com/i)).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ApprovalCard — multi-choice options", () => {
|
||||
beforeEach(() => {
|
||||
useChatStore.setState({
|
||||
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
parseAskUserQuestionPreview,
|
||||
} from "@/lib/askUserQuestion";
|
||||
import { formatPreview } from "@/lib/previewFormat";
|
||||
import type { RememberScope } from "@/lib/types";
|
||||
import { useChatStore } from "@/store/chatStore";
|
||||
import { AskUserQuestionForm, type AskUserQuestionAnswers } from "./AskUserQuestionForm";
|
||||
import { ExitPlanModeReview } from "./ExitPlanModeReview";
|
||||
@@ -136,6 +137,17 @@ interface ApprovalCardProps {
|
||||
* mode switch would be a no-op.
|
||||
*/
|
||||
allowAllEdits?: boolean;
|
||||
/**
|
||||
* Claude-native non-edit tool prompts only: when set, the binary
|
||||
* approve/reject card grows a third "Approve & don't ask again for
|
||||
* <host|tool>" button. Accepting through it asks the server to
|
||||
* install a session-scoped allow rule for the tool (scoped to
|
||||
* ``host`` for WebFetch, tool-wide otherwise) — the web equivalent
|
||||
* of Claude Code's native "don't ask again" permission option, so
|
||||
* same-scope calls stop re-prompting. Absent/null for every other
|
||||
* elicitation (edit tools take the ``allowAllEdits`` path instead).
|
||||
*/
|
||||
rememberScope?: RememberScope | null;
|
||||
/**
|
||||
* Verdict submitter override. Defaults to `chatStore.submitApproval`
|
||||
* (the in-chat path: optimistic block flip + resolve POST + rollback).
|
||||
@@ -159,6 +171,7 @@ export function ApprovalCard({
|
||||
exitPlanMode,
|
||||
codexCommand,
|
||||
allowAllEdits,
|
||||
rememberScope,
|
||||
onSubmit,
|
||||
}: ApprovalCardProps) {
|
||||
const submit: SubmitApprovalFn =
|
||||
@@ -192,6 +205,15 @@ export function ApprovalCard({
|
||||
// mode" action — same flag, server picks the mode).
|
||||
submit(elicitationId, "accept", { allow_all_edits: true });
|
||||
};
|
||||
const submitRemember = () => {
|
||||
// Accept AND ask the server to install a session-scoped allow rule
|
||||
// so the same scope stops prompting. The server reads
|
||||
// ``content.remember`` and re-derives the rule scope (WebFetch
|
||||
// domain or tool-wide) from the gated tool itself — the client only
|
||||
// signals intent, never the rule — then echoes an ``addRules``
|
||||
// permission update back to the PermissionRequest hook.
|
||||
submit(elicitationId, "accept", { remember: true });
|
||||
};
|
||||
const submitPlanRejection = (feedback: string) => {
|
||||
// The typed feedback rides on `content.feedback`; the server
|
||||
// forwards it to Claude as the deny `message`, so Claude stays in
|
||||
@@ -244,6 +266,19 @@ export function ApprovalCard({
|
||||
Array.isArray(response?.content?.execpolicy_amendment) &&
|
||||
response.content.execpolicy_amendment.every((entry) => typeof entry === "string");
|
||||
const acceptedAllEdits = response?.content?.allow_all_edits === true;
|
||||
const acceptedRemember = response?.content?.remember === true;
|
||||
// Persistent "don't ask again" affordance: label by the WebFetch
|
||||
// domain when present, else the tool name. Drives the third binary
|
||||
// button and the responded-state pill.
|
||||
const rememberTarget = rememberScope ? (rememberScope.host ?? rememberScope.tool) : null;
|
||||
// Tooltip spelling out the scope — the tool-wide case (no host) is a
|
||||
// broad grant (every call to the tool), so make that explicit rather
|
||||
// than letting the short button label imply a narrower scope.
|
||||
const rememberTitle = rememberScope
|
||||
? rememberScope.host
|
||||
? `Won't ask again for ${rememberScope.host} for the rest of this session`
|
||||
: `Won't ask again for any ${rememberScope.tool} call for the rest of this session`
|
||||
: undefined;
|
||||
const binaryButtons = (
|
||||
<div className="flex flex-wrap gap-2 pt-1">
|
||||
<Button size="sm" onClick={() => submitBinary("accept")}>
|
||||
@@ -256,6 +291,18 @@ export function ApprovalCard({
|
||||
Accept & allow all edits
|
||||
</Button>
|
||||
)}
|
||||
{rememberTarget && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={submitRemember}
|
||||
title={rememberTitle}
|
||||
data-testid="approval-card-remember"
|
||||
>
|
||||
<CheckIcon className="mr-1 size-3.5" />
|
||||
Approve & don't ask again for {rememberTarget}
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" variant="outline" onClick={() => submitBinary("decline")}>
|
||||
<XIcon className="mr-1 size-3.5" />
|
||||
Reject
|
||||
@@ -339,6 +386,11 @@ export function ApprovalCard({
|
||||
} else if (acceptedAllEdits) {
|
||||
icon = <CheckIcon className="size-4 text-success" />;
|
||||
label = isExitPlanMode ? "Plan approved · auto mode" : "Approved · auto-accepting edits";
|
||||
} else if (acceptedRemember) {
|
||||
icon = <CheckIcon className="size-4 text-success" />;
|
||||
label = rememberTarget
|
||||
? `Approved · won't ask again for ${rememberTarget}`
|
||||
: "Approved · won't ask again";
|
||||
} else if (accepted) {
|
||||
icon = <CheckIcon className="size-4 text-success" />;
|
||||
label = isExitPlanMode ? "Plan approved" : "Approved";
|
||||
|
||||
@@ -483,6 +483,7 @@ function renderItem(item: RenderItem, index: number, isReasoningStreaming: boole
|
||||
exitPlanMode={item.exitPlanMode}
|
||||
codexCommand={item.codexCommand}
|
||||
allowAllEdits={item.allowAllEdits}
|
||||
rememberScope={item.rememberScope}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
// Tests for ThemeModeMenu — the compact sidebar button that cycles the theme
|
||||
// system → dark → light on each click.
|
||||
//
|
||||
// The button previews the *next* mode: its aria-label/title and icon describe
|
||||
// the mode the next click applies (see nextThemeMode). It hides entirely when
|
||||
// The icon shows the *current* mode, while the aria-label/title announce the
|
||||
// *next* mode the click will apply (see nextThemeMode). It hides entirely when
|
||||
// embedded (the host owns the theme). `next-themes` and `@/lib/embedded` are
|
||||
// mocked so each test pins the current theme and embed state; the real
|
||||
// themeMode helpers (pure) run unmocked.
|
||||
// mocked so each test pins the current theme, system theme, and embed state;
|
||||
// the real themeMode helpers (pure) run unmocked.
|
||||
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
@@ -13,11 +13,11 @@ import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
|
||||
const setTheme = vi.fn();
|
||||
let currentTheme: string | undefined;
|
||||
let resolvedTheme: string | undefined;
|
||||
let systemTheme: string | undefined;
|
||||
let embedded: boolean;
|
||||
|
||||
vi.mock("next-themes", () => ({
|
||||
useTheme: () => ({ theme: currentTheme, resolvedTheme, setTheme }),
|
||||
useTheme: () => ({ theme: currentTheme, systemTheme, setTheme }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/embedded", () => ({
|
||||
@@ -36,7 +36,7 @@ function renderMenu() {
|
||||
|
||||
beforeEach(() => {
|
||||
currentTheme = "system";
|
||||
resolvedTheme = undefined;
|
||||
systemTheme = undefined;
|
||||
embedded = false;
|
||||
});
|
||||
|
||||
@@ -96,19 +96,34 @@ describe("ThemeModeMenu", () => {
|
||||
expect(screen.getByRole("button", { name: "Switch to Dark" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("skips dark when system already resolves to dark", () => {
|
||||
it("skips dark when the system theme is dark", () => {
|
||||
// WHY: at "system" on a dark OS, pinning dark would render identically, so
|
||||
// the cycle jumps straight to light.
|
||||
currentTheme = "system";
|
||||
resolvedTheme = "dark";
|
||||
systemTheme = "dark";
|
||||
renderMenu();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Switch to Light" }));
|
||||
expect(setTheme).toHaveBeenCalledWith("light");
|
||||
});
|
||||
|
||||
it("skips light when system already resolves to light", () => {
|
||||
it("does not offer light first when the system theme is light", () => {
|
||||
// WHY: from "system" the cycle's first stop is dark regardless of OS, so a
|
||||
// light OS still advances to dark before anything else.
|
||||
currentTheme = "system";
|
||||
resolvedTheme = "light";
|
||||
systemTheme = "light";
|
||||
renderMenu();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Switch to Dark" }));
|
||||
expect(setTheme).toHaveBeenCalledWith("dark");
|
||||
});
|
||||
|
||||
it("skips light when an explicit dark theme sits on a light system", () => {
|
||||
// WHY: dark's next stop is light, but a light OS already renders light, so
|
||||
// skip the redundant hop and go straight to system. This is the asymmetry
|
||||
// the system-theme check fixes — `resolvedTheme` would have offered light.
|
||||
currentTheme = "dark";
|
||||
systemTheme = "light";
|
||||
renderMenu();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Switch to System" }));
|
||||
expect(setTheme).toHaveBeenCalledWith("system");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,10 +20,10 @@ const themeModeIcons: Record<ThemeMode, typeof SunIcon> = {
|
||||
/**
|
||||
* Compact sidebar control that cycles system → dark → light on click.
|
||||
*
|
||||
* A single icon button rather than a dropdown. The icon previews the
|
||||
* mode the next click will apply (see {@link nextThemeMode}): a moon
|
||||
* when clicking switches to dark, a sun for light, and a laptop for
|
||||
* system. The tooltip and aria-label announce the same action.
|
||||
* A single icon button rather than a dropdown. The icon shows the
|
||||
* current mode — a sun for light, a moon for dark, and a laptop for
|
||||
* system — while the tooltip and aria-label announce the mode the next
|
||||
* click will apply (see {@link nextThemeMode}).
|
||||
*
|
||||
* @returns Theme cycle button.
|
||||
*/
|
||||
@@ -31,10 +31,10 @@ export function ThemeModeMenu() {
|
||||
// Embedded: the host owns the theme and `embed.tsx` forces light, so a theme
|
||||
// switcher would be a no-op. Hide it.
|
||||
const isEmbedded = useIsEmbedded();
|
||||
const { theme, resolvedTheme, setTheme } = useTheme();
|
||||
const { theme, systemTheme, setTheme } = useTheme();
|
||||
const mode = normalizeThemeMode(theme);
|
||||
const next = nextThemeMode(mode, resolvedTheme);
|
||||
const NextIcon = themeModeIcons[next];
|
||||
const next = nextThemeMode(mode, systemTheme);
|
||||
const Icon = themeModeIcons[mode];
|
||||
const action = `Switch to ${themeModeLabels[next]}`;
|
||||
|
||||
if (isEmbedded) return null;
|
||||
@@ -51,7 +51,7 @@ export function ThemeModeMenu() {
|
||||
className="rounded-full"
|
||||
onClick={() => setTheme(next)}
|
||||
>
|
||||
<NextIcon className="size-4" />
|
||||
<Icon className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{action}</TooltipContent>
|
||||
|
||||
@@ -32,18 +32,20 @@ describe("theme mode helpers", () => {
|
||||
expect(normalizeResolvedTheme(undefined)).toBe("light");
|
||||
});
|
||||
|
||||
it("cycles system → dark → light → system without resolved theme", () => {
|
||||
it("cycles system → dark → light → system without a system theme", () => {
|
||||
expect(nextThemeMode("system")).toBe("dark");
|
||||
expect(nextThemeMode("dark")).toBe("light");
|
||||
expect(nextThemeMode("light")).toBe("system");
|
||||
});
|
||||
|
||||
it("skips redundant transition when resolved theme matches next mode", () => {
|
||||
it("skips redundant transition when the system theme matches the next mode", () => {
|
||||
expect(nextThemeMode("system", "dark")).toBe("light");
|
||||
expect(nextThemeMode("system", "light")).toBe("dark");
|
||||
// Explicit dark on a light system would render light identically, so the
|
||||
// light hop is skipped straight to system.
|
||||
expect(nextThemeMode("dark", "light")).toBe("system");
|
||||
});
|
||||
|
||||
it("does not skip when resolved theme differs from next mode", () => {
|
||||
it("does not skip when the system theme differs from the next mode", () => {
|
||||
expect(nextThemeMode("system", "light")).toBe("dark");
|
||||
expect(nextThemeMode("dark", "dark")).toBe("light");
|
||||
expect(nextThemeMode("light", "light")).toBe("system");
|
||||
|
||||
@@ -59,17 +59,17 @@ export function normalizeResolvedTheme(value: string | undefined): ResolvedTheme
|
||||
* light instead of offering "Switch to Dark".
|
||||
*
|
||||
* @param mode Current selectable theme mode, e.g. `"dark"`.
|
||||
* @param resolvedTheme The actual rendered palette, e.g. `"dark"`.
|
||||
* @param systemTheme The system theme, e.g. `"dark"`.
|
||||
* @returns The mode to apply on the next click, e.g. `"light"`.
|
||||
*/
|
||||
export function nextThemeMode(mode: ThemeMode, resolvedTheme?: string): ThemeMode {
|
||||
export function nextThemeMode(mode: ThemeMode, systemTheme?: string): ThemeMode {
|
||||
const cycle: Record<ThemeMode, ThemeMode> = {
|
||||
system: "dark",
|
||||
dark: "light",
|
||||
light: "system",
|
||||
};
|
||||
const next = cycle[mode];
|
||||
if (resolvedTheme && next !== "system" && next === resolvedTheme) {
|
||||
if (systemTheme && next !== "system" && next === systemTheme) {
|
||||
return cycle[next];
|
||||
}
|
||||
return next;
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { isIOSShell } from "@/lib/nativeBridge";
|
||||
|
||||
const KEYBOARD_INSET_THRESHOLD_PX = 80;
|
||||
|
||||
export function useIOSNativeKeyboardInset(enabled = true): number {
|
||||
const [inset, setInset] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !isIOSShell()) {
|
||||
setInset(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const sync = () => {
|
||||
const viewport = window.visualViewport;
|
||||
if (!viewport) {
|
||||
setInset(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const nextInset = getIOSNativeKeyboardInset();
|
||||
setInset(nextInset > KEYBOARD_INSET_THRESHOLD_PX ? nextInset : 0);
|
||||
};
|
||||
|
||||
sync();
|
||||
window.visualViewport?.addEventListener("resize", sync);
|
||||
window.visualViewport?.addEventListener("scroll", sync);
|
||||
window.addEventListener("resize", sync);
|
||||
window.addEventListener("orientationchange", sync);
|
||||
window.addEventListener("focusin", sync, true);
|
||||
window.addEventListener("focusout", sync, true);
|
||||
|
||||
return () => {
|
||||
window.visualViewport?.removeEventListener("resize", sync);
|
||||
window.visualViewport?.removeEventListener("scroll", sync);
|
||||
window.removeEventListener("resize", sync);
|
||||
window.removeEventListener("orientationchange", sync);
|
||||
window.removeEventListener("focusin", sync, true);
|
||||
window.removeEventListener("focusout", sync, true);
|
||||
};
|
||||
}, [enabled]);
|
||||
|
||||
return inset;
|
||||
}
|
||||
|
||||
export function useIOSNativeKeyboardVisible(enabled = true, includeEditableFocus = true): boolean {
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !isIOSShell()) {
|
||||
setVisible(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const sync = () => {
|
||||
setVisible(
|
||||
getIOSNativeKeyboardInset() > KEYBOARD_INSET_THRESHOLD_PX ||
|
||||
(includeEditableFocus && isEditableElementFocused()),
|
||||
);
|
||||
};
|
||||
|
||||
sync();
|
||||
window.visualViewport?.addEventListener("resize", sync);
|
||||
window.visualViewport?.addEventListener("scroll", sync);
|
||||
window.addEventListener("resize", sync);
|
||||
window.addEventListener("orientationchange", sync);
|
||||
window.addEventListener("focusin", sync, true);
|
||||
window.addEventListener("focusout", sync, true);
|
||||
|
||||
return () => {
|
||||
window.visualViewport?.removeEventListener("resize", sync);
|
||||
window.visualViewport?.removeEventListener("scroll", sync);
|
||||
window.removeEventListener("resize", sync);
|
||||
window.removeEventListener("orientationchange", sync);
|
||||
window.removeEventListener("focusin", sync, true);
|
||||
window.removeEventListener("focusout", sync, true);
|
||||
};
|
||||
}, [enabled, includeEditableFocus]);
|
||||
|
||||
return visible;
|
||||
}
|
||||
|
||||
function getIOSNativeKeyboardInset(): number {
|
||||
const viewport = window.visualViewport;
|
||||
if (!viewport) return 0;
|
||||
|
||||
const shellBottom =
|
||||
document.querySelector<HTMLElement>("[data-ios-native].app-shell")?.getBoundingClientRect()
|
||||
.bottom ?? window.innerHeight;
|
||||
const visibleBottom = viewport.offsetTop + viewport.height;
|
||||
return Math.max(0, Math.round(shellBottom - visibleBottom));
|
||||
}
|
||||
|
||||
function isEditableElementFocused(): boolean {
|
||||
const active = document.activeElement;
|
||||
if (!(active instanceof HTMLElement)) return false;
|
||||
return active.matches('input, textarea, select, [contenteditable="true"]');
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { isIOSShell, setNativeServerSwitcherHidden } from "@/lib/nativeBridge";
|
||||
|
||||
/**
|
||||
* Tracks whether `surface` is the frontmost element at its own centre — i.e.
|
||||
* not covered by a drawer / sidebar / sheet. Returns false when inactive,
|
||||
* outside the iOS shell, or while obscured. Re-checks on the layout signals a
|
||||
* drawer transition emits (mutations, transitions, viewport changes). Both the
|
||||
* native server switcher and the native Chat/Terminal bar hide off this signal
|
||||
* so neither floats over an opened panel.
|
||||
*/
|
||||
export function useSurfaceFrontmost(surface: HTMLElement | null, active: boolean): boolean {
|
||||
const [frontmost, setFrontmost] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!isIOSShell() || !active) {
|
||||
setFrontmost(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let frame = 0;
|
||||
const sync = () => {
|
||||
frame = 0;
|
||||
setFrontmost(isSurfaceFrontmost(surface));
|
||||
};
|
||||
const schedule = () => {
|
||||
if (frame !== 0) cancelAnimationFrame(frame);
|
||||
frame = requestAnimationFrame(sync);
|
||||
};
|
||||
|
||||
schedule();
|
||||
|
||||
const observer =
|
||||
typeof MutationObserver !== "undefined" ? new MutationObserver(schedule) : null;
|
||||
observer?.observe(document.body, {
|
||||
subtree: true,
|
||||
childList: true,
|
||||
attributes: true,
|
||||
attributeFilter: ["class", "style", "aria-hidden", "data-state", "data-collapsed", "open"],
|
||||
});
|
||||
|
||||
window.addEventListener("resize", schedule);
|
||||
window.addEventListener("orientationchange", schedule);
|
||||
window.addEventListener("scroll", schedule, true);
|
||||
window.addEventListener("transitionend", schedule, true);
|
||||
window.addEventListener("animationend", schedule, true);
|
||||
window.addEventListener("focusin", schedule, true);
|
||||
window.addEventListener("focusout", schedule, true);
|
||||
window.visualViewport?.addEventListener("resize", schedule);
|
||||
window.visualViewport?.addEventListener("scroll", schedule);
|
||||
|
||||
return () => {
|
||||
if (frame !== 0) cancelAnimationFrame(frame);
|
||||
observer?.disconnect();
|
||||
window.removeEventListener("resize", schedule);
|
||||
window.removeEventListener("orientationchange", schedule);
|
||||
window.removeEventListener("scroll", schedule, true);
|
||||
window.removeEventListener("transitionend", schedule, true);
|
||||
window.removeEventListener("animationend", schedule, true);
|
||||
window.removeEventListener("focusin", schedule, true);
|
||||
window.removeEventListener("focusout", schedule, true);
|
||||
window.visualViewport?.removeEventListener("resize", schedule);
|
||||
window.visualViewport?.removeEventListener("scroll", schedule);
|
||||
setFrontmost(false);
|
||||
};
|
||||
}, [active, surface]);
|
||||
return frontmost;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drive the iOS shell's native server switcher overlay so it shows only while
|
||||
* `surface` is the frontmost element on screen and `active` is true. The
|
||||
* switcher is a native chrome element the web app toggles via the bridge; it
|
||||
* must hide whenever the sidebar (or any other overlay) covers the main
|
||||
* surface, and whenever the surface is unmounted.
|
||||
*
|
||||
* No-ops outside the iOS shell. Used by both the in-session main surface
|
||||
* (ChatPage) and the new-session landing screen (NewChatDialog).
|
||||
*/
|
||||
export function useNativeServerSwitcherForMainSurface(
|
||||
surface: HTMLElement | null,
|
||||
active: boolean,
|
||||
) {
|
||||
const frontmost = useSurfaceFrontmost(surface, active);
|
||||
useEffect(() => {
|
||||
if (!isIOSShell()) return;
|
||||
setNativeServerSwitcherHidden(!frontmost);
|
||||
}, [frontmost]);
|
||||
useEffect(() => {
|
||||
if (!isIOSShell()) return;
|
||||
return () => setNativeServerSwitcherHidden(true);
|
||||
}, []);
|
||||
}
|
||||
|
||||
function isSurfaceFrontmost(surface: HTMLElement | null): boolean {
|
||||
if (!surface) return false;
|
||||
const rect = surface.getBoundingClientRect();
|
||||
if (rect.width <= 0 || rect.height <= 0) return false;
|
||||
|
||||
const xInset = Math.min(24, Math.max(1, rect.width / 4));
|
||||
const yInset = Math.min(24, Math.max(1, rect.height / 4));
|
||||
const x = clamp(window.innerWidth / 2, rect.left + xInset, rect.right - xInset);
|
||||
const y = clamp(rect.top + rect.height * 0.38, rect.top + yInset, rect.bottom - yInset);
|
||||
const topElement = document.elementFromPoint(x, y);
|
||||
|
||||
// A Radix dropdown / select / popover sets `pointer-events: none` on the body
|
||||
// while open WITHOUT covering the surface, so elementFromPoint falls through
|
||||
// to the document root (or null). That's a transient layer, not a panel —
|
||||
// keep the surface "frontmost" so the native overlays don't blink out.
|
||||
if (!topElement || topElement === document.documentElement || topElement === document.body) {
|
||||
return true;
|
||||
}
|
||||
// Likewise if a popover/menu/listbox actually covers the probe point: those
|
||||
// are transient, unlike a persistent drawer/sidebar/sheet.
|
||||
if (
|
||||
topElement.closest(
|
||||
'[data-radix-popper-content-wrapper], [role="menu"], [role="listbox"], [role="tooltip"]',
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return surface.contains(topElement);
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
if (max < min) return min;
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
// Cmd/Ctrl+digit jumps to the Nth pinned session: 1–9 → indices 0–8, 0 → 10th.
|
||||
// Requires Cmd/Ctrl, no Alt/Shift; fires inside text fields; out-of-range and
|
||||
// already-active are no-ops; only out-of-range leaves the native event alone.
|
||||
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { PINNED_HOTKEY_DIGITS, usePinnedSessionHotkeys } from "./usePinnedSessionHotkeys";
|
||||
|
||||
const navigate = vi.fn();
|
||||
vi.mock("@/lib/routing", () => ({
|
||||
useNavigate: () => navigate,
|
||||
}));
|
||||
|
||||
// The shortcut is desktop-only (Cmd+digit collides with browser tab-switching),
|
||||
// so the hook is gated on the Electron shell. Default the mock to "native" and
|
||||
// flip it per-test for the browser case.
|
||||
const isNativeShell = vi.fn(() => true);
|
||||
vi.mock("@/lib/nativeBridge", () => ({
|
||||
isNativeShell: () => isNativeShell(),
|
||||
}));
|
||||
|
||||
/** Dispatch a digit keydown bubbling to window; returns the event so callers
|
||||
* can assert on preventDefault. */
|
||||
function press(
|
||||
key: string,
|
||||
mods: Partial<Pick<KeyboardEvent, "metaKey" | "ctrlKey" | "altKey" | "shiftKey">> = {
|
||||
metaKey: true,
|
||||
},
|
||||
target: HTMLElement = document.body,
|
||||
): KeyboardEvent {
|
||||
const e = new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true, ...mods });
|
||||
target.dispatchEvent(e);
|
||||
return e;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
navigate.mockClear();
|
||||
isNativeShell.mockReturnValue(true);
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
describe("usePinnedSessionHotkeys", () => {
|
||||
const ids = ["a", "b", "c"];
|
||||
|
||||
it("exposes ten digits mapping 1–9 then 0", () => {
|
||||
expect(PINNED_HOTKEY_DIGITS).toEqual(["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"]);
|
||||
});
|
||||
|
||||
it("Cmd+1 opens the first pinned session", () => {
|
||||
renderHook(() => usePinnedSessionHotkeys(ids, undefined));
|
||||
press("1");
|
||||
expect(navigate).toHaveBeenCalledWith("/c/a");
|
||||
});
|
||||
|
||||
it("Cmd+3 opens the third pinned session", () => {
|
||||
renderHook(() => usePinnedSessionHotkeys(ids, undefined));
|
||||
press("3");
|
||||
expect(navigate).toHaveBeenCalledWith("/c/c");
|
||||
});
|
||||
|
||||
it("Cmd+0 opens the tenth pinned session", () => {
|
||||
const ten = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"];
|
||||
renderHook(() => usePinnedSessionHotkeys(ten, undefined));
|
||||
press("0");
|
||||
expect(navigate).toHaveBeenCalledWith("/c/j");
|
||||
});
|
||||
|
||||
it("Cmd+9 opens the ninth pinned session", () => {
|
||||
const ten = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"];
|
||||
renderHook(() => usePinnedSessionHotkeys(ten, undefined));
|
||||
press("9");
|
||||
expect(navigate).toHaveBeenCalledWith("/c/i");
|
||||
});
|
||||
|
||||
it("Ctrl+1 also works (Windows/Linux)", () => {
|
||||
renderHook(() => usePinnedSessionHotkeys(ids, undefined));
|
||||
press("1", { ctrlKey: true });
|
||||
expect(navigate).toHaveBeenCalledWith("/c/a");
|
||||
});
|
||||
|
||||
it("ignores a bare digit with no Cmd/Ctrl", () => {
|
||||
renderHook(() => usePinnedSessionHotkeys(ids, undefined));
|
||||
press("1", {});
|
||||
expect(navigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores Alt+digit (reserved for message navigation discipline)", () => {
|
||||
renderHook(() => usePinnedSessionHotkeys(ids, undefined));
|
||||
press("1", { metaKey: true, altKey: true });
|
||||
expect(navigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores Shift+digit", () => {
|
||||
renderHook(() => usePinnedSessionHotkeys(ids, undefined));
|
||||
press("1", { metaKey: true, shiftKey: true });
|
||||
expect(navigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fires while a text field is focused", () => {
|
||||
renderHook(() => usePinnedSessionHotkeys(ids, undefined));
|
||||
const ta = document.createElement("textarea");
|
||||
document.body.appendChild(ta);
|
||||
press("2", { metaKey: true }, ta);
|
||||
expect(navigate).toHaveBeenCalledWith("/c/b");
|
||||
});
|
||||
|
||||
it("does nothing when no pinned session exists at that index", () => {
|
||||
renderHook(() => usePinnedSessionHotkeys(ids, undefined));
|
||||
const e = press("5"); // only 3 pinned
|
||||
expect(navigate).not.toHaveBeenCalled();
|
||||
expect(e.defaultPrevented).toBe(false); // leaves the native event alone
|
||||
});
|
||||
|
||||
it("does not navigate when the digit points at the already-active session", () => {
|
||||
renderHook(() => usePinnedSessionHotkeys(ids, "a"));
|
||||
const e = press("1");
|
||||
expect(navigate).not.toHaveBeenCalled();
|
||||
expect(e.defaultPrevented).toBe(true); // but still suppresses native tab-switch
|
||||
});
|
||||
|
||||
it("prevents the browser's native tab-switch when it navigates", () => {
|
||||
renderHook(() => usePinnedSessionHotkeys(ids, undefined));
|
||||
const e = press("1");
|
||||
expect(e.defaultPrevented).toBe(true);
|
||||
});
|
||||
|
||||
it("only maps the first ten: an 11th pinned session has no shortcut", () => {
|
||||
const eleven = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k"];
|
||||
renderHook(() => usePinnedSessionHotkeys(eleven, undefined));
|
||||
// No digit maps to index 10, so "k" is unreachable; 0 still lands on the 10th.
|
||||
press("0");
|
||||
expect(navigate).toHaveBeenCalledWith("/c/j");
|
||||
});
|
||||
|
||||
it("does nothing when the list is empty", () => {
|
||||
renderHook(() => usePinnedSessionHotkeys([], undefined));
|
||||
press("1");
|
||||
expect(navigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("is inert in a plain browser (not the Electron shell)", () => {
|
||||
isNativeShell.mockReturnValue(false);
|
||||
renderHook(() => usePinnedSessionHotkeys(ids, undefined));
|
||||
const e = press("1");
|
||||
expect(navigate).not.toHaveBeenCalled();
|
||||
// Leave the browser's own Cmd+1 tab-switch alone.
|
||||
expect(e.defaultPrevented).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
// Cmd+1..9/0 (Ctrl on Win/Linux) jumps to the Nth pinned sidebar session:
|
||||
// 1–9 → the first nine, 0 → the tenth (browser-tab-style mapping). Sibling to
|
||||
// useSessionSwitchHotkey — same once-bound, ref-backed, metaKey||ctrlKey shape.
|
||||
// Fires even in a focused text field so you can jump mid-compose. Bind ONCE.
|
||||
//
|
||||
// Desktop-only: a browser tab reserves Cmd/Ctrl+digit for tab-switching, so the
|
||||
// hook is inert outside the Electron shell (see isNativeShell). The matching
|
||||
// per-row chips and the shortcuts-dialog row are gated the same way.
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useNavigate } from "@/lib/routing";
|
||||
import { isNativeShell } from "@/lib/nativeBridge";
|
||||
|
||||
/** Index → the digit key that selects it. Single source of truth shared with
|
||||
* the sidebar's per-row shortcut chips so the binding and label can't drift. */
|
||||
export const PINNED_HOTKEY_DIGITS = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"] as const;
|
||||
|
||||
/**
|
||||
* @param orderedPinnedIds Pinned conversation ids in sidebar render order
|
||||
* (empty when the Pinned section is collapsed or there are no pins).
|
||||
* @param activeId The open conversation (route param), or undefined off-list.
|
||||
*/
|
||||
export function usePinnedSessionHotkeys(
|
||||
orderedPinnedIds: readonly string[],
|
||||
activeId: string | undefined,
|
||||
): void {
|
||||
const navigate = useNavigate();
|
||||
// Bound once; the ref keeps the handler reading the live list/route.
|
||||
const latest = useRef({ orderedPinnedIds, activeId });
|
||||
latest.current = { orderedPinnedIds, activeId };
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: globalThis.KeyboardEvent): void => {
|
||||
// Desktop-only: in a browser tab Cmd/Ctrl+digit is the native
|
||||
// tab-switch, which we must not hijack. Only the Electron shell owns it.
|
||||
if (!isNativeShell()) return;
|
||||
// Cmd/Ctrl, not Alt (Alt+chord is the message hotkey); Shift left alone.
|
||||
if (!(e.metaKey || e.ctrlKey) || e.altKey || e.shiftKey) return;
|
||||
|
||||
const index = PINNED_HOTKEY_DIGITS.indexOf(e.key as (typeof PINNED_HOTKEY_DIGITS)[number]);
|
||||
if (index === -1) return;
|
||||
|
||||
const { orderedPinnedIds: ids, activeId: active } = latest.current;
|
||||
const targetId = ids[index];
|
||||
// No pinned session at that slot: leave the native event untouched.
|
||||
if (!targetId) return;
|
||||
|
||||
e.preventDefault(); // suppress the browser's native ⌘-digit tab-switch
|
||||
if (targetId !== active) navigate(`/c/${targetId}`);
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [navigate]);
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { livenessRowFromSession, useSessionLiveness } from "./useSessionLiveness";
|
||||
import { type LivenessRow, livenessRowFromSession, useSessionLiveness } from "./useSessionLiveness";
|
||||
import { useSessionHostOnline, useSessionRunnerOnline } from "@/hooks/RunnerHealthProvider";
|
||||
import type { Conversation } from "@/hooks/useConversations";
|
||||
import type { Session } from "@/lib/types";
|
||||
|
||||
// Drive the two split signals directly so the test pins the derivation
|
||||
@@ -29,16 +28,20 @@ function freshCreatedAt(): number {
|
||||
}
|
||||
|
||||
/** Build a minimal conv row carrying just the fields the hook reads. */
|
||||
function conv(
|
||||
partial: Partial<Pick<Conversation, "host_id" | "permission_level" | "created_at">>,
|
||||
): Pick<Conversation, "host_id" | "permission_level" | "created_at"> {
|
||||
return { host_id: null, permission_level: null, created_at: SOME_CREATED_AT, ...partial };
|
||||
function conv(partial: Partial<LivenessRow>): LivenessRow {
|
||||
return {
|
||||
host_id: null,
|
||||
permission_level: null,
|
||||
created_at: SOME_CREATED_AT,
|
||||
host_resumable: false,
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
function derive(
|
||||
runner: boolean | undefined,
|
||||
host: boolean | null | undefined,
|
||||
c: Pick<Conversation, "host_id" | "permission_level" | "created_at"> | null,
|
||||
c: LivenessRow | null,
|
||||
opts?: { turnActive?: boolean },
|
||||
) {
|
||||
runnerMock.mockReturnValue(runner);
|
||||
@@ -117,6 +120,37 @@ describe("useSessionLiveness — derivation truth table", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("host_asleep when a resumable managed host is down — composer stays open", () => {
|
||||
// A dormant resumable managed host the server wakes on the next message:
|
||||
// NOT the host_offline dead-end. host_resumable flips row 3.
|
||||
expect(derive(false, false, conv({ host_id: "h1", host_resumable: true }))).toEqual({
|
||||
kind: "host_asleep",
|
||||
});
|
||||
// The wake is server-side, not owner-gated: resumable wins the offline
|
||||
// split even when shared (non-owner).
|
||||
expect(
|
||||
derive(false, false, conv({ host_id: "h1", permission_level: 1, host_resumable: true })),
|
||||
).toEqual({ kind: "host_asleep" });
|
||||
});
|
||||
|
||||
it("starting (NOT host_asleep) while a just-sent turn is waking the resumable host", () => {
|
||||
// turnActive means the send is resuming the sandbox now — show the
|
||||
// "Connecting…" intermediate through the cold wake, not a blank
|
||||
// host_asleep screen.
|
||||
expect(
|
||||
derive(false, false, conv({ host_id: "h1", host_resumable: true }), { turnActive: true }),
|
||||
).toEqual({ kind: "starting" });
|
||||
});
|
||||
|
||||
it("host_offline (not host_asleep) when the down host is NOT resumable", () => {
|
||||
// The default for an external/non-resumable host: the actionable
|
||||
// reconnect/fork dead-end, unchanged.
|
||||
expect(derive(false, false, conv({ host_id: "h1", host_resumable: false }))).toEqual({
|
||||
kind: "host_offline",
|
||||
isOwner: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("unknown for a host-bound session whose host liveness is not yet observed", () => {
|
||||
// host_id set but host_online undefined: don't guess host-down.
|
||||
expect(derive(false, undefined, conv({ host_id: "h1" }))).toEqual({ kind: "unknown" });
|
||||
@@ -236,10 +270,15 @@ describe("useSessionLiveness — derivation truth table", () => {
|
||||
} as Session;
|
||||
}
|
||||
|
||||
it("maps hostId / permissionLevel / createdAt into the snake_case row", () => {
|
||||
it("maps hostId / permissionLevel / createdAt / hostResumable into the snake_case row", () => {
|
||||
expect(
|
||||
livenessRowFromSession(session({ hostId: "h1", permissionLevel: 1, createdAt: 123 })),
|
||||
).toEqual({ host_id: "h1", permission_level: 1, created_at: 123 });
|
||||
).toEqual({ host_id: "h1", permission_level: 1, created_at: 123, host_resumable: false });
|
||||
// hostResumable flows through so an off-sidebar resumable host can
|
||||
// classify host_asleep rather than dead-ending on host_offline.
|
||||
expect(livenessRowFromSession(session({ hostId: "h1", hostResumable: true }))).toMatchObject({
|
||||
host_resumable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null for a null/undefined snapshot", () => {
|
||||
|
||||
@@ -31,7 +31,16 @@ import { useSessionHostOnline, useSessionRunnerOnline } from "@/hooks/RunnerHeal
|
||||
export const STARTING_GRACE_S = 45;
|
||||
|
||||
/** The subset of a conversation row this hook reads. */
|
||||
export type LivenessRow = Pick<Conversation, "host_id" | "permission_level" | "created_at">;
|
||||
export type LivenessRow = Pick<Conversation, "host_id" | "permission_level" | "created_at"> & {
|
||||
/**
|
||||
* Whether this session's host is a resumable managed host the server wakes
|
||||
* on the next message. NOT a `Conversation` field — the sidebar row doesn't
|
||||
* carry it; it rides the session snapshot, and the open view splices it in
|
||||
* via {@link livenessRowFromSession}. Drives the `host_asleep` vs
|
||||
* `host_offline` split (row 3). Absent ⇒ treated `false`.
|
||||
*/
|
||||
host_resumable?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Build a {@link LivenessRow} from the single-session snapshot
|
||||
@@ -44,13 +53,17 @@ export type LivenessRow = Pick<Conversation, "host_id" | "permission_level" | "c
|
||||
* snapshot carries the same three fields, so it's an exact stand-in.
|
||||
*/
|
||||
export function livenessRowFromSession(
|
||||
session: Pick<Session, "hostId" | "permissionLevel" | "createdAt"> | null | undefined,
|
||||
session:
|
||||
| Pick<Session, "hostId" | "permissionLevel" | "createdAt" | "hostResumable">
|
||||
| null
|
||||
| undefined,
|
||||
): LivenessRow | null {
|
||||
if (!session) return null;
|
||||
return {
|
||||
host_id: session.hostId,
|
||||
permission_level: session.permissionLevel,
|
||||
created_at: session.createdAt,
|
||||
host_resumable: session.hostResumable ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -75,10 +88,20 @@ export function livenessRowFromSession(
|
||||
* host relaunches the runner on the next message, so the composer stays
|
||||
* open. The open view renders no banner for this state — typing
|
||||
* silently relaunches the runner (which then flips it to `starting`).
|
||||
* - `host_offline` — the session is host-bound and the host tunnel is
|
||||
* down. Nothing the web UI sends can wake it; the owner must reconnect
|
||||
* the host from that machine (`isOwner` true), and any viewer can fork
|
||||
* to continue independently.
|
||||
* - `host_asleep` — the session is host-bound, the host tunnel is down, but
|
||||
* the host is a resumable managed host: the server wakes the sandbox on the
|
||||
* next message (the send-message relaunch path calls `resume_managed_host`).
|
||||
* Treated like `runner_asleep` — the composer stays open, no reconnect
|
||||
* banner, and typing wakes it. This is what makes the backend resume
|
||||
* reachable from the web; without it a resumable host would dead-end on
|
||||
* `host_offline` below. While a just-sent turn is waking it (`turnActive`),
|
||||
* this upgrades to `starting` so the ~85s cold wake shows a "Connecting…"
|
||||
* intermediate rather than a blank screen.
|
||||
* - `host_offline` — the session is host-bound and the host tunnel is down,
|
||||
* and the host is NOT resumable from the web (an external/laptop host, or a
|
||||
* managed provider without a stop/resume lifecycle). The owner must
|
||||
* reconnect the host from that machine (`isOwner` true), and any viewer can
|
||||
* fork to continue independently.
|
||||
* - `local_stranded` — not host-bound (no `host_id`) and the runner is
|
||||
* down. There's no host to relaunch it; the user restarts from their
|
||||
* own machine, and forking is the escape hatch.
|
||||
@@ -91,6 +114,7 @@ export type SessionLiveness =
|
||||
| { kind: "online" }
|
||||
| { kind: "starting" }
|
||||
| { kind: "runner_asleep" }
|
||||
| { kind: "host_asleep" }
|
||||
| { kind: "host_offline"; isOwner: boolean }
|
||||
| { kind: "local_stranded" }
|
||||
| { kind: "unknown" };
|
||||
@@ -117,7 +141,9 @@ function isOwner(conv: Pick<Conversation, "permission_level"> | null | undefined
|
||||
* |---|---------------|-------------|---------|------------|----------------------|
|
||||
* | 1 | true | (any) | (any) | (any) | online |
|
||||
* | 2 | not-true | (any) | (any) | (any) | starting (fresh*) |
|
||||
* | 3 | not-true | false | set | (any) | host_offline {owner} |
|
||||
* | 3 | not-true | false | set+resumable | true | starting (waking) |
|
||||
* | 3'| not-true | false | set+resumable | false | host_asleep |
|
||||
* | 3"| not-true | false | set, non-resum| (any) | host_offline {owner} |
|
||||
* | 4 | undefined | (any) | (any) | (any) | unknown (pre-poll) |
|
||||
* | 5 | false | true | (any) | true | starting (relaunch) |
|
||||
* | 5'| false | true | (any) | false | runner_asleep |
|
||||
@@ -133,7 +159,10 @@ function isOwner(conv: Pick<Conversation, "permission_level"> | null | undefined
|
||||
* runner tunnel is the only signal that means "chat now." A just-created
|
||||
* session whose runner hasn't registered yet (row 2) is cold-booting, not
|
||||
* stranded: surface `starting` rather than a reconnect banner until the
|
||||
* grace window lapses. A confirmed host-down (row 3) is actionable.
|
||||
* grace window lapses. A confirmed host-down splits on resumability: a
|
||||
* resumable managed host is `host_asleep` (row 3 — wakeable by sending a
|
||||
* message, composer open), a non-resumable one is `host_offline` (row 3' —
|
||||
* reconnect / fork).
|
||||
* Pre-poll `undefined` (row 4) stays `unknown` so a not-yet-resolved poll
|
||||
* doesn't flash a banner over a live session. A known-down runner with a
|
||||
* live host then splits on whether a turn is in flight: a just-sent turn
|
||||
@@ -144,9 +173,10 @@ function isOwner(conv: Pick<Conversation, "permission_level"> | null | undefined
|
||||
*
|
||||
* @param sessionId The open conversation's id, or undefined when none is
|
||||
* open. Undefined yields `unknown`.
|
||||
* @param conv The open conversation row (carries `host_id` +
|
||||
* `permission_level`). Null/undefined while loading; the host-bound vs.
|
||||
* local distinction and ownership read from it.
|
||||
* @param conv The open session's liveness row (carries `host_id`,
|
||||
* `permission_level`, and `host_resumable`). Null/undefined while loading;
|
||||
* the host-bound vs. local distinction, ownership, and the
|
||||
* host_asleep-vs-host_offline split read from it.
|
||||
* @param opts.turnActive Whether a turn is currently in flight for the
|
||||
* open session (the user just sent, or a cross-client turn is running).
|
||||
* When the runner is down but the host is up, this upgrades the idle
|
||||
@@ -156,7 +186,7 @@ function isOwner(conv: Pick<Conversation, "permission_level"> | null | undefined
|
||||
*/
|
||||
export function useSessionLiveness(
|
||||
sessionId: string | undefined,
|
||||
conv: Pick<Conversation, "host_id" | "permission_level" | "created_at"> | null | undefined,
|
||||
conv: LivenessRow | null | undefined,
|
||||
opts?: { turnActive?: boolean },
|
||||
): SessionLiveness {
|
||||
const runnerOnline = useSessionRunnerOnline(sessionId);
|
||||
@@ -206,9 +236,18 @@ export function useSessionLiveness(
|
||||
return { kind: "starting" };
|
||||
}
|
||||
|
||||
// 3. A host-bound session whose host is confirmed offline is genuinely
|
||||
// stuck and actionable — surface the reconnect/fork affordance.
|
||||
// 3. A host-bound session whose host is confirmed offline. If the host is
|
||||
// a resumable managed host, the server wakes the sandbox on the next
|
||||
// message (the send-message relaunch path calls resume_managed_host). A
|
||||
// just-sent turn (turnActive) is waking it *now* — surface the same
|
||||
// `starting` "Connecting…" intermediate as a fresh launch so the ~85s cold
|
||||
// wake isn't a blank screen; idle (no turn) stays `host_asleep` (composer
|
||||
// open, no banner). Either way it's NOT the host_offline dead-end.
|
||||
// Otherwise (non-resumable) it's genuinely stuck: `host_offline`.
|
||||
if (hostId && hostOnline === false) {
|
||||
if (conv?.host_resumable) {
|
||||
return opts?.turnActive ? { kind: "starting" } : { kind: "host_asleep" };
|
||||
}
|
||||
return { kind: "host_offline", isOwner: isOwner(conv) };
|
||||
}
|
||||
|
||||
|
||||
@@ -401,6 +401,164 @@
|
||||
[data-electron-mac] :is(a, button, input, textarea, [role="button"]) {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
/* iOS native shell (SwiftUI/WKWebView) runs the webview full-screen under the
|
||||
* system status bar and home indicator. Keep the web app visually full-bleed,
|
||||
* but move interactive mobile chrome out of unsafe areas. Scoped to the native
|
||||
* bridge marker so normal iOS Safari keeps its existing browser-safe layout. */
|
||||
@media (width < 48rem) {
|
||||
[data-ios-native].app-shell {
|
||||
height: 100vh;
|
||||
height: 100lvh;
|
||||
min-height: 100vh;
|
||||
min-height: 100lvh;
|
||||
max-height: 100vh;
|
||||
max-height: 100lvh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
[data-ios-native] .conversations-sidebar {
|
||||
transition: transform 360ms cubic-bezier(0.32, 0.72, 0, 1);
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
[data-ios-native] :is(input, textarea, select, [contenteditable="true"]) {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
[data-ios-native] .chat-header {
|
||||
top: max(0px, calc(env(safe-area-inset-top, 0px) - 0.5rem));
|
||||
}
|
||||
|
||||
[data-ios-native] .chat-conversation-content {
|
||||
padding-top: calc(5rem + env(safe-area-inset-top, 0px));
|
||||
}
|
||||
|
||||
[data-ios-native] .main-terminal-view {
|
||||
padding-top: calc(3.25rem + env(safe-area-inset-top, 0px));
|
||||
}
|
||||
|
||||
[data-ios-native] .chat-scroll-fade {
|
||||
mask-image: linear-gradient(
|
||||
to bottom,
|
||||
transparent calc(48px + env(safe-area-inset-top, 0px)),
|
||||
black calc(80px + env(safe-area-inset-top, 0px))
|
||||
);
|
||||
-webkit-mask-image: linear-gradient(
|
||||
to bottom,
|
||||
transparent calc(48px + env(safe-area-inset-top, 0px)),
|
||||
black calc(80px + env(safe-area-inset-top, 0px))
|
||||
);
|
||||
}
|
||||
|
||||
[data-ios-native] .chat-composer-form {
|
||||
padding-bottom: calc(0.75rem + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
[data-ios-native] .chat-composer-form.terminal-first-composer-form {
|
||||
padding-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
[data-ios-native] .terminal-first-switcher-container {
|
||||
padding-bottom: calc(0.35rem + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
/* Reserve room for the native Liquid Glass switcher that floats over the web
|
||||
view (the in-page pill is suppressed in the iOS shell). Fixed height: the
|
||||
bar's footprint above the home-indicator inset (env). Chat sits 1rem
|
||||
tighter — its composer status line already cushions the gap to the bar. */
|
||||
[data-ios-native] .omnigent-native-bottom-spacer {
|
||||
height: calc(3rem + env(safe-area-inset-bottom, 0px));
|
||||
flex: none;
|
||||
}
|
||||
|
||||
[data-ios-native] .omnigent-native-bottom-spacer--chat {
|
||||
height: calc(2rem + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
[data-ios-native] .terminal-first-switcher {
|
||||
min-height: 46px;
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem;
|
||||
border-color: color-mix(in srgb, var(--border) 72%, transparent);
|
||||
background: color-mix(in srgb, var(--card) 88%, transparent);
|
||||
-webkit-backdrop-filter: saturate(180%) blur(18px);
|
||||
backdrop-filter: saturate(180%) blur(18px);
|
||||
box-shadow:
|
||||
0 12px 28px rgb(0 0 0 / 0.13),
|
||||
0 1px 0 rgb(255 255 255 / 0.55) inset;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
[data-ios-native] .terminal-first-switcher > div {
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
[data-ios-native] .terminal-first-switcher-option {
|
||||
min-height: 38px;
|
||||
gap: 0.45rem;
|
||||
padding: 0 0.85rem;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0;
|
||||
transition:
|
||||
background-color 160ms ease,
|
||||
color 160ms ease,
|
||||
box-shadow 160ms ease,
|
||||
transform 160ms ease;
|
||||
}
|
||||
|
||||
[data-ios-native] .terminal-first-switcher-option:active:not(:disabled) {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
[data-ios-native] .terminal-first-switcher-option[aria-pressed="true"] {
|
||||
background: color-mix(in srgb, var(--background) 82%, white 18%);
|
||||
box-shadow:
|
||||
0 3px 10px rgb(0 0 0 / 0.1),
|
||||
0 1px 0 rgb(255 255 255 / 0.72) inset;
|
||||
}
|
||||
|
||||
[data-ios-native] .terminal-first-switcher-option svg {
|
||||
width: 1.15rem;
|
||||
height: 1.15rem;
|
||||
}
|
||||
|
||||
.dark [data-ios-native] .terminal-first-switcher {
|
||||
border-color: color-mix(in srgb, var(--border) 82%, transparent);
|
||||
background: color-mix(in srgb, var(--card) 78%, transparent);
|
||||
box-shadow:
|
||||
0 14px 30px rgb(0 0 0 / 0.35),
|
||||
0 1px 0 rgb(255 255 255 / 0.1) inset;
|
||||
}
|
||||
|
||||
.dark [data-ios-native] .terminal-first-switcher-option[aria-pressed="true"] {
|
||||
background: color-mix(in srgb, var(--muted) 82%, white 6%);
|
||||
box-shadow:
|
||||
0 3px 12px rgb(0 0 0 / 0.28),
|
||||
0 1px 0 rgb(255 255 255 / 0.12) inset;
|
||||
}
|
||||
|
||||
[data-ios-native]
|
||||
:is(
|
||||
.conversations-sidebar,
|
||||
[data-testid="file-viewer"],
|
||||
[data-testid="files-panel-drawer"],
|
||||
[data-testid="terminals-panel"],
|
||||
[data-testid="subagents-panel-drawer"],
|
||||
[data-testid="todos-panel-drawer"]
|
||||
) {
|
||||
padding-top: env(safe-area-inset-top, 0px);
|
||||
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (width < 48rem) and (prefers-reduced-motion: reduce) {
|
||||
[data-ios-native] .conversations-sidebar {
|
||||
transition-duration: 1ms;
|
||||
}
|
||||
}
|
||||
/* Share button — glassy pink effect (both modes). The vertical
|
||||
* gradient alone does the embossed work (lighter top = light catch,
|
||||
* darker bottom = shadow, simulating a convex surface lit from above);
|
||||
@@ -503,6 +661,27 @@
|
||||
font-size: 0.875em;
|
||||
}
|
||||
|
||||
/* Word-wrap toggle for chat code blocks (see ChatCodeBlockPre in message.tsx).
|
||||
* Streamdown renders the body with `overflow-x-auto` and the <code> with
|
||||
* `white-space: pre`, so long lines scroll horizontally. When `.chat-code-wrap`
|
||||
* is set we soft-wrap instead so everything fits the column width. */
|
||||
.chat-code-wrap [data-streamdown="code-block-body"] {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
.chat-code-wrap [data-streamdown="code-block-body"] pre,
|
||||
.chat-code-wrap [data-streamdown="code-block-body"] code {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
/* Hang-indent wrapped continuation lines past the line-number gutter so they
|
||||
* align with the code, not under the numbers. Gutter = before:w-6 (1.5rem) +
|
||||
* before:mr-4 (1rem) = 2.5rem; the negative text-indent pulls the first line
|
||||
* (and its ::before number) back to the left edge. */
|
||||
.chat-code-wrap [data-streamdown="code-block-body"] code > span {
|
||||
padding-left: 2.5rem;
|
||||
text-indent: -2.5rem;
|
||||
}
|
||||
|
||||
/* Streamdown's <button> link-safety-modal variant and wrap-anywhere reset can
|
||||
* leave the default arrow cursor on links — force pointer to signal affordance. */
|
||||
[data-streamdown="link"] {
|
||||
|
||||
@@ -844,6 +844,7 @@ function* processEvent(state: ReducerState, event: StreamEvent): Generator<AnyBl
|
||||
exitPlanMode: event.exitPlanMode,
|
||||
codexCommand: event.codexCommand,
|
||||
allowAllEdits: event.allowAllEdits,
|
||||
rememberScope: event.rememberScope,
|
||||
} satisfies ElicitationBlock;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
// uses camelCase fields + a `type` discriminator string equal to the
|
||||
// Python class name lowercased (e.g. ResponseStartBlock → "response_start").
|
||||
|
||||
import type { Response } from "./types";
|
||||
import type { RememberScope, Response } from "./types";
|
||||
|
||||
/**
|
||||
* Metadata attached to every stream block.
|
||||
@@ -435,6 +435,15 @@ export interface ElicitationBlock {
|
||||
* switch is a no-op.
|
||||
*/
|
||||
allowAllEdits?: boolean;
|
||||
/**
|
||||
* Claude-native non-edit tool prompts only: present when the card
|
||||
* should render an "Approve & don't ask again for <host|tool>" button
|
||||
* that installs a session-scoped allow rule on accept (the web
|
||||
* equivalent of the native TUI's "don't ask again" option). ``tool``
|
||||
* is the gated tool; ``host`` is the WebFetch domain when present.
|
||||
* Absent/null for all other elicitations.
|
||||
*/
|
||||
rememberScope?: RememberScope | null;
|
||||
}
|
||||
|
||||
/** Union of all block types. */
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
// uses camelCase fields + a `type` discriminator string equal to the
|
||||
// Python class name lowercased (e.g. ResponseCreated → "response_created").
|
||||
|
||||
import type { ErrorInfo, ModelUsage, Response, SandboxLaunchStage } from "./types";
|
||||
import type { ErrorInfo, ModelUsage, RememberScope, Response, SandboxLaunchStage } from "./types";
|
||||
|
||||
/** Provider-native tool item types. */
|
||||
export const NATIVE_TOOL_TYPES = new Set<string>([
|
||||
@@ -237,6 +237,22 @@ export interface ElicitationRequest {
|
||||
* mode switch is meaningful.
|
||||
*/
|
||||
allowAllEdits?: boolean;
|
||||
/**
|
||||
* Producer-supplied extra (claude-native non-edit tool prompts only):
|
||||
* present when the PermissionRequest endpoint is gating a tool that
|
||||
* supports a persistent "don't ask again" allow rule (everything
|
||||
* except edit tools, ExitPlanMode, and AskUserQuestion). ``tool`` is
|
||||
* the gated tool name; ``host`` is the WebFetch request domain when
|
||||
* present. The UI's ApprovalCard renders an "Approve & don't ask
|
||||
* again for <host|tool>" button that, on accept, asks the server to
|
||||
* install a session-scoped allow rule — the web equivalent of the
|
||||
* native TUI's "don't ask again" permission option.
|
||||
*
|
||||
* Absent/null for every other elicitation (edit tools, ExitPlanMode,
|
||||
* AskUserQuestion, codex, policy ASK), so the button only appears
|
||||
* where the allow rule is meaningful.
|
||||
*/
|
||||
rememberScope?: RememberScope | null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,10 +2,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
isElectronShell,
|
||||
isIOSShell,
|
||||
isNativeShell,
|
||||
nativeNotify,
|
||||
onNativeNotificationActivated,
|
||||
onNativeSidebarDrag,
|
||||
setBadgeCount as bridgeSetBadge,
|
||||
setNativeServerSwitcherHidden,
|
||||
} from "./nativeBridge";
|
||||
|
||||
// The Electron preload bridge mock, installed on window.omnigentDesktop.
|
||||
@@ -14,6 +17,16 @@ const electronNotify = vi.fn().mockResolvedValue(true);
|
||||
const electronUnsubscribe = vi.fn();
|
||||
const electronOnNotificationActivated = vi.fn().mockReturnValue(electronUnsubscribe);
|
||||
|
||||
// The iOS WKWebView bridge mock, installed on window.omnigentNative.
|
||||
const iosSetBadge = vi.fn();
|
||||
const iosNotify = vi.fn().mockResolvedValue(true);
|
||||
const iosUnsubscribe = vi.fn();
|
||||
const iosOnNotificationActivated = vi.fn().mockReturnValue(iosUnsubscribe);
|
||||
const iosOnSidebarDragUnsubscribe = vi.fn();
|
||||
const iosOnSidebarDrag = vi.fn().mockReturnValue(iosOnSidebarDragUnsubscribe);
|
||||
const iosSetServerSwitcherHidden = vi.fn();
|
||||
const iosSetSidebarOpen = vi.fn();
|
||||
|
||||
/**
|
||||
* Simulate running inside / outside the Electron shell via the preload key.
|
||||
* `withClickRouting` toggles the optional `onNotificationActivated` method so
|
||||
@@ -37,32 +50,68 @@ function setElectron(on: boolean, withClickRouting = true): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Simulate running inside / outside the iOS shell via the WKWebView bridge. */
|
||||
function setIOS(on: boolean, withClickRouting = true): void {
|
||||
if (on) {
|
||||
(window as unknown as Record<string, unknown>).omnigentNative = {
|
||||
kind: "ios",
|
||||
setBadgeCount: (...args: unknown[]) => iosSetBadge(...args),
|
||||
notify: (...args: unknown[]) => iosNotify(...args),
|
||||
setServerSwitcherHidden: (...args: unknown[]) => iosSetServerSwitcherHidden(...args),
|
||||
setSidebarOpen: (...args: unknown[]) => iosSetSidebarOpen(...args),
|
||||
onSidebarDrag: (...args: unknown[]) => iosOnSidebarDrag(...args),
|
||||
...(withClickRouting
|
||||
? {
|
||||
onNotificationActivated: (...args: unknown[]) => iosOnNotificationActivated(...args),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
} else {
|
||||
delete (window as unknown as Record<string, unknown>).omnigentNative;
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
electronNotify.mockResolvedValue(true);
|
||||
iosNotify.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setElectron(false);
|
||||
setIOS(false);
|
||||
});
|
||||
|
||||
describe("isNativeShell / isElectronShell", () => {
|
||||
it("are false in a plain browser (no preload bridge)", () => {
|
||||
setElectron(false);
|
||||
expect(isElectronShell()).toBe(false);
|
||||
expect(isIOSShell()).toBe(false);
|
||||
expect(isNativeShell()).toBe(false);
|
||||
});
|
||||
|
||||
it("are true when the Electron preload bridge is present", () => {
|
||||
setElectron(true);
|
||||
expect(isElectronShell()).toBe(true);
|
||||
expect(isIOSShell()).toBe(false);
|
||||
expect(isNativeShell()).toBe(true);
|
||||
});
|
||||
|
||||
it("treats the iOS bridge as native but not Electron", () => {
|
||||
setIOS(true);
|
||||
expect(isElectronShell()).toBe(false);
|
||||
expect(isIOSShell()).toBe(true);
|
||||
expect(isNativeShell()).toBe(true);
|
||||
});
|
||||
|
||||
it("ignore a bridge with the wrong discriminator", () => {
|
||||
(window as unknown as Record<string, unknown>).omnigentDesktop = { kind: "nope" };
|
||||
(window as unknown as Record<string, unknown>).omnigentNative = { kind: "nope" };
|
||||
expect(isElectronShell()).toBe(false);
|
||||
expect(isIOSShell()).toBe(false);
|
||||
expect(isNativeShell()).toBe(false);
|
||||
delete (window as unknown as Record<string, unknown>).omnigentDesktop;
|
||||
delete (window as unknown as Record<string, unknown>).omnigentNative;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -84,6 +133,17 @@ describe("nativeNotify", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("routes the notification through the iOS bridge when present", async () => {
|
||||
setIOS(true);
|
||||
await expect(nativeNotify({ title: "Session 1", body: "done" })).resolves.toBe(true);
|
||||
expect(iosNotify).toHaveBeenCalledWith({
|
||||
title: "Session 1",
|
||||
body: "done",
|
||||
navigatePath: undefined,
|
||||
});
|
||||
expect(electronNotify).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("forwards navigatePath so the shell can route on click", async () => {
|
||||
setElectron(true);
|
||||
await nativeNotify({ title: "Session 1", body: "done", navigatePath: "/c/a" });
|
||||
@@ -128,6 +188,15 @@ describe("onNativeNotificationActivated", () => {
|
||||
expect(electronUnsubscribe).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("subscribes through the iOS bridge and returns its unsubscribe", () => {
|
||||
setIOS(true);
|
||||
const cb = vi.fn();
|
||||
const unsubscribe = onNativeNotificationActivated(cb);
|
||||
expect(iosOnNotificationActivated).toHaveBeenCalledWith(cb);
|
||||
unsubscribe();
|
||||
expect(iosUnsubscribe).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("returns a no-op unsubscribe when the bridge throws", () => {
|
||||
setElectron(true);
|
||||
electronOnNotificationActivated.mockImplementationOnce(() => {
|
||||
@@ -138,6 +207,43 @@ describe("onNativeNotificationActivated", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("onNativeSidebarDrag", () => {
|
||||
it("returns a no-op unsubscribe outside any native shell", () => {
|
||||
setIOS(false);
|
||||
const cb = vi.fn();
|
||||
const unsubscribe = onNativeSidebarDrag(cb);
|
||||
expect(iosOnSidebarDrag).not.toHaveBeenCalled();
|
||||
expect(() => unsubscribe()).not.toThrow();
|
||||
});
|
||||
|
||||
it("subscribes through the iOS bridge and returns its unsubscribe", () => {
|
||||
setIOS(true);
|
||||
const cb = vi.fn();
|
||||
const unsubscribe = onNativeSidebarDrag(cb);
|
||||
expect(iosOnSidebarDrag).toHaveBeenCalledWith(cb);
|
||||
unsubscribe();
|
||||
expect(iosOnSidebarDragUnsubscribe).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("returns a no-op unsubscribe under a shell lacking the gesture hook", () => {
|
||||
setIOS(true);
|
||||
delete (window as unknown as { omnigentNative: Record<string, unknown> }).omnigentNative
|
||||
.onSidebarDrag;
|
||||
const unsubscribe = onNativeSidebarDrag(vi.fn());
|
||||
expect(iosOnSidebarDrag).not.toHaveBeenCalled();
|
||||
expect(() => unsubscribe()).not.toThrow();
|
||||
});
|
||||
|
||||
it("returns a no-op unsubscribe when the bridge throws", () => {
|
||||
setIOS(true);
|
||||
iosOnSidebarDrag.mockImplementationOnce(() => {
|
||||
throw new Error("bridge down");
|
||||
});
|
||||
const unsubscribe = onNativeSidebarDrag(vi.fn());
|
||||
expect(() => unsubscribe()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("setBadgeCount", () => {
|
||||
it("is a no-op outside the shell", async () => {
|
||||
setElectron(false);
|
||||
@@ -151,6 +257,13 @@ describe("setBadgeCount", () => {
|
||||
expect(electronSetBadge).toHaveBeenCalledWith(5);
|
||||
});
|
||||
|
||||
it("routes the count through the iOS bridge", async () => {
|
||||
setIOS(true);
|
||||
await bridgeSetBadge(5);
|
||||
expect(iosSetBadge).toHaveBeenCalledWith(5);
|
||||
expect(electronSetBadge).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("forwards a zero count (the bridge clears the badge for <= 0)", async () => {
|
||||
setElectron(true);
|
||||
await bridgeSetBadge(0);
|
||||
@@ -165,3 +278,35 @@ describe("setBadgeCount", () => {
|
||||
await expect(bridgeSetBadge(2)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("setNativeServerSwitcherHidden", () => {
|
||||
it("is a no-op outside the shell", () => {
|
||||
setNativeServerSwitcherHidden(true);
|
||||
expect(iosSetServerSwitcherHidden).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("routes switcher visibility through the iOS bridge", () => {
|
||||
setIOS(true);
|
||||
setNativeServerSwitcherHidden(true);
|
||||
setNativeServerSwitcherHidden(false);
|
||||
expect(iosSetServerSwitcherHidden).toHaveBeenNthCalledWith(1, true);
|
||||
expect(iosSetServerSwitcherHidden).toHaveBeenNthCalledWith(2, false);
|
||||
expect(iosSetSidebarOpen).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to the legacy sidebar bridge name", () => {
|
||||
setIOS(true);
|
||||
delete (window as unknown as { omnigentNative: Record<string, unknown> }).omnigentNative
|
||||
.setServerSwitcherHidden;
|
||||
setNativeServerSwitcherHidden(true);
|
||||
expect(iosSetSidebarOpen).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("does not throw when the bridge setter throws", () => {
|
||||
setIOS(true);
|
||||
iosSetServerSwitcherHidden.mockImplementationOnce(() => {
|
||||
throw new Error("bridge down");
|
||||
});
|
||||
expect(() => setNativeServerSwitcherHidden(true)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
+177
-33
@@ -1,48 +1,103 @@
|
||||
// Bridge between the web app and the optional Electron desktop shell.
|
||||
// Bridge between the web app and the optional native shells.
|
||||
//
|
||||
// The SAME `ap-web` bundle runs in two places:
|
||||
// 1. A normal browser tab (served by the Omnigent server).
|
||||
// 2. Inside the Electron desktop wrapper (`ap-web/electron`), which loads
|
||||
// that exact server-served bundle in a Chromium BrowserWindow.
|
||||
// 3. Inside the iOS wrapper (`ap-web/ios`), which loads the same bundle in
|
||||
// a WKWebView.
|
||||
//
|
||||
// In case (2) we can do better than the Web platform: fire OS-native desktop
|
||||
// notifications and paint a dock / taskbar badge count, both via the Electron
|
||||
// preload bridge exposed on `window.omnigentDesktop`. In case (1) none of
|
||||
// that exists, so every function here degrades to a no-op / `false` and the
|
||||
// caller falls back to the Web Notifications path it already has.
|
||||
// In native cases we can do better than the Web platform: fire OS-native
|
||||
// notifications and paint an app badge count via a small injected bridge. In
|
||||
// case (1) none of that exists, so every function here degrades to a no-op /
|
||||
// `false` and the caller falls back to the Web Notifications path it already
|
||||
// has.
|
||||
//
|
||||
// Design notes:
|
||||
// * Detection is feature-based (the preload's `window.omnigentDesktop`
|
||||
// object with `kind: "electron"`), never a build flag — one bundle, two
|
||||
// runtimes, decided at runtime.
|
||||
// * Detection is feature-based (an injected `window.omnigentNative` or the
|
||||
// legacy Electron `window.omnigentDesktop` object), never a build flag —
|
||||
// one bundle, multiple runtimes, decided at runtime.
|
||||
// * This module never throws: a broken/old shell must not take down
|
||||
// notifications in the browser path.
|
||||
|
||||
/**
|
||||
* Minimal API surface exposed by the Electron preload on
|
||||
* `window.omnigentDesktop`. The Electron shell (`ap-web/electron`) wraps the
|
||||
* server-served SPA; its preload bridges to the main process over IPC for the
|
||||
* two OS integrations we need: dock/taskbar badge and OS notifications. Kept
|
||||
* intentionally tiny and string/number only so it survives `contextBridge`
|
||||
* Phase of a native sidebar-drag gesture (see `onSidebarDrag`). `begin` and
|
||||
* `move` are live drag frames carrying an open fraction; `open` and `close`
|
||||
* are the settle decision the shell made on release.
|
||||
*/
|
||||
export type SidebarDragPhase = "begin" | "move" | "open" | "close";
|
||||
|
||||
/**
|
||||
* Minimal API surface exposed by native shells. Electron exposes the legacy
|
||||
* `window.omnigentDesktop`; newer shells expose `window.omnigentNative`.
|
||||
* Kept intentionally tiny and string/number only so it survives bridge
|
||||
* serialization.
|
||||
*/
|
||||
interface ElectronDesktopApi {
|
||||
interface NativeShellApi {
|
||||
/** Discriminator so feature detection is unambiguous. */
|
||||
kind: "electron";
|
||||
kind: "electron" | "ios";
|
||||
/** Paint the dock/taskbar badge; 0 clears it. */
|
||||
setBadgeCount: (count: number) => void;
|
||||
/** Fire an OS notification; resolves true when it was shown. */
|
||||
notify: (params: NativeNotifyParams) => Promise<boolean>;
|
||||
// Optional: a shell older than this SPA may lack notification-click routing,
|
||||
// in which case clicking a desktop toast only focuses the window (the prior
|
||||
// in which case clicking a native toast only focuses the app (the prior
|
||||
// behavior) instead of also navigating.
|
||||
/**
|
||||
* Subscribe to OS-notification clicks. The main process sends the in-app
|
||||
* path the notification carried (its `navigatePath`); returns an unsubscribe.
|
||||
*/
|
||||
onNotificationActivated?: (callback: (path: string) => void) => () => void;
|
||||
// The server-picker trio is optional: the SPA is server-served and may be
|
||||
// newer than the installed shell, whose preload then lacks these methods.
|
||||
/**
|
||||
* Subscribe to native sidebar-drag events. The iOS shell streams a left-edge
|
||||
* swipe here (the gesture it repurposed from back-navigation) so the renderer
|
||||
* can drive its sidebar as an interactive drawer: `begin`/`move` carry a 0→1
|
||||
* open fraction the sidebar should track live (no transition), and
|
||||
* `open`/`close` are the settle decision on release (animate to that resting
|
||||
* state). Returns an unsubscribe.
|
||||
*/
|
||||
onSidebarDrag?: (callback: (phase: SidebarDragPhase, progress: number) => void) => () => void;
|
||||
/**
|
||||
* Let native chrome react to web UI state. The iOS shell uses this to show
|
||||
* its floating server switcher only when the chat transcript is visible.
|
||||
*/
|
||||
setServerSwitcherHidden?: (hidden: boolean) => void;
|
||||
/**
|
||||
* Legacy iOS bridge name from the sidebar-only implementation. Kept as a
|
||||
* fallback so a newer SPA can still ask an older shell to hide the switcher.
|
||||
*/
|
||||
setSidebarOpen?: (open: boolean) => void;
|
||||
/**
|
||||
* Drive the native Chat/Terminal switcher (iOS). The web app owns the truth
|
||||
* and pushes the current mode, whether the terminal is reachable / booting,
|
||||
* and whether the switcher should be shown at all. Absent on older shells,
|
||||
* in which case the web renders its own in-page pill instead.
|
||||
*/
|
||||
setViewMode?: (params: NativeViewModeParams) => void;
|
||||
/** Subscribe to taps on the native switcher; returns an unsubscribe. */
|
||||
onViewModeChanged?: (callback: (mode: NativeViewMode) => void) => () => void;
|
||||
}
|
||||
|
||||
export type NativeViewMode = "chat" | "terminal";
|
||||
|
||||
export interface NativeViewModeParams {
|
||||
/** Currently selected view. */
|
||||
mode: NativeViewMode;
|
||||
/** Whether the Terminal option is selectable (a reachable PTY exists). */
|
||||
terminalEnabled: boolean;
|
||||
/** Terminal is booting but not yet openable — drives a spinner. */
|
||||
terminalStartingUp?: boolean;
|
||||
/** Whether the switcher should be shown at all right now. */
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Electron-specific bridge. The server-picker trio is optional: the SPA is
|
||||
* server-served and may be newer than the installed shell, whose preload then
|
||||
* lacks these methods.
|
||||
*/
|
||||
interface ElectronDesktopApi extends NativeShellApi {
|
||||
kind: "electron";
|
||||
/** Current server origin + recent servers, or null on a foreign page. */
|
||||
getServerPicker?: () => Promise<ServerPickerInfo | null>;
|
||||
/** Re-point this window to a previously-connected server URL. */
|
||||
@@ -66,6 +121,14 @@ function electronApi(): ElectronDesktopApi | undefined {
|
||||
return api?.kind === "electron" ? api : undefined;
|
||||
}
|
||||
|
||||
/** The native shell bridge, or undefined outside any native shell. */
|
||||
function nativeApi(): NativeShellApi | undefined {
|
||||
if (typeof window === "undefined") return undefined;
|
||||
const api = (window as unknown as { omnigentNative?: NativeShellApi }).omnigentNative;
|
||||
if (api?.kind === "ios" || api?.kind === "electron") return api;
|
||||
return electronApi();
|
||||
}
|
||||
|
||||
/** True when running inside the Electron desktop shell. */
|
||||
export function isElectronShell(): boolean {
|
||||
return electronApi() !== undefined;
|
||||
@@ -82,6 +145,11 @@ export function isMacElectronShell(): boolean {
|
||||
return isElectronShell() && navigator.userAgent.includes("Macintosh");
|
||||
}
|
||||
|
||||
/** True when running inside the iOS WKWebView native shell. */
|
||||
export function isIOSShell(): boolean {
|
||||
return nativeApi()?.kind === "ios";
|
||||
}
|
||||
|
||||
/**
|
||||
* True when running inside the native desktop shell (Electron).
|
||||
*
|
||||
@@ -92,7 +160,7 @@ export function isMacElectronShell(): boolean {
|
||||
* this is false and every native call here degrades to a no-op / web fallback.
|
||||
*/
|
||||
export function isNativeShell(): boolean {
|
||||
return isElectronShell();
|
||||
return nativeApi() !== undefined;
|
||||
}
|
||||
|
||||
export interface NativeNotifyParams {
|
||||
@@ -122,14 +190,14 @@ export async function nativeNotify({
|
||||
body,
|
||||
navigatePath,
|
||||
}: NativeNotifyParams): Promise<boolean> {
|
||||
const electron = electronApi();
|
||||
if (!electron) return false;
|
||||
const native = nativeApi();
|
||||
if (!native) return false;
|
||||
try {
|
||||
return await electron.notify({ title, body, navigatePath });
|
||||
return await native.notify({ title, body, navigatePath });
|
||||
} catch (err) {
|
||||
// Only reachable inside the desktop shell. Log rather than swallow so a
|
||||
// Only reachable inside a native shell. Log rather than swallow so a
|
||||
// broken bridge is visible instead of silently dropping notifications.
|
||||
console.warn("[nativeBridge] electron notify failed:", err);
|
||||
console.warn("[nativeBridge] native notify failed:", err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -145,12 +213,35 @@ export async function nativeNotify({
|
||||
* routing, so callers can register it unconditionally.
|
||||
*/
|
||||
export function onNativeNotificationActivated(callback: (path: string) => void): () => void {
|
||||
const electron = electronApi();
|
||||
if (!electron?.onNotificationActivated) return () => {};
|
||||
const native = nativeApi();
|
||||
if (!native?.onNotificationActivated) return () => {};
|
||||
try {
|
||||
return electron.onNotificationActivated(callback);
|
||||
return native.onNotificationActivated(callback);
|
||||
} catch (err) {
|
||||
console.warn("[nativeBridge] electron onNotificationActivated failed:", err);
|
||||
console.warn("[nativeBridge] native onNotificationActivated failed:", err);
|
||||
return () => {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to native sidebar-drag events from the iOS shell's left-edge swipe
|
||||
* (the gesture it repurposed from back-navigation), so the renderer can drive
|
||||
* its sidebar as an interactive drawer — tracking the finger on `begin`/`move`
|
||||
* and animating to the settled state on `open`/`close`.
|
||||
*
|
||||
* Returns an unsubscribe function. A no-op (returning a no-op unsubscribe)
|
||||
* outside a native shell or under a shell too old to support the gesture, so
|
||||
* callers can register it unconditionally.
|
||||
*/
|
||||
export function onNativeSidebarDrag(
|
||||
callback: (phase: SidebarDragPhase, progress: number) => void,
|
||||
): () => void {
|
||||
const native = nativeApi();
|
||||
if (!native?.onSidebarDrag) return () => {};
|
||||
try {
|
||||
return native.onSidebarDrag(callback);
|
||||
} catch (err) {
|
||||
console.warn("[nativeBridge] native onSidebarDrag failed:", err);
|
||||
return () => {};
|
||||
}
|
||||
}
|
||||
@@ -164,12 +255,65 @@ export function onNativeNotificationActivated(callback: (path: string) => void):
|
||||
* intentionally don't paper over that.
|
||||
*/
|
||||
export async function setBadgeCount(count: number): Promise<void> {
|
||||
const electron = electronApi();
|
||||
if (!electron) return;
|
||||
const native = nativeApi();
|
||||
if (!native) return;
|
||||
try {
|
||||
electron.setBadgeCount(count);
|
||||
native.setBadgeCount(count);
|
||||
} catch (err) {
|
||||
console.warn("[nativeBridge] electron setBadgeCount failed:", err);
|
||||
console.warn("[nativeBridge] native setBadgeCount failed:", err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inform a native shell that its server switcher should hide. Older shells
|
||||
* simply lack this optional method, so this degrades to a no-op.
|
||||
*/
|
||||
export function setNativeServerSwitcherHidden(hidden: boolean): void {
|
||||
const native = nativeApi();
|
||||
const setter = native?.setServerSwitcherHidden ?? native?.setSidebarOpen;
|
||||
if (!setter) return;
|
||||
try {
|
||||
setter(hidden);
|
||||
} catch (err) {
|
||||
console.warn("[nativeBridge] native setServerSwitcherHidden failed:", err);
|
||||
}
|
||||
}
|
||||
|
||||
/** @deprecated Use setNativeServerSwitcherHidden. */
|
||||
export function setNativeSidebarOpen(open: boolean): void {
|
||||
setNativeServerSwitcherHidden(open);
|
||||
}
|
||||
|
||||
/**
|
||||
* Push the current Chat/Terminal state to the native switcher (iOS). The web
|
||||
* app owns this state; the native bar is a thin control surface that renders it
|
||||
* and reports taps back via {@link onNativeViewModeChanged}. No-op on shells
|
||||
* without the native switcher (older iOS shells, Electron, plain browser) — the
|
||||
* caller renders its own in-page pill there.
|
||||
*/
|
||||
export function setNativeViewMode(params: NativeViewModeParams): void {
|
||||
const native = nativeApi();
|
||||
if (!native?.setViewMode) return;
|
||||
try {
|
||||
native.setViewMode(params);
|
||||
} catch (err) {
|
||||
console.warn("[nativeBridge] native setViewMode failed:", err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to taps on the native Chat/Terminal switcher. The shell sends the
|
||||
* mode the user selected; route it into the web view's own state. Returns an
|
||||
* unsubscribe; a no-op outside a shell that exposes the native switcher.
|
||||
*/
|
||||
export function onNativeViewModeChanged(callback: (mode: NativeViewMode) => void): () => void {
|
||||
const native = nativeApi();
|
||||
if (!native?.onViewModeChanged) return () => {};
|
||||
try {
|
||||
return native.onViewModeChanged(callback);
|
||||
} catch (err) {
|
||||
console.warn("[nativeBridge] native onViewModeChanged failed:", err);
|
||||
return () => {};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
// Pure function. No React, no DOM. Tested in `renderItems.test.ts`.
|
||||
|
||||
import type { AnyBlock, MessageContentBlock, ToolExecution, ToolResultBlock } from "./blocks";
|
||||
import type { RememberScope } from "./types";
|
||||
import type { ActiveResponse } from "@/store/types";
|
||||
|
||||
/**
|
||||
@@ -112,6 +113,7 @@ export type RenderItem =
|
||||
execPolicyAmendment: string[] | null;
|
||||
} | null;
|
||||
allowAllEdits?: boolean;
|
||||
rememberScope?: RememberScope | null;
|
||||
};
|
||||
|
||||
/** A bubble cluster. The page maps over these. */
|
||||
@@ -696,6 +698,7 @@ function buildAssistantItems(
|
||||
exitPlanMode: b.exitPlanMode,
|
||||
codexCommand: b.codexCommand,
|
||||
allowAllEdits: b.allowAllEdits,
|
||||
rememberScope: b.rememberScope,
|
||||
});
|
||||
i += 1;
|
||||
continue;
|
||||
|
||||
@@ -707,6 +707,77 @@ describe("response.elicitation_request (FLAT envelope)", () => {
|
||||
const ev = out[0] as ElicitationRequest;
|
||||
expect(ev.allowAllEdits).toBe(false);
|
||||
});
|
||||
|
||||
it("lifts the remember_scope hint with a host for WebFetch prompts", () => {
|
||||
// The server stamps ``remember_scope`` on non-edit tool
|
||||
// PermissionRequests so the card can offer "Approve & don't ask
|
||||
// again for <host>". For WebFetch the host scopes the rule, so it
|
||||
// must survive parsing.
|
||||
const out = parse("response.elicitation_request", {
|
||||
type: "response.elicitation_request",
|
||||
elicitation_id: "elicit_webfetch",
|
||||
params: {
|
||||
mode: "form",
|
||||
message: "Claude wants to call **WebFetch**",
|
||||
phase: "pre_tool_use",
|
||||
policy_name: "claude_native_permission",
|
||||
content_preview: 'WebFetch({"url": "https://github.com/a/b"})',
|
||||
requestedSchema: {},
|
||||
tool_name: "WebFetch",
|
||||
remember_scope: { tool: "WebFetch", host: "github.com" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(out).toHaveLength(1);
|
||||
const ev = out[0] as ElicitationRequest;
|
||||
expect(ev.rememberScope).toEqual({ tool: "WebFetch", host: "github.com" });
|
||||
});
|
||||
|
||||
it("lifts a tool-wide remember_scope hint (no host)", () => {
|
||||
// Non-WebFetch tools (here Bash) get a tool-wide scope: ``tool``
|
||||
// only, no ``host``. The card labels the button by the tool name.
|
||||
const out = parse("response.elicitation_request", {
|
||||
type: "response.elicitation_request",
|
||||
elicitation_id: "elicit_bash_remember",
|
||||
params: {
|
||||
mode: "form",
|
||||
message: "Claude wants to call **Bash**",
|
||||
phase: "pre_tool_use",
|
||||
policy_name: "claude_native_permission",
|
||||
content_preview: "Bash({})",
|
||||
requestedSchema: {},
|
||||
tool_name: "Bash",
|
||||
remember_scope: { tool: "Bash" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(out).toHaveLength(1);
|
||||
const ev = out[0] as ElicitationRequest;
|
||||
expect(ev.rememberScope).toEqual({ tool: "Bash", host: undefined });
|
||||
});
|
||||
|
||||
it("leaves rememberScope null when the hint is absent", () => {
|
||||
// Edit tools / ExitPlanMode / AskUserQuestion carry no
|
||||
// ``remember_scope``; the button must stay hidden.
|
||||
const out = parse("response.elicitation_request", {
|
||||
type: "response.elicitation_request",
|
||||
elicitation_id: "elicit_edit_no_remember",
|
||||
params: {
|
||||
mode: "form",
|
||||
message: "Claude wants to call **Edit**",
|
||||
phase: "pre_tool_use",
|
||||
policy_name: "claude_native_permission",
|
||||
content_preview: "Edit({})",
|
||||
requestedSchema: {},
|
||||
tool_name: "Edit",
|
||||
allow_all_edits: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(out).toHaveLength(1);
|
||||
const ev = out[0] as ElicitationRequest;
|
||||
expect(ev.rememberScope).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("response.elicitation_resolved (FLAT envelope)", () => {
|
||||
|
||||
@@ -74,6 +74,7 @@ describe("createSession", () => {
|
||||
agentName: null,
|
||||
runnerId: undefined,
|
||||
hostId: null,
|
||||
hostResumable: false,
|
||||
status: "idle",
|
||||
createdAt: 1704067200,
|
||||
title: null,
|
||||
|
||||
@@ -99,6 +99,14 @@ interface SessionResponseWire {
|
||||
* other carrier and it's absent for those.
|
||||
*/
|
||||
host_id?: string | null;
|
||||
/**
|
||||
* Whether this session is bound to a dormant managed host the server can
|
||||
* wake in place (its sandbox provider supports resume). Read only when the
|
||||
* host is offline, to tell a recoverable "asleep" state (send a message —
|
||||
* the server resumes the sandbox) from the terminal host_offline dead-end.
|
||||
* Absent/`false` for non-managed/non-resumable hosts.
|
||||
*/
|
||||
host_resumable?: boolean;
|
||||
status: SessionStatus;
|
||||
created_at: number;
|
||||
/**
|
||||
@@ -250,6 +258,7 @@ function sessionFromWire(wire: SessionResponseWire): Session {
|
||||
agentName: wire.agent_name ?? null,
|
||||
runnerId: wire.runner_id,
|
||||
hostId: wire.host_id ?? null,
|
||||
hostResumable: wire.host_resumable ?? false,
|
||||
status: wire.status,
|
||||
createdAt: wire.created_at,
|
||||
title: wire.title ?? null,
|
||||
|
||||
+21
-1
@@ -61,7 +61,7 @@ import type {
|
||||
ToolResult,
|
||||
} from "./events";
|
||||
import { NATIVE_TOOL_TYPES } from "./events";
|
||||
import type { ErrorInfo, ModelUsage, Response } from "./types";
|
||||
import type { ErrorInfo, ModelUsage, RememberScope, Response } from "./types";
|
||||
|
||||
/**
|
||||
* Out-param for `parseSseStream`: `sawDone` is set when the server's `[DONE]`
|
||||
@@ -766,6 +766,25 @@ export function parseEvent(rawType: string, data: Record<string, unknown>): Stre
|
||||
// offers the "Accept & allow all edits" button (switches the
|
||||
// session to acceptEdits mode on accept).
|
||||
const allowAllEdits = p.allow_all_edits === true;
|
||||
// claude-native non-edit tool prompts stamp this so the ApprovalCard
|
||||
// offers the persistent "don't ask again" button (installs a
|
||||
// session-scoped allow rule on accept). `tool` is the gated tool;
|
||||
// `host` is the WebFetch request domain when present (drives the
|
||||
// button label and the rule scope).
|
||||
const rememberScopeRaw = p.remember_scope;
|
||||
const rememberScope: RememberScope | null =
|
||||
rememberScopeRaw &&
|
||||
typeof rememberScopeRaw === "object" &&
|
||||
!Array.isArray(rememberScopeRaw) &&
|
||||
typeof (rememberScopeRaw as Record<string, unknown>).tool === "string"
|
||||
? {
|
||||
tool: (rememberScopeRaw as Record<string, unknown>).tool as string,
|
||||
host:
|
||||
typeof (rememberScopeRaw as Record<string, unknown>).host === "string"
|
||||
? ((rememberScopeRaw as Record<string, unknown>).host as string)
|
||||
: undefined,
|
||||
}
|
||||
: null;
|
||||
return {
|
||||
type: "elicitation_request",
|
||||
elicitationId,
|
||||
@@ -805,6 +824,7 @@ export function parseEvent(rawType: string, data: Record<string, unknown>): Stre
|
||||
}
|
||||
: null,
|
||||
allowAllEdits,
|
||||
rememberScope,
|
||||
} satisfies ElicitationRequest;
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,19 @@ export interface ConversationRef {
|
||||
id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope of a claude-native "don't ask again" persistent allow rule,
|
||||
* stamped by the PermissionRequest endpoint for non-edit eligible
|
||||
* tools. ``tool`` is the gated tool; ``host`` is the WebFetch request
|
||||
* domain when present (a domain-scoped rule), absent for a tool-wide
|
||||
* rule. Shared by the elicitation event (`events.ts`), the reduced
|
||||
* block (`blocks.ts`), and the ApprovalCard that renders the button.
|
||||
*/
|
||||
export interface RememberScope {
|
||||
tool: string;
|
||||
host?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* An un-consumed web-composer user message replayed from the session
|
||||
* snapshot. Native-terminal sessions don't persist a web message at
|
||||
@@ -235,6 +248,13 @@ export interface Session {
|
||||
* older recorded fixtures may omit it (treated as `null`).
|
||||
*/
|
||||
hostId?: string | null;
|
||||
/**
|
||||
* Whether this session's host is a dormant resumable managed host the
|
||||
* server can wake on the next message. Carried on the snapshot so the open
|
||||
* view shows a wakeable "asleep" state instead of the terminal host_offline
|
||||
* dead-end. `false`/absent otherwise.
|
||||
*/
|
||||
hostResumable?: boolean;
|
||||
status: SessionStatus;
|
||||
createdAt: number;
|
||||
/**
|
||||
|
||||
+195
-36
@@ -58,6 +58,13 @@ import { parseSystemMessage } from "@/lib/systemMessage";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { OttoIcon } from "@/components/icons/OttoIcon";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useSurfaceFrontmost } from "@/hooks/useNativeServerSwitcher";
|
||||
import {
|
||||
isIOSShell,
|
||||
onNativeViewModeChanged,
|
||||
setNativeServerSwitcherHidden,
|
||||
setNativeViewMode,
|
||||
} from "@/lib/nativeBridge";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -73,6 +80,7 @@ import { usePermissions } from "@/hooks/usePermissions";
|
||||
import type { CodexModelOption, SandboxStatus, Session, SessionStatus } from "@/lib/types";
|
||||
import { usePromptHistory } from "@/hooks/usePromptHistory";
|
||||
import { useAutoGrowTextarea } from "@/hooks/useAutoGrowTextarea";
|
||||
import { useIOSNativeKeyboardVisible } from "@/hooks/useIOSNativeKeyboardInset";
|
||||
import type { MessageContentBlock } from "@/lib/blocks";
|
||||
import { derivePermissionLevel, isOwnerLevel } from "@/lib/permissionsApi";
|
||||
import {
|
||||
@@ -96,6 +104,7 @@ import { useSession } from "@/hooks/useSession";
|
||||
import { useSessionRunnerOnline } from "@/hooks/RunnerHealthProvider";
|
||||
import { useRefreshSessionStateOnRunnerOnline } from "@/hooks/useSessionOnlineRefresh";
|
||||
import {
|
||||
type LivenessRow,
|
||||
type SessionLiveness,
|
||||
livenessRowFromSession,
|
||||
useSessionLiveness,
|
||||
@@ -726,7 +735,15 @@ export function ChatPage() {
|
||||
// so `host_id` still reaches the hook — otherwise a host-bound, host-down
|
||||
// session misclassifies as `local_stranded` and shows the wrong reconnect
|
||||
// path. See `livenessRowFromSession`.
|
||||
const livenessRow = activeConv ?? livenessRowFromSession(activeSession);
|
||||
//
|
||||
// Always source `host_resumable` from the session snapshot — the sidebar
|
||||
// `Conversation` row doesn't carry it. activeSession is loaded for the open
|
||||
// session, so a host-bound, host-down session whose host is a resumable
|
||||
// managed host classifies as `host_asleep` (composer open, send wakes it)
|
||||
// instead of dead-ending on `host_offline`.
|
||||
const livenessRow: LivenessRow | null = activeConv
|
||||
? { ...activeConv, host_resumable: activeSession?.hostResumable ?? false }
|
||||
: livenessRowFromSession(activeSession);
|
||||
const liveness = useSessionLiveness(urlConvId ?? undefined, livenessRow, {
|
||||
turnActive: status === "streaming",
|
||||
});
|
||||
@@ -1272,6 +1289,21 @@ function MainAgentSurface({
|
||||
conversationRef.current = el;
|
||||
setContainerEl(el);
|
||||
}, []);
|
||||
const [terminalSurfaceEl, setTerminalSurfaceEl] = useState<HTMLElement | null>(null);
|
||||
// True only while the chat/terminal surface is the frontmost thing on screen.
|
||||
// Drives both native overlays so neither floats over an opened drawer.
|
||||
const surfaceFrontmost = useSurfaceFrontmost(
|
||||
showTerminal ? terminalSurfaceEl : containerEl,
|
||||
!!conversationId,
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!isIOSShell()) return;
|
||||
setNativeServerSwitcherHidden(!surfaceFrontmost);
|
||||
}, [surfaceFrontmost]);
|
||||
useEffect(() => {
|
||||
if (!isIOSShell()) return;
|
||||
return () => setNativeServerSwitcherHidden(true);
|
||||
}, []);
|
||||
// The conversation's scroll container + the StickToBottom controls needed to
|
||||
// override its bottom-lock, lifted out of the context by
|
||||
// ConversationScrollRefBridge so the pinned-but-unmasked JumpToTopButton can
|
||||
@@ -1318,12 +1350,17 @@ function MainAgentSurface({
|
||||
<MainTerminalView
|
||||
conversationId={conversationId}
|
||||
initialTerminalKey={terminalFirst?.terminalViewKey}
|
||||
onSurfaceElement={setTerminalSurfaceEl}
|
||||
// Non-owners attach read-only: a shared PTY can't attribute
|
||||
// input per-user, so only the owner may type. They drive the
|
||||
// agent via the composer instead. Server enforces this too.
|
||||
readOnly={!isOwnerLevel(permissionLevel)}
|
||||
/>
|
||||
<ConnectionIndicator liveness={liveness} onShowReconnectHelp={onShowReconnectHelp} />
|
||||
<ConnectionIndicator
|
||||
liveness={liveness}
|
||||
onShowReconnectHelp={onShowReconnectHelp}
|
||||
surfaceFrontmost={surfaceFrontmost}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1338,7 +1375,12 @@ function MainAgentSurface({
|
||||
ChatHeader overlay's controls (geometry in index.css). */}
|
||||
<Conversation className="chat-scroll-fade flex-1">
|
||||
{/* gap-4 overrides ConversationContent's default gap-8 so consecutive agent turns read as one thread. */}
|
||||
<ConversationContent className={cn("mx-auto w-full gap-4 pt-20 pb-6", CHAT_COLUMN_WIDTH)}>
|
||||
<ConversationContent
|
||||
className={cn(
|
||||
"chat-conversation-content mx-auto w-full gap-4 pt-20 pb-6",
|
||||
CHAT_COLUMN_WIDTH,
|
||||
)}
|
||||
>
|
||||
{/* Scroll helpers — must live inside StickToBottom to access context. */}
|
||||
<ScrollToBottomOnSend nonce={sendScrollNonce} />
|
||||
<ConversationScrollRefBridge onScroller={setScroller} />
|
||||
@@ -1457,7 +1499,8 @@ function MainAgentSurface({
|
||||
showCodexPlanMode={showCodexPlanMode}
|
||||
isTerminalFirst={isTerminalFirst}
|
||||
isNativeWrapper={isNativeWrapper}
|
||||
reconnectHint={liveness.kind === "runner_asleep"}
|
||||
reconnectHint={liveness.kind === "runner_asleep" || liveness.kind === "host_asleep"}
|
||||
sandboxAsleepHint={liveness.kind === "host_asleep"}
|
||||
unreachable={
|
||||
!sandboxLaunching &&
|
||||
(liveness.kind === "host_offline" || liveness.kind === "local_stranded")
|
||||
@@ -1470,7 +1513,11 @@ function MainAgentSurface({
|
||||
{/* Chat/Terminal toggle for terminal-first sessions, reconnect-or-
|
||||
fork banner when unreachable, nothing otherwise. Sits below the
|
||||
composer so its position is consistent with the terminal view. */}
|
||||
<ConnectionIndicator liveness={liveness} onShowReconnectHelp={onShowReconnectHelp} />
|
||||
<ConnectionIndicator
|
||||
liveness={liveness}
|
||||
onShowReconnectHelp={onShowReconnectHelp}
|
||||
surfaceFrontmost={surfaceFrontmost}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -2015,12 +2062,42 @@ export function SandboxFailedIndicator({ status }: { status: SandboxStatus }) {
|
||||
export function ConnectionIndicator({
|
||||
liveness,
|
||||
onShowReconnectHelp,
|
||||
surfaceFrontmost = true,
|
||||
}: {
|
||||
liveness: SessionLiveness;
|
||||
onShowReconnectHelp: () => void;
|
||||
// Whether the chat/terminal surface is frontmost (not under a drawer). Gates
|
||||
// the native iOS bar so it doesn't float over an opened sidebar/panel.
|
||||
surfaceFrontmost?: boolean;
|
||||
}) {
|
||||
const terminalFirst = useTerminalFirst();
|
||||
const keyboardVisible = useIOSNativeKeyboardVisible(
|
||||
terminalFirst?.isTerminalFirst === true,
|
||||
terminalFirst?.view === "chat",
|
||||
);
|
||||
const sandboxStatus = useChatStore((s) => s.sandboxStatus);
|
||||
// Genuinely-unreachable states get the reconnect banner, for
|
||||
// both terminal-first and regular sessions. `runner_asleep` (host up,
|
||||
// runner relaunches on the next message), `host_asleep` (resumable managed
|
||||
// host the server wakes on the next message), and `unknown` (pre-poll) are
|
||||
// NOT unreachable — they're handled below.
|
||||
const unreachable = liveness.kind === "host_offline" || liveness.kind === "local_stranded";
|
||||
|
||||
// In the iOS shell the Chat/Terminal toggle is the native Liquid Glass bar,
|
||||
// not the in-page pill. Drive it from here (always mounted) with the SAME
|
||||
// visibility the pill would have, expressed as a stable boolean so switching
|
||||
// views never flickers the bar. Hook is called unconditionally (before any
|
||||
// early return) to satisfy the rules of hooks.
|
||||
const nativeBarVisible =
|
||||
isIOSShell() &&
|
||||
terminalFirst?.isTerminalFirst === true &&
|
||||
!terminalFirst.isShellView &&
|
||||
sandboxStatus?.stage !== "failed" &&
|
||||
!unreachable &&
|
||||
!keyboardVisible &&
|
||||
surfaceFrontmost;
|
||||
useNativeChatTerminalBar(terminalFirst, nativeBarVisible);
|
||||
|
||||
if (sandboxStatus !== null) {
|
||||
// A failed launch owns this band with its reason. An IN-FLIGHT
|
||||
// launch renders in the chat thread (RunnerStartingIndicator)
|
||||
@@ -2031,11 +2108,6 @@ export function ConnectionIndicator({
|
||||
}
|
||||
return null;
|
||||
}
|
||||
// Genuinely-unreachable states get the reconnect banner, for
|
||||
// both terminal-first and regular sessions. `runner_asleep` (host up,
|
||||
// runner relaunches on the next message) and `unknown` (pre-poll) are
|
||||
// NOT unreachable — they're handled below.
|
||||
const unreachable = liveness.kind === "host_offline" || liveness.kind === "local_stranded";
|
||||
if (unreachable) {
|
||||
return (
|
||||
<button
|
||||
@@ -2067,11 +2139,28 @@ export function ConnectionIndicator({
|
||||
// as the runner comes back. The strict `runner_online` still gates the
|
||||
// inline PTY *view* (it needs a live tunnel) — but not the toggle.
|
||||
if (terminalFirst?.isTerminalFirst) {
|
||||
// In the iOS shell the toggle is the native bar (driven above). Render only
|
||||
// a spacer reserving its fixed footprint so the composer clears it — and
|
||||
// nothing when the bar is hidden.
|
||||
if (isIOSShell()) {
|
||||
// Chat reserves a touch less than terminal: the composer's own bottom
|
||||
// content (the status line) already cushions the gap to the bar.
|
||||
return nativeBarVisible ? (
|
||||
<div
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"omnigent-native-bottom-spacer",
|
||||
terminalFirst.view === "chat" && "omnigent-native-bottom-spacer--chat",
|
||||
)}
|
||||
/>
|
||||
) : null;
|
||||
}
|
||||
// A rail-opened shell owns the main view chrome-free — no pill: a
|
||||
// "Chat" option under someone else's shell misreads as the shell
|
||||
// being the agent. The shell view carries its own close affordance
|
||||
// (MainTerminalView's X) back to chat.
|
||||
if (terminalFirst.isShellView) return null;
|
||||
if (keyboardVisible) return null;
|
||||
return <ConnectedTerminalFirstPill ctx={terminalFirst} />;
|
||||
}
|
||||
|
||||
@@ -2094,8 +2183,8 @@ export function ConnectionIndicator({
|
||||
}
|
||||
|
||||
// `online`/`unknown` for a non-terminal-first session and
|
||||
// `runner_asleep` for any session: status lives in the sidebar / the
|
||||
// composer stays open, so render nothing here.
|
||||
// `runner_asleep`/`host_asleep` for any session: status lives in the
|
||||
// sidebar / the composer stays open, so render nothing here.
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -2174,9 +2263,64 @@ export function RunnerStartingIndicator({ variant }: { variant: "hero" | "row" }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors the Chat/Terminal state onto the iOS shell's native Liquid Glass
|
||||
* switcher and routes its taps back into `setView`. Driven by a stable
|
||||
* `visible` boolean (not this hook's mount/unmount), so toggling Chat/Terminal
|
||||
* updates the bar in place instead of flickering it hidden→shown. A no-op
|
||||
* outside the iOS shell; the caller renders its own in-page pill there.
|
||||
*/
|
||||
function useNativeChatTerminalBar(
|
||||
ctx: ReturnType<typeof useTerminalFirst> | null,
|
||||
visible: boolean,
|
||||
): void {
|
||||
const native = isIOSShell();
|
||||
const view = ctx?.view ?? "chat";
|
||||
const terminalsAvailable = ctx?.terminalsAvailable ?? false;
|
||||
const terminalStartingUp = ctx?.terminalStartingUp ?? false;
|
||||
|
||||
// Keep `setView` reachable from the subscribe-once effect without
|
||||
// resubscribing whenever the callback identity changes.
|
||||
const setViewRef = useRef(ctx?.setView);
|
||||
setViewRef.current = ctx?.setView;
|
||||
|
||||
// Push current state + visibility down whenever any of it changes.
|
||||
useEffect(() => {
|
||||
if (!native) return;
|
||||
setNativeViewMode({
|
||||
mode: view,
|
||||
terminalEnabled: terminalsAvailable,
|
||||
terminalStartingUp,
|
||||
visible,
|
||||
});
|
||||
}, [native, view, terminalsAvailable, terminalStartingUp, visible]);
|
||||
|
||||
// Belt-and-suspenders: hide the bar if the host component ever unmounts.
|
||||
useEffect(() => {
|
||||
if (!native) return;
|
||||
return () => {
|
||||
setNativeViewMode({
|
||||
mode: "chat",
|
||||
terminalEnabled: false,
|
||||
terminalStartingUp: false,
|
||||
visible: false,
|
||||
});
|
||||
};
|
||||
}, [native]);
|
||||
|
||||
// Route native taps back into the web layer.
|
||||
useEffect(() => {
|
||||
if (!native) return;
|
||||
return onNativeViewModeChanged((mode) => setViewRef.current?.(mode));
|
||||
}, [native]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Chat/Terminal segmented control for terminal-first sessions. Status
|
||||
* lives in the sidebar — this band is purely a view toggle.
|
||||
*
|
||||
* Only rendered outside the iOS shell; inside it the switcher is drawn natively
|
||||
* (Liquid Glass) over the web view — see {@link useNativeChatTerminalBar}.
|
||||
*/
|
||||
function ConnectedTerminalFirstPill({
|
||||
ctx,
|
||||
@@ -2189,17 +2333,18 @@ function ConnectedTerminalFirstPill({
|
||||
// reachable: greyed-and-spinning reads as "loading", greyed-and-static as
|
||||
// "no terminal / stopped".
|
||||
const { view, setView, terminalsAvailable, terminalStartingUp } = ctx;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"mx-auto flex w-full items-center justify-center px-6 pb-1.5",
|
||||
"terminal-first-switcher-container mx-auto flex w-full items-center justify-center px-6 pb-1.5",
|
||||
CHAT_COLUMN_WIDTH,
|
||||
)}
|
||||
>
|
||||
<div
|
||||
role="group"
|
||||
aria-label="View mode"
|
||||
className="flex items-center gap-1 rounded-full border border-border bg-card/90 p-1 text-xs shadow-sm"
|
||||
className="terminal-first-switcher flex items-center gap-1 rounded-full border border-border bg-card/90 p-1 text-xs shadow-sm"
|
||||
>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button
|
||||
@@ -2208,7 +2353,7 @@ function ConnectedTerminalFirstPill({
|
||||
aria-label="Chat"
|
||||
onClick={() => setView("chat")}
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center gap-1 rounded-full px-2 py-0.5 transition-colors",
|
||||
"terminal-first-switcher-option flex cursor-pointer items-center gap-1 rounded-full px-2 py-0.5 transition-colors",
|
||||
view === "chat"
|
||||
? "bg-muted text-foreground"
|
||||
: "text-muted-foreground hover:bg-muted/60 hover:text-foreground",
|
||||
@@ -2225,7 +2370,7 @@ function ConnectedTerminalFirstPill({
|
||||
title={terminalStartingUp ? "Terminal is starting up…" : undefined}
|
||||
onClick={() => setView("terminal")}
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center gap-1 rounded-full px-2 py-0.5 transition-colors disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"terminal-first-switcher-option flex cursor-pointer items-center gap-1 rounded-full px-2 py-0.5 transition-colors disabled:cursor-not-allowed disabled:opacity-50",
|
||||
view === "terminal"
|
||||
? "bg-muted text-foreground"
|
||||
: "text-muted-foreground hover:bg-muted/60 hover:text-foreground",
|
||||
@@ -2562,6 +2707,14 @@ interface ComposerProps {
|
||||
* turn is streaming (the follow-up placeholder wins).
|
||||
*/
|
||||
reconnectHint?: boolean;
|
||||
/**
|
||||
* The session is host-bound to a dormant resumable managed host that is
|
||||
* offline (`host_asleep`): the composer stays enabled, and the placeholder
|
||||
* tells the user their next message will resume the sandbox host (which can
|
||||
* take a few minutes) so the wake latency is expected, not surprising.
|
||||
* Ignored once a turn is streaming.
|
||||
*/
|
||||
sandboxAsleepHint?: boolean;
|
||||
/**
|
||||
* The session is unreachable (`host_offline` / `local_stranded`): a message
|
||||
* can't wake it. The composer is blocked (disabled) and the reconnect
|
||||
@@ -2929,6 +3082,7 @@ export function Composer({
|
||||
isTerminalFirst = false,
|
||||
isNativeWrapper = false,
|
||||
reconnectHint = false,
|
||||
sandboxAsleepHint = false,
|
||||
unreachable = false,
|
||||
costRoutingVerdict = null,
|
||||
costRoutingEligible = false,
|
||||
@@ -2992,13 +3146,27 @@ export function Composer({
|
||||
// the input, which would delete the draft. Only save when the user
|
||||
// has actually changed the value since the last restore.
|
||||
const dirtyRef = useRef(false);
|
||||
// On mobile, programmatic focus immediately summons the software keyboard.
|
||||
// Keep desktop's fast-type affordance, but let mobile users explicitly tap
|
||||
// the composer when switching back from Terminal or changing sessions.
|
||||
const [isMobile, setIsMobile] = useState(
|
||||
() => typeof window !== "undefined" && window.matchMedia("(max-width: 767px)").matches,
|
||||
);
|
||||
const isMobileRef = useRef(isMobile);
|
||||
isMobileRef.current = isMobile;
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia("(max-width: 767px)");
|
||||
const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches);
|
||||
mq.addEventListener("change", handler);
|
||||
return () => mq.removeEventListener("change", handler);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const restored = conversationId ? sessionDrafts.get(conversationId) : undefined;
|
||||
setValue(restored?.text ?? "");
|
||||
setFiles(restored?.files ?? []);
|
||||
dirtyRef.current = false;
|
||||
textareaRef.current?.focus();
|
||||
if (!isMobileRef.current) textareaRef.current?.focus();
|
||||
|
||||
return () => {
|
||||
if (!conversationId || !dirtyRef.current) return;
|
||||
@@ -3018,7 +3186,7 @@ export function Composer({
|
||||
// focus when the count grows — removing a quote shouldn't steal focus.
|
||||
const prevQuoteCountRef = useRef(replyQuotes.length);
|
||||
useEffect(() => {
|
||||
if (replyQuotes.length > prevQuoteCountRef.current) {
|
||||
if (!isMobileRef.current && replyQuotes.length > prevQuoteCountRef.current) {
|
||||
textareaRef.current?.focus();
|
||||
}
|
||||
prevQuoteCountRef.current = replyQuotes.length;
|
||||
@@ -3224,20 +3392,6 @@ export function Composer({
|
||||
}
|
||||
};
|
||||
|
||||
// On mobile-sized viewports the on-screen keyboard has no easy way to
|
||||
// produce Shift+Enter, so Enter-to-send would lock users out of multi-line
|
||||
// composition entirely. Below Tailwind's `md` breakpoint, fall back to
|
||||
// native textarea behavior (Enter = newline) and require tapping Send.
|
||||
const [isMobile, setIsMobile] = useState(
|
||||
() => typeof window !== "undefined" && window.matchMedia("(max-width: 767px)").matches,
|
||||
);
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia("(max-width: 767px)");
|
||||
const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches);
|
||||
mq.addEventListener("change", handler);
|
||||
return () => mq.removeEventListener("change", handler);
|
||||
}, []);
|
||||
|
||||
// Auto-grow the textarea from 1 row up to 10 rows, then let it scroll.
|
||||
useAutoGrowTextarea(textareaRef, value);
|
||||
|
||||
@@ -3489,7 +3643,10 @@ export function Composer({
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className={cn("px-4 md:px-6", isTerminalFirst ? "pb-1.5" : "pb-3")}
|
||||
className={cn(
|
||||
"chat-composer-form px-4 md:px-6",
|
||||
isTerminalFirst ? "terminal-first-composer-form pb-1.5" : "pb-3",
|
||||
)}
|
||||
>
|
||||
{/* Hidden file input for the attach button */}
|
||||
<input
|
||||
@@ -3633,9 +3790,11 @@ export function Composer({
|
||||
? "Waiting for agents…"
|
||||
: isStreaming
|
||||
? "Send a follow-up (queued) — Esc to stop"
|
||||
: reconnectHint
|
||||
? "Send a message to reconnect this session"
|
||||
: "Ask the agent anything…"
|
||||
: sandboxAsleepHint
|
||||
? "Current session's host is offline. Next message will resume the sandbox host which can take minutes"
|
||||
: reconnectHint
|
||||
? "Send a message to reconnect this session"
|
||||
: "Ask the agent anything…"
|
||||
}
|
||||
rows={1}
|
||||
disabled={disabled || isReadOnly || unreachable || hasPendingElicitation}
|
||||
|
||||
@@ -327,6 +327,7 @@ export function InboxPage() {
|
||||
exitPlanMode={item.elicitation.exitPlanMode}
|
||||
codexCommand={item.elicitation.codexCommand}
|
||||
allowAllEdits={item.elicitation.allowAllEdits}
|
||||
rememberScope={item.elicitation.rememberScope}
|
||||
onSubmit={makeSubmit(item)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { AgentInfoContent, agentHasInfo } from "@/components/AgentInfo";
|
||||
import { useIdleNotifications } from "@/hooks/useIdleNotifications";
|
||||
import { readFilesPanelPreferences, writeFilesPanelPreferences } from "@/lib/filesPanelPreferences";
|
||||
import { derivePermissionLevel, isOwnerLevel } from "@/lib/permissionsApi";
|
||||
import { isMacElectronShell } from "@/lib/nativeBridge";
|
||||
import { isIOSShell, isMacElectronShell, onNativeSidebarDrag } from "@/lib/nativeBridge";
|
||||
import { readSessionWorkspaceState, writeSessionWorkspaceState } from "@/lib/sessionWorkspaceState";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -49,7 +49,7 @@ import { FileViewerContext } from "./FileViewerContext";
|
||||
import { FilesPanelDrawer } from "./FilesPanelDrawer";
|
||||
import type { ChangedSort } from "./FlatFileList";
|
||||
import { MobilePanelDrawer } from "./MobilePanelDrawer";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
import { isMobileViewport, Sidebar } from "./Sidebar";
|
||||
import { TitleBarServerPicker } from "./TitleBarServerPicker";
|
||||
import { SubagentsPanel } from "./SubagentsPanel";
|
||||
import { useRootSessionId, useSession } from "@/hooks/useSession";
|
||||
@@ -126,6 +126,27 @@ export function AppShell() {
|
||||
useResizableInlinePanel(conversationId ?? null, inlinePanelMinWidth);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [sidebarOpen, setSidebarOpen] = useState(initialSidebarOpen);
|
||||
// Live open fraction (0→1) while the iOS edge-swipe drags the sidebar; null
|
||||
// when not dragging. Drives the mobile overlay's finger-tracking transform.
|
||||
const [sidebarDragProgress, setSidebarDragProgress] = useState<number | null>(null);
|
||||
// The iOS shell repurposes the left-edge swipe (normally back-navigation) to
|
||||
// drive the sidebar as an interactive drawer, streaming it over the native
|
||||
// bridge. begin/move track the finger (mobile overlay only — the desktop
|
||||
// width-based sidebar can't be partially slid, so it just settles); open/close
|
||||
// are the settle decision on release. No-op outside the iOS shell.
|
||||
useEffect(
|
||||
() =>
|
||||
onNativeSidebarDrag((phase, progress) => {
|
||||
if (phase === "open" || phase === "close") {
|
||||
setSidebarDragProgress(null);
|
||||
setSidebarOpen(phase === "open");
|
||||
return;
|
||||
}
|
||||
if (!isMobileViewport()) return;
|
||||
setSidebarDragProgress(progress);
|
||||
}),
|
||||
[],
|
||||
);
|
||||
const [selectedFilePath, setSelectedFilePath] = useState<string | null>(() =>
|
||||
conversationId ? (readSessionWorkspaceState(conversationId).selectedFilePath ?? null) : null,
|
||||
);
|
||||
@@ -946,6 +967,7 @@ export function AppShell() {
|
||||
<div
|
||||
className="app-shell relative flex h-dvh bg-sidebar text-foreground"
|
||||
data-electron-mac={isMacElectronShell() ? "true" : undefined}
|
||||
data-ios-native={isIOSShell() ? "true" : undefined}
|
||||
>
|
||||
{/* Frameless-window titlebar stand-in (macOS Electron only): the
|
||||
sidebar's electron top margin (see index.css) frees this strip of
|
||||
@@ -958,7 +980,11 @@ export function AppShell() {
|
||||
{isMacElectronShell() && (
|
||||
<TitleBarServerPicker threadTitle={activeSession?.title ?? activeConv?.title} />
|
||||
)}
|
||||
<Sidebar open={sidebarOpen} onClose={() => setSidebarOpen(false)} />
|
||||
<Sidebar
|
||||
open={sidebarOpen}
|
||||
dragProgress={sidebarDragProgress}
|
||||
onClose={() => setSidebarOpen(false)}
|
||||
/>
|
||||
|
||||
{/* Content region (everything right of the sidebar): a relative
|
||||
flex row holding the chat+workspace group and the push panels
|
||||
|
||||
@@ -171,7 +171,7 @@ export function ChatHeader({
|
||||
// Scrolled chat text can't render through the controls because the
|
||||
// conversation viewport fades its top edge instead (chat-scroll-fade
|
||||
// in index.css, applied in ChatPage).
|
||||
"absolute inset-x-0 top-0 z-30 flex h-14 items-center justify-between px-2 py-3",
|
||||
"chat-header absolute inset-x-0 top-0 z-30 flex h-14 items-center justify-between px-2 py-3",
|
||||
)}
|
||||
>
|
||||
{/* Left slot: sidebar toggle (when sidebar is closed) and a
|
||||
|
||||
@@ -14,9 +14,11 @@
|
||||
// shells are enumerated and created in the rail's Shells tab.
|
||||
|
||||
import { TerminalIcon, XIcon } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { CSSProperties } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { TerminalView } from "@/components/blocks/TerminalView";
|
||||
import { AGENT_TERMINAL_IDS, terminalTabKey, useTerminals } from "@/hooks/useTerminals";
|
||||
import { useIOSNativeKeyboardInset } from "@/hooks/useIOSNativeKeyboardInset";
|
||||
import { useTerminalFirst } from "./TerminalFirstContext";
|
||||
import { TerminalStatusBadge } from "./terminalStatus";
|
||||
import { useTerminalStatuses } from "./useTerminalStatuses";
|
||||
@@ -40,12 +42,18 @@ interface MainTerminalViewProps {
|
||||
* instead. Default false (owner / single-user).
|
||||
*/
|
||||
readOnly?: boolean;
|
||||
/**
|
||||
* Exposes the outer terminal surface so the iOS native shell can show its
|
||||
* server switcher only while this surface is actually frontmost.
|
||||
*/
|
||||
onSurfaceElement?: (element: HTMLElement | null) => void;
|
||||
}
|
||||
|
||||
export function MainTerminalView({
|
||||
conversationId,
|
||||
initialTerminalKey,
|
||||
readOnly = false,
|
||||
onSurfaceElement,
|
||||
}: MainTerminalViewProps) {
|
||||
const { terminals } = useTerminals(conversationId);
|
||||
const terminalFirstCtx = useTerminalFirst();
|
||||
@@ -63,6 +71,9 @@ export function MainTerminalView({
|
||||
const [activeKey, setActiveKey] = useState(initialTerminalKey || "");
|
||||
const { getStatus, setTerminalConnectionState, markTerminalActive } =
|
||||
useTerminalStatuses(terminals);
|
||||
const keyboardInset = useIOSNativeKeyboardInset();
|
||||
const containerStyle: CSSProperties | undefined =
|
||||
keyboardInset > 0 ? { paddingBottom: `calc(0.375rem + ${keyboardInset}px)` } : undefined;
|
||||
|
||||
// Honor a retarget while already open (a rail shell click can point
|
||||
// an open view at a different terminal); the validity effect below
|
||||
@@ -94,19 +105,28 @@ export function MainTerminalView({
|
||||
(terminalFirstCtx?.isTerminalFirst ?? false) &&
|
||||
activeTerminal !== null &&
|
||||
!AGENT_TERMINAL_IDS.has(activeTerminal.id);
|
||||
const setSurfaceElement = useCallback(
|
||||
(element: HTMLDivElement | null) => {
|
||||
onSurfaceElement?.(element);
|
||||
},
|
||||
[onSurfaceElement],
|
||||
);
|
||||
|
||||
return (
|
||||
// Outer wrapper fills the main column. `pt-16` clears the
|
||||
// absolute-positioned AppShell header; `px-3` gives a 12px gutter on
|
||||
// absolute-positioned AppShell header on desktop; iOS native gets a
|
||||
// safe-area-aware override in index.css. `px-3` gives a 12px gutter on
|
||||
// the sides. The card stretches to full width and height of the
|
||||
// available area. The ConnectionIndicator pill renders just below
|
||||
// this wrapper in ChatPage's MainAgentSurface.
|
||||
<div
|
||||
ref={setSurfaceElement}
|
||||
data-testid="main-terminal-view"
|
||||
// Exposed for e2e assertions that an expand targeted the right
|
||||
// terminal (not just that the view opened).
|
||||
data-active-terminal={activeKey}
|
||||
className="flex min-h-0 flex-1 flex-col px-3 pt-16 pb-1.5"
|
||||
className="main-terminal-view flex min-h-0 flex-1 flex-col px-3 pt-16 pb-1.5"
|
||||
style={containerStyle}
|
||||
>
|
||||
<div className="flex min-h-0 w-full flex-1 flex-col overflow-hidden rounded-lg border border-border bg-card p-3 shadow-sm">
|
||||
{terminals.length === 0 ? (
|
||||
|
||||
@@ -36,8 +36,10 @@ import "@tiptap/markdown";
|
||||
// Type-only import: activates @tiptap/extension-table's TypeScript module
|
||||
// augmentation so editor.chain() includes table commands (insertTable, etc.)
|
||||
// without pulling the full extension into the runtime bundle.
|
||||
// eslint-disable-next-line import/no-empty-named-blocks -- deliberate type-only augmentation trigger, not a stray empty import
|
||||
import type {} from "@tiptap/extension-table";
|
||||
// Same trick for the list package's command augmentation (toggleTaskList).
|
||||
// eslint-disable-next-line import/no-empty-named-blocks -- deliberate type-only augmentation trigger, not a stray empty import
|
||||
import type {} from "@tiptap/extension-list";
|
||||
import { TableMap, cellAround, colCount, findTable, isInTable } from "@tiptap/pm/tables";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -275,6 +275,65 @@ describe("MarkdownRichTextViewer dirty banners", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── Link following ───────────────────────────────────────────────────────────
|
||||
|
||||
describe("MarkdownRichTextViewer link following", () => {
|
||||
const HREF = "https://omnigent.ai/docs/build/harnesses";
|
||||
|
||||
// The TipTap editor (and the links it renders, incl. those in table cells)
|
||||
// is mocked to null here, so inject an anchor into the scroll container to
|
||||
// exercise the container's click handler directly.
|
||||
function clickLink(
|
||||
container: HTMLElement,
|
||||
eventInit: Parameters<typeof fireEvent.click>[1] = {},
|
||||
) {
|
||||
const scroll = container.querySelector(".overflow-auto");
|
||||
if (!scroll) throw new Error("scroll container not found");
|
||||
const anchor = document.createElement("a");
|
||||
anchor.setAttribute("href", HREF);
|
||||
scroll.appendChild(anchor);
|
||||
fireEvent.click(anchor, eventInit);
|
||||
}
|
||||
|
||||
it("opens a link in a new tab on a plain click in read-only mode", () => {
|
||||
const open = vi.fn();
|
||||
vi.stubGlobal("open", open);
|
||||
setupReadOnlyHooks();
|
||||
const { container } = renderViewer("[harnesses](" + HREF + ")");
|
||||
|
||||
clickLink(container);
|
||||
|
||||
// Read-only: nothing to edit, so any link click should follow.
|
||||
expect(open).toHaveBeenCalledWith(HREF, "_blank", "noopener,noreferrer");
|
||||
});
|
||||
|
||||
it("does NOT follow a link on a plain click in edit mode (click places the cursor)", () => {
|
||||
const open = vi.fn();
|
||||
vi.stubGlobal("open", open);
|
||||
setupEditHooks();
|
||||
const { container } = renderViewer("[harnesses](" + HREF + ")");
|
||||
|
||||
clickLink(container);
|
||||
|
||||
// Edit mode: a bare click must position the cursor, not navigate away.
|
||||
expect(open).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("follows a link on ⌘/Ctrl+click in edit mode (escape hatch)", () => {
|
||||
const open = vi.fn();
|
||||
vi.stubGlobal("open", open);
|
||||
setupEditHooks();
|
||||
const { container } = renderViewer("[harnesses](" + HREF + ")");
|
||||
|
||||
clickLink(container, { metaKey: true });
|
||||
expect(open).toHaveBeenCalledWith(HREF, "_blank", "noopener,noreferrer");
|
||||
|
||||
open.mockClear();
|
||||
clickLink(container, { ctrlKey: true });
|
||||
expect(open).toHaveBeenCalledWith(HREF, "_blank", "noopener,noreferrer");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Truncated-file guard ─────────────────────────────────────────────────────
|
||||
|
||||
describe("MarkdownRichTextViewer truncated guard", () => {
|
||||
|
||||
@@ -398,17 +398,19 @@ function MarkdownRichTextViewerInner({
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className="relative flex-1 overflow-auto px-8 py-6"
|
||||
onClick={
|
||||
!canEdit
|
||||
? (e) => {
|
||||
const anchor = (e.target as Element).closest("a[href]");
|
||||
if (anchor) {
|
||||
e.preventDefault();
|
||||
window.open(anchor.getAttribute("href")!, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
// Link following. The Link extension runs with openOnClick:false so a
|
||||
// plain click in edit mode positions the cursor instead of navigating.
|
||||
// Read-only: any click on a link opens it. Edit mode: only a
|
||||
// modifier-click (⌘/Ctrl) opens it, so plain-click-to-edit is preserved
|
||||
// while still giving an escape hatch to follow links (incl. in tables).
|
||||
onClick={(e) => {
|
||||
if (canEdit && !e.metaKey && !e.ctrlKey) return;
|
||||
const anchor = (e.target as Element).closest("a[href]");
|
||||
if (anchor) {
|
||||
e.preventDefault();
|
||||
window.open(anchor.getAttribute("href")!, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
}}
|
||||
>
|
||||
{!canEdit && (
|
||||
<button
|
||||
|
||||
@@ -58,6 +58,7 @@ import { useRecentWorkspaces } from "@/hooks/useRecentWorkspaces";
|
||||
import { useDirectorySessions } from "@/hooks/useDirectorySessions";
|
||||
import { useRunnerHealthRegistration } from "@/hooks/RunnerHealthProvider";
|
||||
import { useHostFilesystem, type HostFilesystemEntry } from "@/hooks/useHostFilesystem";
|
||||
import { useNativeServerSwitcherForMainSurface } from "@/hooks/useNativeServerSwitcher";
|
||||
import type { Conversation } from "@/hooks/useConversations";
|
||||
import { OttoEyes } from "@/components/OttoEyes";
|
||||
import { SkillPills } from "@/components/SkillPills";
|
||||
@@ -715,6 +716,12 @@ export function NewChatLandingScreen() {
|
||||
[agentList],
|
||||
);
|
||||
|
||||
// Surface element backing the iOS native server switcher overlay, which
|
||||
// the in-session view shows too — the picker stays reachable while starting
|
||||
// a new session. The hook hides it whenever the sidebar covers the surface.
|
||||
const [landingSurface, setLandingSurface] = useState<HTMLElement | null>(null);
|
||||
useNativeServerSwitcherForMainSurface(landingSurface, true);
|
||||
|
||||
const [message, setMessage] = useState<string>("");
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const isComposingRef = useRef(false);
|
||||
@@ -1259,7 +1266,11 @@ export function NewChatLandingScreen() {
|
||||
return (
|
||||
// pb-12 lifts the content slightly above the geometric center, where
|
||||
// the hero reads better optically.
|
||||
<div className="flex flex-1 items-center justify-center" data-testid="new-chat-landing">
|
||||
<div
|
||||
ref={setLandingSurface}
|
||||
className="flex flex-1 items-center justify-center"
|
||||
data-testid="new-chat-landing"
|
||||
>
|
||||
{/* Padding lives inside the 840px cap, so the composer renders at
|
||||
840 − 80 = 760px max. */}
|
||||
<div className="flex w-full max-w-[840px] flex-col items-center gap-8 px-10 pt-8 pb-16">
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user