Compare commits
134 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6f0590560c | |||
| b20f6ce33b | |||
| 29ab2b61cc | |||
| 2fdd521e40 | |||
| c468b29fb7 | |||
| 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 | |||
| 9c556ed617 | |||
| 299631cb26 | |||
| 9d8ed041dd | |||
| c152857d26 | |||
| 833d3be242 | |||
| 9b90d1b9ad | |||
| ee72e7f7bc | |||
| 166f00589c | |||
| d34ab45c05 | |||
| 147043b05a | |||
| 87e7cdd133 | |||
| 09c9619a4c | |||
| bc6b84a995 | |||
| 3613002896 | |||
| a105029010 | |||
| ea606803fc | |||
| 1e4307fece | |||
| 1ecc870e2f | |||
| eeac55a5b8 | |||
| 666db30640 | |||
| 2fa148fcd6 | |||
| 93f229e278 | |||
| b5d6a9dabb | |||
| 72ef89b0d6 | |||
| 090c4e28da | |||
| 80e3b1e685 | |||
| 42d7a3244b | |||
| 490beccc46 | |||
| 89fffcce98 | |||
| de14589b1b | |||
| fd7aa5fdef | |||
| 3a37607913 | |||
| 2d695845c7 | |||
| 948562f36a | |||
| 3f8e035f12 | |||
| 5a0b0c9909 | |||
| b0c2dc30c2 | |||
| c975f62901 | |||
| 63c30ad7c7 | |||
| 7473a6060d | |||
| fc9e276d80 | |||
| 25497559bc | |||
| 62a5e6e033 | |||
| ed9f5525bf | |||
| 997ed7fe55 | |||
| 791eb72f71 | |||
| f2f2a42ba6 | |||
| 9451ae5697 | |||
| db8a1322f3 | |||
| a464e9adf9 | |||
| ad07fb6189 | |||
| d086a80eb6 | |||
| 6156e392b7 | |||
| 300901637a | |||
| b8cd7c6df1 | |||
| c6a9bec25b | |||
| fba2dc153b | |||
| 8ca02a5ba0 | |||
| 57431e6c5e | |||
| c080ecd2b8 | |||
| e1da61159f | |||
| 07ebf9e38d | |||
| ac7967287f | |||
| bef2f259c6 | |||
| d1e0388468 | |||
| 39db39b660 | |||
| ed83ed31e7 | |||
| 60834d2700 | |||
| cdcfd2e82e | |||
| 2703561310 | |||
| 29d86cf0bb | |||
| 70a4c87833 | |||
| 70d916dd52 | |||
| 44a48c388d | |||
| b136b48dc5 | |||
| 73c4a894f4 | |||
| 4d38ebcdb5 | |||
| 5a40acc12e | |||
| 1172cbde62 |
@@ -12,10 +12,10 @@
|
||||
# Used by the issue triage workflow for P0/P1 auto-assignment.
|
||||
bbqiu server,runner,harnesses,repr
|
||||
daniellok-db server,runner,harnesses,web-ui
|
||||
dhruv0811 server,runner,harnesses,repr,infra
|
||||
fanzeyi server,runner,harnesses,repr
|
||||
dhruv0811 server,runner,harnesses,repr,infra,tui
|
||||
fanzeyi server,runner,harnesses,repr,tui
|
||||
PattaraS server,runner,harnesses,infra
|
||||
SabhyaC26 server,runner,harnesses,repr
|
||||
TomeHirata server,runner,harnesses,policies,infra
|
||||
SabhyaC26 server,runner,harnesses,repr,tui
|
||||
TomeHirata server,runner,harnesses,policies,infra,tui
|
||||
serena-ruan server,runner,harnesses,web-ui,infra
|
||||
hzub web-ui
|
||||
|
||||
@@ -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
|
||||
@@ -2,9 +2,10 @@
|
||||
"name": "e2e-ci-deps",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"description": "Pinned npm CLIs the e2e workflow installs (claude-code, codex).",
|
||||
"description": "Pinned npm CLIs the e2e workflow installs (claude-code, codex, pi).",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-code": "2.1.124",
|
||||
"@earendil-works/pi-coding-agent": "0.75.5",
|
||||
"@openai/codex": "0.139.0"
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
# reviewers. All assignment is driven by .github/workflows/auto-assign-reviewer.yml,
|
||||
# which:
|
||||
# - runs ONLY on fork PRs authored by a non-maintainer, and
|
||||
# - assigns EXACTLY 2 load-balanced reviewers from the area(s) the PR touches
|
||||
# - assigns EXACTLY 1 load-balanced reviewer from the area(s) the PR touches
|
||||
# (falling back to the full set of handles in this file for unowned paths).
|
||||
# So the per-area lists below are the CANDIDATE pool per area, not "everyone gets
|
||||
# requested". This is routing only -- it does not gate merge (that stays
|
||||
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
#!/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. 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.]*)?$ ]]
|
||||
}
|
||||
|
||||
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.
|
||||
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
|
||||
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
|
||||
@@ -10,18 +10,11 @@
|
||||
# `if:` skip of a matrixed job would instead leave one check-run with an
|
||||
# unexpanded `Integration (${{ matrix.name }})` name.
|
||||
#
|
||||
# One leg per wrapped harness, no pytest-shard splitting: the journey suite is
|
||||
# a handful of tests per leg. The `Integration (...)` leg-name prefix is load-
|
||||
# bearing -- nightly.yml's notify jq filter keys on it.
|
||||
#
|
||||
# Model pinning rationale:
|
||||
# - claude-sdk on sonnet-4-6: tier 4, most TPM headroom.
|
||||
# - codex on gpt-5-5: gpt-5-4-mini hit 429s historically; also halve its
|
||||
# workers (least rate-limit headroom; burn-in failures were codex-only,
|
||||
# clustered at peak PR traffic).
|
||||
# - openai-agents on gpt-5-4-mini: green there historically.
|
||||
# OMNIGENT_TEST_MODEL_SPREAD in the workflow may rebalance within the same
|
||||
# provider/tier pool (tests/_model_pools.py).
|
||||
# Single openai-agents leg: all tests now run against the mock LLM server.
|
||||
# claude-sdk and codex reject "mock-model" as an unknown model (they validate
|
||||
# against the Databricks model catalog even when mock_llm_base_url is set), so
|
||||
# only openai-agents works without real credentials. The model name is unused
|
||||
# in mock mode (model_name fixture returns "mock-model" regardless).
|
||||
#
|
||||
# Env in: EVENT_NAME (github.event_name), IS_DRAFT, IS_FORK (both may be empty
|
||||
# on non-PR events).
|
||||
@@ -46,9 +39,7 @@ fi
|
||||
|
||||
read -r -d '' matrix <<'JSON' || true
|
||||
{"include":[
|
||||
{"name":"claude-sdk","harness":"claude-sdk","model":"databricks-claude-sonnet-4-6","workers":4},
|
||||
{"name":"openai-agents","harness":"openai-agents","model":"databricks-gpt-5-4-mini","workers":4},
|
||||
{"name":"codex","harness":"codex","model":"databricks-gpt-5-5","workers":2}
|
||||
{"name":"openai-agents","harness":"openai-agents","model":"databricks-gpt-5-4-mini","workers":4}
|
||||
]}
|
||||
JSON
|
||||
# Collapse to one line so the GITHUB_OUTPUT key=value contract holds.
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
#
|
||||
# The gate is green iff every required check is green on its own merits
|
||||
# AND (for fork PRs) a maintainer has approved. There is no CI bypass: to
|
||||
# land despite red required checks, quarantine the flaky test
|
||||
# (tests/known_failures.yaml) or have a repo admin use GitHub's native
|
||||
# "merge without waiting for requirements" affordance.
|
||||
# land despite red required checks, fix or delete the failing test, or
|
||||
# have a repo admin use GitHub's native "merge without waiting for
|
||||
# requirements" affordance.
|
||||
#
|
||||
# CI eval | fork approval | state | meaning
|
||||
# ---------+---------------+----------+---------------------------------
|
||||
|
||||
@@ -32,7 +32,7 @@ prompt: |
|
||||
```
|
||||
{
|
||||
"type": "bug" | "enhancement" | "documentation" | null,
|
||||
"components": ["comp:server" | "comp:runner" | "comp:repr" | "comp:web-ui" | "comp:policies" | "comp:harnesses" | "comp:infra"],
|
||||
"components": ["comp:server" | "comp:runner" | "comp:repr" | "comp:web-ui" | "comp:tui" | "comp:policies" | "comp:harnesses" | "comp:infra"],
|
||||
"priority": "P0-critical" | "P1-high" | "P2-medium" | "P3-low" | null,
|
||||
"needs_info": true | false,
|
||||
"help_wanted": true | false,
|
||||
@@ -58,6 +58,7 @@ prompt: |
|
||||
- `comp:runner` — the agent runner, execution engine
|
||||
- `comp:repr` — serialization, representation layer
|
||||
- `comp:web-ui` — the web frontend (ap-web)
|
||||
- `comp:tui` — the terminal UI, REPL, and CLI
|
||||
- `comp:policies` — safety policies, guardrails
|
||||
- `comp:harnesses` — SDK harnesses (Claude, Cursor, Antigravity, etc.)
|
||||
- `comp:infra` — CI/CD, GitHub Actions workflows, Docker, deployment, packaging
|
||||
@@ -66,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.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Repo-level reviewer assignment: assign EXACTLY 2 load-balanced reviewers to
|
||||
// Repo-level reviewer assignment: assign EXACTLY 1 load-balanced reviewer to
|
||||
// FORK PRs authored by a NON-maintainer, preferring the owners of the area(s)
|
||||
// the PR touches.
|
||||
//
|
||||
@@ -21,7 +21,7 @@
|
||||
// so a manually-added reviewer outside that set is left untouched.
|
||||
module.exports = async ({ github, context, core }) => {
|
||||
const fs = require("fs");
|
||||
const TARGET = 2;
|
||||
const TARGET = 1;
|
||||
const { owner, repo } = context.repo;
|
||||
const pr = context.payload.pull_request;
|
||||
if (!pr || pr.draft) {
|
||||
@@ -130,8 +130,8 @@ module.exports = async ({ github, context, core }) => {
|
||||
return out;
|
||||
};
|
||||
|
||||
// Desired = 2 lowest-load from candidates; top up from the full pool if an
|
||||
// area has fewer than 2 owners.
|
||||
// Desired = 1 lowest-load from candidates; top up from the full pool if an
|
||||
// area has fewer than 1 owner.
|
||||
let desired = takeLowest(candidates, TARGET);
|
||||
if (desired.length < TARGET) {
|
||||
const have = new Set(desired.map((u) => u.toLowerCase()).concat(author));
|
||||
@@ -142,7 +142,7 @@ module.exports = async ({ github, context, core }) => {
|
||||
|
||||
// --- Reconcile current requested reviewers to exactly `desired`. Normally
|
||||
// nothing is pre-requested, but on a reopened PR (or after a manual add) this
|
||||
// keeps the set at the 2 balanced picks.
|
||||
// keeps the set at the 1 balanced pick.
|
||||
const current = (pr.requested_reviewers || []).map((r) => r.login);
|
||||
const currentLc = new Set(current.map((c) => c.toLowerCase()));
|
||||
const toAdd = desired.filter((u) => !currentLc.has(u.toLowerCase()));
|
||||
@@ -162,8 +162,30 @@ module.exports = async ({ github, context, core }) => {
|
||||
owner, repo, pull_number: pr.number, reviewers: toRemove,
|
||||
});
|
||||
}
|
||||
|
||||
// --- Also sync assignees to mirror the desired reviewer set so PRs are
|
||||
// filterable by assignee in the GitHub UI.
|
||||
const currentAssignees = (pr.assignees || []).map((a) => a.login);
|
||||
const currentAssigneesLc = new Set(currentAssignees.map((a) => a.toLowerCase()));
|
||||
const toAddAssignees = desired.filter((u) => !currentAssigneesLc.has(u.toLowerCase()));
|
||||
const toRemoveAssignees = currentAssignees.filter(
|
||||
(u) => managed.has(u.toLowerCase()) && !desiredLc.has(u.toLowerCase())
|
||||
);
|
||||
|
||||
if (toAddAssignees.length) {
|
||||
await github.rest.issues.addAssignees({
|
||||
owner, repo, issue_number: pr.number, assignees: toAddAssignees,
|
||||
});
|
||||
}
|
||||
if (toRemoveAssignees.length) {
|
||||
await github.rest.issues.removeAssignees({
|
||||
owner, repo, issue_number: pr.number, assignees: toRemoveAssignees,
|
||||
});
|
||||
}
|
||||
|
||||
core.info(
|
||||
`Reviewers -> [${desired.join(", ")}]` +
|
||||
` (area pool ${areaOwners.size || "∅→full"}, +${toAdd.length}/-${toRemove.length}).`
|
||||
` (area pool ${areaOwners.size || "∅→full"}, +${toAdd.length}/-${toRemove.length})` +
|
||||
` | Assignees +${toAddAssignees.length}/-${toRemoveAssignees.length}.`
|
||||
);
|
||||
};
|
||||
|
||||
@@ -15,19 +15,25 @@ function mkOpenPRs(loadMap) {
|
||||
|
||||
// author defaults to a non-maintainer; fork defaults to true -- so the scope
|
||||
// guard passes and the selection logic runs (the cases that assert on picks).
|
||||
async function run({ files, load = {}, current = [], author = "someexternaldev", fork = true }) {
|
||||
async function run({ files, load = {}, current = [], currentAssignees = [], author = "someexternaldev", fork = true }) {
|
||||
const listFiles = () => {}; listFiles._tag = "files";
|
||||
const list = () => {}; list._tag = "open";
|
||||
const added = [], removed = [];
|
||||
const added = [], removed = [], assigned = [], unassigned = [];
|
||||
const github = {
|
||||
paginate: async (fn) => (fn._tag === "files"
|
||||
? files.map((f) => ({ filename: f }))
|
||||
: mkOpenPRs(load)),
|
||||
rest: { pulls: {
|
||||
listFiles, list,
|
||||
requestReviewers: async ({ reviewers }) => added.push(...reviewers),
|
||||
removeRequestedReviewers: async ({ reviewers }) => removed.push(...reviewers),
|
||||
} },
|
||||
rest: {
|
||||
pulls: {
|
||||
listFiles, list,
|
||||
requestReviewers: async ({ reviewers }) => added.push(...reviewers),
|
||||
removeRequestedReviewers: async ({ reviewers }) => removed.push(...reviewers),
|
||||
},
|
||||
issues: {
|
||||
addAssignees: async ({ assignees }) => assigned.push(...assignees),
|
||||
removeAssignees: async ({ assignees }) => unassigned.push(...assignees),
|
||||
},
|
||||
},
|
||||
};
|
||||
const context = {
|
||||
repo: { owner: "omnigent-ai", repo: "omnigent" },
|
||||
@@ -38,11 +44,12 @@ async function run({ files, load = {}, current = [], author = "someexternaldev",
|
||||
head: { repo: { full_name: fork ? "external-contributor/omnigent" : "omnigent-ai/omnigent" } },
|
||||
base: { repo: { full_name: "omnigent-ai/omnigent" } },
|
||||
requested_reviewers: current.map((l) => ({ login: l })),
|
||||
assignees: currentAssignees.map((l) => ({ login: l })),
|
||||
} },
|
||||
};
|
||||
const core = { info: () => {}, warning: (m) => console.log("WARN", m) };
|
||||
await script({ github, context, core });
|
||||
return { added: added.sort(), removed: removed.sort() };
|
||||
return { added: added.sort(), removed: removed.sort(), assigned: assigned.sort(), unassigned: unassigned.sort() };
|
||||
}
|
||||
|
||||
function assert(name, cond, detail) {
|
||||
@@ -52,35 +59,40 @@ function assert(name, cond, detail) {
|
||||
|
||||
(async () => {
|
||||
// 1. inner PR: owners SabhyaC26,TomeHirata,dhruv0811,dbczumar. Loads make the
|
||||
// two lowest deterministic: dhruv0811(0), dbczumar(1) win.
|
||||
// single lowest deterministic: dhruv0811(0) wins.
|
||||
let r = await run({
|
||||
files: ["omnigent/inner/foo.py"],
|
||||
load: { SabhyaC26: 5, TomeHirata: 4, dhruv0811: 0, dbczumar: 1 },
|
||||
});
|
||||
assert("inner picks 2 lowest-load owners", JSON.stringify(r.added) === JSON.stringify(["dbczumar", "dhruv0811"]), JSON.stringify(r));
|
||||
assert("inner picks the lowest-load owner", JSON.stringify(r.added) === JSON.stringify(["dhruv0811"]), JSON.stringify(r));
|
||||
assert("inner: reviewer also added as assignee", JSON.stringify(r.assigned) === JSON.stringify(["dhruv0811"]), JSON.stringify(r));
|
||||
|
||||
// 2. unowned path -> full pool; lowest two by load chosen.
|
||||
// 2. unowned path -> full pool; lowest by load chosen.
|
||||
r = await run({
|
||||
files: ["README.md"],
|
||||
load: { PattaraS: 9, "serena-ruan": 9, dhruv0811: 9, TomeHirata: 9, SabhyaC26: 9,
|
||||
"daniellok-db": 9, hzub: 0, dbczumar: 1, fanzeyi: 9, "ckcuslife-source": 9,
|
||||
bbqiu: 9, Edwinhe03: 9 },
|
||||
});
|
||||
assert("unowned -> 2 lowest from full pool", JSON.stringify(r.added) === JSON.stringify(["dbczumar", "hzub"]), JSON.stringify(r));
|
||||
assert("unowned -> lowest from full pool", JSON.stringify(r.added) === JSON.stringify(["hzub"]), JSON.stringify(r));
|
||||
|
||||
// 3. db has only 2 owners (fanzeyi, SabhyaC26) -> both selected.
|
||||
r = await run({ files: ["omnigent/db/x.py"], load: {} });
|
||||
assert("db (2 owners) -> both", JSON.stringify(r.added) === JSON.stringify(["SabhyaC26", "fanzeyi"]), JSON.stringify(r));
|
||||
// 3. db area (fanzeyi, SabhyaC26) -> the lower-load one selected.
|
||||
r = await run({ files: ["omnigent/db/x.py"], load: { SabhyaC26: 1 } });
|
||||
assert("db -> lowest-load owner", JSON.stringify(r.added) === JSON.stringify(["fanzeyi"]), JSON.stringify(r));
|
||||
|
||||
// 4. reconcile: all 4 inner owners already requested; keep 2 lowest-load,
|
||||
// remove the other 2.
|
||||
// 4. reconcile: all 4 inner owners already requested; keep the lowest-load,
|
||||
// remove the other 3.
|
||||
r = await run({
|
||||
files: ["omnigent/inner/foo.py"],
|
||||
load: { SabhyaC26: 5, TomeHirata: 4, dhruv0811: 0, dbczumar: 1 },
|
||||
current: ["SabhyaC26", "TomeHirata", "dhruv0811", "dbczumar"],
|
||||
currentAssignees: ["SabhyaC26", "TomeHirata", "dhruv0811", "dbczumar"],
|
||||
});
|
||||
assert("reconcile removes the 2 highest-load already-requested",
|
||||
JSON.stringify(r.removed) === JSON.stringify(["SabhyaC26", "TomeHirata"]) && r.added.length === 0,
|
||||
assert("reconcile removes the 3 higher-load already-requested",
|
||||
JSON.stringify(r.removed) === JSON.stringify(["SabhyaC26", "TomeHirata", "dbczumar"]) && r.added.length === 0,
|
||||
JSON.stringify(r));
|
||||
assert("reconcile: removes the 3 stale assignees, keeps dhruv0811",
|
||||
JSON.stringify(r.unassigned) === JSON.stringify(["SabhyaC26", "TomeHirata", "dbczumar"]) && r.assigned.length === 0,
|
||||
JSON.stringify(r));
|
||||
|
||||
// 5. mixed current: a managed reviewer not in `desired` is removed, while an
|
||||
@@ -89,30 +101,36 @@ function assert(name, cond, detail) {
|
||||
files: ["omnigent/inner/foo.py"],
|
||||
load: { dhruv0811: 0, dbczumar: 1, SabhyaC26: 5, TomeHirata: 4 },
|
||||
current: ["SabhyaC26", "some-external-human"],
|
||||
currentAssignees: ["SabhyaC26", "some-external-human"],
|
||||
});
|
||||
assert("mixed: managed removed, external preserved",
|
||||
r.removed.includes("SabhyaC26") &&
|
||||
!r.removed.includes("some-external-human") &&
|
||||
JSON.stringify(r.added) === JSON.stringify(["dbczumar", "dhruv0811"]),
|
||||
JSON.stringify(r.added) === JSON.stringify(["dhruv0811"]),
|
||||
JSON.stringify(r));
|
||||
assert("mixed: new reviewer assigned, stale managed assignee removed, external assignee preserved",
|
||||
JSON.stringify(r.assigned) === JSON.stringify(["dhruv0811"]) &&
|
||||
r.unassigned.includes("SabhyaC26") &&
|
||||
!r.unassigned.includes("some-external-human"),
|
||||
JSON.stringify(r));
|
||||
|
||||
// 6. single-owner area (sandbox -> @SabhyaC26): tops up to 2 from the pool.
|
||||
// 6. single-owner area (sandbox -> @SabhyaC26): the lone owner is selected.
|
||||
r = await run({
|
||||
files: ["omnigent/sandbox/x.py"],
|
||||
load: { SabhyaC26: 0, hzub: 0, dhruv0811: 9, dbczumar: 9, TomeHirata: 9, PattaraS: 9,
|
||||
"serena-ruan": 9, "daniellok-db": 9, fanzeyi: 9, "ckcuslife-source": 9, bbqiu: 9, Edwinhe03: 9 },
|
||||
});
|
||||
assert("single-owner area tops up to 2",
|
||||
r.added.length === 2 && r.added.includes("SabhyaC26"), JSON.stringify(r));
|
||||
assert("single-owner area picks that owner",
|
||||
JSON.stringify(r.added) === JSON.stringify(["SabhyaC26"]), JSON.stringify(r));
|
||||
|
||||
// 7. multi-area PR (inner + tools): candidate pool is the UNION; a tools-only
|
||||
// owner (PattaraS) and an inner owner (dhruv0811) can both be picked.
|
||||
// 7. multi-area PR (inner + tools): candidate pool is the UNION; the lowest-load
|
||||
// across both areas wins -- here a tools-only owner (PattaraS).
|
||||
r = await run({
|
||||
files: ["omnigent/inner/a.py", "omnigent/tools/b.py"],
|
||||
load: { SabhyaC26: 9, TomeHirata: 9, dbczumar: 9, PattaraS: 0, dhruv0811: 1 },
|
||||
});
|
||||
assert("multi-area unions both areas' owners",
|
||||
r.added.includes("PattaraS") && r.added.includes("dhruv0811") && r.added.length === 2,
|
||||
JSON.stringify(r.added) === JSON.stringify(["PattaraS"]),
|
||||
JSON.stringify(r));
|
||||
|
||||
// 8. scope guard: non-fork PR -> nothing assigned.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
name: Auto-assign Reviewer
|
||||
|
||||
# Repo-level reviewer assignment: assign EXACTLY 2 load-balanced reviewers to
|
||||
# Repo-level reviewer assignment: assign EXACTLY 1 load-balanced reviewer to
|
||||
# FORK PRs authored by a non-maintainer, preferring the owners of the area(s) the
|
||||
# PR touches. No org team required. Ownership is read from .github/reviewers at
|
||||
# runtime -- a custom, non-magic path (NOT .github/CODEOWNERS), so GitHub's
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
sparse-checkout: .github
|
||||
persist-credentials: false
|
||||
- name: Assign 2 balanced reviewers from the .github/reviewers pool
|
||||
- name: Assign 1 balanced reviewer from the .github/reviewers pool
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
retries: 3
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
name: Bump Version
|
||||
|
||||
# Bumps the project version across ALL lockstep locations in one PR:
|
||||
# the three pyproject.toml files (each package's [project].version plus
|
||||
# its sibling ==pins) and the regenerated uv.lock. Modeled on MLflow's
|
||||
# dev/update_mlflow_versions.py (pre-release / post-release), adapted to
|
||||
# this repo's three-package layout.
|
||||
#
|
||||
# scripts/update_versions.py does the deterministic text edits (anchored
|
||||
# on package name, so unrelated version literals are never touched);
|
||||
# this workflow wraps it with `uv lock`, a consistency check, and an
|
||||
# auto-opened PR.
|
||||
#
|
||||
# NOTE: the PR is created with GITHUB_TOKEN, so by GitHub policy it does
|
||||
# NOT trigger other workflows (CI won't auto-run on it). Push an empty
|
||||
# commit or re-open the PR to kick CI, or swap in a PAT if that matters.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
mode:
|
||||
description: "pre-release = stamp new_version exactly. post-release = set main to the next .dev0 after releasing new_version."
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- pre-release
|
||||
- post-release
|
||||
default: pre-release
|
||||
new_version:
|
||||
description: "Target version (pre-release) or just-released version (post-release), e.g. 0.1.2 or 0.1.2rc1"
|
||||
required: true
|
||||
base_branch:
|
||||
description: "Branch to base the bump PR on"
|
||||
required: false
|
||||
default: main
|
||||
|
||||
concurrency:
|
||||
group: bump-version-${{ github.event.inputs.new_version }}
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
bump:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
ref: ${{ github.event.inputs.base_branch }}
|
||||
fetch-depth: 0
|
||||
|
||||
- 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: Bump versions
|
||||
env:
|
||||
# Bind untrusted inputs to env and validate before use; never
|
||||
# interpolate ${{ }} into the shell (mirrors e2e.yml hardening).
|
||||
MODE: ${{ github.event.inputs.mode }}
|
||||
NEW_VERSION: ${{ github.event.inputs.new_version }}
|
||||
run: |
|
||||
case "$MODE" in
|
||||
pre-release|post-release) ;;
|
||||
*) echo "Invalid mode: $MODE" >&2; exit 1 ;;
|
||||
esac
|
||||
# Conservative PEP 440 shape: release, a/b/rc pre-release, or .devN/.postN.
|
||||
if ! [[ "$NEW_VERSION" =~ ^[0-9]+\.[0-9]+(\.[0-9]+)?((a|b|rc)[0-9]+)?(\.post[0-9]+)?(\.dev[0-9]+)?$ ]]; then
|
||||
echo "Invalid version: $NEW_VERSION" >&2; exit 1
|
||||
fi
|
||||
uv run --no-project --python 3.12 --with packaging \
|
||||
python scripts/update_versions.py "$MODE" --new-version "$NEW_VERSION"
|
||||
|
||||
- name: Regenerate lockfile
|
||||
run: uv lock
|
||||
|
||||
- name: Verify all locations agree
|
||||
run: uv run --no-project --python 3.12 --with packaging python scripts/update_versions.py check
|
||||
|
||||
- name: Open bump PR
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
MODE: ${{ github.event.inputs.mode }}
|
||||
NEW_VERSION: ${{ github.event.inputs.new_version }}
|
||||
BASE: ${{ github.event.inputs.base_branch }}
|
||||
run: |
|
||||
# The resolved version is what landed in the files (in post-release
|
||||
# mode it's the computed .dev0, not the input).
|
||||
resolved="$(uv run --no-project --python 3.12 --with packaging \
|
||||
python scripts/update_versions.py check)"
|
||||
branch="bot/bump-version-${resolved}"
|
||||
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git switch -c "$branch"
|
||||
git add -A
|
||||
if git diff --cached --quiet; then
|
||||
echo "::notice::No version changes to commit (already at ${resolved})."
|
||||
exit 0
|
||||
fi
|
||||
git commit -s -m "Bump version to ${resolved}"
|
||||
git push --force-with-lease origin "$branch"
|
||||
|
||||
existing="$(gh pr list --head "$branch" --base "$BASE" --json number --jq '.[0].number')"
|
||||
if [ -n "$existing" ]; then
|
||||
echo "::notice::PR #${existing} already open for ${branch}; pushed update."
|
||||
exit 0
|
||||
fi
|
||||
gh pr create \
|
||||
--base "$BASE" \
|
||||
--head "$branch" \
|
||||
--title "Bump version to ${resolved}" \
|
||||
--body "Automated version bump via \`.github/workflows/bump-version.yml\` (mode: \`${MODE}\`, input: \`${NEW_VERSION}\`).
|
||||
|
||||
Rewrote \`[project].version\` and sibling \`==\` pins across all three packages (\`pyproject.toml\`, \`sdks/python-client\`, \`sdks/ui\`) and regenerated \`uv.lock\`.
|
||||
|
||||
Generated by \`scripts/update_versions.py\`. CI does not auto-trigger on GITHUB_TOKEN PRs — re-open or push to run it."
|
||||
@@ -144,13 +144,11 @@ 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
|
||||
env:
|
||||
# force-all-tests label bypasses tests/known_failures.yaml.
|
||||
FORCE_ALL_TESTS: ${{ contains(github.event.pull_request.labels.*.name, 'force-all-tests') }}
|
||||
PYTHONFAULTHANDLER: "1"
|
||||
PYTEST_PROGRESS_LOG_DIR: artifacts/progress
|
||||
OMNIGENT_TOKEN_USAGE_JSON: artifacts/tokens-${{ matrix.group }}.json
|
||||
@@ -163,11 +161,6 @@ jobs:
|
||||
COVERAGE_CORE: sysmon
|
||||
run: |
|
||||
mkdir -p artifacts artifacts/progress "$(dirname "$COVERAGE_FILE")"
|
||||
EXTRA_ARGS=()
|
||||
if [[ "$FORCE_ALL_TESTS" == "true" ]]; then
|
||||
EXTRA_ARGS=(--no-skip-known)
|
||||
echo "::notice::force-all-tests label present; bypassing tests/known_failures.yaml"
|
||||
fi
|
||||
# matrix.paths is unquoted on purpose: it word-splits into pytest args.
|
||||
# shellcheck disable=SC2086
|
||||
env -u OPENAI_API_KEY -u ANTHROPIC_API_KEY -u DATABRICKS_TOKEN \
|
||||
@@ -178,7 +171,6 @@ jobs:
|
||||
--junitxml=artifacts/pytest-${{ matrix.group }}.xml \
|
||||
--cov=omnigent --cov-report= \
|
||||
-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: Stage coverage data for upload
|
||||
@@ -249,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: |
|
||||
|
||||
@@ -136,7 +136,7 @@ jobs:
|
||||
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
|
||||
@@ -258,20 +258,25 @@ jobs:
|
||||
# --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 flow into the spawned server via
|
||||
# the conftest's live_server fixture for the openai-agents harness.
|
||||
# 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.
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ env.LLM_API_KEY }}
|
||||
OPENAI_BASE_URL: ${{ secrets.GATEWAY_BASE_URL }}
|
||||
# Scheduled / manually dispatched runs are the full pass;
|
||||
# PR and push runs exclude @pytest.mark.nightly tests.
|
||||
NIGHTLY_FULL: ${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' }}
|
||||
SHARD_ID: ${{ matrix.shard_id }}
|
||||
NUM_SHARDS: ${{ matrix.num_shards }}
|
||||
run: |
|
||||
EXTRA_ARGS=()
|
||||
# Always exclude @visual: the UI diff snapshot runs in its own
|
||||
# pinned-runner gate (ui-snapshot.yml) so its baseline matches the
|
||||
# comparison environment; on this unpinned ubuntu-latest it would
|
||||
# flake on font drift. Add the nightly exclusion for PR/push runs.
|
||||
MARKER="not visual"
|
||||
if [[ "$NIGHTLY_FULL" != "true" ]]; then
|
||||
EXTRA_ARGS+=(-m "not nightly")
|
||||
MARKER="$MARKER and not nightly"
|
||||
fi
|
||||
# --splits/--group partition the suite via a strided slice (see
|
||||
# pytest_collection_modifyitems in tests/e2e_ui/conftest.py), which
|
||||
@@ -285,7 +290,7 @@ jobs:
|
||||
--tracing=retain-on-failure \
|
||||
--screenshot=only-on-failure \
|
||||
--video=retain-on-failure \
|
||||
"${EXTRA_ARGS[@]}" \
|
||||
-m "$MARKER" \
|
||||
|| { rc=$?; [ "$rc" -eq 5 ] && echo "::notice::No tests collected in this shard; treating as a pass." || exit "$rc"; }
|
||||
|
||||
- name: Upload Playwright traces / videos / screenshots on failure
|
||||
@@ -365,3 +370,40 @@ jobs:
|
||||
echo "- 📜 server.log: _no artifact uploaded (glob matched nothing)_"
|
||||
fi
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# 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,
|
||||
# actions:write only; GITHUB_TOKEN workflow_dispatch is exempt from recursion.
|
||||
merge-ready-rerun:
|
||||
name: Merge Ready rerun
|
||||
needs: e2e-ui
|
||||
if: >-
|
||||
always()
|
||||
&& needs.e2e-ui.result != 'skipped'
|
||||
&& (
|
||||
(github.event_name == 'pull_request'
|
||||
&& github.event.pull_request.head.repo.full_name == github.repository)
|
||||
|| (github.event_name == 'push'
|
||||
&& startsWith(github.ref_name, 'fork-e2e/pr-'))
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
actions: write
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
steps:
|
||||
- name: Re-dispatch Merge Ready
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# same-repo PR -> event number; fork-e2e push -> parse fork-e2e/pr-<N>
|
||||
PR="${PR_NUMBER:-${REF_NAME##*/pr-}}"
|
||||
if ! [[ "$PR" =~ ^[0-9]+$ ]]; then
|
||||
echo "::notice::could not resolve PR number (ref='$REF_NAME'); nothing to do."
|
||||
exit 0
|
||||
fi
|
||||
echo "Re-dispatching merge-ready.yml for PR #$PR after $GITHUB_WORKFLOW."
|
||||
gh workflow run merge-ready.yml --repo "$REPO" -f pr="$PR"
|
||||
|
||||
+51
-158
@@ -1,7 +1,8 @@
|
||||
name: E2E Tests
|
||||
|
||||
# Runs the `tests/e2e/` suite against a live LLM (Databricks gateway):
|
||||
# sub-agent spawning, parking, tunneled client tools, PATCH/GET routes.
|
||||
# Runs the `tests/e2e/` suite against the in-process mock LLM server.
|
||||
# All tests use mock LLM by default; real-credential tests skip cleanly
|
||||
# when no DATABRICKS_TOKEN is present.
|
||||
#
|
||||
# Triggers:
|
||||
# schedule 09:00 UTC daily (alongside nightly.yml).
|
||||
@@ -20,11 +21,11 @@ on:
|
||||
- cron: "0 9 * * *"
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, ready_for_review, labeled, unlabeled]
|
||||
paths-ignore: ['ap-web/**']
|
||||
paths-ignore: ['ap-web/**', 'tests/e2e_ui/**']
|
||||
push:
|
||||
branches:
|
||||
- 'fork-e2e/**'
|
||||
paths-ignore: ['ap-web/**']
|
||||
paths-ignore: ['ap-web/**', 'tests/e2e_ui/**']
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
branch:
|
||||
@@ -99,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
|
||||
@@ -115,161 +119,50 @@ 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"
|
||||
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' }}
|
||||
|
||||
- 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: Set LLM credentials
|
||||
run: echo "LLM_API_KEY=${{ secrets.LLM_API_KEY }}" >> "$GITHUB_ENV"
|
||||
- name: Write gateway profile (~/.databrickscfg)
|
||||
env:
|
||||
GATEWAY_BASE_URL: ${{ secrets.GATEWAY_BASE_URL }}
|
||||
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
|
||||
# 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,
|
||||
# actions:write only; GITHUB_TOKEN workflow_dispatch is exempt from recursion.
|
||||
merge-ready-rerun:
|
||||
name: Merge Ready rerun
|
||||
needs: e2e
|
||||
if: >-
|
||||
always()
|
||||
&& needs.e2e.result != 'skipped'
|
||||
&& (
|
||||
(github.event_name == 'pull_request'
|
||||
&& github.event.pull_request.head.repo.full_name == github.repository)
|
||||
|| (github.event_name == 'push'
|
||||
&& startsWith(github.ref_name, 'fork-e2e/pr-'))
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
actions: write
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
steps:
|
||||
- name: Re-dispatch Merge Ready
|
||||
run: |
|
||||
# Strip the /serving-endpoints suffix the conftest re-appends.
|
||||
host="${GATEWAY_BASE_URL%/serving-endpoints}"
|
||||
cat > "$HOME/.databrickscfg" <<EOF
|
||||
[default]
|
||||
host = $host
|
||||
token = $LLM_API_KEY
|
||||
EOF
|
||||
# PAT passthrough for the codex / claude-sdk auth commands.
|
||||
echo "DATABRICKS_BEARER=$LLM_API_KEY" >> "$GITHUB_ENV"
|
||||
|
||||
- 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 has no postinstall; pi is intentionally
|
||||
# absent (its e2e rows skip via skip_if_harness_cli_missing).
|
||||
#
|
||||
# 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
|
||||
# The `force-all-tests` PR label bypasses tests/known_failures.yaml
|
||||
# (matches the ci.yml / nightly.yml pattern).
|
||||
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 }}
|
||||
FORCE_ALL_TESTS: ${{ contains(github.event.pull_request.labels.*.name, 'force-all-tests') }}
|
||||
# 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
|
||||
# Spread interchangeable gateway models across tests (deterministic
|
||||
# per nodeid; tests/_model_pools.py).
|
||||
OMNIGENT_TEST_MODEL_SPREAD: '1'
|
||||
# Drain gpt-5-4 from the pool: its FMAPI quota is far below the
|
||||
# others, so tests hashed to it fail on sustained 429s.
|
||||
OMNIGENT_TEST_MODEL_POOL_GPT: 'databricks-gpt-5-5,databricks-gpt-5-4-mini'
|
||||
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
|
||||
set -euo pipefail
|
||||
# same-repo PR -> event number; fork-e2e push -> parse fork-e2e/pr-<N>
|
||||
PR="${PR_NUMBER:-${REF_NAME##*/pr-}}"
|
||||
if ! [[ "$PR" =~ ^[0-9]+$ ]]; then
|
||||
echo "::notice::could not resolve PR number (ref='$REF_NAME'); nothing to do."
|
||||
exit 0
|
||||
fi
|
||||
WORKERS="$PARALLELISM_INPUT"
|
||||
mkdir -p "$E2E_TMP_BASE"
|
||||
|
||||
EXTRA_ARGS=()
|
||||
if [[ "$FORCE_ALL_TESTS" == "true" ]]; then
|
||||
EXTRA_ARGS=(--no-skip-known)
|
||||
echo "::notice::force-all-tests label present; bypassing tests/known_failures.yaml"
|
||||
fi
|
||||
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/ \
|
||||
--llm-api-key "$LLM_API_KEY" \
|
||||
--profile default \
|
||||
--harness databricks \
|
||||
-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
|
||||
echo "Re-dispatching merge-ready.yml for PR #$PR after $GITHUB_WORKFLOW."
|
||||
gh workflow run merge-ready.yml --repo "$REPO" -f pr="$PR"
|
||||
|
||||
@@ -24,7 +24,7 @@ name: Flake stress (E2E)
|
||||
# -f test_target=tests/e2e/test_subagents.py
|
||||
# gh workflow run flake-stress-e2e.yml --ref main \
|
||||
# -f test_target='tests/e2e/test_routes.py::test_patch_session' \
|
||||
# -f workers=1 -f attempts=30 -f extra_pytest_args=--no-skip-known
|
||||
# -f workers=1 -f attempts=30 -f extra_pytest_args=-x
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -53,7 +53,7 @@ on:
|
||||
required: false
|
||||
default: "default"
|
||||
extra_pytest_args:
|
||||
description: "Extra pytest args appended to the command, e.g. '--no-skip-known' (default: empty)"
|
||||
description: "Extra pytest args appended to the command, e.g. '-x' (default: empty)"
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ name: Flake stress
|
||||
# -f test_target=tests/server/integration/test_routes_responses.py
|
||||
# gh workflow run flake-stress.yml --ref main \
|
||||
# -f test_target='tests/foo.py::test_x[case1]' \
|
||||
# -f workers=1 -f extra_pytest_args=--no-skip-known
|
||||
# -f workers=1 -f extra_pytest_args=-x
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -39,7 +39,7 @@ on:
|
||||
required: false
|
||||
default: "worksteal"
|
||||
extra_pytest_args:
|
||||
description: "Extra pytest args appended to the command, e.g. '--no-skip-known' (default: empty)"
|
||||
description: "Extra pytest args appended to the command, e.g. '-x' (default: empty)"
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
name: Integration Tests
|
||||
|
||||
# Per-PR twin of nightly.yml's journey-suite matrix (tests/integration/),
|
||||
# once per wrapped harness against the real Databricks gateway. Burn-in:
|
||||
# NOT in merge-ready's REQUIRED list yet (reports for signal; flip in
|
||||
# .github/scripts/merge-ready/required.sh after a clean week). Triggers:
|
||||
# daily schedule, same-repo PR gate (secrets flow; fork PRs skip and run
|
||||
# via the fork-e2e/** push after fork-e2e-mirror.yml), the fork-e2e/**
|
||||
# push itself, and workflow_dispatch.
|
||||
# Per-PR journey-suite matrix (tests/integration/), once per wrapped harness
|
||||
# using the mock LLM server (no real gateway credentials required). All tests
|
||||
# are mock_only: they script the LLM responses via configure_mock_llm and run
|
||||
# against a local mock FastAPI server. Triggers: daily schedule, same-repo PR
|
||||
# gate (fork PRs skip and run via the fork-e2e/** push after
|
||||
# fork-e2e-mirror.yml), the fork-e2e/** push itself, and workflow_dispatch.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
@@ -103,121 +102,49 @@ 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"
|
||||
harness: ${{ matrix.harness }}
|
||||
model: ${{ matrix.model }}
|
||||
workers: ${{ matrix.workers }}
|
||||
|
||||
- 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: Set LLM credentials
|
||||
run: echo "LLM_API_KEY=${{ secrets.LLM_API_KEY }}" >> "$GITHUB_ENV"
|
||||
- name: Write gateway profile (~/.databrickscfg)
|
||||
env:
|
||||
GATEWAY_BASE_URL: ${{ secrets.GATEWAY_BASE_URL }}
|
||||
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
|
||||
run: |
|
||||
# Strip the /serving-endpoints suffix the conftest re-appends.
|
||||
host="${GATEWAY_BASE_URL%/serving-endpoints}"
|
||||
cat > "$HOME/.databrickscfg" <<EOF
|
||||
[default]
|
||||
host = $host
|
||||
token = $LLM_API_KEY
|
||||
EOF
|
||||
# PAT passthrough for the codex / claude-sdk auth commands.
|
||||
echo "DATABRICKS_BEARER=$LLM_API_KEY" >> "$GITHUB_ENV"
|
||||
|
||||
- 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
|
||||
# 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,
|
||||
# actions:write only; GITHUB_TOKEN workflow_dispatch is exempt from recursion.
|
||||
merge-ready-rerun:
|
||||
name: Merge Ready rerun
|
||||
needs: integration
|
||||
if: >-
|
||||
always()
|
||||
&& needs.integration.result != 'skipped'
|
||||
&& (
|
||||
(github.event_name == 'pull_request'
|
||||
&& github.event.pull_request.head.repo.full_name == github.repository)
|
||||
|| (github.event_name == 'push'
|
||||
&& startsWith(github.ref_name, 'fork-e2e/pr-'))
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
actions: write
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
steps:
|
||||
- name: Re-dispatch Merge Ready
|
||||
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/ \
|
||||
--integration \
|
||||
--model "$MODEL" \
|
||||
--harness "$HARNESS" \
|
||||
--profile default \
|
||||
--llm-api-key "$LLM_API_KEY" \
|
||||
-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
|
||||
# same-repo PR -> event number; fork-e2e push -> parse fork-e2e/pr-<N>
|
||||
PR="${PR_NUMBER:-${REF_NAME##*/pr-}}"
|
||||
if ! [[ "$PR" =~ ^[0-9]+$ ]]; then
|
||||
echo "::notice::could not resolve PR number (ref='$REF_NAME'); nothing to do."
|
||||
exit 0
|
||||
fi
|
||||
echo "Re-dispatching merge-ready.yml for PR #$PR after $GITHUB_WORKFLOW."
|
||||
gh workflow run merge-ready.yml --repo "$REPO" -f pr="$PR"
|
||||
|
||||
@@ -356,7 +356,7 @@ jobs:
|
||||
ALLOWED_TYPES = {"bug", "enhancement", "documentation"}
|
||||
ALLOWED_COMPONENTS = {
|
||||
"comp:server", "comp:runner", "comp:repr",
|
||||
"comp:web-ui", "comp:policies", "comp:harnesses", "comp:infra",
|
||||
"comp:web-ui", "comp:tui", "comp:policies", "comp:harnesses", "comp:infra",
|
||||
}
|
||||
ALLOWED_PRIORITIES = {"P0-critical", "P1-high", "P2-medium", "P3-low"}
|
||||
|
||||
|
||||
@@ -16,10 +16,9 @@ name: Merge Ready
|
||||
# into continuous gate updates (green AND red).
|
||||
#
|
||||
# There is no CI bypass label. To land a PR despite red required checks,
|
||||
# either quarantine the offending flaky test (tests/known_failures.yaml) or,
|
||||
# for a genuine emergency, a repo admin uses GitHub's native "merge without
|
||||
# waiting for requirements" affordance (branch protection has
|
||||
# enforce_admins=false).
|
||||
# fix or delete the offending test; for a genuine emergency, a repo admin
|
||||
# uses GitHub's native "merge without waiting for requirements" affordance
|
||||
# (branch protection has enforce_admins=false).
|
||||
|
||||
on:
|
||||
# `labeled` only; `workflow_run` re-evaluates on CI completion (same-repo PRs
|
||||
|
||||
@@ -164,6 +164,7 @@ jobs:
|
||||
|
||||
# No build-args: the Dockerfile ARGs default to public registries.
|
||||
- name: Build and push
|
||||
id: build-server
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
with:
|
||||
context: .
|
||||
@@ -179,6 +180,7 @@ jobs:
|
||||
# Host image: same Dockerfile, `host` target. Runs after the server build
|
||||
# so it reuses the shared builder-stage layers from the gha cache.
|
||||
- name: Build and push host image
|
||||
id: build-host
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
with:
|
||||
context: .
|
||||
@@ -191,6 +193,55 @@ jobs:
|
||||
cache-to: type=gha,mode=max
|
||||
provenance: false
|
||||
sbom: true
|
||||
outputs:
|
||||
server-digest: ${{ steps.build-server.outputs.digest }}
|
||||
host-digest: ${{ steps.build-host.outputs.digest }}
|
||||
|
||||
generate-sbom:
|
||||
# Runs in a separate job with read-only permissions so the Syft
|
||||
# install script cannot influence the image push. Scans the
|
||||
# already-pushed images by digest (immutable).
|
||||
needs: build-and-push
|
||||
permissions:
|
||||
contents: read
|
||||
packages: read
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Log in to GHCR (read-only)
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install Syft
|
||||
uses: anchore/sbom-action/download-syft@fc46e51fd3cb168ffb36c6d1915723c47db58abb # v0.17.7
|
||||
|
||||
- name: Generate server SBOM
|
||||
run: |
|
||||
set -euo pipefail
|
||||
syft "ghcr.io/omnigent-ai/omnigent-server@${{ needs.build-and-push.outputs.server-digest }}" \
|
||||
-o cyclonedx-json=server-sbom.cdx.json \
|
||||
-o spdx-json=server-sbom.spdx.json
|
||||
|
||||
- name: Generate host SBOM
|
||||
run: |
|
||||
set -euo pipefail
|
||||
syft "ghcr.io/omnigent-ai/omnigent-host@${{ needs.build-and-push.outputs.host-digest }}" \
|
||||
-o cyclonedx-json=host-sbom.cdx.json \
|
||||
-o spdx-json=host-sbom.spdx.json
|
||||
|
||||
- name: Upload SBOMs
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: sbom
|
||||
path: |
|
||||
server-sbom.cdx.json
|
||||
server-sbom.spdx.json
|
||||
host-sbom.cdx.json
|
||||
host-sbom.spdx.json
|
||||
retention-days: 90
|
||||
|
||||
promote-nightly:
|
||||
# Daily cron (or a manual force_nightly dispatch): move :latest-nightly to
|
||||
|
||||
@@ -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') }}
|
||||
@@ -230,13 +231,13 @@ jobs:
|
||||
'anthropic': {
|
||||
'base_url': gw + '/anthropic',
|
||||
'api_key_ref': 'env:LLM_API_KEY',
|
||||
'models': {'default': 'databricks-claude-sonnet-4-6'},
|
||||
'models': {'default': 'databricks-claude-opus-4-8'},
|
||||
},
|
||||
'openai': {
|
||||
'base_url': host + '/ai-gateway/codex/v1',
|
||||
'api_key_ref': 'env:LLM_API_KEY',
|
||||
'wire_api': 'responses',
|
||||
'models': {'default': 'databricks-gpt-5-4-mini'},
|
||||
'models': {'default': 'databricks-gpt-5-5'},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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,24 +317,47 @@ 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** — bugs, security problems, correctness errors, data loss risks.
|
||||
2. **Security analysis** — carefully check the security implications of the changes. Look for injection vulnerabilities (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.
|
||||
3. **Non-blocking suggestions** — style, naming, performance, test coverage gaps.
|
||||
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.
|
||||
3. **Non-blocking notes** — design concerns or edge cases worth flagging (brief).
|
||||
4. **Summary** — one-paragraph overall assessment.
|
||||
|
||||
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 "waiting for results" narration.
|
||||
updates about dispatching sub-agents, no referring to "reviewers", no "waiting for results" narration.
|
||||
Begin your response with the exact marker <!-- POLLY_REVIEW_START -->
|
||||
on its own line, then the review content. Nothing before the marker
|
||||
will be shown.
|
||||
@@ -310,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
|
||||
@@ -357,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 != ''
|
||||
|
||||
@@ -7,7 +7,7 @@ name: Rerun Security Gate Run
|
||||
# number, resolves the PR's CURRENT head SHA, and re-runs every gate-bearing
|
||||
# workflow whose latest run for that SHA is a completed failure whose
|
||||
# `Security Gate` job failed -- so a workflow that already self-triggered on the
|
||||
# label (ci/e2e trigger on `labeled` for force-all-tests etc.) is in-progress or
|
||||
# label (ci/e2e trigger on `labeled` to re-poll the security gate) is in-progress or
|
||||
# green and skipped, avoiding a double-run. fork-e2e-mirror is excluded: it is
|
||||
# approval-driven mirror plumbing with branch side effects, not a
|
||||
# gate-mirroring check.
|
||||
|
||||
@@ -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 }}
|
||||
@@ -0,0 +1,95 @@
|
||||
name: UI Snapshot Failure Comment
|
||||
|
||||
# When the UI Snapshot compare gate fails on a PR, upsert a PR comment with how
|
||||
# to update the baseline -- tailored for same-repo PRs (the `update-ui-snapshot`
|
||||
# label) and fork PRs (adopt the run's rendered PNG, since CI can't push to a
|
||||
# fork).
|
||||
#
|
||||
# A fork `pull_request` run gets a read-only token and can't comment, so this
|
||||
# runs as `workflow_run` in the BASE-repo context (writable token). Crucially it
|
||||
# NEVER checks out or runs PR/fork code -- it only reads the completed run's
|
||||
# metadata (head SHA + repo, run URL) and posts a comment.
|
||||
#
|
||||
# NOTE: `workflow_run` only triggers from the copy of this file on the DEFAULT
|
||||
# branch, so it activates once merged to main -- it does not fire on its own PR.
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["UI Snapshot"]
|
||||
types: [completed]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: ui-snapshot-fail-comment-${{ github.event.workflow_run.head_sha }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
comment:
|
||||
name: Upsert baseline-update instructions
|
||||
# Only failed compare runs that came from a PR (push/dispatch have no PR).
|
||||
if: >-
|
||||
github.event.workflow_run.event == 'pull_request' &&
|
||||
github.event.workflow_run.conclusion == 'failure'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Upsert the failure comment
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||
HEAD_REPO: ${{ github.event.workflow_run.head_repository.full_name }}
|
||||
RUN_URL: ${{ github.event.workflow_run.html_url }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# workflow_run.pull_requests is empty for forks, so resolve the PR from
|
||||
# the head SHA (works for same-repo and fork). No open PR -> nothing to do.
|
||||
pr=$(gh api "repos/$REPO/commits/$HEAD_SHA/pulls" --jq '.[0].number // empty' 2>/dev/null || true)
|
||||
if [ -z "$pr" ]; then
|
||||
echo "No open PR for $HEAD_SHA; nothing to comment."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# List every update path that applies to where the branch lives. All
|
||||
# render in the same pinned image, so any of them matches this gate.
|
||||
# (workflow_dispatch is for non-PR branches; see the README.) This job
|
||||
# runs on ubuntu-latest, so bash arrays are fine.
|
||||
if [ "$HEAD_REPO" = "$REPO" ]; then
|
||||
opts=(
|
||||
"- **Label the PR (recommended):** add the \`update-ui-snapshot\` label — the bot regenerates the baseline in the pinned image, pushes it back here, and re-runs the checks."
|
||||
"- **Locally with Docker:** run \`tests/e2e_ui/visual/regen_baseline_docker.sh\`, review the PNG, then commit + push."
|
||||
)
|
||||
else
|
||||
opts=(
|
||||
"- **Locally with Docker:** run \`tests/e2e_ui/visual/regen_baseline_docker.sh\` (renders in the same pinned image), review the PNG, then commit + push."
|
||||
"- **Without Docker:** run \`tests/e2e_ui/visual/update_baseline_from_pr.sh $pr\` to adopt this run's render, review the PNG, then commit + push."
|
||||
" _(The \`update-ui-snapshot\` label can't help on a fork — CI can't push to a fork branch.)_"
|
||||
)
|
||||
fi
|
||||
|
||||
marker="<!-- ui-snapshot-fail-comment -->"
|
||||
# printf (not a heredoc) so backticks stay literal and there are no
|
||||
# leading-space markdown surprises. \` is a literal backtick.
|
||||
body=$(printf '%s\n' \
|
||||
"$marker" \
|
||||
"❌ **UI Snapshot** doesn't match the committed baseline." \
|
||||
"" \
|
||||
"If this UI change is intentional, update the baseline — each path renders in the same pinned image, so the result matches this gate:" \
|
||||
"" \
|
||||
"${opts[@]}" \
|
||||
"" \
|
||||
"Diff PNGs (\`expected_\`=baseline, \`actual_\`=your render, \`diff_\`) are in the [run]($RUN_URL) artifact. Full guide: \`tests/e2e_ui/visual/README.md\`.")
|
||||
|
||||
jq -n --arg b "$body" '{body: $b}' > "$RUNNER_TEMP/payload.json"
|
||||
|
||||
# Upsert so repeated failures update one comment instead of spamming.
|
||||
existing=$(gh api --paginate "repos/$REPO/issues/$pr/comments" \
|
||||
--jq ".[] | select(.body | contains(\"$marker\")) | .id" | head -n1 || true)
|
||||
if [ -n "$existing" ]; then
|
||||
gh api -X PATCH "repos/$REPO/issues/comments/$existing" --input "$RUNNER_TEMP/payload.json" --silent
|
||||
else
|
||||
gh api -X POST "repos/$REPO/issues/$pr/comments" --input "$RUNNER_TEMP/payload.json" --silent
|
||||
fi
|
||||
@@ -0,0 +1,280 @@
|
||||
name: UI Snapshot Update
|
||||
|
||||
# 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 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
|
||||
# runner, and uploads only the rendered PNG as an artifact. The `commit` job
|
||||
# runs on a clean runner, executes NO PR code (it just checks out the branch,
|
||||
# drops in the PNG, and pushes), and is the only place the App token exists --
|
||||
# so PR-controlled code can never tamper with the binaries/PATH the privileged
|
||||
# push later uses.
|
||||
#
|
||||
# Re-trigger: the push uses the OMNIGENT_BOT_APP token (NOT GITHUB_TOKEN, whose
|
||||
# pushes GitHub suppresses to avoid loops), so it re-fires the PR's full check
|
||||
# suite on the new commit -- no manual "Re-run". Falls back to GITHUB_TOKEN if
|
||||
# the App isn't configured (lands, but a maintainer must push to re-run CI),
|
||||
# mirroring oss-regen-on-comment.yml.
|
||||
#
|
||||
# Same-repo branches only: Actions tokens can't push to a fork branch, so fork
|
||||
# PRs are skipped here and update the baseline locally instead (Docker regen or
|
||||
# the artifact-adopt script -- see tests/e2e_ui/visual/README.md).
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [labeled]
|
||||
|
||||
# Read-only at the top level; the commit job widens its own scopes below.
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ui-snapshot-update-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
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 visual baselines (no token)
|
||||
permissions:
|
||||
contents: read
|
||||
# Same-repo only: a fork's read-only token can't push to the fork branch.
|
||||
if: >-
|
||||
github.event.label.name == 'update-ui-snapshot' &&
|
||||
github.event.pull_request.head.repo.full_name == github.repository
|
||||
runs-on: ubuntu-24.04
|
||||
# Same digest-pinned image as the compare gate, so the regenerated baseline
|
||||
# is byte-identical to what ui-snapshot.yml will then compare against. Keep
|
||||
# this digest in lockstep with ui-snapshot.yml and regen_baseline_docker.sh.
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright/python:v1.60.0-noble@sha256:8ff591d613b01c884cc488339ed4318b4513eaf0c57a164a878ba49e70e3f384
|
||||
# GitHub defaults `run:` steps inside a container to `sh` (dash); force bash
|
||||
# so the snapshot step's arrays / [[ ]] work (bash ships in the image).
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
timeout-minutes: 20
|
||||
env:
|
||||
OMNIGENT_SKIP_WEB_UI: "true"
|
||||
# The pinned image runs as root, where Chromium needs --no-sandbox; the
|
||||
# e2e_ui conftest adds it (+ --disable-dev-shm-usage) when this is set.
|
||||
OMNIGENT_PW_NO_SANDBOX: "1"
|
||||
# Use the image's Python 3.12 (matches .python-version) rather than a uv
|
||||
# download -- no setup-python step needed inside the container.
|
||||
UV_PYTHON_PREFERENCE: only-system
|
||||
steps:
|
||||
- name: Checkout PR branch
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
# No persisted credentials anywhere in this job: it runs PR-chosen code
|
||||
# and must never have a push token on disk.
|
||||
ref: ${{ github.event.pull_request.head.ref }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Node 20
|
||||
uses: ./.github/actions/setup-node
|
||||
|
||||
- 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
|
||||
# Namespaced + container-scoped to match ui-snapshot.yml (built with
|
||||
# the container's system Python, not the host interpreter).
|
||||
key: venv-uisnapshot-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('uv.lock') }}
|
||||
|
||||
- name: Install project + dev extras
|
||||
run: uv sync --extra all --extra dev
|
||||
|
||||
# No "playwright install": the pinned image ships matching Chromium + deps
|
||||
# under $PLAYWRIGHT_BROWSERS_PATH (/ms-playwright).
|
||||
|
||||
- name: Build ap-web SPA
|
||||
env:
|
||||
NPM_CONFIG_REGISTRY: https://registry.npmjs.org/
|
||||
run: |
|
||||
cd ap-web
|
||||
npm ci --legacy-peer-deps --no-audit --no-fund
|
||||
npm run build
|
||||
|
||||
- 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 || true
|
||||
|
||||
- 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: ${{ runner.temp }}/ui-snapshots.tgz
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
# 2) Commit + push on a clean runner. Runs NO PR code -- only checks out the
|
||||
# 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 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 =>
|
||||
# label/guard didn't match => this is skipped too.)
|
||||
if: ${{ always() && needs.render.result != 'skipped' }}
|
||||
permissions:
|
||||
contents: write # push the regenerated baseline (GITHUB_TOKEN fallback)
|
||||
pull-requests: write # comment the result + drop the trigger label
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Checkout PR branch
|
||||
if: needs.render.result == 'success'
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
# PR files land on disk but are never executed in this job; the push
|
||||
# token authenticates inline at the push step (not via .git/config).
|
||||
ref: ${{ github.event.pull_request.head.ref }}
|
||||
persist-credentials: false
|
||||
|
||||
- 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: Restore the regenerated baselines
|
||||
if: needs.render.result == 'success'
|
||||
run: |
|
||||
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
|
||||
# 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
|
||||
# configured (push then falls back to GITHUB_TOKEN, which won't re-run CI).
|
||||
- name: Mint App token
|
||||
id: app-token
|
||||
if: needs.render.result == 'success' && 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 }}
|
||||
|
||||
# The push token authenticates inline (scoped to this step, never in
|
||||
# .git/config). An App-token push re-fires the PR's checks; a GITHUB_TOKEN
|
||||
# fallback push does not. HEAD_REF (user-influenced) passes via env.
|
||||
- name: Commit + push the regenerated baseline
|
||||
id: push
|
||||
if: needs.render.result == 'success'
|
||||
env:
|
||||
HEAD_REF: ${{ github.event.pull_request.head.ref }}
|
||||
PUSH_TOKEN: ${{ steps.app-token.outputs.token || secrets.GITHUB_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
git config user.name "omnigent-ci[bot]"
|
||||
git config user.email "294685417+omnigent-ci[bot]@users.noreply.github.com"
|
||||
git add tests/e2e_ui/visual/snapshots
|
||||
if git diff --cached --quiet; then
|
||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||
echo "Baseline already matches this PR's render — nothing to commit."
|
||||
exit 0
|
||||
fi
|
||||
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"
|
||||
|
||||
# Report on the PR thread and drop the label so it can be re-applied to
|
||||
# regenerate again.
|
||||
- name: Comment the result + drop the label
|
||||
if: ${{ always() && steps.push.conclusion == 'success' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR: ${{ github.event.pull_request.number }}
|
||||
REPO: ${{ github.repository }}
|
||||
CHANGED: ${{ steps.push.outputs.changed }}
|
||||
# App token used → push re-triggers CI; skipped fallback → it won't.
|
||||
APP_USED: ${{ steps.app-token.conclusion == 'success' }}
|
||||
run: |
|
||||
if [ "$CHANGED" = "true" ]; then
|
||||
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
|
||||
body="$base ⚠️ No bot App configured, so this push won't auto-trigger CI — push any commit to re-run checks."
|
||||
fi
|
||||
gh pr comment "$PR" --repo "$REPO" --body "$body"
|
||||
else
|
||||
gh pr comment "$PR" --repo "$REPO" \
|
||||
--body "ℹ️ Baseline already matches this PR's render — nothing to update."
|
||||
fi
|
||||
gh pr edit "$PR" --repo "$REPO" --remove-label update-ui-snapshot || true
|
||||
|
||||
# Failure path: render crashed or the push failed. Report on the PR (not
|
||||
# just the Actions tab) and still drop the label so the PR isn't stuck.
|
||||
- name: Comment on failure + drop the label
|
||||
if: ${{ always() && (needs.render.result == 'failure' || steps.push.conclusion == 'failure') }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR: ${{ github.event.pull_request.number }}
|
||||
REPO: ${{ github.repository }}
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
run: |
|
||||
gh pr comment "$PR" --repo "$REPO" \
|
||||
--body "❌ \`update-ui-snapshot\` failed — see the [workflow run]($RUN_URL). Baseline unchanged."
|
||||
gh pr edit "$PR" --repo "$REPO" --remove-label update-ui-snapshot || true
|
||||
@@ -0,0 +1,188 @@
|
||||
name: UI Snapshot
|
||||
|
||||
# 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
|
||||
# PR comparison MUST be produced by the same renderer. This job renders INSIDE a
|
||||
# digest-pinned Playwright image (mcr.microsoft.com/playwright/python) -- the
|
||||
# exact same image the local regen script uses
|
||||
# (tests/e2e_ui/visual/regen_baseline_docker.sh), so a baseline regenerated
|
||||
# locally matches this gate byte-for-byte. The test only renders the SPA, so it
|
||||
# needs no LLM credentials and none of the heavy native-CLI setup the main
|
||||
# e2e-ui suite uses.
|
||||
#
|
||||
# Every run (pass or fail) uploads the rendered screenshots as the single
|
||||
# `ui-snapshot-<run_id>` artifact (baseline + current + diff PNGs) and links it
|
||||
# in the job summary, so they are always one click away.
|
||||
#
|
||||
# Triggers:
|
||||
# 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 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 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).
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
description: "Branch/ref to regenerate the baseline against"
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ui-snapshot-${{ github.workflow }}-${{ github.event.pull_request.number || github.event.inputs.ref || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
# No SPA build during `uv sync`: this workflow builds the bundle in a
|
||||
# dedicated step (mirrors e2e-ui.yml), so the setup.py build would be redundant.
|
||||
OMNIGENT_SKIP_WEB_UI: "true"
|
||||
# The pinned image runs as root, where Chromium needs --no-sandbox; the
|
||||
# e2e_ui conftest adds it (+ --disable-dev-shm-usage) when this is set.
|
||||
OMNIGENT_PW_NO_SANDBOX: "1"
|
||||
# Use the image's Python 3.12 (matches .python-version) instead of letting uv
|
||||
# download its own -- no setup-python step needed inside the container.
|
||||
UV_PYTHON_PREFERENCE: only-system
|
||||
|
||||
jobs:
|
||||
ui-snapshot:
|
||||
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
|
||||
# locally regenerated baseline matches. Keep this digest in lockstep with
|
||||
# ui-snapshot-update.yml and regen_baseline_docker.sh.
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright/python:v1.60.0-noble@sha256:8ff591d613b01c884cc488339ed4318b4513eaf0c57a164a878ba49e70e3f384
|
||||
# GitHub defaults `run:` steps inside a container to `sh` (dash); force bash
|
||||
# so the snapshot step's arrays / [[ ]] work (bash ships in the image).
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
ref: ${{ github.event.inputs.ref || github.ref }}
|
||||
|
||||
- name: Set up Node 20
|
||||
uses: ./.github/actions/setup-node
|
||||
|
||||
- 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
|
||||
# Namespaced away from e2e-ui.yml's host venv: this venv is built with
|
||||
# the container's system Python (different interpreter path), so they
|
||||
# must not share a key or a cross-restore would mismatch.
|
||||
key: venv-uisnapshot-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('uv.lock') }}
|
||||
|
||||
- name: Install project + dev extras
|
||||
run: uv sync --extra all --extra dev
|
||||
|
||||
# No "playwright install": the pinned image already ships matching Chromium
|
||||
# + system deps under $PLAYWRIGHT_BROWSERS_PATH (/ms-playwright), so the
|
||||
# uv-synced playwright 1.60.0 finds them with no download.
|
||||
|
||||
- name: Build ap-web SPA
|
||||
# Build BEFORE pytest: Vite's emptyOutDir clobbers the static dir, so
|
||||
# never run it alongside the live server. --legacy-peer-deps avoids
|
||||
# re-resolving the known React 19 peer conflict under @emoji-mart/react.
|
||||
env:
|
||||
NPM_CONFIG_REGISTRY: https://registry.npmjs.org/
|
||||
run: |
|
||||
cd ap-web
|
||||
npm ci --legacy-peer-deps --no-audit --no-fund
|
||||
npm run build
|
||||
|
||||
- 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
|
||||
# baseline and intentionally fails the run so a human reviews the image.
|
||||
env:
|
||||
IS_UPDATE: ${{ github.event_name == 'workflow_dispatch' }}
|
||||
run: |
|
||||
EXTRA_ARGS=()
|
||||
if [[ "$IS_UPDATE" == "true" ]]; then
|
||||
EXTRA_ARGS+=(--update-snapshots)
|
||||
fi
|
||||
# -p no:rerunfailures: this gate is deterministic (one static page),
|
||||
# so reruns add nothing; the plugin also spins a teardown socket
|
||||
# thread that emits a noisy unhandled-exception warning when the
|
||||
# live_server subprocess is torn down.
|
||||
uv run pytest tests/e2e_ui/visual -m visual \
|
||||
-v --tb=long --log-level=INFO -r a \
|
||||
-p no:rerunfailures \
|
||||
--ui-skip-build \
|
||||
"${EXTRA_ARGS[@]}"
|
||||
|
||||
# Always (pass or fail) publish the rendered screenshots so they are one
|
||||
# click away. Gated on the snapshot step's own conclusion (not a bare
|
||||
# always()): if an earlier setup step crashed, the snapshot step is skipped
|
||||
# and there is no meaningful render to publish.
|
||||
- name: Upload screenshots
|
||||
id: upload_screens
|
||||
if: ${{ always() && (steps.snapshot.conclusion == 'success' || steps.snapshot.conclusion == 'failure') }}
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: ui-snapshot-${{ github.run_id }}
|
||||
# snapshots/ is this run's render (identical to the baseline on a pass;
|
||||
# the new/regenerated render on a mismatch or --update-snapshots).
|
||||
# snapshot_failures/ adds the actual_/expected_/diff_ PNGs on a
|
||||
# mismatch -- expected_ IS the baseline, so this single artifact
|
||||
# already carries baseline + current + diff.
|
||||
path: |
|
||||
tests/e2e_ui/visual/snapshots/**
|
||||
tests/e2e_ui/visual/snapshot_failures/**
|
||||
if-no-files-found: warn
|
||||
retention-days: 7
|
||||
|
||||
- name: Link screenshots
|
||||
# Print a clickable artifact link to the job summary + log on every run,
|
||||
# whether the comparison passed or failed.
|
||||
if: ${{ always() && (steps.snapshot.conclusion == 'success' || steps.snapshot.conclusion == 'failure') }}
|
||||
env:
|
||||
SCREENS_URL: ${{ steps.upload_screens.outputs.artifact-url }}
|
||||
SNAPSHOT_OUTCOME: ${{ steps.snapshot.conclusion }}
|
||||
run: |
|
||||
{
|
||||
echo "## UI snapshot screenshots"
|
||||
echo ""
|
||||
echo "Snapshot comparison: \`${SNAPSHOT_OUTCOME}\`"
|
||||
echo ""
|
||||
echo "Artifact (baseline + current + diff PNGs): ${SCREENS_URL:-_(not uploaded)_}"
|
||||
echo ""
|
||||
echo "On a mismatch the artifact's \`snapshot_failures/\` holds \`expected_\` (baseline), \`actual_\` (current) and \`diff_\`; on a pass \`snapshots/\` is the render (identical to the baseline)."
|
||||
echo ""
|
||||
echo "### Updating the baseline (if this UI change is intentional)"
|
||||
echo ""
|
||||
echo "- **Same-repo branch:** add the \`update-ui-snapshot\` label — the bot regenerates + pushes for you."
|
||||
echo "- **Locally with Docker (any branch, incl. forks):** run \`tests/e2e_ui/visual/regen_baseline_docker.sh\` (renders in this same pinned image), then commit + push."
|
||||
echo ""
|
||||
echo "Full instructions, incl. the fork artifact fallback: \`tests/e2e_ui/visual/README.md\`."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "Screenshots artifact: ${SCREENS_URL:-not uploaded}"
|
||||
@@ -54,6 +54,11 @@ artifacts/
|
||||
# Playwright test run output (screenshots, traces, videos).
|
||||
test-results/
|
||||
|
||||
# Visual-snapshot failure output (actual/expected/diff PNGs from the UI diff
|
||||
# gate). Regenerated each run; only the baseline under
|
||||
# tests/e2e_ui/visual/snapshots/ is committed.
|
||||
tests/e2e_ui/visual/snapshot_failures/
|
||||
|
||||
# ap-web SPA build output, emitted into the server's static dir by
|
||||
# `npm run build` / the e2e_ui test fixture. Regenerated on demand;
|
||||
# never committed.
|
||||
|
||||
@@ -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: codex, codex-native, claude-native, cursor, openai-agents, pi, antigravity
|
||||
harness: claude-sdk # or: claude-native, codex, codex-native, cursor, cursor-native, openai-agents, pi, pi-native, antigravity
|
||||
|
||||
tools:
|
||||
# A local Python function (schema auto-generated from the signature)
|
||||
@@ -398,3 +398,13 @@ Polly at [`examples/polly/`](https://github.com/omnigent-ai/omnigent/tree/main/e
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome. See [CONTRIBUTING.md](https://github.com/omnigent-ai/omnigent/blob/main/CONTRIBUTING.md) for how to set up your environment, run the checks, and open a pull request.
|
||||
|
||||
|
||||
### Contributors
|
||||
|
||||
Thanks to all of our amazing contributors!
|
||||
|
||||
<a href="https://github.com/omnigent-ai/omnigent/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=omnigent-ai/omnigent" />
|
||||
</a>
|
||||
|
||||
|
||||
+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/
|
||||
|
||||
@@ -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
|
||||
Generated
+175
-168
@@ -97,13 +97,13 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@ai-sdk/gateway": {
|
||||
"version": "3.0.127",
|
||||
"resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-3.0.127.tgz",
|
||||
"integrity": "sha512-Obmw5hmE5x+ccRrMp/Djx5r0rpFVX87YqE6OY06g5fwYlRI30dA84ARfTzX45ivCvkW4eCnBpOVXVWQ/pjH85w==",
|
||||
"version": "3.0.129",
|
||||
"resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-3.0.129.tgz",
|
||||
"integrity": "sha512-KEQpZGJuCksc4iFxYtVHeHHG7yH0izGFzJLmRZlriI0hFIzJF9bT2AzJoaTHUI6minlxtP0WKYh84dP18o/Cuw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@ai-sdk/provider": "3.0.10",
|
||||
"@ai-sdk/provider-utils": "4.0.27",
|
||||
"@ai-sdk/provider-utils": "4.0.29",
|
||||
"@vercel/oidc": "3.2.0"
|
||||
},
|
||||
"engines": {
|
||||
@@ -126,9 +126,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@ai-sdk/provider-utils": {
|
||||
"version": "4.0.27",
|
||||
"resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.27.tgz",
|
||||
"integrity": "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw==",
|
||||
"version": "4.0.29",
|
||||
"resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.29.tgz",
|
||||
"integrity": "sha512-uhukHaCBvqkwBHkT8C2PrnqKTCoLn3pdHXqtcR9I8ErH+flbzgW4o7VHSNIup9LRu+WBvZIZDQLsx6rwl2tiOA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@ai-sdk/provider": "3.0.10",
|
||||
@@ -889,9 +889,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-color-parser": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.1.tgz",
|
||||
"integrity": "sha512-eZ5XOtyhK+mggRafYUWzA0tvaYOFgdY8AkgQiCJF9qNAePnUo/zmsqqYubBBb3sQ8uNUaSKTY9s9klfRaAXL0g==",
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.3.tgz",
|
||||
"integrity": "sha512-DOgvIPkikIOixQRlD4YF31VN6fLLUTdrzhfRbis8vm0kMTgIbEPX0Ip/YX9fOeV9iywAS4sUUbTclpan7yYP8Q==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -1068,9 +1068,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@dotenvx/dotenvx": {
|
||||
"version": "1.71.2",
|
||||
"resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.71.2.tgz",
|
||||
"integrity": "sha512-Xj9T3Wr+Bo4ILKf9PZJBYJ4SJiZGC/pqIdzOMbX9jgAFb0oGuKkusLleYHN/N6zanZixNvmuMVWYR1T3YJuVTA==",
|
||||
"version": "1.71.3",
|
||||
"resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.71.3.tgz",
|
||||
"integrity": "sha512-WSmox5aD+XxJEUEOTk7gKLpd5+Iz9Nik89Zpbu5DijMln6LsFiv3xpNKBMc/b9sSkUlKvAblzrhik2TqKFE7NA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"commander": "^11.1.0",
|
||||
@@ -1771,9 +1771,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@lobehub/ui": {
|
||||
"version": "5.15.12",
|
||||
"resolved": "https://registry.npmjs.org/@lobehub/ui/-/ui-5.15.12.tgz",
|
||||
"integrity": "sha512-Pyie7j2UzbdTDqCdHjR3J9dw6ewpoqHDrwnkWWMDtJpqeEzPywLhwen90DQ6ETHfXrlbsIfuczgoEkBKirtAPg==",
|
||||
"version": "5.15.15",
|
||||
"resolved": "https://registry.npmjs.org/@lobehub/ui/-/ui-5.15.15.tgz",
|
||||
"integrity": "sha512-nbake8F9Lp6/g1AaBnbt+l0Q8/u5/RjpSeE67ABOf5BV2MMV4Vhac5rTkkS7F4DpRYXug7i4A/ZvGwn3/F+jmA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@ant-design/cssinjs": "^2.1.2",
|
||||
@@ -1789,30 +1789,30 @@
|
||||
"@giscus/react": "^3.1.0",
|
||||
"@mdx-js/mdx": "^3.1.1",
|
||||
"@mdx-js/react": "^3.1.1",
|
||||
"@pierre/diffs": "^1.1.19",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@shikijs/core": "^4.0.2",
|
||||
"@shikijs/transformers": "^4.0.2",
|
||||
"@pierre/diffs": "1.2.8",
|
||||
"@radix-ui/react-slot": "^1.2.5",
|
||||
"@shikijs/core": "^4.2.0",
|
||||
"@shikijs/transformers": "^4.2.0",
|
||||
"@splinetool/runtime": "0.9.526",
|
||||
"ahooks": "^3.9.7",
|
||||
"antd-style": "^4.1.0",
|
||||
"chroma-js": "^3.2.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dayjs": "^1.11.20",
|
||||
"dayjs": "^1.11.21",
|
||||
"emoji-mart": "^5.6.0",
|
||||
"es-toolkit": "^1.46.0",
|
||||
"es-toolkit": "^1.47.0",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"immer": "^11.1.4",
|
||||
"katex": "^0.16.45",
|
||||
"immer": "^11.1.8",
|
||||
"katex": "^0.16.47",
|
||||
"leva": "^0.10.1",
|
||||
"lucide-react": "^1.11.0",
|
||||
"lucide-react": "^1.17.0",
|
||||
"marked": "^17.0.6",
|
||||
"mermaid": "^11.14.0",
|
||||
"motion": "^12.38.0",
|
||||
"mermaid": "^11.15.0",
|
||||
"motion": "^12.40.0",
|
||||
"numeral": "^2.0.6",
|
||||
"polished": "^4.3.1",
|
||||
"query-string": "^9.3.1",
|
||||
"query-string": "^9.4.0",
|
||||
"rc-collapse": "^4.0.0",
|
||||
"rc-footer": "^0.6.8",
|
||||
"rc-image": "^7.12.0",
|
||||
@@ -1820,8 +1820,8 @@
|
||||
"rc-menu": "^9.16.1",
|
||||
"re-resizable": "^6.11.2",
|
||||
"react-avatar-editor": "^15.1.0",
|
||||
"react-error-boundary": "^6.1.1",
|
||||
"react-hotkeys-hook": "^5.2.4",
|
||||
"react-error-boundary": "^6.1.2",
|
||||
"react-hotkeys-hook": "^5.3.2",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-merge-refs": "^3.0.2",
|
||||
"react-rnd": "^10.5.3",
|
||||
@@ -1835,14 +1835,14 @@
|
||||
"remark-github": "^12.0.0",
|
||||
"remark-math": "^6.0.0",
|
||||
"remend": "^1.3.0",
|
||||
"shiki": "^4.0.2",
|
||||
"shiki-stream": "^0.1.4",
|
||||
"shiki": "^4.2.0",
|
||||
"shiki-stream": "^0.1.5",
|
||||
"swr": "^2.4.1",
|
||||
"ts-md5": "^2.0.1",
|
||||
"unified": "^11.0.5",
|
||||
"url-join": "^5.0.0",
|
||||
"use-merge-value": "^1.2.0",
|
||||
"uuid": "^13.0.0",
|
||||
"uuid": "^13.0.2",
|
||||
"virtua": "^0.49.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -2004,14 +2004,14 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
|
||||
"integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz",
|
||||
"integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@tybys/wasm-util": "^0.10.1"
|
||||
"@tybys/wasm-util": "^0.10.2"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
@@ -4102,9 +4102,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rc-component/async-validator": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@rc-component/async-validator/-/async-validator-5.1.1.tgz",
|
||||
"integrity": "sha512-T03+Wk31Kz/28OC+rLlHtSNwD5Io3OWw6rPFPAp898sqALB/XnTrr3trB3mPoj379v0aRaW6t09HUG6dUyHR3g==",
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@rc-component/async-validator/-/async-validator-6.0.0.tgz",
|
||||
"integrity": "sha512-D3AGQwdyE58gmvx6waVSXJ80JGO+IY5L2O8HDnSOex7JNlzB3GuN/4hyHNTdhy2qtOhkpbIjmeAN3tL993wKbA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.24.4"
|
||||
@@ -4114,14 +4114,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rc-component/cascader": {
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmjs.org/@rc-component/cascader/-/cascader-1.15.0.tgz",
|
||||
"integrity": "sha512-ZzpMtwFCRo3fbXHuDnncARJMZQjdqA2w7aDuPofNQt+aDx39st1hgfIpEwTBLhe2Hqsvs/zOr8RTtgxTkCPySw==",
|
||||
"version": "1.16.1",
|
||||
"resolved": "https://registry.npmjs.org/@rc-component/cascader/-/cascader-1.16.1.tgz",
|
||||
"integrity": "sha512-wxLopwM+EBed0zNNGdnGE4coYoqcO+XD42fHgn+pDvO+XzhNFbdgSlSNXdKocIYqccvqgWvoxDPNb0OVRdi59A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@rc-component/select": "~1.6.0",
|
||||
"@rc-component/tree": "~1.3.0",
|
||||
"@rc-component/util": "^1.4.0",
|
||||
"@rc-component/select": "~1.7.1",
|
||||
"@rc-component/tree": "~1.3.2",
|
||||
"@rc-component/util": "^1.11.1",
|
||||
"clsx": "^2.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -4235,12 +4235,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rc-component/form": {
|
||||
"version": "1.8.2",
|
||||
"resolved": "https://registry.npmjs.org/@rc-component/form/-/form-1.8.2.tgz",
|
||||
"integrity": "sha512-ZidCvOLmM9Xr+3vzk4UAoR7Aj1W/5IHyrzlBB7sNkygpTeRVrohQSo4TN7W/nARTH+nt8zSAPsn4BEl4zLEO2g==",
|
||||
"version": "1.8.3",
|
||||
"resolved": "https://registry.npmjs.org/@rc-component/form/-/form-1.8.3.tgz",
|
||||
"integrity": "sha512-jNkat3uxZ246ELudKwnjQhnDI8+rSxgLxjztvQU3Mrb0G+LwDyOrPu9RNfekOjqU5GQ5QJepi225x+9LhCizJw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@rc-component/async-validator": "^5.1.0",
|
||||
"@rc-component/async-validator": "^6.0.0",
|
||||
"@rc-component/util": "^1.11.1",
|
||||
"clsx": "^2.1.1"
|
||||
},
|
||||
@@ -4409,12 +4409,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rc-component/pagination": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@rc-component/pagination/-/pagination-1.2.0.tgz",
|
||||
"integrity": "sha512-YcpUFE8dMLfSo6OARJlK6DbHHvrxz7pMGPGmC/caZSJJz6HRKHC1RPP001PRHCvG9Z/veD039uOQmazVuLJzlw==",
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@rc-component/pagination/-/pagination-1.3.0.tgz",
|
||||
"integrity": "sha512-12ahTY+HPITg1L2bjWKXUqBJe/oOnpA2QsChdCjthqLVf/e19StiCsv8OLKpWoHbc+8PFEkNjRqRqrLoRBHjFw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@rc-component/util": "^1.3.0",
|
||||
"@rc-component/util": "^1.11.1",
|
||||
"clsx": "^2.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -4492,9 +4492,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rc-component/qrcode": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@rc-component/qrcode/-/qrcode-1.1.2.tgz",
|
||||
"integrity": "sha512-CTXG18eP3sO3gc+96ep9HyVI/RzMup7L59apM/D0wWo1SHRdwOb7xyD4bMbmpu4dPlTch59Kxb8lU7U9ME60fg==",
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@rc-component/qrcode/-/qrcode-2.0.0.tgz",
|
||||
"integrity": "sha512-aAv3QhPP1xyafuTZOxub6a54pCeBnN3IwQkpETrBtthq4BL5IgxnCbuoBWPDpdLw1y1j6BgBUCAKV92+yX06Dw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.24.7"
|
||||
@@ -4554,15 +4554,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rc-component/select": {
|
||||
"version": "1.6.15",
|
||||
"resolved": "https://registry.npmjs.org/@rc-component/select/-/select-1.6.15.tgz",
|
||||
"integrity": "sha512-SyVCWnqxCQZZcQvQJ/CxSjx2bGma6ds/HtnpkIfZVnt6RoEgbqUmHgD6vrzNarNXwbLXerwVzWwq8F3d1sst7g==",
|
||||
"version": "1.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@rc-component/select/-/select-1.7.1.tgz",
|
||||
"integrity": "sha512-GZ1cMJk2xQh0VHyOQjjG8drYL4iu24NcbkXioUcReQOCUr+ub/3fmRonZe6cRPEZhWMbJdeHsqnEltogDaZ5Tg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@rc-component/overflow": "^1.0.0",
|
||||
"@rc-component/trigger": "^3.0.0",
|
||||
"@rc-component/util": "^1.3.0",
|
||||
"@rc-component/virtual-list": "^1.0.1",
|
||||
"@rc-component/util": "^1.11.1",
|
||||
"@rc-component/virtual-list": "^1.2.0",
|
||||
"clsx": "^2.1.1"
|
||||
},
|
||||
"engines": {
|
||||
@@ -4716,12 +4716,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rc-component/tree-select": {
|
||||
"version": "1.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@rc-component/tree-select/-/tree-select-1.9.0.tgz",
|
||||
"integrity": "sha512-GXcFe15a+trUl1/J3OHWQhsVWFpwFpGFK2cqYWZ1sK22Zs3KZTvMwDpzr75PIo1s6QVioVxpE/pRwRopkeDQ6w==",
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@rc-component/tree-select/-/tree-select-1.10.0.tgz",
|
||||
"integrity": "sha512-E1U4pn2LAbXEhLJdzIzid7WYbIuFbkTIctuFoeC6weppf8UbPR3+YYB6/ay0c0ksand4gXMRQpa1Z60Auo7VJA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@rc-component/select": "~1.6.0",
|
||||
"@rc-component/select": "~1.7.0",
|
||||
"@rc-component/tree": "~1.3.0",
|
||||
"@rc-component/util": "^1.4.0",
|
||||
"clsx": "^2.1.1"
|
||||
@@ -5196,6 +5196,34 @@
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/@shikijs/stream": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@shikijs/stream/-/stream-4.2.0.tgz",
|
||||
"integrity": "sha512-OaMUUStdIZ+l1GJad9uVACR3Xvgwo4y+RmEuDMU62cgFMMg1IBCaIFmvzAR2HiCpGtwoc/qPfpNnP+ivgrPXZg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@shikijs/core": "4.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.0.0",
|
||||
"solid-js": "^1.9.0",
|
||||
"vue": "^3.2.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"solid-js": {
|
||||
"optional": true
|
||||
},
|
||||
"vue": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@shikijs/themes": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-4.2.0.tgz",
|
||||
@@ -6747,9 +6775,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "24.13.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.1.tgz",
|
||||
"integrity": "sha512-RSpUJGmvsJ1ZeBehQZFhIdpsz+bIpES0nIQXko4Ybq+N+kX6XvOq3Jo+iJ82FWLdblFq85AsMikd3m35jgezYg==",
|
||||
"version": "24.13.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz",
|
||||
"integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.18.0"
|
||||
@@ -7151,9 +7179,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.16.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||
"version": "8.17.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
|
||||
"integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
@@ -7203,14 +7231,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ai": {
|
||||
"version": "6.0.199",
|
||||
"resolved": "https://registry.npmjs.org/ai/-/ai-6.0.199.tgz",
|
||||
"integrity": "sha512-6H9RPEjzBQECM+eU1JxAh6jHcZPU/6q5QZ8D8QV8agubf0Mm/kcBlwqrFcFtup6RQzmEvMkVaQOoLCZ8bQ13lA==",
|
||||
"version": "6.0.203",
|
||||
"resolved": "https://registry.npmjs.org/ai/-/ai-6.0.203.tgz",
|
||||
"integrity": "sha512-2Qi1ZPGF/FnlvnRqntVgRbUYGeA5ZKFYwTtgu8rcUzMmddArM/nLsvCW69Ip99B1cop6XHRHl+GCKk9t9B+GDA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@ai-sdk/gateway": "3.0.127",
|
||||
"@ai-sdk/gateway": "3.0.129",
|
||||
"@ai-sdk/provider": "3.0.10",
|
||||
"@ai-sdk/provider-utils": "4.0.27",
|
||||
"@ai-sdk/provider-utils": "4.0.29",
|
||||
"@opentelemetry/api": "^1.9.0"
|
||||
},
|
||||
"engines": {
|
||||
@@ -7316,54 +7344,54 @@
|
||||
}
|
||||
},
|
||||
"node_modules/antd": {
|
||||
"version": "6.4.3",
|
||||
"resolved": "https://registry.npmjs.org/antd/-/antd-6.4.3.tgz",
|
||||
"integrity": "sha512-6H2avkxCGfxcF67r3J2mwm9Ck50el1pks/73vfM1wDsPL/tPtj5vHuauMgJFnrqmq7CH3g8aoZ0VBQbt+jpAsw==",
|
||||
"version": "6.4.4",
|
||||
"resolved": "https://registry.npmjs.org/antd/-/antd-6.4.4.tgz",
|
||||
"integrity": "sha512-lgPz4KhfhiYddV/qPYo0ieqWimCVgV2OQF72mbeGNixE753JWNnmEc7UNGy08wBS/zZ7hxrmX0pc5aX7EUaIIg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@ant-design/colors": "^8.0.1",
|
||||
"@ant-design/cssinjs": "^2.1.2",
|
||||
"@ant-design/cssinjs-utils": "^2.1.2",
|
||||
"@ant-design/fast-color": "^3.0.1",
|
||||
"@ant-design/icons": "^6.2.3",
|
||||
"@ant-design/icons": "^6.2.5",
|
||||
"@ant-design/react-slick": "~2.0.0",
|
||||
"@babel/runtime": "^7.29.2",
|
||||
"@rc-component/cascader": "~1.15.0",
|
||||
"@rc-component/cascader": "~1.16.1",
|
||||
"@rc-component/checkbox": "~2.0.0",
|
||||
"@rc-component/collapse": "~1.2.0",
|
||||
"@rc-component/color-picker": "~3.1.1",
|
||||
"@rc-component/dialog": "~1.9.0",
|
||||
"@rc-component/drawer": "~1.4.2",
|
||||
"@rc-component/dropdown": "~1.0.2",
|
||||
"@rc-component/form": "~1.8.1",
|
||||
"@rc-component/form": "~1.8.3",
|
||||
"@rc-component/image": "~1.9.0",
|
||||
"@rc-component/input": "~1.3.0",
|
||||
"@rc-component/input": "~1.3.1",
|
||||
"@rc-component/input-number": "~1.6.2",
|
||||
"@rc-component/mentions": "~1.9.0",
|
||||
"@rc-component/menu": "~1.3.0",
|
||||
"@rc-component/motion": "^1.3.2",
|
||||
"@rc-component/menu": "~1.3.1",
|
||||
"@rc-component/motion": "^1.3.3",
|
||||
"@rc-component/mutate-observer": "^2.0.1",
|
||||
"@rc-component/notification": "~2.0.7",
|
||||
"@rc-component/pagination": "~1.2.0",
|
||||
"@rc-component/pagination": "~1.3.0",
|
||||
"@rc-component/picker": "~1.10.0",
|
||||
"@rc-component/progress": "~1.0.2",
|
||||
"@rc-component/qrcode": "~1.1.1",
|
||||
"@rc-component/qrcode": "~2.0.0",
|
||||
"@rc-component/rate": "~1.0.1",
|
||||
"@rc-component/resize-observer": "^1.1.2",
|
||||
"@rc-component/segmented": "~1.3.0",
|
||||
"@rc-component/select": "~1.6.15",
|
||||
"@rc-component/select": "~1.7.1",
|
||||
"@rc-component/slider": "~1.0.1",
|
||||
"@rc-component/steps": "~1.2.2",
|
||||
"@rc-component/switch": "~1.0.3",
|
||||
"@rc-component/table": "~1.10.0",
|
||||
"@rc-component/tabs": "~1.9.0",
|
||||
"@rc-component/table": "~1.10.2",
|
||||
"@rc-component/tabs": "~1.9.1",
|
||||
"@rc-component/tooltip": "~1.4.0",
|
||||
"@rc-component/tour": "~2.4.0",
|
||||
"@rc-component/tree": "~1.3.1",
|
||||
"@rc-component/tree-select": "~1.9.0",
|
||||
"@rc-component/trigger": "^3.9.0",
|
||||
"@rc-component/upload": "~1.1.0",
|
||||
"@rc-component/util": "^1.11.0",
|
||||
"@rc-component/tree": "~1.3.2",
|
||||
"@rc-component/tree-select": "~1.10.0",
|
||||
"@rc-component/trigger": "^3.9.1",
|
||||
"@rc-component/upload": "~1.1.1",
|
||||
"@rc-component/util": "^1.11.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dayjs": "^1.11.11",
|
||||
"scroll-into-view-if-needed": "^3.1.0",
|
||||
@@ -7458,9 +7486,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ast-v8-to-istanbul": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.3.tgz",
|
||||
"integrity": "sha512-jCMQ6ZylLPudp0CDfBmQBZUsrh1/8psbmu9ibeVWKuHWD0YrH9YABwlKu5kVEFoT0GCQQW9Z/SxfuEbbkGQCRg==",
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.4.tgz",
|
||||
"integrity": "sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -7549,9 +7577,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.10.35",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.35.tgz",
|
||||
"integrity": "sha512-honAfLBde0HAFLdNyBEfuuENkF6zR+ozxqxa/2zJKHBe1qzLqyTSeRKpdPEHAP03rlDGyQOPnCSxnVpVqQo9Mg==",
|
||||
"version": "2.10.36",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.36.tgz",
|
||||
"integrity": "sha512-lVq/Df7LXlO79MVaaUHztSwWiG9oXoWHlgvNS51v8Dpd4+G4/VIy6qYePTw31nAVls33nUtnfezYeLkYAak9dg==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"baseline-browser-mapping": "dist/cli.cjs"
|
||||
@@ -7745,9 +7773,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001797",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001797.tgz",
|
||||
"integrity": "sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w==",
|
||||
"version": "1.0.30001799",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz",
|
||||
"integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -8916,9 +8944,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.4.8",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.8.tgz",
|
||||
"integrity": "sha512-yb1cEmaOum7wFvOCSQxyfgVlv5D47Rc30iZWoMpbDIWTnJ6grDDQyu2KFJzB2k7u0pMuJcQ1zphH//fFnw2tjQ==",
|
||||
"version": "3.4.10",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.10.tgz",
|
||||
"integrity": "sha512-0xzNv0e7oYC6yyuOGZIABPM4qtg3QxLFniDNPP4ZP90wR8Yq3zgwpRbrNiT4N3IKqDbbYFEJLV+JWEs19aZ//w==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
@@ -8983,9 +9011,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.370",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.370.tgz",
|
||||
"integrity": "sha512-D5tSHJReAb/Kf3Hu9F/GO4lJuSWzEWHwvQ/kKSUP7pimNgvxkSKj+gUQhHpKKACwrin7rS3byU7IxreF56rl5g==",
|
||||
"version": "1.5.372",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.372.tgz",
|
||||
"integrity": "sha512-M3yhbAlilnwqC8D21t28UCDGHyitShTmmLRU/H+b74P6Ski16Nb9HONYEaVpMj/pwC7BEo5B95FpjODLCWbtfA==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/embla-carousel": {
|
||||
@@ -9038,9 +9066,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/enhanced-resolve": {
|
||||
"version": "5.23.0",
|
||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.23.0.tgz",
|
||||
"integrity": "sha512-yJN/BOOLxcOW2aQgeif9mSnaUB8KtvmMMp56oA1kx1CRfBKbhZm2pJ+NBY+3eOboHxix8lfjWpHE0Ei5U8RbSA==",
|
||||
"version": "5.24.0",
|
||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.0.tgz",
|
||||
"integrity": "sha512-SkE2t82KlkkxQRVMVLAGKxLfORGQfrkx5dkj+vlgXRVNEdPc4eZcR+J/Fvj8C+yKSFH5L0q3NFlyufOVQnCcYQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -9133,9 +9161,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/es-toolkit": {
|
||||
"version": "1.47.0",
|
||||
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.47.0.tgz",
|
||||
"integrity": "sha512-n1GuoD0WEQZMBk5tttoZSqwgyLx01oqa5XsBmCHwPyNe1S9jPBEmtR2pSgp2kJuWE3ciFZ6yRHmY4pM4C3OOkw==",
|
||||
"version": "1.47.1",
|
||||
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.47.1.tgz",
|
||||
"integrity": "sha512-5RAqEwf4P4E17p+W75KLOWw/nOvKZzSQpxM32IpI2KZLaVonjTrZ0Ai5ghMaVI9eKC2p8eoQgcBdkEDgzFk6+Q==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"docs",
|
||||
@@ -11607,9 +11635,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "1.17.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.17.0.tgz",
|
||||
"integrity": "sha512-9FA9evdox/JQL5PT57fdA1x/yg8T7knJ98+zjTL3UfKza6pflQUUh3XtaQIHKvnsJw1lmsEyHVlt5jchYxOQ5w==",
|
||||
"version": "1.18.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.18.0.tgz",
|
||||
"integrity": "sha512-LZDb7H/0YfM+RJncD0hDQRCAu+vSGODqpe35TuVI8EuXaRjkczbsx7p8dY4J87F/MUSj6bpYqeI8nw8qXaAdmA==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
@@ -12046,9 +12074,9 @@
|
||||
"license": "CC0-1.0"
|
||||
},
|
||||
"node_modules/media-chrome": {
|
||||
"version": "4.19.1",
|
||||
"resolved": "https://registry.npmjs.org/media-chrome/-/media-chrome-4.19.1.tgz",
|
||||
"integrity": "sha512-1+x2l0mNulHKZN0lBxGJwJ+TV2W/KzLjaAd//UCGZz8GE5O5YNafFskWTcv/D6Ty0d9drX9SSfimOzGwob8eVQ==",
|
||||
"version": "4.19.2",
|
||||
"resolved": "https://registry.npmjs.org/media-chrome/-/media-chrome-4.19.2.tgz",
|
||||
"integrity": "sha512-4ai1ITN8wBhwugQcRgqe3tN0z6OSKGOXqHLNrS04MgKFfsLqu6Dm8MPq02pI9Y9ZKoXtFjIl85jOryIW9es3BA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ce-la-react": "^0.3.2"
|
||||
@@ -13358,9 +13386,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/obug": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.2.tgz",
|
||||
"integrity": "sha512-AWGB9WFcRXOQs48Z/udjI5ZcZMHXwX8XPByNpOydgcGsDLIzjGizhoMWJyKAWze7AVW/2W1i+/gPX4YtKe5cyg==",
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz",
|
||||
"integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
"https://github.com/sponsors/sxzz",
|
||||
@@ -14005,9 +14033,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-model": {
|
||||
"version": "1.25.7",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.7.tgz",
|
||||
"integrity": "sha512-A79aN8QEFUwI6cax8Yq4Rpcx1TJZ3Kagn+ii7qLo4/V8H3mMiHrhFyhTyHHvpSnOgMPpWiDGSwM3etwrxE50ug==",
|
||||
"version": "1.25.8",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.8.tgz",
|
||||
"integrity": "sha512-BswA4BLSFEiORV6Vjj/yZBXDbos1zTEnhyeSSgT8psGFhstQS7UJ8/WOLiDos9Byaee27+tml0/DuMNxYR84zg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"orderedmap": "^2.0.0"
|
||||
@@ -14058,12 +14086,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-view": {
|
||||
"version": "1.41.8",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.8.tgz",
|
||||
"integrity": "sha512-TnKDdohEatgyZNGCDWIdccOHXhYloJwbwU+phw/a23KBvJIR9lWQWW7WHHK3vBdOLDNuF7TaX98GObUZOWkOnA==",
|
||||
"version": "1.41.9",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.9.tgz",
|
||||
"integrity": "sha512-clTunTX+eaLbr87L1V1QPheRlEQJyTlL3gXe9x3jQIk3rL0RVWxviDGz8tFaydwIVm+hKhYCyr+R/zBtWr9s6A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.20.0",
|
||||
"prosemirror-model": "^1.25.8",
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-transform": "^1.1.0"
|
||||
}
|
||||
@@ -15557,9 +15585,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.8.3",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.3.tgz",
|
||||
"integrity": "sha512-wnilbGyMxzbY7dNOl7jpKbLSjcfeweJWU5j4+u5qW+6/wuGD9KzIGOyZnQVSBM9E7DtWaaH3CyHkppYrKYoxwg==",
|
||||
"version": "7.8.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz",
|
||||
"integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
@@ -15736,9 +15764,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/shadcn/node_modules/postcss-selector-parser": {
|
||||
"version": "7.1.2",
|
||||
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.2.tgz",
|
||||
"integrity": "sha512-Wjvt4scRFouioIInHf51IFNP4ltJ2EngJM+cZPGiqbKetBfmP3vpdPV8ID2S6JS6/jdo74N8+aEYH9lQr2C6sA==",
|
||||
"version": "7.1.4",
|
||||
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.4.tgz",
|
||||
"integrity": "sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cssesc": "^3.0.0",
|
||||
@@ -15798,12 +15826,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/shiki-stream": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/shiki-stream/-/shiki-stream-0.1.4.tgz",
|
||||
"integrity": "sha512-4pz6JGSDmVTTkPJ/ueixHkFAXY4ySCc+unvCaDZV7hqq/sdJZirRxgIXSuNSKgiFlGTgRR97sdu2R8K55sPsrw==",
|
||||
"version": "0.1.5",
|
||||
"resolved": "https://registry.npmjs.org/shiki-stream/-/shiki-stream-0.1.5.tgz",
|
||||
"integrity": "sha512-DzkqVlqf02Tp4zTFNgJp+3rOG2RkuoONBq+Pm2sHslAlJ5M0QbR1devn4dr9SgcBTrtHTf6Rqyj3wVJi0g16Bw==",
|
||||
"deprecated": "shiki-stream is now @shikijs/stream, please migrate by renaming the package",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@shikijs/core": "^3.0.0"
|
||||
"@shikijs/stream": "^4.2.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
@@ -15825,28 +15854,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/shiki-stream/node_modules/@shikijs/core": {
|
||||
"version": "3.23.0",
|
||||
"resolved": "https://registry.npmjs.org/@shikijs/core/-/core-3.23.0.tgz",
|
||||
"integrity": "sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@shikijs/types": "3.23.0",
|
||||
"@shikijs/vscode-textmate": "^10.0.2",
|
||||
"@types/hast": "^3.0.4",
|
||||
"hast-util-to-html": "^9.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/shiki-stream/node_modules/@shikijs/types": {
|
||||
"version": "3.23.0",
|
||||
"resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.23.0.tgz",
|
||||
"integrity": "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@shikijs/vscode-textmate": "^10.0.2",
|
||||
"@types/hast": "^3.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
|
||||
@@ -16497,9 +16504,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ts-dedent": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.2.0.tgz",
|
||||
"integrity": "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==",
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.3.0.tgz",
|
||||
"integrity": "sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.10"
|
||||
|
||||
|
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 |
@@ -8,8 +8,11 @@ import { useChatStore } from "@/store/chatStore";
|
||||
// Mock the policies data layer so SessionPoliciesSection and AddPolicyDialog
|
||||
// render deterministically without network. The add/delete mutations expose
|
||||
// `mutate` spies we can assert on.
|
||||
const addMutate = vi.fn();
|
||||
const deleteMutate = vi.fn();
|
||||
const { addMutate, deleteMutate, copyTextMock } = vi.hoisted(() => ({
|
||||
addMutate: vi.fn(),
|
||||
deleteMutate: vi.fn(),
|
||||
copyTextMock: vi.fn(() => Promise.resolve()),
|
||||
}));
|
||||
const policiesData = { current: [] as unknown[] };
|
||||
const registryData = { current: [] as unknown[] };
|
||||
vi.mock("@/hooks/usePolicies", () => ({
|
||||
@@ -18,11 +21,13 @@ vi.mock("@/hooks/usePolicies", () => ({
|
||||
useAddPolicy: () => ({ mutate: addMutate, isPending: false, isError: false, error: null }),
|
||||
useDeletePolicy: () => ({ mutate: deleteMutate }),
|
||||
}));
|
||||
vi.mock("@/lib/clipboard", () => ({ copyText: copyTextMock }));
|
||||
|
||||
import { AgentInfoButton, AgentInfoContent, agentDisplayLabel } from "./AgentInfo";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
copyTextMock.mockClear();
|
||||
});
|
||||
|
||||
function renderButton(agent: Agent | undefined) {
|
||||
@@ -153,6 +158,21 @@ describe("AgentInfoButton session cost row", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("AgentInfoButton session id row", () => {
|
||||
it("shows and copies the active session id in the popover", async () => {
|
||||
renderButtonWithSession(AGENT_WITH_BOTH, "conv_info123");
|
||||
|
||||
fireEvent.click(screen.getByTestId("agent-info-trigger"));
|
||||
|
||||
expect(screen.getByTestId("agent-info-session-id")).toHaveTextContent("conv_info123");
|
||||
fireEvent.click(screen.getByTestId("agent-info-copy-session-id"));
|
||||
|
||||
expect(copyTextMock).toHaveBeenCalledTimes(1);
|
||||
expect(copyTextMock).toHaveBeenCalledWith("conv_info123");
|
||||
expect(await screen.findByRole("button", { name: "Copied session ID" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("AgentInfoButton per-model usage breakdown", () => {
|
||||
// The breakdown reads `sessionUsageByModel` from the store; reset between
|
||||
// cases so they stay independent.
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
// Agent info surface: the MCP-server and policy badges plus the
|
||||
// header info-icon popover that displays them.
|
||||
|
||||
import { useState } from "react";
|
||||
import { InfoIcon, PlusIcon, ServerIcon, ShieldCheckIcon, TrashIcon } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
CheckIcon,
|
||||
CopyIcon,
|
||||
InfoIcon,
|
||||
PlusIcon,
|
||||
ServerIcon,
|
||||
ShieldCheckIcon,
|
||||
TrashIcon,
|
||||
} from "lucide-react";
|
||||
import type { Agent, McpServerSummary } from "@/hooks/useAgents";
|
||||
import type { ModelUsage } from "@/lib/types";
|
||||
import {
|
||||
@@ -26,6 +34,7 @@ import { capitalizeAgentName } from "@/lib/agentLabels";
|
||||
import { coercePolicyParams } from "@/lib/policyParams";
|
||||
import { agentRootName } from "@/lib/forkHarness";
|
||||
import { nativeCodingAgentForAgentName } from "@/lib/nativeCodingAgents";
|
||||
import { copyText } from "@/lib/clipboard";
|
||||
import { useChatStore } from "@/store/chatStore";
|
||||
|
||||
/**
|
||||
@@ -610,6 +619,8 @@ export function agentHasInfo(agent: Agent | undefined, sessionId?: string | null
|
||||
export function AgentInfoContent({ agent, sessionId }: AgentInfoProps) {
|
||||
const servers = agent?.mcp_servers ?? [];
|
||||
const displayName = agent ? agentDisplayLabel(agent.name) : null;
|
||||
const [sessionIdCopied, setSessionIdCopied] = useState(false);
|
||||
const copyResetTimeoutRef = useRef<number | null>(null);
|
||||
// Cumulative session spend, live from the store (seeded on bind, updated
|
||||
// by SSE ``session_usage``). ``null`` when the session is unpriced (no
|
||||
// turn priced yet) — omit the row rather than show "$0.00" / "—".
|
||||
@@ -620,6 +631,25 @@ export function AgentInfoContent({ agent, sessionId }: AgentInfoProps) {
|
||||
// from this map rather than receiving flat token fields.
|
||||
const usageByModel = useChatStore((s) => s.sessionUsageByModel);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (copyResetTimeoutRef.current !== null) window.clearTimeout(copyResetTimeoutRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function copySessionId() {
|
||||
if (!sessionId) return;
|
||||
try {
|
||||
await copyText(sessionId);
|
||||
} catch (err) {
|
||||
console.warn("Failed to copy session ID", err);
|
||||
return;
|
||||
}
|
||||
setSessionIdCopied(true);
|
||||
if (copyResetTimeoutRef.current !== null) window.clearTimeout(copyResetTimeoutRef.current);
|
||||
copyResetTimeoutRef.current = window.setTimeout(() => setSessionIdCopied(false), 2000);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{displayName && (
|
||||
@@ -630,11 +660,40 @@ export function AgentInfoContent({ agent, sessionId }: AgentInfoProps) {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{sessionId && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<SectionLabel>Session ID</SectionLabel>
|
||||
<div className="flex items-center gap-2">
|
||||
<code
|
||||
className="min-w-0 flex-1 truncate py-1 font-mono text-xs text-muted-foreground"
|
||||
data-testid="agent-info-session-id"
|
||||
title={sessionId}
|
||||
>
|
||||
{sessionId}
|
||||
</code>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={sessionIdCopied ? "Copied session ID" : "Copy session ID"}
|
||||
data-testid="agent-info-copy-session-id"
|
||||
onClick={copySessionId}
|
||||
className="shrink-0"
|
||||
>
|
||||
{sessionIdCopied ? (
|
||||
<CheckIcon className="size-3.5" />
|
||||
) : (
|
||||
<CopyIcon className="size-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{sessionId && sessionCostUsd != null && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<SectionLabel>Session cost</SectionLabel>
|
||||
<span
|
||||
className="text-sm tabular-nums text-muted-foreground"
|
||||
className="font-mono text-xs tabular-nums text-muted-foreground"
|
||||
data-testid="agent-info-session-cost"
|
||||
>
|
||||
{formatSessionCostUsd(sessionCostUsd)}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
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".
|
||||
function toggleViaHotkey() {
|
||||
fireEvent.keyDown(window, { key: "/", ctrlKey: true });
|
||||
}
|
||||
|
||||
describe("KeyboardShortcutsDialog", () => {
|
||||
it("renders nothing until opened", () => {
|
||||
render(<KeyboardShortcutsDialog />);
|
||||
expect(screen.queryByText("Send message")).toBeNull();
|
||||
});
|
||||
|
||||
it("opens on the modifier+/ hotkey and lists one shortcut from each group", () => {
|
||||
render(<KeyboardShortcutsDialog />);
|
||||
toggleViaHotkey();
|
||||
|
||||
expect(screen.getByText("Keyboard shortcuts")).toBeTruthy();
|
||||
// General / In chats / Navigation / Slash commands — one representative each.
|
||||
expect(screen.getByText("Show keyboard shortcuts")).toBeTruthy();
|
||||
expect(screen.getByText("Send message")).toBeTruthy();
|
||||
expect(screen.getByText("Recall previous prompt")).toBeTruthy();
|
||||
expect(screen.getByText("Previous session")).toBeTruthy();
|
||||
expect(screen.getByText("Navigate suggestions")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("toggles closed on a second hotkey press", async () => {
|
||||
render(<KeyboardShortcutsDialog />);
|
||||
toggleViaHotkey();
|
||||
expect(screen.getByText("Send message")).toBeTruthy();
|
||||
|
||||
toggleViaHotkey();
|
||||
await waitFor(() => expect(screen.queryByText("Send message")).toBeNull());
|
||||
});
|
||||
|
||||
it("opens when openKeyboardShortcuts() is dispatched (menu entry path)", async () => {
|
||||
render(<KeyboardShortcutsDialog />);
|
||||
openKeyboardShortcuts();
|
||||
// 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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
// A read-only "Keyboard shortcuts" overlay listing the shortcuts that already
|
||||
// exist in the chat surface. It is intentionally a mirror of the live
|
||||
// behavior — every row here corresponds to a handler that ships today
|
||||
// (composer `handleKeyDown`, the global session-switch / message-nav hotkeys,
|
||||
// and the approve hotkey). Nothing here binds new behavior except the dialog's
|
||||
// own opener (⌘/Ctrl + /), which this component registers.
|
||||
//
|
||||
// Self-contained: it owns its open state and listens for its opener directly
|
||||
// (a window keydown for ⌘/Ctrl+/, plus a custom event so a menu entry can open
|
||||
// it without prop-drilling). Mount it once near the app shell.
|
||||
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
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.
|
||||
export const KEYBOARD_SHORTCUTS_EVENT = "omnigent:open-keyboard-shortcuts";
|
||||
|
||||
/** Dispatch the open event — used by menu entries that can't reach the state. */
|
||||
export function openKeyboardShortcuts(): void {
|
||||
if (typeof window === "undefined") return;
|
||||
window.dispatchEvent(new Event(KEYBOARD_SHORTCUTS_EVENT));
|
||||
}
|
||||
|
||||
// Platform-aware modifier glyphs. macOS shows ⌘/⌥; elsewhere Ctrl/Alt — the
|
||||
// same split the underlying handlers use (`metaKey || ctrlKey`).
|
||||
const IS_MAC =
|
||||
typeof navigator !== "undefined" &&
|
||||
/Mac|iPhone|iPad|iPod/i.test(navigator.platform || navigator.userAgent || "");
|
||||
|
||||
/** Modifier label shown in menu hints (⌘ on macOS, Ctrl elsewhere). */
|
||||
export const MOD_KEY = IS_MAC ? "⌘" : "Ctrl";
|
||||
|
||||
// Glyphs match the in-app tooltips (e.g. UserMessageNav's "⌘⌥↑").
|
||||
const ENTER = "↵";
|
||||
const SHIFT = "⇧";
|
||||
const UP = "↑";
|
||||
const DOWN = "↓";
|
||||
|
||||
interface Shortcut {
|
||||
label: string;
|
||||
/** Keys rendered left→right as chips. A chord (held together) or, for the
|
||||
* arrow-pairs, the two interchangeable keys for that action. */
|
||||
keys: string[];
|
||||
}
|
||||
|
||||
interface ShortcutGroup {
|
||||
title: string;
|
||||
/** Optional qualifier shown next to the group title. */
|
||||
note?: string;
|
||||
items: Shortcut[];
|
||||
}
|
||||
|
||||
// ONLY shortcuts that exist today (see file header). Keep in sync with the
|
||||
// composer's `handleKeyDown` and the global hotkey hooks.
|
||||
const SHORTCUT_GROUPS: ShortcutGroup[] = [
|
||||
{
|
||||
title: "General",
|
||||
items: [{ label: "Show keyboard shortcuts", keys: [MOD_KEY, "/"] }],
|
||||
},
|
||||
{
|
||||
title: "In chats",
|
||||
items: [
|
||||
{ label: "Send message", keys: [ENTER] },
|
||||
{ label: "New line in message", keys: [SHIFT, ENTER] },
|
||||
{ label: "Recall previous prompt", keys: [UP] },
|
||||
{ label: "Recall next prompt", keys: [DOWN] },
|
||||
{ label: "Accept approval prompt", keys: [MOD_KEY, ENTER] },
|
||||
{ label: "Stop response", keys: ["Esc"] },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Navigation",
|
||||
items: [
|
||||
{ label: "Previous session", keys: [MOD_KEY, UP] },
|
||||
{ label: "Next session", keys: [MOD_KEY, DOWN] },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Slash commands",
|
||||
note: "while the suggestions menu is open",
|
||||
items: [
|
||||
{ label: "Navigate suggestions", keys: [UP, DOWN] },
|
||||
{ label: "Apply highlighted command", keys: ["Tab"] },
|
||||
{ label: "Dismiss menu", keys: ["Esc"] },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// 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">
|
||||
{children}
|
||||
</kbd>
|
||||
);
|
||||
}
|
||||
|
||||
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) => {
|
||||
// ⌘/Ctrl + / toggles the panel. Plain `/` is the composer's slash-menu
|
||||
// trigger, so require the modifier and no Shift/Alt to avoid clashing.
|
||||
if ((e.metaKey || e.ctrlKey) && !e.altKey && !e.shiftKey && e.key === "/") {
|
||||
e.preventDefault();
|
||||
setOpen((prev) => !prev);
|
||||
}
|
||||
};
|
||||
const onOpenEvent = () => setOpen(true);
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
window.addEventListener(KEYBOARD_SHORTCUTS_EVENT, onOpenEvent);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKeyDown);
|
||||
window.removeEventListener(KEYBOARD_SHORTCUTS_EVENT, onOpenEvent);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Keyboard shortcuts</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
The keyboard shortcuts available in the chat.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="max-h-[70vh] overflow-y-auto pr-1">
|
||||
{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}
|
||||
{group.note ? (
|
||||
<span className="ml-1.5 font-normal text-muted-foreground/70">
|
||||
· {group.note}
|
||||
</span>
|
||||
) : null}
|
||||
</h3>
|
||||
<ul>
|
||||
{group.items.map((item) => (
|
||||
<li
|
||||
key={item.label}
|
||||
className="flex items-center justify-between gap-4 border-b border-border/60 py-2.5 last:border-b-0"
|
||||
>
|
||||
<span className="text-sm text-foreground">{item.label}</span>
|
||||
<span className="flex shrink-0 items-center gap-1">
|
||||
{item.keys.map((key) => (
|
||||
<Kbd key={`${item.label}-${key}`}>{key}</Kbd>
|
||||
))}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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,10 +13,11 @@ import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
|
||||
const setTheme = vi.fn();
|
||||
let currentTheme: string | undefined;
|
||||
let systemTheme: string | undefined;
|
||||
let embedded: boolean;
|
||||
|
||||
vi.mock("next-themes", () => ({
|
||||
useTheme: () => ({ theme: currentTheme, setTheme }),
|
||||
useTheme: () => ({ theme: currentTheme, systemTheme, setTheme }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/embedded", () => ({
|
||||
@@ -35,6 +36,7 @@ function renderMenu() {
|
||||
|
||||
beforeEach(() => {
|
||||
currentTheme = "system";
|
||||
systemTheme = undefined;
|
||||
embedded = false;
|
||||
});
|
||||
|
||||
@@ -93,4 +95,35 @@ describe("ThemeModeMenu", () => {
|
||||
renderMenu();
|
||||
expect(screen.getByRole("button", { name: "Switch to Dark" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
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";
|
||||
systemTheme = "dark";
|
||||
renderMenu();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Switch to Light" }));
|
||||
expect(setTheme).toHaveBeenCalledWith("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";
|
||||
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, setTheme } = useTheme();
|
||||
const { theme, systemTheme, setTheme } = useTheme();
|
||||
const mode = normalizeThemeMode(theme);
|
||||
const next = nextThemeMode(mode);
|
||||
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,11 +32,22 @@ describe("theme mode helpers", () => {
|
||||
expect(normalizeResolvedTheme(undefined)).toBe("light");
|
||||
});
|
||||
|
||||
it("cycles system → dark → light → system on each click", () => {
|
||||
// The cycle must visit every mode exactly once before wrapping;
|
||||
// a wrong value here would skip a mode or trap the user in two states.
|
||||
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 the system theme matches the next mode", () => {
|
||||
expect(nextThemeMode("system", "dark")).toBe("light");
|
||||
// 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 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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -54,14 +54,23 @@ export function normalizeResolvedTheme(value: string | undefined): ResolvedTheme
|
||||
* click pins dark, the next pins light, and the next returns to
|
||||
* following the OS preference.
|
||||
*
|
||||
* When the resolved appearance is provided, redundant transitions are
|
||||
* skipped — e.g. "system" already rendering as dark jumps straight to
|
||||
* light instead of offering "Switch to Dark".
|
||||
*
|
||||
* @param mode Current selectable theme mode, 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): ThemeMode {
|
||||
export function nextThemeMode(mode: ThemeMode, systemTheme?: string): ThemeMode {
|
||||
const cycle: Record<ThemeMode, ThemeMode> = {
|
||||
system: "dark",
|
||||
dark: "light",
|
||||
light: "system",
|
||||
};
|
||||
return cycle[mode];
|
||||
const next = cycle[mode];
|
||||
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]);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 () => {};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user