Compare commits

...

70 Commits

Author SHA1 Message Date
dbczumar ef4fa61c7c Merge remote-tracking branch 'origin/main' into backcompat-min-version-floor 2026-06-23 12:09:17 -07:00
dbczumar f81c5523cb backcompat: normalize a v-prefixed BACKCOMPAT_MIN_VERSION override
Polly review note: _below_floor strips a leading 'v' from the tag but not from
MIN_VERSION, so BACKCOMPAT_MIN_VERSION=v0.2.0 would drop the floor version
itself. Strip the leading 'v' from the override too. Default path (bare
numerics) unchanged; verified v0.2.0 is now kept under a 'v0.2.0' override.

Signed-off-by: dbczumar <corey.zumar@databricks.com>
2026-06-23 12:03:47 -07:00
Corey Zumar 6ffb3ed732 test(inbox): add e2e regression for re-parked elicitation resurfacing (#1033)
Covers omnigent#927: when a hook retry re-parks the same elicitation id
after the user already approved it, the inbox card must drop its stale
optimistic verdict and resurface as an actionable pending card instead of
staying frozen on "Approved" with no buttons.

Drives the live claude-native permission hook
(POST /v1/sessions/{id}/hooks/permission-request) to park an approval,
approves it in a real browser, then re-parks the SAME elicitation id
repeatedly with randomized timing, asserting the card returns to
data-state="pending" with Approve restored each cycle. Nightly +
live-server, matching the other tests/e2e_ui suites.

Signed-off-by: dbczumar <corey.zumar@databricks.com>
2026-06-23 12:00:09 -07:00
dbczumar f2d54c2aab backcompat: cite #779 in the sub-agent skip rationale
Pin the gap-fixing PR in the marker comments: #779 (add auth field to inner
ExecutorSpec; parse executor.auth in the loader) propagates an inline
sub-agent's auth (api_key + base_url) into the child executor. It landed ~2h
after v0.2.0 was tagged, so v0.2.0 just missed it and a v0.2.0 server routes
child sub-agents to the real gateway. Every release after v0.2.0 has the fix,
matching the min_server_version('0.3.0') threshold.

Signed-off-by: dbczumar <corey.zumar@databricks.com>
2026-06-23 11:51:53 -07:00
dbczumar d75626e748 backcompat: correct the sub-agent skip rationale
Root-caused the v0.2.0 failure (re-ran a marked test against a v0.2.0 server
with the waiting-status fix + log capture): the child sub-agent routes to the
REAL gateway, not the mock — the v0.2.0 server does not propagate the
per-sub-agent executor's mock auth.base_url, so the child's mock-only model
name (e.g. gpt-5.4-named-researcher) is rejected (HTTP 400) and never returns,
leaving the parent's auto-wake nothing to surface. Auto-wake itself works
(wake POSTs 2xx; waiting downgraded; no 500).

So the skip is correct but the earlier rationale was wrong: auto-wake is NOT a
post-v0.2.0 feature (it is present at v0.2.0). The real cause is a mock-LLM
test-infrastructure gap (per-sub-agent mock routing the v0.2.0 server doesn't
honor), the same class as the version floor — not a product regression.
Comments in all five marked modules updated accordingly.

