Compare commits

...

109 Commits

Author SHA1 Message Date
62a4414968 fix(web): inject --bot-accent CSS variable into bot bubbles for colored accent borders
Some checks failed
ci / Detect product changes (push) Has been cancelled
ci / Lint (push) Has been cancelled
ci / Typecheck (push) Has been cancelled
ci / Production builds (push) Has been cancelled
ci / Unit tests (push) Has been cancelled
ci / Postgres journeys (push) Has been cancelled
ci / Web E2E (push) Has been cancelled
ci / Product CI (push) Has been cancelled
ci / Publish compatible mobile update (push) Has been cancelled
publish-server-image / Detect image publish inputs (push) Has been cancelled
publish-server-image / Compose installer smokes (push) Has been cancelled
publish-server-image / Validate app image (push) Has been cancelled
publish-server-image / Validate updater image (push) Has been cancelled
publish-server-image / Validate computer image (push) Has been cancelled
publish-server-image / Build app amd64 (push) Has been cancelled
publish-server-image / Build updater amd64 (push) Has been cancelled
publish-server-image / Build computer amd64 (push) Has been cancelled
publish-server-image / Publish app image (push) Has been cancelled
publish-server-image / Publish computer image (push) Has been cancelled
publish-server-image / Publish updater image (push) Has been cancelled
The CSS uses --bot-accent for the tinted background and left border, but the
inline style injection was missing after the remote merge. Add speakerColorDef.hex
to the two bot bubble render locations so the accent color actually applies.
2026-10-23 13:35:23 +00:00
f2841bdd74 Merge remote main into local 2026-10-23 13:30:27 +00:00
95d786ef2c fix(web): restore bot bubble accent color and user bubble border after refactor regression
- Add --bot-accent CSS variable injection from speakerColorDef.hex to all bot bubbles
- Restore colored left border accent on bot bubbles via CSS custom property
- Improve bot bubble background with subtle accent tint (88% card + 12% accent)
- Add colored right border accent to user bubbles
- Add subtle accent halo shadow to bot bubbles for better visual hierarchy
2026-10-23 13:28:24 +00:00
cynarAI
f11be8735c
Keep source bind mounts out of a recreated supervisor container (#227)
The supervisor on evo had a bind mount of an old source copy over the
image's /app sources. The deploy carried every mount over when it
recreated the container, so a rebuilt image never took effect and the
fix for missing bot networks stayed inactive. Mounts into /app are now
dropped; data and Docker socket mounts are kept.


Claude-Session: https://claude.ai/code/session_01QAiXRe1FLMAJT9jkvwFZFq

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-23 10:34:43 +02:00
cynarAI
679b90a667
Recreate a computer whose Docker network was removed (#226)
When a stack restart or network prune removes a bot's network, its
stopped computer keeps pointing at the old network id. Starting it fails
with "network ... not found", the supervisor returned 500 on every
provision, and runs for that bot retried setup forever while staying
queued. The supervisor now recognizes that error and recreates the
container on a fresh network with the same home, as it already does for
an outdated image.


Claude-Session: https://claude.ai/code/session_01QAiXRe1FLMAJT9jkvwFZFq

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-23 05:23:00 +02:00
cynarAI
ea7892baa3
Compact chat history automatically before it outgrows the model (#225)
* Push agents to act when they end a turn announcing work

A local model often ends a turn with "let me set that up now" and no tool
call. Nothing runs after a turn ends, so the user waits on work that never
happens. A text-only turn that announces unfinished work now gets up to two
follow-ups that ask the agent to do it or report what is open.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011VfFvSskgrByEU75QLyreE

* Summarize project conversations and retry contended handoffs

Group threads, which hold every project's main conversation, were never
compacted. Runs there saw only the last 200 raw messages, so the original
brief and earlier decisions fell out of context. The lead bot now
summarizes them like a bot thread; the summary stays local.

A group handoff now retries when its transaction hits a deadlock, so a
specialist that finished its work no longer fails at the hand-back.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011VfFvSskgrByEU75QLyreE

* Compact chat history automatically before it outgrows the model

Long chats lost their brief and open work. The Pi runtime passed only the
last 25 history entries, and the compacted summary and recalled memory sat
in front of them, so both were cut whenever a chat had more than 24
uncompacted messages. Context pruning then dropped the summary first,
because it was the oldest history entry. Compaction itself only started
after 100 uncompacted messages and ignored tokens.

- History entries can be pinned. The executor pins the summary and recalled
  memory, the runtime always keeps them ahead of the recent window, and
  context pruning never drops them.
- Compaction is driven by messages and tokens: it keeps the newest ten
  messages verbatim and folds the rest into the summary once 20 messages
  are uncompacted or the history exceeds 40% of the model's input budget.
- Before a run whose history would not reach the model in full, the
  executor compacts inline for up to 90 seconds, then runs with what it has.
- The summarizer writes a structured summary: goal and brief, decisions,
  done, open, preferences.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QAiXRe1FLMAJT9jkvwFZFq

* Record runtime failures again instead of leaving the run running

A runtime error closes the stream through withRuntimeCleanup, which aborts
the run's own controller. The catch treated that as an outside stop and
returned without finalizing, so a failed run stayed running and the web
never showed its error. Same fix as on the main-green branch.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QAiXRe1FLMAJT9jkvwFZFq

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-23 04:18:21 +02:00
cynarAI
467d9b0dc4
Keep main green: racing E2E tests, the git-clone push, and runs that never fail (#224)
* Stop two web E2E tests from racing on main

Replies read themselves aloud by default since the voice update, so in the
group chat test the last bot's automatic read-aloud could beat the manual
Speak click. The test turns automatic reading off for its two bots.

Opening the browser panel on a running computer now only fetches the
screen, as it did before the panel change, instead of also reloading the
whole thread on every computer state change.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TnT7eVHuaCzZKY5CnYzpr1

* Wait for the speaking switch to settle in the voice test

The app turns the local voice on by itself on first start and keeps the
switch disabled meanwhile. The helper read "off" during that window and
clicked a disabled switch; it now retries until the switch is on, clicking
only when it is enabled and off.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TnT7eVHuaCzZKY5CnYzpr1

* Fix main after the git-clone push: lint, i18n and abort tests

- Lint: formatting in two files and no assignment inside an expression
  in the steering poller.
- i18n: German for the new "Resume" and "Stopped" labels on web, and
  de/ru/zh on mobile; catalogs re-extracted.
- Tests follow the intended change that a caller abort ends a turn as
  "stopped" instead of failing it as a provider error.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TnT7eVHuaCzZKY5CnYzpr1

* Record runtime failures again instead of leaving the run running

A runtime error closes the stream through withRuntimeCleanup, which aborts the
run's own controller. The catch treated that as an outside stop and returned
without finalizing, so a failed run stayed running forever and its delegated
task never reported back.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TnT7eVHuaCzZKY5CnYzpr1

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-23 03:33:07 +02:00
cynarAI
5168518759 fix(sandboxes,adapters): allow cloning git repositories and link space git remote in prompts 2026-10-23 02:13:25 +02:00
cynarAI
8a98bfe79f
fix(adapters,sandboxes): tell agents where git works and make the refusal explicit (#223)
* fix(adapters,sandboxes): tell agents where git works and make the refusal explicit

Agents searched for the project repository in mounted host folders and read
the broker refusal as a missing broker. The prompt now names the folder where
git and gh work, and the refusal says to cd there instead of looking elsewhere.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

* test(web): wait for the automatic voice start before switching speaking on

Clicking the switch while the app was still turning the voice on by itself
could turn it off again, which failed the voice settings e2e twice in a row.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-22 23:51:31 +02:00
cynarAI
de95cf1b91
Fix red main: typecheck, lint, i18n, unit and web E2E (#222)
* Fix red main: typecheck, lint, i18n and stale unit tests

Direct pushes to main left typecheck, lint and 16 unit tests red.

- Typecheck: AskCard test uses the current ask block shape, the sandbox
  fault test calls the two-argument desktop writeFile, removing a queued
  message notifies with the thread's latest event seq, and the replay
  fixture only reads ref from steps that carry one.
- Lint: format and import order.
- i18n: transcript status labels were German source strings; they are
  English now with German translations, catalogs re-extracted, and the
  mobile "Failed to remove message" string is translated.
- Tests follow intended behavior changes: the time instruction now
  closes the system prompt, short chat turns start at low reasoning,
  owner-owned read-only files (git objects) pass the home check, and the
  desktop computer may read and write host files outside its home. The
  old desktop write-containment suite is replaced by a host file test.
- The composer task pill test mocks the Lingui macros like other tests.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TnT7eVHuaCzZKY5CnYzpr1

* Bump Expo patch releases so expo install --check passes

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TnT7eVHuaCzZKY5CnYzpr1

* Bring web E2E back in line with the new side panel

The side panel lost its computer tab, which left the full computer window
(take control, teach, release) unreachable from the web app. The browser
view now offers an "Open in full window" button, and opening the panel no
longer boots a stopped computer.

Also: the slash picker scrolls instead of growing under the header, user
skills come first in it, the agent overview hides an empty routines block,
the hover time hides again with collapsed details, and the light destructive
token meets contrast on the sidebar. E2E specs follow the new panel layout,
and the cursor and hover tests no longer race the UI.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TnT7eVHuaCzZKY5CnYzpr1

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-22 21:58:24 +02:00
cynarAI
9a753e9adb fix(web): increase transcript bottom padding when task pill is pinned above composer 2026-10-22 16:36:44 +02:00
cynarAI
95061c648f feat(web,mobile): dock expandable live task pill above composer and fold plan steps 2026-10-22 14:11:19 +02:00
cynarAI
78827e0a5b feat(adapters): rasterize SVG attachments to PNG and auto-attach written SVGs 2026-10-22 13:33:55 +02:00
cynarAI
fe80edbf52 feat(adapters,web,chat-ui,core): enhance web research, page browser actions, markdown citations, and executor streaming 2026-10-22 12:19:26 +02:00
cynarAI
996af168ef fix(web): make right sidebar header toggle work across mobile, projects, and groups 2026-10-22 12:04:39 +02:00
cynarAI
35b7291660 feat(web,core,adapters): unify right sidebar styling, hover resize handles, voice & search improvements 2026-10-22 10:24:32 +02:00
cynarAI
75423579b7 fix(api): honor EXTRA_TRUSTED_ORIGINS in CORS and requestWebOrigin 2026-10-21 22:14:00 +02:00
cynarAI
ac0384e49c feat: add automatic plan mode, live step tracking, and chat UX updates 2026-10-21 22:00:28 +02:00
cynarAI
000745592a
Keep voice choices locked until speaking finishes turning on (#220)
Turning speaking on connected the local voice and cleared the busy
state halfway through, so a voice picked in that window was replaced by
the default once the switch finished. The panel now stays busy for the
whole switch.


Claude-Session: https://claude.ai/code/session_01Wy1md9QE1ayJbDCwJ4vAQs

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-20 11:14:28 +02:00
cynarAI
d1dd8ffcd9
Keep the Expo action's caches off the GitHub cache service (#219)
The mobile OTA job uploaded the EAS CLI to the GitHub cache service
because expo-github-action caches by default. Turn both of its caches
off and have the Actions budget test reject the Expo action unless they
are off.


Claude-Session: https://claude.ai/code/session_01Wy1md9QE1ayJbDCwJ4vAQs

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-20 10:34:47 +02:00
cynarAI
263e3a31f8
Fix red main: mobile group kind, i18n catalogs, project picker check (#218)
* Fix red main: mobile group kind, i18n catalogs, project picker check

Main CI failed on typecheck, unit tests and web E2E after the last two
direct pushes. MobileGroup now carries the optional chat kind the mobile
screens read, the Lingui catalogs are re-extracted with German strings
for the new voice texts, the project overview plus icon meets the UI
scale guard, and the sidebar E2E expects the renamed project lead.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R8L4XgqjAemFR2remB5hVY

* Format files merged from main

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wy1md9QE1ayJbDCwJ4vAQs

* Align E2E with the canvas close button and per-chat drafts

The in-app browser now closes from the side panel header, and each chat
keeps its own unsent draft. The group chat test waits for the settings
save to land before switching chats, so the switch no longer races the
panel closing.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wy1md9QE1ayJbDCwJ4vAQs

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-20 09:34:40 +02:00
cynarAI
bb7cf3f55e feat(threads): add send-now action for queued follow-up messages 2026-10-19 19:59:05 +02:00
cynarAI
66dbf0046e fix(web,core,db): resolve responsive layout overflows, dialog clipping, i18n gaps, and runtime stability 2026-10-19 18:57:04 +02:00
cynarAI
ac9fcce0fb feat(web): show routines with quick toggle in project overview sidebar 2026-10-19 17:29:31 +02:00
cynarAI
f820c75632 feat: persist unsent composer drafts across chat and page switches 2026-10-19 12:56:59 +02:00
cynarAI
be657f6e3c feat(web): remove redundant live progress card and add adaptive project vs 1:1 agent overview sidebar 2026-10-18 22:07:53 +02:00
cynarAI
f6053dd2a8 feat: allow project deletion with owned agents, prevent duplicate project names, and refine voice conversation UX 2026-10-18 21:04:18 +02:00
cynarAI
1611fe5fa2
Let computers push from project folders through the GitHub broker (#217)
Two gaps kept agents from pushing on a containerized supervisor:

- The git and gh shims called rakazo-broker by name, and login shells in
  the computer reset PATH, so the broker was "not found". The shims now
  call it next to their own resolved path, and /usr/bin/gh is a shim too.
- A project folder outside the supervisor container's mounts is invisible
  to it, so broker git ran against an empty directory. Such calls now run
  in a one-off computer-image container that binds the folder.


Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-18 15:04:20 +02:00
cd770b9a1d fix: mobile sidebar overlay z-index z-30→z-50 so tap-to-close works 2026-10-18 12:30:03 +00:00
cynarAI
a27cdf733c
Install git and gh in the sandbox supervisor image (#216)
The GitHub broker runs git and gh for agent computers, but the supervisor
image is node:bookworm-slim, which has neither. A containerized supervisor
therefore failed every broker call. Install both, and when the supervisor
runs as root, run git as the workspace owner so clones stay writable by
the computer.


Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-18 12:31:54 +02:00
cynarAI
a0bdf6ddc8
Refresh a containerized sandbox supervisor on deploy (#215)
A supervisor that runs as a standalone container kept the image it was
started with, so broker fixes never reached it. The deploy now rebuilds
that image when its sources change and recreates the container with the
same network, restart policy, mounts and environment, restoring the old
one if the new one stays unhealthy. The broker socket moves into the data
directory and is shared from containerized supervisors too.


Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-18 10:25:09 +02:00
cynarAI
9bc3187a4a
Give every computer the GitHub broker socket and name failed paths (#214)
Chief's computer still timed out reaching the GitHub broker after the
socket mount shipped. A reused computer kept its old container whenever
the image matched, so one created without the mount never got it, and
the socket was only served for a loopback-bound supervisor.

- Recreate a reused computer that lacks the socket mount once the
  supervisor serves the socket.
- Serve the socket from any supervisor running directly on a Linux host.
- Give the broker listeners the same long request timeout as the main
  server, so large brokered clones are not cut after five minutes.
- The broker client now says which path failed (no socket, socket exit,
  network exit) so a relayed error points at the cause.


Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-18 06:37:48 +02:00
cynarAI
7452b8c6d2
Reach the GitHub broker over a mounted socket instead of the network (#213)
Computers on evo could not reach the broker over TCP: the git shim
reported that evOo's GitHub connection was unreachable, so cloning
private repositories still failed. Container-to-host traffic depends on
the host firewall and on which Docker network the computer joined.

- On a Linux host the supervisor also serves the broker on a unix socket
  in a host directory (SANDBOX_GITHUB_BROKER_SOCKET_DIR, default
  ~/.rakazo/github-broker), and mounts that directory read-only into
  each new computer once the socket listens.
- The shim uses the socket when it is present and falls back to the
  network otherwise; connections time out after 5 seconds and the
  unreachable message names the curl exit code.


Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-18 02:49:34 +02:00
cynarAI
f136451e22
Stop agents from using the broker run token as a GitHub password (#212)
Agents kept configuring git with RAKAZO_GITHUB_BROKER_RUN_TOKEN as a
password because the system prompt listed it among "managed credentials"
to use in shell commands. GitHub rejects it, and the agent then asks the
user for a personal access token.

- List only the user's own agent secrets in that instruction; the broker
  variables stay in the environment for the git shim.
- Make /usr/bin/git the shim as well, so git called by absolute path
  still goes through the broker; the real binary moves out of PATH.
- Prompt no longer names the broker variable.


Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-18 01:13:37 +02:00
cynarAI
d962e5e7f4
Fix brokered git calls being rejected before reaching GitHub (#211)
The computer's git and gh shims passed a literal "--" into the JSON argv
(python -c keeps it in sys.argv), so every brokered clone arrived as
"git -- clone" and the broker refused it. Agents then fell back to real
git with the broker run token as a password, which GitHub rejects.

- Drop the stray "--" from both shim payloads.
- Route git through the broker even behind -c/-C/--no-pager options.
- Share one broker client that falls back to the default gateway for
  computers created before host.docker.internal was mapped, and says
  plainly when the broker is unreachable or refuses a request.
- Broker normalizes GitHub SSH remotes and pasted credentials to plain
  HTTPS and points a rejected stored token at reconnecting GitHub.
- Prompt says plain git clone works and the run token is not a GitHub token.


Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-17 18:04:02 +02:00
cynarAI
a36b45b791
Let computer git reach GitHub through the broker with working credentials (#210)
* Let computer git reach GitHub through the broker with working credentials

The broker URL handed to computers pointed at the supervisor's loopback
address, which a container cannot reach, and host git ignored the
integration token for HTTPS remotes. Computers now get the host through
host.docker.internal, host git receives the token via a one-off
credential helper, and clone paths and the working directory map from
the computer onto the host workspace.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

* Serve the GitHub broker to computers on a Linux host-run supervisor

A loopback-bound supervisor is unreachable from containers on Linux, so
it also serves the broker routes alone on the default bridge gateway,
which only containers on the same host can reach.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-17 16:38:25 +02:00
cynarAI
ff7e95a081
Find connected-app tools from queries written in words (#209)
* Find connected-app tools from queries written in words

Catalog search matched the query only as one substring, so "search
repositories" or "get file" found nothing for search_repositories and
get_file_contents and the agent concluded GitHub had no tools. Underscores,
hyphens and spaces now compare equal, a query matches when every word starts
a word of the tool, and a miss returns the source's tool names.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

* Bump Expo SDK 57 packages to the patch versions expo install expects

expo install --check now expects @expo/ui 57.0.25, expo 57.0.30 and
expo-router 57.0.28, which turns the mobile typecheck red on every branch.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-16 20:32:55 +02:00
cynarAI
0b3ce11df8
feat: recruit library agents into project conversations and scan project folders (#206)
* media: check ffmpeg filter support before subtitle operations

* sandbox: handle synchronous spawn errors safely in desktop sandbox

* Voice-first streaming, non-blocking follow-ups, quiet routines and chat UX

Why: questions blocked the chat, follow-up messages had no acknowledgement, message actions sat beside the bubble, and voice turn-taking added latency.

What: waiting runs accept steering messages and the bot marks each claimed follow-up as seen; unclaimed follow-ups show as queued. Reaction, copy and reply controls sit under each message. Streaming voice (VAD endpointing, barge-in that stops the run, clause-based TTS), an optimistic input queue, procedural earcons and delta-based quiet routines were added. Onboarding can be skipped straight to the main conversation. Dual-surface layout and project pickers are included. Status colors use semantic tokens.

Tested: unit suites for core, adapters, api and web pass, typecheck and Biome are clean. Browser end-to-end tests were not run locally.

* Update translation catalogs

Re-extracted catalogs for the new and moved strings; the catalog sync test passes.

* Add project plan and test readiness notes

Notes from the milestone work: feature inventory, test infrastructure and test readiness. No code changes.

* fix(ci): align scale guards, space deletion, computer toggle, and mobile expo deps

* fix(ci): restore space deletion guard, browser toggle, and run-steps contracts

* fix(ci): restore model resolution errors, group stage, and onboarding flow

* fix(ci): restore in-app-browser close button, disable stop button while sending, and sync lingui catalogs

* fix(ci): address web e2e failures for bot-crud, choice cards, message quote, and voice onboarding

* test(web): wait for avatar and rig visibility in oo-avatar motion spec

* test(web): wait for persisted message IDs in message-hover-actions pagination spec

* feat: recruit library agents into project conversations and scan project folders

* test(web): expect repo description in GithubRepoPicker onChange

* Onboarding: always drop the local voice when the voice step is skipped

Skipping waited only for the latest voice load and disconnected only
when the client saw the connect succeed. An earlier or client-failed
connect could still persist the local credential, so a user who
skipped voice was left with one. Wait for every load and always
disconnect.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wy1md9QE1ayJbDCwJ4vAQs

* Shell: hide the optimistic copy once the transcript shows the send

A message sent while the bot works streamed into the transcript before
its send request resolved, so the same text showed twice until the
optimistic bubble was removed. Drop an optimistic message as soon as a
persisted user message with the same text appears.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wy1md9QE1ayJbDCwJ4vAQs

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-15 14:44:53 +02:00
cynarAI
ca869561ac
Voice-first streaming, non-blocking follow-ups and chat UX (#205)
* media: check ffmpeg filter support before subtitle operations

* sandbox: handle synchronous spawn errors safely in desktop sandbox

* Voice-first streaming, non-blocking follow-ups, quiet routines and chat UX

Why: questions blocked the chat, follow-up messages had no acknowledgement, message actions sat beside the bubble, and voice turn-taking added latency.

What: waiting runs accept steering messages and the bot marks each claimed follow-up as seen; unclaimed follow-ups show as queued. Reaction, copy and reply controls sit under each message. Streaming voice (VAD endpointing, barge-in that stops the run, clause-based TTS), an optimistic input queue, procedural earcons and delta-based quiet routines were added. Onboarding can be skipped straight to the main conversation. Dual-surface layout and project pickers are included. Status colors use semantic tokens.

Tested: unit suites for core, adapters, api and web pass, typecheck and Biome are clean. Browser end-to-end tests were not run locally.

* Update translation catalogs

Re-extracted catalogs for the new and moved strings; the catalog sync test passes.

* Add project plan and test readiness notes

Notes from the milestone work: feature inventory, test infrastructure and test readiness. No code changes.

* fix(ci): align scale guards, space deletion, computer toggle, and mobile expo deps

* fix(ci): restore space deletion guard, browser toggle, and run-steps contracts

* fix(ci): restore model resolution errors, group stage, and onboarding flow

* fix(ci): restore in-app-browser close button, disable stop button while sending, and sync lingui catalogs

* fix(ci): address web e2e failures for bot-crud, choice cards, message quote, and voice onboarding

* test(web): wait for avatar and rig visibility in oo-avatar motion spec

* test(web): wait for persisted message IDs in message-hover-actions pagination spec

* Onboarding: always drop the local voice when the voice step is skipped

Skipping waited only for the latest voice load and disconnected only
when the client saw the connect succeed. An earlier or client-failed
connect could still persist the local credential, so a user who
skipped voice was left with one. Wait for every load and always
disconnect.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wy1md9QE1ayJbDCwJ4vAQs

* Shell: hide the optimistic copy once the transcript shows the send

A message sent while the bot works streamed into the transcript before
its send request resolved, so the same text showed twice until the
optimistic bubble was removed. Drop an optimistic message as soon as a
persisted user message with the same text appears.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wy1md9QE1ayJbDCwJ4vAQs

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-15 14:08:31 +02:00
cynarAI
2073b50c94
test: stabilize chart-links E2E after Plot warmup before routes (#204)
Main CI failed Web E2E on a blank chart (no browser errors) when route
handlers were registered before the fixture finished lazy-loading Plot.
Warm the fixture first, and retry once via reload inside expectChart.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
2026-10-15 12:05:14 +02:00
cynarAI
20421e6d58
Log which fields break an RPC output contract (#207)
An 'Output validation failed' response was swallowed by the RPC error logger because it is an ORPCError, so the failing procedure and fields never reached the logs. Log those cases with the procedure and the field paths, without the payload.


Claude-Session: https://claude.ai/code/session_01LFxDxzmAy9mJnKWoBrCtRB

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-15 00:35:50 +02:00
cynarAI
557f8486cd
Bump Expo SDK 57 packages to the patch versions expo install expects (#208)
The mobile typecheck runs expo install --check, which started failing on main once Expo published newer SDK 57 patch releases. Only the eleven flagged packages move.


Claude-Session: https://claude.ai/code/session_01LFxDxzmAy9mJnKWoBrCtRB

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-14 23:58:55 +02:00
cynarAI
f14b112cdd
Broker GitHub Integrations token for computer git/gh (#203)
* Broker GitHub integration token for computer git/gh

Use the Integrations GitHub MCP secret for supervisor broker sessions
so home and project computers can clone private repos without PAT prompts.
Add rakazo git/gh shims, push policy, deploy image rebuild, and tests.

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* ci: retrigger product checks after runner timeout

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* ci: run product change detection on GitHub-hosted runners

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* style: format deployment-github-token

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* ci: self-hosted change detection with timeout and fail-open gate

Keep Detect product changes on evoo-ci with a 10-minute cap, continue-on-error
filtering, and a fail-open product flag when detection fails. Downstream jobs
and Product CI treat a failed or timed-out changes job as full product impact.
Add job timeouts on remaining self-hosted ci jobs.

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* fix: continueRun tests and broker home path guard

- Skip broker workspace resolution when computer homeKey is absent
- Add shared continue-run Prisma/home/secretStore test stubs
- Update computer PATH expectation and evo-deploy command sequence
- Remove unused executor imports flagged by Biome

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* style: Biome format and import order for broker CI

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* fix: compose YAML, typecheck for broker stubs and GitHub hint

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* ci: retrigger Product CI after compose and typecheck fixes

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* ci: retry Product CI queue

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* ci: give self-hosted Web E2E a 45m job timeout

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* ci: extend Web E2E job timeout to 60m on self-hosted runners

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* fix: keep github-broker-token off the core browser barrel

Export broker HMAC helpers only from @rakazo/core/node/github-broker-token so
Vite does not pull node:crypto into the web client and break Playwright signup.

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
2026-10-11 22:58:48 +02:00
cynarAI
22fa2e1606
Accept more argument shapes for connected-app tools and name what is missing (#202)
Catalog execute calls now merge parameters flattened next to an empty
arguments object and unwrap parameters nested under keys like parameters
or args, unless the tool itself takes that key. A failed validation lists
the required and received keys so the agent can correct its call, and the
prompt shows the expected call shape and says connected apps need no token.


Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-11 00:33:27 +02:00
cynarAI
eb777d63ee
Let the agent use GitHub tools and git on its computer (#201)
Catalog execute calls now accept arguments sent as a JSON string or flattened
next to the id, the computer image ships git and curl, and the evo deploy
rebuilds that image when its sources change.


Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-10 19:49:20 +02:00
cynarAI
d6835fe0b0
Keep schedule_create for later or repeated work (#200)
A live check showed the agent answering a business-idea question by creating a schedule that would run the analysis later. The schedule_create description now says never to use it for work the user wants now, however long.
2026-10-10 19:07:31 +02:00
cynarAI
42eb420545
Agent replies without thinking aloud or tool names; chart-links E2E names its failure (#199)
A run of the real prompt against the local model showed reasoning written as chat text, internal tool names in a capabilities answer, and only a data fetch when asked to build a tool. The working principles now say that everything written is shown, to describe abilities in plain words, and that a request for a tool means building one.

The chart-links spec now waits for the chart or its error and reports browser errors, so the recurring 90-second timeout names its cause. The ZIP upload spec matches the file chip exactly, since the agent reply can quote the name.
2026-10-10 17:24:34 +02:00
cynarAI
f3b47cb7cd
Tell the agent what it can reach and how to work reliably (#198)
Agents denied a connected GitHub and did not know they could install
skills: the prompt said "No plugins are connected yet." whenever only
installed tool sources existed, and the skill catalog never mentioned
skill_install. Connections and skills were also bound to the space they
were set up in, so a project never saw them.

- Name every connected app from the tools the run actually received,
  including lazy catalogs and how to reach them.
- Tool sources and skills follow the user into every space; a skill in
  the current space wins a name clash.
- Add shared working principles (do the work, verify before claiming,
  keep it simple, build a missing tool as a tested script plus skill).
- Rewrite the skill catalog in plain words, mention skill_install, clip
  long descriptions and count skills past the limit.
- Routine runs learn that nobody is waiting and history is not shown;
  schedule prompts must be self-contained.


Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-10 15:33:57 +02:00
cynarAI
4cd9468b6f
Install skills from a pasted GitHub link (#197)
A new skill_install agent tool reads every SKILL.md behind a public github.com
repository, folder or file link and saves each as one of the user's own,
editable skills. Only GitHub's API and raw hosts are contacted; invalid files
and name clashes are skipped and reported.


Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-10 14:38:04 +02:00
cynarAI
4585d3b0ec
Add Second opinion, Ship and Founder builtin skills (#196)
* Add Second opinion and Founder builtin skills

Second opinion has a fresh reviewer approve the plan and inspect the build,
so whoever writes never approves their own work. Founder tests a business
idea with simulated roles and a crowd of 100 simulated buyers and ends in a
business plan card with a verdict.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

* Add Ship builtin skill for phased software work

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-10 13:51:03 +02:00
cynarAI
3469165c92
Connect GitHub from a token alone (#195)
* Connect GitHub from a token alone, without a vendor account

Integrations only reached GitHub through Composio or Pipedream, which a
self-hosted instance does not have. A built-in GitHub row now opens
GitHub's prefilled token form, and the pasted token connects GitHub's
own MCP server through the existing installed-connector path. Web and
mobile.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

* Sync web catalogs for the GitHub connect strings

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-10 13:18:44 +02:00
cynarAI
797906dde2
Allow repository ZIP uploads up to 5 GiB (#194)
* Allow ZIP uploads up to 5 GiB and stream them onto Docker computers

Repository archives could not be picked at all: application/zip was not an
allowed attachment type. ZIPs are now allowed with their own 5 GiB cap,
upload as a stream, and reach the bot's Docker computer through a new
streamed supervisor write that commits only after the body ended cleanly.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

* Use a disallowed type for the invalid-attachment check now that ZIP is allowed

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-10 12:35:32 +02:00
cynarAI
81c4bfd7e3
web: move the 1:1 Oo status into the chat header (#193)
The strip under the header of a running 1:1 chat repeated what the header
already carried (a "Present" pill plus a persona line). The header's second
line now shows what the Oo is doing (Working, Thinking, waiting for input,
needs attention, or a delegation), and the bot's role while it rests. The
stage only appears in an empty chat and no longer repeats the name.


Claude-Session: https://claude.ai/code/session_01H3buE7EyYCfz8nYccLCcct

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-10 09:55:01 +02:00
cynarAI
a746783c48
ci: keep every job on internal runners without GitHub Actions storage (#192)
Runs used GitHub's artifact storage and cache service, which ran out of
the monthly included quota and blocked publishing. Caches and job
handoffs now stay on the self-hosted runner host, the desktop release
hands builds over through its draft release, and Windows waits for a
self-hosted runner instead of a GitHub-hosted one. A test enforces it.


Claude-Session: https://claude.ai/code/session_01R8L4XgqjAemFR2remB5hVY

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-10 07:44:16 +02:00
cynarAI
634b3f823b
ci: stop image publish legs from cancelling each other (#191)
Every main push since the concurrency change cancelled two of the three
image builds: the build job's concurrency group named only the ref, so
the app, updater and computer legs of one run shared it and cancelled
each other, and the publish job never ran. Main has not published an
edge image since. The build and publish groups now name the matrix leg.

The Playwright report publisher also read the jobs of a run's latest
attempt, so a re-run already in progress made it fail on an empty
conclusion. It now reads the attempt that finished.


Claude-Session: https://claude.ai/code/session_01R8L4XgqjAemFR2remB5hVY

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-10 06:50:07 +02:00
cynarAI
6ace14c3c8
Projects: one sidebar row with icon and color, coordinator overview (#190)
Each project is now a single sidebar row with its own icon and color that
opens the project's coordinator conversation. The whole row drags to
reorder. The row menu gains Customize for name, icon and color. Inside
the coordinator conversation, an overview lists the project's agent
threads that need the user, are running, or recently finished.


Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-10 03:32:40 +02:00
cynarAI
1c8d13a837
Open an existing project instead of creating a same-named twin (#189)
Creating a project whose name a member already uses (from the dialog, the
agent's create_space tool or the onboarding scan) now returns that project,
so the sidebar no longer lists two projects with one name. The conversation
picker lists the main agent first.


Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-09 19:58:18 +02:00
cynarAI
5cf9d679b4
Fold a run's step messages behind its latest message (#188)
* Fold a run's step messages behind its latest message

Long tool runs posted every step as its own bubble, so the answer drowned in
narration and a summary written before saving a file appeared twice. Web and
mobile now fold a run's text-only step messages behind one "N steps" line
above the run's latest message. The thread snapshot also stops replaying live
narration that was already posted, which showed steps again after a refresh.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

* Keep a run's last step visible above a card

A card such as the peer-message receipt has no words of its own, so folding
the narration into it hid everything the bot said. Steps now fold only into
a written answer or the live row; before a card the last step stays visible.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-09 19:32:21 +02:00
cynarAI
dd177f7ef5
Name 1:1 senders, reach the computer from a group, translate stage lines (#187)
* Name 1:1 senders after their bot and reach the computer from a group

In a 1:1 chat bot messages fell back to the label "evOo" because only group
members were looked up; they now use the bot's name. The main conversation
and project chats show the computer button and open their lead bot's computer.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

* Translate the stage's collaboration lines

Subagent, handoff, delegation and peer-reply lines on the stage were English
in every language. They now go through the catalog, with German filled in.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-09 18:31:49 +02:00
cynarAI
83d2106633
Quieter chat chrome: no repeated names, no English working line, no tool names (#186)
* Name a run's chat once when its group carries the bot's name

A project's main conversation is named after its lead bot, so Today
showed "Chief · Chief". Web and mobile now share one title helper that
drops the repeated name.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

* Drop the "1:1 with" line under a conversation's title

The stage header repeated what the chat already shows, such as
"1:1 with Chief" under a project's name. The title and the phase pill
remain.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

* Let the stage's translated thinking lines speak while a bot works

A running bot raised a "working" awareness event whose English line,
such as "Chief is working on your request.", replaced the translated
thinking lines and added an "In progress" strip next to the phase pill.
The strip now only shows collaboration.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

* Ask bots to describe their steps without naming tools

Long runs showed lines such as "I'm using browser_navigate" in the chat.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

* Announce the stage phase as status and keep following after a tap

The working strip used to be the only status region while a bot worked; the
stage's phase pill now carries that role. A tap at the latest message no
longer stops the transcript from following new messages.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-09 17:48:18 +02:00
cynarAI
f720683496
Unstick chats waiting on the user and show them where the user looks (#185)
* Resume a waiting run after takeover even when its execution lease is gone

When resuming a run failed while it waited for the user, the run stayed
waiting_takeover but lost its execution lease. Taking over then bound no
run, so handing control back resumed nothing and the chat hung at the
computer. Takeover now binds the newest leaseless waiting run.

The stage also counted waiting runs as work and showed "working" while
the bot waited on the user; it now listens instead.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

* Tell every bot it is an agent in evOo, not the model behind it

Local models introduced themselves as the assistant product they were
trained as and even asked for that product's login. The identity block
now names evOo, and the fallback prompts say evOo instead of Rakazo.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

* Show runs waiting on the user in Today from every space

Today and its badge listed only runs of the selected space, while the
sidebar shows chats from all spaces. A chat waiting on the user in the
home space therefore showed "needs you" in the sidebar and "All quiet"
in Today. The activity list now spans every space the user belongs to,
carries each run's space, and opening a run switches to that space on
web and mobile.

The runs-list Postgres suite was not part of the integration run; it is
now, with a case for a waiting run in another space.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

* Keep a project's main conversation to its own asks

Every main conversation showed the inbox of all the user's spaces, so a
takeover waiting in the home chat sat above the composer of every
project. Only the home space's main conversation collects other spaces
now; a project shows its own items.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

* Also resume a waiting run that sits behind an older run's inactive lease

Inactive execution leases stay as fencing tombstones, so a run that lost
its lease can wait behind an expired lease of an earlier run. Takeover
now looks for the bot's waiting run whenever the lease is neither busy
nor bound to a waiting run. Journey 4f covers both the released and the
stale lease.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

* Translate the voice awareness labels and the computer card's state

The voice strip showed hardcoded English labels such as "In progress",
and the computer card showed its stored "Needs you" state untranslated
in German chats.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-09 16:52:26 +02:00
cynarAI
4b814c9a17
Greet with the header's ev-eyes wordmark and a calmer welcome type scale (#184)
Welcome and sign-in now show the same animated wordmark as the sidebar header
("ev" plus the Oo eyes) instead of the teal face. The welcome tagline, subline and
buttons drop to the shared title and body sizes.


Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-09 16:13:38 +02:00
cynarAI
45d3f1ae11
Equal message sizes and one evOo logo on the way in (#183)
* Read agent replies at the size of the user's own messages

Chat markdown set its own 17px reading size scaled for phones, so agent replies
rendered at about 19px while the user's bubbles on the same phone read at 16px.
The markdown now takes size and leading from the bubble around it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

* Show the same evOo logo on the welcome and sign-in screens

The welcome screen showed the eyes-only mascot over an "EvOo" title, while sign-in
and sign-up showed the teal face (the favicon and app icon) through a CSS override of
the old two-pill markup. Both screens now render the teal logo directly, the title reads
"evOo", and the CSS override is gone. The welcome lines balance so no word sits alone.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

* Run the autopilot auto-merge job on Linux runners again

The macOS runners that joined the evoo-ci pool have no gh on the runner's PATH, so the
job died with "gh: command not found" whenever one of them picked it up. The job takes
seconds, so it gains nothing from the extra runners.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-09 13:33:44 +02:00
cynarAI
8ae3764b05
Speak German through a local Piper server, since Kokoro has no German voices (#182)
Kokoro-FastAPI ships English, Spanish, French, Hindi, Italian, Japanese,
Portuguese and Chinese voices only. The German presets pointed at voice
ids no Kokoro server has, so German users either got English voices or
picked a voice that failed to speak.

The local voice now also lists the German voices whose Piper model is
installed (PIPER_BASE_URL) and speaks them through Piper's /synthesize
as WAV. Thorsten, the clearest model, is the German default. Mobile
plays WAV as well as MP3, and the E2E voice stub stands in for Piper.


Claude-Session: https://claude.ai/code/session_01KWEAjTw26eRf3ZXTXfRjmD

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-09 11:55:51 +02:00
cynarAI
6c0fba8cc1
E2E: longer waits for slow fixture renders, shorter Playwright artifact retention (#174)
* fix(mobile): align Expo SDK 57 patch deps for install --check

Bump @expo/ui, expo, expo-constants, expo-linking, expo-modules-core,
expo-notifications, expo-router, and expo-updates to the versions
expo install --check expects after upstream patch releases.

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* ci(e2e): harden flaky waits and trim Playwright artifact uploads

Chart fixture reloads and shell readiness can exceed 30s on loaded
self-hosted runners. Align timeouts with the rest of the suite and wait
for gallery mount before counting species.

Stop uploading per-test trace folders to Actions (HTML report only) with
1-day retention so quota errors stay non-blocking.

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* evo-deploy: probe health before checking the timeout

With a short timeout the clock could tick past it before the first
probe, so a healthy service was reported as timed out. Probe first,
then check the clock.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wy1md9QE1ayJbDCwJ4vAQs

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-10-09 10:56:32 +02:00
cynarAI
b1b76a846e
ci: isolate buildx builders on shared evo Docker hosts (#178)
* ci: isolate buildx builders for parallel publish-server-image builds

Parallel matrix jobs on shared evo runners raced on ~/.docker/buildx current
builder; one job's setup-buildx cleanup then stopped another job's BuildKit during
cache export. Use per-job DOCKER_CONFIG, stable builder names, and wire build-push
to steps.buildx.outputs.name.

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* test: avoid biome noTemplateCurlyInString in publish workflow asserts

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-10-09 10:11:39 +02:00
cynarAI
f07d17e927
Oo computer browser: live Chromium, cowork, AgentCursor (#179)
* feat(web): Oo computer browser panel with cowork and local preview

Wire the in-app browser panel to the active Oo's live Chromium via the
computer screen stream, page-browser navigation, and interactive cowork
input with AgentCursor. User browser actions become system meta lines in
thread context; localhost preview remains as Lokale Vorschau.

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* fix(i18n): sync Lingui catalogs for Oo browser panel

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* Fix the Oo browser's lint and E2E failures

The shared-browser overlay is a focusable input surface, so it gets the
application role and a translated label. The local preview focuses the
address field again when no page is open, which the split out of
InAppBrowser had dropped. The Oo browser spec opens the panel by its exact
name, because the signed-up user's name also contains "Browser", and reads
the user action from the RPC body's json envelope.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wy1md9QE1ayJbDCwJ4vAQs

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-10-09 10:10:25 +02:00
cynarAI
81468063ff
Add LAN dev helper for iPhone against local Mac API (#181)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-10-09 10:10:22 +02:00
cynarAI
b8781a0081
Merge pull request #170 from cynarAI/cursor/ci-queue-reduction-197c
ci: reduce self-hosted queue depth (concurrency + path filters)
2026-10-09 08:49:51 +02:00
cynarAI
a2c7182ed6
Side panel Routinen: list toggles, hide when empty (#180)
* feat(web): routine list toggles and hide empty Routinen section

Show Routinen in the Oo side panel only when the bot has routines; remove
the panel create control. Each list row gets an active switch with optimistic
updates via routines.update. E2E covers visibility, toggle persistence, and
updates existing routine specs to create via RPC like chat.

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* fix(web): sync lingui catalogs after routine panel UI change

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* fix(web): refresh routine list optimistically after save

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* test(e2e): wait for routine list after RPC create in golden path

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* style: format golden e2e after routine list wait

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* fix(web): show sign-up while session lookup is pending

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
2026-10-07 23:41:11 +02:00
cynarAI
e4b995ec14
Production readiness Phase 2: DE profile facts, mobile audit, route matrix E2E (#176)
* feat(web): Phase 2 production readiness — locale, mobile, E2E matrix

Localize onboarding focus facts by profile locale (DE assertion).
Add mobile overflow/touch audits, deep-link matrix, extended console
checks, and E2E for empty project, session unavailable, and teaching
composer. Safe-area and Heute header layout on 390px viewports.

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* fix(e2e): stabilize Phase 2 specs for mobile shell and empty project

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* fix(api): satisfy typecheck for focusFactValue

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* fix(api): type-safe focusFactValue lookup

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* fix(api): narrow focus fact locale key

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* fix(api): use FocusOptionId union for fact lookup

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* test(web): align Shell header control assertions with touch targets

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* style(web): format desktop header control test

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* fix(web): stabilize Phase 2 E2E (settings i18n, empty project, chart plot load)

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* fix(web): empty project E2E simulates pre-start bootstrap; relax module recovery wait

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* fix(web): format empty-project E2E; stabilize chart-links cold Plot load

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* test(web): run chart-links E2E serially to reuse warmed Plot module

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* style(web): format chart-links E2E

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
2026-10-07 15:26:09 +02:00
cynarAI
97787a0272
Production readiness phase 1: journey audit, fixes 1–4, E2E (#175)
* Production readiness phase 1: journeys doc, onboarding persistence, teardown, chat UX

- Add docs/PRODUCTION-READINESS.md with journey matrix and defect backlog
- Persist onboarding draft in session storage across reload
- Localize onboarding discovery main-bot role; German Chief of Staff string
- Destroy team sandboxes and home folders when deleting an account
- Keep composer/transcript visible during queued runs; extend send watchdog
- Add Playwright specs: reload persistence, DE intake, queued progress, console guard

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* Fix CI: sync Lingui catalogs, onboarding name draft, E2E stability

- Localize Chief of Staff in discovery shell; sync all locale PO files
- Propagate Oo name to onboarding draft while typing
- Adjust DE intake, queued-run, and console-error E2E specs

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* Format chat-queued-progress E2E spec

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* fix(api): scope account-delete sandbox teardown to owned spaces

Only destroy team sandboxes and homes for spaces the deleting user
owns with no remaining members or bots. Guard home removal under
dataDir/homes. Add unit and postgres journey coverage.

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* chore: apply biome formatting

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
2026-10-07 11:25:39 +02:00
cynarAI
0e1036b2ff
First-run onboarding UX: naming, locale, voices, Heute route, mobile drawer (#171)
* Fix first-run onboarding UX: naming, locale, voices, Heute route, mobile drawer

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* Fix biome import order and formatting

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* Fix biome formatting on OoStage and E2E specs

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* Fix E2E specs for onboarding name step and Heute reload route

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* Fix German onboarding heading matcher in E2E

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* Fix German signup E2E, Escape menu stacking, and Heute rhythm click

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* Make onboarding E2E helpers locale-aware for German UI

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* Fix completeOnboarding language skip for Skip · locale label

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* Use language-step Skip · locale button in completeOnboarding

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* Fix German E2E: Nachricht composer, voice loading wait, Heute rhythm

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* Stabilize Heute reload URL and German voice E2E assertions

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* Fix Heute bootstrap redirect and German onboarding voices

When Kokoro lists no locale-matched voices, onboarding now uses offline
presets instead of showing English-only options. Shell bootstrap skips
bot redirects while the user is on /app/heute.

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* Relax German voice E2E name matching for voice labels

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* Harden Heute route and German voice onboarding E2E

Rebased onto main with Expo SDK 57 typecheck fixes. Voice options expose
stable test ids; Shell keeps /app/heute across bootstrap and redirects
legacy /app/heute bot URLs. Heute coach E2E asserts URL after reload.

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* Format Shell after Heute route helper

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
2026-10-06 20:15:36 +02:00
cynarAI
ba89574c83
Align Oo avatar eye spacing with evoo-logo (#173)
* Align Oo avatar eye spacing with evoo-logo overlap ratio

Derive small-eye placement from evoo-logo.svg center spacing (~0.94 of
r_big + r_small) for the shared rig, EvooMascot brand lock, and face crops.

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* Format brand-lock spacing test for Biome

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* Align mobile Expo packages for install --check in CI

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* ci: retrigger after flaky chart-links E2E

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
2026-10-06 18:56:40 +02:00
cynarAI
f8b4ce4b6d
Größere Header- und Sidebar-Icons (Web + iPhone) (#172)
* Increase header and sidebar icon scale on web and mobile

Raise shared --icon-sm/md/lg tokens (~25%) for web shell chrome, widen
chat header action controls, and align Expo home/thread header glyphs
with 44pt tap targets.

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* ci: retrigger checks after flaky evo-deploy test

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
2026-10-06 15:36:54 +02:00
Cursor Agent
50e56a2658
ci: run unit tests on Linux; stabilize isToday test across time zones
Revert Unit tests to evoo-ci Linux for ffmpeg/libass coverage. Use a
previous-day instant that stays on the prior calendar date in UTC+14…UTC-12.

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
2026-10-06 05:30:30 +00:00
Cursor Agent
629fcf6b41
ci: spread portable jobs across macOS runners; keep GHCR handoff tags
Drop Linux label from gh-only and Node gate jobs so air/mini can pick them up.
Replace mapfile in autopilot for macOS bash 3.2. Remove unverified GHCR tag
DELETE step; document that ci-* handoff aliases are harmless.

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
2026-10-06 04:17:36 +00:00
Cursor Agent
28cdb33e57
ci: set DOCKER_CONFIG in step env (runner context unavailable on job env)
Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
2026-10-06 04:14:26 +00:00
Cursor Agent
58823d7187
style: format publish-workflow test for biome
Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
2026-10-06 03:53:42 +00:00
Cursor Agent
5a406b16c3
ci: grant pull-requests read for paths-filter on PRs
Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
2026-10-06 03:36:07 +00:00
Cursor Agent
de26578778
ci: isolate GHCR docker login and delete handoff tags safely
Use per-job DOCKER_CONFIG under RUNNER_TEMP so login-action logout on
shared self-hosted runners cannot strip another job's credentials. Drop
broken buildx imagetools rm; untag ci-* handoff labels via the registry
distribution API after publish.

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
2026-10-06 03:32:05 +00:00
Cursor Agent
f8a5ff8e43
ci: fix paths-filter action pin on ci workflow
Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
2026-10-06 03:29:32 +00:00
Cursor Agent
c50f003744
ci: pin paths-filter to the v3.0.2 commit GitHub resolves
Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
2026-10-06 03:29:12 +00:00
Cursor Agent
eeec01cd0e
ci: cut queue depth with path filters and smarter concurrency
Skip heavy jobs for docs-only and out-of-scope paths while keeping
Product CI as the branch-protection gate. Cancel superseded PR image
validation runs; on main cancel stale builds but serialize publish so
attest/manifest steps are never interrupted.

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
2026-10-06 03:17:48 +00:00
cynarAI
6fc734b0ae
ci: pass publish-server-image digests via GHCR handoff tags (#169)
Build jobs already push each architecture by digest. Tag those manifests
with ephemeral ci-${run_id}-${image}-${arch} refs instead of uploading
digest marker files to Actions artifact storage, which is blocked by the
account-wide quota.

Publish resolves handoff tags back into RUNNER_TEMP digest markers, then
assembles semver/edge tags and attestation as before. Handoff tags are
removed after publish with continue-on-error.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
2026-10-06 05:06:23 +02:00
cynarAI
a55ed44394
One product: evOo updates and name, phone actions, German on mobile, working account deletion, consistent settings (#168)
* Pull updates from evOo instead of the upstream Rakazo repository

The in-app server updater, the image-based compose files and the desktop
auto-updater still pointed at elie222/rakazo, so updating an evOo install
would have pulled Rakazo. They now follow cynarAI/evOo-local, where the
publish workflow already pushes the images. The disabled Rakazo production
deploy job is gone from CI; evo deploys itself.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

* Call the product evOo everywhere people see its name

The desktop setup window and menu, account emails, the iOS app name and
permission prompts, sign-in headings, messages bots post into connected chat
apps, the web fetch user agent, issue templates and the marketing site still
said Rakazo, or EvOo. They now say evOo. The marketing site no longer calls
evOo an alternative to itself.

Internal identifiers (package scopes, env vars, bundle ids, the desktop
productName that keys its data folder and keychain entry) stay as they are.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

* Open conversation actions by holding a row on a phone

Pin, move to folder, mark unread, duplicate, archive and delete only opened
on right-click, which iOS Safari never fires. Holding a conversation or a
folder header now dispatches the same contextmenu event, so the existing menu
opens under the finger; Android's own long-press event is not doubled and the
lift does not also open the conversation.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

* Give small chrome icons a 44px touch area on phones

The browser and computer toggles, the activity bell, the create button and the
section plus buttons were 18 to 36 px targets. On touch screens each now gets
an invisible 44px hit area without changing how it looks; the create button
uses the same plus icon as the rest of the sidebar instead of a text glyph,
and the computer toggle gets an accessible name.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

* Add German to the mobile app

German speakers saw the phone app in English because the mobile UI only
knew English, Simplified Chinese, and Russian. The app now ships a German
catalog covering every mobile chrome string, follows a German device
language (de, de-DE, de-AT, de-CH, ...), and offers Deutsch in the Account
language picker.

Strings that also exist in the web German catalog reuse its wording so
web and phone say the same thing; the rest follow the same terms and the
informal "du".

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

* Use the evOo sign-in strings in the German phone catalog

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

* Make account deletion work and give settings one section style

Deleting an account posted to the auth endpoint without a JSON content
type, so the server refused it and the button did nothing. The privacy
page now deletes through the auth client and returns to sign-in. It also
drops the claims it could not verify, the export button that only showed
an alert, and its own padding, and shows where the data and the computer
live. Notifications and media settings use the same SettingsSection.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

* Use status tokens, the shared switch and one German glossary

Voice stage dots, the onboarding progress bar and the Voice First button
used raw emerald, violet, cyan and rose; they now use foreground, success
and destructive like the rest of the app. The routine editor's hand-built
switch is the shared Switch. German says Unterhaltung for threads and
Space consistently on web and phone.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

* Expect the evOo setup copy in the desktop e2e specs

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

* Translate the voice preview label in the German phone catalog

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

* Wait for the main thread URL without waiting for every resource

openMainBotThread already navigated with domcontentloaded; its URL check
then waited for the full load event, which can exceed 20 s on a busy
runner. The composer check that follows covers readiness.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-06 00:38:31 +02:00
cynarAI
b78eff1f7b
Run on the deployment's local models without any model choice, preselect Claude Opus 5.5 otherwise, explain why dictation is unavailable (#151)
* Recommend Claude Opus 5.5 by bumping the Pi model catalog

The pinned pi-ai 0.85.1 had no Claude Opus 5.5, so the connect flow fell
back to Opus 5. pi-ai and pi-agent-core 0.87.1 add it, and the keyless
recommendation now preselects it.

The new catalog names router aliases "Qwen Max Latest (Qwen3.8 Max)";
those now read "(auto-updates)" like the other alias labels, so no
picker entry claims to be "latest".

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KWEAjTw26eRf3ZXTXfRjmD

* Name HTTPS as the cause when dictation is unavailable, and translate it

Over plain HTTP on a LAN address, Safari hides the microphone and speech
recognition, so voice mode said the browser had no dictation, in English
inside a German UI. Dictation now checks for a secure context first and
says to open the address with https://. Its other errors go through
Lingui too, and Safari's blocked recognizer gets a clear message instead
of "Dictation failed: not-allowed".

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KWEAjTw26eRf3ZXTXfRjmD

* Serve a fake Kokoro voice server in the E2E harness

Onboarding's voice step connects to the local Kokoro server on its
default port, so the shell E2E failed on any machine where nothing (or a
misbehaving server) listened there: the failed request shows up as a
browser console error. The harness now starts an offline stand-in and
points KOKORO_BASE_URL at it, so the suite no longer depends on the
runner host's own voice server.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KWEAjTw26eRf3ZXTXfRjmD

* Show only the recommended sign-in until a model is connected

With no model connected, "Connect model" opened the full model settings:
provider list, tiers and a model picker, even though the recommendation
was already preselected. Until something is connected, the Models panel
now shows just the preselected model with its sign-in and API key field;
"Other providers" reveals the full settings.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KWEAjTw26eRf3ZXTXfRjmD

* Give the second chart-links test the same cold-load allowance as the first

Each Playwright test runs in a fresh browser context, so the second test's
first navigation re-fetches the lazily imported Plot chunk just like the
first test's warm-up does. On a loaded CI runner that exceeded the default
20 s expect timeout.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KWEAjTw26eRf3ZXTXfRjmD

* Use the recommended model as the Anthropic deployment default

A deployment that sets only ANTHROPIC_API_KEY and PI_DEFAULT_PROVIDER=anthropic
now runs Claude Opus 5.5, the same model the connect flow preselects, instead
of an older default.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KWEAjTw26eRf3ZXTXfRjmD

* Run every space on the deployment's local models without a connect step

A deployment with PI_DEFAULT_PROVIDER=local and RAKAZO_LOCAL_MODELS set still
asked every user to connect a model, because only OpenRouter and Anthropic
counted as a deployment model. Configured local models now do, keylessly.

RAKAZO_LOCAL_TIERS assigns local models to the tiers the agent delegates to,
for spaces that have not mapped their own, and a RAKAZO_LOCAL_MODELS entry can
name its own server (id=url) when the models run behind different endpoints.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KWEAjTw26eRf3ZXTXfRjmD

* Hide model choices when the deployment routes models itself

When the server provides its own models, the system picks and routes
them per task, so Settings no longer offers Models and bot settings no
longer offer Model and Thinking, on web and mobile.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KWEAjTw26eRf3ZXTXfRjmD

* Wait for the workspace pane's width transition before measuring it

The pane animates its width over 300 ms, so a single boundingBox read
right after it opens can land mid-transition on a slow runner.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KWEAjTw26eRf3ZXTXfRjmD

* Allow chart re-renders inside the chart-links loops to take longer

Each navigation in the loops renders Observable Plot again. On a loaded
CI runner that exceeded the default expect timeout even after the
warm-up, so the per-iteration visibility checks get 30 s.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KWEAjTw26eRf3ZXTXfRjmD

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-05 23:18:40 +02:00
cynarAI
9062717eee
fix(evo-deploy): validate restart config before mutating checkout (#167)
Move restart-mode prerequisites (systemd unit default, tmux, compose file,
script hook, EVO_SYSTEMD_SCOPE) ahead of git fetch/install/build/migrate.
Explicit systemd with no EVO_SYSTEMD_UNIT falls back to EVO_SYSTEMD_AUTO_UNIT.
die() after checkout triggers rollback; rollback marks in-progress to avoid loops.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
2026-10-05 22:39:46 +02:00
cynarAI
eb9c8847a4
Brand videos with a logo overlay and an intro before the cut (#161)
* Export videos for named platforms and check each file against its limits

Exports only knew aspect ratios, so a cut could still be rejected or
degraded on upload: too long for the platform, over its size limit, or
far off its loudness. export_video now takes platforms (TikTok,
Instagram Reels and Feed, YouTube Shorts and YouTube, Facebook Reels,
LinkedIn, X, Pinterest, Snapchat Spotlight), up to six targets with the
presets. Each platform is rendered to its frame with the export's fit,
its frame rate cap, a bitrate cap that also keeps the file under the
size limit, and its loudness target in one loudnorm pass.

Every render is probed and measured afterwards. A file over the size
limit is encoded once more at a bitrate scaled from what it averaged,
and audio more than 2 LU off the target gets one linear loudness pass.
The result is stored on the output as a pass, warn or fail check with
language-neutral issue codes. A platform the video is too long or too
short for is not rendered: the card lists it with the reason, the
other targets still render, and the bot is told to trim first.

The limits come from the earlier profile research, with its sources,
review date and unverified flag; content, disclosure and caption rules
are left out because they need inference or a review flow. Preset
definitions move to their own module so the platform schemas can use
their aspect ratios without an import cycle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u

* Add remove_silence step to edit_video

Talking-head clips are mostly cut by hand to drop pauses, and the bot had
no way to find them: it could only keep segments it guessed. The new
remove_silence step finds every pause quieter than threshold_db (default
-35 dB) and at least min_silence long (default 0.6 s), keeps padding
(default 0.12 s) on each side so word onsets survive, and cuts the rest
through the existing keep-segments graph, so picture and sound stay in
sync.

Steps run in order, so silence is detected on the timeline the steps
before it leave: the planner gets an analysis mode that sends that audio
through silencedetect into a null output, and ametadata prints the marks
to a file in the job directory. The detected speech is handed back to the
planner per step. Nothing silent makes the step a no-op; nothing left
fails with a clear error. At most 200 pieces are kept, bridging the
shortest pauses first, so the filter graph stays a sensible size.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u

* Offer platforms in the media card's export menu and show their checks

The card's Export menu now lists the platforms first, by brand name and
untranslated, then the aspect ratios after a separator; the platform an
output was made for is left out, like its own shape. A platform export
shows the platform's name under the player, or, when its check did not
pass, one line for the first issue instead ("Too long for TikTok (max
10:00)"), in the warning or destructive color. Skipped platforms get
the same kind of line, and they stand in for the error of a job that
skipped every platform, so the card does not say the same thing twice
in two languages.

The E2E spec seeds a platform export with a failed check and a skipped
platform, and captures the card and the open menu.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u

* Add analyze_video to find cut points for the bot

To cut a social video well the bot has to know where the cuts fit, and it
could not look at a video at all. analyze_video is a read-only media job:
one ffmpeg pass prints scene changes (scene score above 0.3, one per cut),
pauses (silencedetect with the remove_silence defaults) and short-term
loudness (ebur128) to files in the job directory, and a transcript made
from the video by transcribe (an SRT whose parent is the video) adds when
each line is spoken. The job stores nothing; its card just says done.

The result reaches the bot through the existing media job path: the job
always wakes the bot that started it (the tool has no continue_after) and
the summary carries a short legend plus compact JSON. The runtime's
summary limit grows to 12,000 characters and the summary is fitted into
it: at most 200 entries per list, then every list shrinks evenly, keeping
the strongest cuts, the longest pauses and the start of the transcript,
with omitted counts. The wake prompt no longer claims the card shows the
result when a job made no files.

Jobs read derived artifacts through a new fetchDerivedArtifact, scoped to
the job's user like fetchArtifact. A migration admits the operation in the
media_jobs check and adds the tool to existing Studio specialists that
still edit video. The web card gets an icon; no new copy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u

* fix(media): keep one skipped entry per platform on the card

A job that recovers after a crash runs again and reports the same
skipped platforms; the newest reason per platform now replaces the
earlier one instead of listing it twice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u

* chore(db): add the widened media job operation check as NOT VALID and validate it separately

Follows the repository's migration convention: the constraint commits
without scanning media_jobs, and a second migration validates it with a
lock that lets reads and writes continue.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u

* Brand videos with a logo overlay and an intro before the cut

edit_video gains an overlay step that lays a PNG, JPEG or WebP image over the
video at a corner or the center, sized to a share of the frame width, with
optional opacity and time range; tall frames keep the 9:16 safe areas clear.
concat takes at: "start" so an intro can go before the video. The image is
authorized like other inputs, read with its own demuxer and refused above
8192 pixels on a side. New Studio specialists are told to brand with the
user's logo and intro.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-05 22:39:21 +02:00
cynarAI
459d7c99e4
Pick the voice from a carousel in the app language, local voice only (#166)
* Pick the voice from a carousel in the app language, local voice only

Voice settings now list only the voices that speak the app language
(German shows German voices, English shows English ones) as a horizontal
carousel: one card per voice, a play button next to the name, tap to
make it the active voice. Changing the app language moves the saved
voice to that language. The hosted voice providers (ElevenLabs, OpenAI,
Cartesia, Fish Audio) are removed; Kokoro on the server is the only
voice. E2E runs against a stub Kokoro server so the carousel is covered.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KxoSHAyzMXvdtwcDJZcn57

* test(e2e): measure the workspace panel width after its transition settles

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KxoSHAyzMXvdtwcDJZcn57

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-05 22:20:06 +02:00
cynarAI
b2fb517286
Simplify the memory settings: what is remembered first, provider setup under Advanced (#164)
The memory pane opened with an untranslated provider description, a
provider picker, a scope toggle and API key fields, and pushed what the
agents actually remember to the bottom. Most people never connect a
semantic-recall provider, so that setup now sits behind a collapsed
Advanced disclosure (open while a provider is connected), and the
remembered documents come first.

- Drop the provider descriptions, the "Shared documents" heading and
  the per-document revision label.
- Replace the Isolated/Shared toggle pair with one "Share between bots"
  switch.
- Remove the unused standalone dialog mode of the memory pane.
- German: call the section "Gedächtnis" so it no longer reads like
  reminders ("Erinnerungen").


Claude-Session: https://claude.ai/code/session_01LWzKXyE6uDmmmzuod4oXNQ

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-05 20:30:30 +02:00
cynarAI
0122fef89e
Drop the transcript heading and empty-state hint in the conversation view (#163)
The encounter view showed an uppercase "Transcript" / "Conversation record"
label and "Start the conversation — <bot> is on stage above." before any
message. Both repeat what the screen already shows, so they are removed.


Claude-Session: https://claude.ai/code/session_01Vyov34VJPqjo6YgiGHnuEL

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-05 19:50:18 +02:00
cynarAI
3b2c46dd36
feat(media): remove pauses and find cut points in videos (#159)
* Export videos for named platforms and check each file against its limits

Exports only knew aspect ratios, so a cut could still be rejected or
degraded on upload: too long for the platform, over its size limit, or
far off its loudness. export_video now takes platforms (TikTok,
Instagram Reels and Feed, YouTube Shorts and YouTube, Facebook Reels,
LinkedIn, X, Pinterest, Snapchat Spotlight), up to six targets with the
presets. Each platform is rendered to its frame with the export's fit,
its frame rate cap, a bitrate cap that also keeps the file under the
size limit, and its loudness target in one loudnorm pass.

Every render is probed and measured afterwards. A file over the size
limit is encoded once more at a bitrate scaled from what it averaged,
and audio more than 2 LU off the target gets one linear loudness pass.
The result is stored on the output as a pass, warn or fail check with
language-neutral issue codes. A platform the video is too long or too
short for is not rendered: the card lists it with the reason, the
other targets still render, and the bot is told to trim first.

The limits come from the earlier profile research, with its sources,
review date and unverified flag; content, disclosure and caption rules
are left out because they need inference or a review flow. Preset
definitions move to their own module so the platform schemas can use
their aspect ratios without an import cycle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u

* Add remove_silence step to edit_video

Talking-head clips are mostly cut by hand to drop pauses, and the bot had
no way to find them: it could only keep segments it guessed. The new
remove_silence step finds every pause quieter than threshold_db (default
-35 dB) and at least min_silence long (default 0.6 s), keeps padding
(default 0.12 s) on each side so word onsets survive, and cuts the rest
through the existing keep-segments graph, so picture and sound stay in
sync.

Steps run in order, so silence is detected on the timeline the steps
before it leave: the planner gets an analysis mode that sends that audio
through silencedetect into a null output, and ametadata prints the marks
to a file in the job directory. The detected speech is handed back to the
planner per step. Nothing silent makes the step a no-op; nothing left
fails with a clear error. At most 200 pieces are kept, bridging the
shortest pauses first, so the filter graph stays a sensible size.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u

* Offer platforms in the media card's export menu and show their checks

The card's Export menu now lists the platforms first, by brand name and
untranslated, then the aspect ratios after a separator; the platform an
output was made for is left out, like its own shape. A platform export
shows the platform's name under the player, or, when its check did not
pass, one line for the first issue instead ("Too long for TikTok (max
10:00)"), in the warning or destructive color. Skipped platforms get
the same kind of line, and they stand in for the error of a job that
skipped every platform, so the card does not say the same thing twice
in two languages.

The E2E spec seeds a platform export with a failed check and a skipped
platform, and captures the card and the open menu.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u

* Add analyze_video to find cut points for the bot

To cut a social video well the bot has to know where the cuts fit, and it
could not look at a video at all. analyze_video is a read-only media job:
one ffmpeg pass prints scene changes (scene score above 0.3, one per cut),
pauses (silencedetect with the remove_silence defaults) and short-term
loudness (ebur128) to files in the job directory, and a transcript made
from the video by transcribe (an SRT whose parent is the video) adds when
each line is spoken. The job stores nothing; its card just says done.

The result reaches the bot through the existing media job path: the job
always wakes the bot that started it (the tool has no continue_after) and
the summary carries a short legend plus compact JSON. The runtime's
summary limit grows to 12,000 characters and the summary is fitted into
it: at most 200 entries per list, then every list shrinks evenly, keeping
the strongest cuts, the longest pauses and the start of the transcript,
with omitted counts. The wake prompt no longer claims the card shows the
result when a job made no files.

Jobs read derived artifacts through a new fetchDerivedArtifact, scoped to
the job's user like fetchArtifact. A migration admits the operation in the
media_jobs check and adds the tool to existing Studio specialists that
still edit video. The web card gets an icon; no new copy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u

* fix(media): keep one skipped entry per platform on the card

A job that recovers after a crash runs again and reports the same
skipped platforms; the newest reason per platform now replaces the
earlier one instead of listing it twice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u

* chore(db): add the widened media job operation check as NOT VALID and validate it separately

Follows the repository's migration convention: the constraint commits
without scanning media_jobs, and a second migration validates it with a
lock that lets reads and writes continue.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-05 19:16:03 +02:00
cynarAI
3291a32d37
Show About you inside General settings (#165)
The profile had its own settings tab although it is a few rows of personal
details. It now sits as one card under Account in General, hides itself
while empty, and the separate tab is gone.


Claude-Session: https://claude.ai/code/session_017gHYZ4c9Twex73v4ZasmMJ

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-05 18:36:54 +02:00
cynarAI
23d70971f8
feat(media): export videos for named platforms and check each file against its limits (#158)
* Export videos for named platforms and check each file against its limits

Exports only knew aspect ratios, so a cut could still be rejected or
degraded on upload: too long for the platform, over its size limit, or
far off its loudness. export_video now takes platforms (TikTok,
Instagram Reels and Feed, YouTube Shorts and YouTube, Facebook Reels,
LinkedIn, X, Pinterest, Snapchat Spotlight), up to six targets with the
presets. Each platform is rendered to its frame with the export's fit,
its frame rate cap, a bitrate cap that also keeps the file under the
size limit, and its loudness target in one loudnorm pass.

Every render is probed and measured afterwards. A file over the size
limit is encoded once more at a bitrate scaled from what it averaged,
and audio more than 2 LU off the target gets one linear loudness pass.
The result is stored on the output as a pass, warn or fail check with
language-neutral issue codes. A platform the video is too long or too
short for is not rendered: the card lists it with the reason, the
other targets still render, and the bot is told to trim first.

The limits come from the earlier profile research, with its sources,
review date and unverified flag; content, disclosure and caption rules
are left out because they need inference or a review flow. Preset
definitions move to their own module so the platform schemas can use
their aspect ratios without an import cycle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u

* Offer platforms in the media card's export menu and show their checks

The card's Export menu now lists the platforms first, by brand name and
untranslated, then the aspect ratios after a separator; the platform an
output was made for is left out, like its own shape. A platform export
shows the platform's name under the player, or, when its check did not
pass, one line for the first issue instead ("Too long for TikTok (max
10:00)"), in the warning or destructive color. Skipped platforms get
the same kind of line, and they stand in for the error of a job that
skipped every platform, so the card does not say the same thing twice
in two languages.

The E2E spec seeds a platform export with a failed check and a skipped
platform, and captures the card and the open menu.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u

* fix(media): keep one skipped entry per platform on the card

A job that recovers after a crash runs again and reports the same
skipped platforms; the newest reason per platform now replaces the
earlier one instead of listing it twice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-05 16:49:18 +02:00
cynarAI
e55207f5d2
Open the side panel without booting the computer (#162)
Showing the right side panel (computer preview and routines) used to boot a
stopped computer right away. A boot is slow and heavy on the host, and most
of the time the panel is opened for routines, not the screen. The panel now
only reconnects a computer that already runs; a resting computer shows its
Open action without hover, and Open still boots it. A stopped computer is no
longer treated as needing maintenance.


Claude-Session: https://claude.ai/code/session_01TafdPTJdWxTYt9SYbDuKQ8

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-05 15:44:51 +02:00
cynarAI
568d37c48c
Merge pull request #157 from cynarAI/claude/composer-mic-send-toggle-jbg7wv
Composer: mic sits in the send slot until there is something to send
2026-10-05 15:38:32 +02:00
cynarAI
a6e611736b
fix(evo-deploy): prefer the fetched remote branch when resolving the deploy ref (#160)
A manual deploy.sh run without a SHA resolved the local 'main' first,
which lags behind origin/main in the deploy checkout, and redeployed the
old commit. Resolve <remote>/<ref> first and fall back to the ref itself
for SHAs and tags.


Claude-Session: https://claude.ai/code/session_01YMUEBB4pr4TtGSMhEAKonM

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-05 14:46:20 +02:00
cynarAI
4ab0d25224
Merge pull request #156 from cynarAI/claude/project-thread-vljeb4
Media library: owner folders that bots can search and import from
2026-10-05 13:22:46 +02:00
Claude
c98eb6e843
Drop the stage's own voice button now that the composer carries the mic
The empty-chat stage offered "Start talking" / "Set up voice" above the
transcript, a second entry to the same voice conversation the composer's
mic now opens. Keep the composer as the single way in.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XcoERsCTbvjqDzE8qC82Wo
2026-10-05 10:51:03 +00:00
cynarAI
738811dbe7
Read the web app at 16px base size on phones (#155)
Text sizes now follow a --font-scale variable that is 1 on wider screens and
8/7 below 768px, so the 14px base text reads at 16px on phones (Safari stops
zooming into fields at that size) and every other size grows by the same
ratio. One-off pixel sizes in the shell move onto the shared text tokens so
they scale too. The stage subtitle now truncates inside the screen instead
of spilling past both edges.


Claude-Session: https://claude.ai/code/session_01PFEDgbyJm12ExL3Kn4Dk8B

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-05 12:42:30 +02:00
Claude
f3e438444a
Put voice in the composer's send slot until there is something to send
An empty draft shows the mic where the send arrow sits; it opens the
voice conversation as the separate Voice button did. As soon as the draft
holds text, a chip or an attachment, the slot turns back into Send. This
frees the width the extra Voice pill took on small screens.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XcoERsCTbvjqDzE8qC82Wo
2026-10-05 08:52:06 +00:00
Claude
044669edfc
Add a media library: owner folders that bots can search and import from
The deployment owner adds folders of photos and videos in the media settings. A per-folder
background job indexes them (relative path, kind, size, capture date, dimensions, duration),
re-indexes every six hours, and drops files that are gone. The owner's own runs get two tools:
search_media (words in folder and file names, kind, capture date range, newest first) and
import_media (copies one file into the conversation as an artifact). Files are never changed;
hidden entries and symlinks are skipped, and an import must resolve inside its folder.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wy1md9QE1ayJbDCwJ4vAQs
2026-10-05 08:45:30 +00:00
cursor[bot]
404a1d1782
Merge pull request #154 from cynarAI/cursor/cleanup-self-hosted-runner-6331
Run artifact cleanup on self-hosted Linux
2026-10-05 08:29:45 +00:00
Cursor Agent
3a5ab6ef44
Run artifact cleanup on self-hosted Linux runners
Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
2026-10-05 08:29:20 +00:00
cursor[bot]
9930d295f2
Merge pull request #153 from cynarAI/cursor/trigger-artifact-cleanup-6331
Auto-run artifact cleanup on workflow update
2026-10-05 08:28:50 +00:00
Cursor Agent
354c59f9e3
Run artifact cleanup when the workflow file changes on main
Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
2026-10-05 08:28:41 +00:00
cursor[bot]
d7e4cb91d6
Merge pull request #152 from cynarAI/cursor/cleanup-actions-artifacts-6331
Add manual Actions artifact cleanup workflow
2026-10-05 08:28:20 +00:00
cynarAI
b3f20967db
Preselect the recommended model while none is connected (#150)
Without a model key the settings opened on the keyless deployment default
(OpenRouter). The connect flow now opens on Claude Opus 5, so a new user
only adds a key or signs in. A connected credential, an owner-chosen
default, or a deployment key still decides as before.


Claude-Session: https://claude.ai/code/session_01KWEAjTw26eRf3ZXTXfRjmD

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-05 10:17:33 +02:00
684 changed files with 73752 additions and 12180 deletions

View File

@ -99,8 +99,12 @@ PI_DEFAULT_PROVIDER=openrouter
# Computer-use tests can override this with COMPUTER_E2E_MODEL.
PI_DEFAULT_MODEL=openai/gpt-5.6-luna
# Optional local OpenAI-compatible models. Leave blank to disable the provider.
# With PI_DEFAULT_PROVIDER=local they are the deployment's model and nobody has to connect one.
# An entry may name its own server: id=http://host:port/v1.
RAKAZO_LOCAL_MODELS=
RAKAZO_LOCAL_MODELS_URL=http://127.0.0.1:11434/v1
# Optional tiers the agent can delegate to, for every space: fast=id,standard=id,code=id.
RAKAZO_LOCAL_TIERS=
RAKAZO_LOCAL_CONTEXT_WINDOW=32768
RAKAZO_LOCAL_MAX_TOKENS=4096
# Allow user-connected OpenAI-compatible endpoints on public hostnames (default: private/loopback only).
@ -202,10 +206,10 @@ RAKAZO_COMPOSE_FILE=
# RAKAZO_UPDATE_SERVICES=supervisor
RAKAZO_UPDATE_SERVICES=
# Published images (defaults match this repository). Tag `local` is built from the checkout.
RAKAZO_IMAGE=ghcr.io/elie222/rakazo/app
RAKAZO_IMAGE=ghcr.io/cynarai/evoo-local/app
RAKAZO_IMAGE_TAG=local
RAKAZO_IMAGE_TAG_PREVIOUS=
RAKAZO_UPDATER_IMAGE=ghcr.io/elie222/rakazo/updater
RAKAZO_UPDATER_IMAGE=ghcr.io/cynarai/evoo-local/updater
RAKAZO_UPDATER_IMAGE_TAG=local
# Required only when starting the opt-in `updater` Compose profile; the sidecar refuses to start
# without it. Use a dedicated random value, not BETTER_AUTH_SECRET,

View File

@ -1,5 +1,5 @@
name: Bug report
description: Something is broken in Rakazo
description: Something is broken in evOo
title: "[Bug]: "
labels:
- bug
@ -7,7 +7,7 @@ body:
- type: markdown
attributes:
value: |
For **security vulnerabilities**, email [security@rakazo.com](mailto:security@rakazo.com) — do not use this form. See [SECURITY.md](https://github.com/elie222/rakazo/blob/main/SECURITY.md).
For **security vulnerabilities**, open a [private security advisory](https://github.com/cynarAI/evOo-local/security/advisories/new) do not use this form. See [SECURITY.md](https://github.com/cynarAI/evOo-local/blob/main/SECURITY.md).
- type: checkboxes
id: not-security
@ -55,7 +55,7 @@ body:
- type: input
id: version
attributes:
label: Rakazo version or commit
label: evOo version or commit
placeholder: v0.1.0 or abc1234
validations:
required: true

View File

@ -1,11 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: Security vulnerabilities
url: https://github.com/elie222/rakazo/blob/main/SECURITY.md
about: Report security issues to security@rakazo.com — do not open a public issue.
- name: User support
url: mailto:support@rakazo.com
about: Questions about using Rakazo or self-hosting help.
- name: Maintainer
url: mailto:elie@rakazo.com
about: Reach the maintainer directly.
url: https://github.com/cynarAI/evOo-local/security/advisories/new
about: Report security issues privately. Do not open a public issue.

View File

@ -1,5 +1,5 @@
name: Self-host setup
description: Cannot start Rakazo, run compose, migrate, or build the sandbox
description: Cannot start evOo, run compose, migrate, or build the sandbox
title: "[Self-host]: "
labels:
- self-host
@ -7,9 +7,9 @@ body:
- type: markdown
attributes:
value: |
For **security vulnerabilities**, email [security@rakazo.com](mailto:security@rakazo.com). See [SECURITY.md](https://github.com/elie222/rakazo/blob/main/SECURITY.md).
For **security vulnerabilities**, open a [private security advisory](https://github.com/cynarAI/evOo-local/security/advisories/new). See [SECURITY.md](https://github.com/cynarAI/evOo-local/blob/main/SECURITY.md).
Setup steps are in [README.md](https://github.com/elie222/rakazo/blob/main/README.md). Do not paste `.env` contents or API keys.
Setup steps are in [README.md](https://github.com/cynarAI/evOo-local/blob/main/README.md). Do not paste `.env` contents or API keys.
- type: dropdown
id: step
@ -46,7 +46,7 @@ body:
- type: input
id: version
attributes:
label: Rakazo version or commit
label: evOo version or commit
placeholder: main @ abc1234
validations:
required: true

View File

@ -24,7 +24,7 @@ These workflows may fail without blocking product delivery. Do **not** add them
| Check / workflow | Why infra |
| --- | --- |
| `macOS setup screenshot (infra)` (`desktop-macos-screenshot.yml`) | Native screenshot capture; skips when the runner has no display |
| Playwright / desktop artifact uploads | Soft-fail (`continue-on-error`) when GitHub artifact quota is exhausted |
| Playwright report handoff | Soft-fail (`continue-on-error`); the report stays on the evo runner host for `publish Playwright report` |
| `publish-server-image.yml` (PR paths) | Docker image validation for infra changes |
| `mobile Android screenshots` | Manual `workflow_dispatch` only |
| `nightly verification` | Scheduled visual regression |

View File

@ -71,7 +71,10 @@ jobs:
HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
run: |
set -euo pipefail
mapfile -t conclusions < <(
conclusions=()
while IFS= read -r conclusion; do
[[ -n "$conclusion" ]] && conclusions+=("$conclusion")
done < <(
gh api "repos/${GITHUB_REPOSITORY}/commits/${HEAD_SHA}/check-runs" --paginate \
--jq '.check_runs[] | select(.name == "Product CI") | .conclusion'
)

View File

@ -5,19 +5,87 @@ name: ci
on:
push:
branches: [main]
paths:
- "apps/**"
- "packages/**"
- "infra/**"
- "scripts/**"
- "ops/**"
- ".github/**"
- "package.json"
- "pnpm-lock.yaml"
- "pnpm-workspace.yaml"
- "turbo.json"
- "biome.json"
- "vitest.config.ts"
- "tsconfig*.json"
pull_request:
permissions:
contents: read
pull-requests: read
concurrency:
group: ci-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
# Jobs that only need Node/pnpm/bash use [self-hosted, evoo-ci] so macOS runners (air, mini)
# can share load. Linux-only work keeps the Linux label (Docker, xvfb, local Postgres :5433,
# Homebrew ffmpeg with libass/subtitles filter for media unit tests).
jobs:
changes:
name: Detect product changes
runs-on: [self-hosted, evoo-ci]
timeout-minutes: 10
outputs:
product: ${{ steps.product.outputs.product }}
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
fetch-depth: 0
- uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
id: filter
continue-on-error: true
with:
filters: |
product:
- 'apps/**'
- 'packages/**'
- 'infra/**'
- 'scripts/**'
- 'ops/**'
- '.github/**'
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- 'turbo.json'
- 'biome.json'
- 'vitest.config.ts'
- 'tsconfig*.json'
- name: Product path decision
id: product
if: always()
run: |
set -euo pipefail
if [[ "${{ steps.filter.outcome }}" == "success" ]]; then
if [[ "${{ steps.filter.outputs.product }}" == "true" ]]; then
echo "product=true" >> "$GITHUB_OUTPUT"
else
echo "product=false" >> "$GITHUB_OUTPUT"
fi
else
echo "Change detection failed; running full Product CI." >&2
echo "product=true" >> "$GITHUB_OUTPUT"
fi
lint:
name: Lint
runs-on: [self-hosted, evoo-ci, Linux]
needs: changes
if: ${{ (needs.changes.result == 'success' && needs.changes.outputs.product == 'true') || needs.changes.result != 'success' }}
runs-on: [self-hosted, evoo-ci]
timeout-minutes: 20
steps:
- uses: actions/checkout@v5
with:
@ -45,13 +113,15 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm lint
check:
name: Typecheck
runs-on: [self-hosted, evoo-ci, Linux]
needs: changes
if: ${{ (needs.changes.result == 'success' && needs.changes.outputs.product == 'true') || needs.changes.result != 'success' }}
runs-on: [self-hosted, evoo-ci]
timeout-minutes: 20
steps:
- uses: actions/checkout@v5
with:
@ -79,7 +149,6 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm db:generate
env:
@ -88,6 +157,8 @@ jobs:
build:
name: Production builds
needs: changes
if: ${{ (needs.changes.result == 'success' && needs.changes.outputs.product == 'true') || needs.changes.result != 'success' }}
runs-on: [self-hosted, evoo-ci, Linux]
timeout-minutes: 10
steps:
@ -117,7 +188,6 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm build
env:
@ -126,19 +196,13 @@ jobs:
ENCRYPTION_KEY: ci-build-encryption-key-at-least-32-characters
- name: Electron smoke
run: xvfb-run --auto-servernum pnpm --filter @rakazo/desktop exec playwright test --config e2e/playwright.config.ts
- name: Upload desktop setup screenshots
if: always()
continue-on-error: true
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: desktop-e2e-screenshots
path: apps/desktop/e2e/screenshots
if-no-files-found: ignore
retention-days: 7
test:
name: Unit tests
needs: changes
if: ${{ (needs.changes.result == 'success' && needs.changes.outputs.product == 'true') || needs.changes.result != 'success' }}
runs-on: [self-hosted, evoo-ci, Linux]
timeout-minutes: 45
steps:
- uses: actions/checkout@v5
with:
@ -166,7 +230,6 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm db:generate
env:
@ -175,6 +238,8 @@ jobs:
test-integration:
name: Postgres journeys
needs: changes
if: ${{ (needs.changes.result == 'success' && needs.changes.outputs.product == 'true') || needs.changes.result != 'success' }}
runs-on: [self-hosted, evoo-ci, Linux]
timeout-minutes: 10
steps:
@ -204,24 +269,28 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm test:integration
test-e2e:
name: Web E2E
needs: changes
if: ${{ (needs.changes.result == 'success' && needs.changes.outputs.product == 'true') || needs.changes.result != 'success' }}
uses: ./.github/workflows/playwright.yml
with:
upload_artifacts: true
hand_off_report: true
product-gate:
name: Product CI
if: always()
needs: [lint, check, build, test, test-integration, test-e2e]
runs-on: [self-hosted, evoo-ci, Linux]
needs: [changes, lint, check, build, test, test-integration, test-e2e]
runs-on: [self-hosted, evoo-ci]
timeout-minutes: 5
steps:
- name: Verify product checks
env:
CHANGES: ${{ needs.changes.result }}
PRODUCT: ${{ needs.changes.outputs.product }}
LINT: ${{ needs.lint.result }}
CHECK: ${{ needs.check.result }}
BUILD: ${{ needs.build.result }}
@ -230,11 +299,23 @@ jobs:
TEST_E2E: ${{ needs.test-e2e.result }}
run: |
set -euo pipefail
product="$PRODUCT"
if [[ "$CHANGES" != "success" ]]; then
echo "Change detection did not finish cleanly; requiring downstream product checks." >&2
product=true
fi
if [[ "$product" != "true" ]]; then
echo "No product-impacting paths changed; Product CI satisfied."
exit 0
fi
failed=0
for name in LINT CHECK BUILD TEST TEST_INTEGRATION TEST_E2E; do
result="${!name}"
printf '%s=%s\n' "$name" "$result"
if [[ "$result" != "success" ]]; then
if [[ "$result" == "skipped" ]]; then
echo "$name was skipped although product paths changed." >&2
failed=1
elif [[ "$result" != "success" ]]; then
failed=1
fi
done
@ -246,9 +327,9 @@ jobs:
publish-mobile-update:
name: Publish compatible mobile update
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
if: github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.changes.outputs.product == 'true'
continue-on-error: true
needs: [lint, check, build, test, test-integration, test-e2e]
needs: [changes, lint, check, build, test, test-integration, test-e2e]
runs-on: [self-hosted, evoo-ci, Linux]
timeout-minutes: 20
concurrency:
@ -389,7 +470,6 @@ jobs:
if: steps.ota.outputs.publish == 'true' && steps.expo_token.outputs.ready == 'true'
with:
node-version: 24
cache: pnpm
- name: Set up Expo
if: steps.ota.outputs.publish == 'true' && steps.expo_token.outputs.ready == 'true'
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # v9
@ -397,6 +477,9 @@ jobs:
eas-version: latest
packager: pnpm
token: ${{ secrets.EXPO_TOKEN }}
# Its default caches go to the GitHub cache service; keep them on the runner.
eas-cache: false
expo-cache: false
- name: Install dependencies
if: steps.ota.outputs.publish == 'true' && steps.expo_token.outputs.ready == 'true'
run: pnpm install --frozen-lockfile
@ -423,46 +506,3 @@ jobs:
fi
eas update --platform all --channel production --environment production --message "$GITHUB_SHA" --non-interactive
deploy-production:
if: >-
github.event_name == 'push' &&
github.ref == 'refs/heads/main' &&
vars.PRODUCTION_DEPLOY_ENABLED == 'true'
needs: [lint, check, build, test, test-integration, test-e2e]
runs-on: [self-hosted, evoo-ci, Linux]
timeout-minutes: 30
concurrency:
group: production
cancel-in-progress: false
environment:
name: production
url: https://app.rakazo.com
steps:
- name: Configure production SSH identity
env:
SSH_PRIVATE_KEY: ${{ secrets.PRODUCTION_SSH_PRIVATE_KEY }}
SSH_KNOWN_HOSTS: ${{ secrets.PRODUCTION_SSH_KNOWN_HOSTS }}
run: |
test -n "$SSH_PRIVATE_KEY"
test -n "$SSH_KNOWN_HOSTS"
install -d -m 700 "$HOME/.ssh"
printf '%s\n' "$SSH_PRIVATE_KEY" > "$HOME/.ssh/rakazo-production"
printf '%s\n' "$SSH_KNOWN_HOSTS" > "$HOME/.ssh/known_hosts"
chmod 600 "$HOME/.ssh/rakazo-production" "$HOME/.ssh/known_hosts"
ssh-keygen -y -f "$HOME/.ssh/rakazo-production" >/dev/null
- name: Deploy successful main revision
env:
SSH_HOST: ${{ secrets.PRODUCTION_SSH_HOST }}
SSH_USER: ${{ secrets.PRODUCTION_SSH_USER }}
run: |
test -n "$SSH_HOST"
test -n "$SSH_USER"
ssh -F /dev/null \
-i "$HOME/.ssh/rakazo-production" \
-o BatchMode=yes \
-o ConnectTimeout=15 \
-o IdentitiesOnly=yes \
-o StrictHostKeyChecking=yes \
-o UserKnownHostsFile="$HOME/.ssh/known_hosts" \
"$SSH_USER@$SSH_HOST" deploy-main

View File

@ -8,6 +8,10 @@ on:
description: List artifacts only; do not delete
type: boolean
default: false
push:
branches: [main]
paths:
- .github/workflows/cleanup-actions-artifacts.yml
permissions:
actions: write
@ -16,13 +20,13 @@ permissions:
jobs:
cleanup:
name: Delete stale Actions artifacts
runs-on: ubuntu-latest
runs-on: [self-hosted, evoo-ci, Linux]
timeout-minutes: 30
steps:
- name: Remove old artifacts
env:
GH_TOKEN: ${{ github.token }}
DRY_RUN: ${{ inputs.dry_run }}
DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || 'false' }}
REPOSITORY: ${{ github.repository }}
run: |
python3 <<'PY'

View File

@ -50,7 +50,6 @@ jobs:
if: steps.display.outputs.available == 'true'
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
if: steps.display.outputs.available == 'true'
- run: pnpm --filter @rakazo/desktop build
@ -77,14 +76,3 @@ jobs:
pnpm --filter @rakazo/desktop exec playwright test
--config e2e/playwright.config.ts setup.spec.ts
--grep "first run asks whether to use a local or existing instance"
- name: Upload native macOS setup screenshot
if: always() && steps.display.outputs.available == 'true'
continue-on-error: true
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: desktop-e2e-macos-screenshot
path: |
apps/desktop/e2e/screenshots/08-setup-macos-native-controls.png
apps/desktop/e2e/screenshots/09-macos-dock.png
if-no-files-found: warn
retention-days: 1

View File

@ -39,41 +39,39 @@ jobs:
with:
persist-credentials: false
# Runner-host caches only: never GitHub's cache or artifact storage.
- name: Restore screenshot APK
id: screenshot-apk
uses: actions/cache/restore@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with:
path: apps/mobile/android/app/build/outputs/apk/release/app-release.apk
key: mobile-android-apk-v2-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'package.json', 'pnpm-workspace.yaml', 'turbo.json', 'tsconfig*.json', '.github/workflows/mobile-android-screenshots.yml', 'apps/mobile/package.json', 'apps/mobile/app.json', 'apps/mobile/app.config.ts', 'apps/mobile/metro.config.js', 'apps/mobile/metro-resolver.js', 'apps/mobile/assets/**', 'apps/mobile/app/**', 'apps/mobile/components/**', 'apps/mobile/lib/**', 'apps/mobile/modules/**', 'apps/mobile/plugins/**', 'packages/chat-ui/**', 'packages/contracts/**', 'packages/core/**', 'packages/ui-tokens/**') }}
env:
APK_KEY: ${{ hashFiles('pnpm-lock.yaml', 'package.json', 'pnpm-workspace.yaml', 'turbo.json', 'tsconfig*.json', '.github/workflows/mobile-android-screenshots.yml', 'apps/mobile/package.json', 'apps/mobile/app.json', 'apps/mobile/app.config.ts', 'apps/mobile/metro.config.js', 'apps/mobile/metro-resolver.js', 'apps/mobile/assets/**', 'apps/mobile/app/**', 'apps/mobile/components/**', 'apps/mobile/lib/**', 'apps/mobile/modules/**', 'apps/mobile/plugins/**', 'packages/chat-ui/**', 'packages/contracts/**', 'packages/core/**', 'packages/ui-tokens/**') }}
run: |
set -euo pipefail
cache="$HOME/.cache/evoo-ci/mobile-android-apk"
echo "path=$cache/v2-$APK_KEY.apk" >> "$GITHUB_OUTPUT"
if [[ -f "$cache/v2-$APK_KEY.apk" ]]; then
mkdir -p apps/mobile/android/app/build/outputs/apk/release
cp "$cache/v2-$APK_KEY.apk" apps/mobile/android/app/build/outputs/apk/release/app-release.apk
echo "cache-hit=true" >> "$GITHUB_OUTPUT"
fi
- uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa # v4
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with:
node-version: 22
cache: pnpm
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
with:
distribution: temurin
java-version: 17
- uses: gradle/actions/setup-gradle@48b5f213c81028ace310571dc5ec0fbbca0b2947 # v4.4.3
if: steps.screenshot-apk.outputs.cache-hit != 'true'
with:
cache-read-only: false
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Restore Maestro CLI
id: maestro-cache
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with:
path: ~/.maestro
key: maestro-${{ runner.os }}-2.6.1-3440825f
- name: Install pinned Maestro CLI
if: steps.maestro-cache.outputs.cache-hit != 'true'
run: |
set -euo pipefail
if [[ -f "$HOME/.maestro/.installed-2.6.1-3440825f" ]]; then
exit 0
fi
maestro_tmp=$(mktemp -d)
trap 'rm -rf "$maestro_tmp"' EXIT
base_url=https://github.com/mobile-dev-inc/maestro/releases/download/cli-2.6.1
@ -88,8 +86,10 @@ jobs:
(cd "$maestro_tmp" && sha256sum --check --strict -)
(cd "$maestro_tmp" && sha256sum --check --strict checksums_sha256.txt)
unzip -q "$maestro_tmp/maestro.zip" -d "$maestro_tmp"
rm -rf "$HOME/.maestro"
mkdir -p "$HOME/.maestro"
cp -R "$maestro_tmp/maestro/." "$HOME/.maestro/"
touch "$HOME/.maestro/.installed-2.6.1-3440825f"
- name: Configure Maestro CLI
run: |
@ -112,10 +112,14 @@ jobs:
- name: Save screenshot APK
if: steps.screenshot-apk.outputs.cache-hit != 'true'
uses: actions/cache/save@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with:
path: apps/mobile/android/app/build/outputs/apk/release/app-release.apk
key: ${{ steps.screenshot-apk.outputs.cache-primary-key }}
env:
APK_CACHE: ${{ steps.screenshot-apk.outputs.path }}
run: |
set -euo pipefail
mkdir -p "$(dirname "$APK_CACHE")"
find "$(dirname "$APK_CACHE")" -name '*.apk' -mtime +14 -delete
cp apps/mobile/android/app/build/outputs/apk/release/app-release.apk "$APK_CACHE.partial"
mv "$APK_CACHE.partial" "$APK_CACHE"
- name: Enable KVM
run: |
@ -151,12 +155,3 @@ jobs:
MOBILE_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
MOBILE_SHA: ${{ github.sha }}
run: bash scripts/publish-mobile-screenshot-gallery.sh
- name: Upload screenshots and diagnostics
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: mobile-android-screenshots-${{ github.run_id }}-${{ github.run_attempt }}
path: test-report/mobile-screenshots
if-no-files-found: error
retention-days: 7

View File

@ -25,8 +25,8 @@ on:
required: false
type: boolean
default: false
upload_artifacts:
description: Retain the report and diagnostics as GitHub artifacts
hand_off_report:
description: Leave the report on the runner host for the trusted publish workflow
required: false
type: boolean
default: false
@ -54,7 +54,7 @@ jobs:
playwright:
name: Web E2E
runs-on: [self-hosted, evoo-ci, Linux]
timeout-minutes: 30
timeout-minutes: 60
concurrency:
group: ${{ inputs.publish_report && 'playwright-publication' || format('playwright-{0}', github.run_id) }}
cancel-in-progress: false
@ -83,16 +83,12 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Clear stale Vite dependency cache
run: rm -rf apps/web/node_modules/.vite node_modules/.vite
- uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ hashFiles('pnpm-lock.yaml') }}
- name: Install Chromium
timeout-minutes: 5
# Browsers persist in ~/.cache/ms-playwright on the runner host; no GitHub cache.
# Skip --with-deps: evo Linux runners keep Chromium's OS libraries installed,
# and apt-get can stall until the job timeout on persistent self-hosted machines.
run: pnpm --filter @rakazo/web exec playwright install chromium
@ -149,7 +145,7 @@ jobs:
if: always() && (steps.playwright_tests.outcome == 'success' || steps.playwright_tests.outcome == 'failure')
run: pnpm --filter @rakazo/www e2e
- name: Record Playwright result
if: always() && inputs.upload_artifacts
if: always() && inputs.hand_off_report
env:
PLAYWRIGHT_RESULT: ${{ steps.playwright_tests.outcome }}
WWW_PLAYWRIGHT_RESULT: ${{ steps.www_playwright_tests.outcome }}
@ -162,19 +158,28 @@ jobs:
else
printf 'failure\n' > test-report/e2e/outcome.txt
fi
- name: Upload Playwright artifacts
if: always() && inputs.upload_artifacts
- name: Hand off Playwright report on the runner host
if: always() && inputs.hand_off_report
continue-on-error: true
uses: actions/upload-artifact@v4
with:
name: playwright-artifacts-${{ github.run_id }}-${{ github.run_attempt }}
path: |
playwright-report
playwright-report-www
apps/web/test-results
test-report/e2e
retention-days: 7
if-no-files-found: warn
# Never GitHub artifact storage: the publish workflow runs on the same evo host
# and reads this archive as untrusted input.
env:
HANDOFF_ARCHIVE: ${{ github.run_id }}-${{ github.run_attempt }}.zip
run: |
set -euo pipefail
handoff="$HOME/.cache/evoo-ci/playwright-handoff"
mkdir -p "$handoff"
python3 - "$handoff/$HANDOFF_ARCHIVE.partial" playwright-report playwright-report-www test-report/e2e <<'PY'
import os, sys, zipfile
with zipfile.ZipFile(sys.argv[1], "w", zipfile.ZIP_DEFLATED) as archive:
for root in sys.argv[2:]:
for directory, _, files in os.walk(root):
for name in files:
file = os.path.join(directory, name)
if os.path.isfile(file) and not os.path.islink(file):
archive.write(file)
PY
mv "$handoff/$HANDOFF_ARCHIVE.partial" "$handoff/$HANDOFF_ARCHIVE"
- name: Publish Playwright visual report
if: always() && inputs.publish_report
env:

View File

@ -37,16 +37,17 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Read Playwright job result
id: playwright
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RUN_ID: ${{ github.event.workflow_run.id }}
RUN_ATTEMPT: ${{ github.event.workflow_run.run_attempt }}
shell: bash
run: |
result="$(gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${RUN_ID}/jobs?per_page=100" \
# Read the attempt that finished: a re-run already in progress has no conclusion yet.
result="$(gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${RUN_ID}/attempts/${RUN_ATTEMPT}/jobs?per_page=100" \
--jq '[.jobs[] | select(.name == "Web E2E / Web E2E") | .conclusion] | unique | if length == 1 then .[0] else error("expected exactly one Playwright job") end')"
case "$result" in
success|failure) ;;
@ -75,38 +76,31 @@ jobs:
echo "::warning::Could not resolve a pull request for ${HEAD_OWNER}:${HEAD_BRANCH} at ${HEAD_SHA}."
fi
echo "number=$number" >> "$GITHUB_OUTPUT"
- name: Check for Playwright artifact
- name: Take over Playwright report from the runner host
id: artifact
env:
ARTIFACT_NAME: playwright-artifacts-${{ github.event.workflow_run.id }}-${{ github.event.workflow_run.run_attempt }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HANDOFF_ARCHIVE: ${{ github.event.workflow_run.id }}-${{ github.event.workflow_run.run_attempt }}.zip
PLAYWRIGHT_JOB_RESULT: ${{ steps.playwright.outputs.job_result }}
RUN_ID: ${{ github.event.workflow_run.id }}
shell: bash
run: |
artifacts="$(gh api --method GET \
"repos/${GITHUB_REPOSITORY}/actions/runs/${RUN_ID}/artifacts?name=${ARTIFACT_NAME}")"
count="$(jq --arg name "$ARTIFACT_NAME" \
'[.artifacts[] | select(.name == $name and .expired == false)] | length' \
<<<"$artifacts")"
if [[ "$count" == "1" ]]; then
# The Web E2E job left this archive on the same evo host; GitHub artifact storage is never used.
handoff="$HOME/.cache/evoo-ci/playwright-handoff"
mkdir -p "$handoff"
find "$handoff" -type f -mtime +1 -delete
if [[ -f "$handoff/$HANDOFF_ARCHIVE" ]]; then
mv "$handoff/$HANDOFF_ARCHIVE" "$RUNNER_TEMP/playwright-artifacts.zip"
echo "exists=true" >> "$GITHUB_OUTPUT"
echo "id=$(jq -r --arg name "$ARTIFACT_NAME" '.artifacts[] | select(.name == $name and .expired == false) | .id' <<<"$artifacts")" >> "$GITHUB_OUTPUT"
elif [[ "$count" == "0" && "$PLAYWRIGHT_JOB_RESULT" == "timed_out" ]]; then
echo "::warning::The timed-out Playwright job ended before it uploaded an artifact. Publishing an empty gallery."
elif [[ "$PLAYWRIGHT_JOB_RESULT" == "timed_out" ]]; then
echo "::warning::The timed-out Playwright job ended before it handed off its report. Publishing an empty gallery."
echo "exists=false" >> "$GITHUB_OUTPUT"
else
echo "Expected exactly one unexpired Playwright artifact; found $count." >&2
echo "Missing Playwright report $handoff/$HANDOFF_ARCHIVE on this runner host." >&2
exit 1
fi
- name: Download Playwright artifacts
- name: Extract Playwright report
if: steps.artifact.outputs.exists == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ARTIFACT_ID: ${{ steps.artifact.outputs.id }}
shell: bash
run: |
gh api "repos/${GITHUB_REPOSITORY}/actions/artifacts/${ARTIFACT_ID}/zip" > "$RUNNER_TEMP/playwright-artifacts.zip"
# Keep untrusted files outside the checkout, dependencies, configuration,
# and generated dashboard consumed by the comment step.
python3 scripts/extract-playwright-artifacts.py \

View File

@ -3,9 +3,26 @@ name: publish-server-image
# Release tags publish the source-addressed image used by in-app updates plus the human-readable
# semver tags. Main publishes `edge`. Pull requests build with a read-only token and an isolated
# cache, so untrusted code can validate the images without receiving package-publish authority.
# Self-hosted evo runners on one host share a Docker daemon and often ~/.docker; parallel matrix
# jobs must use DOCKER_CONFIG under RUNNER_TEMP plus a per-job buildx builder name wired into
# build-push-action, or setup-buildx --use races and one job's post cleanup stops another's BuildKit.
# `ci-<run>-<image>-<arch>` handoff tags are extra aliases on the per-arch digest; GHCR does not
# expose a safe tag-only delete API (DELETE /v2/.../manifests/<tag> is unsupported or digest-scoped),
# so they are left in place and do not affect edge/sha-* manifests assembled in publish.
on:
push:
branches: [main]
paths:
- ".dockerignore"
- ".github/workflows/publish-server-image.yml"
- "apps/**"
- "infra/compose/**"
- "infra/sandboxes/computer/**"
- "infra/updater/**"
- "packages/**"
- "package.json"
- "pnpm-lock.yaml"
- "pnpm-workspace.yaml"
tags: ["v*"]
pull_request:
paths:
@ -27,14 +44,55 @@ permissions:
env:
# docker/metadata-action otherwise truncates commit tags to seven collision-prone characters.
DOCKER_METADATA_SHORT_SHA_LENGTH: 40
# build-push-action v6 uploads large *.dockerbuild records by default; publish only needs digest artifacts.
# build-push-action v6 uploads large *.dockerbuild records by default; publish reads digests from GHCR handoff tags.
DOCKER_BUILD_RECORD_UPLOAD: false
concurrency:
group: publish-server-image-${{ github.ref }}
cancel-in-progress: false
group: publish-server-image-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
changes:
name: Detect image publish inputs
runs-on: [self-hosted, evoo-ci]
outputs:
publish: ${{ steps.decide.outputs.publish }}
steps:
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
with:
persist-credentials: false
fetch-depth: 0
- uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
id: filter
with:
filters: |
publish:
- '.dockerignore'
- '.github/workflows/publish-server-image.yml'
- 'apps/**'
- 'infra/compose/**'
- 'infra/sandboxes/computer/**'
- 'infra/updater/**'
- 'packages/**'
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- name: Decide whether release jobs should run
id: decide
run: |
set -euo pipefail
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]] || [[ "${{ github.ref }}" == refs/tags/v* ]]; then
echo "publish=true" >> "$GITHUB_OUTPUT"
elif [[ "${{ github.event_name }}" == "pull_request" ]]; then
echo "publish=true" >> "$GITHUB_OUTPUT"
elif [[ "${{ steps.filter.outputs.publish }}" == "true" ]]; then
echo "publish=true" >> "$GITHUB_OUTPUT"
else
echo "publish=false" >> "$GITHUB_OUTPUT"
fi
installer-smoke:
name: Compose installer smokes
if: github.event_name == 'pull_request'
@ -72,7 +130,16 @@ jobs:
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
with:
persist-credentials: false
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Prepare isolated Docker config directory
run: |
set -euo pipefail
cfg="${RUNNER_TEMP}/docker-config"
mkdir -p "$cfg"
echo "DOCKER_CONFIG=${cfg}" >> "$GITHUB_ENV"
- id: buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
with:
name: ci-pr-${{ github.event.pull_request.number }}-${{ matrix.name }}
- name: Resolve the lowercase image namespace
id: ns
# GHCR rejects repository names with capitals; the GitHub repository slug has them.
@ -82,28 +149,37 @@ jobs:
with:
images: ghcr.io/${{ steps.ns.outputs.repo }}/${{ matrix.name }}
tags: type=sha,prefix=sha-
- name: Locate the main build cache on this runner host
id: cache
# Build caches stay on the evo host; GitHub's cache service is never used.
run: echo "dir=$HOME/.cache/evoo-ci/buildx/${{ matrix.name }}-amd64" >> "$GITHUB_OUTPUT"
- uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with:
builder: ${{ steps.buildx.outputs.name }}
context: ${{ matrix.context }}
file: ${{ matrix.dockerfile }}
push: false
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: ${{ matrix.name == 'app' && format('GIT_SHA={0}', github.sha) || '' }}
cache-from: type=gha,scope=${{ matrix.name }}
cache-to: type=gha,mode=max,scope=pr-${{ github.event.pull_request.number }}-${{ matrix.name }}
# Pull requests only read the cache main writes.
cache-from: type=local,src=${{ steps.cache.outputs.dir }}
provenance: false
sbom: false
build:
name: Build ${{ matrix.name }} ${{ matrix.arch }}
if: github.event_name != 'pull_request'
needs: changes
if: github.event_name != 'pull_request' && needs.changes.outputs.publish == 'true'
# Each architecture builds natively on its own runner and pushes by digest only.
# amd64 on evo Linux runners. arm64 is paused: the macOS runners have no Docker buildx,
# and QEMU emulation took hours for the desktop-sized computer image.
# See docs/self-host.md (Published images and tags).
runs-on: ${{ matrix.runner }}
timeout-minutes: 90
concurrency:
group: publish-server-image-build-${{ github.ref }}-${{ matrix.name }}-${{ matrix.arch }}
cancel-in-progress: ${{ github.ref == 'refs/heads/main' }}
permissions:
contents: read
packages: write
@ -130,7 +206,16 @@ jobs:
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
with:
persist-credentials: false
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Prepare isolated Docker config directory
run: |
set -euo pipefail
cfg="${RUNNER_TEMP}/docker-config"
mkdir -p "$cfg"
echo "DOCKER_CONFIG=${cfg}" >> "$GITHUB_ENV"
- id: buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
with:
name: ci-${{ github.run_id }}-${{ github.run_attempt }}-${{ matrix.name }}-${{ matrix.arch }}
- name: Log in to GHCR
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
@ -147,9 +232,14 @@ jobs:
# Labels only; the tagged manifest is assembled in the publish job.
images: ghcr.io/${{ steps.ns.outputs.repo }}/${{ matrix.name }}
tags: type=sha,prefix=sha-
- name: Locate the build cache on this runner host
id: cache
# Build caches stay on the evo host; GitHub's cache service is never used.
run: echo "dir=$HOME/.cache/evoo-ci/buildx/${{ matrix.name }}-${{ matrix.arch }}" >> "$GITHUB_OUTPUT"
- id: build
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with:
builder: ${{ steps.buildx.outputs.name }}
context: ${{ matrix.context }}
file: ${{ matrix.dockerfile }}
platforms: linux/${{ matrix.arch }}
@ -158,33 +248,37 @@ jobs:
outputs: type=image,name=ghcr.io/${{ steps.ns.outputs.repo }}/${{ matrix.name }},push-by-digest=true,name-canonical=true,push=true
# GET /health can report the exact source commit without a deployment-supplied override.
build-args: ${{ matrix.name == 'app' && format('GIT_SHA={0}', github.sha) || '' }}
cache-from: type=gha,scope=${{ matrix.name }}-${{ matrix.arch }}
cache-to: type=gha,mode=max,scope=${{ matrix.name }}-${{ matrix.arch }}
cache-from: type=local,src=${{ steps.cache.outputs.dir }}
cache-to: type=local,mode=max,dest=${{ steps.cache.outputs.dir }}-new
provenance: mode=max
sbom: true
- name: Record the architecture digest
id: digest
- name: Replace the build cache
# A local cache only grows; swapping in the fresh export drops layers this build no longer uses.
env:
DIGEST: ${{ steps.build.outputs.digest }}
CACHE_DIR: ${{ steps.cache.outputs.dir }}
run: |
set -euo pipefail
digest_dir="${RUNNER_TEMP}/digest"
mkdir -p "$digest_dir"
touch "${digest_dir}/${DIGEST#sha256:}"
echo "dir=${digest_dir}" >> "$GITHUB_OUTPUT"
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: digest-${{ matrix.name }}-${{ matrix.arch }}
path: ${{ steps.digest.outputs.dir }}/*
if-no-files-found: error
retention-days: 1
rm -rf "$CACHE_DIR"
mv "$CACHE_DIR-new" "$CACHE_DIR"
- name: Tag digest for publish job handoff
env:
DIGEST: ${{ steps.build.outputs.digest }}
IMAGE: ghcr.io/${{ steps.ns.outputs.repo }}/${{ matrix.name }}
run: |
set -euo pipefail
docker buildx imagetools create \
-t "${IMAGE}:ci-${GITHUB_RUN_ID}-${{ matrix.name }}-${{ matrix.arch }}" \
"${IMAGE}@${DIGEST}"
publish:
name: Publish ${{ matrix.name }} image
if: github.event_name != 'pull_request'
needs: build
if: github.event_name != 'pull_request' && needs.changes.outputs.publish == 'true'
needs: [changes, build]
runs-on: [self-hosted, evoo-ci, Linux]
timeout-minutes: 15
concurrency:
group: publish-server-image-publish-${{ github.ref == 'refs/heads/main' && 'main' || github.ref }}-${{ matrix.name }}
cancel-in-progress: false
permissions:
contents: read
packages: write
@ -198,15 +292,16 @@ jobs:
- name: updater
- name: computer
steps:
- name: Prepare an isolated digest directory
id: digest_dir
run: echo "path=${RUNNER_TEMP}/digests" >> "$GITHUB_OUTPUT"
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
- name: Prepare isolated Docker config directory
run: |
set -euo pipefail
cfg="${RUNNER_TEMP}/docker-config"
mkdir -p "$cfg"
echo "DOCKER_CONFIG=${cfg}" >> "$GITHUB_ENV"
- id: buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
with:
pattern: digest-${{ matrix.name }}-*
path: ${{ steps.digest_dir.outputs.path }}
merge-multiple: true
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
name: ci-${{ github.run_id }}-${{ github.run_attempt }}-publish-${{ matrix.name }}
- name: Log in to GHCR
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
@ -217,6 +312,21 @@ jobs:
id: ns
# GHCR rejects repository names with capitals; the GitHub repository slug has them.
run: echo "repo=${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT"
- name: Collect architecture digests from registry handoff tags
id: digest_dir
env:
IMAGE: ghcr.io/${{ steps.ns.outputs.repo }}/${{ matrix.name }}
IMAGE_NAME: ${{ matrix.name }}
run: |
set -euo pipefail
digest_dir="${RUNNER_TEMP}/digests"
mkdir -p "$digest_dir"
for arch in amd64; do
handoff="${IMAGE}:ci-${GITHUB_RUN_ID}-${IMAGE_NAME}-${arch}"
digest="$(docker buildx imagetools inspect "$handoff" --format '{{.Manifest.Digest}}')"
touch "${digest_dir}/${digest#sha256:}"
done
echo "path=${digest_dir}" >> "$GITHUB_OUTPUT"
- id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
with:

View File

@ -18,6 +18,8 @@ jobs:
validate:
name: Validate stable release
runs-on: [self-hosted, evoo-ci, Linux]
permissions:
contents: write
outputs:
tag: ${{ steps.release.outputs.tag }}
version: ${{ steps.release.outputs.version }}
@ -95,17 +97,32 @@ jobs:
set -euo pipefail
include='[{"runner":["self-hosted","evoo-ci","macOS"],"artifact":"macos"},{"runner":["self-hosted","evoo-ci","Linux"],"artifact":"linux"}]'
if [[ "$WINDOWS_SIGNING" == "true" ]]; then
# No self-hosted Windows runner; keep GitHub-hosted only when signing credentials exist.
include="$(jq -c '. + [{"runner":"windows-2022","artifact":"windows"}]' <<<"$include")"
# Internal runners only: Windows waits for a self-hosted Windows runner, never a GitHub-hosted one.
include="$(jq -c '. + [{"runner":["self-hosted","evoo-ci","Windows"],"artifact":"windows"}]' <<<"$include")"
fi
echo "include=$include" >> "$GITHUB_OUTPUT"
echo "windows=$WINDOWS_SIGNING" >> "$GITHUB_OUTPUT"
- name: Open the draft release the platform builds fill
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_TAG: ${{ steps.release.outputs.tag }}
shell: bash
run: |
set -euo pipefail
# Builds hand their files to this draft, never to GitHub Actions artifact storage.
if [[ "$(gh api "repos/${GITHUB_REPOSITORY}/releases?per_page=100" \
--jq "[.[] | select(.draft and .tag_name == \"${RELEASE_TAG}\")] | length")" != "0" ]]; then
gh release delete "$RELEASE_TAG" --yes
fi
gh release create "$RELEASE_TAG" --draft --generate-notes --title "Rakazo $RELEASE_TAG" --verify-tag
build:
name: Build signed ${{ matrix.artifact }} artifacts
needs: validate
runs-on: ${{ matrix.runner }}
timeout-minutes: 60
permissions:
contents: write
strategy:
fail-fast: false
matrix:
@ -172,8 +189,8 @@ jobs:
xcrun stapler validate "$app"
spctl --assess --type execute --verbose=2 "$app"
grep -Fqx "provider: github" "$app/Contents/Resources/app-update.yml"
grep -Fqx "owner: elie222" "$app/Contents/Resources/app-update.yml"
grep -Fqx "repo: rakazo" "$app/Contents/Resources/app-update.yml"
grep -Fqx "owner: cynarAI" "$app/Contents/Resources/app-update.yml"
grep -Fqx "repo: evOo-local" "$app/Contents/Resources/app-update.yml"
- name: Require Windows signing credentials
if: runner.os == 'Windows'
@ -214,7 +231,7 @@ jobs:
throw "Windows update config is not bound to the signing publisher."
}
$feed = Get-Content -Raw $config
foreach ($expected in @("provider: github", "owner: elie222", "repo: rakazo")) {
foreach ($expected in @("provider: github", "owner: cynarAI", "repo: evOo-local")) {
if ($feed -notmatch "(?m)^$([regex]::Escape($expected))\r?$") {
throw "Windows update config missing '$expected'."
}
@ -239,25 +256,32 @@ jobs:
test -n "$config"
test -f "$config"
grep -Fqx "provider: github" "$config"
grep -Fqx "owner: elie222" "$config"
grep -Fqx "repo: rakazo" "$config"
grep -Fqx "owner: cynarAI" "$config"
grep -Fqx "repo: evOo-local" "$config"
test -f apps/desktop/out/latest-linux.yml
grep -Fqx "version: $RELEASE_VERSION" apps/desktop/out/latest-linux.yml
- name: Retain installers and updater metadata
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: desktop-${{ matrix.artifact }}-${{ needs.validate.outputs.sha }}
path: |
- name: Hand installers and updater metadata to the draft release
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_TAG: ${{ needs.validate.outputs.tag }}
run: |
set -euo pipefail
shopt -s nullglob
files=(
apps/desktop/out/*.AppImage
apps/desktop/out/*.blockmap
apps/desktop/out/*.dmg
apps/desktop/out/*.exe
apps/desktop/out/latest*.yml
apps/desktop/out/*.zip
if-no-files-found: error
compression-level: 0
retention-days: 7
)
if [[ ${#files[@]} -eq 0 ]]; then
echo "The build produced no release files." >&2
exit 1
fi
bash scripts/upload-release-assets.sh "${files[@]}"
publish:
name: Attest and publish complete release
@ -275,11 +299,11 @@ jobs:
fetch-depth: 0
persist-credentials: false
ref: ${{ needs.validate.outputs.sha }}
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
pattern: desktop-*-${{ needs.validate.outputs.sha }}
path: release-artifacts
merge-multiple: true
- name: Collect every platform from the draft release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_TAG: ${{ needs.validate.outputs.tag }}
run: gh release download "$RELEASE_TAG" --dir release-artifacts
- name: Verify the complete stable update feed
env:
RELEASE_SHA: ${{ needs.validate.outputs.sha }}
@ -323,26 +347,34 @@ jobs:
uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3
with:
subject-path: release-artifacts/*
- name: Create draft and upload every platform
id: create_release
- name: Add checksums to the draft
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_TAG: ${{ needs.validate.outputs.tag }}
run: >-
gh release create "$RELEASE_TAG" release-artifacts/*
--draft --generate-notes --title "Rakazo $RELEASE_TAG" --verify-tag
run: gh release upload "$RELEASE_TAG" release-artifacts/SHA256SUMS --clobber
- name: Publish the completed release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_TAG: ${{ needs.validate.outputs.tag }}
run: gh release edit "$RELEASE_TAG" --draft=false --latest
- name: Remove an incomplete draft
if: failure() && steps.create_release.outcome != 'skipped'
discard:
name: Remove an incomplete draft
needs: [validate, build, publish]
if: failure() && needs.validate.result == 'success'
runs-on: [self-hosted, evoo-ci, Linux]
timeout-minutes: 5
permissions:
contents: write
steps:
- name: Delete the draft release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
RELEASE_TAG: ${{ needs.validate.outputs.tag }}
shell: bash
run: |
if [[ "$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${RELEASE_TAG}" --jq .draft 2>/dev/null)" == "true" ]]; then
if [[ "$(gh api "repos/${GITHUB_REPOSITORY}/releases?per_page=100" \
--jq "[.[] | select(.draft and .tag_name == \"${RELEASE_TAG}\")] | length")" != "0" ]]; then
gh release delete "$RELEASE_TAG" --yes
fi

View File

@ -19,7 +19,6 @@ jobs:
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm test:topology
- name: Replay computer tools against real Chromium without inference

View File

@ -11,6 +11,7 @@
- Treat auth, secret handling, sandbox boundaries, host commands, and integrations as security-sensitive. Keep tests deterministic and offline by default.
- The desktop Playwright suite (`pnpm --filter @rakazo/desktop test:e2e`) opens real Electron windows and steals focus on macOS. Do not run it on a maintainer's machine as routine verification; run the unit tests locally and let CI's virtual-display job run the e2e on push.
- Work autonomously. Make routine calls yourself, such as opening or reopening a pull request, linking CI screenshots, or fixing gaps you found that belong to the task, then verify and report the outcome. Ask the maintainer only before irreversible actions or for product decisions without a sensible default.
- CI runs only on the self-hosted `evoo-ci` runners and never consumes GitHub Actions resources: no GitHub-hosted runners, no Actions artifact storage (`upload-artifact`, `download-artifact`), and no GitHub cache service (`actions/cache`, setup-node `cache:`, buildx `type=gha`). Keep caches and job handoffs on the runner host; `infra/updater/src/github-actions-budget.test.ts` enforces this.
- After creating a pull request, stay with it until CI and automated review bots have finished; passing checks alone do not mean the review is complete. Follow `.agents/skills/pr-watch/SKILL.md`: run its `pr-digest --watch` helper as a single backgrounded call that blocks until the checks on the current head commit are terminal, then act on the verdict it prints. Do not poll in the foreground on a timer, which mostly buys repeated snapshots of an unchanged PR and dies at the tool timeout. Address every actionable issue, push the fixes, and repeat until no actionable feedback remains. Do not merge while review bots are still pending or review issues remain unresolved.
- PR descriptions say why the change exists, what changed, and how it was tested. Write Why as the product reason (what was wrong and what this changes); never "Elie wants" or a third-person briefing of a named person; the maintainer is the author.
- Commit messages, PR descriptions, issues, and review replies are public too. Describe test results in words instead of pasting tool output, and never include anything that identifies a person, machine, or account: local paths, usernames, hostnames, signing identities, legal entity names, Apple Team IDs, API key IDs, issuer or tenant IDs, account emails, or the accounts, machines, and files that secret values came from. Naming the secrets a workflow reads is fine; use placeholders when a value must be shown.

114
PROJECT.md Normal file
View File

@ -0,0 +1,114 @@
# Project: Dual-Surface-View in evOo (`apps/web`)
## Architecture
evOo (`apps/web`) organizes the workspace into two primary surfaces:
1. **Mission Stream**: The chat, agent transcript (`data-testid="transcript"`), run steps (`data-testid="run-steps"`), and composer inside `<main>`.
2. **Living Canvas**: The computer surface (`data-testid="computer-preview"`), workspace files explorer (`WorkspaceFilesView`), code inspector, in-app browser, and artifact viewer inside `<aside data-testid="side-panel">`.
In desktop viewports (>= 768px `md`), these two surfaces operate as equal, first-class split surfaces within `Shell.tsx`:
- Positioned side-by-side without modal dialog occlusion.
- Separated by an interactive `DualSurfaceDivider` supporting fluid pointer drag-resizing, double-click snap to 50/50 balance, and keyboard arrow adjustments.
- Controlled via quick-switch header preset controls: Focus Stream (100/0), Dual-Surface Split (50/50), Focus Canvas (0/100), and a side-swapping toggle (Stream-first vs. Canvas-first).
- Configured by persisted preferences (`localStorage` via `dual-surface-pref.ts`) with safe defaults and bounded numeric clamping.
- Responsive degradation (< 768px `md`): Automatically transitions to stacked/drawer/single-surface mode without mutating desktop split ratio preferences.
- Monochrome design system adhering to `@rakazo/ui-tokens` with zero hardcoded hex or arbitrary CSS values.
- Internationalized using LinguiJS with 100% German (`de`) translation coverage.
```
+-----------------------------------------------------------------------------------+
| Top Navigation Bar: [Bots Toggle] ... [Preset: 100/0 | 50/50 | 0/100] [Swap] ... |
+----------------------------------------------------+---+--------------------------+
| Surface A (e.g. Mission Stream / Chat) | D | Surface B (Living Canvas)|
| | I | |
| - Transcript (`data-testid="transcript"`) | V | - Computer Preview |
| - Run steps (`data-testid="run-steps"`) | I | - Workspace Explorer |
| - Composer | D | - Code Inspector |
| | E | (`data-testid="side-panel")|
| | R | |
+----------------------------------------------------+---+--------------------------+
```
## Feature Inventory
| # | Feature | Description | Milestone | Source |
|---|---------|-------------|-----------|--------|
| 1 | Preferences Storage & Schema | `dual-surface-pref.ts` managing `splitRatio` (0.20-0.80, default 0.50), `layoutMode` (`split`, `stream`, `canvas`), `orientation` (`stream-first`, `canvas-first`) with localStorage read/write and bounds clamping | M1 | ORIGINAL_REQUEST §R3 |
| 2 | Layout Hook & State Coordination | `useDualSurfaceLayout.ts` React hook handling layout state, preset toggles, orientation flip, and media query synchronization | M1 | ORIGINAL_REQUEST §R1, R3 |
| 3 | Fluid Drag-Resize & Snap Divider | `DualSurfaceDivider.tsx` + `useDualSurfaceResize.ts` with pointer capture, hover/active semantic tokens, bounds clamping, double-click 50/50 snap, and ARIA separator attributes | M2 | ORIGINAL_REQUEST §R2, R4 |
| 4 | Header Preset Controls & Orientation Swap | `DualSurfaceHeaderControls.tsx` with quick-switch presets (100/0, 50/50, 0/100) and Swap Orientation toggle, styled with semantic tokens and full accessibility | M2 | ORIGINAL_REQUEST §R1, R2 |
| 5 | Shell Flex Integration & Surface Swap | Coordinated flex container in `Shell.tsx` hosting `<main>` and `<aside data-testid="side-panel">`, supporting dynamic order swapping while preserving existing test IDs | M3 | ORIGINAL_REQUEST §R1, R2, R4 |
| 6 | Responsive Degradation (< md) | Seamless fallback on viewports < 768px to stacked/single-surface navigation without corrupting persisted desktop preferences | M3 | ORIGINAL_REQUEST §R3 |
| 7 | Design System & Token Compliance | Exclusively semantic tokens from `@rakazo/ui-tokens` (`bg-border`, `hover:bg-accent`, `active:bg-primary`, `ring-ring`), monochrome styling, no hardcoded hex/arbitrary css | M3 | ORIGINAL_REQUEST §R4 |
| 8 | Localization (German Translations) | Lingui message extraction, full German translations in `messages.po`, clean `intl:extract` and `intl:compile` | M3 | ORIGINAL_REQUEST §Acceptance Criteria |
| 9 | E2E Testing & Adversarial Hardening | Comprehensive test suite (Tiers 1-4) and adversarial hardening (Tier 5), 100% green tests (78/78 suites baseline + new tests) and 0 TS errors | M4 | ORIGINAL_REQUEST §Acceptance Criteria |
## Milestones
| # | Name | Scope | Dependencies | Status |
|---|------|-------|-------------|--------|
| M1 | Preferences, Layout Hook & State Management | `dual-surface-pref.ts`, `useDualSurfaceLayout.ts`, unit tests | none | DONE |
| M2 | Interactive Divider & Header Preset Controls | `DualSurfaceDivider.tsx`, `useDualSurfaceResize.ts`, `dual-surface-header-controls.tsx`, unit/DOM tests | M1 | DONE |
| M3 | Shell Integration, Responsive Degradation & Localization | `Shell.tsx` layout refactoring, responsive fallback, German translations in `messages.po`, preserve test IDs | M2 | DONE |
| M4 | E2E Test Suite (Tiers 1-4) & Adversarial Hardening (Tier 5) | Comprehensive E2E test suite execution, 100% pass, forensic integrity audit | M3, E2E Track | IN_PROGRESS |
## Interface Contracts
### `dual-surface-pref.ts`
```typescript
export type DualSurfaceLayoutMode = "split" | "stream" | "canvas";
export type DualSurfaceOrientation = "stream-first" | "canvas-first";
export interface DualSurfacePreferences {
splitRatio: number; // 0.20 to 0.80, default 0.50
layoutMode: DualSurfaceLayoutMode;
orientation: DualSurfaceOrientation;
}
export function readDualSurfacePreferences(): DualSurfacePreferences;
export function writeDualSurfacePreferences(prefs: Partial<DualSurfacePreferences>): void;
export function clampSplitRatio(ratio: number): number;
```
### `useDualSurfaceLayout.ts`
```typescript
export interface DualSurfaceLayoutState {
splitRatio: number;
layoutMode: DualSurfaceLayoutMode;
orientation: DualSurfaceOrientation;
isMobile: boolean; // < 768px (md)
setSplitRatio: (ratio: number) => void;
setLayoutMode: (mode: DualSurfaceLayoutMode) => void;
setOrientation: (orientation: DualSurfaceOrientation) => void;
toggleOrientation: () => void;
resetSplit: () => void; // Snaps to 0.50
}
```
### `DualSurfaceDivider.tsx`
```typescript
export interface DualSurfaceDividerProps {
splitRatio: number;
onResize: (newRatio: number) => void;
onReset: () => void; // Double-click snap
disabled?: boolean;
}
```
### `DualSurfaceHeaderControls.tsx`
```typescript
export interface DualSurfaceHeaderControlsProps {
layoutMode: DualSurfaceLayoutMode;
orientation: DualSurfaceOrientation;
onSelectMode: (mode: DualSurfaceLayoutMode) => void;
onToggleOrientation: () => void;
disabled?: boolean;
}
```
## Code Layout
- `apps/web/src/lib/dual-surface-pref.ts`: Preference reading, writing, and boundary clamping.
- `apps/web/src/lib/dual-surface-pref.test.ts`: Unit tests for storage and mathematical boundaries.
- `apps/web/src/hooks/useDualSurfaceLayout.ts`: Layout state and responsive lifecycle management.
- `apps/web/src/hooks/useDualSurfaceResize.ts`: Pointer capture and keyboard navigation hook for divider.
- `apps/web/src/components/DualSurfaceDivider.tsx`: Accessible interactive divider component.
- `apps/web/src/pages/shell/dual-surface-header-controls.tsx`: Quick-switch header presets and swap buttons.
- `apps/web/src/pages/Shell.tsx`: Integration into application root layout.
- `apps/web/src/locales/de/messages.po`: German translation catalog entries.
- `apps/web/src/tests/dual-surface-view.test.tsx`: Component integration and DOM interaction tests.

View File

@ -49,7 +49,7 @@ Full local notes: **[docs/LOCAL-SETUP.md](./docs/LOCAL-SETUP.md)**
- TypeScript monorepo (pnpm + Turborepo)
- React 19 / Vite / Tailwind (web), Electron (desktop), Expo (mobile)
- Hono + oRPC, PostgreSQL + Prisma, Better Auth, Graphile Worker
- Pi model routing, Docker sandboxes, Voice (Kokoro local / BYO keys)
- Pi model routing, Docker sandboxes, Voice (Kokoro, local)
## Antigravity tools kept on this branch
@ -68,7 +68,7 @@ docker run -p 8880:8880 ghcr.io/remsky/kokoro-fastapi-cpu:latest
export KOKORO_BASE_URL=http://127.0.0.1:8880
```
Details: [docs/kokoro-voice.md](./docs/kokoro-voice.md)
German voices come from a local Piper server (`PIPER_BASE_URL`). Details: [docs/kokoro-voice.md](./docs/kokoro-voice.md)
## Tech debt (brand / packages)

135
TEST_INFRA.md Normal file
View File

@ -0,0 +1,135 @@
# E2E Test Infrastructure: evOo Voice-First Autonomous Agent OS (Dual Track)
## 1. Test Philosophy & Principles
- **Opaque-Box & Requirement-Driven**: Derived strictly from user specifications in `ORIGINAL_REQUEST.md` (§R1, §R2, §R3) and architecture specifications in `orchestrator_2/PROJECT.md` (§F1–§F12).
- **Interface Contract Verification**: Validates concrete contracts for `StreamingVoiceFrame`, `StreamingVoiceClient`, `ActionQueueStep`, `DynamicActionQueue`, and `RoutineNotificationPolicy`.
- **Offline & Deterministic Execution**: Zero external cloud or hosted vendor dependencies; uses simulated Web Audio, deterministic PCM frame generators, and pure React 19 testing harnesses.
- **Strict Linting & Design System Compliance**: Conforms strictly to `@rakazo/ui-tokens` monochrome tokens, zero arbitrary hex colors, top-level `import type`, and clean Biome formatting.
---
## 2. Test Architecture & Runner Commands
### Execution Commands
1. **Run All E2E OS Test Suites (139 tests)**:
```bash
pnpm vitest run apps/web/src/tests/e2e-voice-first-foundation.test.ts apps/web/src/tests/e2e-autonomous-agency-queues.test.ts apps/web/src/tests/e2e-minimalist-tactile-ux.test.tsx apps/web/src/tests/e2e-cross-feature-workflows.test.ts
```
2. **Run Individual Feature Suites**:
- Voice Foundation (F1–F4):
```bash
pnpm vitest run apps/web/src/tests/e2e-voice-first-foundation.test.ts
```
- Autonomous Agency & Queues (F5–F8):
```bash
pnpm vitest run apps/web/src/tests/e2e-autonomous-agency-queues.test.ts
```
- Minimalist Tactile UX & Onboarding (F9–F12):
```bash
pnpm vitest run apps/web/src/tests/e2e-minimalist-tactile-ux.test.tsx
```
- Cross-Feature & Real-World Workflows (T3 & T4):
```bash
pnpm vitest run apps/web/src/tests/e2e-cross-feature-workflows.test.ts
```
3. **TypeScript Compilation Check**:
```bash
pnpm --filter @rakazo/web check
```
4. **Code Quality & Linter**:
```bash
pnpm biome check apps/web/src/tests/e2e-*.ts* apps/web/src/tests/fixtures/*.ts
```
---
## 3. Feature Inventory & Test Coverage Matrix
| # | Feature | Scope & Requirement | Test File | T1 | T2 | T3 | T4 | Total Tests |
|---|---------|---------------------|-----------|:--:|:--:|:--:|:--:|:-----------:|
| **F1** | Streaming Audio Wire & Low-Latency VAD | ORIGINAL_REQUEST §R1 (<120ms framing, PCM16, VAD) | `e2e-voice-first-foundation.test.ts` | 5 | 5 | ✓ | ✓ | 10+ |
| **F2** | Full-Duplex Audio & Barge-In Interruption | ORIGINAL_REQUEST §R1 (Continuous AEC, barge-in mute) | `e2e-voice-first-foundation.test.ts` | 5 | 5 | ✓ | ✓ | 10+ |
| **F3** | Incremental Utterance Streamer & Invite Strip | ORIGINAL_REQUEST §R1 (Clause pipelining, no invite delays) | `e2e-voice-first-foundation.test.ts` | 5 | 5 | ✓ | ✓ | 10+ |
| **F4** | Cross-Platform Earcons & Mobile Audio | ORIGINAL_REQUEST §R1 (Tactile Web Audio & mobile cues) | `e2e-voice-first-foundation.test.ts` | 5 | 5 | ✓ | ✓ | 10+ |
| **F5** | Optimistic Input Queue & Zero UI Freeze | ORIGINAL_REQUEST §R2 (Non-blocking composer, optimistic FIFO) | `e2e-autonomous-agency-queues.test.ts` | 5 | 5 | ✓ | ✓ | 10+ |
| **F6** | Mid-Step Tool Cancellation & Active Steering | ORIGINAL_REQUEST §R2 (Mid-execution abort, active steering) | `e2e-autonomous-agency-queues.test.ts` | 5 | 5 | ✓ | ✓ | 10+ |
| **F7** | Outcome-Driven Action Queue State Machine | ORIGINAL_REQUEST §R2 (Dynamic action queue, outcomes) | `e2e-autonomous-agency-queues.test.ts` | 5 | 5 | ✓ | ✓ | 10+ |
| **F8** | Quiet Background Routines & Delta Evaluation | ORIGINAL_REQUEST §R2 (on_change delta hash gating, no nag) | `e2e-autonomous-agency-queues.test.ts` | 5 | 5 | ✓ | ✓ | 10+ |
| **F9** | Tactile Minimalist Step Execution | ORIGINAL_REQUEST §R3 (Strip chrome bloat, folding step) | `e2e-minimalist-tactile-ux.test.tsx` | 5 | 5 | ✓ | ✓ | 10+ |
| **F10** | 30-Second Rapid Onboarding | ORIGINAL_REQUEST §R3 (1-click bootstrap, 3 action sparks) | `e2e-minimalist-tactile-ux.test.tsx` | 5 | 5 | ✓ | ✓ | 10+ |
| **F11** | Monochrome Semantic Token Hygiene | ORIGINAL_REQUEST §R3 (@rakazo/ui-tokens, zero rogue hex) | `e2e-minimalist-tactile-ux.test.tsx` | 5 | 5 | ✓ | ✓ | 10+ |
| **F12** | Living Canvas Progressive Disclosure | ORIGINAL_REQUEST §R3 (Dynamic canvas, preserve test IDs) | `e2e-minimalist-tactile-ux.test.tsx` | 5 | 5 | ✓ | ✓ | 10+ |
| **T3** | Cross-Feature Interactions | Pairwise combinations across R1, R2, R3 | `e2e-cross-feature-workflows.test.ts` | - | - | 12 | - | 12 |
| **T4** | Real-World Application Workflows | Realistic end-to-end operational scenarios | `e2e-cross-feature-workflows.test.ts` | - | - | - | 7 | 7 |
| **Total** | **All Features F1–F12 across T1–T4** | **100% Comprehensive Coverage** | **4 Suites + 3 Fixtures** | **60** | **60** | **12** | **7** | **139** |
---
## 4. Test Tiers Breakdown
### Tier 1: Feature Coverage (60 Tests: 5 Tests × 12 Features)
- **F1**: 80ms PCM16 frame chunking (1280 samples at 16kHz); VAD normalized RMS energy; <120ms cutoff threshold eliminating 850ms silence floor; monotonic timestamp increment; streaming client transcript & playback chunk callbacks.
- **F2**: Continuous microphone capture with acoustic echo cancellation (`echoCancellation: true`); user speech energy detection while bot speaking triggers barge-in; immediate playback audio mute; backend abort propagation (`abort('barge_in')`); state transition from speaking to listening.
- **F3**: Incremental clause segmentation in 4–6 word chunks; time-to-first-sound pipelining (<300ms TTFS target); stripping robotic invite prompts ("What would you like to do next?"); code fence description transformation per `speakable()`; natural punctuation boundary clause splits.
- **F4**: Core tactile earcons (`listening-start`, `listening-stop`, `speaking-start`, `speaking-done`); Web Audio procedural oscillator profiles; master audio bus ducking (0.28 ducking ratio); cross-platform mobile earcon mapping; state machine audio cue triggers (`interact`, `attention`).
- **F5**: Submitting message while another in-flight enqueues without UI freeze; temporary optimistic ID assignment (`temp-...`) and timestamp; composer text clearing immediately; Stop button unblocked and clickable; sequential FIFO drainage.
- **F6**: Ingesting steering during active tool call dispatches mid-step cancellation signal; tool abort controller receives `abort('steering_redirection')`; aborted tool output discarded from history; agent pivots immediately to steering intent; steering message marked claimed.
- **F7**: `DynamicActionQueue` initialization; multi-step prompt synthesis into `ActionQueueStep` items; queue advancement starts pending step with `startedAt`; completing step sets `completedAt` and advances; `cancelActive()` marks step cancelled.
- **F8**: `RoutineNotificationPolicy` with `"on_change"` evaluates output delta hash; identical output hash suppresses notification (quiet run); distinct output hash triggers notification; policy `"always"` notifies unconditionally; policy `"never"` suppresses all notifications.
- **F9**: Minimalist folding step button renders with `data-testid="run-steps"`; displays clean `# step` counter without progress track bloat; clicking fold button toggles `aria-expanded`; step tool name and summary rendered when expanded; keyboard Enter/Space toggles fold.
- **F10**: 1-click bootstrap creates initial agent workspace without 11-step questionnaire; auto-detects system timezone via `Intl.DateTimeFormat`; auto-detects system language via `navigator.language`; surfaces 3 tactile starter sparks; selecting spark starts session (<30s value).
- **F11**: Theme tokens definition from `@rakazo/ui-tokens`; zero hardcoded product hex colors; zero arbitrary Tailwind brackets `[var(--...)]`; accessible focus rings (`focus-visible:ring-ring`); status indicators use semantic tokens (`text-destructive`, `text-muted-foreground`).
- **F12**: Living Canvas pane renders with `data-testid="side-panel"`; Mission Stream renders with `data-testid="mission-stream-pane"` and `data-testid="transcript"`; preserves critical test IDs (`computer-preview`, `dual-surface-divider`, `dual-surface-header-controls`); progressive disclosure on artifact creation; tab switching between Files, Sandbox, Browser.
### Tier 2: Boundary & Corner Cases (60 Tests: 5 Tests × 12 Features)
- **F1**: Empty audio buffer (`Int16Array(0)`) handled with RMS 0 and no NaN errors; pure silence buffer maintains energy 0 and triggers silence cutoff without hang; clipping values clamped to Int16 limits (-32768 to 32767); out-of-order timestamp detection and jitter buffer tolerance; network drop triggers client reconnect and state preservation.
- **F2**: Microphone permission denied handled with graceful error state; interruption when audio playback is already idle is safe no-op; micro-burst transients (<50ms noise pop) filtered by VAD debounce; rapid alternating speaker turns (ping-pong barge-in); concurrent interrupt and incoming playback chunk race condition resolves in favor of user interrupt.
- **F3**: Single-word answers streamed without minimum character delay stalling; code-only block output sanitized without synthetic audio artifacts; emoji-only message stream strips emojis and does not stall TTS engine; micro-fragment token arrivals buffered into complete words; unexpected LLM stream termination cleanly finalizes active clause.
- **F4**: Rapid consecutive sound cue triggers (<10ms) queue smoothly without audio glitches; muted/silent mode suppresses oscillator output while executing state transition; AudioContext suspended state auto-resumes on first user gesture; unknown sound cue name falls back gracefully without throwing exceptions; cross-platform volume normalization preserves peak headroom.
- **F5**: Rapid typing burst (10 rapid inputs within 100ms) enqueues all 10 without dropping; bounded queue size enforces max pending limit with graceful error; network failure while message in flight triggers failed state with retry option without losing text; empty/whitespace-only messages rejected before enqueueing; duplicate messages submitted in rapid succession deduplicated if flagged.
- **F6**: Race condition: tool finishes at the exact moment abort signal fires resolves cleanly; tool execution timeout triggers automated mid-step cancellation with `timeout` reason; multiple rapid contradictory steering messages resolves to most recent intent; tool that throws exception upon abort caught gracefully without crashing runtime; external system side-effect fence prevents state pollution on aborted tools.
- **F7**: Synthesizing empty or meaningless input results in clean no-op; `advance()` called on an empty queue or completed queue is safe no-op; failed step status prevents automatic advance unless retry policy allows; step status state machine strictly validates allowable transitions; queue serialization and deserialization preserves complete step fidelity.
- **F8**: First execution (`prevOutputHash === ''`) with `"on_change"` triggers initial notification; policy `"on_failure"` triggers notification only when routine status is `"failed"`; whitespace/formatting variations evaluated cleanly against normalized hash; high frequency routine executions do not spam notifications if output remains constant; routine execution failure self-heals without crashing scheduler.
- **F9**: Empty steps list renders cleanly without error or broken layout; extreme step count (50+ tool calls) remains folded by default without layout overflow; failed step renders clean status badge without noisy error stacks unless expanded; absence of chrome bloat (removal of animated tracks, live timers `· 1:24`, uppercase callouts `AUFGABE`); rapid folding/unfolding toggles do not oscillate or corrupt DOM state.
- **F10**: Missing or invalid timezone fallback safely resolves to UTC without throwing error; missing or invalid locale fallback safely resolves to `en`; re-entering onboarding while workspace already exists redirects to active space; starter spark with special characters sanitized safely; offline onboarding gracefully saves initial draft locally without blocking UI.
- **F11**: Semantic contrast in monochrome mode preserves readable text tokens; disabled component states apply semantic opacity `disabled:opacity-50` rather than arbitrary grey; high contrast mode support: borders preserve visibility via semantic `border-border`; dynamic theme switching updates token class mappings deterministically; interactive hover states use semantic hover tokens (`hover:bg-accent`).
- **F12**: Closing Living Canvas collapses aside pane without unmounting workspace state; rapid switching between canvas tabs maintains individual surface state; resizing viewport below `md` breakpoint gracefully transitions Dual-Surface to responsive layout; expanding viewport back above `md` restores Dual-Surface split without reset; concurrent artifact creation from multiple sub-agents surfaces items progressively without flicker.
### Tier 3: Cross-Feature Interactions (12 Tests: Pairwise Coverage)
- **Pair 1**: Voice barge-in interrupt while action queue is actively running step (aborts active step, pauses execution, pivots queue).
- **Pair 2**: Optimistic queue burst while streaming audio TTS is playing (synthesizes follow-ups without audio dropout).
- **Pair 3**: Mid-step tool cancellation triggered via voice interruption event (voice audio energy spike propagates to in-flight tool abort controller).
- **Pair 4**: Quiet background routine fires during active voice session (audio ducking remains unaffected; output unchanged suppresses chat/audio notification).
- **Pair 5**: 30-second rapid onboarding directly initiates full-duplex voice stage (first-time user goes from 1-click bootstrap straight to hands-free voice dialogue).
- **Pair 6**: Action queue synthesis dynamically surfaces Living Canvas for artifact inspection (multi-step queue opens canvas on artifact creation).
- **Pair 7**: Tactile folding step reflects mid-step tool cancellation without UI glitch (shows `[cancelled]` step folded cleanly).
- **Pair 8**: Voice streaming client reconnects after temporary network drop while action queue advances to next step.
- **Pair 9**: Audio earcon triggers on optimistic queue drain and background delta detection (`interact` on drain, `attention` on delta).
- **Pair 10**: Living Canvas orientation swap while action queue runs background steps (DOM flex order reverses while steps advance uninterrupted).
- **Pair 11**: Voice interrupt resets incremental utterance clause pipelining (discards queued clauses immediately).
- **Pair 12**: Delta routine evaluation suppresses notification while user is typing in optimistic composer (zero disruption to active composer focus).
### Tier 4: Real-World Application Scenarios (7 Comprehensive Workflows)
- **Scenario 1: Ambient Hands-Free Meeting & Live Action Queue**: User dictates three multi-step action items in voice mode -> VAD frames audio -> parsed into Dynamic Action Queue -> steps advance deterministically -> earcon signals completion.
- **Scenario 2: Conversational Barge-In & Mid-Step Tool Cancellation**: Agent executing long command -> user speaks barge-in redirect -> playback instantly muted -> in-flight tool aborted -> queue pivoted to new intent.
- **Scenario 3: High-Frequency Multi-Prompt Burst & Optimistic Ordering**: User types 3 follow-up messages rapidly during elevated latency -> optimistic queue renders all 3 immediately with temporary IDs -> requests drain in order -> zero UI freeze.
- **Scenario 4: Quiet Background Routine with Delta Detection & No Nagging**: Scheduled routine runs periodic cycles -> hash unchanged -> zero notifications -> on cycle with detected anomaly, delta triggers notification -> user alerted only when critical.
- **Scenario 5: Rapid 30-Second Zero-Questionnaire Onboarding to Live Execution**: New user lands -> system auto-detects timezone and locale -> user clicks starter spark "Research competitor architecture" -> workspace boots instantly -> agent begins execution within 30 seconds.
- **Scenario 6: Tactile Dual-Surface Progressive Disclosure & Step Folding Workflow**: User asks agent to inspect repo -> agent executes 12 tool steps -> steps fold into clean `# steps` button -> agent writes file -> Living Canvas smoothly unfolds without modal occlusion.
- **Scenario 7: Network Loss & Reconnection Resilience during Full-Duplex Voice Session**: Voice stream active -> network packet loss occurs -> streaming client detects connection drop -> buffers outgoing frames -> reconnects -> flushes buffer and resumes conversation without data loss.
---
## 5. Artifact Index & Directory Layout
```
apps/web/src/tests/
├── e2e-voice-first-foundation.test.ts # 40 tests (Features F1-F4, Tiers 1-2)
├── e2e-autonomous-agency-queues.test.ts # 40 tests (Features F5-F8, Tiers 1-2)
├── e2e-minimalist-tactile-ux.test.tsx # 40 tests (Features F9-F12, Tiers 1-2)
├── e2e-cross-feature-workflows.test.ts # 19 tests (Tier 3 Pairwise & Tier 4 Scenarios)
└── fixtures/
├── voice-contracts.ts # F1-F4 contracts & reference VAD/framing models
├── agency-contracts.ts # F5-F8 contracts, Action Queue & Routine policy models
└── ux-contracts.ts # F9-F12 contracts, environment detection & starter sparks
```

123
TEST_READY.md Normal file
View File

@ -0,0 +1,123 @@
# TEST_READY: evOo Voice-First Autonomous Agent Operating System
## Status
- **Test Suite Status**: READY
- **Author**: `test_writer_r2_1` (Dual-Track E2E Testing Specialist)
- **Timestamp**: 2026-10-12T13:05:00Z
- **Target Milestones**: M1, M2, M3, M4, M5 (Features F1–F12 across Requirements R1, R2, R3)
- **Test Framework**: Vitest 4.1.11 with `// @vitest-environment jsdom` & Pure React 19 harnesses
---
## 1. Authored Test Suites Summary
| # | Test File Path | Scope & Feature Focus | Tiers Covered | Total Test Cases | Passing Baseline |
|---|----------------|-----------------------|:-------------:|:----------------:|:----------------:|
| 1 | `apps/web/src/tests/e2e-voice-first-foundation.test.ts` | Streaming Audio Wire (<120ms framing, PCM16, VAD), Full-Duplex Audio & Barge-In Interruption, Incremental Utterance Streamer (<300ms TTFS, invite strip), Tactile Monochrome Earcons | Tier 1, Tier 2 (F1–F4) | 40 | ✅ 40 / 40 Passed |
| 2 | `apps/web/src/tests/e2e-autonomous-agency-queues.test.ts` | Optimistic Input Queue (Zero UI Freeze, unblocked stop), Mid-Step Tool Cancellation & Active Steering, Dynamic Action Queue State Machine, Quiet Background Routines & Delta Evaluation (`on_change`) | Tier 1, Tier 2 (F5–F8) | 40 | ✅ 40 / 40 Passed |
| 3 | `apps/web/src/tests/e2e-minimalist-tactile-ux.test.tsx` | Tactile Minimalist Step Execution (strip JobProgressCard bloat, folding `# step`), 30-Second Rapid Onboarding (zero questionnaires, 3 action sparks), Monochrome Token Hygiene (@rakazo/ui-tokens), Living Canvas Progressive Disclosure | Tier 1, Tier 2 (F9–F12) | 40 | ✅ 40 / 40 Passed |
| 4 | `apps/web/src/tests/e2e-cross-feature-workflows.test.ts` | 12 Pairwise Cross-Feature Interactions & 7 Realistic End-to-End Workflows (Hands-free meeting, voice barge-in tool abort, high-frequency prompt burst, quiet background routines, 30s onboarding, progressive disclosure, voice network resilience) | Tier 3, Tier 4 | 19 | ✅ 19 / 19 Passed |
| **Total** | **4 Comprehensive Test Suites + 3 Fixtures** | **Full Feature Inventory (F1 to F12) Coverage** | **Tiers 1–4** | **139 tests** | **✅ 139 / 139 (100% Pass)** |
---
## 2. Requirement & Feature Coverage Matrix
| Feature | Source Requirement | Primary Test File | Tiers | Pass / Fail |
|---------|-------------------|-------------------|:-----:|:-----------:|
| **F1: Streaming Audio Wire & Low-Latency VAD** | ORIGINAL_REQUEST §R1 | `e2e-voice-first-foundation.test.ts` | T1, T2, T3, T4 | ✅ PASS (10/10) |
| **F2: Full-Duplex Audio & Barge-In Interruption** | ORIGINAL_REQUEST §R1 | `e2e-voice-first-foundation.test.ts` | T1, T2, T3, T4 | ✅ PASS (10/10) |
| **F3: Incremental Utterance Streamer & Invite Strip** | ORIGINAL_REQUEST §R1 | `e2e-voice-first-foundation.test.ts` | T1, T2, T3, T4 | ✅ PASS (10/10) |
| **F4: Cross-Platform Earcons & Mobile Audio** | ORIGINAL_REQUEST §R1 | `e2e-voice-first-foundation.test.ts` | T1, T2, T3, T4 | ✅ PASS (10/10) |
| **F5: Optimistic Input Queue & Zero UI Freeze** | ORIGINAL_REQUEST §R2 | `e2e-autonomous-agency-queues.test.ts` | T1, T2, T3, T4 | ✅ PASS (10/10) |
| **F6: Mid-Step Tool Cancellation & Active Steering** | ORIGINAL_REQUEST §R2 | `e2e-autonomous-agency-queues.test.ts` | T1, T2, T3, T4 | ✅ PASS (10/10) |
| **F7: Outcome-Driven Action Queue State Machine** | ORIGINAL_REQUEST §R2 | `e2e-autonomous-agency-queues.test.ts` | T1, T2, T3, T4 | ✅ PASS (10/10) |
| **F8: Quiet Background Routines & Delta Evaluation** | ORIGINAL_REQUEST §R2 | `e2e-autonomous-agency-queues.test.ts` | T1, T2, T3, T4 | ✅ PASS (10/10) |
| **F9: Tactile Minimalist Step Execution** | ORIGINAL_REQUEST §R3 | `e2e-minimalist-tactile-ux.test.tsx` | T1, T2, T3, T4 | ✅ PASS (10/10) |
| **F10: 30-Second Rapid Onboarding** | ORIGINAL_REQUEST §R3 | `e2e-minimalist-tactile-ux.test.tsx` | T1, T2, T3, T4 | ✅ PASS (10/10) |
| **F11: Monochrome Semantic Token Hygiene** | ORIGINAL_REQUEST §R3 | `e2e-minimalist-tactile-ux.test.tsx` | T1, T2, T3, T4 | ✅ PASS (10/10) |
| **F12: Living Canvas Progressive Disclosure** | ORIGINAL_REQUEST §R3 | `e2e-minimalist-tactile-ux.test.tsx` | T1, T2, T3, T4 | ✅ PASS (10/10) |
| **T3: Pairwise Cross-Feature Interactions** | Cross-Module Interactions | `e2e-cross-feature-workflows.test.ts` | Tier 3 | ✅ PASS (12/12) |
| **T4: Real-World Application Scenarios** | Operational Workflows | `e2e-cross-feature-workflows.test.ts` | Tier 4 | ✅ PASS (7/7) |
---
## 3. Tier Breakdown & Test Cases
### Tier 1: Feature Coverage (60 Tests)
- **F1**: 80ms PCM16 frame chunking (1280 samples at 16kHz); VAD normalized RMS energy; <120ms cutoff threshold eliminating 850ms silence floor; monotonic timestamp increment; streaming client transcript & playback chunk callbacks.
- **F2**: Continuous microphone capture with acoustic echo cancellation (`echoCancellation: true`); user speech energy detection while bot speaking triggers barge-in; immediate playback audio mute; backend abort propagation (`abort('barge_in')`); state transition from speaking to listening.
- **F3**: Incremental clause segmentation in 4–6 word chunks; time-to-first-sound pipelining (<300ms TTFS target); stripping robotic invite prompts ("What would you like to do next?"); code fence description transformation per `speakable()`; natural punctuation boundary clause splits.
- **F4**: Core tactile earcons (`listening-start`, `listening-stop`, `speaking-start`, `speaking-done`); Web Audio procedural oscillator profiles; master audio bus ducking (0.28 ducking ratio); cross-platform mobile earcon mapping; state machine audio cue triggers (`interact`, `attention`).
- **F5**: Submitting message while another in-flight enqueues without UI freeze; temporary optimistic ID assignment (`temp-...`) and timestamp; composer text clearing immediately; Stop button unblocked and clickable; sequential FIFO drainage.
- **F6**: Ingesting steering during active tool call dispatches mid-step cancellation signal; tool abort controller receives `abort('steering_redirection')`; aborted tool output discarded from history; agent pivots immediately to steering intent; steering message marked claimed.
- **F7**: `DynamicActionQueue` initialization; multi-step prompt synthesis into `ActionQueueStep` items; queue advancement starts pending step with `startedAt`; completing step sets `completedAt` and advances; `cancelActive()` marks step cancelled.
- **F8**: `RoutineNotificationPolicy` with `"on_change"` evaluates output delta hash; identical output hash suppresses notification (quiet run); distinct output hash triggers notification; policy `"always"` notifies unconditionally; policy `"never"` suppresses all notifications.
- **F9**: Minimalist folding step button renders with `data-testid="run-steps"`; displays clean `# step` counter without progress track bloat; clicking fold button toggles `aria-expanded`; step tool name and summary rendered when expanded; keyboard Enter/Space toggles fold.
- **F10**: 1-click bootstrap creates initial agent workspace without 11-step questionnaire; auto-detects system timezone via `Intl.DateTimeFormat`; auto-detects system language via `navigator.language`; surfaces 3 tactile starter sparks; selecting spark starts session (<30s value).
- **F11**: Theme tokens definition from `@rakazo/ui-tokens`; zero hardcoded product hex colors; zero arbitrary Tailwind brackets `[var(--...)]`; accessible focus rings (`focus-visible:ring-ring`); status indicators use semantic tokens (`text-destructive`, `text-muted-foreground`).
- **F12**: Living Canvas pane renders with `data-testid="side-panel"`; Mission Stream renders with `data-testid="mission-stream-pane"` and `data-testid="transcript"`; preserves critical test IDs (`computer-preview`, `dual-surface-divider`, `dual-surface-header-controls`); progressive disclosure on artifact creation; tab switching between Files, Sandbox, Browser.
### Tier 2: Boundary & Corner Cases (60 Tests)
- **F1**: Empty audio buffer (`Int16Array(0)`) handled with RMS 0 and no NaN errors; pure silence buffer maintains energy 0 and triggers silence cutoff without hang; clipping values clamped to Int16 limits (-32768 to 32767); out-of-order timestamp detection and jitter buffer tolerance; network drop triggers client reconnect and state preservation.
- **F2**: Microphone permission denied handled with graceful error state; interruption when audio playback is already idle is safe no-op; micro-burst transients (<50ms noise pop) filtered by VAD debounce; rapid alternating speaker turns (ping-pong barge-in); concurrent interrupt and incoming playback chunk race condition resolves in favor of user interrupt.
- **F3**: Single-word answers streamed without minimum character delay stalling; code-only block output sanitized without synthetic audio artifacts; emoji-only message stream strips emojis and does not stall TTS engine; micro-fragment token arrivals buffered into complete words; unexpected LLM stream termination cleanly finalizes active clause.
- **F4**: Rapid consecutive sound cue triggers (<10ms) queue smoothly without audio glitches; muted/silent mode suppresses oscillator output while executing state transition; AudioContext suspended state auto-resumes on first user gesture; unknown sound cue name falls back gracefully without throwing exceptions; cross-platform volume normalization preserves peak headroom.
- **F5**: Rapid typing burst (10 rapid inputs within 100ms) enqueues all 10 without dropping; bounded queue size enforces max pending limit with graceful error; network failure while message in flight triggers failed state with retry option without losing text; empty/whitespace-only messages rejected before enqueueing; duplicate messages submitted in rapid succession deduplicated if flagged.
- **F6**: Race condition: tool finishes at the exact moment abort signal fires resolves cleanly; tool execution timeout triggers automated mid-step cancellation with `timeout` reason; multiple rapid contradictory steering messages resolves to most recent intent; tool that throws exception upon abort caught gracefully without crashing runtime; external system side-effect fence prevents state pollution on aborted tools.
- **F7**: Synthesizing empty or meaningless input results in clean no-op; `advance()` called on an empty queue or completed queue is safe no-op; failed step status prevents automatic advance unless retry policy allows; step status state machine strictly validates allowable transitions; queue serialization and deserialization preserves complete step fidelity.
- **F8**: First execution (`prevOutputHash === ''`) with `"on_change"` triggers initial notification; policy `"on_failure"` triggers notification only when routine status is `"failed"`; whitespace/formatting variations evaluated cleanly against normalized hash; high frequency routine executions do not spam notifications if output remains constant; routine execution failure self-heals without crashing scheduler.
- **F9**: Empty steps list renders cleanly without error or broken layout; extreme step count (50+ tool calls) remains folded by default without layout overflow; failed step renders clean status badge without noisy error stacks unless expanded; absence of chrome bloat (removal of animated tracks, live timers `· 1:24`, uppercase callouts `AUFGABE`); rapid folding/unfolding toggles do not oscillate or corrupt DOM state.
- **F10**: Missing or invalid timezone fallback safely resolves to UTC without throwing error; missing or invalid locale fallback safely resolves to `en`; re-entering onboarding while workspace already exists redirects to active space; starter spark with special characters sanitized safely; offline onboarding gracefully saves initial draft locally without blocking UI.
- **F11**: Semantic contrast in monochrome mode preserves readable text tokens; disabled component states apply semantic opacity `disabled:opacity-50` rather than arbitrary grey; high contrast mode support: borders preserve visibility via semantic `border-border`; dynamic theme switching updates token class mappings deterministically; interactive hover states use semantic hover tokens (`hover:bg-accent`).
- **F12**: Closing Living Canvas collapses aside pane without unmounting workspace state; rapid switching between canvas tabs maintains individual surface state; resizing viewport below `md` breakpoint gracefully transitions Dual-Surface to responsive layout; expanding viewport back above `md` restores Dual-Surface split without reset; concurrent artifact creation from multiple sub-agents surfaces items progressively without flicker.
### Tier 3: Cross-Feature Interactions (12 Tests)
- Pair 1: Voice barge-in interrupt while action queue is actively running step.
- Pair 2: Optimistic queue burst while streaming audio TTS is playing.
- Pair 3: Mid-step tool cancellation triggered via voice interruption event.
- Pair 4: Quiet background routine fires during active voice session.
- Pair 5: 30-second rapid onboarding directly initiates full-duplex voice stage.
- Pair 6: Action queue synthesis dynamically surfaces Living Canvas for artifact inspection.
- Pair 7: Tactile folding step reflects mid-step tool cancellation without UI glitch.
- Pair 8: Voice streaming client reconnects after temporary network drop while action queue advances.
- Pair 9: Audio earcon triggers on optimistic queue drain and background delta detection.
- Pair 10: Living Canvas orientation swap while action queue runs background steps.
- Pair 11: Voice interrupt resets incremental utterance clause pipelining.
- Pair 12: Delta routine evaluation suppresses notification while user is typing in optimistic composer.
### Tier 4: Real-World Application Scenarios (7 Comprehensive Workflows)
- Scenario 1: Ambient Hands-Free Meeting & Live Action Queue.
- Scenario 2: Conversational Barge-In & Mid-Step Tool Cancellation.
- Scenario 3: High-Frequency Multi-Prompt Burst & Optimistic Ordering.
- Scenario 4: Quiet Background Routine with Delta Detection & No Nagging.
- Scenario 5: Rapid 30-Second Zero-Questionnaire Onboarding to Live Execution.
- Scenario 6: Tactile Dual-Surface Progressive Disclosure & Step Folding Workflow.
- Scenario 7: Network Loss & Reconnection Resilience during Full-Duplex Voice Session.
---
## 4. Verification Method
Run the following command in the repository root to execute the complete E2E test suite:
```bash
pnpm vitest run apps/web/src/tests/e2e-voice-first-foundation.test.ts apps/web/src/tests/e2e-autonomous-agency-queues.test.ts apps/web/src/tests/e2e-minimalist-tactile-ux.test.tsx apps/web/src/tests/e2e-cross-feature-workflows.test.ts
```
Output:
```
Test Files 4 passed (4)
Tests 139 passed (139)
Duration ~3.0s
```
TypeScript Check:
```bash
pnpm --filter @rakazo/web check
```
Biome Lint & Format Check:
```bash
pnpm biome check apps/web/src/tests/e2e-*.ts* apps/web/src/tests/fixtures/*.ts
```

View File

@ -26,9 +26,13 @@ function savedSkill(name: string, source = "user") {
}
function setup(rows: ReturnType<typeof savedSkill>[] = []) {
type Where = { id?: string; spaceId: string; userId: string; source?: string };
type Where = { id?: string; spaceId?: string; userId: string; source?: string; name?: unknown };
const matches = (row: ReturnType<typeof savedSkill>, where: Where) =>
Object.entries(where).every(([key, value]) => row[key as keyof typeof row] === value);
Object.entries(where).every(([key, value]) =>
key === "name" && typeof value === "object" && value && "equals" in value
? row.name.toLowerCase() === String(value.equals).toLowerCase()
: key === "NOT" || row[key as keyof typeof row] === value,
);
const agentSkill = {
findMany: vi.fn(async ({ where }: { where: Where }) =>
rows.filter((row) => matches(row, where)),
@ -55,15 +59,19 @@ function setup(rows: ReturnType<typeof savedSkill>[] = []) {
};
}
/** Only the entries competing for the name "Interrogate"; other builtins are irrelevant here. */
const interrogate = <T extends { name: string }>(skills: T[]) =>
skills.filter((skill) => skill.name.trim().toLowerCase() === "interrogate");
describe("built-in skill precedence in the API", () => {
it.each(["interrogate", " Interrogate "])(
"keeps %j listed, readable and mutable",
async (name) => {
const { service } = setup([savedSkill(name)]);
await expect(service.list(actor)).resolves.toEqual([
expect(interrogate(await service.list(actor))).toEqual([
expect.objectContaining({ id: "saved-1", name, readOnly: false }),
]);
await expect(service.listWithContent(actor)).resolves.toEqual([
expect(interrogate(await service.listWithContent(actor))).toEqual([
expect.objectContaining({ id: "saved-1", content: expect.stringContaining("Saved steps") }),
]);
await expect(service.get(actor, { name: " INTERROGATE " })).resolves.toMatchObject({
@ -97,7 +105,23 @@ describe("built-in skill precedence in the API", () => {
expect(agentSkill.deleteMany).not.toHaveBeenCalled();
});
it.each([{ spaceId: "other-space" }, { userId: "other-user" }])(
it("lists the user's skills from other spaces too, the current space winning a clash", async () => {
const { service } = setup([
{ ...savedSkill("Launch"), id: "home-1", spaceId: "home" },
{ ...savedSkill("launch"), id: "here-1" },
{ ...savedSkill("Weekly review"), id: "home-2", spaceId: "home" },
]);
const own = (await service.list(actor)).filter((skill) => skill.source === "user");
expect(own.map((skill) => skill.id).sort()).toEqual(["here-1", "home-2"]);
await expect(service.get(actor, { name: "Weekly review" })).resolves.toMatchObject({
id: "home-2",
});
await expect(
service.create(actor, { name: "weekly REVIEW", description: "x" }),
).rejects.toThrow("already exists");
});
it.each([{ userId: "other-user" }])(
"does not let foreign skills shadow the builtin: %j",
async (foreignOwner) => {
const { service } = setup([{ ...savedSkill(" Interrogate "), ...foreignOwner }]);
@ -105,7 +129,7 @@ describe("built-in skill precedence in the API", () => {
id: "builtin:Interrogate",
readOnly: true,
});
await expect(service.list(actor)).resolves.toEqual([
expect(interrogate(await service.list(actor))).toEqual([
expect.objectContaining({ id: "builtin:Interrogate" }),
]);
await expect(service.get(actor, { skillId: "saved-1" })).rejects.toThrow();

View File

@ -7,6 +7,7 @@ import {
isSkillReadOnly,
mergeBuiltinSkills,
parseSkillMd,
preferSpaceSkills,
type SkillSource,
} from "@rakazo/core";
import { IsolationError, type PrismaClient } from "@rakazo/db";
@ -121,11 +122,7 @@ export function resolveSkillContent(input: {
export function createAgentSkillsService(prisma: PrismaClient) {
async function owned(actor: Actor, skillId: string) {
const row = await prisma.agentSkill.findFirst({
where: {
id: skillId,
spaceId: actor.spaceId,
userId: actor.userId,
},
where: { id: skillId, userId: actor.userId },
});
if (!row) throw new IsolationError();
return row;
@ -134,20 +131,24 @@ export function createAgentSkillsService(prisma: PrismaClient) {
return {
async list(actor: Actor): Promise<Omit<AgentSkill, "content">[]> {
const rows = await prisma.agentSkill.findMany({
where: { spaceId: actor.spaceId, userId: actor.userId },
where: { userId: actor.userId },
orderBy: [{ name: "asc" }, { id: "asc" }],
});
return mergeBuiltinSkills(builtinCatalog(), rows.map(mapAgentSkill)).map(
({ content: _content, ...entry }) => entry,
);
return mergeBuiltinSkills(
builtinCatalog(),
preferSpaceSkills(rows, actor.spaceId).map(mapAgentSkill),
).map(({ content: _content, ...entry }) => entry);
},
async listWithContent(actor: Actor): Promise<AgentSkill[]> {
const rows = await prisma.agentSkill.findMany({
where: { spaceId: actor.spaceId, userId: actor.userId },
where: { userId: actor.userId },
orderBy: [{ name: "asc" }, { id: "asc" }],
});
return mergeBuiltinSkills(builtinCatalog(), rows.map(mapAgentSkill));
return mergeBuiltinSkills(
builtinCatalog(),
preferSpaceSkills(rows, actor.spaceId).map(mapAgentSkill),
);
},
async get(actor: Actor, input: { skillId?: string; name?: string }): Promise<AgentSkill> {
@ -161,13 +162,10 @@ export function createAgentSkillsService(prisma: PrismaClient) {
}
const name = input.name?.trim() ?? "";
const rows = await prisma.agentSkill.findMany({
where: {
spaceId: actor.spaceId,
userId: actor.userId,
},
where: { userId: actor.userId },
orderBy: [{ name: "asc" }, { id: "asc" }],
});
const row = findSkillByName(rows, name);
const row = findSkillByName(preferSpaceSkills(rows, actor.spaceId), name);
if (row) return mapAgentSkill(row);
const builtin = findSkillByName(builtinCatalog(), name);
if (!builtin) throw new IsolationError();
@ -181,7 +179,6 @@ export function createAgentSkillsService(prisma: PrismaClient) {
const resolved = resolveSkillContent(input);
const clash = await prisma.agentSkill.findFirst({
where: {
spaceId: actor.spaceId,
userId: actor.userId,
name: { equals: resolved.name, mode: "insensitive" },
},
@ -234,7 +231,6 @@ export function createAgentSkillsService(prisma: PrismaClient) {
if (!findSkillByName([existing], resolved.name)) {
const clash = await prisma.agentSkill.findFirst({
where: {
spaceId: actor.spaceId,
userId: actor.userId,
name: { equals: resolved.name, mode: "insensitive" },
NOT: { id: existing.id },
@ -250,12 +246,7 @@ export function createAgentSkillsService(prisma: PrismaClient) {
// Mutate only owner-scoped user rows (never builtin/plugin), even if source was tampered.
try {
const updated = await prisma.agentSkill.updateMany({
where: {
id: existing.id,
spaceId: actor.spaceId,
userId: actor.userId,
source: "user",
},
where: { id: existing.id, userId: actor.userId, source: "user" },
data: {
name: resolved.name,
description: resolved.description,
@ -275,11 +266,7 @@ export function createAgentSkillsService(prisma: PrismaClient) {
throw error;
}
const row = await prisma.agentSkill.findFirst({
where: {
id: existing.id,
spaceId: actor.spaceId,
userId: actor.userId,
},
where: { id: existing.id, userId: actor.userId },
});
if (!row) throw new IsolationError();
return mapAgentSkill(row);
@ -291,12 +278,7 @@ export function createAgentSkillsService(prisma: PrismaClient) {
throw new ORPCError("BAD_REQUEST", { message: "Builtin and plugin skills are read-only." });
}
const deleted = await prisma.agentSkill.deleteMany({
where: {
id: existing.id,
spaceId: actor.spaceId,
userId: actor.userId,
source: "user",
},
where: { id: existing.id, userId: actor.userId, source: "user" },
});
if (deleted.count !== 1) throw new IsolationError();
return { ok: true };

View File

@ -20,8 +20,8 @@ function setup() {
secret: { findMany: vi.fn(async () => []) },
bot: { findMany: vi.fn(async () => []) },
spaceVoicePreference: {
findFirst: vi.fn(async () => ({ credential: { provider: "openai" }, voiceId: "alloy" })),
findMany: vi.fn(async () => [{ credential: { provider: "openai" } }]),
findFirst: vi.fn(async () => ({ credential: { provider: "kokoro" }, voiceId: "af_heart" })),
findMany: vi.fn(async () => [{ credential: { provider: "kokoro" } }]),
},
spaceMemoryConfig: {
findUnique: vi.fn(async () => null),
@ -104,12 +104,12 @@ describe("consent grants", () => {
it("discloses only the selected voice provider before a voice action", async () => {
const { deps, actor } = setup();
vi.mocked(deps.prisma.spaceVoicePreference.findMany).mockResolvedValue([
{ credential: { provider: "openai" } },
{ credential: { provider: "elevenlabs" } },
{ credential: { provider: "kokoro" } },
{ credential: { provider: "scripted" } },
] as never);
const status = await aiConsentStatus(deps, actor, { uses: ["voice"] });
expect(status.recipients).toHaveLength(1);
expect(status.recipients[0]?.name).toBe("OpenAI");
expect(status.recipients[0]?.name).toBe("Local voice");
expect(deps.prisma.spaceVoicePreference.findMany).not.toHaveBeenCalled();
});
it("discloses memory provider details to space owners", async () => {
@ -155,7 +155,12 @@ describe("consent grants", () => {
]);
expect(status.recipients[0]?.detail).toBe("Model: sdxl.");
expect(JSON.stringify(status)).not.toContain("comfy-key");
expect((await aiConsentStatus(deps, actor, { uses: ["voice"] })).recipients).toHaveLength(1);
// The local voice cannot transcribe, so dictation also goes to the transcription server.
expect(
(await aiConsentStatus(deps, actor, { uses: ["voice"] })).recipients.map(
(recipient) => recipient.name,
),
).toEqual(["Local voice", "Transcription (http://127.0.0.1:8080)"]);
});
it("discloses the transcription server for dictation when the voice provider cannot transcribe", async () => {
const { deps, actor } = setup();

View File

@ -1,5 +1,5 @@
import { randomUUID } from "node:crypto";
import { ORPCError, onError } from "@orpc/server";
import { ORPCError, onError, ValidationError } from "@orpc/server";
import { RPCHandler } from "@orpc/server/fetch";
import type {
AgentRuntime,
@ -29,12 +29,14 @@ import {
createMediaRuntime,
createMessagingContextLoader,
createMessagingTeamChatSender,
createPostgresReconciliationLeadership,
createProfileConsolidationScheduler,
createRunExecutor,
createRunSandbox,
createRunSecretWriter,
createWebProvider,
destroyBot,
destroySpaceSandboxes,
EmailEmulator,
EncryptedSecretStore,
ExpoPushSender,
@ -83,6 +85,7 @@ import {
parsePositiveInteger,
provisionMessagingIdentity,
requireMembership,
spaceIdsForSandboxTeardownAfterAccountDelete,
} from "@rakazo/db";
import type { Logger } from "@rakazo/logging";
import {
@ -358,30 +361,45 @@ export async function createApp(
"https://172.*:*",
"http://*.local:*",
"https://*.local:*",
...(process.env.EXTRA_TRUSTED_ORIGINS?.split(",").map((s) => s.trim()) ?? []),
...(env.extraTrustedOrigins ?? []),
],
beforeDeleteUser: async (userId) => {
const bots = await prisma.bot.findMany({
where: { userId },
select: { id: true, userId: true, spaceId: true, name: true, archivedAt: true },
});
const lifecycleDeps = {
prisma,
sandbox,
home,
jobs,
artifacts,
dataDir: env.dataDir,
};
const deleteContext = {
operationId: `account-delete:${userId}`,
traceId: `account-delete:${userId}`,
userId,
signal: new AbortController().signal,
};
await Promise.all(
bots.map((bot) =>
destroyBot(
{ prisma, sandbox, home, jobs, artifacts, dataDir: env.dataDir },
lifecycleDeps,
bot,
{
operationId: `account-delete:${userId}`,
traceId: `account-delete:${userId}`,
spaceId: bot.spaceId,
userId,
botId: bot.id,
signal: new AbortController().signal,
},
{ ...deleteContext, spaceId: bot.spaceId, botId: bot.id },
{ deleteMemories: true },
),
),
);
const candidateSpaceIds = [...new Set(bots.map((bot) => bot.spaceId))];
const spaceIds = await spaceIdsForSandboxTeardownAfterAccountDelete(prisma, {
userId,
spaceIds: candidateSpaceIds,
});
for (const spaceId of spaceIds) {
await destroySpaceSandboxes(lifecycleDeps, { spaceId }, { ...deleteContext, spaceId });
}
await removePiUserSessions(env.dataDir, userId);
},
});
@ -457,19 +475,23 @@ export async function createApp(
cloudAgent,
media,
});
let graphileWorkerHost: GraphileJobWorkerHost | undefined;
if (inMemoryJobs) {
await inMemoryJobs.start(jobHandlers);
} else if (jobPool) {
const graphileWorkerHost = new GraphileJobWorkerHost(jobPool, {
concurrency: 4,
graphileWorkerHost = new GraphileJobWorkerHost(jobPool, {
concurrency: parsePositiveInteger(process.env.GRAPHILE_WORKER_CONCURRENCY, 4),
pollInterval: 500,
});
await graphileWorkerHost.start(jobHandlers);
}
const reconciler = inMemoryJobs
const reconciler =
inMemoryJobs || !prismaOverride
? createJobReconciler({
prisma,
jobs,
events,
leadership: jobPool ? createPostgresReconciliationLeadership(jobPool) : undefined,
reconcileCloudAgents: () => reconcileCloudAgents({ prisma, jobs, cloudAgent }),
reconcileComputerUpdates: () => reconcileComputerUpdates({ prisma, jobs }),
reconcileProfileConsolidation: createProfileConsolidationScheduler({ prisma, jobs }),
@ -595,7 +617,7 @@ export async function createApp(
actor,
signal: c.req.raw.signal,
userAgent: c.req.header("user-agent"),
webOrigin: requestWebOrigin(c.req.header("origin"), env.webOrigin),
webOrigin: requestWebOrigin(c.req.header("origin"), env.webOrigin, env.extraTrustedOrigins),
},
});
if (matched) return c.newResponse(response.body, response);
@ -989,6 +1011,7 @@ export async function createApp(
await teamChatBridge?.stop();
await email?.drain?.();
await reconciler?.stop();
await graphileWorkerHost?.stop();
await jobs.close();
await realtime.close();
await connector.stop();
@ -1004,6 +1027,7 @@ export async function createApp(
function isTrustedOrigin(origin: string, env: AppEnv) {
if (!origin) return true;
if (origin === env.webOrigin || origin === env.apiUrl || origin === env.authUrl) return true;
if (env.extraTrustedOrigins?.includes(origin)) return true;
if (origin.startsWith("rakazo://") || origin.startsWith("exp://")) return true;
try {
const host = new URL(origin).hostname;
@ -1031,7 +1055,18 @@ function sessionHeaders(request: Request) {
* "fetch failed" and keep the host and errno one level down.
*/
export function logUnexpectedRpcError(error: unknown, path: readonly string[]): void {
if (error instanceof ORPCError) return;
const where = `rpc ${path.join("/")} failed`;
if (error instanceof ORPCError) {
// A handler result that breaks its output contract is a server bug, not a chosen error.
if (error.code !== "INTERNAL_SERVER_ERROR" || !(error.cause instanceof ValidationError)) return;
const issues = error.cause.issues.map((issue) => ({
path: (issue.path ?? [])
.map((segment) => (typeof segment === "object" ? segment.key : segment))
.join("."),
problem: issue.message,
}));
getLogger().error(where, error, { issues });
return;
}
getLogger().error(where, error);
}

View File

@ -175,7 +175,7 @@ describe("upload timeouts", () => {
it("gives the server time for large uploads but keeps the header timeout", () => {
const server = createServer(API_SERVER_OPTIONS);
expect(server.requestTimeout).toBe(30 * 60 * 1000);
expect(server.requestTimeout).toBe(2 * 60 * 60 * 1000);
expect(server.headersTimeout).toBe(createServer().headersTimeout);
});

View File

@ -1,8 +1,8 @@
import type { AdapterContext, ArtifactByteRange, ArtifactStore } from "@rakazo/adapter-kit";
import { ArtifactTooLargeError } from "@rakazo/adapter-kit";
import { artifactMaxBytes, storeArtifactStream } from "@rakazo/adapters";
import { storeArtifactStream } from "@rakazo/adapters";
import type { Actor } from "@rakazo/contracts";
import { attachmentKindOf, isAllowedAttachmentMimeType } from "@rakazo/contracts";
import { artifactMaxBytes, attachmentKindOf, isAllowedAttachmentMimeType } from "@rakazo/contracts";
import type { PrismaClient } from "@rakazo/db";
import { createGroupRepos, createRepos, IsolationError, requireMembership } from "@rakazo/db";
import type { Context, Hono } from "hono";
@ -11,11 +11,11 @@ import { cancelBody } from "./http-body.js";
export const ARTIFACT_HTTP_PATH = "/api/artifacts";
/**
* Node's default 5-minute requestTimeout cuts off a 500 MiB video upload on any uplink slower than
* about 14 Mbit/s; 30 minutes fits it at about 2.5 Mbit/s. headersTimeout keeps its default, and
* the per-upload stall timeout stops a slow-drip client from holding a connection that long.
* Node's default 5-minute requestTimeout cuts off a large upload on a slow uplink; 2 hours fits a
* 5 GiB repository ZIP at about 6 Mbit/s. headersTimeout keeps its default, and the per-upload
* stall timeout stops a slow-drip client from holding a connection that long.
*/
export const API_SERVER_OPTIONS = { requestTimeout: 30 * 60 * 1000 };
export const API_SERVER_OPTIONS = { requestTimeout: 2 * 60 * 60 * 1000 };
/** An upload that sends no bytes for this long is aborted and its partial file deleted. */
export const ARTIFACT_UPLOAD_STALL_TIMEOUT_MS = 60_000;

View File

@ -20,6 +20,7 @@ export interface AppEnv {
authSecret: string;
authUrl: string;
webOrigin: string;
extraTrustedOrigins: string[];
privacyPolicyUrl?: string;
apiUrl: string;
apiHost: string;
@ -109,6 +110,10 @@ export function loadEnv(source: NodeJS.ProcessEnv = process.env): AppEnv {
authSecret,
authUrl: source.BETTER_AUTH_URL ?? source.WEB_ORIGIN ?? "http://127.0.0.1:5173",
webOrigin: source.WEB_ORIGIN ?? "http://127.0.0.1:5173",
extraTrustedOrigins: (source.EXTRA_TRUSTED_ORIGINS ?? "")
.split(",")
.map((s) => s.trim())
.filter(Boolean),
privacyPolicyUrl: optional(source.PRIVACY_POLICY_URL),
apiUrl: source.API_URL ?? "http://127.0.0.1:3100",
apiHost: source.API_HOST ?? "127.0.0.1",

View File

@ -213,13 +213,19 @@ export function buildInboxItems(input: {
const botSelect = { id: true, name: true, color: true } as const;
/**
* The other spaces whose items the inbox shows: every space the user belongs to, except spaces
* being deleted.
* The other spaces whose items the inbox shows. The home space's main conversation collects every
* space the user belongs to, except spaces being deleted; a project's main conversation keeps to
* its own project.
*/
async function otherInboxSpaces(
prisma: PrismaClient,
actor: Actor,
): Promise<Map<string, InboxSpace>> {
const current = await prisma.space.findUnique({
where: { id: actor.spaceId },
select: { isDefault: true },
});
if (!current?.isDefault) return new Map();
const memberships = await prisma.spaceMember.findMany({
where: { userId: actor.userId, spaceId: { not: actor.spaceId }, space: { deletingAt: null } },
select: { space: { select: { id: true, name: true } } },

View File

@ -1,4 +1,4 @@
import { ORPCError } from "@orpc/server";
import { ORPCError, ValidationError } from "@orpc/server";
import { createLogger, createTestSink, installLogger } from "@rakazo/logging";
import { afterEach, describe, expect, it } from "vitest";
import { logUnexpectedRpcError } from "./app.js";
@ -20,6 +20,28 @@ describe("logUnexpectedRpcError", () => {
expect(sink.events).toEqual([]);
});
it("names the procedure and the fields when a result breaks its output contract", () => {
const sink = createTestSink();
installLogger(createLogger({ service: "rakazo-api", sinks: [sink] }));
const error = new ORPCError("INTERNAL_SERVER_ERROR", {
message: "Output validation failed",
cause: new ValidationError({
message: "Output validation failed",
issues: [{ message: "Expected string", path: ["items", 0, { key: "title" }] }],
data: { items: [{ title: null, body: "private" }] },
}),
});
logUnexpectedRpcError(error, ["inbox", "list"]);
expect(sink.events).toHaveLength(1);
expect(sink.events[0]).toMatchObject({
message: "rpc inbox/list failed",
issues: [{ path: "items.0.title", problem: "Expected string" }],
});
expect(JSON.stringify(sink.events[0])).not.toContain("private");
});
it("names the procedure and every cause behind an opaque failure", () => {
const sink = createTestSink();
installLogger(createLogger({ service: "rakazo-api", sinks: [sink] }));

View File

@ -1,5 +1,6 @@
import type { MediaCapabilities } from "@rakazo/adapter-kit";
import type { Actor } from "@rakazo/contracts";
import { MediaOperationSchema } from "@rakazo/contracts";
import { describe, expect, it } from "vitest";
import type { MediaRouteDeps } from "./media-jobs.js";
import { getMediaCapabilities } from "./media-jobs.js";
@ -28,7 +29,7 @@ describe("media capabilities", () => {
it("reports nothing available without a media runtime", async () => {
const capabilities = await getMediaCapabilities({} as MediaRouteDeps, actor);
expect(Object.values(capabilities)).toHaveLength(6);
expect(Object.keys(capabilities)).toEqual(MediaOperationSchema.options);
expect(Object.values(capabilities).every((value) => value === false)).toBe(true);
});
});

View File

@ -0,0 +1,82 @@
import path from "node:path";
import { ORPCError } from "@orpc/server";
import type { JobPublisher } from "@rakazo/adapter-kit";
import { mediaLibrarySyncJob } from "@rakazo/adapter-kit";
import { checkMediaLibraryFolder, mediaLibrarySourceView } from "@rakazo/adapters";
import type { Actor, MediaLibrarySourceAddInput } from "@rakazo/contracts";
import type { PrismaClient } from "@rakazo/db";
export interface MediaLibraryRouteDeps {
prisma: PrismaClient;
jobs: JobPublisher;
}
/** Library folders are paths on the server, so only its owner adds them, like host folders. */
function requireDeploymentOwner(actor: Actor) {
if (!actor.isDeploymentOwner) throw new ORPCError("FORBIDDEN");
}
async function ownSource(deps: MediaLibraryRouteDeps, actor: Actor, sourceId: string) {
const source = await deps.prisma.mediaLibrarySource.findFirst({
where: { id: sourceId, userId: actor.userId },
});
if (!source) throw new ORPCError("NOT_FOUND");
return source;
}
export async function listMediaLibrarySources(deps: MediaLibraryRouteDeps, actor: Actor) {
requireDeploymentOwner(actor);
const rows = await deps.prisma.mediaLibrarySource.findMany({
where: { userId: actor.userId },
orderBy: { createdAt: "asc" },
});
return rows.map(mediaLibrarySourceView);
}
export async function addMediaLibrarySource(
deps: MediaLibraryRouteDeps,
actor: Actor,
input: MediaLibrarySourceAddInput,
) {
requireDeploymentOwner(actor);
const problem = await checkMediaLibraryFolder(input.path);
if (problem) throw new ORPCError("BAD_REQUEST", { message: problem });
const folder = path.resolve(input.path);
const existing = await deps.prisma.mediaLibrarySource.findUnique({
where: { userId_path: { userId: actor.userId, path: folder } },
});
if (existing) return mediaLibrarySourceView(existing);
const source = await deps.prisma.mediaLibrarySource.create({
data: { userId: actor.userId, path: folder, status: "syncing" },
});
await deps.jobs.enqueue(mediaLibrarySyncJob(source.id));
return mediaLibrarySourceView(source);
}
export async function removeMediaLibrarySource(
deps: MediaLibraryRouteDeps,
actor: Actor,
sourceId: string,
) {
requireDeploymentOwner(actor);
await ownSource(deps, actor, sourceId);
await deps.prisma.mediaLibrarySource.deleteMany({
where: { id: sourceId, userId: actor.userId },
});
return { ok: true as const };
}
export async function syncMediaLibrarySourceNow(
deps: MediaLibraryRouteDeps,
actor: Actor,
sourceId: string,
) {
requireDeploymentOwner(actor);
await ownSource(deps, actor, sourceId);
const source = await deps.prisma.mediaLibrarySource.update({
where: { id: sourceId },
data: { status: "syncing", error: null },
});
await deps.jobs.enqueue(mediaLibrarySyncJob(sourceId));
return mediaLibrarySourceView(source);
}

View File

@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
closingNote,
focusCard,
focusFactValue,
intakeContext,
intakeStep,
nextIntakeStep,
@ -14,6 +15,13 @@ const plain = (text: string) => text.replace(/\s/g, " ");
const off = { morning: null, evening: null, weekly: null };
describe("onboarding steps", () => {
it("writes focus facts in the user's locale", () => {
expect(focusFactValue("day", "en-US")).toBe("Wants help with day-to-day work first");
expect(focusFactValue("day", "de-DE")).toBe(
"Möchte zuerst bei der täglichen Arbeit unterstützt werden",
);
});
it("asks in order and offers notifications only to a device that can still turn push on", () => {
expect(nextIntakeStep("focus", { canEnablePush: false })?.id).toBe("goal");
expect(nextIntakeStep("goal", { canEnablePush: false })?.id).toBe("rhythm");

View File

@ -43,13 +43,39 @@ interface IntakeStep {
save: (effects: IntakeEffects, answer: IntakeAnswer) => Promise<void>;
}
export type FocusOptionId = "day" | "inbox" | "research" | "crew";
export interface FocusOption {
id: string;
id: FocusOptionId;
letter: string;
label: string;
apps: string[];
/** Stored as a `goals` fact, so it stays a plain English statement. */
fact: string;
}
const FOCUS_FACTS = {
en: {
day: "Wants help with day-to-day work first",
inbox: "Wants help with inbox and email first",
research: "Wants help with research and writing first",
crew: "Wants a coordinated crew of specialist bots",
},
de: {
day: "Möchte zuerst bei der täglichen Arbeit unterstützt werden",
inbox: "Möchte zuerst bei Posteingang und E-Mail unterstützt werden",
research: "Möchte zuerst bei Recherche und Schreiben unterstützt werden",
crew: "Möchte eine koordinierte Crew aus Spezialisten-Bots",
},
} as const satisfies Record<string, Record<FocusOptionId, string>>;
/** Profile fact text for a focus answer, in the user's locale (defaults to English). */
function isFocusOptionId(id: string): id is FocusOptionId {
return FOCUS_OPTIONS.some((option) => option.id === id);
}
export function focusFactValue(focusId: string, locale: string): string {
if (!isFocusOptionId(focusId)) return focusId;
const key: keyof typeof FOCUS_FACTS = locale.toLowerCase().startsWith("de") ? "de" : "en";
return FOCUS_FACTS[key][focusId];
}
export const FOCUS_OPTIONS: FocusOption[] = [
@ -58,28 +84,24 @@ export const FOCUS_OPTIONS: FocusOption[] = [
letter: "A",
label: "Day-to-day work",
apps: ["slack", "gmail", "googlecalendar"],
fact: "Wants help with day-to-day work first",
},
{
id: "inbox",
letter: "B",
label: "Inbox & email",
apps: ["gmail", "googlecalendar", "slack"],
fact: "Wants help with inbox and email first",
},
{
id: "research",
letter: "C",
label: "Research & writing",
apps: ["hackernews", "notion", "googledocs"],
fact: "Wants help with research and writing first",
},
{
id: "crew",
letter: "D",
label: "Specialist crew",
apps: ["slack", "gmail", "googlecalendar"],
fact: "Wants a coordinated crew of specialist bots",
},
];
@ -117,9 +139,16 @@ export const INTAKE_STEPS: IntakeStep[] = [
save: async ({ prisma, tx, userId }, answer) => {
const option = FOCUS_OPTIONS.find((entry) => entry.id === answer.option?.id);
if (!option) return;
const profile = await tx.userProfile.findUnique({
where: { userId },
select: { locale: true, timeZone: true },
});
const locale = intakeContext(profile, new Date()).locale;
await tx.userOnboarding.update({ where: { userId }, data: { focus: option.id } });
await updateUserProfile(prisma, userId, {
addFacts: [{ category: "goals", value: option.fact, source: "onboarding" }],
addFacts: [
{ category: "goals", value: focusFactValue(option.id, locale), source: "onboarding" },
],
});
},
},

View File

@ -0,0 +1,43 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { listHostDirectories } from "./project-directories.js";
describe("listHostDirectories", () => {
it("lists directories in the user home or projects directory by default", async () => {
const listing = await listHostDirectories();
expect(listing.currentPath).toBeTruthy();
expect(Array.isArray(listing.directories)).toBe(true);
expect(Array.isArray(listing.commonPaths)).toBe(true);
expect(listing.commonPaths.some((p) => p.name.includes("Home") || p.name === "Projects")).toBe(
true,
);
});
it("lists specific existing directory and excludes hidden folders and node_modules", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "evoo-test-dirs-"));
try {
await fs.mkdir(path.join(tmpDir, "visible-folder-a"));
await fs.mkdir(path.join(tmpDir, "visible-folder-b"));
await fs.mkdir(path.join(tmpDir, ".hidden-folder"));
await fs.mkdir(path.join(tmpDir, "node_modules"));
await fs.writeFile(path.join(tmpDir, "some-file.txt"), "hello");
const listing = await listHostDirectories(tmpDir);
expect(listing.currentPath).toBe(await fs.realpath(tmpDir));
expect(listing.directories.map((d) => d.name)).toEqual([
"visible-folder-a",
"visible-folder-b",
]);
} finally {
await fs.rm(tmpDir, { recursive: true, force: true });
}
});
it("falls back safely when given an invalid path", async () => {
const listing = await listHostDirectories("/nonexistent-path-12345/abcdef");
expect(listing.currentPath).toBe(os.homedir());
expect(Array.isArray(listing.directories)).toBe(true);
});
});

View File

@ -0,0 +1,95 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
export interface HostDirectoryListing {
currentPath: string;
parentPath: string | null;
commonPaths: Array<{ name: string; path: string }>;
directories: Array<{ name: string; path: string }>;
}
export async function listHostDirectories(targetPath?: string): Promise<HostDirectoryListing> {
const home = os.homedir();
const raw = targetPath?.trim();
let requestedPath = home;
if (raw) {
if (raw === "~") {
requestedPath = home;
} else if (raw.startsWith("~/")) {
requestedPath = path.join(home, raw.slice(2));
} else {
requestedPath = path.resolve(raw);
}
} else {
// Default: prioritize ~/Projects if it exists, otherwise home directory
const projectsCandidate = path.join(home, "Projects");
try {
const stat = await fs.stat(projectsCandidate);
if (stat.isDirectory()) {
requestedPath = projectsCandidate;
}
} catch {
requestedPath = home;
}
}
let currentPath: string;
try {
currentPath = await fs.realpath(requestedPath);
const stat = await fs.stat(currentPath);
if (!stat.isDirectory()) {
currentPath = home;
}
} catch {
currentPath = home;
}
const parsed = path.parse(currentPath);
const parentPath = currentPath === parsed.root ? null : path.dirname(currentPath);
const commonCandidates = [
{ name: "Projects", path: path.join(home, "Projects") },
{ name: "Home (~)", path: home },
{ name: "Desktop", path: path.join(home, "Desktop") },
{ name: "Documents", path: path.join(home, "Documents") },
{ name: "Downloads", path: path.join(home, "Downloads") },
];
const commonPaths: Array<{ name: string; path: string }> = [];
for (const candidate of commonCandidates) {
try {
const s = await fs.stat(candidate.path);
if (s.isDirectory()) {
commonPaths.push(candidate);
}
} catch {
// not present on host
}
}
const directories: Array<{ name: string; path: string }> = [];
try {
const entries = await fs.readdir(currentPath, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
// Skip hidden folders (.git, .Trash, etc.) and node_modules
if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
directories.push({
name: entry.name,
path: path.join(currentPath, entry.name),
});
}
directories.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" }));
} catch {
// Unreadable directory returns empty list
}
return {
currentPath,
parentPath,
commonPaths,
directories,
};
}

View File

@ -0,0 +1,114 @@
import { describe, expect, it, vi } from "vitest";
import { listUserGithubRepos } from "./project-github.js";
vi.mock("node:child_process", () => ({
execFile: vi.fn((_cmd, _args, callback) => {
callback(new Error("gh not installed"));
}),
}));
describe("listUserGithubRepos", () => {
it("queries repositories and filters by query", async () => {
const mockPrisma = {
capabilityInstall: {
findMany: vi.fn().mockResolvedValue([
{
id: "cap-1",
kind: "mcp",
config: { preset: "github" },
secretId: "sec-1",
},
]),
},
secret: {
findUnique: vi.fn().mockResolvedValue({
id: "sec-1",
ciphertext: "fake-ciphertext",
}),
},
};
const mockSecrets = {
load: vi.fn().mockReturnValue("fake-token"),
};
// Mock fetch for GitHub API
const originalFetch = globalThis.fetch;
globalThis.fetch = vi.fn().mockImplementation(async (url: string) => {
if (url.includes("/user/repos")) {
return {
ok: true,
json: async () => [
{
name: "evOo-local",
full_name: "testorg/evOo-local",
html_url: "https://github.com/testorg/evOo-local",
description: "AI Operating System",
private: false,
updated_at: "2026-10-12T00:00:00Z",
},
{
name: "secret-vault",
full_name: "testorg/secret-vault",
html_url: "https://github.com/testorg/secret-vault",
description: "Private keys",
private: true,
updated_at: "2026-10-11T00:00:00Z",
},
],
} as unknown as Response;
}
if (url.includes("/user")) {
return {
ok: true,
json: async () => ({ login: "testuser" }),
} as unknown as Response;
}
return { ok: false } as unknown as Response;
});
try {
const all = await listUserGithubRepos(mockPrisma as any, undefined, mockSecrets);
expect(all.connected).toBe(true);
expect(all.username).toBe("testuser");
expect(all.repos).toHaveLength(2);
expect(all.repos[0].name).toBe("evOo-local");
const filtered = await listUserGithubRepos(
mockPrisma as any,
{ query: "vault" },
mockSecrets,
);
expect(filtered.repos).toHaveLength(1);
expect(filtered.repos[0].name).toBe("secret-vault");
expect(filtered.repos[0].isPrivate).toBe(true);
} finally {
globalThis.fetch = originalFetch;
}
});
it("handles unauthenticated state gracefully", async () => {
const mockPrisma = {
capabilityInstall: {
findMany: vi.fn().mockResolvedValue([]),
},
secret: {
findUnique: vi.fn().mockResolvedValue(null),
},
};
const origToken = process.env.GITHUB_TOKEN;
const origGhToken = process.env.GH_TOKEN;
delete process.env.GITHUB_TOKEN;
delete process.env.GH_TOKEN;
try {
const res = await listUserGithubRepos(mockPrisma as any, { query: "test" });
expect(res.connected !== undefined).toBe(true);
expect(Array.isArray(res.repos)).toBe(true);
} finally {
if (origToken) process.env.GITHUB_TOKEN = origToken;
if (origGhToken) process.env.GH_TOKEN = origGhToken;
}
});
});

View File

@ -0,0 +1,200 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import type { EncryptedSecretStore } from "@rakazo/adapters";
import type { PrismaClient } from "@rakazo/db";
const execFileAsync = promisify(execFile);
export interface GithubRepoItem {
name: string;
fullName: string;
url: string;
description?: string | null;
isPrivate: boolean;
updatedAt?: string;
}
export interface ListGithubReposOutput {
connected: boolean;
username?: string;
repos: GithubRepoItem[];
}
export async function listUserGithubRepos(
prisma: Pick<PrismaClient, "capabilityInstall" | "secret">,
input?: { query?: string; limit?: number },
secrets?: Pick<EncryptedSecretStore, "load">,
): Promise<ListGithubReposOutput> {
let token: string | null = null;
// 1. Check CapabilityInstall in database if secret store is provided
if (secrets) {
try {
const installs = await prisma.capabilityInstall.findMany({
where: { kind: "mcp" },
});
for (const inst of installs) {
const cfg = inst.config as Record<string, unknown> | null;
if (cfg?.preset === "github" && inst.secretId) {
const secret = await prisma.secret.findUnique({
where: { id: inst.secretId },
select: { id: true, ciphertext: true },
});
if (secret) {
try {
const loaded = secrets.load(secret.ciphertext, secret.id);
if (loaded?.trim()) {
token = loaded.trim();
break;
}
} catch {
// decryption error fallback
}
}
}
}
} catch {
// Database lookup error fallback
}
}
// 2. Check environment
if (!token) {
token = process.env.GITHUB_TOKEN?.trim() || process.env.GH_TOKEN?.trim() || null;
}
// 3. Check local gh CLI auth token
if (!token) {
try {
const { stdout } = await execFileAsync("gh", ["auth", "token"], { timeout: 3000 });
if (stdout.trim()) {
token = stdout.trim();
}
} catch {
// gh CLI not available or not logged in
}
}
// If token is available, query GitHub REST API
if (token) {
try {
let username: string | undefined;
const userRes = await fetch("https://api.github.com/user", {
headers: {
Authorization: `Bearer ${token}`,
"User-Agent": "evOo-App",
Accept: "application/vnd.github+json",
},
});
if (userRes.ok) {
const userData = (await userRes.json()) as { login?: string };
username = userData.login;
}
const reposRes = await fetch(
"https://api.github.com/user/repos?sort=pushed&per_page=100&type=all",
{
headers: {
Authorization: `Bearer ${token}`,
"User-Agent": "evOo-App",
Accept: "application/vnd.github+json",
},
},
);
if (reposRes.ok) {
const rawRepos = (await reposRes.json()) as Array<{
name: string;
full_name: string;
html_url: string;
description?: string | null;
private?: boolean;
pushed_at?: string;
updated_at?: string;
}>;
let items: GithubRepoItem[] = rawRepos.map((r) => ({
name: r.name,
fullName: r.full_name,
url: r.html_url,
description: r.description ?? null,
isPrivate: Boolean(r.private),
updatedAt: r.pushed_at || r.updated_at,
}));
if (input?.query?.trim()) {
const q = input.query.trim().toLowerCase();
items = items.filter(
(r) =>
r.name.toLowerCase().includes(q) ||
r.fullName.toLowerCase().includes(q) ||
(r.description && r.description.toLowerCase().includes(q)),
);
}
const limit = input?.limit ?? 50;
return {
connected: true,
username,
repos: items.slice(0, limit),
};
}
} catch {
// Fall through to gh repo list fallback
}
}
// Fallback: try gh repo list directly
try {
const { stdout } = await execFileAsync(
"gh",
[
"repo",
"list",
"--limit",
"100",
"--json",
"nameWithOwner,name,url,description,isPrivate,pushedAt",
],
{ timeout: 5000 },
);
const parsed = JSON.parse(stdout) as Array<{
nameWithOwner: string;
name: string;
url: string;
description?: string;
isPrivate?: boolean;
pushedAt?: string;
}>;
let items: GithubRepoItem[] = parsed.map((r) => ({
name: r.name,
fullName: r.nameWithOwner,
url: r.url,
description: r.description || null,
isPrivate: Boolean(r.isPrivate),
updatedAt: r.pushedAt,
}));
if (input?.query?.trim()) {
const q = input.query.trim().toLowerCase();
items = items.filter(
(r) =>
r.name.toLowerCase().includes(q) ||
r.fullName.toLowerCase().includes(q) ||
(r.description && r.description.toLowerCase().includes(q)),
);
}
const limit = input?.limit ?? 50;
return {
connected: true,
repos: items.slice(0, limit),
};
} catch {
return {
connected: false,
repos: [],
};
}
}

View File

@ -224,7 +224,12 @@ describe("model setup gate", () => {
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({
json: expect.objectContaining({ needsModel: false }),
json: expect.objectContaining({
needsModel: false,
modelsManaged: true,
defaultProvider: "openrouter",
defaultModel: "test-model",
}),
});
});
@ -238,7 +243,13 @@ describe("model setup gate", () => {
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({
json: expect.objectContaining({ needsModel: true }),
// The connect flow opens on the recommendation, not the keyless deployment default.
json: expect.objectContaining({
needsModel: true,
modelsManaged: false,
defaultProvider: "anthropic",
defaultModel: "claude-opus-5-5",
}),
});
});

View File

@ -45,10 +45,12 @@ import {
clearInactiveUserComputerControl,
computerSupportsUpdate,
computerUpdateView,
createBrowserProvider,
createVoiceProvider,
deploymentAutoReviewDefault,
destroyBot,
displayBotWorkspacePath,
engageBrowserCowork,
enqueueTakeoverContinuation,
expireComputerControl,
filterTeamWorkspaceEntries,
@ -71,7 +73,9 @@ import {
probeOpenAiCompatibleModels,
provisionComputer,
queueComputerUpdate,
RECOMMENDED_MODEL,
readProjectWorkspaceFile,
recordBrowserUserAction,
releaseComputerExecutionLease,
replaceComputer,
resolveAutoReviewChecker,
@ -110,11 +114,15 @@ import {
AttachmentValidationError,
containsSecret,
expandSkillReferencesInPrompt,
GITHUB_MCP_SOURCE,
hasMixedOneShotSchedule,
isOneShotRoutineCrons,
isProjectColor,
isProjectIcon,
isValidAvatarValue,
nextCronDateAcrossStrict,
} from "@rakazo/core";
import { readProjectSummary } from "@rakazo/core/node/project-summary";
import type { PrismaClient, ThreadEvents } from "@rakazo/db";
import {
BotSectionNameConflictError,
@ -127,6 +135,7 @@ import {
createProjectGoalRepos,
createRepos,
createSpaceForMember,
DuplicateSpaceNameError,
deleteEmptySpaceForMember,
deleteUnreferencedCredentialSecret,
ensureMainConversation,
@ -163,7 +172,9 @@ import {
savePushSubscription,
selectSpaceModelPreference,
selectSpaceVoicePreference,
syncProjectMembers,
updateNotificationSettings,
updateSpaceForOwner,
wireProjectWorkspace,
} from "@rakazo/db";
import { getLogger } from "@rakazo/logging";
@ -204,6 +215,12 @@ import {
startMediaJob,
updateMediaSettings,
} from "./media-jobs.js";
import {
addMediaLibrarySource,
listMediaLibrarySources,
removeMediaLibrarySource,
syncMediaLibrarySourceNow,
} from "./media-library.js";
import {
disconnectMemoryProvider,
getMemoryProviderConfig,
@ -224,7 +241,9 @@ import {
onboardingScanStatus,
startOnboardingScan,
} from "./onboarding-scan.js";
import { listSpaceRuns } from "./runs.js";
import { listHostDirectories } from "./project-directories.js";
import { listUserGithubRepos } from "./project-github.js";
import { listUserRuns } from "./runs.js";
import { addScreenProxyCapability } from "./screen-proxy.js";
import { querySpaceSearch } from "./search.js";
import { withSerializableRetry } from "./serializable-retry.js";
@ -252,7 +271,10 @@ import {
followUpGroupThreadMessage,
groupDefaultBotId,
reactToThreadMessage,
removeQueuedMessage,
resolveThreadTarget,
resumeThreadRuns,
sendQueuedThreadMessagesNow,
sendThreadMessage,
setThreadUnreadState,
stopThreadRuns,
@ -700,6 +722,12 @@ export function createRouter(deps: RouterDeps) {
spaceNavigationDto(deps, context.actor, repos, groupRepos),
),
create: authed.spaces.create.handler(async ({ context, input }) => {
if (input.icon !== undefined && !isProjectIcon(input.icon)) {
throw new ORPCError("BAD_REQUEST", { message: "Unknown project icon" });
}
if (input.color !== undefined && !isProjectColor(input.color)) {
throw new ORPCError("BAD_REQUEST", { message: "Unknown project color" });
}
// A linked folder lives on the host, and the host is the deployment owner's machine.
if (input.workspacePath && !context.actor.isDeploymentOwner) {
throw new ORPCError("FORBIDDEN");
@ -722,6 +750,9 @@ export function createRouter(deps: RouterDeps) {
currentSpaceId: context.actor.spaceId,
userId: context.actor.userId,
name: input.name,
icon: input.icon,
color: input.color,
gitRemoteUrl: input.gitRemoteUrl,
});
} catch (error) {
if (error instanceof SpaceLimitError || error instanceof InvalidSpaceNameError) {
@ -729,9 +760,54 @@ export function createRouter(deps: RouterDeps) {
}
throw error;
}
const workspace = workspacePath
const wiredWorkspace = workspacePath
? await wireProjectWorkspace(deps.prisma, { spaceId: space.id, workspacePath })
: null;
if (input.gitRemoteUrl && !wiredWorkspace?.git?.remoteUrl) {
await deps.prisma.space.update({
where: { id: space.id },
data: { gitRemoteUrl: input.gitRemoteUrl },
});
}
const workspace =
wiredWorkspace ??
(input.gitRemoteUrl
? {
path: null,
git: {
remoteUrl: input.gitRemoteUrl,
defaultBranch: "main",
headCommit: null,
dirty: false,
dirtyPaths: [],
},
}
: null);
// Without a stated goal, the linked folder's README or manifest says what this is.
const goal =
input.goal?.trim() || (workspacePath ? await readProjectSummary(workspacePath) : null);
if (goal) {
const existingGoal = await deps.prisma.memoryDocument.findFirst({
where: { spaceId: space.id, scope: "user", path: "PROJECT_GOAL.md" },
select: { id: true },
});
if (existingGoal) {
await deps.prisma.memoryDocument.update({
where: { id: existingGoal.id },
data: { content: goal },
});
} else {
await deps.prisma.memoryDocument.create({
data: {
spaceId: space.id,
userId: context.actor.userId,
scope: "user",
path: "PROJECT_GOAL.md",
content: goal,
},
});
}
}
// A project starts as a conversation with its own main agent.
await ensureMainConversation(deps.prisma, context.actor.userId, space.id);
const spaceActor = await requireMembership(deps.prisma, context.actor.userId, space.id);
@ -742,10 +818,14 @@ export function createRouter(deps: RouterDeps) {
return {
id: space.id,
name: space.name,
icon: input.icon ?? null,
color: input.color ?? null,
isDefault: false,
hasContent: false,
canDelete: true,
canEdit: true,
workspace,
goal,
bots: bots.map(spaceBotDto),
groups: groups.map(spaceGroupDto),
externalConversations: [],
@ -766,6 +846,81 @@ export function createRouter(deps: RouterDeps) {
spaceId: conversation.spaceId,
};
}),
update: authed.spaces.update.handler(async ({ context, input }) => {
if (input.icon !== undefined && input.icon !== null && !isProjectIcon(input.icon)) {
throw new ORPCError("BAD_REQUEST", { message: "Unknown project icon" });
}
if (input.color !== undefined && input.color !== null && !isProjectColor(input.color)) {
throw new ORPCError("BAD_REQUEST", { message: "Unknown project color" });
}
if (input.workspacePath && !context.actor.isDeploymentOwner) {
throw new ORPCError("FORBIDDEN");
}
let workspacePath: string | null | undefined;
if (input.workspacePath) {
try {
workspacePath = await resolveProjectWorkspaceDirectory(input.workspacePath);
} catch (error) {
if (error instanceof InvalidProjectWorkspacePathError) {
throw new ORPCError("BAD_REQUEST", { message: error.message });
}
throw error;
}
} else if (input.workspacePath === null) {
workspacePath = null;
}
try {
await updateSpaceForOwner(deps.prisma, {
userId: context.actor.userId,
spaceId: input.spaceId,
name: input.name,
icon: input.icon,
color: input.color,
workspacePath,
gitRemoteUrl: input.gitRemoteUrl,
});
if (workspacePath) {
await wireProjectWorkspace(deps.prisma, { spaceId: input.spaceId, workspacePath });
}
} catch (error) {
if (error instanceof SpaceNotFoundError) {
throw new ORPCError("NOT_FOUND", { message: error.message });
}
if (error instanceof IsolationError) throw new ORPCError("FORBIDDEN");
if (error instanceof InvalidSpaceNameError || error instanceof DuplicateSpaceNameError) {
throw new ORPCError("BAD_REQUEST", { message: error.message });
}
throw error;
}
if (input.goal !== undefined) {
const existingGoal = await deps.prisma.memoryDocument.findFirst({
where: { spaceId: input.spaceId, scope: "user", path: "PROJECT_GOAL.md" },
});
if (input.goal?.trim()) {
if (existingGoal) {
await deps.prisma.memoryDocument.update({
where: { id: existingGoal.id },
data: { content: input.goal.trim() },
});
} else {
await deps.prisma.memoryDocument.create({
data: {
spaceId: input.spaceId,
userId: context.actor.userId,
scope: "user",
path: "PROJECT_GOAL.md",
content: input.goal.trim(),
},
});
}
} else if (existingGoal) {
await deps.prisma.memoryDocument.delete({ where: { id: existingGoal.id } });
}
}
return { ok: true as const };
}),
remove: authed.spaces.remove.handler(async ({ context, input }) => {
let claimId: string | null = null;
let claimActive = false;
@ -925,6 +1080,15 @@ export function createRouter(deps: RouterDeps) {
if (claimRenewal) clearInterval(claimRenewal);
}
}),
listDirectories: authed.spaces.listDirectories.handler(async ({ context, input }) => {
if (!context.actor.isDeploymentOwner) {
throw new ORPCError("FORBIDDEN");
}
return listHostDirectories(input.path);
}),
listGithubRepos: authed.spaces.listGithubRepos.handler(async ({ input }) =>
listUserGithubRepos(deps.prisma, input, deps.secrets),
),
},
bootstrap: authed.bootstrap.handler(async ({ context, input }) => {
const actor = context.actor;
@ -960,7 +1124,7 @@ export function createRouter(deps: RouterDeps) {
try {
const { AppBootstrapSchema } = await import("@rakazo/contracts");
AppBootstrapSchema.parse(output);
} catch (_e: any) {}
} catch (_e) {}
return output;
}),
@ -1500,6 +1664,10 @@ export function createRouter(deps: RouterDeps) {
input.name,
);
}
await syncProjectMembers(deps.prisma, {
userId: context.actor.userId,
libraryBotId: existing.id,
});
const bots = await repos.listBots(context.actor);
const bot = bots.find((b) => b.id === input.botId);
if (!bot) throw new IsolationError();
@ -1897,6 +2065,42 @@ export function createRouter(deps: RouterDeps) {
await stopThreadRuns(deps, context.actor, target);
return { ok: true as const };
}),
resume: authed.threads.resume.handler(async ({ context, input }) => {
if ((await modelSetup(deps, context.actor)).needsModel) {
throw new ORPCError("BAD_REQUEST", { message: "Connect a model to start a run." });
}
const target = await resolveThreadTarget(deps.prisma, context.actor, input);
if (target.kind === "bot") {
await assertTeachingSendAllowed(deps.prisma, context.actor.spaceId, target.botId);
}
return resumeThreadRuns(deps, context.actor, target);
}),
sendNow: authed.threads.sendNow.handler(async ({ context, input }) => {
if ((await modelSetup(deps, context.actor)).needsModel) {
throw new ORPCError("BAD_REQUEST", { message: "Connect a model to start a run." });
}
const target = await resolveThreadTarget(deps.prisma, context.actor, input);
if (target.kind === "bot") {
await assertTeachingSendAllowed(deps.prisma, context.actor.spaceId, target.botId);
}
return sendQueuedThreadMessagesNow(deps, context.actor, target, {
messageId: input.messageId,
});
}),
removeQueuedMessage: authed.threads.removeQueuedMessage.handler(
async ({ context, input }) => {
const target = await resolveThreadTarget(deps.prisma, context.actor, input);
if (target.kind === "bot") {
await assertTeachingSendAllowed(deps.prisma, context.actor.spaceId, target.botId);
}
return removeQueuedMessage(
{ prisma: deps.prisma, events: deps.events },
context.actor,
target,
input.messageId,
);
},
),
clear: authed.threads.clear.handler(async ({ context, input }) => {
const target = await resolveThreadTarget(deps.prisma, context.actor, input);
const contextBotId = target.kind === "bot" ? target.botId : groupDefaultBotId(target);
@ -2300,18 +2504,33 @@ export function createRouter(deps: RouterDeps) {
select: { botId: true, status: true },
})
: null;
const waitingForTakeover =
const leaseRunWaits =
executionRun?.botId === bot.id &&
(executionRun.status === "waiting_takeover" ||
bot.computer.controlRunId === executionLease?.runId);
if (
executionBlocksUserTakeover({
const leaseBusy = executionBlocksUserTakeover({
hasLease: Boolean(executionLease),
leaseExpiresAt: executionLease?.expiresAt,
runStatus: executionRun?.status,
takeoverRequested: waitingForTakeover,
})
) {
});
// A run whose setup failed while it waited keeps waiting_takeover after its execution
// lease is released, or behind an older run's inactive lease. Bind it anyway, or handing
// control back never resumes it.
const leaselessWaitingRun =
leaseRunWaits || leaseBusy
? null
: await deps.prisma.run.findFirst({
where: {
botId: bot.id,
spaceId: context.actor.spaceId,
status: "waiting_takeover",
},
orderBy: { createdAt: "desc" },
select: { id: true },
});
const waitingRunId = leaseRunWaits ? executionLease?.runId : leaselessWaitingRun?.id;
const waitingForTakeover = leaseRunWaits || Boolean(leaselessWaitingRun);
if (leaseBusy && !waitingForTakeover) {
throw new ORPCError("CONFLICT", { message: "Stop the bot first" });
}
// Keep an inactive lease as a fencing tombstone. The next run reclaims it
@ -2332,7 +2551,7 @@ export function createRouter(deps: RouterDeps) {
controlLeaseId: leaseId,
controlLeaseExpiresAt: expiresAt,
controlBotId: bot.id,
controlRunId: waitingForTakeover ? executionLease?.runId : null,
controlRunId: waitingForTakeover ? waitingRunId : null,
state: "running",
},
});
@ -2675,6 +2894,77 @@ export function createRouter(deps: RouterDeps) {
}
return { ok: true as const };
}),
browserChromeState: authed.computer.browserChromeState.handler(async ({ context, input }) => {
const bot = await repos.getBot(context.actor, input.botId);
if (!bot.computer?.providerRef || bot.computer.state !== "running") {
throw new ORPCError("BAD_REQUEST", { message: "computer must be running" });
}
return readBrowserChrome(deps, bot, "state", context);
}),
browserNavigate: authed.computer.browserNavigate.handler(async ({ context, input }) => {
const bot = await repos.getBot(context.actor, input.botId);
if (!bot.computer?.providerRef || bot.computer.state !== "running") {
throw new ORPCError("BAD_REQUEST", { message: "computer must be running" });
}
const browser = createBrowserProvider(undefined, { sandbox: deps.sandbox });
const result = await browser.navigate(
toComputerRef(bot.computer),
{ url: input.url },
computerContext(context.actor, bot.id, "browser.navigate"),
);
if (result.fallback) {
throw new ORPCError("CONFLICT", { message: result.error ?? "Browser unavailable" });
}
await noteBrowserNavigate(deps, context.actor, bot, input.url);
return readBrowserChrome(deps, bot, "state", context);
}),
browserBack: authed.computer.browserBack.handler(async ({ context, input }) => {
const bot = await repos.getBot(context.actor, input.botId);
if (!bot.computer?.providerRef || bot.computer.state !== "running") {
throw new ORPCError("BAD_REQUEST", { message: "computer must be running" });
}
return readBrowserChrome(deps, bot, "back", context);
}),
browserForward: authed.computer.browserForward.handler(async ({ context, input }) => {
const bot = await repos.getBot(context.actor, input.botId);
if (!bot.computer?.providerRef || bot.computer.state !== "running") {
throw new ORPCError("BAD_REQUEST", { message: "computer must be running" });
}
return readBrowserChrome(deps, bot, "forward", context);
}),
browserReload: authed.computer.browserReload.handler(async ({ context, input }) => {
const bot = await repos.getBot(context.actor, input.botId);
if (!bot.computer?.providerRef || bot.computer.state !== "running") {
throw new ORPCError("BAD_REQUEST", { message: "computer must be running" });
}
return readBrowserChrome(deps, bot, "reload", context);
}),
browserCoworkEngage: authed.computer.browserCoworkEngage.handler(
async ({ context, input }) => {
const bot = await repos.getBot(context.actor, input.botId);
if (!bot.computer) throw new IsolationError();
const engaged = await engageBrowserCowork(
deps,
context.actor,
bot,
computerContext(context.actor, bot.id, "browser.cowork"),
);
return engaged;
},
),
browserUserAction: authed.computer.browserUserAction.handler(async ({ context, input }) => {
const bot = await repos.getBot(context.actor, input.botId);
const threadId = bot.thread?.id;
if (!threadId) throw new IsolationError();
await recordBrowserUserAction(deps, context.actor, {
botId: bot.id,
threadId,
kind: input.kind,
detail: input.detail,
passwordField: input.passwordField,
});
return { ok: true as const };
}),
},
memory: {
list: authed.memory.list.handler(async ({ context, input }) => {
@ -3134,8 +3424,9 @@ export function createRouter(deps: RouterDeps) {
},
capabilities: {
list: authed.capabilities.list.handler(async ({ context }) => {
// Tool sources and plugins follow the user into every space.
const rows = await deps.prisma.capabilityInstall.findMany({
where: { spaceId: context.actor.spaceId, userId: context.actor.userId },
where: { userId: context.actor.userId },
});
return rows.map((row) => ({
id: row.id,
@ -3200,6 +3491,10 @@ export function createRouter(deps: RouterDeps) {
source = "https://treg.to/mcp/";
config = { ...config, preset: "treg", auth: { type: "bearer" } };
}
if (config.preset === "github") {
source = GITHUB_MCP_SOURCE;
config = { preset: "github", auth: { type: "bearer" } };
}
const verified = await verifyMcpInstall({
source,
config,
@ -3290,11 +3585,7 @@ export function createRouter(deps: RouterDeps) {
remove: authed.capabilities.remove.handler(async ({ context, input }) => {
await deps.prisma.$transaction(async (tx) => {
const existing = await tx.capabilityInstall.findFirst({
where: {
id: input.id,
spaceId: context.actor.spaceId,
userId: context.actor.userId,
},
where: { id: input.id, userId: context.actor.userId },
});
if (!existing) return;
await tx.capabilityInstall.delete({ where: { id: existing.id } });
@ -3304,11 +3595,7 @@ export function createRouter(deps: RouterDeps) {
});
if (shared === 0) {
await tx.secret.deleteMany({
where: {
id: existing.secretId,
spaceId: context.actor.spaceId,
userId: context.actor.userId,
},
where: { id: existing.secretId, userId: context.actor.userId },
});
}
}
@ -3494,11 +3781,7 @@ export function createRouter(deps: RouterDeps) {
});
} else if (clearing && existing.secretId) {
await tx.secret.deleteMany({
where: {
id: existing.secretId,
spaceId: context.actor.spaceId,
userId: context.actor.userId,
},
where: { id: existing.secretId, userId: context.actor.userId },
});
}
return updated;
@ -3796,6 +4079,20 @@ export function createRouter(deps: RouterDeps) {
cancelMediaJob(deps, context.actor, input.jobId),
),
},
library: {
list: authed.media.library.list.handler(({ context }) =>
listMediaLibrarySources(deps, context.actor),
),
add: authed.media.library.add.handler(({ context, input }) =>
addMediaLibrarySource(deps, context.actor, input),
),
remove: authed.media.library.remove.handler(({ context, input }) =>
removeMediaLibrarySource(deps, context.actor, input.sourceId),
),
sync: authed.media.library.sync.handler(({ context, input }) =>
syncMediaLibrarySourceNow(deps, context.actor, input.sourceId),
),
},
},
connections: {
catalog: authed.connections.catalog.handler(async ({ context, input }) => {
@ -5138,7 +5435,7 @@ export function createRouter(deps: RouterDeps) {
},
runs: {
list: authed.runs.list.handler(async ({ context, input }) => ({
runs: await listSpaceRuns(deps.prisma, context.actor, input.filter),
runs: await listUserRuns(deps.prisma, context.actor, input.filter),
})),
},
tasks: {
@ -5334,6 +5631,8 @@ async function spaceNavigationDto(
space: {
select: {
name: true,
icon: true,
color: true,
isDefault: true,
deletingAt: true,
workspacePath: true,
@ -5361,6 +5660,7 @@ async function spaceNavigationDto(
externalConversations,
contentBots,
contentGroups,
goalDocuments,
] = await Promise.all([
repos.listBots(actor),
groupRepos.listGroups(actor),
@ -5374,19 +5674,29 @@ async function spaceNavigationDto(
// The user's own main agent of a project is part of the project, not content to clear.
deps.prisma.bot.findMany({
where: { spaceId: { in: spaceIds }, NOT: ownProjectMain },
select: { spaceId: true },
distinct: ["spaceId"],
select: { spaceId: true, userId: true },
distinct: ["spaceId", "userId"],
}),
deps.prisma.chatGroup.findMany({
where: { spaceId: { in: spaceIds }, NOT: ownProjectMain },
select: { spaceId: true },
distinct: ["spaceId"],
select: { spaceId: true, userId: true },
distinct: ["spaceId", "userId"],
}),
deps.prisma.memoryDocument.findMany({
where: { spaceId: { in: spaceIds }, scope: "user", path: "PROJECT_GOAL.md" },
select: { spaceId: true, content: true },
}),
]);
const spacesWithContent = new Set([
...contentBots.map((row) => row.spaceId),
...contentGroups.map((row) => row.spaceId),
]);
const spacesBlockedByOtherMember = new Set(
[...contentBots, ...contentGroups]
.filter((row) => row.userId !== actor.userId)
.map((row) => row.spaceId),
);
const goalBySpaceId = new Map(goalDocuments.map((doc) => [doc.spaceId, doc.content]));
const currentMembership = memberships.find((membership) => membership.spaceId === actor.spaceId);
if (!currentMembership) throw new IsolationError();
const botsBySpace = partitionBySpace([...currentBots, ...inactiveBots]);
@ -5414,16 +5724,20 @@ async function spaceNavigationDto(
return {
id: membership.spaceId,
name: membership.space.name,
icon: membership.space.icon,
color: membership.space.color,
isDefault: membership.space.isDefault,
hasContent: spacesWithContent.has(membership.spaceId),
canDelete:
membership.role === "owner" &&
!membership.space.isDefault &&
memberships.length > 1 &&
!spacesWithContent.has(membership.spaceId) &&
!spacesBlockedByOtherMember.has(membership.spaceId) &&
(membership.space.deletingAt === null || membership.space.deletingAt < staleClaimBefore),
canEdit: membership.role === "owner",
// A linked folder is on the owner's host; other members see neither its path nor remote.
workspace: actor.isDeploymentOwner ? projectWorkspaceDto(membership.space) : null,
goal: goalBySpaceId.get(membership.spaceId) ?? null,
bots: spaceBots.map(spaceBotDto),
groups: spaceGroups.map(spaceGroupDto),
externalConversations: externalConversations.filter(
@ -5511,6 +5825,10 @@ async function meDto(deps: RouterDeps, actor: Actor): Promise<Me> {
deps.prisma.user.findUniqueOrThrow({ where: { id: actor.userId } }),
modelSetup(deps, actor),
]);
// Without a key the deployment default cannot run, so the connect flow opens on the recommendation.
const fallback = setup.needsModel
? RECOMMENDED_MODEL
: { provider: deps.env.defaultProvider, model: deps.env.defaultModel };
return {
userId: actor.userId,
email: user.email,
@ -5518,12 +5836,11 @@ async function meDto(deps: RouterDeps, actor: Actor): Promise<Me> {
spaceId: actor.spaceId,
isDeploymentOwner: actor.isDeploymentOwner,
needsModel: setup.needsModel,
modelsManaged: Boolean(deps.env.deploymentModelKey),
defaultProvider:
setup.credential?.provider ??
setup.settings?.defaultModelProvider ??
deps.env.defaultProvider,
setup.credential?.provider ?? setup.settings?.defaultModelProvider ?? fallback.provider,
defaultModel:
setup.credential?.defaultModel ?? setup.settings?.defaultModelId ?? deps.env.defaultModel,
setup.credential?.defaultModel ?? setup.settings?.defaultModelId ?? fallback.model,
computerHost: computerHostFor(setup.settings?.computerHost, deps.env.sandboxProvider),
canChooseHostComputer: actor.isDeploymentOwner && deps.env.sandboxProvider === "docker",
sandboxProvider: deps.env.sandboxProvider,
@ -5818,6 +6135,55 @@ async function persistModelCredential(
return modelCredentialDto(cred, input.plaintext);
}
async function readBrowserChrome(
deps: RouterDeps,
bot: {
id: string;
computer: { homeKey: string; kind: string; providerRef: string | null } | null;
},
command: "state" | "back" | "forward" | "reload",
context: { actor: Actor },
) {
if (!bot.computer?.providerRef) {
throw new ORPCError("BAD_REQUEST", { message: "computer must be running" });
}
const browser = createBrowserProvider(undefined, { sandbox: deps.sandbox });
const chrome = browser as InstanceType<typeof import("@rakazo/adapters").ComputerBrowserProvider>;
const result = await chrome.chrome(
toComputerRef(bot.computer),
command,
computerContext(context.actor, bot.id, `browser.${command}`),
);
if (!result.ok) {
throw new ORPCError("CONFLICT", {
message: "error" in result && result.error ? result.error : "Browser unavailable",
});
}
return {
url: result.url,
title: result.title,
loading: result.loading,
canGoBack: result.canGoBack,
canGoForward: result.canGoForward,
};
}
async function noteBrowserNavigate(
deps: RouterDeps,
actor: Actor,
bot: { id: string; thread: { id: string } | null },
url: string,
) {
const threadId = bot.thread?.id;
if (!threadId) return;
await recordBrowserUserAction(deps, actor, {
botId: bot.id,
threadId,
kind: "navigate",
detail: url,
});
}
function throwIfAborted(signal?: AbortSignal) {
if (signal?.aborted) throw signal.reason ?? new Error("Request cancelled");
}

View File

@ -49,15 +49,19 @@ export function activityNotificationsEnabled(
return groupId !== null || notifyOnFinish;
}
export async function listSpaceRuns(
/**
* The user's runs across every space they belong to, so a chat waiting on them in another
* project still shows up wherever they look.
*/
export async function listUserRuns(
prisma: PrismaClient,
actor: Actor,
filter: "active" | "recent",
): Promise<RunActivityRow[]> {
const rows = await prisma.run.findMany({
where: {
spaceId: actor.spaceId,
userId: actor.userId,
space: { memberships: { some: { userId: actor.userId } } },
bot: { archivedAt: null },
...(filter === "active"
? { status: { in: [...ACTIVE_RUN_STATUSES] } }
@ -88,6 +92,7 @@ export async function listSpaceRuns(
groupId: row.thread.groupId,
groupName: row.thread.group?.name ?? null,
threadId: row.threadId,
spaceId: row.spaceId,
status: row.status as RunActivityRow["status"],
trigger: row.trigger as RunActivityRow["trigger"],
notificationsEnabled: activityNotificationsEnabled(row.thread.groupId, row.bot.notifyOnFinish),

View File

@ -1,6 +1,7 @@
import type { Actor, MessageBlock, SearchHit } from "@rakazo/contracts";
import { extractLinksFromText, matchesSearchQuery, snippetAroundMatch } from "@rakazo/core";
import { Prisma, type PrismaClient } from "@rakazo/db";
import type { PrismaClient } from "@rakazo/db";
import { Prisma } from "@rakazo/db";
const SEARCH_LIMIT = 25;
/** Cap name matches so content hits (messages/files/links/routines) keep most of the budget. */
@ -64,7 +65,8 @@ export async function querySpaceSearch(
bucket.push(hit);
}
const bots = await prisma.bot.findMany({
const [bots, groups, artifacts, groupArtifacts, routines] = await Promise.all([
prisma.bot.findMany({
where: {
spaceId: actor.spaceId,
userId: actor.userId,
@ -76,7 +78,52 @@ export async function querySpaceSearch(
],
},
take: CONVERSATION_HIT_LIMIT,
});
}),
prisma.chatGroup.findMany({
where: {
spaceId: actor.spaceId,
userId: actor.userId,
name: { contains: query, mode: "insensitive" },
},
take: CONVERSATION_HIT_LIMIT,
}),
prisma.artifact.findMany({
where: {
spaceId: actor.spaceId,
userId: actor.userId,
groupId: null,
botId: { not: null },
name: { contains: query, mode: "insensitive" },
bot: { archivedAt: null },
},
include: { bot: { select: { name: true } } },
take: SEARCH_LIMIT,
}),
prisma.artifact.findMany({
where: {
spaceId: actor.spaceId,
userId: actor.userId,
groupId: { not: null },
name: { contains: query, mode: "insensitive" },
},
include: { group: { select: { name: true } } },
take: SEARCH_LIMIT,
}),
prisma.routine.findMany({
where: {
spaceId: actor.spaceId,
userId: actor.userId,
OR: [
{ name: { contains: query, mode: "insensitive" } },
{ prompt: { contains: query, mode: "insensitive" } },
],
bot: { archivedAt: null },
},
include: { bot: { select: { name: true } } },
take: SEARCH_LIMIT,
}),
]);
for (const bot of bots) {
pushInto(
conversationHits,
@ -91,14 +138,6 @@ export async function querySpaceSearch(
);
}
const groups = await prisma.chatGroup.findMany({
where: {
spaceId: actor.spaceId,
userId: actor.userId,
name: { contains: query, mode: "insensitive" },
},
take: CONVERSATION_HIT_LIMIT,
});
for (const group of groups) {
pushInto(
conversationHits,
@ -114,27 +153,72 @@ export async function querySpaceSearch(
}
const contentBudget = Math.max(0, SEARCH_LIMIT - conversationHits.length);
const pattern = `%${query}%`;
const artifacts = await prisma.artifact.findMany({
where: {
spaceId: actor.spaceId,
userId: actor.userId,
groupId: null,
botId: { not: null },
name: { contains: query, mode: "insensitive" },
bot: { archivedAt: null },
},
include: { bot: { select: { name: true } } },
take: SEARCH_LIMIT,
});
const botArtifactMessages = await findArtifactMessages(
const [botArtifactMessages, groupArtifactMessages, messageRows, groupMessageRows] =
await Promise.all([
findArtifactMessages(
prisma,
actor,
artifacts.flatMap((artifact) =>
artifact.botId && artifact.bot ? [{ id: artifact.id, targetId: artifact.botId }] : [],
),
"bot",
);
),
findArtifactMessages(
prisma,
actor,
groupArtifacts.flatMap((artifact) =>
artifact.groupId && artifact.group
? [{ id: artifact.id, targetId: artifact.groupId }]
: [],
),
"group",
),
prisma.$queryRaw<
Array<{
id: string;
threadId: string;
seq: number;
blocks: Prisma.JsonValue;
botId: string;
botName: string;
}>
>`
SELECT m.id, m."threadId", m.seq, m.blocks, b.id AS "botId", b.name AS "botName"
FROM messages m
INNER JOIN threads t ON t.id = m."threadId"
INNER JOIN bots b ON b.id = t."botId"
WHERE t."spaceId" = ${actor.spaceId}
AND t."userId" = ${actor.userId}
AND b."archivedAt" IS NULL
AND m.blocks::text ILIKE ${pattern}
ORDER BY m."createdAt" DESC
LIMIT ${SEARCH_LIMIT}
`,
prisma.$queryRaw<
Array<{
id: string;
threadId: string;
seq: number;
blocks: Prisma.JsonValue;
groupId: string;
groupName: string;
}>
>`
SELECT m.id, m."threadId", m.seq, m.blocks, g.id AS "groupId", g.name AS "groupName"
FROM messages m
INNER JOIN threads t ON t.id = m."threadId"
INNER JOIN chat_groups g ON g.id = t."groupId"
WHERE t."spaceId" = ${actor.spaceId}
AND t."userId" = ${actor.userId}
AND t."groupId" IS NOT NULL
AND m.blocks::text ILIKE ${pattern}
ORDER BY m."createdAt" DESC
LIMIT ${SEARCH_LIMIT}
`,
]);
for (const artifact of artifacts) {
if (!artifact.botId || !artifact.bot) continue;
const message = botArtifactMessages.get(artifact.id);
@ -155,24 +239,6 @@ export async function querySpaceSearch(
);
}
const groupArtifacts = await prisma.artifact.findMany({
where: {
spaceId: actor.spaceId,
userId: actor.userId,
groupId: { not: null },
name: { contains: query, mode: "insensitive" },
},
include: { group: { select: { name: true } } },
take: SEARCH_LIMIT,
});
const groupArtifactMessages = await findArtifactMessages(
prisma,
actor,
groupArtifacts.flatMap((artifact) =>
artifact.groupId && artifact.group ? [{ id: artifact.id, targetId: artifact.groupId }] : [],
),
"group",
);
for (const artifact of groupArtifacts) {
if (!artifact.groupId || !artifact.group) continue;
const message = groupArtifactMessages.get(artifact.id);
@ -193,19 +259,6 @@ export async function querySpaceSearch(
);
}
const routines = await prisma.routine.findMany({
where: {
spaceId: actor.spaceId,
userId: actor.userId,
OR: [
{ name: { contains: query, mode: "insensitive" } },
{ prompt: { contains: query, mode: "insensitive" } },
],
bot: { archivedAt: null },
},
include: { bot: { select: { name: true } } },
take: SEARCH_LIMIT,
});
for (const routine of routines) {
pushInto(
contentHits,
@ -221,29 +274,6 @@ export async function querySpaceSearch(
);
}
const pattern = `%${query}%`;
const messageRows = await prisma.$queryRaw<
Array<{
id: string;
threadId: string;
seq: number;
blocks: Prisma.JsonValue;
botId: string;
botName: string;
}>
>`
SELECT m.id, m."threadId", m.seq, m.blocks, b.id AS "botId", b.name AS "botName"
FROM messages m
INNER JOIN threads t ON t.id = m."threadId"
INNER JOIN bots b ON b.id = t."botId"
WHERE t."spaceId" = ${actor.spaceId}
AND t."userId" = ${actor.userId}
AND b."archivedAt" IS NULL
AND m.blocks::text ILIKE ${pattern}
ORDER BY m."createdAt" DESC
LIMIT ${SEARCH_LIMIT}
`;
for (const row of messageRows) {
pushMessageHits(row.blocks as MessageBlock[], {
botId: row.botId,
@ -255,28 +285,6 @@ export async function querySpaceSearch(
});
}
const groupMessageRows = await prisma.$queryRaw<
Array<{
id: string;
threadId: string;
seq: number;
blocks: Prisma.JsonValue;
groupId: string;
groupName: string;
}>
>`
SELECT m.id, m."threadId", m.seq, m.blocks, g.id AS "groupId", g.name AS "groupName"
FROM messages m
INNER JOIN threads t ON t.id = m."threadId"
INNER JOIN chat_groups g ON g.id = t."groupId"
WHERE t."spaceId" = ${actor.spaceId}
AND t."userId" = ${actor.userId}
AND t."groupId" IS NOT NULL
AND m.blocks::text ILIKE ${pattern}
ORDER BY m."createdAt" DESC
LIMIT ${SEARCH_LIMIT}
`;
for (const row of groupMessageRows) {
pushMessageHits(row.blocks as MessageBlock[], {
groupId: row.groupId,

View File

@ -90,7 +90,7 @@ describe("server update install kind", () => {
if (href.endsWith("/state")) {
return new Response(
JSON.stringify({
image: "ghcr.io/elie222/rakazo/app",
image: "ghcr.io/cynarai/evoo-local/app",
currentTag: "sha-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
previousTag: "sha-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
running: false,
@ -98,7 +98,7 @@ describe("server update install kind", () => {
present: true,
commit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
branch: "main",
remoteUrl: "https://github.com/elie222/rakazo",
remoteUrl: "https://github.com/cynarAI/evOo-local",
dirty: false,
dirtyPaths: [],
},
@ -134,7 +134,7 @@ describe("server update install kind", () => {
fromTag: "sha-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
toTag: "sha-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
strategy: "pull",
repoUrl: "https://github.com/elie222/rakazo",
repoUrl: "https://github.com/cynarAI/evOo-local",
branch: "main",
restart: "not-required",
restartAdvice: "Recreate failed; prior image restored, env pin not restored.",
@ -149,7 +149,7 @@ describe("server update install kind", () => {
if (href.endsWith("/state")) {
return new Response(
JSON.stringify({
image: "ghcr.io/elie222/rakazo/app",
image: "ghcr.io/cynarai/evoo-local/app",
currentTag: "sha-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
previousTag: "sha-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
running: false,
@ -158,7 +158,7 @@ describe("server update install kind", () => {
present: true,
commit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
branch: "main",
remoteUrl: "https://github.com/elie222/rakazo",
remoteUrl: "https://github.com/cynarAI/evOo-local",
dirty: false,
dirtyPaths: [],
},

View File

@ -713,7 +713,7 @@ export class TeamChatBridge {
const blocks = Array.isArray(response?.blocks) ? (response.blocks as MessageBlock[]) : [];
const content =
run.status === "failed"
? `${target.name} could not complete the delegated request. Open Rakazo for details.`
? `${target.name} could not complete the delegated request. Open evOo for details.`
: teamChatResponseText(blocks, target.name);
if (!content) continue;
await this.deps.send({
@ -1160,7 +1160,7 @@ export class TeamChatBridge {
await this.sendOnce(message.id, {
conversationId: message.externalConversation.conversationId,
replyThreadId: message.replyThreadId,
content: `${this.target?.name ?? "The agent"} could not complete that request. Open Rakazo for details.`,
content: `${this.target?.name ?? "The agent"} could not complete that request. Open evOo for details.`,
idempotencyKey: `external-message:${message.id}:failure`,
});
}

View File

@ -88,4 +88,77 @@ describe("model team chat engagement judge", () => {
expect(instructions).toContain("(Europe/Berlin, the user's time zone)");
expect(instructions).toContain("low-cost engagement judge");
});
it("prefers the space fast model tier when mapped and no explicit judge override is set", async () => {
let selectedModel: { provider: string; id: string; apiKey?: string } | undefined;
const runtime = {
run: vi.fn((request: { model: { provider: string; id: string; apiKey?: string } }) => {
selectedModel = request.model;
return (async function* () {
yield { type: "done" as const, text: '{"act":true,"reason":"Direct mention."}' };
})();
}),
} as unknown as AgentRuntime;
const prisma = {
deploymentSettings: { findUnique: vi.fn(async () => null) },
spaceModelPreference: { findFirst: vi.fn(async () => null) },
spaceModelTier: {
findUnique: vi.fn(async () => ({
tier: "fast",
credentialId: "cred-fast",
modelId: "gemma4:latest",
credential: {
id: "cred-fast",
userId: "user-1",
provider: "openai-compatible",
secretId: "sec-fast",
preferences: [{ isDefault: false, modelId: null }],
},
})),
},
secret: {
findFirst: vi.fn(async () => ({ id: "sec-fast", ciphertext: "cipher-fast" })),
},
userProfile: { findUnique: vi.fn(async () => ({ timeZone: "UTC" })) },
} as unknown as PrismaClient;
const secrets = {
load: vi.fn(() =>
JSON.stringify({
kind: "openai_compatible",
apiKey: "local-key",
baseUrl: "http://127.0.0.1:11434/v1",
}),
),
} as unknown as EncryptedSecretStore;
const judge = new ModelTeamChatEngagementJudge({
prisma,
runtime,
secrets,
deploymentProvider: "openrouter",
deploymentModel: "expensive-default",
});
const decision = await judge.decide({
bot: {
id: "bot-1",
spaceId: "space-1",
userId: "user-1",
name: "Arthur",
modelProvider: "anthropic",
modelId: "claude-opus",
},
channelId: "C1",
rules: "",
messages: [],
});
expect(decision).toEqual({ act: true, reason: "Direct mention.", askedByEventId: undefined });
expect(selectedModel).toMatchObject({
provider: "openai-compatible",
id: "gemma4:latest",
apiKey: "local-key",
baseUrl: "http://127.0.0.1:11434/v1",
});
});
});

View File

@ -1,14 +1,15 @@
import { randomUUID } from "node:crypto";
import type { AgentModelOAuthCredential, AgentRuntime } from "@rakazo/adapter-kit";
import type { EncryptedSecretStore } from "@rakazo/adapters";
import {
type EncryptedSecretStore,
formatCurrentTimeInstruction,
loadUserTimeZone,
resolveModelAuth,
serializeModelSecret,
toOAuthCredential,
} from "@rakazo/adapters";
import { findDefaultModelCredential, findModelCredential, type PrismaClient } from "@rakazo/db";
import type { PrismaClient } from "@rakazo/db";
import { findDefaultModelCredential, findModelCredential, findSpaceModelTier } from "@rakazo/db";
import { getLogger } from "@rakazo/logging";
const MAX_RULES_CHARS = 4_000;
@ -191,27 +192,39 @@ export class ModelTeamChatEngagementJudge implements TeamChatEngagementJudge {
};
};
} | null> {
const settings = await this.deps.prisma.deploymentSettings.findUnique({
where: { id: "default" },
});
const scope = { userId: bot.userId, spaceId: bot.spaceId };
const defaultCredential = await findDefaultModelCredential(this.deps.prisma, scope);
const explicitJudgeOverride = Boolean(this.deps.providerOverride && this.deps.modelOverride);
const fastTier =
!explicitJudgeOverride && this.deps.prisma.spaceModelTier
? await findSpaceModelTier(this.deps.prisma, scope, "fast")
: null;
const defaultCredential = fastTier
? null
: await findDefaultModelCredential(this.deps.prisma, scope);
const requestedProvider = this.deps.providerOverride ?? bot.modelProvider;
const requestedModel = this.deps.modelOverride ?? bot.modelId;
const overrideCredential = requestedProvider
const overrideCredential =
!fastTier && requestedProvider
? await findModelCredential(this.deps.prisma, scope, requestedProvider)
: null;
const explicitJudgeOverride = Boolean(this.deps.providerOverride && this.deps.modelOverride);
const useOverride = Boolean(
requestedProvider && requestedModel && (explicitJudgeOverride || overrideCredential),
);
const credential = useOverride ? overrideCredential : defaultCredential;
const credential =
fastTier?.credential ?? (useOverride ? overrideCredential : defaultCredential);
const settings = fastTier
? null
: await this.deps.prisma.deploymentSettings.findUnique({
where: { id: "default" },
});
const provider =
fastTier?.credential.provider ??
(useOverride ? requestedProvider : null) ??
credential?.provider ??
settings?.defaultModelProvider ??
this.deps.deploymentProvider;
const modelId =
fastTier?.modelId ??
(useOverride ? requestedModel : null) ??
credential?.defaultModel ??
settings?.defaultModelId ??

View File

@ -2,15 +2,17 @@ import type { SandboxProvider } from "@rakazo/adapter-kit";
import type { Actor } from "@rakazo/contracts";
import type { PrismaClient } from "@rakazo/db";
import { describe, expect, it, vi } from "vitest";
import type { ThreadTarget } from "./thread-target.js";
import {
cancelSupersededQueuedRuns,
followUpGroupThreadMessage,
groupDefaultBotId,
reactToThreadMessage,
resolveGroupFollowUpBotId,
resumeThreadRuns,
sendQueuedThreadMessagesNow,
sendThreadMessage,
stopThreadRuns,
type ThreadTarget,
threadHead,
threadSnapshot,
} from "./thread-target.js";
@ -211,7 +213,13 @@ describe("threadSnapshot", () => {
expect.objectContaining({
where: expect.objectContaining({
type: {
in: ["thread.progress", "thread.subagent", "agent.tool.called", "agent.tool.completed"],
in: [
"thread.message.created",
"thread.progress",
"thread.subagent",
"agent.tool.called",
"agent.tool.completed",
],
},
}),
}),
@ -230,6 +238,69 @@ describe("threadSnapshot", () => {
]);
});
it("does not reload live narration that was already posted as a message", async () => {
const run = {
id: "run-1",
botId: "bot-1",
threadId: "thread-1",
taskId: "task-1",
status: "running",
trigger: "user",
modelProvider: null,
modelId: null,
error: null,
startedAt: null,
completedAt: null,
createdAt: new Date("2026-08-23T00:00:00.000Z"),
};
const at = new Date("2026-08-23T00:00:00.000Z");
const event = (seq: number, type: string, payload: Record<string, unknown>) => ({
id: `event-${seq}`,
threadId: "thread-1",
botId: "bot-1",
seq,
type,
runId: "run-1",
payload,
createdAt: at,
});
const tx = {
$queryRaw: threadQueries(),
message: { findMany: vi.fn().mockResolvedValue([]) },
event: {
findFirst: vi.fn().mockResolvedValue({ seq: 6 }),
findMany: vi.fn().mockResolvedValue([
event(4, "thread.progress", { text: "Opening the page." }),
event(5, "thread.message.created", {
messageId: "message-1",
role: "bot",
blocks: [{ kind: "text", text: "Opening the page." }],
}),
event(6, "thread.progress", { text: "Writing the summary." }),
]),
},
run: { findFirst: botRunFindFirst([run]) },
};
const prisma = {
$transaction: vi.fn(async (callback: (client: typeof tx) => unknown) => callback(tx)),
} as unknown as PrismaClient;
const target = {
kind: "bot",
botId: "bot-1",
threadId: "thread-1",
bot: { computer: null },
} as ThreadTarget;
const snapshot = await threadSnapshot({ prisma }, target);
expect(snapshot.messages).toEqual([
expect.objectContaining({
id: "progress:run-1",
blocks: [{ kind: "progress", text: "Writing the summary." }],
}),
]);
});
it("returns the latest failed run so the client can show its error", async () => {
const run = {
id: "run-failed",
@ -961,10 +1032,13 @@ function groupTarget() {
}
describe("sendThreadMessage", () => {
it("rejects a new bot message while a run is waiting on input", async () => {
it("steers a waiting-input run instead of refusing the message", async () => {
const tx = {
thread: {
update: vi.fn().mockResolvedValue({ nextMessageSeq: 2 }),
update: vi
.fn()
.mockResolvedValueOnce({ nextMessageSeq: 2 })
.mockResolvedValueOnce({ nextEventSeq: 3 }),
},
message: {
create: vi.fn().mockResolvedValue({
@ -972,7 +1046,7 @@ describe("sendThreadMessage", () => {
threadId: "thread-1",
seq: 1,
role: "user",
blocks: [{ kind: "text", text: "hi" }],
blocks: [{ kind: "text", text: "more context" }],
botId: null,
replyToMessageId: null,
runId: null,
@ -984,9 +1058,13 @@ describe("sendThreadMessage", () => {
findMany: vi
.fn()
.mockResolvedValue([{ id: "run-waiting", taskId: "task-1", status: "waiting_input" }]),
findUnique: vi.fn().mockResolvedValue({
status: "waiting_input",
startedAt: new Date(),
}),
},
steeringMessage: { create: vi.fn() },
event: { create: vi.fn() },
event: { create: vi.fn().mockResolvedValue({ seq: 2 }) },
task: { create: vi.fn() },
};
const prisma = {
@ -1000,26 +1078,37 @@ describe("sendThreadMessage", () => {
threadId: "thread-1",
bot: { computer: null },
} as ThreadTarget;
const enqueue = vi.fn().mockResolvedValue(undefined);
await expect(
sendThreadMessage(
{
prisma,
events: { notify: vi.fn() } as never,
jobs: { enqueue: vi.fn() } as never,
events: { notify: vi.fn().mockResolvedValue(undefined) } as never,
jobs: { enqueue } as never,
},
actor,
target,
{
text: "hi",
text: "more context",
clientNonce: "nonce-1",
},
),
).rejects.toMatchObject({
code: "CONFLICT",
message: "Answer the pending ask first.",
).resolves.toMatchObject({
runId: "run-waiting",
taskId: "task-1",
seq: 1,
runIds: ["run-waiting"],
});
expect(tx.steeringMessage.create).not.toHaveBeenCalled();
expect(tx.steeringMessage.create).toHaveBeenCalledWith({
data: {
messageId: "msg-1",
botId: "bot-1",
userId: "user-1",
runId: "run-waiting",
},
});
expect(enqueue).toHaveBeenCalledWith(expect.objectContaining({ name: "run.continue" }));
});
it("steers a waiting-takeover run instead of refusing the message", async () => {
@ -2031,4 +2120,192 @@ describe("stopThreadRuns", () => {
expect.objectContaining({ runId: "run-b" }),
);
});
it("sendQueuedThreadMessagesNow cancels the active run, preserves pending steering, and enqueues an immediate continuation run", async () => {
const queuedCreatedAt = new Date("2026-01-01T10:00:05.000Z");
const transaction = {
$queryRaw: vi.fn(),
steeringMessage: {
findMany: vi.fn().mockResolvedValue([
{
id: "steer-1",
botId: "bot-a",
userId: "user-1",
messageId: "msg-queued-1",
claimedAt: null,
message: {
id: "msg-queued-1",
blocks: [{ kind: "text", text: "Send this right now" }],
seq: 2,
createdAt: queuedCreatedAt,
},
},
]),
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
},
run: {
updateManyAndReturn: vi.fn().mockResolvedValue([{ id: "run-old", taskId: "task-old" }]),
create: vi.fn().mockResolvedValue({ id: "run-new", status: "queued" }),
},
task: {
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
create: vi.fn().mockResolvedValue({ id: "task-new" }),
},
message: {
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
},
computerExecutionLease: {
findMany: vi.fn().mockResolvedValue([]),
},
computer: {
findMany: vi.fn().mockResolvedValue([]),
},
event: {
findFirst: vi.fn().mockResolvedValue({ seq: 42 }),
},
};
const prisma = {
$transaction: vi.fn(async (callback: (client: typeof transaction) => unknown) =>
callback(transaction),
),
computerExecutionLease: {
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
},
computer: {
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
},
event: {
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
},
} as unknown as PrismaClient;
const jobs = {
enqueue: vi.fn().mockResolvedValue(undefined),
cancel: vi.fn().mockResolvedValue(undefined),
};
const events = {
notify: vi.fn().mockResolvedValue(undefined),
};
const actor = { spaceId: "workspace-1", userId: "user-1" } as Actor;
const target = {
kind: "bot",
botId: "bot-a",
threadId: "thread-1",
bot: {} as never,
} satisfies ThreadTarget;
const result = await sendQueuedThreadMessagesNow(
{
prisma,
sandbox: {} as SandboxProvider,
events: events as never,
jobs: jobs as never,
},
actor,
target,
{ messageId: "msg-queued-1" },
);
expect(result).toEqual({ ok: true, runIds: ["run-new"] });
expect(transaction.run.updateManyAndReturn).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
threadId: "thread-1",
botId: { in: ["bot-a"] },
}),
data: expect.objectContaining({ status: "cancelled" }),
}),
);
expect(transaction.steeringMessage.updateMany).toHaveBeenCalledWith({
where: { id: { in: ["steer-1"] } },
data: { runId: "run-new", claimedAt: null },
});
expect(jobs.cancel).toHaveBeenCalledWith("run:run-old");
expect(jobs.enqueue).toHaveBeenCalledWith({
name: "run.continue",
payload: { runId: "run-new" },
replaceKey: "run:run-new",
});
expect(events.notify).toHaveBeenCalledWith("thread-1", 42);
});
});
describe("resumeThreadRuns", () => {
it("re-queues the latest cancelled or failed run without creating a duplicate message", async () => {
const transaction = {
$queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]),
run: {
findMany: vi
.fn()
.mockResolvedValueOnce([])
.mockResolvedValueOnce([
{
id: "run-cancelled",
taskId: "task-cancelled",
botId: "bot-a",
status: "cancelled",
createdAt: new Date("2026-04-20T10:00:00.000Z"),
completedAt: new Date("2026-04-20T10:02:00.000Z"),
},
]),
update: vi.fn().mockResolvedValue({}),
},
task: {
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
},
event: {
findFirst: vi.fn().mockResolvedValue({ seq: 19 }),
},
};
const prisma = {
$transaction: vi.fn(async (fn: (tx: typeof transaction) => Promise<unknown>) =>
fn(transaction),
),
} as unknown as PrismaClient;
const jobs = {
enqueue: vi.fn().mockResolvedValue(undefined),
};
const events = {
notify: vi.fn().mockResolvedValue(undefined),
};
const actor = { spaceId: "workspace-1", userId: "user-1" } as Actor;
const target = {
kind: "bot",
botId: "bot-a",
threadId: "thread-1",
bot: {} as never,
} satisfies ThreadTarget;
const result = await resumeThreadRuns(
{
prisma,
events: events as never,
jobs: jobs as never,
},
actor,
target,
);
expect(result).toEqual({ ok: true, runIds: ["run-cancelled"] });
expect(transaction.task.updateMany).toHaveBeenCalledWith({
where: { id: "task-cancelled" },
data: { status: "queued" },
});
expect(transaction.run.update).toHaveBeenCalledWith({
where: { id: "run-cancelled" },
data: {
status: "queued",
error: null,
completedAt: null,
leaseOwner: null,
leaseExpiresAt: null,
},
});
expect(jobs.enqueue).toHaveBeenCalledWith({
name: "run.continue",
payload: { runId: "run-cancelled" },
replaceKey: "run:run-cancelled",
});
expect(events.notify).toHaveBeenCalledWith("thread-1", 19);
});
});

View File

@ -1,5 +1,10 @@
import { ORPCError } from "@orpc/server";
import { type JobPublisher, runContinueJob, type SandboxProvider } from "@rakazo/adapter-kit";
import {
type JobPublisher,
runContinueJob,
runJobKey,
type SandboxProvider,
} from "@rakazo/adapter-kit";
import { cancelComputerRunWork, screenLeaseIdForRun, toComputerRef } from "@rakazo/adapters";
import {
type Actor,
@ -160,9 +165,15 @@ function quoteAppearsInBlocks(quote: string, blocks: MessageBlock[]): boolean {
}
const THREAD_MESSAGE_PAGE_SIZE = 100;
const RUNS_NEEDING_CONTINUE = new Set(["queued", "waiting_takeover"]);
const RUNS_NEEDING_CONTINUE = new Set(["queued", "waiting_input", "waiting_takeover"]);
const STEERABLE_RUN_STATUSES = new Set(["queued", "leased", "running", "waiting_takeover"]);
const STEERABLE_RUN_STATUSES = new Set([
"queued",
"leased",
"running",
"waiting_input",
"waiting_takeover",
]);
type MentionTargetInput = string | { kind: "bot" | "group" | "routine" | "connector"; id: string };
@ -491,6 +502,7 @@ export async function threadSnapshot(
runId: currentRun.id,
type: {
in: [
"thread.message.created",
"thread.progress",
"thread.subagent",
"agent.tool.called",
@ -556,6 +568,7 @@ export async function threadSnapshot(
runId: { in: activeRuns.map((run) => run.id) },
type: {
in: [
"thread.message.created",
"thread.progress",
"thread.subagent",
"agent.tool.called",
@ -631,8 +644,12 @@ function messagesWithLiveEvents(
persisted: ThreadSnapshot["messages"],
liveEvents: Parameters<typeof projectMessages>[0],
) {
// Message events only clear the live text they promoted; the persisted rows already carry them.
const live = projectMessages(liveEvents).filter((message) => {
if (message.blocks.some((block) => block.kind === "progress" || block.kind === "steps")) {
if (
message.id.startsWith("progress:") &&
message.blocks.some((block) => block.kind === "progress" || block.kind === "steps")
) {
return true;
}
if (!message.id.startsWith("subagent:")) return false;
@ -1139,51 +1156,27 @@ export async function reactToThreadMessage(
});
}
export async function stopThreadRuns(
deps: {
prisma: PrismaClient;
sandbox: SandboxProvider;
},
actor: Actor,
target: ThreadTarget,
) {
const { runIds, computers, leases } = await deps.prisma.$transaction(async (tx) => {
await tx.$queryRaw`SELECT id FROM threads WHERE id = ${target.threadId} FOR UPDATE`;
const cancelled = await tx.run.updateManyAndReturn({
where: {
threadId: target.threadId,
status: { in: [...ACTIVE_RUN_STATUSES] },
},
data: { status: "cancelled", completedAt: new Date() },
select: { id: true },
});
const ids = cancelled.map((run) => run.id);
await tx.steeringMessage.deleteMany({
where: {
botId: { in: target.kind === "bot" ? [target.botId] : target.memberBotIds },
message: { threadId: target.threadId },
},
});
async function snapshotCancelledRunTeardown(tx: Prisma.TransactionClient, runIds: string[]) {
// Snapshot teardown coordinates before commit. Once cancellation becomes
// visible, a worker can release its lease / execution columns immediately; a
// later lookup would then miss the sandbox work this request must stop.
// Team ownership lives on ComputerExecutionLease; Computer.executionRunId is
// only a legacy secondary path and is not written by current acquisition.
const leases = ids.length
const leases = runIds.length
? await tx.computerExecutionLease.findMany({
where: { runId: { in: ids } },
where: { runId: { in: runIds } },
select: { computerId: true, botId: true, runId: true, fence: true },
})
: [];
const leaseComputerIds = [...new Set(leases.map((lease) => lease.computerId))];
const computers = ids.length
const computers = runIds.length
? await tx.computer.findMany({
where:
leaseComputerIds.length > 0
? {
OR: [{ id: { in: leaseComputerIds } }, { executionRunId: { in: ids } }],
OR: [{ id: { in: leaseComputerIds } }, { executionRunId: { in: runIds } }],
}
: { executionRunId: { in: ids } },
: { executionRunId: { in: runIds } },
select: {
id: true,
homeKey: true,
@ -1194,8 +1187,19 @@ export async function stopThreadRuns(
},
})
: [];
return { runIds: ids, computers, leases };
});
return { runIds, computers, leases };
}
async function teardownCancelledRuns(
deps: {
prisma: PrismaClient;
sandbox: SandboxProvider;
},
actor: Actor,
snapshot: Awaited<ReturnType<typeof snapshotCancelledRunTeardown>>,
) {
const { runIds, computers, leases } = snapshot;
if (runIds.length === 0) return;
// Keep the DB lease until after teardown so a replacement run cannot claim the
// screen while we still need the cancelled run's screenLeaseId to release it.
const computerById = new Map(computers.map((computer) => [computer.id, computer]));
@ -1273,6 +1277,381 @@ export async function stopThreadRuns(
});
}
export async function stopThreadRuns(
deps: {
prisma: PrismaClient;
sandbox: SandboxProvider;
},
actor: Actor,
target: ThreadTarget,
) {
const snapshot = await deps.prisma.$transaction(async (tx) => {
await tx.$queryRaw`SELECT id FROM threads WHERE id = ${target.threadId} FOR UPDATE`;
const cancelled = await tx.run.updateManyAndReturn({
where: {
threadId: target.threadId,
status: { in: [...ACTIVE_RUN_STATUSES] },
},
data: { status: "cancelled", completedAt: new Date() },
select: { id: true },
});
const ids = cancelled.map((run) => run.id);
await tx.steeringMessage.deleteMany({
where: {
botId: { in: target.kind === "bot" ? [target.botId] : target.memberBotIds },
message: { threadId: target.threadId },
},
});
return snapshotCancelledRunTeardown(tx, ids);
});
await teardownCancelledRuns(deps, actor, snapshot);
}
/**
* Resume the latest stopped (`cancelled`) or interrupted (`failed`) run in the thread
* without creating a duplicate user message row.
*/
export async function resumeThreadRuns(
deps: {
prisma: PrismaClient;
events: ThreadEvents;
jobs: JobPublisher;
},
actor: Actor,
target: ThreadTarget,
) {
const botIds = target.kind === "bot" ? [target.botId] : target.memberBotIds;
const committed = await deps.prisma.$transaction(async (tx) => {
await tx.$queryRaw`SELECT id FROM threads WHERE id = ${target.threadId} FOR UPDATE`;
const activeRuns = await tx.run.findMany({
where: {
threadId: target.threadId,
botId: { in: botIds },
status: { in: [...ACTIVE_RUN_STATUSES] },
},
select: { id: true, status: true },
});
if (activeRuns.length > 0) {
return {
runs: activeRuns,
eventSeq: null as number | null,
};
}
const recentTerminals = await tx.run.findMany({
where: {
threadId: target.threadId,
botId: { in: botIds },
trigger: { not: "bot_message" },
status: { in: ["failed", "completed", "cancelled"] },
},
orderBy: [{ updatedAt: "desc" }, { id: "desc" }],
take: 20,
select: {
id: true,
botId: true,
taskId: true,
status: true,
createdAt: true,
completedAt: true,
},
});
const latestTerminal = pickLatestTerminalRun(recentTerminals);
if (
!latestTerminal ||
(latestTerminal.status !== "cancelled" && latestTerminal.status !== "failed")
) {
return {
runs: [] as Array<{ id: string; status: string }>,
eventSeq: null as number | null,
};
}
await tx.task.updateMany({
where: { id: latestTerminal.taskId },
data: { status: "queued" },
});
await tx.run.update({
where: { id: latestTerminal.id },
data: {
status: "queued",
error: null,
completedAt: null,
leaseOwner: null,
leaseExpiresAt: null,
},
});
if (target.kind === "group") {
await touchGroupUpdatedAt(tx, target.groupId);
}
const latestEvent = await tx.event.findFirst({
where: { threadId: target.threadId },
orderBy: { seq: "desc" },
select: { seq: true },
});
return {
runs: [{ id: latestTerminal.id, status: "queued" }],
eventSeq: latestEvent?.seq ?? null,
};
});
if (committed.eventSeq !== null) {
await deps.events.notify(target.threadId, committed.eventSeq).catch((error) => {
getLogger().error("thread resume realtime notification", error);
});
}
await enqueueRunsNeedingContinue(deps.jobs, committed.runs);
return {
ok: true as const,
runIds: committed.runs.map((run) => run.id),
};
}
/**
* Promote queued follow-up messages immediately: cancel any currently active runs
* holding up the queue without discarding unclaimed steering messages, create a
* new continuation run for the pending steering messages, and enqueue it right away.
*/
export async function sendQueuedThreadMessagesNow(
deps: {
prisma: PrismaClient;
sandbox: SandboxProvider;
events: ThreadEvents;
jobs: JobPublisher;
},
actor: Actor,
target: ThreadTarget,
input?: { messageId?: string },
) {
const botIds = target.kind === "bot" ? [target.botId] : target.memberBotIds;
const committed = await deps.prisma.$transaction(async (tx) => {
await tx.$queryRaw`SELECT id FROM threads WHERE id = ${target.threadId} FOR UPDATE`;
const pendingSteering = await tx.steeringMessage.findMany({
where: {
botId: { in: botIds },
claimedAt: null,
message: { threadId: target.threadId },
},
include: {
message: { select: { id: true, blocks: true, seq: true, createdAt: true } },
},
orderBy: [{ message: { seq: "asc" } }, { id: "asc" }],
});
const pendingByBotId = new Map<string, typeof pendingSteering>();
for (const item of pendingSteering) {
const list = pendingByBotId.get(item.botId) ?? [];
list.push(item);
pendingByBotId.set(item.botId, list);
}
if (pendingByBotId.size === 0 && input?.messageId) {
const targetMsg = await tx.message.findFirst({
where: { id: input.messageId, threadId: target.threadId, role: "user" },
select: { id: true, blocks: true, seq: true, createdAt: true },
});
if (targetMsg) {
const activeRuns = await tx.run.findMany({
where: {
threadId: target.threadId,
botId: { in: botIds },
status: { in: [...ACTIVE_RUN_STATUSES] },
createdAt: { lt: targetMsg.createdAt },
},
select: { id: true, botId: true },
});
const fallbackBotId =
activeRuns[0]?.botId ??
(target.kind === "bot" ? target.botId : groupDefaultBotId(target));
if (fallbackBotId && activeRuns.length > 0) {
const steering = await tx.steeringMessage.create({
data: {
messageId: targetMsg.id,
botId: fallbackBotId,
userId: actor.userId,
runId: activeRuns[0]?.id ?? null,
},
});
pendingByBotId.set(fallbackBotId, [{ ...steering, message: targetMsg }]);
}
}
}
if (pendingByBotId.size === 0) {
return {
teardown: { runIds: [] as string[], computers: [], leases: [] },
runs: [] as Array<{ id: string; status: string }>,
eventSeq: null as number | null,
};
}
const affectedBotIds = [...pendingByBotId.keys()];
const now = new Date();
const cancelled = await tx.run.updateManyAndReturn({
where: {
threadId: target.threadId,
botId: { in: affectedBotIds },
status: { in: [...ACTIVE_RUN_STATUSES] },
},
data: { status: "cancelled", completedAt: now },
select: { id: true, taskId: true },
});
const cancelledRunIds = cancelled.map((run) => run.id);
if (cancelled.length > 0) {
await tx.task.updateMany({
where: { id: { in: cancelled.map((run) => run.taskId) } },
data: { status: "cancelled" },
});
await tx.steeringMessage.deleteMany({
where: {
runId: { in: cancelledRunIds },
claimedAt: { not: null },
},
});
}
const teardown = await snapshotCancelledRunTeardown(tx, cancelledRunIds);
const createdRuns: Array<{ id: string; status: string }> = [];
for (const [botId, items] of pendingByBotId) {
const last = items.at(-1)!;
const task = await tx.task.create({
data: {
spaceId: actor.spaceId,
botId,
threadId: target.threadId,
userId: items[0]!.userId,
prompt: "Respond to the user's steering context.",
status: "queued",
},
});
const runCreatedAt = new Date(Math.max(Date.now(), last.message.createdAt.getTime() + 1));
const run = await tx.run.create({
data: {
spaceId: actor.spaceId,
botId,
threadId: target.threadId,
taskId: task.id,
userId: items[0]!.userId,
status: "queued",
trigger: "follow_up",
sourceMessageId: last.message.id,
createdAt: runCreatedAt,
},
select: { id: true, status: true },
});
await tx.steeringMessage.updateMany({
where: { id: { in: items.map((item) => item.id) } },
data: { runId: run.id, claimedAt: null },
});
await tx.message.updateMany({
where: { id: { in: items.map((item) => item.message.id) } },
data: { runId: run.id },
});
createdRuns.push(run);
}
if (target.kind === "group") {
await touchGroupUpdatedAt(tx, target.groupId);
}
const latestEvent = await tx.event.findFirst({
where: { threadId: target.threadId },
orderBy: { seq: "desc" },
select: { seq: true },
});
return {
teardown,
runs: createdRuns,
eventSeq: latestEvent?.seq ?? null,
};
});
if (committed.teardown.runIds.length > 0) {
await Promise.all([
teardownCancelledRuns(deps, actor, committed.teardown),
...committed.teardown.runIds.map((runId) =>
deps.jobs.cancel(runJobKey(runId)).catch(() => undefined),
),
]);
}
if (committed.runs.length > 0) {
await enqueueRunsNeedingContinue(deps.jobs, committed.runs);
if (committed.eventSeq != null) {
await deps.events.notify(target.threadId, committed.eventSeq).catch((error) => {
getLogger().error("thread sendNow realtime notification", error);
});
}
}
return {
ok: true as const,
runIds: committed.runs.map((run) => run.id),
};
}
export async function removeQueuedMessage(
deps: {
prisma: PrismaClient;
events: ThreadEvents;
},
actor: Actor,
target: ThreadTarget,
messageId: string,
) {
const botIds = target.kind === "bot" ? [target.botId] : target.memberBotIds;
const result = await deps.prisma.$transaction(async (tx) => {
// Verify the message exists and belongs to this thread
const message = await tx.message.findFirst({
where: {
id: messageId,
threadId: target.threadId,
role: "user",
},
});
if (!message) {
return { removed: false as const };
}
// Check if this message is queued (has an unclaimed steering message)
const steering = await tx.steeringMessage.findFirst({
where: {
messageId: messageId,
botId: { in: botIds },
claimedAt: null,
},
});
if (!steering) {
// No queued steering entry — nothing to remove
return { removed: false as const };
}
// Delete the steering message (unclaims it from the queue)
await tx.steeringMessage.delete({
where: { id: steering.id },
});
// Also clean up the user message if it's exclusively queued
// (no run attached to it)
if (!message.runId) {
await tx.message.delete({
where: { id: messageId },
});
}
return { removed: true as const };
});
if (result.removed) {
// Notify subscribers so the UI updates
const latest = await deps.prisma.event.findFirst({
where: { threadId: target.threadId },
orderBy: { seq: "desc" },
select: { seq: true },
});
await deps.events.notify(target.threadId, latest?.seq ?? 0).catch(() => undefined);
}
return { ok: true as const, removed: result.removed };
}
export async function setThreadUnreadState(
prisma: PrismaClient,
actor: Actor,

View File

@ -13,18 +13,18 @@ import {
describe("toVoiceStatus", () => {
it("treats a saved key without a voice as configured but not ready", () => {
expect(toVoiceStatus({ provider: "elevenlabs", voiceId: "" })).toEqual({
expect(toVoiceStatus({ provider: "kokoro", voiceId: "" })).toEqual({
configured: true,
ready: false,
transcribe: true,
provider: "elevenlabs",
transcribe: false,
provider: "kokoro",
voiceId: "",
});
});
it("is ready once a voice is chosen", () => {
expect(toVoiceStatus({ provider: "cartesia", voiceId: "katie" }).ready).toBe(true);
expect(toVoiceStatus({ provider: "cartesia", voiceId: "katie" }).transcribe).toBe(false);
expect(toVoiceStatus({ provider: "kokoro", voiceId: "df_eva" }).ready).toBe(true);
expect(toVoiceStatus({ provider: "kokoro", voiceId: "df_eva" }).transcribe).toBe(false);
});
it("is off when nothing is connected", () => {
@ -39,7 +39,7 @@ describe("toVoiceStatus", () => {
});
describe("voice HTTP routes", () => {
it("rejects unauthenticated speak and transcribe", async () => {
it("rejects unauthenticated speak, transcribe, and transcribe-stream", async () => {
const app = new Hono();
mountVoiceHttpRoutes(app, {} as VoiceDeps, async () => null);
const speak = await app.request("/api/voice/speak", {
@ -52,13 +52,20 @@ describe("voice HTTP routes", () => {
headers: { "content-type": "application/json" },
body: JSON.stringify({ audioBase64: "AAAA", mimeType: "audio/webm" }),
});
const transcribeStream = await app.request("/api/voice/transcribe-stream", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ audioBase64: "AAAA", mimeType: "audio/webm" }),
});
expect(speak.status).toBe(401);
expect(transcribe.status).toBe(401);
expect(transcribeStream.status).toBe(401);
});
it.each([
["/api/voice/speak", MAX_SPEAK_REQUEST_BYTES],
["/api/voice/transcribe", MAX_TRANSCRIBE_REQUEST_BYTES],
["/api/voice/transcribe-stream", MAX_TRANSCRIBE_REQUEST_BYTES],
])(
"rejects a declared oversized body on %s without waiting for cancellation",
async (path, max) => {
@ -112,6 +119,22 @@ describe("voice HTTP routes", () => {
expect(response.status).toBe(413);
expect(cancel).toHaveBeenCalledOnce();
});
it("rejects empty recording on transcribe-stream", async () => {
const app = new Hono();
mountVoiceHttpRoutes(
app,
{} as VoiceDeps,
async () => ({ userId: "user", spaceId: "space" }) as Actor,
);
const response = await app.request("/api/voice/transcribe-stream", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ audioBase64: "", mimeType: "audio/webm" }),
});
expect(response.status).toBe(400);
await expect(response.json()).resolves.toEqual({ error: "Recording is empty." });
});
});
const actor = { userId: "user-1", spaceId: "space-1" } as Actor;

View File

@ -25,7 +25,7 @@ import {
selectSpaceVoicePreference,
} from "@rakazo/db";
import type { Context, Hono } from "hono";
import { readBoundedBody } from "./http-body.js";
import { cancelBody, readBoundedBody } from "./http-body.js";
import { withSerializableRetry } from "./serializable-retry.js";
export interface VoiceDeps {
@ -96,7 +96,8 @@ export async function loadVoiceCredential(deps: VoiceDeps, actor: Actor, provide
const cred = provider
? await findVoiceCredential(deps.prisma, actor, provider)
: await findDefaultVoiceCredential(deps.prisma, actor);
if (!cred) return null;
// Credentials of providers that were removed from the catalog no longer speak.
if (!cred || !isVoiceProviderId(cred.provider)) return null;
const secret = await deps.prisma.secret.findFirst({
where: { id: cred.secretId, userId: actor.userId, spaceId: null },
});
@ -146,7 +147,11 @@ export async function persistVoiceCredential(
let voiceId = input.voiceId?.trim() ?? "";
if (!voiceId) {
const voices = await provider.listVoices(input.plaintext, voiceContext(actor, input.signal));
voiceId = voices[0]?.id ?? "";
const germanVoice =
input.provider === "kokoro"
? voices.find((v) => v.id === "df_eva" || /^d[fm]_/i.test(v.id))
: undefined;
voiceId = germanVoice?.id ?? voices[0]?.id ?? "";
}
const stored = await deps.secrets.put(input.plaintext, voiceContext(actor, input.signal));
const cred = await withSerializableRetry(() =>
@ -375,6 +380,113 @@ export function mountVoiceHttpRoutes(
return voiceHttpError(c, error);
}
});
app.post("/api/voice/transcribe-stream", async (c) => {
const actor = await authenticate(c);
if (!actor) return c.json({ error: "Unauthorized" }, 401);
const contentType = c.req.header("content-type") ?? "";
const isJson =
contentType.includes("application/json") ||
(!contentType.startsWith("audio/") && !contentType.includes("octet-stream"));
let audio: Uint8Array;
let mimeType = "audio/webm";
if (isJson) {
const raw = await readBoundedBody(c.req.raw, MAX_TRANSCRIBE_REQUEST_BYTES);
if (raw === null) return c.json({ error: "Request body is too large." }, 413);
const body = parseVoiceRequestBody(raw);
const audioBase64 = String((body as { audioBase64?: unknown }).audioBase64 ?? "");
try {
audio = decodeAudioBase64(audioBase64);
mimeType = String((body as { mimeType?: unknown }).mimeType ?? "audio/webm");
} catch (error) {
return voiceHttpError(c, error);
}
} else {
const rawBytes = await readBoundedBytes(c.req.raw, MAX_TRANSCRIBE_REQUEST_BYTES);
if (rawBytes === null) return c.json({ error: "Request body is too large." }, 413);
if (rawBytes.byteLength === 0) {
return voiceHttpError(c, new ORPCError("BAD_REQUEST", { message: "Recording is empty." }));
}
audio = rawBytes;
mimeType = contentType.split(";")[0]?.trim() || "audio/webm";
}
try {
const result = await transcribeVoice(deps, actor, {
audio,
mimeType,
signal: c.req.raw.signal,
});
const accept = c.req.header("accept") ?? "";
if (accept.includes("application/json") && !accept.includes("text/event-stream")) {
return c.json({ text: result.text, isFinal: true });
}
const encoder = new TextEncoder();
const stream = new ReadableStream({
start(controller) {
controller.enqueue(
encoder.encode(
`data: ${JSON.stringify({ text: result.text, delta: result.text, isFinal: true })}\n\n`,
),
);
controller.enqueue(encoder.encode(`data: [DONE]\n\n`));
controller.close();
},
});
return new Response(stream, {
headers: {
"content-type": "text/event-stream; charset=utf-8",
"cache-control": "no-cache, no-transform",
connection: "keep-alive",
},
});
} catch (error) {
return voiceHttpError(c, error);
}
});
}
async function readBoundedBytes(request: Request, maxBytes: number): Promise<Uint8Array | null> {
const contentLengthHeader = request.headers.get("content-length");
if (contentLengthHeader !== null) {
const contentLength = Number(contentLengthHeader);
if (!Number.isFinite(contentLength) || contentLength < 0 || contentLength > maxBytes) {
cancelBody(request.body);
return null;
}
}
if (!request.body) return new Uint8Array(0);
const reader = request.body.getReader();
const chunks: Uint8Array[] = [];
let bytes = 0;
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
bytes += value.byteLength;
if (bytes > maxBytes) {
cancelBody(reader);
return null;
}
chunks.push(value);
}
const combined = new Uint8Array(bytes);
let offset = 0;
for (const chunk of chunks) {
combined.set(chunk, offset);
offset += chunk.byteLength;
}
return combined;
} finally {
reader.releaseLock();
}
}
function parseVoiceRequestBody(raw: string): Record<string, unknown> {

View File

@ -25,4 +25,12 @@ describe("requestWebOrigin", () => {
"https://evoo.example",
);
});
it("accepts origins listed in extraTrustedOrigins", () => {
expect(
requestWebOrigin("https://host.example.ts.net:8443", configured, [
"https://host.example.ts.net:8443",
]),
).toBe("https://host.example.ts.net:8443");
});
});

View File

@ -14,15 +14,20 @@ export function isLoopbackHost(host: string): boolean {
}
/** The web origin that browser-facing URLs (OAuth returns, screen links) should
* point at for this request. A browser opened over a loopback or private-network
* address gets its own origin back so a phone on the LAN is not sent to
* 127.0.0.1; any other caller gets the configured WEB_ORIGIN. */
export function requestWebOrigin(origin: string | undefined, webOrigin: string): string {
* point at for this request. A browser opened over a loopback, private-network,
* or explicitly trusted origin gets its own origin back so a phone on the LAN
* or Tailscale is not sent to 127.0.0.1; any other caller gets WEB_ORIGIN. */
export function requestWebOrigin(
origin: string | undefined,
webOrigin: string,
extraTrustedOrigins: readonly string[] = [],
): string {
if (!origin) return webOrigin;
try {
const url = new URL(origin);
if (url.protocol !== "http:" && url.protocol !== "https:") return webOrigin;
if (url.origin === new URL(webOrigin).origin) return url.origin;
if (extraTrustedOrigins.includes(url.origin)) return url.origin;
return isPrivateOrLocalHost(url.hostname) ? url.origin : webOrigin;
} catch {
return webOrigin;

View File

@ -177,7 +177,7 @@ test("This computer installs and starts the stack, then opens the app", async ()
const appWindowPromise = app.waitForEvent("window");
await setup.getByRole("button", { name: "Continue" }).click();
await expect(setup.locator("#stack-phase")).toHaveText("Downloading Rakazo…");
await expect(setup.locator("#stack-phase")).toHaveText("Downloading evOo…");
await expect(setup.getByRole("button", { name: "Continue" })).toBeDisabled();
// Docker output stays behind the details toggle; the phase, the bar, and the size show by default.
await expect(setup.locator("#stack-detail")).toHaveText("412 MB downloaded");
@ -249,7 +249,7 @@ test("switching to Existing instance while the stack starts keeps that choice",
const setup = await app.firstWindow();
await setup.getByRole("button", { name: "Continue" }).click();
await expect(setup.locator("#stack-phase")).toHaveText("Downloading Rakazo…");
await expect(setup.locator("#stack-phase")).toHaveText("Downloading evOo…");
// Fake docker sleeps during pull; leave This computer before ready so followStack must not save.
await setup.getByRole("radio", { name: /Existing instance/ }).check();

View File

@ -83,7 +83,7 @@ test("first run asks whether to use a local or existing instance", async () => {
}
const setup = await app.firstWindow();
await expect(setup.getByRole("heading", { name: "Welcome to Rakazo" })).toBeVisible();
await expect(setup.getByRole("heading", { name: "Welcome to evOo" })).toBeVisible();
await expect(setup.getByText("Choose which server this app should use.")).toBeVisible();
await expect(setup.getByText("This computer")).toBeVisible();
await expect(setup.getByText("Existing instance")).toBeVisible();
@ -139,7 +139,7 @@ test("connecting to an existing instance verifies, saves, and opens it", async (
await setup.locator("#server-url").fill(serverUrl);
await setup.getByRole("button", { name: "Check connection" }).click();
await expect(setup.locator("#status")).toHaveText(`Rakazo answered at ${serverUrl}.`);
await expect(setup.locator("#status")).toHaveText(`evOo answered at ${serverUrl}.`);
await expect(setup.locator("#status")).toHaveAttribute("data-tone", "ok");
await setup.screenshot({
@ -437,7 +437,7 @@ test("a malformed address is rejected before anything is written", async () => {
}).toPass();
});
test("a generic web page is not accepted as a Rakazo server", async () => {
test("a generic web page is not accepted as an evOo server", async () => {
const plain = createServer((_request, response) => {
response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
response.end("<!doctype html><p>not Rakazo</p>");
@ -454,7 +454,7 @@ test("a generic web page is not accepted as a Rakazo server", async () => {
await setup.getByRole("button", { name: "Continue" }).click();
await expect(setup.locator("#status")).toHaveText(
"That address did not respond like a Rakazo server.",
"That address did not respond like an evOo server.",
);
await expect(async () => {
await expect(readFile(path.join(userData, "setup.json"), "utf8")).rejects.toThrow();
@ -507,7 +507,7 @@ test("an unreachable saved server falls back to setup with a recovery message",
app = await launch();
const setup = await app.firstWindow();
await expect(setup.getByRole("heading", { name: "Welcome to Rakazo" })).toBeVisible();
await expect(setup.getByRole("heading", { name: "Welcome to evOo" })).toBeVisible();
await expect(setup.getByRole("radio", { name: /Existing instance/ })).toBeChecked();
await expect(setup.locator("#server-url")).toHaveValue(closedUrl);
await expect(setup.locator("#status")).toContainText("Could not reconnect to the saved server.");
@ -529,7 +529,7 @@ test("the native application menu can reopen setup without exposing setup IPC to
});
const setup = await setupPromise;
await expect(setup.getByRole("heading", { name: "Welcome to Rakazo" })).toBeVisible();
await expect(setup.getByRole("heading", { name: "Welcome to evOo" })).toBeVisible();
await expect(setup.locator("#status")).toBeEmpty();
// Closing setup without saving restores the connected instance.

View File

@ -80,8 +80,8 @@
"publish": [
{
"provider": "github",
"owner": "elie222",
"repo": "rakazo"
"owner": "cynarAI",
"repo": "evOo-local"
}
]
}

View File

@ -79,7 +79,7 @@ describe("renderStackEnv", () => {
expect(lines.some((line) => line.startsWith("RAKAZO_IMAGE_TAG="))).toBe(false);
expect(lines.some((line) => line.startsWith("RAKAZO_COMPUTER_IMAGE_TAG="))).toBe(false);
// Everything else, including the image names and empty optional keys, stays verbatim.
expect(lines).toContain("RAKAZO_IMAGE=ghcr.io/elie222/rakazo/app");
expect(lines).toContain("RAKAZO_IMAGE=ghcr.io/cynarai/evoo-local/app");
expect(lines).toContain("SANDBOX_PROVIDER=docker");
expect(lines).toContain("OPENROUTER_API_KEY=");
expect(rendered.endsWith("\n")).toBe(template.endsWith("\n"));

View File

@ -711,7 +711,7 @@ function installApplicationMenu() {
};
const changeServer: Electron.MenuItemConstructorOptions = {
id: "change-rakazo-server",
label: "Change Rakazo Server…",
label: "Change evOo Server…",
accelerator: "CmdOrCtrl+Shift+K",
click: () => showSetupWindow(),
};
@ -789,7 +789,7 @@ async function probeServer(rawUrl: string, signal?: AbortSignal): Promise<Deskto
ok: false,
status: response.status,
url,
error: "That address redirects elsewhere. Enter the final Rakazo server address.",
error: "That address redirects elsewhere. Enter the final evOo server address.",
};
}
if (!response.ok) {
@ -806,7 +806,7 @@ async function probeServer(rawUrl: string, signal?: AbortSignal): Promise<Deskto
ok: false,
status: response.status,
url,
error: "That address did not respond like a Rakazo server.",
error: "That address did not respond like an evOo server.",
};
}
return {
@ -1249,7 +1249,7 @@ app.whenReady().then(async () => {
if (managedUrl === null || !(await localStack.matchesDesiredStack())) {
return {
ok: false,
error: "The app-managed Rakazo services are not ready. Retry setup.",
error: "The app-managed evOo services are not ready. Retry setup.",
};
}
openSetup = { mode: "new", serverUrl: managedUrl };

View File

@ -66,9 +66,9 @@ describe("desktop release workflow", () => {
`include='[{"runner":["self-hosted","evoo-ci","macOS"],"artifact":"macos"},{"runner":["self-hosted","evoo-ci","Linux"],"artifact":"linux"}]'`,
);
expect(workflow).toMatch(
/if \[\[ "\$WINDOWS_SIGNING" == "true" \]\]; then\n(?:\s*#[^\n]*\n)*\s+include=.*\{"runner":"windows-2022","artifact":"windows"\}/,
/if \[\[ "\$WINDOWS_SIGNING" == "true" \]\]; then\n(?:\s*#[^\n]*\n)*\s+include=.*\{"runner":\["self-hosted","evoo-ci","Windows"\],"artifact":"windows"\}/,
);
expect(workflow.split("windows-2022")).toHaveLength(2);
expect(workflow).not.toMatch(/windows-\d|windows-latest/);
expect(workflow).toContain(`WINDOWS_BUILT: ${expression("needs.validate.outputs.windows")}`);
expect(workflow).toContain('if [[ "$WINDOWS_BUILT" == "true" ]]; then');
expect(workflow).toContain("feeds+=(release-artifacts/latest.yml)");
@ -76,8 +76,8 @@ describe("desktop release workflow", () => {
it("pins every platform update feed to the official GitHub owner and repo", () => {
expect(workflow).toContain('grep -Fqx "provider: github"');
expect(workflow).toContain('grep -Fqx "owner: elie222"');
expect(workflow).toContain('grep -Fqx "repo: rakazo"');
expect(workflow).toContain('grep -Fqx "owner: cynarAI"');
expect(workflow).toContain('grep -Fqx "repo: evOo-local"');
expect(workflow).toContain("Verify Linux update feed is pinned to the official GitHub channel");
expect(workflow).toContain("Windows update config missing");
expect(workflow).toContain("RELEASE_VERSION:");
@ -94,4 +94,11 @@ describe("desktop release workflow", () => {
expect(workflow).toContain("--draft --generate-notes");
expect(workflow).toContain("--draft=false --latest");
});
it("hands platform builds to the draft release, never to Actions artifact storage", () => {
expect(workflow).not.toMatch(/actions\/(?:upload|download)-artifact@/);
expect(workflow).toContain("bash scripts/upload-release-assets.sh");
expect(workflow).toContain('gh release download "$RELEASE_TAG" --dir release-artifacts');
expect(workflow).toContain("needs: [validate, build, publish]");
});
});

View File

@ -6,18 +6,18 @@
http-equiv="Content-Security-Policy"
content="default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; form-action 'none'; base-uri 'none'"
/>
<title>Set up Rakazo</title>
<title>Set up evOo</title>
<link rel="stylesheet" href="tokens.css" />
<link rel="stylesheet" href="setup.css" />
</head>
<body>
<header class="titlebar">
<span class="titlebar-name">Rakazo</span>
<button id="quit" class="titlebar-quit" type="button" title="Quit Rakazo">Quit</button>
<span class="titlebar-name">evOo</span>
<button id="quit" class="titlebar-quit" type="button" title="Quit evOo">Quit</button>
</header>
<main class="shell">
<h1 class="heading">Welcome to Rakazo</h1>
<h1 class="heading">Welcome to evOo</h1>
<p class="subheading">Choose which server this app should use.</p>
<form id="setup" novalidate>

View File

@ -23,10 +23,10 @@
const PHASE_LABELS = {
"checking-docker": "Getting ready…",
preparing: "Getting ready…",
pulling: "Downloading Rakazo…",
starting: "Starting Rakazo…",
pulling: "Downloading evOo…",
starting: "Starting evOo…",
"waiting-healthy": "Almost ready…",
ready: "Rakazo is ready.",
ready: "evOo is ready.",
};
const TERMINAL_PHASES = new Set([
"idle",
@ -266,7 +266,7 @@
const result = await bridge.test(value);
if (result.ok) {
serverUrl.value = result.url;
setStatus(`Rakazo answered at ${result.url}.`, "ok");
setStatus(`evOo answered at ${result.url}.`, "ok");
} else {
setStatus(result.error ?? "Could not reach that address.", "error");
}
@ -362,7 +362,7 @@
continueButton.focus();
}
} catch {
setStatus("Setup could not start. Quit Rakazo and try again.", "error");
setStatus("Setup could not start. Quit evOo and try again.", "error");
setBusy(true);
}
}

View File

@ -7,7 +7,7 @@ name: Android live working status demonstration
notifications: allow
# A cold API 34 emulator can show a launcher ANR over the healthy app.
- extendedWaitUntil:
visible: "Sign up for Rakazo|Pixel Launcher isn't responding"
visible: "Sign up for evOo|Pixel Launcher isn't responding"
timeout: 30000
- runFlow:
when:
@ -15,7 +15,7 @@ name: Android live working status demonstration
commands:
- tapOn: "Close app"
- extendedWaitUntil:
visible: "Sign up for Rakazo"
visible: "Sign up for evOo"
timeout: 30000
- tapOn: "Sign in"
- tapOn: "Email"

View File

@ -10,7 +10,7 @@ tags:
# A cold API 34 emulator can show a launcher ANR over the healthy app.
- extendedWaitUntil:
visible: "Sign up for Rakazo|Pixel Launcher isn't responding"
visible: "Sign up for evOo|Pixel Launcher isn't responding"
timeout: 30000
- runFlow:
when:
@ -18,11 +18,11 @@ tags:
commands:
- tapOn: "Close app"
- extendedWaitUntil:
visible: "Sign up for Rakazo"
visible: "Sign up for evOo"
timeout: 30000
- takeScreenshot: "02-sign-up"
- tapOn: "Sign in"
- assertVisible: "Sign in to Rakazo"
- assertVisible: "Sign in to evOo"
- takeScreenshot: "01-sign-in"
- tapOn: "Use a custom server"

View File

@ -4,18 +4,18 @@ name: Rakazo mobile smoke
- launchApp:
clearState: true
- assertVisible: "Sign in to Rakazo"
- assertVisible: "Sign in to evOo"
- assertVisible: "Sign up"
- tapOn: "Sign up"
- assertVisible: "Sign up for Rakazo"
- assertVisible: "Sign up for evOo"
- assertVisible: "Name"
- tapOn: "Sign in"
- assertVisible: "Sign in to Rakazo"
- assertVisible: "Sign in to evOo"
- tapOn: "Email"
- inputText: ${RAKAZO_E2E_EMAIL}
- tapOn: "Password"
- inputText: ${RAKAZO_E2E_PASSWORD}
- tapOn: "Sign in to Rakazo"
- tapOn: "Sign in to evOo"
- tapOn: "Sign in"
- extendedWaitUntil:

View File

@ -1,6 +1,6 @@
{
"expo": {
"name": "Rakazo",
"name": "evOo",
"slug": "rakazo",
"owner": "inbox-zero",
"scheme": "rakazo",
@ -33,8 +33,8 @@
[
"expo-image-picker",
{
"photosPermission": "Rakazo uses your photo library so you can attach images to a bot message.",
"cameraPermission": "Rakazo uses the camera so you can attach photos to a bot message.",
"photosPermission": "evOo uses your photo library so you can attach images to a bot message.",
"cameraPermission": "evOo uses the camera so you can attach photos to a bot message.",
"microphonePermission": false
}
],
@ -51,9 +51,9 @@
"usesNonExemptEncryption": false
},
"infoPlist": {
"NSCameraUsageDescription": "Rakazo uses the camera so you can attach photos to a bot message.",
"NSPhotoLibraryUsageDescription": "Rakazo uses your photo library so you can attach images to a bot message.",
"NSLocalNetworkUsageDescription": "Rakazo connects to a self-hosted Rakazo server on your local network.",
"NSCameraUsageDescription": "evOo uses the camera so you can attach photos to a bot message.",
"NSPhotoLibraryUsageDescription": "evOo uses your photo library so you can attach images to a bot message.",
"NSLocalNetworkUsageDescription": "evOo connects to a self-hosted evOo server on your local network.",
"NSAppTransportSecurity": {
"NSAllowsLocalNetworking": true
}

View File

@ -67,7 +67,7 @@ export default function Layout() {
contentStyle: { backgroundColor: String(native.page) },
}}
>
<Stack.Screen name="index" options={{ headerShown: false, title: "Rakazo" }} />
<Stack.Screen name="index" options={{ headerShown: false, title: "evOo" }} />
<Stack.Screen name="sign-in" options={{ headerShown: false }} />
<Stack.Screen
name="integration-setup"

View File

@ -46,6 +46,7 @@ import { native, useResolvedAppearance, useThemedStyles } from "../lib/native";
import { registerPushToken } from "../lib/push";
import type { AccountUiLocale } from "../lib/ui-locale";
import { ACCOUNT_UI_LOCALES, UI_LOCALE_LABELS } from "../lib/ui-locale";
import { alignVoiceWithLocale } from "../lib/voice";
/** Render account settings, including the entry point for voice configuration. */
export default function Account() {
@ -183,6 +184,7 @@ export default function Account() {
setLocaleSaving(true);
setLocaleError(null);
void setUiLocale(code)
.then((activated) => alignVoiceWithLocale(activated).catch(() => undefined))
.catch(() => {
setLocaleError(t("Could not change language"));
})
@ -351,6 +353,7 @@ export default function Account() {
</View>
) : null}
{me?.modelsManaged ? null : (
<Pressable
accessibilityRole="button"
disabled={pending}
@ -360,6 +363,7 @@ export default function Account() {
<Text style={styles.settingsTitle}>{t("Models")}</Text>
<Text style={styles.chevron}>›</Text>
</Pressable>
)}
<Pressable
accessibilityRole="button"

View File

@ -353,6 +353,7 @@ export default function BotSettingsScreen() {
<OoAtelier value={color} identity={bot?.id ?? ""} onChange={setColor} />
</View>
<ComputerModePicker value={computerMode} onChange={setComputerMode} />
{me?.modelsManaged ? null : (
<Pressable
accessibilityRole="button"
accessibilityLabel={t("Advanced")}
@ -371,7 +372,8 @@ export default function BotSettingsScreen() {
{advancedOpen ? "⌃" : "⌄"}
</Text>
</Pressable>
{advancedOpen ? (
)}
{advancedOpen && !me?.modelsManaged ? (
<View>
<Text
style={{

View File

@ -3,8 +3,15 @@ import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import { useEffect, useState } from "react";
import { Alert, Pressable, ScrollView, Text, TextInput } from "react-native";
import { BotMemberPicker } from "../components/bot-member-picker";
import { type MobileBot, type MobileGroup, rpc } from "../lib/api";
import {
type MobileBot,
type MobileGroup,
type MobileSpace,
type MobileSpaceNavigation,
rpc,
} from "../lib/api";
import { useI18n } from "../lib/i18n";
import { canDeleteInboxSpace, removeInboxSpace } from "../lib/inbox-spaces";
import { useMobileTokens } from "../lib/native";
export default function GroupSettingsScreen() {
@ -13,12 +20,16 @@ export default function GroupSettingsScreen() {
const router = useRouter();
const { groupId } = useLocalSearchParams<{ groupId: string }>();
const [group, setGroup] = useState<MobileGroup | null>(null);
const [projectSpace, setProjectSpace] = useState<MobileSpace | null>(null);
const [bots, setBots] = useState<MobileBot[]>([]);
const [name, setName] = useState("");
const [selected, setSelected] = useState<string[]>([]);
const [error, setError] = useState<string | null>(null);
const [pending, setPending] = useState(false);
const isMainConversation = group?.kind === "main";
const isProjectMain = isMainConversation && projectSpace !== null && !projectSpace.isDefault;
useEffect(() => {
if (!groupId) return;
void Promise.all([
@ -26,11 +37,19 @@ export default function GroupSettingsScreen() {
(groups) => groups.find((row) => row.id === groupId) ?? null,
),
rpc<MobileBot[]>("bots/list"),
rpc<MobileSpaceNavigation>("spaces/list").catch(() => null),
])
.then(([nextGroup, nextBots]) => {
.then(([nextGroup, nextBots, navigation]) => {
if (!nextGroup) throw new Error(t("Group not found"));
setGroup(nextGroup);
setName(nextGroup.name);
const space =
navigation?.spaces.find(
(item) => item.id === nextGroup.spaceId || item.id === navigation.current.id,
) ?? null;
setProjectSpace(space);
setName(
nextGroup.kind === "main" && space && !space.isDefault ? space.name : nextGroup.name,
);
setSelected(nextGroup.members.map((member) => member.botId));
setBots(nextBots.filter((bot) => !bot.archivedAt));
})
@ -42,10 +61,20 @@ export default function GroupSettingsScreen() {
setPending(true);
setError(null);
try {
const trimmed = name.trim();
if (isProjectMain && projectSpace) {
if (trimmed && trimmed !== projectSpace.name) {
await rpc("spaces/update", { spaceId: projectSpace.id, name: trimmed });
}
router.back();
return;
}
const input: { groupId: string; name?: string; botIds?: string[] } = { groupId };
if (name.trim() !== group.name) input.name = name.trim();
if (trimmed !== group.name) input.name = trimmed;
if (!isMainConversation) {
const memberIds = group.members.map((member) => member.botId).join(",");
if (selected.join(",") !== memberIds) input.botIds = selected;
}
if (input.name || input.botIds) await rpc("groups/update", input);
router.back();
} catch (err) {
@ -57,6 +86,29 @@ export default function GroupSettingsScreen() {
function remove() {
if (!groupId || !group) return;
if (isProjectMain && projectSpace) {
Alert.alert(
t("Delete {name}?", { name: projectSpace.name }),
t("This removes the empty space for everyone."),
[
{ text: t("Cancel"), style: "cancel" },
{
text: t("Delete"),
style: "destructive",
onPress: () =>
void removeInboxSpace(projectSpace.id, async () => {})
.then(() => router.replace("/"))
.catch((err) =>
Alert.alert(
t("Could not delete space"),
err instanceof Error ? err.message : t("Try again."),
),
),
},
],
);
return;
}
Alert.alert(group.name, t("Delete this group? Bots and their solo threads are kept."), [
{ text: t("Cancel"), style: "cancel" },
{
@ -75,6 +127,12 @@ export default function GroupSettingsScreen() {
]);
}
const saveDisabled =
!name.trim() ||
pending ||
(!isMainConversation &&
(selected.length < GROUP_MEMBER_MIN || selected.length > GROUP_MEMBER_MAX));
return (
<>
<Stack.Screen options={{ title: t("Group settings") }} />
@ -97,6 +155,8 @@ export default function GroupSettingsScreen() {
fontSize: 16,
}}
/>
{!isMainConversation ? (
<>
<Text style={{ color: tokens.mutedForeground, fontSize: 14, marginTop: 20 }}>
{t("Members")}
</Text>
@ -106,25 +166,16 @@ export default function GroupSettingsScreen() {
onChange={setSelected}
disabled={pending}
/>
</>
) : null}
{error ? <Text style={{ color: tokens.destructive, marginTop: 12 }}>{error}</Text> : null}
<Pressable
onPress={() => void save()}
disabled={
!name.trim() ||
selected.length < GROUP_MEMBER_MIN ||
selected.length > GROUP_MEMBER_MAX ||
pending
}
disabled={saveDisabled}
style={{
marginTop: 24,
backgroundColor: tokens.primary,
opacity:
!name.trim() ||
selected.length < GROUP_MEMBER_MIN ||
selected.length > GROUP_MEMBER_MAX ||
pending
? 0.5
: 1,
opacity: saveDisabled ? 0.5 : 1,
borderRadius: 11,
padding: 14,
alignItems: "center",
@ -134,6 +185,21 @@ export default function GroupSettingsScreen() {
{pending ? t("Saving…") : t("Save")}
</Text>
</Pressable>
{isProjectMain && projectSpace && canDeleteInboxSpace(projectSpace) ? (
<Pressable
onPress={remove}
style={{
marginTop: 16,
borderRadius: 11,
borderWidth: 1,
borderColor: tokens.border,
padding: 14,
alignItems: "center",
}}
>
<Text style={{ color: tokens.destructive, fontSize: 16 }}>{t("Delete space")}</Text>
</Pressable>
) : !isMainConversation ? (
<Pressable
onPress={remove}
style={{
@ -147,6 +213,7 @@ export default function GroupSettingsScreen() {
>
<Text style={{ color: tokens.destructive, fontSize: 16 }}>{t("Delete group")}</Text>
</Pressable>
) : null}
</ScrollView>
</>
);

View File

@ -6,6 +6,7 @@ import {
type SpaceBot,
type SpaceGroup,
} from "@rakazo/contracts";
import { projectLook, runChatTitle } from "@rakazo/core";
import { botColors } from "@rakazo/ui-tokens";
import { Redirect, useFocusEffect, useRouter } from "expo-router";
import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
@ -44,9 +45,10 @@ import {
rpc,
selectedSpaceId,
selectInitialSpace,
selectLibrarySpace,
selectSpace,
} from "../lib/api";
import { mobileTokens, resolveMobileAppearance } from "../lib/appearance";
import { mobileShellIconSizes, mobileTokens, resolveMobileAppearance } from "../lib/appearance";
import { autopilotSummary } from "../lib/autopilot";
import { allowFocusPrompt, scheduleFocusPrompt } from "../lib/focus-prompt";
import { t, useI18n } from "../lib/i18n";
@ -410,6 +412,7 @@ export default function Home() {
return;
creatingBotRef.current = true;
try {
await selectLibrarySpace();
// Authoritative roster so a slow home fetch does not treat later bots as first.
// A failed list is unknown — use the delayed path rather than assuming first.
const existing = await rpc<MobileBot[]>("bots/list").catch(() => null);
@ -461,7 +464,7 @@ export default function Home() {
<NativeSymbol
ios={activityMode ? "bell.fill" : "bell"}
android={activityMode ? "notifications" : "notifications-outline"}
size={17}
size={mobileShellIconSizes.md}
color={activityMode ? tokens.primaryForeground : tokens.foreground}
/>
</CircleButton>
@ -475,7 +478,7 @@ export default function Home() {
})
}
>
<NativeSymbol ios="magnifyingglass" android="search" size={17} />
<NativeSymbol ios="magnifyingglass" android="search" size={mobileShellIconSizes.md} />
</CircleButton>
<CircleButton
accessibilityLabel={t("Create")}
@ -489,7 +492,7 @@ export default function Home() {
]);
}}
>
<NativeSymbol ios="plus" android="add" size={18} />
<NativeSymbol ios="plus" android="add" size={mobileShellIconSizes.lg} />
</CircleButton>
</View>
</View>
@ -594,10 +597,25 @@ export default function Home() {
}}
disabled={spaceBusy}
onPress={() => {
if (item.space) void chooseInboxSpace(item.space.id);
if (!item.space) return;
const targetSpace = item.space;
const main = !targetSpace.isDefault
? targetSpace.groups.find((group) => group.kind === "main")
: undefined;
if (main) {
void openMobileSpace(targetSpace.id, () =>
router.push({
pathname: "/group-thread",
params: { groupId: main.id, name: targetSpace.name },
}),
);
return;
}
void chooseInboxSpace(targetSpace.id);
}}
style={({ pressed }) => [styles.spaceSelect, pressed && styles.rowPressed]}
>
<ProjectTile space={item.space} />
<Text style={styles.spaceTitle}>{item.title}</Text>
</Pressable>
{canDeleteInboxSpace(item.space) ? (
@ -610,7 +628,11 @@ export default function Home() {
}}
style={({ pressed }) => [styles.spaceActions, pressed && styles.rowPressed]}
>
<NativeSymbol ios="ellipsis" android="ellipsis-horizontal" size={20} />
<NativeSymbol
ios="ellipsis"
android="ellipsis-horizontal"
size={mobileShellIconSizes.lg}
/>
</Pressable>
) : null}
</View>
@ -708,7 +730,8 @@ function ActivitySection({
const { t } = useI18n();
const router = useRouter();
const botsById = useMemo(() => new Map(bots.map((bot) => [bot.id, bot])), [bots]);
const openRun = (run: RunActivityRow) => {
const openRun = (run: RunActivityRow) =>
void openMobileSpace(run.spaceId, () => {
if (run.groupId) {
router.push({
pathname: "/group-thread",
@ -717,7 +740,7 @@ function ActivitySection({
return;
}
router.push({ pathname: "/thread", params: { botId: run.botId, name: run.botName } });
};
});
return (
<View style={styles.activitySection}>
@ -823,7 +846,7 @@ function ActivityRow({
bot?: MobileBot;
onPress: () => void;
}) {
const title = run.groupName ? `${run.botName} · ${run.groupName}` : run.botName;
const title = runChatTitle(run);
const status = activityStatusLabel(run.status);
const preview = run.promptSnippet ? `${run.promptSnippet} · ${status}` : status;
return (
@ -901,6 +924,16 @@ function ConversationRow({
);
}
function ProjectTile({ space }: { space: Pick<InboxSpace, "icon" | "color"> }) {
const styles = useThemedStyles(createHomeStyles);
const { icon, color } = projectLook(space);
return (
<View style={[styles.projectTile, { backgroundColor: color.hex }]}>
<NativeSymbol ios={icon.ios} android={icon.android as never} size={15} color={color.ink} />
</View>
);
}
function CircleButton({
children,
onPress,
@ -1058,9 +1091,9 @@ function createHomeStyles() {
gap: 12,
},
circleButton: {
width: 40,
height: 40,
borderRadius: 20,
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: native.fillPressed,
alignItems: "center",
justifyContent: "center",
@ -1186,6 +1219,15 @@ function createHomeStyles() {
spaceSelect: {
flex: 1,
minHeight: 44,
flexDirection: "row",
alignItems: "center",
gap: 10,
},
projectTile: {
width: 28,
height: 28,
borderRadius: 8,
alignItems: "center",
justifyContent: "center",
},
spaceTitle: {

View File

@ -5,7 +5,10 @@ import {
CONNECTION_CATALOG_PAGE_SIZE,
EMPTY_PLUGIN_CATALOG_MESSAGE,
filterConnectionCatalogItems,
GITHUB_MCP_SOURCE,
GITHUB_TOKEN_URL,
humanizeToolName,
isGithubInstall,
} from "@rakazo/core";
import { useEffect, useMemo, useRef, useState } from "react";
import {
@ -84,6 +87,11 @@ export default function Integrations() {
const [query, setQuery] = useState("");
const [visibleCount, setVisibleCount] = useState(CONNECTION_CATALOG_PAGE_SIZE);
const [sources, setSources] = useState<CapabilityInstall[]>([]);
const github = sources.find(isGithubInstall);
const customSources = sources.filter((source) => !isGithubInstall(source));
const [githubOpen, setGithubOpen] = useState(false);
const [githubToken, setGithubToken] = useState("");
const [githubError, setGithubError] = useState<string | null>(null);
const [sourceKind, setSourceKind] = useState<SourceKind | null>(null);
const [advancedOpen, setAdvancedOpen] = useState(false);
const [name, setName] = useState("");
@ -388,6 +396,27 @@ export default function Integrations() {
}
}
async function connectGithub() {
setPending("github");
setGithubError(null);
try {
await rpc("capabilities/install", {
kind: "mcp",
name: "GitHub",
source: GITHUB_MCP_SOURCE,
credential: githubToken.trim(),
config: { preset: "github" },
});
setGithubToken("");
setGithubOpen(false);
await refresh();
} catch (reason) {
setGithubError(reason instanceof Error ? reason.message : t("Could not connect GitHub"));
} finally {
setPending(null);
}
}
async function removeSource(source: CapabilityInstall) {
setPending(source.id);
setSourceError(null);
@ -592,6 +621,63 @@ export default function Integrations() {
renderDetail(detailItem)
) : (
<>
<View testID="github-connect" style={styles.card}>
<View style={styles.detailTitleRow}>
<ConnectorLogo label="GitHub" styles={styles} />
<Text style={[styles.title, styles.grow]}>GitHub</Text>
{github ? (
<Pressable
accessibilityRole="button"
disabled={pending === github.id}
onPress={() => void removeSource(github)}
>
<Text style={styles.remove}>{t("Disconnect")}</Text>
</Pressable>
) : githubOpen ? null : (
<Pressable
accessibilityRole="button"
onPress={() => setGithubOpen(true)}
style={styles.smallButton}
>
<Text style={styles.buttonLabel}>{t("Connect")}</Text>
</Pressable>
)}
</View>
{!github && githubOpen ? (
<>
<Pressable
accessibilityRole="link"
onPress={() => void Linking.openURL(GITHUB_TOKEN_URL)}
style={styles.cardButton}
>
<Text style={styles.buttonLabel}>{t("Create token on GitHub")}</Text>
</Pressable>
<TextInput
value={githubToken}
onChangeText={setGithubToken}
accessibilityLabel={t("GitHub token")}
placeholder="ghp_…"
placeholderTextColor={native.tertiaryLabel}
autoCapitalize="none"
autoCorrect={false}
secureTextEntry
style={styles.input}
/>
<Pressable
accessibilityRole="button"
disabled={pending === "github" || !githubToken.trim()}
onPress={() => void connectGithub()}
style={styles.cardButton}
>
<Text style={styles.buttonLabel}>
{pending === "github" ? t("Connecting…") : t("Connect")}
</Text>
</Pressable>
</>
) : null}
{githubError ? <Text style={styles.error}>{githubError}</Text> : null}
</View>
{!catalogReady ? <ActivityIndicator color={native.fillPressed} /> : null}
{catalogReady && catalog.length === 0 ? (
@ -782,10 +868,10 @@ export default function Integrations() {
) : null}
<Text style={styles.section}>{t("Tool sources")}</Text>
{sources.length === 0 ? (
{customSources.length === 0 ? (
<Text style={styles.secondary}>{t("No custom sources installed.")}</Text>
) : null}
{sources.map((source) => (
{customSources.map((source) => (
<View key={source.id} style={styles.row}>
<View style={styles.grow}>
<Text style={styles.title}>{source.name}</Text>

View File

@ -931,7 +931,7 @@ export default function Models() {
{selected.auth === "oauth" && !subscriptionSignIn ? (
<Text style={styles.secondary}>
{t(
"This subscription sign-in is not available in Rakazo yet. Use a deployment credential or choose another provider.",
"This subscription sign-in is not available in evOo yet. Use a deployment credential or choose another provider.",
)}
</Text>
) : null}

View File

@ -9,7 +9,7 @@ import { Stack, useRouter } from "expo-router";
import { useState } from "react";
import { Pressable, ScrollView, Text, TextInput } from "react-native";
import { ComputerModePicker } from "../components/computer-mode-picker";
import { type MobileBot, rpc } from "../lib/api";
import { type MobileBot, rpc, selectLibrarySpace } from "../lib/api";
import { allowFocusPrompt, scheduleFocusPrompt } from "../lib/focus-prompt";
import { useI18n } from "../lib/i18n";
import { useMobileTokens } from "../lib/native";
@ -42,6 +42,7 @@ export default function NewBot() {
setPending(true);
setError(null);
try {
await selectLibrarySpace();
// Failed list is unknown — delay focus rather than treating the bot as first.
const existing = await rpc<MobileBot[]>("bots/list").catch(() => null);
const isFirstBot = existing !== null && existing.length === 0;

View File

@ -166,9 +166,9 @@ export default function SignIn() {
{resetSent
? t("Check your email")
: mode === "in"
? t("Sign in to Rakazo")
? t("Sign in to evOo")
: mode === "up"
? t("Sign up for Rakazo")
? t("Sign up for evOo")
: t("Reset your password")}
</Text>
{resetSent ? (
@ -457,7 +457,7 @@ function ServerSheet({
<Text
style={{ color: tokens.mutedForeground, marginTop: 28, fontSize: 15, lineHeight: 22 }}
>
{t("Enter your Rakazo server address.")}
{t("Enter your evOo server address.")}
</Text>
<TextInput
autoCapitalize="none"

View File

@ -6,23 +6,21 @@ import type {
Connection,
ConnectionCatalogItem,
MessageBlock,
MessageReaction,
Routine,
} from "@rakazo/contracts";
import {
AUTOPILOT_LIMITS,
canReactToThreadMessage,
MESSAGE_REACTIONS,
type MessageReaction,
} from "@rakazo/contracts";
import type { AutopilotUntilChoice } from "@rakazo/core";
import { AUTOPILOT_LIMITS, canReactToThreadMessage, MESSAGE_REACTIONS } from "@rakazo/contracts";
import type { AutopilotUntilChoice, ComposerMention, SlashActionId } from "@rakazo/core";
import {
abortableDelay,
attachmentsForThread,
autopilotUntil,
buildComposerMentionOptions,
type ComposerMention,
clearComposerDraft,
cloudAgentHttpsUrl,
composerDraftThreadKey,
deriveWorkspaceFocus,
foldRunNarration,
isApprovalAskBlock,
isRoutineEventMessage,
isRunTerminalEvent,
@ -31,13 +29,15 @@ import {
liveScreenRunId,
mentionChipKey,
projectMessageReactions,
queuedFollowUps,
readComposerDraft,
resolveComposerSendPlan,
SLASH_ACTIONS,
type SlashActionId,
selectedAskActionLabel,
serializeComposerPrompt,
truncateSlashDescription,
userVisibleMessages,
writeComposerDraft,
} from "@rakazo/core";
import * as Clipboard from "expo-clipboard";
import { useFocusEffect, useLocalSearchParams, useNavigation, useRouter } from "expo-router";
@ -77,6 +77,7 @@ import {
} from "../components/markdown-artifact-preview";
import { NativeSymbol } from "../components/native-symbol";
import { RoutineEventLine } from "../components/routine-event-line";
import { RunSteps } from "../components/run-steps";
import { ThreadCard } from "../components/thread-card";
import type {
MobileBot,
@ -101,7 +102,7 @@ import {
shouldApplyMobileThreadRefresh,
subscribeThread,
} from "../lib/api";
import { mobileTokens } from "../lib/appearance";
import { mobileShellIconSizes, mobileTokens } from "../lib/appearance";
import { type MobileArtifactTarget, openMobileArtifact } from "../lib/artifact-open";
import { confirmDeleteBot } from "../lib/bot-lifecycle";
import { cancelFocusPrompt, focusPromptThreadActive } from "../lib/focus-prompt";
@ -287,6 +288,8 @@ function Thread() {
const mentionBotsAppliedGeneration = useRef(0);
const readVisibleTarget = useRef<string | null>(null);
const threadKey = groupId ?? botId;
const draftKey = composerDraftThreadKey({ botId, groupId });
const initialDraft = useMemo(() => readComposerDraft(draftKey), [draftKey]);
const [threadScrollState, setThreadScrollState] = useState<ThreadScrollState>(() =>
scrollBehavior.current.state(),
);
@ -308,7 +311,7 @@ function Thread() {
/** Run that last pointed at the bot's screen, so the live screen is offered only during real screen work. */
const [screenRunId, setScreenRunId] = useState<string | null>(null);
const activeThreadId = useRef<string | undefined>(undefined);
const [draft, setDraft] = useState("");
const [draft, setDraft] = useState(() => initialDraft.text);
const [mentionQuery, setMentionQuery] = useState<string | null>(null);
const [slashQuery, setSlashQuery] = useState<string | null>(null);
const [agentSkills, setAgentSkills] = useState<AgentSkillCatalogEntry[]>([]);
@ -323,12 +326,17 @@ function Thread() {
connectionId?: string;
}>
>([]);
const [selectedMentions, setSelectedMentions] = useState<ComposerMention[]>([]);
const [selectedSkill, setSelectedSkill] = useState<AgentSkillCatalogEntry | null>(null);
const [selectedMentions, setSelectedMentions] = useState<ComposerMention[]>(
() => initialDraft.selectedMentions,
);
const [selectedSkill, setSelectedSkill] = useState<AgentSkillCatalogEntry | null>(
() => initialDraft.selectedSkill,
);
const [pendingAttachments, setPendingAttachments] = useState<PendingAttachment[]>([]);
const [replyTarget, setReplyTarget] = useState<MobileMessage | null>(null);
const [attachmentNotice, setAttachmentNotice] = useState<string | null>(null);
const [sending, setSending] = useState(false);
const [stopped, setStopped] = useState(false);
const [error, setError] = useState<string | null>(null);
const [loadingOlder, setLoadingOlder] = useState(false);
const [markdownPreview, setMarkdownPreview] = useState<MarkdownArtifactPreviewTarget | null>(
@ -343,7 +351,17 @@ function Thread() {
),
[snap?.messages],
);
const visibleMessages = reactionView.visibleMessages;
const runNarration = useMemo(
() => foldRunNarration(reactionView.visibleMessages),
[reactionView.visibleMessages],
);
const visibleMessages = useMemo(
() => reactionView.visibleMessages.filter((message) => !runNarration.hidden.has(message.id)),
[reactionView.visibleMessages, runNarration],
);
const [taskPillExpanded, setTaskPillExpanded] = useState(false);
const [dismissedPlanKey, setDismissedPlanKey] = useState<string | null>(null);
const [taskPillSubStepsOpen, setTaskPillSubStepsOpen] = useState(false);
const latestMessageId = visibleMessages.at(-1)?.id ?? null;
const activePendingAttachments = attachmentsForThread(pendingAttachments, threadKey);
const composerMentionTargets = useMemo(
@ -422,6 +440,106 @@ function Thread() {
});
}, [inGroup, snap?.activeRuns, snap?.members, snap?.run]);
const working = inGroup ? workingGroupBots.length > 0 : isWorkingStatus(currentBotStatus);
const activeMobilePlan = useMemo(() => {
const msgs = reactionView.visibleMessages;
let lastUserIdx = -1;
for (let i = msgs.length - 1; i >= 0; i--) {
if (msgs[i]?.role === "user") {
lastUserIdx = i;
break;
}
}
let planIdx = -1;
let planBlock: Extract<MessageBlock, { kind: "plan" }> | null = null;
for (let i = msgs.length - 1; i > lastUserIdx; i--) {
const m = msgs[i];
if (!m) continue;
for (let b = m.blocks.length - 1; b >= 0; b--) {
const block = m.blocks[b];
if (block?.kind === "plan") {
planBlock = block;
planIdx = i;
break;
}
}
if (planBlock) break;
}
if (!planBlock) {
for (let i = lastUserIdx - 1; i >= 0; i--) {
const m = msgs[i];
if (!m) continue;
for (let b = m.blocks.length - 1; b >= 0; b--) {
const block = m.blocks[b];
if (block?.kind === "plan" && block.status !== "completed") {
planBlock = block;
planIdx = i;
break;
}
}
if (planBlock) break;
}
}
if (!planBlock) return null;
const absorbedIds = new Set<string>();
const stepIds = new Set<string>();
const steps: MobileMessage[] = [];
for (let i = Math.max(0, lastUserIdx + 1, planIdx); i < msgs.length; i++) {
const m = msgs[i];
if (!m) continue;
const folded = runNarration.steps.get(m.id);
if (folded && folded.length > 0) {
absorbedIds.add(m.id);
for (const s of folded) {
if (!stepIds.has(s.id)) {
stepIds.add(s.id);
steps.push(s);
}
}
}
}
const doneCount = planBlock.steps.filter(
(s) => s.status === "completed" || s.status === "skipped",
).length;
const isCompleted =
planBlock.status === "completed" ||
(planBlock.steps.length > 0 && doneCount === planBlock.steps.length && !working);
return {
block: planBlock,
steps,
absorbedIds,
doneCount,
isCompleted,
completionKey: `${planBlock.planId}:${isCompleted ? "done" : "active"}`,
};
}, [reactionView.visibleMessages, runNarration.steps, working]);
useEffect(() => {
if (!activeMobilePlan?.isCompleted) {
setDismissedPlanKey(null);
return;
}
const key = activeMobilePlan.completionKey;
const collapseTimer = setTimeout(() => setTaskPillExpanded(false), 1800);
const hideTimer = setTimeout(() => setDismissedPlanKey(key), 3500);
return () => {
clearTimeout(collapseTimer);
clearTimeout(hideTimer);
};
}, [activeMobilePlan?.completionKey, activeMobilePlan?.isCompleted]);
const activeRunCreatedAt = useMemo(() => {
const candidates = snap?.activeRuns ?? (snap?.run ? [snap.run] : []);
return candidates.find((run) => isWorkingStatus(run.status))?.createdAt;
}, [snap?.activeRuns, snap?.run]);
const queuedIds = useMemo(
() =>
new Set(
(working
? queuedFollowUps(snap?.messages ?? [], activeRunCreatedAt, reactionView.reactions)
: []
).map((message) => message.id),
),
[activeRunCreatedAt, reactionView.reactions, snap?.messages, working],
);
useEffect(() => {
void rpc<AgentSkillCatalogEntry[]>("agentSkills/list")
@ -585,7 +703,7 @@ function Thread() {
<NativeSymbol
ios="gearshape"
android="settings-outline"
size={21}
size={mobileShellIconSizes.lg}
color={tokens.foreground}
/>
</Pressable>
@ -594,7 +712,7 @@ function Thread() {
<NativeSymbol
ios="ellipsis"
android="ellipsis-horizontal"
size={21}
size={mobileShellIconSizes.lg}
color={tokens.foreground}
/>
</Pressable>
@ -993,16 +1111,31 @@ function Thread() {
);
}, [botId, groupId, messageId]);
const loadedDraftKeyRef = useRef(draftKey);
useLayoutEffect(() => {
if (loadedDraftKeyRef.current !== draftKey) {
loadedDraftKeyRef.current = draftKey;
const stored = readComposerDraft(draftKey);
setDraft(stored.text);
setSelectedSkill(stored.selectedSkill);
setSelectedMentions(stored.selectedMentions);
return;
}
writeComposerDraft(draftKey, {
text: draft,
selectedSkill,
selectedMentions,
});
}, [draft, draftKey, selectedMentions, selectedSkill]);
useEffect(() => {
setPendingAttachments((current) => attachmentsForThread(current, threadKey));
setDraft("");
setMentionQuery(null);
setSlashQuery(null);
setSelectedSkill(null);
setSelectedMentions([]);
setReplyTarget(null);
setAttachmentNotice(null);
setError(null);
setStopped(false);
}, [threadKey]);
function updateDraft(value: string) {
@ -1044,6 +1177,11 @@ function Thread() {
function runSlashAction(action: SlashActionId) {
setDraft("");
setSlashQuery(null);
writeComposerDraft(draftKey, {
text: "",
selectedSkill,
selectedMentions,
});
if (action === "chat-settings") {
if (inGroup && groupId) {
router.push({ pathname: "/group-settings", params: { groupId } });
@ -1069,6 +1207,10 @@ function Thread() {
const initialGroupTarget = groupId;
if ((!initialBotTarget && !initialGroupTarget) || sending) return;
const originThreadKey = initialGroupTarget ?? initialBotTarget;
const originDraftKey = composerDraftThreadKey({
botId: initialBotTarget,
groupId: initialGroupTarget,
});
const attachments = attachmentsForThread(pendingAttachments, originThreadKey);
const plan = resolveComposerSendPlan({
text: serializeComposerPromptText(),
@ -1088,6 +1230,7 @@ function Thread() {
if (initialBotTarget) cancelFocusPrompt(initialBotTarget);
};
setSending(true);
setStopped(false);
setError(null);
try {
if (plan.shouldRunRoutines) {
@ -1102,6 +1245,7 @@ function Thread() {
);
}
const clearOriginComposer = () => {
clearComposerDraft(originDraftKey);
setPendingAttachments((current) =>
current.filter((attachment) => attachment.threadKey !== originThreadKey),
);
@ -1196,12 +1340,16 @@ function Thread() {
const targetGroupId = groupId;
if ((!targetBotId && !targetGroupId) || sending) return;
setSending(true);
setStopped(false);
setError(null);
try {
await rpc(
"threads/stop",
targetGroupId ? { groupId: targetGroupId } : { botId: targetBotId! },
);
if (isCurrentTarget(targetBotId, targetGroupId)) {
setStopped(true);
}
} catch (err) {
if (isCurrentTarget(targetBotId, targetGroupId)) {
setError(err instanceof Error ? err.message : t("Failed to stop work"));
@ -1221,6 +1369,80 @@ function Thread() {
}
}
async function resume() {
const targetBotId = botId;
const targetGroupId = groupId;
if ((!targetBotId && !targetGroupId) || sending) return;
setSending(true);
setStopped(false);
setError(null);
try {
await rpc(
"threads/resume",
targetGroupId ? { groupId: targetGroupId } : { botId: targetBotId! },
);
if (isCurrentTarget(targetBotId, targetGroupId)) {
await refresh();
}
} catch (err) {
if (isCurrentTarget(targetBotId, targetGroupId)) {
setError(err instanceof Error ? err.message : t("Could not continue"));
}
} finally {
setSending(false);
}
}
const [sendingNowId, setSendingNowId] = useState<string | null>(null);
async function sendQueuedNow(message: MobileMessage) {
const targetBotId = botId;
const targetGroupId = groupId;
if ((!targetBotId && !targetGroupId) || sendingNowId) return;
setSendingNowId(message.id);
setError(null);
try {
await rpc("threads/sendNow", {
...(targetGroupId ? { groupId: targetGroupId } : { botId: targetBotId! }),
messageId: message.id,
});
if (isCurrentTarget(targetBotId, targetGroupId)) {
await refresh();
}
} catch (err) {
if (isCurrentTarget(targetBotId, targetGroupId)) {
setError(err instanceof Error ? err.message : t("Failed to send message"));
}
} finally {
setSendingNowId(null);
}
}
const [removingQueuedId, setRemovingQueuedId] = useState<string | null>(null);
async function removeQueuedNow(message: MobileMessage) {
const targetBotId = botId;
const targetGroupId = groupId;
const messageId = message.id.startsWith("temp-") ? undefined : message.id;
if (!messageId || (!targetBotId && !targetGroupId) || removingQueuedId) return;
setRemovingQueuedId(message.id);
setError(null);
try {
await rpc("threads/removeQueuedMessage", {
...(targetGroupId ? { groupId: targetGroupId } : { botId: targetBotId! }),
messageId,
});
if (isCurrentTarget(targetBotId, targetGroupId)) {
await refresh();
}
} catch (err) {
if (isCurrentTarget(targetBotId, targetGroupId)) {
setError(err instanceof Error ? err.message : t("Failed to remove message"));
}
} finally {
setRemovingQueuedId(null);
}
}
const answerMessage = useCallback(
async (message: MobileMessage, answer: string) => {
const targetBotId = botId;
@ -1304,6 +1526,10 @@ function Thread() {
const targetGroupId = groupId;
if (!targetBotId && !targetGroupId) return;
const target = targetGroupId ? { groupId: targetGroupId } : { botId: targetBotId! };
const targetDraftKey = composerDraftThreadKey({
botId: targetBotId,
groupId: targetGroupId,
});
const until = autopilotUntil(choice, new Date());
void rpc("autopilot/start", {
...target,
@ -1311,8 +1537,12 @@ function Thread() {
...(until ? { until: until.toISOString() } : {}),
})
.then(async () => {
const existing = readComposerDraft(targetDraftKey);
writeComposerDraft(targetDraftKey, { ...existing, text: "" });
if (isCurrentTarget(targetBotId, targetGroupId)) {
setDraft("");
if (isCurrentTarget(targetBotId, targetGroupId)) await refresh();
await refresh();
}
})
.catch((err) =>
Alert.alert(t("Autopilot"), err instanceof Error ? err.message : t("Try again.")),
@ -1484,6 +1714,7 @@ function Thread() {
function renderMessageRow(message: MobileMessage, options?: { enableJump?: boolean }) {
const actionProps = messageActionProps(message);
const messageReactions = reactionView.reactions.get(message.id);
const foldedSteps = runNarration.steps.get(message.id);
const activityBotId =
!inGroup && message.role === "bot" && message.id.startsWith("progress:")
? (message.botId ?? botId)
@ -1543,6 +1774,9 @@ function Thread() {
flexShrink: 1,
}}
>
{foldedSteps && !activeMobilePlan?.absorbedIds.has(message.id) ? (
<RunSteps steps={foldedSteps} />
) : null}
<Pressable accessible={false} onLongPress={actionProps.onLongPress}>
<MessageBubble
botId={botId ?? snap?.members?.[0]?.botId ?? ""}
@ -1563,6 +1797,55 @@ function Thread() {
actionProps={actionProps}
/>
</Pressable>
{queuedIds.has(message.id) ? (
<View
style={{
flexDirection: "row",
alignItems: "center",
justifyContent: "flex-end",
gap: 8,
marginTop: 4,
}}
>
<Text style={{ color: tokens.mutedForeground, fontSize: 12 }}>{t("Queued")}</Text>
<Pressable
accessibilityRole="button"
disabled={sendingNowId === message.id}
hitSlop={6}
onPress={() => void sendQueuedNow(message)}
>
<Text
style={{
color: tokens.foreground,
fontSize: 12,
fontWeight: "600",
opacity: sendingNowId === message.id ? 0.5 : 1,
}}
>
{t("Send now")}
</Text>
</Pressable>
<Pressable
accessibilityRole="button"
disabled={removingQueuedId === message.id}
hitSlop={6}
onPress={() => void removeQueuedNow(message)}
>
<Text
style={{
color:
removingQueuedId === message.id ? tokens.mutedForeground : tokens.destructive,
fontSize: 12,
fontWeight: "600",
opacity: removingQueuedId === message.id ? 0.5 : 1,
}}
numberOfLines={1}
>
{t("Remove")}
</Text>
</Pressable>
</View>
) : null}
{messageReactions ? (
<View
style={{
@ -1673,7 +1956,70 @@ function Thread() {
>
{error ? <Text style={{ color: tokens.mutedForeground, marginTop: 12 }}>{error}</Text> : null}
{runError ? (
<Text style={{ color: tokens.destructive, marginTop: 12 }}>{runError}</Text>
<View
style={{
marginTop: 12,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: 8,
}}
>
<Text style={{ color: tokens.destructive, flex: 1 }}>{runError}</Text>
{!working ? (
<Pressable
accessibilityRole="button"
testID="thread-resume-button"
disabled={sending}
onPress={() => void resume()}
style={{
borderRadius: 999,
borderWidth: 1,
borderColor: tokens.border,
backgroundColor: tokens.card,
paddingHorizontal: 12,
paddingVertical: 6,
opacity: sending ? 0.5 : 1,
}}
>
<Text style={{ color: tokens.foreground, fontSize: 13, fontWeight: "600" }}>
{t("Resume")}
</Text>
</Pressable>
) : null}
</View>
) : stopped && !working ? (
<View
testID="thread-stopped-banner"
style={{
marginTop: 12,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: 8,
}}
>
<Text style={{ color: tokens.mutedForeground, flex: 1 }}>{t("Stopped")}</Text>
<Pressable
accessibilityRole="button"
testID="thread-resume-button"
disabled={sending}
onPress={() => void resume()}
style={{
borderRadius: 999,
borderWidth: 1,
borderColor: tokens.border,
backgroundColor: tokens.card,
paddingHorizontal: 12,
paddingVertical: 6,
opacity: sending ? 0.5 : 1,
}}
>
<Text style={{ color: tokens.foreground, fontSize: 13, fontWeight: "600" }}>
{t("Resume")}
</Text>
</Pressable>
</View>
) : null}
<View style={{ flex: 1, position: "relative" }}>
{showPinnedPage ? (
@ -2014,6 +2360,191 @@ function Thread() {
))}
</View>
) : null}
{activeMobilePlan &&
!(activeMobilePlan.isCompleted && dismissedPlanKey === activeMobilePlan.completionKey) ? (
<View
testID="composer-task-pill"
style={{
marginTop: 12,
borderRadius: 20,
borderWidth: 1,
borderColor: tokens.border,
backgroundColor: tokens.card,
paddingHorizontal: 14,
paddingTop: 8,
paddingBottom: 10,
gap: 8,
}}
>
<Pressable
testID="composer-task-pill-toggle"
accessibilityRole="button"
accessibilityState={{ expanded: taskPillExpanded }}
onPress={() => setTaskPillExpanded((current) => !current)}
style={{ gap: 6 }}
>
<View
style={{
width: 32,
height: 4,
borderRadius: 2,
backgroundColor: tokens.border,
alignSelf: "center",
}}
/>
<View
style={{
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: 8,
}}
>
<View style={{ flexDirection: "row", alignItems: "center", gap: 8, flex: 1 }}>
<Text
style={{
color: activeMobilePlan.isCompleted ? tokens.success : tokens.foreground,
fontSize: 14,
fontWeight: "700",
}}
>
{activeMobilePlan.isCompleted ? "✓" : "▸"}
</Text>
<Text
numberOfLines={1}
style={{
color: tokens.foreground,
fontSize: 13.5,
fontWeight: "600",
flex: 1,
}}
>
{activeMobilePlan.isCompleted
? activeMobilePlan.block.title
: ((
activeMobilePlan.block.steps.find((s) => s.status === "in_progress") ??
activeMobilePlan.block.steps.find((s) => s.status === "pending")
)?.label ?? activeMobilePlan.block.title)}
</Text>
</View>
<View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
<Text
style={{
color: activeMobilePlan.isCompleted ? tokens.success : tokens.mutedForeground,
fontSize: 12.5,
fontWeight: "500",
}}
>
{activeMobilePlan.isCompleted
? t("Completed")
: `${activeMobilePlan.doneCount}/${activeMobilePlan.block.steps.length}`}
</Text>
<NativeSymbol
ios={taskPillExpanded ? "chevron.down" : "chevron.up"}
android={taskPillExpanded ? "chevron-down" : "chevron-up"}
size={14}
color={tokens.mutedForeground}
/>
</View>
</View>
</Pressable>
{taskPillExpanded ? (
<View
style={{
borderTopWidth: 1,
borderTopColor: tokens.border,
paddingTop: 10,
gap: 6,
}}
>
<Text
style={{
color: tokens.cardForeground,
fontSize: 14,
fontWeight: "600",
}}
>
{activeMobilePlan.block.title}
</Text>
{activeMobilePlan.block.steps.map((step) => {
const done = step.status === "completed";
const active = step.status === "in_progress";
const skipped = step.status === "skipped";
const glyph = done ? "✓" : active ? "▸" : skipped ? "–" : "○";
return (
<View
key={step.id}
style={{ flexDirection: "row", alignItems: "flex-start", gap: 8 }}
>
<Text
style={{
color: done
? tokens.success
: active
? tokens.foreground
: tokens.mutedForeground,
fontSize: 13.5,
fontWeight: active ? "700" : "400",
width: 16,
}}
>
{glyph}
</Text>
<View style={{ flex: 1 }}>
<Text
style={{
color: done || skipped ? tokens.mutedForeground : tokens.foreground,
fontSize: 13.5,
fontWeight: active ? "600" : "400",
textDecorationLine: done || skipped ? "line-through" : "none",
}}
>
{step.label}
</Text>
{step.note ? (
<Text style={{ color: tokens.mutedForeground, fontSize: 12 }}>
{step.note}
</Text>
) : null}
</View>
</View>
);
})}
{activeMobilePlan.steps.length > 0 ? (
<View
style={{
borderTopWidth: 1,
borderTopColor: tokens.border,
paddingTop: 6,
marginTop: 2,
gap: 4,
}}
>
<Pressable
accessibilityRole="button"
onPress={() => setTaskPillSubStepsOpen((current) => !current)}
>
<Text style={{ color: tokens.mutedForeground, fontSize: 12.5 }}>
{t("{count} steps", { count: activeMobilePlan.steps.length })}{" "}
{taskPillSubStepsOpen ? "▴" : "▾"}
</Text>
</Pressable>
{taskPillSubStepsOpen
? activeMobilePlan.steps.map((s) => (
<Text
key={s.id}
style={{ color: tokens.mutedForeground, fontSize: 12.5 }}
>
{blockText(s)}
</Text>
))
: null}
</View>
) : null}
</View>
) : null}
</View>
) : null}
<View
style={{
flexDirection: "row",
@ -2517,6 +3048,105 @@ const MessageBubble = memo(function MessageBubble({
</View>
);
}
const plans = message.blocks.filter(
(block): block is Extract<MessageBlock, { kind: "plan" }> => block.kind === "plan",
);
if (plans.length) {
return (
<View style={{ gap: 8, width: "100%" }}>
{plans.map((plan) => {
const doneCount = plan.steps.filter(
(s) => s.status === "completed" || s.status === "skipped",
).length;
const isCompleted = plan.status === "completed";
return (
<View
key={plan.planId}
testID="plan-card"
style={{
width: "90%",
borderRadius: 18,
borderWidth: 1,
borderColor: tokens.border,
backgroundColor: tokens.card,
paddingHorizontal: 16,
paddingVertical: 14,
gap: 10,
}}
>
<View style={{ flexDirection: "row", justifyContent: "space-between", gap: 8 }}>
<Text
style={{
color: tokens.cardForeground,
fontSize: 15,
fontWeight: "600",
flexShrink: 1,
}}
>
{plan.title}
</Text>
<Text
style={{
color: isCompleted ? tokens.success : tokens.mutedForeground,
fontSize: 13,
fontWeight: "500",
}}
>
{isCompleted ? t("Completed") : `${doneCount}/${plan.steps.length}`}
</Text>
</View>
<View style={{ gap: 6 }}>
{plan.steps.map((step) => {
const done = step.status === "completed";
const active = step.status === "in_progress";
const skipped = step.status === "skipped";
const glyph = done ? "✓" : active ? "▸" : skipped ? "–" : "○";
return (
<View
key={step.id}
style={{ flexDirection: "row", alignItems: "flex-start", gap: 8 }}
>
<Text
style={{
color: done
? tokens.success
: active
? tokens.foreground
: tokens.mutedForeground,
fontSize: 14,
fontWeight: active ? "700" : "400",
width: 16,
}}
>
{glyph}
</Text>
<View style={{ flex: 1 }}>
<Text
style={{
color: done || skipped ? tokens.mutedForeground : tokens.foreground,
fontSize: 14,
fontWeight: active ? "600" : "400",
textDecorationLine: done || skipped ? "line-through" : "none",
}}
>
{step.label}
</Text>
{step.note ? (
<Text style={{ color: tokens.mutedForeground, fontSize: 12.5 }}>
{step.note}
</Text>
) : null}
</View>
</View>
);
})}
</View>
</View>
);
})}
</View>
);
}
if (isRoutineEventMessage(message.blocks)) {
return (
<View style={{ width: "100%" }}>

View File

@ -1,76 +1,51 @@
import { voicesForLocale } from "@rakazo/core";
import { useFocusEffect } from "expo-router";
import { useCallback, useState } from "react";
import {
ActivityIndicator,
Pressable,
ScrollView,
StyleSheet,
Text,
TextInput,
View,
} from "react-native";
import { ActivityIndicator, Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { NativeSymbol } from "../components/native-symbol";
import { rpc } from "../lib/api";
import { mobileTokens } from "../lib/appearance";
import { useI18n } from "../lib/i18n";
import { native, useThemedStyles } from "../lib/native";
import { speakText } from "../lib/voice";
import {
alignVoiceWithLocale,
KOKORO_LOCAL_TOKEN,
LOCAL_VOICE_PROVIDER,
speakText,
} from "../lib/voice";
const KOKORO_LOCAL_TOKEN = "local-kokoro";
type VoiceCatalogEntry = {
id: string;
name: string;
description: string;
transcribe: boolean;
};
type VoiceCredential = {
id: string;
provider: string;
voiceId: string;
};
type VoiceStatus = {
configured: boolean;
ready: boolean;
provider: string | null;
voiceId: string;
};
type VoiceInfo = { id: string; label: string; description?: string };
export default function VoiceSettings() {
const styles = useThemedStyles(createVoiceStyles);
const { t } = useI18n();
const [catalog, setCatalog] = useState<VoiceCatalogEntry[]>([]);
const [credentials, setCredentials] = useState<VoiceCredential[]>([]);
const [status, setStatus] = useState<VoiceStatus | null>(null);
const { locale, t } = useI18n();
const [connected, setConnected] = useState(false);
const [voices, setVoices] = useState<VoiceInfo[]>([]);
const [provider, setProvider] = useState("");
const [apiKey, setApiKey] = useState("");
const [voiceId, setVoiceId] = useState("");
const [loading, setLoading] = useState(true);
const [pending, setPending] = useState<"connect" | "disconnect" | "voice" | "test" | null>(null);
const [pending, setPending] = useState<"connect" | "disconnect" | "voice" | null>(null);
const [playing, setPlaying] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const load = useCallback(async (nextProvider?: string) => {
const [nextCatalog, nextCredentials, nextStatus] = await Promise.all([
rpc<VoiceCatalogEntry[]>("voice/catalog"),
rpc<VoiceCredential[]>("voice/credentials"),
rpc<VoiceStatus>("voice/status"),
]);
const selected = nextProvider || nextStatus.provider || nextCatalog[0]?.id || "";
setCatalog(nextCatalog);
setCredentials(nextCredentials);
setStatus(nextStatus);
setProvider(selected);
const cred = nextCredentials.find((entry) => entry.provider === selected);
const load = useCallback(async () => {
await alignVoiceWithLocale(locale).catch(() => undefined);
const credentials = await rpc<VoiceCredential[]>("voice/credentials");
const cred = credentials.find((entry) => entry.provider === LOCAL_VOICE_PROVIDER);
setConnected(Boolean(cred));
setVoiceId(cred?.voiceId ?? "");
if (cred) {
setVoices(await rpc<VoiceInfo[]>("voice/voices", { provider: selected }));
const listed = await rpc<VoiceInfo[]>("voice/voices", { provider: LOCAL_VOICE_PROVIDER });
setVoices(voicesForLocale(listed, locale));
} else {
setVoices([]);
}
}, []);
}, [locale]);
useFocusEffect(
useCallback(() => {
@ -83,24 +58,12 @@ export default function VoiceSettings() {
}, [load]),
);
const selected = catalog.find((entry) => entry.id === provider);
const isKokoro = selected?.id === "kokoro";
const credential = credentials.find((entry) => entry.provider === provider);
const canConnect = isKokoro || apiKey.trim().length >= 8;
async function connect() {
if (!selected || !canConnect) return;
setPending("connect");
setError(null);
try {
await rpc("voice/connect", {
provider: selected.id,
apiKey: isKokoro ? KOKORO_LOCAL_TOKEN : apiKey.trim(),
voiceId: voiceId || undefined,
});
setApiKey("");
await load(selected.id);
setNotice(t("Connected {name}.", { name: selected.name }));
await rpc("voice/connect", { provider: LOCAL_VOICE_PROVIDER, apiKey: KOKORO_LOCAL_TOKEN });
await load();
} catch (err) {
setError(err instanceof Error ? err.message : t("Could not connect"));
} finally {
@ -109,14 +72,11 @@ export default function VoiceSettings() {
}
async function disconnect() {
if (!credential) return;
setPending("disconnect");
setError(null);
setNotice(null);
try {
await rpc("voice/disconnect", { provider: credential.provider });
setApiKey("");
await load(credential.provider);
await rpc("voice/disconnect", { provider: LOCAL_VOICE_PROVIDER });
await load();
} catch (err) {
setError(err instanceof Error ? err.message : t("Could not disconnect"));
} finally {
@ -127,173 +87,145 @@ export default function VoiceSettings() {
async function chooseVoice(nextVoiceId: string) {
setVoiceId(nextVoiceId);
setPending("voice");
setError(null);
try {
await rpc("voice/setVoice", { voiceId: nextVoiceId, provider: selected?.id });
await load(selected?.id);
await rpc("voice/setVoice", { voiceId: nextVoiceId, provider: LOCAL_VOICE_PROVIDER });
} catch (err) {
setError(err instanceof Error ? err.message : t("Could not save that voice"));
await load().catch(() => undefined);
} finally {
setPending(null);
}
}
async function testVoice() {
setPending("test");
async function playVoice(voice: VoiceInfo) {
setPlaying(voice.id);
setError(null);
try {
const ready = await speakText(t("Hi, this is how I'll sound when I read replies out loud."));
if (!ready) {
throw new Error(t("Connect a voice provider first."));
}
await speakText(t("Hi, this is how I'll sound when I read replies out loud."), {
voiceId: voice.id,
});
} catch (err) {
setError(err instanceof Error ? err.message : t("Could not play a sample"));
} finally {
setPending(null);
setPlaying((current) => (current === voice.id ? null : current));
}
}
const busy = pending !== null;
return (
<SafeAreaView edges={["bottom"]} style={styles.screen}>
<ScrollView contentContainerStyle={styles.content}>
{loading ? <ActivityIndicator color={native.secondaryLabel} /> : null}
{error ? <Text style={styles.error}>{error}</Text> : null}
{notice ? <Text style={styles.notice}>{notice}</Text> : null}
{catalog.map((entry) => {
const connected = credentials.some((cred) => cred.provider === entry.id);
{!loading && !connected ? (
<Pressable
disabled={busy}
onPress={() => void connect()}
style={[styles.button, busy && styles.disabled]}
>
<Text style={styles.buttonLabel}>{t("Connect")}</Text>
</Pressable>
) : null}
{voices.length ? (
<ScrollView
horizontal
accessibilityRole="radiogroup"
showsHorizontalScrollIndicator={false}
snapToInterval={CARD_WIDTH + CARD_GAP}
decelerationRate="fast"
style={styles.carousel}
contentContainerStyle={styles.carouselContent}
>
{voices.map((voice) => {
const active = voiceId === voice.id;
return (
<Pressable
key={entry.id}
disabled={pending !== null}
onPress={() => {
setProvider(entry.id);
setPending("voice");
void load(entry.id)
.catch((err: unknown) =>
setError(
err instanceof Error ? err.message : t("Could not load voice settings"),
),
)
.finally(() => setPending(null));
}}
style={[
styles.card,
provider === entry.id && styles.cardActive,
pending !== null && styles.disabled,
]}
key={voice.id}
accessibilityRole="radio"
accessibilityState={{ checked: active, disabled: busy }}
disabled={busy}
onPress={() => void chooseVoice(voice.id)}
style={[styles.card, active && styles.cardActive]}
>
<Text style={styles.cardTitle}>{entry.name}</Text>
<Text style={styles.cardMeta}>
{connected
? t("Connected")
: entry.transcribe
? t("Speak + transcribe")
: t("Speak only")}
<View style={styles.cardHeader}>
<Text numberOfLines={1} style={styles.cardTitle}>
{voice.label}
</Text>
<Pressable
accessibilityRole="button"
accessibilityLabel={t("Play {name}", { name: voice.label })}
disabled={playing !== null}
hitSlop={8}
onPress={() => void playVoice(voice)}
style={styles.play}
>
{playing === voice.id ? (
<ActivityIndicator size="small" color={native.secondaryLabel} />
) : (
<NativeSymbol ios="play.fill" android="play" size={14} />
)}
</Pressable>
</View>
{voice.description ? (
<Text style={styles.cardMeta}>{voice.description}</Text>
) : null}
</Pressable>
);
})}
{selected ? (
<>
{isKokoro ? (
<Text style={styles.help}>
{t("Local voice runs on your server. Tap Connect when it is ready.")}
</Text>
) : (
<TextInput
accessibilityLabel={t("API key")}
autoCapitalize="none"
autoComplete="off"
autoCorrect={false}
importantForAutofill="no"
value={apiKey}
onChangeText={setApiKey}
placeholder={credential ? t("Paste a replacement key") : t("Paste your API key")}
placeholderTextColor={native.tertiaryLabel}
secureTextEntry
style={styles.input}
textContentType="none"
/>
)}
</ScrollView>
) : null}
{connected ? (
<Pressable
disabled={pending !== null || !canConnect}
onPress={() => void connect()}
style={[styles.button, (pending !== null || !canConnect) && styles.disabled]}
>
<Text style={styles.buttonLabel}>
{credential ? (isKokoro ? t("Reconnect") : t("Replace key")) : t("Connect")}
</Text>
</Pressable>
{credential ? (
<Pressable
disabled={pending !== null}
disabled={busy}
onPress={() => void disconnect()}
style={[styles.secondary, pending !== null && styles.disabled]}
style={[styles.secondary, busy && styles.disabled]}
>
<Text style={styles.secondaryLabel}>
{pending === "disconnect" ? t("Disconnecting…") : t("Disconnect")}
</Text>
</Pressable>
) : null}
{voices.length ? (
<View style={styles.voices}>
{voices.map((voice) => (
<Pressable
key={voice.id}
disabled={pending !== null}
onPress={() => void chooseVoice(voice.id)}
style={[styles.voiceRow, pending !== null && styles.disabled]}
>
<Text style={styles.voiceLabel}>{voice.label}</Text>
{voiceId === voice.id ? <Text style={styles.check}>✓</Text> : null}
</Pressable>
))}
</View>
) : null}
{status?.ready ? (
<Pressable
disabled={pending !== null}
onPress={() => void testVoice()}
style={styles.secondary}
>
<Text style={styles.secondaryLabel}>{t("Hear a sample")}</Text>
</Pressable>
) : null}
</>
) : null}
</ScrollView>
</SafeAreaView>
);
}
const CARD_WIDTH = 160;
const CARD_GAP = 12;
function createVoiceStyles() {
const tokens = mobileTokens();
return StyleSheet.create({
screen: { flex: 1, backgroundColor: native.page },
content: { padding: 20, gap: 10 },
error: { color: tokens.destructive, marginBottom: 8 },
notice: { color: tokens.success, marginBottom: 8 },
content: { paddingVertical: 20, gap: 10 },
error: { color: tokens.destructive, marginBottom: 8, marginHorizontal: 20 },
carousel: { flexGrow: 0 },
carouselContent: { paddingHorizontal: 20, gap: CARD_GAP },
card: {
width: CARD_WIDTH,
borderRadius: 14,
borderWidth: 1,
borderColor: tokens.border,
padding: 14,
backgroundColor: tokens.card,
},
cardActive: { borderColor: tokens.ring, backgroundColor: tokens.muted },
cardTitle: { color: native.label, fontSize: 16 },
cardMeta: { color: native.tertiaryLabel, marginTop: 4, fontSize: 12 },
help: { marginTop: 8, color: native.secondaryLabel, fontSize: 14, lineHeight: 20 },
input: {
marginTop: 8,
borderRadius: 12,
borderWidth: 1,
borderColor: tokens.border,
color: native.label,
paddingHorizontal: 14,
paddingVertical: 12,
backgroundColor: tokens.card,
},
cardActive: { borderColor: tokens.primary, borderWidth: 2, backgroundColor: tokens.muted },
cardHeader: { flexDirection: "row", alignItems: "center", justifyContent: "space-between" },
cardTitle: { flexShrink: 1, color: native.label, fontSize: 16, fontWeight: "600" },
cardMeta: { color: native.tertiaryLabel, marginTop: 4, fontSize: 13 },
play: {
width: 30,
height: 30,
borderRadius: 15,
alignItems: "center",
justifyContent: "center",
backgroundColor: tokens.muted,
},
button: {
marginTop: 8,
marginHorizontal: 20,
backgroundColor: tokens.primary,
borderRadius: 12,
paddingVertical: 12,
@ -301,17 +233,6 @@ function createVoiceStyles() {
},
disabled: { opacity: 0.4 },
buttonLabel: { color: tokens.primaryForeground, fontWeight: "600" },
voices: { marginTop: 12, borderRadius: 12, borderWidth: 1, borderColor: tokens.border },
voiceRow: {
flexDirection: "row",
justifyContent: "space-between",
paddingHorizontal: 14,
paddingVertical: 12,
borderBottomWidth: 1,
borderBottomColor: tokens.border,
},
voiceLabel: { color: native.label },
check: { color: tokens.success },
secondary: { marginTop: 16, alignItems: "center" },
secondaryLabel: { color: native.secondaryLabel, fontSize: 15 },
});

View File

@ -3,6 +3,7 @@ import {
buildOoRig,
createOoLife,
OO_LIST_MOMENTS,
OO_RIG_FACE_VIEWBOX,
OO_RIG_INSET,
OO_RIG_SIZE,
OO_STILL_TIME,
@ -26,7 +27,7 @@ import Svg, {
} from "react-native-svg";
const VIEWBOX = `${OO_RIG_INSET} ${OO_RIG_INSET + 4} ${OO_RIG_SIZE - 2 * OO_RIG_INSET} ${OO_RIG_SIZE - 2 * OO_RIG_INSET}`;
const FACE_VIEWBOX = "47 62 112 112";
const FACE_VIEWBOX = OO_RIG_FACE_VIEWBOX;
/** Native frames redraw the SVG tree, so they run at half the display rate. */
const FRAME_SECONDS = 1 / 30;
/** Above this size an Oo always moves; smaller ones only for list moments. */

View File

@ -0,0 +1,52 @@
import { useState } from "react";
import { Pressable, Text, View } from "react-native";
import type { MobileMessage } from "../lib/api";
import { useI18n } from "../lib/i18n";
import { useMobileTokens } from "../lib/native";
import { NativeSymbol } from "./native-symbol";
/** A run's earlier step messages, folded behind one line above its latest message. */
export function RunSteps({ steps }: { steps: readonly MobileMessage[] }) {
const tokens = useMobileTokens();
const { t } = useI18n();
const [open, setOpen] = useState(false);
const label = steps.length === 1 ? t("1 step") : t("{count} steps", { count: steps.length });
return (
<View style={{ marginBottom: 6 }}>
<Pressable
accessibilityRole="button"
accessibilityState={{ expanded: open }}
onPress={() => setOpen((current) => !current)}
hitSlop={8}
style={{ flexDirection: "row", alignItems: "center", gap: 4, alignSelf: "flex-start" }}
>
<NativeSymbol
ios={open ? "chevron.down" : "chevron.right"}
android={open ? "chevron-down" : "chevron-forward"}
size={12}
color={tokens.mutedForeground}
/>
<Text style={{ color: tokens.mutedForeground, fontSize: 13.5 }}>{label}</Text>
</Pressable>
{open ? (
<View
style={{
marginTop: 4,
gap: 6,
borderLeftWidth: 1,
borderLeftColor: tokens.border,
paddingLeft: 10,
}}
>
{steps.map((step) => (
<Text key={step.id} style={{ color: tokens.mutedForeground, fontSize: 14 }}>
{step.blocks
.flatMap((block) => (block.kind === "text" ? [block.text] : []))
.join("\n")}
</Text>
))}
</View>
) : null}
</View>
);
}

View File

@ -144,6 +144,16 @@ export function selectedSpaceId(): string | null {
return cachedSpaceId || null;
}
/**
* Agents live in the home library, where a project's coordinator recruits them from; select it
* before creating one so the new agent and its chat share the request scope.
*/
export async function selectLibrarySpace(): Promise<void> {
const navigation = await rpc<MobileSpaceNavigation>("spaces/list");
const home = navigation.spaces.find((space) => space.isDefault)?.id;
if (home && home !== cachedSpaceId) await selectSpace(home);
}
/** Keep requests usable after the server deleted the selected Space but native
* storage could not replace it. Neutralize any same-endpoint rollback before
* writing the replacement selection so a cleanup failure cannot leave the new
@ -740,6 +750,7 @@ export type MobileMe = Pick<
| "defaultProvider"
| "defaultModel"
| "needsModel"
| "modelsManaged"
| "avatarStyle"
| "isDeploymentOwner"
>;
@ -773,7 +784,7 @@ export type MobileGroup = Pick<
| "updatedAt"
| "members"
> &
Partial<Pick<Group, "spaceId">>;
Partial<Pick<Group, "spaceId" | "kind">>;
export type MobileSpace = Space;
export type MobileSpaceNavigation = SpaceNavigation;
@ -786,8 +797,14 @@ export type MobileSnapshot = {
cursor?: number;
messages: MobileMessage[];
olderCursor: number | null;
run: { id: string; botId?: string; status: string; error?: string | null } | null;
activeRuns?: Array<{ id: string; botId?: string; status: string }>;
run: {
id: string;
botId?: string;
status: string;
error?: string | null;
createdAt?: string;
} | null;
activeRuns?: Array<{ id: string; botId?: string; status: string; createdAt?: string }>;
members?: MobileGroup["members"];
computer?: {
kind?: string;

View File

@ -60,6 +60,12 @@ export function mobileTokens(
return tokensForAppearance(resolveMobileAppearance(preference, scheme));
}
/** Header and inbox nav glyphs — aligned with web `--icon-*` scale (~25% larger). */
export const mobileShellIconSizes = {
md: 22,
lg: 26,
} as const;
export function subscribeAppearance(listener: () => void): () => void {
listeners.add(listener);
return () => {

View File

@ -79,7 +79,7 @@ export async function probeApiBase(
);
if (!res.ok) {
cancelResponseBody(res);
return { ok: false, error: t("That URL did not look like a Rakazo server") };
return { ok: false, error: t("That URL did not look like an evOo server") };
}
let body: HealthResponse;
try {
@ -93,7 +93,7 @@ export async function probeApiBase(
body = {};
}
if (body.error || body.json?.ok !== true) {
return { ok: false, error: t("That URL did not look like a Rakazo server") };
return { ok: false, error: t("That URL did not look like an evOo server") };
}
return parsed;
} catch {

View File

@ -51,7 +51,7 @@ describe("mobile i18n", () => {
const { resetI18nForTests, t } = await import("./i18n");
resetI18nForTests("zh-CN");
expect(t("Account")).toBe("账户");
expect(t("Sign in to Rakazo")).toBe("登录 Rakazo");
expect(t("Sign in to evOo")).toBe("登录 evOo");
expect(t("New bot")).toBe("新建 Bot");
expect(t("{runs} runs · {tokens} tokens", { runs: 3, tokens: 12 })).toBe(
"3 次运行 · 12 个 token",
@ -60,18 +60,26 @@ describe("mobile i18n", () => {
resetI18nForTests("ru");
expect(t("Account")).toBe("Аккаунт");
expect(t("Sign in to Rakazo")).toBe("Войти в Rakazo");
expect(t("Sign in to evOo")).toBe("Войти в evOo");
expect(t("New bot")).toBe("Новый бот");
expect(t("{runs} runs · {tokens} tokens", { runs: 3, tokens: 12 })).toBe(
"Запусков: 3 · токенов: 12",
);
expect(t("Delete {name}?", { name: "Scout" })).toBe("Удалить Scout?");
resetI18nForTests("de");
expect(t("Account")).toBe("Konto");
expect(t("Sign in to evOo")).toBe("Bei evOo anmelden");
expect(t("New bot")).toBe("Neuer Bot");
expect(t("{runs} runs · {tokens} tokens", { runs: 3, tokens: 12 })).toBe("3 Läufe · 12 Tokens");
expect(t("Delete {name}?", { name: "Scout" })).toBe("Scout löschen?");
});
it("preserves interpolations in the Chinese and Russian catalogs", async () => {
it("preserves interpolations in every non-English catalog", async () => {
const { ZH_MESSAGES } = await import("./locales/zh");
const { RU_MESSAGES } = await import("./locales/ru");
for (const messages of [ZH_MESSAGES, RU_MESSAGES]) {
const { DE_MESSAGES } = await import("./locales/de");
for (const messages of [ZH_MESSAGES, RU_MESSAGES, DE_MESSAGES]) {
const empty = Object.entries(messages).filter(([, value]) => !value.trim());
const interpolationMismatches = Object.entries(messages).filter(([id, value]) => {
const tokens = (message: string) =>
@ -83,9 +91,10 @@ describe("mobile i18n", () => {
}
});
it("translates every mobile chrome t() id in both non-English catalogs", async () => {
it("translates every mobile chrome t() id in every non-English catalog", async () => {
const { ZH_MESSAGES } = await import("./locales/zh");
const { RU_MESSAGES } = await import("./locales/ru");
const { DE_MESSAGES } = await import("./locales/de");
const { EMPTY_PLUGIN_CATALOG_MESSAGE, SLASH_ACTIONS } = await import("@rakazo/core");
const { OPENAI_COMPATIBLE_BASE_URL_HINT } = await import("@rakazo/contracts");
const mobileRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
@ -104,7 +113,7 @@ describe("mobile i18n", () => {
ids.add(JSON.parse(`"${match[1]}"`) as string);
}
}
for (const messages of [ZH_MESSAGES, RU_MESSAGES]) {
for (const messages of [ZH_MESSAGES, RU_MESSAGES, DE_MESSAGES]) {
const missing = [...ids].filter((id) => !messages[id]?.trim()).sort();
expect(missing).toEqual([]);
}
@ -139,6 +148,17 @@ describe("mobile i18n", () => {
expect(dateLocaleForUi()).toBe("ru");
});
it("normalizes a regional German stored locale before activation", async () => {
const { getItemAsync } = await import("expo-secure-store");
vi.mocked(getItemAsync).mockResolvedValue("de-AT");
const { bootstrapI18n, dateLocaleForUi, getActiveUiLocale, t } = await import("./i18n");
await expect(bootstrapI18n()).resolves.toBe("de");
expect(getActiveUiLocale()).toBe("de");
expect(t("Language")).toBe("Sprache");
expect(dateLocaleForUi()).toBe("de");
});
it("keeps the last locale when rapid setUiLocale calls finish out of order", async () => {
const { setItemAsync } = await import("expo-secure-store");
const { applyMobileUiDirection } = await import("./ui-direction");

View File

@ -1,4 +1,5 @@
import { useSyncExternalStore } from "react";
import { DE_MESSAGES } from "./locales/de";
import { RU_MESSAGES } from "./locales/ru";
import { ZH_MESSAGES } from "./locales/zh";
import {
@ -9,6 +10,7 @@ import {
} from "./ui-locale";
const catalogs: Partial<Record<UiLocale, Record<string, string>>> = {
de: DE_MESSAGES,
"zh-CN": ZH_MESSAGES,
ru: RU_MESSAGES,
};
@ -65,7 +67,7 @@ export function activateUiLocale(locale: UiLocale): UiLocale {
async function applyDirection(locale: UiLocale): Promise<void> {
try {
// Direction follows the active UI locale only (en / zh-CN / ru → LTR), never the
// Direction follows the active UI locale only (en / de / zh-CN / ru → LTR), never the
// device language. Applying device RTL here would fight this and reload-loop.
const { applyMobileUiDirection } = await import("./ui-direction");
applyMobileUiDirection(htmlLangForLocale(locale));

View File

@ -147,12 +147,18 @@ describe("spaceInboxItems", () => {
expect(canDeleteInboxSpace(space({ canDelete }))).toBe(false);
});
it("offers deletion for an explicitly authorized empty non-default space", () => {
it("offers deletion for an explicitly authorized non-default space", () => {
expect(canDeleteInboxSpace(space())).toBe(true);
expect(canDeleteInboxSpace(space({ hasContent: true, canDelete: true }))).toBe(true);
});
it("hides deletion while a space still has content", () => {
expect(canDeleteInboxSpace(space({ hasContent: true }))).toBe(false);
it("deduplicates same-named projects into a single project entry", () => {
const first = space({ id: "space-work-1", name: "Work", hasContent: false });
const second = space({ id: "space-work-2", name: " work ", hasContent: true, bots: [bot] });
const items = spaceInboxItems([first, second]);
const headings = items.filter((item) => item.type === "heading" && item.space);
expect(headings).toHaveLength(1);
expect(headings[0]).toMatchObject({ key: "space-work-2", title: " work " });
});
});

View File

@ -12,7 +12,7 @@ import {
export type InboxSpace = Pick<
MobileSpace,
"id" | "name" | "isDefault" | "hasContent" | "canDelete" | "botSections"
"id" | "name" | "isDefault" | "hasContent" | "canDelete" | "botSections" | "icon" | "color"
> & {
bots: (MobileBot | SpaceBot)[];
groups: (MobileGroup | SpaceGroup)[];
@ -24,22 +24,47 @@ export type InboxSpaceItem =
| { type: "heading"; key: string; title: string; space?: InboxSpace };
export function canDeleteInboxSpace(
space: Pick<InboxSpace, "isDefault" | "hasContent" | "canDelete">,
space: Pick<InboxSpace, "isDefault" | "canDelete"> & Partial<Pick<InboxSpace, "hasContent">>,
) {
return !space.isDefault && !space.hasContent && space.canDelete === true;
return !space.isDefault && space.canDelete === true;
}
export function spaceInboxItems(spaces: InboxSpace[]): InboxSpaceItem[] {
return spaces.flatMap((space): InboxSpaceItem[] => {
const seenProjectNames = new Set<string>();
const canonicalProjectIds = new Set<string>();
const orderedProjects = [...spaces]
.filter((space) => !space.isDefault)
.sort((left, right) => Number(Boolean(right.hasContent)) - Number(Boolean(left.hasContent)));
for (const space of orderedProjects) {
const normalized = space.name.trim().toLowerCase();
if (normalized && seenProjectNames.has(normalized)) continue;
if (normalized) seenProjectNames.add(normalized);
canonicalProjectIds.add(space.id);
}
const visibleSpaces = spaces.filter(
(space) => space.isDefault || canonicalProjectIds.has(space.id),
);
return visibleSpaces.flatMap((space): InboxSpaceItem[] => {
const normalizedSpaceName = space.name.trim().toLowerCase();
const chats = [
// A main bot is reached through its space's main conversation, not listed again.
...space.bots
.filter((chat) => chat.kind !== "main")
.map((chat) => ({ type: "bot" as const, bot: chat, ...chat })),
...space.groups.map((chat) => ({ type: "group" as const, group: chat, ...chat })),
...space.groups
.filter((chat) => {
const normalizedGroupName = chat.name.trim().toLowerCase();
if (!space.isDefault && normalizedGroupName === normalizedSpaceName) return false;
if (space.isDefault && chat.kind !== "main" && seenProjectNames.has(normalizedGroupName))
return false;
return true;
})
.map((chat) => ({ type: "group" as const, group: chat, ...chat })),
];
const items: InboxSpaceItem[] = [];
if (spaces.length > 1 || !space.isDefault || chats.length === 0) {
if (visibleSpaces.length > 1 || !space.isDefault || chats.length === 0) {
items.push({ type: "heading", key: space.id, title: space.name, space });
}
for (const group of groupBotsForSidebar(chats, space.botSections)) {

View File

@ -0,0 +1,672 @@
export const DE_MESSAGES: Record<string, string> = {
"Connecting…": "Verbinde…",
"GitHub token": "GitHub-Token",
"Create token on GitHub": "Token bei GitHub erstellen",
"Could not connect GitHub": "GitHub konnte nicht verbunden werden",
"1 step": "1 Schritt",
"1 tool": "1 Tool",
"@{name}": "@{name}",
"API key": "API-Schlüssel",
Accept: "Annehmen",
Account: "Konto",
"Account label": "Kontobezeichnung",
Accounts: "Konten",
Active: "Aktiv",
"Active model": "Aktives Modell",
Activity: "Aktivität",
Add: "Hinzufügen",
"Add Executor": "Executor hinzufügen",
"Add GraphQL": "GraphQL hinzufügen",
"Add MCP server": "MCP-Server hinzufügen",
"Add OpenAPI": "OpenAPI hinzufügen",
"Add Treg": "Treg hinzufügen",
"Add a voice provider in Voice settings.":
"Füge in den Stimmeinstellungen einen Sprachanbieter hinzu.",
"Add another": "Weiteres hinzufügen",
"Add another {name}": "Weitere {name}-Verbindung hinzufügen",
"Add {name}": "{name} hinzufügen",
Added: "Hinzugefügt",
Advanced: "Erweitert",
Agent: "Agent",
"Agent messages": "Agent-Nachrichten",
"Alerts from routines": "Hinweise von Routinen",
"Allowed once": "Einmal erlaubt",
"Already have an account?": "Du hast bereits ein Konto?",
"Always allowed": "Immer erlaubt",
"Android blocked notifications.": "Android hat Benachrichtigungen blockiert.",
Answer: "Antwort",
Answered: "Beantwortet",
"Answered: {answer}": "Beantwortet: {answer}",
Appearance: "Erscheinungsbild",
Archive: "Archivieren",
"Archived bots": "Archivierte Bots",
"Archived. Chat, memory, and files kept.":
"Archiviert. Chat, Erinnerungen und Dateien bleiben erhalten.",
Arms: "Arme",
"Ask the server owner to configure this provider.":
"Bitte den Serverbesitzer, diesen Anbieter zu konfigurieren.",
Asleep: "Im Ruhezustand",
Attach: "Anhängen",
"Attach file": "Datei anhängen",
Attachment: "Anhang",
"Authorization code": "Autorisierungscode",
"Authorization timed out. Please try again.":
"Zeitüberschreitung bei der Autorisierung. Versuche es erneut.",
Authorize: "Autorisieren",
"Authorize {name}": "{name} autorisieren",
Autopilot: "Autopilot",
"Autopilot finished": "Autopilot beendet",
"Autopilot paused": "Autopilot pausiert",
"Autopilot stopped": "Autopilot gestoppt",
Back: "Zurück",
"Back to sign in": "Zurück zur Anmeldung",
Beanie: "Mütze",
Bear: "Bär",
"Bearer authentication": "Bearer-Authentifizierung",
"Bearer token": "Bearer-Token",
Bell: "Glöckchen",
Blue: "Blau",
"Booting live desktop…": "Live-Desktop wird gestartet…",
"Booting {label}": "{label} wird gestartet",
Bot: "Bot",
"Bot actions": "Bot-Aktionen",
Bow: "Schleife",
"Bow tie": "Fliege",
Brown: "Braun",
Brows: "Brauen",
Bunny: "Hase",
Bushy: "Buschig",
Calm: "Ruhig",
Camera: "Kamera",
Cancel: "Abbrechen",
"Cancel reply": "Antwort abbrechen",
Cancelled: "Abgebrochen",
Cat: "Katze",
"Change password": "Passwort ändern",
"Chat Settings": "Chat-Einstellungen",
"Chat settings": "Chat-Einstellungen",
"Check your email": "Prüfe deine E-Mails",
"Checking…": "Wird geprüft…",
Cheeks: "Wangen",
"Chef's hat": "Kochmütze",
Classic: "Klassisch",
Clear: "Leeren",
"Clear conversation": "Unterhaltung leeren",
"Clear conversation?": "Unterhaltung leeren?",
"Client ID": "Client-ID",
"Client secret": "Client-Secret",
Close: "Schließen",
"Close chat organization": "Chat-Organisation schließen",
"Close computer": "Computer schließen",
"Close preview": "Vorschau schließen",
"Cloud agent": "Cloud-Agent",
Code: "Code",
Color: "Farbe",
"Color {number}": "Farbe {number}",
Completed: "Abgeschlossen",
Computer: "Computer",
"Computer failed to boot": "Computer konnte nicht gestartet werden",
"Computer is asleep. Take control to wake it.":
"Computer ist im Ruhezustand. Übernimm die Kontrolle, um ihn aufzuwecken.",
"Computer is stopped": "Computer ist gestoppt",
"Configure a plugin catalog on the server to connect apps.":
"Richte auf dem Server einen Plugin-Katalog ein, um Apps zu verbinden.",
"Configured by deployment": "Durch die Bereitstellung konfiguriert",
"Confirm password": "Passwort bestätigen",
Connect: "Verbinden",
"Connect API key": "API-Schlüssel verbinden",
"Connect Executor": "Executor verbinden",
"Connect Treg": "Treg verbinden",
"Connect a voice provider first.": "Verbinde zuerst einen Sprachanbieter.",
"Connect this provider to use it as your personal model.":
"Verbinde diesen Anbieter, um ihn als persönliches Modell zu verwenden.",
Connected: "Verbunden",
"Connected and using {label}.": "Verbunden, {label} wird verwendet.",
"Connected {name}.": "{name} verbunden.",
"Connected · {label}": "Verbunden · {label}",
"Connection pending": "Verbindung ausstehend",
"Context limit": "Kontextlimit",
Continue: "Weiter",
"Continue in Background": "Im Hintergrund fortsetzen",
Copy: "Kopieren",
"Could not add source": "Quelle konnte nicht hinzugefügt werden",
"Could not archive bot": "Bot konnte nicht archiviert werden",
"Could not authorize this app": "App konnte nicht autorisiert werden",
"Could not change language": "Sprache konnte nicht geändert werden",
"Could not change password": "Passwort konnte nicht geändert werden",
"Could not change the default model": "Standardmodell konnte nicht geändert werden",
"Could not clear conversation": "Unterhaltung konnte nicht geleert werden",
"Could not clear the custom server URL": "Eigene Server-URL konnte nicht entfernt werden",
"Could not clear the local session": "Lokale Sitzung konnte nicht entfernt werden",
"Could not clear the previous server session":
"Vorherige Serversitzung konnte nicht entfernt werden",
"Could not clear the previous space": "Vorheriger Space konnte nicht entfernt werden",
"Could not complete action": "Aktion konnte nicht abgeschlossen werden",
"Could not connect": "Verbindung fehlgeschlagen",
"Could not connect this provider": "Anbieter konnte nicht verbunden werden",
"Could not continue": "Fortfahren fehlgeschlagen",
"Could not create bot": "Bot konnte nicht erstellt werden",
"Could not create group": "Gruppe konnte nicht erstellt werden",
"Could not create space": "Space konnte nicht erstellt werden",
"Could not delete account": "Konto konnte nicht gelöscht werden",
"Could not delete bot": "Bot konnte nicht gelöscht werden",
"Could not delete group": "Gruppe konnte nicht gelöscht werden",
"Could not delete space": "Space konnte nicht gelöscht werden",
"Could not disconnect": "Trennen fehlgeschlagen",
"Could not finish sign-in": "Anmeldung konnte nicht abgeschlossen werden",
"Could not load bot": "Bot konnte nicht geladen werden",
"Could not load bots": "Bots konnten nicht geladen werden",
"Could not load earlier messages": "Frühere Nachrichten konnten nicht geladen werden",
"Could not load group": "Gruppe konnte nicht geladen werden",
"Could not load integrations": "Integrationen konnten nicht geladen werden",
"Could not load model settings": "Modelleinstellungen konnten nicht geladen werden",
"Could not load routine": "Routine konnte nicht geladen werden",
"Could not load the desktop. This device cannot reach the screen URL.":
"Desktop konnte nicht geladen werden. Dieses Gerät erreicht die Bildschirm-URL nicht.",
"Could not load this file.": "Datei konnte nicht geladen werden.",
"Could not load voice settings": "Stimmeinstellungen konnten nicht geladen werden",
"Could not open computer": "Computer konnte nicht geöffnet werden",
"Could not open file": "Datei konnte nicht geöffnet werden",
"Could not open image": "Bild konnte nicht geöffnet werden",
"Could not open message": "Nachricht konnte nicht geöffnet werden",
"Could not play a sample": "Beispiel konnte nicht abgespielt werden",
"Could not play that clip.": "Clip konnte nicht abgespielt werden.",
"Could not reach that server": "Server konnte nicht erreicht werden",
"Could not reach this model server": "Modellserver konnte nicht erreicht werden",
"Could not remove source": "Quelle konnte nicht entfernt werden",
"Could not rename connection": "Verbindung konnte nicht umbenannt werden",
"Could not restore bot": "Bot konnte nicht wiederhergestellt werden",
"Could not revoke connection": "Verbindung konnte nicht widerrufen werden",
"Could not save bot": "Bot konnte nicht gespeichert werden",
"Could not save group": "Gruppe konnte nicht gespeichert werden",
"Could not save that voice": "Stimme konnte nicht gespeichert werden",
"Could not save the default space": "Standard-Space konnte nicht gespeichert werden",
"Could not save the server URL": "Server-URL konnte nicht gespeichert werden",
"Could not send answer": "Antwort konnte nicht gesendet werden",
"Could not send reset email": "E-Mail zum Zurücksetzen konnte nicht gesendet werden",
"Could not share file": "Datei konnte nicht geteilt werden",
"Could not sign out": "Abmelden fehlgeschlagen",
"Could not speak": "Vorlesen fehlgeschlagen",
"Could not start sign-in": "Anmeldung konnte nicht gestartet werden",
"Could not submit answer": "Antwort konnte nicht abgesendet werden",
"Could not switch computer": "Computer konnte nicht gewechselt werden",
"Could not switch spaces": "Space konnte nicht gewechselt werden",
"Could not update chat": "Chat konnte nicht aktualisiert werden",
"Could not update computer": "Computer konnte nicht aktualisiert werden",
"Could not update notifications": "Benachrichtigungen konnten nicht aktualisiert werden",
"Could not update reaction": "Reaktion konnte nicht aktualisiert werden",
"Could not verify or save these credentials":
"Zugangsdaten konnten nicht geprüft oder gespeichert werden",
Create: "Erstellen",
"Create group": "Gruppe erstellen",
"Create section": "Abschnitt erstellen",
"Create space": "Space erstellen",
Created: "Erstellt",
"Creating…": "Wird erstellt…",
Crown: "Krone",
"Current password": "Aktuelles Passwort",
"Custom connector": "Eigener Connector",
"Custom server": "Eigener Server",
"Custom server {host}": "Eigener Server {host}",
"Customer support": "Kundensupport",
Customize: "Anpassen",
"Daily, {time}": "Täglich, {time}",
Default: "Standard",
"Default (medium)": "Standard (mittel)",
Delete: "Löschen",
"Delete account": "Konto löschen",
"Delete group": "Gruppe löschen",
"Delete memories too": "Erinnerungen ebenfalls löschen",
"Delete space": "Space löschen",
"Delete this group? Bots and their solo threads are kept.":
"Diese Gruppe löschen? Bots und ihre Einzel-Unterhaltungen bleiben erhalten.",
"Delete your account?": "Konto löschen?",
"Delete {name}?": "{name} löschen?",
"Delete…": "Löschen…",
Denied: "Abgelehnt",
"Deployment default": "Bereitstellungsstandard",
"Describe what this bot does": "Beschreibe, was dieser Bot macht",
Description: "Beschreibung",
"Direct MCP": "Direktes MCP",
Disconnect: "Trennen",
"Disconnecting…": "Verbindung wird getrennt…",
Dismiss: "Ausblenden",
Dismissed: "Abgelehnt",
"Display name": "Anzeigename",
Dog: "Hund",
Done: "Erledigt",
"Don’t have an account?": "Du hast noch kein Konto?",
Dot: "Punkt",
Edit: "Bearbeiten",
Email: "E-Mail",
Embedded: "Eingebettet",
Energy: "Energie",
"Enter a server URL": "Gib eine Server-URL ein",
"Enter a whole number from 1 to 1000 for the image limit.":
"Gib eine ganze Zahl von 1 bis 1000 für das Bilderlimit ein.",
"Enter a whole number from 1 to {max} for maximum output tokens.":
"Gib eine ganze Zahl von 1 bis {max} für die maximalen Ausgabetokens ein.",
"Enter a whole number from 1 to {max} for the context limit.":
"Gib eine ganze Zahl von 1 bis {max} für das Kontextlimit ein.",
"Enter this code in your browser:": "Gib diesen Code in deinem Browser ein:",
"Enter your evOo server address.": "Gib die Adresse deines evOo-Servers ein.",
"Every hour": "Jede Stunde",
"Every {count} h": "Alle {count} Std.",
"Every {count} min": "Alle {count} Min.",
"Every {day}, {time}": "Jeden {day}, {time}",
"Executor token": "Executor-Token",
Expired: "Abgelaufen",
"Extra high": "Sehr hoch",
Eyeliner: "Lidstrich",
Eyeshadow: "Lidschatten",
Failed: "Fehlgeschlagen",
"Failed to refresh": "Aktualisieren fehlgeschlagen",
Resume: "Fortsetzen",
Stopped: "Gestoppt",
"Failed to remove message": "Nachricht konnte nicht entfernt werden",
"Failed to send message": "Nachricht konnte nicht gesendet werden",
"Failed to stop work": "Arbeit konnte nicht gestoppt werden",
"Fewer {label}": "Weniger {label}",
File: "Datei",
"Find models": "Modelle suchen",
"Finding…": "Suche läuft…",
Fine: "Fein",
"Finish MCP authorization in the web app.": "Schließe die MCP-Autorisierung in der Web-App ab.",
"Finish connecting in the browser, then refresh this page.":
"Schließe die Verbindung im Browser ab und aktualisiere dann diese Seite.",
"Finish signing in in your browser:": "Schließe die Anmeldung in deinem Browser ab:",
Flick: "Schwung",
Flower: "Blume",
"Forgot password?": "Passwort vergessen?",
"Found {count} model.": "{count} Modell gefunden.",
"Found {count} models.": "{count} Modelle gefunden.",
Freckles: "Sommersprossen",
Frog: "Frosch",
Full: "Voll",
"Get credentials": "Zugangsdaten holen",
"Getting ready": "Wird vorbereitet",
Ghost: "Geist",
"Git event": "Git-Ereignis",
Glass: "Glas",
Glasses: "Brille",
Gold: "Gold",
"GraphQL endpoint": "GraphQL-Endpunkt",
Green: "Grün",
Group: "Gruppe",
"Group name": "Gruppenname",
"Group not found": "Gruppe nicht gefunden",
"Group settings": "Gruppeneinstellungen",
Hat: "Kopf",
Headphones: "Kopfhörer",
"Hear a sample": "Beispiel anhören",
Heart: "Herz",
"Hi, this is how I'll sound when I read replies out loud.":
"Hallo, so klinge ich, wenn ich Antworten vorlese.",
"Hide {label}": "{label} ausblenden",
High: "Hoch",
Image: "Bild",
Integrations: "Integrationen",
Iris: "Iris",
"It could not be opened. Try again from the sidebar.":
"Er konnte nicht geöffnet werden. Versuche es erneut über die Seitenleiste.",
"Its conversation, files, and routines will be permanently deleted. What should happen to its memories?":
"Unterhaltung, Dateien und Routinen werden dauerhaft gelöscht. Was soll mit den Erinnerungen passieren?",
"I’m done": "Ich bin fertig",
"Jump to latest": "Zur neuesten Nachricht springen",
"Jump to latest, new messages": "Zur neuesten Nachricht springen, neue Nachrichten",
"Keep memories": "Erinnerungen behalten",
Language: "Sprache",
Lashes: "Wimpern",
Lilac: "Flieder",
"Live update settings": "Einstellungen für Live-Updates",
"Live working status": "Live-Arbeitsstatus",
Lively: "Lebhaft",
"Load earlier messages": "Frühere Nachrichten laden",
"Loading preview…": "Vorschau wird geladen…",
"Loading tools…": "Tools werden geladen…",
"Loading…": "Wird geladen…",
"Local voice runs on your server. Tap Connect when it is ready.":
"Lokale Stimme läuft auf deinem Server. Tippe auf „Verbinden“, sobald sie bereit ist.",
"Long press to pin or move to a section":
"Lange drücken zum Anheften oder Verschieben in einen Abschnitt",
"Long press to pin, move, or silence notifications":
"Lange drücken zum Anheften, Verschieben oder Stummschalten",
Low: "Niedrig",
"Make sure nothing is still running on this computer.":
"Stelle sicher, dass auf diesem Computer nichts mehr läuft.",
"Markdown file": "Markdown-Datei",
Max: "Max.",
"Maximum images per request": "Maximale Bilder pro Anfrage",
"Maximum output tokens": "Maximale Ausgabetokens",
Medium: "Mittel",
Members: "Mitglieder",
Message: "Nachricht",
"Message event": "Nachrichtenereignis",
"Message from {peer}": "Nachricht von {peer}",
"Message {name}": "Nachricht an {name}",
"Messaged {peer}": "Nachricht an {peer} gesendet",
"Message…": "Nachricht…",
Minimal: "Minimal",
Mint: "Mint",
Model: "Modell",
"Model id": "Modell-ID",
"Model updated.": "Modell aktualisiert.",
Models: "Modelle",
Monocle: "Monokel",
"Monthly on day {day}, {time}": "Monatlich am {day}., {time}",
More: "Mehr",
"More {label}": "Mehr {label}",
"Move to": "Verschieben nach",
Name: "Name",
"Name this bot": "Bot benennen",
"Name this group": "Gruppe benennen",
Neck: "Hals",
"Needs attention": "Braucht Aufmerksamkeit",
"Needs input": "Braucht Eingabe",
"Needs takeover": "Braucht Übernahme",
Nerd: "Nerd",
"New bot": "Neuer Bot",
"New group": "Neue Gruppe",
"New password": "Neues Passwort",
"New section": "Neuer Abschnitt",
"New space": "Neuer Space",
Next: "Weiter",
"Next {date}": "Nächstes Mal {date}",
"No apps match your search.": "Keine Apps entsprechen deiner Suche.",
"No authentication": "Keine Authentifizierung",
"No custom sources installed.": "Keine eigenen Quellen installiert.",
"No longer active": "Nicht mehr aktiv",
"No matching bots": "Keine passenden Bots",
"No messages yet": "Noch keine Nachrichten",
"No results": "Keine Ergebnisse",
"No tools available.": "Keine Tools verfügbar.",
None: "Keine",
"Not connected": "Nicht verbunden",
"Not in the plugin catalog": "Nicht im Plugin-Katalog",
"Nothing is still running": "Nichts läuft mehr",
"Notification settings": "Benachrichtigungseinstellungen",
Notifications: "Benachrichtigungen",
"Notifications silenced": "Benachrichtigungen stummgeschaltet",
Now: "Jetzt",
"Now using {label}.": "Jetzt wird {label} verwendet.",
"OTA · {id}": "OTA · {id}",
"OTA · {id} · {date}": "OTA · {id} · {date}",
"Open computer": "Computer öffnen",
"Open conversation": "Unterhaltung öffnen",
"Open in full window": "In vollständigem Fenster öffnen",
"Open web app": "Web-App öffnen",
"OpenAI-compatible server URL": "URL eines OpenAI-kompatiblen Servers",
"OpenAPI JSON": "OpenAPI-JSON",
"Opened its thread.": "Unterhaltung geöffnet.",
Optional: "Optional",
"Or connect an API key": "Oder einen API-Schlüssel verbinden",
"Other model…": "Anderes Modell…",
"Party hat": "Partyhut",
Password: "Passwort",
"Password recovery is not configured for this server":
"Die Passwortwiederherstellung ist für diesen Server nicht konfiguriert",
"Password updated": "Passwort aktualisiert",
"Passwords do not match": "Die Passwörter stimmen nicht überein",
"Paste a replacement key": "Ersatzschlüssel einfügen",
"Play {name}": "{name} anhören",
"Paste the OpenAI-compatible address from your server. Rakazo adds /v1 if needed.":
"Füge die OpenAI-kompatible Adresse deines Servers ein. Rakazo ergänzt /v1 bei Bedarf.",
"Paste your API key": "Füge deinen API-Schlüssel ein",
Paused: "Pausiert",
Paw: "Pfote",
Peach: "Pfirsich",
Penguin: "Pinguin",
Personal: "Persönlich",
"Personal credential": "Persönliche Zugangsdaten",
"Photo library": "Fotomediathek",
Pin: "Anheften",
Pink: "Rosa",
Playful: "Verspielt",
"Please try again.": "Bitte versuche es erneut.",
Private: "Privat",
"Project ID": "Projekt-ID",
Prompt: "Prompt",
Providers: "Anbieter",
"Public servers need https://. HTTP only works on your local network.":
"Öffentliche Server brauchen https://. HTTP funktioniert nur in deinem lokalen Netzwerk.",
"Pull request": "Pull Request",
"Questions, approvals, takeover": "Fragen, Freigaben, Übernahme",
Queued: "In Warteschlange",
React: "Reagieren",
"Reading glasses": "Lesebrille",
"Reasoning effort": "Denkaufwand",
Recent: "Kürzlich",
Reconnect: "Erneut verbinden",
Reconnecting: "Verbindung wird wiederhergestellt",
"Recover computer": "Computer wiederherstellen",
"Recovering Team Computer": "Team-Computer wird wiederhergestellt",
"Recovering {name}’s Computer": "Computer von {name} wird wiederhergestellt",
"Recovering…": "Wird wiederhergestellt…",
"Recovery failed": "Wiederherstellung fehlgeschlagen",
"Recovery is unavailable until the previous operation has stopped.":
"Die Wiederherstellung ist erst verfügbar, wenn der vorherige Vorgang beendet ist.",
"Recovery restores the last saved workspace. Unsaved work may be lost.":
"Die Wiederherstellung lädt den zuletzt gespeicherten Arbeitsbereich. Nicht gespeicherte Arbeit kann verloren gehen.",
"Recreating the computer": "Computer wird neu erstellt",
Release: "Freigeben",
"Release computer": "Computer freigeben",
"Release interrupted computer?": "Unterbrochenen Computer freigeben?",
Reminder: "Erinnerung",
"Remote MCP server": "Remote-MCP-Server",
Remove: "Entfernen",
"Remove mention {name}": "Erwähnung von {name} entfernen",
"Remove skill {name}": "Skill {name} entfernen",
"Remove {name}": "{name} entfernen",
Removed: "Entfernt",
"Removed with chat, computer, and memory.":
"Zusammen mit Chat, Computer und Erinnerungen entfernt.",
"Removing…": "Wird entfernt…",
"Rename section": "Abschnitt umbenennen",
"Replace API key": "API-Schlüssel ersetzen",
"Replace key": "Schlüssel ersetzen",
"Replies and completed work": "Antworten und erledigte Arbeit",
Reply: "Antworten",
"Replying to": "Antwort an",
Reset: "Zurücksetzen",
"Reset computer": "Computer zurücksetzen",
"Reset computer?": "Computer zurücksetzen?",
"Reset your password": "Setze dein Passwort zurück",
"Resetting…": "Wird zurückgesetzt…",
Restore: "Wiederherstellen",
"Restore the last saved workspace. Unsaved work on the computer is lost.":
"Stellt den zuletzt gespeicherten Arbeitsbereich wieder her. Nicht gespeicherte Arbeit auf dem Computer geht verloren.",
"Restoring your workspace": "Dein Arbeitsbereich wird wiederhergestellt",
"Resume notifications": "Benachrichtigungen wieder aktivieren",
"Return to inbox": "Zurück zum Posteingang",
Robot: "Roboter",
Rose: "Rosé",
Round: "Rund",
Routine: "Routine",
"Routine link is incomplete": "Routine-Link ist unvollständig",
Running: "Läuft",
Save: "Speichern",
"Save section name": "Abschnittsnamen speichern",
Saved: "Gespeichert",
"Saved {name} locally": "{name} lokal gespeichert",
"Saved.": "Gespeichert.",
"Saving your workspace": "Dein Arbeitsbereich wird gespeichert",
"Saving…": "Wird gespeichert…",
Scarf: "Schal",
"Scheduled tasks": "Geplante Aufgaben",
Search: "Suchen",
"Search apps": "Apps suchen",
"Search conversations, files, and routines": "Unterhaltungen, Dateien und Routinen durchsuchen",
"Searching…": "Suche…",
"Section name": "Abschnittsname",
Send: "Senden",
"Send answer": "Antwort senden",
"Send now": "Jetzt gleich senden",
"Send reset link": "Link zum Zurücksetzen senden",
"Sending…": "Wird gesendet…",
Server: "Server",
"Server URL": "Server-URL",
"Server found. Enter a model name.": "Server gefunden. Gib einen Modellnamen ein.",
"Server integrations": "Server-Integrationen",
"Set up Executor on your server in the web app.":
"Richte Executor für deinen Server in der Web-App ein.",
"Settings: General": "Einstellungen: Allgemein",
"Settings: Usage": "Einstellungen: Nutzung",
"Setup help": "Einrichtungshilfe",
Shape: "Form",
"Shape {number}": "Form {number}",
"Share {name}": "{name} teilen",
"Show less": "Weniger anzeigen",
"Show more": "Mehr anzeigen",
"Show {count} more": "{count} weitere zeigen",
"Show {label}": "{label} anzeigen",
"Sign in": "Anmelden",
"Sign in to evOo": "Bei evOo anmelden",
"Sign out": "Abmelden",
"Sign up": "Registrieren",
"Sign up for evOo": "Bei evOo registrieren",
"Sign-in did not return a session": "Die Anmeldung hat keine Sitzung zurückgegeben",
"Sign-up did not return a session": "Die Registrierung hat keine Sitzung zurückgegeben",
"Silence notifications": "Benachrichtigungen stummschalten",
"Skill {name}": "Skill {name}",
Skip: "Überspringen",
"Skipped {items}": "{items} übersprungen",
"Slack message": "Slack-Nachricht",
Soft: "Zart",
"Space actions for {name}": "Space-Aktionen für {name}",
"Space created": "Space erstellt",
"Space default": "Space-Standard",
Sparkle: "Glanz",
"Speak + transcribe": "Vorlesen + transkribieren",
"Speak message": "Nachricht vorlesen",
"Speak only": "Nur vorlesen",
Species: "Wesen",
Star: "Stern",
Start: "Starten",
"Start timer": "Timer starten",
Starting: "Startet",
"Starting…": "Startet…",
Steady: "Gleichmäßig",
"Step {position} of {total}": "Schritt {position} von {total}",
Stop: "Stoppen",
"Stop timer": "Timer stoppen",
"Stored securely. Never shown here.": "Sicher gespeichert. Hier nie angezeigt.",
Stub: "Stummel",
Submit: "Absenden",
Sunglasses: "Sonnenbrille",
"Supports images": "Unterstützt Bilder",
"Supports thinking": "Unterstützt Denkmodus",
"Surprise me": "Überrasch mich",
"Switching…": "Wird gewechselt…",
"Take control": "Kontrolle übernehmen",
"Tap + to create a bot": "Tippe auf +, um einen Bot zu erstellen",
Team: "Team",
"Team Computer": "Team-Computer",
"Teams message": "Teams-Nachricht",
"That URL did not look like an evOo server": "Diese URL scheint kein evOo-Server zu sein",
"That URL is missing a host": "Dieser URL fehlt ein Host",
"That doesn’t look like a URL": "Das sieht nicht nach einer URL aus",
"The final page may not load. Paste its URL or code here.":
"Die letzte Seite wird möglicherweise nicht geladen. Füge ihre URL oder den Code hier ein.",
"The server changed while starting the request":
"Der Server hat sich beim Starten der Anfrage geändert",
Thinking: "Denkmodus",
"This permanently deletes your account, bots, conversations, memories, files, and saved connections. This cannot be undone.":
"Dadurch werden dein Konto, Bots, Unterhaltungen, Erinnerungen, Dateien und gespeicherten Verbindungen dauerhaft gelöscht. Das kann nicht rückgängig gemacht werden.",
"This removes every message and stops current work. The bot, computer, memory, and routines are kept.":
"Dadurch werden alle Nachrichten entfernt und laufende Arbeit gestoppt. Bot, Computer, Erinnerungen und Routinen bleiben erhalten.",
"This removes the empty space for everyone.": "Dadurch wird der leere Space für alle entfernt.",
"This routine no longer exists": "Diese Routine existiert nicht mehr",
"This subscription sign-in is not available in evOo yet. Use a deployment credential or choose another provider.":
"Diese Abonnementanmeldung ist in evOo noch nicht verfügbar. Verwende Zugangsdaten der Bereitstellung oder wähle einen anderen Anbieter.",
Thread: "Unterhaltung",
Title: "Titel",
"Tool sources": "Toolquellen",
Tools: "Tools",
"Treg token": "Treg-Token",
"Try again": "Erneut versuchen",
"Try again.": "Versuche es erneut.",
"Type your answer": "Gib deine Antwort ein",
Unassigned: "Nicht zugewiesen",
Undo: "Rückgängig",
Uninstall: "Deinstallieren",
Unpin: "Lösen",
"Until a time": "Bis zu einer Uhrzeit",
"Until done": "Bis fertig",
"Until tomorrow morning": "Bis morgen früh",
"Until when?": "Bis wann?",
"Update computer": "Computer aktualisieren",
"Update failed": "Update fehlgeschlagen",
"Update your server to use AI data sharing in this mobile version.":
"Aktualisiere deinen Server, um KI-Datenfreigabe in dieser Mobilversion zu nutzen.",
Updated: "Aktualisiert",
"Updating Team Computer": "Team-Computer wird aktualisiert",
"Updating {name}’s Computer": "Computer von {name} wird aktualisiert",
"Updating…": "Wird aktualisiert…",
Usage: "Nutzung",
"Use a custom server": "Eigenen Server verwenden",
"Use a found model": "Gefundenes Modell verwenden",
"Use an http or https URL": "Verwende eine http- oder https-URL",
"Use default server": "Standardserver verwenden",
"Use this model": "Dieses Modell verwenden",
"Verify and add": "Prüfen und hinzufügen",
Violet: "Violett",
Voice: "Stimme",
"Waiting for sign-in…": "Warten auf Anmeldung…",
"Waiting for this bot’s response.": "Warten auf die Antwort dieses Bots.",
"Waiting…": "Warten…",
Webhook: "Webhook",
"Weekdays, {time}": "Werktags, {time}",
"What this bot is for": "Wofür dieser Bot gedacht ist",
"When needed": "Bei Bedarf",
"While agents are working": "Während Agents arbeiten",
Winged: "Geschwungen",
"Work stopped, but the thread could not refresh: {detail}":
"Arbeit gestoppt, aber die Unterhaltung konnte nicht aktualisiert werden: {detail}",
Working: "Arbeitet",
"Working…": "Wird verarbeitet…",
"You have control": "Du hast die Kontrolle",
"Your account": "Dein Konto",
"allowed: {allowed}": "erlaubt: {allowed}",
archived: "archiviert",
bot: "Bot",
cancelled: "abgebrochen",
deleted: "gelöscht",
"exact-model-id": "exakte-modell-id",
failed: "fehlgeschlagen",
finished: "abgeschlossen",
"http://127.0.0.1:8000/v1": "http://127.0.0.1:8000/v1",
"http://localhost:53692/callback?code=…": "http://localhost:53692/callback?code=…",
"https://example.com/graphql": "https://example.com/graphql",
"https://example.com/mcp": "https://example.com/mcp",
"https://example.com/openapi.json": "https://example.com/openapi.json",
"https://executor.example/mcp": "https://executor.example/mcp",
"just now": "gerade eben",
"last: {latest}": "zuletzt: {latest}",
"notifications silenced": "Benachrichtigungen stummgeschaltet",
running: "läuft",
"sk-…": "sk-…",
subagent: "Subagent",
"this model": "dieses Modell",
unread: "ungelesen",
"until {until}": "bis {until}",
"{channel} · {id}": "{channel} · {id}",
"{channel} · {id} · {date}": "{channel} · {id} · {date}",
"{count} agents working": "{count} Agents arbeiten",
"{count} model": "{count} Modell",
"{count} models": "{count} Modelle",
"{count} steps": "{count} Schritte",
"{count} tools": "{count} Tools",
"{count}d ago": "vor {count} T.",
"{count}h ago": "vor {count} Std.",
"{count}m ago": "vor {count} Min.",
"{from} messaged {to}": "{from} hat {to} geschrieben",
"{name} (max {count} attachments)": "{name} (maximal {count} Anhänge)",
"{name} (over 10 MiB)": "{name} (über 10 MiB)",
"{name} (permission denied)": "{name} (Zugriff verweigert)",
"{name} (unsupported type)": "{name} (nicht unterstützter Typ)",
"{name} connection": "{name}-Verbindung",
"{name} is using it": "{name} verwendet ihn",
"{name} is working": "{name} arbeitet",
"{name}’s computer": "Computer von {name}",
"{runs} runs · {tokens} tokens": "{runs} Läufe · {tokens} Tokens",
};

View File

@ -1,4 +1,8 @@
export const RU_MESSAGES: Record<string, string> = {
"Connecting…": "Подключение…",
"GitHub token": "Токен GitHub",
"Create token on GitHub": "Создать токен на GitHub",
"Could not connect GitHub": "Не удалось подключить GitHub",
"Update your server to use AI data sharing in this mobile version.":
"Обновите сервер, чтобы использовать обмен данными с ИИ в этой версии мобильного приложения.",
"Ask the server owner to configure this provider.":
@ -270,6 +274,9 @@ export const RU_MESSAGES: Record<string, string> = {
"Executor token": "Токен Executor",
Failed: "Ошибка",
"Failed to refresh": "Не удалось обновить",
Resume: "Продолжить",
Stopped: "Остановлено",
"Failed to remove message": "Не удалось удалить сообщение",
"Failed to send message": "Не удалось отправить сообщение",
"Failed to stop work": "Не удалось остановить работу",
File: "Файл",
@ -319,7 +326,7 @@ export const RU_MESSAGES: Record<string, string> = {
"Нажмите и удерживайте, чтобы закрепить или переместить в раздел",
"Long press to pin, move, or silence notifications":
"Длительное нажатие, чтобы закрепить, переместить или отключить уведомления",
"Enter your Rakazo server address.": "Введите адрес вашего сервера Rakazo.",
"Enter your evOo server address.": "Введите адрес вашего сервера evOo.",
"Show less": "Свернуть",
Members: "Участники",
"Members ({min}–{max})": "Участники ({min}–{max})",
@ -375,8 +382,8 @@ export const RU_MESSAGES: Record<string, string> = {
"Photo library": "Библиотека фотографий",
Pin: "Закрепить",
"Please try again.": "Повторите попытку.",
"Point this app at your self-hosted Rakazo origin, the same HTTPS URL you open in a browser.":
"Направьте это приложение на свой собственный источник Rakazo, тот же URL-адрес HTTPS, который вы открываете в браузере.",
"Point this app at your self-hosted evOo origin, the same HTTPS URL you open in a browser.":
"Направьте это приложение на свой собственный источник evOo, тот же URL-адрес HTTPS, который вы открываете в браузере.",
Private: "Личный",
Providers: "Провайдеры",
"Public servers need https://. HTTP only works on your local network.":
@ -425,6 +432,7 @@ export const RU_MESSAGES: Record<string, string> = {
Send: "Отправить",
"Send reset link": "Отправить ссылку для сброса",
"Send answer": "Отправить ответ",
"Send now": "Отправить сейчас",
"Sending…": "Отправка…",
Server: "Сервер",
"Server URL": "URL-адрес сервера",
@ -435,10 +443,10 @@ export const RU_MESSAGES: Record<string, string> = {
"Show more": "Показать ещё",
"Show {label}": "Показать {label}",
"Sign in": "Войти",
"Sign in to Rakazo": "Войти в Rakazo",
"Sign in to evOo": "Войти в evOo",
"Sign out": "Выйти",
"Sign up": "Зарегистрироваться",
"Sign up for Rakazo": "Зарегистрироваться в Rakazo",
"Sign up for evOo": "Зарегистрироваться в evOo",
"Sign-in": "Вход",
"Sign-up": "Регистрация",
"Sign-in did not return a session": "Вход не вернул сессию",
@ -462,7 +470,7 @@ export const RU_MESSAGES: Record<string, string> = {
Team: "Команда",
"Team Computer": "Командный компьютер",
"That doesn’t look like a URL": "Это не похоже на URL",
"That URL did not look like a Rakazo server": "Этот адрес не похож на сервер Rakazo",
"That URL did not look like an evOo server": "Этот адрес не похож на сервер evOo",
"That URL is missing a host": "Для этого URL-адреса отсутствует хост",
"The server changed while starting the request": "Сервер изменился при запуске запроса",
"This permanently deletes your account, bots, conversations, memories, files, and saved connections. This cannot be undone.":
@ -555,7 +563,7 @@ export const RU_MESSAGES: Record<string, string> = {
"OpenAPI JSON": "OpenAPI JSON",
"Opened its thread.": "Диалог открыт.",
"Paste the OpenAI-compatible address from your server. Rakazo adds /v1 if needed.":
"Вставьте OpenAI-совместимый адрес со своего сервера. При необходимости Rakazo добавляет /v1.",
"Вставьте OpenAI-совместимый адрес со своего сервера. При необходимости evOo добавляет /v1.",
Paused: "Приостановлено",
Prompt: "Промпт",
"Recording a live demonstration needs desktop or web with the full computer view. You can still ask this bot to run saved skills from chat.":
@ -570,8 +578,8 @@ export const RU_MESSAGES: Record<string, string> = {
"This removes the empty space for everyone.": "Это удалит пустое пространство для всех.",
"This removes every message and stops current work. The bot, computer, memory, and routines are kept.":
"Это удалит все сообщения и остановит текущую работу. Бот, компьютер, память и задачи сохранятся.",
"This subscription sign-in is not available in Rakazo yet. Use a deployment credential or choose another provider.":
"Вход по этой подписке пока недоступен в Rakazo. Используйте учётные данные развёртывания или выберите другого провайдера.",
"This subscription sign-in is not available in evOo yet. Use a deployment credential or choose another provider.":
"Вход по этой подписке пока недоступен в evOo. Используйте учётные данные развёртывания или выберите другого провайдера.",
"Tool sources": "Источники инструментов",
Tools: "Инструменты",
Uninstall: "Удалить",
@ -667,6 +675,7 @@ export const RU_MESSAGES: Record<string, string> = {
Peach: "Персик",
Penguin: "Пингвин",
Pink: "Розовый",
"Play {name}": "Воспроизвести {name}",
Playful: "Игривость",
"Reading glasses": "Очки для чтения",
Robot: "Робот",

View File

@ -1,4 +1,8 @@
export const ZH_MESSAGES: Record<string, string> = {
"Connecting…": "正在连接…",
"GitHub token": "GitHub 令牌",
"Create token on GitHub": "在 GitHub 上创建令牌",
"Could not connect GitHub": "无法连接 GitHub",
"Update your server to use AI data sharing in this mobile version.":
"请更新服务器,以便在此移动版本中使用 AI 数据共享功能。",
"Release computer": "释放电脑",
@ -255,6 +259,9 @@ export const ZH_MESSAGES: Record<string, string> = {
"Executor token": "Executor 令牌",
Failed: "失败",
"Failed to refresh": "刷新失败",
Resume: "继续",
Stopped: "已停止",
"Failed to remove message": "删除消息失败",
"Failed to send message": "发送消息失败",
"Failed to stop work": "停止工作失败",
File: "文件",
@ -300,7 +307,7 @@ export const ZH_MESSAGES: Record<string, string> = {
"Loading…": "正在加载…",
"Long press to pin or move to a section": "长按可置顶或移动到分组",
"Long press to pin, move, or silence notifications": "长按可置顶、移动或静音通知",
"Enter your Rakazo server address.": "输入你的 Rakazo 服务器地址。",
"Enter your evOo server address.": "输入你的 evOo 服务器地址。",
"Show less": "收起",
Members: "成员",
Max: "最高",
@ -358,8 +365,8 @@ export const ZH_MESSAGES: Record<string, string> = {
"Photo library": "相册",
Pin: "置顶",
"Please try again.": "请重试。",
"Point this app at your self-hosted Rakazo origin, the same HTTPS URL you open in a browser.":
"将此应用指向你自托管的 Rakazo 源站,地址与你在浏览器中打开的 HTTPS URL 相同。",
"Point this app at your self-hosted evOo origin, the same HTTPS URL you open in a browser.":
"将此应用指向你自托管的 evOo 源站,地址与你在浏览器中打开的 HTTPS URL 相同。",
Private: "私有",
Providers: "提供商",
"Public servers need https://. HTTP only works on your local network.":
@ -406,6 +413,7 @@ export const ZH_MESSAGES: Record<string, string> = {
Send: "发送",
"Send reset link": "发送重置链接",
"Send answer": "发送回答",
"Send now": "立即发送",
"Sending…": "正在发送…",
Server: "服务器",
"Server URL": "服务器地址",
@ -416,10 +424,10 @@ export const ZH_MESSAGES: Record<string, string> = {
"Show more": "显示更多",
"Show {label}": "显示 {label}",
"Sign in": "登录",
"Sign in to Rakazo": "登录 Rakazo",
"Sign in to evOo": "登录 evOo",
"Sign out": "退出登录",
"Sign up": "注册",
"Sign up for Rakazo": "注册 Rakazo",
"Sign up for evOo": "注册 evOo",
"Sign-in": "登录",
"Sign-up": "注册",
"Sign-in did not return a session": "登录未返回会话",
@ -445,7 +453,7 @@ export const ZH_MESSAGES: Record<string, string> = {
Team: "团队",
"Team Computer": "团队电脑",
"That doesn’t look like a URL": "这看起来不像 URL",
"That URL did not look like a Rakazo server": "该地址看起来不是 Rakazo 服务器",
"That URL did not look like an evOo server": "该地址看起来不是 evOo 服务器",
"That URL is missing a host": "该地址缺少主机名",
"The server changed while starting the request": "请求开始时服务器已更改",
"This permanently deletes your account, bots, conversations, memories, files, and saved connections. This cannot be undone.":
@ -536,7 +544,7 @@ export const ZH_MESSAGES: Record<string, string> = {
"OpenAPI JSON": "OpenAPI JSON",
"Opened its thread.": "已打开其对话。",
"Paste the OpenAI-compatible address from your server. Rakazo adds /v1 if needed.":
"粘贴你服务器的 OpenAI 兼容地址。如有需要,Rakazo 会自动补上 /v1。",
"粘贴你服务器的 OpenAI 兼容地址。如有需要,evOo 会自动补上 /v1。",
Paused: "已暂停",
Prompt: "提示词",
"Recording a live demonstration needs desktop or web with the full computer view. You can still ask this bot to run saved skills from chat.":
@ -550,8 +558,8 @@ export const ZH_MESSAGES: Record<string, string> = {
"this model": "此模型",
"This removes every message and stops current work. The bot, computer, memory, and routines are kept.":
"这将删除所有消息并停止当前工作。Bot、电脑、记忆和例行任务会保留。",
"This subscription sign-in is not available in Rakazo yet. Use a deployment credential or choose another provider.":
"Rakazo 暂不支持此订阅登录。请使用部署凭据或选择其他提供商。",
"This subscription sign-in is not available in evOo yet. Use a deployment credential or choose another provider.":
"evOo 暂不支持此订阅登录。请使用部署凭据或选择其他提供商。",
"Tool sources": "工具源",
Tools: "工具",
Uninstall: "卸载",
@ -651,6 +659,7 @@ export const ZH_MESSAGES: Record<string, string> = {
Peach: "桃色",
Penguin: "企鹅",
Pink: "粉色",
"Play {name}": "播放 {name}",
Playful: "俏皮",
"Reading glasses": "老花镜",
Robot: "机器人",

69
apps/mobile/lib/sound.ts Normal file
View File

@ -0,0 +1,69 @@
import type { OoSoundCue, SoundDriver } from "@rakazo/core";
import { CUE_RECIPES, PcmWavSynthesizer } from "@rakazo/core";
import { File, Paths } from "expo-file-system";
export class ExpoMobileSoundDriver implements SoundDriver {
private cacheDir = `${Paths.cache}/earcons`;
private cachedUris = new Map<OoSoundCue, string>();
isSupported(): boolean {
return true;
}
async play(cue: OoSoundCue): Promise<void> {
if (!CUE_RECIPES[cue]) return;
// Check if HTML5 audio is present (mobile web fallback / test environment)
const AudioCtor = (globalThis as { Audio?: typeof Audio }).Audio;
if (typeof AudioCtor === "function" && typeof window !== "undefined") {
const wav = PcmWavSynthesizer.renderCueWav(cue);
const blob = new Blob([wav as Uint8Array<ArrayBuffer>], { type: "audio/wav" });
const url = URL.createObjectURL(blob);
try {
const audio = new AudioCtor(url);
await audio.play().catch(() => undefined);
} finally {
setTimeout(() => URL.revokeObjectURL(url), 2000);
}
return;
}
// Native Expo Audio playback
try {
const { createAudioPlayer, setAudioModeAsync } = await import("expo-audio");
await setAudioModeAsync({
playsInSilentMode: true,
interruptionMode: "mixWithOthers",
shouldPlayInBackground: false,
});
let uri = this.cachedUris.get(cue);
if (!uri) {
const wav = PcmWavSynthesizer.renderCueWav(cue);
const file = new File(this.cacheDir, `earcon-${cue}.wav`);
file.create({ overwrite: true });
file.write(this.bytesToBase64(wav), { encoding: "base64" });
uri = file.uri;
this.cachedUris.set(cue, uri);
}
const player = createAudioPlayer({ uri });
player.play();
setTimeout(() => player.release(), 2000);
} catch {
// Best-effort degradation
}
}
private bytesToBase64(bytes: Uint8Array): string {
if (typeof Buffer !== "undefined") return Buffer.from(bytes).toString("base64");
let binary = "";
const chunk = 0x8000;
for (let i = 0; i < bytes.length; i += chunk) {
binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
}
return btoa(binary);
}
}
export const mobileSound = new ExpoMobileSoundDriver();

View File

@ -11,9 +11,10 @@ import {
describe("UI_LOCALES", () => {
it("offers every locale with a mobile catalog", () => {
expect([...UI_LOCALES]).toEqual(["en", "zh-CN", "ru"]);
expect(Object.keys(UI_LOCALE_LABELS).sort()).toEqual(["en", "ru", "zh-CN"]);
expect(isUiLocale("de")).toBe(false);
expect([...UI_LOCALES]).toEqual(["en", "de", "zh-CN", "ru"]);
expect(Object.keys(UI_LOCALE_LABELS).sort()).toEqual(["de", "en", "ru", "zh-CN"]);
expect(UI_LOCALE_LABELS.de).toBe("Deutsch");
expect(isUiLocale("de")).toBe(true);
expect(isUiLocale("ko")).toBe(false);
expect(isUiLocale("tr")).toBe(false);
expect(isUiLocale("hi")).toBe(false);
@ -23,8 +24,8 @@ describe("UI_LOCALES", () => {
});
describe("ACCOUNT_UI_LOCALES", () => {
it("limits the Account picker to English and Simplified Chinese", () => {
expect([...ACCOUNT_UI_LOCALES]).toEqual(["en", "zh-CN"]);
it("limits the Account picker to English, German, and Simplified Chinese", () => {
expect([...ACCOUNT_UI_LOCALES]).toEqual(["en", "de", "zh-CN"]);
expect(ACCOUNT_UI_LOCALES).not.toContain("ru");
});
});
@ -38,6 +39,10 @@ describe("normalizeUiLocale", () => {
expect(normalizeUiLocale("zh-SG")).toBe("zh-CN");
expect(normalizeUiLocale("ru-RU")).toBe("ru");
expect(normalizeUiLocale("ru_KZ")).toBe("ru");
expect(normalizeUiLocale("de")).toBe("de");
expect(normalizeUiLocale("de-DE")).toBe("de");
expect(normalizeUiLocale("de-AT")).toBe("de");
expect(normalizeUiLocale("de_CH")).toBe("de");
});
it("does not fold Traditional Chinese into Simplified", () => {
@ -48,8 +53,6 @@ describe("normalizeUiLocale", () => {
it("falls back to English for unknown locales and web-only languages", () => {
expect(normalizeUiLocale("fr-FR")).toBe("en");
expect(normalizeUiLocale("de")).toBe("en");
expect(normalizeUiLocale("de-DE")).toBe("en");
expect(normalizeUiLocale("ko")).toBe("en");
expect(normalizeUiLocale("ko-KR")).toBe("en");
expect(normalizeUiLocale("tr")).toBe("en");
@ -65,6 +68,7 @@ describe("htmlLangForLocale", () => {
it("keeps Simplified Chinese on zh-CN", () => {
expect(htmlLangForLocale("zh-CN")).toBe("zh-CN");
expect(htmlLangForLocale("en")).toBe("en");
expect(htmlLangForLocale("de")).toBe("de");
});
});

View File

@ -1,9 +1,9 @@
export const UI_LOCALES = ["en", "zh-CN", "ru"] as const;
export const UI_LOCALES = ["en", "de", "zh-CN", "ru"] as const;
export type UiLocale = (typeof UI_LOCALES)[number];
/** Locales offered on the mobile Account language picker. */
export const ACCOUNT_UI_LOCALES = ["en", "zh-CN"] as const satisfies readonly UiLocale[];
export const ACCOUNT_UI_LOCALES = ["en", "de", "zh-CN"] as const satisfies readonly UiLocale[];
export type AccountUiLocale = (typeof ACCOUNT_UI_LOCALES)[number];
@ -11,6 +11,7 @@ export const UI_LOCALE_STORAGE_KEY = "rakazo.uiLocale";
export const UI_LOCALE_LABELS: Record<UiLocale, string> = {
en: "English",
de: "Deutsch",
"zh-CN": "简体中文",
ru: "Русский",
};
@ -22,7 +23,7 @@ export function htmlLangForLocale(locale: string): string {
/** Return whether a value is one of the supported mobile UI locales. */
export function isUiLocale(value: string | null | undefined): value is UiLocale {
return value === "en" || value === "zh-CN" || value === "ru";
return value === "en" || value === "de" || value === "zh-CN" || value === "ru";
}
/** Normalize BCP-47 tags to a mobile UI locale, else `en`. */
@ -40,6 +41,7 @@ export function normalizeUiLocale(raw: string | null | undefined): UiLocale {
) {
return "zh-CN";
}
if (normalized === "de" || normalized.startsWith("de-")) return "de";
if (normalized === "ru" || normalized.startsWith("ru-")) return "ru";
const primary = normalized.split("-")[0] ?? "";
return isUiLocale(primary) ? primary : "en";

View File

@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { captureApiRequestContext, currentApiBase, rpc } from "./api";
import {
MAX_VOICE_AUDIO_BYTES,
playMpeg,
playSpeech,
speakText,
speakUtterance,
VOICE_RESPONSE_TIMEOUT_MS,
@ -92,7 +92,7 @@ describe("mobile speech", () => {
}
vi.stubGlobal("Audio", ImmediateAudio);
await expect(playMpeg(new Uint8Array([1, 2, 3]))).resolves.toBeUndefined();
await expect(playSpeech(new Uint8Array([1, 2, 3]))).resolves.toBeUndefined();
});
it("rejects oversized audio without waiting for response cancellation", async () => {

View File

@ -1,4 +1,5 @@
import { ensureAiDataConsent, readBoundedResponseBytes } from "@rakazo/core";
import type { VoiceInfo } from "@rakazo/contracts";
import { ensureAiDataConsent, readBoundedResponseBytes, voiceIdForLocale } from "@rakazo/core";
import { File, Paths } from "expo-file-system";
import { promptAiConsent } from "./ai-consent";
import type { ApiRequestContext } from "./api";
@ -10,6 +11,21 @@ export const VOICE_RESPONSE_TIMEOUT_MS = 70_000;
export const MAX_VOICE_AUDIO_BYTES = 16 * 1024 * 1024;
const MAX_VOICE_ERROR_BYTES = 64 * 1024;
/** Must match packages/adapters KokoroVoiceProvider placeholder. */
export const KOKORO_LOCAL_TOKEN = "local-kokoro";
export const LOCAL_VOICE_PROVIDER = "kokoro";
/** Keep the saved voice in the UI language, so German speaks German and English speaks English. */
export async function alignVoiceWithLocale(locale: string): Promise<void> {
const status = await rpc<{ provider: string | null; voiceId: string }>("voice/status");
if (status.provider !== LOCAL_VOICE_PROVIDER || !status.voiceId) return;
const voices = await rpc<VoiceInfo[]>("voice/voices", { provider: LOCAL_VOICE_PROVIDER });
const next = voiceIdForLocale(voices, locale, status.voiceId);
if (next && next !== status.voiceId) {
await rpc("voice/setVoice", { voiceId: next, provider: LOCAL_VOICE_PROVIDER });
}
}
export async function speakText(text: string, opts: SpeechOptions = {}): Promise<boolean> {
const requestContext = await captureApiRequestContext();
const prepared = await rpc<{ ready: boolean; utterances: string[] }>(
@ -19,7 +35,7 @@ export async function speakText(text: string, opts: SpeechOptions = {}): Promise
);
if (!prepared.ready) return false;
for (const utterance of prepared.utterances) {
await playMpeg(await renderUtterance(utterance, opts, requestContext));
await playSpeech(await renderUtterance(utterance, opts, requestContext));
}
return true;
}
@ -68,7 +84,13 @@ async function renderUtterance(
}
}
export async function playMpeg(bytes: Uint8Array): Promise<void> {
/** Kokoro answers with MP3, Piper (German voices) with WAV. */
function clipFormat(bytes: Uint8Array): { type: string; extension: string } {
const riff = bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46;
return riff ? { type: "audio/wav", extension: "wav" } : { type: "audio/mpeg", extension: "mp3" };
}
export async function playSpeech(bytes: Uint8Array): Promise<void> {
const AudioCtor = (globalThis as { Audio?: typeof Audio }).Audio;
if (typeof AudioCtor === "function") {
await playWithHtmlAudio(AudioCtor, bytes);
@ -78,7 +100,7 @@ export async function playMpeg(bytes: Uint8Array): Promise<void> {
}
async function playWithHtmlAudio(AudioCtor: typeof Audio, bytes: Uint8Array): Promise<void> {
const blob = new Blob([new Uint8Array(bytes)], { type: "audio/mpeg" });
const blob = new Blob([new Uint8Array(bytes)], { type: clipFormat(bytes).type });
const url = URL.createObjectURL(blob);
try {
const audio = new AudioCtor(url);
@ -116,7 +138,7 @@ async function playWithNativeAudio(bytes: Uint8Array): Promise<void> {
interruptionMode: "mixWithOthers",
shouldPlayInBackground: false,
});
const file = new File(Paths.cache, `rakazo-voice-${Date.now()}.mp3`);
const file = new File(Paths.cache, `rakazo-voice-${Date.now()}.${clipFormat(bytes).extension}`);
file.create({ overwrite: true });
file.write(bytesToBase64(bytes), { encoding: "base64" });
const player = createAudioPlayer({ uri: file.uri });

View File

@ -17,35 +17,35 @@
"test:e2e": "maestro test .maestro/smoke.yaml"
},
"dependencies": {
"@expo/metro-runtime": "57.0.16",
"@expo/ui": "57.0.21",
"@expo/metro-runtime": "57.0.17",
"@expo/ui": "57.0.26",
"@rakazo/chat-ui": "workspace:*",
"@rakazo/contracts": "workspace:*",
"@rakazo/core": "workspace:*",
"@rakazo/ui-tokens": "workspace:*",
"@react-native-vector-icons/ionicons": "13.1.4",
"@react-native-vector-icons/material-design-icons": "13.1.4",
"expo": "57.0.26",
"expo": "57.0.31",
"expo-application": "57.0.3",
"expo-audio": "57.0.5",
"expo-clipboard": "57.0.2",
"expo-constants": "57.0.20",
"expo-constants": "57.0.22",
"expo-document-picker": "57.0.3",
"expo-file-system": "57.0.7",
"expo-font": "57.0.4",
"expo-image-picker": "57.0.20",
"expo-keep-awake": "57.0.2",
"expo-linking": "57.0.11",
"expo-linking": "57.0.13",
"expo-localization": "57.0.2",
"expo-modules-core": "57.0.20",
"expo-notifications": "57.0.21",
"expo-router": "57.0.24",
"expo-modules-core": "57.0.24",
"expo-notifications": "57.0.24",
"expo-router": "57.0.29",
"expo-secure-store": "57.0.4",
"expo-sharing": "57.0.22",
"expo-splash-screen": "57.0.9",
"expo-sharing": "57.0.24",
"expo-splash-screen": "57.0.10",
"expo-status-bar": "57.0.1",
"expo-symbols": "57.0.3",
"expo-updates": "57.0.24",
"expo-updates": "57.0.26",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-native": "0.86.3",

View File

@ -78,7 +78,7 @@ test("sidebar Now and Recent surface active and terminal runs", async ({ page },
await captureActivitySidebar(page, testInfo, "58-activity-now");
await page.getByRole("button", { name: "Stop", exact: true }).click();
await expect(page.getByRole("button", { name: "Send" })).toBeVisible({ timeout: 30_000 });
await expect(page.getByRole("combobox", { name: "Message" })).toBeVisible({ timeout: 30_000 });
await page.reload();
await expect(activityToggle).toHaveAttribute("aria-pressed", "true");

View File

@ -31,12 +31,13 @@ test("the bot's screen opens beside the chat while it works and steps back after
await expect(sidePanel).toHaveAttribute("data-panel", "computer");
await expect(sidePanel).toHaveAttribute("data-auto", "");
await expect(page.getByTestId("computer-preview").locator("iframe")).toHaveAttribute(
await expect(page.getByTestId("oo-computer-browser-viewport").locator("iframe")).toHaveAttribute(
"src",
SCREEN_URL,
);
// The live screen gets more room than a settings panel.
expect((await sidePanel.boundingBox())!.width).toBeGreaterThan(400);
// The live screen gets more room than a settings panel. The panel widens
// over a CSS transition, so measure once it has settled.
await expect.poll(async () => (await sidePanel.boundingBox())?.width ?? 0).toBeGreaterThan(400);
await captureScreenshot(page, testInfo, "workspace-screen-beside-chat");
// Once the work is done the conversation gets its full width back.

View File

@ -26,16 +26,28 @@ test("the bot's cursor glides to where it clicks on its live screen", async ({
);
await page.getByTitle("Agent computer").click();
const preview = page.getByTestId("computer-preview");
const preview = page.getByTestId("oo-computer-browser-viewport");
await expect(preview.locator("iframe")).toHaveAttribute("src", SCREEN_URL);
const cursor = preview.getByTestId("agent-cursor");
await expect(cursor).toHaveCSS("opacity", "0");
// The click and typing last about a second, so record every action rather than racing a poll.
await cursor.evaluate((node) => {
const seen: string[] = [];
(window as unknown as { cursorActions: string[] }).cursorActions = seen;
new MutationObserver(() => seen.push(node.getAttribute("data-action") ?? "")).observe(node, {
attributeFilter: ["data-action"],
});
});
// The scripted runtime clicks the screen center (640, 400) and types.
await rpc(page, "threads/send", { botId, text: "use your screen and type cursor-demo" });
await expect(cursor).toHaveCSS("opacity", "1");
await expect(cursor).toHaveAttribute("data-action", /click|type/);
await expect
.poll(() =>
page.evaluate(() => (window as unknown as { cursorActions: string[] }).cursorActions),
)
.toEqual(expect.arrayContaining(["click", "type"]));
const layer = (await preview.getByTestId("agent-cursor-layer").boundingBox())!;
await expect
.poll(async () => {

View File

@ -12,14 +12,12 @@ test("agent-attached files appear as downloadable cards", async ({ page }, testI
await composer.fill("write notes/result.txt and attach it to the thread");
await page.keyboard.press("Enter");
// Scope to ArtifactFileCard text (name + mime); sidebar bot status can also
// include the prompt path notes/result.txt once status syncs immediately.
const fileCard = page.getByRole("button", { name: /result\.txt text\/plain/ });
const fileCard = page.getByRole("button", { name: "Preview result.txt" });
await expect(fileCard).toBeVisible({ timeout: 30_000 });
await captureScreenshot(page, testInfo, "current-file-card");
const downloadPromise = page.waitForEvent("download");
await fileCard.click();
await page.getByRole("button", { name: "Download result.txt" }).click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe("result.txt");
});

View File

@ -24,7 +24,7 @@ test("restricted signup waits for mailbox verification", async ({ page }, testIn
await expect(page.getByRole("heading", { name: "Check your email" })).toBeVisible();
await captureScreenshot(page, testInfo, "signup-verification-required");
await page.getByRole("link", { name: "Back to sign in" }).click();
await expect(page.getByRole("heading", { name: "Sign in to EvOo" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Sign in to evOo" })).toBeVisible();
});
test("logout protects bot deep links and sign-in restores the session", async ({
@ -72,19 +72,19 @@ test("logout protects bot deep links and sign-in restores the session", async ({
await captureScreenshot(page, testInfo, "36-account-menu");
await page.getByRole("button", { name: "Log out" }).click();
await expect(page.getByRole("heading", { name: "Sign in to EvOo" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Sign in to evOo" })).toBeVisible();
await page.goto("/");
await expect(page.locator('[data-rakazo-surface="welcome"]')).toBeVisible();
await expect(page.getByText("Your specialists. One calm place to talk.")).toBeVisible();
await page.getByRole("button", { name: /Sign up/ }).click();
await expect(page).toHaveURL(/\/sign-up$/);
await expect(page.getByRole("heading", { name: "Create your EvOo" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Create your evOo" })).toBeVisible();
await page.goto("/");
await captureScreenshot(page, testInfo, "37-logged-out-welcome");
await page.goto(protectedBotPath);
await page.waitForURL((url) => url.pathname === "/sign-in");
await expect(page.getByRole("heading", { name: "Sign in to EvOo" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Sign in to evOo" })).toBeVisible();
await expect(page.getByText("Chief", { exact: true })).toHaveCount(0);
await expect(page.getByText(userName, { exact: true })).toHaveCount(0);
await expect(page.getByLabel("Email")).toHaveAttribute("autocomplete", "username");

View File

@ -114,12 +114,12 @@ test("bot creation, editing, and deletion persist", async ({ page }, testInfo) =
await captureScreenshot(page, testInfo, "27a-bot-settings-model");
await page.getByRole("button", { name: "Show computer" }).click();
const sidePanel = page.getByTestId("side-panel");
await expect(sidePanel).toHaveAttribute("data-panel", "computer");
await expect(sidePanel).toHaveAttribute("data-panel", /^(computer|browser)$/);
await expect(page.getByRole("button", { name: "Show settings" })).toBeVisible();
// Overlay may flash during boot or never appear (already ready/asleep/stopped). Assert panel
// chrome, then wait until any overlay has cleared — avoid Locator.or() strict-mode multi-hits.
const bootOverlay = page.getByText(/Booting up .* computer/);
await expect(sidePanel.getByTestId("computer-preview")).toBeVisible();
await expect(sidePanel.getByTestId("oo-computer-browser-viewport")).toBeVisible();
await expect(bootOverlay).toBeHidden();
await expect(sidePanel.getByText("Teach a task")).toHaveCount(0);
await expect(sidePanel.getByTestId("teach-start-button")).toHaveCount(0);

View File

@ -195,15 +195,24 @@ test("chat composer controls are vertically centered", async ({ page }) => {
await signup(page, `composer-layout-${stamp}@rakazo.test`, "password12", "Composer Layout");
await completeOnboarding(page);
const centers = await page.getByTestId("composer-bar").evaluate((composer) =>
["Add", "Message", "Voice", "Send"].map((label) => {
const bar = page.getByTestId("composer-bar");
const centersOf = (labels: string[]) =>
bar.evaluate(
(composer, controls) =>
controls.map((label) => {
const element = composer.querySelector<HTMLElement>(`[aria-label="${label}"]`);
if (!element) throw new Error(`Missing composer control: ${label}`);
const box = element.getBoundingClientRect();
return box.top + box.height / 2;
}),
labels,
);
const idle = await centersOf(["Add", "Message", "Voice"]);
await page.getByRole("combobox", { name: "Message" }).fill("hello");
const typing = await centersOf(["Add", "Message", "Send"]);
const centers = [...idle, ...typing];
expect(Math.max(...centers) - Math.min(...centers)).toBeLessThanOrEqual(1);
});
@ -298,6 +307,10 @@ test("group chats share every context-menu action", async ({ page }, testInfo) =
await group.click({ button: "right" });
await captureScreenshot(page, testInfo, "group-context-menu-mobile");
await page.keyboard.press("Escape");
if ((await page.getByRole("menu").count()) > 0) {
await page.keyboard.press("Escape");
}
await expect(page.getByRole("menu")).toHaveCount(0);
await page.setViewportSize({ width: 1280, height: 800 });
await group.click({ button: "right" });

View File

@ -1,13 +1,51 @@
import { expect, test } from "@playwright/test";
import { expect, type Locator, type Page, test } from "@playwright/test";
import { captureScreenshot } from "./helpers";
const fixture = "/e2e/fixtures/chart-links.html";
test("chart links cannot execute script when clicked or expanded", async ({ page }, testInfo) => {
/** Browser errors per page, so a chart that never renders fails with its cause. */
const browserErrors = new WeakMap<Page, string[]>();
test.beforeEach(({ page }) => {
const errors: string[] = [];
browserErrors.set(page, errors);
page.on("pageerror", (error) => errors.push(error.message));
page.on("console", (message) => {
if (message.type() === "error") errors.push(message.text());
});
page.on("response", (response) => {
if (response.status() >= 400) errors.push(`${response.status()} ${response.url()}`);
});
});
/** Waits for the plotted dot, failing fast with the chart error and browser errors instead. */
async function expectChart(page: Page, scope: Locator | Page = page, reloadAttempt = 0) {
const circle = scope.locator("svg circle").first();
const failure = scope.getByText("Chart failed to render", { exact: false }).first();
try {
// Every navigation renders Plot again, which takes seconds on a loaded CI runner.
await expect(circle.or(failure).first()).toBeVisible({ timeout: 90_000 });
if (await failure.isVisible()) throw new Error(await failure.innerText());
} catch (error) {
if (reloadAttempt === 0 && scope === page) {
await page.reload();
return expectChart(page, scope, 1);
}
const errors = browserErrors.get(page) ?? [];
throw new Error(
`${error instanceof Error ? error.message : String(error)}\nBrowser errors:\n${errors.join("\n") || "none"}`,
);
}
return circle;
}
test.describe
.serial("chart links", () => {
test("cannot execute script when clicked or expanded", async ({ page }, testInfo) => {
test.setTimeout(120_000);
// ChartCanvas lazy-loads Observable Plot; pay the import cost once before the matrix below.
await page.goto(`${fixture}?${new URLSearchParams({ href: "#details", inspect: "0" })}`);
await expect(page.locator("svg circle").first()).toBeVisible({ timeout: 90_000 });
await expectChart(page);
for (const inspect of ["0", "1"]) {
for (const href of [
'javascript:void(document.body.dataset.plotExecuted="yes")',
@ -17,8 +55,7 @@ test("chart links cannot execute script when clicked or expanded", async ({ page
"file:///example.txt",
]) {
await page.goto(`${fixture}?${new URLSearchParams({ href, inspect })}`);
const circle = page.locator("svg circle").first();
await expect(circle).toBeVisible();
const circle = await expectChart(page);
const assertInert = async () => {
const links = page.locator("svg a");
expect(await links.count()).toBeGreaterThan(0);
@ -40,7 +77,7 @@ test("chart links cannot execute script when clicked or expanded", async ({ page
await assertInert();
await page.getByRole("button", { name: "Expand", exact: true }).click();
const dialog = page.getByRole("dialog");
await expect(dialog.locator("svg circle")).toBeVisible();
await expectChart(page, dialog);
await dialog.locator("svg circle").click();
await assertInert();
await expect(page).toHaveURL(`${fixture}?${new URLSearchParams({ href, inspect })}`);
@ -50,7 +87,11 @@ test("chart links cannot execute script when clicked or expanded", async ({ page
await captureScreenshot(page, testInfo, "chart-with-inert-link");
});
test("chart links retain supported URLs and browser encoding semantics", async ({ page }) => {
test("retain supported URLs and browser encoding semantics", async ({ page }) => {
test.setTimeout(120_000);
// Warm Plot before request interception so lazy import and fixture modules are not racing routes.
await page.goto(`${fixture}?${new URLSearchParams({ href: "#details" })}`);
await expectChart(page);
await page.route("https://example.test/**", (route) =>
route.fulfill({ body: "Chart destination" }),
);
@ -67,8 +108,7 @@ test("chart links retain supported URLs and browser encoding semantics", async (
"java%73cript:void(0)",
]) {
await page.goto(`${fixture}?${new URLSearchParams({ href })}`);
// ChartCanvas lazy-loads Plot; mirror the inert-link test and wait for marks first.
await expect(page.locator("svg circle").first()).toBeVisible();
await expectChart(page);
const link = page.locator("svg a").first();
await expect(link).toBeAttached();
expect(
@ -85,3 +125,4 @@ test("chart links retain supported URLs and browser encoding semantics", async (
}
}
});
});

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