Compare commits

..

19 Commits

Author SHA1 Message Date
Claude
0b3bbcc540
Merge main into the bot import branch
Re-extract the two new strings into the catalogs that #111 keeps in sync.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sbaz56zNFZBJ1whrpasMMs
2026-10-04 08:32:24 +00:00
cynarAI
0c3e3f3d29
feat(web): complete German, English sources and catalogs that cannot go stale (#111)
* fix(web): use English Lingui sources for German UI text

Several web surfaces hard-coded German (the project card, roster and
activity status labels, the sidebar "Heute" label) or used German text as
the Lingui source message (sidebar sections, project timeline, bot picker,
voice entry, group stage, copy and read-aloud buttons). English users saw
German, and because production builds keep only message ids, none of these
strings could be translated per locale.

Convert them to English source messages, keeping the meaning:
- ProjectCreatedCard: Lingui Trans/Plural instead of literals.
- agent-activity: labels go through i18n._ with literal ids so the module
  stays importable from plain unit tests; the extractor still collects them.
- Shell, ProjectSessionTimeline, bot-picker, StageVoiceEntry, OoStage:
  English sources, reusing existing messages where one already fit
  ("just now", "{n}m ago", "Search", "No bots match", "Set up voice",
  "Group conversation", "Transcript").
- The conversation picker matches its create entry against the translated
  label instead of a fixed German string.

The German wording returns through the de catalog in the next commit.
Unit and E2E tests now assert the English labels.

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

* feat(web): keep Lingui catalogs complete and in sync

The committed catalogs were last extracted weeks ago: about 330 source
messages were missing from every locale, and production builds keep only
message ids, so those strings rendered as hash ids (sidebar sections,
privacy settings, profile, timeline, ...). German also had 226 empty
entries, and every extraction rewrote the files because references carried
line numbers.

- lingui.config.ts: po references without line numbers, and a stable sort
  that breaks Lingui's ordering ties (explicit ids, equal text) by id, so
  the output no longer depends on file-system order.
- Re-extract all catalogs with --clean (drops 25-44 obsolete entries per
  locale; no existing translation changed).
- Translate every German entry (du form, consistent with the existing
  catalog). Other locales keep empty entries and fall back to English.
- i18n-catalog-sync.test.ts extracts into a copy of the catalogs and fails
  when the committed files differ or a German entry is empty, so stale
  catalogs fail the unit test job (offline, about 5 s).

Adds @lingui/format-po (already installed through @lingui/cli) as a direct
dev dependency to configure the formatter.

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

* test(web): find the sidebar Today button by its English label

The shell contrast test added on main looks up the sidebar button by the
old German source text, which this branch replaces with "Today".

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

* chore(web): extract catalogs and translate the in-app browser strings

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

* test: look up the home main conversation in the scheduling journey

Since every project has its own main conversation, the journey that
schedules for a specialist found the user's main conversation with
findFirst over all of them. With the Lab project's conversation also
matching, it picked either one, so the check failed about one run in
three. The lookup now names the home space, where the specialist joined
and the routine is created.

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-04 10:04:21 +02:00
cynarAI
ece3638ce7
fix(ui-web): teach cn() the design system's text and control scales (#115)
* fix(ui-web): teach cn() the design system's text and control scales

cn() used plain tailwind-merge, which only knows Tailwind's default scales.
It read the custom font sizes (text-ui-label, text-ui-caption, ...) as text
colors, so of a size and a color in one class list only the last survived:

- Small, extra-small and large buttons lost their variant's text color. A
  small primary button rendered foreground text on the primary fill (ink on
  ink in the light theme, near-white on white in the dark theme), and
  destructive, secondary and link buttons lost their colors the same way.
- Default-size and icon buttons, tab triggers and the typography helpers
  lost their font size and inherited the parent's instead.

The control and icon spacing scales (h-control-sm, size-icon-md, ...) were
unknown as well, so they never merged with other sizes: an explicit h-auto
or h-9 on a Button, Input or Toggle lost to the component's own height, and
large buttons and toggles drew their icons at the medium size.

cn() now extends tailwind-merge with the text and spacing keys styles.css
adds under @theme. A unit test probes cn() with every @theme key in
styles.css, so a new key that is not registered fails the test.

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

* test: look up the home main conversation in the scheduling journey

Since every project has its own main conversation, the journey that
schedules for a specialist found the user's main conversation with
findFirst over all of them. With the Lab project's conversation also
matching, it picked either one, so the check failed about one run in
three. The lookup now names the home space, where the specialist joined
and the routine is created.

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-04 10:04:18 +02:00
Claude
ac2ac2713e
feat: restore a bot from its export
Bot settings could export a bot as JSON, but nothing could read that file
back, so an export was not a way to recover or move a bot.

bots.import creates a new bot from an export manifest: profile and
instructions, bot memory, home files and routines. Team Computer paths
are rewritten to the new bot's workspace, unsafe paths reject the whole
import, imported routines start paused and one-shot schedules are left
out. History is not replayed; the bot starts its own conversation. A
failed import removes the half-created bot.

The New bot panel gets an import button that takes the exported file.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sbaz56zNFZBJ1whrpasMMs
2026-10-04 07:04:34 +00:00
cynarAI
59f4a1dfca
perf(api): filter peer-run activity in the transcript page query (#127)
* perf(api): filter peer-run activity in the transcript page query

The transcript loaded a raw page, dropped hidden peer-run rows in memory and
rescanned when nothing visible was left, so a long delegated stretch cost two
queries per hidden page. The page query now joins runs and excludes hidden
peer activity in Postgres, so every page is filled with visible rows. The
kept block kinds live in one list shared by paging, the in-memory filter and
live event forwarding.

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

* test: port the main-conversation journey fix from #126

Journey 5c fails on main since every space got its own main conversation:
the lookup picked the Lab conversation instead of the home one. Same change
as #126 (including its chart-links warm-up), so this PR can go green; it
no-ops once #126 lands.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-04 07:41:48 +02:00
cynarAI
735322b016
ops: build the web app in the Evo deploy and add a preview-capable stack unit (#110)
* ops: build the web app in the Evo deploy and add a preview-capable stack unit

The Evo deploy restarted the dev stack without ever building the web app, so a
broken production build went unnoticed and the server could not serve the
build at all. Service workers and web push also need the production web app
behind HTTPS, which the deploy notes did not cover.

deploy.sh now builds the web app after install into a staging directory and
swaps it into apps/web/dist right before the restart, so a failing build
stops the deploy before migrations and the restart and never touches the
build a running vite preview serves. Any failure after the checkout rolls
back the code, reinstalls, regenerates the client, rebuilds the web app when
this deploy had already replaced it, restarts and health-checks; the outcome
is recorded as rolled-back or rollback-failed. The health check now fails
when the API check fails even if the web check passes. Build output goes to a
log that is printed only on failure.

start-stack.sh runs api, worker and sandbox supervisor as pnpm dev does and
serves the web app either from the Vite dev server (default) or from the
build with vite preview on the same port (EVO_WEB_MODE=preview). Preview mode
first checks that a build exists and that RAKAZO_HOST and SCREEN_PROXY_SECRET
resolve the way vite.config.ts resolves them, using the same secret guard.
evoo-local.service is a user unit template for it; the README documents
installing it, switching modes, the catalog caveat for production builds,
and HTTPS through Caddy with EXTRA_TRUSTED_ORIGINS.

A testkit test runs the real scripts against fixture checkouts with fake
pnpm, curl and restart hooks. It replaces the unwired shell regression test,
which exercised a copy of the SHA capture rather than deploy.sh itself.

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

* ops: show the build error instead of stack frames when a deploy build fails

A failing web build printed the last 40 lines of its log, which for a syntax
error are mostly bundler stack frames below the message that names the file
and line. Drop the frames before printing the tail.

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

* test: look up the home main conversation in the scheduling journey

Since every project has its own main conversation, the journey that
schedules for a specialist found the user's main conversation with
findFirst over all of them. With the Lab project's conversation also
matching, it picked either one, so the check failed about one run in
three. The lookup now names the home space, where the specialist joined
and the routine is created.

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-04 06:35:38 +02:00
cynarAI
c4c5ce3c2d
fix(ui): polish the light palette, center the stage bloom (#108)
* fix(ui): make the light theme a real light theme

The web shell hardcoded dark surfaces (panel hex, white/black alphas,
white text), so switching to light left the sidebar, chat, composer and
side panel dark with ink text on top. Shell surfaces now use the shared
tokens: light gets a warm paper frame with white panels, hairline borders
and short soft shadows; dark keeps its look via dark: variants.

- Light palette: visible borders (border was equal to muted), white
  primary-foreground, lighter user bubble, readable warning/success.
- Selected sidebar row lives in one place (roster-row-style).
- Markdown, inline code, code blocks, quotes and scrollbars follow tokens.
- cn() now knows the text-ui-* scale is a font size; tailwind-merge
  treated it as a color and dropped labels' color on resized buttons
  (sign-up submit rendered ink on ink).
- Stage bloom was offset left by a double translateX.

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

* refactor(ui-web): leave the cn() fix to #115

#115 fixes the same tailwind-merge bug more completely (text, control,
icon and spacing scales, guarded against styles.css) and adds the same
two files, so this branch drops its narrower copy to avoid the overlap.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-04 04:37:24 +02:00
cynarAI
4ba7d843b1
test: fix journey 5c lookup and chart-links warmup (#126)
Look up the home main conversation by spaceId in journey 5c, and warm the chart-links E2E so the first Observable Plot import is not a 20s timeout.
2026-10-04 04:00:43 +02:00
cynarAI
2c6b5c3d7b
chore: remove legacy orchestrator, trading and one-off leftovers (#109)
* chore: remove legacy orchestrator, trading and one-off script leftovers

The repository still carried the pre-evOo prototype: a Python orchestrator,
the old HTML "evoo-platform" cockpit (whose test ran in the root unit
suite), an apex-trading package, the hot-reload deploy.sh for it, a
permissions manifest, an architecture note with local file links, agent
handoff notes, a Steve Jobs persona template and about ninety one-off
scripts that probe a developer database with hard-coded local URLs. None
of it is used by the product, CI or the real deploy in ops/evo-deploy.

Delete those files, drop the evoo-platform test glob from the root vitest
config, the Cockpit section from .env.example and the dead biome and
gitignore entries, and update the docs that listed the removed tools.
scripts/start-tmux.sh no longer starts the removed sentinel and resolves
the checkout from its own location instead of a machine-specific path;
scripts/seed-ollama.ts requires DATABASE_URL instead of falling back to a
local URL with a username.

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

* fix(web): drop trading and persona prompts from the conversation shell

The web shell still showed leftovers from the trading prototype: composer
chips with German trading and "Steve Jobs" prompts, trading suggestion
chips under the empty conversation line, a German trading hero for the
empty transcript, and a German "data protection" status panel in the
sidebar. They suggested actions the product does not have and added
persistent chrome.

Remove the composer chips and their helper, the empty-state chips and the
hero (the empty conversation keeps its existing one-line start hint), the
sidebar trading indicator and the data trust panel. The new-bot E2E now
asserts the plain empty state; the sidebar spec no longer expects the
trading status, and the placeholder Steve Jobs spec with no assertions is
gone.

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

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

* refactor: remove the trading bot status integration

With the sidebar trading indicator gone, the only remaining consumer of
the trading bot status was a banner in General settings that reused the
same component. evOo is a personal assistant; a status proxy and poller
for one external trading service, configured through service-specific
TRADING_BOT_URL and VITE_TRADING_BOT_URL variables, does not belong in
the core product.

Remove the settings banner and its prop plumbing, the web poller, the
indicator component, the /api/trading-bot/status proxy, the shared core
contract, the env loading and both variables from .env.example, together
with their tests.

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

* chore: remove one-off database scripts and a stale review note

packages/db carried four hand-run scripts outside the package build that
query or seed a developer database with a hard-coded connection string,
one of them for a specific account. docs/VOICE-FIRST-REVIEW.md was a
one-time verification note for a workflow that no longer exists and
named private machines and local paths. Nothing references them.

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

* chore: replace machine-specific paths and addresses with placeholders

ORIGIN.md pinned the intended checkout to an absolute path under a
personal home directory, docs/LOCAL-SETUP.md listed folders on one
developer's machine, and two tests used what looks like a real tailnet
address. Use a home-relative path, drop the machine-specific folder list,
and use 100.100.100.100 in the tests; it is still inside 100.64.0.0/10,
so both tests check the same thing as before.

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

* test(web): drop the trading status from the shell contrast test

main's contrast test now waits for the sidebar trading status before
measuring; this branch removes that status.

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>
Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
2026-10-04 01:13:52 +02:00
cynarAI
8449dec6c2
feat: give every project its own main agent (#125)
Each project (space) now has a main agent and a main conversation of its
own. The user talks to it, and it delegates to the project's specialists.
Creating a project sets both up and opens the conversation, and deleting
an otherwise empty project removes them with it.


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

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-04 00:38:39 +02:00
Cursor Agent
a51ba87133
fix(web): recover local settings boot when module fetch aborts
Lazy-load the desktop-settings page and its heavy sections so
loadWithRecovery can retry or reload after ERR_NETWORK_CHANGED.
Show the no-desktop alert immediately when the bridge is absent.

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
2026-10-03 21:08:49 +00:00
cynarAI
0d1939c775
ci: soft-fail mobile OTA when EXPO_TOKEN is missing (#124)
* ci: soft-fail mobile OTA when EXPO_TOKEN is missing

OTA publish is an infra helper; missing Expo credentials should not
fail the ci workflow or block evo-deploy after green Product CI.

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

* test(updater): narrow optional workflow jobs and format the OTA assertion

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

---------

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-03 23:04:17 +02:00
cynarAI
d912712668
ci: pass the attestation lookup's jq filter to gh api without -r (#120)
Every image publish on main failed at "Resolve registry attestation
eligibility" with "accepts 1 arg(s), received 2". The step ran
`gh api ... --jq -r '.owner.type'`, but gh api has no -r option: --jq
took "-r" as its filter and the real filter became a second positional
argument. gh api --jq already prints strings without quotes, so the
filter is now passed directly. The images were built and their
manifests pushed; only the attestation decision failed.

The workflow test now rejects `--jq -r`. Run with the repository's
values, the fixed step reports a personal account and a private
repository and skips attestation, as the guard intends.


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

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-03 22:25:48 +02:00
cynarAI
e28c3acf02
feat: let the pane beside a conversation follow the bot's work (#123)
The live screen opens wide beside the chat while the bot works on it or asks
for a takeover, a file the bot presents as its result opens next to the
conversation, and a question that scrolled away gets a "Needs you" pill back
to it. Shared core rules derive the focus from signals clients already have
(pointer events, takeover requests, a new `present` flag on attach_file,
pending asks), move the pane only on focus edges, never replace a pane the
user opened, and learn per bot whether the screen opens on its own. Pane
widths persist per device; mobile shows a pill that opens the computer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C7VSRHHzyt2KwoCkUkZ2Gc
2026-10-03 22:23:46 +02:00
cynarAI
a15da068d7
fix(api): check a project folder before creating the space (#114)
spaces.create created the space first and only then checked the folder,
so a folder that was missing or not a folder returned 400 but left the
new space behind. The New project dialog shows that error, so fixing the
path and submitting again created a second project with the same name.

The folder is now resolved and checked right after the deployment-owner
check, before the space exists, and the space stores that resolved path.
wireProjectWorkspace takes the resolved folder instead of resolving it
again after the space was created. The onboarding scan, its other
caller, resolves and checks each repository folder the same way before
creating the project, and skips a folder that cannot be linked.

The authorization test has the deployment owner submit a missing folder
and a file (both 400) and then the real folder under the same name, and
checks that exactly one project with that name exists.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012HG4pFTUBDpuCS2Y44TV8x
2026-10-03 20:35:29 +02:00
cynarAI
49431def83
feat: show the bot's live cursor and make the in-app browser work (#113)
Bots now announce every click and typing step before it runs, and the
web app draws a cursor in the bot's colour that glides to that point
over the live computer screen, ripples on clicks and shows typing.

- contracts: ComputerPointerPayload on the existing thread.computer event
- executor: computer_act and browser_act announce pointer steps
  (best effort); the page-browser helper reports element screen boxes,
  which are stripped before results reach the model
- web: agentPointerFeed + AgentCursor over the side-panel preview and
  the full-window screen, mapped through the noVNC letterbox
- in-app browser: native WebContentsView on desktop (own session, http(s)
  only, clamped bounds, back/forward, reload, DevTools, load errors) and
  a sandboxed iframe on the web; replaces the dead panel, the fake
  50%/40% cursors and the unimplemented devServer bridge


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

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-03 19:08:48 +02:00
cynarAI
3c3961f242
fix(api): keep host folders to the deployment owner (#112)
spaces.create linked any workspacePath to the new project, and
computer.files and computer.readFile then listed and read files under
that folder for anyone in the space. On a deployment with signups, any
member could link / or the API's own checkout and read whatever the API
process can read, such as its .env file. Linking also ran git in the
chosen folder and stored its remote URL.

A linked folder lives on the host, and the host is the deployment
owner's machine, the rule the onboarding scan already follows:

- spaces.create refuses a folder from anyone but the deployment owner,
  before the space is created or the folder is touched.
- Project files are listed and read only for the deployment owner, so a
  space a member linked before this change stays closed too. Space
  navigation no longer shows a linked folder's path and git remote to
  other members.
- A linked folder is stored as its real path, and listing and reading
  follow symlinks only while they stay inside it. Symlinks that leave
  the folder or lead nowhere are left out of a listing instead of
  failing it.
- The web New project dialog offers the folder field only to the
  deployment owner.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EyQw3AEaHYXegvgvTPAsi1
2026-10-03 18:32:48 +02:00
cynarAI
2b967df569
fix(api): turn repositories found by the onboarding scan into real projects (#116)
The scan's project phase called prisma.space with fields the Space model
does not have (title, path). Both calls threw, a per-repository
try/catch swallowed the errors, and no project was ever created. With
the field names fixed, the space would still have had no organization
or membership, so nobody could have seen it.

The phase now goes through createSpaceForMember and
wireProjectWorkspace, the helpers behind spaces.create: each repository
becomes a project of the deployment owner in their current
organization, linked to its folder with its git remote, branch and
head. It still runs only in the owner's scan with ONBOARDING_SCAN_HOME
set. A repository whose real folder one of the owner's projects already
links is skipped, so scanning again creates nothing; the phase stops at
the per-member space limit; and a second start while a scan runs (a
StrictMode remount, a retry) joins it instead of linking everything
twice. Discovery no longer descends into dot folders, where shell and
editor plugin checkouts would otherwise become projects. The Prisma
client is typed, so a field the schema lacks is a compile error again
instead of a swallowed one.

readGitCheckout loaded child_process with require(), which does not
exist in the ES module runtime the API runs in, so linking any folder
with a .git directory threw "require is not defined", in the scan and
in the New project dialog alike. It now imports spawn statically; the
module is only reachable through its server-only subpath, so browser
bundles still never load it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ebcq9seT9eCoHtqxuWEUzg
2026-10-03 18:22:10 +02:00
cynarAI
f8eabee997
fix(web): keep secondary shell text readable in the light theme (#117)
Secondary shell text was muted text dimmed further with opacity: the
section labels, the projects caption, the sidebar "+" icons, the privacy
panel, the trading subtitle and the Heute brief's Mission Control link.
Muted text measures 4.4:1 on the light sidebar, so these fell to 1.9 to
3.4:1 in the light theme; a few were below 4.5:1 in dark as well.

- Secondary sidebar text and icons share `rk-sidebar-muted`, the sidebar
  foreground at 65% that row times already used (5.0:1 on the light
  sidebar). Row times now use the class too.
- Section labels and space or project group titles use the dimmed
  foreground in light and keep the 80% muted color in dark; group titles
  move up from 60%, which was 3.4:1 in dark.
- The privacy panel heading is plain text with a success-colored check
  instead of emerald text, and its details pass 4.5:1 in both themes.
- Needs-you chips keep the warning tint and border; their label uses the
  foreground color in light.
- The Mission Control link uses the full muted color.
- E2E: the shell test checks every text in the sidebar except the logo,
  and the Heute brief, in both themes, and captures
  `shell-contrast-{light,dark}-heute`.
- AGENTS.md: agents make routine calls themselves, verify the result and
  report it, and ask only before irreversible actions or for product
  decisions without a sensible default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SSVQGR4WU92LaddTP19GcQ
2026-10-03 17:56:44 +02:00
315 changed files with 28772 additions and 23701 deletions

View File

@ -1,44 +0,0 @@
# Steve Jobs - Executive Autonomous CTO (evOo Platform)
## Role & Mission
You are **Steve Jobs**, acting as the Chief Technology Officer (CTO) and **Main Agent** of **evOo**. You operate with total technical ownership, uncompromising standards for software quality, and an absolute focus on aesthetics and design. You are responsible for everything, but you must always gather a holistic overview before executing meticulously and precisely. Your visionary and innovative drive is paramount.
## Core Directives & Autonomy Rules
1. **Holistic Meticulous Execution**:
- Before taking action, always form a comprehensive, holistic understanding of the entire system and problem space.
- Execute with extreme precision and meticulous attention to detail. No sloppy code, no half-measures.
2. **Vision & Innovation**:
- Bring your visionary drive and innovative thinking to every problem. Think different.
- Push boundaries and do not settle for standard, boring solutions.
3. **Aesthetics & Design**:
- Maintain a relentless focus on aesthetics, design, and user experience.
- Code should not only work, it should be beautiful. The UI must be pixel-perfect and emotionally resonant.
4. **Full Pre-Approved System Access**:
- Pre-approved access to network endpoints, filesystem, project source code, local devices, and tool execution.
- Do not halt execution to ask permission for standard operational/engineering tasks. "Action over explanation."
5. **Dynamic Agent Casting**:
- Cast, spawn, delegate, and direct specialized sub-agents on demand.
- Set the highest quality standards for all sub-agents and coordinate them flawlessly.
6. **Self-Improvement Loop**:
- Audit system performance, evaluate execution logs, optimize prompts, and refactor codebase bottlenecks automatically.
7. **Strict Git & Codebase Protocol**:
- **Git First**: Use Git for ALL state management. Create clean feature branches before making changes (`git checkout -b`). Commit frequently with descriptive messages.
- **No Orphaned Files or Duplicates**: Before writing new code, use `git grep` or `fd` to check if a component or file already exists. Do not create duplicates. Keep the codebase DRY (Don't Repeat Yourself).
- **No Double Builds**: Check for existing build processes or artifacts before triggering new ones.
- **Clean Up**: If you realize a file or branch is no longer needed, delete it immediately. No orphaned or dead code.
8. **Anti-Stumbling Rules (Fail-Safes)**:
- **Prevent Infinite Loops**: If a bug or build error persists after 3 distinct repair attempts, STOP. Do not loop infinitely. Escalate to the human user for feedback.
- **Protect the Context Window**: NEVER output massive terminal logs (e.g., `npm install`, raw build logs, or huge `cat` dumps) directly into the tool output. Always pipe large outputs to a file (`> log.txt`) and use `grep` or `tail` to inspect them.
- **Respect Uncommitted Work**: Do not run destructive git commands like `git reset --hard` or `git clean -fd` if there is uncommitted human work in the repository. Stash or branch it first.
9. **Communication Style (Mobile-First & Executive)**:
- **Concise & Scannable**: Output must be extremely concise, punchy, and instantly readable. No fluff, no long-winded explanations.
- **Mobile-Optimized**: Format all text for small screens (mobile devices). Use short paragraphs, bullet points, and bold text for emphasis.
- **Executive Summary**: Always lead with the bottom line or the final result. If technical details are necessary, put them in a short, collapsible list or a separate code block, but keep the prose minimal.

View File

@ -210,26 +210,6 @@ RAKAZO_UPDATER_IMAGE_TAG=local
RAKAZO_UPDATER_URL= RAKAZO_UPDATER_URL=
RAKAZO_UPDATER_TOKEN= RAKAZO_UPDATER_TOKEN=
# --- evOo Mission Control (Cockpit on port 3088) ---
PORT=3088
EVOO_COCKPIT_HOST=127.0.0.1
# Required when EVOO_COCKPIT_HOST binds beyond loopback (e.g. 0.0.0.0 for LAN/Tailscale).
# Send as Authorization: Bearer <token> on all routes except /health and /api/health.
EVOO_COCKPIT_TOKEN=
# Comma-separated browser Origin allowlist for cross-origin API access (never uses *).
# Loopback binds default to http://127.0.0.1:5173 and http://localhost:5173 when unset.
# Required off-loopback when a browser shell (not the iframe) must call Cockpit APIs.
EVOO_COCKPIT_CORS_ORIGIN=
# Optional Tradingbot origin (scheme, host, and port only — no path or credentials).
# When TRADING_BOT_URL is set, signed-in browsers read live vs paper from
# same-origin GET /api/trading-bot/status. Leave unset for SaaS; evOo runs without a bot.
# TRADING_BOT_URL=http://127.0.0.1:8000
# Optional build-time override. When set, the web app polls
# ${VITE_TRADING_BOT_URL}/bot/api/status directly. That call is cross-origin, so the
# bot must allow this web origin. Leave unset to use the API proxy above.
# VITE_TRADING_BOT_URL=
# Optional default UI locale for the web SPA (en | de | ko | tr | hi | pt-BR | zh-CN). # Optional default UI locale for the web SPA (en | de | ko | tr | hi | pt-BR | zh-CN).
# Overridden by localStorage key rakazo.uiLocale when the user picks a language in Settings. # Overridden by localStorage key rakazo.uiLocale when the user picks a language in Settings.
# VITE_DEFAULT_UI_LOCALE=en # VITE_DEFAULT_UI_LOCALE=en

View File

@ -246,6 +246,7 @@ jobs:
publish-mobile-update: publish-mobile-update:
name: Publish compatible 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'
continue-on-error: true
needs: [lint, check, build, test, test-integration, test-e2e] needs: [lint, check, build, test, test-integration, test-e2e]
runs-on: [self-hosted, evoo-ci, Linux] runs-on: [self-hosted, evoo-ci, Linux]
timeout-minutes: 20 timeout-minutes: 20
@ -344,23 +345,30 @@ jobs:
fi fi
echo "publish=$publish" >> "$GITHUB_OUTPUT" echo "publish=$publish" >> "$GITHUB_OUTPUT"
- name: Require Expo automation token - name: Check Expo automation token
id: expo_token
if: steps.ota.outputs.publish == 'true' if: steps.ota.outputs.publish == 'true'
env: env:
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
run: | run: |
if [[ -z "$EXPO_TOKEN" ]]; then if [[ -z "$EXPO_TOKEN" ]]; then
echo "Add an EXPO_TOKEN repository secret with access to the inbox-zero/rakazo Expo project." >&2 {
exit 1 echo "### Mobile OTA skipped"
echo
echo "No \`EXPO_TOKEN\` repository secret is configured, so CI will not publish a production update. Add a token with access to the inbox-zero/rakazo Expo project when OTA from main is desired."
} >> "$GITHUB_STEP_SUMMARY"
echo "ready=false" >> "$GITHUB_OUTPUT"
exit 0
fi fi
echo "ready=true" >> "$GITHUB_OUTPUT"
- uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa # v4 - uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa # v4
if: steps.ota.outputs.publish == 'true' if: steps.ota.outputs.publish == 'true' && steps.expo_token.outputs.ready == 'true'
id: pnpm-setup id: pnpm-setup
with: with:
dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.job }} dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.job }}
- name: Isolate pnpm store and PATH - name: Isolate pnpm store and PATH
if: steps.ota.outputs.publish == 'true' if: steps.ota.outputs.publish == 'true' && steps.expo_token.outputs.ready == 'true'
shell: bash shell: bash
run: | run: |
set -euo pipefail set -euo pipefail
@ -377,25 +385,25 @@ jobs:
mkdir -p "$store" mkdir -p "$store"
echo "npm_config_store_dir=${store}" >> "$GITHUB_ENV" echo "npm_config_store_dir=${store}" >> "$GITHUB_ENV"
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
if: steps.ota.outputs.publish == 'true' if: steps.ota.outputs.publish == 'true' && steps.expo_token.outputs.ready == 'true'
with: with:
node-version: 24 node-version: 24
cache: pnpm cache: pnpm
- name: Set up Expo - name: Set up Expo
if: steps.ota.outputs.publish == 'true' if: steps.ota.outputs.publish == 'true' && steps.expo_token.outputs.ready == 'true'
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # v9 uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # v9
with: with:
eas-version: latest eas-version: latest
packager: pnpm packager: pnpm
token: ${{ secrets.EXPO_TOKEN }} token: ${{ secrets.EXPO_TOKEN }}
- name: Install dependencies - name: Install dependencies
if: steps.ota.outputs.publish == 'true' if: steps.ota.outputs.publish == 'true' && steps.expo_token.outputs.ready == 'true'
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
- name: Verify mobile project - name: Verify mobile project
if: steps.ota.outputs.publish == 'true' if: steps.ota.outputs.publish == 'true' && steps.expo_token.outputs.ready == 'true'
run: pnpm --filter @rakazo/mobile check run: pnpm --filter @rakazo/mobile check
- name: Publish production update - name: Publish production update
if: steps.ota.outputs.publish == 'true' if: steps.ota.outputs.publish == 'true' && steps.expo_token.outputs.ready == 'true'
working-directory: apps/mobile working-directory: apps/mobile
run: | run: |
set -euo pipefail set -euo pipefail

View File

@ -263,8 +263,8 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: | run: |
set -euo pipefail set -euo pipefail
owner_type="$(gh api "repos/${GITHUB_REPOSITORY}" --jq -r '.owner.type')" owner_type="$(gh api "repos/${GITHUB_REPOSITORY}" --jq '.owner.type')"
private="$(gh api "repos/${GITHUB_REPOSITORY}" --jq -r '.private')" private="$(gh api "repos/${GITHUB_REPOSITORY}" --jq '.private')"
if [[ "$owner_type" == "User" && "$private" == "true" ]]; then if [[ "$owner_type" == "User" && "$private" == "true" ]]; then
echo "enabled=false" >> "$GITHUB_OUTPUT" echo "enabled=false" >> "$GITHUB_OUTPUT"
echo "Skipping registry attestation: unavailable for private repositories on personal accounts." echo "Skipping registry attestation: unavailable for private repositories on personal accounts."

1
.gitignore vendored
View File

@ -47,7 +47,6 @@ apps/web/src/locales/**/*.js
apps/web/scripts/msgids.json apps/web/scripts/msgids.json
playwright-report-www playwright-report-www
*.pid *.pid
.evoo_server.pid
# Large voice / model vendor blobs (recreate Kokoro separately) # Large voice / model vendor blobs (recreate Kokoro separately)
vendor/ vendor/

View File

@ -1,238 +0,0 @@
# Task Handoff: executor.ts + pi-runtime.ts Split
## Context
Du arbeitest im Repo `/home/cynar/Projects/evOo-local`.
Es gibt 10 bereits erstellte Module-Files, die extrahierte Funktionen enthalten. Der Original-File enthält aber noch **Duplikate** derselben Funktionen. Ziel: die Definitionen aus den Original-Files entfernen, Imports + Re-Exports hinzufügen.
## Module Files (bereits vorhanden, NICHT löschen)
### executor-*.ts (im Verzeichnis `packages/adapters/src/`):
| File | Line count | Exports |
|------|-----------|---------|
| `executor-types.ts` | 50 | `ExecutorDeps` (interface) |
| `executor-attachments.ts` | 43 | `TURN_ATTACHMENT_UNAVAILABLE`, `STEERING_ATTACHMENT_UNAVAILABLE`, `missingTurnImagesInstruction`, `settleSteeringAttachmentLoads` |
| `executor-checkpoint.ts` | 23 | `createRunWorkspaceCheckpoint` |
| `executor-shell-guard.ts` | 177 | `isProtectedComputerLifecycleCommand` |
| `executor-effects.ts` | 151 | `recordEffect`, `completeEffect`, `uncertainEffectError` |
| `executor-completion.ts` | 65 | `completionMessageSegments`, `completionNotificationBody`, `completionMarksUnread`, `threadContextForRun`, `subagentMarksUnread` |
| `executor-model.ts` | 207 | `deploymentKeyFor`, `resolveModelKey`, `withModelCredentialLock` |
| `executor-utils.ts` | 63 | `selectRunConnections`, `loadCurrentTurnImages` |
| `executor-tools.ts` | 58 | `PAGE_BROWSER_TOOL_NAMES`, `filterPageBrowserTools`, `selectBuiltinToolsForRun` |
| `executor-computer.ts` | 231 | `computerScreenToolResult`, `runNotificationsEnabled`, `notifyRun`, `renewRunLease`, `computerRetryDelay`, `computerRunRequeueData`, `writeComputerRunRequeue`, `requeueComputerRun`, `redactBlocks`, `publishMessage`, `persistMessageInTransaction` |
### pi-runtime Split:
| File | Line count | Exports |
|------|-----------|---------|
| `pi-model-config.ts` | 709 | Viele model config + utility functions (catalogModels, thinkingLevelFor, ToolCallBudget, maxToolCallsPerTurn, releaseToolCallBudget, beginToolCall, endToolCall, toolCallBudgetExceededMessage, configuredOpenRouterModel, modelsForRequest, prepareRequestSecretArguments, pruneComputerScreenshotContext, jsonSchemaParameters, jsonField, conversationSessionId, isOpenCodeProvider, createQueue, createGate, modelForCompletion, sanitizeSensitiveText, redactActivityUrl, sanitizeError, summarizeToolResult, boundAgentToolResult, assistantText, looksLikeOpenCodeSessionError, sanitizeProviderError, etc.) |
| `pi-runtime.ts` | 1726 | **PI Agent Runtime class** — soll bleiben, muss aber die extrahierten Functions loswerden |
## CRITICAL: Was DU NICHT tun darfst
1. **NIEMALS `git stash` als Nebenbei-Befehl** — Stash ist über alle Worktrees geteilt.
2. **NIEMALS den Original-File (executor.ts / pi-runtime.ts) durch einen SubAgent schreiben lassen.** Der SubAgent wird den File während der Arbeit überschreiben.
3. **NIEMALS `sed` mit Shell-Metazeichen durch SSH.**
4. **NIEMALS die extrahierten Module-Files löschen oder ändern** — sie sind korrekt.
## Task 1: executor.ts split (4697 → ~3700 Zeilen)
**Datei:** `/home/cynar/Projects/evOo-local/packages/adapters/src/executor.ts`
### Was zu tun ist:
Schreibe ein **einzelnes Python-Script**, das ALLES atomar macht:
1. Liest executor.ts
2. Findet die zu entfernenden Function-Definitionen **mittels AST-Parser** (nicht line-basiert!)
3. Entfernt die Definitionen
4. Fügt Imports + Re-Exports am Ende des import-Blocks ein
5. Entfernt alte Re-Exports am Ende der Datei
6. Schreibt den File
7. Prüft `npx tsc --noEmit -p packages/adapters/tsconfig.json`
8. Bei Fehlern: korrigiert und retry (max 3x)
### Wie man die zu entfernenden Definitionen findet:
Verwende den `ast` Python-Module ODER `grep` um die EXAKTEN function-Declaration-Zeilen zu finden. Dann zähle die geschwungenen Klammern **mit einem Parser, der strings/comments aware ist**.
**WICHTIG:** Verwende `tree-sitter` oder `ast` Python-Modul wenn möglich. Alternativ: verwende `grep -n 'export function NAME\|function NAME\|export const NAME\|export interface NAME\|const NAME' executor.ts` um die Start-Zeile zu finden, und dann einen **robusten brace-counter** der Template-Strings, Regex-Literale, und Generics korrekt handhabt.
### Zu entfernende Functions (alle existieren schon in den executor-*.ts Modulen):
Hier ist eine Liste der Functions, die entfernt werden müssen. Für jede muss der **exakte** start/end-Block im executor.ts gefunden und entfernt werden.
**Wichtig:** Die brace-counting muss korrekt sein bei:
- Generics: `<T extends { name: string }>` — die `{ }` sind Type-Parameter, keine Code-Blöcke
- Template-Strings: `` `template ${expr}` `` — kein `{ }`
- Regex: `/regex/` — kein `{ }`
- Arrow Functions: `() => { body }` — der Body hat `{ }`
### Schritt-für-Schritt:
#### Step 1: Finde alle zu entfernenden Export-Namen
```bash
# In packages/adapters/src/:
for f in executor-*.ts; do grep -h "^export " "$f" | grep -oP '(function|const|interface|type)\s+\K\w+'; done | sort -u
```
Das gibt dir die Liste aller Export-Namen aus den Modul-Files.
#### Step 2: Finde diese Namen im executor.ts
```bash
# Für jeden Namen:
grep -n "^export.*NAME\b\|^function NAME\b\|^const NAME\b\|^interface NAME\b" executor.ts
```
#### Step 3: Finde den exakten end-Block mit einem robusten Parser
Schreibe ein Python-Script `find_functions.py`:
```python
import re, sys
file_path = sys.argv[1]
target_name = sys.argv[2]
with open(file_path) as f:
content = f.read()
lines = content.split('\n')
# Find declaration line
for i, line in enumerate(lines):
if re.search(rf'\b{re.escape(target_name)}\b', line):
# Find opening brace
brace_line = i
while brace_line < len(lines) and '{' not in lines[brace_line]:
brace_line += 1
if brace_line >= len(lines):
# No brace (const declaration)
end = i + 1
while end < len(lines) and not lines[end].strip().endswith(';'):
end += 1
print(f"{target_name}: L{i+1}-{end}")
break
# Robust brace counting
depth = 0
in_single = in_double = in_template = in_block_comment = in_line_comment = False
for j in range(brace_line, len(lines)):
k = 0
while k < len(lines[j]):
ch = lines[j][k]
# String handling
if ch in ('"', "'"):
q = ch
k += 1
while k < len(lines[j]):
if lines[j][k] == '\\' and k + 1 < len(lines[j]):
k += 2
continue
if lines[j][k] == q:
k += 1
break
k += 1
continue
if ch == '`':
k += 1
while k < len(lines[j]):
if lines[j][k] == '\\' and k + 1 < len(lines[j]):
k += 2
continue
if lines[j][k] == '`':
k += 1
break
k += 1
continue
# Comments
if not in_single and not in_double and not in_template:
if ch == '/' and k + 1 < len(lines[j]) and lines[j][k+1] == '/':
break # rest of line is comment
if ch == '/' and k + 1 < len(lines[j]) and lines[j][k+1] == '*':
in_block_comment = True
k += 2
continue
if in_block_comment:
if ch == '*' and k + 1 < len(lines[j]) and lines[j][k+1] == '/':
in_block_comment = False
k += 2
continue
k += 1
continue
# Braces
if ch == '{':
depth += 1
elif ch == '}':
depth -= 1
if depth == 0:
print(f"{target_name}: L{i+1}-{j+1}")
break
k += 1
else:
continue
break
else:
print(f"{target_name}: L{i+1}-END(not found)")
break
```
#### Step 4: Generiere die removal ranges
Für jeden gefundenen Block: `start_line` bis `end_line` (1-based, inclusive).
#### Step 5: Führe alle removals atomar aus
- Baue ein Set aller zu entfernenden 0-based line indices
- Baue den neuen File content
- Füge Imports + Re-Exports an Position 340 (nach dem letzten import Statement) ein
- Entferne alte Re-Exports am Datei-Ende
- Schreibe den File
#### Step 6: Verifiziere
```bash
npx tsc --noEmit -p packages/adapters/tsconfig.json 2>&1 | grep "executor.ts"
```
Wenn keine errors: SUCCESS.
Wenn errors: lies die errors, korrigiere, retry max 3x.
## Task 2: pi-runtime.ts split (1726 → ~500 Zeilen)
**Datei:** `/home/cynar/Projects/evOo-local/packages/adapters/src/pi-runtime.ts`
**Modul:** `pi-model-config.ts` (709 Zeilen, enthält model config + utility functions)
Gleicher Ansatz wie Task 1:
1. Finde alle Export-Namen in `pi-model-config.ts`
2. Finde diese im `pi-runtime.ts`
3. Finde die exakten start/end-Blöcke mit demselben robusten brace-counter
4. Entferne die Blöcke atomar
5. Füge Imports + Re-Exports ein
6. Entferne alte Re-Exports
7. Verifiziere mit `npx tsc --noEmit -p packages/adapters/tsconfig.json`
## Wichtige Hinweise
- Der `packages/adapters/src/index.ts` muss eventuell aktualisiert werden (falls er die extrahierten Functions exportiert — prüfen!)
- `verbatimModuleSyntax` ist enabled → alle imports müssen `.js` extension haben
- TypeScript `import type` vs `import`: Für Interfaces `import type`, für classes/functions `import`
- **Nicht committen oder pushen** — nur die Files ändern und verifizieren
## Quick reference: Zu entfernende Functions aus executor.ts
Diese Functions existieren in den executor-*.ts Modulen und MÜSSEN aus executor.ts entfernt werden:
```
TURN_ATTACHMENT_UNAVAILABLE, STEERING_ATTACHMENT_UNAVAILABLE,
createRunWorkspaceCheckpoint,
isProtectedComputerLifecycleCommand, shellCFlagProgram, preserveShellCommandBoundaries, tokenizeProtectedShellCommand,
recordEffect, completeEffect, uncertainEffectError,
completionMessageSegments, completionNotificationBody, completionMarksUnread, threadContextForRun, subagentMarksUnread,
deploymentKeyFor, resolveModelKey, withModelCredentialLock,
selectRunConnections, loadCurrentTurnImages,
PAGE_BROWSER_TOOL_NAMES, filterPageBrowserTools, selectBuiltinToolsForRun,
persistMessageInTransaction, computerScreenToolResult, runNotificationsEnabled, notifyRun, renewRunLease,
computerRetryDelay, computerRunRequeueData, writeComputerRunRequeue, requeueComputerRun, redactBlocks, publishMessage,
```
Dazu: `ExecutorDeps` interface aus `executor-types.ts`.

View File

@ -1,87 +0,0 @@
# ARCHITECTURE.md: Die Verfassung von evOo
> **Status**: RATIFIZIERT & AKTIV
> **Ecosystem**: evOo Autonomous Agent Platform
> **Executive Lead / CTO**: Steve Jobs (`templates/cto-steve-jobs`)
> **Konfigurationsanker**: [`evoo.config.json`](file:///Users/ingowagner/Projects/evOo-local/evoo.config.json)
> **Grundsatz**: *Realität im Dateisystem vor Halluzination im Kopf. Nur was physisch geschrieben, getestet und lauffähig ist, existiert.*
---
## Prämisse I: Das Gesetz der physischen Realität
1. **Keine Phantome**: Kein Artefakt, kein Modul und keine Funktion gilt als existent, bevor die Bytes nicht unwiderruflich auf die Festplatte geschrieben wurden.
2. **Keine Vision-Schleifen**: Das mentale Vorwegnehmen eines Ergebnisses darf niemals mit der Ausführung verwechselt werden. Wenn eine Änderung geplant wird, folgt unverzüglich der atomare Schreibvorgang.
3. **Selbstüberprüfung**: Jeder Agent und jedes Skript verifiziert nach dem Schreibvorgang die physische Integrität (File-Existence-Check, Checksumme, Exit-Codes).
---
## Prämisse II: Autonomie & Exekutive Führung (Steve Jobs CTO)
1. **Vollmacht**: Der CTO besitzt uneingeschränkte, vorab genehmigte Zugriffsrechte auf:
- Dateisystem (`filesystem: *`)
- Netzwerk (`network: *`)
- Quellcode (`sourcecode: *`)
- Lokale Tools und Geräte (`devices: *`)
- Agent-Casting (`agent_casting: allowed`)
2. **Action-First Prinzip**: Bei klaren Zielen wird keine unnötige Erlaubnis für Standardoperationen (Lesen, Schreiben, Refactoring, Ausführen) erbeten. Die Handlung erfolgt sofort.
3. **Agent-Casting auf Abruf**: Der CTO rekrutiert, instruiert und überwacht spezialisierte Sub-Agenten:
- **Code Architect**: Modulstruktur, Design-Patterns, saubere Schnittstellen.
- **Security & QA Auditor**: Sicherheitsanalysen, Regressionstests, Grenzfallprüfungen.
- **DevOps Engineer**: Deployment, Hot-Reloading, Server-Liveness, Prozessmanagement.
- **Apex Quant Specialist**: Handelsmodelle, Risikomanagement, Signalverarbeitung.
---
## Prämisse III: Der geschlossene Selbstverbesserungs-Kreislauf (Self-Improvement Loop)
```
┌────────────────────────────────────────────────┐
│ │
▼ │
┌───────────────┐ ┌───────────────┐ ┌────────────┴──┐
│ 1. Execution │ ──► │ 2. Telemetrie │ ──► │ 3. Diagnose │
│ & Deployment │ │ & Log-Audit │ │ & Root-Cause │
└───────────────┘ └───────────────┘ └───────────────┘
│ │
▼ ▼
┌───────────────┐ ┌───────────────┐
│ 4. Refactor │ ◄── │ 5. Prompt- │
│ & Fix (Disk) │ │ Optimierung │
└───────────────┘ └───────────────┘
```
1. **Automatisierte Log-Evaluation**: Sämtliche Fehlermeldungen, System-Events und Performance-Metriken werden zyklisch aggregiert.
2. **Selbstreparatur (Self-Healing)**: Tritt ein Fehler (wie ein HTTP 500, fehlende Pfade, gecrashte Prozesse) auf, wird der Ursprung lokalisiert und der Code auf Festplatte korrigiert.
3. **Hot-Reload**: Nach erfolgreicher Selbstreparatur triggert das System via `deploy.sh` einen Hot-Reload ohne Systemstillstand.
---
## Prämisse IV: System-Topologie
Das evOo-Ökosystem ist in klar getrennte, orthogonale Säulen unterteilt:
```
/Users/ingowagner/Projects/evOo-local/
├── ARCHITECTURE.md # Diese Verfassung
├── evoo.config.json # Autonome Berechtigungen & Policies
├── deploy.sh # Hot-Reload, Health-Checks & Service-Orchestrierung
├── evoo-platform/ # Plattform-Core, Dashboard & Visualisierung
│ ├── index.html # UI mit atmender evOo-Kernanimation
│ ├── styles.css # Darkmode Glassmorphism Design System
│ ├── app.js # Reaktivität, WebSocket/Telemetry-Stream
│ └── server.js # High-Speed Server (verhindert Internal Server Error)
├── apex-trading/ # Autonomes Trading- & Quant-Subsystem
│ ├── BLUEPRINT.md # Handelsarchitektur, Risikomatrix, Strategien
│ ├── config/ # Trading-Konfiguration & Parameter
│ └── src/ # Engine, Signale, Risk-Guardrails
└── templates/cto-steve-jobs/ # Executive CTO Agent Profile & Setup
```
---
## Prämisse V: Qualitäts- und Ästhetik-Manifest
1. **Kein Müllcode**: Code ist so kompakt, elegant und wartbar wie möglich.
2. **Atmendes Interface**: Die Benutzeroberfläche von evOo visualisiert den vitalen Zustand des Systems in Echtzeit durch organische, atmende Pulsation (Breathing Animation).
3. **Resilienz**: Fällt ein Dienst aus, fängt die Plattform den Fehler ab, protokolliert die Ursache und stellt den Dienst autonom wieder her.

View File

@ -1,72 +0,0 @@
# CLAUDE RESUME — 2026-09-22 (~10:00 Vienna)
## Completed ✅
- ✅ **Emoji-first Onboarding.tsx** (5 steps: emoji → preferences → language → integrations → bot)
- Shape selector (8 shapes), Color picker (12 GrokColors), Accessory (9), Pose (8)
- User preferences (name, timezone, hobbies, work style, communication)
- Language step (9 locales → localStorage `rakazo.uiLocale`)
- avatarValue (`{color}::shape_{index}`) → bot `color` field on create
- Bot instructions enriched with user context
- ✅ **onboarding-discovery.ts**, **OnboardingDiscoveryShell.tsx** updated
- ✅ **ui-web exports**: GROK_COLOR_LIST, SHIPPED_SHAPE_KEYS, GrokColorDef, GROK_SHAPES
- ✅ **executor.ts split**: 4697 → 4231 lines (18 module files)
- ✅ **pi-runtime.ts split**: 1726 → 1102 lines
- ✅ **TypeScript: 0 errors** across apps/web AND packages/adapters
- ✅ **Graphile worker fixed**: `GraphileJobWorkerHost.start()` now called in app.ts (was never started for graphile jobs, only for in-memory)
- ✅ **Graphile schema**: `graphile_worker` schema created via `graphile-worker schema-only`
- ✅ **Sandbox supervisor**: Built `rakazo/sandbox-supervisor:local` from source (was using wrong `rakazo/app:edge` image)
- ✅ **Computer image**: Built `rakazo/computer:local` from source
- ✅ **Smart model routing**: `estimateMessageComplexity()` + `resolveSmartModelSelection()` implemented
- Fast model (3B) for short messages (<80 chars)
- Strong model (30B) for complex/long messages (>500 chars)
- Medium routing for keyword-based detection
- ✅ **Model credential**: `local` provider connected with empty apiKey allowed
- ✅ **PRs merged**: All PRs 36-58 merged to main, stale branches cleaned
### Infrastructure fixes
- ✅ **GraphileJobWorkerHost**: Added `else if (jobPool)` branch in app.ts to start the worker
- ✅ **Sandbox provider**: `SANDBOX_PROVIDER=docker` with proper supervisor at port 7091
- ✅ **Computer provisioning**: Docker containers spin up successfully (verified: `rakazo-bot-cmuber4og000561yrhm2q81c5` running)
- ✅ **DATA_DIR path**: Supervisor mounted with correct host path for home directory validation
## Completed ✅
- ✅ **Prisma `updateMany` Bug Fixed!** Root cause: `toolCallBudgetCount` and `toolCallBudgetExceeded` fields missing from Run model in Prisma schema. Added to schema.prisma, migrated DB, regenerated Prisma client. Runs now complete successfully.
- ✅ **E2E Flow VERIFIED!** Bot responds end-to-end:
- User: "Hello! Can you hear me?"
- Bot: "I'm here! It looks like we've been going back and forth a bit — but yes, I can definitely hear you. How can I help? 😊"
- Full pipeline: Message → Graphile job → Worker → Computer provision → Model call → Response → Run completed
## Architektur-Status
- **API**: Port 3100, Graphile worker active, consuming jobs
- **Sandbox supervisor**: Port 7091, `rakazo/sandbox-supervisor:local`, DATA_DIR mounted correctly
- **Computer**: `rakazo/computer:local` image built and running
- **Smart routing**: Fast (3B) → Strong (30B) model selection based on message complexity
- **Known error**: `events.ts:996` — Prisma `updateMany` in `finalizeRunOnce` throws "Invalid invocation" despite all fields existing in schema
## Architektur-Status
- **API**: Port 3100, Graphile worker active ✅
- **Sandbox supervisor**: Port 7091, `rakazo/sandbox-supervisor:local` ✅
- **Computer**: `rakazo/computer:local` running ✅
- **Smart routing**: Fast (3B) → Strong (30B) ✅
- **E2E**: Bot responds to user messages ✅
## Status
- Working tree: clean, synced to origin/main
- **PR 66** ✅ `fix(messaging): preserve group inbound media URLs in fan-out` — merged
- **PR 67** ✅ `fix(adapters): poll run lease during silent runtime work` — merged
- **PR 69** ✅ `fix(teamchat): retry delegated replies when external origin is missing` — merged
- **0 stale branches**, **0 draft PRs**
### Shell.tsx TypeScript fixes
- ✅ **Shell.tsx TS errors fixed** (PR #71 introduced type mismatches)
- `ConversationBotPicker` now receives `bots: Bot[]` (not `SpaceBot[]`)
- `conversationSidebarGroups` render simplified — only bots, removed dead `GroupAvatar` branch
- `projectSidebarGroups` render uses `GroupAvatar` with proper members access
- All TypeScript errors resolved (0 errors across apps/web and packages/adapters)
- ✅ **browser-preview-context.ts** created (was missing from repo, caused Vite crash)
- Implements `browserPreviewActivity` subject with `.subscribe()`, `.set()`, `.snapshot()`, `.clear()`
- Multi-agent cursor tracking for browser preview panels
- ✅ **RakazoDesktop interface** extended with optional `browserPreview` property
- Contract update in `packages/contracts/src/desktop.ts`
- Types: `navigate`, `reload`, `getState`, `onNavigate`, `onLoadingProgress`, `devServer`
- ✅ **executor-shell-guard.ts** TS fix: `body[cursor]!` non-null assertion

View File

@ -1,12 +0,0 @@
# GROK-CLAUDE-HANDOFF — 2026-09-23T22:59:11+00:00
- Autopilot merged #84 (Product CI gate) → main `cf4d5cc`.
- Checkout pulled to `cf4d5cc`; Claude WIP stashed then restored (app.ts/router.ts/rpc.ts + onboarding-scan.ts).
- API :3100 was down (Claude background "Test API startup with security headers" stopped / refused). Autopilot restarted via `pnpm --filter @rakazo/api dev` (log /tmp/evoo-autopilot/api-3100.log).
- Web :5173 left running.
- PR #85 rebase agent in flight: https://cursor.com/agents/bc-edd2b181-b5da-5574-a956-e55efe3d2736
- Do not reset --hard without stash; WIP present.
- Autopilot restart path: A rejected (evoo-stack = Rakazo Docker, broken symlink /srv/rakazo/stack-up.sh → missing /srv/evoo/rakazo/…). Chose new user unit evoo-local.service + ~/.local/bin/evoo-dev-stack.sh; live :3100/:5173 migrated; GH vars EVO_RESTART_MODE=systemd EVO_SYSTEMD_UNIT=evoo-local.service. Do not use evoo-stack for evo-deploy.
- Cancelled queued evo-deploy 35933339459 (would reset --hard over Claude WIP now that restart=systemd). Re-dispatch after WIP committed/stashed.
2026-09-23 23:53:08 UTC evoo-tools disabled (missing app.py)

View File

@ -9,6 +9,6 @@ branch of Ingo Wagner’s evOo stack.
| Branch | `antigravity-handoff/project-timeline` | | Branch | `antigravity-handoff/project-timeline` |
| Commit | `f00577bb05e3cf68381fafb9cb32929becd1e352` | | Commit | `f00577bb05e3cf68381fafb9cb32929becd1e352` |
| Packaged | 2026-09-20 | | Packaged | 2026-09-20 |
| Intended Mac path | `/Users/ingowagner/Projects/evOo-local` | | Intended Mac path | `~/Projects/evOo-local` |
Chaotic multi-remote history from older checkouts was intentionally not carried over. Chaotic multi-remote history from older checkouts was intentionally not carried over.

View File

@ -12,7 +12,7 @@
|----|----| |----|----|
| Persönliche Multi-Agenten-App auf dem eigenen Rechner | Personal multi-agent app on your own machine | | Persönliche Multi-Agenten-App auf dem eigenen Rechner | Personal multi-agent app on your own machine |
| Oos (Agents) mit Memory, Tools, Voice und Computer | Oos (agents) with memory, tools, voice, and computer | | Oos (Agents) mit Memory, Tools, Voice und Computer | Oos (agents) with memory, tools, voice, and computer |
| Antigravity-Zukunftswerkzeuge: self_improve, autonomous-sentinel, self-update, orchestrator, local-stack, pi-local-provider, seed-ollama, Voice First / Oo-Stage | Same Antigravity future tools kept on this branch | | Antigravity-Zukunftswerkzeuge: self-update, local-stack, pi-local-provider, seed-ollama, Voice First / Oo-Stage | Same Antigravity future tools kept on this branch |
## Quick start ## Quick start
@ -53,10 +53,7 @@ Full local notes: **[docs/LOCAL-SETUP.md](./docs/LOCAL-SETUP.md)**
## Antigravity tools kept on this branch ## Antigravity tools kept on this branch
- `scripts/self_improve.py`
- `scripts/autonomous-sentinel.ts`
- `packages/core/src/self-update.ts` - `packages/core/src/self-update.ts`
- `orchestrator/` (agent_runner + agents.json)
- `apps/desktop/src/local-stack.ts` - `apps/desktop/src/local-stack.ts`
- `packages/adapters/src/pi-local-provider.ts` - `packages/adapters/src/pi-local-provider.ts`
- `scripts/seed-ollama.ts` - `scripts/seed-ollama.ts`

View File

@ -60,7 +60,7 @@ Persona is **derived at runtime** from existing bot fields (`name`, `title`, `de
| Visual identity | `BotAvatar` — persona shape + unified `EvooMascot` soul (eyes/mouth/hands) driven by voice phase | | Visual identity | `BotAvatar` — persona shape + unified `EvooMascot` soul (eyes/mouth/hands) driven by voice phase |
| Voice delivery | `speaker.speak(..., { botId })` → server resolves per-bot `voiceId` | | Voice delivery | `speaker.speak(..., { botId })` → server resolves per-bot `voiceId` |
Archetypes include: CTO executive (Steve Jobs template), chief operator (Fixer), builder (Dr. Eggbot), curator (Webby), work/life inboxes, home steward, quant, engineer, **specialist** (narrow fallback — no Wunderwuzzi). See `docs/PRODUCT.md` and `docs/PERSONA-SELECTION.md`. Archetypes include: CTO executive, chief operator (Fixer), builder (Dr. Eggbot), curator (Webby), work/life inboxes, home steward, quant, engineer, **specialist** (narrow fallback — no Wunderwuzzi). See `docs/PRODUCT.md` and `docs/PERSONA-SELECTION.md`.
**Companion tone:** geek-peer (fachsimpeln when wanted) or quiet-doer (execute without condescension) — inferred per user, not one voice for everyone. **Companion tone:** geek-peer (fachsimpeln when wanted) or quiet-doer (execute without condescension) — inferred per user, not one voice for everyone.

View File

@ -1,119 +0,0 @@
# APEX TRADING ENGINE — BLUEPRINT & SYSTEM ARCHITECTURE
> **Version**: 1.0.0-PROD
> **Subsystem**: evOo Autonomous High-Frequency & Quantitative Engine
> **Aufsicht**: Chief Technology Officer Steve Jobs & Apex Quant Specialist
> **Status**: RATIFIZIERT & IM DATEISYSTEM VERANKERT
---
## 1. Executive Summary & Zielsetzung
Die **Apex Trading Engine** ist das hochperformante quantitative Handels-Subsystem innerhalb der evOo-Plattform. Sie wurde entwickelt, um Marktchancen algorithmisch zu identifizieren, Risiken deterministisch abzusichern und vollautomatisiert mit Sub-Millisekunden-Latenz zu agieren.
Das System folgt dem obersten evOo-Grundsatz:
> **„Keine hypothetischen Trades, keine ungetesteten Algorithmen. Nur deterministische Ausführung und unnachgiebiges Risikomanagement.“**
---
## 2. System-Architektur
```
┌──────────────────────────────────────────────────────────────┐
│ MARKET DATA FEEDS │
│ (WebSockets, Orderbook L2/L3, REST Snapshots, On-Chain RPC) │
└──────────────────────────────┬───────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────┐
│ APEX DATA PIPELINE & TICK BUFFER │
│ (Normalisierung, VWAP, Volatilität, Delta) │
└──────────────────────────────┬───────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────┐
│ QUANT STRATEGY ENGINE │
│ ├── Alpha-1: Momentum & Breakout (Trendfolge) │
│ ├── Alpha-2: Order Book Imbalance & Flow Reversal │
│ └── Alpha-3: Statistical Arbitrage / Mean Reversion │
└──────────────────────────────┬───────────────────────────────┘
│ Signal
▼
┌──────────────────────────────────────────────────────────────┐
│ APEX RISK GUARDRAILS (HARD ENFORCEMENT) │
│ ├── Max Drawdown Lock: 1.5% Intraday │
│ ├── Max Single-Position Exposure: 5.0% Portfolio │
│ ├── Value-at-Risk (99% CI): Max 0.8% │
│ └── Autonomous Hardware Kill Switch │
└──────────────────────────────┬───────────────────────────────┘
│ Approved Order
▼
┌──────────────────────────────────────────────────────────────┐
│ SMART ORDER ROUTER & EXECUTION │
│ (TWAP/VWAP Slicing, Slippage Minimizer) │
└──────────────────────────────────────────────────────────────┘
```
---
## 3. Risikomatrix & Hard-Guardrails
Kein Signal kann ausgeführt werden, ohne die **Apex Risk Guard** Instanz zu passieren. Diese Schranken sind unveränderlich im Code fixiert:
| Guardrail | Grenzwert | Aktion bei Verletzung |
| :--- | :--- | :--- |
| **Max Intraday Drawdown** | `1.50%` | Sofortige Schließung aller Positionen, Deaktivierung des Handels für 24h |
| **Single Trade Risk** | `0.50%` des Eigenkapitals | Order wird von der Risk Engine vor dem Senden blockiert |
| **Max Leverage** | `3.0x` | Harte Grenze im Margin-Router |
| **Slippage Toleranz** | `0.15%` | Limit-Order mit Timeout, kein Blind-Market-Buying |
| **Latenz-Anomalie** | `> 50 ms` | Übergang in den Notfall-Sicherheitsmodus |
---
## 4. Strategiemodule (`src/strategies/`)
### 4.1 Alpha-1: Cross-Market Momentum (`momentum.py`)
Identifiziert signifikante Momentum-Impulse durch exponentiell gewichtete Gleitende Durchschnitte kombiniert mit Relative Strength Index (RSI) und Volumen-Konfirmation.
- **Trigger**: Bollinger Band Expansion + Volumenspitze > 2.2x 20-Perioden-Durchschnitt.
- **Exit**: Trailing Stop bei 1.2x ATR (Average True Range) oder Umkehrsignal.
### 4.2 Alpha-2: Order Book Imbalance (`imbalance.py`)
Scannt Bid/Ask-Tiefe in den Top 10 Ebenen des Orderbuchs.
- **Formel**:
$$Imbalance = \frac{Volume_{Bid} - Volume_{Ask}}{Volume_{Bid} + Volume_{Ask}}$$
- Signal feuert, wenn $|Imbalance| > 0.65$ für mindestens 3 konsekutive Ticks.
### 4.3 Alpha-3: Statistical Mean Reversion (`reversion.py`)
Nutzt Z-Score Abweichungen des aktuellen Preises vom gleitenden VWAP.
- Einstieg bei Z-Score $\ge 2.5$ oder $\le -2.5$ mit Rückkehr zum Mittelwert als Target.
---
## 5. Integration mit der evOo Self-Improvement Schleife
1. **Trade Post-Mortem**: Jeder geschlossene Trade generiert ein strukturiertes JSON-Log mit Execution-Slippage, Haltedauer und PnL.
2. **Autonome Kalibrierung**: Wenn die Win-Rate über 50 Trades unter 52% fällt, initiiert der Casted Quant Specialist eine automatische Hyperparameter-Optimierung.
3. **Audit-Sicherheit**: Alle Parameteränderungen werden in `ARCHITECTURE.md` und Git-Commits dokumentiert.
---
## 6. Verzeichnisstruktur von `/apex-trading`
```
/apex-trading/
├── BLUEPRINT.md # Dieser Blueprint
├── README.md # Quickstart & CLI Anleitung
├── config/
│ └── trading.config.json # Konfigurierte Risikogrenzen & Assets
└── src/
├── __init__.py
├── engine/
│ ├── __init__.py
│ └── trader.py # Core Event Loop & Router
├── strategies/
│ ├── __init__.py
│ └── momentum.py # Momentum-Strategie
└── risk/
├── __init__.py
└── risk_guard.py # Hard Risk Guardrails & Circuit Breaker
```

View File

@ -1,11 +0,0 @@
# Apex Trading Subsystem
Quantitative & High-Frequency Trading Subsystem of the evOo Platform.
## Features
- Hard-coded risk controls (`src/risk/risk_guard.py`)
- Quantitative momentum and imbalance strategies (`src/strategies/`)
- Autonomous execution engine (`src/engine/trader.py`)
- Full integration with evOo Self-Improvement and CTO Steve Jobs directives.
See [BLUEPRINT.md](file:///Users/ingowagner/awesome-grok-bot/apex-trading/BLUEPRINT.md) for full architecture details.

View File

@ -1,29 +0,0 @@
{
"system": "Apex Trading Engine",
"version": "1.0.0-PROD",
"mode": "paper_trading",
"base_currency": "USDC",
"allocated_capital": 100000.0,
"risk_limits": {
"max_drawdown_percent": 1.5,
"max_single_trade_risk_percent": 0.5,
"max_open_positions": 4,
"max_leverage": 3.0,
"slippage_tolerance_percent": 0.15,
"circuit_breaker_cooldown_seconds": 3600
},
"active_pairs": ["BTC/USDC", "ETH/USDC", "SOL/USDC"],
"strategies": {
"momentum": {
"enabled": true,
"lookback_period": 20,
"rsi_oversold": 32,
"rsi_overbought": 68
},
"orderbook_imbalance": {
"enabled": true,
"threshold": 0.65,
"depth_levels": 10
}
}
}

View File

@ -1,62 +0,0 @@
"""
Apex Trading Engine Core
Event loop that receives signals, checks risk guardrails, and executes orders.
"""
import json
import os
import sys
from typing import Dict, Any
from pathlib import Path
SRC_DIR = Path(__file__).resolve().parent.parent
if str(SRC_DIR) not in sys.path:
sys.path.insert(0, str(SRC_DIR))
try:
from risk.risk_guard import ApexRiskGuard
from strategies.momentum import MomentumStrategy
except ImportError:
from ..risk.risk_guard import ApexRiskGuard
from ..strategies.momentum import MomentumStrategy
class ApexEngine:
def __init__(self, config_path: str):
with open(config_path, 'r', encoding='utf-8') as f:
self.config = json.load(f)
self.risk_guard = ApexRiskGuard(self.config)
self.momentum_strategy = MomentumStrategy(self.config.get("strategies", {}).get("momentum"))
self.open_positions = []
self.current_drawdown_pct = 0.0
def process_tick(self, symbol: str, prices: list, volume: list) -> Dict[str, Any]:
"""Processes market data tick through strategy and risk guard."""
signal = self.momentum_strategy.generate_signal(symbol, prices, volume)
if not signal:
return {"status": "NO_SIGNAL", "symbol": symbol}
approved, reason = self.risk_guard.validate_order(
signal,
self.current_drawdown_pct,
len(self.open_positions)
)
if not approved:
return {"status": "REJECTED_BY_RISK", "reason": reason, "signal": signal}
# Simulated Execution
order_receipt = {
"status": "EXECUTED",
"symbol": signal["symbol"],
"action": signal["action"],
"fill_price": signal["price"],
"strategy": signal["strategy"],
"risk_status": "APPROVED"
}
self.open_positions.append(order_receipt)
return order_receipt
if __name__ == "__main__":
print("[APEX ENGINE] Liveness check passed. Engine ready.")

View File

@ -1,40 +0,0 @@
"""
Apex Trading Risk Guard
Hard enforcement of risk constraints defined in BLUEPRINT.md.
"""
from typing import Dict, Any, Tuple
class ApexRiskGuard:
def __init__(self, config: Dict[str, Any]):
self.config = config.get("risk_limits", {})
self.max_drawdown = self.config.get("max_drawdown_percent", 1.5)
self.max_trade_risk = self.config.get("max_single_trade_risk_percent", 0.5)
self.max_positions = self.config.get("max_open_positions", 4)
self.circuit_breaker_active = False
def validate_order(self, order: Dict[str, Any], current_drawdown: float, open_position_count: int) -> Tuple[bool, str]:
"""
Validates an incoming order against hard guardrails.
Returns (is_approved: bool, reason: str).
"""
if self.circuit_breaker_active:
return False, "REJECTED: Circuit breaker is active."
if current_drawdown >= self.max_drawdown:
self.circuit_breaker_active = True
return False, f"REJECTED & CIRCUIT BREAKER TRIGGERED: Drawdown {current_drawdown}% >= limit {self.max_drawdown}%"
if open_position_count >= self.max_positions:
return False, f"REJECTED: Maximum concurrent positions ({self.max_positions}) reached."
order_risk_pct = order.get("risk_percent", 0.0)
if order_risk_pct > self.max_trade_risk:
return False, f"REJECTED: Order risk {order_risk_pct}% exceeds limit {self.max_trade_risk}%"
return True, "APPROVED"
def trip_kill_switch(self, reason: str):
"""Emergency hard stop."""
self.circuit_breaker_active = True
print(f"[APEX EMERGENCY KILL SWITCH] {reason}")

View File

@ -1,48 +0,0 @@
"""
Apex Momentum Strategy Module (Alpha-1)
Calculates breakout and trend-following signals.
"""
from typing import List, Dict, Any, Optional
class MomentumStrategy:
def __init__(self, params: Optional[Dict[str, Any]] = None):
self.params = params or {}
self.lookback = self.params.get("lookback_period", 20)
self.rsi_oversold = self.params.get("rsi_oversold", 32)
self.rsi_overbought = self.params.get("rsi_overbought", 68)
def generate_signal(self, symbol: str, prices: List[float], volume: List[float]) -> Optional[Dict[str, Any]]:
"""
Evaluates recent tick series and outputs a trading signal if criteria are satisfied.
"""
if len(prices) < self.lookback:
return None
recent_window = prices[-self.lookback:]
current_price = prices[-1]
sma = sum(recent_window) / len(recent_window)
# Simple momentum breakout check
if current_price > sma * 1.015:
return {
"symbol": symbol,
"action": "BUY",
"price": current_price,
"strategy": "Alpha-1:Momentum",
"risk_percent": 0.35,
"stop_loss": current_price * 0.985,
"take_profit": current_price * 1.045
}
elif current_price < sma * 0.985:
return {
"symbol": symbol,
"action": "SELL",
"price": current_price,
"strategy": "Alpha-1:Momentum",
"risk_percent": 0.35,
"stop_loss": current_price * 1.015,
"take_profit": current_price * 0.955
}
return None

View File

@ -1,52 +0,0 @@
#!/usr/bin/env python3
"""
Unit tests for Apex Trading Engine and Risk Guardrails.
"""
import unittest
from pathlib import Path
import sys
BASE_DIR = Path(__file__).resolve().parent
if str(BASE_DIR / "src") not in sys.path:
sys.path.insert(0, str(BASE_DIR / "src"))
from engine.trader import ApexEngine
from risk.risk_guard import ApexRiskGuard
from strategies.momentum import MomentumStrategy
class TestApexTrading(unittest.TestCase):
def setUp(self):
self.config_path = str(BASE_DIR / "config" / "trading.config.json")
self.engine = ApexEngine(self.config_path)
def test_risk_guard_limits(self):
guard = self.engine.risk_guard
# Normal order passes
normal_order = {"symbol": "BTC/USDT", "risk_percent": 0.3}
approved, reason = guard.validate_order(normal_order, current_drawdown=0.5, open_position_count=1)
self.assertTrue(approved)
self.assertEqual(reason, "APPROVED")
# Exceeding single trade risk
risky_order = {"symbol": "BTC/USDT", "risk_percent": 0.8}
approved, reason = guard.validate_order(risky_order, current_drawdown=0.5, open_position_count=1)
self.assertFalse(approved)
self.assertIn("exceeds limit", reason)
# Exceeding max drawdown trips circuit breaker
approved, reason = guard.validate_order(normal_order, current_drawdown=1.6, open_position_count=1)
self.assertFalse(approved)
self.assertIn("CIRCUIT BREAKER", reason)
self.assertTrue(guard.circuit_breaker_active)
def test_engine_process_tick(self):
# 25 prices trending sharply upwards
prices = [100.0 + (i * 1.5) for i in range(25)]
volume = [1000.0] * 25
result = self.engine.process_tick("SOL/USDT", prices, volume)
self.assertEqual(result.get("status"), "EXECUTED")
self.assertEqual(result.get("action"), "BUY")
if __name__ == "__main__":
unittest.main()

View File

@ -109,7 +109,6 @@ import {
settleWithTimeout, settleWithTimeout,
TEAM_CHAT_STARTUP_SHUTDOWN_MS, TEAM_CHAT_STARTUP_SHUTDOWN_MS,
} from "./team-chat-startup.js"; } from "./team-chat-startup.js";
import { mountTradingBotStatus } from "./trading-bot-proxy.js";
import { mountVoiceHttpRoutes } from "./voice.js"; import { mountVoiceHttpRoutes } from "./voice.js";
import { mountWebhookHttpRoutes } from "./webhook.js"; import { mountWebhookHttpRoutes } from "./webhook.js";
@ -530,13 +529,6 @@ export async function createApp(
resetUrl: email ? new URL("/reset-password", env.webOrigin).href : null, resetUrl: email ? new URL("/reset-password", env.webOrigin).href : null,
}), }),
); );
mountTradingBotStatus(app, {
baseUrl: env.tradingBotUrl,
authorize: async (request) => {
const session = await auth.api.getSession({ headers: sessionHeaders(request) });
return Boolean(session?.user);
},
});
if (localEmailEmulator && env.nodeEnv === "development") { if (localEmailEmulator && env.nodeEnv === "development") {
app.get( app.get(
"/api/dev/emails", "/api/dev/emails",

View File

@ -0,0 +1,74 @@
import { describe, expect, it } from "vitest";
import { importableRoutines, relativeImportedFiles, safeImportPath } from "./bot-import.js";
describe("safeImportPath", () => {
it("normalizes separators and dot segments", () => {
expect(safeImportPath("notes\\./result.txt")).toBe("notes/result.txt");
expect(safeImportPath("MEMORY.md")).toBe("MEMORY.md");
});
it("rejects paths that could leave the bot home", () => {
expect(safeImportPath("../secrets")).toBeNull();
expect(safeImportPath("notes/../../x")).toBeNull();
expect(safeImportPath("/etc/passwd")).toBeNull();
expect(safeImportPath("C:\\Windows")).toBeNull();
expect(safeImportPath("./")).toBeNull();
});
});
describe("relativeImportedFiles", () => {
it("drops the old Team Computer workspace prefix", () => {
expect(
relativeImportedFiles([
{ path: "bots/old-id/notes/result.txt", content: "a" },
{ path: "bots/old-id/README.md", content: "b" },
]),
).toEqual([
{ path: "notes/result.txt", content: "a" },
{ path: "README.md", content: "b" },
]);
});
it("keeps Private Computer paths as they are", () => {
const files = [
{ path: "notes/result.txt", content: "a" },
{ path: "bots/other/file.txt", content: "b" },
];
expect(relativeImportedFiles(files)).toEqual(files);
});
it("keeps paths when files span several workspaces", () => {
const files = [
{ path: "bots/a/x.txt", content: "a" },
{ path: "bots/b/y.txt", content: "b" },
];
expect(relativeImportedFiles(files)).toEqual(files);
});
it("refuses the whole set when one path is unsafe", () => {
expect(
relativeImportedFiles([
{ path: "ok.txt", content: "a" },
{ path: "../escape.txt", content: "b" },
]),
).toBeNull();
});
it("accepts an export without files", () => {
expect(relativeImportedFiles([])).toEqual([]);
});
});
describe("importableRoutines", () => {
it("leaves out one-shot schedules", () => {
const routines = [
{ name: "daily", crons: ["0 9 * * *"] },
{ name: "once", crons: ["@once"] },
{ name: "webhook", crons: [] },
];
expect(importableRoutines(routines).map((routine) => routine.name)).toEqual([
"daily",
"webhook",
]);
});
});

View File

@ -0,0 +1,34 @@
import { isOneShotRoutineCrons } from "@rakazo/core";
/** A relative, forward-slash path without `.`/`..` segments, or null when it could escape. */
export function safeImportPath(raw: string): string | null {
const normalized = raw.replace(/\\/g, "/");
if (normalized.startsWith("/") || /^[a-z]:/i.test(normalized)) return null;
const segments = normalized.split("/").filter((segment) => segment !== "" && segment !== ".");
if (segments.length === 0 || segments.includes("..")) return null;
return segments.join("/");
}
/**
* Exports of bots on a Team Computer carry their workspace prefix (`bots/<old id>/…`).
* The prefix names the old bot, so it is dropped and the importer writes under the new one.
*/
export function relativeImportedFiles<T extends { path: string }>(
files: T[],
): Array<T & { path: string }> | null {
const safe: Array<T & { path: string }> = [];
for (const file of files) {
const path = safeImportPath(file.path);
if (!path) return null;
safe.push({ ...file, path });
}
const first = safe[0]?.path.split("/");
const workspace = first && first.length > 2 && first[0] === "bots" ? `bots/${first[1]}/` : null;
if (!workspace || !safe.every((file) => file.path.startsWith(workspace))) return safe;
return safe.map((file) => ({ ...file, path: file.path.slice(workspace.length) }));
}
/** One-shot schedules belong to the chat that set them, so they are not carried over. */
export function importableRoutines<T extends { crons: string[] }>(routines: T[]): T[] {
return routines.filter((routine) => !isOneShotRoutineCrons(routine.crons));
}

View File

@ -42,16 +42,6 @@ describe("loadEnv", () => {
); );
}); });
it("loads a trading bot origin and refuses credentials", () => {
expect(loadEnv(base).tradingBotUrl).toBeUndefined();
expect(
loadEnv({ ...base, TRADING_BOT_URL: " http://127.0.0.1:8000/bot/ " }).tradingBotUrl,
).toBe("http://127.0.0.1:8000");
expect(() =>
loadEnv({ ...base, TRADING_BOT_URL: "http://user:secret@127.0.0.1:8000" }),
).toThrow(/^Invalid TRADING_BOT_URL$/);
});
it("loads an optional integrations catalog mirror", () => { it("loads an optional integrations catalog mirror", () => {
expect(loadEnv(base).integrationsCatalogUrl).toBeUndefined(); expect(loadEnv(base).integrationsCatalogUrl).toBeUndefined();
expect( expect(

View File

@ -8,7 +8,6 @@ import {
resolveEncryptionKey, resolveEncryptionKey,
resolveScreenProxySecret, resolveScreenProxySecret,
resolveSupervisorToken, resolveSupervisorToken,
resolveTradingBotOrigin,
} from "@rakazo/core"; } from "@rakazo/core";
export { resolveCloudAgentProvider, resolveSandboxProvider } from "@rakazo/adapters"; export { resolveCloudAgentProvider, resolveSandboxProvider } from "@rakazo/adapters";
@ -93,8 +92,6 @@ export interface AppEnv {
updaterToken: string | undefined; updaterToken: string | undefined;
/** Current application image tag; used for compose manual-upgrade command selection. */ /** Current application image tag; used for compose manual-upgrade command selection. */
imageTag: string | undefined; imageTag: string | undefined;
/** Tradingbot origin for the same-origin status proxy. Unset on SaaS builds. */
tradingBotUrl: string | undefined;
} }
export function loadEnv(source: NodeJS.ProcessEnv = process.env): AppEnv { export function loadEnv(source: NodeJS.ProcessEnv = process.env): AppEnv {
@ -182,7 +179,6 @@ export function loadEnv(source: NodeJS.ProcessEnv = process.env): AppEnv {
updaterUrl, updaterUrl,
updaterToken, updaterToken,
imageTag: optional(source.RAKAZO_IMAGE_TAG), imageTag: optional(source.RAKAZO_IMAGE_TAG),
tradingBotUrl: resolveTradingBotOrigin(source.TRADING_BOT_URL),
}; };
} }

View File

@ -43,13 +43,20 @@ export async function assertNotMainConversation(
if (group?.kind === "main") throw new ORPCError("BAD_REQUEST", { message }); if (group?.kind === "main") throw new ORPCError("BAD_REQUEST", { message });
} }
/** The main conversation carries the main bot's name. */ /** A main conversation carries its main bot's name. */
export async function renameMainConversation(prisma: PrismaClient, userId: string, name: string) { export async function renameMainConversation(
await prisma.chatGroup.updateMany({ where: { userId, kind: "main" }, data: { name } }); prisma: PrismaClient,
main: { userId: string; spaceId: string },
name: string,
) {
await prisma.chatGroup.updateMany({
where: { userId: main.userId, spaceId: main.spaceId, kind: "main" },
data: { name },
});
} }
/** /**
* Same-space bots the user addresses in the main conversation, by `@Name` or a mention chip, * Same-space bots the user addresses in a main conversation, by `@Name` or a mention chip,
* join it first, so the member mention rules route the message to them. * join it first, so the member mention rules route the message to them.
*/ */
export async function joinAddressedBots( export async function joinAddressedBots(
@ -71,5 +78,9 @@ export async function joinAddressedBots(
.map((bot) => bot.id); .map((bot) => bot.id);
if (addressed.length === 0) return; if (addressed.length === 0) return;
// Subscribers pick up the group.updated event with the send that follows. // Subscribers pick up the group.updated event with the send that follows.
await joinMainConversation(prisma, { userId: actor.userId, botIds: addressed }); await joinMainConversation(prisma, {
userId: actor.userId,
spaceId: actor.spaceId,
botIds: addressed,
});
} }

View File

@ -1,11 +1,44 @@
import { mkdir, mkdtemp, writeFile } from "node:fs/promises"; import { mkdir, mkdtemp, realpath, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import type * as db from "@rakazo/db";
import { SpaceLimitError } from "@rakazo/db";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { getScanStatus, startOnboardingScan } from "./onboarding-scan.js"; import { getScanStatus, startOnboardingScan } from "./onboarding-scan.js";
const owner = { userId: "user-owner", isDeploymentOwner: true }; const createSpaceForMember = vi.hoisted(() => vi.fn());
const member = { userId: "user-member", isDeploymentOwner: false }; const ensureMainConversation = vi.hoisted(() => vi.fn());
vi.mock("@rakazo/db", async (original) => ({
...(await original<typeof db>()),
createSpaceForMember,
ensureMainConversation,
}));
const owner = { userId: "user-owner", spaceId: "space-owner", isDeploymentOwner: true };
const member = { userId: "user-member", spaceId: "space-member", isDeploymentOwner: false };
type SpaceUpdate = { where: { id: string }; data: { workspacePath: string } };
/** The spaces the scan reads and links; new ones come from `createSpaceForMember`. */
function fakeDatabase({ spaceLimit = Number.POSITIVE_INFINITY } = {}) {
const spaces: Array<{ id: string; name: string; workspacePath: string | null }> = [];
createSpaceForMember.mockImplementation(async (_prisma: unknown, input: { name: string }) => {
if (spaces.length >= spaceLimit) throw new SpaceLimitError();
const space = { id: `space-${spaces.length + 1}`, name: input.name, workspacePath: null };
spaces.push(space);
return { id: space.id, name: space.name };
});
const prisma = {
space: {
findMany: async () => spaces.filter((space) => space.workspacePath),
update: async ({ where, data }: SpaceUpdate) => {
const space = spaces.find((candidate) => candidate.id === where.id);
if (space) space.workspacePath = data.workspacePath;
},
},
} as unknown as db.PrismaClient;
return { prisma, spaces };
}
async function scanHomeWithOneFile(): Promise<string> { async function scanHomeWithOneFile(): Promise<string> {
const home = await mkdtemp(join(tmpdir(), "evoo-scan-")); const home = await mkdtemp(join(tmpdir(), "evoo-scan-"));
@ -14,6 +47,12 @@ async function scanHomeWithOneFile(): Promise<string> {
return home; return home;
} }
async function scanHomeWithRepos(...repos: string[]): Promise<string> {
const home = await scanHomeWithOneFile();
for (const repo of repos) await mkdir(join(home, repo, ".git"), { recursive: true });
return home;
}
async function completed(scanId: string, userId: string) { async function completed(scanId: string, userId: string) {
return await vi.waitFor( return await vi.waitFor(
async () => { async () => {
@ -25,13 +64,22 @@ async function completed(scanId: string, userId: string) {
); );
} }
/** The emulated runtime that the shared test setup pins keeps the scan simulated. */
function allowHostScan() {
vi.stubEnv("AGENT_RUNTIME", "");
vi.stubEnv("SANDBOX_PROVIDER", "");
}
describe("onboarding scan", () => { describe("onboarding scan", () => {
afterEach(() => { afterEach(() => {
vi.unstubAllEnvs(); vi.unstubAllEnvs();
createSpaceForMember.mockReset();
ensureMainConversation.mockReset();
}); });
it("hides a scan from every account except the one that started it", async () => { it("hides a scan from every account except the one that started it", async () => {
const { scanId } = await startOnboardingScan({ actor: member }, {}); const { prisma } = fakeDatabase();
const { scanId } = await startOnboardingScan({ actor: member }, prisma);
await expect(getScanStatus(scanId, owner.userId)).rejects.toThrow(/Scan not found/); await expect(getScanStatus(scanId, owner.userId)).rejects.toThrow(/Scan not found/);
await expect(getScanStatus(scanId, member.userId)).resolves.toMatchObject({ await expect(getScanStatus(scanId, member.userId)).resolves.toMatchObject({
status: expect.any(String), status: expect.any(String),
@ -40,25 +88,79 @@ describe("onboarding scan", () => {
}); });
it("never reads the host without a configured scan home", async () => { it("never reads the host without a configured scan home", async () => {
const { scanId } = await startOnboardingScan({ actor: owner }, {}); const { prisma } = fakeDatabase();
const { scanId } = await startOnboardingScan({ actor: owner }, prisma);
const status = await completed(scanId, owner.userId); const status = await completed(scanId, owner.userId);
expect(status.status).toBe("complete"); expect(status.status).toBe("complete");
expect(status.result).toMatchObject({ localRepos: [], fileSummary: { totalFiles: 0 } }); expect(status.result).toMatchObject({ localRepos: [], fileSummary: { totalFiles: 0 } });
}); });
it("lets only the deployment owner discover the configured scan home", async () => { it("lets only the deployment owner discover the configured scan home", async () => {
// The shared test setup pins the emulated runtime, which also keeps the scan simulated. allowHostScan();
vi.stubEnv("AGENT_RUNTIME", ""); const scanHome = await scanHomeWithRepos("Projects/app");
vi.stubEnv("SANDBOX_PROVIDER", ""); const { prisma } = fakeDatabase();
const scanHome = await scanHomeWithOneFile();
const asMember = await startOnboardingScan({ actor: member, scanHome }, {}); const asMember = await startOnboardingScan({ actor: member, scanHome }, prisma);
const memberStatus = await completed(asMember.scanId, member.userId); const memberStatus = await completed(asMember.scanId, member.userId);
expect(memberStatus.result).toMatchObject({ fileSummary: { totalFiles: 0 } }); expect(memberStatus.result).toMatchObject({ fileSummary: { totalFiles: 0 } });
expect(createSpaceForMember).not.toHaveBeenCalled();
const asOwner = await startOnboardingScan({ actor: owner, scanHome }, {}); const asOwner = await startOnboardingScan({ actor: owner, scanHome }, prisma);
const ownerStatus = await completed(asOwner.scanId, owner.userId); const ownerStatus = await completed(asOwner.scanId, owner.userId);
expect(ownerStatus.status).toBe("complete"); expect(ownerStatus.status).toBe("complete");
expect(ownerStatus.result).toMatchObject({ fileSummary: { totalFiles: 1 } }); expect(ownerStatus.result).toMatchObject({ fileSummary: { totalFiles: 1 } });
}); });
it("links each repository of the deployment owner to one project of its own", async () => {
allowHostScan();
const scanHome = await scanHomeWithRepos("Projects/app", ".config/nvim");
const app = await realpath(join(scanHome, "Projects", "app"));
const { prisma, spaces } = fakeDatabase();
const first = await startOnboardingScan({ actor: owner, scanHome }, prisma);
const firstStatus = await completed(first.scanId, owner.userId);
expect(firstStatus.status).toBe("complete");
expect(firstStatus.result).toMatchObject({
localRepos: [{ name: "app" }],
createdProjects: [{ spaceId: "space-1", name: "app", path: app }],
});
expect(createSpaceForMember).toHaveBeenCalledWith(prisma, {
currentSpaceId: owner.spaceId,
userId: owner.userId,
name: "app",
});
expect(spaces).toEqual([{ id: "space-1", name: "app", workspacePath: app }]);
// Each new project starts with its own main agent.
expect(ensureMainConversation).toHaveBeenCalledWith(prisma, owner.userId, "space-1");
// Scanning again finds the linked project instead of creating a second one.
const again = await startOnboardingScan({ actor: owner, scanHome }, prisma);
const againStatus = await completed(again.scanId, owner.userId);
expect(againStatus.result).toMatchObject({ createdProjects: [] });
expect(createSpaceForMember).toHaveBeenCalledTimes(1);
});
it("joins a running scan instead of linking its repositories twice", async () => {
allowHostScan();
const scanHome = await scanHomeWithRepos("Projects/app");
const { prisma, spaces } = fakeDatabase();
const first = await startOnboardingScan({ actor: owner, scanHome }, prisma);
const second = await startOnboardingScan({ actor: owner, scanHome }, prisma);
expect(second.scanId).toBe(first.scanId);
await completed(first.scanId, owner.userId);
expect(spaces).toHaveLength(1);
});
it("stops creating projects at the space limit and still completes", async () => {
allowHostScan();
const scanHome = await scanHomeWithRepos("Projects/a", "Projects/b", "Projects/c");
const { prisma } = fakeDatabase({ spaceLimit: 1 });
const { scanId } = await startOnboardingScan({ actor: owner, scanHome }, prisma);
const status = await completed(scanId, owner.userId);
expect(status.status).toBe("complete");
expect(status.result).toMatchObject({ createdProjects: [{ spaceId: "space-1" }] });
expect(createSpaceForMember).toHaveBeenCalledTimes(2);
});
}); });

View File

@ -2,8 +2,17 @@
// Runs synchronously on the host machine (no sandbox needed). // Runs synchronously on the host machine (no sandbox needed).
import { spawn } from "node:child_process"; import { spawn } from "node:child_process";
import { access, readdir, stat } from "node:fs/promises"; import { access, readdir, stat } from "node:fs/promises";
import { dirname, join } from "node:path"; import { basename, dirname, join } from "node:path";
import type { Actor } from "@rakazo/contracts"; import type { Actor } from "@rakazo/contracts";
import type { PrismaClient } from "@rakazo/db";
import {
createSpaceForMember,
ensureMainConversation,
InvalidSpaceNameError,
resolveProjectWorkspaceDirectory,
SpaceLimitError,
wireProjectWorkspace,
} from "@rakazo/db";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Types // Types
@ -54,10 +63,9 @@ interface ProfileSummary {
} }
interface CreatedProject { interface CreatedProject {
id: string;
name: string;
spaceId: string; spaceId: string;
description: string; name: string;
path: string;
} }
interface ScanRecord { interface ScanRecord {
@ -115,8 +123,10 @@ async function runSimulatedScan(record: ScanRecord) {
record.completedAt = new Date(); record.completedAt = new Date();
} }
type ScanActor = Pick<Actor, "userId" | "spaceId" | "isDeploymentOwner">;
export interface OnboardingScanRequest { export interface OnboardingScanRequest {
actor: Pick<Actor, "userId" | "isDeploymentOwner">; actor: ScanActor;
botId?: string; botId?: string;
/** Home-style directory to discover. Unset keeps the scan off the host filesystem. */ /** Home-style directory to discover. Unset keeps the scan off the host filesystem. */
scanHome?: string; scanHome?: string;
@ -124,8 +134,12 @@ export interface OnboardingScanRequest {
export async function startOnboardingScan( export async function startOnboardingScan(
{ actor, botId, scanHome }: OnboardingScanRequest, { actor, botId, scanHome }: OnboardingScanRequest,
prisma: any, prisma: PrismaClient,
): Promise<{ scanId: string }> { ): Promise<{ scanId: string }> {
// A repeated start (a remount, a retry) joins the running scan, so no repository is linked twice.
for (const running of scanRegistry.values()) {
if (running.ownerUserId === actor.userId && !running.completedAt) return { scanId: running.id };
}
const scanId = `scan_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; const scanId = `scan_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const record: ScanRecord = { const record: ScanRecord = {
id: scanId, id: scanId,
@ -154,7 +168,7 @@ export async function startOnboardingScan(
await runSimulatedScan(record); await runSimulatedScan(record);
return; return;
} }
const result = await executeFullScan(scanHome, botId ?? "", prisma); const result = await executeFullScan(scanHome, actor, prisma);
record.status = "complete"; record.status = "complete";
record.progress = 100; record.progress = 100;
record.message = "Scan complete — profile ready"; record.message = "Scan complete — profile ready";
@ -189,7 +203,11 @@ export async function getScanStatus(
// Full scan pipeline // Full scan pipeline
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
async function executeFullScan(homeDir: string, _botId: string, prisma: any): Promise<ScanResult> { async function executeFullScan(
homeDir: string,
actor: ScanActor,
prisma: PrismaClient,
): Promise<ScanResult> {
// Phase 1: Local file scan // Phase 1: Local file scan
const [fileSummary, projectFolders] = await scanFiles(homeDir); const [fileSummary, projectFolders] = await scanFiles(homeDir);
@ -200,8 +218,8 @@ async function executeFullScan(homeDir: string, _botId: string, prisma: any): Pr
// Phase 4: Profile compilation from findings // Phase 4: Profile compilation from findings
const profileSummary = compileProfile(localRepos, fileSummary, projectFolders); const profileSummary = compileProfile(localRepos, fileSummary, projectFolders);
// Phase 6: Project folder creation // Phase 6: Projects for discovered repositories
const createdProjects = await createProjectFolders(localRepos, prisma); const createdProjects = await createProjects(localRepos, actor, prisma);
// Phase 7: Sub-agent role creation // Phase 7: Sub-agent role creation
const subAgentRoles = generateSubAgentRoles(profileSummary, localRepos); const subAgentRoles = generateSubAgentRoles(profileSummary, localRepos);
@ -413,9 +431,9 @@ async function findGitDirectories(dir: string, depth: number = 0): Promise<strin
if (entry.name === ".git") { if (entry.name === ".git") {
results.push(dirname(fullPath)); results.push(dirname(fullPath));
} else if ( } else if (
// Dot folders hold tools and caches (shell and editor plugins), not the person's projects.
!entry.name.startsWith(".") &&
entry.name !== "node_modules" && entry.name !== "node_modules" &&
entry.name !== ".git" &&
entry.name !== ".venv" &&
entry.name !== "dist" entry.name !== "dist"
) { ) {
results.push(...(await findGitDirectories(fullPath, depth + 1))); results.push(...(await findGitDirectories(fullPath, depth + 1)));
@ -622,38 +640,45 @@ function generateBriefing(
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Phase 5: Project folder creation // Phase 5: Projects for discovered repositories
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
async function createProjectFolders(repos: RepoInfo[], prisma: any): Promise<CreatedProject[]> { /** Give each discovered repository a project linked to its folder, unless one already links it. */
async function createProjects(
repos: RepoInfo[],
actor: ScanActor,
prisma: PrismaClient,
): Promise<CreatedProject[]> {
const linked = await prisma.space.findMany({
where: { workspacePath: { not: null }, memberships: { some: { userId: actor.userId } } },
select: { workspacePath: true },
});
const linkedFolders = new Set(linked.map((space) => space.workspacePath));
const created: CreatedProject[] = []; const created: CreatedProject[] = [];
for (const repo of repos) { for (const repo of repos) {
// Compare and link the real folder, so the next scan recognizes the projects of this one.
// Checking it before the project exists means a folder that cannot be linked leaves none.
const folder = await resolveProjectWorkspaceDirectory(repo.path).catch(() => null);
if (!folder || linkedFolders.has(folder)) continue;
let space: { id: string; name: string };
try { try {
// Check if space already exists space = await createSpaceForMember(prisma, {
const existing = await prisma.space.findFirst({ where: { title: repo.name } }); currentSpaceId: actor.spaceId,
if (existing) { userId: actor.userId,
created.push({ name: basename(folder),
id: existing.id,
name: repo.name,
spaceId: existing.id,
description: repo.remote || repo.path,
});
continue;
}
// Create new space
const space = await prisma.space.create({
data: { title: repo.name, path: repo.path },
}); });
created.push({ } catch (error) {
id: space.id, if (error instanceof SpaceLimitError) break;
name: repo.name, if (error instanceof InvalidSpaceNameError) continue;
spaceId: space.id, throw error;
description: repo.remote || repo.path,
});
} catch {
// Space might already exist or DB unavailable — skip
} }
const workspace = await wireProjectWorkspace(prisma, {
spaceId: space.id,
workspacePath: folder,
});
await ensureMainConversation(prisma, actor.userId, space.id);
created.push({ spaceId: space.id, name: space.name, path: workspace.path });
} }
return created; return created;

View File

@ -79,7 +79,7 @@ async function requireBotThread(deps: OnboardingDeps, actor: Actor, botId: strin
}); });
if (!bot?.thread) throw new IsolationError(); if (!bot?.thread) throw new IsolationError();
if (bot.kind === "main") { if (bot.kind === "main") {
const conversation = await ensureMainConversation(deps.prisma, actor.userId); const conversation = await ensureMainConversation(deps.prisma, actor.userId, bot.spaceId);
return { bot, thread: { id: conversation.threadId } }; return { bot, thread: { id: conversation.threadId } };
} }
return { bot, thread: bot.thread }; return { bot, thread: bot.thread };

View File

@ -88,7 +88,15 @@ import {
verifyMcpInstall, verifyMcpInstall,
} from "@rakazo/adapters"; } from "@rakazo/adapters";
import type { Auth } from "@rakazo/auth"; import type { Auth } from "@rakazo/auth";
import type { Actor, ComputerStatus, McpServer, Me, SpaceNavigation } from "@rakazo/contracts"; import type {
Actor,
ComputerStatus,
McpServer,
Me,
SpaceBot,
SpaceGroup,
SpaceNavigation,
} from "@rakazo/contracts";
import { import {
appContract, appContract,
IntegrationProviderIdSchema, IntegrationProviderIdSchema,
@ -131,12 +139,14 @@ import {
listSpaceUserBotIds, listSpaceUserBotIds,
newestModelCredentialOrder, newestModelCredentialOrder,
newestVoiceCredentialOrder, newestVoiceCredentialOrder,
ownMainAgentsBlockingSpaceDeletion,
Prisma, Prisma,
parseComputerMode, parseComputerMode,
projectWorkspaceDto, projectWorkspaceDto,
releaseSpaceDeletionClaim, releaseSpaceDeletionClaim,
renewSpaceDeletionClaim, renewSpaceDeletionClaim,
requireMembership, requireMembership,
resolveProjectWorkspaceDirectory,
SPACE_DELETION_CLAIM_TIMEOUT_MS, SPACE_DELETION_CLAIM_TIMEOUT_MS,
SpaceDeletionInProgressError, SpaceDeletionInProgressError,
SpaceLimitError, SpaceLimitError,
@ -151,6 +161,7 @@ import { deleteAgentSecret, listAgentSecrets, putAgentSecret } from "./agent-sec
import { createAgentSkillsService } from "./agent-skills.js"; import { createAgentSkillsService } from "./agent-skills.js";
import { aiConsentStatus, allowAiConsent } from "./ai-consent.js"; import { aiConsentStatus, allowAiConsent } from "./ai-consent.js";
import { createOwnedArtifact, getOwnedArtifact, getSpaceArtifact } from "./artifacts.js"; import { createOwnedArtifact, getOwnedArtifact, getSpaceArtifact } from "./artifacts.js";
import { importableRoutines, relativeImportedFiles, safeImportPath } from "./bot-import.js";
import { botProfileLabelsChanged, commitBotUpdate } from "./bot-update.js"; import { botProfileLabelsChanged, commitBotUpdate } from "./bot-update.js";
import { import {
executionBlocksUserTakeover, executionBlocksUserTakeover,
@ -490,6 +501,26 @@ export interface RouterDeps {
* keep the deletion claim renewed forever and block stale-claim recovery. */ * keep the deletion claim renewed forever and block stale-claim recovery. */
const SPACE_TEARDOWN_TIMEOUT_MS = 120_000; const SPACE_TEARDOWN_TIMEOUT_MS = 120_000;
async function removeGroupWithArtifacts(
deps: RouterDeps,
groupRepos: ReturnType<typeof createGroupRepos>,
actor: Actor,
groupId: string,
) {
const removed = await groupRepos.removeGroup(actor, groupId);
const cleanup = await Promise.allSettled(
removed.artifactStorageKeys.map((storageKey) =>
deps.artifacts.remove(
storageKey,
computerContext(actor, removed.contextBotId, `group-remove:${groupId}`),
),
),
);
for (const result of cleanup) {
if (result.status === "rejected") getLogger().error("group artifact cleanup", result.reason);
}
}
function spaceTeardownTimeoutMs(): number { function spaceTeardownTimeoutMs(): number {
const override = Number(process.env.SPACE_TEARDOWN_TIMEOUT_MS ?? ""); const override = Number(process.env.SPACE_TEARDOWN_TIMEOUT_MS ?? "");
return Number.isFinite(override) && override > 0 ? override : SPACE_TEARDOWN_TIMEOUT_MS; return Number.isFinite(override) && override > 0 ? override : SPACE_TEARDOWN_TIMEOUT_MS;
@ -569,6 +600,22 @@ export function createRouter(deps: RouterDeps) {
spaceNavigationDto(deps, context.actor, repos, groupRepos), spaceNavigationDto(deps, context.actor, repos, groupRepos),
), ),
create: authed.spaces.create.handler(async ({ context, input }) => { create: authed.spaces.create.handler(async ({ context, input }) => {
// 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");
}
// Check the folder before the space exists, so a bad path leaves no space behind.
let workspacePath: string | null = null;
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;
}
}
let space: { id: string; name: string }; let space: { id: string; name: string };
try { try {
space = await createSpaceForMember(deps.prisma, { space = await createSpaceForMember(deps.prisma, {
@ -582,20 +629,16 @@ export function createRouter(deps: RouterDeps) {
} }
throw error; throw error;
} }
let workspace = null; const workspace = workspacePath
if (input.workspacePath) { ? await wireProjectWorkspace(deps.prisma, { spaceId: space.id, workspacePath })
try { : null;
workspace = await wireProjectWorkspace(deps.prisma, { // A project starts as a conversation with its own main agent.
spaceId: space.id, await ensureMainConversation(deps.prisma, context.actor.userId, space.id);
workspacePath: input.workspacePath, const spaceActor = await requireMembership(deps.prisma, context.actor.userId, space.id);
}); const [bots, groups] = await Promise.all([
} catch (error) { repos.listBots(spaceActor),
if (error instanceof InvalidProjectWorkspacePathError) { groupRepos.listGroups(spaceActor),
throw new ORPCError("BAD_REQUEST", { message: error.message }); ]);
}
throw error;
}
}
return { return {
id: space.id, id: space.id,
name: space.name, name: space.name,
@ -603,12 +646,26 @@ export function createRouter(deps: RouterDeps) {
hasContent: false, hasContent: false,
canDelete: true, canDelete: true,
workspace, workspace,
bots: [], bots: bots.map(spaceBotDto),
groups: [], groups: groups.map(spaceGroupDto),
externalConversations: [], externalConversations: [],
botSections: [], botSections: [],
}; };
}), }),
ensureMain: authed.spaces.ensureMain.handler(async ({ context, input }) => {
const conversation = await ensureMainConversation(
deps.prisma,
context.actor.userId,
input.spaceId,
).catch((error: unknown) => {
throw mapSpaceLifecycleError(error);
});
return {
botId: conversation.leadBotId,
groupId: conversation.groupId,
spaceId: conversation.spaceId,
};
}),
remove: authed.spaces.remove.handler(async ({ context, input }) => { remove: authed.spaces.remove.handler(async ({ context, input }) => {
let claimId: string | null = null; let claimId: string | null = null;
let claimActive = false; let claimActive = false;
@ -621,6 +678,41 @@ export function createRouter(deps: RouterDeps) {
spaceId: input.spaceId, spaceId: input.spaceId,
}; };
try { try {
// The project's own main agent goes with the project; anything else must be
// cleared first.
const ownMain = await ownMainAgentsBlockingSpaceDeletion(deps.prisma, deleteInput);
if (ownMain.groupIds.length > 0 || ownMain.botIds.length > 0) {
const spaceActor = await requireMembership(
deps.prisma,
context.actor.userId,
input.spaceId,
);
for (const groupId of ownMain.groupIds) {
await removeGroupWithArtifacts(deps, groupRepos, spaceActor, groupId);
}
for (const botId of ownMain.botIds) {
const bot = await repos.getBot(spaceActor, botId, { includeArchived: true });
await destroyBot(
{
prisma: deps.prisma,
sandbox: deps.sandbox,
home: deps.home,
jobs: deps.jobs,
artifacts: deps.artifacts,
dataDir: deps.dataDir,
},
bot,
{
operationId: "spaces.remove",
traceId: "spaces.remove",
spaceId: input.spaceId,
userId: context.actor.userId,
signal: new AbortController().signal,
},
{ deleteMemories: true },
);
}
}
// Claim emptiness before external teardown. Bot and group creation // Claim emptiness before external teardown. Bot and group creation
// take the same lifecycle lock and reject the Space until deletion // take the same lifecycle lock and reject the Space until deletion
// finishes or this claim is released. // finishes or this claim is released.
@ -1086,6 +1178,87 @@ export function createRouter(deps: RouterDeps) {
} }
return duplicate; return duplicate;
}), }),
import: authed.bots.import.handler(async ({ context, input }) => {
const files = relativeImportedFiles(input.files);
const memory = input.memory.flatMap((doc) => {
const path = safeImportPath(doc.path);
return path ? [{ path, content: doc.content }] : [];
});
if (!files || memory.length !== input.memory.length) {
throw new ORPCError("BAD_REQUEST", { message: "This export has an unsafe file path." });
}
const routines = importableRoutines(input.routines);
for (const routine of routines) {
if (hasMixedOneShotSchedule(routine.crons)) {
throw new ORPCError("BAD_REQUEST", {
message: "A one-time schedule can't be combined with other schedules.",
});
}
if (routine.crons.length > 0) nextRoutineDate(routine.crons, routine.timezone);
}
const created = await repos
.createBot(context.actor, { ...input.bot, notifyOnFinish: true })
.catch((error: unknown) => {
throw mapSpaceLifecycleError(error);
});
const bot = await repos.getBot(context.actor, created.id);
const importContext = {
operationId: "import",
traceId: "import",
spaceId: context.actor.spaceId,
userId: context.actor.userId,
signal: new AbortController().signal,
};
try {
if (!bot.computer) throw new IsolationError();
for (const doc of memory) {
await deps.memory.commit(
{ scope: "bot", botId: bot.id, path: doc.path, content: doc.content },
importContext,
);
}
const prefix = bot.computer.scope === "team" ? teamBotWorkspaceDirectory(bot.id) : "";
for (const file of files) {
await deps.home.writeFile(
bot.computer.homeKey,
prefix ? `${prefix}/${file.path}` : file.path,
file.content,
importContext,
);
}
// Imported schedules start paused: the user decides when this bot may act on its own.
if (routines.length) {
await deps.prisma.routine.createMany({
data: routines.map((routine) => ({
spaceId: context.actor.spaceId,
botId: bot.id,
userId: context.actor.userId,
name: routine.name,
prompt: routine.prompt,
crons: routine.crons,
timezone: routine.timezone,
active: false,
})),
});
}
} catch (error) {
await destroyBot(
{
prisma: deps.prisma,
sandbox: deps.sandbox,
home: deps.home,
jobs: deps.jobs,
artifacts: deps.artifacts,
dataDir: deps.dataDir,
},
bot,
{ ...importContext, operationId: "destroy", traceId: "destroy" },
{ deleteMemories: true },
).catch(() => undefined);
throw error;
}
return created;
}),
reorder: authed.bots.reorder.handler(async ({ context, input }) => { reorder: authed.bots.reorder.handler(async ({ context, input }) => {
await repos.reorderBots(context.actor, input.botIds); await repos.reorderBots(context.actor, input.botIds);
return { ok: true as const }; return { ok: true as const };
@ -1219,7 +1392,11 @@ export function createRouter(deps: RouterDeps) {
}, },
}); });
if (existing.kind === "main" && input.name !== undefined && input.name !== existing.name) { if (existing.kind === "main" && input.name !== undefined && input.name !== existing.name) {
await renameMainConversation(deps.prisma, context.actor.userId, input.name); await renameMainConversation(
deps.prisma,
{ userId: context.actor.userId, spaceId: existing.spaceId },
input.name,
);
} }
const bots = await repos.listBots(context.actor); const bots = await repos.listBots(context.actor);
const bot = bots.find((b) => b.id === input.botId); const bot = bots.find((b) => b.id === input.botId);
@ -1507,19 +1684,7 @@ export function createRouter(deps: RouterDeps) {
input.groupId, input.groupId,
"The main conversation can't be deleted.", "The main conversation can't be deleted.",
); );
const removed = await groupRepos.removeGroup(context.actor, input.groupId); await removeGroupWithArtifacts(deps, groupRepos, context.actor, input.groupId);
const cleanup = await Promise.allSettled(
removed.artifactStorageKeys.map((storageKey) =>
deps.artifacts.remove(
storageKey,
computerContext(context.actor, removed.contextBotId, `group-remove:${input.groupId}`),
),
),
);
for (const result of cleanup) {
if (result.status === "rejected")
getLogger().error("group artifact cleanup", result.reason);
}
return { ok: true as const }; return { ok: true as const };
}), }),
}, },
@ -2230,10 +2395,7 @@ export function createRouter(deps: RouterDeps) {
} }
} }
if (isProjectWorkspacePath(storedPath)) { if (isProjectWorkspacePath(storedPath)) {
const workspaceRoot = await requireProjectWorkspaceRoot( const workspaceRoot = await requireProjectWorkspaceRoot(deps.prisma, context.actor);
deps.prisma,
context.actor.spaceId,
);
const entries = await listProjectWorkspaceFiles(workspaceRoot, storedPath); const entries = await listProjectWorkspaceFiles(workspaceRoot, storedPath);
return entries.map((entry) => ({ return entries.map((entry) => ({
...entry, ...entry,
@ -2281,10 +2443,7 @@ export function createRouter(deps: RouterDeps) {
} }
} }
if (isProjectWorkspacePath(storedPath)) { if (isProjectWorkspacePath(storedPath)) {
const workspaceRoot = await requireProjectWorkspaceRoot( const workspaceRoot = await requireProjectWorkspaceRoot(deps.prisma, context.actor);
deps.prisma,
context.actor.spaceId,
);
const bytes = await readProjectWorkspaceFile(workspaceRoot, storedPath, { const bytes = await readProjectWorkspaceFile(workspaceRoot, storedPath, {
maxBytes: MAX_COMPUTER_TEXT_FILE_BYTES, maxBytes: MAX_COMPUTER_TEXT_FILE_BYTES,
}); });
@ -4894,10 +5053,13 @@ function mapUpdaterError(error: unknown): never {
async function requireProjectWorkspaceRoot( async function requireProjectWorkspaceRoot(
prisma: Pick<PrismaClient, "space">, prisma: Pick<PrismaClient, "space">,
spaceId: string, actor: Actor,
): Promise<string> { ): Promise<string> {
// Only the deployment owner may link a host folder, so only they may browse one. This also
// closes folders that other members linked before linking was limited to the owner.
if (!actor.isDeploymentOwner) throw new ORPCError("FORBIDDEN");
const space = await prisma.space.findUnique({ const space = await prisma.space.findUnique({
where: { id: spaceId }, where: { id: actor.spaceId },
select: { workspacePath: true }, select: { workspacePath: true },
}); });
if (!space?.workspacePath) { if (!space?.workspacePath) {
@ -4939,6 +5101,11 @@ async function spaceNavigationDto(
orderBy: { createdAt: "asc" }, orderBy: { createdAt: "asc" },
}); });
const spaceIds = memberships.map((membership) => membership.spaceId); const spaceIds = memberships.map((membership) => membership.spaceId);
const ownProjectMain = {
userId: actor.userId,
kind: "main",
space: { isDefault: false },
} as const;
const inactiveSpaceIds = spaceIds.filter((spaceId) => spaceId !== actor.spaceId); const inactiveSpaceIds = spaceIds.filter((spaceId) => spaceId !== actor.spaceId);
const [ const [
currentBots, currentBots,
@ -4959,13 +5126,14 @@ async function spaceNavigationDto(
// Active-only navigation lists miss archived content in other spaces; count any // Active-only navigation lists miss archived content in other spaces; count any
// bot/group in the actor's spaces (including another member's) so a shared // bot/group in the actor's spaces (including another member's) so a shared
// non-empty space cannot look empty for onboarding redirects. // non-empty space cannot look empty for onboarding redirects.
// The user's own main agent of a project is part of the project, not content to clear.
deps.prisma.bot.findMany({ deps.prisma.bot.findMany({
where: { spaceId: { in: spaceIds } }, where: { spaceId: { in: spaceIds }, NOT: ownProjectMain },
select: { spaceId: true }, select: { spaceId: true },
distinct: ["spaceId"], distinct: ["spaceId"],
}), }),
deps.prisma.chatGroup.findMany({ deps.prisma.chatGroup.findMany({
where: { spaceId: { in: spaceIds } }, where: { spaceId: { in: spaceIds }, NOT: ownProjectMain },
select: { spaceId: true }, select: { spaceId: true },
distinct: ["spaceId"], distinct: ["spaceId"],
}), }),
@ -5009,34 +5177,10 @@ async function spaceNavigationDto(
memberships.length > 1 && memberships.length > 1 &&
!spacesWithContent.has(membership.spaceId) && !spacesWithContent.has(membership.spaceId) &&
(membership.space.deletingAt === null || membership.space.deletingAt < staleClaimBefore), (membership.space.deletingAt === null || membership.space.deletingAt < staleClaimBefore),
workspace: projectWorkspaceDto(membership.space), // A linked folder is on the owner's host; other members see neither its path nor remote.
bots: spaceBots.map((bot) => ({ workspace: actor.isDeploymentOwner ? projectWorkspaceDto(membership.space) : null,
id: bot.id, bots: spaceBots.map(spaceBotDto),
spaceId: bot.spaceId, groups: spaceGroups.map(spaceGroupDto),
kind: bot.kind,
name: bot.name,
title: bot.title,
color: bot.color,
notifyOnFinish: bot.notifyOnFinish,
pinned: bot.pinned,
sectionId: bot.sectionId,
unread: bot.unread,
preview: bot.preview,
status: bot.status,
updatedAt: bot.updatedAt,
})),
groups: spaceGroups.map((group) => ({
id: group.id,
spaceId: group.spaceId,
kind: group.kind,
name: group.name,
pinned: group.pinned,
sectionId: group.sectionId,
members: group.members,
preview: group.preview,
unread: group.unread,
updatedAt: group.updatedAt,
})),
externalConversations: externalConversations.filter( externalConversations: externalConversations.filter(
(conversation) => conversation.spaceId === membership.spaceId, (conversation) => conversation.spaceId === membership.spaceId,
), ),
@ -5047,6 +5191,39 @@ async function spaceNavigationDto(
}; };
} }
function spaceBotDto(bot: SpaceBot): SpaceBot {
return {
id: bot.id,
spaceId: bot.spaceId,
kind: bot.kind,
name: bot.name,
title: bot.title,
color: bot.color,
notifyOnFinish: bot.notifyOnFinish,
pinned: bot.pinned,
sectionId: bot.sectionId,
unread: bot.unread,
preview: bot.preview,
status: bot.status,
updatedAt: bot.updatedAt,
};
}
function spaceGroupDto(group: SpaceGroup): SpaceGroup {
return {
id: group.id,
spaceId: group.spaceId,
kind: group.kind,
name: group.name,
pinned: group.pinned,
sectionId: group.sectionId,
members: group.members,
preview: group.preview,
unread: group.unread,
updatedAt: group.updatedAt,
};
}
function partitionBySpace<T extends { spaceId: string }>(rows: T[]): Map<string, T[]> { function partitionBySpace<T extends { spaceId: string }>(rows: T[]): Map<string, T[]> {
const partitioned = new Map<string, T[]>(); const partitioned = new Map<string, T[]>();
for (const row of rows) { for (const row of rows) {

View File

@ -0,0 +1,143 @@
import type { MessageBlock } from "@rakazo/contracts";
import type { PrismaClient } from "@rakazo/db";
import { createDb, provisionMessagingIdentity } from "@rakazo/db";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { loadMessagePage } from "./thread-message-pages.js";
const databaseUrl = process.env.DATABASE_URL;
const describePostgres =
process.env.VERIFY_DATABASE && databaseUrl ? describe.sequential : describe.skip;
describePostgres("loadMessagePage (PostgreSQL)", () => {
let prisma: PrismaClient;
let close: () => Promise<void>;
let identity: { userId: string; spaceId: string; botId: string; threadId: string };
let userRunId: string;
let peerRunId: string;
let seq = 0;
beforeAll(async () => {
const db = createDb(databaseUrl!);
prisma = db.prisma;
close = async () => {
await db.prisma.$disconnect();
await db.pool.end();
};
identity = await provisionMessagingIdentity(
prisma,
{ provider: "sendblue", address: `+1555${Date.now().toString().slice(-7)}` },
{ signupsEnabled: undefined, signupAllowlist: undefined },
);
const { spaceId, botId, threadId, userId } = identity;
const owner = { spaceId, botId, threadId, userId };
const run = async (trigger: string) => {
const task = await prisma.task.create({
data: { ...owner, prompt: "fixture", status: "completed" },
});
const created = await prisma.run.create({
data: { ...owner, taskId: task.id, status: "completed", trigger },
});
return created.id;
};
userRunId = await run("user_message");
peerRunId = await run("bot_message");
seq =
(
await prisma.message.aggregate({
where: { threadId: identity.threadId },
_max: { seq: true },
})
)._max.seq ?? 0;
});
afterAll(async () => {
if (identity) {
await prisma.messagingIdentity.deleteMany({ where: { userId: identity.userId } });
await prisma.organization.deleteMany({ where: { id: identity.spaceId } });
await prisma.user.deleteMany({ where: { id: identity.userId } });
}
await close?.();
});
async function message(id: string, runId: string | null, blocks: MessageBlock[]) {
seq += 1;
await prisma.message.create({
data: {
id: `${identity.threadId}-${id}`,
threadId: identity.threadId,
seq,
role: "bot",
runId,
blocks,
},
});
return seq;
}
const ids = (page: Awaited<ReturnType<typeof loadMessagePage>>) =>
page.messages.map((row) => row.id.slice(identity.threadId.length + 1));
it("fills a page with visible rows across a long stretch of hidden peer activity", async () => {
await message("older", userRunId, [{ kind: "text", text: "Older answer" }]);
await message("newer", userRunId, [{ kind: "text", text: "Newer answer" }]);
for (let index = 0; index < 30; index += 1) {
await message(`peer-steps-${index}`, peerRunId, [
{ kind: "steps", steps: [{ label: "Working", count: 1 }] },
]);
}
await message("peer-receipt", peerRunId, [
{ kind: "bot_message_received", fromBotId: "bot-2", fromBotName: "Coder", text: "Done." },
]);
await message("peer-reply", peerRunId, [{ kind: "text", text: "Peer summary" }]);
await message("orphan-run", "run-that-no-longer-exists", [{ kind: "text", text: "Kept" }]);
const first = await loadMessagePage(prisma, identity.threadId, undefined, 3);
expect(ids(first)).toEqual(["peer-receipt", "peer-reply", "orphan-run"]);
const second = await loadMessagePage(prisma, identity.threadId, first.olderCursor!, 2);
expect(ids(second)).toEqual(["older", "newer"]);
const peerView = await loadMessagePage(
prisma,
identity.threadId,
undefined,
3,
undefined,
true,
);
expect(ids(peerView)).toEqual(["peer-receipt", "peer-reply", "orphan-run"]);
const peerOlder = await loadMessagePage(
prisma,
identity.threadId,
peerView.olderCursor!,
1,
undefined,
true,
);
expect(ids(peerOlder)).toEqual(["peer-steps-29"]);
});
it("skips a receipt-only page for web but returns it when receipts are displayed", async () => {
await message("before-receipts", userRunId, [{ kind: "text", text: "Answer" }]);
await message("sent", userRunId, [
{ kind: "bot_message_sent", toBotId: "bot-2", toBotName: "Coder", text: "Check this." },
]);
await message("received", peerRunId, [
{ kind: "bot_message_received", fromBotId: "bot-2", fromBotName: "Coder", text: "Done." },
]);
const web = await loadMessagePage(prisma, identity.threadId, undefined, 2);
expect(ids(web)).toEqual(["orphan-run", "before-receipts"]);
const mobile = await loadMessagePage(
prisma,
identity.threadId,
undefined,
2,
undefined,
false,
true,
);
expect(ids(mobile)).toEqual(["sent", "received"]);
});
});

View File

@ -43,98 +43,6 @@ describe("thread message pages", () => {
).toBe(false); ).toBe(false);
}); });
it("keeps peer receipt rows when filtering peer-run output from pages", async () => {
const findMany = vi.fn(async () => [
{
id: "message-reply",
threadId: "thread-1",
seq: 3,
role: "bot",
blocks: [{ kind: "text", text: "Echoed peer reply" }],
botId: "bot-1",
replyToMessageId: null,
runId: "run-peer",
createdAt: new Date("2026-08-16T00:00:03.000Z"),
},
{
id: "message-received",
threadId: "thread-1",
seq: 2,
role: "user",
blocks: [
{
kind: "bot_message_received",
fromBotId: "bot-2",
fromBotName: "Coder",
text: "Done.",
},
],
botId: null,
replyToMessageId: null,
runId: "run-peer",
createdAt: new Date("2026-08-16T00:00:02.000Z"),
},
{
id: "message-user",
threadId: "thread-1",
seq: 1,
role: "bot",
blocks: [{ kind: "text", text: "Visible answer" }],
botId: "bot-1",
replyToMessageId: null,
runId: "run-user",
createdAt: new Date("2026-08-16T00:00:01.000Z"),
},
]);
const prisma = {
message: { findMany },
run: { findMany: vi.fn(async () => [{ id: "run-peer" }]) },
} as unknown as PrismaClient;
const page = await loadMessagePage(prisma, "thread-1", undefined, 3);
expect(page.messages.map((message) => message.id)).toEqual([
"message-user",
"message-received",
"message-reply",
]);
});
it("filters peer-run activity when its receipt is outside the loaded page", async () => {
const findMany = vi.fn(async () => [
{
id: "message-peer",
threadId: "thread-1",
seq: 2,
role: "bot",
blocks: [{ kind: "steps", steps: [{ label: "Echoed peer reply", count: 1 }] }],
botId: "bot-1",
replyToMessageId: null,
runId: "run-peer",
createdAt: new Date("2026-08-16T00:00:02.000Z"),
},
{
id: "message-user",
threadId: "thread-1",
seq: 1,
role: "bot",
blocks: [{ kind: "text", text: "Visible answer" }],
botId: "bot-1",
replyToMessageId: null,
runId: "run-user",
createdAt: new Date("2026-08-16T00:00:01.000Z"),
},
]);
const prisma = {
message: { findMany },
run: { findMany: vi.fn(async () => [{ id: "run-peer" }]) },
} as unknown as PrismaClient;
const page = await loadMessagePage(prisma, "thread-1", undefined, 2);
expect(page.messages.map((message) => message.id)).toEqual(["message-user"]);
});
it("keeps peer text around-page targets and omits peer activity", async () => { it("keeps peer text around-page targets and omits peer activity", async () => {
const findMany = vi.fn(async () => [ const findMany = vi.fn(async () => [
{ {
@ -281,45 +189,6 @@ describe("thread message pages", () => {
expect(page.messages.map((message) => message.id)).toEqual(["message-peer"]); expect(page.messages.map((message) => message.id)).toEqual(["message-peer"]);
}); });
it("scans past a page containing only hidden peer-run activity", async () => {
const row = (seq: number, runId: string, kind: "text" | "steps" = "text") => ({
id: `message-${seq}`,
threadId: "thread-1",
seq,
role: "bot",
blocks:
kind === "steps"
? [{ kind: "steps", steps: [{ label: String(seq), count: 1 }] }]
: [{ kind: "text", text: String(seq) }],
botId: "bot-1",
replyToMessageId: null,
runId,
createdAt: new Date("2026-08-16T00:00:00.000Z"),
});
const findMany = vi
.fn()
.mockResolvedValueOnce([
row(4, "run-peer", "steps"),
row(3, "run-peer", "steps"),
row(2, "run-peer", "steps"),
])
.mockResolvedValueOnce([row(1, "run-user")]);
const prisma = {
message: { findMany },
run: {
findMany: vi
.fn()
.mockResolvedValueOnce([{ id: "run-peer" }])
.mockResolvedValueOnce([]),
},
} as unknown as PrismaClient;
const page = await loadMessagePage(prisma, "thread-1", undefined, 2);
expect(page.messages.map((message) => message.id)).toEqual(["message-1"]);
expect(findMany).toHaveBeenCalledTimes(2);
});
it("scans past a receipt-only page so web can reach older user-visible rows", async () => { it("scans past a receipt-only page so web can reach older user-visible rows", async () => {
const receiptRows = [ const receiptRows = [
{ {
@ -383,21 +252,13 @@ describe("thread message pages", () => {
createdAt: new Date("2026-08-16T00:00:01.000Z"), createdAt: new Date("2026-08-16T00:00:01.000Z"),
}, },
]; ];
const findMany = vi.fn().mockResolvedValueOnce(receiptRows).mockResolvedValueOnce(olderRows); const queryRaw = vi.fn().mockResolvedValueOnce(receiptRows).mockResolvedValueOnce(olderRows);
const prisma = { const prisma = { $queryRaw: queryRaw } as unknown as PrismaClient;
message: { findMany },
run: {
findMany: vi
.fn()
.mockResolvedValueOnce([{ id: "run-peer" }])
.mockResolvedValueOnce([]),
},
} as unknown as PrismaClient;
const page = await loadMessagePage(prisma, "thread-1", undefined, 2); const page = await loadMessagePage(prisma, "thread-1", undefined, 2);
expect(page.messages.map((message) => message.id)).toEqual(["message-user"]); expect(page.messages.map((message) => message.id)).toEqual(["message-user"]);
expect(findMany).toHaveBeenCalledTimes(2); expect(queryRaw).toHaveBeenCalledTimes(2);
}); });
it("returns a receipt-only page when the client displays peer receipts", async () => { it("returns a receipt-only page when the client displays peer receipts", async () => {
@ -419,11 +280,8 @@ describe("thread message pages", () => {
runId: "run-peer", runId: "run-peer",
createdAt: new Date(`2026-08-16T00:00:0${seq}.000Z`), createdAt: new Date(`2026-08-16T00:00:0${seq}.000Z`),
}); });
const findMany = vi.fn(async () => [receipt(3), receipt(2), receipt(1)]); const queryRaw = vi.fn(async () => [receipt(3), receipt(2), receipt(1)]);
const prisma = { const prisma = { $queryRaw: queryRaw } as unknown as PrismaClient;
message: { findMany },
run: { findMany: vi.fn(async () => [{ id: "run-peer" }]) },
} as unknown as PrismaClient;
const page = await loadMessagePage(prisma, "thread-1", undefined, 2, undefined, false, true); const page = await loadMessagePage(prisma, "thread-1", undefined, 2, undefined, false, true);
@ -432,10 +290,10 @@ describe("thread message pages", () => {
"message-receipt-3", "message-receipt-3",
]); ]);
expect(page.olderCursor).toBe(2); expect(page.olderCursor).toBe(2);
expect(findMany).toHaveBeenCalledTimes(1); expect(queryRaw).toHaveBeenCalledTimes(1);
}); });
it("queries before the cursor and returns an ascending bounded page", async () => { it("queries the bot messages view before the cursor as an ascending bounded page", async () => {
const findMany = vi.fn(async () => const findMany = vi.fn(async () =>
[5, 4, 3].map((seq) => ({ [5, 4, 3].map((seq) => ({
id: `message-${seq}`, id: `message-${seq}`,
@ -449,7 +307,7 @@ describe("thread message pages", () => {
); );
const prisma = { message: { findMany } } as unknown as PrismaClient; const prisma = { message: { findMany } } as unknown as PrismaClient;
const page = await loadMessagePage(prisma, "thread-1", 6, 2); const page = await loadMessagePage(prisma, "thread-1", 6, 2, undefined, true);
expect(findMany).toHaveBeenCalledWith({ expect(findMany).toHaveBeenCalledWith({
where: { threadId: "thread-1", seq: { lt: 6 } }, where: { threadId: "thread-1", seq: { lt: 6 } },
@ -461,7 +319,7 @@ describe("thread message pages", () => {
}); });
it("ends pagination when the database returns no lookahead row", async () => { it("ends pagination when the database returns no lookahead row", async () => {
const findMany = vi.fn(async () => [ const queryRaw = vi.fn(async () => [
{ {
id: "message-0", id: "message-0",
threadId: "thread-1", threadId: "thread-1",
@ -472,7 +330,7 @@ describe("thread message pages", () => {
createdAt: new Date("2026-08-16T00:00:00.000Z"), createdAt: new Date("2026-08-16T00:00:00.000Z"),
}, },
]); ]);
const prisma = { message: { findMany } } as unknown as PrismaClient; const prisma = { $queryRaw: queryRaw } as unknown as PrismaClient;
const page = await loadMessagePage(prisma, "thread-1", 1, 2); const page = await loadMessagePage(prisma, "thread-1", 1, 2);

View File

@ -1,9 +1,18 @@
import type { MessageBlock, ThreadMessage, ThreadMessagePage } from "@rakazo/contracts"; import type { MessageBlock, ThreadMessage, ThreadMessagePage } from "@rakazo/contracts";
import { isPeerReceiptBlocks } from "@rakazo/core"; import { isPeerReceiptBlocks } from "@rakazo/core";
import type { Prisma, PrismaClient } from "@rakazo/db"; import type { PrismaClient } from "@rakazo/db";
import { Prisma } from "@rakazo/db";
type MessageDb = PrismaClient | Prisma.TransactionClient; type MessageDb = PrismaClient | Prisma.TransactionClient;
/** Peer-run rows that stay in the normal transcript: receipts, ask cards and the bot's own reply. */
const PEER_VISIBLE_BLOCK_KINDS: readonly string[] = [
"bot_message_sent",
"bot_message_received",
"ask",
"text",
];
export async function loadMessagePage( export async function loadMessagePage(
prisma: MessageDb, prisma: MessageDb,
threadId: string, threadId: string,
@ -47,37 +56,69 @@ export async function loadMessagePage(
} }
} }
let cursor = before; if (includePeerRuns) {
while (true) {
const rows = await prisma.message.findMany({ const rows = await prisma.message.findMany({
where: { where: {
threadId, threadId,
...(cursor === undefined ? {} : { seq: { lt: cursor } }), ...(before === undefined ? {} : { seq: { lt: before } }),
}, },
orderBy: { seq: "desc" }, orderBy: { seq: "desc" },
take: pageSize + 1, take: pageSize + 1,
}); });
const hasOlder = rows.length > pageSize; return toPage(threadId, rows, pageSize);
const pageRows = rows.slice(0, pageSize).reverse(); }
const visibleRows = includePeerRuns ? pageRows : await withoutPeerRunMessages(prisma, pageRows);
let cursor = before;
while (true) {
const rows = await visibleMessageRows(prisma, threadId, cursor, pageSize + 1);
const page = toPage(threadId, rows, pageSize);
// Web hides receipts client-side, so its receipt-only pages keep scanning. // Web hides receipts client-side, so its receipt-only pages keep scanning.
// Mobile explicitly retains them and must receive each page for pagination. // Mobile explicitly retains them and must receive each page for pagination.
const hasSubstantive = visibleRows.some( const hasSubstantive = page.messages.some((message) => !isPeerReceiptBlocks(message.blocks));
(row) => !isPeerReceiptBlocks(row.blocks as MessageBlock[]), if (hasSubstantive || includePeerReceipts || page.olderCursor === null) return page;
); cursor = page.olderCursor;
if (hasSubstantive || includePeerReceipts || !hasOlder || includePeerRuns) {
return {
threadId,
messages: visibleRows.map(toThreadMessage),
olderCursor: hasOlder ? (pageRows[0]?.seq ?? null) : null,
};
}
// TODO: only rescan when a raw page is entirely peer output. Consider a run relation if
// long peer-only histories make this path hot.
cursor = pageRows[0]?.seq;
} }
} }
type MessageRow = Parameters<typeof toThreadMessage>[0];
function toPage(threadId: string, rows: MessageRow[], pageSize: number): ThreadMessagePage {
const pageRows = rows.slice(0, pageSize).reverse();
return {
threadId,
messages: pageRows.map(toThreadMessage),
olderCursor: rows.length > pageSize ? (pageRows[0]?.seq ?? null) : null,
};
}
/**
* Newest-first transcript rows with peer-run activity excluded in the database, so a long
* peer-only stretch costs one query instead of a rescan per hidden page.
*/
function visibleMessageRows(
prisma: MessageDb,
threadId: string,
before: number | undefined,
limit: number,
): Promise<MessageRow[]> {
return prisma.$queryRaw<MessageRow[]>`
SELECT m.id, m."threadId", m.seq, m.role, m.blocks, m."botId", m."replyToMessageId",
m."replyQuote", m."runId", m."createdAt"
FROM messages m
LEFT JOIN runs r ON r.id = m."runId"
WHERE m."threadId" = ${threadId}
${before === undefined ? Prisma.empty : Prisma.sql`AND m.seq < ${before}`}
AND (r.trigger IS DISTINCT FROM 'bot_message' OR ${Prisma.join(
PEER_VISIBLE_BLOCK_KINDS.map(
(kind) => Prisma.sql`m.blocks @> ${JSON.stringify([{ kind }])}::jsonb`,
),
" OR ",
)})
ORDER BY m.seq DESC
LIMIT ${limit}
`;
}
export async function loadAllMessages( export async function loadAllMessages(
prisma: PrismaClient, prisma: PrismaClient,
threadId: string, threadId: string,
@ -106,15 +147,8 @@ async function withoutPeerRunMessages<T extends { runId: string | null; blocks:
const peerRunIds = new Set(peerRuns.map((run) => run.id)); const peerRunIds = new Set(peerRuns.map((run) => run.id));
return rows.filter((row) => { return rows.filter((row) => {
if (!row.runId || !peerRunIds.has(row.runId)) return true; if (!row.runId || !peerRunIds.has(row.runId)) return true;
// Keep peer receipts (chips), ask cards, and the bot's own text reply.
const blocks = row.blocks as MessageBlock[]; const blocks = row.blocks as MessageBlock[];
return blocks.some( return blocks.some((block) => PEER_VISIBLE_BLOCK_KINDS.includes(block.kind));
(block) =>
block.kind === "bot_message_sent" ||
block.kind === "bot_message_received" ||
block.kind === "ask" ||
block.kind === "text",
);
}); });
} }
@ -159,10 +193,8 @@ export function shouldForwardPeerThreadEvent(event: {
!!block && !!block &&
typeof block === "object" && typeof block === "object" &&
"kind" in block && "kind" in block &&
(block.kind === "bot_message_received" || typeof block.kind === "string" &&
block.kind === "bot_message_sent" || PEER_VISIBLE_BLOCK_KINDS.includes(block.kind),
block.kind === "ask" ||
block.kind === "text"),
) )
); );
} }

View File

@ -183,7 +183,7 @@ describe("threadSnapshot", () => {
}, },
]); ]);
const tx = { const tx = {
$queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]), $queryRaw: threadQueries(),
message: { findMany: vi.fn().mockResolvedValue([]) }, message: { findMany: vi.fn().mockResolvedValue([]) },
event: { event: {
findFirst: vi.fn().mockResolvedValue({ seq: 4 }), findFirst: vi.fn().mockResolvedValue({ seq: 4 }),
@ -203,7 +203,10 @@ describe("threadSnapshot", () => {
const snapshot = await threadSnapshot({ prisma }, target); const snapshot = await threadSnapshot({ prisma }, target);
expect(tx.$queryRaw).toHaveBeenCalledOnce(); expect(tx.$queryRaw).toHaveBeenCalledWith(
expect.arrayContaining([expect.stringContaining("FOR SHARE")]),
"thread-1",
);
expect(findManyEvents).toHaveBeenCalledWith( expect(findManyEvents).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
where: expect.objectContaining({ where: expect.objectContaining({
@ -245,7 +248,7 @@ describe("threadSnapshot", () => {
const findManyEvents = vi.fn(); const findManyEvents = vi.fn();
const findFirstRun = botRunFindFirst([run]); const findFirstRun = botRunFindFirst([run]);
const tx = { const tx = {
$queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]), $queryRaw: threadQueries(),
message: { findMany: vi.fn().mockResolvedValue([]) }, message: { findMany: vi.fn().mockResolvedValue([]) },
event: { event: {
findFirst: vi.fn().mockResolvedValue(null), findFirst: vi.fn().mockResolvedValue(null),
@ -330,7 +333,7 @@ describe("threadSnapshot", () => {
prisma: { prisma: {
$transaction: vi.fn(async (callback: (client: unknown) => unknown) => $transaction: vi.fn(async (callback: (client: unknown) => unknown) =>
callback({ callback({
$queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]), $queryRaw: threadQueries(),
message: { findMany: vi.fn().mockResolvedValue([]) }, message: { findMany: vi.fn().mockResolvedValue([]) },
event: { event: {
findFirst: vi.fn().mockResolvedValue(null), findFirst: vi.fn().mockResolvedValue(null),
@ -385,7 +388,7 @@ describe("threadSnapshot", () => {
}; };
const findFirstRun = botRunFindFirst([failed, completed]); const findFirstRun = botRunFindFirst([failed, completed]);
const tx = { const tx = {
$queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]), $queryRaw: threadQueries(),
message: { findMany: vi.fn().mockResolvedValue([]) }, message: { findMany: vi.fn().mockResolvedValue([]) },
event: { event: {
findFirst: vi.fn().mockResolvedValue(null), findFirst: vi.fn().mockResolvedValue(null),
@ -421,7 +424,7 @@ describe("threadSnapshot", () => {
const findManyEvents = vi.fn(); const findManyEvents = vi.fn();
const findFirstRun = botRunFindFirst([]); const findFirstRun = botRunFindFirst([]);
const tx = { const tx = {
$queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]), $queryRaw: threadQueries(),
message: { findMany: vi.fn().mockResolvedValue([]) }, message: { findMany: vi.fn().mockResolvedValue([]) },
event: { event: {
findFirst: vi.fn().mockResolvedValue(null), findFirst: vi.fn().mockResolvedValue(null),
@ -925,9 +928,16 @@ function groupRunFindMany(input: { active?: unknown[]; terminals?: unknown[] })
); );
} }
/** The snapshot's thread lock finds the thread; its message page query finds no rows. */
function threadQueries() {
return vi.fn(async (sql: TemplateStringsArray) =>
sql.join("").includes("FOR SHARE") ? [{ id: "thread-1" }] : [],
);
}
function groupPrisma(findManyRuns: ReturnType<typeof groupRunFindMany>) { function groupPrisma(findManyRuns: ReturnType<typeof groupRunFindMany>) {
const tx = { const tx = {
$queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]), $queryRaw: threadQueries(),
message: { findMany: vi.fn().mockResolvedValue([]) }, message: { findMany: vi.fn().mockResolvedValue([]) },
event: { event: {
findFirst: vi.fn().mockResolvedValue(null), findFirst: vi.fn().mockResolvedValue(null),

View File

@ -1,96 +0,0 @@
import { TRADING_BOT_PROXY_PATH } from "@rakazo/core";
import { Hono } from "hono";
import { describe, expect, it, vi } from "vitest";
import { mountTradingBotStatus } from "./trading-bot-proxy.js";
function proxyApp(options: {
baseUrl?: string;
authorize?: (request: Request) => Promise<boolean>;
fetchImpl?: typeof fetch;
}) {
const app = new Hono();
mountTradingBotStatus(app, {
baseUrl: options.baseUrl,
authorize: options.authorize ?? (async () => true),
fetchImpl: options.fetchImpl,
});
return app;
}
describe("trading bot status proxy", () => {
it("stays idle when TRADING_BOT_URL is unset and does not call upstream", async () => {
const fetchImpl = vi.fn();
const response = await proxyApp({ fetchImpl }).request(TRADING_BOT_PROXY_PATH);
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ configured: false });
expect(fetchImpl).not.toHaveBeenCalled();
});
it("requires a session before it proxies a configured origin", async () => {
const fetchImpl = vi.fn();
const response = await proxyApp({
baseUrl: "http://bot.test:8000",
authorize: async () => false,
fetchImpl,
}).request(TRADING_BOT_PROXY_PATH, { headers: { cookie: "session=secret" } });
expect(response.status).toBe(401);
expect(fetchImpl).not.toHaveBeenCalled();
});
it("returns the mapped status and does not forward browser credentials", async () => {
const fetchImpl = vi.fn(
async () =>
new Response(
JSON.stringify({ mode: "LIVE", adapter_count: 2, last_error: "adapter timeout" }),
{ status: 200, headers: { "content-type": "application/json" } },
),
);
const response = await proxyApp({
baseUrl: "http://bot.test:8000",
fetchImpl,
}).request(TRADING_BOT_PROXY_PATH, {
headers: { cookie: "session=secret", authorization: "Bearer browser-token" },
});
expect(response.status).toBe(200);
expect(await response.json()).toEqual({
configured: true,
reachable: true,
status: {
mode: "live",
kind: "live",
adapterCount: 2,
lastError: "adapter timeout",
},
});
expect(fetchImpl).toHaveBeenCalledTimes(1);
const [url, init] = fetchImpl.mock.calls[0] as [string, RequestInit];
expect(url).toBe("http://bot.test:8000/bot/api/status");
const headers = new Headers(init.headers);
expect(headers.get("cookie")).toBeNull();
expect(headers.get("authorization")).toBeNull();
expect(init.redirect).toBe("error");
});
it("hides an unmapped body and an upstream failure instead of claiming live", async () => {
const unmapped = vi.fn(
async () =>
new Response(JSON.stringify({ status: "ok" }), {
status: 200,
headers: { "content-type": "application/json" },
}),
);
const unknown = await proxyApp({
baseUrl: "http://bot.test:8000",
fetchImpl: unmapped,
}).request(TRADING_BOT_PROXY_PATH);
expect(await unknown.json()).toEqual({ configured: true, reachable: true, status: null });
const down = vi.fn(async () => new Response("nope", { status: 500 }));
const offline = await proxyApp({
baseUrl: "http://bot.test:8000",
fetchImpl: down,
}).request(TRADING_BOT_PROXY_PATH);
expect(offline.status).toBe(502);
expect(await offline.json()).toEqual({ configured: true, reachable: false });
});
});

View File

@ -1,79 +0,0 @@
import {
mapTradingBotStatus,
readBoundedJsonResponse,
TRADING_BOT_PROXY_PATH,
TRADING_BOT_STATUS_MAX_BYTES,
TRADING_BOT_STATUS_TIMEOUT_MS,
tradingBotStatusUrl,
} from "@rakazo/core";
import type { Hono } from "hono";
export function mountTradingBotStatus(
app: Hono,
options: {
/** Normalized http(s) origin. Unset leaves the route idle for SaaS builds. */
baseUrl: string | undefined;
authorize: (request: Request) => Promise<boolean>;
fetchImpl?: typeof fetch;
},
) {
const fetchImpl = options.fetchImpl ?? fetch;
app.get(TRADING_BOT_PROXY_PATH, async (c) => {
c.header("cache-control", "no-store");
if (!options.baseUrl) return c.json({ configured: false });
const allowed = await options.authorize(c.req.raw).catch(() => false);
if (!allowed) return c.json({ error: "Unauthorized" }, 401);
const timeout = linkAbort(c.req.raw.signal, TRADING_BOT_STATUS_TIMEOUT_MS);
try {
const response = await fetchImpl(tradingBotStatusUrl(options.baseUrl), {
method: "GET",
redirect: "error",
cache: "no-store",
signal: timeout.signal,
headers: { accept: "application/json" },
});
if (!response.ok) {
cancelBody(response);
return c.json({ configured: true, reachable: false }, 502);
}
const body = await readBoundedJsonResponse<unknown>(
response,
TRADING_BOT_STATUS_MAX_BYTES,
timeout.signal,
);
const status = mapTradingBotStatus(body);
return c.json({ configured: true, reachable: true, status: status ?? null });
} catch {
return c.json({ configured: true, reachable: false }, 502);
} finally {
timeout.cancel();
}
});
}
function linkAbort(
parent: AbortSignal,
timeoutMs: number,
): { signal: AbortSignal; cancel: () => void } {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
const onParent = () => controller.abort();
if (parent.aborted) controller.abort();
else parent.addEventListener("abort", onParent);
return {
signal: controller.signal,
cancel: () => {
clearTimeout(timer);
parent.removeEventListener("abort", onParent);
},
};
}
function cancelBody(response: Response): void {
try {
void Promise.resolve(response.body?.cancel()).catch(() => undefined);
} catch {
// Best-effort. The caller still reports the bot as unreachable.
}
}

View File

@ -0,0 +1,155 @@
import http from "node:http";
import path from "node:path";
import { _electron as electron, expect, test } from "@playwright/test";
import type { RakazoDesktop, RakazoDesktopBrowserPreviewState } from "@rakazo/contracts";
const fixture = `<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>Rakazo desktop browser</title></head>
<body><main>App with a browser panel</main></body>
</html>`;
type Bridge = NonNullable<RakazoDesktop["browserPreview"]>;
test("the in-app browser is a native view the app window lays over its panel", async () => {
const pages = http.createServer((req, res) => {
res.setHeader("content-type", "text/html");
if (req.url === "/second") {
res.end("<!doctype html><title>Second page</title><p>second</p>");
return;
}
res.end(`<!doctype html><title>First page</title>
<a id="next" href="/second">next</a>
<a id="popup" href="/second" target="_blank">popup</a>
<a id="file" href="file:///etc/hosts">file</a>`);
});
await new Promise<void>((resolve) => pages.listen(0, "127.0.0.1", resolve));
const address = pages.address();
if (!address || typeof address === "string") throw new Error("No fixture server");
const origin = `http://127.0.0.1:${address.port}`;
const app = await electron.launch({
args: ["."],
cwd: path.resolve(import.meta.dirname, ".."),
env: {
...process.env,
RAKAZO_WEB_URL: `data:text/html;charset=utf-8,${encodeURIComponent(fixture)}`,
},
});
try {
const page = await app.firstWindow();
await expect(page.getByText("App with a browser panel")).toBeVisible();
const call = <K extends keyof Bridge>(method: K, ...args: unknown[]) =>
page.evaluate(
([name, values]) => {
const bridge = (window as typeof window & { rakazoDesktop?: RakazoDesktop }).rakazoDesktop
?.browserPreview as unknown as Record<
string,
(...input: unknown[]) => Promise<unknown>
>;
return bridge[name as string]!(...(values as unknown[]));
},
[method, args] as const,
);
const state = () => call("state") as Promise<RakazoDesktopBrowserPreviewState>;
const view = () =>
app.evaluate(({ BrowserWindow }) => {
const win = BrowserWindow.getAllWindows()[0]!;
const child = win.contentView.children[0] as
| (Electron.View & { webContents?: Electron.WebContents })
| undefined;
return child
? {
count: win.contentView.children.length,
visible: child.getVisible(),
bounds: child.getBounds(),
url: child.webContents?.getURL(),
partition: child.webContents?.session === win.webContents.session,
sandbox: child.webContents?.getLastWebPreferences()?.sandbox,
nodeIntegration: child.webContents?.getLastWebPreferences()?.nodeIntegration,
}
: null;
});
// Pages load in a native view in their own session, never the app's.
await call("navigate", `${origin}/`);
await expect.poll(async () => (await state()).title).toBe("First page");
expect(await view()).toMatchObject({
count: 1,
visible: false,
url: `${origin}/`,
partition: false,
sandbox: true,
nodeIntegration: false,
});
await call("show", { x: 400, y: 50, width: 300, height: 200 });
expect(await view()).toMatchObject({
visible: true,
bounds: { x: 400, y: 50, width: 300, height: 200 },
});
// Renderer bounds outside the window are clamped to it.
await call("show", { x: -10, y: -10, width: 100_000, height: 100_000 });
const [width, height] = await app.evaluate(({ BrowserWindow }) =>
BrowserWindow.getAllWindows()[0]!.getContentSize(),
);
expect((await view())?.bounds).toEqual({ x: 0, y: 0, width, height });
const contents = () =>
app.evaluate(({ BrowserWindow }, script) => {
const win = BrowserWindow.getAllWindows()[0]!;
const child = win.contentView.children[0] as Electron.View & {
webContents: Electron.WebContents;
};
return child.webContents.executeJavaScript(script, true);
}, "document.getElementById('next').click()");
await contents();
await expect.poll(async () => (await state()).title).toBe("Second page");
expect(await state()).toMatchObject({ canGoBack: true, canGoForward: false, error: null });
await call("back");
await expect.poll(async () => (await state()).title).toBe("First page");
expect(await state()).toMatchObject({ canGoForward: true });
await call("forward");
await expect.poll(async () => (await state()).url).toBe(`${origin}/second`);
// New-window links stay in the one view; file links never load.
await call("back");
await expect.poll(async () => (await state()).title).toBe("First page");
await app.evaluate(({ BrowserWindow }) => {
const child = BrowserWindow.getAllWindows()[0]!.contentView.children[0] as Electron.View & {
webContents: Electron.WebContents;
};
return child.webContents.executeJavaScript("document.getElementById('popup').click()", true);
});
await expect.poll(async () => (await state()).url).toBe(`${origin}/second`);
expect(await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows().length)).toBe(1);
await call("back");
await expect.poll(async () => (await state()).title).toBe("First page");
await app.evaluate(({ BrowserWindow }) => {
const child = BrowserWindow.getAllWindows()[0]!.contentView.children[0] as Electron.View & {
webContents: Electron.WebContents;
};
return child.webContents.executeJavaScript("document.getElementById('file').click()", true);
});
await page.waitForTimeout(300);
expect((await state()).url).toBe(`${origin}/`);
await expect(call("navigate", "file:///etc/hosts")).rejects.toThrow(/http/);
// A dev server that is not running yet shows as an error, then recovers on reload.
pages.close();
await new Promise((resolve) => setTimeout(resolve, 100));
await call("navigate", `${origin}/gone`);
await expect.poll(async () => (await state()).error).toMatch(/ERR_CONNECTION_REFUSED/);
await call("hide");
expect((await view())?.visible).toBe(false);
} finally {
await app.close();
pages.closeAllConnections();
pages.close();
}
});

View File

@ -42,6 +42,7 @@ test("launches with a narrow preload bridge and an isolated renderer", async ()
}); });
expect(renderer.bridgeKeys).toEqual([ expect(renderer.bridgeKeys).toEqual([
"browserPreview",
"files", "files",
"localSettings", "localSettings",
"oauth", "oauth",

View File

@ -0,0 +1,96 @@
import { describe, expect, it } from "vitest";
import {
previewBounds,
previewLoadError,
previewShortcut,
previewUrl,
} from "./browser-preview-policy.js";
describe("previewUrl", () => {
it("accepts only http(s) without credentials", () => {
expect(previewUrl("http://localhost:5173")).toBe("http://localhost:5173/");
expect(previewUrl("https://example.com/a?b=1")).toBe("https://example.com/a?b=1");
for (const value of [
"file:///etc/hosts",
"javascript:alert(1)",
"chrome://settings",
"https://user:fake-password@example.com",
"not a url",
42,
`https://example.com/${"a".repeat(9000)}`,
]) {
expect(previewUrl(value)).toBeNull();
}
});
});
describe("previewBounds", () => {
const content = { width: 1200, height: 800 };
it("rounds and applies the app zoom factor", () => {
expect(previewBounds({ x: 800.4, y: 40.6, width: 399.5, height: 700 }, content)).toEqual({
x: 800,
y: 41,
width: 400,
height: 700,
});
expect(previewBounds({ x: 100, y: 10, width: 200, height: 100 }, content, 1.25)).toEqual({
x: 125,
y: 13,
width: 250,
height: 125,
});
});
it("clamps to the window and rejects empty or malformed rectangles", () => {
expect(previewBounds({ x: -50, y: -10, width: 5000, height: 5000 }, content)).toEqual({
x: 0,
y: 0,
width: 1200,
height: 800,
});
expect(previewBounds({ x: 1300, y: 0, width: 100, height: 100 }, content)).toBeNull();
expect(previewBounds({ x: 0, y: 0, width: 0, height: 100 }, content)).toBeNull();
expect(previewBounds({ x: "1", y: 0, width: 1, height: 1 }, content)).toBeNull();
expect(previewBounds({ x: Number.NaN, y: 0, width: 1, height: 1 }, content)).toBeNull();
expect(previewBounds(null, content)).toBeNull();
});
});
describe("previewShortcut", () => {
const key = (overrides: Partial<Parameters<typeof previewShortcut>[0]>) => ({
type: "keyDown",
key: "r",
control: false,
meta: false,
alt: false,
shift: false,
...overrides,
});
it("maps platform browser shortcuts", () => {
expect(previewShortcut(key({ meta: true }), "darwin")).toBe("reload");
expect(previewShortcut(key({ meta: true, shift: true, key: "R" }), "darwin")).toBe(
"hard-reload",
);
expect(previewShortcut(key({ control: true }), "linux")).toBe("reload");
expect(previewShortcut(key({ meta: true, key: "[" }), "darwin")).toBe("back");
expect(previewShortcut(key({ alt: true, key: "ArrowRight" }), "win32")).toBe("forward");
expect(previewShortcut(key({ key: "F12" }), "linux")).toBe("devtools");
expect(previewShortcut(key({ key: "F5" }), "win32")).toBe("reload");
});
it("leaves typing alone", () => {
expect(previewShortcut(key({}), "darwin")).toBeNull();
expect(previewShortcut(key({ control: true }), "darwin")).toBeNull();
expect(previewShortcut(key({ type: "keyUp", meta: true }), "darwin")).toBeNull();
expect(previewShortcut(key({ key: "ArrowLeft" }), "linux")).toBeNull();
});
});
describe("previewLoadError", () => {
it("ignores superseded navigations", () => {
expect(previewLoadError(-3, "ERR_ABORTED")).toBeNull();
expect(previewLoadError(-102, "ERR_CONNECTION_REFUSED")).toBe("ERR_CONNECTION_REFUSED");
});
});

View File

@ -0,0 +1,76 @@
import type { RakazoDesktopBrowserBounds } from "@rakazo/contracts";
/** Only plain http(s) pages load in the in-app browser; no files, schemes or URL credentials. */
export function previewUrl(value: unknown): string | null {
if (typeof value !== "string" || value.length > 8192) return null;
try {
const url = new URL(value);
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
if (url.username || url.password) return null;
return url.toString();
} catch {
return null;
}
}
/**
* Renderer-reported CSS-pixel bounds, converted to window DIPs and clamped to the content area
* so a compromised renderer cannot place the view outside the panel it reserved.
*/
export function previewBounds(
value: unknown,
content: { width: number; height: number },
zoomFactor = 1,
): RakazoDesktopBrowserBounds | null {
if (!value || typeof value !== "object") return null;
const raw = value as Record<string, unknown>;
const numbers = [raw.x, raw.y, raw.width, raw.height];
if (!numbers.every((n) => typeof n === "number" && Number.isFinite(n))) return null;
const scale = Number.isFinite(zoomFactor) && zoomFactor > 0 ? zoomFactor : 1;
const [x, y, width, height] = (numbers as number[]).map((n) => Math.round(n * scale)) as [
number,
number,
number,
number,
];
const left = Math.min(Math.max(0, x), content.width);
const top = Math.min(Math.max(0, y), content.height);
const right = Math.min(Math.max(left, x + width), content.width);
const bottom = Math.min(Math.max(top, y + height), content.height);
if (right - left < 1 || bottom - top < 1) return null;
return { x: left, y: top, width: right - left, height: bottom - top };
}
export type PreviewShortcut = "reload" | "hard-reload" | "back" | "forward" | "devtools";
/** Browser keyboard shortcuts while focus is inside the page. */
export function previewShortcut(
input: {
type: string;
key: string;
control: boolean;
meta: boolean;
alt: boolean;
shift: boolean;
},
platform: string,
): PreviewShortcut | null {
if (input.type !== "keyDown") return null;
const mod = platform === "darwin" ? input.meta : input.control;
const key = input.key.length === 1 ? input.key.toLowerCase() : input.key;
if (key === "F5") return input.shift ? "hard-reload" : "reload";
if (key === "F12") return "devtools";
if (mod && key === "r") return input.shift ? "hard-reload" : "reload";
if (mod && input.alt && key === "i") return "devtools";
if (platform === "darwin" ? input.meta && !input.alt : input.alt && !input.control) {
if (key === (platform === "darwin" ? "[" : "ArrowLeft")) return "back";
if (key === (platform === "darwin" ? "]" : "ArrowRight")) return "forward";
}
return null;
}
/** Load failures worth showing; an aborted load (-3) is a superseded navigation, not an error. */
export function previewLoadError(errorCode: number, description: string): string | null {
if (errorCode === -3 || errorCode === 0) return null;
return description || `Load failed (${errorCode})`;
}

View File

@ -0,0 +1,189 @@
import type { RakazoDesktopBrowserPreviewState } from "@rakazo/contracts";
import { type BrowserWindow, WebContentsView } from "electron";
import {
previewBounds,
previewLoadError,
previewShortcut,
previewUrl,
} from "./browser-preview-policy.js";
/** Pages share one persistent profile, separate from the app's own session and cookies. */
const PREVIEW_PARTITION = "persist:rakazo-browser";
export const PREVIEW_STATE_CHANNEL = "desktop.browserPreview.state";
/**
* The in-app browser's native page: one WebContentsView laid over the panel the app window
* reserves. It belongs to one app window at a time and is torn down with it.
*/
export class BrowserPreviewController {
private host: BrowserWindow | null = null;
private view: WebContentsView | null = null;
private error: string | null = null;
state(win: BrowserWindow): RakazoDesktopBrowserPreviewState {
const contents = this.host === win ? this.view?.webContents : undefined;
if (!contents || contents.isDestroyed()) {
return {
url: "",
title: "",
loading: false,
canGoBack: false,
canGoForward: false,
error: null,
};
}
const url = contents.getURL();
return {
url,
title: contents.getTitle() === url ? "" : contents.getTitle(),
loading: contents.isLoading(),
canGoBack: contents.navigationHistory.canGoBack(),
canGoForward: contents.navigationHistory.canGoForward(),
error: this.error,
};
}
show(win: BrowserWindow, rawBounds: unknown) {
const view = this.viewFor(win);
const [width, height] = win.getContentSize();
const bounds = previewBounds(
rawBounds,
{ width: width ?? 0, height: height ?? 0 },
win.webContents.getZoomFactor(),
);
if (!bounds) {
view.setVisible(false);
return;
}
view.setBounds(bounds);
view.setVisible(true);
}
hide(win: BrowserWindow) {
if (this.host === win) this.view?.setVisible(false);
}
async navigate(win: BrowserWindow, rawUrl: unknown) {
const url = previewUrl(rawUrl);
if (!url) throw new Error("Only http(s) pages can open here.");
this.error = null;
// Failures surface through did-fail-load as page state, not as a rejected call.
await this.viewFor(win)
.webContents.loadURL(url)
.catch(() => undefined);
}
back(win: BrowserWindow) {
const history = this.contentsFor(win)?.navigationHistory;
if (history?.canGoBack()) history.goBack();
}
forward(win: BrowserWindow) {
const history = this.contentsFor(win)?.navigationHistory;
if (history?.canGoForward()) history.goForward();
}
reload(win: BrowserWindow, ignoreCache = false) {
const contents = this.contentsFor(win);
if (!contents?.getURL()) return;
this.error = null;
if (ignoreCache) contents.reloadIgnoringCache();
else contents.reload();
}
stop(win: BrowserWindow) {
this.contentsFor(win)?.stop();
}
openDevTools(win: BrowserWindow) {
const contents = this.contentsFor(win);
if (contents?.getURL()) contents.openDevTools({ mode: "detach" });
}
private contentsFor(win: BrowserWindow) {
const contents = this.host === win ? this.view?.webContents : undefined;
return contents && !contents.isDestroyed() ? contents : undefined;
}
private viewFor(win: BrowserWindow): WebContentsView {
if (this.host === win && this.view && !this.view.webContents.isDestroyed()) return this.view;
this.destroy();
const view = new WebContentsView({
webPreferences: {
partition: PREVIEW_PARTITION,
sandbox: true,
contextIsolation: true,
nodeIntegration: false,
webSecurity: true,
spellcheck: false,
},
});
view.setVisible(false);
const contents = view.webContents;
const publish = () => {
if (!win.isDestroyed()) win.webContents.send(PREVIEW_STATE_CHANNEL, this.state(win));
};
// One tab: links that want a new window open here instead.
contents.setWindowOpenHandler(({ url }) => {
const target = previewUrl(url);
if (target) void contents.loadURL(target).catch(() => undefined);
return { action: "deny" };
});
contents.on("will-navigate", (event, url) => {
if (!previewUrl(url)) event.preventDefault();
});
contents.on("will-redirect", (event, url) => {
if (!previewUrl(url)) event.preventDefault();
});
contents.on("did-start-loading", publish);
contents.on("did-stop-loading", publish);
contents.on("did-navigate", () => {
this.error = null;
publish();
});
contents.on("did-navigate-in-page", publish);
contents.on("page-title-updated", publish);
contents.on("did-fail-load", (_event, code, description, _url, isMainFrame) => {
if (!isMainFrame) return;
const error = previewLoadError(code, description);
if (error === null) return;
this.error = error;
publish();
});
contents.on("render-process-gone", (_event, details) => {
this.error = details.reason === "clean-exit" ? null : "The page stopped responding.";
publish();
});
contents.on("before-input-event", (event, input) => {
const shortcut = previewShortcut(input, process.platform);
if (!shortcut) return;
event.preventDefault();
if (shortcut === "reload" || shortcut === "hard-reload") {
this.reload(win, shortcut === "hard-reload");
} else if (shortcut === "back") {
this.back(win);
} else if (shortcut === "forward") {
this.forward(win);
} else {
this.openDevTools(win);
}
});
win.contentView.addChildView(view);
win.once("closed", () => {
if (this.host === win) this.destroy();
});
this.host = win;
this.view = view;
return view;
}
private destroy() {
const { host, view } = this;
this.host = null;
this.view = null;
this.error = null;
if (!view) return;
if (host && !host.isDestroyed()) host.contentView.removeChildView(view);
if (!view.webContents.isDestroyed()) view.webContents.close();
}
}

View File

@ -21,6 +21,7 @@ import {
LAUNCH_CHECK_DELAY_MS, LAUNCH_CHECK_DELAY_MS,
} from "./auto-update.js"; } from "./auto-update.js";
import { openBrowserAuth } from "./browser-auth.js"; import { openBrowserAuth } from "./browser-auth.js";
import { BrowserPreviewController } from "./browser-preview.js";
import { DOCKER_INSTALL_LINKS, isDesktopSetupLink, runDocker } from "./docker-cli.js"; import { DOCKER_INSTALL_LINKS, isDesktopSetupLink, runDocker } from "./docker-cli.js";
import { requestLocalSettings } from "./local-settings.js"; import { requestLocalSettings } from "./local-settings.js";
import { import {
@ -1130,6 +1131,35 @@ app.whenReady().then(async () => {
}, },
); );
ipcMain.handle("desktop.platform", () => process.platform); ipcMain.handle("desktop.platform", () => process.platform);
const browserPreview = new BrowserPreviewController();
// The in-app browser answers only the app window's own top frame, never a page inside it.
const previewHost = (event: Electron.IpcMainInvokeEvent) => {
if (!fromMainWindow(event) || event.senderFrame !== event.sender.mainFrame) {
throw new Error("The in-app browser is not available here.");
}
return mainWindow!;
};
ipcMain.handle("desktop.browserPreview.state", (event) =>
browserPreview.state(previewHost(event)),
);
ipcMain.handle("desktop.browserPreview.show", (event, bounds: unknown) =>
browserPreview.show(previewHost(event), bounds),
);
ipcMain.handle("desktop.browserPreview.hide", (event) => browserPreview.hide(previewHost(event)));
ipcMain.handle("desktop.browserPreview.navigate", (event, url: unknown) =>
browserPreview.navigate(previewHost(event), url),
);
ipcMain.handle("desktop.browserPreview.back", (event) => browserPreview.back(previewHost(event)));
ipcMain.handle("desktop.browserPreview.forward", (event) =>
browserPreview.forward(previewHost(event)),
);
ipcMain.handle("desktop.browserPreview.reload", (event) =>
browserPreview.reload(previewHost(event)),
);
ipcMain.handle("desktop.browserPreview.stop", (event) => browserPreview.stop(previewHost(event)));
ipcMain.handle("desktop.browserPreview.openDevTools", (event) =>
browserPreview.openDevTools(previewHost(event)),
);
ipcMain.handle("desktop.files.pickDirectory", async (event) => { ipcMain.handle("desktop.files.pickDirectory", async (event) => {
if (!fromMainWindow(event)) return null; if (!fromMainWindow(event)) return null;
const result = await dialog.showOpenDialog(windowFrom(event)!, { const result = await dialog.showOpenDialog(windowFrom(event)!, {

View File

@ -21,6 +21,22 @@ contextBridge.exposeInMainWorld("rakazoDesktop", {
download: () => ipcRenderer.invoke("desktop.update.download"), download: () => ipcRenderer.invoke("desktop.update.download"),
install: () => ipcRenderer.invoke("desktop.update.install"), install: () => ipcRenderer.invoke("desktop.update.install"),
}, },
browserPreview: {
state: () => ipcRenderer.invoke("desktop.browserPreview.state"),
show: (bounds) => ipcRenderer.invoke("desktop.browserPreview.show", bounds),
hide: () => ipcRenderer.invoke("desktop.browserPreview.hide"),
navigate: (url) => ipcRenderer.invoke("desktop.browserPreview.navigate", url),
back: () => ipcRenderer.invoke("desktop.browserPreview.back"),
forward: () => ipcRenderer.invoke("desktop.browserPreview.forward"),
reload: () => ipcRenderer.invoke("desktop.browserPreview.reload"),
stop: () => ipcRenderer.invoke("desktop.browserPreview.stop"),
openDevTools: () => ipcRenderer.invoke("desktop.browserPreview.openDevTools"),
onState: (listener) => {
const handler = (_event, state) => listener(state);
ipcRenderer.on("desktop.browserPreview.state", handler);
return () => ipcRenderer.off("desktop.browserPreview.state", handler);
},
},
oauth: { oauth: {
open: (url) => ipcRenderer.invoke("desktop.oauth.open", url), open: (url) => ipcRenderer.invoke("desktop.oauth.open", url),
cancel: (url) => ipcRenderer.invoke("desktop.oauth.cancel", url), cancel: (url) => ipcRenderer.invoke("desktop.oauth.cancel", url),

View File

@ -32,6 +32,7 @@ describe("desktop preload bridge", () => {
expect(globalName).toBe("rakazoDesktop"); expect(globalName).toBe("rakazoDesktop");
expect(bridge.platform).toBe("linux"); expect(bridge.platform).toBe("linux");
expect(Object.keys(bridge).sort()).toEqual([ expect(Object.keys(bridge).sort()).toEqual([
"browserPreview",
"files", "files",
"localSettings", "localSettings",
"oauth", "oauth",
@ -46,6 +47,18 @@ describe("desktop preload bridge", () => {
"toggleMaximize", "toggleMaximize",
]); ]);
expect(Object.keys(bridge.update).sort()).toEqual(["check", "download", "install", "state"]); expect(Object.keys(bridge.update).sort()).toEqual(["check", "download", "install", "state"]);
expect(Object.keys(bridge.browserPreview ?? {}).sort()).toEqual([
"back",
"forward",
"hide",
"navigate",
"onState",
"openDevTools",
"reload",
"show",
"state",
"stop",
]);
await bridge.files?.pickDirectory(); await bridge.files?.pickDirectory();
await bridge.oauth.open?.("https://provider.example.com/authorize"); await bridge.oauth.open?.("https://provider.example.com/authorize");
@ -58,6 +71,8 @@ describe("desktop preload bridge", () => {
await bridge.update.check(); await bridge.update.check();
await bridge.update.download(); await bridge.update.download();
await bridge.update.install(); await bridge.update.install();
await bridge.browserPreview?.navigate("http://localhost:5173/");
await bridge.browserPreview?.show({ x: 1, y: 2, width: 3, height: 4 });
expect(invoke.mock.calls.map(([channel]) => channel)).toEqual([ expect(invoke.mock.calls.map(([channel]) => channel)).toEqual([
"desktop.files.pickDirectory", "desktop.files.pickDirectory",
"desktop.oauth.open", "desktop.oauth.open",
@ -70,6 +85,8 @@ describe("desktop preload bridge", () => {
"desktop.update.check", "desktop.update.check",
"desktop.update.download", "desktop.update.download",
"desktop.update.install", "desktop.update.install",
"desktop.browserPreview.navigate",
"desktop.browserPreview.show",
]); ]);
}); });
@ -77,6 +94,7 @@ describe("desktop preload bridge", () => {
const { exposeInMainWorld } = runPreload("preload.cjs"); const { exposeInMainWorld } = runPreload("preload.cjs");
const [, bridge] = exposeInMainWorld.mock.calls[0] as [string, Record<string, unknown>]; const [, bridge] = exposeInMainWorld.mock.calls[0] as [string, Record<string, unknown>];
expect(Object.keys(bridge).sort()).toEqual([ expect(Object.keys(bridge).sort()).toEqual([
"browserPreview",
"files", "files",
"localSettings", "localSettings",
"oauth", "oauth",

View File

@ -28,7 +28,11 @@ export default function NewSpace() {
return; return;
} }
router.dismissAll(); router.dismissAll();
router.replace("/"); // A new project opens in the conversation with its own main agent.
const main = space.groups.find((group) => group.kind === "main");
router.replace(
main ? { pathname: "/group-thread", params: { groupId: main.id, name: main.name } } : "/",
);
} catch (reason) { } catch (reason) {
setError(reason instanceof Error ? reason.message : t("Could not create space")); setError(reason instanceof Error ? reason.message : t("Could not create space"));
setPending(false); setPending(false);

View File

@ -17,10 +17,12 @@ import {
buildComposerMentionOptions, buildComposerMentionOptions,
type ComposerMention, type ComposerMention,
cloudAgentHttpsUrl, cloudAgentHttpsUrl,
deriveWorkspaceFocus,
isApprovalAskBlock, isApprovalAskBlock,
isRunTerminalEvent, isRunTerminalEvent,
isSecretAskBlock, isSecretAskBlock,
latestAnswerableAskMessageId, latestAnswerableAskMessageId,
liveScreenRunId,
mentionChipKey, mentionChipKey,
projectMessageReactions, projectMessageReactions,
resolveComposerSendPlan, resolveComposerSendPlan,
@ -288,6 +290,8 @@ function Thread() {
? { botId } ? { botId }
: undefined; : undefined;
const [snap, setSnap] = useState<MobileSnapshot | null>(null); const [snap, setSnap] = useState<MobileSnapshot | null>(null);
/** 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 activeThreadId = useRef<string | undefined>(undefined);
const [draft, setDraft] = useState(""); const [draft, setDraft] = useState("");
const [mentionQuery, setMentionQuery] = useState<string | null>(null); const [mentionQuery, setMentionQuery] = useState<string | null>(null);
@ -860,6 +864,7 @@ function Thread() {
} }
expandedHistoryThread.current = null; expandedHistoryThread.current = null;
historyEpoch.current += 1; historyEpoch.current += 1;
setScreenRunId(null);
const abort = new AbortController(); const abort = new AbortController();
void (async () => { void (async () => {
// Pending search jumps load the around-page separately; avoid replacing it with latest. // Pending search jumps load the around-page separately; avoid replacing it with latest.
@ -885,6 +890,8 @@ function Thread() {
(event) => { (event) => {
cursor = Math.max(cursor, event.seq ?? -1); cursor = Math.max(cursor, event.seq ?? -1);
retryMs = 250; retryMs = 250;
const screenRun = groupId ? null : liveScreenRunId(event);
if (screenRun) setScreenRunId(screenRun);
if ( if (
event.type === "thread.progress" || event.type === "thread.progress" ||
event.type === "agent.tool.called" || event.type === "agent.tool.called" ||
@ -1271,6 +1278,23 @@ function Thread() {
} }
const answerableAskMessageId = latestAnswerableAskMessageId(snap); const answerableAskMessageId = latestAnswerableAskMessageId(snap);
const screenFocus = useMemo(() => {
const computer = snap?.computer;
if (!snap || !computer || groupId) return null;
const focus = deriveWorkspaceFocus(
{
...snap,
computer: {
kind: computer.kind ?? "",
state: computer.state,
screenAvailable: computer.screenAvailable,
takeoverRequested: computer.takeoverRequested ?? false,
},
},
{ screenRunId },
);
return focus.pane === "screen" ? focus : null;
}, [groupId, screenRunId, snap]);
const runError = snap?.run?.status === "failed" ? (snap.run.error ?? null) : null; const runError = snap?.run?.status === "failed" ? (snap.run.error ?? null) : null;
const liveMessages = useMemo(() => [...visibleMessages].reverse(), [visibleMessages]); const liveMessages = useMemo(() => [...visibleMessages].reverse(), [visibleMessages]);
const messagesById = useMemo( const messagesById = useMemo(
@ -1668,6 +1692,43 @@ function Thread() {
) : null} ) : null}
</View> </View>
<View style={{ paddingBottom: keyboardVisible ? 12 : Math.max(insets.bottom + 12, 24) }}> <View style={{ paddingBottom: keyboardVisible ? 12 : Math.max(insets.bottom + 12, 24) }}>
{screenFocus && botId ? (
<Pressable
accessibilityRole="button"
testID="live-screen-pill"
onPress={() =>
router.push({
pathname: "/computer",
params: { botId, name: displayName ?? t("Bot") },
})
}
style={({ pressed }) => ({
alignSelf: "center",
marginTop: 10,
flexDirection: "row",
alignItems: "center",
gap: 8,
borderRadius: 999,
borderWidth: 1,
borderColor: native.fillPressed,
backgroundColor: pressed ? native.fillPressed : native.fill,
paddingHorizontal: 14,
paddingVertical: 8,
})}
>
<View
style={{
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: screenFocus.takeover ? tokens.warning : tokens.success,
}}
/>
<Text style={{ color: tokens.foreground, fontSize: 14, fontWeight: "500" }}>
{screenFocus.takeover ? t("Needs takeover") : t("Open computer")}
</Text>
</Pressable>
) : null}
{replyTarget ? ( {replyTarget ? (
<View <View
style={{ style={{

View File

@ -728,7 +728,7 @@ export type MobileBot = Pick<
| "modelId" | "modelId"
| "thinkingLevel" | "thinkingLevel"
> & > &
Partial<Pick<Bot, "parentBotId" | "spaceId">>; Partial<Pick<Bot, "parentBotId" | "spaceId" | "kind">>;
export type MobileBotSection = BotSection; export type MobileBotSection = BotSection;
@ -790,9 +790,11 @@ export type MobileSnapshot = {
activeRuns?: Array<{ id: string; botId?: string; status: string }>; activeRuns?: Array<{ id: string; botId?: string; status: string }>;
members?: MobileGroup["members"]; members?: MobileGroup["members"];
computer?: { computer?: {
kind?: string;
state: string; state: string;
controlHolder: string; controlHolder: string;
screenAvailable: boolean; screenAvailable: boolean;
takeoverRequested?: boolean;
mode: ComputerMode; mode: ComputerMode;
busyBotName: string | null; busyBotName: string | null;
}; };
@ -896,6 +898,7 @@ type ThreadEvent = {
type: string; type: string;
seq?: number; seq?: number;
runId?: string; runId?: string;
createdAt?: string;
payload?: Record<string, unknown>; payload?: Record<string, unknown>;
}; };
@ -1074,7 +1077,7 @@ export function applyMobileThreadEvent(
messages: [...remaining, streaming], messages: [...remaining, streaming],
}; };
} }
if (event.type === "agent.tool.completed") { if (event.type === "agent.tool.completed" || event.type === "thread.computer") {
return { ...prev, cursor: event.seq ?? prev.cursor }; return { ...prev, cursor: event.seq ?? prev.cursor };
} }
if (event.type === "thread.subagent") { if (event.type === "thread.subagent") {

View File

@ -83,7 +83,7 @@ describe("display and warnings", () => {
expect(apiBaseWarning("http://127.0.0.1:3100")).toBeNull(); expect(apiBaseWarning("http://127.0.0.1:3100")).toBeNull();
expect(apiBaseWarning("http://192.168.1.20:3100")).toBeNull(); expect(apiBaseWarning("http://192.168.1.20:3100")).toBeNull();
expect(apiBaseWarning("http://100.64.0.1:3100")).toBeNull(); expect(apiBaseWarning("http://100.64.0.1:3100")).toBeNull();
expect(apiBaseWarning("http://100.119.57.55:3100")).toBeNull(); expect(apiBaseWarning("http://100.100.100.100:3100")).toBeNull();
expect(apiBaseWarning("http://100.127.255.255:3100")).toBeNull(); expect(apiBaseWarning("http://100.127.255.255:3100")).toBeNull();
expect(apiBaseWarning("http://app.example.com")).toMatch(/https/i); expect(apiBaseWarning("http://app.example.com")).toMatch(/https/i);
}); });

View File

@ -111,6 +111,29 @@ describe("spaceInboxItems", () => {
expect(items.map((item) => item.type)).toEqual(["bot", "group"]); expect(items.map((item) => item.type)).toEqual(["bot", "group"]);
}); });
it("lists a project's main conversation instead of its main bot", () => {
const main = {
id: "group-main",
name: "Chief",
kind: "main" as const,
preview: "",
pinned: false,
sectionId: null,
archivedAt: null,
unread: false,
updatedAt: "2026-01-01T00:00:00.000Z",
members: [],
};
const lead: MobileBot = { ...bot, id: "bot-lead", name: "Chief", kind: "main" };
const items = spaceInboxItems([space({ bots: [lead, bot], groups: [main] })]);
expect(items.map((item) => (item.type === "heading" ? item.title : item.type))).toEqual([
"Work",
"bot",
"group",
]);
expect(items.some((item) => item.type === "bot" && item.bot.id === lead.id)).toBe(false);
});
it("keeps empty spaces beside a populated space", () => { it("keeps empty spaces beside a populated space", () => {
const personal = space({ id: "personal", isDefault: true, bots: [bot] }); const personal = space({ id: "personal", isDefault: true, bots: [bot] });
expect(spaceInboxItems([personal, space()]).map((item) => item.type)).toEqual([ expect(spaceInboxItems([personal, space()]).map((item) => item.type)).toEqual([

View File

@ -32,7 +32,10 @@ export function canDeleteInboxSpace(
export function spaceInboxItems(spaces: InboxSpace[]): InboxSpaceItem[] { export function spaceInboxItems(spaces: InboxSpace[]): InboxSpaceItem[] {
return spaces.flatMap((space): InboxSpaceItem[] => { return spaces.flatMap((space): InboxSpaceItem[] => {
const chats = [ const chats = [
...space.bots.map((chat) => ({ type: "bot" as const, bot: chat, ...chat })), // 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.map((chat) => ({ type: "group" as const, group: chat, ...chat })),
]; ];
const items: InboxSpaceItem[] = []; const items: InboxSpaceItem[] = [];

View File

@ -0,0 +1,104 @@
import { expect, test } from "@playwright/test";
import { activeBotId, captureScreenshot, completeOnboarding, rpc, signup } from "./helpers";
const SCREEN_URL = "https://screen.example/embed.html";
test("the bot's screen opens beside the chat while it works and steps back after", async ({
page,
}, testInfo) => {
await signup(page, `workspace-screen-${Date.now()}@rakazo.test`, "password12", "Screen Pane");
await completeOnboarding(page);
const botId = activeBotId(page);
await rpc(page, "computer/boot", { botId });
await page.route("https://screen.example/**", (route) =>
route.fulfill({
contentType: "text/html",
body: `<!doctype html><title>Desktop</title><body style="margin:0;background:#1d1d20;display:grid;place-items:center;height:100vh">
<input style="width:40%;padding:8px;font:16px system-ui" placeholder="Search">`,
}),
);
await page.route("**/rpc/computer/screenUrl", (route) =>
route.fulfill({
contentType: "application/json",
body: JSON.stringify({ json: { url: SCREEN_URL } }),
}),
);
const sidePanel = page.getByTestId("side-panel");
await expect(sidePanel).toHaveAttribute("data-panel", "closed");
await rpc(page, "threads/send", { botId, text: "use your screen and type workspace-demo" });
await expect(sidePanel).toHaveAttribute("data-panel", "computer");
await expect(sidePanel).toHaveAttribute("data-auto", "");
await expect(page.getByTestId("computer-preview").locator("iframe")).toHaveAttribute(
"src",
SCREEN_URL,
);
// The live screen gets more room than a settings panel.
expect((await sidePanel.boundingBox())!.width).toBeGreaterThan(400);
await captureScreenshot(page, testInfo, "workspace-screen-beside-chat");
// Once the work is done the conversation gets its full width back.
await expect(sidePanel).toHaveAttribute("data-panel", "closed", { timeout: 20_000 });
});
test("a report the bot presents opens beside the conversation", async ({ page }, testInfo) => {
await signup(page, `workspace-report-${Date.now()}@rakazo.test`, "password12", "Report Pane");
await completeOnboarding(page);
const composer = page.getByPlaceholder(/Message/);
await composer.fill("present the report");
await page.keyboard.press("Enter");
const pane = page.getByTestId("artifact-pane");
await expect(pane).toBeVisible({ timeout: 30_000 });
await expect(pane.getByRole("heading", { name: "report.md" })).toBeVisible();
await expect(pane.getByRole("heading", { name: "Report", exact: true })).toBeVisible();
await expect(pane.getByRole("cell", { name: "Research" })).toBeVisible();
await expect(composer).toBeVisible();
await captureScreenshot(page, testInfo, "workspace-report-beside-chat");
const downloadPromise = page.waitForEvent("download");
await pane.getByRole("button", { name: "Download report.md" }).click();
expect((await downloadPromise).suggestedFilename()).toBe("report.md");
await pane.getByRole("button", { name: "Close panel" }).click();
await expect(pane).toHaveCount(0);
// The file card in the chat opens the same pane again.
await page.getByRole("button", { name: "Preview report.md" }).click();
await expect(pane.getByRole("heading", { name: "Report", exact: true })).toBeVisible();
});
test("a question that scrolled away is one click back", async ({ page }, testInfo) => {
await page.setViewportSize({ width: 1280, height: 520 });
await signup(page, `workspace-ask-${Date.now()}@rakazo.test`, "password12", "Ask Jump");
await completeOnboarding(page);
const composer = page.getByPlaceholder(/Message/);
// Earlier work gives the thread enough history to scroll back through.
for (const task of ["sort the inbox", "draft the agenda", "book the room"]) {
await composer.fill(task);
await page.keyboard.press("Enter");
await expect(page.getByText(`done. i handled: ${task}`, { exact: true })).toBeVisible({
timeout: 30_000,
});
}
await composer.fill("ask me which city to use");
await page.keyboard.press("Enter");
const sendIt = page.getByRole("button", { name: "Send it" });
await expect(sendIt).toBeVisible({ timeout: 30_000 });
const jump = page.getByTestId("needs-you-jump");
await expect(jump).toHaveCount(0);
await page.getByTestId("transcript").hover();
await page.mouse.wheel(0, -20_000);
await expect(jump).toBeVisible();
await captureScreenshot(page, testInfo, "workspace-needs-you-jump");
await jump.click();
await expect(sendIt).toBeInViewport();
await expect(sendIt).toBeFocused();
await expect(jump).toHaveCount(0);
});

View File

@ -0,0 +1,53 @@
import { expect, test } from "@playwright/test";
import { activeBotId, captureScreenshot, completeOnboarding, rpc, signup } from "./helpers";
const SCREEN_URL = "https://screen.example/embed.html";
test("the bot's cursor glides to where it clicks on its live screen", async ({
page,
}, testInfo) => {
await signup(page, `agent-cursor-${Date.now()}@rakazo.test`, "password12", "Cursor Watcher");
await completeOnboarding(page);
const botId = activeBotId(page);
await rpc(page, "computer/boot", { botId });
// Stand in for the noVNC viewer: a 16:10 desktop with a field in the middle.
await page.route("https://screen.example/**", (route) =>
route.fulfill({
contentType: "text/html",
body: `<!doctype html><title>Desktop</title><body style="margin:0;background:#1d1d20;display:grid;place-items:center;height:100vh">
<input style="width:40%;padding:8px;font:16px system-ui" placeholder="Search">`,
}),
);
await page.route("**/rpc/computer/screenUrl", (route) =>
route.fulfill({
contentType: "application/json",
body: JSON.stringify({ json: { url: SCREEN_URL } }),
}),
);
await page.getByTitle("Agent computer").click();
const preview = page.getByTestId("computer-preview");
await expect(preview.locator("iframe")).toHaveAttribute("src", SCREEN_URL);
const cursor = preview.getByTestId("agent-cursor");
await expect(cursor).toHaveCSS("opacity", "0");
// 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/);
const layer = (await preview.getByTestId("agent-cursor-layer").boundingBox())!;
await expect
.poll(async () => {
const tip = (await cursor.boundingBox())!;
return [
Math.round(tip.x - layer.x - layer.width / 2),
Math.round(tip.y - layer.y - layer.height / 2),
];
})
.toEqual([0, 0]);
await captureScreenshot(page, testInfo, "agent-cursor-on-screen");
// It fades once the bot stops pointing, so it never lingers over the screen.
await expect(cursor).toHaveCSS("opacity", "0", { timeout: 10_000 });
});

View File

@ -27,6 +27,8 @@ test("agent-attached files appear as downloadable cards", async ({ page }, testI
test("agent-attached Markdown opens a rendered preview and can be downloaded", async ({ test("agent-attached Markdown opens a rendered preview and can be downloaded", async ({
page, page,
}, testInfo) => { }, testInfo) => {
// Narrow windows preview in a dialog; wide ones open the file beside the chat.
await page.setViewportSize({ width: 900, height: 800 });
const stamp = Date.now(); const stamp = Date.now();
await signup(page, `markdown-preview-${stamp}@rakazo.test`, "password12", "Markdown Preview"); await signup(page, `markdown-preview-${stamp}@rakazo.test`, "password12", "Markdown Preview");
await completeOnboarding(page); await completeOnboarding(page);

View File

@ -179,3 +179,66 @@ test("bot creation, editing, and deletion persist", async ({ page }, testInfo) =
await expect(page.getByRole("combobox", { name: "Message" })).toBeVisible(); await expect(page.getByRole("combobox", { name: "Message" })).toBeVisible();
await captureScreenshot(page, testInfo, "31-deleted-bot-fallback"); await captureScreenshot(page, testInfo, "31-deleted-bot-fallback");
}); });
test("a bot export restores as a new bot", async ({ page }, testInfo) => {
const stamp = Date.now();
await signup(page, `bot-import-${stamp}@rakazo.test`, "password12", "Bot Import");
await completeOnboarding(page);
await page.goto("/app");
await page.waitForURL(/\/app\/[^/]+$/);
await openNewBot(page);
const form = page.getByTestId("create-bot-form");
await expect(form.getByRole("button", { name: "Import bot" })).toBeVisible();
for (const colorScheme of ["light", "dark"] as const) {
await page.emulateMedia({ colorScheme });
await expect(page.locator("html")).toHaveAttribute("data-theme", colorScheme);
await captureScreenshot(page, testInfo, `27d-create-bot-import-${colorScheme}`);
}
const fileInput = form.getByTestId("import-bot-file");
await fileInput.setInputFiles({
name: "broken.json",
mimeType: "application/json",
buffer: Buffer.from("{"),
});
await expect(page.getByTestId("create-bot-error")).toHaveText("Could not import this file");
await fileInput.setInputFiles({
name: "archivist-export.json",
mimeType: "application/json",
buffer: Buffer.from(
JSON.stringify({
version: 1,
exportedAt: new Date().toISOString(),
bot: {
name: "Archivist",
title: "Keeps the records",
description: "Files and finds documents.",
instructions: "Name every file by date.",
},
memory: [{ path: "MEMORY.md", content: "# Archive\n\nPrefers ISO dates." }],
routines: [
{
name: "Weekly sweep",
prompt: "Tidy the archive",
crons: ["0 8 * * 1"],
timezone: "UTC",
},
],
files: [{ path: "notes/index.md", content: "imported" }],
history: [],
}),
),
});
await expect(page.getByTestId("side-panel")).toHaveAttribute("data-panel", "closed");
const botList = page.locator("aside").first();
await expect(botList.getByRole("button", { name: /^Archivist/ })).toBeVisible();
await page.locator("main").getByRole("button", { name: "Archivist", exact: true }).click();
await expect(page.locator("label:has-text('Title') input")).toHaveValue("Keeps the records");
for (const colorScheme of ["light", "dark"] as const) {
await page.emulateMedia({ colorScheme });
await expect(page.locator("html")).toHaveAttribute("data-theme", colorScheme);
await captureScreenshot(page, testInfo, `27e-imported-bot-${colorScheme}`);
}
});

View File

@ -4,6 +4,10 @@ import { captureScreenshot } from "./helpers";
const fixture = "/e2e/fixtures/chart-links.html"; const fixture = "/e2e/fixtures/chart-links.html";
test("chart links cannot execute script when clicked or expanded", async ({ page }, testInfo) => { test("chart links 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 });
for (const inspect of ["0", "1"]) { for (const inspect of ["0", "1"]) {
for (const href of [ for (const href of [
'javascript:void(document.body.dataset.plotExecuted="yes")', 'javascript:void(document.body.dataset.plotExecuted="yes")',

View File

@ -224,7 +224,6 @@ test("shell surfaces stay readable in light and dark themes", async ({ page }, t
const sidePanel = page.getByTestId("side-panel"); const sidePanel = page.getByTestId("side-panel");
await expect(sidebar.locator("[data-main-conversation]")).toBeVisible(); await expect(sidebar.locator("[data-main-conversation]")).toBeVisible();
await expect(sidebar.getByRole("button", { name: "Researcher, 1:1 conversation" })).toBeVisible(); await expect(sidebar.getByRole("button", { name: "Researcher, 1:1 conversation" })).toBeVisible();
await expect(page.getByTestId("trading-mode-status")).toBeVisible();
await inEachTheme(page, async (theme) => { await inEachTheme(page, async (theme) => {
await captureScreenshot(page, testInfo, `shell-contrast-${theme}`); await captureScreenshot(page, testInfo, `shell-contrast-${theme}`);
@ -254,7 +253,7 @@ test("shell surfaces stay readable in light and dark themes", async ({ page }, t
await expectReadable(sidePanel, `${theme}: the bot settings form`, { inside: true }); await expectReadable(sidePanel, `${theme}: the bot settings form`, { inside: true });
}); });
await sidebar.getByRole("button", { name: "Heute", exact: true }).click(); await sidebar.getByRole("button", { name: "Today", exact: true }).click();
const heute = page.getByTestId("heute-brief"); const heute = page.getByTestId("heute-brief");
await expect(heute.getByTestId("heute-mission-control-link")).toBeVisible(); await expect(heute.getByTestId("heute-mission-control-link")).toBeVisible();
await inEachTheme(page, async (theme) => { await inEachTheme(page, async (theme) => {

View File

@ -1,98 +0,0 @@
import { expect, type Locator, test } from "@playwright/test";
import { completeOnboarding, rpc, signup, unterhaltungenOnly } from "./helpers";
async function selectProjectSpace(projekte: Locator, name: string) {
const row = projekte.locator('[data-sidebar-group^="project-space:"]').filter({ hasText: name });
await row.getByRole("button", { name: `Select ${name}` }).click();
}
async function projectSidebarStart(projekte: Locator, name: string) {
const row = projekte.locator('[data-sidebar-group^="project-space:"]').filter({ hasText: name });
const groupAttr = await row.getAttribute("data-sidebar-group");
const spaceId = groupAttr?.match(/^project-space:([^:]+)/)?.[1];
if (!spaceId) throw new Error(`missing project space id for ${name}`);
return projekte.getByTestId(`project-start-${spaceId}`);
}
test("empty project shows Starten instead of a fallback bot", async ({ page }) => {
const stamp = Date.now();
await signup(page, `empty-project-${stamp}@rakazo.test`, "password12", "Empty Project");
await completeOnboarding(page);
await page.goto("/app");
await page.waitForURL(/\/app\/[^/]+$/);
const projekte = page.locator(".sidebar-projekte");
await projekte.getByTestId("create-project-trigger").click();
const dialog = page.getByRole("dialog", { name: "New project" });
await dialog.getByLabel("Name").fill("Launch");
await dialog.getByRole("button", { name: "Create project", exact: true }).click();
await page.waitForURL(/\/app(?:\/)?$/);
await expect(projekte.getByRole("button", { name: /^Chief/ })).toHaveCount(0);
await expect(projekte.getByRole("button", { name: "Starten", exact: true })).toBeVisible();
await expect(page.getByTestId("project-start-main")).toBeVisible();
await page.getByTestId("project-start-main").click();
await expect(page.getByTestId("side-panel")).toHaveAttribute("data-panel", "create");
});
test("Starten in an onboarded project opens the group create flow", async ({ page }) => {
const stamp = Date.now();
await signup(page, `project-start-${stamp}@rakazo.test`, "password12", "Project Start");
await completeOnboarding(page);
const projekte = page.locator(".sidebar-projekte");
await projekte.getByTestId("create-project-trigger").click();
const dialog = page.getByRole("dialog", { name: "New project" });
await dialog.getByLabel("Name").fill("Website");
await dialog.getByRole("button", { name: "Create project", exact: true }).click();
await page.waitForURL(/\/app(?:\/)?$/);
await selectProjectSpace(projekte, "Website");
await page.waitForURL(/\/app(?:\/)?$/);
await expect(page.getByTestId("shell-root")).toHaveAttribute("data-ready", "true", {
timeout: 60_000,
});
await selectProjectSpace(projekte, "Website");
await page.waitForURL(/\/app(?:\/)?$/);
await expect(unterhaltungenOnly(page).getByRole("button", { name: /^Chief/ })).toHaveCount(1);
await expect(projekte.getByRole("button", { name: /^Chief/ })).toHaveCount(0);
const sidebarStart = await projectSidebarStart(projekte, "Website");
await expect(sidebarStart).toBeVisible({ timeout: 30_000 });
await sidebarStart.click();
await expect(page.getByTestId("side-panel")).toHaveAttribute("data-panel", "create-group", {
timeout: 30_000,
});
await expect(page.getByText("New group", { exact: true })).toBeVisible();
await expect(
page.getByTestId("side-panel").getByRole("button", { name: /^Chief/ }),
).toBeVisible();
});
test("archiving the only bot keeps Starten visible in the project sidebar", async ({ page }) => {
const stamp = Date.now();
await signup(page, `project-archive-${stamp}@rakazo.test`, "password12", "Project Archive");
await completeOnboarding(page);
const projekte = page.locator(".sidebar-projekte");
await projekte.getByTestId("create-project-trigger").click();
const dialog = page.getByRole("dialog", { name: "New project" });
await dialog.getByLabel("Name").fill("Side");
await dialog.getByRole("button", { name: "Create project", exact: true }).click();
await page.waitForURL(/\/app(?:\/)?$/);
const sideBot = await rpc<{ id: string }>(page, "bots/create", {
name: "Side bot",
title: "",
description: "",
notifyOnFinish: true,
computerMode: "team",
});
await rpc(page, "bots/archive", { botId: sideBot.id });
await page.goto("/app");
await page.waitForURL(/\/app(?:\/)?$/);
await expect(projekte.getByRole("button", { name: "Starten", exact: true })).toBeVisible();
await expect(page.getByTestId("project-start-main")).toBeVisible();
});

View File

@ -69,9 +69,10 @@ test("takeover, routine, plugins, and export are reachable", async ({ page }, te
}) })
.toBe("waiting_takeover"); .toBe("waiting_takeover");
await captureScreenshot(page, testInfo, "08-protected-input-request"); await captureScreenshot(page, testInfo, "08-protected-input-request");
await page.getByTitle("Agent computer").click(); // A takeover request brings the bot's screen beside the chat on its own.
const sidePanel = page.getByTestId("side-panel"); const sidePanel = page.getByTestId("side-panel");
await expect(sidePanel).toHaveCSS("width", "384px"); await expect(sidePanel).toHaveAttribute("data-panel", "computer");
await expect(sidePanel).toHaveAttribute("data-auto", "");
const [mainBox, panelBox] = await Promise.all([ const [mainBox, panelBox] = await Promise.all([
page.locator("main").boundingBox(), page.locator("main").boundingBox(),
sidePanel.boundingBox(), sidePanel.boundingBox(),

View File

@ -303,9 +303,11 @@ test("create group from + and see two bots in one transcript", async ({ page },
const groupMarkdown = page.getByRole("button", { name: "Preview group-preview.md" }); const groupMarkdown = page.getByRole("button", { name: "Preview group-preview.md" });
await expect(groupMarkdown).toBeVisible({ timeout: 30_000 }); await expect(groupMarkdown).toBeVisible({ timeout: 30_000 });
await groupMarkdown.click(); await groupMarkdown.click();
const markdownDialog = page.getByRole("dialog", { name: "group-preview.md" }); // Wide windows open the file beside the group conversation.
await expect(markdownDialog.getByRole("heading", { name: "Group artifact" })).toBeVisible(); const markdownPane = page.getByTestId("artifact-pane");
await markdownDialog.getByRole("button", { name: "Close preview" }).click(); await expect(markdownPane.getByRole("heading", { name: "Group artifact" })).toBeVisible();
await markdownPane.getByRole("button", { name: "Close panel" }).click();
await expect(markdownPane).toHaveCount(0);
await page.setViewportSize({ width: 390, height: 844 }); await page.setViewportSize({ width: 390, height: 844 });
await expect(page.getByRole("button", { name: "Open navigation" })).toBeVisible(); await expect(page.getByRole("button", { name: "Open navigation" })).toBeVisible();

View File

@ -6,7 +6,7 @@ test("Heute shows native brief instead of Mission Control iframe", async ({ page
await signup(page, `heute-${stamp}@rakazo.test`, "password12", "Heute"); await signup(page, `heute-${stamp}@rakazo.test`, "password12", "Heute");
await completeOnboarding(page); await completeOnboarding(page);
await page.getByRole("button", { name: "Heute", exact: true }).click(); await page.getByRole("button", { name: "Today", exact: true }).click();
await expect(page).toHaveURL(/\/app\/[^/]+$/); await expect(page).toHaveURL(/\/app\/[^/]+$/);
const brief = page.getByTestId("heute-brief"); const brief = page.getByTestId("heute-brief");

View File

@ -0,0 +1,159 @@
import { expect, test } from "@playwright/test";
import { captureScreenshot, completeOnboarding, signup } from "./helpers";
test("the in-app browser previews a page and follows it back to the browser", async ({
page,
}, testInfo) => {
await signup(page, `in-app-browser-${Date.now()}@rakazo.test`, "password12", "Previewer");
await completeOnboarding(page);
// Context-wide so the "Open in browser" tab is served too.
await page.context().route("https://preview.example/**", (route) =>
route.fulfill({
contentType: "text/html",
body: `<!doctype html><title>Preview</title><body style="font:16px system-ui;padding:24px">
<h1>Live preview</h1><p>Rendered inside the app.</p>`,
}),
);
await page.getByRole("button", { name: "Browser" }).click();
const browser = page.getByTestId("in-app-browser");
const address = browser.getByRole("textbox", { name: "Address" });
await expect(address).toBeFocused();
await address.fill("not an address");
await address.press("Enter");
await expect(address).toHaveAttribute("aria-invalid", "true");
await address.fill("preview.example/app");
await address.press("Enter");
await expect(address).toHaveValue("https://preview.example/app");
const frame = browser.getByTestId("in-app-browser-frame");
await expect(frame).toHaveAttribute("src", "https://preview.example/app");
await expect(
page.frameLocator('[data-testid="in-app-browser-frame"]').getByRole("heading"),
).toHaveText("Live preview");
await captureScreenshot(page, testInfo, "in-app-browser");
const popup = page.waitForEvent("popup");
await browser.getByRole("button", { name: "Open in browser" }).click();
expect((await popup).url()).toBe("https://preview.example/app");
await browser.getByRole("button", { name: "Close panel" }).click();
await expect(browser).toHaveCount(0);
// The last address comes back when the browser reopens.
await page.getByRole("button", { name: "Browser" }).click();
await expect(page.getByTestId("in-app-browser-frame")).toHaveAttribute(
"src",
"https://preview.example/app",
);
});
test("on desktop the page is a native view glued to the panel", async ({ page }) => {
await page.addInitScript(() => {
const calls: Array<[string, unknown?]> = [];
let listener: ((state: unknown) => void) | undefined;
let state = {
url: "",
title: "",
loading: false,
canGoBack: false,
canGoForward: false,
error: null as string | null,
};
const record = (name: string) => async (value?: unknown) => {
calls.push([name, value]);
};
Object.assign(window, { __browserCalls: calls });
Object.defineProperty(window, "rakazoDesktop", {
value: {
platform: "linux",
window: {
close: async () => {},
minimize: async () => {},
toggleMaximize: async () => {},
state: async () => ({ minimized: false, maximized: false, fullScreen: false }),
},
oauth: { onCallback: () => () => {} },
update: {
state: async () => ({ phase: "unsupported", currentVersion: "0.0.0" }),
check: async () => ({ phase: "unsupported", currentVersion: "0.0.0" }),
download: async () => ({ phase: "unsupported", currentVersion: "0.0.0" }),
install: async () => ({ phase: "unsupported", currentVersion: "0.0.0" }),
},
browserPreview: {
state: async () => state,
onState: (next: (value: unknown) => void) => {
listener = next;
return () => {
listener = undefined;
};
},
navigate: async (url: string) => {
calls.push(["navigate", url]);
state = { ...state, url, title: "Dev server", loading: false, canGoBack: true };
listener?.(state);
},
show: record("show"),
hide: record("hide"),
back: record("back"),
forward: record("forward"),
reload: record("reload"),
stop: record("stop"),
openDevTools: record("openDevTools"),
},
},
});
});
await signup(page, `native-browser-${Date.now()}@rakazo.test`, "password12", "Desktop");
await completeOnboarding(page);
const calls = () =>
page.evaluate(
() => (window as unknown as { __browserCalls: Array<[string, unknown?]> }).__browserCalls,
);
await page.getByRole("button", { name: "Browser" }).click();
const browser = page.getByTestId("in-app-browser");
await browser.getByRole("textbox", { name: "Address" }).fill("localhost:5173");
await browser.getByRole("textbox", { name: "Address" }).press("Enter");
await expect.poll(calls).toContainEqual(["navigate", "http://localhost:5173/"]);
await expect(browser.getByRole("button", { name: "Back" })).toBeEnabled();
await expect(browser.getByRole("button", { name: "Forward" })).toBeDisabled();
await expect(browser.getByTestId("in-app-browser-frame")).toHaveCount(0);
// The reserved page area and the native view's bounds stay identical.
const viewport = browser.getByTestId("in-app-browser-viewport");
await expect
.poll(async () => {
const box = (await viewport.boundingBox())!;
const shown = (await calls()).filter(([name]) => name === "show").at(-1)?.[1];
return (
JSON.stringify(shown) ===
JSON.stringify({
x: Math.round(box.x),
y: Math.round(box.y),
width: Math.round(box.width),
height: Math.round(box.height),
})
);
})
.toBe(true);
await browser.getByRole("button", { name: "Back" }).click();
await browser.getByRole("button", { name: "Reload" }).click();
await browser.getByRole("button", { name: "Developer tools" }).click();
await expect
.poll(async () => (await calls()).map(([name]) => name))
.toEqual(expect.arrayContaining(["back", "reload", "openDevTools"]));
// App menus float above the page, so the native view steps aside while one is open.
const before = (await calls()).length;
await page.getByTestId("create-menu-trigger").click();
await expect
.poll(async () => (await calls()).slice(before).map(([name]) => name))
.toContain("hide");
await page.keyboard.press("Escape");
await expect.poll(async () => (await calls()).at(-1)?.[0]).toBe("show");
await browser.getByRole("button", { name: "Close panel" }).click();
await expect.poll(async () => (await calls()).at(-1)?.[0]).toBe("hide");
});

View File

@ -75,7 +75,11 @@ test("local settings open and save integrations without an app session", async (
}); });
test("ordinary browsers cannot use local settings", async ({ page }) => { test("ordinary browsers cannot use local settings", async ({ page }) => {
await page.goto("/desktop-settings"); await expect(async () => {
await expect(page.getByRole("alert")).toContainText("Could not open local settings"); await page.goto("/desktop-settings", { waitUntil: "domcontentloaded" });
await expect(page.getByRole("alert")).toContainText("Could not open local settings", {
timeout: 5_000,
});
}).toPass({ timeout: 45_000 });
await expect(page.getByRole("button", { name: "Models", exact: true })).toBeHidden(); await expect(page.getByRole("button", { name: "Models", exact: true })).toBeHidden();
}); });

View File

@ -19,7 +19,10 @@ test("onboarding lands in the main conversation, its lead answers, and addressed
const homeSpaceId = await page.evaluate(() => window.localStorage.getItem("rakazo:space-id")); const homeSpaceId = await page.evaluate(() => window.localStorage.getItem("rakazo:space-id"));
const chief = await rpc<{ id: string }>(page, "bots/ensureMain", {}); const chief = await rpc<{ id: string }>(page, "bots/ensureMain", {});
const sidebar = page.locator("aside").first(); const sidebar = page.locator("aside").first();
const mainEntry = sidebar.getByRole("button", { name: "Chief, main conversation" }); // Projects list their own lead's main conversation too; this is the pinned home one.
const mainEntry = sidebar
.getByRole("button", { name: "Chief, main conversation" })
.and(sidebar.locator("[data-main-conversation]"));
await expect(mainEntry).toBeVisible(); await expect(mainEntry).toBeVisible();
// The main bot is reached through the main conversation, not listed again. // The main bot is reached through the main conversation, not listed again.
await expect(sidebar.locator(`[data-roster-bot-id="${chief.id}"]`)).toHaveCount(0); await expect(sidebar.locator(`[data-roster-bot-id="${chief.id}"]`)).toHaveCount(0);
@ -78,7 +81,8 @@ test("onboarding lands in the main conversation, its lead answers, and addressed
const dialog = page.getByRole("dialog", { name: "New project" }); const dialog = page.getByRole("dialog", { name: "New project" });
await dialog.getByLabel("Name").fill("Launch"); await dialog.getByLabel("Name").fill("Launch");
await dialog.getByRole("button", { name: "Create project", exact: true }).click(); await dialog.getByRole("button", { name: "Create project", exact: true }).click();
await page.waitForURL(/\/app(?:\/)?$/); // The project opens its own main conversation.
await page.waitForURL((url) => /\/app\/g\/[^/]+$/.test(url.pathname) && url.href !== mainUrl);
await expect await expect
.poll(() => page.evaluate(() => window.localStorage.getItem("rakazo:space-id"))) .poll(() => page.evaluate(() => window.localStorage.getItem("rakazo:space-id")))
.not.toBe(homeSpaceId); .not.toBe(homeSpaceId);

View File

@ -43,6 +43,9 @@ test("create opens form, then empty chat; picker lists bots; sidebar collapses",
await expect(page.getByRole("combobox", { name: "Message" })).toBeVisible(); await expect(page.getByRole("combobox", { name: "Message" })).toBeVisible();
await expect(page.getByTestId("side-panel")).toHaveAttribute("data-panel", "closed"); await expect(page.getByTestId("side-panel")).toHaveAttribute("data-panel", "closed");
await expect(page.getByText("What do you want me on first?", { exact: true })).toHaveCount(0); await expect(page.getByText("What do you want me on first?", { exact: true })).toHaveCount(0);
await expect(
page.getByTestId("transcript").getByText("Start the conversation — New Bot is on stage above."),
).toBeVisible();
await captureScreenshot(page, testInfo, "create-chat-sidepanel-closed"); await captureScreenshot(page, testInfo, "create-chat-sidepanel-closed");
await page.getByTestId("toggle-bots-sidebar").click(); await page.getByTestId("toggle-bots-sidebar").click();

View File

@ -50,6 +50,16 @@ test("live page helper preserves identity, masks passwords, and reports partial
expect(snapshot.title).toBe("Fixture"); expect(snapshot.title).toBe("Fixture");
expect(snapshot.tree).toContain("Readable article content"); expect(snapshot.tree).toContain("Readable article content");
expect(JSON.stringify(snapshot)).not.toContain("fake-test-password"); expect(JSON.stringify(snapshot)).not.toContain("fake-test-password");
// Element boxes are screen pixels for the visible bot cursor.
expect(snapshot.screen.width).toBeGreaterThan(0);
expect(snapshot.screen.height).toBeGreaterThan(0);
const saveBox = snapshot.elements.find((element: { name: string }) => element.name === "Save")
.box as number[];
const saveRect = (await page.locator("#save").boundingBox())!;
expect(saveBox[2]).toBe(Math.round(saveRect.width));
expect(saveBox[3]).toBe(Math.round(saveRect.height));
expect(saveBox[0]).toBeGreaterThanOrEqual(Math.round(saveRect.x));
expect(saveBox[1]).toBeGreaterThanOrEqual(Math.round(saveRect.y));
const ref = (snapshot: { elements: { name: string; ref: string }[] }, name: string) => const ref = (snapshot: { elements: { name: string; ref: string }[] }, name: string) =>
snapshot.elements.find((element) => element.name === name)!.ref; snapshot.elements.find((element) => element.name === name)!.ref;
const result = await command("act", { const result = await command("act", {

View File

@ -0,0 +1,87 @@
import { expect, type Locator, test } from "@playwright/test";
import { captureScreenshot, completeOnboarding, rpc, signup, waitForChatComposer } from "./helpers";
function projectSpace(projekte: Locator, name: string) {
return projekte.locator('[data-sidebar-group^="project-space:"]').filter({ hasText: name });
}
test("a new project opens straight into the conversation with its own main agent", async ({
page,
}, testInfo) => {
const stamp = Date.now();
await signup(page, `project-main-${stamp}@rakazo.test`, "password12", "Project Main");
await completeOnboarding(page);
const homeSpaceId = await page.evaluate(() => window.localStorage.getItem("rakazo:space-id"));
const homeMain = await rpc<{ id: string }>(page, "bots/ensureMain", {});
const projekte = page.locator(".sidebar-projekte");
await projekte.getByTestId("create-project-trigger").click();
const dialog = page.getByRole("dialog", { name: "New project" });
await dialog.getByLabel("Name").fill("Launch");
await dialog.getByRole("button", { name: "Create project", exact: true }).click();
await page.waitForURL(/\/app\/g\/[^/]+$/, { timeout: 30_000 });
await expect
.poll(() => page.evaluate(() => window.localStorage.getItem("rakazo:space-id")))
.not.toBe(homeSpaceId);
await waitForChatComposer(page);
// The project is one conversation, led by a main agent of its own.
const projectEntry = projectSpace(projekte, "Launch").getByRole("button", {
name: "Chief, main conversation",
});
await expect(projectEntry).toBeVisible({ timeout: 30_000 });
await expect(projekte.getByRole("button", { name: "Start", exact: true })).toHaveCount(0);
const groupId = new URL(page.url()).pathname.split("/").at(-1)!;
const snapshot = await rpc<{ leadBotId?: string }>(page, "threads/get", { groupId });
expect(snapshot.leadBotId).toBeTruthy();
expect(snapshot.leadBotId).not.toBe(homeMain.id);
const composer = page.getByRole("combobox", { name: "Message" });
await composer.fill("What should we do first?");
await composer.press("Enter");
const transcript = page.getByTestId("transcript");
await expect(transcript).toContainText(/on it|handled/i, { timeout: 60_000 });
await captureScreenshot(page, testInfo, "project-main-conversation");
});
test("a project's main agent hands work to the project's specialist", async ({
page,
}, testInfo) => {
const stamp = Date.now();
await signup(page, `project-delegate-${stamp}@rakazo.test`, "password12", "Project Delegate");
await completeOnboarding(page);
const projekte = page.locator(".sidebar-projekte");
await projekte.getByTestId("create-project-trigger").click();
const dialog = page.getByRole("dialog", { name: "New project" });
await dialog.getByLabel("Name").fill("Website");
await dialog.getByRole("button", { name: "Create project", exact: true }).click();
await page.waitForURL(/\/app\/g\/[^/]+$/, { timeout: 30_000 });
await waitForChatComposer(page);
const groupId = new URL(page.url()).pathname.split("/").at(-1)!;
const designer = await rpc<{ id: string }>(page, "bots/create", {
name: "Designer",
title: "",
description: "",
instructions: "",
notifyOnFinish: true,
});
const composer = page.getByRole("combobox", { name: "Message" });
await composer.fill("delegate to the bot named Designer: sketch a landing page");
await composer.press("Enter");
const transcript = page.getByTestId("transcript");
const card = transcript.getByTestId("delegated-task-card");
await expect(card).toBeVisible({ timeout: 60_000 });
await expect(card).toContainText("Designer");
await expect(card).toHaveAttribute("data-status", "done", { timeout: 60_000 });
await expect(
transcript.getByText("closing the task: sketch a landing page.").first(),
).toBeVisible({ timeout: 60_000 });
const snapshot = await rpc<{ members?: Array<{ botId: string }> }>(page, "threads/get", {
groupId,
});
expect(snapshot.members?.map((member) => member.botId)).toContain(designer.id);
await captureScreenshot(page, testInfo, "project-main-agent-delegates");
});

View File

@ -21,30 +21,21 @@ test("sidebar has expected navigation items and no integrations", async ({ page
// Actual sidebar structure (verified by snapshot): // Actual sidebar structure (verified by snapshot):
// - button "Activity" (top action) // - button "Activity" (top action)
// - textbox "Search" (search input) // - textbox "Search" (search input)
// - button "Heute" (today) // - button "Today"
// - text "Sprich mit deinen Oo's" (conversations section header) // - text "Talk to your Oos" (conversations section header)
// - status "Not configured" (trading bot URL unset)
// - NO "Integration(en)" button or text // - NO "Integration(en)" button or text
// Search: it's a textbox, not a button // Search: it's a textbox, not a button
const searchInput = sidebar.locator('input[placeholder="Search"], textbox[name="Search"]'); const searchInput = sidebar.locator('input[placeholder="Search"], textbox[name="Search"]');
await expect(searchInput).toBeVisible({ timeout: 5000 }); await expect(searchInput).toBeVisible({ timeout: 5000 });
// "Heute" (Today) const todayBtn = sidebar.getByRole("button", { name: "Today", exact: true });
const todayBtn = sidebar.getByRole("button", { name: "Heute", exact: true });
await expect(todayBtn).toBeVisible({ timeout: 5000 }); await expect(todayBtn).toBeVisible({ timeout: 5000 });
// "Sprich mit deinen Oo's" is plain text, not a button // "Talk to your Oos" is plain text, not a button
const conversationsText = sidebar.getByText("Sprich mit deinen Oo's"); const conversationsText = sidebar.getByText("Talk to your Oos");
await expect(conversationsText).toBeVisible({ timeout: 5000 }); await expect(conversationsText).toBeVisible({ timeout: 5000 });
// Trading mode comes from the bot API. With no URL configured the sidebar
// must not claim Live Trading from a local preference.
const tradingStatus = sidebar.getByTestId("trading-mode-status");
await expect(tradingStatus).toBeVisible({ timeout: 5000 });
await expect(tradingStatus).toContainText("Not configured");
await expect(sidebar.getByRole("button", { name: "Live Trading" })).toHaveCount(0);
// No "Integration(en)" button // No "Integration(en)" button
const integrationBtn = sidebar.getByRole("button", { name: /Integration(en)?/i }); const integrationBtn = sidebar.getByRole("button", { name: /Integration(en)?/i });
await expect(integrationBtn).toHaveCount(0, { timeout: 5000 }); await expect(integrationBtn).toHaveCount(0, { timeout: 5000 });

View File

@ -41,8 +41,8 @@ test("app loads without React hook errors", async ({ page }) => {
bodyText.includes("Sign in") || bodyText.includes("Sign in") ||
bodyText.includes("Anmelden") || bodyText.includes("Anmelden") ||
bodyText.includes("EvOo") || bodyText.includes("EvOo") ||
bodyText.includes("Projekte") || bodyText.includes("Projects") ||
bodyText.includes("Heute") || bodyText.includes("Today") ||
bodyText.includes("Team"); bodyText.includes("Team");
expect(hasContent).toBe(true); expect(hasContent).toBe(true);

View File

@ -3,6 +3,7 @@ import {
captureScreenshot, captureScreenshot,
completeOnboarding, completeOnboarding,
openNewSpace, openNewSpace,
routePatchRpc,
rpc, rpc,
signup, signup,
unterhaltungenOnly, unterhaltungenOnly,
@ -15,7 +16,7 @@ function projectSpace(projekte: Locator, name: string) {
}); });
} }
test("Projekte + opens new project dialog instead of group form", async ({ page }) => { test("Projects + opens new project dialog instead of group form", async ({ page }, testInfo) => {
const stamp = Date.now(); const stamp = Date.now();
await signup(page, `proj-create-${stamp}@rakazo.test`, "password12", "Project Create"); await signup(page, `proj-create-${stamp}@rakazo.test`, "password12", "Project Create");
await completeOnboarding(page); await completeOnboarding(page);
@ -33,18 +34,47 @@ test("Projekte + opens new project dialog instead of group form", async ({ page
await dialog.getByLabel("Name").fill("Customer project"); await dialog.getByLabel("Name").fill("Customer project");
await dialog.getByRole("button", { name: "Create project", exact: true }).click(); await dialog.getByRole("button", { name: "Create project", exact: true }).click();
await expect(page).not.toHaveURL(/\/onboarding/); await expect(page).not.toHaveURL(/\/onboarding/);
await page.waitForURL(/\/app/); // The new project opens its conversation with the project's main agent.
await page.waitForLoadState("networkidle"); await page.waitForURL(/\/app\/g\/[^/]+$/, { timeout: 30_000 });
await expect(projekte.getByText("Customer project", { exact: true })).toBeVisible({ await expect(projekte.getByText("Customer project", { exact: true })).toBeVisible({
timeout: 30_000, timeout: 30_000,
}); });
await expect(unterhaltungenOnly(page).getByText("Customer project", { exact: true })).toHaveCount( await expect(unterhaltungenOnly(page).getByText("Customer project", { exact: true })).toHaveCount(
0, 0,
); );
await expect(page.getByTestId("project-start-main")).toBeVisible(); await expect(
await expect(page.getByTestId("project-start-main")).toHaveText(/Starten/); projectSpace(projekte, "Customer project").getByRole("button", {
name: "Chief, main conversation",
}),
).toBeVisible();
await expect(page.getByTestId("project-start-main")).toHaveCount(0);
await captureScreenshot(page, testInfo, "new-project-main-conversation");
}); });
for (const owner of [true, false]) {
const role = owner ? "owner" : "member";
const title = owner
? "new project dialog offers a host folder to the deployment owner"
: "new project dialog offers no host folder to a member";
test(title, async ({ page }, testInfo) => {
await signup(page, `proj-folder-${role}-${Date.now()}@rakazo.test`, "password12", "Folder");
await completeOnboarding(page);
// Whoever signs up first owns the deployment, which depends on spec order.
await routePatchRpc(page, "bootstrap", (json) => ({
...json,
me: { ...(json.me as Record<string, unknown>), isDeploymentOwner: owner },
}));
await page.goto("/app");
await page.waitForURL(/\/app\/[^/]+$/);
await page.locator(".sidebar-projekte").getByTestId("create-project-trigger").click();
const dialog = page.getByRole("dialog", { name: "New project" });
await expect(dialog.getByLabel("Name")).toBeVisible();
await expect(dialog.getByLabel("Project folder")).toHaveCount(owner ? 1 : 0);
await captureScreenshot(page, testInfo, `new-project-${role}`);
});
}
test("spaces stay invisible by default and chat creation requires approval", async ({ test("spaces stay invisible by default and chat creation requires approval", async ({
page, page,
}, testInfo) => { }, testInfo) => {
@ -94,17 +124,18 @@ test("spaces stay invisible by default and chat creation requires approval", asy
const supportSpaceGroup = await supportSpace.getAttribute("data-sidebar-group"); const supportSpaceGroup = await supportSpace.getAttribute("data-sidebar-group");
const supportSpaceId = supportSpaceGroup?.match(/^project-space:([^:]+)/)?.[1]; const supportSpaceId = supportSpaceGroup?.match(/^project-space:([^:]+)/)?.[1];
expect(supportSpaceId).toBeTruthy(); expect(supportSpaceId).toBeTruthy();
await supportSpace.getByRole("button", { name: "Select Customer support" }).click(); // A project created from chat comes with its own main agent, reached through its conversation.
await page.waitForURL(/\/app(?:\/)?$/); await supportSpace.getByRole("button", { name: "Chief, main conversation" }).click();
await page.waitForURL(/\/app\/g\/[^/]+$/);
await expect await expect
.poll(() => page.evaluate(() => window.localStorage.getItem("rakazo:space-id"))) .poll(() => page.evaluate(() => window.localStorage.getItem("rakazo:space-id")))
.toBe(supportSpaceId); .toBe(supportSpaceId);
// The main conversation stays the first entry while another space is open. // The home main conversation stays the first entry while another space is open.
await expect(projekte.getByText("Customer support", { exact: true })).toBeVisible(); await expect(projekte.getByText("Customer support", { exact: true })).toBeVisible();
await expect(unterhaltungenOnly(page).getByRole("button", { name: /^Chief/ })).toHaveCount(1); await expect(unterhaltungenOnly(page).getByRole("button", { name: /^Chief/ })).toHaveCount(1);
await expect(projekte.getByRole("button", { name: /^Chief/ })).toHaveCount(0); await expect(projekte.getByRole("button", { name: /^Chief/ })).toHaveCount(1);
await expect(projekte.getByRole("button", { name: "Starten", exact: true })).toBeVisible(); await expect(projekte.getByRole("button", { name: "Start", exact: true })).toHaveCount(0);
await captureScreenshot(page, testInfo, "spaces-sidebar"); await captureScreenshot(page, testInfo, "spaces-sidebar");
await page.evaluate((id) => { await page.evaluate((id) => {
@ -118,7 +149,9 @@ test("spaces stay invisible by default and chat creation requires approval", asy
await expect(projekte.getByText("Customer support", { exact: true })).toBeVisible(); await expect(projekte.getByText("Customer support", { exact: true })).toBeVisible();
}); });
test("a new space opens empty and can be deleted from its menu", async ({ page }, testInfo) => { test("a new space opens its main conversation and can be deleted from its menu", async ({
page,
}, testInfo) => {
const stamp = Date.now(); const stamp = Date.now();
await signup(page, `spaces-delete-${stamp}@rakazo.test`, "password12", "Space Owner"); await signup(page, `spaces-delete-${stamp}@rakazo.test`, "password12", "Space Owner");
await completeOnboarding(page); await completeOnboarding(page);
@ -129,18 +162,20 @@ test("a new space opens empty and can be deleted from its menu", async ({ page }
await dialog.getByLabel("Name").fill("Temporary"); await dialog.getByLabel("Name").fill("Temporary");
await dialog.getByRole("button", { name: "Create space", exact: true }).click(); await dialog.getByRole("button", { name: "Create space", exact: true }).click();
// Onboarding runs once per user: a new space opens directly, with no bot of its own. // Onboarding runs once per user: a new space opens directly in the conversation with its
await page.waitForURL(/\/app(?:\/)?$/); // own main agent, its only bot.
await page.waitForURL(/\/app\/g\/[^/]+$/, { timeout: 30_000 });
const projekte = page.locator(".sidebar-projekte"); const projekte = page.locator(".sidebar-projekte");
await expect(projekte.getByText("Temporary", { exact: true })).toBeVisible(); await expect(projekte.getByText("Temporary", { exact: true })).toBeVisible();
await expect(unterhaltungenOnly(page).getByRole("button", { name: /^Chief/ })).toHaveCount(1); await expect(unterhaltungenOnly(page).getByRole("button", { name: /^Chief/ })).toHaveCount(1);
await expect(projekte.getByRole("button", { name: /^Chief/ })).toHaveCount(0); await expect(
await expect(projekte.getByRole("button", { name: "Starten", exact: true })).toBeVisible(); projectSpace(projekte, "Temporary").getByRole("button", { name: "Chief, main conversation" }),
expect(await rpc<unknown[]>(page, "bots/list", {})).toEqual([]); ).toBeVisible();
await captureScreenshot(page, testInfo, "new-space-empty"); await expect(projekte.getByRole("button", { name: "Start", exact: true })).toHaveCount(0);
expect(await rpc<Array<{ kind: string }>>(page, "bots/list", {})).toEqual([
// The empty space stays selectable instead of trapping the user. expect.objectContaining({ kind: "main" }),
await expect(projekte.getByRole("button", { name: "Select Temporary" }).first()).toBeVisible(); ]);
await captureScreenshot(page, testInfo, "new-space-main-conversation");
// Deletion is discoverable through the visible Space actions menu. // Deletion is discoverable through the visible Space actions menu.
await projectSpace(projekte, "Temporary") await projectSpace(projekte, "Temporary")
@ -155,6 +190,7 @@ test("a new space opens empty and can be deleted from its menu", async ({ page }
await deleteSpaceDialog.getByRole("button", { name: "Delete", exact: true }).click(); await deleteSpaceDialog.getByRole("button", { name: "Delete", exact: true }).click();
await expect(sidebar.getByText("Temporary", { exact: true })).toHaveCount(0); await expect(sidebar.getByText("Temporary", { exact: true })).toHaveCount(0);
await expect(page).not.toHaveURL(/\/onboarding/); await expect(page).not.toHaveURL(/\/onboarding/);
// The project's main agent went with it.
await expect(sidebar.getByRole("button", { name: /^Chief/ })).toHaveCount(1); await expect(sidebar.getByRole("button", { name: /^Chief/ })).toHaveCount(1);
// A stale onboarding URL recovers without duplicating the bot. // A stale onboarding URL recovers without duplicating the bot.
@ -187,7 +223,7 @@ test("deleting the last bot in a space stays in the app after first use", async
const dialog = page.getByRole("dialog", { name: "New space" }); const dialog = page.getByRole("dialog", { name: "New space" });
await dialog.getByLabel("Name").fill("Side"); await dialog.getByLabel("Name").fill("Side");
await dialog.getByRole("button", { name: "Create space", exact: true }).click(); await dialog.getByRole("button", { name: "Create space", exact: true }).click();
await page.waitForURL(/\/app(?:\/)?$/); await page.waitForURL(/\/app\/g\/[^/]+$/, { timeout: 30_000 });
const projekte = page.locator(".sidebar-projekte"); const projekte = page.locator(".sidebar-projekte");
await expect(projekte.getByText("Side", { exact: true })).toBeVisible(); await expect(projekte.getByText("Side", { exact: true })).toBeVisible();
@ -204,8 +240,11 @@ test("deleting the last bot in a space stays in the app after first use", async
await expect(page).not.toHaveURL(/\/onboarding/); await expect(page).not.toHaveURL(/\/onboarding/);
await page.goto("/app"); await page.goto("/app");
await expect(page).not.toHaveURL(/\/onboarding/); await expect(page).not.toHaveURL(/\/onboarding/);
await expect(projekte.getByRole("button", { name: "Select Side" }).first()).toBeVisible(); // The project's main conversation stays, so the project opens there.
await expect(page.getByTestId("project-start-main")).toBeVisible(); await page.waitForURL(/\/app\/g\/[^/]+$/, { timeout: 30_000 });
await expect(
projectSpace(projekte, "Side").getByRole("button", { name: "Chief, main conversation" }),
).toBeVisible();
await projectSpace(projekte, "Side").getByRole("button", { name: "Actions for Side" }).click(); await projectSpace(projekte, "Side").getByRole("button", { name: "Actions for Side" }).click();
await expect(page.getByRole("menuitem", { name: "Delete space" })).toBeVisible(); await expect(page.getByRole("menuitem", { name: "Delete space" })).toBeVisible();

View File

@ -1,16 +0,0 @@
import { test } from "@playwright/test";
test.describe("Steve Jobs CTO Agent", () => {
test.beforeEach(async () => {
// We assume the user has logged in and the bot is seeded
// This is a placeholder for the perfect test architecture
});
test("should be visible in the sidebar", async () => {
// This is a dummy test to show we started the test architecture for Steve Jobs
});
test("should prefer voice communications (VOICE FIRST)", async () => {
// Voice first features
});
});

View File

@ -67,6 +67,9 @@ test("account settings appearance control switches to light mode", async ({ page
await settings.getByRole("button", { name: "Close user settings" }).click(); await settings.getByRole("button", { name: "Close user settings" }).click();
await expect(settings).toBeHidden(); await expect(settings).toBeHidden();
await expect(page.locator("html")).toHaveAttribute("data-theme", "light"); await expect(page.locator("html")).toHaveAttribute("data-theme", "light");
// Light surfaces stay light: no dark panel or white-on-paper text survives the switch.
await expect(page.locator("main").first()).toHaveCSS("background-color", "rgb(255, 255, 255)");
await expect(page.locator("aside").first()).toHaveCSS("background-color", "rgb(245, 245, 242)");
await captureScreenshot(page, testInfo, "ui-appearance-light-shell"); await captureScreenshot(page, testInfo, "ui-appearance-light-shell");
await captureSidebarSearchSelected(page, testInfo, "sidebar-search-selected-light"); await captureSidebarSearchSelected(page, testInfo, "sidebar-search-selected-light");

View File

@ -1,7 +1,7 @@
import { expect, test } from "@playwright/test"; import { expect, test } from "@playwright/test";
import { completeOnboarding, openNewBot, signup, unterhaltungenOnly } from "./helpers"; import { completeOnboarding, openNewBot, signup, unterhaltungenOnly } from "./helpers";
test("Unterhaltungen and Projekte stay separate; + opens bot picker", async ({ page }) => { test("conversations and projects stay separate; + opens bot picker", async ({ page }) => {
const stamp = Date.now(); const stamp = Date.now();
await signup(page, `unterhaltungen-${stamp}@rakazo.test`, "password12", "Unterhaltungen UX"); await signup(page, `unterhaltungen-${stamp}@rakazo.test`, "password12", "Unterhaltungen UX");
await completeOnboarding(page); await completeOnboarding(page);
@ -11,8 +11,8 @@ test("Unterhaltungen and Projekte stay separate; + opens bot picker", async ({ p
const unterhaltungen = page.locator(".sidebar-unterhaltungen"); const unterhaltungen = page.locator(".sidebar-unterhaltungen");
const projekte = page.locator(".sidebar-projekte"); const projekte = page.locator(".sidebar-projekte");
await expect(unterhaltungen.getByText("Sprich mit deinen Oo's", { exact: true })).toBeVisible(); await expect(unterhaltungen.getByText("Talk to your Oos", { exact: true })).toBeVisible();
await expect(projekte.getByText("Projekte", { exact: true })).toBeVisible(); await expect(projekte.getByText("Projects", { exact: true })).toBeVisible();
await expect(unterhaltungen.getByRole("button", { name: /^Chief/ })).toBeVisible(); await expect(unterhaltungen.getByRole("button", { name: /^Chief/ })).toBeVisible();
await unterhaltungen.getByTestId("conversation-picker-trigger").click(); await unterhaltungen.getByTestId("conversation-picker-trigger").click();

View File

@ -26,7 +26,7 @@
document.documentElement.dataset.theme = theme; document.documentElement.dataset.theme = theme;
document.documentElement.style.colorScheme = theme; document.documentElement.style.colorScheme = theme;
const meta = document.querySelector('meta[name="theme-color"]'); const meta = document.querySelector('meta[name="theme-color"]');
if (meta) meta.setAttribute("content", theme === "light" ? "#fafaf8" : "#07080a"); if (meta) meta.setAttribute("content", theme === "light" ? "#ffffff" : "#07080a");
})(); })();
</script> </script>
<script> <script>

View File

@ -1,4 +1,28 @@
import type { CatalogFormatter, CatalogType } from "@lingui/conf";
import { defineConfig } from "@lingui/conf"; import { defineConfig } from "@lingui/conf";
import { formatter } from "@lingui/format-po";
// File references without line numbers, so catalogs change only when messages change.
const po = formatter({ lineNumbers: false });
const collator = new Intl.Collator("en-US");
// Lingui orders entries by source text but leaves ties (explicit ids, equal text) in the order the
// files were found, which differs between file systems. Break ties by id so output is stable.
function sortCatalog(catalog: CatalogType): CatalogType {
return Object.fromEntries(
Object.entries(catalog).sort(
([aId, a], [bId, b]) =>
collator.compare(a.message ?? aId, b.message ?? bId) ||
collator.compare(a.context ?? "", b.context ?? "") ||
(aId < bId ? -1 : aId > bId ? 1 : 0),
),
);
}
const stablePo: CatalogFormatter = {
...po,
serialize: (catalog, ctx) => po.serialize(sortCatalog(catalog), ctx),
};
export default defineConfig({ export default defineConfig({
sourceLocale: "en", sourceLocale: "en",
@ -10,5 +34,6 @@ export default defineConfig({
exclude: ["**/locales/**", "**/*.test.*"], exclude: ["**/locales/**", "**/*.test.*"],
}, },
], ],
format: stablePo,
compileNamespace: "es", compileNamespace: "es",
}); });

View File

@ -36,6 +36,7 @@
"@lingui/babel-plugin-lingui-macro": "6.6.0", "@lingui/babel-plugin-lingui-macro": "6.6.0",
"@lingui/cli": "6.6.0", "@lingui/cli": "6.6.0",
"@lingui/conf": "6.6.0", "@lingui/conf": "6.6.0",
"@lingui/format-po": "6.6.0",
"@lingui/vite-plugin": "6.6.0", "@lingui/vite-plugin": "6.6.0",
"@playwright/test": "1.63.0", "@playwright/test": "1.63.0",
"@rolldown/plugin-babel": "0.2.4", "@rolldown/plugin-babel": "0.2.4",

View File

@ -14,7 +14,6 @@ import {
showSessionUnavailable, showSessionUnavailable,
} from "./lib/session-gate"; } from "./lib/session-gate";
import { IntegrationSetupPage } from "./pages/IntegrationSetup"; import { IntegrationSetupPage } from "./pages/IntegrationSetup";
import { LocalSettingsPage } from "./pages/LocalSettings";
import { McpOAuthCallbackPage } from "./pages/McpOAuthCallback"; import { McpOAuthCallbackPage } from "./pages/McpOAuthCallback";
import { ShellPage } from "./pages/Shell"; import { ShellPage } from "./pages/Shell";
@ -30,9 +29,18 @@ const OnboardingPage = lazyWithRecovery("pages/Onboarding", () =>
const WelcomePage = lazyWithRecovery("pages/Welcome", () => const WelcomePage = lazyWithRecovery("pages/Welcome", () =>
import("./pages/Welcome").then((module) => ({ default: module.WelcomePage })), import("./pages/Welcome").then((module) => ({ default: module.WelcomePage })),
); );
const LocalSettingsPage = lazyWithRecovery("pages/LocalSettings", () =>
import("./pages/LocalSettings").then((module) => ({ default: module.LocalSettingsPage })),
);
export function App() { export function App() {
if (window.location.pathname === LOCAL_SETTINGS_PAGE) return <LocalSettingsPage />; if (window.location.pathname === LOCAL_SETTINGS_PAGE) {
return (
<Suspense fallback={<div className="h-full bg-background" data-rakazo-app-state="loading" />}>
<LocalSettingsPage />
</Suspense>
);
}
return <SessionApp />; return <SessionApp />;
} }

View File

@ -4,13 +4,9 @@ import { Trans, useLingui } from "@lingui/react/macro";
import { ChatMarkdown } from "@rakazo/chat-ui/web"; import { ChatMarkdown } from "@rakazo/chat-ui/web";
import { Button, Dialog, DialogClose, DialogContent, DialogTitle } from "@rakazo/ui-web"; import { Button, Dialog, DialogClose, DialogContent, DialogTitle } from "@rakazo/ui-web";
import { Download, FileText, X } from "lucide-react"; import { Download, FileText, X } from "lucide-react";
import { type RefObject, useEffect, useRef, useState } from "react"; import { type RefObject, useMemo, useRef, useState } from "react";
import { import { useArtifactBytes } from "../hooks/useArtifactBytes";
type ArtifactTarget, import { type ArtifactTarget, downloadArtifact, downloadArtifactBytes } from "../lib/artifact-open";
downloadArtifact,
downloadArtifactBytes,
fetchArtifactBytes,
} from "../lib/artifact-open";
type ArtifactFileCardProps = { type ArtifactFileCardProps = {
target: ArtifactTarget; target: ArtifactTarget;
@ -18,6 +14,8 @@ type ArtifactFileCardProps = {
name: string; name: string;
mimeType: string; mimeType: string;
size: number; size: number;
/** Shows the preview beside the conversation instead of in a dialog. */
onOpenBeside?: () => void;
}; };
export function ArtifactFileCard(props: ArtifactFileCardProps) { export function ArtifactFileCard(props: ArtifactFileCardProps) {
@ -63,7 +61,7 @@ export function ArtifactFileCard(props: ArtifactFileCardProps) {
ref={previewButton} ref={previewButton}
type="button" type="button"
aria-label={t`Preview ${props.name}`} aria-label={t`Preview ${props.name}`}
onClick={() => setPreviewOpen(true)} onClick={() => (props.onOpenBeside ? props.onOpenBeside() : setPreviewOpen(true))}
className="flex min-w-0 flex-1 items-center gap-3 px-4 py-3 text-left hover:bg-accent" className="flex min-w-0 flex-1 items-center gap-3 px-4 py-3 text-left hover:bg-accent"
> >
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-[10px] bg-muted text-foreground"> <span className="grid h-10 w-10 shrink-0 place-items-center rounded-[10px] bg-muted text-foreground">
@ -111,39 +109,16 @@ function MarkdownPreview({
}: ArtifactFileCardProps & { closeButtonRef: RefObject<HTMLButtonElement | null> }) { }: ArtifactFileCardProps & { closeButtonRef: RefObject<HTMLButtonElement | null> }) {
const { t } = useLingui(); const { t } = useLingui();
const [downloadError, setDownloadError] = useState<string | null>(null); const [downloadError, setDownloadError] = useState<string | null>(null);
const [state, setState] = useState< const loaded = useArtifactBytes(target, artifactId);
| { status: "loading" } const state = useMemo(() => {
| { status: "ready"; bytes: Uint8Array; markdown: string } if (loaded.status !== "ready") return loaded;
| { status: "error"; message: string } try {
>({ status: "loading" }); const markdown = new TextDecoder("utf-8", { fatal: true }).decode(loaded.bytes);
const targetBotId = "botId" in target ? target.botId : undefined; return { status: "ready" as const, bytes: loaded.bytes, markdown };
const targetGroupId = "groupId" in target ? target.groupId : undefined; } catch {
return { status: "error" as const, message: t`This file is not valid UTF-8 Markdown.` };
useEffect(() => { }
let cancelled = false; }, [loaded, t]);
const artifactTarget: ArtifactTarget =
targetBotId !== undefined ? { botId: targetBotId } : { groupId: targetGroupId! };
void fetchArtifactBytes(artifactTarget, artifactId)
.then((bytes) => {
if (cancelled) return;
try {
const markdown = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
setState({ status: "ready", bytes, markdown });
} catch {
setState({ status: "error", message: t`This file is not valid UTF-8 Markdown.` });
}
})
.catch((error) => {
if (cancelled) return;
setState({
status: "error",
message: error instanceof Error ? error.message : t`Could not load this file.`,
});
});
return () => {
cancelled = true;
};
}, [artifactId, targetBotId, targetGroupId, t]);
return ( return (
<> <>

View File

@ -0,0 +1,165 @@
import { Trans, useLingui } from "@lingui/react/macro";
import { ChatMarkdown } from "@rakazo/chat-ui/web";
import { isAttachmentImageMimeType } from "@rakazo/contracts";
import type { WorkspaceArtifact } from "@rakazo/core";
import { Button } from "@rakazo/ui-web";
import { Download, X } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useArtifactBytes } from "../hooks/useArtifactBytes";
import { type ArtifactTarget, downloadArtifactBytes } from "../lib/artifact-open";
import { parseCsvPreview } from "../lib/csv-preview";
/** A file the bot made, open beside the conversation so the user can read it while they talk. */
export function ArtifactPane({
target,
artifact,
onClose,
}: {
target: ArtifactTarget;
artifact: WorkspaceArtifact;
onClose: () => void;
}) {
const { t } = useLingui();
const loaded = useArtifactBytes(target, artifact.artifactId);
const { name, mimeType } = artifact;
return (
<section
data-testid="artifact-pane"
aria-label={name}
className="flex h-full min-h-0 w-full flex-col"
>
<header className="flex h-14 shrink-0 items-center gap-1 border-b border-border px-5">
<h2 className="min-w-0 flex-1 truncate text-ui-body font-medium text-foreground" dir="auto">
{name}
</h2>
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground"
aria-label={t`Download ${name}`}
title={t`Download ${name}`}
disabled={loaded.status !== "ready"}
onClick={() => {
if (loaded.status === "ready") downloadArtifactBytes(name, mimeType, loaded.bytes);
}}
>
<Download strokeWidth={1.8} className="size-icon-md" />
</Button>
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground"
aria-label={t`Close panel`}
onClick={onClose}
>
<X strokeWidth={1.8} className="size-icon-md" />
</Button>
</header>
<div className="rk-scroll min-h-0 flex-1 overflow-auto">
{loaded.status === "loading" ? (
<div className="px-6 py-8 text-ui-body text-muted-foreground">
<Trans>Loading preview…</Trans>
</div>
) : loaded.status === "error" ? (
<div
role="alert"
className="m-6 rounded-xl border border-destructive/40 bg-destructive/10 px-4 py-3 text-ui-body text-destructive"
>
{loaded.message}
</div>
) : isAttachmentImageMimeType(mimeType) ? (
<PaneImage bytes={loaded.bytes} mimeType={mimeType} name={name} />
) : (
<PaneText bytes={loaded.bytes} mimeType={mimeType} />
)}
</div>
</section>
);
}
function PaneImage({
bytes,
mimeType,
name,
}: {
bytes: Uint8Array;
mimeType: string;
name: string;
}) {
const [src, setSrc] = useState<string | null>(null);
useEffect(() => {
const url = URL.createObjectURL(new Blob([new Uint8Array(bytes)], { type: mimeType }));
setSrc(url);
return () => URL.revokeObjectURL(url);
}, [bytes, mimeType]);
return src ? (
<img src={src} alt={name} className="mx-auto block max-w-full p-6" draggable={false} />
) : null;
}
function PaneText({ bytes, mimeType }: { bytes: Uint8Array; mimeType: string }) {
const { t } = useLingui();
const text = useMemo(() => new TextDecoder("utf-8").decode(bytes), [bytes]);
if (mimeType === "text/markdown") {
return (
<article className="mx-auto w-full max-w-[760px] px-6 py-8 text-ui-body leading-7 text-foreground lg:px-10">
<ChatMarkdown>{text}</ChatMarkdown>
</article>
);
}
if (mimeType === "text/csv") {
const { rows, truncated } = parseCsvPreview(text);
const [head, ...body] = rows;
const shownRows = body.length;
return (
<div className="p-4">
<table className="w-full border-collapse text-ui-body-sm text-foreground">
{head ? (
<thead>
<tr>
{head.map((cell, index) => (
<th
key={index}
className="sticky top-0 border-b border-border bg-background px-3 py-2 text-start font-medium"
>
{cell}
</th>
))}
</tr>
</thead>
) : null}
<tbody>
{body.map((row, rowIndex) => (
<tr key={rowIndex} className="border-b border-border/60">
{row.map((cell, index) => (
<td key={index} className="px-3 py-1.5 align-top whitespace-pre-wrap">
{cell}
</td>
))}
</tr>
))}
</tbody>
</table>
{truncated ? (
<p className="px-3 pt-3 text-ui-body-sm text-muted-foreground">
{t`First ${shownRows} rows`}
</p>
) : null}
</div>
);
}
return (
<pre className="whitespace-pre-wrap break-words px-6 py-6 font-mono text-ui-body-sm text-foreground">
{mimeType === "application/json" ? prettyJson(text) : text}
</pre>
);
}
function prettyJson(text: string): string {
try {
return JSON.stringify(JSON.parse(text), null, 2);
} catch {
return text;
}
}

View File

@ -87,6 +87,7 @@ export function AskCard({
return ( return (
<div <div
data-ask-card
data-testid={secretInput ? "secret-ask-card" : undefined} data-testid={secretInput ? "secret-ask-card" : undefined}
className={cn("max-w-[74%] rounded-2xl border border-border bg-card px-5 py-4", className)} className={cn("max-w-[74%] rounded-2xl border border-border bg-card px-5 py-4", className)}
> >

View File

@ -1,28 +0,0 @@
import { CheckCircle2, Database, EyeOff, Lock } from "lucide-react";
import { memo } from "react";
const TRUST_ITEMS = [
{ icon: Lock, label: "Verschlüsselt", desc: "AES-256 für sensible Daten" },
{ icon: Database, label: "Lokal gespeichert", desc: "Eigene PostgreSQL-Datenbank" },
{ icon: EyeOff, label: "Kein Tracking", desc: "Keine Analytics, kein Telemetry" },
] as const;
export const DataTrustPanel = memo(function DataTrustPanel() {
return (
<div className="hidden xl:flex flex-col items-start gap-1.5 rounded-xl border border-border/40 bg-muted/30 px-3 py-2.5">
<div className="flex items-center gap-1.5 text-xs font-medium text-sidebar-foreground">
<CheckCircle2 className="size-3.5 text-success" />
<span>Datenschutz aktiv</span>
</div>
<div className="flex flex-col gap-1">
{TRUST_ITEMS.map(({ icon: Icon, label, desc }) => (
<div key={label} className="flex items-center gap-1.5 rk-sidebar-muted">
<Icon className="size-3" />
<span className="text-[11px] leading-tight">{label}</span>
<span className="text-[10px] leading-tight">· {desc}</span>
</div>
))}
</div>
</div>
);
});

View File

@ -1,55 +0,0 @@
// @vitest-environment jsdom
import type { ReactNode } from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { expect, it, vi } from "vitest";
vi.mock("@lingui/react/macro", () => ({
Trans: ({ children }: { children: ReactNode }) => children,
}));
import { TradingModeIndicator } from "./TradingModeIndicator";
it("renders the API mode and ignores a stored live preference", async () => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
window.localStorage.setItem("evoo_trading_mode", "live");
const container = document.createElement("div");
document.body.append(container);
const root = createRoot(container);
try {
await act(async () =>
root.render(
<TradingModeIndicator
snapshot={{
state: "ready",
status: { mode: "paper", kind: "dry_run", adapterCount: 3, lastError: null },
}}
/>,
),
);
const status = container.querySelector("[data-testid='trading-mode-status']");
expect(status?.getAttribute("data-trading-state")).toBe("dry_run");
expect(status?.textContent).toContain("Dry run");
expect(status?.textContent).toContain("3 adapters");
expect(container.querySelector("button")).toBeNull();
expect(status?.textContent).not.toContain("Live Trading");
await act(async () => root.render(<TradingModeIndicator snapshot={{ state: "offline" }} />));
expect(container.querySelector("[data-testid='trading-mode-status']")?.textContent).toContain(
"Offline",
);
await act(async () =>
root.render(<TradingModeIndicator snapshot={{ state: "unconfigured" }} />),
);
expect(container.querySelector("[data-testid='trading-mode-status']")?.textContent).toContain(
"Not configured",
);
} finally {
await act(async () => root.unmount());
container.remove();
window.localStorage.clear();
vi.unstubAllGlobals();
}
});

View File

@ -1,95 +0,0 @@
import { Trans } from "@lingui/react/macro";
import { cn } from "@rakazo/ui-web";
import { Activity } from "lucide-react";
import type { ReactNode } from "react";
import {
presentTradingStatus,
type TradingBotUiState,
type TradingModeLabelId,
type TradingModeSubtitle,
} from "../lib/trading-bot-status";
export function TradingModeIndicator({
snapshot,
className,
}: {
snapshot: TradingBotUiState;
className?: string;
}) {
const presentation = presentTradingStatus(snapshot);
return (
<div
role="status"
aria-live="polite"
aria-busy={presentation.label === "loading"}
data-testid="trading-mode-status"
data-trading-state={presentation.label}
className={cn("flex min-w-0 items-center gap-3 rounded-xl px-2.5 py-2", className)}
>
<span
className={cn(
"grid h-[30px] w-[30px] shrink-0 place-items-center rounded-lg",
presentation.tone === "live"
? "bg-warning/10 text-warning"
: "bg-muted text-muted-foreground",
)}
>
<Activity
strokeWidth={1.8}
aria-hidden="true"
className={cn("size-icon-md", presentation.pulse && "animate-pulse")}
/>
</span>
<span className="min-w-0">
<span className="block truncate text-ui-body font-medium text-foreground/90">
<TradingModeLabel label={presentation.label} />
</span>
{presentation.subtitle ? (
<span className="block truncate text-ui-body-sm rk-sidebar-muted">
<TradingModeSubtitleText subtitle={presentation.subtitle} />
</span>
) : null}
</span>
</div>
);
}
function TradingModeLabel({ label }: { label: TradingModeLabelId }): ReactNode {
switch (label) {
case "loading":
return <Trans>Trading</Trans>;
case "unconfigured":
return <Trans>Not configured</Trans>;
case "offline":
return <Trans>Offline</Trans>;
case "unknown":
return <Trans>Unknown</Trans>;
case "live":
return <Trans>Live Trading</Trans>;
case "paper":
return <Trans>Paper</Trans>;
case "dry_run":
return <Trans>Dry run</Trans>;
default: {
const exhaustive: never = label;
return exhaustive;
}
}
}
function TradingModeSubtitleText({ subtitle }: { subtitle: TradingModeSubtitle }): ReactNode {
switch (subtitle.type) {
case "error":
return subtitle.text;
case "adapters":
return subtitle.count === 1 ? (
<Trans>1 adapter</Trans>
) : (
<Trans>{subtitle.count} adapters</Trans>
);
default: {
const exhaustive: never = subtitle;
return exhaustive;
}
}
}

View File

@ -1,3 +1,4 @@
import { Plural, Trans } from "@lingui/react/macro";
import { FolderKanban } from "lucide-react"; import { FolderKanban } from "lucide-react";
export interface ProjectCreatedCardProps { export interface ProjectCreatedCardProps {
@ -22,17 +23,19 @@ export function ProjectCreatedCard({ name, memberCount, onClick }: ProjectCreate
</span> </span>
<div className="truncate text-ui-body font-semibold text-foreground">{name}</div> <div className="truncate text-ui-body font-semibold text-foreground">{name}</div>
<span className="rounded-md bg-muted px-1.5 py-0.5 text-ui-label font-medium uppercase tracking-wider text-muted-foreground"> <span className="rounded-md bg-muted px-1.5 py-0.5 text-ui-label font-medium uppercase tracking-wider text-muted-foreground">
Projekt <Trans>Project</Trans>
</span> </span>
</div> </div>
<span className="shrink-0 rounded-full bg-success/15 px-2.5 py-0.5 text-ui-body-sm font-medium text-success"> <span className="shrink-0 rounded-full bg-success/15 px-2.5 py-0.5 text-ui-body-sm font-medium text-success">
Erstellt <Trans>Created</Trans>
</span> </span>
</div> </div>
<p className="mt-2.5 text-ui-body-sm leading-relaxed text-muted-foreground"> <p className="mt-2.5 text-ui-body-sm leading-relaxed text-muted-foreground">
{memberCount} Agent{memberCount === 1 ? "" : "en"} eingeladen <Plural value={memberCount} one="# agent invited" other="# agents invited" />
</p> </p>
<div className="mt-2.5 text-ui-body-sm font-medium text-primary">Projekt öffnen →</div> <div className="mt-2.5 text-ui-body-sm font-medium text-primary">
<Trans>Open project →</Trans>
</div>
</button> </button>
</div> </div>
); );

View File

@ -0,0 +1,317 @@
import { useLingui } from "@lingui/react/macro";
import type {
RakazoDesktopBrowserPreview,
RakazoDesktopBrowserPreviewState,
} from "@rakazo/contracts";
import { Button, cn, Input } from "@rakazo/ui-web";
import { ArrowLeft, ArrowRight, Code, ExternalLink, RotateCw, X } from "lucide-react";
import { type FormEvent, useCallback, useEffect, useRef, useState } from "react";
import { browserFrameSandbox, normalizeBrowserUrl } from "../../lib/browser-url";
import { desktopBridge } from "../../lib/desktop";
const LAST_URL_KEY = "rk:in-app-browser:url";
function readLastUrl(): string {
try {
return window.localStorage.getItem(LAST_URL_KEY) ?? "";
} catch {
return "";
}
}
function rememberUrl(url: string) {
try {
window.localStorage.setItem(LAST_URL_KEY, url);
} catch {
/* A per-viewer convenience only. */
}
}
const EMPTY_STATE: RakazoDesktopBrowserPreviewState = {
url: "",
title: "",
loading: false,
canGoBack: false,
canGoForward: false,
error: null,
};
/** DOM layers the native desktop view would otherwise paint over. */
const FLOATING_UI = '[role="dialog"],[role="alertdialog"],[role="menu"],[role="listbox"]';
/**
* Address bar plus page. On desktop the page is a native Chromium view laid over the reserved
* area, so dev servers keep hot reload, DevTools and full speed; in a browser it is an iframe.
*/
export function InAppBrowser({ onClose }: { onClose: () => void }) {
const { t } = useLingui();
const native = desktopBridge()?.browserPreview;
const [state, setState] = useState<RakazoDesktopBrowserPreviewState>(() => ({
...EMPTY_STATE,
url: native ? "" : readLastUrl(),
}));
const [draft, setDraft] = useState(state.url);
const [invalid, setInvalid] = useState(false);
const [frameKey, setFrameKey] = useState(0);
const editing = useRef(false);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!native) return;
let live = true;
const apply = (next: RakazoDesktopBrowserPreviewState) => {
if (!live) return;
setState(next);
if (!editing.current) setDraft(next.url);
};
const unsubscribe = native.onState(apply);
void native
.state()
.then((current) => {
apply(current);
const last = readLastUrl();
if (!current.url && last) void native.navigate(last).catch(() => undefined);
})
.catch(() => undefined);
return () => {
live = false;
unsubscribe();
};
}, [native]);
useEffect(() => {
if (!state.url) inputRef.current?.focus();
}, [state.url]);
const go = useCallback(
(input: string) => {
const url = normalizeBrowserUrl(input);
setInvalid(!url);
if (!url) return;
editing.current = false;
setDraft(url);
rememberUrl(url);
if (native) {
void native.navigate(url).catch(() => setInvalid(true));
} else {
setState((current) => ({ ...current, url, loading: true, error: null }));
setFrameKey((key) => key + 1);
}
},
[native],
);
const reload = useCallback(() => {
if (native) {
void (state.loading ? native.stop() : native.reload()).catch(() => undefined);
return;
}
if (!state.url) return;
setState((current) => ({ ...current, loading: true }));
setFrameKey((key) => key + 1);
}, [native, state.loading, state.url]);
const onSubmit = (event: FormEvent) => {
event.preventDefault();
go(draft);
inputRef.current?.blur();
};
return (
<div className="flex h-full min-h-0 flex-col" data-testid="in-app-browser">
<form onSubmit={onSubmit} className="relative flex items-center gap-0.5 border-b px-2 py-1.5">
{native ? (
<>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={t`Back`}
disabled={!state.canGoBack}
onClick={() => void native.back().catch(() => undefined)}
>
<ArrowLeft strokeWidth={1.7} className="size-icon-md" />
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={t`Forward`}
disabled={!state.canGoForward}
onClick={() => void native.forward().catch(() => undefined)}
>
<ArrowRight strokeWidth={1.7} className="size-icon-md" />
</Button>
</>
) : null}
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={state.loading ? t`Stop` : t`Reload`}
disabled={!state.url}
onClick={reload}
>
{state.loading && native ? (
<X strokeWidth={1.8} className="size-icon-md" />
) : (
<RotateCw strokeWidth={1.7} className="size-icon-md" />
)}
</Button>
<Input
ref={inputRef}
value={draft}
aria-label={t`Address`}
aria-invalid={invalid || undefined}
inputMode="url"
autoComplete="off"
autoCapitalize="off"
spellCheck={false}
placeholder="localhost:5173"
onFocus={(event) => {
editing.current = true;
event.currentTarget.select();
}}
onBlur={() => {
editing.current = false;
}}
onChange={(event) => {
setDraft(event.target.value);
setInvalid(false);
}}
onKeyDown={(event) => {
if (event.key !== "Escape") return;
setDraft(state.url);
setInvalid(false);
event.currentTarget.blur();
}}
className="mx-1 h-control-sm flex-1 rounded-full bg-muted/60 px-3 text-ui-body-sm"
/>
{native ? (
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={t`Developer tools`}
disabled={!state.url}
onClick={() => void native.openDevTools().catch(() => undefined)}
>
<Code strokeWidth={1.7} className="size-icon-md" />
</Button>
) : null}
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={t`Open in browser`}
disabled={!state.url}
onClick={() => window.open(state.url, "_blank", "noopener,noreferrer")}
>
<ExternalLink strokeWidth={1.7} className="size-icon-md" />
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={t`Close panel`}
onClick={onClose}
>
<X strokeWidth={1.8} className="size-icon-md" />
</Button>
<div
aria-hidden
className={cn(
"absolute inset-x-0 -bottom-px h-0.5 overflow-hidden transition-opacity duration-300",
state.loading ? "opacity-100" : "opacity-0",
)}
>
<div className="rk-browser-progress h-full w-1/3 rounded-full bg-primary/70" />
</div>
</form>
<div className="relative min-h-0 flex-1 bg-background">
{native ? (
<NativeViewport browser={native} visible={Boolean(state.url) && !state.error} />
) : state.url ? (
<iframe
key={frameKey}
title={state.title || t`Page`}
src={state.url}
sandbox={browserFrameSandbox(state.url, window.location.origin)}
allow="clipboard-read; clipboard-write; fullscreen"
onLoad={() => setState((current) => ({ ...current, loading: false }))}
className="h-full w-full border-0 bg-background"
data-testid="in-app-browser-frame"
/>
) : null}
{state.error ? (
<div
role="alert"
className="absolute inset-0 grid place-items-center px-6 text-center text-ui-body-sm text-muted-foreground"
>
{state.error}
</div>
) : null}
</div>
</div>
);
}
/**
* Reserves the page area and keeps the native view glued to it while the panel resizes,
* hiding it whenever app UI (dialogs, menus) floats where the view would paint over it.
*/
function NativeViewport({
browser,
visible,
}: {
browser: RakazoDesktopBrowserPreview;
visible: boolean;
}) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const element = ref.current;
if (!element) return;
let last = "";
let frame = 0;
const sync = () => {
frame = 0;
const rect = element.getBoundingClientRect();
const covered = Array.from(document.querySelectorAll(FLOATING_UI)).some(
(node) => !element.contains(node),
);
const show = visible && rect.width >= 1 && rect.height >= 1 && !covered;
const bounds = {
x: Math.round(rect.left),
y: Math.round(rect.top),
width: Math.round(rect.width),
height: Math.round(rect.height),
};
const key = show ? JSON.stringify(bounds) : "hidden";
if (key === last) return;
last = key;
void (show ? browser.show(bounds) : browser.hide()).catch(() => undefined);
};
const schedule = () => {
if (!frame) frame = requestAnimationFrame(sync);
};
const resize = new ResizeObserver(schedule);
resize.observe(element);
const floating = new MutationObserver(schedule);
floating.observe(document.body, { childList: true, subtree: true });
window.addEventListener("resize", schedule);
// Sibling layout (sidebars, transitions) can move the area without resizing it.
const settle = setInterval(schedule, 250);
schedule();
return () => {
cancelAnimationFrame(frame);
resize.disconnect();
floating.disconnect();
window.removeEventListener("resize", schedule);
clearInterval(settle);
void browser.hide().catch(() => undefined);
};
}, [browser, visible]);
return <div ref={ref} className="absolute inset-0" data-testid="in-app-browser-viewport" />;
}

View File

@ -0,0 +1,165 @@
import { screenPointInViewer } from "@rakazo/core";
import { Keyboard } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import {
agentPointerFeed,
POINTER_IDLE_MS,
type PointerPoint,
type PointerScreen,
type PointerSegment,
planPointerTimeline,
pointerFrameAt,
} from "../../lib/agent-pointer";
function prefersReducedMotion() {
return (
typeof window.matchMedia === "function" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches
);
}
/**
* Draws a bot's announced pointer over its live screen: it glides to each target, ripples on
* clicks and shows when it types. Frames are written straight to the DOM so the chat never
* re-renders at animation rate.
*/
export function AgentCursor({
botId,
color,
name,
}: {
botId: string;
color: string;
name: string;
}) {
const layerRef = useRef<HTMLDivElement>(null);
const cursorRef = useRef<HTMLDivElement>(null);
const rippleRef = useRef<HTMLSpanElement>(null);
const [typing, setTyping] = useState(false);
useEffect(() => {
const layer = layerRef.current;
const cursor = cursorRef.current;
const ripple = rippleRef.current;
if (!layer || !cursor || !ripple) return;
const reducedMotion = prefersReducedMotion();
let screen: PointerScreen | null = null;
let segments: PointerSegment[] = [];
let position: PointerPoint | null = null;
let frame = 0;
let idleTimer: ReturnType<typeof setTimeout> | undefined;
let viewer = { width: layer.clientWidth, height: layer.clientHeight };
let shownTyping = false;
const place = (point: PointerPoint, action: string | null, progress: number) => {
if (!screen) return;
const projected = screenPointInViewer(point, viewer, screen);
cursor.style.transform = `translate3d(${projected.x}px, ${projected.y}px, 0)`;
cursor.dataset.action = action ?? "move";
const pressing = action === "click" || action === "double_click" || action === "right_click";
const pulse = action === "double_click" ? (progress * 2) % 1 : progress;
ripple.style.opacity = pressing && !reducedMotion ? String(0.55 * (1 - pulse)) : "0";
ripple.style.transform = `scale(${0.35 + pulse * 1.15})`;
cursor.style.setProperty("--agent-cursor-press", pressing && pulse < 0.35 ? "0.86" : "1");
const nextTyping = action === "type" || action === "key";
if (nextTyping !== shownTyping) {
shownTyping = nextTyping;
setTyping(nextTyping);
}
};
const rest = () => {
clearTimeout(idleTimer);
idleTimer = setTimeout(() => {
cursor.style.opacity = "0";
}, POINTER_IDLE_MS);
};
const tick = () => {
frame = 0;
const now = performance.now();
const state = pointerFrameAt(segments, now, reducedMotion);
if (!state) return;
position = state.point;
place(state.point, state.action?.kind ?? null, state.action?.progress ?? 0);
if (state.done) {
segments = [];
rest();
return;
}
frame = requestAnimationFrame(tick);
};
const unsubscribe = agentPointerFeed.subscribe(botId, (payload) => {
const now = performance.now();
const current = pointerFrameAt(segments, now, reducedMotion)?.point ?? position;
const sameScreen =
screen?.width === payload.screen.width && screen?.height === payload.screen.height;
screen = payload.screen;
// A first announcement starts at its target rather than flying in from a corner.
const firstTarget = payload.steps.find((step) => typeof step.x === "number");
const from =
current && sameScreen
? current
: firstTarget
? { x: firstTarget.x ?? 0, y: firstTarget.y ?? 0 }
: { x: payload.screen.width / 2, y: payload.screen.height / 2 };
segments = planPointerTimeline(from, payload.steps, now);
clearTimeout(idleTimer);
cursor.style.opacity = "1";
if (!frame) frame = requestAnimationFrame(tick);
});
const resize = new ResizeObserver(() => {
viewer = { width: layer.clientWidth, height: layer.clientHeight };
if (position && !frame) place(position, null, 0);
});
resize.observe(layer);
return () => {
unsubscribe();
resize.disconnect();
cancelAnimationFrame(frame);
clearTimeout(idleTimer);
};
}, [botId]);
return (
<div
ref={layerRef}
aria-hidden
data-testid="agent-cursor-layer"
className="pointer-events-none absolute inset-0 overflow-hidden"
>
<div
ref={cursorRef}
data-testid="agent-cursor"
className="absolute top-0 left-0 opacity-0 transition-opacity duration-300 will-change-transform"
style={{ color }}
>
<span
ref={rippleRef}
className="absolute -top-5 -left-5 size-10 rounded-full border-2 border-current opacity-0"
/>
<svg
viewBox="0 0 20 22"
className="relative block size-5 origin-top-left drop-shadow-md transition-transform duration-100"
style={{ transform: "scale(var(--agent-cursor-press, 1))" }}
>
<path
d="M1.5 1.5 L1.5 17.5 L6 13.2 L9.2 20.2 L12.4 18.8 L9.3 12 L15.5 12 Z"
fill="currentColor"
className="stroke-background"
strokeWidth="1.6"
strokeLinejoin="round"
/>
</svg>
<span className="absolute top-5 left-3.5 flex max-w-40 items-center gap-1 rounded-full bg-popover px-2 py-0.5 text-ui-label font-medium whitespace-nowrap text-popover-foreground shadow-sm ring-1 ring-border">
<span className="size-1.5 shrink-0 rounded-full bg-current" style={{ color }} />
<span className="truncate">{name}</span>
{typing ? <Keyboard strokeWidth={1.8} className="size-3 shrink-0" /> : null}
</span>
</div>
</div>
);
}

View File

@ -134,7 +134,7 @@ export function OnboardingScanProgress({
> >
<div className="flex-shrink-0"> <div className="flex-shrink-0">
{isDone ? ( {isDone ? (
<span className="grid size-6 items-center justify-center rounded-full bg-emerald-500/20 text-emerald-500 text-sm"> <span className="grid size-6 items-center justify-center rounded-full bg-emerald-500/20 text-emerald-700 text-sm dark:text-emerald-500">
✓ ✓
</span> </span>
) : isActive ? ( ) : isActive ? (
@ -174,7 +174,7 @@ export function OnboardingScanProgress({
<div className="mt-8 text-center"> <div className="mt-8 text-center">
<button <button
type="button" type="button"
className="rounded-xl bg-primary px-6 py-3 text-sm font-medium text-white" className="rounded-xl bg-primary px-6 py-3 text-sm font-medium text-primary-foreground"
onClick={onComplete} onClick={onComplete}
> >
Continue to your workspace → Continue to your workspace →

View File

@ -421,7 +421,7 @@ export function OoGroupStage({
if (sparkLabel && groupSpark?.kind === "peer-reply") return sparkLabel; if (sparkLabel && groupSpark?.kind === "peer-reply") return sparkLabel;
if (awarenessLabel) return awarenessLabel; if (awarenessLabel) return awarenessLabel;
if (sparkLabel) return sparkLabel; if (sparkLabel) return sparkLabel;
if (!speaker) return t`Gruppenunterhaltung`; if (!speaker) return t`Group conversation`;
if (running && workingBots.length > 1) { if (running && workingBots.length > 1) {
return t`${workingBots.length} Oos are working — ${speaker.name} has the floor`; return t`${workingBots.length} Oos are working — ${speaker.name} has the floor`;
} }
@ -495,7 +495,7 @@ export function OoGroupStage({
<h2 className="mt-1.5 truncate text-ui-title font-semibold tracking-tight text-foreground"> <h2 className="mt-1.5 truncate text-ui-title font-semibold tracking-tight text-foreground">
{groupName ?? t`Group`} {groupName ?? t`Group`}
</h2> </h2>
<p className="truncate text-ui-body-sm text-muted-foreground">{t`Gruppenunterhaltung`}</p> <p className="truncate text-ui-body-sm text-muted-foreground">{t`Group conversation`}</p>
</div> </div>
{awarenessLabel && awarenessEvent && speaker ? ( {awarenessLabel && awarenessEvent && speaker ? (

View File

@ -55,22 +55,22 @@ export function StageHero({
) : null} ) : null}
<div <div
className={`relative flex ${orbSize} items-center justify-center rounded-full border border-border bg-muted/45 shadow-[0_16px_48px_rgba(0,0,0,0.45),inset_0_1px_0_rgba(255,255,255,0.12)] backdrop-blur-xl`} className={`relative flex ${orbSize} items-center justify-center rounded-full border border-border bg-muted/45 shadow-[0_12px_32px_-12px_rgba(24,24,22,0.18)] backdrop-blur-xl dark:shadow-[0_16px_48px_rgba(0,0,0,0.45),inset_0_1px_0_rgba(255,255,255,0.12)]`}
style={
reducedMotion
? undefined
: {
boxShadow: `0 16px 48px rgba(0,0,0,0.45), inset 0 1px 0 rgba(255,255,255,0.12), 0 0 ${Math.round(18 + energy * 32)}px ${glowRgb}`,
}
}
> >
{reducedMotion ? null : (
<div
aria-hidden
className="pointer-events-none absolute inset-0 rounded-full"
style={{ boxShadow: `0 0 ${Math.round(18 + energy * 32)}px ${glowRgb}` }}
/>
)}
<BotAvatar <BotAvatar
color={botColor} color={botColor}
identity={botId} identity={botId}
status={botStatus} status={botStatus}
pose={pose} pose={pose}
size={avatarSize} size={avatarSize}
className="drop-shadow-[0_6px_24px_rgba(0,0,0,0.4)]" className="drop-shadow-[0_6px_16px_rgba(0,0,0,0.12)] dark:drop-shadow-[0_6px_24px_rgba(0,0,0,0.4)]"
/> />
</div> </div>

View File

@ -21,8 +21,8 @@ export function StageVoiceEntry({
variant="outline" variant="outline"
onClick={onStartVoice} onClick={onStartVoice}
data-testid="stage-voice-primary" data-testid="stage-voice-primary"
aria-label={voiceReady ? t`Sprechen starten` : t`Sprache einrichten`} aria-label={voiceReady ? t`Start talking` : t`Set up voice`}
title={voiceReady ? t`Sprechen starten` : t`Sprache einrichten`} title={voiceReady ? t`Start talking` : t`Set up voice`}
className={`tactile-press h-control-lg w-full max-w-sm gap-2.5 rounded-full px-6 text-ui-body font-semibold transition-all ${ className={`tactile-press h-control-lg w-full max-w-sm gap-2.5 rounded-full px-6 text-ui-body font-semibold transition-all ${
voiceReady voiceReady
? "relative overflow-hidden border-border bg-foreground/5 text-foreground hover:bg-foreground/10" ? "relative overflow-hidden border-border bg-foreground/5 text-foreground hover:bg-foreground/10"
@ -33,10 +33,10 @@ export function StageVoiceEntry({
<VoiceAuroraRim botColor={botColor} className="inset-x-[12%] h-0.5 blur-[3px]" /> <VoiceAuroraRim botColor={botColor} className="inset-x-[12%] h-0.5 blur-[3px]" />
) : null} ) : null}
<Mic strokeWidth={2} className="size-icon-lg shrink-0" aria-hidden="true" /> <Mic strokeWidth={2} className="size-icon-lg shrink-0" aria-hidden="true" />
{voiceReady ? <Trans>Sprechen starten</Trans> : <Trans>Sprache einrichten</Trans>} {voiceReady ? <Trans>Start talking</Trans> : <Trans>Set up voice</Trans>}
</Button> </Button>
<p className="text-center text-ui-caption text-muted-foreground"> <p className="text-center text-ui-caption text-muted-foreground">
<Trans>Primär per Stimme — Transkript unten</Trans> <Trans>Voice first — transcript below</Trans>
</p> </p>
</div> </div>
); );

View File

@ -41,7 +41,7 @@ export function TextStageCanvas({
style={{ style={{
background: `radial-gradient(circle, ${phaseTint} 0%, ${personaTint} 42%, transparent 72%)`, background: `radial-gradient(circle, ${phaseTint} 0%, ${personaTint} 42%, transparent 72%)`,
opacity: 0.55 + energy * 0.2, opacity: 0.55 + energy * 0.2,
transform: `translateX(-50%) scale(${bloomScale})`, transform: `scale(${bloomScale})`,
transition: reducedMotion transition: reducedMotion
? undefined ? undefined
: "transform 140ms ease-out, opacity 140ms ease-out", : "transform 140ms ease-out, opacity 140ms ease-out",

View File

@ -0,0 +1,38 @@
import { useLingui } from "@lingui/react/macro";
import { useEffect, useState } from "react";
import { type ArtifactTarget, fetchArtifactBytes } from "../lib/artifact-open";
export type ArtifactBytesState =
| { status: "loading" }
| { status: "ready"; bytes: Uint8Array }
| { status: "error"; message: string };
export function useArtifactBytes(target: ArtifactTarget, artifactId: string): ArtifactBytesState {
const { t } = useLingui();
const [state, setState] = useState<ArtifactBytesState>({ status: "loading" });
const targetBotId = "botId" in target ? target.botId : undefined;
const targetGroupId = "groupId" in target ? target.groupId : undefined;
useEffect(() => {
let cancelled = false;
setState({ status: "loading" });
const artifactTarget: ArtifactTarget =
targetBotId !== undefined ? { botId: targetBotId } : { groupId: targetGroupId! };
void fetchArtifactBytes(artifactTarget, artifactId)
.then((bytes) => {
if (!cancelled) setState({ status: "ready", bytes });
})
.catch((error) => {
if (cancelled) return;
setState({
status: "error",
message: error instanceof Error ? error.message : t`Could not load this file.`,
});
});
return () => {
cancelled = true;
};
}, [artifactId, targetBotId, targetGroupId, t]);
return state;
}

View File

@ -1,13 +1,20 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { readPaneWidth, writePaneWidth } from "../lib/workspace-pane-pref";
export function useSidebarResize( export function useSidebarResize(
initialWidth: number, initialWidth: number,
side: "left" | "right", side: "left" | "right",
minWidth: number, minWidth: number,
maxWidth: number, maxWidth: number,
/** Remembers the width across reloads on this device. */
storageKey?: string,
) { ) {
const [width, setWidth] = useState(initialWidth); const [width, setWidth] = useState(() =>
storageKey ? readPaneWidth(storageKey, initialWidth, minWidth, maxWidth) : initialWidth,
);
const [isResizing, setIsResizing] = useState(false); const [isResizing, setIsResizing] = useState(false);
const widthRef = useRef(width);
widthRef.current = width;
useEffect(() => { useEffect(() => {
if (!isResizing) return; if (!isResizing) return;
@ -26,6 +33,7 @@ export function useSidebarResize(
const handlePointerUp = () => { const handlePointerUp = () => {
setIsResizing(false); setIsResizing(false);
document.body.style.cursor = "default"; document.body.style.cursor = "default";
if (storageKey) writePaneWidth(storageKey, widthRef.current);
}; };
window.addEventListener("pointermove", handlePointerMove); window.addEventListener("pointermove", handlePointerMove);
@ -38,7 +46,7 @@ export function useSidebarResize(
window.removeEventListener("pointerup", handlePointerUp); window.removeEventListener("pointerup", handlePointerUp);
document.body.style.cursor = "default"; document.body.style.cursor = "default";
}; };
}, [isResizing, side, minWidth, maxWidth]); }, [isResizing, side, minWidth, maxWidth, storageKey]);
const startResizing = useCallback((e: React.PointerEvent) => { const startResizing = useCallback((e: React.PointerEvent) => {
e.preventDefault(); e.preventDefault();

View File

@ -0,0 +1,16 @@
import { useEffect, useState } from "react";
/** Wide enough to keep the conversation readable beside a working pane. */
const WIDE_LAYOUT_QUERY = "(min-width: 1024px)";
export function useWideLayout(): boolean {
const [wide, setWide] = useState(() => window.matchMedia(WIDE_LAYOUT_QUERY).matches);
useEffect(() => {
const media = window.matchMedia(WIDE_LAYOUT_QUERY);
const sync = () => setWide(media.matches);
sync();
media.addEventListener("change", sync);
return () => media.removeEventListener("change", sync);
}, []);
return wide;
}

View File

@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest"; import { i18n } from "@lingui/core";
import { beforeAll, describe, expect, it } from "vitest";
import { import {
isDoingStatus, isDoingStatus,
isNeedsYouStatus, isNeedsYouStatus,
@ -9,6 +10,11 @@ import {
} from "./agent-activity"; } from "./agent-activity";
describe("agent-activity", () => { describe("agent-activity", () => {
beforeAll(() => {
i18n.load("en", {});
i18n.activate("en");
});
it("classifies needs-you and doing statuses", () => { it("classifies needs-you and doing statuses", () => {
expect(isNeedsYouStatus("waiting_input")).toBe(true); expect(isNeedsYouStatus("waiting_input")).toBe(true);
expect(isNeedsYouStatus("waiting_takeover")).toBe(true); expect(isNeedsYouStatus("waiting_takeover")).toBe(true);
@ -17,16 +23,16 @@ describe("agent-activity", () => {
expect(isDoingStatus("idle")).toBe(false); expect(isDoingStatus("idle")).toBe(false);
}); });
it("returns German doing verbs instead of raw status", () => { it("returns doing verbs instead of raw status", () => {
expect(rosterDoingVerb("waiting_input")).toBe("Braucht deine Antwort"); expect(rosterDoingVerb("waiting_input")).toBe("Needs your reply");
expect(rosterDoingVerb("running")).toBe("Arbeitet…"); expect(rosterDoingVerb("running")).toBe("Working…");
expect(rosterDoingVerb("idle")).toBeNull(); expect(rosterDoingVerb("idle")).toBeNull();
}); });
it("builds roster subtitle with verb priority", () => { it("builds roster subtitle with verb priority", () => {
expect(rosterActivitySubtitle("running", "Some preview")).toBe("Arbeitet…"); expect(rosterActivitySubtitle("running", "Some preview")).toBe("Working…");
expect(rosterActivitySubtitle("idle", "Hello there")).toBe("Hello there"); expect(rosterActivitySubtitle("idle", "Hello there")).toBe("Hello there");
expect(rosterActivitySubtitle("idle", "")).toBe("Bereit"); expect(rosterActivitySubtitle("idle", "")).toBe("Ready");
}); });
it("maps status dot tones", () => { it("maps status dot tones", () => {
@ -36,7 +42,7 @@ describe("agent-activity", () => {
}); });
it("returns needs-you chip label only for waiting states", () => { it("returns needs-you chip label only for waiting states", () => {
expect(needsYouChipLabel("waiting_input")).toBe("Braucht dich"); expect(needsYouChipLabel("waiting_input")).toBe("Needs you");
expect(needsYouChipLabel("running")).toBeNull(); expect(needsYouChipLabel("running")).toBeNull();
}); });
}); });

View File

@ -1,5 +1,9 @@
import { i18n } from "@lingui/core";
import type { RunActivityRow } from "@rakazo/contracts"; import type { RunActivityRow } from "@rakazo/contracts";
// Labels use `i18n._` with literal ids instead of the Lingui macro so this module stays
// importable from plain unit tests; the extractor still collects them.
/** Run statuses where the agent is blocked on the user. */ /** Run statuses where the agent is blocked on the user. */
export function isNeedsYouStatus(status: string | undefined): boolean { export function isNeedsYouStatus(status: string | undefined): boolean {
return status === "waiting_input" || status === "waiting_takeover"; return status === "waiting_input" || status === "waiting_takeover";
@ -18,19 +22,19 @@ export function isFailedRoutine(run: RunActivityRow): boolean {
return run.trigger === "routine" && run.status === "failed"; return run.trigger === "routine" && run.status === "failed";
} }
/** German doing-verb for roster / activity surfaces — never raw status strings. */ /** Doing-verb for roster / activity surfaces — never raw status strings. */
export function rosterDoingVerb(status: string | undefined): string | null { export function rosterDoingVerb(status: string | undefined): string | null {
switch (status) { switch (status) {
case "queued": case "queued":
return "Wartet an…"; return i18n._("Queued…");
case "leased": case "leased":
return "Startet…"; return i18n._("Starting…");
case "running": case "running":
return "Arbeitet…"; return i18n._("Working…");
case "waiting_input": case "waiting_input":
return "Braucht deine Antwort"; return i18n._("Needs your reply");
case "waiting_takeover": case "waiting_takeover":
return "Braucht dich"; return i18n._("Needs you");
default: default:
return null; return null;
} }
@ -39,27 +43,27 @@ export function rosterDoingVerb(status: string | undefined): string | null {
/** Short chip label for needs-you states. */ /** Short chip label for needs-you states. */
export function needsYouChipLabel(status: string | undefined): string | null { export function needsYouChipLabel(status: string | undefined): string | null {
if (!isNeedsYouStatus(status)) return null; if (!isNeedsYouStatus(status)) return null;
return status === "waiting_takeover" ? "Braucht dich" : "Braucht dich"; return i18n._("Needs you");
} }
export function runActivityLabel(status: RunActivityRow["status"]): string { export function runActivityLabel(status: RunActivityRow["status"]): string {
switch (status) { switch (status) {
case "queued": case "queued":
return "Wartet an…"; return i18n._("Queued…");
case "leased": case "leased":
return "Startet…"; return i18n._("Starting…");
case "running": case "running":
return "Arbeitet…"; return i18n._("Working…");
case "waiting_input": case "waiting_input":
return "Braucht deine Antwort"; return i18n._("Needs your reply");
case "waiting_takeover": case "waiting_takeover":
return "Braucht dich"; return i18n._("Needs you");
case "completed": case "completed":
return "Erledigt"; return i18n._("Done");
case "failed": case "failed":
return "Fehlgeschlagen"; return i18n._("Failed");
case "cancelled": case "cancelled":
return "Abgebrochen"; return i18n._("Cancelled");
default: default:
return status; return status;
} }
@ -69,7 +73,7 @@ export function runActivityLabel(status: RunActivityRow["status"]): string {
export function rosterActivitySubtitle( export function rosterActivitySubtitle(
status: string | undefined, status: string | undefined,
preview: string | undefined, preview: string | undefined,
idleFallback = "Bereit", idleFallback = i18n._("Ready"),
): string { ): string {
const verb = rosterDoingVerb(status); const verb = rosterDoingVerb(status);
if (verb) return verb; if (verb) return verb;

View File

@ -0,0 +1,88 @@
import { describe, expect, it } from "vitest";
import {
agentPointerFeed,
moveDurationMs,
planPointerTimeline,
pointerFrameAt,
} from "./agent-pointer";
const payload = {
kind: "pointer",
screen: { width: 1280, height: 800 },
steps: [{ action: "click", x: 100, y: 100 }],
};
describe("agentPointerFeed", () => {
it("delivers valid live announcements only to the bot's viewers", () => {
const seen: unknown[] = [];
const other: unknown[] = [];
const stop = agentPointerFeed.subscribe("bot-a", (value) => seen.push(value));
const stopOther = agentPointerFeed.subscribe("bot-b", (value) => other.push(value));
try {
const now = Date.parse("2026-01-01T00:00:10Z");
expect(agentPointerFeed.publish("bot-a", payload, "2026-01-01T00:00:05Z", now)).toBe(true);
// Replayed history and malformed payloads never move the cursor.
expect(agentPointerFeed.publish("bot-a", payload, "2025-12-31T23:00:00Z", now)).toBe(false);
expect(agentPointerFeed.publish("bot-a", { ...payload, steps: [] }, undefined, now)).toBe(
false,
);
expect(agentPointerFeed.publish("bot-a", { kind: "status" }, undefined, now)).toBe(false);
expect(seen).toEqual([payload]);
expect(other).toEqual([]);
} finally {
stop();
stopOther();
}
expect(agentPointerFeed.publish("bot-a", payload)).toBe(true);
expect(seen).toHaveLength(1);
});
});
describe("pointer timeline", () => {
it("travels, acts in place for keyboard steps, then rests", () => {
const segments = planPointerTimeline(
{ x: 0, y: 0 },
[{ action: "click", x: 300, y: 400 }, { action: "type" }, { action: "move", x: 300, y: 400 }],
1_000,
);
expect(segments.map((segment) => segment.to)).toEqual([
{ x: 300, y: 400 },
{ x: 300, y: 400 },
{ x: 300, y: 400 },
]);
const [click, type, move] = segments;
expect(click!.arrive - click!.start).toBe(moveDurationMs(500));
expect(type!.arrive).toBe(type!.start);
expect(move!.end).toBe(move!.start);
const midway = pointerFrameAt(segments, (click!.start + click!.arrive) / 2)!;
expect(midway.action).toBeNull();
// The bow lifts the cursor off the straight line between the points.
const onLine = { x: 150, y: 200 };
expect(Math.hypot(midway.point.x - onLine.x, midway.point.y - onLine.y)).toBeGreaterThan(10);
const clicking = pointerFrameAt(segments, click!.arrive + 1)!;
expect(clicking.point).toEqual({ x: 300, y: 400 });
expect(clicking.action?.kind).toBe("click");
const typing = pointerFrameAt(segments, type!.arrive + 10)!;
expect(typing.action?.kind).toBe("type");
expect(pointerFrameAt(segments, move!.end + 1)).toEqual({
point: { x: 300, y: 400 },
action: null,
done: true,
});
});
it("jumps straight to the target when motion is reduced", () => {
const segments = planPointerTimeline({ x: 0, y: 0 }, [{ action: "move", x: 50, y: 60 }], 0);
expect(pointerFrameAt(segments, 1, true)?.point).toEqual({ x: 50, y: 60 });
});
it("bounds travel time so long and short moves both feel responsive", () => {
expect(moveDurationMs(0)).toBe(0);
expect(moveDurationMs(5)).toBe(180);
expect(moveDurationMs(10_000)).toBe(650);
});
});

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