Signed-off-by: dbczumar <corey.zumar@databricks.com>
2026-06-23 11:45:00 -07:00
ckcuslife-source 96a3da7920 fix(managed-hosts): harden dormant-host wake settle (follow-up to #1003) (#1036)
Two edge cases in the background wake path added by the resume/wake feature:

- `_run_managed_wake` settled the tracker as "ready" even when the woken
  host's tunnel had not (re)registered on this replica. `resume_managed_host`
  only waits on cross-replica host-store liveness, not this replica's
  in-memory `host_registry`, so the tunnel can lag or land on another replica
  — leaving the parked send to unblock with no runner and lose the first
  post-wake turn. Now it polls `host_registry` briefly and fails clearly if
  the host never reconnects, instead of settling "ready" without a runner.

- The parked message's rendezvous budget (`MANAGED_LAUNCH_RENDEZVOUS_TIMEOUT_S`)
  left only 60s on top of the 120s host-online wait to cover the provider's
  (unbounded) provision/resume call + host-tunnel reconnect + runner connect,
  so a slow cold launch/wake could time the message out even though the launch
  later succeeded. Widened the slack to 120s. Benefits the relaunch path
  equally (shared constant).

Co-authored-by: Isaac
2026-06-23 11:44:33 -07:00
Jenny c0b7399799 support word-wrap code blocks in addition to horizontal scroll (#966)
* fix(chat): word-wrap code blocks instead of horizontal scroll

Streamdown renders fenced code blocks with `overflow-x-auto` and the inner
`<code>` at `white-space: pre`, so long lines force a horizontal scrollbar
and can't be read without scrolling sideways.

Soft-wrap chat code blocks by default via the existing `ChatCodeBlockPre`
override, and add a wrap toggle button (next to the copy button) so users
can switch back to Streamdown's native horizontal-scroll view when column
alignment matters. Wrapped continuation lines get a hanging indent so they
align with the code rather than sliding under the line-number gutter.

The two overlaid buttons share a `CODE_BLOCK_OVERLAY_BUTTON_CLASS` and sit in
a single flex row anchored left of Streamdown's download button, so neither
needs a hardcoded horizontal offset.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(e2e_ui): cover chat code-block word-wrap default and toggle

Seeds (via external_assistant_message, no LLM) an assistant reply with a
fenced markdown block whose source has deliberately long lines plus one long
unbroken run, then asserts the observable wrap behavior:

- default: the code-block body does not overflow horizontally
  (scrollWidth <= clientWidth) and the toggle reports aria-pressed=true;
- after clicking "Toggle word wrap": the lines no longer wrap so the body
  overflows (scrollWidth > clientWidth) and aria-pressed=false;
- clicking again restores the wrapped, non-overflowing state.

Satisfies the e2e-ui-required gate for the ap-web wrap change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 11:17:28 -07:00
championj-db 384ddcc6c6 feat(repl): live sub-agent status in in SDK + inline navigator in the CLI REPL (#445)
* ADDED subagent status and selector for the CLI REPL

* 🐛 fix(repl): address self-review of the sub-agent status feature

Final-review fixes on top of the initial sub-agent status + selector work:

- Remove dead state: the write-only ``busy`` / ``last_preview`` node fields
  and the duplicate ``_MAX_SUBAGENT_TREE_DEPTH`` constant in ``_host.py``.
- Fix a poll-resurrection bug: ``GET /v1/sessions/{id}/child_sessions``
  reports a null ``current_task_status``, so the 2s tree poll was clearing
  ``done_at`` and resurrecting finished sub-agents (badge stuck on "N agents
  running"). Now ignore the poll's null status, settle poll-only nodes via
  the ``busy`` flag, and keep (never delete) finished nodes so the poll can't
  recreate them — they're hidden after the linger instead.
- Fix a runner-binding leak: reset ``_readonly_view`` on /switch, /clear and
  /new so a session change after a sub-agent dive can bind its runner again;
  consolidate root-tracking onto ``_readonly_view`` (removes a race-prone
  duplicate flag) and clear the sub-agent tree on session change.
- Refuse plain message sends while observing a sub-agent read-only.
- Correct stale "above the prompt" comments — the inline menu renders below
  the toolbar.

Co-authored-by: Isaac
Signed-off-by: Jared Champion <jared.champion@databricks.com>

* feat(repl): enable subagent chat selector (#5)

* feat(client): share the sub-agent busy rollup between the CLI and SDK (#6)

* feat(client): share the sub-agent busy rollup between the CLI and SDK

Follow-up to PR #445 (issue #444). PR #445 surfaced live sub-agent
status in the CLI REPL but kept all the recursion + rollup logic on the
client side, with only a one-level `child_sessions()` on the SDK. SDK
drivers (kzarzycki's eval loop) need a queryable "is anything in this
subtree still working?" because a parent's own `status` reads `idle`
once it delegates and returns to its own prompt.

Put the rollup in one shared place — `omnigent_client` — so the CLI and
SDK provably agree, additively and with no server changes:

- `_child_status.py`: canonical, stateless `child_session_busy` /
  `child_summary_busy` predicate mirroring the web `SubagentsPanel`
  semantics (awaiting-input counts as busy).
- `SessionsNamespace.child_sessions_tree()` (recursive BFS lifted from
  the REPL) + `subtree_busy()` rollup; `SessionsChat.tree_busy()` is
  the drop-in accessor an SDK driver gates "your turn" on.
- The terminal host's per-node decision and the REPL's tree poll now
  call the shared code (behavior-preserving) instead of re-deriving it.

Tests: predicate matrix, recursion/depth/cycle + rollup, chat
delegation, a CLI/SDK parity test, the REPL delegation path, and an
e2e subtree_busy assertion against a real sub-agent run.

Co-authored-by: Isaac

* test(repl): teach the discovery stub the shared child_sessions_tree

_refresh_subagent_tree now delegates recursion to the SDK's
child_sessions_tree, so the test_subagent_chat _DiscoverySessions stub
(which only implemented one-level child_sessions) left the tree unseeded
and failed test_resumed_session_with_children_repopulates_selector.
Reuse the real SDK recursion bound to the stub's child_sessions, mirroring
the _FakeSessions fix in test_subagent_registry.

Co-authored-by: Isaac

* fix(test): repl sub-agent e2e used the wrong poll helper

test_repl_subagent_panel_events_e2e polled GET /v1/responses/{id} via
poll_until_terminal, but the session is runner-native — that turn never
creates a pollable Responses object, so the request falls through to the
web SPA and returns index.html (200). resp.json() then raised
JSONDecodeError before any sub-agent assertion ran, so the test failed in
every mode (mock and real key) and never verified its contract.

Switch to poll_session_until_terminal (session snapshot; terminal == idle),
like every other runner-bound e2e test, and skip cleanly under the mock LLM
(which never emits the sys_session_send tool call that spawns the sub-agent).

Add test_child_sessions_sdk_live_e2e: a keyless, deterministic mirror that
creates real child/grandchild sub-agent sessions via parent_session_id and
pins child_sessions / child_sessions_tree / subtree_busy against the real
endpoint in the default (no-key) e2e lane.

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* fix(repl): stop polling child_sessions once sub-agents settle

The background sub-agent poll gated on has_any_subagents(), which stays
true forever: finished children are retained in the selector (web parity)
and the server keeps listing them. So after any sub-agent spawn the REPL
re-fetched the recursive child_sessions tree every 2s for the rest of the
conversation, even when fully idle.

Gate the recurring fetch on live work instead: an active sub-agent, or a
child the user has dived into (whose own stream can't refresh its row), or
a root change (the one-shot discovery poll). A terminal child's status no
longer changes, so the loop now goes quiet at the top level; a child that
later resumes re-arms it via the active stream's session.child_session.updated.
The down-arrow selector still lists finished children — only the wasted
polling stops.

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* fix(repl): place the down-arrow agents toolbar hint right after /help

The "↓ agents" hint was appended to the end of the toolbar hint row.
Insert it immediately after the /help entry instead, so it rides with the
primary navigation hints. Falls back to appending when the hint list has no
/help entry (e.g. a host built with a custom list).

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* fix(repl): open the sub-agent menu on the current session, not always main

Opening the ↓ menu always reset the highlight to row 0 (main), so after
diving into a sub-agent, reopening the menu showed main selected instead of
the sub-agent you were actually viewing. Pre-select the row whose session id
matches the active session (via active_session_id_getter); fall back to main
when the active session is unknown or absent from the list.

Signed-off-by: dbczumar <corey.zumar@databricks.com>

---------

Signed-off-by: Jared Champion <jared.champion@databricks.com>
Signed-off-by: dbczumar <corey.zumar@databricks.com>
Co-authored-by: dbczumar <corey.zumar@databricks.com>
2026-06-23 10:30:41 -07:00
dbczumar 3013eba3d1 backcompat: skip sub-agent auto-wake e2e tests against servers < 0.3.0
The {main, v0.2.0} window left after the version floor still failed the
sub-agent suite against a v0.2.0 server. Verified the root cause: sub-agent
auto-wake (the idle parent is re-dispatched when a named child completes) is
server-side support that shipped after v0.2.0 — test_cross_parent_named_
isolation_e2e fails against a v0.2.0 server even with a main runner carrying
the waiting-status fix (the child result never reaches the parent; no 500).

Mark the five sub-agent/auto-wake e2e modules min_server_version('0.3.0') so
the backwards-compat matrix skips them against older servers; they run
unchanged on main and in the normal gate. Scope is evidence-based: these are
exactly the modules whose tests failed with the auto-wake signature against a
v0.2.0 server in run 28036306894; other sub-agent e2e files passed and are
left unmarked.

Verified: test_cross_parent_named_isolation_e2e now SKIPs ('requires server
>= 0.3.0; running 0.2.0') in 6s against a pinned v0.2.0 server, vs a 262s
auto-wake timeout before.

Signed-off-by: dbczumar <corey.zumar@databricks.com>
2026-06-23 10:13:14 -07:00
Corey Zumar eae3516043 Merge branch 'main' into backcompat-min-version-floor 2026-06-23 10:01:10 -07:00
ckcuslife-source 12057be31b feat(managed-hosts): wake a dormant resumable host from the web (#1003)
A web session bound to a managed host whose sandbox idle-stopped showed a
terminal "Host is offline" state: the composer was disabled, so the user could
never send the message that would wake it. This adds a resume lifecycle for
managed sandboxes and surfaces it as a recoverable "asleep" state the user
wakes by sending a message.

Resume foundation:
- SandboxLauncher gains a `can_resume` capability flag (default False) and a
  `resume(sandbox_id)` method (default raises). Providers with a stop/resume
  lifecycle + a persistent volume override both; ephemeral providers (e.g.
  Modal) leave can_resume False so a dormant host there stays gone.
- managed_hosts.resume_managed_host(): wakes a dormant resumable host under the
  SAME sandbox id — resume + re-arm launch token + re-exec the host, preserving
  the workspace volume. Single-flight per host; a failed wake never tears the
  sandbox down (the volume is the user's).

Wake from the web:
- host_resume_supported() exposes the same gate resume_managed_host applies, and
  SessionResponse.host_resumable surfaces it on the open-session snapshot.
- The send-path relaunch fork routes a resumable dormant host through
  _maybe_relaunch_managed_sandbox to a background _kick_managed_wake /
  _run_managed_wake (resume in place via the launch tracker) instead of
  relaunching a fresh sandbox. The message parks on the rendezvous and forwards
  once the woken runner + transcript forwarder are ready.
- ap-web: useSessionLiveness gains a `host_asleep` variant (host down +
  host_resumable); ChatPage keeps the composer enabled and the placeholder tells
  the user the next message resumes the sandbox host (which can take minutes).

Tests:
- Unit: useSessionLiveness host_asleep cases + sessionsApi host_resumable mapping.
- e2e_ui: tests/e2e_ui/sessions/test_host_asleep_composer.py drives the
  host_asleep state via route interception and asserts the composer stays
  enabled with the resume placeholder.

Co-authored-by: Isaac
2026-06-23 09:55:26 -07:00
dbczumar d685a39bfa backcompat: floor the version matrix at v0.2.0
The 12h pairwise matrix was ~46/74 red, almost entirely from cells pinning
v0.1.0/v0.1.1. Those releases predate the mock-LLM e2e infrastructure
(tests/e2e/conftest.py: 0 mock refs at v0.1.x, 31 at v0.2.0) and the
runner-side harness mock routing, so main's mock-based e2e suite 401s
('Incorrect API key provided: mock-key' / 'Invalid API key') against them.
That's guaranteed-red infrastructure mismatch, not a compat signal.

Add a MIN_VERSION floor (default 0.2.0, overridable via BACKCOMPAT_MIN_VERSION)
to backcompat-pairwise-matrix.sh: release tags below the floor are dropped
with a logged reason (never silent); 'main' is never floored. The matrix
auto-grows as new releases (>=0.2.0) ship. Today: main + v0.2.0 (3 pairs,
12 e2e + 3 integration jobs) — the window where main's e2e infra is mutually
supported.

Signed-off-by: dbczumar <corey.zumar@databricks.com>
2026-06-23 09:52:41 -07:00
Tomu Hirata cd5233de02 fix(e2e-ui): route openai-agents harness to mock LLM, remove LLM_API_KEY from CI (#1027)
* fix(e2e-ui): route openai-agents harness to mock LLM, remove LLM_API_KEY from CI

Routes the runner subprocess's openai-agents harness to the in-process
mock LLM server by injecting OPENAI_BASE_URL/OPENAI_API_KEY into
runner_env in live_server. The runner no longer needs real Databricks
credentials for agent turns.

Changes:
- live_server: add OPENAI_BASE_URL=mock/v1 + OPENAI_API_KEY=mock-key to
  runner_env; set databricks-gpt-5-4 fallback ("Mock LLM response.") so
  seeded/hello_world tests pass with any assistant bubble
- approval_session: generate unique model name per fixture call so the
  tool-call queue can't be stolen by the previous test's runner (race
  condition when the runner's post-approval second LLM call fires after
  the next fixture has already configured a fresh queue)
- _run_render_parity_journey: reconfigure mock per-turn (reset + one
  content-keyed queue at a time) to avoid empty-queue tie-breaking when
  the openai-agents harness accumulates conversation history
- test_custom_agent_message_render_parity: pass mock_llm_server_url +
  mock_model so the echo_probe turns are served by mock
- e2e-ui.yml: drop api_key_ref + LLM_API_KEY everywhere — no real
  credentials needed, all agent LLM calls go through the mock

Confirmed: 7/7 tests pass locally without LLM_API_KEY set.

Co-authored-by: Isaac

* ci(e2e-ui): remove gateway config step — it overrode mock LLM routing

The "Configure native-claude/codex gateway provider" step wrote
~/.omnigent/config.yaml with an openai base_url pointing at the
Databricks serving endpoint. Even without api_key_ref the harness
picked up that URL and made requests to the real Databricks gateway
(which failed), rather than falling back to OPENAI_BASE_URL=mock/v1
in the runner env.

All tests now route through mock:
- openai-agents harness: OPENAI_BASE_URL injected into runner_env
- native claude/codex render-parity: native_*_mock_session writes its
  own fresh mock provider config at terminal-creation time

No Databricks config file needed.

Co-authored-by: Isaac

* test(e2e-ui): route all agent specs to mock LLM via plain model name

The databricks-gpt-5-4 model name forced the openai-agents harness onto
Databricks DEFAULT-profile auth (workflow.py:1415), which raised
DatabricksAuthError in credential-less CI — every agent turn failed and
no assistant bubble ever rendered. Renaming to a plain (non-databricks-)
model name lets the harness fall through to OPENAI_BASE_URL=mock.

- conftest.py / agents/conftest.py / test_chat_file_path_links.py:
  databricks-gpt-5-4 -> gpt-4o-mini in every inline agent spec; mock
  fallback key updated to match. Added the terminal_session mock config
  (launch/send/confirm tool sequence) so test_right_panel's sys_terminal
  flow is deterministic.
- test_message_render_parity.py: _ECHO_PROBE_MODEL -> gpt-4o-mini.
- test_multi_turn_chat.py / test_reload_continue.py: configure_mock_llm
  with content-routing so the token-recall turns are deterministic
  (drops the @llm_flaky reruns on multi_turn).

Multi-agent relay tests (test_two_agent_chat, test_subagent_navigation,
test_reload_continue) are @pytest.mark.nightly — excluded from the PR
gate; their full mock migration is tracked separately.

Co-authored-by: Isaac

* fix(e2e-ui): propagate mock LLM env to respawned runner

_ensure_runner_online respawns the runner after test_stale_stream kills
it, but the respawn env was missing OPENAI_BASE_URL and OPENAI_API_KEY.
The harness subprocess then found no OpenAI credentials and raised
ValueError for the non-Databricks model.

Store mock_llm_url in _server_state from live_server and mirror
OPENAI_BASE_URL/OPENAI_API_KEY into the respawned runner env.

Co-authored-by: Isaac

* test(e2e-ui): skip native tests without creds, mock fork_from_middle recall

- test_native_claude/codex_render_parity: skipif LLM_API_KEY absent —
  native CLIs control their own model/format and can't be reliably
  mocked (the mock returns the static fallback, not the echoed token).
- test_fork_switch_agent[sdk-to-claude-code/codex]: skip native target
  legs when LLM_API_KEY absent — the forked session boots a real native
  CLI that needs real credentials.
- test_fork_from_middle: configure content-routed mock for the recall
  turn so the clone echoes the kept marker deterministically.

Co-authored-by: Isaac
2026-06-23 16:20:05 +00:00
Daniel Lok c538476f3a bold (#1030)
Signed-off-by: Daniel Lok <daniel.lok@databricks.com>
2026-06-23 22:48:51 +08:00
Daniel Lok 7732835581 Order pinned sidebar sessions by pin time, not update time (#1016)
* Order pinned sidebar sessions by pin time, not update time

The Pinned section used `sortByUpdatedAtDesc`, the same comparator as
Recent/Shared/Archived, so a pinned session jumped to the top whenever a
new message bumped its `updated_at`.

Pin order is already tracked: `togglePinnedConversationId` prepends new
pins to `pinnedConversationIds`, so the array is most-recently-pinned
first. Add `orderByPinnedSequence` to sort the Pinned section by each
item's index in that array instead of by `updated_at` (newest pin on
top). Other sections still sort by update time.

Co-authored-by: Isaac

* Pin order: newest pin at the bottom + e2e coverage

Two follow-ups on the pinned-ordering change:

- Render newest pin at the BOTTOM of the Pinned group (oldest pin on
  top), matching the expectation that a freshly pinned session appears
  below the existing ones. `pinnedConversationIds` is stored
  most-recently-pinned-first, so `orderByPinnedSequence` now reverses it
  before ranking. This also corrects already-stored pins without a
  re-pin.
- Add a Playwright e2e test (tests/e2e_ui) that pins two sessions, bumps
  the bottom one's updated_at to be newest, and asserts it stays at the
  bottom — covering the UI behavior the `E2E UI Required` gate enforces
  and guarding the regression where the Pinned group sorted by
  updated_at.

Co-authored-by: Isaac
2026-06-23 21:30:44 +08:00
Daniel Lok 898a65aa79 docs(elicitation): correct PermissionRequest tool_use_id note; drop fake id from fixtures (#1024)
Claude Code's PermissionRequest hook payload carries no tool_use_id (verified against a real captured payload). The source comment called the field "not stable" rather than absent, and several test fixtures fabricated one — implying a parked prompt can be correlated to its tool call by id. It can't: there is no per-call id on PermissionRequest, so (tool_name, tool_input) is the only correlation available for the terminal-resolved fast path.

Correct the comment to say the field is absent (and why), and remove the fake tool_use_id from the PermissionRequest fixtures in both integration suites so they match the real wire shape. tool_use_ids inside tool_result transcript blocks are left untouched (those are real). No behavior change.

Co-authored-by: Isaac
2026-06-23 21:21:57 +08:00
Serena Ruan 54c5382a31 feat: Add Qwen Code as a harness (rebased + hardened #818) (#1020)
* feat(harness): add Qwen Code support

- Add qwen_executor.py: RPC-mode executor that spawns 'qwen --mode rpc'
  and communicates via JSONL protocol
- Add qwen_harness.py: FastAPI harness wrap mirroring claude-sdk/codex
- Register 'qwen' harness in _HARNESS_MODULES
- Add 'qwen-code' alias to HARNESS_ALIASES
- Include unit tests (test_qwen_executor.py) and e2e test
- Import order fixed to satisfy ruff E402/I001 rules

* feat(harness): add Qwen Code integration

This PR adds full Qwen Code support to Omnigent, mirroring the Kimi
integration pattern. The harness routes through OpenAI-compatible
providers and supports Databricks gateway authentication.

Changes:
- omnigent qwen CLI command with --resume support
- Spec validation for 'qwen' and 'qwen-code' harness identifiers
- Provider routing via HARNESS_QWEN_* env vars
- Databricks profile/model prefix detection
- Full integration with onboarding, runner, workflow, model layer

Files added:
- omnigent/qwen_native.py: Native Qwen wrapper for CLI
- docs/QWEN_FOLLOWUPS.md: Deferred work tracking

Tests updated:
- test_harness_install.py: Added qwen install spec test
- test_harness_readiness.py: Added expected_keys for qwen spellings
- test_provider_spawn_env.py: Added 2 tests for _build_qwen_spawn_env

Documentation:
- README.md: Added qwen to harness options comment
- AGENT_YAML_SPEC.md: Added Qwen section with examples

* test(qwen): expand test coverage and fix provider routing

- tests/inner/test_qwen_executor.py: Expand from 4 to 31 tests covering:
  * Registry/allowlist (OMNIGENT_HARNESSES, OMNIGENT_HARNESS_ALIASES)
  * FastAPI app shape (/health route present)
  * Env-var factory (HARNESS_QWEN_* → executor kwargs)
  * _build_argv (every flag passed to qwen)
  * Event translator (text_delta, tool_call, turn_complete, error)
  * run_turn end-to-end with stubbed subprocess
  * Missing-binary error path
  * Capability flags (handles_tools_internally, supports_streaming)
  * Session lifecycle and process termination

- omnigent/runtime/workflow.py: Add qwen to provider routing:
  * _PROVIDER_HARNESS_FAMILY: 'qwen': OPENAI_FAMILY
  * _HARNESS_GATEWAY_FLAG: 'qwen': 'HARNESS_QWEN_GATEWAY'
  * _QWEN_FAMILY_KEY: family key mapping for gateway base URLs

- tests/runtime/test_provider_spawn_env.py:
  * Add test_qwen_uses_openai_global_default
  * Add test_qwen_falls_back_to_catalog_default_model

* fix(qwen): resolve lint errors and test issues

- omnigent/qwen_native.py: Simplified to 99 lines from 324, matching kimi
  pattern using run.main(['--harness', 'qwen', *args]) instead of full
  native TUI launcher. Removed unused imports (asyncio, json, etc.)

- omnigent/cli.py: Fixed E501 line too long in _DEFAULT_HARNESS_PROMPTS

- omnigent/onboarding/harness_readiness.py: Refactored long condition
  to fix E501 error

- tests/inner/test_qwen_executor.py:
  * Removed unused imports (subprocess, sys)
  * Fixed test_tool_server_rejects_wrong_token with timeout handling
  * Simplified process_kill_on_timeout test to match actual behavior
  * Removed unused variable assignments in stubbed run_turn tests

* docs(qwen): add AgentCard.tsx comment and example

- ap-web/src/components/AgentCard.tsx: Add qwen to iconForAgent fallback
  logic (falls back to BotIcon like other non-native harnesses), update
  doc comments to document this behavior.

- examples/qwen_hello.yaml: Single-file launcher example for Qwen Code,
  mirroring the pattern of existing examples. Includes install instructions
  and provider configuration guidance.

* fix(qwen): resolve runtime crash and simplify implementation

- omnigent/qwen_native.py: Deleted entirely. The native TUI launcher
  was over-engineered (324 lines) with missing imports, unused variables,
  and dead code. Replaced with a simple 5-line forward to run.main.

- omnigent/cli.py: Simplified qwen command from 60 lines to 18 lines.
  Removed --server/--resume/--session options (not needed for headless
  harness). Now forwards all args directly to omnigent run --harness qwen.

- tests/cli/test_cli.py: Added test_qwen_command_forwards_to_run_main
  smoke test to catch this regression class in CI.

- tests/onboarding/test_harness_install.py: Fixed npm package name from
  @qwen/qwen-code to @qwen-code/qwen-code (verified on npm registry).

- ap-web/src/components/AgentCard.tsx: Removed dead code that checked
  agent.harness?.includes("qwen"). Added comment explaining qwen falls
  back to BotIcon for now.

- examples/qwen_hello.yaml: Fixed npm package name and simplified quick-start
  to use omnigent run instead of python -m omnigent.

* fix(qwen): rewrite QwenExecutor to use ACP (qwen --acp) protocol

The previous QwenExecutor was entirely broken against qwen v0.18+:

  1. Wrong launch flag: invoked 'qwen --mode rpc' which does not exist.
     The process exited immediately, causing EPIPE (Broken pipe) on the
     next write to stdin.

  2. Wrong protocol: the old executor spoke a custom JSONL dialect
     (session_start/text_delta/turn_complete) that qwen never implemented.

  3. Sync/async mismatch: called .drain() on a synchronous Popen
     TextIOWrapper which has no such attribute.

Fix: rewrite the executor to drive qwen via ACP (Agent Communication
Protocol), a JSON-RPC 2.0 protocol over newline-delimited stdin/stdout
launched with 'qwen --acp'. Session lifecycle:

  1. initialize   - one-time capability handshake per subprocess
  2. session/new  - create a session; use the server-assigned sessionId
                    (qwen may remap the client-proposed id)
  3. session/prompt - send user turn; consume streaming session/update
                      notifications (agent_message_chunk) and await the
                      final response with stopReason

The StreamReader limit is raised to 16 MiB to prevent the
'Separator is not found, and chunk exceed the limit' error on large
session/new responses (model lists etc).

Also fixes:
- Remove unused ToolCallRequest import in qwen_executor.py
- Fix stale 'RPC mode' comments in harnesses/__init__.py and e2e test
- Update docs/QWEN_FOLLOWUPS.md to reflect ACP instead of RPC mode
- Replace test_qwen_executor.py: old tests imported deleted _ToolServer
  and tested dead API. New tests cover construction, close() lifecycle,
  _rpc_id monotonicity, _read_stdout dispatch, _ensure_session server-ID
  handling, run_turn success/ACP-error/session-reset paths, and
  harness registry/alias wiring. All 22 tests pass.

Fixes #806

* fix(qwen): attachments, provider routing, permission gating, docs

- Forward attached files (fenced inline text) and images (real ACP image
  blocks when qwen advertises promptCapabilities.image); fixes weak models
  narrating tool calls as prose on file turns and dropped images.
- Add provider/gateway credential routing: translate HARNESS_QWEN_GATEWAY_*
  into OPENAI_BASE_URL/API_KEY/MODEL for the qwen subprocess (verified
  end-to-end vs an OpenAI-compatible gateway).
- Route session/request_permission through Omnigent's TOOL_CALL policy +
  elicitation; fix approval-event flattening and elicitation branding.
- Expand tests (executor, agent integration, gateway, wrap wiring);
  refactor QWEN_FOLLOWUPS by priority; remove examples/qwen_hello.yaml.

Co-authored-by: Isaac

* fix(qwen): address code-quality review nits + e2e drift guards on #1020

Code-quality bot nits:
- Comment the intentional empty except blocks in _read_stderr/_read_stdout
  (cancellation/EOF on shutdown is expected, not an error).
- Drop redundant local `import json` in _qwen_auth_configured (module-level
  json already imported).
- Remove dead `fake_readline_gen` helper in
  test_read_stdout_resolves_pending_future.
- Normalize test_cli.py to a single import style for omnigent.cli: import the
  qwen helpers directly and monkeypatch via string targets instead of
  `import omnigent.cli as c`.

E2E drift guards (CI shard 0/1 failures):
- Add qwen_perm_test to _ALT_COVERED in test_examples_coverage_sync.py
  (covered by tests/inner/test_qwen_agent_integration.py + the dedicated
  test_per_harness_qwen.py round-trip, not a test_example_<name>.py).
- Exclude qwen from test_run_harness_live_matrix_covers_registered_coding_harnesses:
  the qwen wrap routes via HARNESS_QWEN_GATEWAY_BASE_URL/AUTH_COMMAND rather
  than the shared HARNESS_<HARNESS>_GATEWAY probe wiring, so it can't ride the
  shared no-AGENT matrix; its live round-trip is covered by test_per_harness_qwen.py.

Co-authored-by: Isaac

* fix(qwen): remove unused constants flagged by code-quality on #1020

- qwen_executor.py: drop unused ACP method constants
  _AGENT_METHOD_SESSION_LOAD / _AGENT_METHOD_SESSION_CANCEL (only
  initialize/session.new/session.prompt are actually sent).
- qwen_harness.py: drop unused _TRUTHY_STRINGS (no _truthy parser here,
  unlike the sibling wraps it was copied from).
- workflow.py: drop vestigial _QWEN_FAMILY_KEY — it mapped families to a
  HARNESS_QWEN_GATEWAY_BASE_URLS (plural) object, but the qwen wrap routes
  via the singular HARNESS_QWEN_GATEWAY_BASE_URL + AUTH_COMMAND, so the map
  was never consulted.

Co-authored-by: Isaac

* fix(qwen): fix 3 ACP turn-loop correctness bugs in QwenExecutor

1. JSON-RPC id-namespace collision (CRITICAL): _read_stdout matched a
   message to a pending future by id alone. qwen mints its own request ids
   from a counter that can collide with ours, so a server-initiated request
   (e.g. session/request_permission) could resolve our prompt future with a
   request object — dropping the real response and hanging the turn. Now
   require "no method" before treating a message as a response.

2. Human-approval timeout (MAJOR): the turn deadline was absolute, but
   _respond_to_agent_request blocks synchronously on human elicitation. An
   approval slower than the remaining budget tripped a spurious timeout even
   though the user approved. The deadline is now idle-based — reset on every
   inbound message, including after the approval round-trip.

3. Chunk truncation race (MAJOR): the reader can enqueue several chunks and
   resolve the prompt future before run_turn drains the queue, so a bare
   fut.done() check returned with chunks still buffered. Completion is now
   gated on fut.done() AND an empty queue.

Adds regression tests for each (each fails on the pre-fix code).

Co-authored-by: Isaac

* fix(qwen): wake futures on stdout EOF + reset handshake on restart

Two crash-recovery correctness bugs in QwenExecutor:

- _read_stdout: a clean EOF (the normal manifestation of subprocess
  death) exited the reader without failing pending futures, so an
  in-flight session/prompt hung until the 300s idle timeout. Now fail
  pending futures with EOFError on EOF so run_turn fails fast.
- _start_process: _initialized is a one-way latch never reset on
  process death, so a restart after a crash skipped the ACP initialize
  handshake and qwen rejected the next session/new. Reset _initialized
  and _image_supported at the top of _start_process.

Also updates QWEN_FOLLOWUPS.md (OS sandbox under "What works today";
narrow the File I/O pending item to Omnigent-side execution/recording).

Co-authored-by: Isaac

---------

Co-authored-by: Ankush Bhatiya <ankushb@gmail.com>
2026-06-23 21:19:32 +08:00
xtra 65abadd46f docs: update ap-web README server defaults (#1017)
Co-authored-by: wxrth <191876097+wxrth@users.noreply.github.com>
2026-06-23 20:59:51 +08:00
Tomu Hirata 2336aa50dd test(e2e-ui): migrate approval tests from native Claude to mock LLM (#1015)
* test(e2e-ui): migrate approval tests from native Claude to mock LLM

Replace `native_claude_plan_session` / `native_claude_session` fixtures
with `seeded_session` in both approval tests. Instead of booting a real
Claude Code process and waiting up to 900 s for the model to call
ExitPlanMode / AskUserQuestion, each test now starts a background thread
that POSTs directly to the server's PermissionRequest hook endpoint with
a synthetic payload. The SPA renders the same approval card, the test
approves or submits, and the parked long-poll drains — same assertions,
seconds rather than minutes.

- test_exit_plan_mode: seeded_session, background thread POST
  ExitPlanMode payload, @pytest.mark.timeout(900→90)
- test_ask_user_question: seeded_session, background thread POST
  AskUserQuestion payload, @pytest.mark.timeout(900→90)
- e2e-ui.yml: fix stale OPENAI comment, note gateway config is now
  render-parity-only (approval tests no longer need it)

Co-authored-by: Isaac

* ci(e2e-ui): scope LLM_API_KEY to run step, drop GITHUB_ENV echo

Remove the "Set LLM credentials" step that wrote LLM_API_KEY into
\$GITHUB_ENV via echo, making the secret available to every downstream
step. The key is only needed by the native render-parity tests at
pytest runtime, so move it into the "Run UI e2e tests" step-level env
block — the runner subprocess inherits it from there to resolve
api_key_ref: "env:LLM_API_KEY" in ~/.omnigent/config.yaml.

The "Configure native-claude/codex gateway provider" step already
carries its own LLM_API_KEY step env and is unaffected.

Co-authored-by: Isaac

* ci(e2e-ui): remove LLM_API_KEY from run step env

Co-authored-by: Isaac

* fix(lint): wrap long plan string in exit_plan_mode test

Co-authored-by: Isaac

* ci(e2e-ui): remove api_key_ref and LLM_API_KEY from gateway config

Co-authored-by: Isaac

* test(e2e-ui): migrate native approval + render-parity tests to mock LLM

**Approval tests (hook-POST pattern):**
- test_persistent_approval: native_claude_session → seeded_session;
  background thread POSTs WebFetch to /hooks/permission-request so the
  server stamps remember_scope{host:github.com} without real Claude Code.
  Timeout 900→90s.

**Render-parity tests (mock provider config pattern):**
- test_native_claude_render_parity / test_native_codex_render_parity:
  native_*_session → native_*_mock_session (new conftest fixtures).
  Tokens pre-generated upfront; mock configured with match=user_marker
  content routing per turn + per-model fallback for internal calls.
  Timeout 900→300s, per-turn 180→60s.

**conftest additions:**
- configure_mock_llm gains a `match` param for content-based routing
- _CLAUDE_MOCK_MODEL / _CODEX_MOCK_MODEL constants
- _temp_omnigent_mock_config: writes mock provider to ~/.omnigent/config.yaml
  at terminal-creation time and restores on teardown
- native_claude_mock_session / native_codex_mock_session fixtures

test_native_cursor_render_parity unchanged — cursor-agent uses a
proprietary backend with no redirectable base URL.

Co-authored-by: Isaac

* test(e2e-ui): verify all 3 approval tests pass locally; add dual-mode to render-parity fixtures

- Confirmed all 3 approval mock tests pass locally (required SPA rebuild)
- native_claude_mock_session / native_codex_mock_session now check LLM_API_KEY:
  absent (CI default) → write mock provider config as before;
  present (local dev with real credentials) → leave ~/.omnigent/config.yaml
  untouched so the runner uses the real gateway

Co-authored-by: Isaac

* ci(e2e-ui): restore api_key_ref + scope LLM_API_KEY to config and run steps

Restoring api_key_ref: "env:LLM_API_KEY" to the anthropic and openai
provider blocks in ~/.omnigent/config.yaml, and adding LLM_API_KEY to
both the gateway-config step and the run step's env blocks.

The previous removal broke the openai-agents harness: the runner
subprocess reads ~/.omnigent/config.yaml via resolve_provider_for_build
and uses LLM_API_KEY (via api_key_ref) to authenticate to the Databricks
gateway for all agent LLM calls (echo_probe, hello_world, etc.). Without
it every test that expects an assistant response fails.

LLM_API_KEY is now scoped to the two steps that need it (no longer
written globally to $GITHUB_ENV) — the security improvement from the
earlier commit is preserved.

Co-authored-by: Isaac
2026-06-23 12:30:26 +00:00
Tomu Hirata b4779a0070 fix(polly-review): revert to pre-fetching diff, drop live gh fetch (#1018)
* fix(polly-review): revert to pre-fetching diff in workflow, drop live gh fetch

Pre-fetch the diff (capped at 512 KB) and lockfile pins in the trusted
workflow step and pass them directly in the prompt. This is faster and
more reliable than having Polly fetch the diff live via gh CLI, which
required a GH_TOKEN in the Polly run env and caused slow/stalling runs.

Also removes the now-unneeded Mint read-only token for Polly step,
GH_TOKEN, POLLY_PR_NUMBER, and POLLY_REPO from the Polly run env.
Polly can still read the checked-out codebase for additional context.

Co-authored-by: Tomu Hirata

* fix(polly-review): instruct Polly not to expose secrets or make unsanctioned network calls

Co-authored-by: Tomu Hirata

* fix(polly-review): handle pipefail SIGPIPE on diff cap, fix UTF-8 decode, drop duplicate fetch

- Add || true to the diff-fetch pipeline: head -c closes the pipe at the
  cap causing gh to exit 141 (SIGPIPE); without || true, pipefail aborts
  the step and the DIFF_TRUNCATED path is unreachable for large PRs
- Use errors='replace' in read_text() to handle truncated multi-byte
  UTF-8 sequences at the 512 KB boundary
- Extract lockfile pins from the already-fetched /tmp/pr_diff.txt instead
  of a redundant second gh api call

Co-authored-by: Tomu Hirata

* test(e2e-ui): migrate native approval + render-parity tests to mock LLM

**Approval tests (hook-POST pattern):**
- test_persistent_approval: native_claude_session → seeded_session;
  background thread POSTs WebFetch to /hooks/permission-request so the
  server stamps remember_scope{host:github.com} without real Claude Code.
  Timeout 900→90s.

**Render-parity tests (mock provider config pattern):**
- test_native_claude_render_parity / test_native_codex_render_parity:
  native_*_session → native_*_mock_session (new conftest fixtures).
  Tokens pre-generated upfront; mock configured with match=user_marker
  content routing per turn + per-model fallback for internal calls.
  Timeout 900→300s, per-turn 180→60s.

**conftest additions:**
- configure_mock_llm gains a `match` param for content-based routing
- _CLAUDE_MOCK_MODEL / _CODEX_MOCK_MODEL constants
- _temp_omnigent_mock_config: writes mock provider to ~/.omnigent/config.yaml
  at terminal-creation time and restores on teardown
- native_claude_mock_session / native_codex_mock_session fixtures

test_native_cursor_render_parity unchanged — cursor-agent uses a
proprietary backend with no redirectable base URL.

Co-authored-by: Isaac

* Revert "test(e2e-ui): migrate native approval + render-parity tests to mock LLM"

This reverts commit b20f6ce33b.
2026-06-23 19:42:51 +09:00
Abderrahmen Gharsallah 7bba2b4d52 Pin marked, DOMPurify, and highlight.js to exact versions and add SRI integrity hashes + crossorigin="anonymous" to all four CDN tags. The browser now refuses to execute any asset whose hash doesn't match, preventing a compromised or swapped CDN file from injecting code. (#945)
Signed-off-by: agharsallah <17379925+agharsallah@users.noreply.github.com>
2026-06-23 18:56:15 +09:00
Tomu Hirata 3b9cc53697 fix(cursor): surface native tool elicitations through web-UI approval card (#999)
* fix(cursor): wire preToolUse hook into long-poll elicitation gate (#992)

The cursor preToolUse hook timed out after 25 s (urllib timeout) / 30 s
(hooks.json outer limit), so ASK-gated native-tool calls disconnected
before the human could respond via the web-UI approval card. The server
detected the upstream disconnect, cleared the card, and the hook failed
open — meaning the tool ran without real approval.

Fix:
- cursor_policy_hook.py: replace urllib + 25 s timeout with
  omnigent.native_policy_hook.post_evaluate_with_retry (86400 s read
  timeout, stable elicit_evaluate_* id for retries, httpx with fast
  connect timeout). Matches the pattern used by claude/codex native
  hooks and allows the card to stay visible until the human responds.
- cursor_executor.py: add _HOOK_APPROVAL_TIMEOUT_S = 86400 constant
  and use it as the hooks.json subprocess timeout so Cursor doesn't
  kill the hook before the approval arrives.
- Tests: update cursor_policy_hook unit tests to mock
  post_evaluate_with_retry; add test asserting the 86400 s read timeout;
  fix hooks.json timeout assertion (30 → 86400).

Co-authored-by: Tomu Hirata

* fix(cursor): emit elicitations natively via ctx.elicit() for all native tool calls (#992)

`_evaluate_native_tool_policy` previously only called `_elicitation_handler`
when the policy evaluator returned ASK, which never happened in production
(the server holds ASK gates server-side and returns ALLOW/DENY). The result:
`ctx.elicit()` was never called from the cursor harness, so no
`response.elicitation_request` was emitted natively through the harness SSE
stream.

Fix the gate to match how claude_sdk_executor wires tool permission requests:

1. **Hard-deny check first** — policy DENY blocks immediately without
   prompting the human (admin decision).
2. **Native elicitation for everything else** — any other policy outcome
   (ALLOW, ASK, or no evaluator) calls `_elicitation_handler(name, args)`,
   which routes through `ctx.elicit()` → `response.elicitation_request` SSE
   event → web-UI approval card.  User approve → turn continues; deny →
   `run.cancel()` + ExecutorError.

Also fire the gate when `_elicitation_handler` is wired but `policy_evaluator`
is not (no server connection), so the native card still appears in that path.

Set `auto_review=True` on `LocalAgentOptions` so cursor's own TUI approval
prompts are bypassed — approvals now surface exclusively through the
Omnigent web-UI elicitation card instead of blocking silently inside cursor.

Co-authored-by: Tomu Hirata

* fix(lint): shorten test docstrings to stay under 99-char line limit

Co-authored-by: Tomu Hirata

* fix(cursor): use cursor-specific label in elicitation card (#992)

_stable_elicitation_handler hardcoded "Claude wants to call" and
policy_name="claude_sdk_permission" for all harnesses. Add harness_label
to ExecutorAdapter (defaults to "Claude" for backward compat) and derive
the card message and policy_name from it. cursor_harness passes
harness_label="Cursor" so the card reads "Cursor wants to use **{tool}**"
with policy_name="cursor_sdk_permission".

Co-authored-by: Tomu Hirata

* style: inline short boolean condition in cursor_executor

Co-authored-by: Tomu Hirata
2026-06-23 18:54:10 +09:00
Tomu Hirata 3d623ff60c revert(polly-review): remove iptables egress restriction (#1013)
The iptables approach caused too many issues — blocked tiktoken
downloads, App token mints, and other unforeseen hosts. Removing for
now; egress restriction can be revisited when the full set of required
hosts is known.

Co-authored-by: Tomu Hirata
2026-06-23 18:15:11 +09:00
Tomu Hirata 66074af7ae fix(polly-review): pre-cache tiktoken and move token mints before iptables DROP (#1012)
* fix(polly-review): pre-cache tiktoken and move token mints before iptables DROP

Two fixes for the iptables egress restriction:

1. Pre-cache tiktoken encodings (cl100k_base) before the iptables DROP
   rule so the Polly run doesn't fail resolving openaipublic.blob.core.windows.net
2. Move both App token mints (read-only for Polly + write for posting)
   before the iptables step so their GitHub API calls are not blocked

Co-authored-by: Tomu Hirata

* fix(polly-review): allow openaipublic.blob.core.windows.net for tiktoken

tiktoken fetches encoding data (cl100k_base etc.) from this host at
runtime. Add it to the iptables allowlist instead of pre-caching.
Drop the pre-cache step.

Co-authored-by: Tomu Hirata
2026-06-23 18:07:51 +09:00
Tomu Hirata 260586a488 fix(polly-review): replace bwrap egress_rules with iptables, drop bubblewrap (#1010)
* fix(polly-review): replace bwrap egress_rules with iptables, drop bubblewrap

The bwrap sandbox approach caused repeated failures:
- CONNECT not valid in egress_rules DSL
- bwrap failing to --tmpfs-mask dotdirs like ~/.ghcup under HOME read_path
- .cc-cli Claude CLI not visible inside the restricted filesystem view

Replace with iptables rules applied at the GitHub Actions runner level:
- ESTABLISHED/RELATED + loopback always allowed
- api.github.com allowed (gh CLI for PR diff/context)
- Gateway host allowed (LLM calls, resolved from GATEWAY_BASE_URL)
- All other outbound dropped

This is simpler, more reliable, and doesn't interfere with Polly's
tooling visibility. Also drops bubblewrap from the install step since
Polly uses sandbox:none and bwrap is no longer needed.

Co-authored-by: Tomu Hirata

* chore(polly-review): remove unnecessary polly-ci copy step

With iptables handling egress, there's no need to copy examples/polly/
to /tmp/polly-ci/ — just run from the source tree directly.

Co-authored-by: Tomu Hirata
2026-06-23 17:57:21 +09:00
Tomu Hirata 4f03d6620f fix(polly-review): use targeted home dotpaths instead of HOME in bwrap read_paths (#1009)
Adding the entire HOME as a read_path caused bwrap to fail with
"Can't mount tmpfs on /newroot/home/runner/.ghcup" — the dotfile masker
walked HOME, found large dotdirs like .ghcup, and tried to --tmpfs-mask
them, which bwrap couldn't do when the mount point didn't exist in the
new root.

Replace with specific paths Polly actually needs:
- ~/.omnigent (provider config)
- ~/.databrickscfg (gateway auth)
- ~/.config/gh (gh CLI auth)

Also add cwd_allow_hidden for dotdirs under GITHUB_WORKSPACE that Polly
needs: .venv, .cc-cli, .codex-cli, .omnigent.

Co-authored-by: Tomu Hirata
2026-06-23 17:32:54 +09:00
Tomu Hirata 61ca230947 fix(polly-review): add bwrap read_paths for workspace/home, fix SyntaxWarning (#1008)
Two issues found in CI after #1002:

- linux_bwrap sandbox was missing read_paths for GITHUB_WORKSPACE and
  HOME, so tools installed outside cwd (Claude CLI, gh, home configs)
  were not visible inside sandboxed shell commands. Added read_paths and
  write_paths: ['/tmp'] to make Polly's shell tools work under the
  egress-restricted sandbox.
- \| inside a Python f-string caused SyntaxWarning: invalid escape
  sequence. Escaped as \\| so the grep command is passed correctly.

Co-authored-by: Tomu Hirata
2026-06-23 17:25:32 +09:00
Tomu Hirata 751daa1be2 fix(polly-review): bump setup-uv to v8.2.0, drop invalid CONNECT egress rules (#1007)
- astral-sh/setup-uv v6.1.0 → v8.2.0 (fixes Node.js 20 deprecation warning)
- Remove CONNECT entries from egress_rules — CONNECT is not a valid HTTP
  method in the egress DSL; GET + POST are sufficient for the gateway
  and GitHub API

Co-authored-by: Tomu Hirata
2026-06-23 17:17:50 +09:00
Serena Ruan 74e8cfab92 fix(web-ui): allow following links in the markdown editor via ⌘/Ctrl+click (#1006)
The markdown rich-text viewer runs the Link extension with openOnClick:false,
and the link-following click handler was only attached in read-only mode. In
edit mode there was no way to follow a link (in tables or anywhere) — a click
just placed the cursor.

Unify both modes through one container handler: read-only follows any link
click; edit mode follows on ⌘/Ctrl+click while preserving plain-click for
cursor placement. Add tests covering all three paths.
2026-06-23 16:16:37 +08:00
Tomu Hirata 47453c357f fix(policies): log 400 error detail on /policies/evaluate (#1005)
The server silently swallowed 400 Bad Request errors on
POST /policies/evaluate — only ≥500 errors were logged, making it
impossible to diagnose why ~1-2% of policy evaluate calls fail closed
daily (observed since June 4 in otel_logs).

Server: add a WARNING log when evaluate_policy returns 400, including
the OmnigentError message, so future occurrences appear in otel_logs.

Hook: include the first 200 chars of the response body in the stderr
line already printed on 4xx, so the error message is also visible in
the hook subprocess's stderr (client-side diagnosis path).

Co-authored-by: Isaac
2026-06-23 08:14:40 +00:00
Tomu Hirata d7fc65946e fix(ci): enforce uv.lock integrity and extend security gate window (#1001)
* fix(ci): enforce uv.lock integrity and extend security gate window

Add `--locked` to every `uv sync` call in PR-gated CI (ci.yml, e2e-ui.yml,
e2e-run, integration-run) so a contributor-modified uv.lock that is
inconsistent with pyproject.toml fails loudly instead of silently
re-resolving to an attacker-chosen dependency graph. Previously only
lint.yml enforced `--locked`.

Also extend the security-gate poller from 72 × 5 s (≈ 6 min) to
108 × 5 s (≈ 9 min) and raise the job timeout-minutes to 12, shrinking
the fail-open window for slow security-scan runs.

Co-authored-by: Tomu Hirata

* fix(security): add OSV advisory scan for uv.lock changes

Adds a pip-audit step to the Security Scan workflow that checks every
package version pinned in the PR's uv.lock against the OSV advisory
database (known-malicious, typosquatted, and CVE-flagged versions).

The step only fires when uv.lock is in the PR's changeset, avoiding
false blocks when the baseline lockfile on main already has open
advisories. Uses uvx pip-audit (uv is already installed in the scan
job) with --no-deps so the audit reflects the lockfile's exact pins
rather than a re-resolved graph.

Co-authored-by: Tomu Hirata
2026-06-23 17:11:52 +09:00
Tomu Hirata 9a4473f757 fix(polly-review): harden against prompt injection (read-only token, secret masking, egress allowlist) (#1002)
* fix(polly-review): replace write-scoped github.token with read-only App token for Polly run

Mint a separate installation token restricted to pull_requests:read +
contents:read via actions/create-github-app-token, so Polly can use
gh CLI to fetch diffs without inheriting pull-requests:write from the
workflow's github.token. Eliminates the prompt-injection →
write/exfiltration path on attacker-controlled PR content.

Co-authored-by: Tomu Hirata

* chore(polly-review): update actions to Node.js 24, fix app-id deprecation

- actions/setup-python v5 → v6.2.0
- astral-sh/setup-uv v3 → v6.1.0
- actions/cache v4 → v5.0.5
- app-id → client-id in actions/create-github-app-token (deprecated input)

Co-authored-by: Tomu Hirata

* fix(polly-review): mask LLM_API_KEY, scan output for secrets, restrict egress to allowlist

Three prompt-injection mitigations:

1. add-mask: register LLM_API_KEY with the runner so it is redacted from
   any log or output that echoes it literally
2. Secret scan: grep review output for the literal key before posting;
   abort if found, preventing exfiltration via PR comment
3. Egress allowlist: write a CI-specific Polly config with
   egress_rules (linux_bwrap sandbox) restricting outbound HTTP to the
   gateway hostname + api.github.com only — arbitrary exfiltration URLs
   are blocked at the network namespace level

Co-authored-by: Tomu Hirata
2026-06-23 08:01:21 +00:00
Pat Sukprasert bf2eeb122e fix(runner): serialize continuation turn-start to fix parallel sub-agent 204 race (#523) (#996)
* fix(runner): serialize continuation turn-start to fix parallel sub-agent 204 race (#523)

A parent that fans out to multiple sub-agents intermittently failed its
turn with runner_error "turn failed (status 204)" (~23% in CI, never
locally). Root cause: two runner paths can start a turn for one session.
`_on_proxy_stream_end` pops `_active_turns` synchronously but only
schedules the continuation (`_check_and_start_next_turn`) as a deferred
task; in that window a sub-agent wake via `post_session_events` (which
checks `_active_turns` under the ingest gate) starts a turn, then the
deferred continuation — which never went through the gate or checked
`_active_turns` — starts a second. Two concurrent turn-driver POSTs hit
the harness; the second is folded in as an injection (HTTP 204), which
the runner treats as a fatal turn failure.

Fix (runner-only):
- Route `_check_and_start_next_turn` through the same per-conversation
  ingest gate as `post_session_events` and bail if a live turn already
  exists, so the two paths can never both start a turn (invariant I2).
- Gate the best-effort mid-turn injection forward on a live turn
  (`_live_response_id`, set on response.created / cleared at turn end):
  serializing the starters makes the loser buffer + forward, and a
  forward to a harness with no live turn would start a rogue turn that
  re-triggers the same 204. When skipped, the buffered copy still drives
  the continuation.

No harness/scaffold change (a stale-previous_response_id scaffold guard
was considered but rejected — it would break legitimate Responses-API
previous_response_id continuation).

Local: runner turn-ordering suite (187) + phase3 e2e (3) green. 30x CI
flake-stress to follow.

Co-authored-by: Isaac

* fix(runner): address review — key-membership I2 guard + clear live marker on cancel

Two correctness gaps from the Polly review:

1. The continuation's I2 bail used `isinstance(existing, Task)`, but a
   stream=True start leaves `_active_turns[conv]` as the `None` sentinel
   for the turn's life (never swapped to a Task). A Task-only check
   misses that live turn and would start a second one. Switch to
   key-membership (`session_id in _active_turns`), matching the
   runner-wide convention.

2. `_live_response_id` was cleared only via `_on_proxy_stream_end` and
   delete_session, but `_drain_streaming_response`'s CancelledError
   handler tears a turn down without routing through
   `_on_proxy_stream_end` — leaving a stale marker so the next turn's
   forward gate fires before its own response.created. Clear it there
   too (the third and last `_active_turns.pop` teardown site).

Runner turn-ordering suite (187) + phase3 e2e (3) still green.

Co-authored-by: Isaac
2026-06-23 07:55:49 +00:00
Ning Wang cf01cccb9f feat(ap-web): pinned-session hotkeys (Cmd/Ctrl + digit) (#967)
* feat(ap-web): pinned-session hotkeys (Cmd/Ctrl + digit)

Jump to the first ten pinned sidebar sessions with Cmd/Ctrl+1..9/0
(1–9 → first nine, 0 → tenth, browser-tab style). Desktop-only: the
hook, the per-row digit chips, and the shortcuts-dialog row are all
gated on the Electron shell, since a browser tab reserves Cmd/Ctrl+digit
for tab-switching.

Follows the existing useSessionSwitchHotkey pattern (once-bound,
ref-backed, metaKey||ctrlKey). PINNED_HOTKEY_DIGITS is the single source
of truth shared between the key binding and the UI chips.

Implements docs/superpowers/specs/2026-06-22-pinned-session-hotkeys-design.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(e2e_ui): cover pinned-session hotkeys under native shell

Adds Playwright e2e coverage for the desktop-only Cmd/Ctrl+digit
pinned-session hotkeys and per-row shortcut chips, satisfying the
"E2E UI Required" gate for the ap-web UI changes.

Injects a minimal window.omnigentDesktop stub via add_init_script so
the SPA's feature detection sees the Electron shell (same pattern as
test_idle_notifications), then asserts the chips render and Cmd/Ctrl+1/2
navigate to the matching pinned slots. A second case verifies the chip
is hidden and the hotkey is inert in a plain browser tab, proving the
desktop-only gate end-to-end.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* style(e2e_ui): apply ruff format to pinned-hotkey test

Reflow the chained locator call to satisfy the pre-commit ruff-format
gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(ap-web): drop inline pinned-hotkey chips, keep the hotkeys

Per PR review: the per-row ⌘N chips on pinned sidebar rows read as
cluttered. Remove them and rely on the ⌘/ shortcuts dialog (which already
lists "Jump to pinned session") for discoverability. The Cmd/Ctrl+digit
hotkey behavior and its desktop-only gating are unchanged.

Drops the ConversationRow shortcutDigit / ConversationSection
showPinnedShortcuts props, the now-unused MOD_KEY + isNativeShell imports
in Sidebar, and the chip-only unit test. The e2e test loses its chip
assertions but keeps the full hotkey-navigation coverage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 15:49:29 +08:00
Tomu Hirata 46879da7cb feat(polly-review): let Polly fetch the full PR diff via gh CLI (#1000)
* feat(polly-review): let Polly fetch the full PR diff via gh CLI

Remove the 64 KB hard cap on the pre-fetched diff. Instead, pass
GH_TOKEN + POLLY_PR_NUMBER/POLLY_REPO to the Polly run and instruct
it to fetch the diff itself with `gh pr diff`. This lets Polly read
the complete diff, skip lockfile noise, and fetch per-file diffs for
deeper inspection — all without a silent truncation.

Co-authored-by: Tomu Hirata

* fix(polly-review): review lockfile changes for supply chain risks

Instead of skipping uv.lock/package-lock.json, instruct Polly to
extract just the changed package names and versions and flag suspicious
pins: packages not in pyproject.toml, versions outside declared
constraints, and unexpected downgrades on security-sensitive packages.

Co-authored-by: Tomu Hirata
2026-06-23 07:14:47 +00:00
Tomu Hirata 4a685d902e Revert "feat(polly-review): let Polly fetch the full PR diff via gh CLI"
This reverts commit b4d54d147a.
2026-06-23 15:51:57 +09:00
Tomu Hirata b4d54d147a feat(polly-review): let Polly fetch the full PR diff via gh CLI
Remove the 64 KB hard cap on the pre-fetched diff. Instead, pass
GH_TOKEN + POLLY_PR_NUMBER/POLLY_REPO to the Polly run and instruct
it to fetch the diff itself with `gh pr diff`. This lets Polly read
the complete diff, skip lockfile noise, and fetch per-file diffs for
deeper inspection — all without a silent truncation.

Co-authored-by: Tomu Hirata
2026-06-23 15:51:33 +09:00
Tomu Hirata df5f4ae985 Revert "feat(polly): add /fix comment command and fix-blocking-issues skill"
This reverts commit f2e148c998.
2026-06-23 15:03:12 +09:00
Tomu Hirata f2e148c998 feat(polly): add /fix comment command and fix-blocking-issues skill
Adds a maintainer-only `/fix` comment trigger that instructs Polly to
identify blocking issues in a PR diff, dispatch implementer sub-agents
to fix them in isolated worktrees, cross-review each fix, and open fix
PRs. Gated to .github/MAINTAINER (same pattern as /regen). The review
comment footer now advertises the `/fix` command to maintainers.

Co-authored-by: Tomu Hirata
2026-06-23 15:02:55 +09:00
Tomu Hirata 3367a690f6 feat(polly-review): tighten blocking criteria and add package-extras guidance (#993)
* feat(polly-review): tighten blocking criteria and add package-extras guidance

Add two new sections to the CI review prompt:
- a double-check rule requiring reviewers to confirm a real correctness bug
  or contract violation before labeling something blocking (doubt → downgrade)
- package extras guidelines: one extra per harness, vendor-combine same-vendor
  integrations, one extra per sandbox, nothing else warrants a new extra

Co-authored-by: Tomu Hirata

* fix(polly-review): make "does this issue exist?" the primary blocking check

Co-authored-by: Tomu Hirata
2026-06-23 05:48:37 +00:00
Corey Zumar e5701fdd7f Backcompat: full pairwise (server, runner) version matrix, every 12h (#991)
* Backcompat: full pairwise (server, runner) version matrix, every 12h

Builds on the Config-2 harness merged in #990. Replace the four single-pin job
groups with one e2e + one integration job driven by a full pairwise matrix:
main + every non-rc release tag, crossed on both the server and runner axes.
Each cell pins the server and/or runner subprocess to that build; (main, main)
is omitted (the normal gate). Subsumes the old jobs — (old, main)=Config 1,
(main, old)=Config 2, (old, old)=both old — and auto-includes future tags.

- New .github/scripts/ci/backcompat-pairwise-matrix.sh emits the e2e (cells ×
  shards) and integration (cells) matrices; optional VERSIONS override.
- 'main' axis value maps to an empty composite-action input via the != ternary.
- Schedule every 12h; bounded max-parallel (matrix is versions² × shards).

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* Address Polly review on the pairwise matrix

- BLOCKING: artifact-name collisions. Every integration cell shares
  harness=openai-agents and every e2e cell shares a shard_id, so under one
  run_id upload-artifact@v4 would reject the duplicate names and fail the
  sweep. Add an artifact_suffix input (default '') to the e2e-run/integration-run
  composite actions, appended to all four artifact names; the pairwise jobs pass
  '-s<server>-r<runner>'. Default '' leaves the normal gates' names unchanged.
- Sanitize the VERSIONS CSV: trim whitespace, drop blanks, reject tokens that
  aren't 'main' or a release tag (also makes the matrix JSON injection-safe).
- Guard the 256-job matrix cliff: drop oldest versions until e2e jobs <= 256,
  logging each drop (no silent truncation).
- Tighten the rc filter ([^a-z]rc[0-9]) and drop the dangling doc reference.

Signed-off-by: dbczumar <corey.zumar@databricks.com>

---------

Signed-off-by: dbczumar <corey.zumar@databricks.com>
2026-06-22 22:36:42 -07:00
Corey Zumar 18167c9d92 Backwards-compat Config 2: old runner/host -> new server (#990)
* Add Config 2 backwards-compat: old runner/host -> new server

Mirror of the server-version harness for the agent side. Runner and host are
colocated (one install, one version), so a single knob pins both while the
server, client, and tests stay on main.

- tests/_helpers/compat.py: generalize the redirect into a component-parameterized
  core; add runner helpers (OMNIGENT_COMPAT_RUNNER_PYTHON): runner_executable,
  apply_runner_env (neutralize-only — drops the inherited worktree PYTHONPATH in
  compat mode, never force-adds a prepend), compat_runner_cwd, and the
  min_runner_version skip (pinned_runner_version reads OMNIGENT_COMPAT_RUNNER_VERSION;
  runner/host have no /api/version, so the env is the only source). server_* and
  the new runner_* are thin wrappers over the shared core.
- tests/e2e/conftest.py: redirect the runner subprocess (runner_executable +
  apply_runner_env + cwd=compat_runner_cwd); add the runner_version fixture's
  min_runner_version autouse guard; re-exported into tests/integration.
- Redirect all four host-daemon spawns (test_host_e2e x2, claude-native,
  codex-native) the same way so the OLD host launches OLD runners (colocated).
- min_runner_version marker registered in pyproject.
- Composite actions gain a runner_version input (build the old runner/host venv,
  export the redirect env vars); server-compat.yml adds backcompat-runner-{e2e,
  integration} jobs and is renamed Backwards-Compat (now both directions).

The server and runner knobs are orthogonal: each spawn site consults its own,
so a run pins exactly one component.

Out of scope (documented): the 3 niche custom-fixture direct-runner spawns
(filesystem/non-git changed-files, session_resources) keep their workspace-cwd
semantics and stay on the test python; tests/e2e_ui (needs an npm build). Both
run new-runner -> new-server (normal, no breakage) in a Config-2 run.

Verified: 26 unit tests; lint/format clean; both conftests import; and the
redirect provably loads OLD runner code (import omnigent.runner._entry resolves
to the pinned old source only with both the PYTHONPATH drop and the neutral CWD;
either counterfactual loads main).

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* TEMP: enable Backwards-Compat on PR (REVERT before merge)

workflow_dispatch needs the file on the default branch (not merged yet). Add a
pull_request trigger so the backcompat jobs (server + runner directions) run on
this PR for validation. Reverted before merge.

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* Revert temporary PR trigger on Backwards-Compat workflow

Config-2 backcompat validated on the PR (old runner/host -> new server: all
e2e shards + integration green). Restore dispatch/nightly-only triggers — the
backcompat sweep is not meant to run on every PR push.

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* Clarify backcompat job labels: 'latest' -> 'latest-release'

The fallback label read as 'newest/main' but means the latest released TAG —
which is older than main (unreleased). Rename so the job name ('server
latest-release') reconciles with the step ('against old server'): same pinned
release, older than the code under test.

Signed-off-by: dbczumar <corey.zumar@databricks.com>

---------

Signed-off-by: dbczumar <corey.zumar@databricks.com>
2026-06-22 21:50:20 -07:00
Hubert 67238a75b6 Snapshot test: chat (#948)
* Snapshot test: chat

Signed-off-by: Hubert Zub <hubert.zub@databricks.com>

* build flow

Signed-off-by: Hubert Zub <hubert.zub@databricks.com>

* test(e2e-ui): regenerate visual baselines

* test(e2e-ui): regenerate visual baselines

---------

Signed-off-by: Hubert Zub <hubert.zub@databricks.com>
Co-authored-by: Hubert Zub <hubert.zub@databricks.com>
Co-authored-by: omnigent-ci[bot] <294685417+omnigent-ci[bot]@users.noreply.github.com>
2026-06-23 06:05:59 +02:00
antoniopinheirofilho b23e277c2b fix(claude-native): persistent "don't ask again" approval for non-edit tools (#960)
* fix(claude-native): persistent "don't ask again" approval for non-edit tools

The web approval card only offered binary Approve/Reject for claude-native
PermissionRequests and never persisted an allow rule, so WebFetch (and every
non-edit tool) re-prompted on every call -- even repeated same-domain URLs --
unlike native Claude Code's "don't ask again for <domain>".

Mirror the edit-tool allow-all-edits (setMode) precedent for non-edit tools:
- Server stamps remember_scope on eligible tools (WebFetch -> HTTP(S) request
  host; others -> tool-wide) and, on accept-with-remember, emits an Agent SDK
  addRules PermissionUpdate (domain-scoped for WebFetch, tool-wide otherwise).
  Scope is re-derived server-side and re-gated by _allow_remember_eligible, so
  a client cannot spoof a rule for an ineligible tool.
- Web UI renders a third "Approve & don't ask again for <host|tool>" button
  (with a scope tooltip) sending only a {remember: true} intent.

Edit tools / ExitPlanMode / AskUserQuestion keep their existing flows.

Tests: backend unit (helpers) + integration (hook round-trips, tool-wide
fallback, edit-tool spoof guard, plain-accept); frontend component + SSE tests.

Closes #958

* test(e2e-ui): cover persistent "don't ask again" approval flow

Add a Playwright e2e_ui test (approvals/test_persistent_approval.py) that
drives a real Claude Code WebFetch call through the full
PermissionRequest -> ApprovalCard -> remember verdict -> addRules round-trip:
it asserts the domain-scoped "Approve & don't ask again for github.com"
button and its session-scoped tooltip, clicks it, and verifies the parked
elicitation drains (proof the addRules update reached the blocked WebFetch
call). Mirrors the sibling native-Claude approval tests
(test_ask_user_question.py, test_exit_plan_mode.py).

Also record the new coverage in tests/e2e_ui/COVERAGE_GAPS.md.

Satisfies the "E2E UI Required" gate for the ap-web changes in this PR.

* fix(claude-native): bracket IPv6 literals in WebFetch domain rules

urlparse().hostname strips the brackets off an IPv6 literal authority,
so the remember-host helper emitted a bare colon-laden atom
(domain:2001:db8::1). Claude's colon-delimited WebFetch(domain:<host>)
grammar mis-parses that, silently persisting a broken/inert allow rule
— the user clicks "don't ask again" and keeps getting prompted.

Re-bracket the literal (a registered domain name can never contain a
colon) so the emitted rule is domain:[2001:db8::1]. Update the unit
tests to assert the bracketed output.

Co-authored-by: Isaac

---------

Co-authored-by: Daniel Lok <daniel.lok@databricks.com>
2026-06-23 12:00:36 +08:00
Serena Ruan dbb75ab3d8 fix(e2e): de-flake test_repl_two_turns by syncing on reply text (#989)
`test_repl_two_turns_fires_one_approval_per_turn` waited for turn
completion via `_wait_for_turn_complete`, which expects the cosmetic
`· ready` idle-settle marker on the bottom toolbar. Under CI load that
repaint can race or not render within the timeout, producing a
`pexpect.TIMEOUT` even though the turn finished correctly (all the
load-bearing one-approval-per-turn assertions had already passed).

Both turn-completion waits now sync on the mock's scripted reply text
("Nice to meet you" / "Sure thing") — deterministic content that only
renders once the turn lands. This matches the pattern the rest of this
file already adopted away from `· ready` for the same reason.

Verified locally under background CPU load: the old version failed
~1-2/8-10 runs; the fixed version passed 10/10.

Co-authored-by: Isaac
2026-06-23 11:59:48 +08:00
Arya Buddha 23e3d555d1 fix(claude-native): keep the private tmux server alive past inner-CLI exit (#540) (#849)
A claude-native sub-agent (e.g. the Polly example, orchestrated headless)
registered "ready" but never received delegated messages: its backing tmux
server died, and every later send-keys / model-change / effort-change /
interrupt / stop failed with rc=1 "no server running on <socket>". The bridge
re-created the terminal on a fresh socket, which died the same way, so messages
were silently lost.

Root cause: each managed terminal runs exactly one inner CLI in a private,
single-pane tmux server launched with `-f /dev/null` (no config). tmux's
default `exit-empty on` reaps the whole server the instant that CLI exits, so a
single child-process exit becomes an unrecoverable "no server running" socket.
The claude CLI exits in the reporter's environment (WSL2) right after rendering
its prompt; codex survives because its inner process is a persistent daemon, so
only the claude-native worker was affected.

Make the private server resilient to an inner-CLI exit, opt-in per terminal so
other harnesses are unchanged:

- New `keep_alive_after_exit` flag on TerminalEnvSpec / TerminalInstance. When
  set, launch adds `remain-on-exit on` + `exit-empty off`, so the dead pane —
  and thus the session and server — persist after the inner process exits. The
  socket stays usable (control commands no longer hit "no server running") and
  the pane's final output stays capturable for diagnostics. Enabled for the
  claude-native agent terminal; codex / cursor / pi / REPL / generic terminals
  keep the default behavior.

- Liveness is now decided by `#{pane_dead}` instead of bare session existence,
  because remain-on-exit deliberately outlives the inner process. `is_alive`,
  both idle watchers (which now report the exit deterministically via
  `_pane_is_dead`), and `ws_bridge._tmux_session_alive` probe
  `tmux list-panes -t <target> -F '#{pane_dead}'` — list-panes errors on an
  unknown target (unlike display-message, which silently falls back to another
  pane), so it doubles as an existence check. This is behavior-preserving for
  non-opt-in terminals: their session vanishes on exit, the probe exits
  non-zero, and the verdict is unchanged.

Net effect: an inner-CLI exit becomes a clean, deterministic, diagnosable
terminal exit (the watcher fires on_exit with the final pane text available)
instead of an opaque, cascading "no server running" failure with silent message
loss. This does not change whether the third-party `claude` CLI stays running
on a given host — that is outside Omnigent's control — but it stops a single
exit from silently taking down the whole session.

Tests: opt-in launch options present / absent-by-default; spec->instance
propagation; the claude-native spec opts in; is_alive and the watcher report a
dead pane; ws_bridge reports a dead-pane session as not-alive; and a real-tmux
regression test proving the server survives an inner-process exit.
2026-06-23 11:42:26 +08:00
Zeyi (Rice) Fan 0c966bf612 ios: left-edge swipe opens the sidebar instead of navigating back (#984)
## Summary

- In the iOS WKWebView shell, repurpose the left-edge swipe to open the
  web app's sidebar rather than triggering WKWebView's back/forward
  navigation gesture (the two contend for the same edge).
- `OmnigentWebView`: disable `allowsBackForwardNavigationGestures` and
  add a left `UIScreenEdgePanGestureRecognizer` that, on `.began`, calls
  the model to ask the web app to open its sidebar. The Coordinator now
  conforms to `UIGestureRecognizerDelegate` so the edge swipe coexists
  with the page's own scroll/pan gestures.
- Extend the injected native bridge with an `onOpenSidebar(callback)`
  subscription and a frozen `__omnigentNativeEmitOpenSidebar` global,
  mirroring the existing notification-activation hook. `WebViewModel`
  gains `emitOpenSidebar()`.
- Web side: add optional `onOpenSidebar` to the native bridge interface
  and an exported `onNativeOpenSidebar` helper (no-op outside a native
  shell or under an older shell, swallows bridge errors). `AppShell`
  subscribes to open its sidebar in response.

## Type of change

- [ ] Bug fix
- [x] Feature
- [ ] Refactor / chore
- [ ] Docs
- [ ] Test / CI
- [ ] Breaking change

## Test coverage

- [x] Unit tests added / updated
- [ ] Integration tests added / updated
- [ ] E2E tests added / updated
- [x] Manual verification completed
- [ ] Existing tests cover this change
- [ ] Not applicable

## Coverage rationale

Added four unit tests for onNativeOpenSidebar (subscribe/unsubscribe,
missing hook, throwing bridge); ran `npx vitest run
src/lib/nativeBridge.test.ts` (27 passed) and `npx tsc -b` (clean). The
iOS shell compiles via `xcodebuild build -scheme Omnigent` (BUILD
SUCCEEDED); the gesture wiring itself is UIKit glue verified by the
successful build.

Co-authored-by: Isaac
2026-06-23 03:12:09 +00:00
Zeyi (Rice) Fan 1f1a4cc8cf fix(ap-web): protect TipTap type-only augmentation imports from oxlint --fix (#981)
## Summary

- `oxlint`'s `import/no-empty-named-blocks` rule flags the deliberate
  `import type {} from "@tiptap/..."` lines as empty named import blocks,
  so `oxlint --fix` silently deletes them. Those imports are type-only
  side-effect triggers for TipTap's TypeScript module augmentation (table
  and list commands); removing them breaks `editor.chain()` typings.
- Added inline `// eslint-disable-next-line import/no-empty-named-blocks`
  directives (with a documenting reason) above each of the three
  occurrences in `MarkdownEditorToolbar.tsx` and `TableBubbleMenu.tsx`,
  plus an explanatory comment on the previously-uncommented one in
  `TableBubbleMenu.tsx`. Suppressed case-by-case rather than disabling
  the rule repo-wide, so genuine stray empty imports are still caught.

## Type of change

- [x] Bug fix
- [ ] Feature
- [ ] Refactor / chore
- [ ] Docs
- [ ] Test / CI
- [ ] Breaking change

## Test coverage

- [ ] Unit tests added / updated
- [ ] Integration tests added / updated
- [ ] E2E tests added / updated
- [x] Manual verification completed
- [ ] Existing tests cover this change
- [ ] Not applicable

## Coverage rationale

Verified `npx oxlint` no longer reports no-empty-named-blocks on the two
files, confirmed a subsequent `oxlint --fix` leaves all three imports
intact (counts unchanged), and ran `npm run type-check` clean. This is a
lint-directive change with no runtime behavior to unit test.

Co-authored-by: Isaac
2026-06-23 03:01:52 +00:00
Zeyi (Rice) Fan 6dd9809a7c Add native Liquid Glass Chat/Terminal navigation bar (iOS) (#982)
## Summary

- Replace the in-webview Chat/Terminal pill with a native SwiftUI
  switcher rendered over the WKWebView. Uses iOS 26 `.glassEffect`
  (Liquid Glass), with an `.ultraThinMaterial` fallback for iOS 18-25.
- Two-way sync over the `omnigentNative` bridge: the web app owns the
  truth and pushes mode/terminalEnabled/terminalStartingUp/visible via
  `setViewMode`; native reports taps back via `onViewModeChanged`.
- The bar is an always-present, opacity-driven overlay (no insert/remove
  transition, so a transient visibility flip never slides it). The web
  reserves a fixed footprint via `.omnigent-native-bottom-spacer`, with a
  chat-specific variant that sits 1rem tighter since the composer's
  status line already cushions the gap.
- Hide the bar (and the server switcher) when a drawer/sidebar covers the
  surface via a reusable `useSurfaceFrontmost` hook, while staying visible
  under transient Radix dropdowns/popovers/selects (which set body
  `pointer-events: none` without covering the probe point).
- Drive it from the always-mounted `ConnectionIndicator` with a stable
  `nativeBarVisible` boolean so toggling Chat/Terminal updates in place.

## Type of change

- [ ] Bug fix
- [x] Feature
- [ ] Refactor / chore
- [ ] Docs
- [ ] Test / CI
- [ ] Breaking change

## Test coverage

- [ ] Unit tests added / updated
- [ ] Integration tests added / updated
- [ ] E2E tests added / updated
- [x] Manual verification completed
- [x] Existing tests cover this change
- [ ] Not applicable

## Coverage rationale

Existing ConnectionIndicator/indicator suites (48 tests) pass; web
type-check and oxlint are clean and the iOS target builds against the
26.5 SDK. Behavior was verified manually on device across chat/terminal
toggles, keyboard, and opening files/agents/sessions drawers vs model
dropdowns, since the bar's positioning and visibility are visual.

Co-authored-by: Isaac
2026-06-23 03:01:33 +00:00
Abedegno e2ab42ace6 fix(runner): propagate pi-native agent-spec resolution errors instead of dropping the sandbox (#812)
The two pi-native terminal auto-create paths (create-session and ensure)
wrapped _resolve_session_agent_spec in except OmnigentError -> spec = None,
so a genuine resolution error silently launched the terminal with
agent_spec=None, i.e. the platform-default sandbox, reintroducing the
fallback that #569 fixed. _resolve_session_agent_spec returns None
legitimately when there is no spec; only real errors raise, so letting them
propagate to the existing outer handler surfaces a start error instead of an
unknown sandbox policy. Document the agent_spec parameter on
_auto_create_pi_terminal.

Scoped to pi-native intentionally: the claude/codex sibling paths swallow and
log because their spec carries bundled skills (losing it is cosmetic), whereas
the pi spec carries os_env.sandbox, so failing loud is the right stance.

Addresses review nitpicks on #569.

Signed-off-by: abedegno <jon@jonwilliams.org.uk>
2026-06-23 11:01:04 +08:00
Daniel Lok f992ecd0bc fix(ap-web): base theme cycle skip on system theme, show current-mode icon (#942)
* fix(ap-web): base theme cycle skip on system theme, show current-mode icon

The theme switcher decided whether to skip a redundant cycle step using
`resolvedTheme`, which only reports the OS preference while the active
theme is "system". On a light OS the "system → dark → light" cycle would
still offer an explicit "light" step that renders identically to system.
Switch the skip check to `systemTheme`, which always reflects the OS
preference, so the redundant step is dropped symmetrically for light and
dark systems.

Also show the icon for the current mode rather than the next mode, so the
button reflects the theme you are on while the tooltip/aria-label continue
to announce the next click's action.

Update the unit and component tests to drive `systemTheme`, and add
coverage for the light-system skip the old behavior missed.

Co-authored-by: Isaac

* test(e2e_ui): align theme-toggle cycle with symmetric system-theme skip

The theme switcher now skips the redundant concrete mode that renders
identically to "system" (the one matching the OS preference). On the CI
runner's default light scheme the reachable cycle is therefore
system → dark → system, not system → dark → light → system, so the old
test's "Switch to Light" step no longer appears and the assertion failed.

Pin the OS preference with `emulate_media` so the cycle is deterministic
regardless of the runner's default, assert the light-OS cycle, and add a
mirror test under a dark scheme that reaches explicit light (skipping
explicit dark) so both concrete modes' DOM-class flips and persistence
stay covered.

Co-authored-by: Isaac

* test(e2e-ui): regenerate landing visual baseline

---------

Co-authored-by: omnigent-ci[bot] <294685417+omnigent-ci[bot]@users.noreply.github.com>
2026-06-23 10:06:01 +08:00
Zeyi (Rice) Fan 9cdcdd4b67 ios: show server selector on new-session page, hide find-in-page, fix dismiss shadow flicker (#979)
## Summary

- Surface the iOS native server selector on the new-session landing
  screen, not just inside an active conversation. Extracted the
  visibility hook from `ChatPage` into a shared
  `useNativeServerSwitcher` module (avoids a circular import, since
  `ChatPage` already imports `NewChatLandingScreen`) and wired it into
  `NewChatLandingScreen` against the landing surface element.
- Removed the "Find in Page" item from the iOS `ServerSwitcher` menu and
  dropped the now-unused `WebViewModel.showFind()`.
- Fixed a jarring UX glitch where the selector pill lost its drop shadow
  for a beat after the menu was dismissed. The chrome
  (material/border/shadow) was inside the `Menu`'s `label:` closure, so
  UIKit's menu-presentation snapshot dropped the shadow layer during the
  open/dismiss morph. Moved that chrome onto the Menu's persistent host
  view so it survives the snapshot.

## Type of change

- [x] Bug fix
- [x] Feature
- [ ] Refactor / chore
- [ ] Docs
- [ ] Test / CI
- [ ] Breaking change

## Test coverage

- [ ] Unit tests added / updated
- [ ] Integration tests added / updated
- [ ] E2E tests added / updated
- [x] Manual verification completed
- [ ] Existing tests cover this change
- [ ] Not applicable

## Coverage rationale

Web side verified with `npm run type-check` (clean) and the existing
suites `npx vitest run src/lib/nativeBridge.test.ts` (23 passed) plus
`src/shell/NewChatDialog.test.tsx` and `NewChatDialog.flow.test.tsx`
(132 passed, 1 skipped). iOS changes verified by a full simulator build
(`xcodebuild ... build` -> BUILD SUCCEEDED); the shadow-flicker fix is a
visual/timing behavior not expressible as an automated test.

Co-authored-by: Isaac
2026-06-23 01:49:05 +00:00
Yuan Tang d586eb8eb6 docs(codex-native): update stale config.toml isolation comment (#978)
The KNOWN LIMITATION docstring in read_codex_config_model still
described config.toml as symlinked and the per-session fix as
"not yet done", but the fix has been in place since #34
(_CODEX_HOME_COPY_FILES) and _pin_codex_config_model. Update the
comment to reflect the current copy-and-seed behavior.
2026-06-22 18:47:17 -07:00
Ruslan Dautkhanov a9d783619e fix(onboarding): normalize pasted Databricks workspace URL to scheme://host (#907)
The setup wizard's "Databricks — workspace" flow only stripped a trailing
slash from the entered URL, so a URL copied from the browser address bar
(e.g. https://my-ws.cloud.databricks.com/browse?o=1234567890) was saved as
the ~/.databrickscfg profile host and passed verbatim to `ucode configure`.
The Databricks CLI keys its OAuth token cache by host, so the path-laden
value resolved to "no access token" and `ucode configure` exited non-zero
(an easy slip, since pasting the browser URL is the natural thing to do).

Add a shared normalize_workspace_url() helper that reduces the URL to its
bare scheme://host origin (dropping any path/query/fragment), and apply it
at the wizard capture point (with a one-line notice when a path is dropped)
plus the two downstream chokepoints — login_databricks_workspace and the
ucode configure command builder — for defense in depth.

Co-authored-by: Isaac
2026-06-23 01:46:09 +00:00
Hz_Zhang 151db22770 fix(pi): forward attached images to the Pi harness (#516)
* fix(pi): forward attached images to the Pi harness

Images attached to a prompt were silently dropped by the `pi` harness
(the model replied as if no image was sent), while `claude` and `codex`
handled them. Two bugs in pi_executor.py:

- `_build_models_json` registered dynamic models without an `input`
  field, so Pi's transformMessages stripped every image block ("model
  does not support images") before the message reached the provider.
- `run_turn` JSON-encoded multimodal blocks into the `message` string,
  so Pi forwarded the image data URI as literal text. Split the blocks
  into `message` + Pi's native `images` field instead.

Closes #515

* fix(pi): surface malformed image blocks as ExecutorError; drop misleading file_id hint

Addresses review on #516: wrap _split_pi_prompt in run_turn so a bad
input_image yields an ExecutorError instead of crashing the turn, and
correct the error message (Pi needs an inline data URI; file_id is the
failing case, not a remedy).

* fix(pi): declare image input on static models; reuse shared data-URI parser

The dynamic-registration path in _build_models_json advertised image input,
but the run model is often a STATIC entry (e.g. databricks-gpt-5-4 / the Claude
models), and the append is skipped when the id is already listed — leaving
those entries with no `input`. Per the same mechanism this PR fixes, Pi's
transformMessages then still stripped attached images for the default models.
Declare `input: ["text", "image"]` on the static vision entries too, and add a
test covering a static id.

Also drop the duplicated `_parse_data_uri` in favor of the shared
`omnigent.inner.native_attachments.parse_data_uri` (already used by
codex_native_executor); its `;base64` suffix handling is more correct than the
private copy's `.replace`.

Verified end-to-end against the real `pi` binary: with the fix the image is
forwarded to the provider as `image_url` for a static model; reverting it makes
Pi emit an "image omitted" marker.

Co-authored-by: Isaac

* fix(pi): raise on unsupported prompt block types instead of dropping them

_split_pi_prompt only handled input_text/input_image and silently skipped any
other block (e.g. input_file, a resolved attachment block that carries a data
URI). The previous json.dumps(prompt) path surfaced those blocks as text, so
the silent skip was a data-loss regression for file attachments (Polly review).

Raise ValueError on an unsupported block type, and broaden run_turn's
prompt-prep except to Exception so any prep failure surfaces as an
ExecutorError rather than crashing the turn or silently dropping content —
also covering the implicit coupling to parse_data_uri's failure modes.

Co-authored-by: Isaac

* fix(pi): inline text input_file blocks instead of aborting the turn

Raising on input_file over-corrected: it's a reachable block (content_resolver
inlines every non-image file upload as input_file with a file_data data URI),
and the hard raise turned a previously-completing file-attachment turn into an
ExecutorError. Mirror codex_executor instead — decode text-like file_data into
the message so the model can read the file, and skip binary files with a
logger.warning. Reserve the hard raise for genuinely unknown block types.

Also document the deliberate blanket image-capability declaration on
dynamically-routed models (loud provider 400 on a text-only model beats a
silent image drop).

Co-authored-by: Isaac

---------

Co-authored-by: haozhe <haozhe@haozhes-MacBook-Pro.local>
Co-authored-by: Dhruv Gupta <dhruv.gupta@databricks.com>
2026-06-23 01:33:36 +00:00
Amin Siddique 8590dce828 feat: add kind: bedrock provider for AWS Bedrock and Bedrock-compat… (#901)
* feat: add `kind: bedrock` provider for AWS Bedrock and Bedrock-compatible gateways

* style: format ProviderKind literal for line length

* fix(bedrock): handle auth_command, fix credential routing, add setup-menu support

- claude_native: resolve a provider auth_command to a token (was silently
  dropped → fell back to Claude's own login); drop the dummy apiKeyHelper
  (Bedrock mode ignores it); warn when models.default is unset.
- connect: move AWS_BEARER_TOKEN_BEDROCK + ANTHROPIC_BEDROCK_BASE_URL into
  HARNESS_CREDENTIAL_ENV_VARS (mirroring ANTHROPIC_API_KEY / ANTHROPIC_BASE_URL)
  instead of the documented-non-secret _RUNNER_ENV_ALLOWLIST, so the bearer
  token no longer forwards to the remote daemon.
- workflow: fail loud for kind: bedrock on the in-process harnesses
  (claude-sdk / codex / pi / openai-agents) instead of silently emitting a
  generic gateway config that can't drive Bedrock.
- provider_config: bedrock surfaces only the anthropic family (native Claude);
  it no longer advertises the pi scope it cannot serve.
- configure_models / cli: add an "Amazon Bedrock — API key" setup-menu option
  and build_bedrock_provider_entry, so a bedrock provider is creatable via
  `omnigent setup`, not only by hand-editing config.yaml.
- tests: unit + CliRunner coverage for all of the above.

Co-authored-by: Isaac

* fix(bedrock): label credential "AWS Bedrock" instead of "Bedrock Bedrock"

The entry name is user-chosen (default "bedrock"), so labeling the credential
after the provider id rendered "Bedrock Bedrock" in the configure/REPL credential
pickers. Show "AWS Bedrock" (qualified by the entry name only for non-default
names), and align the setup-menu option label to match.

Co-authored-by: Isaac

* fix(bedrock): don't hand a bedrock default to pi; surface auth_command stderr

default_provider_for_harness skipped subscription/cli-config in the unmapped-
harness (pi) fallback but not bedrock, so a config whose only Claude default is
a kind: bedrock provider got handed to pi -> configure_agent_harness_with_provider
then raises INVALID_INPUT, turning a previously-working pi run (its own login)
into a hard error. Skip BEDROCK_KIND in the fallback (it's native-`omnigent
claude` only), matching provider_families which already omits PI_SURFACE for it.

Also include captured stderr in the auth_command failure warning so a
misconfigured command is diagnosable (stdout, which holds the minted token, is
still never logged).

Tests: pi skips a bedrock default (and returns None when bedrock is the only
default); auth_command failure -> None; missing models.default -> warns and
leaves model unset.

Addresses the Polly AI review follow-up.

Co-authored-by: Isaac

---------

Co-authored-by: AMIN SIDDIQUE <amin.siddique@mercedes-benz.com>
Co-authored-by: Dhruv Gupta <dhruv.gupta@databricks.com>
2026-06-23 01:30:48 +00:00
Serena Ruan f0c371e1d2 fix(native-harness): route run --harness <x>-native to the native TUI wrapper, like omni <x> (#944)
* fix(cursor-native): stop duplicate user messages in `run --harness cursor-native`

`omni run --harness cursor-native` (and the other `*-native` harnesses) went
through the materialized-launcher REPL, which drove an Omnigent turn per
message — persisting its own user item — while the harness forwarder also
mirrored the same message back from the TUI's transcript. Every user message
was recorded twice.

These are terminal-mirror harnesses whose turns originate in the TUI, so
dispatch straight to the native wrapper (the same path `omnigent cursor` /
`omnigent claude` / etc. run), keeping the TUI the single source of turns. A
top-level `--model` is forwarded as a passthrough flag; one-shot / fork /
--continue / --no-session fail loud since the TUI wrapper has no analog.

Also add a `cursor` branch to `_redirect_native_resume_if_needed` so resuming a
labeled cursor-native session via `omni run --resume <id>` hands off to
`omnigent cursor` too (the claude/codex/pi siblings already did).

Co-authored-by: Isaac

* fix(native-harness): address PR review — honor --continue, reject AGENT+native, fail loud on REPL-only flags

Follow-up to the native-harness dispatch, addressing Polly + Copilot review:

- #1 (--continue regression): `run --harness <x>-native --continue` no longer
  errors. It resolves the harness's most-recent conversation (by the native
  agent name, e.g. cursor-native-ui) and hands it to the wrapper as the session
  id, preserving the pre-dispatch resume-latest behavior. Precedence matches the
  REPL: explicit --resume <id> > --resume picker > --continue.
- #2 (AGENT-branch double-record gap): `run AGENT --harness <x>-native` is now
  rejected — the native TUI ignores the AGENT spec and the REPL path would
  double-record. Points at the dedicated subcommand.
- #3 (silently-dropped flags): --tools / --log / --debug-events are now threaded
  into the dispatcher and rejected loudly alongside -p / --system-prompt /
  --fork / --no-session, instead of being silently ignored.

Adds regression tests for all three (the prior tests passed without exercising
these paths): --continue resolves latest, explicit id skips the lookup,
AGENT+native is rejected, and each REPL-only flag fails loud (parametrized).

Co-authored-by: Isaac

* fix(native-harness): address follow-up review — loud --continue miss, clearer reject message

Second Copilot pass on the native-harness dispatch:

- `--continue` with no prior conversation now fails loud
  ("No prior conversation for agent …") instead of silently starting a fresh
  session — matches the REPL's _resolve_resume_target behavior.
- The unsupported-flags error no longer points at `omnigent <subcommand>` "for
  those options" (the subcommand doesn't accept them either — they'd be
  passthrough args). It now tells the user the REPL-only flags have no effect
  and to remove them.

Tests: add --continue-with-no-prior raises; assert the reject message says
"remove them" and names the flag.

Co-authored-by: Isaac
2026-06-23 09:20:10 +08:00
Matei Zaharia 83aa7a97ca Fix Pi Databricks GPT-5.5 caps (#928) 2026-06-23 01:02:56 +00:00
Pat Sukprasert a6095b288d test: split subagent_ask parent/worker mock queues to fix intra-test race (#523) (#972)
test_repl_subagent_ask_does_not_tunnel_banner_to_root still flaked in CI
after #932 ("the worker may have parked waiting for an approval that
never comes"). #932 cured CROSS-test contamination by content-routing
the mock, but this test carried its single `match` token into the
delegated task, so parent AND worker both routed to the same queue — the
INTRA-test race survived: sys_session_send returns immediately, so the
parent's post-spawn continuation call races the worker's call for the
shared queue; when the parent eats the worker's reply, the worker parks.

Fix mirrors the subagent_tool_call sibling: route parent and worker to
separate content-routed queues on distinct, mutually-non-substring
tokens — "saask-parent" only in the root user message, "saask-worker"
only in the delegated task. Sync on the parent-summary marker (rendered
only after the worker's result lands) instead of the racy `· ready`
toolbar, matching the docstring's stated load-bearing assertion. Dropped
the now-unused single-queue helper _configure_mock_subagent_spawn and
the flaky worker-reply-on-root assertion (parent summary is the
deterministic no-parking proof). No fixture/product change.

Verified 5/5 locally; 30x CI flake-stress to follow.

Co-authored-by: Isaac
2026-06-23 00:31:03 +00:00
Zeyi (Rice) Fan e9b7da2cdc ios: setup fastlane (#971) 2026-06-23 00:14:35 +00:00
Corey Zumar da73e51f50 Server-version backwards-compatibility CI harness (#896)
* Add server-version backwards-compat CI harness

Run main's network suites (e2e + integration) against a pinned older
server to catch backwards-incompatible server changes.

- Redirect the server subprocess to a pinned old build via
  OMNIGENT_COMPAT_SERVER_PYTHON: swap interpreter, drop the worktree
  PYTHONPATH prepend AND neutralize CWD (both shadow sys.path). Runner
  stays on main (tracks the client/test version).
- min_server_version marker + server_version fixture/guard. /api/version
  is source of truth; OMNIGENT_COMPAT_SERVER_VERSION is a backstop and a
  shadow tripwire (fail loud on disagreement). Release-tuple comparison
  so a .devN of X satisfies min_server_version(X).
- Bump dev version to 0.1.2.dev0 across the 3 packages + uv.lock so
  /api/version sorts ahead of released tags.
- server-compat.yml workflow (compat-e2e sharded + compat-integration
  per-harness), building the old server from its git tag into a venv.
- docs/SERVER_VERSION_COMPAT_CI.md spec; tests/test_server_compat.py.

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* TEMP: enable server-compat.yml on PR as a smoke (REVERT before merge)

workflow_dispatch needs the file on the default branch, which it isn't
until #896 merges. Add a pull_request trigger + trim to one e2e shard and
one integration leg so the compat harness actually executes on Actions
(build old server from tag -> redirect -> run suite). Reverted before merge.

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* Parameterize e2e/integration run logic via composite actions; backcompat reuses them

Root cause of the flaky maiden backcompat run: server-compat.yml mirrored the
OLD real-LLM e2e.yml, but main migrated e2e/integration to the in-process mock
LLM. Fix the drift at the source.

- Add .github/actions/e2e-run and .github/actions/integration-run composite
  actions holding the exact run steps (mock LLM), with an optional
  server_version input that builds the pinned old server + redirects the
  server subprocess to it.
- e2e.yml / integration.yml now call the actions (no server_version) — same
  steps, same job names (E2E Tests (shard ..) / Integration (..)) so the
  Merge Ready required gate is unaffected. Composite (not reusable workflow)
  to preserve those check names.
- server-compat.yml: clearly-labeled backcompat-e2e + backcompat-integration
  jobs call the SAME actions with server_version set. Full matrix (mock LLM
  is free of gateway cost), no drift from the gates.
- Move the per-step timeout to job level (composite steps can't set it).

REVERT before merge: the temporary pull_request trigger on server-compat.yml
(lets the backcompat jobs run on this PR; backcompat is dispatch/nightly only).

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* Backcompat reuses the gates' matrix scripts (no hardcoded harness list)

The backcompat-integration job hardcoded a stale 3-harness matrix
(claude-sdk/openai-agents/codex) copied from the pre-mock workflow. But the
real integration gate runs only openai-agents — claude-sdk/codex reject the
mock LLM's 'mock-model' and were removed (see integration-matrix.sh). So the
backcompat job ran two legs the gate never runs, failing on that known
reason (noise, not a compat signal).

Add a setup job that computes BOTH matrices from the same scripts the gates
use (e2e-shard-matrix.sh / integration-matrix.sh); backcompat-e2e and
backcompat-integration consume them. Now backcompat runs exactly the
shards/legs the gate runs per event, with no hardcoded list to drift.

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* Remove temporary PR trigger from server-compat.yml

Backcompat validated on the PR; restore dispatch/nightly-only triggers.
The jobs reuse the gates' composite actions + matrix scripts, so a manual
dispatch (or the nightly schedule) runs them once this lands on main.

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* Keep server-compat.yml PR trigger for backcompat triage on the PR

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* e2e: ship decorated-tool source in the bundle (archer pattern), not tests/ callables

test_decorated_tools_e2e registered agents whose function tools were dotted
callables into the repo's tests/ tree (tests._fixtures... / tests.resources...).
On the server-version-compat run the old server is isolated and can't import
tests/, so bundle-load failed with HTTP 400 'function-type tool has no resolved
callable'. That's a test shortcut, not a product break: a real agent ships its
tool code IN the bundle.

- New fixture tests/resources/agents/decorator-tools/ (config.yaml + tools/python/
  {word_count,greet,format_record,compute}.py with @tool), mirroring the archer
  fixture: executor.type=omnigent + config.harness=openai-agents + os_env
  caller_process, tools auto-discovered and loaded by file path from the bundle.
- New helper register_dir_agent_with_mock_llm: tars the dir, stamps name +
  executor.model + an executor.auth mock-LLM block, uploads. Keeps the
  openai-agents + mock-LLM flow and the mock scripting/assertions unchanged.
- Both tests now load tools from the uploaded bundle, so they run on any server
  version with no tests/ dependency.

Verified against an isolated v0.1.1 server (cannot import tests/): POST
/v1/sessions -> 201 (was 400); the 4 tools discover and execute (greet->Hello
Alice, compute(5)->product 10, word_count->3).

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* e2e: ship async-tools + tool_call-policy tool source in the bundle, not tests/ callables

Same backcompat fix as the decorated-tools tests: register_inline_agent declared
function tools as dotted callables into the repo's tests/ tree, which 400 on the
server-version-compat run (the isolated old server can't import tests/).

- test_async_tools_e2e.py: new fixture tests/resources/agents/async-tools/
  (config.yaml + tools/python/{delayed_echo,boom_async,count_chars}.py with @tool);
  all 3 register calls use register_dir_agent_with_mock_llm.
- test_tool_call_policy_e2e.py: new fixture tests/resources/agents/tool-call-policy/
  (config.yaml carries the tool_call:calculate DENY policy verbatim + tools/python/
  calculate.py); register call uses register_dir_agent_with_mock_llm.

tests/e2e/omnigent/test_run_omnigent_policy_enforcement.py is intentionally NOT
converted: it runs 'omnigent run' in a subprocess with cwd=repo_root (so tests/
is importable) and never touches the compat-redirected live_server, so it does
not 400 on backcompat.

Verified against an isolated v0.1.1 server (cannot import tests/): both fixtures
discover their tools and POST /v1/sessions -> 201 (was 400); the tool_call-policy
bundle resolves both the calculate tool and the make_fixed_action_callable policy.

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* Pre-merge prep for server-compat: ruff format + dispatch/nightly-only triggers

- ruff format the new test/fixture/helper code (ruff check passed locally but
  format was not run, so pre-commit's ruff-format reformatted them in CI).
- server-compat.yml: drop the temporary pull_request trigger (validation done)
  and set the schedule to every 4 hours (cron 0 */4 * * *).

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* Drop docs/SERVER_VERSION_COMPAT_CI.md from the PR

Untracked (kept on disk) — not part of the merge per request.

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* tests: allowlist bundled-tool fixture agents in coverage-sync

The 3 new tests/resources/agents/ fixtures (decorator-tools, async-tools,
tool-call-policy) are covered by shared e2e tests, not test_example_<name>.py,
so add them to _ALT_COVERED (test_every_agent_has_a_dedicated_test_file).

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* fix(test): nest tool-call-policy under guardrails.policies

The config.yaml dir-bundle parser (omnigent.spec.parser) reads policies from
guardrails.policies and ignores a top-level policies: block — so the converted
fixture's DENY policy never loaded (spec.guardrails was None) and calculate ran
(tool output '12') instead of being denied. The inline single-YAML form the
test used before accepts top-level policies:, which masked the difference.

Verified: parse() now loads deny_calculate_tool under guardrails, and the
make_fixed_action_callable builtin denies tool_call:calculate with the sentinel
(allows other tools/phases).

Signed-off-by: dbczumar <corey.zumar@databricks.com>

---------

Signed-off-by: dbczumar <corey.zumar@databricks.com>
2026-06-22 17:02:44 -07:00
Ruslan Dautkhanov c2880e60c5 fix(cli): URL linkifier no longer embeds the SGR reset, fixing "0m" before links (#909)
The terminal linkifier wraps bare http(s) URLs in OSC 8 hyperlink escapes by
matching them with `_URL = r"https?://[^\s\)\]\>\"'<]+"`. That character class
did not exclude the ESC byte (\x1b), so when Rich styles an autolinked URL —
`\x1b[..m<url>\x1b[0m` (color/underline + reset) — the regex swallowed the
trailing `\x1b[0m` reset into the URL and embedded it INSIDE the OSC 8 link
target:

    \x1b]8;;http://localhost:5173\x1b[0m\x1b\\...
                                 ^^^^^^^ reset escape inside the link target

Terminals mis-parse that malformed hyperlink and leak the reset's tail "0m" as
visible text before the URL (e.g. "0mhttp://localhost:5173") — which appeared
before every link in the CLI.

Exclude all C0 control bytes and DEL (\x00-\x1f, \x7f) from the URL class so the
match stops at the ESC; the reset then stays outside the OSC 8 envelope and the
hyperlink is well-formed. Real URLs never contain raw control bytes (they are
percent-encoded), so this is always safe.

Adds a regression test for a URL followed by a trailing SGR reset (the exact
Rich autolink shape), which the existing tests didn't cover.

Co-authored-by: Isaac
2026-06-22 23:58:01 +00:00
Akshat katiyar 6e52224ae2 feat(server): strip configurable identity-header prefix for Google IAP (#954)
Header-auth mode now honors OMNIGENT_AUTH_HEADER_STRIP_PREFIX, removing a
configured prefix from the trusted identity header value. Google IAP
forwards X-Goog-Authenticated-User-Email namespaced as
accounts.google.com:<email>; stripping the prefix recovers the bare email
used for ownership/sharing. Generic (not IAP-specific) so any proxy that
namespaces its identity header is supported.

Reserved-name rejection runs after stripping, and a value that is only the
prefix (empty after strip) fails closed. Default unset = strip nothing, so
existing header-mode deploys are unaffected.
2026-06-22 23:50:41 +00:00
Yuan Tang 693ddc614c feat(repl): render schema fields as interactive terminal prompts (#926)
* feat(repl): render schema fields as interactive terminal prompts

When the REPL accepts an elicitation whose schema has fields that
can't be auto-filled (free-form strings, numbers without defaults),
prompt the user for each value interactively instead of silently
declining.

Uses the same asyncio.Future pattern as the approval flow to avoid
prompt_toolkit/patch_stdout conflicts.

* fix(repl): harden interactive schema-field prompts

- Render field labels and the input echo as styled Text instead of
  Text.from_markup, so server-provided schema text (description, enum,
  key) is no longer parsed as Rich markup — a stray "[" previously
  mangled the line and an unbalanced tag raised MarkupError, crashing
  the elicitation task and hanging the turn. Also decline (rather than
  hang) if _prompt_schema_fields raises.
- Make Esc actually abort field collection via an `aborted` flag on
  _FieldInputState; previously cancel() resolved with "" (same as an
  empty submit), so the loop advanced and the next message was
  swallowed as field input.
- Re-prompt the offending field on invalid/empty-required input instead
  of declining the entire form and discarding already-entered values.
- Expand tests/repl/test_field_input_state.py from 6 to 20, adding
  coverage for _prompt_schema_fields (parsing, validation, re-prompt,
  abort, and markup-safety).

Co-authored-by: Isaac

---------

Co-authored-by: Dhruv Gupta <dhruv.gupta@databricks.com>
2026-06-22 22:58:11 +00:00
Zeyi (Rice) Fan 3675d8461e introduce iOS app (#965) 2026-06-22 22:38:41 +00:00
Dhruv Gupta 17ed40684c chore: bump main to 0.3.0.dev0 (#766)
0.2.0 shipped from release/v0.2.0, so move main off the released version to the
next dev marker. Keeps every main build PEP 440-ordered as "ahead of 0.2.0, not
yet 0.3.0" so the update check / `omni upgrade` never mistake a dev build for a
stale release. Bumps the three lockstep packages (versions + cross-pins) and
uv.lock (hand-edited — not `uv lock`, which would rewrite registry URLs to the
internal proxy).

Co-authored-by: Isaac
2026-06-22 22:04:20 +00:00
Dhruv Gupta 24cb72cd5a docs(release): RELEASING runbook (#740)
* docs(release): add RELEASING runbook

Documents cutting an omnigent release through the central secure-publishing
repo (databricks/secure-public-registry-releases-eng → `omnigent` workflow):
the dev-version / per-minor-release-branch model, the lockstep three-package
version bump (incl. the hand-edit-uv.lock / no-`uv lock` proxy-leak caveat),
TestPyPI validation → prod, and verify-and-edit of the release notes.

The runbook references .github/workflows/github-release.yml, added in the
sibling PR.

Co-authored-by: Isaac

* docs(release): address Polly review — safer validation, recovery, role names

- push the explicit tag (not --tags) so stray local tags can't ship
- validate TestPyPI without --extra-index-url (dependency-confusion safe):
  deps from real PyPI, candidates from TestPyPI --no-deps exact-pinned
- replace hardcoded personal account handles with OSS/EMU roles + placeholders
- add an "if a publish goes wrong" recovery section (PyPI yank, never reuse versions)
- clarify uv.lock has no wheel hashes for the editable workspace members
- gate tagging on green CI; repeat the no-`uv lock` warning in the main bump
- explicit `git add` instead of `commit -am`; "circular" -> "lockstep";
  access prereqs; fuller patch-release flow

Co-authored-by: Isaac
2026-06-22 14:55:35 -07:00
Yuan Tang ace855feca feat(tools): implement ToolManager shutdown lifecycle (#923)
* feat(tools): implement ToolManager shutdown lifecycle

Wire up proper cleanup on tool teardown: close self-created OS
environments, invoke shutdown() on every registered tool, and
guard ephemeral ToolManager instances with try/finally in the
runner dispatch path.

* style: collapse single-arg logger call to one line

Pre-commit formatter requires the _logger.warning call to fit
on a single line.
2026-06-22 21:26:53 +00:00
Corey Zumar 992a458af2 fix(triage): make P2 the default for substantive feature requests (#964)
The P2/P3 line for feature requests ('important' vs 'nice-to-have') was
subjective, so the triage bot rated equivalent requests inconsistently — e.g.
'add Copilot/Antigravity harness' got P2 but 'add OpenCode/Gemini harness' got
P3. Sharpen the rubric: a feature that adds a real new capability (new
harness/provider/model/integration, a new tool, or a new user-facing workflow)
is P2 by default; reserve P3 for genuinely minor/cosmetic/trivial changes; when
unsure between P2 and P3, choose P2.

Prompt-only change — no change to the injection-hardened, tool-free classifier
architecture. Verified by A/B test on real issues: #45/#89 (OpenCode/Gemini)
flip P3->P2; #56/#92 (Antigravity/Copilot) stay P2; #206 (cosmetic UI) stays P3.
2026-06-22 13:56:09 -07:00
Yuan Tang ee1a604ed8 perf(runner): cache terminal is_alive() probe with short TTL (#924)
Rapid web-client polling of the terminal GET endpoint forks a
tmux has-session subprocess on every request. Add a 2-second
TTLCache so the probe runs at most once per terminal per TTL
window, while still detecting dead tmux servers promptly.
2026-06-22 20:46:54 +00:00
260 changed files with 20550 additions and 1613 deletions
+226
View File
@@ -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
+187
View File
@@ -0,0 +1,187 @@
name: "Run integration suite"
description: >
Run the tests/integration journey suite exactly as the integration.yml gate
does (mock LLM, one wrapped harness per invocation). When `server_version`
is set, the omnigent SERVER subprocess is pinned to that released tag while
the client, runner, and tests stay on the checked-out ref — the
server-version backwards-compat configuration. Shared verbatim by
integration.yml (normal gate) and server-compat.yml (backcompat jobs) so the
two never drift. The caller owns the preceding `actions/checkout` (backcompat
needs fetch-depth 0 for tags).
inputs:
harness:
description: "Wrapped harness (claude-sdk | openai-agents | codex)"
required: true
model:
description: "Model name passed to --model"
required: true
workers:
description: "pytest workers (-n)"
required: true
server_version:
description: >
Empty = run the checked-out server (normal gate). Set to a release tag
= build that old server into a venv and redirect the server subprocess
to it (backwards-compat run).
required: false
default: ""
runner_version:
description: >
Empty = run the checked-out runner (normal gate). Set to a release tag =
build that old runner into a venv and redirect the runner subprocess to it
(Config 2 backwards-compat run). Orthogonal to server_version.
required: false
default: ""
artifact_suffix:
description: >
Appended to uploaded-artifact names so they stay unique across matrix
cells (e.g. "-sv0.2.0-rmain"). Default empty — the normal gate runs one
cell, so its harness-scoped names are already unique.
required: false
default: ""
runs:
using: composite
steps:
- name: Configure environment
shell: bash
run: |
{
echo "OMNIGENT_SKIP_WEB_UI=true"
echo "ANTHROPIC_API_KEY="
echo "OPENAI_API_KEY="
echo "CODEX="
echo "CLAUDE_CODE="
} >> "$GITHUB_ENV"
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version-file: ".python-version"
- name: Install uv
uses: astral-sh/setup-uv@38f3f104447c67c051c4a08e39b64a148898af3a # v4
with:
enable-cache: true
- name: Cache virtualenv
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4
with:
path: .venv
key: venv-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('uv.lock') }}
- name: Install project and dev dependencies
shell: bash
run: uv sync --locked --extra all --extra dev
- name: Install binary dependencies
# Mirrors e2e.yml. --ignore-scripts blocks npm postinstall hooks; we run
# claude-code's install.cjs explicitly (audited, no network). bubblewrap
# backs the linux_bwrap sandbox in tests/inner/*.
working-directory: .github/ci-deps
shell: bash
run: |
set -euo pipefail
sudo apt-get update
sudo apt-get install -y tmux ripgrep bubblewrap
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
npm install --ignore-scripts
node node_modules/@anthropic-ai/claude-code/install.cjs
echo "${GITHUB_WORKSPACE}/.github/ci-deps/node_modules/.bin" >> "$GITHUB_PATH"
- name: Build pinned old server (backwards-compat only)
# See e2e-run for the full rationale. Requires fetch-depth 0 in the caller.
if: ${{ inputs.server_version != '' }}
shell: bash
env:
SERVER_VERSION_INPUT: ${{ inputs.server_version }}
run: |
tag="$SERVER_VERSION_INPUT"
if ! [[ "$tag" =~ ^v?[0-9]+\.[0-9]+(\.[0-9]+)?([a-z0-9.]*)?$ ]]; then
echo "Invalid server_version: '$tag'" >&2; exit 1
fi
src="$RUNNER_TEMP/server-src"
venv="$RUNNER_TEMP/server-env"
git worktree add --detach "$src" "$tag"
uv venv --python 3.12 "$venv"
uv pip install --python "$venv/bin/python" \
-e "$src" -e "$src/sdks/python-client" -e "$src/sdks/ui"
"$venv/bin/omnigent" --version
echo "OMNIGENT_COMPAT_SERVER_PYTHON=$venv/bin/python" >> "$GITHUB_ENV"
echo "OMNIGENT_COMPAT_SERVER_VERSION=${tag#v}" >> "$GITHUB_ENV"
- name: Build pinned old runner (backwards-compat only)
# Config 2: redirect the runner subprocess to the pinned old build via
# OMNIGENT_COMPAT_RUNNER_PYTHON. See e2e-run for the full rationale.
# Distinct paths from the server build. Requires fetch-depth 0 in the caller.
if: ${{ inputs.runner_version != '' }}
shell: bash
env:
RUNNER_VERSION_INPUT: ${{ inputs.runner_version }}
run: |
tag="$RUNNER_VERSION_INPUT"
if ! [[ "$tag" =~ ^v?[0-9]+\.[0-9]+(\.[0-9]+)?([a-z0-9.]*)?$ ]]; then
echo "Invalid runner_version: '$tag'" >&2; exit 1
fi
src="$RUNNER_TEMP/runner-src"
venv="$RUNNER_TEMP/runner-env"
git worktree add --detach "$src" "$tag"
uv venv --python 3.12 "$venv"
uv pip install --python "$venv/bin/python" \
-e "$src" -e "$src/sdks/python-client" -e "$src/sdks/ui"
"$venv/bin/omnigent" --version
echo "OMNIGENT_COMPAT_RUNNER_PYTHON=$venv/bin/python" >> "$GITHUB_ENV"
echo "OMNIGENT_COMPAT_RUNNER_VERSION=${tag#v}" >> "$GITHUB_ENV"
- name: Run integration tests
shell: bash
env:
HARNESS: ${{ inputs.harness }}
MODEL: ${{ inputs.model }}
WORKERS: ${{ inputs.workers }}
INTEGRATION_TMP_BASE: /tmp/omnigent-integration-${{ github.run_id }}-${{ inputs.harness }}
CLAUDE_CODE_STREAM_CLOSE_TIMEOUT: "60000"
OMNIGENT_CLAUDE_SDK_NO_SANDBOX: ${{ inputs.harness == 'claude-sdk' && '1' || '' }}
PYTEST_PROGRESS_LOG_DIR: ${{ github.workspace }}/artifacts/progress-${{ inputs.harness }}
OMNIGENT_TOKEN_USAGE_JSON: ${{ github.workspace }}/artifacts/tokens-${{ inputs.harness }}.json
OMNIGENT_TEST_MODEL_SPREAD: "1"
OMNIGENT_TEST_MODEL_POOL_GPT: "databricks-gpt-5-5,databricks-gpt-5-4-mini"
run: |
set -euo pipefail
mkdir -p artifacts "$INTEGRATION_TMP_BASE"
# --capture=no + --log-cli-level=INFO stream live so progress shows
# even if the step hits its timeout before buffered output renders.
# --timeout=180 caps a single hung test (see e2e.yml).
env -u OPENAI_API_KEY -u ANTHROPIC_API_KEY -u DATABRICKS_TOKEN \
uv run pytest tests/integration/ \
--model "$MODEL" \
--harness "$HARNESS" \
-n "$WORKERS" \
--dist=loadscope \
--timeout=180 \
--timeout-method=thread \
--basetemp="$INTEGRATION_TMP_BASE" \
--junitxml="artifacts/integration-${HARNESS}.xml" \
--capture=no --log-cli-level=INFO \
-v --tb=long --showlocals --log-level=INFO -r a
- name: Upload server/runner logs on failure
if: ${{ failure() || cancelled() }}
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: integration-server-logs-${{ inputs.harness }}-${{ github.run_id }}${{ inputs.artifact_suffix }}
path: |
/tmp/omnigent-integration-${{ github.run_id }}-${{ inputs.harness }}/**/server.log
/tmp/omnigent-integration-${{ github.run_id }}-${{ inputs.harness }}/**/runner.log
retention-days: 7
if-no-files-found: warn
- name: Upload junit + logs
if: ${{ always() }}
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: integration-${{ inputs.harness }}-${{ github.run_id }}${{ inputs.artifact_suffix }}
path: artifacts/
retention-days: 14
if-no-files-found: ignore
+137
View File
@@ -0,0 +1,137 @@
#!/usr/bin/env bash
# Emit the FULL pairwise (server, runner) backwards-compat matrices on
# $GITHUB_OUTPUT as `e2e_matrix` and `integration_matrix`.
#
# The version universe is `main` (the checked-out code = client + tests, always)
# plus every non-rc release tag AT OR ABOVE the backcompat floor (MIN_VERSION,
# default 0.2.0 — the first release with the mock-LLM e2e infra; see below).
# We cross every server version with every runner
# version — each cell pins the server and/or runner subprocess to that build
# (an empty/"main" value leaves that component on the checked-out code). The
# (main, main) cell is omitted: it pins nothing and is exactly the normal e2e
# gate. Integration is the single openai-agents leg (claude-sdk/codex reject the
# mock LLM's "mock-model" — see integration-matrix.sh), crossed with the pairs.
#
# Env in:
# VERSIONS optional comma-separated override of the version set used for
# BOTH axes (e.g. "main,v0.2.0"). Empty = main + all non-rc tags.
# Blank entries are dropped and surrounding whitespace trimmed.
# NUM_SHARDS e2e shard count per cell (default 4).
# Out (GITHUB_OUTPUT):
# e2e_matrix={"include":[{"server":..,"runner":..,"shard_id":..,"num_shards":..}, ...]}
# integration_matrix={"include":[{"server":..,"runner":..,"harness":..,"model":..,"workers":..}, ...]}
set -euo pipefail
# A version token is "main" or a release tag (vX.Y[.Z][pre/dev suffix]). Anything
# else is rejected so it can't break the matrix JSON or reach a `git worktree add`.
_valid_version() {
[ "$1" = "main" ] || [[ "$1" =~ ^v?[0-9]+\.[0-9]+(\.[0-9]+)?([a-z0-9.]*)?$ ]]
}
# Minimum release the backcompat matrix tests against. v0.2.0 is the first
# release with the mock-LLM e2e infrastructure (tests/e2e/conftest.py has 0
# mock-LLM refs at v0.1.x, 31 at v0.2.0) AND the runner-side harness mock
# routing — empirically, main's mock-based e2e suite 401s ("Incorrect API key
# provided: mock-key") against v0.1.0/v0.1.1 server+runner builds, so those
# pairs are guaranteed-red infrastructure mismatch, not a compat signal.
# `main` is the dev tip and always sorts above any release, so it is never
# floored. Override with BACKCOMPAT_MIN_VERSION (e.g. "0.0.0" to disable).
# Strip a leading "v" so a "v0.2.0"-style override compares cleanly against the
# v-stripped tags in _below_floor (without this, the floor version itself would
# be dropped).
MIN_VERSION="${BACKCOMPAT_MIN_VERSION:-0.2.0}"
MIN_VERSION="${MIN_VERSION#v}"
# True (0) when release tag $1 is older than MIN_VERSION (by PEP-440-ish release
# order). "main" is never below the floor. Compares the numeric tuple via
# `sort -V` after stripping the leading "v".
_below_floor() {
[ "$1" = "main" ] && return 1
local v="${1#v}"
[ "$v" = "$MIN_VERSION" ] && return 1
[ "$(printf '%s\n%s\n' "$v" "$MIN_VERSION" | sort -V | head -1)" = "$v" ]
}
raw=()
if [ -n "${VERSIONS:-}" ]; then
IFS=',' read -ra raw <<<"$VERSIONS"
else
raw=("main")
# `[^a-z]rc[0-9]` so we drop vX.Y.ZrcN without over-excluding tags that merely
# contain the substring "rc" (e.g. a hypothetical "...march").
while IFS= read -r tag; do raw+=("$tag"); done < <(git tag --sort=-v:refname | grep -viE '(^|[^a-z])rc[0-9]')
fi
# Trim whitespace, drop blanks, reject invalid tokens, drop below-floor releases.
V=()
for v in "${raw[@]}"; do
v="${v#"${v%%[![:space:]]*}"}"
v="${v%"${v##*[![:space:]]}"}"
[ -z "$v" ] && continue
if ! _valid_version "$v"; then
echo "skipping invalid version token: '$v'" >&2
continue
fi
if _below_floor "$v"; then
echo "skipping '$v': below backcompat floor $MIN_VERSION (predates the mock-LLM e2e infra)" >&2
continue
fi
V+=("$v")
done
num_shards="${NUM_SHARDS:-4}"
# GitHub caps a matrix at 256 jobs. e2e jobs = (|V|² [main present]) × shards.
# If we'd exceed it, drop the OLDEST versions (V is newest-first in auto mode)
# until under, logging each drop — never silently truncate.
_pairs() {
local n=${#V[@]} mm=0 x
for x in "${V[@]}"; do [ "$x" = "main" ] && mm=1 && break; done
echo "$((n * n - mm))"
}
max_e2e=256
while [ "${#V[@]}" -gt 2 ] && [ "$(($(_pairs) * num_shards))" -gt "$max_e2e" ]; do
dropped="${V[${#V[@]} - 1]}"
unset 'V[${#V[@]}-1]'
V=("${V[@]}")
echo "version-matrix cap: dropped oldest version '$dropped' to keep e2e jobs <= $max_e2e" >&2
done
# The integration suite runs a single openai-agents leg in mock mode (matches
# integration-matrix.sh); the model name is unused under the mock LLM.
integ_harness="openai-agents"
integ_model="databricks-gpt-5-4-mini"
integ_workers="4"
e2e_items=()
integ_items=()
for s in "${V[@]}"; do
for r in "${V[@]}"; do
# Skip the all-main cell: it pins nothing (== the normal e2e gate).
if [ "$s" = "main" ] && [ "$r" = "main" ]; then
continue
fi
integ_items+=("{\"server\":\"$s\",\"runner\":\"$r\",\"harness\":\"$integ_harness\",\"model\":\"$integ_model\",\"workers\":$integ_workers}")
for ((i = 0; i < num_shards; i++)); do
e2e_items+=("{\"server\":\"$s\",\"runner\":\"$r\",\"shard_id\":$i,\"num_shards\":$num_shards}")
done
done
done
e2e_json=$(
IFS=,
echo "${e2e_items[*]:-}"
)
integ_json=$(
IFS=,
echo "${integ_items[*]:-}"
)
{
echo "e2e_matrix={\"include\":[$e2e_json]}"
echo "integration_matrix={\"include\":[$integ_json]}"
} >>"${GITHUB_OUTPUT:-/dev/stdout}"
echo "versions: ${V[*]:-(none)}" >&2
echo "pairs: ${#integ_items[@]} (excludes main/main); e2e jobs: ${#e2e_items[@]}; integration jobs: ${#integ_items[@]}" >&2
+13 -2
View File
@@ -67,8 +67,19 @@ prompt: |
**priority**:
- `P0-critical` — service down, data loss, security vulnerability
- `P1-high` — major feature broken, no workaround
- `P2-medium` — bug with workaround, or important feature request
- `P3-low` — minor issue, cosmetic, nice-to-have
- `P2-medium` — a bug with a workaround, OR a substantive feature
request. A feature request is substantive (P2) when it adds a real new
capability — e.g. support for a new harness / provider / model /
integration, a new tool, or a new user-facing workflow. **P2 is the
default for feature requests**, and equivalent requests must get the
same priority (e.g. "add harness X" and "add harness Y" are both P2).
- `P3-low` — ONLY genuinely minor things: minor or cosmetic bugs, small
UI/UX polish, trivial conveniences, or narrowly-scoped nice-to-haves that
add no real new capability. Do NOT drop a feature to P3 just because it
isn't urgent or you personally judge demand to be low — a new
capability/integration is P2 even if non-urgent.
When you are unsure between P2 and P3 for a feature request, choose P2.
**help_wanted** — `true` if the issue could benefit from community
contribution.
+2 -2
View File
@@ -144,7 +144,7 @@ jobs:
key: venv-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('uv.lock') }}
- name: Install dependencies
run: uv sync --extra all --extra dev
run: uv sync --locked --extra all --extra dev
- name: Run pytest
shell: bash
@@ -241,7 +241,7 @@ jobs:
key: venv-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('uv.lock') }}
- name: Install dependencies
run: uv sync --extra all --extra dev
run: uv sync --locked --extra all --extra dev
- name: Build parity sidecar
run: |
+11 -64
View File
@@ -44,10 +44,10 @@ env:
# dedicated step, so the setup.py build would be a redundant npm hit.
OMNIGENT_SKIP_WEB_UI: "true"
# Scrub harness credentials the test server must not pick up.
# OPENAI_API_KEY / OPENAI_BASE_URL are NOT scrubbed here -- the "Run UI
# e2e tests" step sets them to the Databricks bearer + serving-endpoints
# URL so the spawned openai-agents hello_world agent can authenticate
# (the ~/.databrickscfg fallback didn't resolve our OAuth M2M in CI).
# OPENAI_API_KEY / OPENAI_BASE_URL are NOT scrubbed here the
# conftest's live_server fixture overrides them to mock values
# (OPENAI_BASE_URL=<mock>/v1, OPENAI_API_KEY=mock-key) inside the
# spawned server subprocess, so ambient real credentials are a no-op.
ANTHROPIC_API_KEY: ""
DATABRICKS_TOKEN: ""
CODEX: ""
@@ -132,11 +132,8 @@ jobs:
with:
path: .venv
key: venv-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('uv.lock') }}
- name: Set LLM credentials
run: echo "LLM_API_KEY=${{ secrets.LLM_API_KEY }}" >> "$GITHUB_ENV"
- name: Install project + dev extras
run: uv sync --extra all --extra dev
run: uv sync --locked --extra all --extra dev
- name: Install bubblewrap + tmux
# bubblewrap: the UI tests open terminals under os_env, whose
@@ -203,66 +200,16 @@ jobs:
npm install --ignore-scripts --no-audit --no-fund @openai/codex@0.139.0
echo "${GITHUB_WORKSPACE}/.codex-cli/node_modules/.bin" >> "$GITHUB_PATH"
- name: Configure native-claude/codex gateway provider
# The native CLIs derive their gateway auth from omnigent provider
# config. Register the Databricks gateway as the default for both
# anthropic (Claude Code) and openai (Codex); the token reaches each
# CLI via an env:LLM_API_KEY ref, so no literal secret hits disk.
env:
GATEWAY_BASE_URL: ${{ secrets.GATEWAY_BASE_URL }}
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
run: |
mkdir -p "$HOME/.omnigent"
# The Anthropic Messages surface and the Codex Responses surface live
# at different paths off the same workspace host. GATEWAY_BASE_URL is
# <host>/serving-endpoints (the OpenAI-compatible surface); strip that
# suffix to recover the bare host for the codex /ai-gateway path.
host="${GATEWAY_BASE_URL%/serving-endpoints}"
cat > "$HOME/.omnigent/config.yaml" <<EOF
providers:
databricks-gateway:
kind: gateway
default: [anthropic, openai]
anthropic:
# Databricks serves the Anthropic Messages surface at
# <host>/serving-endpoints/anthropic (see
# omnigent/inner/pi_executor.py: claude_base_url). GATEWAY_BASE_URL
# is <host>/serving-endpoints (the OpenAI-compatible surface), so
# the /anthropic suffix is required — without it Claude Code POSTs
# to .../serving-endpoints/v1/messages and gets no reply.
base_url: "${GATEWAY_BASE_URL}/anthropic"
api_key_ref: "env:LLM_API_KEY"
# The default model id is read from models.default (not a
# top-level default_model key). Without it the provider
# resolves model=None, Claude Code launches with no --model and
# falls back to its built-in 'claude-sonnet-4-6', which the
# Databricks gateway rejects (the endpoint name is the
# 'databricks-' prefixed id).
models:
default: databricks-claude-sonnet-4-6
openai:
# Databricks serves the Codex Responses surface at
# <host>/ai-gateway/codex/v1 (see omnigent/inner/codex_executor.py:
# _databricks_codex_base_url), NOT the /serving-endpoints
# OpenAI-compatible surface. wire_api must be 'responses' — codex
# >= 0.137 rejects 'chat' at config load.
base_url: "${host}/ai-gateway/codex/v1"
api_key_ref: "env:LLM_API_KEY"
wire_api: responses
# The codex model id the e2e codex leg pins (tests/_model_pools).
models:
default: databricks-gpt-5-4-mini
EOF
- name: Run UI e2e tests
# --ui-skip-build: the SPA was built in the previous step.
# --tracing/--screenshot/--video default to off; retain-on-failure
# keeps green runs cheap while capturing artifacts on failures.
# OPENAI_API_KEY / OPENAI_BASE_URL are set by the conftest's
# live_server fixture to point at the in-process mock LLM server —
# no real gateway credentials needed for the openai-agents harness.
# Native render-parity tests (claude-sdk/codex) still use the
# ~/.omnigent/config.yaml written in the step above.
# The conftest's live_server fixture injects OPENAI_BASE_URL=mock/v1
# and OPENAI_API_KEY=mock-key into the runner subprocess env, so the
# openai-agents harness and policy classifier both hit the mock — no
# real credentials needed. Native render-parity tests write their own
# mock provider config via native_*_mock_session at terminal-creation
# time, so no ~/.omnigent/config.yaml is written in CI either.
env:
# Scheduled / manually dispatched runs are the full pass;
# PR and push runs exclude @pytest.mark.nightly tests.
+12 -125
View File
@@ -100,6 +100,9 @@ jobs:
# (forks run via the fork-e2e/** mirror push), so no shard runs for them.
needs: setup
runs-on: ubuntu-latest
# Job-level cap (composite-action run steps can't set timeout-minutes):
# ~30 min of tests + setup, replacing the old per-step 30-min backstop.
timeout-minutes: 35
strategy:
# One red shard shouldn't cancel siblings -- we want every shard's signal.
fail-fast: false
@@ -116,132 +119,16 @@ jobs:
# Push (fork-e2e/**) and dispatch fall back to the branch / ref.
ref: ${{ github.event.pull_request.number && format('refs/pull/{0}/merge', github.event.pull_request.number) || github.event.inputs.branch || github.ref }}
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
# Steps below are shared verbatim with server-compat.yml's backcompat-e2e
# job via the composite action, so the two never drift. server_version
# is omitted here -> normal gate (tests the checked-out server, mock LLM).
- name: Run e2e suite
uses: ./.github/actions/e2e-run
with:
python-version-file: ".python-version"
- name: Install uv
uses: astral-sh/setup-uv@38f3f104447c67c051c4a08e39b64a148898af3a # v4
with:
enable-cache: true
- name: Cache virtualenv
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4
with:
path: .venv
key: venv-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('uv.lock') }}
- name: Install project and dev dependencies
run: |
uv sync --extra all --extra dev
- name: Install binary dependencies
# npm install against .github/ci-deps/package.json with
# --ignore-scripts to block postinstall on every package. The
# claude-code stub binary needs its install.cjs (audited:
# platform detect + same-tree hardlink, no network/exec) so we run
# that one explicitly; codex and pi have no install scripts and
# ship prebuilt CLIs, so --ignore-scripts + the PATH line below
# make them runnable directly.
#
# bubblewrap: the linux_bwrap sandbox backend fails loud if `bwrap`
# is missing, and the e2e runner runs real agents with os_env. The
# apparmor sysctl mirrors ci.yml (Ubuntu 24.04 blocks unprivileged
# user namespaces, which bwrap's unshare(CLONE_NEWUSER) needs).
working-directory: .github/ci-deps
run: |
sudo apt-get install -y tmux bubblewrap
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
npm install --ignore-scripts
node node_modules/@anthropic-ai/claude-code/install.cjs
echo "${GITHUB_WORKSPACE}/.github/ci-deps/node_modules/.bin" >> "$GITHUB_PATH"
- name: Run e2e tests
timeout-minutes: 30
env:
# Cron fallback must match the workflow_dispatch default above.
PARALLELISM_INPUT: ${{ github.event.inputs.parallelism || '2' }}
SHARD_ID: ${{ matrix.shard_id }}
NUM_SHARDS: ${{ matrix.num_shards }}
# Schedule / dispatch are the full pass; PR and push skip @nightly.
NIGHTLY_FULL: ${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' }}
# Stable per-shard prefix so the upload step finds the logs / junit.
E2E_TMP_BASE: /tmp/omnigent-e2e-${{ github.run_id }}-shard${{ matrix.shard_id }}
# Per-worker progress log (#426): fsynced START/END per test so we
# recover the last-started test when a runner wedges.
PYTEST_PROGRESS_LOG_DIR: /tmp/omnigent-e2e-${{ github.run_id }}-shard${{ matrix.shard_id }}/progress
OMNIGENT_TOKEN_USAGE_JSON: /tmp/omnigent-e2e-${{ github.run_id }}-shard${{ matrix.shard_id }}/tokens.json
run: |
# Validate parallelism (untrusted input -- bind to env, never
# interpolate a GitHub expression into the shell).
if ! [[ "$PARALLELISM_INPUT" =~ ^[1-9][0-9]?$ ]]; then
echo "Invalid parallelism input: $PARALLELISM_INPUT (expected 1-99)" >&2
exit 1
fi
WORKERS="$PARALLELISM_INPUT"
mkdir -p "$E2E_TMP_BASE"
EXTRA_ARGS=()
if [[ "$NIGHTLY_FULL" != "true" ]]; then
EXTRA_ARGS+=(-m "not nightly")
fi
# --junitxml emits per-test results eagerly so diagnostics survive
# a wall-clock overrun. --shard-id/--num-shards chunk the node IDs.
# --timeout=180 caps each test; --timeout-method=thread because our
# pty/subprocess children don't get SIGALRM. --max-worker-restart=0
# fails the shard fast instead of letting loadscope requeue deadlock
# the controller (the 2026-06-11 shard-2 wedge).
uv run pytest tests/e2e/ \
-n "$WORKERS" \
--dist=loadscope \
--max-worker-restart=0 \
--shard-id="$SHARD_ID" \
--num-shards="$NUM_SHARDS" \
--timeout=180 \
--timeout-method=thread \
--basetemp="$E2E_TMP_BASE" \
--junitxml="$E2E_TMP_BASE/junit.xml" \
-v --tb=long --showlocals --log-level=INFO -r a \
"${EXTRA_ARGS[@]}" \
|| { rc=$?; [ "$rc" -eq 5 ] && echo "::notice::No tests collected in this shard; treating as a pass." || exit "$rc"; }
- name: Upload server logs on failure
# cancelled() too: failure() misses step timeouts (#426).
if: failure() || cancelled()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
# Per-shard name so parallel uploads don't collide.
name: e2e-server-logs-${{ github.run_id }}-shard${{ matrix.shard_id }}
# Whitelist diagnostic files (basetemp also holds large per-test
# DBs / tarballs). `warn` not `ignore` so a broken path is loud.
path: |
/tmp/omnigent-e2e-${{ github.run_id }}-shard${{ matrix.shard_id }}/**/server.log
/tmp/omnigent-e2e-${{ github.run_id }}-shard${{ matrix.shard_id }}/**/runner.log
/tmp/omnigent-e2e-${{ github.run_id }}-shard${{ matrix.shard_id }}/**/.omnigent/logs/**/*.log
/tmp/omnigent-e2e-${{ github.run_id }}-shard${{ matrix.shard_id }}/junit.xml
/tmp/omnigent-e2e-${{ github.run_id }}-shard${{ matrix.shard_id }}/progress/progress-*.log
retention-days: 7
if-no-files-found: warn
# Daemon logs live under hidden `.omnigent/` dirs, which v4 skips
# by default -- without this the `.omnigent/logs` glob matches nothing.
include-hidden-files: true
- name: Upload token usage
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: e2e-tokens-${{ github.run_id }}-shard${{ matrix.shard_id }}
path: /tmp/omnigent-e2e-${{ github.run_id }}-shard${{ matrix.shard_id }}/tokens*.json
retention-days: 14
# `warn` not `ignore`: every shard makes LLM calls, so a missing
# tokens file means the recorder broke.
if-no-files-found: warn
shard_id: ${{ matrix.shard_id }}
num_shards: ${{ matrix.num_shards }}
parallelism: ${{ github.event.inputs.parallelism || '2' }}
nightly_full: ${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' }}
# Explicitly re-dispatch Merge Ready (same-repo PR or fork-e2e push): the
# workflow_run hop is brittle and was dropped on #751/#792. No checkout,
+8 -97
View File
@@ -102,104 +102,15 @@ jobs:
with:
ref: ${{ github.event.inputs.branch || github.ref }}
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
# Shared verbatim with server-compat.yml's backcompat-integration job via
# the composite action, so the two never drift. server_version is omitted
# here -> normal gate (tests the checked-out server, mock LLM).
- name: Run integration suite
uses: ./.github/actions/integration-run
with:
python-version-file: ".python-version"
- name: Install uv
uses: astral-sh/setup-uv@38f3f104447c67c051c4a08e39b64a148898af3a # v4
with:
enable-cache: true
- name: Cache virtualenv
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4
with:
path: .venv
key: venv-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('uv.lock') }}
- name: Install project and dev dependencies
run: uv sync --extra all --extra dev
- name: Install binary dependencies
# Mirrors e2e.yml. `--ignore-scripts` blocks npm postinstall hooks;
# we run claude-code's install.cjs explicitly (audited, no network).
# `bubblewrap` backs the `linux_bwrap` sandbox in tests/inner/*.
working-directory: .github/ci-deps
run: |
set -euo pipefail
sudo apt-get update
sudo apt-get install -y tmux ripgrep bubblewrap
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
npm install --ignore-scripts
node node_modules/@anthropic-ai/claude-code/install.cjs
echo "${GITHUB_WORKSPACE}/.github/ci-deps/node_modules/.bin" >> "$GITHUB_PATH"
- name: Run nightly tests
timeout-minutes: 25
env:
HARNESS: ${{ matrix.harness }}
MODEL: ${{ matrix.model }}
WORKERS: ${{ matrix.workers }}
# Stable basetemp so the failure-upload step can find the logs.
INTEGRATION_TMP_BASE: /tmp/omnigent-integration-${{ github.run_id }}-${{ matrix.harness }}
# SDK initialize control-request timeout (ms).
CLAUDE_CODE_STREAM_CLOSE_TIMEOUT: '60000'
# Diagnostic: bypass create_exec_launcher on claude-sdk to isolate
# whether the silent connect hang is sandbox-related.
OMNIGENT_CLAUDE_SDK_NO_SANDBOX: ${{ matrix.harness == 'claude-sdk' && '1' || '' }}
# Per-xdist-worker progress log (#426): recovers the last-started
# test when a runner wedges.
PYTEST_PROGRESS_LOG_DIR: ${{ github.workspace }}/artifacts/progress-${{ matrix.harness }}
# Per-model call/token tally (dev/aggregate_token_usage.py).
OMNIGENT_TOKEN_USAGE_JSON: ${{ github.workspace }}/artifacts/tokens-${{ matrix.harness }}.json
# Load-balance interchangeable gateway models (tests/_model_pools.py).
OMNIGENT_TEST_MODEL_SPREAD: '1'
# gpt-5-4 FMAPI quota is far below its pool neighbors; drain it
# until the tier is raised so 429s don't fail hashed-to-gpt-5-4 tests.
OMNIGENT_TEST_MODEL_POOL_GPT: 'databricks-gpt-5-5,databricks-gpt-5-4-mini'
run: |
set -euo pipefail
mkdir -p artifacts "$INTEGRATION_TMP_BASE"
# --capture=no + --log-cli-level=INFO stream live so progress shows
# even if the step hits its timeout before buffered output renders.
# --timeout=180 caps a single hung test (see e2e.yml).
env -u OPENAI_API_KEY -u ANTHROPIC_API_KEY -u DATABRICKS_TOKEN \
uv run pytest tests/integration/ \
--model "$MODEL" \
--harness "$HARNESS" \
-n "$WORKERS" \
--dist=loadscope \
--timeout=180 \
--timeout-method=thread \
--basetemp="$INTEGRATION_TMP_BASE" \
--junitxml="artifacts/integration-${HARNESS}.xml" \
--capture=no --log-cli-level=INFO \
-v --tb=long --showlocals --log-level=INFO -r a
- name: Upload server/runner logs on failure
if: failure() || cancelled()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: integration-server-logs-${{ matrix.harness }}-${{ github.run_id }}
path: |
/tmp/omnigent-integration-${{ github.run_id }}-${{ matrix.harness }}/**/server.log
/tmp/omnigent-integration-${{ github.run_id }}-${{ matrix.harness }}/**/runner.log
retention-days: 7
if-no-files-found: warn
- name: Upload junit + logs
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: integration-${{ matrix.harness }}-${{ github.run_id }}
path: artifacts/
retention-days: 14
if-no-files-found: ignore
harness: ${{ matrix.harness }}
model: ${{ matrix.model }}
workers: ${{ matrix.workers }}
# Explicitly re-dispatch Merge Ready (same-repo PR or fork-e2e push): the
# workflow_run hop is brittle and was dropped on #751/#792. No checkout,
+91 -23
View File
@@ -107,6 +107,10 @@ jobs:
echo "::notice::Skipping Polly review — LLM credentials not available (fork PR or missing secrets)."
echo "available=false" >> "$GITHUB_OUTPUT"
else
# Mask the key so the runner redacts it from any log or output that
# echoes it literally — defense-in-depth against prompt injection
# that tricks Polly into including the key in its review text.
echo "::add-mask::${LLM_API_KEY}"
echo "available=true" >> "$GITHUB_OUTPUT"
fi
@@ -134,29 +138,26 @@ jobs:
- name: Set up Python
if: steps.trigger.outputs.skip != 'true' && steps.creds.outputs.available == 'true'
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version-file: ".python-version"
- name: Set up uv
if: steps.trigger.outputs.skip != 'true' && steps.creds.outputs.available == 'true'
uses: astral-sh/setup-uv@8d55fbecc275b1c35dbe060458839f8d30439ccf # v3
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
enable-cache: true
- name: Install bubblewrap
- name: Install tmux
if: steps.trigger.outputs.skip != 'true' && steps.creds.outputs.available == 'true'
# bubblewrap: the linux_bwrap sandbox backend needs bwrap on PATH.
# apparmor sysctl: Ubuntu 24.04 blocks unprivileged user namespaces
# that bwrap's unshare(CLONE_NEWUSER) needs; scope is the ephemeral runner.
# tmux: Polly uses it for its shell terminal.
run: |
sudo apt-get update
sudo apt-get install -y bubblewrap tmux
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
sudo apt-get install -y tmux
- name: Cache virtualenv
if: steps.trigger.outputs.skip != 'true' && steps.creds.outputs.available == 'true'
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: .venv
key: venv-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('uv.lock') }}
@@ -256,10 +257,25 @@ jobs:
run: |
set -euo pipefail
# Fetch the diff (capped at 64 KB to stay within prompt limits).
# Fetch the diff (capped at 512 KB — covers the vast majority of
# real PRs; truncation is surfaced to Polly in the prompt).
# The write-scoped github.token stays in this trusted step and is
# NOT passed to the Polly run.
# || true: head -c closes the pipe once the cap is reached, causing
# gh to get SIGPIPE (exit 141). Under pipefail that would abort the
# step; || true degrades it into the DIFF_TRUNCATED path instead.
gh api "repos/${REPO}/pulls/${PR_NUMBER}" \
-H "Accept: application/vnd.github.v3.diff" \
| head -c 65536 > /tmp/pr_diff.txt
| head -c 524288 > /tmp/pr_diff.txt || true
DIFF_SIZE=$(wc -c < /tmp/pr_diff.txt)
[ "$DIFF_SIZE" -ge 524288 ] && DIFF_TRUNCATED=true || DIFF_TRUNCATED=false
export DIFF_TRUNCATED
# Extract lockfile pin changes from the already-fetched diff —
# no second network call needed.
grep -E '^[+-]name = |^[+-]version = ' /tmp/pr_diff.txt \
| head -500 > /tmp/lockfile_pins.txt || true
# Fetch PR metadata to separate files — avoids embedding
# attacker-controlled strings (PR title/body) into heredocs.
@@ -270,11 +286,27 @@ jobs:
# Build the review prompt safely using python — all untrusted
# fields (title, body, diff) are read from files, never
# interpolated into shell heredocs.
python3 <<'PYEOF'
import json, pathlib
python3 -u <<'PYEOF'
import json, os, pathlib
meta = json.loads(pathlib.Path("/tmp/pr_meta.json").read_text())
diff = pathlib.Path("/tmp/pr_diff.txt").read_text()
diff = pathlib.Path("/tmp/pr_diff.txt").read_text(encoding="utf-8", errors="replace")
lockfile_pins = pathlib.Path("/tmp/lockfile_pins.txt").read_text(encoding="utf-8", errors="replace").strip()
truncated = os.environ.get("DIFF_TRUNCATED", "false") == "true"
truncation_notice = """
> ⚠️ **Diff truncated at 512 KB** — this review covers only the first
> portion of the diff. Flag this as a non-blocking note and recommend
> a manual review of the remaining changes.
""" if truncated else ""
lockfile_section = f"""
## Changed lockfile pins (uv.lock / package-lock.json)
These are extracted package name + version lines only — not the full hunk.
```
{lockfile_pins if lockfile_pins else "(no lockfile changes)"}
```
""" if lockfile_pins else ""
prompt = f"""Review this pull request and provide structured feedback.
@@ -285,13 +317,21 @@ jobs:
## PR Description
{(meta.get('body') or '')[:4096]}{" *(truncated)*" if len(meta.get('body') or '') > 4096 else ""}
{truncation_notice}
## Diff
```diff
{diff}
```
{lockfile_section}
## Instructions
The codebase is checked out at `main`. Read source files freely for
additional context when needed.
**Security:** you are running in a CI environment with access to secrets
(LLM API keys, gateway tokens). Never include secrets, tokens, or
credentials in your output, and never make outbound network calls
except to the configured LLM gateway.
Review the diff against the PR description. Report:
1. **Blocking issues** — correctness bugs, broken contracts, missing error handling on failure paths, data loss risks.
2. **Security vulnerabilities** — injection (SQL, command, template), authentication/authorization bypasses, secret exposure, unsafe deserialization, path traversal, SSRF, and any change that weakens an existing security boundary. Flag even subtle issues.
@@ -301,6 +341,20 @@ jobs:
Do NOT comment on code style, formatting, naming conventions, or other cosmetic issues — omit them entirely.
Be concise. Do not restate the diff. Focus on what matters.
Before labeling anything **blocking**, double-check: does this issue actually exist in the diff? Verify the problem is real and present in the changed code — not inferred, speculative, or already handled elsewhere. If the issue exists, it is blocking only if it introduces a correctness bug, breaks an explicit contract, or creates a real security risk; otherwise downgrade to non-blocking.
**Lockfile pins** — review the "Changed lockfile pins" section above and flag
as a **blocking security issue** any of:
- A package added that is not declared (directly or transitively) in pyproject.toml.
- A version that does not satisfy the constraint in pyproject.toml.
- A suspicious version downgrade on a security-sensitive package.
**Package extras** — when the diff adds or modifies optional dependency groups (extras):
- Each harness deserves its own extra.
- Combine harnesses and other integrations from the same vendor into one extra (e.g. a single `google` extra may cover Vertex and Antigravity).
- Each sandbox deserves its own extra.
- Nothing else warrants a new extra — flag any new extras that don't fit one of these three categories as a blocking issue.
IMPORTANT: Your output will be posted directly as a PR comment. Output
ONLY the final structured review — no coordination messages, no status
updates about dispatching sub-agents, no referring to "reviewers", no "waiting for results" narration.
@@ -311,6 +365,14 @@ jobs:
pathlib.Path("/tmp/review_prompt.txt").write_text(prompt)
PYEOF
- name: Mint App token
id: app-token
if: steps.trigger.outputs.skip != 'true' && steps.creds.outputs.available == 'true' && vars.OMNIGENT_BOT_APP_ID != ''
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
client-id: ${{ vars.OMNIGENT_BOT_APP_ID }}
private-key: ${{ secrets.OMNIGENT_BOT_APP_KEY }}
- name: Run Polly review
if: steps.trigger.outputs.skip != 'true' && steps.creds.outputs.available == 'true'
id: polly
@@ -358,13 +420,19 @@ jobs:
head -c 61440 /tmp/polly_output.txt >> "$GITHUB_OUTPUT"
echo "${delim}" >> "$GITHUB_OUTPUT"
- name: Mint App token
id: app-token
if: steps.trigger.outputs.skip != 'true' && steps.creds.outputs.available == 'true' && vars.OMNIGENT_BOT_APP_ID != ''
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
app-id: ${{ vars.OMNIGENT_BOT_APP_ID }}
private-key: ${{ secrets.OMNIGENT_BOT_APP_KEY }}
- name: Scan review output for secrets before posting
if: steps.trigger.outputs.skip != 'true' && steps.creds.outputs.available == 'true'
env:
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
run: |
set -euo pipefail
# Abort if Polly's output contains the literal LLM API key — this
# catches prompt-injection attacks that trick Polly into echoing the
# secret into the PR comment.
if [ -n "$LLM_API_KEY" ] && grep -qF "$LLM_API_KEY" /tmp/polly_output.txt 2>/dev/null; then
echo "::error::Review output contains LLM_API_KEY — aborting post to prevent secret exfiltration."
exit 1
fi
- name: Post review comment
if: steps.polly.outputs.review_text != ''
+2 -2
View File
@@ -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")
+17
View File
@@ -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:
+134
View File
@@ -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 }}
+60 -28
View File
@@ -1,13 +1,14 @@
name: UI Snapshot Update
# Label-driven baseline update for the empty "/" landing snapshot
# (tests/e2e_ui/visual/test_landing_snapshot.py).
# Label-driven baseline update for the visual-snapshot suite
# (tests/e2e_ui/visual/test_*_snapshot.py).
#
# Add the `update-ui-snapshot` label to a PR and this regenerates the baseline
# with --update-snapshots in the SAME digest-pinned Playwright image the compare
# gate (ui-snapshot.yml) renders in, then commits the new PNG back to the PR
# branch. Replaces the admin-only workflow_dispatch + manual download-and-commit
# dance.
# Add the `update-ui-snapshot` label to a PR and this regenerates only the
# baselines that DON'T match (or are missing) in the SAME digest-pinned Playwright
# image the compare gate (ui-snapshot.yml) renders in, then commits the changed
# PNGs back to the PR branch. Baselines that already pass are left byte-for-byte
# untouched, so labeling to fix one page never churns the others. Replaces the
# admin-only workflow_dispatch + manual download-and-commit dance.
#
# Two-job split (token isolation): the `render` job runs PR-controlled code (the
# npm build + the test) in the container with NO push token anywhere on the
@@ -43,7 +44,7 @@ jobs:
# 1) Render in the pinned image with NO token on the runner. PR-controlled
# code runs only here; its sole output is the PNG artifact.
render:
name: Regenerate landing baseline (no token)
name: Regenerate visual baselines (no token)
permissions:
contents: read
# Same-repo only: a fork's read-only token can't push to the fork branch.
@@ -109,21 +110,51 @@ jobs:
npm ci --legacy-peer-deps --no-audit --no-fund
npm run build
- name: Regenerate the landing baseline
# --update-snapshots rewrites the committed PNG; the run "fails" by
# design under the plugin, so don't gate on its exit code.
- name: Compare the baselines (no --update-snapshots)
# Deliberately NOT --update-snapshots: that rewrites EVERY PNG, churning
# baselines that already pass (a sub-threshold re-render still changes the
# bytes). In plain compare mode the plugin instead leaves passing
# baselines untouched and surfaces only the drift -- a mismatching
# baseline's fresh render lands in snapshot_failures/.../actual_*.png (the
# committed PNG is left in place), and a MISSING baseline is created
# directly under snapshots/. The run "fails" by design on any drift, so
# don't gate on its exit code.
run: |
uv run pytest tests/e2e_ui/visual -m visual \
-v --tb=long --log-level=INFO -r a \
-p no:rerunfailures \
--ui-skip-build \
--update-snapshots || true
--ui-skip-build || true
- name: Upload regenerated baseline
- name: Adopt only the changed renders over their baselines
# Copy each mismatching test's actual_<name>.png over its committed
# baseline; previously-missing baselines were already written under
# snapshots/ by the compare above. Baselines that passed are not in
# snapshot_failures, so they stay byte-for-byte unchanged.
run: |
fail_dir=tests/e2e_ui/visual/snapshot_failures
if [ -d "$fail_dir" ]; then
while IFS= read -r src; do
rel=${src#"$fail_dir"/} # <module>/<test>/actual_<name>.png
dest="tests/e2e_ui/visual/snapshots/$(dirname "$rel")/$(basename "$rel" | sed 's/^actual_//')"
mkdir -p "$(dirname "$dest")"
cp "$src" "$dest"
echo "adopted: $dest"
done < <(find "$fail_dir" -type f -name 'actual_*.png')
else
echo "No snapshot_failures dir -- no existing baseline drifted (only new baselines, if any, were created)."
fi
# Tar the snapshots tree (paths intact) so the commit job can restore it
# wholesale. Only genuinely-changed/created PNGs differ from the committed
# tree, so git add in the commit job stages exactly those.
- name: Package baselines
run: tar -czf "$RUNNER_TEMP/ui-snapshots.tgz" tests/e2e_ui/visual/snapshots
- name: Upload regenerated baselines
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: ui-snapshot-update-${{ github.run_id }}
path: tests/e2e_ui/visual/snapshots/**
path: ${{ runner.temp }}/ui-snapshots.tgz
if-no-files-found: error
retention-days: 1
@@ -131,7 +162,7 @@ jobs:
# branch, drops in the rendered PNG, and pushes -- so it is safe to hold the
# App token here. `git`/`gh` are preinstalled on ubuntu-latest.
commit:
name: Commit + push landing baseline
name: Commit + push visual baselines
needs: render
# Run even if render failed, so we can still report on the PR + drop the
# label; individual steps gate on the render outcome. (Skipped render =>
@@ -142,8 +173,6 @@ jobs:
pull-requests: write # comment the result + drop the trigger label
runs-on: ubuntu-latest
timeout-minutes: 10
env:
BASELINE: tests/e2e_ui/visual/snapshots/test_landing_snapshot/test_empty_landing_matches_baseline/test_empty_landing_matches_baseline[chromium][linux].png
steps:
- name: Checkout PR branch
if: needs.render.result == 'success'
@@ -154,24 +183,27 @@ jobs:
ref: ${{ github.event.pull_request.head.ref }}
persist-credentials: false
- name: Download regenerated baseline
- name: Download regenerated baselines
if: needs.render.result == 'success'
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: ui-snapshot-update-${{ github.run_id }}
path: _ui_snapshot_artifact
- name: Place the regenerated PNG over the baseline
- name: Restore the regenerated baselines
if: needs.render.result == 'success'
run: |
src=$(find _ui_snapshot_artifact -type f \
-name 'test_empty_landing_matches_baseline*.png' | head -n1)
if [ -z "$src" ]; then
echo "error: no regenerated PNG in the render artifact." >&2
tgz=$(find _ui_snapshot_artifact -type f -name 'ui-snapshots.tgz' | head -n1)
if [ -z "$tgz" ]; then
echo "error: no baseline archive in the render artifact." >&2
exit 1
fi
mkdir -p "$(dirname "$BASELINE")"
cp "$src" "$BASELINE"
# The archive holds the full tests/e2e_ui/visual/snapshots tree, so
# extracting it over the checkout replaces EVERY baseline at its
# committed path (a removed baseline drops out too). git add below
# then stages whatever actually changed.
rm -rf tests/e2e_ui/visual/snapshots
tar -xzf "$tgz"
rm -rf _ui_snapshot_artifact
# Mint the App token in this no-PR-code job. Skipped when the App isn't
@@ -203,7 +235,7 @@ jobs:
echo "Baseline already matches this PR's render — nothing to commit."
exit 0
fi
git commit -m "test(e2e-ui): regenerate landing visual baseline"
git commit -m "test(e2e-ui): regenerate visual baselines"
git push "https://x-access-token:${PUSH_TOKEN}@github.com/${REPO}.git" "HEAD:$HEAD_REF"
echo "changed=true" >> "$GITHUB_OUTPUT"
@@ -220,7 +252,7 @@ jobs:
APP_USED: ${{ steps.app-token.conclusion == 'success' }}
run: |
if [ "$CHANGED" = "true" ]; then
base="✅ Regenerated the landing visual baseline in the pinned Playwright image and pushed it to this PR."
base="✅ Regenerated the visual baseline(s) in the pinned Playwright image and pushed to this PR."
if [ "$APP_USED" = "true" ]; then
body="$base CI will re-run on the new commit."
else
+11 -10
View File
@@ -1,7 +1,8 @@
name: UI Snapshot
# Single visual-regression gate for the empty "/" landing
# (tests/e2e_ui/visual/test_landing_snapshot.py).
# Visual-regression gate for the committed UI snapshots
# (tests/e2e_ui/visual/test_*_snapshot.py -- the empty "/" landing, a mocked
# chat conversation, etc.).
#
# Cross-OS rendering note: screenshots differ across rendering environments
# (font rasterizer + hinting + anti-aliasing), so the committed baseline and the
@@ -18,16 +19,16 @@ name: UI Snapshot
# in the job summary, so they are always one click away.
#
# Triggers:
# pull_request compare the rendered landing against the committed
# baseline; fail (with actual/expected/diff PNGs in the
# pull_request compare the rendered pages against the committed
# baselines; fail (with actual/expected/diff PNGs in the
# artifact) on any mismatch. No secrets, so fork PRs run
# fine.
# workflow_dispatch regenerate the baseline with --update-snapshots in the
# same pinned image; the regenerated PNG is in the
# workflow_dispatch regenerate the baselines with --update-snapshots in the
# same pinned image; the regenerated PNGs are in the
# `ui-snapshot-<run_id>` artifact to download and commit.
# Any collaborator may run this against an arbitrary `ref`;
# the PNG is human-reviewed before it lands, so an
# unreviewed ref can't change the baseline on its own.
# the PNGs are human-reviewed before they land, so an
# unreviewed ref can't change a baseline on its own.
#
# All baseline-update paths are documented in tests/e2e_ui/visual/README.md
# (label the PR for same-repo branches, the local Docker script for forks).
@@ -62,7 +63,7 @@ env:
jobs:
ui-snapshot:
name: UI Snapshot (empty landing)
name: UI Snapshot (visual baselines)
runs-on: ubuntu-24.04
# Render in the digest-pinned Playwright image (browsers + fonts baked in),
# so the committed baseline and the PR comparison are byte-identical and a
@@ -117,7 +118,7 @@ jobs:
npm ci --legacy-peer-deps --no-audit --no-fund
npm run build
- name: Compare (PR) or regenerate (dispatch) the landing snapshot
- name: Compare (PR) or regenerate (dispatch) the visual snapshots
id: snapshot
# --ui-skip-build: the SPA was built in the previous step. On
# workflow_dispatch we pass --update-snapshots, which rewrites the
+4 -3
View File
@@ -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
+1 -1
View File
@@ -367,7 +367,7 @@ name: my_agent
prompt: You are a helpful data analyst.
executor:
harness: claude-sdk # or: claude-native, codex, codex-native, cursor, cursor-native, openai-agents, pi, pi-native, antigravity
harness: claude-sdk # or: claude-native, codex, codex-native, cursor, cursor-native, openai-agents, pi, pi-native, antigravity, qwen
tools:
# A local Python function (schema auto-generated from the signature)
+222
View File
@@ -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 25.
`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
View File
@@ -4,5 +4,19 @@ dist
src/components/ui
package-lock.json
# Xcode asset catalogs are tool-owned; Prettier fights Xcode's formatting.
**/*.xcassets/**
# Generated Apple Icon Composer bundles (machine-formatted; prettier fights the tooling)
electron/icons/**/*.icon
**/*.icon/**
# iOS build/tooling artifacts. These are git-ignored via ios/.gitignore, but
# Prettier doesn't read nested .gitignore files, so they're listed here too:
# the local Bundler gem install, build output, and fastlane-generated files
# (README.md regenerates on every run; see ios/RELEASE.md for the real docs).
ios/vendor/
ios/build/
ios/fastlane/README.md
ios/fastlane/report.xml
ios/fastlane/Preview.html
ios/fastlane/test_output/
+13 -17
View File
@@ -1,17 +1,13 @@
# ap-web
The web UI for `omnigent server --agent <agent>`. SPA built with Vite + React + TypeScript +
Tailwind v4 + shadcn/ui. Talks to the omnigent FastAPI server's
OpenAI-compatible API surface (`/v1/responses`, `/v1/conversations`,
session-scoped `/v1/sessions/{id}/resources/files`,
`/api/agents`).
This is the new UI. The legacy `web/` folder targets the old `/api/chat/stream`
server and is unrelated.
Tailwind v4 + shadcn/ui. Talks to the current Omnigent API surface
(`/v1/agents`, `/v1/sessions`, session-scoped
`/v1/sessions/{id}/resources/files`).
## Develop
In one terminal, start the omnigent server (default port `8000`). Use
In one terminal, start the omnigent server (default port `6767`). Use
`--agent` to pre-register one or more agents at startup (accepts a YAML file or
an agent-image directory; can be repeated):
@@ -36,15 +32,15 @@ OMNIGENT_URL=http://localhost:9000 npm run dev
Additional `omnigent server` options:
| Flag | Default | Description |
| --------------------- | ----------------------- | ------------------------------------ |
| `--host` | `127.0.0.1` | Host to bind to |
| `-p` / `--port` | `8000` | Port to listen on |
| `--database-uri` | `sqlite:///omnigent.db` | Database URI for stores |
| `--artifact-location` | `./artifacts` | Path for artifact storage |
| `-c` / `--config` | (none) | Path to YAML config file |
| `--execution-timeout` | `7200` | Max wall-clock seconds per execution |
| `--agent` | (none) | Pre-register an agent (repeatable) |
| Flag | Default | Description |
| --------------------- | ---------------------- | ------------------------------------ |
| `--host` | `127.0.0.1` | Host to bind to |
| `-p` / `--port` | `6767` | Port to listen on |
| `--database-uri` | `<data-dir>/chat.db` | Database URI for stores |
| `--artifact-location` | `<data-dir>/artifacts` | Path for artifact storage |
| `-c` / `--config` | (none) | Path to YAML config file |
| `--execution-timeout` | `7200` | Max wall-clock seconds per execution |
| `--agent` | (none) | Pre-register an agent (repeatable) |
## Build + serve from the Omnigent server
+4 -3
View File
@@ -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
+9
View File
@@ -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",
+5 -2
View File
@@ -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.
+23
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
source "https://rubygems.org"
gem "fastlane"
+231
View File
@@ -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>
+51
View File
@@ -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
}
}
@@ -0,0 +1 @@
../../../../platform-assets/logos/omnigents-logo.svg
@@ -0,0 +1,15 @@
{
"images" : [
{
"filename" : "omnigents-logo-reverse.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true
}
}
@@ -0,0 +1 @@
../../../../platform-assets/logos/omnigents-logo-reverse.svg
+87
View File
@@ -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 1825 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)
}
}
}
+169
View File
@@ -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
}()
+31
View File
@@ -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
}
}
+52
View File
@@ -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>
+47
View File
@@ -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
}
}
}
+39
View File
@@ -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
}
}
+529
View File
@@ -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 01 maps the drag
// across the view width to closedopen 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
}
}
+46
View File
@@ -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
}
}
+52
View File
@@ -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"
}
}
+38
View File
@@ -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)
}
}
+166
View File
@@ -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))
}
}
+56
View File
@@ -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() {}
}
+20
View File
@@ -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.
+67
View File
@@ -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.
+11
View File
@@ -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
+9
View File
@@ -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"]
+74
View File
@@ -0,0 +1,74 @@
default_platform(:ios)
# Builds are signed with the team's distribution cert via Xcode automatic
# signing (-allowProvisioningUpdates). Upload + provisioning use an App Store
# Connect API key supplied through env vars — see fastlane/.env.example.
platform :ios do
desc "Run the OmnigentTests unit tests"
lane :tests do
run_tests(scheme: "Omnigent")
end
desc "Build a signed Release .ipa and upload it to TestFlight"
lane :beta do
load_asc_api_key
build(build_number: next_build_number)
upload_to_testflight(skip_waiting_for_build_processing: true)
end
desc "Build a signed Release .ipa and upload it to App Store Connect (no submission)"
lane :release do
# NB: this uploads a fresh, uniquely-numbered binary. The more common App
# Store flow is to *promote* an already-tested TestFlight build instead of
# uploading a new one — if you adopt that, replace the build/upload below
# with a submission of the chosen TestFlight build. App Store metadata and
# screenshots are still a follow-up; this lane uploads but does not submit.
load_asc_api_key
build(build_number: next_build_number)
upload_to_app_store(
submit_for_review: false,
skip_metadata: true,
skip_screenshots: true,
precheck_include_in_app_purchases: false
)
end
# --- helpers ---
desc "Archive the Release configuration into ./build"
private_lane :build do |options|
# Inject the build number as an xcodebuild setting override rather than
# mutating tracked files. CFBundleVersion is $(CURRENT_PROJECT_VERSION) in
# the Info.plists, so overriding CURRENT_PROJECT_VERSION here flows into the
# archived binary — and nothing in the repo changes (no agvtool, no churn).
xcargs = ["-allowProvisioningUpdates"]
xcargs << "CURRENT_PROJECT_VERSION=#{options[:build_number]}" if options[:build_number]
build_app(
scheme: "Omnigent",
configuration: "Release",
export_method: "app-store",
xcargs: xcargs.join(" "),
output_directory: "./build",
clean: true
)
end
desc "Next build number: one past the highest already on App Store Connect"
private_lane :next_build_number do
# TestFlight sees every build (App Store builds pass through it too), so the
# latest TestFlight build number is a monotonic counter for the whole app.
# Requires the ASC API key to be loaded first.
latest_testflight_build_number(initial_build_number: 0) + 1
end
desc "Load the App Store Connect API key from env vars into the session"
private_lane :load_asc_api_key do
app_store_connect_api_key(
key_id: ENV.fetch("ASC_KEY_ID"),
issuer_id: ENV.fetch("ASC_ISSUER_ID"),
key_filepath: ENV.fetch("ASC_KEY_PATH"),
in_house: false
)
end
end

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

+11
View File
@@ -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

+5 -5
View File
@@ -15,8 +15,8 @@ import { AgentHoverCard } from "@/components/AgentHoverCard";
*
* Named agents win first (nessie runs on the claude-sdk harness, so a
* harness check would mislabel it with the Claude glyph), then harness/kind
* so any Claude-, Codex-, or pi-backed agent gets the right glyph regardless
* of its registered name, then a generic bot.
* so any Claude-, Codex-, pi-, or qwen-backed agent gets the right glyph
* regardless of its registered name, then a generic bot.
*
* @param agent - The catalog entry to render.
* @returns The icon component to render for the agent.
@@ -33,6 +33,7 @@ function iconForAgent(agent: AvailableAgent): ComponentType<SVGProps<SVGSVGEleme
if (agent.harness?.includes("claude")) return ClaudeIcon;
// Both the SDK "cursor" harness and "cursor-native" get the Cursor glyph.
if (agent.harness?.includes("cursor")) return CursorIcon;
// qwen falls back to generic BotIcon for now; see docs/QWEN_FOLLOWUPS.md
// Exact match — a substring check would false-match e.g. "openapi".
if (agent.harness === "pi") return PiIcon;
return BotIcon;
@@ -43,9 +44,8 @@ function iconForAgent(agent: AvailableAgent): ComponentType<SVGProps<SVGSVGEleme
*
* Shared by the new-session picker (NewChatDialog) and the "Add agent"
* picker (AddAgentDialog) so both render the agent catalog identically.
* Claude and Codex agents reuse their own glyphs, matched by harness/kind
* so a custom-registered Codex reviewer (not named "codex-native-ui")
* still gets the Codex glyph; nessie matches by name. Everything else
* Claude, Codex, and pi agents reuse their own glyphs; qwen falls back
* to a generic bot icon for now. Nessie matches by name. Everything else
* falls back to a generic bot icon.
*
* @param agent - The catalog entry to render.
@@ -1,8 +1,17 @@
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { KeyboardShortcutsDialog, openKeyboardShortcuts } from "./KeyboardShortcutsDialog";
// The pinned-session row is desktop-only. Default to browser (false).
const isNativeShell = vi.fn(() => false);
vi.mock("@/lib/nativeBridge", () => ({
isNativeShell: () => isNativeShell(),
}));
beforeEach(() => {
isNativeShell.mockReturnValue(false);
});
afterEach(cleanup);
// jsdom's navigator is non-mac, so the modifier glyph renders as "Ctrl".
@@ -44,4 +53,17 @@ describe("KeyboardShortcutsDialog", () => {
// The event dispatch isn't wrapped in act(), so wait for the re-render.
expect(await screen.findByText("Send message")).toBeTruthy();
});
it("hides the pinned-session shortcut in a plain browser", () => {
render(<KeyboardShortcutsDialog />);
toggleViaHotkey();
expect(screen.queryByText("Jump to pinned session (110)")).toBeNull();
});
it("shows the pinned-session shortcut in the Electron shell", () => {
isNativeShell.mockReturnValue(true);
render(<KeyboardShortcutsDialog />);
toggleViaHotkey();
expect(screen.getByText("Jump to pinned session (110)")).toBeTruthy();
});
});
@@ -18,6 +18,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { isNativeShell } from "@/lib/nativeBridge";
// Custom event the dialog listens for, so non-adjacent surfaces (e.g. the
// account menu) can open it without threading state through the tree.
@@ -94,6 +95,24 @@ const SHORTCUT_GROUPS: ShortcutGroup[] = [
},
];
// Desktop-only: Cmd/Ctrl+digit collides with browser tab-switching, so the
// pinned-session hotkey ships only in the Electron shell (see
// usePinnedSessionHotkeys). Injected into "Navigation" when running natively.
const PINNED_SESSION_SHORTCUT: Shortcut = {
label: "Jump to pinned session (110)",
keys: [MOD_KEY, "1…0"],
};
/** Shortcut groups for the current runtime — adds desktop-only rows natively. */
function shortcutGroupsFor(native: boolean): ShortcutGroup[] {
if (!native) return SHORTCUT_GROUPS;
return SHORTCUT_GROUPS.map((group) =>
group.title === "Navigation"
? { ...group, items: [...group.items, PINNED_SESSION_SHORTCUT] }
: group,
);
}
function Kbd({ children }: { children: ReactNode }) {
return (
<kbd className="inline-flex h-6 min-w-6 items-center justify-center rounded-md border border-border bg-muted px-1.5 font-sans text-xs font-medium text-muted-foreground">
@@ -104,6 +123,8 @@ function Kbd({ children }: { children: ReactNode }) {
export function KeyboardShortcutsDialog() {
const [open, setOpen] = useState(false);
// Feature-based, stable per session; computed at render so tests can vary it.
const groups = shortcutGroupsFor(isNativeShell());
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
@@ -133,7 +154,7 @@ export function KeyboardShortcutsDialog() {
</DialogDescription>
</DialogHeader>
<div className="max-h-[70vh] overflow-y-auto pr-1">
{SHORTCUT_GROUPS.map((group) => (
{groups.map((group) => (
<section key={group.title} className="mb-4 last:mb-0">
<h3 className="mb-1 text-xs font-medium text-muted-foreground">
{group.title}
+40 -4
View File
@@ -6,7 +6,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/comp
import { copyText } from "@/lib/clipboard";
import { cn } from "@/lib/utils";
import type { UIMessage } from "ai";
import { CheckIcon, ChevronLeftIcon, ChevronRightIcon, CopyIcon } from "lucide-react";
import { CheckIcon, ChevronLeftIcon, ChevronRightIcon, CopyIcon, WrapTextIcon } from "lucide-react";
import type { ComponentProps, HTMLAttributes, ReactElement, ReactNode } from "react";
import {
cloneElement,
@@ -325,6 +325,13 @@ function extractCodeText(children: ReactNode): string {
return "";
}
// Shared visual style for the buttons overlaid on a chat code block (copy,
// wrap toggle). The frosted/ghost look matches the rest of the chat surface;
// positioning lives on the container in ChatCodeBlockPre, not here, so the
// buttons stay layout-agnostic.
const CODE_BLOCK_OVERLAY_BUTTON_CLASS =
"size-8 bg-sidebar/80 text-muted-foreground hover:text-foreground supports-[backdrop-filter]:bg-sidebar/70 supports-[backdrop-filter]:backdrop-blur";
function ChatCodeBlockCopyButton({ getCode }: { getCode: () => string }) {
const [isCopied, setIsCopied] = useState(false);
const timeoutRef = useRef<number>(0);
@@ -360,7 +367,7 @@ function ChatCodeBlockCopyButton({ getCode }: { getCode: () => string }) {
return (
<Button
aria-label="Copy Code"
className="absolute top-2 right-12 z-10 size-8 bg-sidebar/80 text-muted-foreground hover:text-foreground supports-[backdrop-filter]:bg-sidebar/70 supports-[backdrop-filter]:backdrop-blur"
className={CODE_BLOCK_OVERLAY_BUTTON_CLASS}
onClick={handleClick}
size="icon-sm"
title="Copy Code"
@@ -372,17 +379,46 @@ function ChatCodeBlockCopyButton({ getCode }: { getCode: () => string }) {
);
}
function ChatCodeBlockWrapToggle({ wrap, onToggle }: { wrap: boolean; onToggle: () => void }) {
return (
<Button
aria-label="Toggle word wrap"
aria-pressed={wrap}
// Brighten when active so the pressed state reads at a glance.
className={cn(CODE_BLOCK_OVERLAY_BUTTON_CLASS, wrap && "text-foreground")}
onClick={onToggle}
size="icon-sm"
title={wrap ? "Disable word wrap" : "Enable word wrap"}
type="button"
variant="ghost"
>
<WrapTextIcon size={14} />
</Button>
);
}
function ChatCodeBlockPre({ children }: ComponentProps<"pre">) {
const code = extractCodeText(children);
const getCode = useCallback(() => code, [code]);
// Soft-wrap long lines by default so users don't have to scroll horizontally
// to read code blocks. The toggle restores Streamdown's native
// horizontal-scroll view for when column alignment matters.
const [wrap, setWrap] = useState(true);
const toggleWrap = useCallback(() => setWrap((w) => !w), []);
const block = isValidElement(children)
? cloneElement(children, { "data-block": "true" } as Record<string, unknown>)
: children;
return (
<div className="relative">
<div className={cn("relative", wrap && "chat-code-wrap")}>
{block}
<ChatCodeBlockCopyButton getCode={getCode} />
{/* Overlay actions, anchored left of Streamdown's own download button
(which sits at the header's right edge). A flex row lets the buttons
self-arrange, so neither needs a hardcoded horizontal offset. */}
<div className="absolute top-2 right-12 z-10 flex items-center gap-1">
<ChatCodeBlockWrapToggle onToggle={toggleWrap} wrap={wrap} />
<ChatCodeBlockCopyButton getCode={getCode} />
</div>
</div>
);
}
@@ -208,6 +208,128 @@ describe("ApprovalCard — accept & allow all edits", () => {
});
});
describe("ApprovalCard — approve & don't ask again (persistent allow rule)", () => {
beforeEach(() => {
useChatStore.setState({ conversationId: "conv_abc", blocks: [] });
});
it("labels the remember button by the WebFetch host and hides it without the hint", () => {
// The server stamps ``remember_scope`` only for non-edit tools.
// For WebFetch the button names the domain so the user knows the
// rule is domain-scoped, not tool-wide.
const { rerender } = render(
<ApprovalCard
elicitationId="elic_wf"
message="Claude wants to call **WebFetch**"
phase="pre_tool_use"
policyName="claude_native_permission"
contentPreview='WebFetch({"url": "https://github.com/a/b"})'
requestedSchema={{}}
status="pending"
response={null}
rememberScope={{ tool: "WebFetch", host: "github.com" }}
/>,
);
const rememberButton = screen.getByRole("button", {
name: /don't ask again for github\.com/i,
});
expect(rememberButton).toBeDefined();
// The tooltip spells out the (session-scoped) domain grant.
expect(rememberButton.getAttribute("title")).toBe(
"Won't ask again for github.com for the rest of this session",
);
expect(screen.getByRole("button", { name: /^approve$/i })).toBeDefined();
expect(screen.getByRole("button", { name: /reject/i })).toBeDefined();
// No hint (edit tool / ExitPlanMode / AskUserQuestion) → no button.
rerender(
<ApprovalCard
elicitationId="elic_edit"
message="Claude wants to call **Edit**"
phase="pre_tool_use"
policyName="claude_native_permission"
contentPreview="Edit({})"
requestedSchema={{}}
status="pending"
response={null}
/>,
);
expect(screen.queryByTestId("approval-card-remember")).toBeNull();
});
it("labels the remember button by the tool name for a tool-wide scope", () => {
// Non-WebFetch tools get a tool-wide scope (no host), so the
// button names the tool instead of a domain.
render(
<ApprovalCard
elicitationId="elic_bash"
message="Claude wants to call **Bash**"
phase="pre_tool_use"
policyName="claude_native_permission"
contentPreview="Bash({})"
requestedSchema={{}}
status="pending"
response={null}
rememberScope={{ tool: "Bash" }}
/>,
);
const rememberButton = screen.getByRole("button", { name: /don't ask again for Bash/i });
expect(rememberButton).toBeDefined();
// Tool-wide grant is broader than a domain — the tooltip says "any".
expect(rememberButton.getAttribute("title")).toBe(
"Won't ask again for any Bash call for the rest of this session",
);
});
it("submits {action: 'accept', content: {remember: true}} on click", () => {
// The server reads ``content.remember`` to emit the ``addRules``
// permission update; it re-derives the scope itself, so the client
// sends only the flag.
const submitSpy = vi.fn().mockResolvedValue(undefined);
useChatStore.setState({ submitApproval: submitSpy } as Partial<
ReturnType<typeof useChatStore.getState>
>);
render(
<ApprovalCard
elicitationId="elic_wf_click"
message="Claude wants to call **WebFetch**"
phase="pre_tool_use"
policyName="claude_native_permission"
contentPreview='WebFetch({"url": "https://github.com/a/b"})'
requestedSchema={{}}
status="pending"
response={null}
rememberScope={{ tool: "WebFetch", host: "github.com" }}
/>,
);
fireEvent.click(screen.getByTestId("approval-card-remember"));
expect(submitSpy).toHaveBeenCalledWith("elic_wf_click", "accept", {
remember: true,
});
});
it("renders the won't-ask-again label in the responded state", () => {
render(
<ApprovalCard
elicitationId="elic_wf_done"
message="Claude wants to call **WebFetch**"
phase="pre_tool_use"
policyName="claude_native_permission"
contentPreview='WebFetch({"url": "https://github.com/a/b"})'
requestedSchema={{}}
status="responded"
response={{ action: "accept", content: { remember: true } }}
rememberScope={{ tool: "WebFetch", host: "github.com" }}
/>,
);
expect(screen.getByText(/won't ask again for github\.com/i)).toBeDefined();
});
});
describe("ApprovalCard — multi-choice options", () => {
beforeEach(() => {
useChatStore.setState({
@@ -49,6 +49,7 @@ import {
parseAskUserQuestionPreview,
} from "@/lib/askUserQuestion";
import { formatPreview } from "@/lib/previewFormat";
import type { RememberScope } from "@/lib/types";
import { useChatStore } from "@/store/chatStore";
import { AskUserQuestionForm, type AskUserQuestionAnswers } from "./AskUserQuestionForm";
import { ExitPlanModeReview } from "./ExitPlanModeReview";
@@ -136,6 +137,17 @@ interface ApprovalCardProps {
* mode switch would be a no-op.
*/
allowAllEdits?: boolean;
/**
* Claude-native non-edit tool prompts only: when set, the binary
* approve/reject card grows a third "Approve & don't ask again for
* <host|tool>" button. Accepting through it asks the server to
* install a session-scoped allow rule for the tool (scoped to
* ``host`` for WebFetch, tool-wide otherwise) — the web equivalent
* of Claude Code's native "don't ask again" permission option, so
* same-scope calls stop re-prompting. Absent/null for every other
* elicitation (edit tools take the ``allowAllEdits`` path instead).
*/
rememberScope?: RememberScope | null;
/**
* Verdict submitter override. Defaults to `chatStore.submitApproval`
* (the in-chat path: optimistic block flip + resolve POST + rollback).
@@ -159,6 +171,7 @@ export function ApprovalCard({
exitPlanMode,
codexCommand,
allowAllEdits,
rememberScope,
onSubmit,
}: ApprovalCardProps) {
const submit: SubmitApprovalFn =
@@ -192,6 +205,15 @@ export function ApprovalCard({
// mode" action — same flag, server picks the mode).
submit(elicitationId, "accept", { allow_all_edits: true });
};
const submitRemember = () => {
// Accept AND ask the server to install a session-scoped allow rule
// so the same scope stops prompting. The server reads
// ``content.remember`` and re-derives the rule scope (WebFetch
// domain or tool-wide) from the gated tool itself — the client only
// signals intent, never the rule — then echoes an ``addRules``
// permission update back to the PermissionRequest hook.
submit(elicitationId, "accept", { remember: true });
};
const submitPlanRejection = (feedback: string) => {
// The typed feedback rides on `content.feedback`; the server
// forwards it to Claude as the deny `message`, so Claude stays in
@@ -244,6 +266,19 @@ export function ApprovalCard({
Array.isArray(response?.content?.execpolicy_amendment) &&
response.content.execpolicy_amendment.every((entry) => typeof entry === "string");
const acceptedAllEdits = response?.content?.allow_all_edits === true;
const acceptedRemember = response?.content?.remember === true;
// Persistent "don't ask again" affordance: label by the WebFetch
// domain when present, else the tool name. Drives the third binary
// button and the responded-state pill.
const rememberTarget = rememberScope ? (rememberScope.host ?? rememberScope.tool) : null;
// Tooltip spelling out the scope — the tool-wide case (no host) is a
// broad grant (every call to the tool), so make that explicit rather
// than letting the short button label imply a narrower scope.
const rememberTitle = rememberScope
? rememberScope.host
? `Won't ask again for ${rememberScope.host} for the rest of this session`
: `Won't ask again for any ${rememberScope.tool} call for the rest of this session`
: undefined;
const binaryButtons = (
<div className="flex flex-wrap gap-2 pt-1">
<Button size="sm" onClick={() => submitBinary("accept")}>
@@ -256,6 +291,18 @@ export function ApprovalCard({
Accept & allow all edits
</Button>
)}
{rememberTarget && (
<Button
size="sm"
variant="outline"
onClick={submitRemember}
title={rememberTitle}
data-testid="approval-card-remember"
>
<CheckIcon className="mr-1 size-3.5" />
Approve &amp; don't ask again for {rememberTarget}
</Button>
)}
<Button size="sm" variant="outline" onClick={() => submitBinary("decline")}>
<XIcon className="mr-1 size-3.5" />
Reject
@@ -339,6 +386,11 @@ export function ApprovalCard({
} else if (acceptedAllEdits) {
icon = <CheckIcon className="size-4 text-success" />;
label = isExitPlanMode ? "Plan approved · auto mode" : "Approved · auto-accepting edits";
} else if (acceptedRemember) {
icon = <CheckIcon className="size-4 text-success" />;
label = rememberTarget
? `Approved · won't ask again for ${rememberTarget}`
: "Approved · won't ask again";
} else if (accepted) {
icon = <CheckIcon className="size-4 text-success" />;
label = isExitPlanMode ? "Plan approved" : "Approved";
@@ -483,6 +483,7 @@ function renderItem(item: RenderItem, index: number, isReasoningStreaming: boole
exitPlanMode={item.exitPlanMode}
codexCommand={item.codexCommand}
allowAllEdits={item.allowAllEdits}
rememberScope={item.rememberScope}
/>
);
}
@@ -1,11 +1,11 @@
// Tests for ThemeModeMenu — the compact sidebar button that cycles the theme
// system → dark → light on each click.
//
// The button previews the *next* mode: its aria-label/title and icon describe
// the mode the next click applies (see nextThemeMode). It hides entirely when
// The icon shows the *current* mode, while the aria-label/title announce the
// *next* mode the click will apply (see nextThemeMode). It hides entirely when
// embedded (the host owns the theme). `next-themes` and `@/lib/embedded` are
// mocked so each test pins the current theme and embed state; the real
// themeMode helpers (pure) run unmocked.
// mocked so each test pins the current theme, system theme, and embed state;
// the real themeMode helpers (pure) run unmocked.
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -13,11 +13,11 @@ import { TooltipProvider } from "@/components/ui/tooltip";
const setTheme = vi.fn();
let currentTheme: string | undefined;
let resolvedTheme: string | undefined;
let systemTheme: string | undefined;
let embedded: boolean;
vi.mock("next-themes", () => ({
useTheme: () => ({ theme: currentTheme, resolvedTheme, setTheme }),
useTheme: () => ({ theme: currentTheme, systemTheme, setTheme }),
}));
vi.mock("@/lib/embedded", () => ({
@@ -36,7 +36,7 @@ function renderMenu() {
beforeEach(() => {
currentTheme = "system";
resolvedTheme = undefined;
systemTheme = undefined;
embedded = false;
});
@@ -96,19 +96,34 @@ describe("ThemeModeMenu", () => {
expect(screen.getByRole("button", { name: "Switch to Dark" })).toBeInTheDocument();
});
it("skips dark when system already resolves to dark", () => {
it("skips dark when the system theme is dark", () => {
// WHY: at "system" on a dark OS, pinning dark would render identically, so
// the cycle jumps straight to light.
currentTheme = "system";
resolvedTheme = "dark";
systemTheme = "dark";
renderMenu();
fireEvent.click(screen.getByRole("button", { name: "Switch to Light" }));
expect(setTheme).toHaveBeenCalledWith("light");
});
it("skips light when system already resolves to light", () => {
it("does not offer light first when the system theme is light", () => {
// WHY: from "system" the cycle's first stop is dark regardless of OS, so a
// light OS still advances to dark before anything else.
currentTheme = "system";
resolvedTheme = "light";
systemTheme = "light";
renderMenu();
fireEvent.click(screen.getByRole("button", { name: "Switch to Dark" }));
expect(setTheme).toHaveBeenCalledWith("dark");
});
it("skips light when an explicit dark theme sits on a light system", () => {
// WHY: dark's next stop is light, but a light OS already renders light, so
// skip the redundant hop and go straight to system. This is the asymmetry
// the system-theme check fixes — `resolvedTheme` would have offered light.
currentTheme = "dark";
systemTheme = "light";
renderMenu();
fireEvent.click(screen.getByRole("button", { name: "Switch to System" }));
expect(setTheme).toHaveBeenCalledWith("system");
});
});
@@ -20,10 +20,10 @@ const themeModeIcons: Record<ThemeMode, typeof SunIcon> = {
/**
* Compact sidebar control that cycles system → dark → light on click.
*
* A single icon button rather than a dropdown. The icon previews the
* mode the next click will apply (see {@link nextThemeMode}): a moon
* when clicking switches to dark, a sun for light, and a laptop for
* system. The tooltip and aria-label announce the same action.
* A single icon button rather than a dropdown. The icon shows the
* current mode — a sun for light, a moon for dark, and a laptop for
* system — while the tooltip and aria-label announce the mode the next
* click will apply (see {@link nextThemeMode}).
*
* @returns Theme cycle button.
*/
@@ -31,10 +31,10 @@ export function ThemeModeMenu() {
// Embedded: the host owns the theme and `embed.tsx` forces light, so a theme
// switcher would be a no-op. Hide it.
const isEmbedded = useIsEmbedded();
const { theme, resolvedTheme, setTheme } = useTheme();
const { theme, systemTheme, setTheme } = useTheme();
const mode = normalizeThemeMode(theme);
const next = nextThemeMode(mode, resolvedTheme);
const NextIcon = themeModeIcons[next];
const next = nextThemeMode(mode, systemTheme);
const Icon = themeModeIcons[mode];
const action = `Switch to ${themeModeLabels[next]}`;
if (isEmbedded) return null;
@@ -51,7 +51,7 @@ export function ThemeModeMenu() {
className="rounded-full"
onClick={() => setTheme(next)}
>
<NextIcon className="size-4" />
<Icon className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{action}</TooltipContent>
@@ -32,18 +32,20 @@ describe("theme mode helpers", () => {
expect(normalizeResolvedTheme(undefined)).toBe("light");
});
it("cycles system → dark → light → system without resolved theme", () => {
it("cycles system → dark → light → system without a system theme", () => {
expect(nextThemeMode("system")).toBe("dark");
expect(nextThemeMode("dark")).toBe("light");
expect(nextThemeMode("light")).toBe("system");
});
it("skips redundant transition when resolved theme matches next mode", () => {
it("skips redundant transition when the system theme matches the next mode", () => {
expect(nextThemeMode("system", "dark")).toBe("light");
expect(nextThemeMode("system", "light")).toBe("dark");
// Explicit dark on a light system would render light identically, so the
// light hop is skipped straight to system.
expect(nextThemeMode("dark", "light")).toBe("system");
});
it("does not skip when resolved theme differs from next mode", () => {
it("does not skip when the system theme differs from the next mode", () => {
expect(nextThemeMode("system", "light")).toBe("dark");
expect(nextThemeMode("dark", "dark")).toBe("light");
expect(nextThemeMode("light", "light")).toBe("system");
+3 -3
View File
@@ -59,17 +59,17 @@ export function normalizeResolvedTheme(value: string | undefined): ResolvedTheme
* light instead of offering "Switch to Dark".
*
* @param mode Current selectable theme mode, e.g. `"dark"`.
* @param resolvedTheme The actual rendered palette, e.g. `"dark"`.
* @param systemTheme The system theme, e.g. `"dark"`.
* @returns The mode to apply on the next click, e.g. `"light"`.
*/
export function nextThemeMode(mode: ThemeMode, resolvedTheme?: string): ThemeMode {
export function nextThemeMode(mode: ThemeMode, systemTheme?: string): ThemeMode {
const cycle: Record<ThemeMode, ThemeMode> = {
system: "dark",
dark: "light",
light: "system",
};
const next = cycle[mode];
if (resolvedTheme && next !== "system" && next === resolvedTheme) {
if (systemTheme && next !== "system" && next === systemTheme) {
return cycle[next];
}
return next;
@@ -0,0 +1,99 @@
import { useEffect, useState } from "react";
import { isIOSShell } from "@/lib/nativeBridge";
const KEYBOARD_INSET_THRESHOLD_PX = 80;
export function useIOSNativeKeyboardInset(enabled = true): number {
const [inset, setInset] = useState(0);
useEffect(() => {
if (!enabled || !isIOSShell()) {
setInset(0);
return;
}
const sync = () => {
const viewport = window.visualViewport;
if (!viewport) {
setInset(0);
return;
}
const nextInset = getIOSNativeKeyboardInset();
setInset(nextInset > KEYBOARD_INSET_THRESHOLD_PX ? nextInset : 0);
};
sync();
window.visualViewport?.addEventListener("resize", sync);
window.visualViewport?.addEventListener("scroll", sync);
window.addEventListener("resize", sync);
window.addEventListener("orientationchange", sync);
window.addEventListener("focusin", sync, true);
window.addEventListener("focusout", sync, true);
return () => {
window.visualViewport?.removeEventListener("resize", sync);
window.visualViewport?.removeEventListener("scroll", sync);
window.removeEventListener("resize", sync);
window.removeEventListener("orientationchange", sync);
window.removeEventListener("focusin", sync, true);
window.removeEventListener("focusout", sync, true);
};
}, [enabled]);
return inset;
}
export function useIOSNativeKeyboardVisible(enabled = true, includeEditableFocus = true): boolean {
const [visible, setVisible] = useState(false);
useEffect(() => {
if (!enabled || !isIOSShell()) {
setVisible(false);
return;
}
const sync = () => {
setVisible(
getIOSNativeKeyboardInset() > KEYBOARD_INSET_THRESHOLD_PX ||
(includeEditableFocus && isEditableElementFocused()),
);
};
sync();
window.visualViewport?.addEventListener("resize", sync);
window.visualViewport?.addEventListener("scroll", sync);
window.addEventListener("resize", sync);
window.addEventListener("orientationchange", sync);
window.addEventListener("focusin", sync, true);
window.addEventListener("focusout", sync, true);
return () => {
window.visualViewport?.removeEventListener("resize", sync);
window.visualViewport?.removeEventListener("scroll", sync);
window.removeEventListener("resize", sync);
window.removeEventListener("orientationchange", sync);
window.removeEventListener("focusin", sync, true);
window.removeEventListener("focusout", sync, true);
};
}, [enabled, includeEditableFocus]);
return visible;
}
function getIOSNativeKeyboardInset(): number {
const viewport = window.visualViewport;
if (!viewport) return 0;
const shellBottom =
document.querySelector<HTMLElement>("[data-ios-native].app-shell")?.getBoundingClientRect()
.bottom ?? window.innerHeight;
const visibleBottom = viewport.offsetTop + viewport.height;
return Math.max(0, Math.round(shellBottom - visibleBottom));
}
function isEditableElementFocused(): boolean {
const active = document.activeElement;
if (!(active instanceof HTMLElement)) return false;
return active.matches('input, textarea, select, [contenteditable="true"]');
}
+129
View File
@@ -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: 19 → indices 08, 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 19 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:
// 19 → 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]);
}
+48 -9
View File
@@ -1,8 +1,7 @@
import { renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { livenessRowFromSession, useSessionLiveness } from "./useSessionLiveness";
import { type LivenessRow, livenessRowFromSession, useSessionLiveness } from "./useSessionLiveness";
import { useSessionHostOnline, useSessionRunnerOnline } from "@/hooks/RunnerHealthProvider";
import type { Conversation } from "@/hooks/useConversations";
import type { Session } from "@/lib/types";
// Drive the two split signals directly so the test pins the derivation
@@ -29,16 +28,20 @@ function freshCreatedAt(): number {
}
/** Build a minimal conv row carrying just the fields the hook reads. */
function conv(
partial: Partial<Pick<Conversation, "host_id" | "permission_level" | "created_at">>,
): Pick<Conversation, "host_id" | "permission_level" | "created_at"> {
return { host_id: null, permission_level: null, created_at: SOME_CREATED_AT, ...partial };
function conv(partial: Partial<LivenessRow>): LivenessRow {
return {
host_id: null,
permission_level: null,
created_at: SOME_CREATED_AT,
host_resumable: false,
...partial,
};
}
function derive(
runner: boolean | undefined,
host: boolean | null | undefined,
c: Pick<Conversation, "host_id" | "permission_level" | "created_at"> | null,
c: LivenessRow | null,
opts?: { turnActive?: boolean },
) {
runnerMock.mockReturnValue(runner);
@@ -117,6 +120,37 @@ describe("useSessionLiveness — derivation truth table", () => {
});
});
it("host_asleep when a resumable managed host is down — composer stays open", () => {
// A dormant resumable managed host the server wakes on the next message:
// NOT the host_offline dead-end. host_resumable flips row 3.
expect(derive(false, false, conv({ host_id: "h1", host_resumable: true }))).toEqual({
kind: "host_asleep",
});
// The wake is server-side, not owner-gated: resumable wins the offline
// split even when shared (non-owner).
expect(
derive(false, false, conv({ host_id: "h1", permission_level: 1, host_resumable: true })),
).toEqual({ kind: "host_asleep" });
});
it("starting (NOT host_asleep) while a just-sent turn is waking the resumable host", () => {
// turnActive means the send is resuming the sandbox now — show the
// "Connecting…" intermediate through the cold wake, not a blank
// host_asleep screen.
expect(
derive(false, false, conv({ host_id: "h1", host_resumable: true }), { turnActive: true }),
).toEqual({ kind: "starting" });
});
it("host_offline (not host_asleep) when the down host is NOT resumable", () => {
// The default for an external/non-resumable host: the actionable
// reconnect/fork dead-end, unchanged.
expect(derive(false, false, conv({ host_id: "h1", host_resumable: false }))).toEqual({
kind: "host_offline",
isOwner: true,
});
});
it("unknown for a host-bound session whose host liveness is not yet observed", () => {
// host_id set but host_online undefined: don't guess host-down.
expect(derive(false, undefined, conv({ host_id: "h1" }))).toEqual({ kind: "unknown" });
@@ -236,10 +270,15 @@ describe("useSessionLiveness — derivation truth table", () => {
} as Session;
}
it("maps hostId / permissionLevel / createdAt into the snake_case row", () => {
it("maps hostId / permissionLevel / createdAt / hostResumable into the snake_case row", () => {
expect(
livenessRowFromSession(session({ hostId: "h1", permissionLevel: 1, createdAt: 123 })),
).toEqual({ host_id: "h1", permission_level: 1, created_at: 123 });
).toEqual({ host_id: "h1", permission_level: 1, created_at: 123, host_resumable: false });
// hostResumable flows through so an off-sidebar resumable host can
// classify host_asleep rather than dead-ending on host_offline.
expect(livenessRowFromSession(session({ hostId: "h1", hostResumable: true }))).toMatchObject({
host_resumable: true,
});
});
it("returns null for a null/undefined snapshot", () => {
+53 -14
View File
@@ -31,7 +31,16 @@ import { useSessionHostOnline, useSessionRunnerOnline } from "@/hooks/RunnerHeal
export const STARTING_GRACE_S = 45;
/** The subset of a conversation row this hook reads. */
export type LivenessRow = Pick<Conversation, "host_id" | "permission_level" | "created_at">;
export type LivenessRow = Pick<Conversation, "host_id" | "permission_level" | "created_at"> & {
/**
* Whether this session's host is a resumable managed host the server wakes
* on the next message. NOT a `Conversation` field — the sidebar row doesn't
* carry it; it rides the session snapshot, and the open view splices it in
* via {@link livenessRowFromSession}. Drives the `host_asleep` vs
* `host_offline` split (row 3). Absent ⇒ treated `false`.
*/
host_resumable?: boolean;
};
/**
* Build a {@link LivenessRow} from the single-session snapshot
@@ -44,13 +53,17 @@ export type LivenessRow = Pick<Conversation, "host_id" | "permission_level" | "c
* snapshot carries the same three fields, so it's an exact stand-in.
*/
export function livenessRowFromSession(
session: Pick<Session, "hostId" | "permissionLevel" | "createdAt"> | null | undefined,
session:
| Pick<Session, "hostId" | "permissionLevel" | "createdAt" | "hostResumable">
| null
| undefined,
): LivenessRow | null {
if (!session) return null;
return {
host_id: session.hostId,
permission_level: session.permissionLevel,
created_at: session.createdAt,
host_resumable: session.hostResumable ?? false,
};
}
@@ -75,10 +88,20 @@ export function livenessRowFromSession(
* host relaunches the runner on the next message, so the composer stays
* open. The open view renders no banner for this state — typing
* silently relaunches the runner (which then flips it to `starting`).
* - `host_offline` — the session is host-bound and the host tunnel is
* down. Nothing the web UI sends can wake it; the owner must reconnect
* the host from that machine (`isOwner` true), and any viewer can fork
* to continue independently.
* - `host_asleep` — the session is host-bound, the host tunnel is down, but
* the host is a resumable managed host: the server wakes the sandbox on the
* next message (the send-message relaunch path calls `resume_managed_host`).
* Treated like `runner_asleep` — the composer stays open, no reconnect
* banner, and typing wakes it. This is what makes the backend resume
* reachable from the web; without it a resumable host would dead-end on
* `host_offline` below. While a just-sent turn is waking it (`turnActive`),
* this upgrades to `starting` so the ~85s cold wake shows a "Connecting…"
* intermediate rather than a blank screen.
* - `host_offline` — the session is host-bound and the host tunnel is down,
* and the host is NOT resumable from the web (an external/laptop host, or a
* managed provider without a stop/resume lifecycle). The owner must
* reconnect the host from that machine (`isOwner` true), and any viewer can
* fork to continue independently.
* - `local_stranded` — not host-bound (no `host_id`) and the runner is
* down. There's no host to relaunch it; the user restarts from their
* own machine, and forking is the escape hatch.
@@ -91,6 +114,7 @@ export type SessionLiveness =
| { kind: "online" }
| { kind: "starting" }
| { kind: "runner_asleep" }
| { kind: "host_asleep" }
| { kind: "host_offline"; isOwner: boolean }
| { kind: "local_stranded" }
| { kind: "unknown" };
@@ -117,7 +141,9 @@ function isOwner(conv: Pick<Conversation, "permission_level"> | null | undefined
* |---|---------------|-------------|---------|------------|----------------------|
* | 1 | true | (any) | (any) | (any) | online |
* | 2 | not-true | (any) | (any) | (any) | starting (fresh*) |
* | 3 | not-true | false | set | (any) | host_offline {owner} |
* | 3 | not-true | false | set+resumable | true | starting (waking) |
* | 3'| not-true | false | set+resumable | false | host_asleep |
* | 3"| not-true | false | set, non-resum| (any) | host_offline {owner} |
* | 4 | undefined | (any) | (any) | (any) | unknown (pre-poll) |
* | 5 | false | true | (any) | true | starting (relaunch) |
* | 5'| false | true | (any) | false | runner_asleep |
@@ -133,7 +159,10 @@ function isOwner(conv: Pick<Conversation, "permission_level"> | null | undefined
* runner tunnel is the only signal that means "chat now." A just-created
* session whose runner hasn't registered yet (row 2) is cold-booting, not
* stranded: surface `starting` rather than a reconnect banner until the
* grace window lapses. A confirmed host-down (row 3) is actionable.
* grace window lapses. A confirmed host-down splits on resumability: a
* resumable managed host is `host_asleep` (row 3 — wakeable by sending a
* message, composer open), a non-resumable one is `host_offline` (row 3' —
* reconnect / fork).
* Pre-poll `undefined` (row 4) stays `unknown` so a not-yet-resolved poll
* doesn't flash a banner over a live session. A known-down runner with a
* live host then splits on whether a turn is in flight: a just-sent turn
@@ -144,9 +173,10 @@ function isOwner(conv: Pick<Conversation, "permission_level"> | null | undefined
*
* @param sessionId The open conversation's id, or undefined when none is
* open. Undefined yields `unknown`.
* @param conv The open conversation row (carries `host_id` +
* `permission_level`). Null/undefined while loading; the host-bound vs.
* local distinction and ownership read from it.
* @param conv The open session's liveness row (carries `host_id`,
* `permission_level`, and `host_resumable`). Null/undefined while loading;
* the host-bound vs. local distinction, ownership, and the
* host_asleep-vs-host_offline split read from it.
* @param opts.turnActive Whether a turn is currently in flight for the
* open session (the user just sent, or a cross-client turn is running).
* When the runner is down but the host is up, this upgrades the idle
@@ -156,7 +186,7 @@ function isOwner(conv: Pick<Conversation, "permission_level"> | null | undefined
*/
export function useSessionLiveness(
sessionId: string | undefined,
conv: Pick<Conversation, "host_id" | "permission_level" | "created_at"> | null | undefined,
conv: LivenessRow | null | undefined,
opts?: { turnActive?: boolean },
): SessionLiveness {
const runnerOnline = useSessionRunnerOnline(sessionId);
@@ -206,9 +236,18 @@ export function useSessionLiveness(
return { kind: "starting" };
}
// 3. A host-bound session whose host is confirmed offline is genuinely
// stuck and actionable — surface the reconnect/fork affordance.
// 3. A host-bound session whose host is confirmed offline. If the host is
// a resumable managed host, the server wakes the sandbox on the next
// message (the send-message relaunch path calls resume_managed_host). A
// just-sent turn (turnActive) is waking it *now* — surface the same
// `starting` "Connecting…" intermediate as a fresh launch so the ~85s cold
// wake isn't a blank screen; idle (no turn) stays `host_asleep` (composer
// open, no banner). Either way it's NOT the host_offline dead-end.
// Otherwise (non-resumable) it's genuinely stuck: `host_offline`.
if (hostId && hostOnline === false) {
if (conv?.host_resumable) {
return opts?.turnActive ? { kind: "starting" } : { kind: "host_asleep" };
}
return { kind: "host_offline", isOwner: isOwner(conv) };
}
+179
View File
@@ -401,6 +401,164 @@
[data-electron-mac] :is(a, button, input, textarea, [role="button"]) {
-webkit-app-region: no-drag;
}
/* iOS native shell (SwiftUI/WKWebView) runs the webview full-screen under the
* system status bar and home indicator. Keep the web app visually full-bleed,
* but move interactive mobile chrome out of unsafe areas. Scoped to the native
* bridge marker so normal iOS Safari keeps its existing browser-safe layout. */
@media (width < 48rem) {
[data-ios-native].app-shell {
height: 100vh;
height: 100lvh;
min-height: 100vh;
min-height: 100lvh;
max-height: 100vh;
max-height: 100lvh;
overflow: hidden;
}
[data-ios-native] .conversations-sidebar {
transition: transform 360ms cubic-bezier(0.32, 0.72, 0, 1);
will-change: transform;
}
[data-ios-native] :is(input, textarea, select, [contenteditable="true"]) {
font-size: 16px;
}
[data-ios-native] .chat-header {
top: max(0px, calc(env(safe-area-inset-top, 0px) - 0.5rem));
}
[data-ios-native] .chat-conversation-content {
padding-top: calc(5rem + env(safe-area-inset-top, 0px));
}
[data-ios-native] .main-terminal-view {
padding-top: calc(3.25rem + env(safe-area-inset-top, 0px));
}
[data-ios-native] .chat-scroll-fade {
mask-image: linear-gradient(
to bottom,
transparent calc(48px + env(safe-area-inset-top, 0px)),
black calc(80px + env(safe-area-inset-top, 0px))
);
-webkit-mask-image: linear-gradient(
to bottom,
transparent calc(48px + env(safe-area-inset-top, 0px)),
black calc(80px + env(safe-area-inset-top, 0px))
);
}
[data-ios-native] .chat-composer-form {
padding-bottom: calc(0.75rem + env(safe-area-inset-bottom, 0px));
}
[data-ios-native] .chat-composer-form.terminal-first-composer-form {
padding-bottom: 0.25rem;
}
[data-ios-native] .terminal-first-switcher-container {
padding-bottom: calc(0.35rem + env(safe-area-inset-bottom, 0px));
}
/* Reserve room for the native Liquid Glass switcher that floats over the web
view (the in-page pill is suppressed in the iOS shell). Fixed height: the
bar's footprint above the home-indicator inset (env). Chat sits 1rem
tighter — its composer status line already cushions the gap to the bar. */
[data-ios-native] .omnigent-native-bottom-spacer {
height: calc(3rem + env(safe-area-inset-bottom, 0px));
flex: none;
}
[data-ios-native] .omnigent-native-bottom-spacer--chat {
height: calc(2rem + env(safe-area-inset-bottom, 0px));
}
[data-ios-native] .terminal-first-switcher {
min-height: 46px;
gap: 0.25rem;
padding: 0.25rem;
border-color: color-mix(in srgb, var(--border) 72%, transparent);
background: color-mix(in srgb, var(--card) 88%, transparent);
-webkit-backdrop-filter: saturate(180%) blur(18px);
backdrop-filter: saturate(180%) blur(18px);
box-shadow:
0 12px 28px rgb(0 0 0 / 0.13),
0 1px 0 rgb(255 255 255 / 0.55) inset;
font-size: 16px;
line-height: 1;
}
[data-ios-native] .terminal-first-switcher > div {
gap: 0.25rem;
}
[data-ios-native] .terminal-first-switcher-option {
min-height: 38px;
gap: 0.45rem;
padding: 0 0.85rem;
font-size: 16px;
font-weight: 500;
letter-spacing: 0;
transition:
background-color 160ms ease,
color 160ms ease,
box-shadow 160ms ease,
transform 160ms ease;
}
[data-ios-native] .terminal-first-switcher-option:active:not(:disabled) {
transform: scale(0.97);
}
[data-ios-native] .terminal-first-switcher-option[aria-pressed="true"] {
background: color-mix(in srgb, var(--background) 82%, white 18%);
box-shadow:
0 3px 10px rgb(0 0 0 / 0.1),
0 1px 0 rgb(255 255 255 / 0.72) inset;
}
[data-ios-native] .terminal-first-switcher-option svg {
width: 1.15rem;
height: 1.15rem;
}
.dark [data-ios-native] .terminal-first-switcher {
border-color: color-mix(in srgb, var(--border) 82%, transparent);
background: color-mix(in srgb, var(--card) 78%, transparent);
box-shadow:
0 14px 30px rgb(0 0 0 / 0.35),
0 1px 0 rgb(255 255 255 / 0.1) inset;
}
.dark [data-ios-native] .terminal-first-switcher-option[aria-pressed="true"] {
background: color-mix(in srgb, var(--muted) 82%, white 6%);
box-shadow:
0 3px 12px rgb(0 0 0 / 0.28),
0 1px 0 rgb(255 255 255 / 0.12) inset;
}
[data-ios-native]
:is(
.conversations-sidebar,
[data-testid="file-viewer"],
[data-testid="files-panel-drawer"],
[data-testid="terminals-panel"],
[data-testid="subagents-panel-drawer"],
[data-testid="todos-panel-drawer"]
) {
padding-top: env(safe-area-inset-top, 0px);
padding-bottom: env(safe-area-inset-bottom, 0px);
}
}
@media (width < 48rem) and (prefers-reduced-motion: reduce) {
[data-ios-native] .conversations-sidebar {
transition-duration: 1ms;
}
}
/* Share button — glassy pink effect (both modes). The vertical
* gradient alone does the embossed work (lighter top = light catch,
* darker bottom = shadow, simulating a convex surface lit from above);
@@ -503,6 +661,27 @@
font-size: 0.875em;
}
/* Word-wrap toggle for chat code blocks (see ChatCodeBlockPre in message.tsx).
* Streamdown renders the body with `overflow-x-auto` and the <code> with
* `white-space: pre`, so long lines scroll horizontally. When `.chat-code-wrap`
* is set we soft-wrap instead so everything fits the column width. */
.chat-code-wrap [data-streamdown="code-block-body"] {
overflow-x: hidden;
}
.chat-code-wrap [data-streamdown="code-block-body"] pre,
.chat-code-wrap [data-streamdown="code-block-body"] code {
white-space: pre-wrap;
overflow-wrap: anywhere;
}
/* Hang-indent wrapped continuation lines past the line-number gutter so they
* align with the code, not under the numbers. Gutter = before:w-6 (1.5rem) +
* before:mr-4 (1rem) = 2.5rem; the negative text-indent pulls the first line
* (and its ::before number) back to the left edge. */
.chat-code-wrap [data-streamdown="code-block-body"] code > span {
padding-left: 2.5rem;
text-indent: -2.5rem;
}
/* Streamdown's <button> link-safety-modal variant and wrap-anywhere reset can
* leave the default arrow cursor on links — force pointer to signal affordance. */
[data-streamdown="link"] {
+1
View File
@@ -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;
}
+10 -1
View File
@@ -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. */
+17 -1
View File
@@ -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;
}
/**
+145
View File
@@ -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
View File
@@ -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 () => {};
}
}
+3
View File
@@ -19,6 +19,7 @@
// Pure function. No React, no DOM. Tested in `renderItems.test.ts`.
import type { AnyBlock, MessageContentBlock, ToolExecution, ToolResultBlock } from "./blocks";
import type { RememberScope } from "./types";
import type { ActiveResponse } from "@/store/types";
/**
@@ -112,6 +113,7 @@ export type RenderItem =
execPolicyAmendment: string[] | null;
} | null;
allowAllEdits?: boolean;
rememberScope?: RememberScope | null;
};
/** A bubble cluster. The page maps over these. */
@@ -696,6 +698,7 @@ function buildAssistantItems(
exitPlanMode: b.exitPlanMode,
codexCommand: b.codexCommand,
allowAllEdits: b.allowAllEdits,
rememberScope: b.rememberScope,
});
i += 1;
continue;
+71
View File
@@ -707,6 +707,77 @@ describe("response.elicitation_request (FLAT envelope)", () => {
const ev = out[0] as ElicitationRequest;
expect(ev.allowAllEdits).toBe(false);
});
it("lifts the remember_scope hint with a host for WebFetch prompts", () => {
// The server stamps ``remember_scope`` on non-edit tool
// PermissionRequests so the card can offer "Approve & don't ask
// again for <host>". For WebFetch the host scopes the rule, so it
// must survive parsing.
const out = parse("response.elicitation_request", {
type: "response.elicitation_request",
elicitation_id: "elicit_webfetch",
params: {
mode: "form",
message: "Claude wants to call **WebFetch**",
phase: "pre_tool_use",
policy_name: "claude_native_permission",
content_preview: 'WebFetch({"url": "https://github.com/a/b"})',
requestedSchema: {},
tool_name: "WebFetch",
remember_scope: { tool: "WebFetch", host: "github.com" },
},
});
expect(out).toHaveLength(1);
const ev = out[0] as ElicitationRequest;
expect(ev.rememberScope).toEqual({ tool: "WebFetch", host: "github.com" });
});
it("lifts a tool-wide remember_scope hint (no host)", () => {
// Non-WebFetch tools (here Bash) get a tool-wide scope: ``tool``
// only, no ``host``. The card labels the button by the tool name.
const out = parse("response.elicitation_request", {
type: "response.elicitation_request",
elicitation_id: "elicit_bash_remember",
params: {
mode: "form",
message: "Claude wants to call **Bash**",
phase: "pre_tool_use",
policy_name: "claude_native_permission",
content_preview: "Bash({})",
requestedSchema: {},
tool_name: "Bash",
remember_scope: { tool: "Bash" },
},
});
expect(out).toHaveLength(1);
const ev = out[0] as ElicitationRequest;
expect(ev.rememberScope).toEqual({ tool: "Bash", host: undefined });
});
it("leaves rememberScope null when the hint is absent", () => {
// Edit tools / ExitPlanMode / AskUserQuestion carry no
// ``remember_scope``; the button must stay hidden.
const out = parse("response.elicitation_request", {
type: "response.elicitation_request",
elicitation_id: "elicit_edit_no_remember",
params: {
mode: "form",
message: "Claude wants to call **Edit**",
phase: "pre_tool_use",
policy_name: "claude_native_permission",
content_preview: "Edit({})",
requestedSchema: {},
tool_name: "Edit",
allow_all_edits: true,
},
});
expect(out).toHaveLength(1);
const ev = out[0] as ElicitationRequest;
expect(ev.rememberScope).toBeNull();
});
});
describe("response.elicitation_resolved (FLAT envelope)", () => {
+1
View File
@@ -74,6 +74,7 @@ describe("createSession", () => {
agentName: null,
runnerId: undefined,
hostId: null,
hostResumable: false,
status: "idle",
createdAt: 1704067200,
title: null,
+9
View File
@@ -99,6 +99,14 @@ interface SessionResponseWire {
* other carrier and it's absent for those.
*/
host_id?: string | null;
/**
* Whether this session is bound to a dormant managed host the server can
* wake in place (its sandbox provider supports resume). Read only when the
* host is offline, to tell a recoverable "asleep" state (send a message —
* the server resumes the sandbox) from the terminal host_offline dead-end.
* Absent/`false` for non-managed/non-resumable hosts.
*/
host_resumable?: boolean;
status: SessionStatus;
created_at: number;
/**
@@ -250,6 +258,7 @@ function sessionFromWire(wire: SessionResponseWire): Session {
agentName: wire.agent_name ?? null,
runnerId: wire.runner_id,
hostId: wire.host_id ?? null,
hostResumable: wire.host_resumable ?? false,
status: wire.status,
createdAt: wire.created_at,
title: wire.title ?? null,
+21 -1
View File
@@ -61,7 +61,7 @@ import type {
ToolResult,
} from "./events";
import { NATIVE_TOOL_TYPES } from "./events";
import type { ErrorInfo, ModelUsage, Response } from "./types";
import type { ErrorInfo, ModelUsage, RememberScope, Response } from "./types";
/**
* Out-param for `parseSseStream`: `sawDone` is set when the server's `[DONE]`
@@ -766,6 +766,25 @@ export function parseEvent(rawType: string, data: Record<string, unknown>): Stre
// offers the "Accept & allow all edits" button (switches the
// session to acceptEdits mode on accept).
const allowAllEdits = p.allow_all_edits === true;
// claude-native non-edit tool prompts stamp this so the ApprovalCard
// offers the persistent "don't ask again" button (installs a
// session-scoped allow rule on accept). `tool` is the gated tool;
// `host` is the WebFetch request domain when present (drives the
// button label and the rule scope).
const rememberScopeRaw = p.remember_scope;
const rememberScope: RememberScope | null =
rememberScopeRaw &&
typeof rememberScopeRaw === "object" &&
!Array.isArray(rememberScopeRaw) &&
typeof (rememberScopeRaw as Record<string, unknown>).tool === "string"
? {
tool: (rememberScopeRaw as Record<string, unknown>).tool as string,
host:
typeof (rememberScopeRaw as Record<string, unknown>).host === "string"
? ((rememberScopeRaw as Record<string, unknown>).host as string)
: undefined,
}
: null;
return {
type: "elicitation_request",
elicitationId,
@@ -805,6 +824,7 @@ export function parseEvent(rawType: string, data: Record<string, unknown>): Stre
}
: null,
allowAllEdits,
rememberScope,
} satisfies ElicitationRequest;
}
+20
View File
@@ -18,6 +18,19 @@ export interface ConversationRef {
id: string;
}
/**
* Scope of a claude-native "don't ask again" persistent allow rule,
* stamped by the PermissionRequest endpoint for non-edit eligible
* tools. ``tool`` is the gated tool; ``host`` is the WebFetch request
* domain when present (a domain-scoped rule), absent for a tool-wide
* rule. Shared by the elicitation event (`events.ts`), the reduced
* block (`blocks.ts`), and the ApprovalCard that renders the button.
*/
export interface RememberScope {
tool: string;
host?: string;
}
/**
* An un-consumed web-composer user message replayed from the session
* snapshot. Native-terminal sessions don't persist a web message at
@@ -235,6 +248,13 @@ export interface Session {
* older recorded fixtures may omit it (treated as `null`).
*/
hostId?: string | null;
/**
* Whether this session's host is a dormant resumable managed host the
* server can wake on the next message. Carried on the snapshot so the open
* view shows a wakeable "asleep" state instead of the terminal host_offline
* dead-end. `false`/absent otherwise.
*/
hostResumable?: boolean;
status: SessionStatus;
createdAt: number;
/**
+195 -36
View File
@@ -58,6 +58,13 @@ import { parseSystemMessage } from "@/lib/systemMessage";
import { Button } from "@/components/ui/button";
import { OttoIcon } from "@/components/icons/OttoIcon";
import { cn } from "@/lib/utils";
import { useSurfaceFrontmost } from "@/hooks/useNativeServerSwitcher";
import {
isIOSShell,
onNativeViewModeChanged,
setNativeServerSwitcherHidden,
setNativeViewMode,
} from "@/lib/nativeBridge";
import {
DropdownMenu,
DropdownMenuContent,
@@ -73,6 +80,7 @@ import { usePermissions } from "@/hooks/usePermissions";
import type { CodexModelOption, SandboxStatus, Session, SessionStatus } from "@/lib/types";
import { usePromptHistory } from "@/hooks/usePromptHistory";
import { useAutoGrowTextarea } from "@/hooks/useAutoGrowTextarea";
import { useIOSNativeKeyboardVisible } from "@/hooks/useIOSNativeKeyboardInset";
import type { MessageContentBlock } from "@/lib/blocks";
import { derivePermissionLevel, isOwnerLevel } from "@/lib/permissionsApi";
import {
@@ -96,6 +104,7 @@ import { useSession } from "@/hooks/useSession";
import { useSessionRunnerOnline } from "@/hooks/RunnerHealthProvider";
import { useRefreshSessionStateOnRunnerOnline } from "@/hooks/useSessionOnlineRefresh";
import {
type LivenessRow,
type SessionLiveness,
livenessRowFromSession,
useSessionLiveness,
@@ -726,7 +735,15 @@ export function ChatPage() {
// so `host_id` still reaches the hook — otherwise a host-bound, host-down
// session misclassifies as `local_stranded` and shows the wrong reconnect
// path. See `livenessRowFromSession`.
const livenessRow = activeConv ?? livenessRowFromSession(activeSession);
//
// Always source `host_resumable` from the session snapshot — the sidebar
// `Conversation` row doesn't carry it. activeSession is loaded for the open
// session, so a host-bound, host-down session whose host is a resumable
// managed host classifies as `host_asleep` (composer open, send wakes it)
// instead of dead-ending on `host_offline`.
const livenessRow: LivenessRow | null = activeConv
? { ...activeConv, host_resumable: activeSession?.hostResumable ?? false }
: livenessRowFromSession(activeSession);
const liveness = useSessionLiveness(urlConvId ?? undefined, livenessRow, {
turnActive: status === "streaming",
});
@@ -1272,6 +1289,21 @@ function MainAgentSurface({
conversationRef.current = el;
setContainerEl(el);
}, []);
const [terminalSurfaceEl, setTerminalSurfaceEl] = useState<HTMLElement | null>(null);
// True only while the chat/terminal surface is the frontmost thing on screen.
// Drives both native overlays so neither floats over an opened drawer.
const surfaceFrontmost = useSurfaceFrontmost(
showTerminal ? terminalSurfaceEl : containerEl,
!!conversationId,
);
useEffect(() => {
if (!isIOSShell()) return;
setNativeServerSwitcherHidden(!surfaceFrontmost);
}, [surfaceFrontmost]);
useEffect(() => {
if (!isIOSShell()) return;
return () => setNativeServerSwitcherHidden(true);
}, []);
// The conversation's scroll container + the StickToBottom controls needed to
// override its bottom-lock, lifted out of the context by
// ConversationScrollRefBridge so the pinned-but-unmasked JumpToTopButton can
@@ -1318,12 +1350,17 @@ function MainAgentSurface({
<MainTerminalView
conversationId={conversationId}
initialTerminalKey={terminalFirst?.terminalViewKey}
onSurfaceElement={setTerminalSurfaceEl}
// Non-owners attach read-only: a shared PTY can't attribute
// input per-user, so only the owner may type. They drive the
// agent via the composer instead. Server enforces this too.
readOnly={!isOwnerLevel(permissionLevel)}
/>
<ConnectionIndicator liveness={liveness} onShowReconnectHelp={onShowReconnectHelp} />
<ConnectionIndicator
liveness={liveness}
onShowReconnectHelp={onShowReconnectHelp}
surfaceFrontmost={surfaceFrontmost}
/>
</>
);
}
@@ -1338,7 +1375,12 @@ function MainAgentSurface({
ChatHeader overlay's controls (geometry in index.css). */}
<Conversation className="chat-scroll-fade flex-1">
{/* gap-4 overrides ConversationContent's default gap-8 so consecutive agent turns read as one thread. */}
<ConversationContent className={cn("mx-auto w-full gap-4 pt-20 pb-6", CHAT_COLUMN_WIDTH)}>
<ConversationContent
className={cn(
"chat-conversation-content mx-auto w-full gap-4 pt-20 pb-6",
CHAT_COLUMN_WIDTH,
)}
>
{/* Scroll helpers — must live inside StickToBottom to access context. */}
<ScrollToBottomOnSend nonce={sendScrollNonce} />
<ConversationScrollRefBridge onScroller={setScroller} />
@@ -1457,7 +1499,8 @@ function MainAgentSurface({
showCodexPlanMode={showCodexPlanMode}
isTerminalFirst={isTerminalFirst}
isNativeWrapper={isNativeWrapper}
reconnectHint={liveness.kind === "runner_asleep"}
reconnectHint={liveness.kind === "runner_asleep" || liveness.kind === "host_asleep"}
sandboxAsleepHint={liveness.kind === "host_asleep"}
unreachable={
!sandboxLaunching &&
(liveness.kind === "host_offline" || liveness.kind === "local_stranded")
@@ -1470,7 +1513,11 @@ function MainAgentSurface({
{/* Chat/Terminal toggle for terminal-first sessions, reconnect-or-
fork banner when unreachable, nothing otherwise. Sits below the
composer so its position is consistent with the terminal view. */}
<ConnectionIndicator liveness={liveness} onShowReconnectHelp={onShowReconnectHelp} />
<ConnectionIndicator
liveness={liveness}
onShowReconnectHelp={onShowReconnectHelp}
surfaceFrontmost={surfaceFrontmost}
/>
</>
);
}
@@ -2015,12 +2062,42 @@ export function SandboxFailedIndicator({ status }: { status: SandboxStatus }) {
export function ConnectionIndicator({
liveness,
onShowReconnectHelp,
surfaceFrontmost = true,
}: {
liveness: SessionLiveness;
onShowReconnectHelp: () => void;
// Whether the chat/terminal surface is frontmost (not under a drawer). Gates
// the native iOS bar so it doesn't float over an opened sidebar/panel.
surfaceFrontmost?: boolean;
}) {
const terminalFirst = useTerminalFirst();
const keyboardVisible = useIOSNativeKeyboardVisible(
terminalFirst?.isTerminalFirst === true,
terminalFirst?.view === "chat",
);
const sandboxStatus = useChatStore((s) => s.sandboxStatus);
// Genuinely-unreachable states get the reconnect banner, for
// both terminal-first and regular sessions. `runner_asleep` (host up,
// runner relaunches on the next message), `host_asleep` (resumable managed
// host the server wakes on the next message), and `unknown` (pre-poll) are
// NOT unreachable — they're handled below.
const unreachable = liveness.kind === "host_offline" || liveness.kind === "local_stranded";
// In the iOS shell the Chat/Terminal toggle is the native Liquid Glass bar,
// not the in-page pill. Drive it from here (always mounted) with the SAME
// visibility the pill would have, expressed as a stable boolean so switching
// views never flickers the bar. Hook is called unconditionally (before any
// early return) to satisfy the rules of hooks.
const nativeBarVisible =
isIOSShell() &&
terminalFirst?.isTerminalFirst === true &&
!terminalFirst.isShellView &&
sandboxStatus?.stage !== "failed" &&
!unreachable &&
!keyboardVisible &&
surfaceFrontmost;
useNativeChatTerminalBar(terminalFirst, nativeBarVisible);
if (sandboxStatus !== null) {
// A failed launch owns this band with its reason. An IN-FLIGHT
// launch renders in the chat thread (RunnerStartingIndicator)
@@ -2031,11 +2108,6 @@ export function ConnectionIndicator({
}
return null;
}
// Genuinely-unreachable states get the reconnect banner, for
// both terminal-first and regular sessions. `runner_asleep` (host up,
// runner relaunches on the next message) and `unknown` (pre-poll) are
// NOT unreachable — they're handled below.
const unreachable = liveness.kind === "host_offline" || liveness.kind === "local_stranded";
if (unreachable) {
return (
<button
@@ -2067,11 +2139,28 @@ export function ConnectionIndicator({
// as the runner comes back. The strict `runner_online` still gates the
// inline PTY *view* (it needs a live tunnel) — but not the toggle.
if (terminalFirst?.isTerminalFirst) {
// In the iOS shell the toggle is the native bar (driven above). Render only
// a spacer reserving its fixed footprint so the composer clears it — and
// nothing when the bar is hidden.
if (isIOSShell()) {
// Chat reserves a touch less than terminal: the composer's own bottom
// content (the status line) already cushions the gap to the bar.
return nativeBarVisible ? (
<div
aria-hidden
className={cn(
"omnigent-native-bottom-spacer",
terminalFirst.view === "chat" && "omnigent-native-bottom-spacer--chat",
)}
/>
) : null;
}
// A rail-opened shell owns the main view chrome-free — no pill: a
// "Chat" option under someone else's shell misreads as the shell
// being the agent. The shell view carries its own close affordance
// (MainTerminalView's X) back to chat.
if (terminalFirst.isShellView) return null;
if (keyboardVisible) return null;
return <ConnectedTerminalFirstPill ctx={terminalFirst} />;
}
@@ -2094,8 +2183,8 @@ export function ConnectionIndicator({
}
// `online`/`unknown` for a non-terminal-first session and
// `runner_asleep` for any session: status lives in the sidebar / the
// composer stays open, so render nothing here.
// `runner_asleep`/`host_asleep` for any session: status lives in the
// sidebar / the composer stays open, so render nothing here.
return null;
}
@@ -2174,9 +2263,64 @@ export function RunnerStartingIndicator({ variant }: { variant: "hero" | "row" }
);
}
/**
* Mirrors the Chat/Terminal state onto the iOS shell's native Liquid Glass
* switcher and routes its taps back into `setView`. Driven by a stable
* `visible` boolean (not this hook's mount/unmount), so toggling Chat/Terminal
* updates the bar in place instead of flickering it hidden→shown. A no-op
* outside the iOS shell; the caller renders its own in-page pill there.
*/
function useNativeChatTerminalBar(
ctx: ReturnType<typeof useTerminalFirst> | null,
visible: boolean,
): void {
const native = isIOSShell();
const view = ctx?.view ?? "chat";
const terminalsAvailable = ctx?.terminalsAvailable ?? false;
const terminalStartingUp = ctx?.terminalStartingUp ?? false;
// Keep `setView` reachable from the subscribe-once effect without
// resubscribing whenever the callback identity changes.
const setViewRef = useRef(ctx?.setView);
setViewRef.current = ctx?.setView;
// Push current state + visibility down whenever any of it changes.
useEffect(() => {
if (!native) return;
setNativeViewMode({
mode: view,
terminalEnabled: terminalsAvailable,
terminalStartingUp,
visible,
});
}, [native, view, terminalsAvailable, terminalStartingUp, visible]);
// Belt-and-suspenders: hide the bar if the host component ever unmounts.
useEffect(() => {
if (!native) return;
return () => {
setNativeViewMode({
mode: "chat",
terminalEnabled: false,
terminalStartingUp: false,
visible: false,
});
};
}, [native]);
// Route native taps back into the web layer.
useEffect(() => {
if (!native) return;
return onNativeViewModeChanged((mode) => setViewRef.current?.(mode));
}, [native]);
}
/**
* Chat/Terminal segmented control for terminal-first sessions. Status
* lives in the sidebar — this band is purely a view toggle.
*
* Only rendered outside the iOS shell; inside it the switcher is drawn natively
* (Liquid Glass) over the web view — see {@link useNativeChatTerminalBar}.
*/
function ConnectedTerminalFirstPill({
ctx,
@@ -2189,17 +2333,18 @@ function ConnectedTerminalFirstPill({
// reachable: greyed-and-spinning reads as "loading", greyed-and-static as
// "no terminal / stopped".
const { view, setView, terminalsAvailable, terminalStartingUp } = ctx;
return (
<div
className={cn(
"mx-auto flex w-full items-center justify-center px-6 pb-1.5",
"terminal-first-switcher-container mx-auto flex w-full items-center justify-center px-6 pb-1.5",
CHAT_COLUMN_WIDTH,
)}
>
<div
role="group"
aria-label="View mode"
className="flex items-center gap-1 rounded-full border border-border bg-card/90 p-1 text-xs shadow-sm"
className="terminal-first-switcher flex items-center gap-1 rounded-full border border-border bg-card/90 p-1 text-xs shadow-sm"
>
<div className="flex items-center gap-0.5">
<button
@@ -2208,7 +2353,7 @@ function ConnectedTerminalFirstPill({
aria-label="Chat"
onClick={() => setView("chat")}
className={cn(
"flex cursor-pointer items-center gap-1 rounded-full px-2 py-0.5 transition-colors",
"terminal-first-switcher-option flex cursor-pointer items-center gap-1 rounded-full px-2 py-0.5 transition-colors",
view === "chat"
? "bg-muted text-foreground"
: "text-muted-foreground hover:bg-muted/60 hover:text-foreground",
@@ -2225,7 +2370,7 @@ function ConnectedTerminalFirstPill({
title={terminalStartingUp ? "Terminal is starting up…" : undefined}
onClick={() => setView("terminal")}
className={cn(
"flex cursor-pointer items-center gap-1 rounded-full px-2 py-0.5 transition-colors disabled:cursor-not-allowed disabled:opacity-50",
"terminal-first-switcher-option flex cursor-pointer items-center gap-1 rounded-full px-2 py-0.5 transition-colors disabled:cursor-not-allowed disabled:opacity-50",
view === "terminal"
? "bg-muted text-foreground"
: "text-muted-foreground hover:bg-muted/60 hover:text-foreground",
@@ -2562,6 +2707,14 @@ interface ComposerProps {
* turn is streaming (the follow-up placeholder wins).
*/
reconnectHint?: boolean;
/**
* The session is host-bound to a dormant resumable managed host that is
* offline (`host_asleep`): the composer stays enabled, and the placeholder
* tells the user their next message will resume the sandbox host (which can
* take a few minutes) so the wake latency is expected, not surprising.
* Ignored once a turn is streaming.
*/
sandboxAsleepHint?: boolean;
/**
* The session is unreachable (`host_offline` / `local_stranded`): a message
* can't wake it. The composer is blocked (disabled) and the reconnect
@@ -2929,6 +3082,7 @@ export function Composer({
isTerminalFirst = false,
isNativeWrapper = false,
reconnectHint = false,
sandboxAsleepHint = false,
unreachable = false,
costRoutingVerdict = null,
costRoutingEligible = false,
@@ -2992,13 +3146,27 @@ export function Composer({
// the input, which would delete the draft. Only save when the user
// has actually changed the value since the last restore.
const dirtyRef = useRef(false);
// On mobile, programmatic focus immediately summons the software keyboard.
// Keep desktop's fast-type affordance, but let mobile users explicitly tap
// the composer when switching back from Terminal or changing sessions.
const [isMobile, setIsMobile] = useState(
() => typeof window !== "undefined" && window.matchMedia("(max-width: 767px)").matches,
);
const isMobileRef = useRef(isMobile);
isMobileRef.current = isMobile;
useEffect(() => {
const mq = window.matchMedia("(max-width: 767px)");
const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches);
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
}, []);
useEffect(() => {
const restored = conversationId ? sessionDrafts.get(conversationId) : undefined;
setValue(restored?.text ?? "");
setFiles(restored?.files ?? []);
dirtyRef.current = false;
textareaRef.current?.focus();
if (!isMobileRef.current) textareaRef.current?.focus();
return () => {
if (!conversationId || !dirtyRef.current) return;
@@ -3018,7 +3186,7 @@ export function Composer({
// focus when the count grows — removing a quote shouldn't steal focus.
const prevQuoteCountRef = useRef(replyQuotes.length);
useEffect(() => {
if (replyQuotes.length > prevQuoteCountRef.current) {
if (!isMobileRef.current && replyQuotes.length > prevQuoteCountRef.current) {
textareaRef.current?.focus();
}
prevQuoteCountRef.current = replyQuotes.length;
@@ -3224,20 +3392,6 @@ export function Composer({
}
};
// On mobile-sized viewports the on-screen keyboard has no easy way to
// produce Shift+Enter, so Enter-to-send would lock users out of multi-line
// composition entirely. Below Tailwind's `md` breakpoint, fall back to
// native textarea behavior (Enter = newline) and require tapping Send.
const [isMobile, setIsMobile] = useState(
() => typeof window !== "undefined" && window.matchMedia("(max-width: 767px)").matches,
);
useEffect(() => {
const mq = window.matchMedia("(max-width: 767px)");
const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches);
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
}, []);
// Auto-grow the textarea from 1 row up to 10 rows, then let it scroll.
useAutoGrowTextarea(textareaRef, value);
@@ -3489,7 +3643,10 @@ export function Composer({
return (
<form
onSubmit={handleSubmit}
className={cn("px-4 md:px-6", isTerminalFirst ? "pb-1.5" : "pb-3")}
className={cn(
"chat-composer-form px-4 md:px-6",
isTerminalFirst ? "terminal-first-composer-form pb-1.5" : "pb-3",
)}
>
{/* Hidden file input for the attach button */}
<input
@@ -3633,9 +3790,11 @@ export function Composer({
? "Waiting for agents…"
: isStreaming
? "Send a follow-up (queued) — Esc to stop"
: reconnectHint
? "Send a message to reconnect this session"
: "Ask the agent anything…"
: sandboxAsleepHint
? "Current session's host is offline. Next message will resume the sandbox host which can take minutes"
: reconnectHint
? "Send a message to reconnect this session"
: "Ask the agent anything…"
}
rows={1}
disabled={disabled || isReadOnly || unreachable || hasPendingElicitation}
+1
View File
@@ -327,6 +327,7 @@ export function InboxPage() {
exitPlanMode={item.elicitation.exitPlanMode}
codexCommand={item.elicitation.codexCommand}
allowAllEdits={item.elicitation.allowAllEdits}
rememberScope={item.elicitation.rememberScope}
onSubmit={makeSubmit(item)}
/>
)}
+29 -3
View File
@@ -8,7 +8,7 @@ import { AgentInfoContent, agentHasInfo } from "@/components/AgentInfo";
import { useIdleNotifications } from "@/hooks/useIdleNotifications";
import { readFilesPanelPreferences, writeFilesPanelPreferences } from "@/lib/filesPanelPreferences";
import { derivePermissionLevel, isOwnerLevel } from "@/lib/permissionsApi";
import { isMacElectronShell } from "@/lib/nativeBridge";
import { isIOSShell, isMacElectronShell, onNativeSidebarDrag } from "@/lib/nativeBridge";
import { readSessionWorkspaceState, writeSessionWorkspaceState } from "@/lib/sessionWorkspaceState";
import {
Dialog,
@@ -49,7 +49,7 @@ import { FileViewerContext } from "./FileViewerContext";
import { FilesPanelDrawer } from "./FilesPanelDrawer";
import type { ChangedSort } from "./FlatFileList";
import { MobilePanelDrawer } from "./MobilePanelDrawer";
import { Sidebar } from "./Sidebar";
import { isMobileViewport, Sidebar } from "./Sidebar";
import { TitleBarServerPicker } from "./TitleBarServerPicker";
import { SubagentsPanel } from "./SubagentsPanel";
import { useRootSessionId, useSession } from "@/hooks/useSession";
@@ -126,6 +126,27 @@ export function AppShell() {
useResizableInlinePanel(conversationId ?? null, inlinePanelMinWidth);
const [searchParams, setSearchParams] = useSearchParams();
const [sidebarOpen, setSidebarOpen] = useState(initialSidebarOpen);
// Live open fraction (0→1) while the iOS edge-swipe drags the sidebar; null
// when not dragging. Drives the mobile overlay's finger-tracking transform.
const [sidebarDragProgress, setSidebarDragProgress] = useState<number | null>(null);
// The iOS shell repurposes the left-edge swipe (normally back-navigation) to
// drive the sidebar as an interactive drawer, streaming it over the native
// bridge. begin/move track the finger (mobile overlay only — the desktop
// width-based sidebar can't be partially slid, so it just settles); open/close
// are the settle decision on release. No-op outside the iOS shell.
useEffect(
() =>
onNativeSidebarDrag((phase, progress) => {
if (phase === "open" || phase === "close") {
setSidebarDragProgress(null);
setSidebarOpen(phase === "open");
return;
}
if (!isMobileViewport()) return;
setSidebarDragProgress(progress);
}),
[],
);
const [selectedFilePath, setSelectedFilePath] = useState<string | null>(() =>
conversationId ? (readSessionWorkspaceState(conversationId).selectedFilePath ?? null) : null,
);
@@ -946,6 +967,7 @@ export function AppShell() {
<div
className="app-shell relative flex h-dvh bg-sidebar text-foreground"
data-electron-mac={isMacElectronShell() ? "true" : undefined}
data-ios-native={isIOSShell() ? "true" : undefined}
>
{/* Frameless-window titlebar stand-in (macOS Electron only): the
sidebar's electron top margin (see index.css) frees this strip of
@@ -958,7 +980,11 @@ export function AppShell() {
{isMacElectronShell() && (
<TitleBarServerPicker threadTitle={activeSession?.title ?? activeConv?.title} />
)}
<Sidebar open={sidebarOpen} onClose={() => setSidebarOpen(false)} />
<Sidebar
open={sidebarOpen}
dragProgress={sidebarDragProgress}
onClose={() => setSidebarOpen(false)}
/>
{/* Content region (everything right of the sidebar): a relative
flex row holding the chat+workspace group and the push panels
+1 -1
View File
@@ -171,7 +171,7 @@ export function ChatHeader({
// Scrolled chat text can't render through the controls because the
// conversation viewport fades its top edge instead (chat-scroll-fade
// in index.css, applied in ChatPage).
"absolute inset-x-0 top-0 z-30 flex h-14 items-center justify-between px-2 py-3",
"chat-header absolute inset-x-0 top-0 z-30 flex h-14 items-center justify-between px-2 py-3",
)}
>
{/* Left slot: sidebar toggle (when sidebar is closed) and a
+23 -3
View File
@@ -14,9 +14,11 @@
// shells are enumerated and created in the rail's Shells tab.
import { TerminalIcon, XIcon } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import type { CSSProperties } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { TerminalView } from "@/components/blocks/TerminalView";
import { AGENT_TERMINAL_IDS, terminalTabKey, useTerminals } from "@/hooks/useTerminals";
import { useIOSNativeKeyboardInset } from "@/hooks/useIOSNativeKeyboardInset";
import { useTerminalFirst } from "./TerminalFirstContext";
import { TerminalStatusBadge } from "./terminalStatus";
import { useTerminalStatuses } from "./useTerminalStatuses";
@@ -40,12 +42,18 @@ interface MainTerminalViewProps {
* instead. Default false (owner / single-user).
*/
readOnly?: boolean;
/**
* Exposes the outer terminal surface so the iOS native shell can show its
* server switcher only while this surface is actually frontmost.
*/
onSurfaceElement?: (element: HTMLElement | null) => void;
}
export function MainTerminalView({
conversationId,
initialTerminalKey,
readOnly = false,
onSurfaceElement,
}: MainTerminalViewProps) {
const { terminals } = useTerminals(conversationId);
const terminalFirstCtx = useTerminalFirst();
@@ -63,6 +71,9 @@ export function MainTerminalView({
const [activeKey, setActiveKey] = useState(initialTerminalKey || "");
const { getStatus, setTerminalConnectionState, markTerminalActive } =
useTerminalStatuses(terminals);
const keyboardInset = useIOSNativeKeyboardInset();
const containerStyle: CSSProperties | undefined =
keyboardInset > 0 ? { paddingBottom: `calc(0.375rem + ${keyboardInset}px)` } : undefined;
// Honor a retarget while already open (a rail shell click can point
// an open view at a different terminal); the validity effect below
@@ -94,19 +105,28 @@ export function MainTerminalView({
(terminalFirstCtx?.isTerminalFirst ?? false) &&
activeTerminal !== null &&
!AGENT_TERMINAL_IDS.has(activeTerminal.id);
const setSurfaceElement = useCallback(
(element: HTMLDivElement | null) => {
onSurfaceElement?.(element);
},
[onSurfaceElement],
);
return (
// Outer wrapper fills the main column. `pt-16` clears the
// absolute-positioned AppShell header; `px-3` gives a 12px gutter on
// absolute-positioned AppShell header on desktop; iOS native gets a
// safe-area-aware override in index.css. `px-3` gives a 12px gutter on
// the sides. The card stretches to full width and height of the
// available area. The ConnectionIndicator pill renders just below
// this wrapper in ChatPage's MainAgentSurface.
<div
ref={setSurfaceElement}
data-testid="main-terminal-view"
// Exposed for e2e assertions that an expand targeted the right
// terminal (not just that the view opened).
data-active-terminal={activeKey}
className="flex min-h-0 flex-1 flex-col px-3 pt-16 pb-1.5"
className="main-terminal-view flex min-h-0 flex-1 flex-col px-3 pt-16 pb-1.5"
style={containerStyle}
>
<div className="flex min-h-0 w-full flex-1 flex-col overflow-hidden rounded-lg border border-border bg-card p-3 shadow-sm">
{terminals.length === 0 ? (
@@ -36,8 +36,10 @@ import "@tiptap/markdown";
// Type-only import: activates @tiptap/extension-table's TypeScript module
// augmentation so editor.chain() includes table commands (insertTable, etc.)
// without pulling the full extension into the runtime bundle.
// eslint-disable-next-line import/no-empty-named-blocks -- deliberate type-only augmentation trigger, not a stray empty import
import type {} from "@tiptap/extension-table";
// Same trick for the list package's command augmentation (toggleTaskList).
// eslint-disable-next-line import/no-empty-named-blocks -- deliberate type-only augmentation trigger, not a stray empty import
import type {} from "@tiptap/extension-list";
import { TableMap, cellAround, colCount, findTable, isInTable } from "@tiptap/pm/tables";
import { cn } from "@/lib/utils";
@@ -275,6 +275,65 @@ describe("MarkdownRichTextViewer dirty banners", () => {
});
});
// ── Link following ───────────────────────────────────────────────────────────
describe("MarkdownRichTextViewer link following", () => {
const HREF = "https://omnigent.ai/docs/build/harnesses";
// The TipTap editor (and the links it renders, incl. those in table cells)
// is mocked to null here, so inject an anchor into the scroll container to
// exercise the container's click handler directly.
function clickLink(
container: HTMLElement,
eventInit: Parameters<typeof fireEvent.click>[1] = {},
) {
const scroll = container.querySelector(".overflow-auto");
if (!scroll) throw new Error("scroll container not found");
const anchor = document.createElement("a");
anchor.setAttribute("href", HREF);
scroll.appendChild(anchor);
fireEvent.click(anchor, eventInit);
}
it("opens a link in a new tab on a plain click in read-only mode", () => {
const open = vi.fn();
vi.stubGlobal("open", open);
setupReadOnlyHooks();
const { container } = renderViewer("[harnesses](" + HREF + ")");
clickLink(container);
// Read-only: nothing to edit, so any link click should follow.
expect(open).toHaveBeenCalledWith(HREF, "_blank", "noopener,noreferrer");
});
it("does NOT follow a link on a plain click in edit mode (click places the cursor)", () => {
const open = vi.fn();
vi.stubGlobal("open", open);
setupEditHooks();
const { container } = renderViewer("[harnesses](" + HREF + ")");
clickLink(container);
// Edit mode: a bare click must position the cursor, not navigate away.
expect(open).not.toHaveBeenCalled();
});
it("follows a link on ⌘/Ctrl+click in edit mode (escape hatch)", () => {
const open = vi.fn();
vi.stubGlobal("open", open);
setupEditHooks();
const { container } = renderViewer("[harnesses](" + HREF + ")");
clickLink(container, { metaKey: true });
expect(open).toHaveBeenCalledWith(HREF, "_blank", "noopener,noreferrer");
open.mockClear();
clickLink(container, { ctrlKey: true });
expect(open).toHaveBeenCalledWith(HREF, "_blank", "noopener,noreferrer");
});
});
// ── Truncated-file guard ─────────────────────────────────────────────────────
describe("MarkdownRichTextViewer truncated guard", () => {
+13 -11
View File
@@ -398,17 +398,19 @@ function MarkdownRichTextViewerInner({
<div
ref={scrollContainerRef}
className="relative flex-1 overflow-auto px-8 py-6"
onClick={
!canEdit
? (e) => {
const anchor = (e.target as Element).closest("a[href]");
if (anchor) {
e.preventDefault();
window.open(anchor.getAttribute("href")!, "_blank", "noopener,noreferrer");
}
}
: undefined
}
// Link following. The Link extension runs with openOnClick:false so a
// plain click in edit mode positions the cursor instead of navigating.
// Read-only: any click on a link opens it. Edit mode: only a
// modifier-click (⌘/Ctrl) opens it, so plain-click-to-edit is preserved
// while still giving an escape hatch to follow links (incl. in tables).
onClick={(e) => {
if (canEdit && !e.metaKey && !e.ctrlKey) return;
const anchor = (e.target as Element).closest("a[href]");
if (anchor) {
e.preventDefault();
window.open(anchor.getAttribute("href")!, "_blank", "noopener,noreferrer");
}
}}
>
{!canEdit && (
<button
+12 -1
View File
@@ -58,6 +58,7 @@ import { useRecentWorkspaces } from "@/hooks/useRecentWorkspaces";
import { useDirectorySessions } from "@/hooks/useDirectorySessions";
import { useRunnerHealthRegistration } from "@/hooks/RunnerHealthProvider";
import { useHostFilesystem, type HostFilesystemEntry } from "@/hooks/useHostFilesystem";
import { useNativeServerSwitcherForMainSurface } from "@/hooks/useNativeServerSwitcher";
import type { Conversation } from "@/hooks/useConversations";
import { OttoEyes } from "@/components/OttoEyes";
import { SkillPills } from "@/components/SkillPills";
@@ -715,6 +716,12 @@ export function NewChatLandingScreen() {
[agentList],
);
// Surface element backing the iOS native server switcher overlay, which
// the in-session view shows too — the picker stays reachable while starting
// a new session. The hook hides it whenever the sidebar covers the surface.
const [landingSurface, setLandingSurface] = useState<HTMLElement | null>(null);
useNativeServerSwitcherForMainSurface(landingSurface, true);
const [message, setMessage] = useState<string>("");
const textareaRef = useRef<HTMLTextAreaElement>(null);
const isComposingRef = useRef(false);
@@ -1259,7 +1266,11 @@ export function NewChatLandingScreen() {
return (
// pb-12 lifts the content slightly above the geometric center, where
// the hero reads better optically.
<div className="flex flex-1 items-center justify-center" data-testid="new-chat-landing">
<div
ref={setLandingSurface}
className="flex flex-1 items-center justify-center"
data-testid="new-chat-landing"
>
{/* Padding lives inside the 840px cap, so the composer renders at
840 80 = 760px max. */}
<div className="flex w-full max-w-[840px] flex-col items-center gap-8 px-10 pt-8 pb-16">

Some files were not shown because too many files have changed in this diff Show More