Compare commits
19 Commits
claude/ama
...
claude/pro
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b3bbcc540 | ||
|
|
0c3e3f3d29 | ||
|
|
ece3638ce7 | ||
|
|
ac2ac2713e | ||
|
|
59f4a1dfca | ||
|
|
735322b016 | ||
|
|
c4c5ce3c2d | ||
|
|
4ba7d843b1 | ||
|
|
2c6b5c3d7b | ||
|
|
8449dec6c2 | ||
|
|
a51ba87133 | ||
|
|
0d1939c775 | ||
|
|
d912712668 | ||
|
|
e28c3acf02 | ||
|
|
a15da068d7 | ||
|
|
49431def83 | ||
|
|
3c3961f242 | ||
|
|
2b967df569 | ||
|
|
f8eabee997 |
@ -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.
|
|
||||||
20
.env.example
20
.env.example
@ -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
|
||||||
|
|||||||
28
.github/workflows/ci.yml
vendored
28
.github/workflows/ci.yml
vendored
@ -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
|
||||||
|
|||||||
4
.github/workflows/publish-server-image.yml
vendored
4
.github/workflows/publish-server-image.yml
vendored
@ -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
1
.gitignore
vendored
@ -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/
|
||||||
|
|||||||
@ -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`.
|
|
||||||
@ -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.
|
|
||||||
@ -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
|
|
||||||
@ -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)
|
|
||||||
@ -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.
|
||||||
|
|||||||
@ -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`
|
||||||
|
|||||||
2
VOICE.md
2
VOICE.md
@ -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.
|
||||||
|
|
||||||
|
|||||||
@ -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
|
|
||||||
```
|
|
||||||
@ -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.
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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.")
|
|
||||||
@ -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}")
|
|
||||||
@ -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
|
|
||||||
@ -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()
|
|
||||||
@ -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",
|
||||||
|
|||||||
74
apps/api/src/bot-import.test.ts
Normal file
74
apps/api/src/bot-import.test.ts
Normal 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",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
34
apps/api/src/bot-import.ts
Normal file
34
apps/api/src/bot-import.ts
Normal 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));
|
||||||
|
}
|
||||||
@ -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(
|
||||||
|
|||||||
@ -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),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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 };
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
143
apps/api/src/thread-message-pages.postgres.test.ts
Normal file
143
apps/api/src/thread-message-pages.postgres.test.ts
Normal 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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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);
|
||||||
|
|
||||||
|
|||||||
@ -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"),
|
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
@ -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 });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -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.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
155
apps/desktop/e2e/browser-preview.spec.ts
Normal file
155
apps/desktop/e2e/browser-preview.spec.ts
Normal 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();
|
||||||
|
}
|
||||||
|
});
|
||||||
@ -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",
|
||||||
|
|||||||
96
apps/desktop/src/browser-preview-policy.test.ts
Normal file
96
apps/desktop/src/browser-preview-policy.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
76
apps/desktop/src/browser-preview-policy.ts
Normal file
76
apps/desktop/src/browser-preview-policy.ts
Normal 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})`;
|
||||||
|
}
|
||||||
189
apps/desktop/src/browser-preview.ts
Normal file
189
apps/desktop/src/browser-preview.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)!, {
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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={{
|
||||||
|
|||||||
@ -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") {
|
||||||
|
|||||||
@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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([
|
||||||
|
|||||||
@ -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[] = [];
|
||||||
|
|||||||
104
apps/web/e2e/adaptive-workspace.spec.ts
Normal file
104
apps/web/e2e/adaptive-workspace.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
53
apps/web/e2e/agent-cursor.spec.ts
Normal file
53
apps/web/e2e/agent-cursor.spec.ts
Normal 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 });
|
||||||
|
});
|
||||||
@ -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);
|
||||||
|
|||||||
@ -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}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@ -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")',
|
||||||
|
|||||||
@ -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) => {
|
||||||
|
|||||||
@ -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();
|
|
||||||
});
|
|
||||||
@ -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(),
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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");
|
||||||
|
|||||||
159
apps/web/e2e/in-app-browser.spec.ts
Normal file
159
apps/web/e2e/in-app-browser.spec.ts
Normal 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");
|
||||||
|
});
|
||||||
@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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", {
|
||||||
|
|||||||
87
apps/web/e2e/project-main-agent.spec.ts
Normal file
87
apps/web/e2e/project-main-agent.spec.ts
Normal 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");
|
||||||
|
});
|
||||||
@ -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 });
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -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");
|
||||||
|
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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",
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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 />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 (
|
||||||
<>
|
<>
|
||||||
|
|||||||
165
apps/web/src/components/ArtifactPane.tsx
Normal file
165
apps/web/src/components/ArtifactPane.tsx
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -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>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@ -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();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
317
apps/web/src/components/browser/InAppBrowser.tsx
Normal file
317
apps/web/src/components/browser/InAppBrowser.tsx
Normal 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" />;
|
||||||
|
}
|
||||||
165
apps/web/src/components/computer/AgentCursor.tsx
Normal file
165
apps/web/src/components/computer/AgentCursor.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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 →
|
||||||
|
|||||||
@ -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 ? (
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
38
apps/web/src/hooks/useArtifactBytes.ts
Normal file
38
apps/web/src/hooks/useArtifactBytes.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
|||||||
16
apps/web/src/hooks/useWideLayout.ts
Normal file
16
apps/web/src/hooks/useWideLayout.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
88
apps/web/src/lib/agent-pointer.test.ts
Normal file
88
apps/web/src/lib/agent-pointer.test.ts
Normal 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
Loading…
x
Reference in New Issue
Block a user