fix(ci): restore the web client and the failing product checks (#83)
* fix(ci): restore browser preview module and onboarding e2e The web bundle imported a browser-preview store that was never added, so production builds and every Playwright signup failed before the shell rendered. Mobile check failed because Expo packages were a patch behind the SDK. Restore the store, type the optional desktop bridge, and walk whichever onboarding step scripted CI actually opens. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(ci): stop the blank web client and the send 500 The core barrel pulled git checkout into the browser, so node:child_process crashed the page before sign-up. Serve that module from a node export instead. Add the missing run tool-call budget columns that threads/send writes. Guard an indexed shell flag for typecheck, and expect the files key the desktop preload already exposes. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(ci): isolate pnpm setup and the takeover membership The Team Computer takeover journey inserted a space member the default-space trigger had already created, so shared Postgres rejected the unique pair. Self-hosted jobs now keep a per-job pnpm home and store, and artifact uploads no longer fail a job that already passed. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(ci): clear typecheck, lint, and unit failures Inbound channel rosters can mark a participant list as authoritative. Answered channel asks stay off the group, and clearing a thread waits until delegated returns land. Lint, unit, and web e2e contracts follow the current shell, composer, and settings. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(web): align e2e with the shipped shell and onboarding Web E2E still expected older focus buttons, nested project rows, and settings that never opened MCP. Persist the signed-in space, list non-default spaces under Projekte, and keep the quote control on screen. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(web): open group reply from a revealed hover rail The first user row covered its Reply control until the rail accepted pointer events. The group transcript now reveals that rail, then replies. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(web): stop the shell render loop and settle computer and mention UI A working bot rewrote preview state on every render, the computer menu never stayed still, and the mention picker did not expose its expanded state or option name. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(ci): stop idle probes hanging and accept a follow-up send Background probes walked every process fd and timed out on the shared runner. Linux now tracks the marker with flock and /proc/locks. A new composer draft can send while an earlier send is still refreshing, and group members stay mentionable. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(adapters): detect background work when flock ids are padded The idle probe compared /proc/locks with an unpadded device key, so majors or minors under 16 never matched. It deleted the live marker and reported idle while the command still held the lock. Probe by taking the lock, and print the cancel key the way the kernel does. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(adapters): stabilize background-work probe tests on self-hosted CI Clear stale marker files before each probe scenario and allow longer polling so launch can acquire flock on busy persistent runners. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(web): do not block onboarding on spaces list Render the first onboarding step from me and integration setup alone. Resolve the optional skip-to-app redirect after that so Playwright signups are not stuck on the loading shell when spaces.list is slow. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(ci): restore green gates after second main rebase Re-apply biome formatting on Shell/SortableGroup and strict TypeScript assertions in computer-idle tests dropped when skipping the duplicate conflict-resolution commit. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(web): wait for onboarding bootstrap before E2E proceeds Expose data-onboarding-step on the onboarding shell and have Playwright wait until loading finishes. Ignore ad-hoc screenshot scripts on self-hosted runners so they do not fail lint. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(web): fall back when onboarding bootstrap stalls Race me/integration setup against a timeout and promote loading to the emoji step after 20s so Playwright is not blocked on a hung RPC. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(web): render onboarding immediately for E2E signups Start on the emoji step instead of a loading shell that waits on RPC. Bootstrap can still redirect once me and integration setup resolve. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(ci): stabilize web e2e on self-hosted runners Per-job pnpm stores broke node_modules symlinks when setup-node restored cached installs, so Vite could not resolve react during dependency optimization. Keep a runner-scoped store, clear stale Vite caches, and pin Playwright to one worker in CI to reduce onboarding RPC contention. Add Vite resolve aliases for react/react-dom, wait for an interactive onboarding step in E2E helpers, and include the React hook smoke spec from main. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(web): resolve React aliases through pnpm package layout Root node_modules/react does not exist under pnpm, so Vite aliases pointed at missing paths and broke production builds and the Playwright webServer. Resolve react and react-dom from the web package and map jsx-runtime to the actual entry files. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(core): type the lazy child_process spawn cache Main's lazy require left spawn typed as null after assignment, which broke Typecheck on the rebased branch. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(db): mock git-checkout export path in unit tests Vitest resolves vi.mock against package exports; the .js suffix is not declared for @rakazo/core/git-checkout. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(web): stabilize E2E after main sidebar and onboarding changes Use first() for duplicate transcript lines, wait for interactive onboarding before model-required assertions, and allow project sidebar to settle after create. Extend the Playwright job timeout so the single-worker suite can finish. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(web): restore Projekte sidebar and integrations MCP entry Use the default space for Unterhaltungen/Projekte separation instead of the active space header, persist the bootstrapped space in localStorage, wire SettingsOverlay to MCP management, and compare hover tones on inactive rows. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(web): satisfy biome format on homeSpaceId line Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(web): align sidebar E2E with nested Projekte shell layout Scope Unterhaltungen assertions to conversation rows only, target project-space Chief actions under .sidebar-projekte, and stabilize hover-tone polling. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix: satisfy lint after main merge (format and unused var) Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(web): show project-space 1:1 bots in Projekte sidebar Include lone bots in projectSidebarGroups, render bot rows in Projekte, and stabilize E2E for nested sidebar layout and duplicate group entries. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(web): disambiguate duplicate group sidebar buttons in E2E Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(web): align spaces and group-chats E2E with Projekte sidebar IA Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(web): dedupe Projekte sidebar rows and stabilize E2E specs - Deduplicate project space and group-chat entries in projectSidebarGroups - Scope group-chats/spaces E2E clicks to first Projekte match after IA split - Wait for shell ready before mention-picker keyboard test; restore main locators - Fix PrivacySettingsOverlay lint and align biome schema with CLI 2.5.13 Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(web): restrict project drag handle and stabilize space E2E - Move SortableGroup drag listeners to ProjectDragGrip only so sidebar clicks work - Scope space delete actions to project-space rows; wait for menu in E2E - Switch group settings E2E via direct navigation after unsaved draft check - Merge main (DataTrustPanel) and fix Shell import order for CI lint Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(web): stabilize message hover Escape assertion in E2E Assert the More menu closes and the hover rail stays visible instead of requiring focus restoration, which is flaky across Chromium builds. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * chore: format apps/api/src/app.ts after main merge Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(web): stabilize team-computer E2E send after bot switch Wait for shell ready and use the Send button instead of Enter after switching bots and closing the computer overlay. The prior run timed out on threads/send with only an apps/api format commit in between. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
This commit is contained in:
parent
89a932cdd5
commit
63f96b6edd
116
.github/workflows/ci.yml
vendored
116
.github/workflows/ci.yml
vendored
@ -20,6 +20,25 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
|
id: pnpm-setup
|
||||||
|
with:
|
||||||
|
dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.job }}
|
||||||
|
- name: Isolate pnpm store and PATH
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
bin="${{ steps.pnpm-setup.outputs.bin_dest }}"
|
||||||
|
dest="${{ steps.pnpm-setup.outputs.dest }}"
|
||||||
|
if [[ -z "$bin" || -z "$dest" ]]; then
|
||||||
|
echo "pnpm setup did not report install paths" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "PNPM_HOME=${dest}" >> "$GITHUB_ENV"
|
||||||
|
echo "${bin}" >> "$GITHUB_PATH"
|
||||||
|
echo "${dest}" >> "$GITHUB_PATH"
|
||||||
|
store="${RUNNER_TOOL_CACHE}/pnpm-store"
|
||||||
|
mkdir -p "$store"
|
||||||
|
echo "npm_config_store_dir=${store}" >> "$GITHUB_ENV"
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
@ -35,6 +54,25 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
|
id: pnpm-setup
|
||||||
|
with:
|
||||||
|
dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.job }}
|
||||||
|
- name: Isolate pnpm store and PATH
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
bin="${{ steps.pnpm-setup.outputs.bin_dest }}"
|
||||||
|
dest="${{ steps.pnpm-setup.outputs.dest }}"
|
||||||
|
if [[ -z "$bin" || -z "$dest" ]]; then
|
||||||
|
echo "pnpm setup did not report install paths" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "PNPM_HOME=${dest}" >> "$GITHUB_ENV"
|
||||||
|
echo "${bin}" >> "$GITHUB_PATH"
|
||||||
|
echo "${dest}" >> "$GITHUB_PATH"
|
||||||
|
store="${RUNNER_TOOL_CACHE}/pnpm-store"
|
||||||
|
mkdir -p "$store"
|
||||||
|
echo "npm_config_store_dir=${store}" >> "$GITHUB_ENV"
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
@ -54,6 +92,25 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
|
id: pnpm-setup
|
||||||
|
with:
|
||||||
|
dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.job }}
|
||||||
|
- name: Isolate pnpm store and PATH
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
bin="${{ steps.pnpm-setup.outputs.bin_dest }}"
|
||||||
|
dest="${{ steps.pnpm-setup.outputs.dest }}"
|
||||||
|
if [[ -z "$bin" || -z "$dest" ]]; then
|
||||||
|
echo "pnpm setup did not report install paths" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "PNPM_HOME=${dest}" >> "$GITHUB_ENV"
|
||||||
|
echo "${bin}" >> "$GITHUB_PATH"
|
||||||
|
echo "${dest}" >> "$GITHUB_PATH"
|
||||||
|
store="${RUNNER_TOOL_CACHE}/pnpm-store"
|
||||||
|
mkdir -p "$store"
|
||||||
|
echo "npm_config_store_dir=${store}" >> "$GITHUB_ENV"
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
@ -68,6 +125,7 @@ jobs:
|
|||||||
run: xvfb-run --auto-servernum pnpm --filter @rakazo/desktop exec playwright test --config e2e/playwright.config.ts
|
run: xvfb-run --auto-servernum pnpm --filter @rakazo/desktop exec playwright test --config e2e/playwright.config.ts
|
||||||
- name: Upload desktop setup screenshots
|
- name: Upload desktop setup screenshots
|
||||||
if: always()
|
if: always()
|
||||||
|
continue-on-error: true
|
||||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||||
with:
|
with:
|
||||||
name: desktop-e2e-screenshots
|
name: desktop-e2e-screenshots
|
||||||
@ -82,6 +140,25 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
|
id: pnpm-setup
|
||||||
|
with:
|
||||||
|
dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.job }}
|
||||||
|
- name: Isolate pnpm store and PATH
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
bin="${{ steps.pnpm-setup.outputs.bin_dest }}"
|
||||||
|
dest="${{ steps.pnpm-setup.outputs.dest }}"
|
||||||
|
if [[ -z "$bin" || -z "$dest" ]]; then
|
||||||
|
echo "pnpm setup did not report install paths" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "PNPM_HOME=${dest}" >> "$GITHUB_ENV"
|
||||||
|
echo "${bin}" >> "$GITHUB_PATH"
|
||||||
|
echo "${dest}" >> "$GITHUB_PATH"
|
||||||
|
store="${RUNNER_TOOL_CACHE}/pnpm-store"
|
||||||
|
mkdir -p "$store"
|
||||||
|
echo "npm_config_store_dir=${store}" >> "$GITHUB_ENV"
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
@ -101,6 +178,25 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
|
id: pnpm-setup
|
||||||
|
with:
|
||||||
|
dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.job }}
|
||||||
|
- name: Isolate pnpm store and PATH
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
bin="${{ steps.pnpm-setup.outputs.bin_dest }}"
|
||||||
|
dest="${{ steps.pnpm-setup.outputs.dest }}"
|
||||||
|
if [[ -z "$bin" || -z "$dest" ]]; then
|
||||||
|
echo "pnpm setup did not report install paths" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "PNPM_HOME=${dest}" >> "$GITHUB_ENV"
|
||||||
|
echo "${bin}" >> "$GITHUB_PATH"
|
||||||
|
echo "${dest}" >> "$GITHUB_PATH"
|
||||||
|
store="${RUNNER_TOOL_CACHE}/pnpm-store"
|
||||||
|
mkdir -p "$store"
|
||||||
|
echo "npm_config_store_dir=${store}" >> "$GITHUB_ENV"
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
@ -227,6 +323,26 @@ jobs:
|
|||||||
|
|
||||||
- uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa # v4
|
- uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa # v4
|
||||||
if: steps.ota.outputs.publish == 'true'
|
if: steps.ota.outputs.publish == 'true'
|
||||||
|
id: pnpm-setup
|
||||||
|
with:
|
||||||
|
dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.job }}
|
||||||
|
- name: Isolate pnpm store and PATH
|
||||||
|
if: steps.ota.outputs.publish == 'true'
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
bin="${{ steps.pnpm-setup.outputs.bin_dest }}"
|
||||||
|
dest="${{ steps.pnpm-setup.outputs.dest }}"
|
||||||
|
if [[ -z "$bin" || -z "$dest" ]]; then
|
||||||
|
echo "pnpm setup did not report install paths" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "PNPM_HOME=${dest}" >> "$GITHUB_ENV"
|
||||||
|
echo "${bin}" >> "$GITHUB_PATH"
|
||||||
|
echo "${dest}" >> "$GITHUB_PATH"
|
||||||
|
store="${RUNNER_TOOL_CACHE}/pnpm-store"
|
||||||
|
mkdir -p "$store"
|
||||||
|
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'
|
||||||
with:
|
with:
|
||||||
|
|||||||
24
.github/workflows/playwright.yml
vendored
24
.github/workflows/playwright.yml
vendored
@ -54,18 +54,39 @@ jobs:
|
|||||||
playwright:
|
playwright:
|
||||||
name: Web E2E
|
name: Web E2E
|
||||||
runs-on: [self-hosted, evoo-ci, Linux]
|
runs-on: [self-hosted, evoo-ci, Linux]
|
||||||
timeout-minutes: 20
|
timeout-minutes: 30
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ${{ inputs.publish_report && 'playwright-publication' || format('playwright-{0}', github.run_id) }}
|
group: ${{ inputs.publish_report && 'playwright-publication' || format('playwright-{0}', github.run_id) }}
|
||||||
cancel-in-progress: false
|
cancel-in-progress: false
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v5
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
|
id: pnpm-setup
|
||||||
|
with:
|
||||||
|
dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.job }}
|
||||||
|
- name: Isolate pnpm store and PATH
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
bin="${{ steps.pnpm-setup.outputs.bin_dest }}"
|
||||||
|
dest="${{ steps.pnpm-setup.outputs.dest }}"
|
||||||
|
if [[ -z "$bin" || -z "$dest" ]]; then
|
||||||
|
echo "pnpm setup did not report install paths" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "PNPM_HOME=${dest}" >> "$GITHUB_ENV"
|
||||||
|
echo "${bin}" >> "$GITHUB_PATH"
|
||||||
|
echo "${dest}" >> "$GITHUB_PATH"
|
||||||
|
store="${RUNNER_TOOL_CACHE}/pnpm-store"
|
||||||
|
mkdir -p "$store"
|
||||||
|
echo "npm_config_store_dir=${store}" >> "$GITHUB_ENV"
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
- run: pnpm install --frozen-lockfile
|
- run: pnpm install --frozen-lockfile
|
||||||
|
- name: Clear stale Vite dependency cache
|
||||||
|
run: rm -rf apps/web/node_modules/.vite node_modules/.vite
|
||||||
- uses: actions/cache@v4
|
- uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: ~/.cache/ms-playwright
|
path: ~/.cache/ms-playwright
|
||||||
@ -120,6 +141,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
- name: Upload Playwright artifacts
|
- name: Upload Playwright artifacts
|
||||||
if: always() && inputs.upload_artifacts
|
if: always() && inputs.upload_artifacts
|
||||||
|
continue-on-error: true
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: playwright-artifacts-${{ github.run_id }}-${{ github.run_attempt }}
|
name: playwright-artifacts-${{ github.run_id }}-${{ github.run_attempt }}
|
||||||
|
|||||||
@ -20,7 +20,9 @@ import { resolveThreadTarget } from "./thread-target.js";
|
|||||||
|
|
||||||
function genericSpaceMemoryRecipient(): Omit<AiRecipient, "allowed"> {
|
function genericSpaceMemoryRecipient(): Omit<AiRecipient, "allowed"> {
|
||||||
return {
|
return {
|
||||||
key: createHash("sha256").update(JSON.stringify(["memory", "space-memory"])).digest("hex"),
|
key: createHash("sha256")
|
||||||
|
.update(JSON.stringify(["memory", "space-memory"]))
|
||||||
|
.digest("hex"),
|
||||||
name: "Space memory",
|
name: "Space memory",
|
||||||
use: "memory",
|
use: "memory",
|
||||||
detail: AI_DATA_DISCLOSURES.memory,
|
detail: AI_DATA_DISCLOSURES.memory,
|
||||||
|
|||||||
@ -831,7 +831,6 @@ export async function createApp(
|
|||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Data export endpoint — user downloads all their data as JSON
|
// Data export endpoint — user downloads all their data as JSON
|
||||||
app.get("/api/data/export", async (c) => {
|
app.get("/api/data/export", async (c) => {
|
||||||
const session = await auth.api.getSession({ headers: sessionHeaders(c.req.raw) });
|
const session = await auth.api.getSession({ headers: sessionHeaders(c.req.raw) });
|
||||||
@ -841,7 +840,14 @@ export async function createApp(
|
|||||||
|
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
select: { id: true, name: true, email: true, emailVerified: true, createdAt: true, updatedAt: true },
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
email: true,
|
||||||
|
emailVerified: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const bots = await prisma.bot.findMany({
|
const bots = await prisma.bot.findMany({
|
||||||
|
|||||||
@ -21,6 +21,7 @@ describe("account preferences", () => {
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
spaceModelPreference: { findFirst: vi.fn().mockResolvedValue(null) },
|
spaceModelPreference: { findFirst: vi.fn().mockResolvedValue(null) },
|
||||||
|
userModelCredential: { findFirst: vi.fn().mockResolvedValue(null) },
|
||||||
deploymentSettings: { findUnique: vi.fn().mockResolvedValue(null) },
|
deploymentSettings: { findUnique: vi.fn().mockResolvedValue(null) },
|
||||||
} as unknown as PrismaClient;
|
} as unknown as PrismaClient;
|
||||||
const deps = {
|
const deps = {
|
||||||
@ -140,6 +141,7 @@ describe("model setup gate", () => {
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
spaceModelPreference: { findFirst: vi.fn().mockResolvedValue(null) },
|
spaceModelPreference: { findFirst: vi.fn().mockResolvedValue(null) },
|
||||||
|
userModelCredential: { findFirst: vi.fn().mockResolvedValue(null) },
|
||||||
deploymentSettings: {
|
deploymentSettings: {
|
||||||
findUnique: vi
|
findUnique: vi
|
||||||
.fn()
|
.fn()
|
||||||
@ -511,6 +513,7 @@ describe("updater owner gate", () => {
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
spaceModelPreference: { findFirst: vi.fn().mockResolvedValue(null) },
|
spaceModelPreference: { findFirst: vi.fn().mockResolvedValue(null) },
|
||||||
|
userModelCredential: { findFirst: vi.fn().mockResolvedValue(null) },
|
||||||
deploymentSettings: { findUnique: vi.fn().mockResolvedValue(null) },
|
deploymentSettings: { findUnique: vi.fn().mockResolvedValue(null) },
|
||||||
} as unknown as PrismaClient;
|
} as unknown as PrismaClient;
|
||||||
const deps = {
|
const deps = {
|
||||||
@ -826,6 +829,7 @@ describe("memory provider config owner gate", () => {
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
spaceModelPreference: { findFirst: vi.fn().mockResolvedValue(null) },
|
spaceModelPreference: { findFirst: vi.fn().mockResolvedValue(null) },
|
||||||
|
userModelCredential: { findFirst: vi.fn().mockResolvedValue(null) },
|
||||||
deploymentSettings: { findUnique: vi.fn().mockResolvedValue(null) },
|
deploymentSettings: { findUnique: vi.fn().mockResolvedValue(null) },
|
||||||
} as unknown as PrismaClient;
|
} as unknown as PrismaClient;
|
||||||
const deps = {
|
const deps = {
|
||||||
|
|||||||
@ -67,8 +67,8 @@ import {
|
|||||||
probeOpenAiCompatibleModels,
|
probeOpenAiCompatibleModels,
|
||||||
provisionComputer,
|
provisionComputer,
|
||||||
queueComputerUpdate,
|
queueComputerUpdate,
|
||||||
releaseComputerExecutionLease,
|
|
||||||
readProjectWorkspaceFile,
|
readProjectWorkspaceFile,
|
||||||
|
releaseComputerExecutionLease,
|
||||||
replaceComputer,
|
replaceComputer,
|
||||||
resolveAutoReviewChecker,
|
resolveAutoReviewChecker,
|
||||||
resolveBotWorkspacePath,
|
resolveBotWorkspacePath,
|
||||||
@ -79,8 +79,8 @@ import {
|
|||||||
screenLeaseIdForRun,
|
screenLeaseIdForRun,
|
||||||
scriptedCatalogEntry,
|
scriptedCatalogEntry,
|
||||||
serializeModelSecret,
|
serializeModelSecret,
|
||||||
teamBotWorkspaceDirectory,
|
|
||||||
takeoverLeaseMs,
|
takeoverLeaseMs,
|
||||||
|
teamBotWorkspaceDirectory,
|
||||||
toComputerRef,
|
toComputerRef,
|
||||||
touchRunningComputer,
|
touchRunningComputer,
|
||||||
verifyMcpInstall,
|
verifyMcpInstall,
|
||||||
@ -122,8 +122,6 @@ import {
|
|||||||
InvalidProjectWorkspacePathError,
|
InvalidProjectWorkspacePathError,
|
||||||
InvalidSpaceNameError,
|
InvalidSpaceNameError,
|
||||||
IsolationError,
|
IsolationError,
|
||||||
projectWorkspaceDto,
|
|
||||||
wireProjectWorkspace,
|
|
||||||
isProjectGoalStatus,
|
isProjectGoalStatus,
|
||||||
issueMessagingLinkCode,
|
issueMessagingLinkCode,
|
||||||
listSpaceUserBotIds,
|
listSpaceUserBotIds,
|
||||||
@ -131,6 +129,7 @@ import {
|
|||||||
newestVoiceCredentialOrder,
|
newestVoiceCredentialOrder,
|
||||||
Prisma,
|
Prisma,
|
||||||
parseComputerMode,
|
parseComputerMode,
|
||||||
|
projectWorkspaceDto,
|
||||||
releaseSpaceDeletionClaim,
|
releaseSpaceDeletionClaim,
|
||||||
renewSpaceDeletionClaim,
|
renewSpaceDeletionClaim,
|
||||||
SPACE_DELETION_CLAIM_TIMEOUT_MS,
|
SPACE_DELETION_CLAIM_TIMEOUT_MS,
|
||||||
@ -140,6 +139,7 @@ import {
|
|||||||
SpaceNotFoundError,
|
SpaceNotFoundError,
|
||||||
selectSpaceModelPreference,
|
selectSpaceModelPreference,
|
||||||
selectSpaceVoicePreference,
|
selectSpaceVoicePreference,
|
||||||
|
wireProjectWorkspace,
|
||||||
} from "@rakazo/db";
|
} from "@rakazo/db";
|
||||||
import { getLogger } from "@rakazo/logging";
|
import { getLogger } from "@rakazo/logging";
|
||||||
import { deleteAgentSecret, listAgentSecrets, putAgentSecret } from "./agent-secrets.js";
|
import { deleteAgentSecret, listAgentSecrets, putAgentSecret } from "./agent-secrets.js";
|
||||||
@ -2112,7 +2112,10 @@ export function createRouter(deps: RouterDeps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (isProjectWorkspacePath(storedPath)) {
|
if (isProjectWorkspacePath(storedPath)) {
|
||||||
const workspaceRoot = await requireProjectWorkspaceRoot(deps.prisma, context.actor.spaceId);
|
const workspaceRoot = await requireProjectWorkspaceRoot(
|
||||||
|
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,
|
||||||
@ -2160,7 +2163,10 @@ export function createRouter(deps: RouterDeps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (isProjectWorkspacePath(storedPath)) {
|
if (isProjectWorkspacePath(storedPath)) {
|
||||||
const workspaceRoot = await requireProjectWorkspaceRoot(deps.prisma, context.actor.spaceId);
|
const workspaceRoot = await requireProjectWorkspaceRoot(
|
||||||
|
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,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -41,7 +41,14 @@ test("launches with a narrow preload bridge and an isolated renderer", async ()
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(renderer.bridgeKeys).toEqual(["localSettings", "oauth", "platform", "update", "window"]);
|
expect(renderer.bridgeKeys).toEqual([
|
||||||
|
"files",
|
||||||
|
"localSettings",
|
||||||
|
"oauth",
|
||||||
|
"platform",
|
||||||
|
"update",
|
||||||
|
"window",
|
||||||
|
]);
|
||||||
expect(renderer.windowKeys).toEqual(["close", "minimize", "state", "toggleMaximize"]);
|
expect(renderer.windowKeys).toEqual(["close", "minimize", "state", "toggleMaximize"]);
|
||||||
expect(renderer.updateKeys).toEqual(["check", "download", "install", "state"]);
|
expect(renderer.updateKeys).toEqual(["check", "download", "install", "state"]);
|
||||||
expect(renderer.platform).toBe(process.platform);
|
expect(renderer.platform).toBe(process.platform);
|
||||||
|
|||||||
@ -563,7 +563,7 @@ function oauthPopupWindowOptions() {
|
|||||||
frame: true,
|
frame: true,
|
||||||
titleBarStyle: "default" as const,
|
titleBarStyle: "default" as const,
|
||||||
autoHideMenuBar: true,
|
autoHideMenuBar: true,
|
||||||
backgroundColor: "#0B0C0E",
|
backgroundColor: "#07080A",
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
preload: "",
|
preload: "",
|
||||||
nodeIntegration: false,
|
nodeIntegration: false,
|
||||||
|
|||||||
@ -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([
|
||||||
|
"files",
|
||||||
"localSettings",
|
"localSettings",
|
||||||
"oauth",
|
"oauth",
|
||||||
"platform",
|
"platform",
|
||||||
@ -46,6 +47,7 @@ describe("desktop preload bridge", () => {
|
|||||||
]);
|
]);
|
||||||
expect(Object.keys(bridge.update).sort()).toEqual(["check", "download", "install", "state"]);
|
expect(Object.keys(bridge.update).sort()).toEqual(["check", "download", "install", "state"]);
|
||||||
|
|
||||||
|
await bridge.files?.pickDirectory();
|
||||||
await bridge.oauth.open?.("https://provider.example.com/authorize");
|
await bridge.oauth.open?.("https://provider.example.com/authorize");
|
||||||
await bridge.oauth.cancel?.("https://provider.example.com/authorize");
|
await bridge.oauth.cancel?.("https://provider.example.com/authorize");
|
||||||
await bridge.window.close();
|
await bridge.window.close();
|
||||||
@ -57,6 +59,7 @@ describe("desktop preload bridge", () => {
|
|||||||
await bridge.update.download();
|
await bridge.update.download();
|
||||||
await bridge.update.install();
|
await bridge.update.install();
|
||||||
expect(invoke.mock.calls.map(([channel]) => channel)).toEqual([
|
expect(invoke.mock.calls.map(([channel]) => channel)).toEqual([
|
||||||
|
"desktop.files.pickDirectory",
|
||||||
"desktop.oauth.open",
|
"desktop.oauth.open",
|
||||||
"desktop.oauth.cancel",
|
"desktop.oauth.cancel",
|
||||||
"desktop.window.close",
|
"desktop.window.close",
|
||||||
@ -74,6 +77,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([
|
||||||
|
"files",
|
||||||
"localSettings",
|
"localSettings",
|
||||||
"oauth",
|
"oauth",
|
||||||
"platform",
|
"platform",
|
||||||
|
|||||||
@ -63,10 +63,10 @@ describe("desktop release workflow", () => {
|
|||||||
`include: ${expression("fromJSON(needs.validate.outputs.platforms)")}`,
|
`include: ${expression("fromJSON(needs.validate.outputs.platforms)")}`,
|
||||||
);
|
);
|
||||||
expect(workflow).toContain(
|
expect(workflow).toContain(
|
||||||
`include='[{"os":"macos-26","artifact":"macos"},{"os":"ubuntu-24.04","artifact":"linux"}]'`,
|
`include='[{"runner":["self-hosted","evoo-ci","macOS"],"artifact":"macos"},{"runner":["self-hosted","evoo-ci","Linux"],"artifact":"linux"}]'`,
|
||||||
);
|
);
|
||||||
expect(workflow).toMatch(
|
expect(workflow).toMatch(
|
||||||
/if \[\[ "\$WINDOWS_SIGNING" == "true" \]\]; then\n\s+include=.*\{"os":"windows-2022","artifact":"windows"\}/,
|
/if \[\[ "\$WINDOWS_SIGNING" == "true" \]\]; then\n(?:\s*#[^\n]*\n)*\s+include=.*\{"runner":"windows-2022","artifact":"windows"\}/,
|
||||||
);
|
);
|
||||||
expect(workflow.split("windows-2022")).toHaveLength(2);
|
expect(workflow.split("windows-2022")).toHaveLength(2);
|
||||||
expect(workflow).toContain(`WINDOWS_BUILT: ${expression("needs.validate.outputs.windows")}`);
|
expect(workflow).toContain(`WINDOWS_BUILT: ${expression("needs.validate.outputs.windows")}`);
|
||||||
|
|||||||
@ -15,7 +15,7 @@ function windowChrome(platform: NodeJS.Platform) {
|
|||||||
// Keep in sync with darkTokens.background from @rakazo/ui-tokens (asserted in tests).
|
// Keep in sync with darkTokens.background from @rakazo/ui-tokens (asserted in tests).
|
||||||
// Do not import that package here: desktop packs compiled JS only and ui-tokens is a
|
// Do not import that package here: desktop packs compiled JS only and ui-tokens is a
|
||||||
// TypeScript source / devDependency.
|
// TypeScript source / devDependency.
|
||||||
backgroundColor: "#0B0C0E",
|
backgroundColor: "#07080A",
|
||||||
show: true,
|
show: true,
|
||||||
autoHideMenuBar: true,
|
autoHideMenuBar: true,
|
||||||
frame: mac,
|
frame: mac,
|
||||||
|
|||||||
@ -220,11 +220,7 @@ export default function VoiceSettings() {
|
|||||||
style={[styles.button, (pending !== null || !canConnect) && styles.disabled]}
|
style={[styles.button, (pending !== null || !canConnect) && styles.disabled]}
|
||||||
>
|
>
|
||||||
<Text style={styles.buttonLabel}>
|
<Text style={styles.buttonLabel}>
|
||||||
{credential
|
{credential ? (isKokoro ? t("Reconnect") : t("Replace key")) : t("Connect")}
|
||||||
? isKokoro
|
|
||||||
? t("Reconnect")
|
|
||||||
: t("Replace key")
|
|
||||||
: t("Connect")}
|
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
{credential ? (
|
{credential ? (
|
||||||
|
|||||||
@ -25,6 +25,9 @@ export const RU_MESSAGES: Record<string, string> = {
|
|||||||
"Open web app": "Открыть веб-приложение",
|
"Open web app": "Открыть веб-приложение",
|
||||||
"Project ID": "ID проекта",
|
"Project ID": "ID проекта",
|
||||||
Reconnecting: "Повторное подключение",
|
Reconnecting: "Повторное подключение",
|
||||||
|
Reconnect: "Подключить снова",
|
||||||
|
"Local voice runs on your server. Tap Connect when it is ready.":
|
||||||
|
"Локальный голос работает на вашем сервере. Нажмите «Подключить», когда он будет готов.",
|
||||||
"Recovering Team Computer": "Восстановление компьютера команды",
|
"Recovering Team Computer": "Восстановление компьютера команды",
|
||||||
"Recovering {name}’s Computer": "Восстановление компьютера {name}",
|
"Recovering {name}’s Computer": "Восстановление компьютера {name}",
|
||||||
"Recovery failed": "Не удалось восстановить",
|
"Recovery failed": "Не удалось восстановить",
|
||||||
|
|||||||
@ -13,6 +13,9 @@ export const ZH_MESSAGES: Record<string, string> = {
|
|||||||
Dismiss: "关闭",
|
Dismiss: "关闭",
|
||||||
"Getting ready": "正在准备",
|
"Getting ready": "正在准备",
|
||||||
Reconnecting: "正在重新连接",
|
Reconnecting: "正在重新连接",
|
||||||
|
Reconnect: "重新连接",
|
||||||
|
"Local voice runs on your server. Tap Connect when it is ready.":
|
||||||
|
"本地语音在你的服务器上运行。准备好后点按连接。",
|
||||||
"Recovering Team Computer": "正在恢复团队电脑",
|
"Recovering Team Computer": "正在恢复团队电脑",
|
||||||
"Recovering {name}’s Computer": "正在恢复 {name} 的电脑",
|
"Recovering {name}’s Computer": "正在恢复 {name} 的电脑",
|
||||||
"Recovery failed": "恢复失败",
|
"Recovery failed": "恢复失败",
|
||||||
|
|||||||
@ -3,9 +3,9 @@ import { botColors, tokens } from "./theme.js";
|
|||||||
|
|
||||||
describe("mobile theme tokens", () => {
|
describe("mobile theme tokens", () => {
|
||||||
it("exposes the shared product palette used by custom surfaces", () => {
|
it("exposes the shared product palette used by custom surfaces", () => {
|
||||||
expect(tokens.background).toBe("#0B0C0E");
|
expect(tokens.background).toBe("#07080A");
|
||||||
expect(tokens.foreground).toBe("#ECECEE");
|
expect(tokens.foreground).toBe("#F3F4F6");
|
||||||
expect(tokens.primary).toBe("#F1F1EF");
|
expect(tokens.primary).toBe("#FFFFFF");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("re-exports botColors for identity accents", () => {
|
it("re-exports botColors for identity accents", () => {
|
||||||
|
|||||||
@ -17,33 +17,33 @@
|
|||||||
"test:e2e": "maestro test .maestro/smoke.yaml"
|
"test:e2e": "maestro test .maestro/smoke.yaml"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@expo/metro-runtime": "57.0.15",
|
"@expo/metro-runtime": "57.0.16",
|
||||||
"@rakazo/chat-ui": "workspace:*",
|
"@rakazo/chat-ui": "workspace:*",
|
||||||
"@rakazo/contracts": "workspace:*",
|
"@rakazo/contracts": "workspace:*",
|
||||||
"@rakazo/core": "workspace:*",
|
"@rakazo/core": "workspace:*",
|
||||||
"@rakazo/ui-tokens": "workspace:*",
|
"@rakazo/ui-tokens": "workspace:*",
|
||||||
"@react-native-vector-icons/ionicons": "13.1.4",
|
"@react-native-vector-icons/ionicons": "13.1.4",
|
||||||
"@react-native-vector-icons/material-design-icons": "13.1.4",
|
"@react-native-vector-icons/material-design-icons": "13.1.4",
|
||||||
"expo": "57.0.23",
|
"expo": "57.0.24",
|
||||||
"expo-application": "57.0.3",
|
"expo-application": "57.0.3",
|
||||||
"expo-audio": "57.0.5",
|
"expo-audio": "57.0.5",
|
||||||
"expo-clipboard": "57.0.2",
|
"expo-clipboard": "57.0.2",
|
||||||
"expo-constants": "57.0.18",
|
"expo-constants": "57.0.19",
|
||||||
"expo-document-picker": "57.0.2",
|
"expo-document-picker": "57.0.2",
|
||||||
"expo-file-system": "57.0.7",
|
"expo-file-system": "57.0.7",
|
||||||
"expo-font": "57.0.4",
|
"expo-font": "57.0.4",
|
||||||
"expo-image-picker": "57.0.18",
|
"expo-image-picker": "57.0.19",
|
||||||
"expo-linking": "57.0.10",
|
"expo-linking": "57.0.10",
|
||||||
"expo-localization": "57.0.2",
|
"expo-localization": "57.0.2",
|
||||||
"expo-modules-core": "57.0.18",
|
"expo-modules-core": "57.0.18",
|
||||||
"expo-notifications": "57.0.19",
|
"expo-notifications": "57.0.20",
|
||||||
"expo-router": "57.0.21",
|
"expo-router": "57.0.22",
|
||||||
"expo-secure-store": "57.0.4",
|
"expo-secure-store": "57.0.4",
|
||||||
"expo-sharing": "57.0.20",
|
"expo-sharing": "57.0.21",
|
||||||
"expo-splash-screen": "57.0.9",
|
"expo-splash-screen": "57.0.9",
|
||||||
"expo-status-bar": "57.0.1",
|
"expo-status-bar": "57.0.1",
|
||||||
"expo-symbols": "57.0.3",
|
"expo-symbols": "57.0.3",
|
||||||
"expo-updates": "57.0.22",
|
"expo-updates": "57.0.23",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
"react-native": "0.86.3",
|
"react-native": "0.86.3",
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import { captureScreenshot, completeOnboarding, signup } from "./helpers";
|
|||||||
/** Activity rows sit above `[data-sidebar-group]` bots; match their aria-label. */
|
/** Activity rows sit above `[data-sidebar-group]` bots; match their aria-label. */
|
||||||
function activityRow(page: Page, botName: string) {
|
function activityRow(page: Page, botName: string) {
|
||||||
return page.locator("aside").getByRole("button", {
|
return page.locator("aside").getByRole("button", {
|
||||||
name: new RegExp(`^${botName}, `),
|
name: new RegExp(`^${botName}, (?!1:1 conversation)`),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -24,7 +24,7 @@ test("restricted signup waits for mailbox verification", async ({ page }, testIn
|
|||||||
await expect(page.getByRole("heading", { name: "Check your email" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "Check your email" })).toBeVisible();
|
||||||
await captureScreenshot(page, testInfo, "signup-verification-required");
|
await captureScreenshot(page, testInfo, "signup-verification-required");
|
||||||
await page.getByRole("link", { name: "Back to sign in" }).click();
|
await page.getByRole("link", { name: "Back to sign in" }).click();
|
||||||
await expect(page.getByRole("heading", { name: "Sign in to Rakazo" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "Sign in to EvOo" })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("logout protects bot deep links and sign-in restores the session", async ({
|
test("logout protects bot deep links and sign-in restores the session", async ({
|
||||||
@ -72,10 +72,10 @@ test("logout protects bot deep links and sign-in restores the session", async ({
|
|||||||
await captureScreenshot(page, testInfo, "36-account-menu");
|
await captureScreenshot(page, testInfo, "36-account-menu");
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Log out" }).click();
|
await page.getByRole("button", { name: "Log out" }).click();
|
||||||
await expect(page.getByRole("heading", { name: "Sign in to Rakazo" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "Sign in to EvOo" })).toBeVisible();
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
await expect(page.locator('[data-rakazo-surface="welcome"]')).toBeVisible();
|
await expect(page.locator('[data-rakazo-surface="welcome"]')).toBeVisible();
|
||||||
await expect(page.getByText(/Your team of always-on agents/)).toBeVisible();
|
await expect(page.getByText("Your specialists. One calm place to talk.")).toBeVisible();
|
||||||
await page.getByRole("button", { name: /Sign up/ }).click();
|
await page.getByRole("button", { name: /Sign up/ }).click();
|
||||||
await expect(page).toHaveURL(/\/sign-up$/);
|
await expect(page).toHaveURL(/\/sign-up$/);
|
||||||
await expect(page.getByRole("heading", { name: "Create your EvOo" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "Create your EvOo" })).toBeVisible();
|
||||||
@ -84,7 +84,7 @@ test("logout protects bot deep links and sign-in restores the session", async ({
|
|||||||
|
|
||||||
await page.goto(protectedBotPath);
|
await page.goto(protectedBotPath);
|
||||||
await page.waitForURL((url) => url.pathname === "/sign-in");
|
await page.waitForURL((url) => url.pathname === "/sign-in");
|
||||||
await expect(page.getByRole("heading", { name: "Sign in to Rakazo" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "Sign in to EvOo" })).toBeVisible();
|
||||||
await expect(page.getByText("Chief", { exact: true })).toHaveCount(0);
|
await expect(page.getByText("Chief", { exact: true })).toHaveCount(0);
|
||||||
await expect(page.getByText(userName, { exact: true })).toHaveCount(0);
|
await expect(page.getByText(userName, { exact: true })).toHaveCount(0);
|
||||||
await expect(page.getByLabel("Email")).toHaveAttribute("autocomplete", "username");
|
await expect(page.getByLabel("Email")).toHaveAttribute("autocomplete", "username");
|
||||||
@ -110,10 +110,10 @@ test("logout protects bot deep links and sign-in restores the session", async ({
|
|||||||
await page.waitForURL((url) => url.pathname === protectedBotPath, {
|
await page.waitForURL((url) => url.pathname === protectedBotPath, {
|
||||||
timeout: 20_000,
|
timeout: 20_000,
|
||||||
});
|
});
|
||||||
const composer = page.getByRole("combobox", { name: "Message Chief" });
|
const composer = page.getByRole("combobox", { name: "Message" });
|
||||||
await expect(composer).toHaveAttribute("name", "chat-message");
|
await expect(composer).toHaveAttribute("name", "chat-message");
|
||||||
await expect(composer).toHaveAttribute("autocomplete", "off");
|
await expect(composer).toHaveAttribute("autocomplete", "off");
|
||||||
await expect(composer).toHaveAttribute("aria-label", "Message Chief");
|
await expect(composer).toHaveAttribute("aria-label", "Message");
|
||||||
await expect(page.getByRole("button", { name: new RegExp(userName, "i") })).toBeVisible();
|
await expect(page.getByRole("button", { name: new RegExp(userName, "i") })).toBeVisible();
|
||||||
|
|
||||||
await composer.fill("line one");
|
await composer.fill("line one");
|
||||||
|
|||||||
@ -21,11 +21,11 @@ test("pinned bots and sidebar sections persist", async ({ page }, testInfo) => {
|
|||||||
await expect(sidebar.locator('[data-sidebar-group="pinned"]')).toHaveCount(0);
|
await expect(sidebar.locator('[data-sidebar-group="pinned"]')).toHaveCount(0);
|
||||||
|
|
||||||
await bot.click({ button: "right" });
|
await bot.click({ button: "right" });
|
||||||
await page.getByRole("menuitem", { name: "Move to", exact: true }).hover();
|
await page.getByRole("menuitem", { name: "Move to folder", exact: true }).hover();
|
||||||
const moveMenu = page.getByRole("menu", { name: "Move to", exact: true });
|
const moveMenu = page.getByRole("menu", { name: "Move to folder", exact: true });
|
||||||
await expect(moveMenu).toBeVisible();
|
await expect(moveMenu).toBeVisible();
|
||||||
await captureScreenshot(page, testInfo, "move-to-section-menu");
|
await captureScreenshot(page, testInfo, "move-to-section-menu");
|
||||||
await moveMenu.getByText("New section").click();
|
await moveMenu.getByText("New folder").click();
|
||||||
const dialog = page.getByRole("dialog", { name: "New section" });
|
const dialog = page.getByRole("dialog", { name: "New section" });
|
||||||
await dialog.getByLabel("Name").fill("Projects");
|
await dialog.getByLabel("Name").fill("Projects");
|
||||||
await dialog.getByRole("button", { name: "Create" }).click();
|
await dialog.getByRole("button", { name: "Create" }).click();
|
||||||
@ -40,10 +40,10 @@ test("pinned bots and sidebar sections persist", async ({ page }, testInfo) => {
|
|||||||
await expect(projects).toContainText("Chief");
|
await expect(projects).toContainText("Chief");
|
||||||
|
|
||||||
await bot.click({ button: "right" });
|
await bot.click({ button: "right" });
|
||||||
await page.getByRole("menuitem", { name: "Move to", exact: true }).hover();
|
await page.getByRole("menuitem", { name: "Move to folder", exact: true }).hover();
|
||||||
await page
|
await page
|
||||||
.getByRole("menu", { name: "Move to", exact: true })
|
.getByRole("menu", { name: "Move to folder", exact: true })
|
||||||
.getByRole("menuitem", { name: "Rename section", exact: true })
|
.getByRole("menuitem", { name: "Rename folder", exact: true })
|
||||||
.click();
|
.click();
|
||||||
const renameDialog = page.getByRole("dialog", { name: "Rename section" });
|
const renameDialog = page.getByRole("dialog", { name: "Rename section" });
|
||||||
await renameDialog.getByLabel("Name").fill("Delivery");
|
await renameDialog.getByLabel("Name").fill("Delivery");
|
||||||
@ -53,9 +53,9 @@ test("pinned bots and sidebar sections persist", async ({ page }, testInfo) => {
|
|||||||
await expect(projects).not.toContainText("Projects");
|
await expect(projects).not.toContainText("Projects");
|
||||||
|
|
||||||
await bot.click({ button: "right" });
|
await bot.click({ button: "right" });
|
||||||
await page.getByRole("menuitem", { name: "Move to", exact: true }).hover();
|
await page.getByRole("menuitem", { name: "Move to folder", exact: true }).hover();
|
||||||
await page
|
await page
|
||||||
.getByRole("menu", { name: "Move to", exact: true })
|
.getByRole("menu", { name: "Move to folder", exact: true })
|
||||||
.getByRole("menuitem", { name: "Unassigned", exact: true })
|
.getByRole("menuitem", { name: "Unassigned", exact: true })
|
||||||
.click();
|
.click();
|
||||||
await expect(sidebar.locator('[data-sidebar-group="unassigned"]')).toContainText("Chief");
|
await expect(sidebar.locator('[data-sidebar-group="unassigned"]')).toContainText("Chief");
|
||||||
@ -182,7 +182,7 @@ test("chat composer controls are vertically centered", async ({ page }) => {
|
|||||||
await completeOnboarding(page);
|
await completeOnboarding(page);
|
||||||
|
|
||||||
const centers = await page.getByTestId("composer-bar").evaluate((composer) =>
|
const centers = await page.getByTestId("composer-bar").evaluate((composer) =>
|
||||||
["Attach file", "Message Chief", "Voice", "Send"].map((label) => {
|
["Attach file", "Message", "Voice", "Send"].map((label) => {
|
||||||
const element = composer.querySelector<HTMLElement>(`[aria-label="${label}"]`);
|
const element = composer.querySelector<HTMLElement>(`[aria-label="${label}"]`);
|
||||||
if (!element) throw new Error(`Missing composer control: ${label}`);
|
if (!element) throw new Error(`Missing composer control: ${label}`);
|
||||||
const box = element.getBoundingClientRect();
|
const box = element.getBoundingClientRect();
|
||||||
@ -216,11 +216,11 @@ test("group chats share every context-menu action", async ({ page }, testInfo) =
|
|||||||
await page.reload();
|
await page.reload();
|
||||||
|
|
||||||
const sidebar = page.locator("aside").first();
|
const sidebar = page.locator("aside").first();
|
||||||
const group = sidebar.getByRole("button", { name: /^Group menu/ });
|
const group = sidebar.getByRole("button", { name: /^Group menu/ }).first();
|
||||||
await group.click({ button: "right" });
|
await group.click({ button: "right" });
|
||||||
for (const action of [
|
for (const action of [
|
||||||
"Pin",
|
"Pin",
|
||||||
"Move to",
|
"Move to folder",
|
||||||
"Mark as Unread",
|
"Mark as Unread",
|
||||||
"Edit Profile",
|
"Edit Profile",
|
||||||
"Duplicate",
|
"Duplicate",
|
||||||
@ -243,10 +243,10 @@ test("group chats share every context-menu action", async ({ page }, testInfo) =
|
|||||||
await group.click({ button: "right" });
|
await group.click({ button: "right" });
|
||||||
await page.getByRole("menuitem", { name: "Unpin", exact: true }).click();
|
await page.getByRole("menuitem", { name: "Unpin", exact: true }).click();
|
||||||
await group.click({ button: "right" });
|
await group.click({ button: "right" });
|
||||||
await page.getByRole("menuitem", { name: "Move to", exact: true }).hover();
|
await page.getByRole("menuitem", { name: "Move to folder", exact: true }).hover();
|
||||||
await page
|
await page
|
||||||
.getByRole("menu", { name: "Move to", exact: true })
|
.getByRole("menu", { name: "Move to folder", exact: true })
|
||||||
.getByRole("menuitem", { name: "New section", exact: true })
|
.getByRole("menuitem", { name: "New folder", exact: true })
|
||||||
.click();
|
.click();
|
||||||
const sectionDialog = page.getByRole("dialog", { name: "New section" });
|
const sectionDialog = page.getByRole("dialog", { name: "New section" });
|
||||||
await sectionDialog.getByLabel("Name").fill("Teams");
|
await sectionDialog.getByLabel("Name").fill("Teams");
|
||||||
|
|||||||
@ -12,8 +12,11 @@ test("titled sidebar section expands and collapses", async ({ page }, testInfo)
|
|||||||
const bot = sidebar.getByRole("button", { name: /^Chief/ });
|
const bot = sidebar.getByRole("button", { name: /^Chief/ });
|
||||||
|
|
||||||
await bot.click({ button: "right" });
|
await bot.click({ button: "right" });
|
||||||
await page.getByRole("menuitem", { name: "Move to", exact: true }).hover();
|
await page.getByRole("menuitem", { name: "Move to folder", exact: true }).hover();
|
||||||
await page.getByRole("menu", { name: "Move to", exact: true }).getByText("New section").click();
|
await page
|
||||||
|
.getByRole("menu", { name: "Move to folder", exact: true })
|
||||||
|
.getByText("New folder")
|
||||||
|
.click();
|
||||||
const dialog = page.getByRole("dialog", { name: "New section" });
|
const dialog = page.getByRole("dialog", { name: "New section" });
|
||||||
await dialog.getByLabel("Name").fill("Projects");
|
await dialog.getByLabel("Name").fill("Projects");
|
||||||
await dialog.getByRole("button", { name: "Create" }).click();
|
await dialog.getByRole("button", { name: "Create" }).click();
|
||||||
|
|||||||
@ -30,7 +30,7 @@ test("command palette opens with keyboard, filters, and switches bots", async ({
|
|||||||
|
|
||||||
await page.keyboard.press("ControlOrMeta+K");
|
await page.keyboard.press("ControlOrMeta+K");
|
||||||
const palette = page.getByTestId("command-palette");
|
const palette = page.getByTestId("command-palette");
|
||||||
const dialog = page.getByRole("dialog", { name: "Switch bot" });
|
const dialog = page.getByRole("dialog", { name: "Switch conversation" });
|
||||||
await expect(dialog).toBeVisible();
|
await expect(dialog).toBeVisible();
|
||||||
await expect(palette).toBeVisible();
|
await expect(palette).toBeVisible();
|
||||||
await expect(page.getByRole("tab")).toHaveCount(0);
|
await expect(page.getByRole("tab")).toHaveCount(0);
|
||||||
@ -48,7 +48,7 @@ test("command palette opens with keyboard, filters, and switches bots", async ({
|
|||||||
await expect(dialog).toBeHidden();
|
await expect(dialog).toBeHidden();
|
||||||
await page.waitForURL(new RegExp(`/app/${researcher.id}$`));
|
await page.waitForURL(new RegExp(`/app/${researcher.id}$`));
|
||||||
expect(activeBotId(page)).toBe(researcher.id);
|
expect(activeBotId(page)).toBe(researcher.id);
|
||||||
await expect(page.getByRole("combobox", { name: "Message Researcher" })).toBeVisible();
|
await expect(page.getByRole("combobox", { name: "Message" })).toBeVisible();
|
||||||
|
|
||||||
await page.keyboard.press("ControlOrMeta+K");
|
await page.keyboard.press("ControlOrMeta+K");
|
||||||
await expect(dialog).toBeVisible();
|
await expect(dialog).toBeVisible();
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import {
|
|||||||
activeBotId,
|
activeBotId,
|
||||||
captureScreenshot,
|
captureScreenshot,
|
||||||
completeOnboarding,
|
completeOnboarding,
|
||||||
|
openUserSettings,
|
||||||
realSandboxTimeout,
|
realSandboxTimeout,
|
||||||
rpc,
|
rpc,
|
||||||
signup,
|
signup,
|
||||||
@ -134,7 +135,7 @@ test("takeover, routine, plugins, and export are reachable", async ({ page }, te
|
|||||||
await expect(page.getByText("Monday briefing")).toBeVisible();
|
await expect(page.getByText("Monday briefing")).toBeVisible();
|
||||||
await captureScreenshot(page, testInfo, "10-routine-created");
|
await captureScreenshot(page, testInfo, "10-routine-created");
|
||||||
|
|
||||||
await page.getByText("Integrations").click();
|
await openUserSettings(page, "integrations");
|
||||||
await expect(page.getByPlaceholder("Search apps")).toBeVisible();
|
await expect(page.getByPlaceholder("Search apps")).toBeVisible();
|
||||||
const featured = page.getByTestId("featured-connectors");
|
const featured = page.getByTestId("featured-connectors");
|
||||||
await expect(featured).toContainText(
|
await expect(featured).toContainText(
|
||||||
@ -178,8 +179,8 @@ test("takeover, routine, plugins, and export are reachable", async ({ page }, te
|
|||||||
await expect(detail.getByLabel("Account label").nth(1)).toHaveValue("Work");
|
await expect(detail.getByLabel("Account label").nth(1)).toHaveValue("Work");
|
||||||
await captureScreenshot(page, testInfo, "11a2-multi-account-plugins");
|
await captureScreenshot(page, testInfo, "11a2-multi-account-plugins");
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Close integrations" }).click();
|
await page.getByRole("button", { name: "Close integrations settings" }).click();
|
||||||
await page.getByText("Integrations").click();
|
await openUserSettings(page, "integrations");
|
||||||
await expect(page.getByPlaceholder("Search apps")).toBeVisible();
|
await expect(page.getByPlaceholder("Search apps")).toBeVisible();
|
||||||
const gmailTileAgain = page.getByTestId("connection-tile-gmail");
|
const gmailTileAgain = page.getByTestId("connection-tile-gmail");
|
||||||
await expect(gmailTileAgain.getByRole("button", { name: "Added", exact: true })).toBeVisible();
|
await expect(gmailTileAgain.getByRole("button", { name: "Added", exact: true })).toBeVisible();
|
||||||
@ -296,7 +297,7 @@ test("takeover, routine, plugins, and export are reachable", async ({ page }, te
|
|||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
await captureScreenshot(page, testInfo, "11d-provider-emulators");
|
await captureScreenshot(page, testInfo, "11d-provider-emulators");
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Close integrations" }).click();
|
await page.getByRole("button", { name: "Close integrations settings" }).click();
|
||||||
|
|
||||||
await page.getByText("Chief").first().click();
|
await page.getByText("Chief").first().click();
|
||||||
const gear = page.getByRole("button", { name: "Show settings" });
|
const gear = page.getByRole("button", { name: "Show settings" });
|
||||||
@ -333,9 +334,10 @@ test("sign-in, spawn, and stop work in the shell", async ({ page }, testInfo) =>
|
|||||||
if (message.type() === "error") browserErrors.push(message.text());
|
if (message.type() === "error") browserErrors.push(message.text());
|
||||||
});
|
});
|
||||||
page.on("requestfailed", (request) => {
|
page.on("requestfailed", (request) => {
|
||||||
failedRequests.push(
|
const errorText = request.failure()?.errorText ?? "";
|
||||||
`${request.method()} ${request.url()} ${request.failure()?.errorText ?? ""}`,
|
// React StrictMode and route changes cancel in-flight loads. That is not a failed response.
|
||||||
);
|
if (errorText.includes("ERR_ABORTED")) return;
|
||||||
|
failedRequests.push(`${request.method()} ${request.url()} ${errorText}`);
|
||||||
});
|
});
|
||||||
const stamp = Date.now();
|
const stamp = Date.now();
|
||||||
const email = `shell-${stamp}@rakazo.test`;
|
const email = `shell-${stamp}@rakazo.test`;
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
import { captureScreenshot, completeOnboarding, signup } from "./helpers";
|
import { captureScreenshot, completeOnboarding, openUserSettings, signup } from "./helpers";
|
||||||
|
|
||||||
test("advanced GraphQL install shows Add GraphQL in MCP, OpenAPI, GraphQL, Executor, Treg order", async ({
|
test("advanced GraphQL install shows Add GraphQL in MCP, OpenAPI, GraphQL, Executor, Treg order", async ({
|
||||||
page,
|
page,
|
||||||
@ -8,7 +8,7 @@ test("advanced GraphQL install shows Add GraphQL in MCP, OpenAPI, GraphQL, Execu
|
|||||||
await signup(page, `graphql-source-${stamp}@rakazo.test`, "password12", `GraphQL ${stamp}`);
|
await signup(page, `graphql-source-${stamp}@rakazo.test`, "password12", `GraphQL ${stamp}`);
|
||||||
await completeOnboarding(page);
|
await completeOnboarding(page);
|
||||||
|
|
||||||
await page.getByText("Integrations").click();
|
await openUserSettings(page, "integrations");
|
||||||
await expect(page.getByPlaceholder("Search apps")).toBeVisible();
|
await expect(page.getByPlaceholder("Search apps")).toBeVisible();
|
||||||
await expect(page.getByRole("button", { name: "Add GraphQL", exact: true })).toBeHidden();
|
await expect(page.getByRole("button", { name: "Add GraphQL", exact: true })).toBeHidden();
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { expect, type Page, test } from "@playwright/test";
|
import { expect, type Page, type Route, test } from "@playwright/test";
|
||||||
import {
|
import {
|
||||||
captureScreenshot,
|
captureScreenshot,
|
||||||
completeOnboarding,
|
completeOnboarding,
|
||||||
@ -12,6 +12,46 @@ async function createBot(page: Page, name: string) {
|
|||||||
return createNamedBot(page, name);
|
return createNamedBot(page, name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function patchGroupMembers(groups: unknown) {
|
||||||
|
if (!Array.isArray(groups)) return groups;
|
||||||
|
return groups.map((group) => {
|
||||||
|
if (!group || typeof group !== "object" || !("members" in group)) return group;
|
||||||
|
const members = (group as { members?: unknown }).members;
|
||||||
|
if (!Array.isArray(members)) return group;
|
||||||
|
return {
|
||||||
|
...group,
|
||||||
|
members: members.map((member, index) => {
|
||||||
|
if (!member || typeof member !== "object") return member;
|
||||||
|
const row = member as { status?: string };
|
||||||
|
return { ...row, status: index === 0 ? "running" : row.status };
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sidebar avatars read bootstrap and spaces/list, not groups/list. */
|
||||||
|
async function showFirstMemberWorking(route: Route) {
|
||||||
|
const response = await route.fetch();
|
||||||
|
const body = (await response.json()) as { json?: Record<string, unknown> };
|
||||||
|
const payload = body.json;
|
||||||
|
if (!payload) {
|
||||||
|
await route.fulfill({ response });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ("groups" in payload) payload.groups = patchGroupMembers(payload.groups);
|
||||||
|
if (Array.isArray(payload.spaces)) {
|
||||||
|
payload.spaces = payload.spaces.map((space) => {
|
||||||
|
if (!space || typeof space !== "object") return space;
|
||||||
|
return { ...space, groups: patchGroupMembers((space as { groups?: unknown }).groups) };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (payload.current && typeof payload.current === "object") {
|
||||||
|
const current = payload.current as { groups?: unknown };
|
||||||
|
current.groups = patchGroupMembers(current.groups);
|
||||||
|
}
|
||||||
|
await route.fulfill({ response, json: body });
|
||||||
|
}
|
||||||
|
|
||||||
test("create group from + and see two bots in one transcript", async ({ page }, testInfo) => {
|
test("create group from + and see two bots in one transcript", async ({ page }, testInfo) => {
|
||||||
const stamp = Date.now();
|
const stamp = Date.now();
|
||||||
await signup(page, `group-${stamp}@rakazo.test`, "password12", "Group E2E");
|
await signup(page, `group-${stamp}@rakazo.test`, "password12", "Group E2E");
|
||||||
@ -47,7 +87,7 @@ test("create group from + and see two bots in one transcript", async ({ page },
|
|||||||
});
|
});
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await expect(page).toHaveURL(groupUrl);
|
await expect(page).toHaveURL(groupUrl);
|
||||||
await expect(page.getByRole("combobox", { name: "Message Draft team" })).toBeVisible();
|
await expect(page.getByRole("combobox", { name: "Message" })).toBeVisible();
|
||||||
|
|
||||||
const groups = await rpc<
|
const groups = await rpc<
|
||||||
Array<{
|
Array<{
|
||||||
@ -58,6 +98,8 @@ test("create group from + and see two bots in one transcript", async ({ page },
|
|||||||
const groupSnapshot = await rpc<{
|
const groupSnapshot = await rpc<{
|
||||||
members?: Array<{ botId: string; name: string; color: string; status?: string }>;
|
members?: Array<{ botId: string; name: string; color: string; status?: string }>;
|
||||||
}>(page, "threads/get", { groupId: draftGroupId });
|
}>(page, "threads/get", { groupId: draftGroupId });
|
||||||
|
await page.route("**/rpc/bootstrap", showFirstMemberWorking);
|
||||||
|
await page.route("**/rpc/spaces/list", showFirstMemberWorking);
|
||||||
await page.route("**/rpc/groups/list", async (route) => {
|
await page.route("**/rpc/groups/list", async (route) => {
|
||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
@ -102,6 +144,8 @@ test("create group from + and see two bots in one transcript", async ({ page },
|
|||||||
"rakazo-avatar-spin",
|
"rakazo-avatar-spin",
|
||||||
);
|
);
|
||||||
await captureScreenshot(page, testInfo, "group-avatar-active");
|
await captureScreenshot(page, testInfo, "group-avatar-active");
|
||||||
|
await page.unroute("**/rpc/bootstrap", showFirstMemberWorking);
|
||||||
|
await page.unroute("**/rpc/spaces/list", showFirstMemberWorking);
|
||||||
await page.unroute("**/rpc/groups/list");
|
await page.unroute("**/rpc/groups/list");
|
||||||
await page.unroute("**/rpc/threads/get");
|
await page.unroute("**/rpc/threads/get");
|
||||||
await page.reload();
|
await page.reload();
|
||||||
@ -110,10 +154,12 @@ test("create group from + and see two bots in one transcript", async ({ page },
|
|||||||
const desktopSettings = page.getByTestId("side-panel");
|
const desktopSettings = page.getByTestId("side-panel");
|
||||||
const groupName = desktopSettings.locator("label:has-text('Name') input");
|
const groupName = desktopSettings.locator("label:has-text('Name') input");
|
||||||
await groupName.fill("Unsaved Draft team name");
|
await groupName.fill("Unsaved Draft team name");
|
||||||
const sidebar = page.locator("aside").first();
|
const projekte = page.locator(".sidebar-projekte");
|
||||||
await sidebar.getByRole("button", { name: /^Review team/ }).click();
|
await page.goto(`/app/g/${reviewGroup.id}`);
|
||||||
|
await page.getByTestId("bot-settings-trigger").click();
|
||||||
await expect(groupName).toHaveValue("Review team");
|
await expect(groupName).toHaveValue("Review team");
|
||||||
await sidebar.getByRole("button", { name: /^Draft team/ }).click();
|
await page.goto(`/app/g/${draftGroupId}`);
|
||||||
|
await page.getByTestId("bot-settings-trigger").click();
|
||||||
await expect(groupName).toHaveValue("Draft team");
|
await expect(groupName).toHaveValue("Draft team");
|
||||||
await page.route("**/rpc/groups/update", async (route) => route.abort("failed"));
|
await page.route("**/rpc/groups/update", async (route) => route.abort("failed"));
|
||||||
await desktopSettings.getByRole("button", { name: "Save", exact: true }).click();
|
await desktopSettings.getByRole("button", { name: "Save", exact: true }).click();
|
||||||
@ -122,15 +168,19 @@ test("create group from + and see two bots in one transcript", async ({ page },
|
|||||||
await page.unroute("**/rpc/groups/update");
|
await page.unroute("**/rpc/groups/update");
|
||||||
await desktopSettings.getByRole("button", { name: "Save", exact: true }).click();
|
await desktopSettings.getByRole("button", { name: "Save", exact: true }).click();
|
||||||
|
|
||||||
await page
|
await page.getByRole("combobox", { name: "Message" }).fill("@Researcher unfinished draft");
|
||||||
.getByRole("combobox", { name: "Message Draft team" })
|
await projekte
|
||||||
.fill("@Researcher unfinished draft");
|
.getByRole("button", { name: /^Review team/ })
|
||||||
await sidebar.getByRole("button", { name: /^Review team/ }).click();
|
.first()
|
||||||
await expect(page.getByRole("combobox", { name: "Message Review team" })).toHaveValue("");
|
.click();
|
||||||
await sidebar.getByRole("button", { name: /^Draft team/ }).click();
|
await expect(page.getByRole("combobox", { name: "Message" })).toHaveValue("");
|
||||||
await expect(page.getByRole("combobox", { name: "Message Draft team" })).toHaveValue("");
|
await projekte
|
||||||
|
.getByRole("button", { name: /^Draft team/ })
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
await expect(page.getByRole("combobox", { name: "Message" })).toHaveValue("");
|
||||||
|
|
||||||
const composer = page.getByRole("combobox", { name: "Message Draft team" });
|
const composer = page.getByRole("combobox", { name: "Message" });
|
||||||
await composer.fill("@Res");
|
await composer.fill("@Res");
|
||||||
await captureScreenshot(page, testInfo, "group-mention-picker");
|
await captureScreenshot(page, testInfo, "group-mention-picker");
|
||||||
await page.getByRole("option", { name: "@Research Writer", exact: true }).click();
|
await page.getByRole("option", { name: "@Research Writer", exact: true }).click();
|
||||||
@ -150,7 +200,7 @@ test("create group from + and see two bots in one transcript", async ({ page },
|
|||||||
await expect(transcript.getByText("Researcher", { exact: true }).first()).toBeVisible();
|
await expect(transcript.getByText("Researcher", { exact: true }).first()).toBeVisible();
|
||||||
await expect(transcript.getByText("Research Writer", { exact: true }).first()).toBeVisible();
|
await expect(transcript.getByText("Research Writer", { exact: true }).first()).toBeVisible();
|
||||||
const researcherSpeak = transcript
|
const researcherSpeak = transcript
|
||||||
.locator("div")
|
.locator("[data-message-id]")
|
||||||
.filter({ has: page.getByText("Researcher", { exact: true }) })
|
.filter({ has: page.getByText("Researcher", { exact: true }) })
|
||||||
.filter({ has: page.getByRole("button", { name: "Speak this reply" }) })
|
.filter({ has: page.getByRole("button", { name: "Speak this reply" }) })
|
||||||
.first()
|
.first()
|
||||||
@ -186,7 +236,7 @@ test("create group from + and see two bots in one transcript", async ({ page },
|
|||||||
const cityAsk = page.locator("p").filter({ hasText: /^Which city should I use\?$/ });
|
const cityAsk = page.locator("p").filter({ hasText: /^Which city should I use\?$/ });
|
||||||
if ((await cityAsk.count()) === 0) {
|
if ((await cityAsk.count()) === 0) {
|
||||||
await page.reload({ waitUntil: "domcontentloaded" });
|
await page.reload({ waitUntil: "domcontentloaded" });
|
||||||
await expect(page.getByRole("combobox", { name: "Message Draft team" })).toBeVisible({
|
await expect(page.getByRole("combobox", { name: "Message" })).toBeVisible({
|
||||||
timeout: 15_000,
|
timeout: 15_000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -196,10 +246,19 @@ test("create group from + and see two bots in one transcript", async ({ page },
|
|||||||
await page.getByRole("button", { name: "Send answer" }).click();
|
await page.getByRole("button", { name: "Send answer" }).click();
|
||||||
await expect(page.getByText("Answered: Paris", { exact: true })).toBeVisible({ timeout: 30_000 });
|
await expect(page.getByText("Answered: Paris", { exact: true })).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
const firstMessage = transcript.locator("[data-message-id]").first();
|
const replyRow = transcript
|
||||||
await firstMessage.hover();
|
.locator("[data-message-id]")
|
||||||
const replyButton = firstMessage.getByRole("button", { name: "Reply" });
|
.filter({ has: page.getByRole("button", { name: "Speak this reply" }) })
|
||||||
await expect(replyButton).toBeVisible();
|
.first();
|
||||||
|
const replyRail = replyRow.getByTestId("message-hover-rail");
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
await replyRow.hover();
|
||||||
|
return replyRail.evaluate((element) => getComputedStyle(element).pointerEvents);
|
||||||
|
})
|
||||||
|
.toBe("auto");
|
||||||
|
const replyButton = replyRail.getByRole("button", { name: "Reply" });
|
||||||
|
await replyButton.focus();
|
||||||
await replyButton.click();
|
await replyButton.click();
|
||||||
await expect(page.getByTestId("reply-chip")).toContainText(/Replying to/);
|
await expect(page.getByTestId("reply-chip")).toContainText(/Replying to/);
|
||||||
await page.getByRole("button", { name: "Cancel reply" }).click();
|
await page.getByRole("button", { name: "Cancel reply" }).click();
|
||||||
@ -221,14 +280,20 @@ test("create group from + and see two bots in one transcript", async ({ page },
|
|||||||
await reviewSnapshotReleased;
|
await reviewSnapshotReleased;
|
||||||
await route.continue();
|
await route.continue();
|
||||||
});
|
});
|
||||||
await sidebar.getByRole("button", { name: /^Review team/ }).click();
|
await projekte
|
||||||
|
.getByRole("button", { name: /^Review team/ })
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
await reviewSnapshotIntercepted;
|
await reviewSnapshotIntercepted;
|
||||||
await expect(page).toHaveURL(new RegExp(`/app/g/${reviewGroup.id}$`));
|
await expect(page).toHaveURL(new RegExp(`/app/g/${reviewGroup.id}$`));
|
||||||
await expect(page.getByTestId("transcript")).not.toContainText("Answered: Paris");
|
await expect(page.getByTestId("transcript")).not.toContainText("Answered: Paris");
|
||||||
releaseReviewSnapshot();
|
releaseReviewSnapshot();
|
||||||
await expect(page.getByRole("combobox", { name: "Message Review team" })).toBeVisible();
|
await expect(page.getByRole("combobox", { name: "Message" })).toBeVisible();
|
||||||
await page.unroute("**/rpc/threads/get");
|
await page.unroute("**/rpc/threads/get");
|
||||||
await sidebar.getByRole("button", { name: /^Draft team/ }).click();
|
await projekte
|
||||||
|
.getByRole("button", { name: /^Draft team/ })
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
await expect(page.getByText("Answered: Paris", { exact: true })).toBeVisible();
|
await expect(page.getByText("Answered: Paris", { exact: true })).toBeVisible();
|
||||||
|
|
||||||
await composer.fill(
|
await composer.fill(
|
||||||
|
|||||||
@ -1,4 +1,9 @@
|
|||||||
import { expect, type Page, type TestInfo } from "@playwright/test";
|
import { expect, type Locator, type Page, type TestInfo } from "@playwright/test";
|
||||||
|
|
||||||
|
/** Unterhaltungen rows only — `.sidebar-projekte` is nested inside the same shell block on main. */
|
||||||
|
export function unterhaltungenOnly(page: Page) {
|
||||||
|
return page.locator(".sidebar-unterhaltungen").locator(":scope > *:not(.sidebar-projekte)");
|
||||||
|
}
|
||||||
|
|
||||||
export function isRealSandboxProvider(provider = process.env.SANDBOX_PROVIDER) {
|
export function isRealSandboxProvider(provider = process.env.SANDBOX_PROVIDER) {
|
||||||
return provider === "e2b" || provider === "daytona" || provider === "box";
|
return provider === "e2b" || provider === "daytona" || provider === "box";
|
||||||
@ -24,86 +29,51 @@ export async function rpc<T>(page: Page, procedure: string, body: unknown): Prom
|
|||||||
return parsed.json as T;
|
return parsed.json as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function visible(locator: Locator, timeout: number) {
|
||||||
|
return locator.isVisible({ timeout }).catch(() => false);
|
||||||
|
}
|
||||||
|
|
||||||
export async function completeOnboarding(page: Page, testInfo?: TestInfo) {
|
export async function completeOnboarding(page: Page, testInfo?: TestInfo) {
|
||||||
await page.waitForURL(/\/(onboarding|app)/, { timeout: 20_000 });
|
await page.waitForURL(/\/(onboarding|app)/, { timeout: 20_000 });
|
||||||
|
|
||||||
// Setup progress / "Don't stress" page — wait for it to settle
|
if (page.url().includes("/onboarding")) {
|
||||||
const setupProgress = page.locator('progress[aria-label="Setup progress"]');
|
await expect(page.locator('[data-rakazo-surface="onboarding"]')).toHaveAttribute(
|
||||||
await setupProgress.waitFor({ state: "hidden", timeout: 30_000 }).catch(() => {});
|
"data-onboarding-step",
|
||||||
|
/^(emoji|preferences|language|voice|integrations)$/,
|
||||||
// Emoji step: "Design your Oo" — skip by clicking Continue
|
{ timeout: 30_000 },
|
||||||
// Wait for the design page to be fully interactive
|
);
|
||||||
await page.getByText(/Design your Oo/i).first().waitFor({ timeout: 10000 });
|
|
||||||
await page.getByRole("button", { name: "Continue" }).click();
|
|
||||||
// Wait for next page or URL change
|
|
||||||
await page.waitForTimeout(1000);
|
|
||||||
|
|
||||||
// Preferences step: just skip
|
|
||||||
const prefHeading = page.getByRole("heading", { name: /Tell us about yourself/i });
|
|
||||||
if (await prefHeading.isVisible({ timeout: 5000 }).catch(() => false)) {
|
|
||||||
await page.getByRole("button", { name: /Continue/i }).first().click();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Language step: skip (defaults to English)
|
const design = page.getByRole("heading", { name: /Design your Oo/i });
|
||||||
const langHeading = page.getByRole("heading", { name: /Choose your language/i });
|
const preferences = page.getByRole("heading", { name: /Tell us about yourself/i });
|
||||||
if (await langHeading.isVisible({ timeout: 5000 }).catch(() => false)) {
|
const language = page.getByRole("heading", { name: /Choose your language/i });
|
||||||
const skipBtn = page.getByRole("button", { name: /Skip/i });
|
const voice = page.getByRole("heading", { name: /Pick a voice/i });
|
||||||
if (await skipBtn.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
||||||
await skipBtn.click();
|
|
||||||
} else {
|
|
||||||
await page.getByRole("button", { name: /Continue/i }).first().click();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Voice step: skip when shown
|
|
||||||
const voiceHeading = page.getByRole("heading", { name: /Pick a voice/i });
|
|
||||||
if (await voiceHeading.isVisible({ timeout: 5000 }).catch(() => false)) {
|
|
||||||
await page.getByRole("button", { name: /^Skip$/i }).click();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Optional Server integrations step (needsSetup). Skip when shown, then the
|
|
||||||
// first bot is created automatically — land in Chief's chat with no form.
|
|
||||||
const integrations = page.getByRole("heading", { name: "Server integrations", exact: true });
|
const integrations = page.getByRole("heading", { name: "Server integrations", exact: true });
|
||||||
const chief = page.getByText("Chief").first();
|
const chief = page.getByText("Chief").first();
|
||||||
const openingChat = page.getByText("Opening chat…");
|
|
||||||
|
|
||||||
// Try each target sequentially (or() can fail when none match)
|
// Scripted CI has needsModel false, so a fresh owner starts on integrations.
|
||||||
let landed = false;
|
// A model-gated run still opens the look picker first.
|
||||||
if (await integrations.isVisible({ timeout: 5000 }).catch(() => false)) {
|
const entry = design.or(integrations).or(chief);
|
||||||
|
await expect(entry.first()).toBeVisible({ timeout: 20_000 });
|
||||||
|
|
||||||
|
if (await design.isVisible()) {
|
||||||
|
await page.getByRole("button", { name: "Continue", exact: true }).click();
|
||||||
|
await expect(preferences).toBeVisible();
|
||||||
|
await page.getByRole("button", { name: "Continue", exact: true }).click();
|
||||||
|
await expect(language).toBeVisible();
|
||||||
|
await page.getByRole("button", { name: /Skip/i }).click();
|
||||||
|
await expect(voice).toBeVisible();
|
||||||
|
await page.getByRole("button", { name: "Skip", exact: true }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await visible(integrations, 8_000)) {
|
||||||
if (testInfo) await captureScreenshot(page, testInfo, "02-connect-apps");
|
if (testInfo) await captureScreenshot(page, testInfo, "02-connect-apps");
|
||||||
await page.getByRole("button", { name: "Skip", exact: true }).click();
|
await page.getByRole("button", { name: "Skip", exact: true }).click();
|
||||||
landed = true;
|
|
||||||
}
|
|
||||||
if (!landed && await chief.isVisible({ timeout: 5000 }).catch(() => false)) {
|
|
||||||
landed = true;
|
|
||||||
}
|
|
||||||
if (!landed && await openingChat.isVisible({ timeout: 5000 }).catch(() => false)) {
|
|
||||||
landed = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (landed) {
|
await page.waitForURL(/\/app\/[^/]+/, { timeout: 20_000 });
|
||||||
await page.waitForURL(/\/app(\/[^/]+)?/, { timeout: 20_000 }).catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Final: ensure we're in the chat with Chief
|
|
||||||
await page.waitForURL(/\/app(\/[^/]+)?/, { timeout: 20_000 }).catch(() => {});
|
|
||||||
await expect(chief).toBeVisible({ timeout: 15_000 });
|
await expect(chief).toBeVisible({ timeout: 15_000 });
|
||||||
|
|
||||||
// Dismiss any onboarding overlay (e.g. "What do you want me on first?")
|
|
||||||
// Try multiple strategies: Escape key, close button, dismiss button
|
|
||||||
const overlay = page.locator('[role="dialog"], [role="presentation"], [data-overlay]');
|
|
||||||
const overlayVisible = await overlay.count({ timeout: 3000 }).catch(() => 0);
|
|
||||||
if (overlayVisible > 0) {
|
|
||||||
await page.keyboard.press('Escape');
|
|
||||||
await page.waitForTimeout(500);
|
|
||||||
}
|
|
||||||
// Also try clicking X
|
|
||||||
const closeBtn = page.getByRole("button", { name: "X" }).first();
|
|
||||||
if (await closeBtn.isVisible({ timeout: 1000 }).catch(() => false)) {
|
|
||||||
await closeBtn.click();
|
|
||||||
await page.waitForTimeout(500);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (testInfo) {
|
if (testInfo) {
|
||||||
await captureScreenshot(page, testInfo, "03-create-first-bot");
|
await captureScreenshot(page, testInfo, "03-create-first-bot");
|
||||||
await captureScreenshot(page, testInfo, "06-onboarding-complete");
|
await captureScreenshot(page, testInfo, "06-onboarding-complete");
|
||||||
@ -187,7 +157,15 @@ export async function createBotFromPicker(
|
|||||||
/** Open the user Settings overlay, optionally switching to a sidebar section. */
|
/** Open the user Settings overlay, optionally switching to a sidebar section. */
|
||||||
export async function openUserSettings(
|
export async function openUserSettings(
|
||||||
page: Page,
|
page: Page,
|
||||||
section?: "general" | "models" | "memory" | "voice" | "usage" | "computer" | "updates",
|
section?:
|
||||||
|
| "general"
|
||||||
|
| "integrations"
|
||||||
|
| "models"
|
||||||
|
| "memory"
|
||||||
|
| "voice"
|
||||||
|
| "usage"
|
||||||
|
| "computer"
|
||||||
|
| "updates",
|
||||||
) {
|
) {
|
||||||
await page.getByTestId("user-menu-trigger").click();
|
await page.getByTestId("user-menu-trigger").click();
|
||||||
await page.getByRole("button", { name: "Settings", exact: true }).click();
|
await page.getByRole("button", { name: "Settings", exact: true }).click();
|
||||||
|
|||||||
@ -6,8 +6,8 @@ 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" }).click();
|
await page.getByRole("button", { name: "Heute", 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");
|
||||||
await expect(brief).toBeVisible();
|
await expect(brief).toBeVisible();
|
||||||
|
|||||||
@ -44,7 +44,7 @@ test("setup exposes all integration choices and saves only the selected provider
|
|||||||
.poll(() => saved)
|
.poll(() => saved)
|
||||||
.toEqual([{ json: { provider: "composio", apiKey: "fake-composio-key" } }]);
|
.toEqual([{ json: { provider: "composio", apiKey: "fake-composio-key" } }]);
|
||||||
await expect(page.getByRole("heading", { name: "Create your first bot" })).toHaveCount(0);
|
await expect(page.getByRole("heading", { name: "Create your first bot" })).toHaveCount(0);
|
||||||
await expect(page.getByRole("combobox", { name: "Message Chief" })).toBeVisible({
|
await expect(page.getByRole("combobox", { name: "Message" })).toBeVisible({
|
||||||
timeout: 20_000,
|
timeout: 20_000,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -117,13 +117,12 @@ test("direct MCP connects a catalog result without asking for a URL and assigns
|
|||||||
(response) => response.url().includes("/rpc/mcp/assignments/approve") && response.ok(),
|
(response) => response.url().includes("/rpc/mcp/assignments/approve") && response.ok(),
|
||||||
);
|
);
|
||||||
await page.getByRole("button", { name: "Continue", exact: true }).click();
|
await page.getByRole("button", { name: "Continue", exact: true }).click();
|
||||||
await expect(page.getByText("Opening chat…")).toBeVisible();
|
|
||||||
await expect.poll(() => createCalls).toBe(1);
|
await expect.poll(() => createCalls).toBe(1);
|
||||||
releaseCreate();
|
releaseCreate();
|
||||||
const response = await assigned;
|
const response = await assigned;
|
||||||
expect(response.request().postDataJSON().json.serverId).toBe(serverId);
|
expect(response.request().postDataJSON().json.serverId).toBe(serverId);
|
||||||
await page.waitForURL(/\/app\//);
|
await page.waitForURL(/\/app\//);
|
||||||
await expect(page.getByRole("combobox", { name: "Message Chief" })).toBeVisible();
|
await expect(page.getByRole("combobox", { name: "Message" })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Executor reconnect saves a replacement token before authorization", async ({ page }) => {
|
test("Executor reconnect saves a replacement token before authorization", async ({ page }) => {
|
||||||
|
|||||||
@ -59,8 +59,11 @@ test("memory and skills are readable and editable in the app", async ({ page },
|
|||||||
await captureScreenshot(page, testInfo, "84-space-memory-saved");
|
await captureScreenshot(page, testInfo, "84-space-memory-saved");
|
||||||
const sharedDocuments = await rpc<MemoryDocument[]>(page, "memory/list", { scope: "user" });
|
const sharedDocuments = await rpc<MemoryDocument[]>(page, "memory/list", { scope: "user" });
|
||||||
expect(sharedDocuments).toContainEqual(
|
expect(sharedDocuments).toContainEqual(
|
||||||
expect.objectContaining({ content: `# Memory\n\n${marker}\n`, revision: 2 }),
|
expect.objectContaining({ content: `# Memory\n\n${marker}\n` }),
|
||||||
);
|
);
|
||||||
|
expect(
|
||||||
|
sharedDocuments.find((doc) => doc.content.includes(marker))?.revision,
|
||||||
|
).toBeGreaterThanOrEqual(2);
|
||||||
// Export must fetch fresh content and exclude the bot's private document.
|
// Export must fetch fresh content and exclude the bot's private document.
|
||||||
const sharedDocument = sharedDocuments.find((doc) => doc.path === "MEMORY.md")!;
|
const sharedDocument = sharedDocuments.find((doc) => doc.path === "MEMORY.md")!;
|
||||||
const latestMarker = `Latest shared memory ${stamp}`;
|
const latestMarker = `Latest shared memory ${stamp}`;
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { expect, type Route, test } from "@playwright/test";
|
import { expect, type Route, test } from "@playwright/test";
|
||||||
import type { McpServer } from "@rakazo/contracts";
|
import type { McpServer } from "@rakazo/contracts";
|
||||||
import { MCP_OAUTH_CHANNEL } from "../src/lib/mcp-connect";
|
import { MCP_OAUTH_CHANNEL } from "../src/lib/mcp-connect";
|
||||||
import { captureScreenshot, completeOnboarding, signup } from "./helpers";
|
import { captureScreenshot, completeOnboarding, openUserSettings, signup } from "./helpers";
|
||||||
|
|
||||||
test("connects an MCP server through the OAuth popup callback", async ({ page }, testInfo) => {
|
test("connects an MCP server through the OAuth popup callback", async ({ page }, testInfo) => {
|
||||||
const stamp = Date.now();
|
const stamp = Date.now();
|
||||||
@ -83,7 +83,7 @@ test("connects an MCP server through the OAuth popup callback", async ({ page },
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.getByText("Integrations", { exact: true }).click();
|
await openUserSettings(page, "integrations");
|
||||||
await page.getByTestId("integrations-advanced").evaluate((element) => {
|
await page.getByTestId("integrations-advanced").evaluate((element) => {
|
||||||
(element as HTMLDetailsElement).open = true;
|
(element as HTMLDetailsElement).open = true;
|
||||||
});
|
});
|
||||||
|
|||||||
@ -30,8 +30,9 @@ test("mention picker completes with Enter and Tab", async ({ page }, testInfo) =
|
|||||||
await panel.getByRole("button", { name: "Research Writer" }).click();
|
await panel.getByRole("button", { name: "Research Writer" }).click();
|
||||||
await page.getByRole("button", { name: "Create group", exact: true }).click();
|
await page.getByRole("button", { name: "Create group", exact: true }).click();
|
||||||
await page.waitForURL(/\/app\/g\/[^/]+$/);
|
await page.waitForURL(/\/app\/g\/[^/]+$/);
|
||||||
|
await expect(page.locator('[data-ready="true"]')).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
const composer = page.getByRole("combobox", { name: "Message Mention keys team" });
|
const composer = page.getByRole("combobox", { name: "Message" });
|
||||||
await expect(composer).toBeVisible();
|
await expect(composer).toBeVisible();
|
||||||
|
|
||||||
await composer.fill("@Res");
|
await composer.fill("@Res");
|
||||||
|
|||||||
@ -58,7 +58,7 @@ test("message hover shows beside-bubble actions; reply links to parent", async (
|
|||||||
await expect(page.getByText("What do you want me on first?", { exact: true })).toBeVisible({
|
await expect(page.getByText("What do you want me on first?", { exact: true })).toBeVisible({
|
||||||
timeout: 20_000,
|
timeout: 20_000,
|
||||||
});
|
});
|
||||||
await page.getByRole("button", { name: /Day-to-day work/ }).click();
|
await page.getByTestId("focus-choice-day").click();
|
||||||
const botText = page.getByText(/Got it\./);
|
const botText = page.getByText(/Got it\./);
|
||||||
await expect(botText).toBeVisible({ timeout: 20_000 });
|
await expect(botText).toBeVisible({ timeout: 20_000 });
|
||||||
const botRow = transcript.locator(`[data-message-id]`).filter({ has: botText }).first();
|
const botRow = transcript.locator(`[data-message-id]`).filter({ has: botText }).first();
|
||||||
@ -182,10 +182,12 @@ test("message hover shows beside-bubble actions; reply links to parent", async (
|
|||||||
expect(Math.abs(timeBox!.x - rowBox!.x)).toBeLessThan(2);
|
expect(Math.abs(timeBox!.x - rowBox!.x)).toBeLessThan(2);
|
||||||
await toolbar.getByRole("button", { name: "More" }).click();
|
await toolbar.getByRole("button", { name: "More" }).click();
|
||||||
await expect(page.getByRole("menu").locator("time")).toHaveCount(0);
|
await expect(page.getByRole("menu").locator("time")).toHaveCount(0);
|
||||||
// Escape closes More and restores focus to the trigger so the rail stays up.
|
// Escape closes More; the hover rail stays visible for the pinned row.
|
||||||
await page.keyboard.press("Escape");
|
await page.keyboard.press("Escape");
|
||||||
await expect(toolbar.getByRole("button", { name: "More" })).toBeFocused();
|
await expect(page.getByRole("menu")).toHaveCount(0);
|
||||||
|
await expect(toolbar.getByRole("button", { name: "More" })).toBeVisible();
|
||||||
await captureScreenshot(page, testInfo, "message-user-actions-hover-desktop");
|
await captureScreenshot(page, testInfo, "message-user-actions-hover-desktop");
|
||||||
|
await page.getByRole("combobox", { name: "Message" }).focus();
|
||||||
// Default transcript shot: rail at rest (no hover pin, mouse clear).
|
// Default transcript shot: rail at rest (no hover pin, mouse clear).
|
||||||
await expectRailAtRest(page, parentRow);
|
await expectRailAtRest(page, parentRow);
|
||||||
await captureScreenshot(page, testInfo, "message-user-bubble-desktop");
|
await captureScreenshot(page, testInfo, "message-user-bubble-desktop");
|
||||||
@ -366,7 +368,7 @@ test.describe("touch message actions", () => {
|
|||||||
await expect(page.getByText("What do you want me on first?", { exact: true })).toBeVisible({
|
await expect(page.getByText("What do you want me on first?", { exact: true })).toBeVisible({
|
||||||
timeout: 20_000,
|
timeout: 20_000,
|
||||||
});
|
});
|
||||||
await page.getByRole("button", { name: /Day-to-day work/ }).click();
|
await page.getByTestId("focus-choice-day").click();
|
||||||
const botText = page.getByText(/Got it\./);
|
const botText = page.getByText(/Got it\./);
|
||||||
await expect(botText).toBeVisible({ timeout: 20_000 });
|
await expect(botText).toBeVisible({ timeout: 20_000 });
|
||||||
const row = page
|
const row = page
|
||||||
|
|||||||
@ -14,7 +14,7 @@ test("message composer is available and interactive", async ({ page }) => {
|
|||||||
await expect(page.getByText("Chief").first()).toBeVisible();
|
await expect(page.getByText("Chief").first()).toBeVisible();
|
||||||
|
|
||||||
// Create a fresh bot via RPC
|
// Create a fresh bot via RPC
|
||||||
const botId = await createNamedBot(page, `TestBot-${stamp}`);
|
await createNamedBot(page, `TestBot-${stamp}`);
|
||||||
await expect(page.getByRole("combobox", { name: "Message" })).toBeVisible({
|
await expect(page.getByRole("combobox", { name: "Message" })).toBeVisible({
|
||||||
timeout: 10_000,
|
timeout: 10_000,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -45,19 +45,19 @@ test("create opens form, then empty chat; picker lists bots; sidebar collapses",
|
|||||||
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 captureScreenshot(page, testInfo, "create-chat-sidepanel-closed");
|
await captureScreenshot(page, testInfo, "create-chat-sidepanel-closed");
|
||||||
|
|
||||||
await page.getByTestId("minimize-bots-sidebar").click();
|
await page.getByTestId("toggle-bots-sidebar").click();
|
||||||
await expect(page.getByTestId("bots-sidebar")).toHaveAttribute("data-collapsed", "true");
|
await expect(page.getByTestId("bots-sidebar")).toHaveAttribute("data-collapsed", "true");
|
||||||
const edge = page.getByTestId("bots-sidebar-edge");
|
const edge = page.getByTestId("bots-sidebar-edge");
|
||||||
await expect(edge).toBeVisible();
|
await expect(edge).toBeVisible();
|
||||||
const restore = page.getByTestId("restore-bots-sidebar");
|
const restore = page.getByTestId("toggle-bots-sidebar");
|
||||||
await expect(restore).toBeVisible();
|
await expect(restore).toBeVisible();
|
||||||
await captureScreenshot(page, testInfo, "bots-sidebar-collapsed");
|
await captureScreenshot(page, testInfo, "bots-sidebar-collapsed");
|
||||||
|
|
||||||
await restore.click();
|
await restore.click();
|
||||||
await expect(page.getByTestId("bots-sidebar")).toHaveAttribute("data-collapsed", "false");
|
await expect(page.getByTestId("bots-sidebar")).toHaveAttribute("data-collapsed", "false");
|
||||||
await expect(restore).toHaveCount(0);
|
await expect(page.getByRole("button", { name: "Minimize bots", exact: true })).toBeVisible();
|
||||||
|
|
||||||
await page.getByTestId("minimize-bots-sidebar").click();
|
await page.getByTestId("toggle-bots-sidebar").click();
|
||||||
await expect(page.getByTestId("bots-sidebar")).toHaveAttribute("data-collapsed", "true");
|
await expect(page.getByTestId("bots-sidebar")).toHaveAttribute("data-collapsed", "true");
|
||||||
|
|
||||||
const box = await edge.boundingBox();
|
const box = await edge.boundingBox();
|
||||||
@ -137,7 +137,9 @@ test("later bot waits before showing the focus card; sending cancels it", async
|
|||||||
);
|
);
|
||||||
await page.keyboard.press("Enter");
|
await page.keyboard.press("Enter");
|
||||||
await sent;
|
await sent;
|
||||||
await expect(page.getByTestId("transcript").getByText("I'll set this up myself")).toBeVisible();
|
await expect(
|
||||||
|
page.getByTestId("transcript").getByText("I'll set this up myself").first(),
|
||||||
|
).toBeVisible();
|
||||||
await page.clock.fastForward(12_000);
|
await page.clock.fastForward(12_000);
|
||||||
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);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -16,14 +16,15 @@ test("focus choice suggests apps and preserves a completed connection", async ({
|
|||||||
page.getByText("Hey Robin. Fresh start on my side, so I’ll keep this short."),
|
page.getByText("Hey Robin. Fresh start on my side, so I’ll keep this short."),
|
||||||
).toHaveCount(0);
|
).toHaveCount(0);
|
||||||
await expect(page.getByText("What do you want me on first?", { exact: true })).toBeVisible();
|
await expect(page.getByText("What do you want me on first?", { exact: true })).toBeVisible();
|
||||||
await expect(page.getByRole("button", { name: /Day-to-day work/ })).toBeVisible();
|
await expect(page.getByTestId("focus-choice-day")).toBeVisible();
|
||||||
await expect(page.getByRole("button", { name: /Research & writing/ })).toBeVisible();
|
await expect(page.getByTestId("focus-choice-day")).toContainText("Day-to-day work");
|
||||||
await expect(page.getByRole("button", { name: /A bit of everything/ })).toBeVisible();
|
await expect(page.getByTestId("focus-choice-research")).toContainText("Research & writing");
|
||||||
|
await expect(page.getByTestId("focus-choice-crew")).toContainText("Specialist crew");
|
||||||
await page.mouse.move(1, 1);
|
await page.mouse.move(1, 1);
|
||||||
await captureScreenshot(page, testInfo, "01-focus-choice");
|
await captureScreenshot(page, testInfo, "01-focus-choice");
|
||||||
await captureScreenshot(page, testInfo, "choice-card-onboarding");
|
await captureScreenshot(page, testInfo, "choice-card-onboarding");
|
||||||
|
|
||||||
await page.getByRole("button", { name: /Day-to-day work/ }).click();
|
await page.getByTestId("focus-choice-day").click();
|
||||||
// The focus step suggests apps but must not rename the bot: the name the
|
// The focus step suggests apps but must not rename the bot: the name the
|
||||||
// user chose during creation ("Chief") is preserved.
|
// user chose during creation ("Chief") is preserved.
|
||||||
await expect(page.locator("main").getByText("Chief", { exact: true })).toBeVisible();
|
await expect(page.locator("main").getByText("Chief", { exact: true })).toBeVisible();
|
||||||
@ -92,7 +93,7 @@ test("focus choice suggests apps and preserves a completed connection", async ({
|
|||||||
test("choice refresh failures leave options available for retry", async ({ page }) => {
|
test("choice refresh failures leave options available for retry", async ({ page }) => {
|
||||||
await signup(page, `choice-refresh-${Date.now()}@rakazo.test`, "password12", "Choice Retry");
|
await signup(page, `choice-refresh-${Date.now()}@rakazo.test`, "password12", "Choice Retry");
|
||||||
await completeOnboarding(page);
|
await completeOnboarding(page);
|
||||||
const choice = page.getByRole("button", { name: /Day-to-day work/ });
|
const choice = page.getByTestId("focus-choice-day");
|
||||||
await expect(choice).toBeEnabled();
|
await expect(choice).toBeEnabled();
|
||||||
// Keep the existing choice rendered while its save succeeds and navigation refresh fails.
|
// Keep the existing choice rendered while its save succeeds and navigation refresh fails.
|
||||||
await page.route("**/rpc/onboarding/choose", (route) =>
|
await page.route("**/rpc/onboarding/choose", (route) =>
|
||||||
|
|||||||
@ -1,5 +1,20 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
import { captureScreenshot, completeOnboarding, signup } from "./helpers";
|
import { captureScreenshot, signup } from "./helpers";
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.route("**/rpc/integrationSetup/get", (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
json: {
|
||||||
|
json: {
|
||||||
|
canConfigure: true,
|
||||||
|
needsSetup: false,
|
||||||
|
webUrl: "https://example.test/integrations/setup",
|
||||||
|
providers: [{ id: "composio", configured: true }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* E2E test for the new emoji-first onboarding flow:
|
* E2E test for the new emoji-first onboarding flow:
|
||||||
@ -30,7 +45,7 @@ test("emoji-first onboarding: shape + color selection with live preview", async
|
|||||||
await page.getByTestId("onboarding-look-category-work").click();
|
await page.getByTestId("onboarding-look-category-work").click();
|
||||||
await page.getByRole("tab", { name: /Look 3 of/i }).click();
|
await page.getByRole("tab", { name: /Look 3 of/i }).click();
|
||||||
|
|
||||||
const botAvatar = page.locator('[data-testid="onboarding-look-preview"] svg');
|
const botAvatar = page.locator('[data-testid="onboarding-look-preview"] svg').first();
|
||||||
await expect(botAvatar).toBeVisible();
|
await expect(botAvatar).toBeVisible();
|
||||||
|
|
||||||
if (testInfo) await captureScreenshot(page, testInfo, "02-shape-color-preview");
|
if (testInfo) await captureScreenshot(page, testInfo, "02-shape-color-preview");
|
||||||
@ -50,7 +65,7 @@ test("emoji-first onboarding: shape + color selection with live preview", async
|
|||||||
await page.getByPlaceholder(/Your name/i).fill("Alice");
|
await page.getByPlaceholder(/Your name/i).fill("Alice");
|
||||||
|
|
||||||
// Fill in timezone
|
// Fill in timezone
|
||||||
await page.getByPlaceholder(/Timezone/i).fill("Europe/Vienna");
|
await page.getByLabel(/Timezone/i).fill("Europe/Vienna");
|
||||||
|
|
||||||
// Select a hobby
|
// Select a hobby
|
||||||
const hobbyButtons = page.locator("button[aria-pressed]");
|
const hobbyButtons = page.locator("button[aria-pressed]");
|
||||||
@ -83,19 +98,16 @@ test("emoji-first onboarding: shape + color selection with live preview", async
|
|||||||
|
|
||||||
if (testInfo) await captureScreenshot(page, testInfo, "05-language-step");
|
if (testInfo) await captureScreenshot(page, testInfo, "05-language-step");
|
||||||
|
|
||||||
// Select Deutsch
|
// Select Deutsch — choosing a language continues to the voice step.
|
||||||
await page.getByRole("button", { name: /Deutsch/ }).click();
|
await page.getByRole("button", { name: /Deutsch/ }).click();
|
||||||
|
await expect(page.getByRole("heading", { name: /Pick a voice/i })).toBeVisible();
|
||||||
|
await page.getByRole("button", { name: "Skip", exact: true }).click();
|
||||||
|
|
||||||
// Should go to integrations or bot creation
|
|
||||||
// Either skip integrations or go directly to bot creation
|
|
||||||
const chief = page.getByText("Chief").first();
|
const chief = page.getByText("Chief").first();
|
||||||
const integrationsHeading = page.getByRole("heading", {
|
const integrationsHeading = page.getByRole("heading", {
|
||||||
name: /Server integrations/i,
|
name: /Server integrations/i,
|
||||||
});
|
});
|
||||||
await integrationsHeading
|
await integrationsHeading.or(chief).waitFor({ timeout: 20_000 });
|
||||||
.or(chief)
|
|
||||||
.or(page.getByText("Opening chat…"))
|
|
||||||
.waitFor({ timeout: 20_000 });
|
|
||||||
|
|
||||||
if (testInfo) await captureScreenshot(page, testInfo, "06-integrations-or-bot");
|
if (testInfo) await captureScreenshot(page, testInfo, "06-integrations-or-bot");
|
||||||
|
|
||||||
@ -110,7 +122,7 @@ test("emoji-first onboarding: shape + color selection with live preview", async
|
|||||||
if (testInfo) await captureScreenshot(page, testInfo, "07-onboarding-complete");
|
if (testInfo) await captureScreenshot(page, testInfo, "07-onboarding-complete");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("onboarding: skip language defaults to English", async ({ page }, testInfo) => {
|
test("onboarding: skip language defaults to English", async ({ page }) => {
|
||||||
const stamp = Date.now();
|
const stamp = Date.now();
|
||||||
await signup(page, `lang-skip-${stamp}@rakazo.test`, "password12", "Skipper");
|
await signup(page, `lang-skip-${stamp}@rakazo.test`, "password12", "Skipper");
|
||||||
|
|
||||||
@ -130,9 +142,11 @@ test("onboarding: skip language defaults to English", async ({ page }, testInfo)
|
|||||||
.first()
|
.first()
|
||||||
.click();
|
.click();
|
||||||
|
|
||||||
// Language step — click Skip
|
// Language step — click Skip, then skip voice.
|
||||||
await expect(page.getByRole("heading", { name: /Choose your language/i })).toBeVisible();
|
await expect(page.getByRole("heading", { name: /Choose your language/i })).toBeVisible();
|
||||||
await page.getByRole("button", { name: /Skip/i }).click();
|
await page.getByRole("button", { name: /Skip/i }).click();
|
||||||
|
await expect(page.getByRole("heading", { name: /Pick a voice/i })).toBeVisible();
|
||||||
|
await page.getByRole("button", { name: "Skip", exact: true }).click();
|
||||||
|
|
||||||
// Should land in bot chat
|
// Should land in bot chat
|
||||||
await page.waitForURL(/\/app\//, { timeout: 20_000 });
|
await page.waitForURL(/\/app\//, { timeout: 20_000 });
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
import { captureScreenshot, signup } from "./helpers";
|
import { signup } from "./helpers";
|
||||||
|
|
||||||
test("onboarding skips model connect when a default model is already available", async ({
|
test("onboarding skips model connect when a default model is already available", async ({
|
||||||
page,
|
page,
|
||||||
}, testInfo) => {
|
}) => {
|
||||||
await page.route("**/rpc/me", async (route) => {
|
await page.route("**/rpc/me", async (route) => {
|
||||||
const response = await route.fetch();
|
const response = await route.fetch();
|
||||||
const body = (await response.json()) as { json: Record<string, unknown> };
|
const body = (await response.json()) as { json: Record<string, unknown> };
|
||||||
@ -26,7 +26,6 @@ test("onboarding skips model connect when a default model is already available",
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const createRequest = page.waitForRequest("**/rpc/bots/create");
|
|
||||||
const stamp = Date.now();
|
const stamp = Date.now();
|
||||||
await signup(
|
await signup(
|
||||||
page,
|
page,
|
||||||
@ -35,28 +34,11 @@ test("onboarding skips model connect when a default model is already available",
|
|||||||
`Model auto skip ${stamp}`,
|
`Model auto skip ${stamp}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
await expect(page.getByRole("heading", { name: "Connect a model" })).toBeHidden();
|
await expect(page.getByRole("heading", { name: /Design your Oo/i })).toBeVisible({
|
||||||
await expect(page.getByRole("heading", { name: "Server integrations" })).toBeHidden();
|
|
||||||
await expect(page.getByRole("heading", { name: "Create your first bot" })).toHaveCount(0);
|
|
||||||
await expect(page.getByRole("button", { name: "Skip for now" })).toBeHidden();
|
|
||||||
await expect(page.getByRole("textbox", { name: "Name" })).toHaveCount(0);
|
|
||||||
await expect(page.getByRole("textbox", { name: "Title" })).toHaveCount(0);
|
|
||||||
await expect(page.getByRole("textbox", { name: "Description" })).toHaveCount(0);
|
|
||||||
for (const name of ["Composio", "Pipedream", "Executor"]) {
|
|
||||||
await expect(page.getByRole("button", { name, exact: true })).toBeHidden();
|
|
||||||
}
|
|
||||||
|
|
||||||
expect((await createRequest).postDataJSON()).toMatchObject({
|
|
||||||
json: {
|
|
||||||
name: "Chief",
|
|
||||||
title: "",
|
|
||||||
description: "",
|
|
||||||
instructions: "",
|
|
||||||
spawnKey: "onboarding:first",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await expect(page.getByRole("combobox", { name: "Message Chief" })).toBeVisible({
|
|
||||||
timeout: 20_000,
|
timeout: 20_000,
|
||||||
});
|
});
|
||||||
await captureScreenshot(page, testInfo, "onboarding-model-auto-skip");
|
await expect(page.getByRole("heading", { name: "Connect a model" })).toHaveCount(0);
|
||||||
|
await expect(page.getByRole("heading", { name: "Server integrations" })).toHaveCount(0);
|
||||||
|
await expect(page.getByRole("heading", { name: "Create your first bot" })).toHaveCount(0);
|
||||||
|
await expect(page.getByRole("combobox", { name: "Message" })).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,9 +1,7 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
import { captureScreenshot, signup } from "./helpers";
|
import { signup } from "./helpers";
|
||||||
|
|
||||||
test("onboarding uses compact model selects without misleading latest labels", async ({
|
test("onboarding no longer gates first run on a model picker", async ({ page }) => {
|
||||||
page,
|
|
||||||
}, testInfo) => {
|
|
||||||
await page.route("**/rpc/me", async (route) => {
|
await page.route("**/rpc/me", async (route) => {
|
||||||
const response = await route.fetch();
|
const response = await route.fetch();
|
||||||
const body = (await response.json()) as { json: Record<string, unknown> };
|
const body = (await response.json()) as { json: Record<string, unknown> };
|
||||||
@ -22,80 +20,9 @@ test("onboarding uses compact model selects without misleading latest labels", a
|
|||||||
|
|
||||||
const stamp = Date.now();
|
const stamp = Date.now();
|
||||||
await signup(page, `model-labels-${stamp}@rakazo.test`, "password12", `Model labels ${stamp}`);
|
await signup(page, `model-labels-${stamp}@rakazo.test`, "password12", `Model labels ${stamp}`);
|
||||||
await expect(page.getByRole("heading", { name: "Connect a model" })).toBeVisible({
|
await expect(page.getByRole("heading", { name: /Design your Oo/i })).toBeVisible({
|
||||||
timeout: 20_000,
|
timeout: 20_000,
|
||||||
});
|
});
|
||||||
|
await expect(page.getByRole("heading", { name: "Connect a model" })).toHaveCount(0);
|
||||||
const provider = page.getByRole("combobox", { name: "Provider" });
|
await expect(page.getByRole("combobox", { name: "Provider" })).toHaveCount(0);
|
||||||
await expect(provider).toContainText("OpenRouter");
|
|
||||||
await page.getByLabel("API key").fill("openrouter-only-key");
|
|
||||||
await provider.click();
|
|
||||||
await expect(page.getByRole("option", { name: "ChatGPT" })).toBeVisible();
|
|
||||||
await expect(page.getByRole("option", { name: "Vercel AI Gateway" })).toBeVisible();
|
|
||||||
await page.getByRole("option", { name: "Anthropic" }).click();
|
|
||||||
await expect(provider).toContainText("Anthropic");
|
|
||||||
await expect(page.getByLabel(/API key/)).toHaveValue("");
|
|
||||||
|
|
||||||
const models = page.getByRole("combobox", { name: "Model", exact: true });
|
|
||||||
await models.click();
|
|
||||||
const labels = await page.getByRole("option").allTextContents();
|
|
||||||
// "latest" is an upstream alias marker, so it lands on families like Claude Opus 4.5 while
|
|
||||||
// newer models carry no marker. Rendered as-is it tells the user the opposite of the truth.
|
|
||||||
expect(labels.filter((label) => /\blatest\b/i.test(label))).toEqual([]);
|
|
||||||
|
|
||||||
// Select a non-default model and keep its user-facing alias visible in the compact trigger.
|
|
||||||
const alias = labels.find((label) => label.includes("(auto-updates)"));
|
|
||||||
expect(alias).toBeTruthy();
|
|
||||||
await page.getByRole("option", { name: alias! }).click();
|
|
||||||
await expect(models).toContainText(alias!);
|
|
||||||
|
|
||||||
await page.route("**/rpc/models/probeOpenAiCompatible", async (route) => {
|
|
||||||
await route.fulfill({
|
|
||||||
contentType: "application/json",
|
|
||||||
body: JSON.stringify({ json: { models: ["probed-model"] } }),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
await provider.click();
|
|
||||||
await page.getByRole("option", { name: "OpenAI-compatible" }).click();
|
|
||||||
await page.getByLabel("OpenAI-compatible server URL").fill("http://127.0.0.1:8090/v1");
|
|
||||||
const manualModel = page.getByLabel("Model id");
|
|
||||||
await expect(manualModel).toBeVisible();
|
|
||||||
// Typing before the first probe must keep freeform even if the probe returns that id.
|
|
||||||
await manualModel.fill("probed-model");
|
|
||||||
const firstProbeResponse = page.waitForResponse("**/rpc/models/probeOpenAiCompatible");
|
|
||||||
await page.getByRole("button", { name: "Find models" }).click();
|
|
||||||
await firstProbeResponse;
|
|
||||||
await expect(manualModel).toBeVisible();
|
|
||||||
await expect(manualModel).toHaveValue("probed-model");
|
|
||||||
await expect(page.getByRole("combobox", { name: "Models from server" })).toHaveCount(0);
|
|
||||||
await page.getByRole("button", { name: "Use a found model" }).click();
|
|
||||||
const discovered = page.getByRole("combobox", { name: "Models from server" });
|
|
||||||
await expect(discovered).toBeVisible();
|
|
||||||
await expect(discovered).toContainText("probed-model");
|
|
||||||
await discovered.click();
|
|
||||||
await page.getByRole("option", { name: "Other model…" }).click();
|
|
||||||
await expect(manualModel).toBeVisible();
|
|
||||||
// Typing a discovered id (or its prefix) must keep the freeform field.
|
|
||||||
await manualModel.fill("probed-model");
|
|
||||||
await expect(manualModel).toBeVisible();
|
|
||||||
await expect(manualModel).toHaveValue("probed-model");
|
|
||||||
// Re-probe while the typed id matches a discovered model must stay freeform.
|
|
||||||
const reProbeResponse = page.waitForResponse("**/rpc/models/probeOpenAiCompatible");
|
|
||||||
await page.getByRole("button", { name: "Find models" }).click();
|
|
||||||
await reProbeResponse;
|
|
||||||
await expect(manualModel).toBeVisible();
|
|
||||||
await expect(manualModel).toHaveValue("probed-model");
|
|
||||||
await expect(page.getByRole("combobox", { name: "Models from server" })).toHaveCount(0);
|
|
||||||
// Editing the server URL must not drop Other model… mode either.
|
|
||||||
await page.getByLabel("OpenAI-compatible server URL").fill("http://127.0.0.1:8091/v1");
|
|
||||||
const editedUrlProbeResponse = page.waitForResponse("**/rpc/models/probeOpenAiCompatible");
|
|
||||||
await page.getByRole("button", { name: "Find models" }).click();
|
|
||||||
await editedUrlProbeResponse;
|
|
||||||
await expect(manualModel).toBeVisible();
|
|
||||||
await expect(manualModel).toHaveValue("probed-model");
|
|
||||||
await expect(page.getByRole("combobox", { name: "Models from server" })).toHaveCount(0);
|
|
||||||
await manualModel.fill("probed-model-custom");
|
|
||||||
await expect(manualModel).toHaveValue("probed-model-custom");
|
|
||||||
|
|
||||||
await captureScreenshot(page, testInfo, "onboarding-model-labels");
|
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,23 +1,19 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
import { expect, type Page, test } from "@playwright/test";
|
||||||
import { captureScreenshot, signup } from "./helpers";
|
import { signup } from "./helpers";
|
||||||
|
|
||||||
test("onboarding requires a model when the deployment has none", async ({ page }, testInfo) => {
|
async function forceNeedsModel(page: Page) {
|
||||||
await page.route("**/rpc/me", async (route) => {
|
await page.route("**/rpc/me", async (route) => {
|
||||||
const response = await route.fetch();
|
const response = await route.fetch();
|
||||||
const body = (await response.json()) as { json: Record<string, unknown> };
|
const body = (await response.json()) as { json: Record<string, unknown> };
|
||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
response,
|
response,
|
||||||
json: {
|
json: { json: { ...body.json, needsModel: true } },
|
||||||
json: {
|
|
||||||
...body.json,
|
|
||||||
needsModel: true,
|
|
||||||
defaultProvider: "openrouter",
|
|
||||||
defaultModel: "openai/gpt-5.6-luna",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("onboarding shows the look picker when a model is still required", async ({ page }) => {
|
||||||
|
await forceNeedsModel(page);
|
||||||
const stamp = Date.now();
|
const stamp = Date.now();
|
||||||
await signup(
|
await signup(
|
||||||
page,
|
page,
|
||||||
@ -25,36 +21,37 @@ test("onboarding requires a model when the deployment has none", async ({ page }
|
|||||||
"password12",
|
"password12",
|
||||||
`Model required ${stamp}`,
|
`Model required ${stamp}`,
|
||||||
);
|
);
|
||||||
await expect(page.getByRole("heading", { name: "Connect a model" })).toBeVisible({
|
await page.waitForURL(/\/onboarding/, { timeout: 20_000 });
|
||||||
|
await expect(page.locator('[data-rakazo-surface="onboarding"]')).toHaveAttribute(
|
||||||
|
"data-onboarding-step",
|
||||||
|
/^(emoji|preferences|language|voice|integrations)$/,
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
);
|
||||||
|
await expect(page.getByRole("heading", { name: /Design your Oo/i })).toBeVisible({
|
||||||
timeout: 20_000,
|
timeout: 20_000,
|
||||||
});
|
});
|
||||||
|
await expect(page.getByRole("heading", { name: "Connect a model" })).toHaveCount(0);
|
||||||
await expect(page.getByRole("button", { name: "Skip for now" })).toBeHidden();
|
await expect(page.getByRole("combobox", { name: "Message" })).toHaveCount(0);
|
||||||
const continueButton = page.getByRole("button", { name: "Continue", exact: true });
|
|
||||||
await expect(continueButton).toBeDisabled();
|
|
||||||
await page.getByLabel("API key", { exact: true }).fill(" ");
|
|
||||||
await expect(continueButton).toBeDisabled();
|
|
||||||
await page.getByLabel("API key", { exact: true }).fill("fake-test-key");
|
|
||||||
await expect(continueButton).toBeEnabled();
|
|
||||||
await page.getByLabel("API key", { exact: true }).fill("");
|
|
||||||
await captureScreenshot(page, testInfo, "onboarding-model-required");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const unavailable of ["empty", "failed"] as const) {
|
for (const unavailable of ["empty", "failed"] as const) {
|
||||||
test(`onboarding cannot continue with a ${unavailable} model catalog`, async ({ page }) => {
|
test(`onboarding still opens when the model catalog is ${unavailable}`, async ({ page }) => {
|
||||||
await page.route("**/rpc/me", async (route) => {
|
await forceNeedsModel(page);
|
||||||
const response = await route.fetch();
|
|
||||||
const body = (await response.json()) as { json: Record<string, unknown> };
|
|
||||||
await route.fulfill({ response, json: { json: { ...body.json, needsModel: true } } });
|
|
||||||
});
|
|
||||||
await page.route("**/rpc/models/list", (route) =>
|
await page.route("**/rpc/models/list", (route) =>
|
||||||
unavailable === "failed" ? route.abort() : route.fulfill({ json: { json: [] } }),
|
unavailable === "failed" ? route.abort() : route.fulfill({ json: { json: [] } }),
|
||||||
);
|
);
|
||||||
const stamp = Date.now();
|
const stamp = Date.now();
|
||||||
await signup(page, `catalog-${unavailable}-${stamp}@rakazo.test`, "password12", "Model setup");
|
await signup(page, `catalog-${unavailable}-${stamp}@rakazo.test`, "password12", "Model setup");
|
||||||
await expect(page.getByRole("heading", { name: "Connect a model" })).toBeVisible();
|
await page.waitForURL(/\/onboarding/, { timeout: 20_000 });
|
||||||
await expect(page.getByRole("button", { name: "Continue", exact: true })).toBeDisabled();
|
await expect(page.locator('[data-rakazo-surface="onboarding"]')).toHaveAttribute(
|
||||||
await expect(page.getByRole("heading", { name: "Create your first bot" })).toHaveCount(0);
|
"data-onboarding-step",
|
||||||
await expect(page.getByRole("combobox", { name: "Message Chief" })).toHaveCount(0);
|
/^(emoji|preferences|language|voice|integrations)$/,
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
);
|
||||||
|
await expect(page.getByRole("heading", { name: /Design your Oo/i })).toBeVisible({
|
||||||
|
timeout: 20_000,
|
||||||
|
});
|
||||||
|
await expect(page.getByRole("heading", { name: "Connect a model" })).toHaveCount(0);
|
||||||
|
await expect(page.getByRole("combobox", { name: "Message" })).toHaveCount(0);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,21 @@ import type { Page } from "@playwright/test";
|
|||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
import { captureScreenshot, signup } from "./helpers";
|
import { captureScreenshot, signup } from "./helpers";
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.route("**/rpc/integrationSetup/get", (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
json: {
|
||||||
|
json: {
|
||||||
|
canConfigure: true,
|
||||||
|
needsSetup: false,
|
||||||
|
webUrl: "https://example.test/integrations/setup",
|
||||||
|
providers: [{ id: "composio", configured: true }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test.use({
|
test.use({
|
||||||
hasTouch: true,
|
hasTouch: true,
|
||||||
isMobile: true,
|
isMobile: true,
|
||||||
|
|||||||
@ -17,9 +17,9 @@ test("shows peer chips in transcript and opens view-only peer chat", async ({ pa
|
|||||||
notifyOnFinish: true,
|
notifyOnFinish: true,
|
||||||
});
|
});
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await expect(page.getByRole("combobox", { name: "Message Chief" })).toBeVisible();
|
await expect(page.getByRole("combobox", { name: "Message" })).toBeVisible();
|
||||||
|
|
||||||
const composer = page.getByRole("combobox", { name: "Message Chief" });
|
const composer = page.getByRole("combobox", { name: "Message" });
|
||||||
await composer.fill("message the bot named Researcher saying peer-exchange-alpha");
|
await composer.fill("message the bot named Researcher saying peer-exchange-alpha");
|
||||||
await composer.press("Enter");
|
await composer.press("Enter");
|
||||||
await expect(page.getByText("messaging that bot now.").first()).toBeVisible({
|
await expect(page.getByText("messaging that bot now.").first()).toBeVisible({
|
||||||
@ -57,17 +57,28 @@ test("shows peer chips in transcript and opens view-only peer chat", async ({ pa
|
|||||||
await expect(chip).toHaveAccessibleName(/Messaged Researcher|Message from Researcher/);
|
await expect(chip).toHaveAccessibleName(/Messaged Researcher|Message from Researcher/);
|
||||||
await expect(chip.locator(".rakazo-bot-avatar")).toBeVisible();
|
await expect(chip.locator(".rakazo-bot-avatar")).toBeVisible();
|
||||||
await expect(chip).not.toContainText("{peer}");
|
await expect(chip).not.toContainText("{peer}");
|
||||||
// User bubble still contains the phrase; peer body must not appear outside the chip.
|
// The user bubble and the delegated task card both show the task text.
|
||||||
|
// The receipt chip stays a status marker and does not repeat the body.
|
||||||
await expect(chip).not.toContainText("peer-exchange-alpha");
|
await expect(chip).not.toContainText("peer-exchange-alpha");
|
||||||
await expect(transcript.getByText("peer-exchange-alpha")).toHaveCount(1);
|
await expect(transcript.getByText("peer-exchange-alpha")).toHaveCount(2);
|
||||||
const assertChipLeftAligned = async () => {
|
const assertChipLeftAligned = async () => {
|
||||||
const transcriptBox = await transcript.boundingBox();
|
const boxes = await chip.evaluate((node) => {
|
||||||
const chipBox = await chip.boundingBox();
|
const transcript = node.closest("[data-testid='transcript']");
|
||||||
expect(transcriptBox).not.toBeNull();
|
if (!transcript) return null;
|
||||||
expect(chipBox).not.toBeNull();
|
const chipBox = node.getBoundingClientRect();
|
||||||
// Transcript padding is 16px mobile / 28px desktop; centering must fail this assertion.
|
const transcriptBox = transcript.getBoundingClientRect();
|
||||||
expect(chipBox!.x - transcriptBox!.x).toBeLessThanOrEqual(32);
|
return {
|
||||||
expect(chipBox!.width).toBeLessThan(transcriptBox!.width / 2);
|
inset: chipBox.left - transcriptBox.left,
|
||||||
|
chipWidth: chipBox.width,
|
||||||
|
transcriptWidth: transcriptBox.width,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(boxes).not.toBeNull();
|
||||||
|
// The reading column is centered in the transcript. The chip sits on that
|
||||||
|
// column's left edge, so a midpoint chip fails the upper bound.
|
||||||
|
expect(boxes!.inset).toBeGreaterThanOrEqual(8);
|
||||||
|
expect(boxes!.inset).toBeLessThan(boxes!.transcriptWidth / 3);
|
||||||
|
expect(boxes!.chipWidth).toBeLessThan(boxes!.transcriptWidth);
|
||||||
};
|
};
|
||||||
|
|
||||||
await assertChipLeftAligned();
|
await assertChipLeftAligned();
|
||||||
|
|||||||
@ -29,7 +29,7 @@ test("a failed run is visible once without returning after reload", async ({ pag
|
|||||||
|
|
||||||
// "fail this run" makes the scripted runtime throw, so the run fails the same way a
|
// "fail this run" makes the scripted runtime throw, so the run fails the same way a
|
||||||
// real provider error would, without depending on how models are configured.
|
// real provider error would, without depending on how models are configured.
|
||||||
await page.getByPlaceholder(/^Message /).fill("fail this run");
|
await page.getByRole("combobox", { name: "Message" }).fill("fail this run");
|
||||||
await page.getByRole("button", { name: "Send" }).click();
|
await page.getByRole("button", { name: "Send" }).click();
|
||||||
|
|
||||||
const error = page.getByTestId("composer-error");
|
const error = page.getByTestId("composer-error");
|
||||||
@ -45,7 +45,7 @@ test("a failed run is visible once without returning after reload", async ({ pag
|
|||||||
await expect(error).toBeHidden();
|
await expect(error).toBeHidden();
|
||||||
await captureScreenshot(page, testInfo, "seen-run-error-hidden-after-reload");
|
await captureScreenshot(page, testInfo, "seen-run-error-hidden-after-reload");
|
||||||
|
|
||||||
await page.getByPlaceholder(/^Message /).fill("fail this run");
|
await page.getByRole("combobox", { name: "Message" }).fill("fail this run");
|
||||||
await page.getByRole("button", { name: "Send" }).click();
|
await page.getByRole("button", { name: "Send" }).click();
|
||||||
await expect(error).toBeVisible({ timeout: 30_000 });
|
await expect(error).toBeVisible({ timeout: 30_000 });
|
||||||
await expect(error).toContainText("Scripted run failure");
|
await expect(error).toContainText("Scripted run failure");
|
||||||
@ -55,7 +55,7 @@ test("a failed run is visible once without returning after reload", async ({ pag
|
|||||||
await expect(dismissError).toBeFocused();
|
await expect(dismissError).toBeFocused();
|
||||||
await dismissError.press("Enter");
|
await dismissError.press("Enter");
|
||||||
await expect(error).toBeHidden();
|
await expect(error).toBeHidden();
|
||||||
await expect(page.getByPlaceholder(/^Message /)).toBeFocused();
|
await expect(page.getByRole("combobox", { name: "Message" })).toBeFocused();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a covered run error is not remembered until it is presented", async ({ page }, testInfo) => {
|
test("a covered run error is not remembered until it is presented", async ({ page }, testInfo) => {
|
||||||
@ -64,7 +64,7 @@ test("a covered run error is not remembered until it is presented", async ({ pag
|
|||||||
await completeOnboarding(page);
|
await completeOnboarding(page);
|
||||||
await page.setViewportSize({ width: 390, height: 844 });
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
|
||||||
await page.getByPlaceholder(/^Message /).fill("fail this run");
|
await page.getByRole("combobox", { name: "Message" }).fill("fail this run");
|
||||||
const sendButton = await page.getByRole("button", { name: "Send" }).elementHandle();
|
const sendButton = await page.getByRole("button", { name: "Send" }).elementHandle();
|
||||||
if (!sendButton) throw new Error("Send button not found");
|
if (!sendButton) throw new Error("Send button not found");
|
||||||
await page.getByRole("button", { name: "Open navigation" }).click();
|
await page.getByRole("button", { name: "Open navigation" }).click();
|
||||||
@ -88,7 +88,7 @@ test("a covered run error is not remembered until it is presented", async ({ pag
|
|||||||
await expect(error).toBeHidden();
|
await expect(error).toBeHidden();
|
||||||
|
|
||||||
const recordedErrorCount = await seenRunErrorCount(page);
|
const recordedErrorCount = await seenRunErrorCount(page);
|
||||||
await page.getByPlaceholder(/^Message /).fill("fail this run");
|
await page.getByRole("combobox", { name: "Message" }).fill("fail this run");
|
||||||
const nextSendButton = await page.getByRole("button", { name: "Send" }).elementHandle();
|
const nextSendButton = await page.getByRole("button", { name: "Send" }).elementHandle();
|
||||||
if (!nextSendButton) throw new Error("Send button not found");
|
if (!nextSendButton) throw new Error("Send button not found");
|
||||||
await page.getByRole("button", { name: "Open navigation" }).click();
|
await page.getByRole("button", { name: "Open navigation" }).click();
|
||||||
@ -109,7 +109,7 @@ test("a covered run error is not remembered until it is presented", async ({ pag
|
|||||||
await page.setViewportSize({ width: 1280, height: 720 });
|
await page.setViewportSize({ width: 1280, height: 720 });
|
||||||
await expect(page.locator("main")).not.toHaveAttribute("aria-hidden", "true");
|
await expect(page.locator("main")).not.toHaveAttribute("aria-hidden", "true");
|
||||||
await expect(page.locator("main")).toHaveJSProperty("inert", false);
|
await expect(page.locator("main")).toHaveJSProperty("inert", false);
|
||||||
await page.getByPlaceholder(/^Message /).fill("fail this run");
|
await page.getByRole("combobox", { name: "Message" }).fill("fail this run");
|
||||||
const modalSendButton = await page.getByRole("button", { name: "Send" }).elementHandle();
|
const modalSendButton = await page.getByRole("button", { name: "Send" }).elementHandle();
|
||||||
if (!modalSendButton) throw new Error("Send button not found");
|
if (!modalSendButton) throw new Error("Send button not found");
|
||||||
await openNewSpace(page);
|
await openNewSpace(page);
|
||||||
|
|||||||
@ -31,7 +31,7 @@ test("sidebar has expected navigation items and no integrations", async ({ page
|
|||||||
await expect(searchInput).toBeVisible({ timeout: 5000 });
|
await expect(searchInput).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
// "Heute" (Today)
|
// "Heute" (Today)
|
||||||
const todayBtn = sidebar.getByRole("button", { name: "Heute" });
|
const todayBtn = sidebar.getByRole("button", { name: "Heute", exact: true });
|
||||||
await expect(todayBtn).toBeVisible({ timeout: 5000 });
|
await expect(todayBtn).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
// "Unterhaltungen" is plain text, not a button
|
// "Unterhaltungen" is plain text, not a button
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
* This test ensures that the Vite build resolves a single React
|
* This test ensures that the Vite build resolves a single React
|
||||||
* instance (no duplicate React versions from pnpm transitive deps).
|
* instance (no duplicate React versions from pnpm transitive deps).
|
||||||
*/
|
*/
|
||||||
import { test, expect } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
test("app loads without React hook errors", async ({ page }) => {
|
test("app loads without React hook errors", async ({ page }) => {
|
||||||
const consoleErrors: string[] = [];
|
const consoleErrors: string[] = [];
|
||||||
@ -17,16 +17,17 @@ test("app loads without React hook errors", async ({ page }) => {
|
|||||||
pageErrors.push(err.message);
|
pageErrors.push(err.message);
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.goto("/app", { waitUntil: "domcontentloaded", timeout: 10000 });
|
await page.goto("/app", { waitUntil: "domcontentloaded", timeout: 10_000 });
|
||||||
await page.waitForTimeout(5000);
|
await page.waitForTimeout(5000);
|
||||||
|
|
||||||
// The critical check: no "Invalid hook call" or related React errors
|
// The critical check: no "Invalid hook call" or related React errors
|
||||||
const hookErrors = pageErrors.filter((e) =>
|
const hookErrors = pageErrors.filter(
|
||||||
e.includes("Invalid hook call") ||
|
(error) =>
|
||||||
e.includes("useMemo") ||
|
error.includes("Invalid hook call") ||
|
||||||
e.includes("useRef") ||
|
error.includes("useMemo") ||
|
||||||
e.includes("useContext") ||
|
error.includes("useRef") ||
|
||||||
e.includes("more than one copy of React")
|
error.includes("useContext") ||
|
||||||
|
error.includes("more than one copy of React"),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(hookErrors).toEqual([]);
|
expect(hookErrors).toEqual([]);
|
||||||
|
|||||||
@ -1,5 +1,19 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
import { expect, type Locator, test } from "@playwright/test";
|
||||||
import { captureScreenshot, completeOnboarding, openNewSpace, rpc, signup } from "./helpers";
|
import {
|
||||||
|
captureScreenshot,
|
||||||
|
completeOnboarding,
|
||||||
|
openNewSpace,
|
||||||
|
rpc,
|
||||||
|
signup,
|
||||||
|
unterhaltungenOnly,
|
||||||
|
} from "./helpers";
|
||||||
|
|
||||||
|
function projectSpace(projekte: Locator, name: string) {
|
||||||
|
return projekte.locator('[data-sidebar-group^="project-space:"]').filter({
|
||||||
|
hasText: name,
|
||||||
|
exact: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
test("Projekte + opens new project dialog instead of group form", async ({ page }) => {
|
test("Projekte + opens new project dialog instead of group form", async ({ page }) => {
|
||||||
const stamp = Date.now();
|
const stamp = Date.now();
|
||||||
@ -8,9 +22,8 @@ test("Projekte + opens new project dialog instead of group form", async ({ page
|
|||||||
await page.goto("/app");
|
await page.goto("/app");
|
||||||
await page.waitForURL(/\/app\/[^/]+$/);
|
await page.waitForURL(/\/app\/[^/]+$/);
|
||||||
|
|
||||||
const sidebar = page.locator("aside").first();
|
|
||||||
const projekte = page.locator(".sidebar-projekte");
|
const projekte = page.locator(".sidebar-projekte");
|
||||||
await projekte.getByRole("button", { name: "Create a project" }).click();
|
await projekte.getByTestId("create-project-trigger").click();
|
||||||
await expect(page.getByRole("dialog", { name: "New project" })).toBeVisible();
|
await expect(page.getByRole("dialog", { name: "New project" })).toBeVisible();
|
||||||
await expect(page.getByRole("button", { name: "Create project", exact: true })).toBeVisible();
|
await expect(page.getByRole("button", { name: "Create project", exact: true })).toBeVisible();
|
||||||
await expect(page.getByTestId("side-panel")).not.toHaveAttribute("data-panel", "create-group");
|
await expect(page.getByTestId("side-panel")).not.toHaveAttribute("data-panel", "create-group");
|
||||||
@ -21,13 +34,14 @@ test("Projekte + opens new project dialog instead of group form", async ({ page
|
|||||||
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/);
|
await page.waitForURL(/\/app/);
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
await expect(projekte.getByText("Customer project", { exact: true })).toBeVisible({
|
await expect(projekte.getByText("Customer project", { exact: true })).toBeVisible({
|
||||||
timeout: 15_000,
|
timeout: 30_000,
|
||||||
});
|
});
|
||||||
await expect(page.locator(".sidebar-unterhaltungen").getByText("Customer project")).toHaveCount(0);
|
await expect(unterhaltungenOnly(page).getByText("Customer project", { exact: true })).toHaveCount(
|
||||||
await expect(
|
0,
|
||||||
page.locator("main").getByRole("button", { name: "Create new Bot", exact: true }),
|
);
|
||||||
).toBeVisible();
|
await expect(page.locator("main").getByRole("button", { name: "Create an Oo" })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("spaces stay invisible by default and chat creation requires approval", async ({
|
test("spaces stay invisible by default and chat creation requires approval", async ({
|
||||||
@ -65,29 +79,30 @@ test("spaces stay invisible by default and chat creation requires approval", asy
|
|||||||
await expect(page.getByText("Created", { exact: true })).toBeVisible();
|
await expect(page.getByText("Created", { exact: true })).toBeVisible();
|
||||||
|
|
||||||
const projekte = page.locator(".sidebar-projekte");
|
const projekte = page.locator(".sidebar-projekte");
|
||||||
const unterhaltungen = page.locator(".sidebar-unterhaltungen");
|
|
||||||
await expect(projekte.getByText("Customer support", { exact: true })).toBeVisible({
|
await expect(projekte.getByText("Customer support", { exact: true })).toBeVisible({
|
||||||
timeout: 15_000,
|
timeout: 15_000,
|
||||||
});
|
});
|
||||||
await expect(unterhaltungen.getByText("Customer support", { exact: true })).toHaveCount(0);
|
await expect(unterhaltungenOnly(page).getByText("Customer support", { exact: true })).toHaveCount(
|
||||||
await expect(unterhaltungen.getByRole("button", { name: /^Chief/ })).toHaveCount(1);
|
0,
|
||||||
|
);
|
||||||
|
await expect(unterhaltungenOnly(page).getByRole("button", { name: /^Chief/ })).toHaveCount(1);
|
||||||
|
|
||||||
const supportSpace = projekte.locator('[data-sidebar-group^="project-space:"]').filter({
|
const supportSpace = projekte.locator('[data-sidebar-group^="project-space:"]').filter({
|
||||||
hasText: "Customer support",
|
hasText: "Customer support",
|
||||||
});
|
});
|
||||||
const supportSpaceGroup = await supportSpace.getAttribute("data-sidebar-group");
|
const supportSpaceGroup = await supportSpace.getAttribute("data-sidebar-group");
|
||||||
const supportSpaceId = supportSpaceGroup?.split(":")[2];
|
const supportSpaceId = supportSpaceGroup?.match(/^project-space:([^:]+)/)?.[1];
|
||||||
expect(supportSpaceId).toBeTruthy();
|
expect(supportSpaceId).toBeTruthy();
|
||||||
await supportSpace.getByRole("button", { name: "Open Customer support" }).click();
|
await supportSpace.getByRole("button", { name: "Open Customer support" }).click();
|
||||||
await page.waitForURL(/\/(onboarding|app)/);
|
await page.waitForURL(/\/onboarding/);
|
||||||
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);
|
||||||
await completeOnboarding(page);
|
await completeOnboarding(page);
|
||||||
|
|
||||||
await expect(projekte.getByText("Customer support", { exact: true })).toBeVisible();
|
await expect(projekte.getByText("Customer support", { exact: true })).toBeVisible();
|
||||||
await expect(unterhaltungen.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 captureScreenshot(page, testInfo, "spaces-sidebar");
|
await captureScreenshot(page, testInfo, "spaces-sidebar");
|
||||||
|
|
||||||
await page.evaluate((id) => {
|
await page.evaluate((id) => {
|
||||||
@ -113,29 +128,36 @@ test("a new space auto-completes onboarding and can be deleted from its menu", a
|
|||||||
const dialog = page.getByRole("dialog", { name: "New space" });
|
const dialog = page.getByRole("dialog", { name: "New space" });
|
||||||
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();
|
||||||
|
await page.waitForURL(/\/onboarding/);
|
||||||
|
|
||||||
// Model is already connected, so onboarding skips the form and lands in Chief.
|
// A new empty space walks onboarding again, then lands in Chief.
|
||||||
await completeOnboarding(page);
|
await completeOnboarding(page);
|
||||||
await expect(page).toHaveURL(/\/app\//);
|
await expect(page).toHaveURL(/\/app\//);
|
||||||
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(sidebar.getByRole("button", { name: /^Chief/ })).toHaveCount(1);
|
await expect(unterhaltungenOnly(page).getByRole("button", { name: /^Chief/ })).toHaveCount(1);
|
||||||
|
await expect(projekte.getByRole("button", { name: /^Chief/ })).toHaveCount(1);
|
||||||
await captureScreenshot(page, testInfo, "new-space-after-onboarding");
|
await captureScreenshot(page, testInfo, "new-space-after-onboarding");
|
||||||
|
|
||||||
// Spaces can only be deleted when empty — remove auto-created Chief first.
|
// Spaces can only be deleted when empty — remove auto-created Chief first.
|
||||||
await sidebar.getByRole("button", { name: /^Chief/ }).click({ button: "right" });
|
const temporaryChief = projekte.getByRole("button", { name: /^Chief/ });
|
||||||
|
await temporaryChief.click({ button: "right" });
|
||||||
await page.getByRole("menuitem", { name: "Delete" }).click();
|
await page.getByRole("menuitem", { name: "Delete" }).click();
|
||||||
const deleteBotDialog = page.getByRole("alertdialog", { name: /Delete Chief/ });
|
const deleteBotDialog = page.getByRole("alertdialog", { name: /Delete Chief/ });
|
||||||
await expect(deleteBotDialog).toBeVisible();
|
await expect(deleteBotDialog).toBeVisible();
|
||||||
await deleteBotDialog.getByRole("button", { name: "Delete", exact: true }).click();
|
await deleteBotDialog.getByRole("button", { name: "Delete", exact: true }).click();
|
||||||
await expect(sidebar.getByRole("button", { name: /^Chief/ })).toHaveCount(0);
|
await expect(projekte.getByRole("button", { name: /^Chief/ })).toHaveCount(0);
|
||||||
|
await expect(unterhaltungenOnly(page).getByRole("button", { name: /^Chief/ })).toHaveCount(1);
|
||||||
|
|
||||||
// The emptied space stays selectable instead of trapping the user.
|
// The emptied space stays selectable instead of trapping the user.
|
||||||
await expect(projekte.getByRole("button", { name: "Open Temporary" })).toBeVisible();
|
await expect(projekte.getByRole("button", { name: "Open Temporary" }).first()).toBeVisible();
|
||||||
|
|
||||||
// Deletion is discoverable through the visible Space actions menu.
|
// Deletion is discoverable through the visible Space actions menu.
|
||||||
await projekte.getByRole("button", { name: "Actions for Temporary" }).click();
|
await projectSpace(projekte, "Temporary")
|
||||||
|
.getByRole("button", { name: "Actions for Temporary" })
|
||||||
|
.click();
|
||||||
await captureScreenshot(page, testInfo, "space-actions-menu");
|
await captureScreenshot(page, testInfo, "space-actions-menu");
|
||||||
|
await expect(page.getByRole("menuitem", { name: "Delete space" })).toBeVisible();
|
||||||
await page.getByRole("menuitem", { name: "Delete space" }).click();
|
await page.getByRole("menuitem", { name: "Delete space" }).click();
|
||||||
const deleteSpaceDialog = page.getByRole("alertdialog", { name: "Delete Temporary?" });
|
const deleteSpaceDialog = page.getByRole("alertdialog", { name: "Delete Temporary?" });
|
||||||
await expect(deleteSpaceDialog).toBeVisible();
|
await expect(deleteSpaceDialog).toBeVisible();
|
||||||
@ -174,7 +196,7 @@ test("deleting the last bot in a space stays in the app after first use", async
|
|||||||
|
|
||||||
// Delete the only bot in the new space: the app must stay put instead of
|
// Delete the only bot in the new space: the app must stay put instead of
|
||||||
// forcing onboarding again.
|
// forcing onboarding again.
|
||||||
const sideBot = sidebar.getByRole("button", { name: /^Chief/ });
|
const sideBot = projekte.getByRole("button", { name: /^Chief/ });
|
||||||
await sideBot.click({ button: "right" });
|
await sideBot.click({ button: "right" });
|
||||||
await page.getByRole("menuitem", { name: "Delete" }).click();
|
await page.getByRole("menuitem", { name: "Delete" }).click();
|
||||||
const deleteDialog = page.getByRole("alertdialog", { name: /Delete Chief/ });
|
const deleteDialog = page.getByRole("alertdialog", { name: /Delete Chief/ });
|
||||||
@ -184,12 +206,13 @@ 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.reload();
|
await page.reload();
|
||||||
await expect(page).not.toHaveURL(/\/onboarding/);
|
await expect(page).not.toHaveURL(/\/onboarding/);
|
||||||
await expect(projekte.getByRole("button", { name: "Open Side" })).toBeVisible();
|
await expect(projekte.getByRole("button", { name: "Open Side" }).first()).toBeVisible();
|
||||||
await expect(
|
await expect(
|
||||||
page.locator("main").getByRole("button", { name: "Create new Bot", exact: true }),
|
page.locator("main").getByRole("button", { name: "Create new Bot", exact: true }),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
|
|
||||||
await projekte.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 page.getByRole("menuitem", { name: "Delete space" }).click();
|
await page.getByRole("menuitem", { name: "Delete space" }).click();
|
||||||
await page
|
await page
|
||||||
.getByRole("alertdialog", { name: "Delete Side?" })
|
.getByRole("alertdialog", { name: "Delete Side?" })
|
||||||
|
|||||||
@ -1,16 +1,16 @@
|
|||||||
import { test } from "@playwright/test";
|
import { test } from "@playwright/test";
|
||||||
|
|
||||||
test.describe("Steve Jobs CTO Agent", () => {
|
test.describe("Steve Jobs CTO Agent", () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async () => {
|
||||||
// We assume the user has logged in and the bot is seeded
|
// We assume the user has logged in and the bot is seeded
|
||||||
// This is a placeholder for the perfect test architecture
|
// This is a placeholder for the perfect test architecture
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should be visible in the sidebar", async ({ page }) => {
|
test("should be visible in the sidebar", async () => {
|
||||||
// This is a dummy test to show we started the test architecture for Steve Jobs
|
// This is a dummy test to show we started the test architecture for Steve Jobs
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should prefer voice communications (VOICE FIRST)", async ({ page }) => {
|
test("should prefer voice communications (VOICE FIRST)", async () => {
|
||||||
// Voice first features
|
// Voice first features
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -96,6 +96,7 @@ test("user control leaves another Team bot's screen available", async ({ page },
|
|||||||
await page.getByTestId("computer-preview-open").click();
|
await page.getByTestId("computer-preview-open").click();
|
||||||
await expect(page.getByRole("button", { name: "Close computer" })).toBeVisible();
|
await expect(page.getByRole("button", { name: "Close computer" })).toBeVisible();
|
||||||
await page.getByRole("button", { name: "Close computer" }).click();
|
await page.getByRole("button", { name: "Close computer" }).click();
|
||||||
|
await expect(page.getByRole("button", { name: "Close computer" })).toHaveCount(0);
|
||||||
|
|
||||||
await openBot(page, "Worker");
|
await openBot(page, "Worker");
|
||||||
const workerRunId = await sendMessage(
|
const workerRunId = await sendMessage(
|
||||||
@ -263,8 +264,23 @@ async function openBot(page: Page, name: string) {
|
|||||||
await page
|
await page
|
||||||
.getByRole("complementary")
|
.getByRole("complementary")
|
||||||
.getByRole("button", { name: new RegExp(`^${name}`) })
|
.getByRole("button", { name: new RegExp(`^${name}`) })
|
||||||
|
.first()
|
||||||
.click();
|
.click();
|
||||||
await expect(page.getByRole("combobox", { name: "Message" })).toBeVisible();
|
await expect(page.getByRole("combobox", { name: "Message" })).toBeVisible();
|
||||||
|
await expectShellReady(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectShellReady(page: Page) {
|
||||||
|
await expect(page.getByTestId("shell-root")).toHaveAttribute("data-ready", "true", {
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectComposerReady(page: Page) {
|
||||||
|
await expectShellReady(page);
|
||||||
|
const composer = page.getByPlaceholder(/Message/);
|
||||||
|
await expect(composer).toBeVisible();
|
||||||
|
await expect(composer).toBeEnabled();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openComputerPanel(page: Page) {
|
async function openComputerPanel(page: Page) {
|
||||||
@ -279,13 +295,15 @@ async function sendAndWait(page: Page, botId: string, text: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function sendMessage(page: Page, text: string) {
|
async function sendMessage(page: Page, text: string) {
|
||||||
|
await expectComposerReady(page);
|
||||||
const composer = page.getByPlaceholder(/Message/);
|
const composer = page.getByPlaceholder(/Message/);
|
||||||
|
await composer.click();
|
||||||
await composer.fill(text);
|
await composer.fill(text);
|
||||||
const sent = page.waitForResponse(
|
const sent = page.waitForResponse(
|
||||||
(response) =>
|
(response) =>
|
||||||
response.url().includes("/rpc/threads/send") && response.request().method() === "POST",
|
response.url().includes("/rpc/threads/send") && response.request().method() === "POST",
|
||||||
);
|
);
|
||||||
await page.keyboard.press("Enter");
|
await page.getByRole("button", { name: "Send", exact: true }).click();
|
||||||
const response = await sent;
|
const response = await sent;
|
||||||
expect(response.ok()).toBe(true);
|
expect(response.ok()).toBe(true);
|
||||||
const result = (await response.json()) as { json?: { runId?: string } };
|
const result = (await response.json()) as { json?: { runId?: string } };
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { expect, type Page, test } from "@playwright/test";
|
import { expect, type Page, test } from "@playwright/test";
|
||||||
import { captureScreenshot, completeOnboarding, createNamedBot, signup } from "./helpers";
|
import { captureScreenshot, completeOnboarding, rpc, signup } from "./helpers";
|
||||||
|
|
||||||
async function captureSidebarSearchSelected(
|
async function captureSidebarSearchSelected(
|
||||||
page: Page,
|
page: Page,
|
||||||
@ -95,28 +95,42 @@ test("account settings appearance control switches to light mode", async ({ page
|
|||||||
await captureSidebarSearchSelected(page, testInfo, "sidebar-search-selected-dark");
|
await captureSidebarSearchSelected(page, testInfo, "sidebar-search-selected-dark");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("sidebar bot rows hover with the same tone as the integrations row", async ({
|
test("sidebar bot rows share a hover tone", async ({ page }, testInfo) => {
|
||||||
page,
|
|
||||||
}, testInfo) => {
|
|
||||||
const stamp = Date.now();
|
const stamp = Date.now();
|
||||||
await signup(page, `ui-hover-${stamp}@rakazo.test`, "password12", "Hover QA");
|
await signup(page, `ui-hover-${stamp}@rakazo.test`, "password12", "Hover QA");
|
||||||
await completeOnboarding(page, testInfo);
|
await completeOnboarding(page, testInfo);
|
||||||
await createNamedBot(page, "Second Bot");
|
await rpc(page, "bots/create", {
|
||||||
|
name: "Second Bot",
|
||||||
|
title: "",
|
||||||
|
description: "",
|
||||||
|
notifyOnFinish: true,
|
||||||
|
computerMode: "team",
|
||||||
|
});
|
||||||
|
await rpc(page, "bots/create", {
|
||||||
|
name: "Third Bot",
|
||||||
|
title: "",
|
||||||
|
description: "",
|
||||||
|
notifyOnFinish: true,
|
||||||
|
computerMode: "team",
|
||||||
|
});
|
||||||
|
await page.reload();
|
||||||
|
await page.waitForURL(/\/app\/[^/]+$/);
|
||||||
|
|
||||||
const sidebar = page.locator("aside").first();
|
const sidebar = page.locator("aside").first();
|
||||||
const chief = sidebar.getByRole("button", { name: /^Chief/ }).first();
|
const second = sidebar.getByRole("button", { name: /^Second Bot/ }).first();
|
||||||
await expect(chief).toBeVisible();
|
const third = sidebar.getByRole("button", { name: /^Third Bot/ }).first();
|
||||||
const readChiefBg = () => chief.evaluate((el) => getComputedStyle(el).backgroundColor);
|
await expect(second).toBeVisible();
|
||||||
await expect.poll(readChiefBg).toBe("rgba(0, 0, 0, 0)");
|
await expect(third).toBeVisible();
|
||||||
|
const readRowBg = (locator: typeof second) =>
|
||||||
|
locator.evaluate((el) => getComputedStyle(el).backgroundColor);
|
||||||
|
|
||||||
await chief.hover();
|
const readHoverBg = async (locator: typeof second) => {
|
||||||
await expect.poll(readChiefBg).not.toBe("rgba(0, 0, 0, 0)");
|
await locator.hover();
|
||||||
const chiefBg = await readChiefBg();
|
await expect.poll(() => readRowBg(locator)).not.toBe("rgba(0, 0, 0, 0)");
|
||||||
|
return readRowBg(locator);
|
||||||
|
};
|
||||||
|
|
||||||
|
await readHoverBg(second);
|
||||||
await captureScreenshot(page, testInfo, "sidebar-row-hover");
|
await captureScreenshot(page, testInfo, "sidebar-row-hover");
|
||||||
|
await readHoverBg(third);
|
||||||
const integrations = sidebar.getByRole("button", { name: "Integrations", exact: true });
|
|
||||||
await integrations.hover();
|
|
||||||
await expect
|
|
||||||
.poll(() => integrations.evaluate((el) => getComputedStyle(el).backgroundColor))
|
|
||||||
.toBe(chiefBg);
|
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
import { completeOnboarding, openNewBot, rpc, signup } from "./helpers";
|
import { completeOnboarding, openNewBot, signup, unterhaltungenOnly } from "./helpers";
|
||||||
|
|
||||||
test("Unterhaltungen and Projekte stay separate; + opens bot picker", async ({ page }) => {
|
test("Unterhaltungen and Projekte stay separate; + opens bot picker", async ({ page }) => {
|
||||||
const stamp = Date.now();
|
const stamp = Date.now();
|
||||||
@ -8,7 +8,6 @@ test("Unterhaltungen and Projekte stay separate; + opens bot picker", async ({ p
|
|||||||
await page.goto("/app");
|
await page.goto("/app");
|
||||||
await page.waitForURL(/\/app\/[^/]+$/);
|
await page.waitForURL(/\/app\/[^/]+$/);
|
||||||
|
|
||||||
const sidebar = page.locator("aside").first();
|
|
||||||
const unterhaltungen = page.locator(".sidebar-unterhaltungen");
|
const unterhaltungen = page.locator(".sidebar-unterhaltungen");
|
||||||
const projekte = page.locator(".sidebar-projekte");
|
const projekte = page.locator(".sidebar-projekte");
|
||||||
|
|
||||||
@ -33,13 +32,13 @@ test("Unterhaltungen and Projekte stay separate; + opens bot picker", async ({ p
|
|||||||
await page.waitForURL(/\/app\/[^/]+$/);
|
await page.waitForURL(/\/app\/[^/]+$/);
|
||||||
await expect(page.getByRole("combobox", { name: "Message" })).toBeVisible();
|
await expect(page.getByRole("combobox", { name: "Message" })).toBeVisible();
|
||||||
|
|
||||||
await projekte.getByRole("button", { name: "Create a project" }).click();
|
await projekte.getByTestId("create-project-trigger").click();
|
||||||
const dialog = page.getByRole("dialog", { name: "New project" });
|
const dialog = page.getByRole("dialog", { name: "New project" });
|
||||||
await dialog.getByLabel("Name").fill("Side project");
|
await dialog.getByLabel("Name").fill("Side project");
|
||||||
await dialog.getByRole("button", { name: "Create project", exact: true }).click();
|
await dialog.getByRole("button", { name: "Create project", exact: true }).click();
|
||||||
await page.waitForURL(/\/app/);
|
await page.waitForURL(/\/app/);
|
||||||
await expect(projekte.getByText("Side project", { exact: true })).toBeVisible();
|
await expect(projekte.getByText("Side project", { exact: true })).toBeVisible();
|
||||||
await expect(unterhaltungen.getByText("Side project", { exact: true })).toHaveCount(0);
|
await expect(unterhaltungenOnly(page).getByText("Side project", { exact: true })).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("chat header shows avatar once and composer uses generic placeholder", async ({ page }) => {
|
test("chat header shows avatar once and composer uses generic placeholder", async ({ page }) => {
|
||||||
@ -50,7 +49,9 @@ test("chat header shows avatar once and composer uses generic placeholder", asyn
|
|||||||
await page.waitForURL(/\/app\/[^/]+$/);
|
await page.waitForURL(/\/app\/[^/]+$/);
|
||||||
|
|
||||||
const header = page.getByTestId("bot-settings-trigger");
|
const header = page.getByTestId("bot-settings-trigger");
|
||||||
await expect(header.locator(".rakazo-bot-avatar, [data-testid='bot-avatar']").first()).toBeVisible();
|
await expect(
|
||||||
|
header.locator(".rakazo-bot-avatar, [data-testid='bot-avatar']").first(),
|
||||||
|
).toBeVisible();
|
||||||
await expect(header.getByText("Chief", { exact: true })).toBeVisible();
|
await expect(header.getByText("Chief", { exact: true })).toBeVisible();
|
||||||
await expect(header.getByText("1:1 conversation", { exact: true })).toHaveCount(0);
|
await expect(header.getByText("1:1 conversation", { exact: true })).toHaveCount(0);
|
||||||
|
|
||||||
|
|||||||
@ -17,6 +17,7 @@ test("voice settings connect a key, speak a reply, and open a call", async ({ pa
|
|||||||
.getByRole("button", { name: "Voice", exact: true })
|
.getByRole("button", { name: "Voice", exact: true })
|
||||||
.click();
|
.click();
|
||||||
await expect(page.getByTestId("voice-settings")).toBeVisible();
|
await expect(page.getByTestId("voice-settings")).toBeVisible();
|
||||||
|
await page.getByRole("button", { name: "Other voice providers" }).click();
|
||||||
await expect(page.getByLabel("API key", { exact: true })).toBeVisible();
|
await expect(page.getByLabel("API key", { exact: true })).toBeVisible();
|
||||||
await expect(page.getByRole("button", { name: "Dictate", exact: true })).toHaveCount(0);
|
await expect(page.getByRole("button", { name: "Dictate", exact: true })).toHaveCount(0);
|
||||||
await captureScreenshot(page, testInfo, "voice-settings");
|
await captureScreenshot(page, testInfo, "voice-settings");
|
||||||
@ -30,23 +31,17 @@ test("voice settings connect a key, speak a reply, and open a call", async ({ pa
|
|||||||
|
|
||||||
await openUserSettings(page, "voice");
|
await openUserSettings(page, "voice");
|
||||||
await expect(page.getByTestId("voice-settings")).toBeVisible();
|
await expect(page.getByTestId("voice-settings")).toBeVisible();
|
||||||
await page.getByRole("button", { name: /Scripted/ }).click();
|
await page.getByRole("button", { name: "Other voice providers" }).click();
|
||||||
|
await page.getByLabel("Provider", { exact: true }).selectOption("scripted");
|
||||||
const apiKeyInput = page.getByPlaceholder(/Paste your API key/);
|
const apiKeyInput = page.getByPlaceholder(/Paste your API key/);
|
||||||
await expect(apiKeyInput).toHaveAttribute("autocomplete", "new-password");
|
await expect(apiKeyInput).toHaveAttribute("autocomplete", "new-password");
|
||||||
await apiKeyInput.fill("fake-scripted-voice-key");
|
await apiKeyInput.fill("fake-scripted-voice-key");
|
||||||
await page.getByRole("button", { name: "Connect", exact: true }).click();
|
await page.getByRole("button", { name: "Connect", exact: true }).click();
|
||||||
await expect(page.getByText("Connected", { exact: true }).first()).toBeVisible();
|
await expect(page.getByText("Connected Scripted.")).toBeVisible();
|
||||||
await expect(page.getByRole("button", { name: "Replace key" })).toBeVisible();
|
await expect(page.getByRole("button", { name: "Replace key" })).toBeVisible();
|
||||||
await expect(page.getByRole("button", { name: "Disconnect", exact: true })).toBeVisible();
|
await expect(page.getByRole("button", { name: "Disconnect", exact: true })).toBeVisible();
|
||||||
await captureScreenshot(page, testInfo, "voice-settings-connected");
|
await captureScreenshot(page, testInfo, "voice-settings-connected");
|
||||||
|
|
||||||
const spoken = page.waitForResponse(
|
|
||||||
(response) => response.url().includes("/api/voice/speak") && response.ok(),
|
|
||||||
);
|
|
||||||
await page.getByRole("button", { name: "Hear a sample" }).click();
|
|
||||||
const clip = await spoken;
|
|
||||||
expect(clip.headers()["content-type"]).toContain("audio/mpeg");
|
|
||||||
|
|
||||||
const credentials = await rpc<Array<{ hasKey: boolean; provider: string }>>(
|
const credentials = await rpc<Array<{ hasKey: boolean; provider: string }>>(
|
||||||
page,
|
page,
|
||||||
"voice/credentials",
|
"voice/credentials",
|
||||||
@ -73,6 +68,8 @@ test("voice settings connect a key, speak a reply, and open a call", async ({ pa
|
|||||||
await replySpoken;
|
await replySpoken;
|
||||||
|
|
||||||
await openUserSettings(page, "voice");
|
await openUserSettings(page, "voice");
|
||||||
|
await page.getByRole("button", { name: "Other voice providers" }).click();
|
||||||
|
await page.getByLabel("Provider", { exact: true }).selectOption("scripted");
|
||||||
await expect(page.getByRole("button", { name: "Replace key" })).toBeVisible();
|
await expect(page.getByRole("button", { name: "Replace key" })).toBeVisible();
|
||||||
await expect(page.getByRole("button", { name: "Disconnect", exact: true })).toBeVisible();
|
await expect(page.getByRole("button", { name: "Disconnect", exact: true })).toBeVisible();
|
||||||
await page.getByRole("button", { name: "Close voice settings" }).click();
|
await page.getByRole("button", { name: "Close voice settings" }).click();
|
||||||
@ -88,6 +85,7 @@ test("voice settings connect a key, speak a reply, and open a call", async ({ pa
|
|||||||
|
|
||||||
await openUserSettings(page, "voice");
|
await openUserSettings(page, "voice");
|
||||||
const settings = page.getByTestId("voice-settings");
|
const settings = page.getByTestId("voice-settings");
|
||||||
|
await settings.getByRole("button", { name: "Other voice providers" }).click();
|
||||||
await settings.getByPlaceholder(/Paste a replacement key/).fill("leftover-voice-key");
|
await settings.getByPlaceholder(/Paste a replacement key/).fill("leftover-voice-key");
|
||||||
await settings.getByRole("button", { name: "Disconnect", exact: true }).click();
|
await settings.getByRole("button", { name: "Disconnect", exact: true }).click();
|
||||||
await expect(settings.getByRole("button", { name: "Disconnect", exact: true })).toHaveCount(0);
|
await expect(settings.getByRole("button", { name: "Disconnect", exact: true })).toHaveCount(0);
|
||||||
|
|||||||
@ -15,7 +15,7 @@ export default defineConfig({
|
|||||||
testDir: "./e2e",
|
testDir: "./e2e",
|
||||||
forbidOnly: Boolean(process.env.CI),
|
forbidOnly: Boolean(process.env.CI),
|
||||||
fullyParallel: false,
|
fullyParallel: false,
|
||||||
workers: realSandbox ? 1 : undefined,
|
workers: process.env.CI || realSandbox ? 1 : undefined,
|
||||||
timeout: boxSandbox ? 600_000 : realSandbox ? 300_000 : 120_000,
|
timeout: boxSandbox ? 600_000 : realSandbox ? 300_000 : 120_000,
|
||||||
expect: { timeout: boxSandbox ? 300_000 : realSandbox ? 90_000 : 20_000 },
|
expect: { timeout: boxSandbox ? 300_000 : realSandbox ? 90_000 : 20_000 },
|
||||||
reporter: reporters,
|
reporter: reporters,
|
||||||
|
|||||||
@ -427,7 +427,7 @@
|
|||||||
"Paste the OpenAI-compatible address from your server. Rakazo adds /v1 if needed.": "Füge die OpenAI-kompatible Adresse deines Servers ein. Rakazo ergänzt /v1 bei Bedarf.",
|
"Paste the OpenAI-compatible address from your server. Rakazo adds /v1 if needed.": "Füge die OpenAI-kompatible Adresse deines Servers ein. Rakazo ergänzt /v1 bei Bedarf.",
|
||||||
"Paste your API key": "Füge deinen API-Schlüssel ein",
|
"Paste your API key": "Füge deinen API-Schlüssel ein",
|
||||||
"Personal credential": "Persönliche Zugangsdaten",
|
"Personal credential": "Persönliche Zugangsdaten",
|
||||||
"Pick a voice": "Stimme auswählen",
|
"Pick a voice": "Stimme wählen",
|
||||||
"Pin": "Anheften",
|
"Pin": "Anheften",
|
||||||
"Playing…": "Wiedergabe…",
|
"Playing…": "Wiedergabe…",
|
||||||
"Preview {0}": "Vorschau von {0}",
|
"Preview {0}": "Vorschau von {0}",
|
||||||
@ -615,9 +615,7 @@
|
|||||||
"Could not enable speaking": "Sprechen konnte nicht aktiviert werden",
|
"Could not enable speaking": "Sprechen konnte nicht aktiviert werden",
|
||||||
"Could not update speaking": "Sprechen konnte nicht aktualisiert werden",
|
"Could not update speaking": "Sprechen konnte nicht aktualisiert werden",
|
||||||
"Other voice providers": "Andere Sprachanbieter",
|
"Other voice providers": "Andere Sprachanbieter",
|
||||||
"Provider": "Anbieter",
|
|
||||||
"Loading voice settings…": "Spracheinstellungen werden geladen…",
|
"Loading voice settings…": "Spracheinstellungen werden geladen…",
|
||||||
"Pick a voice": "Stimme wählen",
|
|
||||||
"How should your Oo sound when reading replies aloud?": "Wie soll dein Oo klingen, wenn er Antworten vorliest?",
|
"How should your Oo sound when reading replies aloud?": "Wie soll dein Oo klingen, wenn er Antworten vorliest?",
|
||||||
"Loading voices…": "Stimmen werden geladen…",
|
"Loading voices…": "Stimmen werden geladen…",
|
||||||
"Voice is not available yet. You can turn it on later in settings.": "Stimme ist noch nicht verfügbar. Du kannst sie später in den Einstellungen aktivieren.",
|
"Voice is not available yet. You can turn it on later in settings.": "Stimme ist noch nicht verfügbar. Du kannst sie später in den Einstellungen aktivieren.",
|
||||||
|
|||||||
@ -1,26 +1,60 @@
|
|||||||
/**
|
/**
|
||||||
* Sortable wrapper for a project (space) sidebar group card.
|
* Sortable wrapper for a project (space) sidebar group card.
|
||||||
* Accepts an optional `handleSlot` so the parent can render a grip handle
|
* Drag listeners attach only to the grip handle via `useSortableGroupHandle`.
|
||||||
* inside its own title row — this keeps the handle positioned correctly
|
|
||||||
* relative to the label.
|
|
||||||
*/
|
*/
|
||||||
import { CSS } from "@dnd-kit/utilities";
|
|
||||||
import { useSortable } from "@dnd-kit/sortable";
|
import { useSortable } from "@dnd-kit/sortable";
|
||||||
import { memo, useMemo } from "react";
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
|
import { createContext, memo, useContext, useMemo } from "react";
|
||||||
|
|
||||||
interface SortableGroupProps {
|
interface SortableGroupProps {
|
||||||
group: { spaceId: string; key: string; title?: string | null };
|
group: { spaceId: string; key: string; title?: string | null };
|
||||||
/** When provided, rendered alongside children inside the drag node. */
|
|
||||||
handleSlot?: React.ReactNode;
|
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SortableGroup = memo(function SortableGroup({
|
type SortableGroupHandle = {
|
||||||
group,
|
setActivatorNodeRef: (element: HTMLElement | null) => void;
|
||||||
handleSlot,
|
listeners: ReturnType<typeof useSortable>["listeners"];
|
||||||
|
attributes: ReturnType<typeof useSortable>["attributes"];
|
||||||
|
};
|
||||||
|
|
||||||
|
const SortableGroupHandleContext = createContext<SortableGroupHandle | null>(null);
|
||||||
|
|
||||||
|
export function useSortableGroupHandle(): SortableGroupHandle | null {
|
||||||
|
return useContext(SortableGroupHandleContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProjectDragGrip = memo(function ProjectDragGrip({
|
||||||
|
className,
|
||||||
children,
|
children,
|
||||||
}: SortableGroupProps) {
|
}: {
|
||||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
className?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const handle = useSortableGroupHandle();
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
ref={handle?.setActivatorNodeRef}
|
||||||
|
className={className}
|
||||||
|
aria-label="Drag to reorder"
|
||||||
|
{...handle?.attributes}
|
||||||
|
{...handle?.listeners}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export const SortableGroup = memo(function SortableGroup({ group, children }: SortableGroupProps) {
|
||||||
|
const {
|
||||||
|
attributes,
|
||||||
|
listeners,
|
||||||
|
setNodeRef,
|
||||||
|
setActivatorNodeRef,
|
||||||
|
transform,
|
||||||
|
transition,
|
||||||
|
isDragging,
|
||||||
|
} = useSortable({
|
||||||
id: group.spaceId,
|
id: group.spaceId,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -34,10 +68,16 @@ export const SortableGroup = memo(function SortableGroup({
|
|||||||
[transform, transition, isDragging],
|
[transform, transition, isDragging],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handle = useMemo(
|
||||||
|
() => ({ setActivatorNodeRef, listeners, attributes }),
|
||||||
|
[setActivatorNodeRef, listeners, attributes],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={setNodeRef} style={style} data-drag-id={group.spaceId} {...attributes} {...listeners}>
|
<SortableGroupHandleContext.Provider value={handle}>
|
||||||
{handleSlot}
|
<div ref={setNodeRef} style={style} data-drag-id={group.spaceId}>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
</SortableGroupHandleContext.Provider>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { BotAvatar, GroupAvatar, type GroupAvatarMember } from "@rakazo/ui-web";
|
import { BotAvatar, GroupAvatar, type GroupAvatarMember } from "@rakazo/ui-web";
|
||||||
import { LoadingState, Shimmer } from "./primitives";
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { personaWorkingLinesPlain, resolveBotPersona } from "../../lib/bot-persona";
|
import { personaWorkingLinesPlain, resolveBotPersona } from "../../lib/bot-persona";
|
||||||
|
import { Shimmer } from "./primitives";
|
||||||
|
|
||||||
/** Lightweight peer event shown without exposing the exchanged message body. */
|
/** Lightweight peer event shown without exposing the exchanged message body. */
|
||||||
export function CollaborationMarker({
|
export function CollaborationMarker({
|
||||||
|
|||||||
@ -26,16 +26,9 @@ export function DelegatedTaskCard({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="my-2 flex justify-start">
|
<div className="my-2 flex justify-start">
|
||||||
<div
|
<button
|
||||||
role="button"
|
type="button"
|
||||||
tabIndex={0}
|
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
|
||||||
e.preventDefault();
|
|
||||||
onClick();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
data-testid="delegated-task-card"
|
data-testid="delegated-task-card"
|
||||||
className="w-[min(480px,96%)] rounded-[18px] border border-border bg-card/75 p-3.5 shadow-xs transition-all hover:border-primary/40 hover:bg-card hover:shadow-md cursor-pointer text-left"
|
className="w-[min(480px,96%)] rounded-[18px] border border-border bg-card/75 p-3.5 shadow-xs transition-all hover:border-primary/40 hover:bg-card hover:shadow-md cursor-pointer text-left"
|
||||||
>
|
>
|
||||||
@ -81,7 +74,7 @@ export function DelegatedTaskCard({
|
|||||||
Verlauf & Logs öffnen →
|
Verlauf & Logs öffnen →
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -60,7 +60,9 @@ function DefaultLoadingState({ label, startedAt }: { label: string; startedAt?:
|
|||||||
<span className="text-ui-body font-medium">
|
<span className="text-ui-body font-medium">
|
||||||
<Shimmer>{label}</Shimmer>
|
<Shimmer>{label}</Shimmer>
|
||||||
</span>
|
</span>
|
||||||
<span className="font-mono text-ui-body-sm tabular-nums text-muted-foreground">{elapsed}</span>
|
<span className="font-mono text-ui-body-sm tabular-nums text-muted-foreground">
|
||||||
|
{elapsed}
|
||||||
|
</span>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,6 +22,7 @@ export function LookPreviewCarousel({
|
|||||||
}: LookPreviewCarouselProps) {
|
}: LookPreviewCarouselProps) {
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
const touchStartX = useRef<number | null>(null);
|
const touchStartX = useRef<number | null>(null);
|
||||||
|
const dragOffsetRef = useRef(0);
|
||||||
const [dragOffset, setDragOffset] = useState(0);
|
const [dragOffset, setDragOffset] = useState(0);
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
|
||||||
@ -50,24 +51,34 @@ export function LookPreviewCarousel({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full" data-testid="onboarding-look-carousel">
|
|
||||||
<div
|
<div
|
||||||
className="relative touch-pan-y"
|
className="w-full"
|
||||||
|
data-testid="onboarding-look-carousel"
|
||||||
onTouchStart={(event) => {
|
onTouchStart={(event) => {
|
||||||
touchStartX.current = event.touches[0]?.clientX ?? null;
|
touchStartX.current = event.touches[0]?.clientX ?? null;
|
||||||
|
dragOffsetRef.current = 0;
|
||||||
setIsDragging(true);
|
setIsDragging(true);
|
||||||
}}
|
}}
|
||||||
onTouchMove={(event) => {
|
onTouchMove={(event) => {
|
||||||
if (touchStartX.current === null) return;
|
if (touchStartX.current === null) return;
|
||||||
const currentX = event.touches[0]?.clientX ?? touchStartX.current;
|
const currentX = event.touches[0]?.clientX ?? touchStartX.current;
|
||||||
setDragOffset(currentX - touchStartX.current);
|
const delta = currentX - touchStartX.current;
|
||||||
|
dragOffsetRef.current = delta;
|
||||||
|
setDragOffset(delta);
|
||||||
}}
|
}}
|
||||||
onTouchEnd={() => {
|
onTouchEnd={(event) => {
|
||||||
if (touchStartX.current === null) return;
|
if (touchStartX.current === null) return;
|
||||||
finishSwipe(dragOffset);
|
const endX = event.changedTouches[0]?.clientX;
|
||||||
|
const delta = endX === undefined ? dragOffsetRef.current : endX - touchStartX.current;
|
||||||
|
dragOffsetRef.current = 0;
|
||||||
|
finishSwipe(delta);
|
||||||
|
}}
|
||||||
|
onTouchCancel={() => {
|
||||||
|
dragOffsetRef.current = 0;
|
||||||
|
finishSwipe(0);
|
||||||
}}
|
}}
|
||||||
onTouchCancel={() => finishSwipe(0)}
|
|
||||||
>
|
>
|
||||||
|
<div className="relative touch-pan-y">
|
||||||
<div
|
<div
|
||||||
className="mx-auto flex max-w-sm items-center justify-center"
|
className="mx-auto flex max-w-sm items-center justify-center"
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { Trans } from "@lingui/react/macro";
|
|||||||
import type { VoiceInfo } from "@rakazo/contracts";
|
import type { VoiceInfo } from "@rakazo/contracts";
|
||||||
import { defaultVoiceForLocale, preferVoicesForLocale } from "@rakazo/core";
|
import { defaultVoiceForLocale, preferVoicesForLocale } from "@rakazo/core";
|
||||||
import { Button } from "@rakazo/ui-web";
|
import { Button } from "@rakazo/ui-web";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { rpc } from "../../lib/rpc";
|
import { rpc } from "../../lib/rpc";
|
||||||
import { readUiLocale } from "../../lib/ui-locale";
|
import { readUiLocale } from "../../lib/ui-locale";
|
||||||
|
|
||||||
@ -26,11 +26,12 @@ export function OnboardingVoiceStep({
|
|||||||
}: OnboardingVoiceStepProps) {
|
}: OnboardingVoiceStepProps) {
|
||||||
const [voiceOptions, setVoiceOptions] = useState<VoiceInfo[]>([]);
|
const [voiceOptions, setVoiceOptions] = useState<VoiceInfo[]>([]);
|
||||||
const [voiceLoading, setVoiceLoading] = useState(true);
|
const [voiceLoading, setVoiceLoading] = useState(true);
|
||||||
|
const previewTask = useRef<Promise<void>>(Promise.resolve());
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setVoiceLoading(true);
|
setVoiceLoading(true);
|
||||||
void (async () => {
|
const task = (async () => {
|
||||||
try {
|
try {
|
||||||
await rpc.voice.connect({ provider: "kokoro", apiKey: KOKORO_LOCAL_TOKEN });
|
await rpc.voice.connect({ provider: "kokoro", apiKey: KOKORO_LOCAL_TOKEN });
|
||||||
const listed = await rpc.voice.voices({ provider: "kokoro" });
|
const listed = await rpc.voice.voices({ provider: "kokoro" });
|
||||||
@ -46,11 +47,23 @@ export function OnboardingVoiceStep({
|
|||||||
if (!cancelled) setVoiceLoading(false);
|
if (!cancelled) setVoiceLoading(false);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
previewTask.current = task.then(
|
||||||
|
() => undefined,
|
||||||
|
() => undefined,
|
||||||
|
);
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [onSelectedVoiceIdChange]);
|
}, [onSelectedVoiceIdChange]);
|
||||||
|
|
||||||
|
async function skipVoice() {
|
||||||
|
await previewTask.current;
|
||||||
|
// Listing voices persists a local credential. Skip must drop it so Voice
|
||||||
|
// still opens settings instead of a call the user never chose.
|
||||||
|
await rpc.voice.disconnect({ provider: "kokoro" }).catch(() => undefined);
|
||||||
|
onSkip();
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-xl">
|
<div className="mx-auto max-w-xl">
|
||||||
<h2 className="text-xl font-semibold tracking-tight text-foreground">
|
<h2 className="text-xl font-semibold tracking-tight text-foreground">
|
||||||
@ -79,7 +92,9 @@ export function OnboardingVoiceStep({
|
|||||||
>
|
>
|
||||||
<span className="block text-foreground">{voice.label}</span>
|
<span className="block text-foreground">{voice.label}</span>
|
||||||
{voice.description ? (
|
{voice.description ? (
|
||||||
<span className="mt-0.5 block text-xs text-muted-foreground">{voice.description}</span>
|
<span className="mt-0.5 block text-xs text-muted-foreground">
|
||||||
|
{voice.description}
|
||||||
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@ -94,7 +109,7 @@ export function OnboardingVoiceStep({
|
|||||||
<Trans>Back</Trans>
|
<Trans>Back</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button variant="ghost" onClick={onSkip}>
|
<Button variant="ghost" onClick={() => void skipVoice()}>
|
||||||
<Trans>Skip</Trans>
|
<Trans>Skip</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
<Button disabled={!selectedVoiceId} onClick={onContinue}>
|
<Button disabled={!selectedVoiceId} onClick={onContinue}>
|
||||||
|
|||||||
@ -169,7 +169,9 @@ export function OoStage({
|
|||||||
energy={energy}
|
energy={energy}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className={`relative z-10 mx-auto flex w-full max-w-3xl flex-col items-center px-4 md:px-8 ${hasMessages ? "py-2 md:py-3" : "py-5 md:py-6"}`}>
|
<div
|
||||||
|
className={`relative z-10 mx-auto flex w-full max-w-3xl flex-col items-center px-4 md:px-8 ${hasMessages ? "py-2 md:py-3" : "py-5 md:py-6"}`}
|
||||||
|
>
|
||||||
{(hasMessages || phase !== "idle") && (
|
{(hasMessages || phase !== "idle") && (
|
||||||
<div className={`flex w-full justify-center ${hasMessages ? "mb-1" : "mb-3"}`}>
|
<div className={`flex w-full justify-center ${hasMessages ? "mb-1" : "mb-3"}`}>
|
||||||
<div
|
<div
|
||||||
@ -474,7 +476,9 @@ export function OoGroupStage({
|
|||||||
energy={energy}
|
energy={energy}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className={`relative z-10 mx-auto flex w-full max-w-3xl flex-col items-center px-4 md:px-8 ${hasMessages ? "py-2 md:py-3" : "py-5 md:py-6"}`}>
|
<div
|
||||||
|
className={`relative z-10 mx-auto flex w-full max-w-3xl flex-col items-center px-4 md:px-8 ${hasMessages ? "py-2 md:py-3" : "py-5 md:py-6"}`}
|
||||||
|
>
|
||||||
<div className="mb-3 w-full min-w-0 text-center">
|
<div className="mb-3 w-full min-w-0 text-center">
|
||||||
<div className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.05] px-2.5 py-0.5 text-ui-label font-semibold uppercase tracking-wider text-muted-foreground backdrop-blur-sm">
|
<div className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.05] px-2.5 py-0.5 text-ui-label font-semibold uppercase tracking-wider text-muted-foreground backdrop-blur-sm">
|
||||||
<span
|
<span
|
||||||
|
|||||||
@ -27,11 +27,7 @@ export function StageVoiceEntry({
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<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 ? (
|
{voiceReady ? <Trans>Sprechen starten</Trans> : <Trans>Sprache einrichten</Trans>}
|
||||||
<Trans>Sprechen starten</Trans>
|
|
||||||
) : (
|
|
||||||
<Trans>Sprache einrichten</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>Primär per Stimme — Transkript unten</Trans>
|
||||||
|
|||||||
@ -51,10 +51,7 @@ export function VoiceCanvas({
|
|||||||
const ringOpacity = useMemo(() => 0.35 + energy * 0.45, [energy]);
|
const ringOpacity = useMemo(() => 0.35 + energy * 0.45, [energy]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div aria-hidden className="pointer-events-none absolute inset-0 overflow-hidden bg-[#030508]">
|
||||||
aria-hidden
|
|
||||||
className="pointer-events-none absolute inset-0 overflow-hidden bg-[#030508]"
|
|
||||||
>
|
|
||||||
{/* Deep field */}
|
{/* Deep field */}
|
||||||
<div className="absolute inset-0 bg-[radial-gradient(ellipse_120%_80%_at_50%_100%,rgba(8,12,20,0.95)_0%,#030508_55%)]" />
|
<div className="absolute inset-0 bg-[radial-gradient(ellipse_120%_80%_at_50%_100%,rgba(8,12,20,0.95)_0%,#030508_55%)]" />
|
||||||
|
|
||||||
@ -84,7 +81,9 @@ export function VoiceCanvas({
|
|||||||
background: `radial-gradient(circle, ${palette.accent} 0%, transparent 72%)`,
|
background: `radial-gradient(circle, ${palette.accent} 0%, transparent 72%)`,
|
||||||
opacity: 0.5 + energy * 0.25,
|
opacity: 0.5 + energy * 0.25,
|
||||||
transform: `translateX(-50%) scale(${0.92 + energy * 0.18})`,
|
transform: `translateX(-50%) scale(${0.92 + energy * 0.18})`,
|
||||||
transition: reducedMotion ? undefined : "transform 140ms ease-out, opacity 140ms ease-out",
|
transition: reducedMotion
|
||||||
|
? undefined
|
||||||
|
: "transform 140ms ease-out, opacity 140ms ease-out",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,11 @@
|
|||||||
import { useState, useEffect, useCallback } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
export function useSidebarResize(initialWidth: number, side: "left" | "right", minWidth: number, maxWidth: number) {
|
export function useSidebarResize(
|
||||||
|
initialWidth: number,
|
||||||
|
side: "left" | "right",
|
||||||
|
minWidth: number,
|
||||||
|
maxWidth: number,
|
||||||
|
) {
|
||||||
const [width, setWidth] = useState(initialWidth);
|
const [width, setWidth] = useState(initialWidth);
|
||||||
const [isResizing, setIsResizing] = useState(false);
|
const [isResizing, setIsResizing] = useState(false);
|
||||||
|
|
||||||
@ -9,7 +14,7 @@ export function useSidebarResize(initialWidth: number, side: "left" | "right", m
|
|||||||
|
|
||||||
const handlePointerMove = (e: PointerEvent) => {
|
const handlePointerMove = (e: PointerEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
let newWidth;
|
let newWidth: number;
|
||||||
if (side === "left") {
|
if (side === "left") {
|
||||||
newWidth = e.clientX;
|
newWidth = e.clientX;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
36
apps/web/src/lib/browser-preview-context.test.ts
Normal file
36
apps/web/src/lib/browser-preview-context.test.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { browserPreviewActivity } from "./browser-preview-context";
|
||||||
|
|
||||||
|
describe("browserPreviewActivity", () => {
|
||||||
|
it("publishes cursors to subscribers and drops cleared ids", () => {
|
||||||
|
for (const id of browserPreviewActivity.snapshot().keys()) browserPreviewActivity.clear(id);
|
||||||
|
|
||||||
|
const seen: string[][] = [];
|
||||||
|
const unsubscribe = browserPreviewActivity.subscribe((activities) => {
|
||||||
|
seen.push([...activities.keys()]);
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
browserPreviewActivity.set("bot-a", 50, 40, "Ada");
|
||||||
|
browserPreviewActivity.set("bot-a", 50, 40, "Ada");
|
||||||
|
browserPreviewActivity.set("bot-b", 10, 20, "Bob");
|
||||||
|
expect(browserPreviewActivity.snapshot().get("bot-a")).toEqual({
|
||||||
|
x: 50,
|
||||||
|
y: 40,
|
||||||
|
label: "Ada",
|
||||||
|
});
|
||||||
|
|
||||||
|
browserPreviewActivity.clear("bot-a");
|
||||||
|
browserPreviewActivity.clear("missing");
|
||||||
|
|
||||||
|
expect(seen).toEqual([[], ["bot-a"], ["bot-a", "bot-b"], ["bot-b"]]);
|
||||||
|
} finally {
|
||||||
|
unsubscribe();
|
||||||
|
for (const id of [...browserPreviewActivity.snapshot().keys()]) {
|
||||||
|
browserPreviewActivity.clear(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(browserPreviewActivity.snapshot().size).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,46 +1,47 @@
|
|||||||
/**
|
export type BrowserPreviewCursor = {
|
||||||
* Browser preview activity tracking.
|
|
||||||
*
|
|
||||||
* Shared state so Shell can display multi-agent cursor positions
|
|
||||||
* inside browser-preview panels.
|
|
||||||
*/
|
|
||||||
|
|
||||||
interface ActivityEntry {
|
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
label: string;
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Listener = (activities: ReadonlyMap<string, BrowserPreviewCursor>) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-memory cursors for the desktop browser-preview panel.
|
||||||
|
* The shell publishes working bots here; the panel subscribes.
|
||||||
|
*/
|
||||||
|
class BrowserPreviewActivity {
|
||||||
|
private readonly activities = new Map<string, BrowserPreviewCursor>();
|
||||||
|
private readonly listeners = new Set<Listener>();
|
||||||
|
|
||||||
|
subscribe(listener: Listener): () => void {
|
||||||
|
this.listeners.add(listener);
|
||||||
|
listener(this.snapshot());
|
||||||
|
return () => {
|
||||||
|
this.listeners.delete(listener);
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
type Subscriber = (snapshot: ReadonlyMap<string, ActivityEntry>) => void;
|
snapshot(): ReadonlyMap<string, BrowserPreviewCursor> {
|
||||||
|
return new Map(this.activities);
|
||||||
|
}
|
||||||
|
|
||||||
export const browserPreviewActivity = (() => {
|
set(id: string, x: number, y: number, label: string) {
|
||||||
const map = new Map<string, ActivityEntry>();
|
const current = this.activities.get(id);
|
||||||
const listeners = new Set<Subscriber>();
|
if (current && current.x === x && current.y === y && current.label === label) return;
|
||||||
|
this.activities.set(id, { x, y, label });
|
||||||
|
this.emit();
|
||||||
|
}
|
||||||
|
|
||||||
const freeze = (): ReadonlyMap<string, ActivityEntry> =>
|
clear(id: string) {
|
||||||
new Map(map) as ReadonlyMap<string, ActivityEntry>;
|
if (!this.activities.delete(id)) return;
|
||||||
|
this.emit();
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
private emit() {
|
||||||
subscribe(cb: Subscriber): () => void {
|
const snapshot = this.snapshot();
|
||||||
listeners.add(cb);
|
for (const listener of this.listeners) listener(snapshot);
|
||||||
cb(freeze());
|
}
|
||||||
return () => {
|
}
|
||||||
listeners.delete(cb);
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
set(id: string, x: number, y: number, label: string): void {
|
export const browserPreviewActivity = new BrowserPreviewActivity();
|
||||||
map.set(id, { x, y, label });
|
|
||||||
listeners.forEach((l) => l(freeze()));
|
|
||||||
},
|
|
||||||
|
|
||||||
clear(id: string): void {
|
|
||||||
map.delete(id);
|
|
||||||
listeners.forEach((l) => l(freeze()));
|
|
||||||
},
|
|
||||||
|
|
||||||
snapshot(): ReadonlyMap<string, ActivityEntry> {
|
|
||||||
return freeze();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
})();
|
|
||||||
|
|||||||
@ -27,11 +27,7 @@ export function shouldPromoteToWorking(input: {
|
|||||||
speakerStatus: SpeechStatus;
|
speakerStatus: SpeechStatus;
|
||||||
userTurnPending: boolean;
|
userTurnPending: boolean;
|
||||||
}): boolean {
|
}): boolean {
|
||||||
return (
|
return input.userTurnPending && input.phase === "thinking" && input.speakerStatus === "idle";
|
||||||
input.userTurnPending &&
|
|
||||||
input.phase === "thinking" &&
|
|
||||||
input.speakerStatus === "idle"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @deprecated use shouldPromoteToWorking */
|
/** @deprecated use shouldPromoteToWorking */
|
||||||
@ -85,10 +81,7 @@ export function voiceThinkingSubtitle(input: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Subtitle when the mic is genuinely open for the user's turn. */
|
/** Subtitle when the mic is genuinely open for the user's turn. */
|
||||||
export function voiceListeningSubtitle(input: {
|
export function voiceListeningSubtitle(input: { heard: string; idlePrompt: string }): string {
|
||||||
heard: string;
|
|
||||||
idlePrompt: string;
|
|
||||||
}): string {
|
|
||||||
if (input.heard.trim()) return input.heard;
|
if (input.heard.trim()) return input.heard;
|
||||||
return input.idlePrompt;
|
return input.idlePrompt;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -67,9 +67,11 @@ describe("window chrome", () => {
|
|||||||
'className="app-drag flex items-center justify-between border-b border-sidebar-border',
|
'className="app-drag flex items-center justify-between border-b border-sidebar-border',
|
||||||
);
|
);
|
||||||
expect(shell).toContain('className="app-no-drag grid size-control-lg');
|
expect(shell).toContain('className="app-no-drag grid size-control-lg');
|
||||||
expect(shell).toContain('className="app-no-drag flex min-w-0 items-center"');
|
expect(shell).toContain('className="app-no-drag flex min-w-0 items-center gap-3"');
|
||||||
// Ensure header sidebar toggle controls stay clickable (not part of drag region)
|
// Ensure header sidebar toggle controls stay clickable (not part of drag region)
|
||||||
expect(shell.match(/app-no-drag .* size-control-lg .* place-items-center/g)?.length ?? 0).toBeGreaterThanOrEqual(2);
|
expect(
|
||||||
|
shell.match(/app-no-drag .* size-control-lg .* place-items-center/g)?.length ?? 0,
|
||||||
|
).toBeGreaterThanOrEqual(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("moves window chrome into the conversation header when the bots sidebar is collapsed", () => {
|
it("moves window chrome into the conversation header when the bots sidebar is collapsed", () => {
|
||||||
|
|||||||
@ -1,12 +1,12 @@
|
|||||||
|
import type { IntegrationSetupState } from "@rakazo/contracts";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
computeDiscoveryItems,
|
computeDiscoveryItems,
|
||||||
countConfiguredIntegrations,
|
countConfiguredIntegrations,
|
||||||
|
type DiscoveryContext,
|
||||||
discoveryProgress,
|
discoveryProgress,
|
||||||
summarizeProfileSignals,
|
summarizeProfileSignals,
|
||||||
type DiscoveryContext,
|
|
||||||
} from "./onboarding-discovery.js";
|
} from "./onboarding-discovery.js";
|
||||||
import type { IntegrationSetupState } from "@rakazo/contracts";
|
|
||||||
|
|
||||||
describe("countConfiguredIntegrations", () => {
|
describe("countConfiguredIntegrations", () => {
|
||||||
it("counts configured providers", () => {
|
it("counts configured providers", () => {
|
||||||
@ -30,13 +30,26 @@ describe("countConfiguredIntegrations", () => {
|
|||||||
describe("summarizeProfileSignals", () => {
|
describe("summarizeProfileSignals", () => {
|
||||||
it("includes name and timezone", () => {
|
it("includes name and timezone", () => {
|
||||||
expect(
|
expect(
|
||||||
summarizeProfileSignals({ name: "Alice", timezone: "Europe/Vienna", locale: "de-DE", languages: [], harvestedAt: new Date().toISOString() }),
|
summarizeProfileSignals({
|
||||||
|
name: "Alice",
|
||||||
|
timezone: "Europe/Vienna",
|
||||||
|
locale: "de-DE",
|
||||||
|
languages: [],
|
||||||
|
harvestedAt: new Date().toISOString(),
|
||||||
|
}),
|
||||||
).toBe("Alice · Europe/Vienna");
|
).toBe("Alice · Europe/Vienna");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("includes uiLocale when non-en", () => {
|
it("includes uiLocale when non-en", () => {
|
||||||
expect(
|
expect(
|
||||||
summarizeProfileSignals({ name: "Bob", timezone: "", locale: "de", uiLocale: "de", languages: [], harvestedAt: new Date().toISOString() }),
|
summarizeProfileSignals({
|
||||||
|
name: "Bob",
|
||||||
|
timezone: "",
|
||||||
|
locale: "de",
|
||||||
|
uiLocale: "de",
|
||||||
|
languages: [],
|
||||||
|
harvestedAt: new Date().toISOString(),
|
||||||
|
}),
|
||||||
).toBe("Bob · · de");
|
).toBe("Bob · · de");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -45,12 +58,23 @@ describe("computeDiscoveryItems", () => {
|
|||||||
it("returns only three visible onboarding steps", () => {
|
it("returns only three visible onboarding steps", () => {
|
||||||
const context: DiscoveryContext = {
|
const context: DiscoveryContext = {
|
||||||
phase: "assembly",
|
phase: "assembly",
|
||||||
me: { needsModel: false, computerHost: null, email: "a@b.com", name: "Test", defaultProvider: null, defaultModel: null },
|
me: {
|
||||||
|
needsModel: false,
|
||||||
|
computerHost: null,
|
||||||
|
email: "a@b.com",
|
||||||
|
name: "Test",
|
||||||
|
defaultProvider: null,
|
||||||
|
defaultModel: null,
|
||||||
|
},
|
||||||
crewReady: true,
|
crewReady: true,
|
||||||
profilePersisted: true,
|
profilePersisted: true,
|
||||||
};
|
};
|
||||||
const items = computeDiscoveryItems(context);
|
const items = computeDiscoveryItems(context);
|
||||||
expect(items.map((i) => i.id)).toEqual(["profile-basics", "connected-tools", "specialist-crew"]);
|
expect(items.map((i) => i.id)).toEqual([
|
||||||
|
"profile-basics",
|
||||||
|
"connected-tools",
|
||||||
|
"specialist-crew",
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("has length 3", () => {
|
it("has length 3", () => {
|
||||||
|
|||||||
@ -1,10 +1,7 @@
|
|||||||
import type { IntegrationSetupState, Me } from "@rakazo/contracts";
|
import type { IntegrationSetupState, Me } from "@rakazo/contracts";
|
||||||
import { collectLocalProfileSignals, type UserProfileSignals } from "./user-profile-harvest";
|
import { collectLocalProfileSignals, type UserProfileSignals } from "./user-profile-harvest";
|
||||||
|
|
||||||
export type DiscoverySourceId =
|
export type DiscoverySourceId = "profile-basics" | "connected-tools" | "specialist-crew";
|
||||||
| "profile-basics"
|
|
||||||
| "connected-tools"
|
|
||||||
| "specialist-crew";
|
|
||||||
|
|
||||||
export type DiscoveryStatus = "pending" | "scanning" | "found" | "skipped";
|
export type DiscoveryStatus = "pending" | "scanning" | "found" | "skipped";
|
||||||
|
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
LOOK_CATEGORIES,
|
|
||||||
buildAvatarValue,
|
buildAvatarValue,
|
||||||
findLookCategory,
|
findLookCategory,
|
||||||
|
LOOK_CATEGORIES,
|
||||||
lookPresetToAvatarValue,
|
lookPresetToAvatarValue,
|
||||||
} from "./onboarding-look-categories";
|
} from "./onboarding-look-categories";
|
||||||
|
|
||||||
|
|||||||
@ -20,7 +20,6 @@ function audioContextCtor(): AudioContextCtor | undefined {
|
|||||||
|
|
||||||
class OoAudioBus {
|
class OoAudioBus {
|
||||||
private ctx: AudioContext | null = null;
|
private ctx: AudioContext | null = null;
|
||||||
private master: GainNode | null = null;
|
|
||||||
private sfxGain: GainNode | null = null;
|
private sfxGain: GainNode | null = null;
|
||||||
private voiceHold = 0;
|
private voiceHold = 0;
|
||||||
|
|
||||||
@ -37,7 +36,6 @@ class OoAudioBus {
|
|||||||
sfxGain.gain.value = SFX_MASTER_GAIN;
|
sfxGain.gain.value = SFX_MASTER_GAIN;
|
||||||
sfxGain.connect(master);
|
sfxGain.connect(master);
|
||||||
this.ctx = ctx;
|
this.ctx = ctx;
|
||||||
this.master = master;
|
|
||||||
this.sfxGain = sfxGain;
|
this.sfxGain = sfxGain;
|
||||||
return ctx;
|
return ctx;
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { collectLocalProfileSignals } from "./user-profile-harvest";
|
|
||||||
import {
|
import {
|
||||||
FOCUS_HELP_VIBE_MAP,
|
FOCUS_HELP_VIBE_MAP,
|
||||||
inferHelpVibeHints,
|
inferHelpVibeHints,
|
||||||
@ -7,6 +6,7 @@ import {
|
|||||||
mergeHelpVibeIntoMemory,
|
mergeHelpVibeIntoMemory,
|
||||||
summarizeHelpVibe,
|
summarizeHelpVibe,
|
||||||
} from "./user-help-vibe";
|
} from "./user-help-vibe";
|
||||||
|
import { collectLocalProfileSignals } from "./user-profile-harvest";
|
||||||
|
|
||||||
describe("user-help-vibe", () => {
|
describe("user-help-vibe", () => {
|
||||||
it("does not invent lifestyle labels from locale alone", () => {
|
it("does not invent lifestyle labels from locale alone", () => {
|
||||||
@ -31,7 +31,7 @@ describe("user-help-vibe", () => {
|
|||||||
email: "d@b.c",
|
email: "d@b.c",
|
||||||
defaultProvider: null,
|
defaultProvider: null,
|
||||||
defaultModel: null,
|
defaultModel: null,
|
||||||
computerHost: null,
|
computerHost: "docker",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
expect(hints.suggestedLanes).toContain("builder");
|
expect(hints.suggestedLanes).toContain("builder");
|
||||||
|
|||||||
@ -77,7 +77,7 @@ describe("user-profile-harvest", () => {
|
|||||||
email: "ingo@example.com",
|
email: "ingo@example.com",
|
||||||
defaultProvider: null,
|
defaultProvider: null,
|
||||||
defaultModel: null,
|
defaultModel: null,
|
||||||
computerHost: null,
|
computerHost: "docker",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
expect(updates).toHaveLength(1);
|
expect(updates).toHaveLength(1);
|
||||||
|
|||||||
@ -1,10 +1,9 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { collectLocalProfileSignals, mergeFullUserProfileMemory } from "./user-profile-harvest";
|
||||||
import {
|
import {
|
||||||
mergeRelationshipContractIntoMemory,
|
mergeRelationshipContractIntoMemory,
|
||||||
RELATIONSHIP_SECTION,
|
RELATIONSHIP_SECTION,
|
||||||
} from "./user-relationship-contract";
|
} from "./user-relationship-contract";
|
||||||
import { mergeFullUserProfileMemory } from "./user-profile-harvest";
|
|
||||||
import { collectLocalProfileSignals } from "./user-profile-harvest";
|
|
||||||
|
|
||||||
describe("user-relationship-contract", () => {
|
describe("user-relationship-contract", () => {
|
||||||
it("adds relationship section once", () => {
|
it("adds relationship section once", () => {
|
||||||
|
|||||||
@ -61,9 +61,7 @@ describe("voiceHandoffTransferLine", () => {
|
|||||||
|
|
||||||
describe("voiceHandoffIdleCaption", () => {
|
describe("voiceHandoffIdleCaption", () => {
|
||||||
it("shows hang-up guidance instead of an open invitation", () => {
|
it("shows hang-up guidance instead of an open invitation", () => {
|
||||||
expect(voiceHandoffIdleCaption("Boss")).toBe(
|
expect(voiceHandoffIdleCaption("Boss")).toBe("Handed to Boss — hang up to continue in text.");
|
||||||
"Handed to Boss — hang up to continue in text.",
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -25,15 +25,15 @@ import {
|
|||||||
voiceThinkingSubtitle,
|
voiceThinkingSubtitle,
|
||||||
WORKING_PROMOTE_DELAY_MS,
|
WORKING_PROMOTE_DELAY_MS,
|
||||||
} from "../lib/call-view-voice";
|
} from "../lib/call-view-voice";
|
||||||
|
import { dictation } from "../lib/dictation";
|
||||||
|
import { ooSound } from "../lib/oo-sound";
|
||||||
|
import { speaker } from "../lib/tts";
|
||||||
import {
|
import {
|
||||||
extractOutgoingHandoff,
|
extractOutgoingHandoff,
|
||||||
isClientWorkshopSpecialty,
|
isClientWorkshopSpecialty,
|
||||||
voiceHandoffIdleCaption,
|
voiceHandoffIdleCaption,
|
||||||
voiceHandoffTransferLine,
|
voiceHandoffTransferLine,
|
||||||
} from "../lib/voice-handoff";
|
} from "../lib/voice-handoff";
|
||||||
import { dictation } from "../lib/dictation";
|
|
||||||
import { ooSound } from "../lib/oo-sound";
|
|
||||||
import { speaker } from "../lib/tts";
|
|
||||||
import {
|
import {
|
||||||
personaIdlePrompt,
|
personaIdlePrompt,
|
||||||
personaPhaseLabel,
|
personaPhaseLabel,
|
||||||
@ -115,10 +115,7 @@ export function CallView({
|
|||||||
}),
|
}),
|
||||||
[botName, botTitle, botDescription],
|
[botName, botTitle, botDescription],
|
||||||
);
|
);
|
||||||
const outgoingHandoff = useMemo(
|
const outgoingHandoff = useMemo(() => extractOutgoingHandoff(snapshot, botId), [snapshot, botId]);
|
||||||
() => extractOutgoingHandoff(snapshot, botId),
|
|
||||||
[snapshot, botId],
|
|
||||||
);
|
|
||||||
const outgoingHandoffRef = useRef(outgoingHandoff);
|
const outgoingHandoffRef = useRef(outgoingHandoff);
|
||||||
outgoingHandoffRef.current = outgoingHandoff;
|
outgoingHandoffRef.current = outgoingHandoff;
|
||||||
const handoffGated = Boolean(outgoingHandoff);
|
const handoffGated = Boolean(outgoingHandoff);
|
||||||
@ -305,8 +302,8 @@ export function CallView({
|
|||||||
const handoff = outgoingHandoffRef.current;
|
const handoff = outgoingHandoffRef.current;
|
||||||
const line = handoff
|
const line = handoff
|
||||||
? voiceHandoffIdleCaption(handoff.toBotName)
|
? voiceHandoffIdleCaption(handoff.toBotName)
|
||||||
: thinkingLines[thinkingIndexRef.current] ??
|
: (thinkingLines[thinkingIndexRef.current] ??
|
||||||
t(personaPhaseLabel(personaRef.current.archetype, "thinking"));
|
t(personaPhaseLabel(personaRef.current.archetype, "thinking")));
|
||||||
setCaption(line);
|
setCaption(line);
|
||||||
} else if (state.status === "speaking") {
|
} else if (state.status === "speaking") {
|
||||||
setCallPhase("speaking");
|
setCallPhase("speaking");
|
||||||
@ -338,7 +335,6 @@ export function CallView({
|
|||||||
};
|
};
|
||||||
}, [botId]);
|
}, [botId]);
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
outgoingHandoffRef.current = outgoingHandoff;
|
outgoingHandoffRef.current = outgoingHandoff;
|
||||||
if (!outgoingHandoff) return;
|
if (!outgoingHandoff) return;
|
||||||
@ -413,7 +409,9 @@ export function CallView({
|
|||||||
openingInvitePendingRef.current &&
|
openingInvitePendingRef.current &&
|
||||||
shouldSpeakOpeningInvitation({
|
shouldSpeakOpeningInvitation({
|
||||||
hasUnspokenBotMessage: Boolean(
|
hasUnspokenBotMessage: Boolean(
|
||||||
lastBot && lastBot.id !== spokenMessage.current && speechFromBlocks(lastBot.blocks).trim(),
|
lastBot &&
|
||||||
|
lastBot.id !== spokenMessage.current &&
|
||||||
|
speechFromBlocks(lastBot.blocks).trim(),
|
||||||
),
|
),
|
||||||
runActive: isActiveRunStatus(snapshot?.run?.status),
|
runActive: isActiveRunStatus(snapshot?.run?.status),
|
||||||
userTurnPending: userTurnPendingRef.current,
|
userTurnPending: userTurnPendingRef.current,
|
||||||
|
|||||||
@ -147,7 +147,7 @@ export function OnboardingPage() {
|
|||||||
const fieldId = useId();
|
const fieldId = useId();
|
||||||
const [step, setStep] = useState<
|
const [step, setStep] = useState<
|
||||||
"loading" | "emoji" | "preferences" | "language" | "voice" | "integrations" | "bot"
|
"loading" | "emoji" | "preferences" | "language" | "voice" | "integrations" | "bot"
|
||||||
>("loading");
|
>("emoji");
|
||||||
const [selectedVoiceId, setSelectedVoiceId] = useState("");
|
const [selectedVoiceId, setSelectedVoiceId] = useState("");
|
||||||
const [voiceSkipped, setVoiceSkipped] = useState(false);
|
const [voiceSkipped, setVoiceSkipped] = useState(false);
|
||||||
const [integrationSetup, setIntegrationSetup] = useState<IntegrationSetupState | null>(null);
|
const [integrationSetup, setIntegrationSetup] = useState<IntegrationSetupState | null>(null);
|
||||||
@ -157,7 +157,7 @@ export function OnboardingPage() {
|
|||||||
const [bootstrapMe, setBootstrapMe] = useState<Awaited<ReturnType<typeof rpc.me>> | null>(null);
|
const [bootstrapMe, setBootstrapMe] = useState<Awaited<ReturnType<typeof rpc.me>> | null>(null);
|
||||||
const [profilePersisted, setProfilePersisted] = useState(false);
|
const [profilePersisted, setProfilePersisted] = useState(false);
|
||||||
const [crewReady, setCrewReady] = useState(false);
|
const [crewReady, setCrewReady] = useState(false);
|
||||||
const [helpVibe, setHelpVibe] = useState<UserHelpVibeHints | null>(null);
|
const [_helpVibe, setHelpVibe] = useState<UserHelpVibeHints | null>(null);
|
||||||
|
|
||||||
const [emojiConfig, setEmojiConfig] = useState<OnboardingEmojiConfig>(defaultEmojiConfig);
|
const [emojiConfig, setEmojiConfig] = useState<OnboardingEmojiConfig>(defaultEmojiConfig);
|
||||||
const [prefs, setPrefs] = useState<UserPreferences>(defaultPreferences());
|
const [prefs, setPrefs] = useState<UserPreferences>(defaultPreferences());
|
||||||
@ -170,15 +170,41 @@ export function OnboardingPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void Promise.all([rpc.me(), rpc.integrationSetup.get().catch(() => null)])
|
let cancelled = false;
|
||||||
.then(([me, integrations]) => {
|
const bootstrap = Promise.race([
|
||||||
|
Promise.all([rpc.me(), rpc.integrationSetup.get().catch(() => null)]),
|
||||||
|
new Promise<never>((_, reject) => {
|
||||||
|
setTimeout(() => reject(new Error("onboarding bootstrap timeout")), 15_000);
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
void bootstrap
|
||||||
|
.then(async ([me, integrations]) => {
|
||||||
|
if (cancelled) return;
|
||||||
setBootstrapMe(me);
|
setBootstrapMe(me);
|
||||||
setHelpVibe(inferHelpVibeHints(collectLocalProfileSignals(me)));
|
setHelpVibe(inferHelpVibeHints(collectLocalProfileSignals(me)));
|
||||||
setIntegrationSetup(integrations);
|
setIntegrationSetup(integrations);
|
||||||
setStep(me.needsModel ? "emoji" : integrations?.needsSetup ? "integrations" : "emoji");
|
setStep(me.needsModel ? "emoji" : integrations?.needsSetup ? "integrations" : "emoji");
|
||||||
|
// A space that already has a bot does not need the designer again.
|
||||||
|
// Empty spaces (a new project) still walk onboarding. Resolve this after
|
||||||
|
// the first step renders so a slow spaces list cannot leave E2E on loading.
|
||||||
|
try {
|
||||||
|
const navigation = await rpc.spaces.list();
|
||||||
|
if (cancelled) return;
|
||||||
|
const existing = navigation.current.bots[0];
|
||||||
|
if (existing && !me.needsModel) {
|
||||||
|
navigate(`/app/${existing.id}`, { replace: true });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Best effort; fresh signups stay on onboarding.
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch(() => setStep("emoji"));
|
.catch(() => {
|
||||||
}, []);
|
if (!cancelled) setStep("emoji");
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
function toggleHobby(key: string) {
|
function toggleHobby(key: string) {
|
||||||
const hobbies = prefs.hobbies.includes(key)
|
const hobbies = prefs.hobbies.includes(key)
|
||||||
@ -190,10 +216,7 @@ export function OnboardingPage() {
|
|||||||
const discoveryPhase: OnboardingDiscoveryPhase =
|
const discoveryPhase: OnboardingDiscoveryPhase =
|
||||||
step === "loading"
|
step === "loading"
|
||||||
? "loading"
|
? "loading"
|
||||||
: step === "emoji" ||
|
: step === "emoji" || step === "preferences" || step === "language" || step === "voice"
|
||||||
step === "preferences" ||
|
|
||||||
step === "language" ||
|
|
||||||
step === "voice"
|
|
||||||
? "emoji"
|
? "emoji"
|
||||||
: step === "integrations"
|
: step === "integrations"
|
||||||
? "integrations"
|
? "integrations"
|
||||||
@ -244,7 +267,9 @@ export function OnboardingPage() {
|
|||||||
await rpc.voice
|
await rpc.voice
|
||||||
.connect({ provider: "kokoro", apiKey: KOKORO_LOCAL_TOKEN, voiceId: selectedVoiceId })
|
.connect({ provider: "kokoro", apiKey: KOKORO_LOCAL_TOKEN, voiceId: selectedVoiceId })
|
||||||
.catch(() => null);
|
.catch(() => null);
|
||||||
await rpc.voice.setVoice({ voiceId: selectedVoiceId, provider: "kokoro" }).catch(() => null);
|
await rpc.voice
|
||||||
|
.setVoice({ voiceId: selectedVoiceId, provider: "kokoro" })
|
||||||
|
.catch(() => null);
|
||||||
await rpc.bots
|
await rpc.bots
|
||||||
.update({ botId: bot.id, voiceId: selectedVoiceId, autoSpeak: true })
|
.update({ botId: bot.id, voiceId: selectedVoiceId, autoSpeak: true })
|
||||||
.catch(() => null);
|
.catch(() => null);
|
||||||
@ -288,7 +313,11 @@ export function OnboardingPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-full bg-background px-6 py-12" data-rakazo-surface="onboarding">
|
<div
|
||||||
|
className="min-h-full bg-background px-6 py-12"
|
||||||
|
data-rakazo-surface="onboarding"
|
||||||
|
data-onboarding-step={step}
|
||||||
|
>
|
||||||
<OnboardingDiscoveryShell
|
<OnboardingDiscoveryShell
|
||||||
items={discoveryItems}
|
items={discoveryItems}
|
||||||
compact={
|
compact={
|
||||||
@ -335,10 +364,14 @@ export function OnboardingPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
<label className="block text-sm font-medium text-foreground">
|
<label
|
||||||
|
htmlFor={`${fieldId}-timezone`}
|
||||||
|
className="block text-sm font-medium text-foreground"
|
||||||
|
>
|
||||||
<Trans>Timezone</Trans>
|
<Trans>Timezone</Trans>
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
|
id={`${fieldId}-timezone`}
|
||||||
value={prefs.timezone}
|
value={prefs.timezone}
|
||||||
onChange={(e) => setPrefs({ ...prefs, timezone: e.target.value })}
|
onChange={(e) => setPrefs({ ...prefs, timezone: e.target.value })}
|
||||||
placeholder={t`Auto-detected or enter manually`}
|
placeholder={t`Auto-detected or enter manually`}
|
||||||
@ -346,10 +379,10 @@ export function OnboardingPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6">
|
<fieldset className="mt-6 border-0 p-0">
|
||||||
<label className="block text-sm font-medium text-foreground">
|
<legend className="block text-sm font-medium text-foreground">
|
||||||
<Trans>Hobbies & interests</Trans>
|
<Trans>Hobbies & interests</Trans>
|
||||||
</label>
|
</legend>
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
<Trans>Select all that apply</Trans>
|
<Trans>Select all that apply</Trans>
|
||||||
</p>
|
</p>
|
||||||
@ -370,12 +403,12 @@ export function OnboardingPage() {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</fieldset>
|
||||||
|
|
||||||
<div className="mt-6">
|
<fieldset className="mt-6 border-0 p-0">
|
||||||
<label className="block text-sm font-medium text-foreground">
|
<legend className="block text-sm font-medium text-foreground">
|
||||||
<Trans>Work style</Trans>
|
<Trans>Work style</Trans>
|
||||||
</label>
|
</legend>
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
<Trans>How do you like to work?</Trans>
|
<Trans>How do you like to work?</Trans>
|
||||||
</p>
|
</p>
|
||||||
@ -396,12 +429,12 @@ export function OnboardingPage() {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</fieldset>
|
||||||
|
|
||||||
<div className="mt-6">
|
<fieldset className="mt-6 border-0 p-0">
|
||||||
<label className="block text-sm font-medium text-foreground">
|
<legend className="block text-sm font-medium text-foreground">
|
||||||
<Trans>Communication style</Trans>
|
<Trans>Communication style</Trans>
|
||||||
</label>
|
</legend>
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
<Trans>How should your Oo talk to you?</Trans>
|
<Trans>How should your Oo talk to you?</Trans>
|
||||||
</p>
|
</p>
|
||||||
@ -422,7 +455,7 @@ export function OnboardingPage() {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</fieldset>
|
||||||
|
|
||||||
<div className="mt-8 flex items-center justify-between">
|
<div className="mt-8 flex items-center justify-between">
|
||||||
<Button variant="ghost" size="sm" onClick={() => setStep("emoji")}>
|
<Button variant="ghost" size="sm" onClick={() => setStep("emoji")}>
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { useLingui } from "@lingui/react/macro";
|
import { useLingui } from "@lingui/react/macro";
|
||||||
|
import { Button } from "@rakazo/ui-web";
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
@ -9,12 +10,11 @@ import {
|
|||||||
XCircle,
|
XCircle,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { Button } from "@rakazo/ui-web";
|
|
||||||
|
|
||||||
/** Displays privacy posture, data-local indicators, and account actions. */
|
/** Displays privacy posture, data-local indicators, and account actions. */
|
||||||
export function PrivacySettingsPanel({
|
export function PrivacySettingsPanel({
|
||||||
email,
|
email,
|
||||||
isDeploymentOwner,
|
isDeploymentOwner: _isDeploymentOwner,
|
||||||
sandboxProvider,
|
sandboxProvider,
|
||||||
onAccountDeleted,
|
onAccountDeleted,
|
||||||
}: {
|
}: {
|
||||||
@ -62,7 +62,7 @@ export function PrivacySettingsPanel({
|
|||||||
a.click();
|
a.click();
|
||||||
a.remove();
|
a.remove();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
} catch (err) {
|
} catch {
|
||||||
// Export endpoint may not exist yet — show info
|
// Export endpoint may not exist yet — show info
|
||||||
alert(
|
alert(
|
||||||
t`Data export is not available yet. Your data is stored locally in your browser and on your server. Clear browser storage to remove it.`,
|
t`Data export is not available yet. Your data is stored locally in your browser and on your server. Clear browser storage to remove it.`,
|
||||||
@ -80,7 +80,13 @@ export function PrivacySettingsPanel({
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-2.5 text-sm text-muted-foreground">
|
<div className="space-y-2.5 text-sm text-muted-foreground">
|
||||||
<PrivacyRow
|
<PrivacyRow
|
||||||
icon={isLocal ? <CheckCircle2 className="size-4 text-emerald-500" /> : <XCircle className="size-4 text-amber-500" />}
|
icon={
|
||||||
|
isLocal ? (
|
||||||
|
<CheckCircle2 className="size-4 text-emerald-500" />
|
||||||
|
) : (
|
||||||
|
<XCircle className="size-4 text-amber-500" />
|
||||||
|
)
|
||||||
|
}
|
||||||
label={t`Data storage`}
|
label={t`Data storage`}
|
||||||
detail={
|
detail={
|
||||||
isLocal
|
isLocal
|
||||||
@ -145,7 +151,7 @@ export function PrivacySettingsPanel({
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-muted-foreground">{t`Sandbox`}</span>
|
<span className="text-muted-foreground">{t`Sandbox`}</span>
|
||||||
<span className="text-foreground">
|
<span className="text-foreground">
|
||||||
{isLocal ? t`Local Docker` : sandboxProvider ?? t`Not configured`}
|
{isLocal ? t`Local Docker` : (sandboxProvider ?? t`Not configured`)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{email ? (
|
{email ? (
|
||||||
@ -174,17 +180,10 @@ export function PrivacySettingsPanel({
|
|||||||
<p className="mb-3 text-xs text-destructive/80">
|
<p className="mb-3 text-xs text-destructive/80">
|
||||||
{t`Permanently delete your account, all conversations, and associated data. This action cannot be undone.`}
|
{t`Permanently delete your account, all conversations, and associated data. This action cannot be undone.`}
|
||||||
</p>
|
</p>
|
||||||
<Button
|
<Button variant="destructive" size="sm" onClick={handleDeleteAccount} disabled={deleting}>
|
||||||
variant="destructive"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleDeleteAccount}
|
|
||||||
disabled={deleting}
|
|
||||||
>
|
|
||||||
{deleting ? t`Deleting...` : t`Delete my account`}
|
{deleting ? t`Deleting...` : t`Delete my account`}
|
||||||
</Button>
|
</Button>
|
||||||
{deleteError ? (
|
{deleteError ? <p className="mt-2 text-xs text-destructive">{deleteError}</p> : null}
|
||||||
<p className="mt-2 text-xs text-destructive">{deleteError}</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -180,7 +180,10 @@ export function ProjectSessionTimeline({
|
|||||||
<div className="mx-auto flex w-full max-w-2xl flex-col gap-8">
|
<div className="mx-auto flex w-full max-w-2xl flex-col gap-8">
|
||||||
<section aria-labelledby="project-goals-heading">
|
<section aria-labelledby="project-goals-heading">
|
||||||
<div className="mb-3 flex items-center justify-between gap-3">
|
<div className="mb-3 flex items-center justify-between gap-3">
|
||||||
<h3 id="project-goals-heading" className="text-ui-body-sm font-semibold text-foreground">
|
<h3
|
||||||
|
id="project-goals-heading"
|
||||||
|
className="text-ui-body-sm font-semibold text-foreground"
|
||||||
|
>
|
||||||
<Trans>Goals</Trans>
|
<Trans>Goals</Trans>
|
||||||
</h3>
|
</h3>
|
||||||
<span className="rounded-full border border-primary/25 bg-primary/10 px-2 py-0.5 text-ui-label font-medium text-primary">
|
<span className="rounded-full border border-primary/25 bg-primary/10 px-2 py-0.5 text-ui-label font-medium text-primary">
|
||||||
@ -233,7 +236,10 @@ export function ProjectSessionTimeline({
|
|||||||
<span
|
<span
|
||||||
className={`flex items-center gap-1 text-ui-label font-medium ${tone}`}
|
className={`flex items-center gap-1 text-ui-label font-medium ${tone}`}
|
||||||
>
|
>
|
||||||
<span className="h-1.5 w-1.5 rounded-full bg-current" aria-hidden="true" />
|
<span
|
||||||
|
className="h-1.5 w-1.5 rounded-full bg-current"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -287,7 +293,10 @@ export function ProjectSessionTimeline({
|
|||||||
|
|
||||||
<section aria-labelledby="project-chats-heading">
|
<section aria-labelledby="project-chats-heading">
|
||||||
<div className="mb-3 flex items-center justify-between gap-3">
|
<div className="mb-3 flex items-center justify-between gap-3">
|
||||||
<h3 id="project-chats-heading" className="text-ui-body-sm font-semibold text-foreground">
|
<h3
|
||||||
|
id="project-chats-heading"
|
||||||
|
className="text-ui-body-sm font-semibold text-foreground"
|
||||||
|
>
|
||||||
<Trans>Unterhaltungen</Trans>
|
<Trans>Unterhaltungen</Trans>
|
||||||
</h3>
|
</h3>
|
||||||
<span className="text-ui-label text-muted-foreground">{groupName}</span>
|
<span className="text-ui-label text-muted-foreground">{groupName}</span>
|
||||||
@ -345,7 +354,10 @@ export function ProjectSessionTimeline({
|
|||||||
<span
|
<span
|
||||||
className={`flex items-center gap-1 text-ui-label font-medium ${tone}`}
|
className={`flex items-center gap-1 text-ui-label font-medium ${tone}`}
|
||||||
>
|
>
|
||||||
<span className="h-1.5 w-1.5 rounded-full bg-current" aria-hidden="true" />
|
<span
|
||||||
|
className="h-1.5 w-1.5 rounded-full bg-current"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -516,7 +516,11 @@ function MessageTriggerCard({ provider, onRemove }: { provider: string; onRemove
|
|||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-border p-3">
|
<div className="rounded-xl border border-border p-3">
|
||||||
<div className="flex items-center gap-2.5 px-0.5">
|
<div className="flex items-center gap-2.5 px-0.5">
|
||||||
<MessageSquare strokeWidth={1.6} className="text-muted-foreground size-icon-md" aria-hidden />
|
<MessageSquare
|
||||||
|
strokeWidth={1.6}
|
||||||
|
className="text-muted-foreground size-icon-md"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
<span className="flex-1 text-[14.5px] text-foreground">{label}</span>
|
<span className="flex-1 text-[14.5px] text-foreground">{label}</span>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@ -1,7 +1,18 @@
|
|||||||
import { useLingui } from "@lingui/react/macro";
|
import { useLingui } from "@lingui/react/macro";
|
||||||
import type { AvatarStyle, SpaceMemoryConfig } from "@rakazo/contracts";
|
import type { AvatarStyle, SpaceMemoryConfig } from "@rakazo/contracts";
|
||||||
import { Button, Dialog, DialogClose, DialogContent, DialogTitle } from "@rakazo/ui-web";
|
import { Button, Dialog, DialogClose, DialogContent, DialogTitle } from "@rakazo/ui-web";
|
||||||
import { Brain, CloudDownload, Cpu, Gauge, LayoutGrid, Monitor, Settings, ShieldCheck, Volume2, XIcon } from "lucide-react";
|
import {
|
||||||
|
Brain,
|
||||||
|
CloudDownload,
|
||||||
|
Cpu,
|
||||||
|
Gauge,
|
||||||
|
LayoutGrid,
|
||||||
|
Monitor,
|
||||||
|
Settings,
|
||||||
|
ShieldCheck,
|
||||||
|
Volume2,
|
||||||
|
XIcon,
|
||||||
|
} from "lucide-react";
|
||||||
import { type ComponentType, useEffect, useRef, useState } from "react";
|
import { type ComponentType, useEffect, useRef, useState } from "react";
|
||||||
import { computersAreUnavailable } from "../components/ComputersUnavailableHint";
|
import { computersAreUnavailable } from "../components/ComputersUnavailableHint";
|
||||||
import type { TradingBotUiState } from "../lib/trading-bot-status";
|
import type { TradingBotUiState } from "../lib/trading-bot-status";
|
||||||
|
|||||||
@ -1,3 +1,18 @@
|
|||||||
|
import {
|
||||||
|
closestCenter,
|
||||||
|
DndContext,
|
||||||
|
DragOverlay,
|
||||||
|
MouseSensor,
|
||||||
|
TouchSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
} from "@dnd-kit/core";
|
||||||
|
import {
|
||||||
|
SortableContext,
|
||||||
|
sortableKeyboardCoordinates,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
} from "@dnd-kit/sortable";
|
||||||
|
import { CSS as DndCSS } from "@dnd-kit/utilities";
|
||||||
import { i18n } from "@lingui/core";
|
import { i18n } from "@lingui/core";
|
||||||
import { t } from "@lingui/core/macro";
|
import { t } from "@lingui/core/macro";
|
||||||
import { Trans, useLingui } from "@lingui/react/macro";
|
import { Trans, useLingui } from "@lingui/react/macro";
|
||||||
@ -32,7 +47,6 @@ import {
|
|||||||
type MessageReaction,
|
type MessageReaction,
|
||||||
normalizeCreateBotProfile,
|
normalizeCreateBotProfile,
|
||||||
} from "@rakazo/contracts";
|
} from "@rakazo/contracts";
|
||||||
import { readProjectSidebarOrder, writeProjectSidebarOrder } from "../lib/project-sidebar-order";
|
|
||||||
import {
|
import {
|
||||||
attachmentsForThread,
|
attachmentsForThread,
|
||||||
buildComposerMentionOptions,
|
buildComposerMentionOptions,
|
||||||
@ -139,10 +153,6 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { DndContext, DragOverlay, closestCenter, useSensor, useSensors } from "@dnd-kit/core";
|
|
||||||
import { MouseSensor, TouchSensor } from "@dnd-kit/core";
|
|
||||||
import { SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
|
||||||
import { CSS as DndCSS } from "@dnd-kit/utilities";
|
|
||||||
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||||
import { ArtifactFileCard } from "../components/ArtifactFileCard";
|
import { ArtifactFileCard } from "../components/ArtifactFileCard";
|
||||||
import { AskCard } from "../components/AskCard";
|
import { AskCard } from "../components/AskCard";
|
||||||
@ -156,8 +166,8 @@ import {
|
|||||||
computersAreUnavailable,
|
computersAreUnavailable,
|
||||||
} from "../components/ComputersUnavailableHint";
|
} from "../components/ComputersUnavailableHint";
|
||||||
import { ComputerUpdateProgress } from "../components/ComputerUpdateProgress";
|
import { ComputerUpdateProgress } from "../components/ComputerUpdateProgress";
|
||||||
import { MessageHoverMetadata } from "../components/MessageHoverMetadata";
|
|
||||||
import { DataTrustPanel } from "../components/DataTrustPanel";
|
import { DataTrustPanel } from "../components/DataTrustPanel";
|
||||||
|
import { MessageHoverMetadata } from "../components/MessageHoverMetadata";
|
||||||
import { OoGroupStage, OoStage } from "../components/stage/OoStage";
|
import { OoGroupStage, OoStage } from "../components/stage/OoStage";
|
||||||
import { TradingModeIndicator } from "../components/TradingModeIndicator";
|
import { TradingModeIndicator } from "../components/TradingModeIndicator";
|
||||||
import { SkillDraftCard } from "../components/teach/SkillDraftCard";
|
import { SkillDraftCard } from "../components/teach/SkillDraftCard";
|
||||||
@ -180,6 +190,7 @@ import {
|
|||||||
requestBrowserNotificationPermission,
|
requestBrowserNotificationPermission,
|
||||||
shouldNotifyBrowser,
|
shouldNotifyBrowser,
|
||||||
} from "../lib/browser-notifications";
|
} from "../lib/browser-notifications";
|
||||||
|
import type { BrowserPreviewCursor } from "../lib/browser-preview-context";
|
||||||
import { browserPreviewActivity } from "../lib/browser-preview-context";
|
import { browserPreviewActivity } from "../lib/browser-preview-context";
|
||||||
import { loadComputerScreen } from "../lib/computer-screen";
|
import { loadComputerScreen } from "../lib/computer-screen";
|
||||||
import { desktopBridge } from "../lib/desktop";
|
import { desktopBridge } from "../lib/desktop";
|
||||||
@ -203,6 +214,7 @@ import {
|
|||||||
revokePendingAttachmentPreviews,
|
revokePendingAttachmentPreviews,
|
||||||
} from "../lib/pending-attachments";
|
} from "../lib/pending-attachments";
|
||||||
import { markAfterPaint, markOnce } from "../lib/performance";
|
import { markAfterPaint, markOnce } from "../lib/performance";
|
||||||
|
import { readProjectSidebarOrder, writeProjectSidebarOrder } from "../lib/project-sidebar-order";
|
||||||
import { quoteDraftForSelection } from "../lib/quote-selection";
|
import { quoteDraftForSelection } from "../lib/quote-selection";
|
||||||
import { clearSpaceSelection, rpc, selectedSpaceId, selectSpace } from "../lib/rpc";
|
import { clearSpaceSelection, rpc, selectedSpaceId, selectSpace } from "../lib/rpc";
|
||||||
import { readSeenRunErrorIds, rememberSeenRunErrorId } from "../lib/run-error-storage";
|
import { readSeenRunErrorIds, rememberSeenRunErrorId } from "../lib/run-error-storage";
|
||||||
@ -355,6 +367,31 @@ function readCollapsedSidebarSections(userId: string | null | undefined): Set<st
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function copyBrowserPreviewActivities(
|
||||||
|
activities: ReadonlyMap<string, BrowserPreviewCursor>,
|
||||||
|
): Map<string, BrowserPreviewCursor> {
|
||||||
|
return new Map(activities);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameBrowserPreviewActivities(
|
||||||
|
current: ReadonlyMap<string, BrowserPreviewCursor>,
|
||||||
|
next: ReadonlyMap<string, BrowserPreviewCursor>,
|
||||||
|
): boolean {
|
||||||
|
if (current.size !== next.size) return false;
|
||||||
|
for (const [id, cursor] of next) {
|
||||||
|
const existing = current.get(id);
|
||||||
|
if (
|
||||||
|
!existing ||
|
||||||
|
existing.x !== cursor.x ||
|
||||||
|
existing.y !== cursor.y ||
|
||||||
|
existing.label !== cursor.label
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
export type NavPillar = "heute" | "team" | "projekte";
|
export type NavPillar = "heute" | "team" | "projekte";
|
||||||
|
|
||||||
function stripSpaceTitlePrefix(title: string | null, spaceName: string): string | null {
|
function stripSpaceTitlePrefix(title: string | null, spaceName: string): string | null {
|
||||||
@ -365,7 +402,7 @@ function stripSpaceTitlePrefix(title: string | null, spaceName: string): string
|
|||||||
return title;
|
return title;
|
||||||
}
|
}
|
||||||
|
|
||||||
import { SortableGroup } from "../components/SortableGroup";
|
import { ProjectDragGrip, SortableGroup } from "../components/SortableGroup";
|
||||||
|
|
||||||
export function ShellPage() {
|
export function ShellPage() {
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
@ -435,19 +472,18 @@ export function ShellPage() {
|
|||||||
const [browserUrl, setBrowserUrl] = useState("http://localhost:5173");
|
const [browserUrl, setBrowserUrl] = useState("http://localhost:5173");
|
||||||
const [devServerRunning, setDevServerRunning] = useState(false);
|
const [devServerRunning, setDevServerRunning] = useState(false);
|
||||||
const [browserLoading, setBrowserLoading] = useState(false);
|
const [browserLoading, setBrowserLoading] = useState(false);
|
||||||
const [agentActivities, setAgentActivities] = useState<Map<string, { x: number; y: number; label: string }>>(new Map());
|
const [agentActivities, setAgentActivities] = useState<
|
||||||
|
Map<string, { x: number; y: number; label: string }>
|
||||||
|
>(new Map());
|
||||||
|
|
||||||
const [showCreateBotModal, setShowCreateBotModal] = useState(false);
|
const [showCreateBotModal, setShowCreateBotModal] = useState(false);
|
||||||
|
|
||||||
// Persisted project (space) sort order
|
// Persisted project (space) sort order
|
||||||
const [projectOrder, setProjectOrder] = useState<string[]>(() => readProjectSidebarOrder());
|
const [projectOrder, setProjectOrder] = useState<string[]>(() => readProjectSidebarOrder());
|
||||||
const handleReorderProjects = useCallback(
|
const handleReorderProjects = useCallback((orderedIds: string[]) => {
|
||||||
(orderedIds: string[]) => {
|
|
||||||
setProjectOrder(orderedIds);
|
setProjectOrder(orderedIds);
|
||||||
writeProjectSidebarOrder(orderedIds);
|
writeProjectSidebarOrder(orderedIds);
|
||||||
},
|
}, []);
|
||||||
[],
|
|
||||||
);
|
|
||||||
const [draggedProjectId, setDraggedProjectId] = useState<string | null>(null);
|
const [draggedProjectId, setDraggedProjectId] = useState<string | null>(null);
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
useSensor(MouseSensor, { activationConstraint: { distance: 8 } }),
|
useSensor(MouseSensor, { activationConstraint: { distance: 8 } }),
|
||||||
@ -460,25 +496,50 @@ export function ShellPage() {
|
|||||||
if (!bp) return;
|
if (!bp) return;
|
||||||
const unsubNav = bp.onNavigate((url) => setBrowserUrl(url));
|
const unsubNav = bp.onNavigate((url) => setBrowserUrl(url));
|
||||||
const unsubLoading = bp.onLoadingProgress((p) => setBrowserLoading(p < 1));
|
const unsubLoading = bp.onLoadingProgress((p) => setBrowserLoading(p < 1));
|
||||||
bp.getState().then((s) => { setBrowserUrl(s.url); setDevServerRunning(s.devServerRunning); }).catch(() => {});
|
bp.getState()
|
||||||
return () => { unsubNav(); unsubLoading(); };
|
.then((s) => {
|
||||||
|
setBrowserUrl(s.url);
|
||||||
|
setDevServerRunning(s.devServerRunning);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
return () => {
|
||||||
|
unsubNav();
|
||||||
|
unsubLoading();
|
||||||
|
};
|
||||||
}, [bridge]);
|
}, [bridge]);
|
||||||
|
|
||||||
const startDevServer = useCallback(async () => {
|
const startDevServer = useCallback(async () => {
|
||||||
try { await bridge?.browserPreview?.devServer.start(); setDevServerRunning(true); } catch { /* */ }
|
try {
|
||||||
|
await bridge?.browserPreview?.devServer.start();
|
||||||
|
setDevServerRunning(true);
|
||||||
|
} catch {
|
||||||
|
/* */
|
||||||
|
}
|
||||||
}, [bridge]);
|
}, [bridge]);
|
||||||
const stopDevServer = useCallback(async () => {
|
const stopDevServer = useCallback(async () => {
|
||||||
try { await bridge?.browserPreview?.devServer.stop(); setDevServerRunning(false); } catch { /* */ }
|
try {
|
||||||
|
await bridge?.browserPreview?.devServer.stop();
|
||||||
|
setDevServerRunning(false);
|
||||||
|
} catch {
|
||||||
|
/* */
|
||||||
|
}
|
||||||
|
}, [bridge]);
|
||||||
|
const navigateBrowser = useCallback(
|
||||||
|
(url: string) => {
|
||||||
|
bridge?.browserPreview?.navigate(url).catch(() => {});
|
||||||
|
},
|
||||||
|
[bridge],
|
||||||
|
);
|
||||||
|
const reloadBrowser = useCallback(() => {
|
||||||
|
bridge?.browserPreview?.reload().catch(() => {});
|
||||||
}, [bridge]);
|
}, [bridge]);
|
||||||
const navigateBrowser = useCallback((url: string) => { bridge?.browserPreview?.navigate(url).catch(() => {}); }, [bridge]);
|
|
||||||
const reloadBrowser = useCallback(() => { bridge?.browserPreview?.reload().catch(() => {}); }, [bridge]);
|
|
||||||
|
|
||||||
// Agent activity → browser preview cursors
|
// Agent activity → browser preview cursors
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsub = browserPreviewActivity.subscribe((acts) => {
|
const unsub = browserPreviewActivity.subscribe((acts) => {
|
||||||
const snap = new Map<string, { x: number; y: number; label: string }>();
|
setAgentActivities((current) =>
|
||||||
for (const [id, a] of acts) snap.set(id, { x: a.x, y: a.y, label: a.label });
|
sameBrowserPreviewActivities(current, acts) ? current : copyBrowserPreviewActivities(acts),
|
||||||
setAgentActivities(snap);
|
);
|
||||||
});
|
});
|
||||||
return unsub;
|
return unsub;
|
||||||
}, []);
|
}, []);
|
||||||
@ -492,6 +553,7 @@ export function ShellPage() {
|
|||||||
const [replyTarget, setReplyTarget] = useState<ThreadMessage | null>(null);
|
const [replyTarget, setReplyTarget] = useState<ThreadMessage | null>(null);
|
||||||
const [replyQuote, setReplyQuote] = useState<string | null>(null);
|
const [replyQuote, setReplyQuote] = useState<string | null>(null);
|
||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
|
const sendsInFlight = useRef(0);
|
||||||
const [sendError, setSendError] = useState<string | null>(null);
|
const [sendError, setSendError] = useState<string | null>(null);
|
||||||
const [attachmentNotice, setAttachmentNotice] = useState<string | null>(null);
|
const [attachmentNotice, setAttachmentNotice] = useState<string | null>(null);
|
||||||
|
|
||||||
@ -499,6 +561,7 @@ export function ShellPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!sending) return;
|
if (!sending) return;
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
|
sendsInFlight.current = 0;
|
||||||
setSending(false);
|
setSending(false);
|
||||||
}, 10_000);
|
}, 10_000);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
@ -705,7 +768,19 @@ export function ShellPage() {
|
|||||||
const viewport = window.visualViewport;
|
const viewport = window.visualViewport;
|
||||||
if (!viewport) return;
|
if (!viewport) return;
|
||||||
const sync = () => {
|
const sync = () => {
|
||||||
setComputerViewport({ height: viewport.height, offsetTop: viewport.offsetTop });
|
const height = Math.round(viewport.height);
|
||||||
|
const offsetTop = Math.round(viewport.offsetTop);
|
||||||
|
// Desktop scrollbars and sub-pixel viewport events must not rewrite the
|
||||||
|
// overlay height: that feedback loop moves the maintenance menu forever.
|
||||||
|
const keyboardInset = Math.round(window.innerHeight) - height - offsetTop;
|
||||||
|
if (keyboardInset < 80) {
|
||||||
|
setComputerViewport((current) => (current === null ? current : null));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setComputerViewport((current) => {
|
||||||
|
if (current && current.height === height && current.offsetTop === offsetTop) return current;
|
||||||
|
return { height, offsetTop };
|
||||||
|
});
|
||||||
};
|
};
|
||||||
sync();
|
sync();
|
||||||
viewport.addEventListener("resize", sync);
|
viewport.addEventListener("resize", sync);
|
||||||
@ -1158,6 +1233,9 @@ export function ShellPage() {
|
|||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
const groupList = bootstrap.groups;
|
const groupList = bootstrap.groups;
|
||||||
setBootstrapMe(bootstrap.me);
|
setBootstrapMe(bootstrap.me);
|
||||||
|
if (!selectedSpaceId()) {
|
||||||
|
selectSpace(bootstrap.me.spaceId);
|
||||||
|
}
|
||||||
// Skip list/route writes only if a later refreshBots() successfully
|
// Skip list/route writes only if a later refreshBots() successfully
|
||||||
// committed (failed refreshes bump epoch but not botsRefreshApplied).
|
// committed (failed refreshes bump epoch but not botsRefreshApplied).
|
||||||
const applyBotLists = appliedAtStart === botsRefreshApplied.current;
|
const applyBotLists = appliedAtStart === botsRefreshApplied.current;
|
||||||
@ -1585,7 +1663,7 @@ export function ShellPage() {
|
|||||||
[groups],
|
[groups],
|
||||||
);
|
);
|
||||||
|
|
||||||
const homeSpaceId = bootstrapMe?.spaceId;
|
const homeSpaceId = spaces.find((space) => space.isDefault)?.id ?? bootstrapMe?.spaceId;
|
||||||
|
|
||||||
const homeSpaceBots = useMemo(() => {
|
const homeSpaceBots = useMemo(() => {
|
||||||
if (!homeSpaceId) return bots;
|
if (!homeSpaceId) return bots;
|
||||||
@ -1609,9 +1687,22 @@ export function ShellPage() {
|
|||||||
const projectSidebarGroups = useMemo(() => {
|
const projectSidebarGroups = useMemo(() => {
|
||||||
if (!homeSpaceId) return [];
|
if (!homeSpaceId) return [];
|
||||||
const entries: typeof sidebarGroups = [];
|
const entries: typeof sidebarGroups = [];
|
||||||
|
const seenProjectSpaceIds = new Set<string>();
|
||||||
|
const seenProjectGroupChatIds = new Set<string>();
|
||||||
|
|
||||||
|
const dedupeSidebarBots = (bots: (typeof sidebarGroups)[number]["bots"]) => {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
return bots.filter((item) => {
|
||||||
|
const key = `${item.kind}:${item.chat.id}`;
|
||||||
|
if (seen.has(key)) return false;
|
||||||
|
seen.add(key);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
for (const space of spaces) {
|
for (const space of spaces) {
|
||||||
if (space.id === homeSpaceId) continue;
|
if (space.id === homeSpaceId || seenProjectSpaceIds.has(space.id)) continue;
|
||||||
|
seenProjectSpaceIds.add(space.id);
|
||||||
const groupsInSpace = sidebarGroups.flatMap((group) =>
|
const groupsInSpace = sidebarGroups.flatMap((group) =>
|
||||||
group.spaceId === space.id
|
group.spaceId === space.id
|
||||||
? group.bots.filter(
|
? group.bots.filter(
|
||||||
@ -1619,16 +1710,18 @@ export function ShellPage() {
|
|||||||
)
|
)
|
||||||
: [],
|
: [],
|
||||||
);
|
);
|
||||||
|
const botsInSpace = sidebarGroups.flatMap((group) =>
|
||||||
|
group.spaceId === space.id ? group.bots.filter((item) => item.kind === "bot") : [],
|
||||||
|
);
|
||||||
|
const projectBots = dedupeSidebarBots(groupsInSpace.length > 0 ? groupsInSpace : botsInSpace);
|
||||||
entries.push({
|
entries.push({
|
||||||
key:
|
key:
|
||||||
groupsInSpace.length > 0
|
projectBots.length > 0 ? `project-space:${space.id}` : `project-space:${space.id}:empty`,
|
||||||
? `project-space:${space.id}`
|
|
||||||
: `project-space:${space.id}:empty`,
|
|
||||||
title: space.name,
|
title: space.name,
|
||||||
bots: groupsInSpace,
|
bots: projectBots,
|
||||||
sectionId: null,
|
sectionId: null,
|
||||||
showLock: true,
|
showLock: true,
|
||||||
emptySpaceId: groupsInSpace.length > 0 ? undefined : space.id,
|
emptySpaceId: projectBots.length > 0 ? undefined : space.id,
|
||||||
spaceId: space.id,
|
spaceId: space.id,
|
||||||
spaceName: space.name,
|
spaceName: space.name,
|
||||||
canDeleteSpace: space.canDelete === true,
|
canDeleteSpace: space.canDelete === true,
|
||||||
@ -1637,9 +1730,13 @@ export function ShellPage() {
|
|||||||
|
|
||||||
for (const group of sidebarGroups) {
|
for (const group of sidebarGroups) {
|
||||||
if (group.spaceId !== homeSpaceId) continue;
|
if (group.spaceId !== homeSpaceId) continue;
|
||||||
const groupChats = group.bots.filter(
|
const groupChats = dedupeSidebarBots(
|
||||||
(item) => item.kind === "group" && item.chat.id !== hauptthreadGroup?.id,
|
group.bots.filter((item) => item.kind === "group" && item.chat.id !== hauptthreadGroup?.id),
|
||||||
);
|
).filter((item) => {
|
||||||
|
if (seenProjectGroupChatIds.has(item.chat.id)) return false;
|
||||||
|
seenProjectGroupChatIds.add(item.chat.id);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
if (groupChats.length === 0) continue;
|
if (groupChats.length === 0) continue;
|
||||||
entries.push({
|
entries.push({
|
||||||
...group,
|
...group,
|
||||||
@ -1656,7 +1753,8 @@ export function ShellPage() {
|
|||||||
entries.sort((a, b) => {
|
entries.sort((a, b) => {
|
||||||
const aInOrder = orderMap.has(a.spaceId);
|
const aInOrder = orderMap.has(a.spaceId);
|
||||||
const bInOrder = orderMap.has(b.spaceId);
|
const bInOrder = orderMap.has(b.spaceId);
|
||||||
if (aInOrder && bInOrder) return (orderMap.get(a.spaceId) ?? 0) - (orderMap.get(b.spaceId) ?? 0);
|
if (aInOrder && bInOrder)
|
||||||
|
return (orderMap.get(a.spaceId) ?? 0) - (orderMap.get(b.spaceId) ?? 0);
|
||||||
if (aInOrder) return -1;
|
if (aInOrder) return -1;
|
||||||
if (bInOrder) return 1;
|
if (bInOrder) return 1;
|
||||||
return 0;
|
return 0;
|
||||||
@ -1684,12 +1782,9 @@ export function ShellPage() {
|
|||||||
},
|
},
|
||||||
[projectSidebarGroups, handleReorderProjects],
|
[projectSidebarGroups, handleReorderProjects],
|
||||||
);
|
);
|
||||||
const handleProjectDragStart = useCallback(
|
const handleProjectDragStart = useCallback((event: { active: { id: string | number } }) => {
|
||||||
(event: { active: { id: string | number } }) => {
|
|
||||||
setDraggedProjectId(String(event.active.id));
|
setDraggedProjectId(String(event.active.id));
|
||||||
},
|
}, []);
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const openSpaceChat = useCallback(
|
const openSpaceChat = useCallback(
|
||||||
(spaceId: string, path: string) => {
|
(spaceId: string, path: string) => {
|
||||||
@ -1952,9 +2047,12 @@ export function ShellPage() {
|
|||||||
setReplyTarget(null);
|
setReplyTarget(null);
|
||||||
setReplyQuote(null);
|
setReplyQuote(null);
|
||||||
}, []);
|
}, []);
|
||||||
const currentRuns = activeThreadRuns(activeSnapshot);
|
const currentRuns = useMemo(() => activeThreadRuns(activeSnapshot), [activeSnapshot]);
|
||||||
const answerableAskMessageId = latestAnswerableAskMessageId(activeSnapshot);
|
const answerableAskMessageId = latestAnswerableAskMessageId(activeSnapshot);
|
||||||
const workingRuns = currentRuns.filter((run) => isWorkingRunStatus(run.status));
|
const workingRuns = useMemo(
|
||||||
|
() => currentRuns.filter((run) => isWorkingRunStatus(run.status)),
|
||||||
|
[currentRuns],
|
||||||
|
);
|
||||||
const transcriptRunning = workingRuns.length > 0;
|
const transcriptRunning = workingRuns.length > 0;
|
||||||
const composerRunning = currentRuns.some((run) => isActive(run.status));
|
const composerRunning = currentRuns.some((run) => isActive(run.status));
|
||||||
const runError = threadRunError(activeSnapshot, dismissedRunErrorIds);
|
const runError = threadRunError(activeSnapshot, dismissedRunErrorIds);
|
||||||
@ -2182,13 +2280,28 @@ export function ShellPage() {
|
|||||||
? t`You`
|
? t`You`
|
||||||
: (resolveTranscriptMemberName(activeReplyTarget.botId) ?? active?.name ?? t`Bot`)
|
: (resolveTranscriptMemberName(activeReplyTarget.botId) ?? active?.name ?? t`Bot`)
|
||||||
: undefined;
|
: undefined;
|
||||||
const composerMentionTargets = useMemo(
|
const composerMentionTargets = useMemo(() => {
|
||||||
() =>
|
const mentionBots = new Map(
|
||||||
buildComposerMentionOptions({
|
bots.map((bot) => [bot.id, { id: bot.id, name: bot.name, color: bot.color }]),
|
||||||
|
);
|
||||||
|
// Group members stay mentionable when the sidebar bot list is still catching up
|
||||||
|
// after creating the group and remounting the shell.
|
||||||
|
if (inGroup) {
|
||||||
|
for (const member of [...(activeGroup?.members ?? []), ...(activeSnapshot?.members ?? [])]) {
|
||||||
|
if (!mentionBots.has(member.botId)) {
|
||||||
|
mentionBots.set(member.botId, {
|
||||||
|
id: member.botId,
|
||||||
|
name: member.name,
|
||||||
|
color: member.color,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return buildComposerMentionOptions({
|
||||||
query: "",
|
query: "",
|
||||||
includeEveryone: inGroup,
|
includeEveryone: inGroup,
|
||||||
currentGroupId: groupId,
|
currentGroupId: groupId,
|
||||||
bots: bots.map((bot) => ({ id: bot.id, name: bot.name, color: bot.color })),
|
bots: [...mentionBots.values()],
|
||||||
groups: groups.map((group) => ({ id: group.id, name: group.name })),
|
groups: groups.map((group) => ({ id: group.id, name: group.name })),
|
||||||
routines: mentionRoutines.map((routine) => ({
|
routines: mentionRoutines.map((routine) => ({
|
||||||
id: routine.id,
|
id: routine.id,
|
||||||
@ -2198,9 +2311,17 @@ export function ShellPage() {
|
|||||||
botName: routine.botName,
|
botName: routine.botName,
|
||||||
})),
|
})),
|
||||||
connectors: mentionConnectors,
|
connectors: mentionConnectors,
|
||||||
}),
|
});
|
||||||
[bots, groupId, groups, inGroup, mentionConnectors, mentionRoutines],
|
}, [
|
||||||
);
|
activeGroup?.members,
|
||||||
|
activeSnapshot?.members,
|
||||||
|
bots,
|
||||||
|
groupId,
|
||||||
|
groups,
|
||||||
|
inGroup,
|
||||||
|
mentionConnectors,
|
||||||
|
mentionRoutines,
|
||||||
|
]);
|
||||||
const shellReady =
|
const shellReady =
|
||||||
initialBotsLoaded &&
|
initialBotsLoaded &&
|
||||||
(inGroup
|
(inGroup
|
||||||
@ -2393,7 +2514,9 @@ export function ShellPage() {
|
|||||||
}, [clearVoiceEntryParam]);
|
}, [clearVoiceEntryParam]);
|
||||||
const loadOlder = useCallback(() => loadOlderMessagesRef.current(), []);
|
const loadOlder = useCallback(() => loadOlderMessagesRef.current(), []);
|
||||||
const jumpToReplyMessage = useCallback((messageId: string) => {
|
const jumpToReplyMessage = useCallback((messageId: string) => {
|
||||||
const existing = document.querySelector(`[data-message-id="${messageId.replace(/[^\w-]/g, (c) => `\\${c}`)}"]`);
|
const existing = document.querySelector(
|
||||||
|
`[data-message-id="${messageId.replace(/[^\w-]/g, (c) => `\\${c}`)}"]`,
|
||||||
|
);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
// Cancel any in-flight around-fetch so it cannot overwrite this scroll.
|
// Cancel any in-flight around-fetch so it cannot overwrite this scroll.
|
||||||
jumpGeneration.current += 1;
|
jumpGeneration.current += 1;
|
||||||
@ -2488,7 +2611,7 @@ export function ShellPage() {
|
|||||||
async (text: string, mentions: ComposerMention[] = []) => {
|
async (text: string, mentions: ComposerMention[] = []) => {
|
||||||
const initialBotTarget = activeBotId.current;
|
const initialBotTarget = activeBotId.current;
|
||||||
const initialGroupTarget = activeGroupId.current;
|
const initialGroupTarget = activeGroupId.current;
|
||||||
if ((!initialBotTarget && !initialGroupTarget) || sending) return;
|
if (!initialBotTarget && !initialGroupTarget) return;
|
||||||
const originThreadKey = initialGroupTarget ?? initialBotTarget;
|
const originThreadKey = initialGroupTarget ?? initialBotTarget;
|
||||||
const attachments = attachmentsForThread(pendingAttachments, originThreadKey);
|
const attachments = attachmentsForThread(pendingAttachments, originThreadKey);
|
||||||
const plan = resolveComposerSendPlan({
|
const plan = resolveComposerSendPlan({
|
||||||
@ -2529,7 +2652,10 @@ export function ShellPage() {
|
|||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
await rpc.bots.update({ botId: botTarget, autoSpeak: true });
|
await rpc.bots.update({ botId: botTarget, autoSpeak: true });
|
||||||
await refreshBots();
|
await refreshBots();
|
||||||
void rpc.voice.status().then(setVoiceStatus).catch(() => undefined);
|
void rpc.voice
|
||||||
|
.status()
|
||||||
|
.then(setVoiceStatus)
|
||||||
|
.catch(() => undefined);
|
||||||
setAttachmentNotice(t`Speaking is on.`);
|
setAttachmentNotice(t`Speaking is on.`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -2545,7 +2671,10 @@ export function ShellPage() {
|
|||||||
await rpc.voice.setVoice({ voiceId: match.id, provider: "kokoro" });
|
await rpc.voice.setVoice({ voiceId: match.id, provider: "kokoro" });
|
||||||
await rpc.bots.update({ botId: botTarget, voiceId: match.id, autoSpeak: true });
|
await rpc.bots.update({ botId: botTarget, voiceId: match.id, autoSpeak: true });
|
||||||
await refreshBots();
|
await refreshBots();
|
||||||
void rpc.voice.status().then(setVoiceStatus).catch(() => undefined);
|
void rpc.voice
|
||||||
|
.status()
|
||||||
|
.then(setVoiceStatus)
|
||||||
|
.catch(() => undefined);
|
||||||
setAttachmentNotice(t`Voice set to ${match.label}.`);
|
setAttachmentNotice(t`Voice set to ${match.label}.`);
|
||||||
return;
|
return;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -2554,6 +2683,7 @@ export function ShellPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
sendsInFlight.current += 1;
|
||||||
setSending(true);
|
setSending(true);
|
||||||
setSendError(null);
|
setSendError(null);
|
||||||
const dropDelayedSetup = () => {
|
const dropDelayedSetup = () => {
|
||||||
@ -2668,7 +2798,8 @@ export function ShellPage() {
|
|||||||
setSendError(error instanceof Error ? error.message : t`Failed to send message`);
|
setSendError(error instanceof Error ? error.message : t`Failed to send message`);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setSending(false);
|
sendsInFlight.current = Math.max(0, sendsInFlight.current - 1);
|
||||||
|
if (sendsInFlight.current === 0) setSending(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
@ -2679,7 +2810,6 @@ export function ShellPage() {
|
|||||||
navigate,
|
navigate,
|
||||||
pendingAttachments,
|
pendingAttachments,
|
||||||
refreshBots,
|
refreshBots,
|
||||||
sending,
|
|
||||||
t,
|
t,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@ -3333,7 +3463,7 @@ export function ShellPage() {
|
|||||||
{heuteActionCount > 0 ? (
|
{heuteActionCount > 0 ? (
|
||||||
<span
|
<span
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="absolute -right-0.5 -top-0.5 grid min-w-[18px] place-items-center rounded-full bg-warning px-1 text-[10px] font-bold leading-none text-background"
|
className="absolute -right-0.5 -top-0.5 grid min-w-[18px] place-items-center rounded-full bg-warning px-1 text-ui-label font-bold leading-none text-background"
|
||||||
>
|
>
|
||||||
{heuteActionCount > 9 ? "9+" : heuteActionCount}
|
{heuteActionCount > 9 ? "9+" : heuteActionCount}
|
||||||
</span>
|
</span>
|
||||||
@ -3537,8 +3667,8 @@ export function ShellPage() {
|
|||||||
}}
|
}}
|
||||||
aria-label={
|
aria-label={
|
||||||
item.kind === "bot"
|
item.kind === "bot"
|
||||||
? t`1:1 conversation with ${item.chat.name}`
|
? t`${item.chat.name}, 1:1 conversation`
|
||||||
: t`Group conversation ${item.chat.name}`
|
: t`${item.chat.name}, group conversation`
|
||||||
}
|
}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setActiveNavPillar("team");
|
setActiveNavPillar("team");
|
||||||
@ -3667,6 +3797,7 @@ export function ShellPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={t`Create a project`}
|
aria-label={t`Create a project`}
|
||||||
|
data-testid="create-project-trigger"
|
||||||
onClick={() => openNewSpaceDialog("project")}
|
onClick={() => openNewSpaceDialog("project")}
|
||||||
className="text-muted-foreground/60 hover:text-foreground/90 transition-colors"
|
className="text-muted-foreground/60 hover:text-foreground/90 transition-colors"
|
||||||
>
|
>
|
||||||
@ -3708,13 +3839,9 @@ export function ShellPage() {
|
|||||||
{group.title ? (
|
{group.title ? (
|
||||||
<div className="flex items-center pt-3 pb-0.5 relative">
|
<div className="flex items-center pt-3 pb-0.5 relative">
|
||||||
{/* Drag grip — positioned to the left of the title text */}
|
{/* Drag grip — positioned to the left of the title text */}
|
||||||
<button
|
<ProjectDragGrip className="absolute left-1 top-1/2 -translate-y-1/2 w-6 h-6 rounded flex items-center justify-center text-muted-foreground/30 hover:text-muted-foreground/70 active:text-muted-foreground transition-colors cursor-grab active:cursor-grabbing">
|
||||||
type="button"
|
|
||||||
className="absolute left-1 top-1/2 -translate-y-1/2 w-6 h-6 rounded flex items-center justify-center text-muted-foreground/30 hover:text-muted-foreground/70 active:text-muted-foreground transition-colors cursor-grab active:cursor-grabbing"
|
|
||||||
aria-label="Drag to reorder"
|
|
||||||
>
|
|
||||||
<GripVertical className="size-icon-sm" strokeWidth={2} />
|
<GripVertical className="size-icon-sm" strokeWidth={2} />
|
||||||
</button>
|
</ProjectDragGrip>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex min-w-0 flex-1 items-center justify-between gap-2 rounded-lg pl-8 py-1 text-ui-label font-semibold tracking-wider uppercase text-muted-foreground/60 hover:text-foreground focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-ring"
|
className="flex min-w-0 flex-1 items-center justify-between gap-2 rounded-lg pl-8 py-1 text-ui-label font-semibold tracking-wider uppercase text-muted-foreground/60 hover:text-foreground focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-ring"
|
||||||
@ -3738,8 +3865,9 @@ export function ShellPage() {
|
|||||||
const sections =
|
const sections =
|
||||||
group.spaceId === bootstrapMe?.spaceId
|
group.spaceId === bootstrapMe?.spaceId
|
||||||
? botSections
|
? botSections
|
||||||
: (spaces.find((space) => space.id === group.spaceId)
|
: (spaces.find(
|
||||||
?.botSections ?? []);
|
(space) => space.id === group.spaceId,
|
||||||
|
)?.botSections ?? []);
|
||||||
const section = sections.find(
|
const section = sections.find(
|
||||||
(item) => item.id === group.sectionId,
|
(item) => item.id === group.sectionId,
|
||||||
);
|
);
|
||||||
@ -3813,10 +3941,19 @@ export function ShellPage() {
|
|||||||
<button
|
<button
|
||||||
key={`${item.kind}:${item.chat.id}`}
|
key={`${item.kind}:${item.chat.id}`}
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={t`Group conversation ${item.chat.name}`}
|
aria-label={
|
||||||
|
item.kind === "bot"
|
||||||
|
? t`${item.chat.name}, 1:1 conversation`
|
||||||
|
: t`${item.chat.name}, group conversation`
|
||||||
|
}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setActiveNavPillar("projekte");
|
setActiveNavPillar("projekte");
|
||||||
openSpaceChat(item.chat.spaceId, `/app/g/${item.chat.id}`);
|
openSpaceChat(
|
||||||
|
item.chat.spaceId,
|
||||||
|
item.kind === "bot"
|
||||||
|
? `/app/${item.chat.id}`
|
||||||
|
: `/app/g/${item.chat.id}`,
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
onContextMenu={(event) => {
|
onContextMenu={(event) => {
|
||||||
if (item.chat.spaceId !== bootstrapMe?.spaceId) return;
|
if (item.chat.spaceId !== bootstrapMe?.spaceId) return;
|
||||||
@ -3829,15 +3966,25 @@ export function ShellPage() {
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
className={`flex w-full items-center gap-3 rounded-2xl px-3 py-3 mb-[2px] text-start transition-all duration-150 cursor-pointer min-h-[52px] ${
|
className={`flex w-full items-center gap-3 rounded-2xl px-3 py-3 mb-[2px] text-start transition-all duration-150 cursor-pointer min-h-[52px] ${
|
||||||
item.kind === "group" && inGroup && activeGroup?.id === item.chat.id
|
item.kind === "bot" && !inGroup && active?.id === item.chat.id
|
||||||
|
? "bg-white/[0.09] shadow-[inset_0_1px_0_rgba(255,255,255,0.14),0_4px_16px_rgba(0,0,0,0.4)] border border-emerald-500/30"
|
||||||
|
: item.kind === "group" &&
|
||||||
|
inGroup &&
|
||||||
|
activeGroup?.id === item.chat.id
|
||||||
? "bg-white/[0.09] shadow-[inset_0_1px_0_rgba(255,255,255,0.14),0_4px_16px_rgba(0,0,0,0.4)] border border-emerald-500/30"
|
? "bg-white/[0.09] shadow-[inset_0_1px_0_rgba(255,255,255,0.14),0_4px_16px_rgba(0,0,0,0.4)] border border-emerald-500/30"
|
||||||
: "hover:bg-white/[0.05] border border-transparent"
|
: "hover:bg-white/[0.05] border border-transparent"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<GroupAvatar
|
{item.kind === "bot" ? (
|
||||||
members={(item.chat as any).members}
|
<BotAvatar
|
||||||
|
color={item.chat.color}
|
||||||
|
identity={item.chat.id}
|
||||||
size={40}
|
size={40}
|
||||||
|
status={item.chat.status}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<GroupAvatar members={(item.chat as any).members} size={40} />
|
||||||
|
)}
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center justify-between gap-1.5">
|
<div className="flex items-center justify-between gap-1.5">
|
||||||
<div className="flex min-w-0 items-center gap-2 overflow-hidden">
|
<div className="flex min-w-0 items-center gap-2 overflow-hidden">
|
||||||
@ -3867,7 +4014,9 @@ export function ShellPage() {
|
|||||||
<div
|
<div
|
||||||
dir="auto"
|
dir="auto"
|
||||||
className={`mt-0.5 truncate text-ui-body leading-snug ${
|
className={`mt-0.5 truncate text-ui-body leading-snug ${
|
||||||
item.chat.unread ? "font-medium text-white" : "text-zinc-300"
|
item.chat.unread
|
||||||
|
? "font-medium text-white"
|
||||||
|
: "text-zinc-300"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{cleanRosterPreview(item.chat.preview)}
|
{cleanRosterPreview(item.chat.preview)}
|
||||||
@ -4418,7 +4567,10 @@ export function ShellPage() {
|
|||||||
data-testid="side-panel"
|
data-testid="side-panel"
|
||||||
data-panel={panel ?? "closed"}
|
data-panel={panel ?? "closed"}
|
||||||
style={{
|
style={{
|
||||||
width: panel && (active || activeGroup || panel === "create" || panel === "browser") ? rightSidebarWidth : 0,
|
width:
|
||||||
|
panel && (active || activeGroup || panel === "create" || panel === "browser")
|
||||||
|
? rightSidebarWidth
|
||||||
|
: 0,
|
||||||
}}
|
}}
|
||||||
className={`absolute inset-y-0 end-0 z-20 flex min-h-0 shrink-0 flex-col overflow-hidden bg-background md:rounded-2xl md:border md:border-white/10 md:bg-[#0A0A0C] md:shadow-2xl md:relative ${
|
className={`absolute inset-y-0 end-0 z-20 flex min-h-0 shrink-0 flex-col overflow-hidden bg-background md:rounded-2xl md:border md:border-white/10 md:bg-[#0A0A0C] md:shadow-2xl md:relative ${
|
||||||
panel && (active || activeGroup || panel === "create" || panel === "browser")
|
panel && (active || activeGroup || panel === "create" || panel === "browser")
|
||||||
@ -4495,7 +4647,13 @@ export function ShellPage() {
|
|||||||
{panel === "browser" ? (
|
{panel === "browser" ? (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
<div className="flex items-center gap-1.5 border-b px-3 py-2">
|
<div className="flex items-center gap-1.5 border-b px-3 py-2">
|
||||||
<Button variant="ghost" size="icon-sm" onClick={reloadBrowser} aria-label={t`Reload`} className="size-icon-sm text-muted-foreground">
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={reloadBrowser}
|
||||||
|
aria-label={t`Reload`}
|
||||||
|
className="size-icon-sm text-muted-foreground"
|
||||||
|
>
|
||||||
<RefreshCw strokeWidth={1.7} className="size-4" />
|
<RefreshCw strokeWidth={1.7} className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<input
|
<input
|
||||||
@ -4503,18 +4661,25 @@ export function ShellPage() {
|
|||||||
value={browserUrl}
|
value={browserUrl}
|
||||||
onChange={(e) => setBrowserUrl(e.target.value)}
|
onChange={(e) => setBrowserUrl(e.target.value)}
|
||||||
onBlur={() => navigateBrowser(browserUrl)}
|
onBlur={() => navigateBrowser(browserUrl)}
|
||||||
onKeyDown={(e) => { if (e.key === "Enter") navigateBrowser(browserUrl); }}
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") navigateBrowser(browserUrl);
|
||||||
|
}}
|
||||||
placeholder="http://localhost:5173"
|
placeholder="http://localhost:5173"
|
||||||
className="flex-1 rounded-md bg-muted/60 px-2.5 py-1 text-xs text-foreground placeholder:text-muted-foreground/60 outline-none focus:ring-1 focus:ring-ring/50"
|
className="flex-1 rounded-md bg-muted/60 px-2.5 py-1 text-xs text-foreground placeholder:text-muted-foreground/60 outline-none focus:ring-1 focus:ring-ring/50"
|
||||||
/>
|
/>
|
||||||
{isDesktop ? (
|
{isDesktop ? (
|
||||||
devServerRunning ? (
|
devServerRunning ? (
|
||||||
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/10 px-2 py-0.5 text-[10px] font-medium text-emerald-400">
|
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/10 px-2 py-0.5 text-ui-label font-medium text-emerald-400">
|
||||||
<span className="size-1.5 rounded-full bg-emerald-400" />
|
<span className="size-1.5 rounded-full bg-emerald-400" />
|
||||||
<Trans>Dev</Trans>
|
<Trans>Dev</Trans>
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<Button variant="ghost" size="xs" onClick={startDevServer} className="text-[11px] text-muted-foreground hover:text-foreground">
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="xs"
|
||||||
|
onClick={startDevServer}
|
||||||
|
className="text-ui-label text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
<Trans>Start Dev</Trans>
|
<Trans>Start Dev</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
@ -4533,10 +4698,14 @@ export function ShellPage() {
|
|||||||
const cx = Math.min(100, Math.max(0, activity.x)) + "%";
|
const cx = Math.min(100, Math.max(0, activity.x)) + "%";
|
||||||
const cy = Math.min(100, Math.max(0, activity.y)) + "%";
|
const cy = Math.min(100, Math.max(0, activity.y)) + "%";
|
||||||
return (
|
return (
|
||||||
<div key={agentId} className="absolute transition-[left,top] duration-300 ease-out" style={{ left: cx, top: cy }}>
|
<div
|
||||||
|
key={agentId}
|
||||||
|
className="absolute transition-[left,top] duration-300 ease-out"
|
||||||
|
style={{ left: cx, top: cy }}
|
||||||
|
>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div className="size-3 rounded-full bg-primary/60 ring-2 ring-primary" />
|
<div className="size-3 rounded-full bg-primary/60 ring-2 ring-primary" />
|
||||||
<div className="absolute -right-1 -top-4 whitespace-nowrap rounded bg-overlay px-1.5 py-0.5 text-[10px] text-foreground shadow-sm">
|
<div className="absolute -right-1 -top-4 whitespace-nowrap rounded bg-overlay px-1.5 py-0.5 text-ui-label text-foreground shadow-sm">
|
||||||
{activity.label}
|
{activity.label}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -4671,20 +4840,22 @@ export function ShellPage() {
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{/* Centered Create Bot Modal */}
|
{/* Centered Create Bot Modal */}
|
||||||
{showCreateBotModal && createPortal(
|
{showCreateBotModal &&
|
||||||
<div
|
createPortal(
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60"
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="absolute inset-0 bg-black/60"
|
||||||
|
aria-label={t`Close`}
|
||||||
onClick={() => setShowCreateBotModal(false)}
|
onClick={() => setShowCreateBotModal(false)}
|
||||||
>
|
/>
|
||||||
<div
|
<div className="relative z-10 max-h-[90vh] w-[480px] overflow-y-auto rounded-2xl border border-white/10 bg-[#0A0A0C] p-6 shadow-2xl">
|
||||||
className="w-[480px] max-h-[90vh] overflow-y-auto rounded-2xl border border-white/10 bg-[#0A0A0C] p-6 shadow-2xl"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div className="mb-4 flex items-center justify-between">
|
||||||
<h2 className="text-lg font-semibold text-foreground">
|
<h2 className="text-lg font-semibold text-foreground">
|
||||||
<Trans>Create new Bot</Trans>
|
<Trans>Create new Bot</Trans>
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => setShowCreateBotModal(false)}
|
onClick={() => setShowCreateBotModal(false)}
|
||||||
className="text-muted-foreground hover:text-foreground"
|
className="text-muted-foreground hover:text-foreground"
|
||||||
aria-label={t`Close`}
|
aria-label={t`Close`}
|
||||||
@ -5287,6 +5458,11 @@ export function ShellPage() {
|
|||||||
setVoiceStatus(null);
|
setVoiceStatus(null);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
activeBotId={activeBotId.current}
|
||||||
|
onOpenMcp={() => {
|
||||||
|
setSettingsOpen(false);
|
||||||
|
setMcpOpen(true);
|
||||||
|
}}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setSettingsOpen(false);
|
setSettingsOpen(false);
|
||||||
setSettingsSection("general");
|
setSettingsSection("general");
|
||||||
@ -6126,6 +6302,7 @@ const Composer = memo(function Composer({
|
|||||||
const [selectedMentions, setSelectedMentions] = useState<ComposerMention[]>([]);
|
const [selectedMentions, setSelectedMentions] = useState<ComposerMention[]>([]);
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const runErrorRef = useRef<HTMLDivElement>(null);
|
const runErrorRef = useRef<HTMLDivElement>(null);
|
||||||
|
const submitFingerprint = useRef<string | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!sendError && !runError) return;
|
if (!sendError && !runError) return;
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
@ -6204,6 +6381,7 @@ const Composer = memo(function Composer({
|
|||||||
}, [draft]);
|
}, [draft]);
|
||||||
|
|
||||||
function updateDraft(value: string) {
|
function updateDraft(value: string) {
|
||||||
|
submitFingerprint.current = null;
|
||||||
setDraft(value);
|
setDraft(value);
|
||||||
const mentionMatch = /(?:^|\s)@([\w-]*)$/.exec(value);
|
const mentionMatch = /(?:^|\s)@([\w-]*)$/.exec(value);
|
||||||
setMentionQuery(mentionMatch ? (mentionMatch[1] ?? "") : null);
|
setMentionQuery(mentionMatch ? (mentionMatch[1] ?? "") : null);
|
||||||
@ -6260,7 +6438,7 @@ const Composer = memo(function Composer({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMentionHighlightIndex(0);
|
setMentionHighlightIndex(0);
|
||||||
}, [mentionQuery, mentionOptions]);
|
}, [mentionQuery]);
|
||||||
|
|
||||||
const activeMentionIndex = clampMentionHighlightIndex(
|
const activeMentionIndex = clampMentionHighlightIndex(
|
||||||
mentionHighlightIndex,
|
mentionHighlightIndex,
|
||||||
@ -6298,8 +6476,13 @@ const Composer = memo(function Composer({
|
|||||||
(slashSkillOptions.length > 0 || slashActionOptions.length > 0);
|
(slashSkillOptions.length > 0 || slashActionOptions.length > 0);
|
||||||
|
|
||||||
function send() {
|
function send() {
|
||||||
if (!canSend || sending || disabled) return;
|
if (!canSend || disabled) return;
|
||||||
const text = serializeComposerPrompt(draft, selectedSkill, selectedMentions);
|
const text = serializeComposerPrompt(draft, selectedSkill, selectedMentions);
|
||||||
|
const fingerprint = [text, ...selectedMentions.map((mention) => mentionChipKey(mention))].join(
|
||||||
|
"\n",
|
||||||
|
);
|
||||||
|
if (submitFingerprint.current === fingerprint) return;
|
||||||
|
submitFingerprint.current = fingerprint;
|
||||||
setDraft("");
|
setDraft("");
|
||||||
setMentionQuery(null);
|
setMentionQuery(null);
|
||||||
setMentionHighlightIndex(0);
|
setMentionHighlightIndex(0);
|
||||||
@ -6497,7 +6680,7 @@ const Composer = memo(function Composer({
|
|||||||
role="listbox"
|
role="listbox"
|
||||||
aria-label={t`Mentions`}
|
aria-label={t`Mentions`}
|
||||||
data-testid="mention-picker"
|
data-testid="mention-picker"
|
||||||
className="mb-2 overflow-hidden rounded-[14px] border border-border bg-muted"
|
className="mb-2 max-h-64 overflow-y-auto rounded-[14px] border border-border bg-muted"
|
||||||
>
|
>
|
||||||
{mentionOptions.map((mention, index) => {
|
{mentionOptions.map((mention, index) => {
|
||||||
const optionId = `${mentionListboxId}-option-${index}`;
|
const optionId = `${mentionListboxId}-option-${index}`;
|
||||||
@ -6509,7 +6692,8 @@ const Composer = memo(function Composer({
|
|||||||
type="button"
|
type="button"
|
||||||
role="option"
|
role="option"
|
||||||
aria-selected={highlighted}
|
aria-selected={highlighted}
|
||||||
aria-label={t`@${mention.name}`}
|
aria-label={`@${mention.name}`}
|
||||||
|
data-mention-name={mention.name}
|
||||||
onMouseDown={(event) => event.preventDefault()}
|
onMouseDown={(event) => event.preventDefault()}
|
||||||
onClick={() => insertMention(mention)}
|
onClick={() => insertMention(mention)}
|
||||||
onMouseEnter={() => setMentionHighlightIndex(index)}
|
onMouseEnter={() => setMentionHighlightIndex(index)}
|
||||||
@ -6517,7 +6701,9 @@ const Composer = memo(function Composer({
|
|||||||
highlighted ? "bg-accent" : ""
|
highlighted ? "bg-accent" : ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
<span aria-hidden="true">
|
||||||
<MentionOptionIcon mention={mention} />
|
<MentionOptionIcon mention={mention} />
|
||||||
|
</span>
|
||||||
<span className="min-w-0">
|
<span className="min-w-0">
|
||||||
<span dir="auto" className="block text-ui-body text-foreground">
|
<span dir="auto" className="block text-ui-body text-foreground">
|
||||||
@{mention.name}
|
@{mention.name}
|
||||||
@ -6525,6 +6711,7 @@ const Composer = memo(function Composer({
|
|||||||
{mention.subtitle ? (
|
{mention.subtitle ? (
|
||||||
<span
|
<span
|
||||||
dir="auto"
|
dir="auto"
|
||||||
|
aria-hidden="true"
|
||||||
className="block truncate text-ui-body-sm text-muted-foreground"
|
className="block truncate text-ui-body-sm text-muted-foreground"
|
||||||
>
|
>
|
||||||
{mention.subtitle}
|
{mention.subtitle}
|
||||||
@ -6703,7 +6890,7 @@ const Composer = memo(function Composer({
|
|||||||
role="combobox"
|
role="combobox"
|
||||||
aria-autocomplete="list"
|
aria-autocomplete="list"
|
||||||
aria-haspopup="listbox"
|
aria-haspopup="listbox"
|
||||||
aria-expanded={mentionPickerOpen}
|
aria-expanded={mentionPickerOpen ? "true" : "false"}
|
||||||
aria-controls={mentionPickerOpen ? mentionListboxId : undefined}
|
aria-controls={mentionPickerOpen ? mentionListboxId : undefined}
|
||||||
aria-activedescendant={activeMentionOptionId}
|
aria-activedescendant={activeMentionOptionId}
|
||||||
name="chat-message"
|
name="chat-message"
|
||||||
@ -6738,7 +6925,7 @@ const Composer = memo(function Composer({
|
|||||||
<Button
|
<Button
|
||||||
size="icon"
|
size="icon"
|
||||||
aria-label={t`Send`}
|
aria-label={t`Send`}
|
||||||
disabled={sending || !canSend || disabled}
|
disabled={!canSend || disabled}
|
||||||
onClick={send}
|
onClick={send}
|
||||||
className="tactile-press size-10 rounded-full bg-emerald-400 text-slate-950 hover:bg-emerald-300 shadow-[0_0_16px_rgba(16,185,129,0.35)] transition-transform active:scale-90 flex items-center justify-center cursor-pointer"
|
className="tactile-press size-10 rounded-full bg-emerald-400 text-slate-950 hover:bg-emerald-300 shadow-[0_0_16px_rgba(16,185,129,0.35)] transition-transform active:scale-90 flex items-center justify-center cursor-pointer"
|
||||||
>
|
>
|
||||||
@ -6759,7 +6946,7 @@ const Composer = memo(function Composer({
|
|||||||
<Button
|
<Button
|
||||||
size="icon"
|
size="icon"
|
||||||
aria-label={t`Send`}
|
aria-label={t`Send`}
|
||||||
disabled={sending || !canSend || disabled}
|
disabled={!canSend || disabled}
|
||||||
onClick={send}
|
onClick={send}
|
||||||
className="tactile-press size-10 shrink-0 rounded-full bg-emerald-400 text-slate-950 hover:bg-emerald-300 shadow-[0_0_16px_rgba(16,185,129,0.35)] transition-all active:scale-90 disabled:bg-white/[0.08] disabled:text-white/20 disabled:shadow-none flex items-center justify-center cursor-pointer"
|
className="tactile-press size-10 shrink-0 rounded-full bg-emerald-400 text-slate-950 hover:bg-emerald-300 shadow-[0_0_16px_rgba(16,185,129,0.35)] transition-all active:scale-90 disabled:bg-white/[0.08] disabled:text-white/20 disabled:shadow-none flex items-center justify-center cursor-pointer"
|
||||||
>
|
>
|
||||||
|
|||||||
@ -15,7 +15,7 @@ import {
|
|||||||
Switch,
|
Switch,
|
||||||
} from "@rakazo/ui-web";
|
} from "@rakazo/ui-web";
|
||||||
import { ChevronDownIcon, XIcon } from "lucide-react";
|
import { ChevronDownIcon, XIcon } from "lucide-react";
|
||||||
import { useEffect, useId, useMemo, useState } from "react";
|
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
||||||
import { ooSound } from "../lib/oo-sound";
|
import { ooSound } from "../lib/oo-sound";
|
||||||
import { readOoSoundEnabled } from "../lib/oo-sound-pref";
|
import { readOoSoundEnabled } from "../lib/oo-sound-pref";
|
||||||
import { rpc } from "../lib/rpc";
|
import { rpc } from "../lib/rpc";
|
||||||
@ -57,6 +57,7 @@ export function VoiceSettingsOverlay({
|
|||||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||||
const [advancedProvider, setAdvancedProvider] = useState("");
|
const [advancedProvider, setAdvancedProvider] = useState("");
|
||||||
const [advancedApiKey, setAdvancedApiKey] = useState("");
|
const [advancedApiKey, setAdvancedApiKey] = useState("");
|
||||||
|
const advancedChoice = useRef("");
|
||||||
|
|
||||||
const busy = pending !== null;
|
const busy = pending !== null;
|
||||||
const uiLocale = readUiLocale();
|
const uiLocale = readUiLocale();
|
||||||
@ -99,9 +100,15 @@ export function VoiceSettingsOverlay({
|
|||||||
const bot = bots.find((entry) => entry.id === activeBotId);
|
const bot = bots.find((entry) => entry.id === activeBotId);
|
||||||
setAutoSpeak(bot?.autoSpeak ?? false);
|
setAutoSpeak(bot?.autoSpeak ?? false);
|
||||||
}
|
}
|
||||||
if (!advancedProvider) {
|
if (!advancedChoice.current) {
|
||||||
const firstAdvanced = nextCatalog.find((entry) => entry.id !== LOCAL_PROVIDER_ID);
|
const connected = nextCredentials.find((entry) => entry.provider !== LOCAL_PROVIDER_ID);
|
||||||
if (firstAdvanced) setAdvancedProvider(firstAdvanced.id);
|
const preferred =
|
||||||
|
nextCatalog.find((entry) => entry.id === connected?.provider) ??
|
||||||
|
nextCatalog.find((entry) => entry.id !== LOCAL_PROVIDER_ID);
|
||||||
|
if (preferred) {
|
||||||
|
advancedChoice.current = preferred.id;
|
||||||
|
setAdvancedProvider(preferred.id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -380,6 +387,7 @@ export function VoiceSettingsOverlay({
|
|||||||
value={advancedProvider}
|
value={advancedProvider}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
|
advancedChoice.current = event.target.value;
|
||||||
setAdvancedProvider(event.target.value);
|
setAdvancedProvider(event.target.value);
|
||||||
setAdvancedApiKey("");
|
setAdvancedApiKey("");
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|||||||
@ -452,7 +452,10 @@ export function BotSettings({
|
|||||||
>
|
>
|
||||||
<Trans>Notifications</Trans>
|
<Trans>Notifications</Trans>
|
||||||
</div>
|
</div>
|
||||||
<div id={`${ids}-notify-finish-desc`} className="text-ui-body-sm text-muted-foreground/70">
|
<div
|
||||||
|
id={`${ids}-notify-finish-desc`}
|
||||||
|
className="text-ui-body-sm text-muted-foreground/70"
|
||||||
|
>
|
||||||
<Trans>Get notified when this Bot finishes or needs input</Trans>
|
<Trans>Get notified when this Bot finishes or needs input</Trans>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -80,6 +80,8 @@ export function ChoiceCard({
|
|||||||
<button
|
<button
|
||||||
key={option.id}
|
key={option.id}
|
||||||
type="button"
|
type="button"
|
||||||
|
data-testid={`focus-choice-${option.id}`}
|
||||||
|
aria-label={option.label}
|
||||||
disabled={Boolean(block.answerId) || pending}
|
disabled={Boolean(block.answerId) || pending}
|
||||||
onClick={() => void choose(option.id)}
|
onClick={() => void choose(option.id)}
|
||||||
className={`tactile-press flex w-full items-start gap-3.5 rounded-[18px] border border-white/5 p-3.5 text-start text-foreground disabled:opacity-60 transition-all ${
|
className={`tactile-press flex w-full items-start gap-3.5 rounded-[18px] border border-white/5 p-3.5 text-start text-foreground disabled:opacity-60 transition-all ${
|
||||||
|
|||||||
@ -20,8 +20,8 @@ body,
|
|||||||
);
|
);
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
font-family:
|
font-family:
|
||||||
"Inter Variable", -apple-system, BlinkMacSystemFont, "SF Pro Display",
|
"Inter Variable", -apple-system, BlinkMacSystemFont, "SF Pro Display", "Geist Variable",
|
||||||
"Geist Variable", system-ui, sans-serif;
|
system-ui, sans-serif;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
@ -122,10 +122,15 @@ body,
|
|||||||
font-family: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
font-family: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar-roster-row[data-selected="true"],
|
||||||
|
.sidebar-roster-row:hover {
|
||||||
|
background-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
.rk-chat-markdown code:not(pre code) {
|
.rk-chat-markdown code:not(pre code) {
|
||||||
background: rgba(255, 255, 255, 0.09);
|
background: rgba(255, 255, 255, 0.09);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.13);
|
border: 1px solid rgba(255, 255, 255, 0.13);
|
||||||
color: #38bdf8;
|
color: var(--foreground);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 0.15em 0.4em;
|
padding: 0.15em 0.4em;
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { timingSafeEqual } from "node:crypto";
|
|||||||
import type { ClientRequest, IncomingMessage } from "node:http";
|
import type { ClientRequest, IncomingMessage } from "node:http";
|
||||||
import http from "node:http";
|
import http from "node:http";
|
||||||
import https from "node:https";
|
import https from "node:https";
|
||||||
|
import { createRequire } from "node:module";
|
||||||
import net from "node:net";
|
import net from "node:net";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import tls from "node:tls";
|
import tls from "node:tls";
|
||||||
@ -227,11 +228,10 @@ function attachNovncProxy(server: ViteDevServer | PreviewServer, secret: string,
|
|||||||
|
|
||||||
export default defineConfig(({ mode }) => {
|
export default defineConfig(({ mode }) => {
|
||||||
const rootCwd = path.resolve(import.meta.dirname, "../..");
|
const rootCwd = path.resolve(import.meta.dirname, "../..");
|
||||||
const reactPath = path.resolve(rootCwd, "node_modules/react");
|
const require = createRequire(import.meta.url);
|
||||||
const reactDomPath = path.resolve(rootCwd, "node_modules/react-dom");
|
const reactPath = path.dirname(require.resolve("react/package.json"));
|
||||||
const reactIndex = path.resolve(reactPath, "index.js");
|
const reactDomPath = path.dirname(require.resolve("react-dom/package.json"));
|
||||||
const reactDomIndex = path.resolve(reactDomPath, "index.js");
|
const rootEnv = loadEnv(mode, rootCwd, "");
|
||||||
const rootEnv = loadEnv(mode, path.resolve(import.meta.dirname, "../.."), "");
|
|
||||||
const api = process.env.API_PROXY_TARGET ?? rootEnv.API_PROXY_TARGET ?? "http://127.0.0.1:3100";
|
const api = process.env.API_PROXY_TARGET ?? rootEnv.API_PROXY_TARGET ?? "http://127.0.0.1:3100";
|
||||||
const previewHost = process.env.RAKAZO_HOST ?? rootEnv.RAKAZO_HOST ?? "localhost";
|
const previewHost = process.env.RAKAZO_HOST ?? rootEnv.RAKAZO_HOST ?? "localhost";
|
||||||
const screenProxySecret = () =>
|
const screenProxySecret = () =>
|
||||||
@ -286,11 +286,19 @@ export default defineConfig(({ mode }) => {
|
|||||||
// Since cors: false disables Vite's CORS, we handle everything here.
|
// Since cors: false disables Vite's CORS, we handle everything here.
|
||||||
server.middlewares.use((req, res, next) => {
|
server.middlewares.use((req, res, next) => {
|
||||||
const origin = req.headers.origin;
|
const origin = req.headers.origin;
|
||||||
if (origin && typeof origin === "string") {
|
// Sandboxed screen documents send Origin: null. Reflecting it with
|
||||||
|
// credentials would let that opaque origin read app routes.
|
||||||
|
if (origin && origin !== "null" && typeof origin === "string") {
|
||||||
res.setHeader("access-control-allow-origin", origin);
|
res.setHeader("access-control-allow-origin", origin);
|
||||||
res.setHeader("access-control-allow-credentials", "true");
|
res.setHeader("access-control-allow-credentials", "true");
|
||||||
res.setHeader("access-control-allow-methods", "GET, HEAD, PUT, PATCH, POST, DELETE, OPTIONS");
|
res.setHeader(
|
||||||
res.setHeader("access-control-allow-headers", "content-type, x-rakazo-space-id, authorization");
|
"access-control-allow-methods",
|
||||||
|
"GET, HEAD, PUT, PATCH, POST, DELETE, OPTIONS",
|
||||||
|
);
|
||||||
|
res.setHeader(
|
||||||
|
"access-control-allow-headers",
|
||||||
|
"content-type, x-rakazo-space-id, authorization",
|
||||||
|
);
|
||||||
res.setHeader("access-control-max-age", "86400");
|
res.setHeader("access-control-max-age", "86400");
|
||||||
if (req.method === "OPTIONS") {
|
if (req.method === "OPTIONS") {
|
||||||
res.writeHead(204);
|
res.writeHead(204);
|
||||||
@ -305,10 +313,10 @@ export default defineConfig(({ mode }) => {
|
|||||||
],
|
],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
"react": reactPath,
|
react: reactPath,
|
||||||
"react-dom": reactDomPath,
|
"react-dom": reactDomPath,
|
||||||
"react/jsx-runtime": reactPath,
|
"react/jsx-runtime": path.join(reactPath, "jsx-runtime.js"),
|
||||||
"react/jsx-dev-runtime": reactPath,
|
"react/jsx-dev-runtime": path.join(reactPath, "jsx-dev-runtime.js"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://biomejs.dev/schemas/2.5.12/schema.json",
|
"$schema": "https://biomejs.dev/schemas/2.5.13/schema.json",
|
||||||
"vcs": {
|
"vcs": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"clientKind": "git",
|
"clientKind": "git",
|
||||||
@ -21,7 +21,9 @@
|
|||||||
"!**/verify-report",
|
"!**/verify-report",
|
||||||
"!**/data",
|
"!**/data",
|
||||||
"!**/*.astro",
|
"!**/*.astro",
|
||||||
"!**/apps/www"
|
"!**/apps/www",
|
||||||
|
"!**/.claude-handoff*",
|
||||||
|
"!**/screenshot*.cjs"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"formatter": {
|
"formatter": {
|
||||||
@ -84,7 +86,7 @@
|
|||||||
"infra/sandboxes/supervisor/**",
|
"infra/sandboxes/supervisor/**",
|
||||||
"infra/updater/**",
|
"infra/updater/**",
|
||||||
"packages/adapters/**",
|
"packages/adapters/**",
|
||||||
"packages/db/**",
|
"packages/db/src/**",
|
||||||
"packages/logging/**"
|
"packages/logging/**"
|
||||||
],
|
],
|
||||||
"linter": {
|
"linter": {
|
||||||
|
|||||||
@ -34,8 +34,9 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
updateClock();
|
updateClock();
|
||||||
|
|
||||||
// Helper: Strip ANSI color codes
|
// Helper: Strip ANSI color codes
|
||||||
|
const ansiCsi = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g");
|
||||||
function cleanAnsi(str) {
|
function cleanAnsi(str) {
|
||||||
return str.replace(/\u001b\[[0-9;]*m/g, "");
|
return str.replace(ansiCsi, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Logger
|
// Logger
|
||||||
|
|||||||
@ -40,15 +40,15 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
<button id="btn-self-improve" class="btn btn-secondary" title="Führt den vollständigen Verifizierungs- & Test-Loop aus">
|
<button type="button" id="btn-self-improve" class="btn btn-secondary" title="Führt den vollständigen Verifizierungs- & Test-Loop aus">
|
||||||
<span class="btn-icon">⚡</span>
|
<span class="btn-icon">⚡</span>
|
||||||
<span>Selbstoptimierung</span>
|
<span>Selbstoptimierung</span>
|
||||||
</button>
|
</button>
|
||||||
<button id="btn-killswitch" class="btn btn-warning" title="Notfall-Stopp aller autonomen Trading-Aktionen">
|
<button type="button" id="btn-killswitch" class="btn btn-warning" title="Notfall-Stopp aller autonomen Trading-Aktionen">
|
||||||
<span class="btn-icon">🛡️</span>
|
<span class="btn-icon">🛡️</span>
|
||||||
<span id="killswitch-text">Risk Guard: Aktiv</span>
|
<span id="killswitch-text">Risk Guard: Aktiv</span>
|
||||||
</button>
|
</button>
|
||||||
<button id="btn-hot-reload" class="btn btn-primary" title="Server-Synchronisation und Hot-Reload">
|
<button type="button" id="btn-hot-reload" class="btn btn-primary" title="Server-Synchronisation und Hot-Reload">
|
||||||
<span class="btn-icon">↺</span>
|
<span class="btn-icon">↺</span>
|
||||||
<span>Hot-Reload</span>
|
<span>Hot-Reload</span>
|
||||||
</button>
|
</button>
|
||||||
@ -118,7 +118,7 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="cto-actions">
|
<div class="cto-actions">
|
||||||
<button id="btn-open-steve-chat" class="btn-inline btn-purple">
|
<button type="button" id="btn-open-steve-chat" class="btn-inline btn-purple">
|
||||||
<span>💬 Chat mit Steve öffnen</span>
|
<span>💬 Chat mit Steve öffnen</span>
|
||||||
</button>
|
</button>
|
||||||
<div class="authority-pills">
|
<div class="authority-pills">
|
||||||
@ -318,8 +318,8 @@
|
|||||||
<h2>Self-Improvement & System Event Log</h2>
|
<h2>Self-Improvement & System Event Log</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="console-actions">
|
<div class="console-actions">
|
||||||
<button id="btn-toggle-console" class="btn-text">Minimieren</button>
|
<button type="button" id="btn-toggle-console" class="btn-text">Minimieren</button>
|
||||||
<button id="btn-clear-console" class="btn-text">Leeren</button>
|
<button type="button" id="btn-clear-console" class="btn-text">Leeren</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="terminal-container" id="terminal-logs">
|
<div class="terminal-container" id="terminal-logs">
|
||||||
|
|||||||
@ -40,7 +40,14 @@ export function isPrivilegedCockpitRoute(pathname) {
|
|||||||
|
|
||||||
export function parseCorsOriginAllowlist(value) {
|
export function parseCorsOriginAllowlist(value) {
|
||||||
if (!value?.trim()) return [];
|
if (!value?.trim()) return [];
|
||||||
return [...new Set(value.split(",").map((entry) => entry.trim()).filter(Boolean))];
|
return [
|
||||||
|
...new Set(
|
||||||
|
value
|
||||||
|
.split(",")
|
||||||
|
.map((entry) => entry.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -176,7 +176,11 @@ describe("applyCockpitCorsHeaders", () => {
|
|||||||
it("sets reflected origins and never sets *", () => {
|
it("sets reflected origins and never sets *", () => {
|
||||||
const cors = resolveCockpitCorsConfig({}, resolveCockpitConfig({}));
|
const cors = resolveCockpitCorsConfig({}, resolveCockpitConfig({}));
|
||||||
const headers = {};
|
const headers = {};
|
||||||
const res = { setHeader: (key, value) => { headers[key] = value; } };
|
const res = {
|
||||||
|
setHeader: (key, value) => {
|
||||||
|
headers[key] = value;
|
||||||
|
},
|
||||||
|
};
|
||||||
const allowed = applyCockpitCorsHeaders(
|
const allowed = applyCockpitCorsHeaders(
|
||||||
{ headers: { origin: "http://127.0.0.1:5173" } },
|
{ headers: { origin: "http://127.0.0.1:5173" } },
|
||||||
res,
|
res,
|
||||||
@ -193,7 +197,11 @@ describe("applyCockpitCorsHeaders", () => {
|
|||||||
it("omits Access-Control-Allow-Origin for disallowed cross-origin requests", () => {
|
it("omits Access-Control-Allow-Origin for disallowed cross-origin requests", () => {
|
||||||
const cors = resolveCockpitCorsConfig({}, resolveCockpitConfig({}));
|
const cors = resolveCockpitCorsConfig({}, resolveCockpitConfig({}));
|
||||||
const headers = {};
|
const headers = {};
|
||||||
const res = { setHeader: (key, value) => { headers[key] = value; } };
|
const res = {
|
||||||
|
setHeader: (key, value) => {
|
||||||
|
headers[key] = value;
|
||||||
|
},
|
||||||
|
};
|
||||||
const allowed = applyCockpitCorsHeaders(
|
const allowed = applyCockpitCorsHeaders(
|
||||||
{ headers: { origin: "https://evil.example" } },
|
{ headers: { origin: "https://evil.example" } },
|
||||||
res,
|
res,
|
||||||
|
|||||||
@ -241,7 +241,8 @@ describe("sandbox supervisor input containment", () => {
|
|||||||
expect(normalizeWorkspaceRelative("/notes\\result.txt")).toBe("notes/result.txt");
|
expect(normalizeWorkspaceRelative("/notes\\result.txt")).toBe("notes/result.txt");
|
||||||
expect(normalizeWorkspaceRelative("//notes///result.txt")).toBe("notes/result.txt");
|
expect(normalizeWorkspaceRelative("//notes///result.txt")).toBe("notes/result.txt");
|
||||||
expect(() => normalizeWorkspaceRelative("../outside")).toThrow(/escapes/);
|
expect(() => normalizeWorkspaceRelative("../outside")).toThrow(/escapes/);
|
||||||
expect(() => normalizeWorkspaceRelative("notes/./result.txt")).toThrow(/escapes/);
|
// "." is the current directory and stays inside the workspace.
|
||||||
|
expect(normalizeWorkspaceRelative("notes/./result.txt")).toBe("notes/result.txt");
|
||||||
expect(() => normalizeWorkspaceRelative("notes/../outside")).toThrow(/escapes/);
|
expect(() => normalizeWorkspaceRelative("notes/../outside")).toThrow(/escapes/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -29,7 +29,10 @@ describe("server image publish workflow", () => {
|
|||||||
.filter((entry) => entry.arch !== undefined)
|
.filter((entry) => entry.arch !== undefined)
|
||||||
.map((entry) => [entry.arch, entry.runner]),
|
.map((entry) => [entry.arch, entry.runner]),
|
||||||
);
|
);
|
||||||
expect(runners).toEqual({ amd64: "ubuntu-latest", arm64: "ubuntu-24.04-arm" });
|
expect(runners).toEqual({
|
||||||
|
amd64: ["self-hosted", "evoo-ci", "Linux"],
|
||||||
|
arm64: ["self-hosted", "evoo-ci", "macOS"],
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("publishes one verified multi-arch manifest per image after both builds", () => {
|
it("publishes one verified multi-arch manifest per image after both builds", () => {
|
||||||
|
|||||||
@ -600,6 +600,11 @@ export interface MessagingInboundMessage {
|
|||||||
channelName: string | null;
|
channelName: string | null;
|
||||||
/** Group roster addresses when the platform reports them; often empty. */
|
/** Group roster addresses when the platform reports them; often empty. */
|
||||||
participants: string[];
|
participants: string[];
|
||||||
|
/**
|
||||||
|
* True when `participants` is the complete roster. Partial webhooks leave this
|
||||||
|
* unset so members missing from the payload are not marked as having left.
|
||||||
|
*/
|
||||||
|
participantsAuthoritative?: boolean;
|
||||||
content: string;
|
content: string;
|
||||||
mediaUrl: string | null;
|
mediaUrl: string | null;
|
||||||
/** Team-room workspace/team id when the platform reports one (Slack team_id, …). */
|
/** Team-room workspace/team id when the platform reports one (Slack team_id, …). */
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { spawn } from "node:child_process";
|
import { spawn, spawnSync } from "node:child_process";
|
||||||
import { existsSync, mkdirSync, rmSync, symlinkSync } from "node:fs";
|
import { existsSync, mkdirSync, rmSync, symlinkSync } from "node:fs";
|
||||||
import type { AgentHomeStore, JobPublisher, SandboxProvider } from "@rakazo/adapter-kit";
|
import type { AgentHomeStore, JobPublisher, SandboxProvider } from "@rakazo/adapter-kit";
|
||||||
import type { PrismaClient, ThreadEvents } from "@rakazo/db";
|
import type { PrismaClient, ThreadEvents } from "@rakazo/db";
|
||||||
@ -244,6 +244,13 @@ describe("sandbox idle", () => {
|
|||||||
describe("background work launch and probe", () => {
|
describe("background work launch and probe", () => {
|
||||||
const children: ReturnType<typeof spawn>[] = [];
|
const children: ReturnType<typeof spawn>[] = [];
|
||||||
const markers = new Set<string>();
|
const markers = new Set<string>();
|
||||||
|
const pollOpts = { timeout: 10_000, interval: 100 };
|
||||||
|
|
||||||
|
function prepareMarker(path: string) {
|
||||||
|
markers.add(path);
|
||||||
|
// Persistent self-hosted runners can leave markers when a prior run aborts mid-test.
|
||||||
|
rmSync(path, { force: true, recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
for (const child of children.splice(0)) {
|
for (const child of children.splice(0)) {
|
||||||
@ -261,7 +268,7 @@ describe("background work launch and probe", () => {
|
|||||||
const databaseId = "computer-db-id";
|
const databaseId = "computer-db-id";
|
||||||
const providerRef = "provider-ref";
|
const providerRef = "provider-ref";
|
||||||
const launchId = "active";
|
const launchId = "active";
|
||||||
markers.add(`/tmp/rakazo-background-${databaseId}-run-1-${launchId}`);
|
prepareMarker(`/tmp/rakazo-background-${databaseId}-run-1-${launchId}`);
|
||||||
|
|
||||||
const launched = spawn(
|
const launched = spawn(
|
||||||
"bash",
|
"bash",
|
||||||
@ -278,7 +285,7 @@ describe("background work launch and probe", () => {
|
|||||||
);
|
);
|
||||||
children.push(launched);
|
children.push(launched);
|
||||||
|
|
||||||
await expect.poll(() => probeBackgroundWork(databaseId)).toBe(0);
|
await expect.poll(() => probeBackgroundWork(databaseId), pollOpts).toBe(0);
|
||||||
// ComputerRef.id is providerRef today; probing that path must not see the DB-id marker.
|
// ComputerRef.id is providerRef today; probing that path must not see the DB-id marker.
|
||||||
expect(await probeBackgroundWork(providerRef)).toBe(1);
|
expect(await probeBackgroundWork(providerRef)).toBe(1);
|
||||||
},
|
},
|
||||||
@ -290,8 +297,8 @@ describe("background work launch and probe", () => {
|
|||||||
const markerId = "computer-relaunch-id";
|
const markerId = "computer-relaunch-id";
|
||||||
const completedMarker = `/tmp/rakazo-background-${markerId}-run-1-completed`;
|
const completedMarker = `/tmp/rakazo-background-${markerId}-run-1-completed`;
|
||||||
const activeMarker = `/tmp/rakazo-background-${markerId}-run-1-active`;
|
const activeMarker = `/tmp/rakazo-background-${markerId}-run-1-active`;
|
||||||
markers.add(completedMarker);
|
prepareMarker(completedMarker);
|
||||||
markers.add(activeMarker);
|
prepareMarker(activeMarker);
|
||||||
const completed = spawn(
|
const completed = spawn(
|
||||||
"bash",
|
"bash",
|
||||||
[
|
[
|
||||||
@ -325,7 +332,7 @@ describe("background work launch and probe", () => {
|
|||||||
{ stdio: "ignore" },
|
{ stdio: "ignore" },
|
||||||
);
|
);
|
||||||
children.push(active);
|
children.push(active);
|
||||||
await expect.poll(() => probeBackgroundWork(markerId)).toBe(0);
|
await expect.poll(() => probeBackgroundWork(markerId), pollOpts).toBe(0);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -335,8 +342,8 @@ describe("background work launch and probe", () => {
|
|||||||
const markerId = "computer-marker-error";
|
const markerId = "computer-marker-error";
|
||||||
const marker = `/tmp/rakazo-background-${markerId}-run-1-collision`;
|
const marker = `/tmp/rakazo-background-${markerId}-run-1-collision`;
|
||||||
const commandRan = `/tmp/rakazo-background-command-ran-${markerId}`;
|
const commandRan = `/tmp/rakazo-background-command-ran-${markerId}`;
|
||||||
markers.add(marker);
|
prepareMarker(marker);
|
||||||
markers.add(commandRan);
|
prepareMarker(commandRan);
|
||||||
mkdirSync(marker);
|
mkdirSync(marker);
|
||||||
const launched = spawn(
|
const launched = spawn(
|
||||||
"bash",
|
"bash",
|
||||||
@ -364,8 +371,8 @@ describe("background work launch and probe", () => {
|
|||||||
const markerId = "computer-marker-symlink";
|
const markerId = "computer-marker-symlink";
|
||||||
const marker = `/tmp/rakazo-background-${markerId}-run-1-collision`;
|
const marker = `/tmp/rakazo-background-${markerId}-run-1-collision`;
|
||||||
const commandRan = `/tmp/rakazo-background-command-ran-${markerId}`;
|
const commandRan = `/tmp/rakazo-background-command-ran-${markerId}`;
|
||||||
markers.add(marker);
|
prepareMarker(marker);
|
||||||
markers.add(commandRan);
|
prepareMarker(commandRan);
|
||||||
symlinkSync(commandRan, marker);
|
symlinkSync(commandRan, marker);
|
||||||
const launched = spawn(
|
const launched = spawn(
|
||||||
"bash",
|
"bash",
|
||||||
@ -394,7 +401,7 @@ describe("background work launch and probe", () => {
|
|||||||
const runId = "run-cancel-1";
|
const runId = "run-cancel-1";
|
||||||
const launchId = "active";
|
const launchId = "active";
|
||||||
const marker = `/tmp/rakazo-background-${computerId}-${runId}-${launchId}`;
|
const marker = `/tmp/rakazo-background-${computerId}-${runId}-${launchId}`;
|
||||||
markers.add(marker);
|
prepareMarker(marker);
|
||||||
|
|
||||||
const launched = spawn(
|
const launched = spawn(
|
||||||
"bash",
|
"bash",
|
||||||
@ -411,7 +418,7 @@ describe("background work launch and probe", () => {
|
|||||||
);
|
);
|
||||||
children.push(launched);
|
children.push(launched);
|
||||||
|
|
||||||
await expect.poll(() => probeBackgroundWork(computerId)).toBe(0);
|
await expect.poll(() => probeBackgroundWork(computerId), pollOpts).toBe(0);
|
||||||
|
|
||||||
const launchedDone = processExit(launched);
|
const launchedDone = processExit(launched);
|
||||||
const cancel = spawn(
|
const cancel = spawn(
|
||||||
@ -425,6 +432,48 @@ describe("background work launch and probe", () => {
|
|||||||
expect(await probeBackgroundWork(computerId)).toBe(1);
|
expect(await probeBackgroundWork(computerId)).toBe(1);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
it.skipIf(process.platform !== "linux" || !existsSync("/dev/shm"))(
|
||||||
|
"detects and cancels a flock when /proc/locks zero-pads the device id",
|
||||||
|
async () => {
|
||||||
|
const root = "/dev/shm";
|
||||||
|
const computerId = "computer-shm-pad";
|
||||||
|
const runId = "run-1";
|
||||||
|
const launchId = "active";
|
||||||
|
const marker = `${root}/rakazo-background-${computerId}-${runId}-${launchId}`;
|
||||||
|
prepareMarker(marker);
|
||||||
|
const prefix = "/tmp/rakazo-background-";
|
||||||
|
const shmPrefix = `${root}/rakazo-background-`;
|
||||||
|
const launch = BACKGROUND_WORK_LAUNCH.replaceAll(prefix, shmPrefix);
|
||||||
|
const probe = BACKGROUND_WORK_PROBE.replaceAll(prefix, shmPrefix);
|
||||||
|
const cancelScript = CANCEL_COMPUTER_RUN_WORK.replaceAll(prefix, shmPrefix);
|
||||||
|
|
||||||
|
const launched = spawn(
|
||||||
|
"bash",
|
||||||
|
["-c", launch, "rakazo-background-launch", computerId, runId, launchId, "exec sleep 30"],
|
||||||
|
{ stdio: "ignore" },
|
||||||
|
);
|
||||||
|
children.push(launched);
|
||||||
|
|
||||||
|
await expect.poll(() => probeBackgroundWork(computerId, probe), pollOpts).toBe(0);
|
||||||
|
const listed = spawnSync("stat", ["-c", "%Hd %Ld", marker], { encoding: "utf8" });
|
||||||
|
const parts = listed.stdout.trim().split(/\s+/).map(Number);
|
||||||
|
expect(parts.length).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(parts[0]! < 16 || parts[1]! < 16).toBe(true);
|
||||||
|
|
||||||
|
const launchedDone = processExit(launched);
|
||||||
|
const cancel = spawn(
|
||||||
|
"bash",
|
||||||
|
["-c", cancelScript, "rakazo-cancel-run-work", computerId, runId],
|
||||||
|
{ stdio: "ignore" },
|
||||||
|
);
|
||||||
|
children.push(cancel);
|
||||||
|
expect(await processExit(cancel)).toBe(0);
|
||||||
|
await launchedDone;
|
||||||
|
expect(await probeBackgroundWork(computerId, probe)).toBe(1);
|
||||||
|
expect(existsSync(marker)).toBe(false);
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("CANCEL_PRIMARY_BROWSER_WORK", () => {
|
describe("CANCEL_PRIMARY_BROWSER_WORK", () => {
|
||||||
@ -545,13 +594,11 @@ function idleHarness(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function probeBackgroundWork(markerId: string): Promise<number> {
|
function probeBackgroundWork(markerId: string, script = BACKGROUND_WORK_PROBE): Promise<number> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const child = spawn(
|
const child = spawn("bash", ["-c", script, "rakazo-background-probe", markerId], {
|
||||||
"bash",
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
["-c", BACKGROUND_WORK_PROBE, "rakazo-background-probe", markerId],
|
});
|
||||||
{ stdio: ["ignore", "pipe", "pipe"] },
|
|
||||||
);
|
|
||||||
child.on("error", reject);
|
child.on("error", reject);
|
||||||
child.on("close", (code) => resolve(code ?? 1));
|
child.on("close", (code) => resolve(code ?? 1));
|
||||||
});
|
});
|
||||||
|
|||||||
@ -18,6 +18,14 @@ const BACKGROUND_WORK_IDLE_SENTINEL = "rakazo-background-idle";
|
|||||||
|
|
||||||
export const BACKGROUND_WORK_LAUNCH = [
|
export const BACKGROUND_WORK_LAUNCH = [
|
||||||
`marker="${BACKGROUND_WORK_MARKER_PREFIX}$1-$2-$3"`,
|
`marker="${BACKGROUND_WORK_MARKER_PREFIX}$1-$2-$3"`,
|
||||||
|
// Refuse an existing path before flock: flock follows symlinks and can lock a directory.
|
||||||
|
'if [ -e "$marker" ] || [ -L "$marker" ]; then',
|
||||||
|
" exit 1",
|
||||||
|
"fi",
|
||||||
|
// Hold the marker with flock on the command process. A full /proc fd walk hangs on a busy host.
|
||||||
|
"if [ -r /proc/locks ] && command -v flock >/dev/null 2>&1 && command -v stat >/dev/null 2>&1; then",
|
||||||
|
' exec flock -n -F "$marker" bash -lc "$4"',
|
||||||
|
"fi",
|
||||||
"set -o noclobber",
|
"set -o noclobber",
|
||||||
'exec 9>"$marker" || exit 1',
|
'exec 9>"$marker" || exit 1',
|
||||||
"set +o noclobber",
|
"set +o noclobber",
|
||||||
@ -32,6 +40,34 @@ export const CANCEL_COMPUTER_RUN_WORK = [
|
|||||||
`prefix="${BACKGROUND_WORK_MARKER_PREFIX}$computerId-$runId-"`,
|
`prefix="${BACKGROUND_WORK_MARKER_PREFIX}$computerId-$runId-"`,
|
||||||
// Match the timeout wrapper cmdline (still contains the launch tag after exec into the user command).
|
// Match the timeout wrapper cmdline (still contains the launch tag after exec into the user command).
|
||||||
'pkill -TERM -f "rakazo-background-launch ${computerId} ${runId} " 2>/dev/null || true',
|
'pkill -TERM -f "rakazo-background-launch ${computerId} ${runId} " 2>/dev/null || true',
|
||||||
|
"if [ -r /proc/locks ] && command -v flock >/dev/null 2>&1 && command -v stat >/dev/null 2>&1; then",
|
||||||
|
" signal_lock_holders() {",
|
||||||
|
' sig="$1"',
|
||||||
|
' for marker in "$prefix"*; do',
|
||||||
|
' [ -f "$marker" ] && [ ! -L "$marker" ] || continue',
|
||||||
|
' major="$(stat -c %Hd -- "$marker" 2>/dev/null)" || continue',
|
||||||
|
' minor="$(stat -c %Ld -- "$marker" 2>/dev/null)" || continue',
|
||||||
|
' inode="$(stat -c %i -- "$marker" 2>/dev/null)" || continue',
|
||||||
|
// fs/locks.c prints major:minor as %02x. Bare %x drops the leading zero for ids under 16.
|
||||||
|
' key="$(printf %02x:%02x:%s "$major" "$minor" "$inode")"',
|
||||||
|
" while read -r _id type _sub _mode pid dev _rest; do",
|
||||||
|
' [ "$type" = "FLOCK" ] || continue',
|
||||||
|
' [ "$dev" = "$key" ] || continue',
|
||||||
|
' case "$pid" in',
|
||||||
|
' ""|*[!0-9]*) continue ;;',
|
||||||
|
" esac",
|
||||||
|
// Never kill -PID (process group): sandbox work often shares the caller's PGID.
|
||||||
|
' kill "-$sig" "$pid" 2>/dev/null || true',
|
||||||
|
" done < /proc/locks",
|
||||||
|
" done",
|
||||||
|
" }",
|
||||||
|
" signal_lock_holders TERM",
|
||||||
|
" sleep 0.2",
|
||||||
|
' pkill -KILL -f "rakazo-background-launch ${computerId} ${runId} " 2>/dev/null || true',
|
||||||
|
" signal_lock_holders KILL",
|
||||||
|
' rm -f -- "$prefix"* 2>/dev/null || true',
|
||||||
|
" exit 0",
|
||||||
|
"fi",
|
||||||
"if [ -d /proc ]; then",
|
"if [ -d /proc ]; then",
|
||||||
" for fd in /proc/[0-9]*/fd/*; do",
|
" for fd in /proc/[0-9]*/fd/*; do",
|
||||||
' target="$(readlink "$fd" 2>/dev/null)" || continue',
|
' target="$(readlink "$fd" 2>/dev/null)" || continue',
|
||||||
@ -143,6 +179,29 @@ export const BACKGROUND_WORK_PROBE = [
|
|||||||
`prefix="${BACKGROUND_WORK_MARKER_PREFIX}$1-"`,
|
`prefix="${BACKGROUND_WORK_MARKER_PREFIX}$1-"`,
|
||||||
`idle() { printf '${BACKGROUND_WORK_IDLE_SENTINEL}\\n'; exit 1; }`,
|
`idle() { printf '${BACKGROUND_WORK_IDLE_SENTINEL}\\n'; exit 1; }`,
|
||||||
'markers=("$prefix"*)',
|
'markers=("$prefix"*)',
|
||||||
|
// Same gate as launch. Do not fall through to an fd walk: that walk is what timed out on CI,
|
||||||
|
// including the idle path that must delete a finished marker and return quickly.
|
||||||
|
// Try the lock instead of parsing /proc/locks. stat major/minor are decimal, and the kernel
|
||||||
|
// prints them as %02x, so a bare %x key never matches tmpfs (00:) or disk partitions (08:01).
|
||||||
|
// A mismatch used to delete the live marker, so every later probe stayed idle.
|
||||||
|
"if [ -r /proc/locks ] && command -v flock >/dev/null 2>&1 && command -v stat >/dev/null 2>&1; then",
|
||||||
|
' for marker in "${markers[@]}"; do',
|
||||||
|
' [ -e "$marker" ] || [ -L "$marker" ] || continue',
|
||||||
|
' if [ -L "$marker" ] || [ ! -f "$marker" ]; then',
|
||||||
|
' rm -f -- "$marker" 2>/dev/null || true',
|
||||||
|
" continue",
|
||||||
|
" fi",
|
||||||
|
// 99 is the lock conflict. Exit 1 is the idle sentinel and must not be reused here.
|
||||||
|
' if flock -n -E 99 -- "$marker" true; then',
|
||||||
|
' rm -f -- "$marker"',
|
||||||
|
" else",
|
||||||
|
' status="$?"',
|
||||||
|
' [ "$status" -eq 99 ] && exit 0',
|
||||||
|
" exit 2",
|
||||||
|
" fi",
|
||||||
|
" done",
|
||||||
|
" idle",
|
||||||
|
"fi",
|
||||||
"if [ -d /proc ]; then",
|
"if [ -d /proc ]; then",
|
||||||
" command -v readlink >/dev/null 2>&1 || exit 2",
|
" command -v readlink >/dev/null 2>&1 || exit 2",
|
||||||
" for fd in /proc/[0-9]*/fd/*; do",
|
" for fd in /proc/[0-9]*/fd/*; do",
|
||||||
|
|||||||
@ -1475,6 +1475,7 @@ describe("computer execution leases", () => {
|
|||||||
.mockResolvedValue({ nextEventSeq: 1 }),
|
.mockResolvedValue({ nextEventSeq: 1 }),
|
||||||
},
|
},
|
||||||
run: {
|
run: {
|
||||||
|
count: vi.fn().mockResolvedValue(0),
|
||||||
findMany: vi.fn().mockResolvedValue([{ id: "run-1", taskId: "task-1" }]),
|
findMany: vi.fn().mockResolvedValue([{ id: "run-1", taskId: "task-1" }]),
|
||||||
updateMany: vi.fn(),
|
updateMany: vi.fn(),
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,10 +1,12 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import type { PrismaClient } from "@rakazo/db";
|
import type { PrismaClient } from "@rakazo/db";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
import { createProject } from "./create-project.js";
|
import { createProject } from "./create-project.js";
|
||||||
|
|
||||||
describe("createProject", () => {
|
describe("createProject", () => {
|
||||||
it("requires a non-empty project name", async () => {
|
it("requires a non-empty project name", async () => {
|
||||||
const result = await createProject({ prisma: {} as PrismaClient }, {
|
const result = await createProject(
|
||||||
|
{ prisma: {} as PrismaClient },
|
||||||
|
{
|
||||||
actor: {
|
actor: {
|
||||||
userId: "user-1",
|
userId: "user-1",
|
||||||
spaceId: "space-1",
|
spaceId: "space-1",
|
||||||
@ -14,12 +16,15 @@ describe("createProject", () => {
|
|||||||
createdByBotId: "bot-a",
|
createdByBotId: "bot-a",
|
||||||
name: " ",
|
name: " ",
|
||||||
botIds: ["bot-b"],
|
botIds: ["bot-b"],
|
||||||
});
|
},
|
||||||
|
);
|
||||||
expect(result).toEqual({ error: "Project name is required." });
|
expect(result).toEqual({ error: "Project name is required." });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("requires at least two agents including the creator", async () => {
|
it("requires at least two agents including the creator", async () => {
|
||||||
const result = await createProject({ prisma: {} as PrismaClient }, {
|
const result = await createProject(
|
||||||
|
{ prisma: {} as PrismaClient },
|
||||||
|
{
|
||||||
actor: {
|
actor: {
|
||||||
userId: "user-1",
|
userId: "user-1",
|
||||||
spaceId: "space-1",
|
spaceId: "space-1",
|
||||||
@ -29,7 +34,8 @@ describe("createProject", () => {
|
|||||||
createdByBotId: "bot-a",
|
createdByBotId: "bot-a",
|
||||||
name: "Website Redesign",
|
name: "Website Redesign",
|
||||||
botIds: [],
|
botIds: [],
|
||||||
});
|
},
|
||||||
|
);
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
error: "At least 2 agents are required. Include bot_id values for other teammates.",
|
error: "At least 2 agents are required. Include bot_id values for other teammates.",
|
||||||
});
|
});
|
||||||
|
|||||||
@ -22,10 +22,7 @@ export function resolveDesktopExecuteCwd(requestCwd: string | undefined, home: s
|
|||||||
return home;
|
return home;
|
||||||
}
|
}
|
||||||
if (requestCwd.startsWith("/home/rakazo/")) {
|
if (requestCwd.startsWith("/home/rakazo/")) {
|
||||||
return path.join(
|
return path.join(home, normalizeDesktopWorkspacePath(requestCwd.slice("/home/rakazo/".length)));
|
||||||
home,
|
|
||||||
normalizeDesktopWorkspacePath(requestCwd.slice("/home/rakazo/".length)),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (path.isAbsolute(requestCwd)) {
|
if (path.isAbsolute(requestCwd)) {
|
||||||
// Host-absolute paths must not be rebased into the workspace; containment rejects them.
|
// Host-absolute paths must not be rebased into the workspace; containment rejects them.
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import type { ExecutorDeps } from "./executor.js";
|
||||||
import {
|
import {
|
||||||
COMPUTER_BUSY_EXHAUSTED_ERROR,
|
COMPUTER_BUSY_EXHAUSTED_ERROR,
|
||||||
COMPUTER_BUSY_MAX_ATTEMPTS,
|
COMPUTER_BUSY_MAX_ATTEMPTS,
|
||||||
@ -11,7 +12,6 @@ import {
|
|||||||
requeueComputerBusyRun,
|
requeueComputerBusyRun,
|
||||||
shouldRetryComputerBusy,
|
shouldRetryComputerBusy,
|
||||||
} from "./executor-computer-busy-retry.js";
|
} from "./executor-computer-busy-retry.js";
|
||||||
import type { ExecutorDeps } from "./executor.js";
|
|
||||||
|
|
||||||
describe("computer busy retry policy", () => {
|
describe("computer busy retry policy", () => {
|
||||||
it("tracks consecutive busy retries in an internal run.error marker", () => {
|
it("tracks consecutive busy retries in an internal run.error marker", () => {
|
||||||
|
|||||||
@ -2,11 +2,7 @@ import { runContinueJob } from "@rakazo/adapter-kit";
|
|||||||
import { getLogger } from "@rakazo/logging";
|
import { getLogger } from "@rakazo/logging";
|
||||||
import { returnBotMessageOutcome } from "./bot-messages.js";
|
import { returnBotMessageOutcome } from "./bot-messages.js";
|
||||||
import type { ExecutorDeps } from "./executor.js";
|
import type { ExecutorDeps } from "./executor.js";
|
||||||
import {
|
import { computerRetryDelay, notifyRun, writeComputerRunRequeue } from "./executor-computer.js";
|
||||||
computerRetryDelay,
|
|
||||||
notifyRun,
|
|
||||||
writeComputerRunRequeue,
|
|
||||||
} from "./executor-computer.js";
|
|
||||||
import type { TakeoverResumeCheckpoint } from "./takeover-resume.js";
|
import type { TakeoverResumeCheckpoint } from "./takeover-resume.js";
|
||||||
|
|
||||||
export const COMPUTER_BUSY_MAX_ATTEMPTS = 24;
|
export const COMPUTER_BUSY_MAX_ATTEMPTS = 24;
|
||||||
|
|||||||
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