Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9976ad081b |
@@ -44,39 +44,6 @@
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Ensure jq is available. Runners may not have it pre-installed, and the
|
||||
# workflow-level jq install can fail on runners with network restrictions
|
||||
# (GitHub releases not reachable from some runner networks — infra#241
|
||||
# follow-up). This fallback is idempotent — no-op when jq is already on PATH.
|
||||
# SOP_FAIL_OPEN=1 makes this always exit 0 so CI never blocks on jq absence.
|
||||
if ! command -v jq >/dev/null 2>&1; then
|
||||
echo "::notice::jq not found on PATH — attempting install..."
|
||||
_jq_installed="no"
|
||||
# apt-get first (primary) — Ubuntu package mirrors are reliably reachable.
|
||||
if apt-get update -qq && apt-get install -y -qq jq 2>/dev/null; then
|
||||
echo "::notice::jq installed via apt-get: $(jq --version)"
|
||||
_jq_installed="yes"
|
||||
# GitHub binary as secondary fallback — may fail on restricted networks.
|
||||
elif timeout 120 curl -sSL \
|
||||
"https://github.com/jqlang/jq/releases/download/jq-1.7.1/jq-linux-amd64" \
|
||||
-o /usr/local/bin/jq \
|
||||
&& chmod +x /usr/local/bin/jq; then
|
||||
echo "::notice::jq binary downloaded: $(/usr/local/bin/jq --version)"
|
||||
_jq_installed="yes"
|
||||
fi
|
||||
if ! command -v jq >/dev/null 2>&1; then
|
||||
echo "::error::jq installation failed — apt-get and GitHub binary both failed."
|
||||
echo "::error::sop-tier-check requires jq for all JSON API parsing."
|
||||
# SOP_FAIL_OPEN=1 is set in the workflow step's env — makes script always
|
||||
# exit 0 so CI never blocks. The SOP-6 tier review gate remains enforced.
|
||||
if [ "${SOP_FAIL_OPEN:-}" = "1" ]; then
|
||||
echo "::warning::SOP_FAIL_OPEN=1 — exiting 0 so CI does not block."
|
||||
exit 0
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
debug() {
|
||||
if [ "${SOP_DEBUG:-}" = "1" ]; then
|
||||
echo " [debug] $*" >&2
|
||||
@@ -96,27 +63,16 @@ API="https://${GITEA_HOST}/api/v1"
|
||||
AUTH="Authorization: token ${GITEA_TOKEN}"
|
||||
echo "::notice::tier-check start: repo=$OWNER/$NAME pr=$PR_NUMBER author=$PR_AUTHOR"
|
||||
|
||||
# Sanity: token resolves to a user.
|
||||
# Use || true on the jq pipeline so that set -euo pipefail (line 45) does not
|
||||
# cause the script to exit prematurely when the token is empty/invalid — the
|
||||
# if check below handles that case gracefully. Without || true, a 401 from an
|
||||
# empty/invalid token causes jq to exit 1, triggering set -e and exiting the
|
||||
# entire script before SOP_FAIL_OPEN can be evaluated (the check is in the jq-
|
||||
# install block; if jq is already on PATH, that block is skipped entirely).
|
||||
WHOAMI=$(curl -sS -H "$AUTH" "${API}/user" | jq -r '.login // ""') || true
|
||||
# Sanity: token resolves to a user
|
||||
WHOAMI=$(curl -sS -H "$AUTH" "${API}/user" | jq -r '.login // ""')
|
||||
if [ -z "$WHOAMI" ]; then
|
||||
echo "::error::GITEA_TOKEN cannot resolve a user via /api/v1/user — check the token scope and that the secret is wired correctly."
|
||||
if [ "${SOP_FAIL_OPEN:-}" = "1" ]; then
|
||||
echo "::warning::SOP_FAIL_OPEN=1 — exiting 0 so CI does not block."
|
||||
exit 0
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
echo "::notice::token resolves to user: $WHOAMI"
|
||||
|
||||
# 1. Read tier label. || true ensures set -euo pipefail does not abort the
|
||||
# script if curl or jq fails (e.g. 401 from empty token).
|
||||
LABELS=$(curl -sS -H "$AUTH" "${API}/repos/${OWNER}/${NAME}/issues/${PR_NUMBER}/labels" | jq -r '.[].name') || true
|
||||
# 1. Read tier label
|
||||
LABELS=$(curl -sS -H "$AUTH" "${API}/repos/${OWNER}/${NAME}/issues/${PR_NUMBER}/labels" | jq -r '.[].name')
|
||||
TIER=""
|
||||
for L in $LABELS; do
|
||||
case "$L" in
|
||||
@@ -187,25 +143,17 @@ fi
|
||||
# 4. Resolve all team names → IDs
|
||||
# /orgs/{org}/teams/{slug}/... endpoints don't exist on Gitea 1.22;
|
||||
# we use /teams/{id}.
|
||||
# set +e prevents set -e from aborting the script if curl fails (e.g. empty token).
|
||||
ORG_TEAMS_FILE=$(mktemp)
|
||||
trap 'rm -f "$ORG_TEAMS_FILE"' EXIT
|
||||
set +e
|
||||
HTTP_CODE=$(curl -sS -o "$ORG_TEAMS_FILE" -w '%{http_code}' -H "$AUTH" \
|
||||
"${API}/orgs/${OWNER}/teams")
|
||||
_HTTP_EXIT=$?
|
||||
set -e
|
||||
debug "teams-list HTTP=$HTTP_CODE (curl exit=$_HTTP_EXIT) size=$(wc -c <"$ORG_TEAMS_FILE")"
|
||||
debug "teams-list HTTP=$HTTP_CODE size=$(wc -c <"$ORG_TEAMS_FILE")"
|
||||
if [ "${SOP_DEBUG:-}" = "1" ]; then
|
||||
echo " [debug] teams-list body (first 300 chars):" >&2
|
||||
head -c 300 "$ORG_TEAMS_FILE" >&2; echo >&2
|
||||
fi
|
||||
if [ "$_HTTP_EXIT" -ne 0 ] || [ "$HTTP_CODE" != "200" ]; then
|
||||
echo "::error::GET /orgs/${OWNER}/teams failed (curl exit=$_HTTP_EXIT HTTP=$HTTP_CODE) — token may lack read:org scope or be invalid."
|
||||
if [ "${SOP_FAIL_OPEN:-}" = "1" ]; then
|
||||
echo "::warning::SOP_FAIL_OPEN=1 — exiting 0 so CI does not block."
|
||||
exit 0
|
||||
fi
|
||||
if [ "$HTTP_CODE" != "200" ]; then
|
||||
echo "::error::GET /orgs/${OWNER}/teams returned HTTP $HTTP_CODE — token likely lacks read:org scope."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -250,22 +198,9 @@ for _t in $_all_teams; do
|
||||
debug "team-id: $_t → $_id"
|
||||
done
|
||||
|
||||
# 5. Read approving reviewers. set +e disables set -e temporarily so that curl
|
||||
# failures (e.g. empty/invalid token → HTTP 401) do not abort the script before
|
||||
# SOP_FAIL_OPEN is evaluated. set -e is restored immediately after.
|
||||
set +e
|
||||
# 5. Read approving reviewers
|
||||
REVIEWS=$(curl -sS -H "$AUTH" "${API}/repos/${OWNER}/${NAME}/pulls/${PR_NUMBER}/reviews")
|
||||
_REVIEWS_EXIT=$?
|
||||
set -e
|
||||
if [ $_REVIEWS_EXIT -ne 0 ] || [ -z "$REVIEWS" ]; then
|
||||
echo "::error::Failed to fetch reviews (curl exit=$_REVIEWS_EXIT) — token may be invalid or unreachable."
|
||||
if [ "${SOP_FAIL_OPEN:-}" = "1" ]; then
|
||||
echo "::warning::SOP_FAIL_OPEN=1 — exiting 0 so CI does not block."
|
||||
exit 0
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
APPROVERS=$(echo "$REVIEWS" | jq -r '[.[] | select(.state=="APPROVED") | .user.login] | unique | .[]') || true
|
||||
APPROVERS=$(echo "$REVIEWS" | jq -r '[.[] | select(.state=="APPROVED") | .user.login] | unique | .[]')
|
||||
if [ -z "$APPROVERS" ]; then
|
||||
echo "::error::No approving reviews on this PR. Set SOP_DEBUG=1 and re-run for diagnostics."
|
||||
exit 1
|
||||
|
||||
@@ -23,7 +23,7 @@ name: publish-workspace-server-image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
branches: [staging, main]
|
||||
paths:
|
||||
- 'workspace-server/**'
|
||||
- 'canvas/**'
|
||||
@@ -32,9 +32,11 @@ on:
|
||||
- '.gitea/workflows/publish-workspace-server-image.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
# Serialize per-branch so two rapid main pushes don't race the same
|
||||
# :staging-latest tag retag. Allow parallel runs as they produce
|
||||
# different :staging-<sha> tags and last-write-wins on :staging-latest.
|
||||
# Serialize per-branch so two rapid staging pushes don't race the same
|
||||
# :staging-latest tag retag. Allow staging and main to run in parallel
|
||||
# (different GITHUB_REF → different concurrency group) since they
|
||||
# produce different :staging-<sha> tags and last-write-wins on
|
||||
# :staging-latest is acceptable across branches.
|
||||
#
|
||||
# cancel-in-progress: false → in-flight builds finish; the next push's
|
||||
# build queues. This avoids a partially-pushed image.
|
||||
@@ -57,25 +59,6 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
# Health check: verify Docker daemon is accessible before attempting any
|
||||
# build steps. This fails loudly at step 1 when the runner's docker.sock
|
||||
# is inaccessible (e.g. permission change, daemon restart, or group-membership
|
||||
# drift) rather than silently continuing to step 2 where `docker build`
|
||||
# fails deep in the process with a cryptic ECR auth error that doesn't
|
||||
# surface the root cause. Also reports the daemon version so operator
|
||||
# can correlate with runner host logs.
|
||||
- name: Verify Docker daemon access
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "::group::Docker daemon health check"
|
||||
docker info 2>&1 | head -5 || {
|
||||
echo "::error::Docker daemon is not accessible at /var/run/docker.sock"
|
||||
echo "::error::Check: (1) daemon is running, (2) runner user is in docker group, (3) sock permissions are 660+"
|
||||
exit 1
|
||||
}
|
||||
echo "Docker daemon OK"
|
||||
echo "::endgroup::"
|
||||
|
||||
# Pre-clone manifest deps before docker build.
|
||||
#
|
||||
# Why: workspace-template-* repos on Gitea are private. The pre-fix
|
||||
|
||||
@@ -77,50 +77,24 @@ jobs:
|
||||
# works if we never check out PR HEAD. Same SHA the workflow
|
||||
# itself was loaded from.
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
- name: Install jq
|
||||
# Gitea Actions runners (ubuntu-latest label) do not bundle jq.
|
||||
# The sop-tier-check script uses jq for all JSON API parsing.
|
||||
# Install jq before the script runs so sop-tier-check can pass.
|
||||
#
|
||||
# Method: apt-get first (reliable for Ubuntu runners with internet
|
||||
# access to package mirrors). Falls back to GitHub binary download.
|
||||
# GitHub releases may be unreachable from some runner networks
|
||||
# (infra#241 follow-up: GitHub timeout after 3s on 5.78.80.188
|
||||
# runners). The sop-tier-check script has its own fallback as a
|
||||
# third line of defense. continue-on-error: true ensures this step
|
||||
# failing does not block the job.
|
||||
continue-on-error: true
|
||||
run: |
|
||||
# apt-get is the primary method — Ubuntu package mirrors are reliably
|
||||
# reachable from runner containers. GitHub releases may be blocked
|
||||
# or slow on some networks (infra#241 follow-up).
|
||||
if apt-get update -qq && apt-get install -y -qq jq; then
|
||||
echo "::notice::jq installed via apt-get: $(jq --version)"
|
||||
elif timeout 120 curl -sSL \
|
||||
"https://github.com/jqlang/jq/releases/download/jq-1.7.1/jq-linux-amd64" \
|
||||
-o /usr/local/bin/jq && chmod +x /usr/local/bin/jq; then
|
||||
echo "::notice::jq binary downloaded: $(/usr/local/bin/jq --version)"
|
||||
else
|
||||
echo "::warning::jq install failed — apt-get and GitHub download both failed."
|
||||
fi
|
||||
jq --version 2>/dev/null || echo "::notice::jq not yet available — script fallback will retry"
|
||||
|
||||
- name: Verify tier label + reviewer team membership
|
||||
# continue-on-error: true at step level — job-level is ignored by Gitea
|
||||
# Actions (quirk #10, internal runbooks). Belt-and-suspenders with
|
||||
# SOP_FAIL_OPEN=1 + || true below.
|
||||
continue-on-error: true
|
||||
env:
|
||||
# SOP_TIER_CHECK_TOKEN is the org-level secret for the
|
||||
# sop-tier-bot PAT (read:organization,read:user,read:issue,
|
||||
# read:repository). Stored at the org level
|
||||
# (/api/v1/orgs/molecule-ai/actions/secrets) so per-repo
|
||||
# configuration is unnecessary — every repo in the org
|
||||
# picks it up automatically.
|
||||
# Falls back to GITHUB_TOKEN with a clear error if missing.
|
||||
GITEA_TOKEN: ${{ secrets.SOP_TIER_CHECK_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
GITEA_HOST: git.moleculesai.app
|
||||
REPO: ${{ github.repository }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
|
||||
# Set to '1' for diagnostic per-API-call output. Off by default
|
||||
# so production logs aren't noisy.
|
||||
SOP_DEBUG: '0'
|
||||
# BURN-IN: set to '1' for PRs in-flight at AND-composition deploy
|
||||
# time to use the legacy OR-gate. Remove after 2026-05-17.
|
||||
SOP_LEGACY_CHECK: '0'
|
||||
# SOP_FAIL_OPEN=1 makes the script always exit 0. The UI enforces
|
||||
# the actual merge gate. Combined with continue-on-error: true
|
||||
# above, this step never fails the job regardless of script exit.
|
||||
SOP_FAIL_OPEN: '1'
|
||||
run: |
|
||||
bash .gitea/scripts/sop-tier-check.sh || true
|
||||
run: bash .gitea/scripts/sop-tier-check.sh
|
||||
|
||||
@@ -54,22 +54,6 @@ jobs:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
# Health check: verify Docker daemon is accessible before attempting any
|
||||
# build steps. This fails loudly at step 1 when the runner's docker.sock
|
||||
# is inaccessible rather than silently continuing to the build step
|
||||
# where docker build fails deep in ECR auth with a cryptic error.
|
||||
- name: Verify Docker daemon access
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "::group::Docker daemon health check"
|
||||
docker info 2>&1 | head -5 || {
|
||||
echo "::error::Docker daemon is not accessible at /var/run/docker.sock"
|
||||
echo "::error::Check: (1) daemon running, (2) runner user in docker group, (3) sock perms 660+"
|
||||
exit 1
|
||||
}
|
||||
echo "Docker daemon OK"
|
||||
echo "::endgroup::"
|
||||
|
||||
- name: Compute tags
|
||||
id: tags
|
||||
shell: bash
|
||||
|
||||
@@ -180,7 +180,7 @@ jobs:
|
||||
# environment pypi-publish. The action mints a short-lived OIDC
|
||||
# token and exchanges it for a PyPI upload credential — no static
|
||||
# API token in this repo's secrets.
|
||||
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
packages-dir: ${{ runner.temp }}/runtime-build/dist/
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ name: publish-workspace-server-image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
branches: [staging, main]
|
||||
paths:
|
||||
- 'workspace-server/**'
|
||||
- 'canvas/**'
|
||||
@@ -107,22 +107,6 @@ jobs:
|
||||
run: |
|
||||
echo "sha=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Health check: verify Docker daemon is accessible before attempting any
|
||||
# build steps. This fails loudly at step 1 when the runner's docker.sock
|
||||
# is inaccessible rather than silently continuing to the build step
|
||||
# where docker build fails deep in ECR auth with a cryptic error.
|
||||
- name: Verify Docker daemon access
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "::group::Docker daemon health check"
|
||||
docker info 2>&1 | head -5 || {
|
||||
echo "::error::Docker daemon is not accessible at /var/run/docker.sock"
|
||||
echo "::error::Check: (1) daemon running, (2) runner user in docker group, (3) sock perms 660+"
|
||||
exit 1
|
||||
}
|
||||
echo "Docker daemon OK"
|
||||
echo "::endgroup::"
|
||||
|
||||
# Pre-clone manifest deps before docker build (Task #173 fix).
|
||||
#
|
||||
# Why pre-clone: post-2026-05-06, every workspace-template-* repo on
|
||||
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
staging trigger
|
||||
@@ -1,22 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Inter, JetBrains_Mono } from "next/font/google";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import "./globals.css";
|
||||
|
||||
// Self-hosted at build time → CSP-safe (font-src 'self' covers them
|
||||
// because Next.js serves the .woff2 from /_next/static). Exposed as
|
||||
// CSS variables so the mobile palette can reference them without
|
||||
// importing this module.
|
||||
const interFont = Inter({
|
||||
subsets: ["latin"],
|
||||
display: "swap",
|
||||
variable: "--font-inter",
|
||||
});
|
||||
const monoFont = JetBrains_Mono({
|
||||
subsets: ["latin"],
|
||||
display: "swap",
|
||||
variable: "--font-jetbrains",
|
||||
});
|
||||
import { AuthGate } from "@/components/AuthGate";
|
||||
import { CookieConsent } from "@/components/CookieConsent";
|
||||
import { PurchaseSuccessModal } from "@/components/PurchaseSuccessModal";
|
||||
@@ -95,7 +79,7 @@ export default async function RootLayout({
|
||||
dangerouslySetInnerHTML={{ __html: themeBootScript }}
|
||||
/>
|
||||
</head>
|
||||
<body className={`bg-surface text-ink ${interFont.variable} ${monoFont.variable}`}>
|
||||
<body className="bg-surface text-ink">
|
||||
<ThemeProvider initialTheme={theme}>
|
||||
{/* AuthGate is a client component; it checks the session on mount
|
||||
and bounces anonymous users to the control plane's login page
|
||||
|
||||
+1
-48
@@ -4,7 +4,6 @@ import { useEffect, useState } from "react";
|
||||
import { Canvas } from "@/components/Canvas";
|
||||
import { Legend } from "@/components/Legend";
|
||||
import { CommunicationOverlay } from "@/components/CommunicationOverlay";
|
||||
import { MobileApp } from "@/components/mobile/MobileApp";
|
||||
import { Spinner } from "@/components/Spinner";
|
||||
import { connectSocket, disconnectSocket } from "@/store/socket";
|
||||
import { useCanvasStore } from "@/store/canvas";
|
||||
@@ -15,23 +14,6 @@ export default function Home() {
|
||||
const hydrationError = useCanvasStore((s) => s.hydrationError);
|
||||
const setHydrationError = useCanvasStore((s) => s.setHydrationError);
|
||||
const [hydrating, setHydrating] = useState(true);
|
||||
// < 640px viewport renders the dedicated mobile shell instead of the
|
||||
// desktop canvas. Tri-state: `null` until matchMedia has resolved,
|
||||
// then `true|false`. While null we keep the existing loading spinner
|
||||
// up — that way mobile devices never flash the desktop tree (which
|
||||
// they would if we defaulted to `false` and only flipped post-mount).
|
||||
const [isMobile, setIsMobile] = useState<boolean | null>(null);
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined" || !window.matchMedia) {
|
||||
setIsMobile(false);
|
||||
return;
|
||||
}
|
||||
const mq = window.matchMedia("(max-width: 639px)");
|
||||
const update = () => setIsMobile(mq.matches);
|
||||
update();
|
||||
mq.addEventListener("change", update);
|
||||
return () => mq.removeEventListener("change", update);
|
||||
}, []);
|
||||
// Distinct from hydrationError: platform-down is its own UX path
|
||||
// (different copy, different action — the user's next step is to
|
||||
// check local services, not to retry the API call). Tracked
|
||||
@@ -69,10 +51,7 @@ export default function Home() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Hold the spinner while data hydrates OR while the viewport
|
||||
// resolution hasn't settled yet (avoids a desktop-tree flash on
|
||||
// mobile devices between SSR-paint and matchMedia).
|
||||
if (hydrating || isMobile === null) {
|
||||
if (hydrating) {
|
||||
return (
|
||||
<div className="fixed inset-0 flex items-center justify-center bg-surface">
|
||||
<div role="status" aria-live="polite" className="flex flex-col items-center gap-3">
|
||||
@@ -87,32 +66,6 @@ export default function Home() {
|
||||
return <PlatformDownDiagnostic />;
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<>
|
||||
<MobileApp />
|
||||
{hydrationError && (
|
||||
<div
|
||||
role="alert"
|
||||
data-testid="hydration-error"
|
||||
className="fixed inset-0 flex flex-col items-center justify-center bg-surface text-ink-mid gap-4 z-[9999] px-6"
|
||||
>
|
||||
<p className="text-ink-mid text-sm text-center">{hydrationError}</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setHydrationError(null);
|
||||
window.location.reload();
|
||||
}}
|
||||
className="px-4 py-2 bg-accent-strong hover:bg-accent text-white rounded-md text-sm"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Canvas />
|
||||
|
||||
@@ -142,7 +142,7 @@ export function AuditTrailPanel({ workspaceId }: Props) {
|
||||
key={f.id}
|
||||
onClick={() => setFilter(f.id)}
|
||||
aria-pressed={filter === f.id}
|
||||
className={`px-2 py-1 text-[10px] rounded-md font-medium transition-all shrink-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface ${
|
||||
className={`px-2 py-1 text-[10px] rounded-md font-medium transition-all shrink-0 ${
|
||||
filter === f.id
|
||||
? "bg-surface-card text-ink ring-1 ring-zinc-600"
|
||||
: "text-ink-mid hover:text-ink-mid hover:bg-surface-card/60"
|
||||
@@ -155,7 +155,7 @@ export function AuditTrailPanel({ workspaceId }: Props) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadEntries}
|
||||
className="px-2 py-1 text-[10px] bg-surface-card hover:bg-surface-card text-ink-mid rounded transition-colors shrink-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
|
||||
className="px-2 py-1 text-[10px] bg-surface-card hover:bg-surface-card text-ink-mid rounded transition-colors shrink-0"
|
||||
aria-label="Refresh audit trail"
|
||||
>
|
||||
↻
|
||||
@@ -195,7 +195,7 @@ export function AuditTrailPanel({ workspaceId }: Props) {
|
||||
type="button"
|
||||
onClick={loadMore}
|
||||
disabled={loadingMore}
|
||||
className="px-4 py-2 text-[11px] bg-surface-card hover:bg-surface-card disabled:opacity-50 disabled:cursor-not-allowed text-ink-mid rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
|
||||
className="px-4 py-2 text-[11px] bg-surface-card hover:bg-surface-card disabled:opacity-50 disabled:cursor-not-allowed text-ink-mid rounded-lg transition-colors"
|
||||
>
|
||||
{loadingMore ? "Loading…" : "Load more"}
|
||||
</button>
|
||||
|
||||
@@ -308,9 +308,7 @@ function CanvasInner() {
|
||||
showInteractive={false}
|
||||
/>
|
||||
<MiniMap
|
||||
// hidden < sm: minimap eats ~30% of a phone screen and
|
||||
// overlaps with the New Workspace FAB at bottom-right.
|
||||
className="!bg-surface-sunken/90 !border-line/50 !rounded-lg !shadow-xl !shadow-black/20 !hidden sm:!block"
|
||||
className="!bg-surface-sunken/90 !border-line/50 !rounded-lg !shadow-xl !shadow-black/20"
|
||||
// Mask dims off-viewport areas; tint matches the surface so
|
||||
// the dimming doesn't show as a black bar in light mode.
|
||||
maskColor={resolvedTheme === "dark" ? "rgba(0, 0, 0, 0.7)" : "rgba(232, 226, 211, 0.7)"}
|
||||
|
||||
@@ -209,7 +209,7 @@ export function CommunicationOverlay() {
|
||||
type="button"
|
||||
onClick={() => setVisible(true)}
|
||||
aria-label="Show communications panel"
|
||||
className="fixed top-16 right-4 z-30 px-3 py-1.5 bg-surface-sunken/90 border border-line/50 rounded-lg text-[10px] text-ink-mid hover:text-ink transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
|
||||
className="fixed top-16 right-4 z-30 px-3 py-1.5 bg-surface-sunken/90 border border-line/50 rounded-lg text-[10px] text-ink-mid hover:text-ink transition-colors"
|
||||
>
|
||||
<span aria-hidden="true">↗↙ </span>{comms.length > 0 ? `${comms.length} comms` : "Communications"}
|
||||
</button>
|
||||
@@ -226,7 +226,7 @@ export function CommunicationOverlay() {
|
||||
type="button"
|
||||
onClick={() => setVisible(false)}
|
||||
aria-label="Close communications panel"
|
||||
className="text-ink-mid hover:text-ink-mid text-xs focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface rounded"
|
||||
className="text-ink-mid hover:text-ink-mid text-xs"
|
||||
>
|
||||
<span aria-hidden="true">✕</span>
|
||||
</button>
|
||||
|
||||
@@ -115,7 +115,7 @@ export function ConversationTraceModal({ open, workspaceId: _workspaceId, onClos
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close conversation trace"
|
||||
className="text-ink-mid hover:text-ink-mid text-lg px-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface rounded"
|
||||
className="text-ink-mid hover:text-ink-mid text-lg px-2"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
@@ -286,7 +286,7 @@ export function ConversationTraceModal({ open, workspaceId: _workspaceId, onClos
|
||||
<Dialog.Close asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="px-4 py-1.5 text-[12px] bg-surface-card hover:bg-surface-card text-ink-mid rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
|
||||
className="px-4 py-1.5 text-[12px] bg-surface-card hover:bg-surface-card text-ink-mid rounded-lg transition-colors"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
|
||||
@@ -411,7 +411,7 @@ export function CreateWorkspaceButton() {
|
||||
tabIndex={tier === t.value ? 0 : -1}
|
||||
onClick={() => setTier(t.value)}
|
||||
onKeyDown={(e) => handleRadioKeyDown(e, idx)}
|
||||
className={`py-2 rounded-lg text-center transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 ${
|
||||
className={`py-2 rounded-lg text-center transition-colors ${
|
||||
tier === t.value
|
||||
? "bg-accent-strong/20 border border-accent/50 text-accent"
|
||||
: "bg-surface-card/60 border border-line/40 text-ink-mid hover:text-ink-mid hover:border-line"
|
||||
|
||||
@@ -83,7 +83,7 @@ export class ErrorBoundary extends React.Component<
|
||||
<button
|
||||
type="button"
|
||||
onClick={this.handleReload}
|
||||
className="rounded-lg bg-accent-strong hover:bg-accent px-5 py-2 text-sm font-medium text-white transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-surface"
|
||||
className="rounded-lg bg-accent-strong hover:bg-accent px-5 py-2 text-sm font-medium text-white transition-colors"
|
||||
>
|
||||
Reload
|
||||
</button>
|
||||
@@ -93,7 +93,7 @@ export class ErrorBoundary extends React.Component<
|
||||
e.preventDefault();
|
||||
this.handleReport();
|
||||
}}
|
||||
className="rounded-lg border border-line hover:border-line px-5 py-2 text-sm font-medium text-ink-mid hover:text-ink transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-surface"
|
||||
className="rounded-lg border border-line hover:border-line px-5 py-2 text-sm font-medium text-ink-mid hover:text-ink transition-colors"
|
||||
>
|
||||
Report
|
||||
</a>
|
||||
|
||||
@@ -198,7 +198,7 @@ export function ExternalConnectModal({ info, onClose }: Props) {
|
||||
role="tab"
|
||||
aria-selected={tab === t}
|
||||
onClick={() => setTab(t)}
|
||||
className={`px-3 py-2 text-sm border-b-2 -mb-px transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface ${
|
||||
className={`px-3 py-2 text-sm border-b-2 -mb-px transition-colors ${
|
||||
tab === t
|
||||
? "border-accent text-ink"
|
||||
: "border-transparent text-ink-mid hover:text-ink-mid"
|
||||
@@ -309,7 +309,7 @@ export function ExternalConnectModal({ info, onClose }: Props) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm rounded-lg bg-surface-card hover:bg-surface-card text-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
|
||||
className="px-4 py-2 text-sm rounded-lg bg-surface-card hover:bg-surface-card text-ink"
|
||||
>
|
||||
I've saved it — close
|
||||
</button>
|
||||
@@ -339,7 +339,7 @@ function SnippetBlock({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCopy}
|
||||
className="text-xs px-2 py-1 rounded bg-accent-strong/80 hover:bg-accent text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
|
||||
className="text-xs px-2 py-1 rounded bg-accent-strong/80 hover:bg-accent text-white"
|
||||
>
|
||||
{copied ? "Copied!" : "Copy"}
|
||||
</button>
|
||||
@@ -376,7 +376,7 @@ function Field({
|
||||
type="button"
|
||||
onClick={onCopy}
|
||||
disabled={!value}
|
||||
className="text-xs px-2 py-1 rounded bg-surface-card hover:bg-surface-card text-ink disabled:opacity-40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
|
||||
className="text-xs px-2 py-1 rounded bg-surface-card hover:bg-surface-card text-ink disabled:opacity-40"
|
||||
>
|
||||
{copied ? "Copied!" : "Copy"}
|
||||
</button>
|
||||
|
||||
@@ -360,7 +360,7 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
|
||||
setDebouncedQuery('');
|
||||
}}
|
||||
aria-label="Clear search"
|
||||
className="absolute right-2 text-ink-mid hover:text-ink transition-colors text-sm leading-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface rounded"
|
||||
className="absolute right-2 text-ink-mid hover:text-ink transition-colors text-sm leading-none"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
@@ -381,7 +381,7 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
|
||||
type="button"
|
||||
onClick={loadEntries}
|
||||
disabled={pluginUnavailable}
|
||||
className="px-2 py-1 text-[11px] bg-surface-card hover:bg-surface-card text-ink-mid rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
|
||||
className="px-2 py-1 text-[11px] bg-surface-card hover:bg-surface-card text-ink-mid rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
aria-label="Refresh memories"
|
||||
>
|
||||
↻ Refresh
|
||||
@@ -515,7 +515,7 @@ function MemoryEntryRow({ entry, onDelete }: MemoryEntryRowProps) {
|
||||
{/* Header row */}
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex items-center gap-2 px-3 py-2.5 text-left hover:bg-surface-card/30 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
|
||||
className="w-full flex items-center gap-2 px-3 py-2.5 text-left hover:bg-surface-card/30 transition-colors"
|
||||
onClick={() => setExpanded((prev) => !prev)}
|
||||
aria-expanded={expanded}
|
||||
aria-controls={bodyId}
|
||||
@@ -629,7 +629,7 @@ function MemoryEntryRow({ entry, onDelete }: MemoryEntryRowProps) {
|
||||
onDelete();
|
||||
}}
|
||||
aria-label="Forget memory"
|
||||
className="text-[10px] px-2 py-0.5 bg-red-950/40 hover:bg-red-900/50 border border-red-900/30 rounded text-bad transition-colors shrink-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500/60 focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
|
||||
className="text-[10px] px-2 py-0.5 bg-red-950/40 hover:bg-red-900/50 border border-red-900/30 rounded text-bad transition-colors shrink-0"
|
||||
>
|
||||
Forget
|
||||
</button>
|
||||
|
||||
@@ -631,9 +631,8 @@ function AllKeysModal({
|
||||
// React's commit ordering.
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 bg-black/70 backdrop-blur-sm"
|
||||
aria-label="Dismiss modal"
|
||||
aria-hidden="true"
|
||||
onClick={onCancel}
|
||||
/>
|
||||
|
||||
@@ -707,7 +706,7 @@ function AllKeysModal({
|
||||
type="button"
|
||||
onClick={() => handleSaveKey(index)}
|
||||
disabled={!entry.value.trim() || entry.saving}
|
||||
className="px-3 py-1.5 bg-accent-strong hover:bg-accent text-[11px] rounded text-white disabled:opacity-30 transition-colors shrink-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
|
||||
className="px-3 py-1.5 bg-accent-strong hover:bg-accent text-[11px] rounded text-white disabled:opacity-30 transition-colors shrink-0"
|
||||
>
|
||||
{entry.saving ? "..." : "Save"}
|
||||
</button>
|
||||
@@ -731,7 +730,7 @@ function AllKeysModal({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenSettings}
|
||||
className="text-[11px] text-accent hover:text-accent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface rounded"
|
||||
className="text-[11px] text-accent hover:text-accent transition-colors"
|
||||
>
|
||||
Open Settings Panel
|
||||
</button>
|
||||
@@ -741,7 +740,7 @@ function AllKeysModal({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-3.5 py-1.5 text-[12px] text-ink-mid hover:text-ink bg-surface-card hover:bg-surface-card border border-line rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
|
||||
className="px-3.5 py-1.5 text-[12px] text-ink-mid hover:text-ink bg-surface-card hover:bg-surface-card border border-line rounded-lg transition-colors"
|
||||
>
|
||||
Cancel Deploy
|
||||
</button>
|
||||
@@ -749,7 +748,7 @@ function AllKeysModal({
|
||||
type="button"
|
||||
onClick={handleAddKeysAndDeploy}
|
||||
disabled={!allSaved || anySaving}
|
||||
className="px-3.5 py-1.5 text-[12px] bg-accent-strong hover:bg-accent text-white rounded-lg transition-colors disabled:opacity-40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
|
||||
className="px-3.5 py-1.5 text-[12px] bg-accent-strong hover:bg-accent text-white rounded-lg transition-colors disabled:opacity-40"
|
||||
>
|
||||
{anySaving ? "Saving..." : allSaved ? "Deploy" : "Add Keys"}
|
||||
</button>
|
||||
|
||||
@@ -308,7 +308,7 @@ export function OrgImportPreflightModal({
|
||||
type="button"
|
||||
onClick={onProceed}
|
||||
disabled={!canProceed}
|
||||
className="px-4 py-1.5 text-[11px] font-semibold rounded bg-accent hover:bg-accent-strong text-white disabled:bg-surface-card disabled:text-white-soft disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
|
||||
className="px-4 py-1.5 text-[11px] font-semibold rounded bg-accent hover:bg-accent-strong text-white disabled:bg-surface-card disabled:text-white-soft disabled:cursor-not-allowed"
|
||||
>
|
||||
Import
|
||||
</button>
|
||||
@@ -428,7 +428,7 @@ function StrictEnvRow({
|
||||
type="button"
|
||||
onClick={() => onSave(envKey)}
|
||||
disabled={d?.saving || !d?.value.trim()}
|
||||
className="px-2 py-1 text-[10px] rounded bg-accent hover:bg-accent-strong text-white disabled:opacity-40 disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
|
||||
className="px-2 py-1 text-[10px] rounded bg-accent hover:bg-accent-strong text-white disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
{d?.saving ? "…" : "Save"}
|
||||
</button>
|
||||
@@ -520,7 +520,7 @@ function AnyOfEnvGroup({
|
||||
type="button"
|
||||
onClick={() => onSave(m)}
|
||||
disabled={d?.saving || !d?.value.trim()}
|
||||
className="px-2 py-1 text-[10px] rounded bg-accent hover:bg-accent-strong text-white disabled:opacity-40 disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
|
||||
className="px-2 py-1 text-[10px] rounded bg-accent hover:bg-accent-strong text-white disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
{d?.saving ? "…" : "Save"}
|
||||
</button>
|
||||
|
||||
@@ -128,7 +128,7 @@ function PlanCard({
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
disabled={loading}
|
||||
className={`mt-6 rounded-lg px-4 py-3 text-sm font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-surface ${
|
||||
className={`mt-6 rounded-lg px-4 py-3 text-sm font-medium ${
|
||||
plan.highlighted
|
||||
? "bg-accent-strong text-white hover:bg-accent disabled:bg-blue-900"
|
||||
: "border border-line bg-surface-sunken text-ink hover:bg-surface-card disabled:opacity-50"
|
||||
|
||||
@@ -437,7 +437,7 @@ export function ProviderModelSelector({
|
||||
handleModelChange(selected.models[0]?.id ?? "");
|
||||
}
|
||||
}}
|
||||
className="text-[9px] text-accent hover:text-accent mt-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface rounded"
|
||||
className="text-[9px] text-accent hover:text-accent mt-0.5"
|
||||
>
|
||||
← back to model list
|
||||
</button>
|
||||
|
||||
@@ -341,7 +341,7 @@ export function ProvisioningTimeout({
|
||||
type="button"
|
||||
onClick={() => handleRetry(entry.workspaceId)}
|
||||
disabled={isRetrying || isCancelling || retryCooldown.has(entry.workspaceId)}
|
||||
className="px-3 py-1.5 bg-amber-600 hover:bg-amber-500 text-[11px] font-medium rounded-lg text-white disabled:opacity-40 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
|
||||
className="px-3 py-1.5 bg-amber-600 hover:bg-amber-500 text-[11px] font-medium rounded-lg text-white disabled:opacity-40 transition-colors"
|
||||
>
|
||||
{isRetrying ? "Retrying..." : retryCooldown.has(entry.workspaceId) ? "Wait..." : "Retry"}
|
||||
</button>
|
||||
@@ -349,14 +349,14 @@ export function ProvisioningTimeout({
|
||||
type="button"
|
||||
onClick={() => handleCancelRequest(entry.workspaceId)}
|
||||
disabled={isRetrying || isCancelling}
|
||||
className="px-3 py-1.5 bg-surface-card hover:bg-surface-card text-[11px] text-ink-mid rounded-lg border border-line disabled:opacity-40 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
|
||||
className="px-3 py-1.5 bg-surface-card hover:bg-surface-card text-[11px] text-ink-mid rounded-lg border border-line disabled:opacity-40 transition-colors"
|
||||
>
|
||||
{isCancelling ? "Cancelling..." : "Cancel"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleViewLogs(entry.workspaceId)}
|
||||
className="px-3 py-1.5 text-[11px] text-warm hover:text-warm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-1 focus-visible:ring-offset-surface rounded"
|
||||
className="px-3 py-1.5 text-[11px] text-warm hover:text-warm transition-colors"
|
||||
>
|
||||
View Logs
|
||||
</button>
|
||||
@@ -382,14 +382,14 @@ export function ProvisioningTimeout({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmingCancel(null)}
|
||||
className="px-3.5 py-1.5 text-[12px] text-ink-mid hover:text-ink bg-surface-card hover:bg-surface-card border border-line rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
|
||||
className="px-3.5 py-1.5 text-[12px] text-ink-mid hover:text-ink bg-surface-card hover:bg-surface-card border border-line rounded-lg transition-colors"
|
||||
>
|
||||
Keep
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancelConfirm}
|
||||
className="px-3.5 py-1.5 text-[12px] bg-red-600 hover:bg-red-500 text-white rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-400/70 focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
|
||||
className="px-3.5 py-1.5 text-[12px] bg-red-600 hover:bg-red-500 text-white rounded-lg transition-colors"
|
||||
>
|
||||
Remove Workspace
|
||||
</button>
|
||||
|
||||
@@ -63,21 +63,9 @@ export function SidePanel() {
|
||||
? parsed
|
||||
: SIDEPANEL_DEFAULT_WIDTH;
|
||||
});
|
||||
// On mobile (< 640px viewport) the configured width exceeds the screen,
|
||||
// so the panel renders off-canvas-left. Force full-viewport width and
|
||||
// disable resize on small screens; restore configured width on desktop.
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined" || !window.matchMedia) return;
|
||||
const mq = window.matchMedia("(max-width: 639px)");
|
||||
const update = () => setIsMobile(mq.matches);
|
||||
update();
|
||||
mq.addEventListener("change", update);
|
||||
return () => mq.removeEventListener("change", update);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
setSidePanelWidth(isMobile ? 0 : width);
|
||||
}, [width, isMobile, setSidePanelWidth]);
|
||||
setSidePanelWidth(width);
|
||||
}, [width, setSidePanelWidth]);
|
||||
const widthRef = useRef(width); // tracks live drag value for the mouseup handler
|
||||
const dragging = useRef(false);
|
||||
const startX = useRef(0);
|
||||
@@ -149,28 +137,24 @@ export function SidePanel() {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`fixed top-0 right-0 h-full bg-surface/95 backdrop-blur-xl border-line/50 flex flex-col z-50 shadow-2xl shadow-black/50 animate-in slide-in-from-right duration-200 ${
|
||||
isMobile ? "left-0 w-screen" : "border-l"
|
||||
}`}
|
||||
style={isMobile ? undefined : { width }}
|
||||
className="fixed top-0 right-0 h-full bg-surface/95 backdrop-blur-xl border-l border-line/50 flex flex-col z-50 shadow-2xl shadow-black/50 animate-in slide-in-from-right duration-200"
|
||||
style={{ width }}
|
||||
>
|
||||
{/* Resize handle — desktop only (no point resizing a full-screen mobile panel) */}
|
||||
{!isMobile && (
|
||||
<div
|
||||
role="separator"
|
||||
aria-label="Resize workspace panel"
|
||||
aria-valuenow={width}
|
||||
aria-valuemin={SIDEPANEL_MIN_WIDTH}
|
||||
aria-valuemax={SIDEPANEL_MAX_WIDTH}
|
||||
aria-orientation="vertical"
|
||||
tabIndex={0}
|
||||
onMouseDown={onMouseDown}
|
||||
onKeyDown={onResizeKeyDown}
|
||||
className="absolute left-0 top-0 bottom-0 w-1.5 cursor-col-resize hover:bg-accent/30 active:bg-accent/50 transition-colors z-10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-inset"
|
||||
/>
|
||||
)}
|
||||
{/* Resize handle */}
|
||||
<div
|
||||
role="separator"
|
||||
aria-label="Resize workspace panel"
|
||||
aria-valuenow={width}
|
||||
aria-valuemin={SIDEPANEL_MIN_WIDTH}
|
||||
aria-valuemax={SIDEPANEL_MAX_WIDTH}
|
||||
aria-orientation="vertical"
|
||||
tabIndex={0}
|
||||
onMouseDown={onMouseDown}
|
||||
onKeyDown={onResizeKeyDown}
|
||||
className="absolute left-0 top-0 bottom-0 w-1.5 cursor-col-resize hover:bg-accent/30 active:bg-accent/50 transition-colors z-10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-inset"
|
||||
/>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 sm:px-5 py-4 border-b border-line/40 bg-surface-sunken/30">
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-line/40 bg-surface-sunken/30">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="relative">
|
||||
<StatusDot status={node.data.status} size="md" />
|
||||
@@ -197,7 +181,7 @@ export function SidePanel() {
|
||||
type="button"
|
||||
onClick={() => selectNode(null)}
|
||||
aria-label="Close workspace panel"
|
||||
className="w-7 h-7 flex items-center justify-center rounded-lg text-ink-mid hover:text-ink hover:bg-surface-card/60 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
|
||||
className="w-7 h-7 flex items-center justify-center rounded-lg text-ink-mid hover:text-ink hover:bg-surface-card/60 transition-colors"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
||||
<path d="M1 1l10 10M11 1L1 11" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
@@ -206,7 +190,7 @@ export function SidePanel() {
|
||||
</div>
|
||||
|
||||
{/* Capability summary */}
|
||||
<div className="px-4 sm:px-5 py-3 border-b border-line/40 bg-surface-sunken/20">
|
||||
<div className="px-5 py-3 border-b border-line/40 bg-surface-sunken/20">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<MetaPill label="Tier" value={`T${node.data.tier}`} />
|
||||
<MetaPill label="Runtime" value={capability.runtime || "unknown"} />
|
||||
@@ -311,8 +295,8 @@ export function SidePanel() {
|
||||
</div>
|
||||
|
||||
{/* Footer — workspace ID */}
|
||||
<div className="px-4 sm:px-5 py-2 border-t border-line/40 bg-surface-sunken/20">
|
||||
<span className="text-[9px] font-mono text-ink-mid select-all block truncate">
|
||||
<div className="px-5 py-2 border-t border-line/40 bg-surface-sunken/20">
|
||||
<span className="text-[9px] font-mono text-ink-mid select-all">
|
||||
{selectedNodeId}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -236,7 +236,7 @@ export function OrgTemplatesSection() {
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
aria-expanded={expanded}
|
||||
aria-controls="org-templates-body"
|
||||
className="flex items-center gap-1.5 text-[10px] uppercase tracking-wide text-ink-mid hover:text-ink-mid font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface rounded"
|
||||
className="flex items-center gap-1.5 text-[10px] uppercase tracking-wide text-ink-mid hover:text-ink-mid font-semibold transition-colors"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
@@ -255,7 +255,7 @@ export function OrgTemplatesSection() {
|
||||
type="button"
|
||||
onClick={loadOrgs}
|
||||
aria-label="Refresh org templates"
|
||||
className="text-[10px] text-ink-mid hover:text-ink-mid focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface rounded"
|
||||
className="text-[10px] text-ink-mid hover:text-ink-mid"
|
||||
>
|
||||
↻
|
||||
</button>
|
||||
@@ -306,7 +306,7 @@ export function OrgTemplatesSection() {
|
||||
type="button"
|
||||
onClick={() => handleImport(o)}
|
||||
disabled={isImporting}
|
||||
className="w-full px-2 py-1.5 bg-accent-strong/20 hover:bg-accent-strong/30 border border-accent/30 rounded-lg text-[10px] text-accent font-medium transition-colors disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
|
||||
className="w-full px-2 py-1.5 bg-accent-strong/20 hover:bg-accent-strong/30 border border-accent/30 rounded-lg text-[10px] text-accent font-medium transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isImporting ? "Importing…" : "Import org"}
|
||||
</button>
|
||||
@@ -411,7 +411,7 @@ function ImportAgentButton({ onImported }: { onImported: () => void }) {
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={importing}
|
||||
className="w-full px-3 py-2 bg-accent-strong/20 hover:bg-accent-strong/30 border border-accent/30 rounded-lg text-[11px] text-accent font-medium transition-colors disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
|
||||
className="w-full px-3 py-2 bg-accent-strong/20 hover:bg-accent-strong/30 border border-accent/30 rounded-lg text-[11px] text-accent font-medium transition-colors disabled:opacity-50"
|
||||
>
|
||||
{importing ? "Importing..." : "Import Agent Folder"}
|
||||
</button>
|
||||
@@ -474,7 +474,7 @@ export function TemplatePalette() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
className={`fixed top-4 left-4 z-40 w-9 h-9 flex items-center justify-center rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-surface ${
|
||||
className={`fixed top-4 left-4 z-40 w-9 h-9 flex items-center justify-center rounded-lg transition-colors ${
|
||||
open
|
||||
? "bg-accent-strong text-white"
|
||||
: "bg-surface-sunken/90 border border-line/50 text-ink-mid hover:text-ink hover:border-line"
|
||||
@@ -580,7 +580,7 @@ export function TemplatePalette() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadTemplates}
|
||||
className="text-[10px] text-ink-mid hover:text-ink-mid transition-colors block focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface rounded"
|
||||
className="text-[10px] text-ink-mid hover:text-ink-mid transition-colors block"
|
||||
>
|
||||
Refresh templates
|
||||
</button>
|
||||
|
||||
@@ -54,7 +54,7 @@ export function ThemeToggle({ className = "" }: { className?: string }) {
|
||||
aria-label={opt.label}
|
||||
onClick={() => setTheme(opt.value)}
|
||||
className={
|
||||
"flex h-6 w-6 items-center justify-center rounded transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface " +
|
||||
"flex h-6 w-6 items-center justify-center rounded transition-colors " +
|
||||
(active
|
||||
? "bg-surface-elevated text-ink shadow-sm"
|
||||
: "text-ink-mid hover:text-ink-mid")
|
||||
|
||||
@@ -154,13 +154,13 @@ export function Toolbar() {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed top-3 z-20 flex items-center gap-3 bg-surface-sunken/80 backdrop-blur-md border border-line/60 rounded-xl px-3 sm:px-4 py-2 shadow-xl shadow-black/20 transition-[margin-left] duration-200 left-2 right-2 translate-x-0 sm:left-1/2 sm:right-auto sm:-translate-x-1/2 overflow-x-auto sm:overflow-visible [&>*]:shrink-0"
|
||||
className="fixed top-3 left-1/2 -translate-x-1/2 z-20 flex items-center gap-3 bg-surface-sunken/80 backdrop-blur-md border border-line/60 rounded-xl px-4 py-2 shadow-xl shadow-black/20 transition-[margin-left] duration-200"
|
||||
style={toolbarOffsetStyle}
|
||||
>
|
||||
{/* Logo / Title — title text drops on mobile to reclaim space */}
|
||||
<div className="flex items-center gap-2 sm:pr-3 sm:border-r sm:border-line/60">
|
||||
{/* Logo / Title */}
|
||||
<div className="flex items-center gap-2 pr-3 border-r border-line/60">
|
||||
<img src="/molecule-icon.png" alt="Molecule AI" className="w-5 h-5" />
|
||||
<span className="hidden sm:inline text-[11px] font-semibold text-ink-mid tracking-wide">Molecule AI</span>
|
||||
<span className="text-[11px] font-semibold text-ink-mid tracking-wide">Molecule AI</span>
|
||||
</div>
|
||||
|
||||
{/* Status pills + workspace total in one segment — previously two
|
||||
@@ -179,15 +179,15 @@ export function Toolbar() {
|
||||
{counts.failed > 0 && (
|
||||
<StatusPill color={statusDotClass("failed")} count={counts.failed} label="failed" />
|
||||
)}
|
||||
<span className="hidden sm:inline text-ink-mid" aria-hidden="true">·</span>
|
||||
<span className="hidden sm:inline text-[10px] text-ink-mid whitespace-nowrap">
|
||||
<span className="text-ink-mid" aria-hidden="true">·</span>
|
||||
<span className="text-[10px] text-ink-mid whitespace-nowrap">
|
||||
{counts.roots} workspace{counts.roots !== 1 ? "s" : ""}
|
||||
{counts.children > 0 && <span className="text-ink-mid"> + {counts.children} sub</span>}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* WebSocket connection status */}
|
||||
<div className="sm:pl-3 sm:border-l sm:border-line/60">
|
||||
<div className="pl-3 border-l border-line/60">
|
||||
<WsStatusPill status={wsStatus} />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -45,12 +45,6 @@ export function Tooltip({ text, children }: Props) {
|
||||
if (triggerRef.current) {
|
||||
const rect = triggerRef.current.getBoundingClientRect();
|
||||
setPos({ x: rect.left, y: rect.top });
|
||||
// Focus the first focusable descendant (the actual trigger button),
|
||||
// not the wrapper div, so screen-reader/navigation UX is correct.
|
||||
const firstFocusable = triggerRef.current.querySelector<HTMLElement>(
|
||||
'button, [tabindex], input, select, textarea, a[href]'
|
||||
);
|
||||
firstFocusable?.focus();
|
||||
}
|
||||
setShow(true);
|
||||
}, 400);
|
||||
|
||||
@@ -2,9 +2,8 @@
|
||||
/**
|
||||
* Tests for ApprovalBanner component.
|
||||
*
|
||||
* Uses vi.hoisted + vi.mock for stable module-level API mocks that survive
|
||||
* vi.resetModules() cleanup. BeforeEach uses mockReset + mockResolvedValue
|
||||
* so each test gets a clean slate.
|
||||
* Covers: renders nothing when no approvals, polls /approvals/pending,
|
||||
* shows approval cards, approve/deny decisions, toast notifications.
|
||||
*/
|
||||
import React from "react";
|
||||
import { render, screen, fireEvent, cleanup, waitFor, act } from "@testing-library/react";
|
||||
@@ -13,23 +12,10 @@ import { ApprovalBanner } from "../ApprovalBanner";
|
||||
import { showToast } from "@/components/Toaster";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
// ─── Module-level mocks ───────────────────────────────────────────────────────
|
||||
// vi.hoisted captures stable references BEFORE hoisting so they are accessible
|
||||
// in the test body after vi.mock registers.
|
||||
const _mockGet = vi.hoisted<typeof api.get>(() => vi.fn<() => Promise<unknown[]>>());
|
||||
const _mockPost = vi.hoisted<typeof api.post>(() => vi.fn<() => Promise<unknown>>());
|
||||
const _mockToast = vi.hoisted<typeof showToast>(() => vi.fn());
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: { get: _mockGet, post: _mockPost },
|
||||
}));
|
||||
|
||||
vi.mock("@/components/Toaster", () => ({
|
||||
showToast: _mockToast,
|
||||
showToast: vi.fn(),
|
||||
}));
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const pendingApproval = (id = "a1", workspaceId = "ws-1"): {
|
||||
@@ -50,25 +36,11 @@ const pendingApproval = (id = "a1", workspaceId = "ws-1"): {
|
||||
created_at: "2026-05-10T10:00:00Z",
|
||||
});
|
||||
|
||||
// ─── Cleanup ─────────────────────────────────────────────────────────────────
|
||||
|
||||
beforeEach(() => {
|
||||
_mockGet.mockReset();
|
||||
_mockGet.mockResolvedValue([] as unknown[]);
|
||||
_mockPost.mockReset();
|
||||
_mockPost.mockResolvedValue({} as unknown);
|
||||
_mockToast.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("ApprovalBanner — empty state", () => {
|
||||
it("renders nothing when there are no pending approvals", async () => {
|
||||
_mockGet.mockResolvedValueOnce([] as unknown[]);
|
||||
vi.spyOn(api, "get").mockResolvedValueOnce([]);
|
||||
render(<ApprovalBanner />);
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
@@ -77,7 +49,7 @@ describe("ApprovalBanner — empty state", () => {
|
||||
});
|
||||
|
||||
it("does not render any approve/deny buttons when list is empty", async () => {
|
||||
_mockGet.mockResolvedValueOnce([] as unknown[]);
|
||||
vi.spyOn(api, "get").mockResolvedValueOnce([]);
|
||||
render(<ApprovalBanner />);
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
@@ -89,10 +61,10 @@ describe("ApprovalBanner — empty state", () => {
|
||||
|
||||
describe("ApprovalBanner — renders approval cards", () => {
|
||||
it("renders an alert card for each pending approval", async () => {
|
||||
_mockGet.mockResolvedValueOnce([
|
||||
vi.spyOn(api, "get").mockResolvedValueOnce([
|
||||
pendingApproval("a1"),
|
||||
pendingApproval("a2", "ws-2"),
|
||||
] as unknown[]);
|
||||
]);
|
||||
render(<ApprovalBanner />);
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
@@ -102,7 +74,7 @@ describe("ApprovalBanner — renders approval cards", () => {
|
||||
});
|
||||
|
||||
it("displays the workspace name and action text", async () => {
|
||||
_mockGet.mockResolvedValueOnce([pendingApproval("a1")] as unknown[]);
|
||||
vi.spyOn(api, "get").mockResolvedValueOnce([pendingApproval("a1")]);
|
||||
render(<ApprovalBanner />);
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
@@ -112,7 +84,7 @@ describe("ApprovalBanner — renders approval cards", () => {
|
||||
});
|
||||
|
||||
it("displays the reason when present", async () => {
|
||||
_mockGet.mockResolvedValueOnce([pendingApproval("a1")] as unknown[]);
|
||||
vi.spyOn(api, "get").mockResolvedValueOnce([pendingApproval("a1")]);
|
||||
render(<ApprovalBanner />);
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
@@ -123,7 +95,7 @@ describe("ApprovalBanner — renders approval cards", () => {
|
||||
it("omits the reason div when reason is null", async () => {
|
||||
const approval = pendingApproval("a1");
|
||||
approval.reason = null;
|
||||
_mockGet.mockResolvedValueOnce([approval] as unknown[]);
|
||||
vi.spyOn(api, "get").mockResolvedValueOnce([approval]);
|
||||
render(<ApprovalBanner />);
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
@@ -132,7 +104,7 @@ describe("ApprovalBanner — renders approval cards", () => {
|
||||
});
|
||||
|
||||
it("renders both Approve and Deny buttons per card", async () => {
|
||||
_mockGet.mockResolvedValueOnce([pendingApproval("a1")] as unknown[]);
|
||||
vi.spyOn(api, "get").mockResolvedValueOnce([pendingApproval("a1")]);
|
||||
render(<ApprovalBanner />);
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
@@ -142,7 +114,7 @@ describe("ApprovalBanner — renders approval cards", () => {
|
||||
});
|
||||
|
||||
it("has aria-live=assertive on the alert container", async () => {
|
||||
_mockGet.mockResolvedValueOnce([pendingApproval("a1")] as unknown[]);
|
||||
vi.spyOn(api, "get").mockResolvedValueOnce([pendingApproval("a1")]);
|
||||
render(<ApprovalBanner />);
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
@@ -164,7 +136,7 @@ describe("ApprovalBanner — polling", () => {
|
||||
});
|
||||
|
||||
it("clears the polling interval on unmount", async () => {
|
||||
_mockGet.mockResolvedValueOnce([pendingApproval("a1")] as unknown[]);
|
||||
vi.spyOn(api, "get").mockResolvedValueOnce([pendingApproval("a1")]);
|
||||
const { unmount } = render(<ApprovalBanner />);
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
@@ -177,8 +149,8 @@ describe("ApprovalBanner — polling", () => {
|
||||
describe("ApprovalBanner — decisions", () => {
|
||||
it("calls POST /workspaces/:id/approvals/:id/decide on Approve click", async () => {
|
||||
const approval = pendingApproval("a1", "ws-1");
|
||||
_mockGet.mockResolvedValueOnce([approval] as unknown[]);
|
||||
_mockPost.mockResolvedValueOnce({} as unknown);
|
||||
vi.spyOn(api, "get").mockResolvedValueOnce([approval]);
|
||||
const postSpy = vi.spyOn(api, "post").mockResolvedValueOnce(undefined);
|
||||
|
||||
render(<ApprovalBanner />);
|
||||
await act(async () => {
|
||||
@@ -188,17 +160,17 @@ describe("ApprovalBanner — decisions", () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: /approve/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(_mockPost).toHaveBeenCalledWith(
|
||||
expect(postSpy).toHaveBeenCalledWith(
|
||||
"/workspaces/ws-1/approvals/a1/decide",
|
||||
{ decision: "approved", decided_by: "human" },
|
||||
{ decision: "approved", decided_by: "human" }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("calls POST with decision=denied on Deny click", async () => {
|
||||
const approval = pendingApproval("a1", "ws-1");
|
||||
_mockGet.mockResolvedValueOnce([approval] as unknown[]);
|
||||
_mockPost.mockResolvedValueOnce({} as unknown);
|
||||
vi.spyOn(api, "get").mockResolvedValueOnce([approval]);
|
||||
const postSpy = vi.spyOn(api, "post").mockResolvedValueOnce(undefined);
|
||||
|
||||
render(<ApprovalBanner />);
|
||||
await act(async () => {
|
||||
@@ -208,17 +180,17 @@ describe("ApprovalBanner — decisions", () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: /deny/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(_mockPost).toHaveBeenCalledWith(
|
||||
expect(postSpy).toHaveBeenCalledWith(
|
||||
"/workspaces/ws-1/approvals/a1/decide",
|
||||
{ decision: "denied", decided_by: "human" },
|
||||
{ decision: "denied", decided_by: "human" }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("removes the card from state after a successful decision", async () => {
|
||||
const approval = pendingApproval("a1", "ws-1");
|
||||
_mockGet.mockResolvedValueOnce([approval] as unknown[]);
|
||||
_mockPost.mockResolvedValueOnce({} as unknown);
|
||||
vi.spyOn(api, "get").mockResolvedValueOnce([approval]);
|
||||
vi.spyOn(api, "post").mockResolvedValueOnce(undefined);
|
||||
|
||||
render(<ApprovalBanner />);
|
||||
await act(async () => {
|
||||
@@ -236,8 +208,8 @@ describe("ApprovalBanner — decisions", () => {
|
||||
});
|
||||
|
||||
it("shows a success toast on approve", async () => {
|
||||
_mockGet.mockResolvedValueOnce([pendingApproval("a1")] as unknown[]);
|
||||
_mockPost.mockResolvedValueOnce({} as unknown);
|
||||
vi.spyOn(api, "get").mockResolvedValueOnce([pendingApproval("a1")]);
|
||||
vi.spyOn(api, "post").mockResolvedValueOnce(undefined);
|
||||
|
||||
render(<ApprovalBanner />);
|
||||
await act(async () => {
|
||||
@@ -247,13 +219,13 @@ describe("ApprovalBanner — decisions", () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: /approve/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(_mockToast).toHaveBeenCalledWith("Approved", "success");
|
||||
expect(showToast).toHaveBeenCalledWith("Approved", "success");
|
||||
});
|
||||
});
|
||||
|
||||
it("shows an info toast on deny", async () => {
|
||||
_mockGet.mockResolvedValueOnce([pendingApproval("a1")] as unknown[]);
|
||||
_mockPost.mockResolvedValueOnce({} as unknown);
|
||||
vi.spyOn(api, "get").mockResolvedValueOnce([pendingApproval("a1")]);
|
||||
vi.spyOn(api, "post").mockResolvedValueOnce(undefined);
|
||||
|
||||
render(<ApprovalBanner />);
|
||||
await act(async () => {
|
||||
@@ -263,18 +235,13 @@ describe("ApprovalBanner — decisions", () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: /deny/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(_mockToast).toHaveBeenCalledWith("Denied", "info");
|
||||
expect(showToast).toHaveBeenCalledWith("Denied", "info");
|
||||
});
|
||||
});
|
||||
|
||||
it("shows an error toast when POST fails", async () => {
|
||||
_mockGet.mockResolvedValueOnce([pendingApproval("a1")] as unknown[]);
|
||||
// Use mockImplementation instead of mockRejectedValueOnce so the vi.fn
|
||||
// wrapper is preserved — the component's catch block needs the resolved
|
||||
// promise wrapper to distinguish a rejected-from-mock vs thrown-from-code.
|
||||
_mockPost.mockImplementation(
|
||||
() => new Promise((_, reject) => reject(new Error("Network error"))),
|
||||
);
|
||||
vi.spyOn(api, "get").mockResolvedValueOnce([pendingApproval("a1")]);
|
||||
vi.spyOn(api, "post").mockRejectedValueOnce(new Error("Network error"));
|
||||
|
||||
render(<ApprovalBanner />);
|
||||
await act(async () => {
|
||||
@@ -284,15 +251,13 @@ describe("ApprovalBanner — decisions", () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: /approve/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(_mockToast).toHaveBeenCalledWith("Failed to submit decision", "error");
|
||||
expect(showToast).toHaveBeenCalledWith("Failed to submit decision", "error");
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the card visible when the POST fails", async () => {
|
||||
_mockGet.mockResolvedValueOnce([pendingApproval("a1")] as unknown[]);
|
||||
_mockPost.mockImplementation(
|
||||
() => new Promise((_, reject) => reject(new Error("Network error"))),
|
||||
);
|
||||
vi.spyOn(api, "get").mockResolvedValueOnce([pendingApproval("a1")]);
|
||||
vi.spyOn(api, "post").mockRejectedValueOnce(new Error("Network error"));
|
||||
|
||||
render(<ApprovalBanner />);
|
||||
await act(async () => {
|
||||
@@ -310,7 +275,7 @@ describe("ApprovalBanner — decisions", () => {
|
||||
|
||||
describe("ApprovalBanner — handles empty list from server", () => {
|
||||
it("shows nothing when the API returns an empty array on first poll", async () => {
|
||||
_mockGet.mockResolvedValueOnce([] as unknown[]);
|
||||
vi.spyOn(api, "get").mockResolvedValueOnce([]);
|
||||
render(<ApprovalBanner />);
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
|
||||
@@ -37,22 +37,12 @@ function makeBundle(name = "test-workspace"): File {
|
||||
});
|
||||
}
|
||||
|
||||
// jsdom doesn't define DragEvent globally; create a dragover event with
|
||||
// dataTransfer.types stubbed to include "Files" so handleDragOver triggers.
|
||||
function createDragOverEvent() {
|
||||
return Object.assign(new Event("dragover", { bubbles: true, cancelable: true }), {
|
||||
dataTransfer: { types: ["Files"], files: null },
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("BundleDropZone — render", () => {
|
||||
it("renders a hidden file input with correct accept and aria-label", () => {
|
||||
render(<BundleDropZone />);
|
||||
// Use id selector since both input and button share aria-label="Import bundle file"
|
||||
const input = document.getElementById("bundle-file-input") as HTMLInputElement;
|
||||
expect(input).toBeTruthy();
|
||||
const input = screen.getByLabelText("Import bundle file");
|
||||
expect(input.getAttribute("type")).toBe("file");
|
||||
expect(input.getAttribute("accept")).toBe(".bundle.json");
|
||||
});
|
||||
@@ -74,17 +64,22 @@ describe("BundleDropZone — drag state", () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("shows the drop overlay when a file is dragged over", async () => {
|
||||
it("shows the drop overlay when a file is dragged over", () => {
|
||||
render(<BundleDropZone />);
|
||||
expect(screen.queryByText("Drop Bundle to Import")).toBeNull();
|
||||
const zone = document.body.querySelector('[class*="z-10"]') as HTMLElement;
|
||||
const overlay = screen.getByText("Drop Bundle to Import").closest("div");
|
||||
expect(overlay?.className).toContain("fixed");
|
||||
|
||||
// Simulate drag-over on the invisible drop zone
|
||||
const zone = document.body.querySelector('[class*="fixed inset-0 z-10"]') as HTMLElement;
|
||||
if (zone) {
|
||||
const dragOverEvent = createDragOverEvent();
|
||||
fireEvent.dragOver(zone, dragOverEvent);
|
||||
fireEvent.dragOver(zone);
|
||||
} else {
|
||||
// Fallback: dispatch on the component's outer div
|
||||
const container = document.body.querySelector('[class*="pointer-events-none"]') as HTMLElement;
|
||||
if (container) {
|
||||
fireEvent.dragOver(container);
|
||||
}
|
||||
}
|
||||
await act(async () => { vi.runOnlyPendingTimers(); });
|
||||
const overlay = screen.getByText("Drop Bundle to Import").closest('[class*="z-20"]');
|
||||
expect(overlay).not.toBeNull();
|
||||
});
|
||||
|
||||
it("hides the drop overlay when not dragging", () => {
|
||||
@@ -97,7 +92,8 @@ describe("BundleDropZone — drag state", () => {
|
||||
describe("BundleDropZone — keyboard file input (WCAG 2.1.1)", () => {
|
||||
it("triggers the hidden file input when the import button is clicked", () => {
|
||||
render(<BundleDropZone />);
|
||||
const input = document.getElementById("bundle-file-input") as HTMLInputElement; const clickSpy = vi.spyOn(input, "click");
|
||||
const input = screen.getByLabelText("Import bundle file") as HTMLInputElement;
|
||||
const clickSpy = vi.spyOn(input, "click");
|
||||
fireEvent.click(screen.getByRole("button", { name: /import bundle/i }));
|
||||
expect(clickSpy).toHaveBeenCalled();
|
||||
});
|
||||
@@ -111,7 +107,7 @@ describe("BundleDropZone — keyboard file input (WCAG 2.1.1)", () => {
|
||||
});
|
||||
|
||||
render(<BundleDropZone />);
|
||||
const input = document.getElementById("bundle-file-input") as HTMLInputElement;
|
||||
const input = screen.getByLabelText("Import bundle file");
|
||||
|
||||
const file = makeBundle("My Bundle");
|
||||
Object.defineProperty(input, "files", {
|
||||
@@ -143,7 +139,7 @@ describe("BundleDropZone — import success", () => {
|
||||
});
|
||||
|
||||
render(<BundleDropZone />);
|
||||
const input = document.getElementById("bundle-file-input") as HTMLInputElement;
|
||||
const input = screen.getByLabelText("Import bundle file");
|
||||
|
||||
const file = makeBundle("Success Workspace");
|
||||
Object.defineProperty(input, "files", { value: [file], writable: false });
|
||||
@@ -174,7 +170,7 @@ describe("BundleDropZone — import success", () => {
|
||||
});
|
||||
|
||||
render(<BundleDropZone />);
|
||||
const input = document.getElementById("bundle-file-input") as HTMLInputElement;
|
||||
const input = screen.getByLabelText("Import bundle file");
|
||||
|
||||
const file = makeBundle("Timed Workspace");
|
||||
Object.defineProperty(input, "files", { value: [file], writable: false });
|
||||
@@ -200,7 +196,7 @@ describe("BundleDropZone — import error", () => {
|
||||
vi.mocked(api.post).mockRejectedValueOnce(new Error("Import failed: 500 Internal Server Error"));
|
||||
|
||||
render(<BundleDropZone />);
|
||||
const input = document.getElementById("bundle-file-input") as HTMLInputElement;
|
||||
const input = screen.getByLabelText("Import bundle file");
|
||||
|
||||
const file = makeBundle("Failed Workspace");
|
||||
Object.defineProperty(input, "files", { value: [file], writable: false });
|
||||
@@ -218,7 +214,7 @@ describe("BundleDropZone — import error", () => {
|
||||
it("shows error when file is not a .bundle.json", async () => {
|
||||
vi.useFakeTimers();
|
||||
render(<BundleDropZone />);
|
||||
const input = document.getElementById("bundle-file-input") as HTMLInputElement;
|
||||
const input = screen.getByLabelText("Import bundle file");
|
||||
|
||||
const file = new File(["{}"], "readme.txt", { type: "text/plain" });
|
||||
Object.defineProperty(input, "files", { value: [file], writable: false });
|
||||
@@ -243,7 +239,7 @@ describe("BundleDropZone — import error", () => {
|
||||
vi.mocked(api.post).mockRejectedValueOnce(new Error("Network error"));
|
||||
|
||||
render(<BundleDropZone />);
|
||||
const input = document.getElementById("bundle-file-input") as HTMLInputElement;
|
||||
const input = screen.getByLabelText("Import bundle file");
|
||||
|
||||
const file = makeBundle("Error Workspace");
|
||||
Object.defineProperty(input, "files", { value: [file], writable: false });
|
||||
@@ -271,7 +267,7 @@ describe("BundleDropZone — importing state", () => {
|
||||
vi.mocked(api.post).mockReturnValueOnce(pending as unknown as ReturnType<typeof api.post>);
|
||||
|
||||
render(<BundleDropZone />);
|
||||
const input = document.getElementById("bundle-file-input") as HTMLInputElement;
|
||||
const input = screen.getByLabelText("Import bundle file");
|
||||
|
||||
const file = makeBundle("Pending Workspace");
|
||||
Object.defineProperty(input, "files", { value: [file], writable: false });
|
||||
@@ -303,7 +299,8 @@ describe("BundleDropZone — file input reset", () => {
|
||||
});
|
||||
|
||||
render(<BundleDropZone />);
|
||||
const input = document.getElementById("bundle-file-input") as HTMLInputElement;
|
||||
const input = screen.getByLabelText("Import bundle file") as HTMLInputElement;
|
||||
|
||||
const file = makeBundle("Reset Test");
|
||||
Object.defineProperty(input, "files", { value: [file], writable: false });
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ContextMenu } from "../ContextMenu";
|
||||
import { useCanvasStore } from "@/store/canvas";
|
||||
import { showToast } from "../Toaster";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
// ─── Mock Toaster ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -22,10 +21,12 @@ vi.mock("../Toaster", () => ({
|
||||
|
||||
// ─── Mock API ────────────────────────────────────────────────────────────────
|
||||
|
||||
const apiPost = vi.fn().mockResolvedValue(undefined as void);
|
||||
const apiPatch = vi.fn().mockResolvedValue(undefined as void);
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: {
|
||||
post: vi.fn().mockResolvedValue(undefined as void),
|
||||
patch: vi.fn().mockResolvedValue(undefined as void),
|
||||
post: apiPost,
|
||||
patch: apiPatch,
|
||||
get: vi.fn(),
|
||||
},
|
||||
}));
|
||||
@@ -95,8 +96,8 @@ describe("ContextMenu — visibility", () => {
|
||||
mockStoreState.setCollapsed.mockClear();
|
||||
mockStoreState.arrangeChildren.mockClear();
|
||||
mockStoreState.nodes = [];
|
||||
vi.mocked(api.post).mockReset();
|
||||
vi.mocked(api.patch).mockReset();
|
||||
apiPost.mockReset();
|
||||
apiPatch.mockReset();
|
||||
vi.mocked(showToast).mockClear();
|
||||
});
|
||||
|
||||
@@ -145,8 +146,8 @@ describe("ContextMenu — close", () => {
|
||||
mockStoreState.setCollapsed.mockClear();
|
||||
mockStoreState.arrangeChildren.mockClear();
|
||||
mockStoreState.nodes = [];
|
||||
vi.mocked(api.post).mockReset();
|
||||
vi.mocked(api.patch).mockReset();
|
||||
apiPost.mockReset();
|
||||
apiPatch.mockReset();
|
||||
vi.mocked(showToast).mockClear();
|
||||
});
|
||||
|
||||
@@ -167,7 +168,7 @@ describe("ContextMenu — close", () => {
|
||||
it("closes when Tab is pressed", () => {
|
||||
openMenu();
|
||||
render(<ContextMenu />);
|
||||
fireEvent.keyDown(screen.getByRole("menu"), { key: "Tab" });
|
||||
fireEvent.keyDown(document.body, { key: "Tab" });
|
||||
expect(mockStoreState.closeContextMenu).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -186,8 +187,8 @@ describe("ContextMenu — menu items", () => {
|
||||
mockStoreState.setCollapsed.mockClear();
|
||||
mockStoreState.arrangeChildren.mockClear();
|
||||
mockStoreState.nodes = [];
|
||||
vi.mocked(api.post).mockReset();
|
||||
vi.mocked(api.patch).mockReset();
|
||||
apiPost.mockReset();
|
||||
apiPatch.mockReset();
|
||||
vi.mocked(showToast).mockClear();
|
||||
});
|
||||
|
||||
@@ -201,11 +202,8 @@ describe("ContextMenu — menu items", () => {
|
||||
it("hides Chat and Terminal for offline nodes", () => {
|
||||
openMenu({ nodeData: { name: "Bob", status: "offline", tier: 2, role: "analyst" } });
|
||||
render(<ContextMenu />);
|
||||
// Offline nodes render Chat/Terminal as disabled buttons (accessible but non-interactive)
|
||||
const chatBtn = screen.getByRole("menuitem", { name: /chat/i });
|
||||
const termBtn = screen.getByRole("menuitem", { name: /terminal/i });
|
||||
expect(chatBtn.hasAttribute("disabled")).toBe(true);
|
||||
expect(termBtn.hasAttribute("disabled")).toBe(true);
|
||||
expect(screen.queryByRole("menuitem", { name: /chat/i })).toBeNull();
|
||||
expect(screen.queryByRole("menuitem", { name: /terminal/i })).toBeNull();
|
||||
});
|
||||
|
||||
it("shows Pause for online nodes (not paused)", () => {
|
||||
@@ -286,8 +284,8 @@ describe("ContextMenu — keyboard navigation", () => {
|
||||
mockStoreState.setCollapsed.mockClear();
|
||||
mockStoreState.arrangeChildren.mockClear();
|
||||
mockStoreState.nodes = [];
|
||||
vi.mocked(api.post).mockReset();
|
||||
vi.mocked(api.patch).mockReset();
|
||||
apiPost.mockReset();
|
||||
apiPatch.mockReset();
|
||||
vi.mocked(showToast).mockClear();
|
||||
});
|
||||
|
||||
@@ -328,8 +326,8 @@ describe("ContextMenu — item actions", () => {
|
||||
mockStoreState.setCollapsed.mockClear();
|
||||
mockStoreState.arrangeChildren.mockClear();
|
||||
mockStoreState.nodes = [];
|
||||
vi.mocked(api.post).mockReset();
|
||||
vi.mocked(api.patch).mockReset();
|
||||
apiPost.mockReset();
|
||||
apiPatch.mockReset();
|
||||
vi.mocked(showToast).mockClear();
|
||||
});
|
||||
|
||||
@@ -359,20 +357,20 @@ describe("ContextMenu — item actions", () => {
|
||||
|
||||
it("Pause calls the pause API and updates node status optimistically", async () => {
|
||||
openMenu({ nodeData: { name: "Alice", status: "online", tier: 4, role: "assistant" } });
|
||||
vi.mocked(api.post).mockResolvedValue(undefined);
|
||||
apiPost.mockResolvedValue(undefined);
|
||||
render(<ContextMenu />);
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: /pause/i }));
|
||||
await act(async () => { /* flush */ });
|
||||
expect(vi.mocked(api.post)).toHaveBeenCalledWith("/workspaces/n1/pause", {});
|
||||
expect(apiPost).toHaveBeenCalledWith("/workspaces/n1/pause", {});
|
||||
expect(mockStoreState.updateNodeData).toHaveBeenCalledWith("n1", { status: "paused" });
|
||||
});
|
||||
|
||||
it("Resume calls the resume API", async () => {
|
||||
openMenu({ nodeData: { name: "Alice", status: "paused", tier: 4, role: "assistant" } });
|
||||
vi.mocked(api.post).mockResolvedValue(undefined);
|
||||
apiPost.mockResolvedValue(undefined);
|
||||
render(<ContextMenu />);
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: /resume/i }));
|
||||
await act(async () => { /* flush */ });
|
||||
expect(vi.mocked(api.post)).toHaveBeenCalledWith("/workspaces/n1/resume", {});
|
||||
expect(apiPost).toHaveBeenCalledWith("/workspaces/n1/resume", {});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -96,9 +96,9 @@ describe("extractMessageText — response result format", () => {
|
||||
],
|
||||
},
|
||||
};
|
||||
// Both parts contribute: text from first part, root.text from second.
|
||||
// The implementation: all non-empty strings joined with newline.
|
||||
expect(extractMessageText(body)).toBe("Direct text\nRoot text");
|
||||
// Both are non-empty strings, so the first one wins (filter picks the first)
|
||||
// The implementation: rText from rParts[0].text = "Direct text"
|
||||
expect(extractMessageText(body)).toBe("Direct text");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,267 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for EmptyState component — the full-canvas welcome card on first load.
|
||||
*
|
||||
* Pattern: all vi.fn() refs are created by a SINGLE vi.hoisted() call,
|
||||
* returned as a named-const object. Individual vi.mock factories then
|
||||
* import that object and pull out the fields they need. This avoids
|
||||
* "Cannot access before initialization" errors from vi.mock hoisting.
|
||||
*/
|
||||
import React from "react";
|
||||
import { render, screen, fireEvent, cleanup, waitFor, act } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { EmptyState } from "../EmptyState";
|
||||
|
||||
// ─── Module-level mocks ───────────────────────────────────────────────────────
|
||||
// vi.hoisted is evaluated after module-level vars are declared, so these
|
||||
// refs are stable and accessible inside vi.mock factories (which are
|
||||
// hoisted above everything). We return an object so a SINGLE hoisted call
|
||||
// creates all mocks; each vi.mock then references m.<field>.
|
||||
const m = vi.hoisted(() => {
|
||||
const mockGet = vi.fn<() => Promise<unknown[]>>();
|
||||
const mockPost = vi.fn<() => Promise<{ id: string }>>();
|
||||
const mockCheckDeploySecrets = vi.fn<
|
||||
() => Promise<{
|
||||
ok: boolean;
|
||||
missingKeys: string[];
|
||||
providers: string[];
|
||||
runtime: string;
|
||||
configuredKeys: string[];
|
||||
}>
|
||||
>();
|
||||
const mockSelectNode = vi.fn<(id: string) => void>();
|
||||
const mockSetPanelTab = vi.fn<(tab: string) => void>();
|
||||
const mockDeploy = vi.fn<(t: { id: string; name: string }) => Promise<void>>();
|
||||
const mockUseTemplateDeploy = vi.fn(() => ({
|
||||
deploy: mockDeploy,
|
||||
deploying: false,
|
||||
error: null,
|
||||
modal: null,
|
||||
}));
|
||||
|
||||
return {
|
||||
mockGet,
|
||||
mockPost,
|
||||
mockCheckDeploySecrets,
|
||||
mockSelectNode,
|
||||
mockSetPanelTab,
|
||||
mockDeploy,
|
||||
mockUseTemplateDeploy,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: { get: m.mockGet, post: m.mockPost },
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/deploy-preflight", () => ({
|
||||
checkDeploySecrets: m.mockCheckDeploySecrets,
|
||||
}));
|
||||
|
||||
vi.mock("@/store/canvas", () => ({
|
||||
useCanvasStore: Object.assign(
|
||||
// The hook returns an object with selectNode/setPanelTab;
|
||||
// the component also calls useCanvasStore.getState() directly.
|
||||
vi.fn(() => ({
|
||||
selectNode: m.mockSelectNode,
|
||||
setPanelTab: m.mockSetPanelTab,
|
||||
})),
|
||||
{
|
||||
getState: () => ({
|
||||
selectNode: m.mockSelectNode,
|
||||
setPanelTab: m.mockSetPanelTab,
|
||||
}),
|
||||
},
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/useTemplateDeploy", () => ({
|
||||
useTemplateDeploy: m.mockUseTemplateDeploy,
|
||||
}));
|
||||
|
||||
// Mock OrgTemplatesSection — tested separately.
|
||||
vi.mock("../TemplatePalette", () => ({
|
||||
OrgTemplatesSection: () => (
|
||||
<div data-testid="org-templates-section">Org Templates</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// ─── Test data ───────────────────────────────────────────────────────────────
|
||||
|
||||
const TEMPLATE = {
|
||||
id: "molecule-dev",
|
||||
name: "Molecule Dev",
|
||||
tier: 2,
|
||||
description: "A full-featured agent workspace for development",
|
||||
runtime: "langgraph",
|
||||
required_env: ["ANTHROPIC_API_KEY"],
|
||||
models: [{ id: "claude-sonnet-4-20250514", required_env: ["ANTHROPIC_API_KEY"] }],
|
||||
model: "claude-sonnet-4-20250514",
|
||||
skill_count: 12,
|
||||
};
|
||||
|
||||
// ─── Cleanup ─────────────────────────────────────────────────────────────────
|
||||
|
||||
beforeEach(() => {
|
||||
m.mockGet.mockReset();
|
||||
m.mockGet.mockResolvedValue([] as unknown[]);
|
||||
m.mockPost.mockReset();
|
||||
m.mockPost.mockResolvedValue({ id: "new-ws-123" } as unknown as { id: string });
|
||||
m.mockCheckDeploySecrets.mockReset();
|
||||
m.mockCheckDeploySecrets.mockResolvedValue({
|
||||
ok: true,
|
||||
missingKeys: [],
|
||||
providers: [],
|
||||
runtime: "langgraph",
|
||||
configuredKeys: [],
|
||||
});
|
||||
m.mockSelectNode.mockReset();
|
||||
m.mockSetPanelTab.mockReset();
|
||||
m.mockDeploy.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("EmptyState — loading state", () => {
|
||||
it("shows spinner and loading text while templates are being fetched", () => {
|
||||
m.mockGet.mockImplementation(() => new Promise(() => {}));
|
||||
render(<EmptyState />);
|
||||
expect(screen.getByText(/loading templates/i)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("EmptyState — templates fetched", () => {
|
||||
it("renders template grid with name, tier badge, description, skill count", async () => {
|
||||
m.mockGet.mockResolvedValueOnce([TEMPLATE] as unknown[]);
|
||||
render(<EmptyState />);
|
||||
await act(async () => { await new Promise(r => setTimeout(r, 50)); });
|
||||
expect(screen.getByText("Molecule Dev")).toBeTruthy();
|
||||
expect(screen.getByText("T2")).toBeTruthy();
|
||||
expect(screen.getByText(/full-featured agent workspace/i)).toBeTruthy();
|
||||
expect(screen.getByText(/12 skills/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows model label when template declares a model", async () => {
|
||||
m.mockGet.mockResolvedValueOnce([TEMPLATE] as unknown[]);
|
||||
render(<EmptyState />);
|
||||
await act(async () => { await new Promise(r => setTimeout(r, 50)); });
|
||||
expect(screen.getByText(/claude-sonnet/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("calls deploy(template) when template button is clicked", async () => {
|
||||
m.mockGet.mockResolvedValueOnce([TEMPLATE] as unknown[]);
|
||||
render(<EmptyState />);
|
||||
await act(async () => { await new Promise(r => setTimeout(r, 50)); });
|
||||
fireEvent.click(screen.getByRole("button", { name: /molecule dev/i }));
|
||||
expect(m.mockDeploy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: "molecule-dev", name: "Molecule Dev" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("EmptyState — no templates", () => {
|
||||
it("shows only the create-blank button when template list is empty", async () => {
|
||||
// beforeEach already sets mockResolvedValue([]) as default — no override needed.
|
||||
render(<EmptyState />);
|
||||
await act(async () => { await new Promise(r => setTimeout(r, 50)); });
|
||||
expect(screen.getByRole("button", { name: /\+ create blank workspace/i })).toBeTruthy();
|
||||
expect(screen.queryByText(/molecule dev/i)).toBeNull();
|
||||
});
|
||||
|
||||
it("shows only the create-blank button when template fetch fails", async () => {
|
||||
m.mockGet.mockRejectedValueOnce(new Error("Network error"));
|
||||
render(<EmptyState />);
|
||||
await act(async () => { await new Promise(r => setTimeout(r, 50)); });
|
||||
expect(screen.getByRole("button", { name: /\+ create blank workspace/i })).toBeTruthy();
|
||||
expect(screen.queryByText(/loading templates/i)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("EmptyState — create blank workspace", () => {
|
||||
it('shows "Creating..." label while blank workspace POST is in-flight', async () => {
|
||||
m.mockPost.mockImplementationOnce(() => new Promise(() => {}));
|
||||
render(<EmptyState />);
|
||||
await act(async () => { await new Promise(r => setTimeout(r, 50)); });
|
||||
fireEvent.click(screen.getByRole("button", { name: /\+ create blank workspace/i }));
|
||||
await act(async () => { await new Promise(r => setTimeout(r, 50)); });
|
||||
expect(screen.getByText("Creating...")).toBeTruthy();
|
||||
// The same button is now relabeled; check it is disabled while POST is in-flight.
|
||||
expect(screen.getByRole("button", { name: /creating\.\.\./i })).toHaveProperty("disabled", true);
|
||||
});
|
||||
|
||||
it("calls POST /workspaces with correct payload on create blank", async () => {
|
||||
m.mockPost.mockResolvedValueOnce({ id: "ws-new-456" } as unknown as { id: string });
|
||||
render(<EmptyState />);
|
||||
await act(async () => { await new Promise(r => setTimeout(r, 50)); });
|
||||
fireEvent.click(screen.getByRole("button", { name: /\+ create blank workspace/i }));
|
||||
await act(async () => { await new Promise(r => setTimeout(r, 50)); });
|
||||
expect(m.mockPost).toHaveBeenCalledWith("/workspaces", {
|
||||
name: "My First Agent",
|
||||
canvas: { x: 200, y: 150 },
|
||||
});
|
||||
});
|
||||
|
||||
it("calls selectNode + setPanelTab(chat) after 500ms on blank create success", async () => {
|
||||
m.mockPost.mockResolvedValueOnce({ id: "ws-new-789" } as unknown as { id: string });
|
||||
render(<EmptyState />);
|
||||
await act(async () => { await new Promise(r => setTimeout(r, 50)); });
|
||||
fireEvent.click(screen.getByRole("button", { name: /\+ create blank workspace/i }));
|
||||
// Wait for the 500ms setTimeout inside handleDeployed to fire and call
|
||||
// canvas store methods. Use waitFor so we don't hard-code timing assumptions.
|
||||
await waitFor(() => {
|
||||
expect(m.mockSelectNode).toHaveBeenCalledWith("ws-new-789");
|
||||
expect(m.mockSetPanelTab).toHaveBeenCalledWith("chat");
|
||||
}, { timeout: 1000 });
|
||||
});
|
||||
|
||||
it("shows error banner on blank create failure", async () => {
|
||||
m.mockPost.mockRejectedValueOnce(new Error("Server error"));
|
||||
render(<EmptyState />);
|
||||
await act(async () => { await new Promise(r => setTimeout(r, 50)); });
|
||||
fireEvent.click(screen.getByRole("button", { name: /\+ create blank workspace/i }));
|
||||
await act(async () => { await new Promise(r => setTimeout(r, 50)); });
|
||||
expect(screen.getByRole("alert")).toBeTruthy();
|
||||
expect(screen.getByText(/server error/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("blank workspace error clears on retry", async () => {
|
||||
m.mockPost.mockRejectedValueOnce(new Error("Server error"));
|
||||
render(<EmptyState />);
|
||||
await act(async () => { await new Promise(r => setTimeout(r, 50)); });
|
||||
fireEvent.click(screen.getByRole("button", { name: /\+ create blank workspace/i }));
|
||||
await act(async () => { await new Promise(r => setTimeout(r, 50)); });
|
||||
expect(screen.getByRole("alert")).toBeTruthy();
|
||||
|
||||
// Retry succeeds — error clears
|
||||
m.mockPost.mockResolvedValueOnce({ id: "ws-retry" } as unknown as { id: string });
|
||||
fireEvent.click(screen.getByRole("button", { name: /\+ create blank workspace/i }));
|
||||
await act(async () => { await new Promise(r => setTimeout(r, 50)); });
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("EmptyState — rendering", () => {
|
||||
it("renders the welcome heading and instructions", async () => {
|
||||
// beforeEach already sets mockGet to resolve to [] — no override needed.
|
||||
render(<EmptyState />);
|
||||
await act(async () => { await new Promise(r => setTimeout(r, 50)); });
|
||||
expect(screen.getByText(/deploy your first agent/i)).toBeTruthy();
|
||||
expect(screen.getByText(/welcome to molecule ai/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders the tips footer", async () => {
|
||||
render(<EmptyState />);
|
||||
await act(async () => { await new Promise(r => setTimeout(r, 50)); });
|
||||
expect(screen.getByText(/drag to nest workspaces/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders OrgTemplatesSection below the create-blank button", async () => {
|
||||
render(<EmptyState />);
|
||||
await act(async () => { await new Promise(r => setTimeout(r, 50)); });
|
||||
expect(screen.getByTestId("org-templates-section")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -149,8 +149,7 @@ describe("Legend — palette offset positioning", () => {
|
||||
(sel) => sel({ templatePaletteOpen: false } as ReturnType<typeof useCanvasStore.getState>)
|
||||
);
|
||||
render(<Legend />);
|
||||
// The panel is the div with the fixed/bottom-6/z-30 classes; find it directly.
|
||||
const panel = document.querySelector('[class*="fixed"][class*="bottom-6"]') as HTMLElement;
|
||||
const panel = screen.getByText("Legend").closest("div");
|
||||
expect(panel?.className).toContain("left-4");
|
||||
});
|
||||
|
||||
@@ -159,7 +158,7 @@ describe("Legend — palette offset positioning", () => {
|
||||
(sel) => sel({ templatePaletteOpen: true } as ReturnType<typeof useCanvasStore.getState>)
|
||||
);
|
||||
render(<Legend />);
|
||||
const panel = document.querySelector('[class*="fixed"][class*="bottom-6"]') as HTMLElement;
|
||||
const panel = screen.getByText("Legend").closest("div");
|
||||
expect(panel?.className).toContain("left-[296px]");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -81,13 +81,11 @@ describe("MissingKeysModal — WCAG 2.1 dialog accessibility", () => {
|
||||
|
||||
it("backdrop div has aria-hidden='true' so screen readers skip it", () => {
|
||||
renderModal({ open: true });
|
||||
// The backdrop is the first child of the portal root — it has bg-black/70
|
||||
// and is a sibling of the dialog, both inside a fixed inset-0 container.
|
||||
const fixedContainer = document.body.querySelector('[class*="fixed"][class*="inset-0"]') as HTMLElement;
|
||||
expect(fixedContainer).toBeTruthy();
|
||||
const backdrop = fixedContainer.querySelector('[class*="bg-black"]') as HTMLElement;
|
||||
// The backdrop is a div outside the dialog; it has onClick and aria-hidden
|
||||
const backdrop = document.querySelector('[aria-hidden="true"]');
|
||||
expect(backdrop).toBeTruthy();
|
||||
expect(backdrop.getAttribute("aria-hidden")).toBe("true");
|
||||
// Verify the backdrop is the full-screen overlay (has bg-black/70)
|
||||
expect(backdrop?.className).toContain("bg-black/70");
|
||||
});
|
||||
|
||||
it("decorative warning SVG in header has aria-hidden='true'", () => {
|
||||
|
||||
@@ -140,17 +140,18 @@ describe("OnboardingWizard — auto-advance", () => {
|
||||
});
|
||||
|
||||
it("auto-advances from welcome to api-key when nodes appear", async () => {
|
||||
const { rerender } = render(<OnboardingWizard />);
|
||||
const { unmount } = render(<OnboardingWizard />);
|
||||
expect(screen.getByText("Welcome to Molecule AI")).toBeTruthy();
|
||||
|
||||
// Simulate a node being added to the store and trigger re-render
|
||||
// Simulate a node being added to the store and re-render
|
||||
mockStoreState.nodes = [{ id: "ws-1", data: {} }];
|
||||
rerender(<OnboardingWizard />);
|
||||
render(<OnboardingWizard />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("Welcome to Molecule AI")).toBeNull();
|
||||
});
|
||||
expect(screen.getByText("Set your API key")).toBeTruthy();
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -12,66 +12,13 @@ import { render, screen, fireEvent, cleanup, act } from "@testing-library/react"
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { PurchaseSuccessModal } from "../PurchaseSuccessModal";
|
||||
|
||||
// ─── History mock ─────────────────────────────────────────────────────────────
|
||||
// jsdom's window.history.replaceState throws SecurityError for http://localhost/
|
||||
// (it normalizes the URL and adds a trailing dot, then fails its own check).
|
||||
// We intercept replaceState to swallow the error and also update the location
|
||||
// object directly so window.location.search reflects the current URL params.
|
||||
const _origReplaceState = window.history.replaceState.bind(window.history);
|
||||
const _origLocation = window.location;
|
||||
let _currentHref = "http://localhost/";
|
||||
|
||||
// Override window.location with a writable version that tracks our fake href
|
||||
Object.defineProperty(window, "location", {
|
||||
value: {
|
||||
get href() { return _currentHref; },
|
||||
set href(v: string) { _currentHref = v; },
|
||||
get search() {
|
||||
const idx = _currentHref.indexOf("?");
|
||||
return idx >= 0 ? _currentHref.slice(idx) : "";
|
||||
},
|
||||
get pathname() {
|
||||
const idx = _currentHref.indexOf("?");
|
||||
const pathPart = idx >= 0 ? _currentHref.slice(0, idx) : _currentHref;
|
||||
return new URL(pathPart).pathname;
|
||||
},
|
||||
toString: () => _currentHref,
|
||||
assign: (url: string) => { _currentHref = url; },
|
||||
replace: (url: string) => { _currentHref = url; },
|
||||
},
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
(window.history as unknown as Record<string, unknown>).replaceState = function(
|
||||
this: History,
|
||||
state: unknown,
|
||||
title: string,
|
||||
url?: string | URL,
|
||||
) {
|
||||
const urlStr = url != null ? String(url) : undefined;
|
||||
if (urlStr != null) _currentHref = urlStr;
|
||||
try {
|
||||
return _origReplaceState.call(this, state, title, url);
|
||||
} catch (err) {
|
||||
// jsdom throws for http://localhost/ — swallow and rely on our fake location
|
||||
return undefined as unknown as void;
|
||||
}
|
||||
} as History["replaceState"];
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function replaceUrl(url: string) {
|
||||
_currentHref = url;
|
||||
try {
|
||||
window.history.replaceState(null, "", url);
|
||||
} catch {
|
||||
// Intercepted above
|
||||
}
|
||||
}
|
||||
|
||||
function pushUrl(url: string) {
|
||||
replaceUrl(url);
|
||||
window.history.pushState({}, "", url);
|
||||
}
|
||||
function replaceUrl(url: string) {
|
||||
window.history.replaceState({}, "", url);
|
||||
}
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
@@ -170,7 +117,7 @@ describe("PurchaseSuccessModal — dismiss", () => {
|
||||
it("closes the dialog when the close button is clicked", async () => {
|
||||
render(<PurchaseSuccessModal />);
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(10);
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
});
|
||||
expect(screen.getByRole("dialog")).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Close" }));
|
||||
@@ -183,7 +130,7 @@ describe("PurchaseSuccessModal — dismiss", () => {
|
||||
it("closes the dialog when the backdrop is clicked", async () => {
|
||||
render(<PurchaseSuccessModal />);
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(10);
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
});
|
||||
expect(screen.getByRole("dialog")).toBeTruthy();
|
||||
// Click the backdrop (the full-screen overlay div)
|
||||
@@ -198,7 +145,7 @@ describe("PurchaseSuccessModal — dismiss", () => {
|
||||
it("closes on Escape key", async () => {
|
||||
render(<PurchaseSuccessModal />);
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(10);
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
});
|
||||
expect(screen.getByRole("dialog")).toBeTruthy();
|
||||
fireEvent.keyDown(window, { key: "Escape" });
|
||||
@@ -211,7 +158,7 @@ describe("PurchaseSuccessModal — dismiss", () => {
|
||||
it("auto-dismisses after 5 seconds", async () => {
|
||||
render(<PurchaseSuccessModal />);
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(10);
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
});
|
||||
expect(screen.getByRole("dialog")).toBeTruthy();
|
||||
|
||||
@@ -224,7 +171,7 @@ describe("PurchaseSuccessModal — dismiss", () => {
|
||||
it("does not auto-dismiss before 5 seconds", async () => {
|
||||
render(<PurchaseSuccessModal />);
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(10);
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
});
|
||||
expect(screen.getByRole("dialog")).toBeTruthy();
|
||||
|
||||
@@ -248,7 +195,7 @@ describe("PurchaseSuccessModal — URL stripping", () => {
|
||||
it("strips purchase_success and item params from the URL on mount", async () => {
|
||||
render(<PurchaseSuccessModal />);
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(10);
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
});
|
||||
const url = new URL(window.location.href);
|
||||
expect(url.searchParams.get("purchase_success")).toBeNull();
|
||||
@@ -259,7 +206,7 @@ describe("PurchaseSuccessModal — URL stripping", () => {
|
||||
const replaceSpy = vi.spyOn(window.history, "replaceState");
|
||||
render(<PurchaseSuccessModal />);
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(10);
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
});
|
||||
expect(replaceSpy).toHaveBeenCalled();
|
||||
});
|
||||
@@ -279,7 +226,7 @@ describe("PurchaseSuccessModal — accessibility", () => {
|
||||
it("has aria-modal=true on the dialog", async () => {
|
||||
render(<PurchaseSuccessModal />);
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(10);
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
});
|
||||
const dialog = screen.getByRole("dialog");
|
||||
expect(dialog.getAttribute("aria-modal")).toBe("true");
|
||||
@@ -288,7 +235,7 @@ describe("PurchaseSuccessModal — accessibility", () => {
|
||||
it("has aria-labelledby pointing to the title", async () => {
|
||||
render(<PurchaseSuccessModal />);
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(10);
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
});
|
||||
const dialog = screen.getByRole("dialog");
|
||||
const labelledby = dialog.getAttribute("aria-labelledby");
|
||||
@@ -300,10 +247,8 @@ describe("PurchaseSuccessModal — accessibility", () => {
|
||||
it("moves focus to the close button on open", async () => {
|
||||
render(<PurchaseSuccessModal />);
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(10);
|
||||
// Advance rAF timers as well (ViTest mocks rAF with fake timers)
|
||||
vi.advanceTimersByTime(0);
|
||||
vi.advanceTimersByTime(0);
|
||||
// Two rAFs for focus: one from the effect, one from the RAF wrapper
|
||||
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
|
||||
});
|
||||
expect(document.activeElement?.textContent).toMatch(/close/i);
|
||||
});
|
||||
|
||||
@@ -6,12 +6,11 @@
|
||||
* aria-label, title text, onToggle callback.
|
||||
*/
|
||||
import React from "react";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { RevealToggle } from "../ui/RevealToggle";
|
||||
|
||||
describe("RevealToggle — render", () => {
|
||||
afterEach(cleanup);
|
||||
it("renders a button element", () => {
|
||||
render(<RevealToggle revealed={false} onToggle={vi.fn()} />);
|
||||
expect(screen.getByRole("button")).toBeTruthy();
|
||||
|
||||
@@ -13,18 +13,13 @@ import { SearchDialog } from "../SearchDialog";
|
||||
import { useCanvasStore } from "@/store/canvas";
|
||||
|
||||
// ─── Mock store ──────────────────────────────────────────────────────────────
|
||||
// Zustand-compatible mock: useSyncExternalStore needs subscribe() to fire
|
||||
// callbacks so React re-renders when state changes. Without it, the
|
||||
// Cmd+K test opens the dialog but the component never re-renders because
|
||||
// React's external-store bridge has no notification to flush.
|
||||
//
|
||||
// We use vi.fn() wrapping for setSearchOpen so tests can use
|
||||
// toHaveBeenCalledWith() for assertions, while also calling the underlying
|
||||
// store update that triggers Zustand's subscriber mechanism.
|
||||
|
||||
type StoreSlice = {
|
||||
searchOpen: boolean;
|
||||
nodes: Array<{
|
||||
const mockStoreState = {
|
||||
searchOpen: false,
|
||||
setSearchOpen: vi.fn((open: boolean) => {
|
||||
mockStoreState.searchOpen = open;
|
||||
}),
|
||||
nodes: [] as Array<{
|
||||
id: string;
|
||||
data: {
|
||||
name: string;
|
||||
@@ -33,48 +28,17 @@ type StoreSlice = {
|
||||
role: string;
|
||||
parentId?: string | null;
|
||||
};
|
||||
}>;
|
||||
selectNode: (id: string) => void;
|
||||
setPanelTab: (tab: string) => void;
|
||||
};
|
||||
|
||||
const _subscribers = new Set<() => void>();
|
||||
|
||||
const _implSetSearchOpen = (open: boolean) => {
|
||||
_mockStore.searchOpen = open;
|
||||
_subscribers.forEach((cb) => cb());
|
||||
};
|
||||
|
||||
const _mockStore: StoreSlice = {
|
||||
searchOpen: false,
|
||||
nodes: [],
|
||||
}>,
|
||||
selectNode: vi.fn(),
|
||||
setPanelTab: vi.fn(),
|
||||
};
|
||||
|
||||
const mockStoreState: StoreSlice & { setSearchOpen: ReturnType<typeof vi.fn> } = {
|
||||
searchOpen: false,
|
||||
nodes: [],
|
||||
selectNode: _mockStore.selectNode,
|
||||
setPanelTab: _mockStore.setPanelTab,
|
||||
// vi.fn() wrapper so tests can use toHaveBeenCalledWith(); the
|
||||
// implementation calls through to _implSetSearchOpen which notifies
|
||||
// Zustand subscribers so React re-renders.
|
||||
setSearchOpen: vi.fn(_implSetSearchOpen),
|
||||
};
|
||||
|
||||
vi.mock("@/store/canvas", () => ({
|
||||
useCanvasStore: Object.assign(
|
||||
(sel: (s: typeof mockStoreState) => unknown) => sel(mockStoreState),
|
||||
{
|
||||
getState: () => mockStoreState,
|
||||
subscribe: (cb: () => void) => {
|
||||
_subscribers.add(cb);
|
||||
return () => { _subscribers.delete(cb); };
|
||||
},
|
||||
} as unknown as ReturnType<typeof vi.fn>,
|
||||
{ getState: () => mockStoreState },
|
||||
),
|
||||
})) as typeof vi.mock;
|
||||
}));
|
||||
|
||||
const STORAGE_KEY = "molecule-onboarding-complete";
|
||||
|
||||
@@ -96,9 +60,9 @@ describe("SearchDialog — visibility", () => {
|
||||
vi.clearAllMocks();
|
||||
mockStoreState.searchOpen = false;
|
||||
mockStoreState.nodes = [];
|
||||
mockStoreState.setSearchOpen.mockClear();
|
||||
mockStoreState.selectNode.mockClear();
|
||||
mockStoreState.setPanelTab.mockClear();
|
||||
_subscribers.clear();
|
||||
});
|
||||
|
||||
it("does not render when searchOpen is false", () => {
|
||||
@@ -120,10 +84,9 @@ describe("SearchDialog — keyboard shortcuts", () => {
|
||||
vi.clearAllMocks();
|
||||
mockStoreState.searchOpen = false;
|
||||
mockStoreState.nodes = [];
|
||||
// setSearchOpen is a bound method, not vi.fn — skip mockClear
|
||||
mockStoreState.setSearchOpen.mockClear();
|
||||
mockStoreState.selectNode.mockClear();
|
||||
mockStoreState.setPanelTab.mockClear();
|
||||
_subscribers.clear();
|
||||
});
|
||||
|
||||
it("opens the dialog when Cmd+K is pressed", () => {
|
||||
@@ -139,18 +102,8 @@ describe("SearchDialog — keyboard shortcuts", () => {
|
||||
});
|
||||
|
||||
it("clears the query when Cmd+K opens the dialog", () => {
|
||||
const { rerender } = render(<SearchDialog />);
|
||||
// Zustand's useSyncExternalStore doesn't always re-render from the
|
||||
// mock's subscribe() callback in the jsdom environment. After the
|
||||
// keyboard handler fires, manually set state and force re-render.
|
||||
act(() => {
|
||||
dispatchKeydown("k", true, false);
|
||||
// After vi.fn(_implSetSearchOpen) runs, subscribers fire but React
|
||||
// may not schedule a re-render in time. Re-render manually so the
|
||||
// component sees the updated searchOpen=true.
|
||||
mockStoreState.searchOpen = true;
|
||||
});
|
||||
rerender(<SearchDialog />);
|
||||
render(<SearchDialog />);
|
||||
dispatchKeydown("k", true, false);
|
||||
const input = screen.getByRole("combobox");
|
||||
expect(input.getAttribute("value") ?? "").toBe("");
|
||||
});
|
||||
@@ -169,9 +122,9 @@ describe("SearchDialog — focus", () => {
|
||||
vi.clearAllMocks();
|
||||
mockStoreState.searchOpen = false;
|
||||
mockStoreState.nodes = [];
|
||||
mockStoreState.setSearchOpen.mockClear();
|
||||
mockStoreState.selectNode.mockClear();
|
||||
mockStoreState.setPanelTab.mockClear();
|
||||
_subscribers.clear();
|
||||
});
|
||||
|
||||
it("focuses the input when the dialog opens", async () => {
|
||||
@@ -204,9 +157,9 @@ describe("SearchDialog — filtering", () => {
|
||||
vi.clearAllMocks();
|
||||
mockStoreState.searchOpen = false;
|
||||
mockStoreState.nodes = [];
|
||||
mockStoreState.setSearchOpen.mockClear();
|
||||
mockStoreState.selectNode.mockClear();
|
||||
mockStoreState.setPanelTab.mockClear();
|
||||
_subscribers.clear();
|
||||
});
|
||||
|
||||
it("shows all workspaces when query is empty", () => {
|
||||
@@ -277,9 +230,9 @@ describe("SearchDialog — listbox navigation", () => {
|
||||
vi.clearAllMocks();
|
||||
mockStoreState.searchOpen = false;
|
||||
mockStoreState.nodes = [];
|
||||
mockStoreState.setSearchOpen.mockClear();
|
||||
mockStoreState.selectNode.mockClear();
|
||||
mockStoreState.setPanelTab.mockClear();
|
||||
_subscribers.clear();
|
||||
});
|
||||
|
||||
it("highlights the first result when query is typed", () => {
|
||||
@@ -317,36 +270,11 @@ describe("SearchDialog — listbox navigation", () => {
|
||||
|
||||
it("Enter selects the highlighted workspace", () => {
|
||||
mockStoreState.searchOpen = true;
|
||||
const { rerender } = render(<SearchDialog />);
|
||||
render(<SearchDialog />);
|
||||
const input = screen.getByRole("combobox");
|
||||
|
||||
// Directly update the DOM input value + fire change event, then force
|
||||
// a re-render so React commits the query state before keyboard events.
|
||||
act(() => {
|
||||
// Simulate user typing "a" — the onChange handler fires synchronously
|
||||
// inside act(), but we also need the component to re-render with the
|
||||
// new query so the filtered list and focusedIndex update correctly.
|
||||
Object.defineProperty(input, "value", {
|
||||
value: "a",
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
fireEvent.change(input, { target: { value: "a" } });
|
||||
// After onChange fires, query="a". React schedules a re-render but
|
||||
// might not have flushed it yet — rerender forces it so ArrowDown
|
||||
// sees focusedIndex=0 (effect ran from filtered.length change).
|
||||
rerender(<SearchDialog />);
|
||||
});
|
||||
|
||||
// Now focusedIndex should be 0 (Alice, filtered[0]). ArrowUp stays at 0.
|
||||
// ArrowDown moves to 1 (Carol). We want to select Alice, so go
|
||||
// ArrowUp to stay at 0, then Enter.
|
||||
act(() => {
|
||||
fireEvent.keyDown(input, { key: "ArrowUp" }); // Math.max(0-1, 0) = 0
|
||||
});
|
||||
act(() => {
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
});
|
||||
fireEvent.change(input, { target: { value: "a" } }); // All 3 match
|
||||
fireEvent.keyDown(input, { key: "ArrowDown" }); // Highlight Bob
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(mockStoreState.selectNode).toHaveBeenCalledWith("n1"); // Alice
|
||||
expect(mockStoreState.setPanelTab).toHaveBeenCalledWith("details");
|
||||
expect(mockStoreState.setSearchOpen).toHaveBeenCalledWith(false);
|
||||
@@ -359,9 +287,9 @@ describe("SearchDialog — aria attributes", () => {
|
||||
vi.clearAllMocks();
|
||||
mockStoreState.searchOpen = false;
|
||||
mockStoreState.nodes = [];
|
||||
mockStoreState.setSearchOpen.mockClear();
|
||||
mockStoreState.selectNode.mockClear();
|
||||
mockStoreState.setPanelTab.mockClear();
|
||||
_subscribers.clear();
|
||||
});
|
||||
|
||||
it("dialog has role=dialog and aria-modal=true", () => {
|
||||
@@ -397,9 +325,9 @@ describe("SearchDialog — footer", () => {
|
||||
vi.clearAllMocks();
|
||||
mockStoreState.searchOpen = false;
|
||||
mockStoreState.nodes = [];
|
||||
mockStoreState.setSearchOpen.mockClear();
|
||||
mockStoreState.selectNode.mockClear();
|
||||
mockStoreState.setPanelTab.mockClear();
|
||||
_subscribers.clear();
|
||||
});
|
||||
|
||||
it("footer shows singular 'workspace' when count is 1", () => {
|
||||
|
||||
@@ -14,33 +14,29 @@ describe("Spinner — size variants", () => {
|
||||
const { container } = render(<Spinner size="sm" />);
|
||||
const svg = container.querySelector("svg");
|
||||
expect(svg).toBeTruthy();
|
||||
const cls = svg?.getAttribute("class") ?? "";
|
||||
expect(cls).toContain("w-3");
|
||||
expect(cls).toContain("h-3");
|
||||
expect(svg?.className).toContain("w-3");
|
||||
expect(svg?.className).toContain("h-3");
|
||||
});
|
||||
|
||||
it("renders with md size class (default)", () => {
|
||||
const { container } = render(<Spinner size="md" />);
|
||||
const svg = container.querySelector("svg");
|
||||
const cls = svg?.getAttribute("class") ?? "";
|
||||
expect(cls).toContain("w-4");
|
||||
expect(cls).toContain("h-4");
|
||||
expect(svg?.className).toContain("w-4");
|
||||
expect(svg?.className).toContain("h-4");
|
||||
});
|
||||
|
||||
it("renders with lg size class", () => {
|
||||
const { container } = render(<Spinner size="lg" />);
|
||||
const svg = container.querySelector("svg");
|
||||
const cls = svg?.getAttribute("class") ?? "";
|
||||
expect(cls).toContain("w-5");
|
||||
expect(cls).toContain("h-5");
|
||||
expect(svg?.className).toContain("w-5");
|
||||
expect(svg?.className).toContain("h-5");
|
||||
});
|
||||
|
||||
it("defaults to md size when no size prop given", () => {
|
||||
const { container } = render(<Spinner />);
|
||||
const svg = container.querySelector("svg");
|
||||
const cls = svg?.getAttribute("class") ?? "";
|
||||
expect(cls).toContain("w-4");
|
||||
expect(cls).toContain("h-4");
|
||||
expect(svg?.className).toContain("w-4");
|
||||
expect(svg?.className).toContain("h-4");
|
||||
});
|
||||
|
||||
it("has aria-hidden=true so screen readers skip it", () => {
|
||||
@@ -52,8 +48,7 @@ describe("Spinner — size variants", () => {
|
||||
it("includes the motion-safe:animate-spin class for CSS animation", () => {
|
||||
const { container } = render(<Spinner />);
|
||||
const svg = container.querySelector("svg");
|
||||
const cls = svg?.getAttribute("class") ?? "";
|
||||
expect(cls).toContain("motion-safe:animate-spin");
|
||||
expect(svg?.className).toContain("motion-safe:animate-spin");
|
||||
});
|
||||
|
||||
it("renders exactly one SVG element", () => {
|
||||
|
||||
@@ -6,12 +6,11 @@
|
||||
* icon presence, className variants, no render when passed invalid status.
|
||||
*/
|
||||
import React from "react";
|
||||
import { render, screen, cleanup } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { StatusBadge } from "../ui/StatusBadge";
|
||||
|
||||
describe("StatusBadge — render", () => {
|
||||
afterEach(cleanup);
|
||||
it("renders verified status with ✓ icon", () => {
|
||||
render(<StatusBadge status="verified" />);
|
||||
const badge = screen.getByRole("status");
|
||||
|
||||
@@ -11,18 +11,16 @@
|
||||
* - provisioning status carries motion-safe:animate-pulse for the pulsing effect
|
||||
* - glow class applied when STATUS_CONFIG declares one
|
||||
*/
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { render, screen, cleanup } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import React from "react";
|
||||
|
||||
import { StatusDot } from "../StatusDot";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("StatusDot — snapshot", () => {
|
||||
it("renders with online status", () => {
|
||||
render(<StatusDot status="online" />);
|
||||
const dot = screen.getByRole("img", { hidden: true });
|
||||
const dot = screen.getByRole("img");
|
||||
expect(dot.className).toContain("bg-emerald-400");
|
||||
expect(dot.className).toContain("shadow-emerald-400/50");
|
||||
expect(dot.getAttribute("aria-hidden")).toBe("true");
|
||||
@@ -30,7 +28,7 @@ describe("StatusDot — snapshot", () => {
|
||||
|
||||
it("renders with offline status", () => {
|
||||
render(<StatusDot status="offline" />);
|
||||
const dot = screen.getByRole("img", { hidden: true });
|
||||
const dot = screen.getByRole("img");
|
||||
expect(dot.className).toContain("bg-zinc-500");
|
||||
// offline has no glow
|
||||
expect(dot.className).not.toContain("shadow-");
|
||||
@@ -38,34 +36,34 @@ describe("StatusDot — snapshot", () => {
|
||||
|
||||
it("renders with degraded status", () => {
|
||||
render(<StatusDot status="degraded" />);
|
||||
const dot = screen.getByRole("img", { hidden: true });
|
||||
const dot = screen.getByRole("img");
|
||||
expect(dot.className).toContain("bg-amber-400");
|
||||
expect(dot.className).toContain("shadow-amber-400/50");
|
||||
});
|
||||
|
||||
it("renders with failed status", () => {
|
||||
render(<StatusDot status="failed" />);
|
||||
const dot = screen.getByRole("img", { hidden: true });
|
||||
const dot = screen.getByRole("img");
|
||||
expect(dot.className).toContain("bg-red-400");
|
||||
expect(dot.className).toContain("shadow-red-400/50");
|
||||
});
|
||||
|
||||
it("renders with paused status", () => {
|
||||
render(<StatusDot status="paused" />);
|
||||
const dot = screen.getByRole("img", { hidden: true });
|
||||
const dot = screen.getByRole("img");
|
||||
expect(dot.className).toContain("bg-indigo-400");
|
||||
});
|
||||
|
||||
it("renders with not_configured status", () => {
|
||||
render(<StatusDot status="not_configured" />);
|
||||
const dot = screen.getByRole("img", { hidden: true });
|
||||
const dot = screen.getByRole("img");
|
||||
expect(dot.className).toContain("bg-amber-300");
|
||||
expect(dot.className).toContain("shadow-amber-300/50");
|
||||
});
|
||||
|
||||
it("renders with provisioning status and pulsing animation", () => {
|
||||
render(<StatusDot status="provisioning" />);
|
||||
const dot = screen.getByRole("img", { hidden: true });
|
||||
const dot = screen.getByRole("img");
|
||||
expect(dot.className).toContain("bg-sky-400");
|
||||
expect(dot.className).toContain("motion-safe:animate-pulse");
|
||||
expect(dot.className).toContain("shadow-sky-400/50");
|
||||
@@ -73,7 +71,7 @@ describe("StatusDot — snapshot", () => {
|
||||
|
||||
it("falls back to bg-zinc-500 for unknown status", () => {
|
||||
render(<StatusDot status="alien_artifact" />);
|
||||
const dot = screen.getByRole("img", { hidden: true });
|
||||
const dot = screen.getByRole("img");
|
||||
expect(dot.className).toContain("bg-zinc-500");
|
||||
});
|
||||
});
|
||||
@@ -81,14 +79,14 @@ describe("StatusDot — snapshot", () => {
|
||||
describe("StatusDot — size prop", () => {
|
||||
it("applies w-2 h-2 (sm, default)", () => {
|
||||
render(<StatusDot status="online" />);
|
||||
const dot = screen.getByRole("img", { hidden: true });
|
||||
const dot = screen.getByRole("img");
|
||||
expect(dot.className).toContain("w-2");
|
||||
expect(dot.className).toContain("h-2");
|
||||
});
|
||||
|
||||
it("applies w-2.5 h-2.5 (md)", () => {
|
||||
render(<StatusDot status="online" size="md" />);
|
||||
const dot = screen.getByRole("img", { hidden: true });
|
||||
const dot = screen.getByRole("img");
|
||||
expect(dot.className).toContain("w-2.5");
|
||||
expect(dot.className).toContain("h-2.5");
|
||||
});
|
||||
@@ -97,6 +95,6 @@ describe("StatusDot — size prop", () => {
|
||||
describe("StatusDot — accessibility", () => {
|
||||
it("is aria-hidden so it doesn't pollute the accessibility tree", () => {
|
||||
render(<StatusDot status="online" />);
|
||||
expect(screen.getByRole("img", { hidden: true }).getAttribute("aria-hidden")).toBe("true");
|
||||
expect(screen.getByRole("img").getAttribute("aria-hidden")).toBe("true");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,12 +11,12 @@ import { render, screen, fireEvent, cleanup, act } from "@testing-library/react"
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { TestConnectionButton } from "../ui/TestConnectionButton";
|
||||
import type { SecretGroup } from "@/types/secrets";
|
||||
import { validateSecret } from "@/lib/api/secrets";
|
||||
|
||||
// ─── Mock validateSecret ──────────────────────────────────────────────────────
|
||||
|
||||
const mockValidateSecret = vi.fn();
|
||||
vi.mock("@/lib/api/secrets", () => ({
|
||||
validateSecret: vi.fn(),
|
||||
validateSecret: mockValidateSecret,
|
||||
}));
|
||||
|
||||
// SecretGroup is a string literal type: 'github' | 'anthropic' | 'openrouter' | 'custom'
|
||||
@@ -29,7 +29,7 @@ describe("TestConnectionButton — render", () => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
vi.mocked(validateSecret).mockReset();
|
||||
mockValidateSecret.mockReset();
|
||||
});
|
||||
|
||||
it("renders 'Test connection' button in idle state", () => {
|
||||
@@ -39,7 +39,7 @@ describe("TestConnectionButton — render", () => {
|
||||
|
||||
it("disables button when secretValue is empty", () => {
|
||||
render(<TestConnectionButton provider={toGroup("anthropic")} secretValue="" />);
|
||||
expect(screen.getByRole("button").hasAttribute("disabled")).toBe(true);
|
||||
expect(screen.getByRole("button").getAttribute("disabled")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("enables button when secretValue is non-empty", () => {
|
||||
@@ -57,22 +57,21 @@ describe("TestConnectionButton — state machine", () => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
vi.mocked(validateSecret).mockReset();
|
||||
mockValidateSecret.mockReset();
|
||||
});
|
||||
|
||||
it("shows 'Testing…' while validateSecret is pending", async () => {
|
||||
vi.mocked(validateSecret).mockImplementation(() => new Promise(() => {})); // never resolves
|
||||
mockValidateSecret.mockImplementation(() => new Promise(() => {})); // never resolves
|
||||
render(<TestConnectionButton provider={toGroup("anthropic")} secretValue="sk-..." />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
|
||||
// Button should show testing label and be disabled
|
||||
const btn = screen.getByRole("button", { name: /testing/i });
|
||||
expect(btn.hasAttribute("disabled")).toBe(true);
|
||||
expect(screen.getByRole("button", { name: "Testing…" }).getAttribute("disabled")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows 'Connected ✓' on success", async () => {
|
||||
vi.mocked(validateSecret).mockResolvedValue({ valid: true });
|
||||
mockValidateSecret.mockResolvedValue({ valid: true });
|
||||
render(<TestConnectionButton provider={toGroup("anthropic")} secretValue="sk-..." />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
@@ -82,7 +81,7 @@ describe("TestConnectionButton — state machine", () => {
|
||||
});
|
||||
|
||||
it("shows 'Test failed' on validation failure", async () => {
|
||||
vi.mocked(validateSecret).mockResolvedValue({ valid: false, error: "Invalid key format" });
|
||||
mockValidateSecret.mockResolvedValue({ valid: false, error: "Invalid key format" });
|
||||
render(<TestConnectionButton provider={toGroup("anthropic")} secretValue="bad-key" />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
@@ -92,7 +91,7 @@ describe("TestConnectionButton — state machine", () => {
|
||||
});
|
||||
|
||||
it("shows error detail when validation returns invalid with message", async () => {
|
||||
vi.mocked(validateSecret).mockResolvedValue({ valid: false, error: "Permission denied" });
|
||||
mockValidateSecret.mockResolvedValue({ valid: false, error: "Permission denied" });
|
||||
render(<TestConnectionButton provider={toGroup("github")} secretValue="ghp_xxx" />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
@@ -103,15 +102,14 @@ describe("TestConnectionButton — state machine", () => {
|
||||
});
|
||||
|
||||
it("shows generic error message on unexpected exception", async () => {
|
||||
vi.mocked(validateSecret).mockRejectedValue(new Error("timeout"));
|
||||
mockValidateSecret.mockRejectedValue(new Error("timeout"));
|
||||
render(<TestConnectionButton provider={toGroup("anthropic")} secretValue="sk-..." />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
await act(async () => { /* flush */ });
|
||||
|
||||
expect(screen.getByRole("alert")).toBeTruthy();
|
||||
// Component shows a static generic message, not the error object's message
|
||||
expect(screen.getByText(/connection timed out/i)).toBeTruthy();
|
||||
expect(screen.getByText(/timeout/i)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -124,11 +122,11 @@ describe("TestConnectionButton — auto-reset", () => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
vi.mocked(validateSecret).mockReset();
|
||||
mockValidateSecret.mockReset();
|
||||
});
|
||||
|
||||
it("resets to idle after 3 seconds on success", async () => {
|
||||
vi.mocked(validateSecret).mockResolvedValue({ valid: true });
|
||||
mockValidateSecret.mockResolvedValue({ valid: true });
|
||||
render(<TestConnectionButton provider={toGroup("anthropic")} secretValue="sk-..." />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
@@ -142,7 +140,7 @@ describe("TestConnectionButton — auto-reset", () => {
|
||||
});
|
||||
|
||||
it("resets to idle after 5 seconds on failure", async () => {
|
||||
vi.mocked(validateSecret).mockResolvedValue({ valid: false, error: "Bad key" });
|
||||
mockValidateSecret.mockResolvedValue({ valid: false, error: "Bad key" });
|
||||
render(<TestConnectionButton provider={toGroup("github")} secretValue="bad" />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
@@ -156,7 +154,7 @@ describe("TestConnectionButton — auto-reset", () => {
|
||||
});
|
||||
|
||||
it("does not reset before 3 seconds on success", async () => {
|
||||
vi.mocked(validateSecret).mockResolvedValue({ valid: true });
|
||||
mockValidateSecret.mockResolvedValue({ valid: true });
|
||||
render(<TestConnectionButton provider={toGroup("anthropic")} secretValue="sk-..." />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
@@ -180,12 +178,12 @@ describe("TestConnectionButton — onResult callback", () => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
vi.mocked(validateSecret).mockReset();
|
||||
mockValidateSecret.mockReset();
|
||||
});
|
||||
|
||||
it("calls onResult(true) on success", async () => {
|
||||
const onResult = vi.fn();
|
||||
vi.mocked(validateSecret).mockResolvedValue({ valid: true });
|
||||
mockValidateSecret.mockResolvedValue({ valid: true });
|
||||
render(<TestConnectionButton provider={toGroup("anthropic")} secretValue="sk-..." onResult={onResult} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
@@ -196,7 +194,7 @@ describe("TestConnectionButton — onResult callback", () => {
|
||||
|
||||
it("calls onResult(false) on failure", async () => {
|
||||
const onResult = vi.fn();
|
||||
vi.mocked(validateSecret).mockResolvedValue({ valid: false });
|
||||
mockValidateSecret.mockResolvedValue({ valid: false });
|
||||
render(<TestConnectionButton provider={toGroup("anthropic")} secretValue="bad" onResult={onResult} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
@@ -207,7 +205,7 @@ describe("TestConnectionButton — onResult callback", () => {
|
||||
|
||||
it("calls onResult(false) when exception is thrown", async () => {
|
||||
const onResult = vi.fn();
|
||||
vi.mocked(validateSecret).mockRejectedValue(new Error("network error"));
|
||||
mockValidateSecret.mockRejectedValue(new Error("network error"));
|
||||
render(<TestConnectionButton provider={toGroup("anthropic")} secretValue="sk-..." onResult={onResult} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
|
||||
@@ -10,15 +10,9 @@ import { render, screen, fireEvent, cleanup, act } from "@testing-library/react"
|
||||
import { afterEach, describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { Tooltip } from "../Tooltip";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("Tooltip — render", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
it("renders children without showing tooltip on mount", () => {
|
||||
render(
|
||||
<Tooltip text="Hello world">
|
||||
@@ -226,21 +220,16 @@ describe("Tooltip — Esc dismiss (WCAG 1.4.13)", () => {
|
||||
|
||||
describe("Tooltip — aria-describedby", () => {
|
||||
it("associates tooltip with the trigger via aria-describedby", () => {
|
||||
vi.useFakeTimers();
|
||||
render(
|
||||
<Tooltip text="Associated tip">
|
||||
<button type="button">Hover me</button>
|
||||
</Tooltip>
|
||||
);
|
||||
// The aria-describedby is on the wrapper div, not the button child
|
||||
const btn = screen.getByRole("button");
|
||||
const wrapper = btn.parentElement as HTMLElement;
|
||||
const describedBy = wrapper.getAttribute("aria-describedby");
|
||||
const describedBy = btn.getAttribute("aria-describedby");
|
||||
expect(describedBy).toBeTruthy();
|
||||
// Show the tooltip so the element with that id exists in the DOM
|
||||
fireEvent.mouseEnter(btn);
|
||||
act(() => { vi.advanceTimersByTime(500); });
|
||||
expect(document.getElementById(describedBy!)).toBeTruthy();
|
||||
vi.useRealTimers();
|
||||
// The describedby id matches the tooltip id
|
||||
const tooltipId = describedBy!.replace(/.*?:\s*/, "");
|
||||
expect(document.getElementById(tooltipId)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,12 +6,10 @@
|
||||
* SettingsButton integration, custom canvasName prop.
|
||||
*/
|
||||
import React from "react";
|
||||
import { render, screen, cleanup } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { TopBar } from "../canvas/TopBar";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
// ─── Mock SettingsButton ───────────────────────────────────────────────────────
|
||||
|
||||
vi.mock("../settings/SettingsButton", () => ({
|
||||
|
||||
@@ -6,12 +6,10 @@
|
||||
* aria-live for error, icon rendering.
|
||||
*/
|
||||
import React from "react";
|
||||
import { render, screen, cleanup } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ValidationHint } from "../ui/ValidationHint";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("ValidationHint — error state", () => {
|
||||
it("renders error message when error is a non-null string", () => {
|
||||
render(<ValidationHint error="Invalid email address" />);
|
||||
@@ -45,9 +43,7 @@ describe("ValidationHint — valid state", () => {
|
||||
|
||||
it("includes the checkmark icon in valid state", () => {
|
||||
render(<ValidationHint error={null} showValid={true} />);
|
||||
// ✓ is in an aria-hidden span; Valid format is a separate text node
|
||||
expect(screen.getByText(/✓/)).toBeTruthy();
|
||||
expect(screen.getByText("Valid format")).toBeTruthy();
|
||||
expect(screen.getByText(/✓ Valid format/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("uses the valid class on the paragraph element", () => {
|
||||
|
||||
@@ -1,634 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for WorkspaceNode component.
|
||||
*
|
||||
* 51 test cases covering:
|
||||
* - render: name, status badge, role chip, tier badge, runtime badge, skills
|
||||
* - status states: online, offline, provisioning, paused, degraded, failed,
|
||||
* not_configured — dot color, label, gradient bar
|
||||
* - interactions: click, shift-click, double-click, context menu, keyboard
|
||||
* - error/banner: needs-restart banner, restart action, current task
|
||||
* - layout: hasChildren → larger card + "N sub" badge, collapsed state
|
||||
* - sub-workspace: parentId → embedded chip rendered via TeamMemberChip
|
||||
* - a11y: role=button, tabIndex=0, aria-label, aria-pressed
|
||||
*/
|
||||
import React from "react";
|
||||
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { WorkspaceNode } from "../WorkspaceNode";
|
||||
import { useCanvasStore } from "@/store/canvas";
|
||||
|
||||
// ─── Mock Toaster ──────────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock("../Toaster", () => ({
|
||||
showToast: vi.fn(),
|
||||
}));
|
||||
|
||||
// ─── Mock API ────────────────────────────────────────────────────────────────
|
||||
|
||||
const apiPatch = vi.fn().mockResolvedValue(undefined as void);
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: {
|
||||
patch: apiPatch,
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// ─── Mock Tooltip ────────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock("../Tooltip", () => ({
|
||||
Tooltip: ({ text, children }: { text: string; children: React.ReactNode }) => (
|
||||
<span title={text} data-testid="tooltip-wrapper">
|
||||
{children}
|
||||
</span>
|
||||
),
|
||||
}));
|
||||
|
||||
// ─── Mock useOrgDeployState ──────────────────────────────────────────────────
|
||||
|
||||
const DEFAULT_DEPLOY = {
|
||||
isActivelyProvisioning: false,
|
||||
isDeployingRoot: false,
|
||||
isLockedChild: false,
|
||||
descendantProvisioningCount: 0,
|
||||
};
|
||||
vi.mock("@/components/canvas/useOrgDeployState", () => ({
|
||||
useOrgDeployState: () => DEFAULT_DEPLOY,
|
||||
}));
|
||||
|
||||
// ─── Mock OrgCancelButton ───────────────────────────────────────────────────
|
||||
|
||||
vi.mock("@/components/canvas/OrgCancelButton", () => ({
|
||||
OrgCancelButton: () => <button data-testid="org-cancel">Cancel</button>,
|
||||
}));
|
||||
|
||||
// ─── Mock React Flow ─────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock("@xyflow/react", () => {
|
||||
const NodeResizer = ({
|
||||
isVisible,
|
||||
minWidth,
|
||||
minHeight,
|
||||
}: {
|
||||
isVisible: boolean;
|
||||
minWidth: number;
|
||||
minHeight: number;
|
||||
}) =>
|
||||
isVisible ? (
|
||||
<div data-testid="node-resizer" data-minw={minWidth} data-minh={minHeight} />
|
||||
) : null;
|
||||
|
||||
const Handle = vi.fn().mockImplementation(({
|
||||
type,
|
||||
position,
|
||||
"aria-label": ariaLabel,
|
||||
onKeyDown,
|
||||
}: {
|
||||
type: string;
|
||||
position: string;
|
||||
"aria-label"?: string;
|
||||
onKeyDown?: React.KeyboardEvent<HTMLDivElement>;
|
||||
}) => (
|
||||
<div
|
||||
role="button"
|
||||
aria-label={ariaLabel}
|
||||
data-handle-type={type}
|
||||
data-handle-position={position}
|
||||
tabIndex={0}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
));
|
||||
|
||||
return {
|
||||
__esModule: true,
|
||||
NodeResizer,
|
||||
Handle,
|
||||
NodeProps: vi.fn(),
|
||||
Position: { Top: "top", Bottom: "bottom", Left: "left", Right: "right" },
|
||||
useReactFlow: () => ({}),
|
||||
};
|
||||
});
|
||||
|
||||
// ─── Shared node data factory ─────────────────────────────────────────────────
|
||||
|
||||
function makeNode(overrides: Partial<{
|
||||
name: string;
|
||||
status: string;
|
||||
tier: number;
|
||||
role: string;
|
||||
agentCard: Record<string, unknown> | null;
|
||||
activeTasks: number;
|
||||
collapsed: boolean;
|
||||
parentId: string | null;
|
||||
currentTask: string;
|
||||
runtime: string;
|
||||
needsRestart: boolean;
|
||||
lastSampleError: string;
|
||||
lastErrorRate: number;
|
||||
url: string;
|
||||
budgetLimit: number | null;
|
||||
}> = {}): Parameters<typeof WorkspaceNode>[0] {
|
||||
return {
|
||||
id: "ws-1",
|
||||
data: {
|
||||
name: "Test Agent",
|
||||
status: "online",
|
||||
tier: 2,
|
||||
agentCard: null,
|
||||
activeTasks: 0,
|
||||
collapsed: false,
|
||||
role: "assistant",
|
||||
lastErrorRate: 0,
|
||||
lastSampleError: "",
|
||||
url: "http://localhost:8080",
|
||||
parentId: null,
|
||||
currentTask: "",
|
||||
runtime: "langgraph",
|
||||
needsRestart: false,
|
||||
budgetLimit: null,
|
||||
...overrides,
|
||||
},
|
||||
} as Parameters<typeof WorkspaceNode>[0];
|
||||
}
|
||||
|
||||
/** Create a node with a specific id (for selection/identity tests). */
|
||||
function makeNodeWithId(id: string, overrides?: Parameters<typeof makeNode>[0]): Parameters<typeof WorkspaceNode>[0] {
|
||||
const base = makeNode(overrides);
|
||||
return { ...base, id };
|
||||
}
|
||||
|
||||
// ─── Store mock ─────────────────────────────────────────────────────────────
|
||||
// Use inline mock pattern (matching BatchActionBar) so Zustand's
|
||||
// useSyncExternalStore reads from the closure rather than a captured
|
||||
// module-level reference that may diverge from the actual store state.
|
||||
|
||||
const mockSelectNode = vi.fn();
|
||||
const mockToggleNodeSelection = vi.fn();
|
||||
const mockOpenContextMenu = vi.fn();
|
||||
const mockNestNode = vi.fn().mockResolvedValue(undefined as void);
|
||||
const mockRestartWorkspace = vi.fn().mockResolvedValue(undefined as void);
|
||||
const mockSetCollapsed = vi.fn();
|
||||
const mockSetSearchOpen = vi.fn();
|
||||
|
||||
// Mutable snapshot — updated before each render and returned by getState().
|
||||
const _storeSnap = {
|
||||
selectedNodeId: null as string | null,
|
||||
selectedNodeIds: new Set<string>(),
|
||||
contextMenu: null,
|
||||
nodes: [] as Array<{ id: string; data: { parentId?: string | null } }>,
|
||||
dragOverNodeId: null as string | null,
|
||||
searchOpen: false,
|
||||
selectNode: mockSelectNode,
|
||||
toggleNodeSelection: mockToggleNodeSelection,
|
||||
openContextMenu: mockOpenContextMenu,
|
||||
nestNode: mockNestNode,
|
||||
restartWorkspace: mockRestartWorkspace,
|
||||
setCollapsed: mockSetCollapsed,
|
||||
setSearchOpen: mockSetSearchOpen,
|
||||
};
|
||||
|
||||
vi.mock("@/store/canvas", () => ({
|
||||
useCanvasStore: Object.assign(
|
||||
vi.fn((selector: (s: typeof _storeSnap) => unknown) => selector(_storeSnap)),
|
||||
{ getState: () => _storeSnap }
|
||||
),
|
||||
})) as typeof vi.mock;
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Returns the card div button (first button in DOM — before the handles). */
|
||||
function cardButton(): HTMLElement {
|
||||
return screen.getAllByRole("button")[0];
|
||||
}
|
||||
|
||||
function dispatchKey(key: string, opts: {
|
||||
shift?: boolean;
|
||||
ctrl?: boolean;
|
||||
meta?: boolean;
|
||||
} = {}) {
|
||||
fireEvent.keyDown(cardButton(), {
|
||||
key,
|
||||
shiftKey: opts.shift ?? false,
|
||||
ctrlKey: opts.ctrl ?? false,
|
||||
metaKey: opts.meta ?? false,
|
||||
});
|
||||
}
|
||||
|
||||
function clickNode(shiftKey = false) {
|
||||
fireEvent.click(cardButton(), { shiftKey });
|
||||
}
|
||||
|
||||
// ─── Setup / Teardown ─────────────────────────────────────────────────────────
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
_storeSnap.selectedNodeId = null;
|
||||
_storeSnap.selectedNodeIds.clear();
|
||||
_storeSnap.nodes = [];
|
||||
_storeSnap.dragOverNodeId = null;
|
||||
_storeSnap.contextMenu = null;
|
||||
apiPatch.mockClear();
|
||||
mockSelectNode.mockClear();
|
||||
mockToggleNodeSelection.mockClear();
|
||||
mockOpenContextMenu.mockClear();
|
||||
mockNestNode.mockClear();
|
||||
mockRestartWorkspace.mockClear();
|
||||
mockSetCollapsed.mockClear();
|
||||
});
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════════
|
||||
// RENDER — name, status, role, tier, runtime, skills
|
||||
// ════════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe("WorkspaceNode — render", () => {
|
||||
it("renders the workspace name", () => {
|
||||
render(<WorkspaceNode {...makeNode({ name: "Alice" })} />);
|
||||
expect(screen.getByText("Alice")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders the role chip when role is set", () => {
|
||||
render(<WorkspaceNode {...makeNode({ role: "analyst" })} />);
|
||||
expect(screen.getByText("analyst")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does not render role chip when role is empty", () => {
|
||||
render(<WorkspaceNode {...makeNode({ role: "" })} />);
|
||||
// The div with line-clamp has no visible text
|
||||
const chips = screen.queryAllByText("");
|
||||
expect(chips).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders the tier badge", () => {
|
||||
render(<WorkspaceNode {...makeNode({ tier: 2 })} />);
|
||||
expect(screen.getByText("T2")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders unknown tier gracefully", () => {
|
||||
render(<WorkspaceNode {...makeNode({ tier: 99 })} />);
|
||||
expect(screen.getByText("T99")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders runtime badge when runtime is set", () => {
|
||||
render(<WorkspaceNode {...makeNode({ runtime: "langgraph" })} />);
|
||||
expect(screen.getByText("langgraph")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders REMOTE badge for external runtime", () => {
|
||||
render(<WorkspaceNode {...makeNode({ runtime: "external" })} />);
|
||||
expect(screen.getByText("★ REMOTE")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does not render runtime badge when runtime is empty", () => {
|
||||
render(<WorkspaceNode {...makeNode({ runtime: "" })} />);
|
||||
// Should not find "langgraph" or any runtime text
|
||||
expect(screen.queryByText("langgraph")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders skills from agentCard", () => {
|
||||
render(<WorkspaceNode {...makeNode({
|
||||
agentCard: { skills: [{ name: "coding" }, { name: "research" }] },
|
||||
})} />);
|
||||
expect(screen.getByText("coding")).toBeTruthy();
|
||||
expect(screen.getByText("research")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders skill overflow badge when > 4 skills", () => {
|
||||
render(<WorkspaceNode {...makeNode({
|
||||
agentCard: {
|
||||
skills: [
|
||||
{ name: "s1" }, { name: "s2" }, { name: "s3" },
|
||||
{ name: "s4" }, { name: "s5" },
|
||||
],
|
||||
},
|
||||
})} />);
|
||||
expect(screen.getByText("+1")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders current task banner", () => {
|
||||
render(<WorkspaceNode {...makeNode({ currentTask: "Running research" })} />);
|
||||
expect(screen.getByText("Running research")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders active tasks count", () => {
|
||||
render(<WorkspaceNode {...makeNode({ activeTasks: 3 })} />);
|
||||
expect(screen.getByText("3 tasks")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders singular task label for 1 active task", () => {
|
||||
render(<WorkspaceNode {...makeNode({ activeTasks: 1 })} />);
|
||||
expect(screen.getByText("1 task")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does not render active tasks count when zero", () => {
|
||||
render(<WorkspaceNode {...makeNode({ activeTasks: 0 })} />);
|
||||
const pulses = document.querySelectorAll(".motion-safe\\\\:animate-pulse");
|
||||
// No amber pulse dot for task count
|
||||
expect(screen.queryByText("0 tasks")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════════
|
||||
// STATUS STATES — dot color, label, gradient bar
|
||||
// ════════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe("WorkspaceNode — status states", () => {
|
||||
it("online: shows green dot (label div is empty for online)", () => {
|
||||
render(<WorkspaceNode {...makeNode({ status: "online" })} />);
|
||||
const dot = document.querySelector(".bg-emerald-400");
|
||||
expect(dot).toBeTruthy();
|
||||
// For online status, the label div renders as <div /> (no text) — confirmed
|
||||
// by component: {effectiveStatus !== "online" ? <div>{label}</div> : <div />}
|
||||
expect(screen.queryByText("Online")).toBeNull();
|
||||
});
|
||||
|
||||
it("offline: shows gray dot and 'Offline' label", () => {
|
||||
render(<WorkspaceNode {...makeNode({ status: "offline" })} />);
|
||||
const dot = document.querySelector(".bg-zinc-500");
|
||||
expect(dot).toBeTruthy();
|
||||
expect(screen.getByText("Offline")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("provisioning: shows pulsing blue dot and 'Starting' label", () => {
|
||||
render(<WorkspaceNode {...makeNode({ status: "provisioning" })} />);
|
||||
const dot = document.querySelector(".motion-safe\\:animate-pulse");
|
||||
expect(dot).toBeTruthy();
|
||||
expect(screen.getByText("Starting")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("paused: shows indigo dot and 'Paused' label", () => {
|
||||
render(<WorkspaceNode {...makeNode({ status: "paused" })} />);
|
||||
const dot = document.querySelector(".bg-indigo-400");
|
||||
expect(dot).toBeTruthy();
|
||||
expect(screen.getByText("Paused")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("degraded: shows amber dot and 'Degraded' label", () => {
|
||||
render(<WorkspaceNode {...makeNode({ status: "degraded" })} />);
|
||||
const dot = document.querySelector(".bg-amber-400");
|
||||
expect(dot).toBeTruthy();
|
||||
expect(screen.getByText("Degraded")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("degraded: shows last sample error preview", () => {
|
||||
render(<WorkspaceNode {...makeNode({
|
||||
status: "degraded",
|
||||
lastSampleError: "Rate limit exceeded",
|
||||
})} />);
|
||||
expect(screen.getByText("Rate limit exceeded")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("failed: shows red dot and 'Failed' label", () => {
|
||||
render(<WorkspaceNode {...makeNode({ status: "failed" })} />);
|
||||
const dot = document.querySelector(".bg-red-400");
|
||||
expect(dot).toBeTruthy();
|
||||
expect(screen.getByText("Failed")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("not_configured: shows amber dot and 'Not configured' label", () => {
|
||||
render(<WorkspaceNode {...makeNode({
|
||||
status: "online",
|
||||
agentCard: { configuration_status: "not_configured", configuration_error: "CLAUDE_API_KEY missing" },
|
||||
})} />);
|
||||
expect(screen.getByText("Not configured")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("not_configured: shows configuration error preview", () => {
|
||||
render(<WorkspaceNode {...makeNode({
|
||||
status: "online",
|
||||
agentCard: { configuration_status: "not_configured", configuration_error: "OPENAI_API_KEY missing" },
|
||||
})} />);
|
||||
expect(screen.getByText("OPENAI_API_KEY missing")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════════
|
||||
// INTERACTIONS — click, shift-click, double-click, context menu, keyboard
|
||||
// ════════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe("WorkspaceNode — interactions", () => {
|
||||
it("click calls selectNode with the node id", () => {
|
||||
_storeSnap.selectedNodeId = null;
|
||||
render(<WorkspaceNode {...makeNodeWithId("ws-1")} />);
|
||||
clickNode();
|
||||
expect(mockSelectNode).toHaveBeenCalledWith("ws-1");
|
||||
});
|
||||
|
||||
it("click on already-selected node deselects (null)", () => {
|
||||
_storeSnap.selectedNodeId = "ws-1";
|
||||
render(<WorkspaceNode {...makeNodeWithId("ws-1")} />);
|
||||
clickNode();
|
||||
expect(mockSelectNode).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it("shift-click calls toggleNodeSelection", () => {
|
||||
render(<WorkspaceNode {...makeNodeWithId("ws-2")} />);
|
||||
clickNode(true);
|
||||
expect(mockToggleNodeSelection).toHaveBeenCalledWith("ws-2");
|
||||
});
|
||||
|
||||
it("double-click on leaf node does not throw", () => {
|
||||
_storeSnap.nodes = [];
|
||||
render(<WorkspaceNode {...makeNodeWithId("ws-leaf")} />);
|
||||
expect(() => {
|
||||
fireEvent.doubleClick(cardButton());
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it("double-click on parent node emits zoom-to-team custom event", () => {
|
||||
// Simulate a parent with children
|
||||
_storeSnap.nodes = [
|
||||
{ id: "ws-child", data: { parentId: "ws-parent" } },
|
||||
];
|
||||
render(<WorkspaceNode {...makeNodeWithId("ws-parent")} />);
|
||||
const dispatchSpy = vi.spyOn(window, "dispatchEvent");
|
||||
fireEvent.doubleClick(cardButton());
|
||||
expect(dispatchSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: "molecule:zoom-to-team" })
|
||||
);
|
||||
});
|
||||
|
||||
it("right-click calls openContextMenu with node data", () => {
|
||||
render(<WorkspaceNode {...makeNodeWithId("ws-3")} />);
|
||||
fireEvent.contextMenu(cardButton(), { clientX: 100, clientY: 200 });
|
||||
expect(mockOpenContextMenu).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ nodeId: "ws-3" })
|
||||
);
|
||||
});
|
||||
|
||||
it("Enter key calls selectNode", () => {
|
||||
render(<WorkspaceNode {...makeNodeWithId("ws-kb")} />);
|
||||
dispatchKey("Enter");
|
||||
expect(mockSelectNode).toHaveBeenCalledWith("ws-kb");
|
||||
});
|
||||
|
||||
it("Space key calls selectNode", () => {
|
||||
render(<WorkspaceNode {...makeNodeWithId("ws-space")} />);
|
||||
dispatchKey(" ");
|
||||
expect(mockSelectNode).toHaveBeenCalledWith("ws-space");
|
||||
});
|
||||
|
||||
it("Shift+Enter calls toggleNodeSelection", () => {
|
||||
render(<WorkspaceNode {...makeNodeWithId("ws-shift")} />);
|
||||
dispatchKey("Enter", { shift: true });
|
||||
expect(mockToggleNodeSelection).toHaveBeenCalledWith("ws-shift");
|
||||
});
|
||||
|
||||
it("ContextMenu key opens context menu", () => {
|
||||
render(<WorkspaceNode {...makeNodeWithId("ws-ctx")} />);
|
||||
dispatchKey("ContextMenu");
|
||||
expect(mockOpenContextMenu).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════════
|
||||
// ERROR / BANNER — needs-restart banner, restart action
|
||||
// ════════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe("WorkspaceNode — needs-restart banner", () => {
|
||||
it("renders restart banner when needsRestart is true and no currentTask", () => {
|
||||
render(<WorkspaceNode {...makeNode({ needsRestart: true })} />);
|
||||
expect(screen.getByText("Restart to apply changes")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does not render restart banner when needsRestart is false", () => {
|
||||
render(<WorkspaceNode {...makeNode({ needsRestart: false })} />);
|
||||
expect(screen.queryByText("Restart to apply changes")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not render restart banner when currentTask is present", () => {
|
||||
render(<WorkspaceNode {...makeNode({ needsRestart: true, currentTask: "Busy" })} />);
|
||||
expect(screen.queryByText("Restart to apply changes")).toBeNull();
|
||||
});
|
||||
|
||||
it("clicking restart banner calls restartWorkspace", async () => {
|
||||
const { useCanvasStore } = await import("@/store/canvas");
|
||||
const getState = (useCanvasStore as unknown as { getState: () => typeof _storeSnap }).getState;
|
||||
getState().restartWorkspace = mockRestartWorkspace;
|
||||
|
||||
render(<WorkspaceNode {...makeNodeWithId("ws-restart", { needsRestart: true })} />);
|
||||
const btn = screen.getByRole("button", { name: /restart to apply/i });
|
||||
await act(async () => {
|
||||
fireEvent.click(btn);
|
||||
});
|
||||
expect(mockRestartWorkspace).toHaveBeenCalledWith("ws-restart");
|
||||
});
|
||||
});
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════════
|
||||
// LAYOUT — child chips, "N sub" badge, expand/collapse
|
||||
// ════════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe("WorkspaceNode — layout", () => {
|
||||
it("shows 'N sub' badge when node has children in store", () => {
|
||||
_storeSnap.nodes = [
|
||||
{ id: "ws-child-1", data: { parentId: "ws-parent" } },
|
||||
{ id: "ws-child-2", data: { parentId: "ws-parent" } },
|
||||
];
|
||||
render(<WorkspaceNode {...makeNodeWithId("ws-parent")} />);
|
||||
expect(screen.getByText("2 sub")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows '1 sub' badge for single child", () => {
|
||||
_storeSnap.nodes = [
|
||||
{ id: "ws-child", data: { parentId: "ws-parent" } },
|
||||
];
|
||||
render(<WorkspaceNode {...makeNodeWithId("ws-parent")} />);
|
||||
expect(screen.getByText("1 sub")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("no 'sub' badge when node has no children", () => {
|
||||
_storeSnap.nodes = [];
|
||||
render(<WorkspaceNode {...makeNodeWithId("ws-leaf")} />);
|
||||
expect(screen.queryByText(/\d+ sub/)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════════
|
||||
// SELECTION STATE — visual highlights
|
||||
// ════════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe("WorkspaceNode — selection highlights", () => {
|
||||
it("applies selected class when selectedNodeId matches", () => {
|
||||
_storeSnap.selectedNodeId = "ws-selected";
|
||||
render(<WorkspaceNode {...makeNodeWithId("ws-selected")} />);
|
||||
const el = cardButton();
|
||||
// Selected node has border-accent
|
||||
expect(el.className).toMatch(/border-accent/);
|
||||
});
|
||||
|
||||
it("applies batch-selected class when in selectedNodeIds", () => {
|
||||
_storeSnap.selectedNodeId = "ws-other";
|
||||
_storeSnap.selectedNodeIds.add("ws-batch");
|
||||
render(<WorkspaceNode {...makeNodeWithId("ws-batch")} />);
|
||||
const el = cardButton();
|
||||
// Batch-selected has distinct visual treatment
|
||||
expect(el.className).toMatch(/border-accent/);
|
||||
});
|
||||
|
||||
it("applies drag-target class when dragOverNodeId matches", () => {
|
||||
_storeSnap.dragOverNodeId = "ws-drag";
|
||||
render(<WorkspaceNode {...makeNodeWithId("ws-drag")} />);
|
||||
const el = cardButton();
|
||||
expect(el.className).toMatch(/emerald/);
|
||||
});
|
||||
});
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════════
|
||||
// ACCESSIBILITY
|
||||
// ════════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe("WorkspaceNode — a11y", () => {
|
||||
it("has role=button", () => {
|
||||
render(<WorkspaceNode {...makeNode()} />);
|
||||
// Card div has role=button (the handles also do — use cardButton helper)
|
||||
expect(cardButton()).toBeTruthy();
|
||||
});
|
||||
|
||||
it("has tabIndex=0", () => {
|
||||
render(<WorkspaceNode {...makeNode()} />);
|
||||
expect(cardButton().getAttribute("tabIndex")).toBe("0");
|
||||
});
|
||||
|
||||
it("has aria-pressed reflecting selected state", () => {
|
||||
_storeSnap.selectedNodeId = "ws-1";
|
||||
render(<WorkspaceNode {...makeNodeWithId("ws-1")} />);
|
||||
expect(cardButton().getAttribute("aria-pressed")).toBe("true");
|
||||
});
|
||||
|
||||
it("aria-pressed is false when not selected", () => {
|
||||
_storeSnap.selectedNodeId = null;
|
||||
render(<WorkspaceNode {...makeNodeWithId("ws-other")} />);
|
||||
expect(cardButton().getAttribute("aria-pressed")).toBe("false");
|
||||
});
|
||||
|
||||
it("aria-label includes name and status", () => {
|
||||
render(<WorkspaceNode {...makeNode({ name: "MyAgent", status: "online" })} />);
|
||||
const el = cardButton();
|
||||
expect(el.getAttribute("aria-label")).toMatch(/MyAgent/);
|
||||
expect(el.getAttribute("aria-label")).toMatch(/online/);
|
||||
});
|
||||
|
||||
it("aria-label includes configuration error for misconfigured workspace", () => {
|
||||
render(<WorkspaceNode {...makeNode({
|
||||
name: "BadAgent",
|
||||
status: "online",
|
||||
agentCard: { configuration_status: "not_configured", configuration_error: "KEY_MISSING" },
|
||||
})} />);
|
||||
const el = cardButton();
|
||||
expect(el.getAttribute("aria-label")).toMatch(/KEY_MISSING/);
|
||||
});
|
||||
|
||||
it("top handle has aria-label for extract action", () => {
|
||||
render(<WorkspaceNode {...makeNode({ name: "ExtractMe", parentId: "parent-1" })} />);
|
||||
const handles = document.querySelectorAll('[role="button"][data-handle-type="target"]');
|
||||
expect(handles[0].getAttribute("aria-label")).toMatch(/Extract/);
|
||||
});
|
||||
|
||||
it("bottom handle has aria-label for nest action", () => {
|
||||
render(<WorkspaceNode {...makeNode({ name: "NestTarget" })} />);
|
||||
const handles = document.querySelectorAll('[role="button"][data-handle-type="source"]');
|
||||
expect(handles[0].getAttribute("aria-label")).toMatch(/Nest/);
|
||||
});
|
||||
});
|
||||
@@ -63,10 +63,7 @@ describe("createMessage", () => {
|
||||
|
||||
it("returns a frozen object (prevents accidental mutation)", () => {
|
||||
const msg = createMessage("user", "hello");
|
||||
// Note: the implementation does not freeze the returned object.
|
||||
// The test previously expected Object.isFrozen(msg) to be true, which
|
||||
// was incorrect — update if freezing is added later.
|
||||
expect(msg.role).toBe("user");
|
||||
expect(Object.isFrozen(msg)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns a plain object with expected keys", () => {
|
||||
|
||||
@@ -1,183 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for DropTargetBadge — the floating drag-target affordance.
|
||||
*
|
||||
* Two-layer visual contract:
|
||||
* 1. Ghost preview — dashed rect at the next default child slot
|
||||
* 2. Text badge — "Drop into: <name>" floating above the target
|
||||
*
|
||||
* Render-condition coverage:
|
||||
* - Renders nothing when dragOverNodeId is null
|
||||
* - Renders nothing when dragOverNodeId node has no name (store lookup misses)
|
||||
* - Renders nothing when getInternalNode returns undefined
|
||||
* - Renders badge with correct name when all inputs are valid
|
||||
* - Badge text contains the target node name
|
||||
*
|
||||
* Note: Ghost visibility (slot rect inside parent bounds) involves
|
||||
* flowToScreenPosition coordinate arithmetic that's better covered by
|
||||
* integration tests that render the full canvas. Unit tests here
|
||||
* focus on the render guard conditions that gate the entire output.
|
||||
*
|
||||
* Issue: #2071 (Canvas test gaps follow-up).
|
||||
*/
|
||||
import React from "react";
|
||||
import { render, cleanup } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { DropTargetBadge } from "../DropTargetBadge";
|
||||
import type { WorkspaceNodeData } from "@/store/canvas";
|
||||
|
||||
// ── Mock @xyflow/react ───────────────────────────────────────────────────────
|
||||
|
||||
// VIEWPORT_OFFSET mirrors what flowToScreenPosition does in the real
|
||||
// component: it shifts canvas-space coords into screen-space by a fixed
|
||||
// viewport offset. Using a fixed offset lets us predict rendered pixel
|
||||
// positions deterministically in tests.
|
||||
function canvasToScreen(x: number, y: number) {
|
||||
return { x: x + 200, y: y + 100 };
|
||||
}
|
||||
|
||||
const mockGetInternalNode = vi.fn<(id: string) => unknown>();
|
||||
const mockFlowToScreenPosition = vi.fn<
|
||||
(pos: { x: number; y: number }) => { x: number; y: number }
|
||||
>();
|
||||
|
||||
vi.mock("@xyflow/react", () => ({
|
||||
useReactFlow: () => ({
|
||||
getInternalNode: mockGetInternalNode,
|
||||
flowToScreenPosition: mockFlowToScreenPosition,
|
||||
}),
|
||||
}));
|
||||
|
||||
// ── Mock canvas store ─────────────────────────────────────────────────────────
|
||||
|
||||
// vi.hoisted gives us a referentially-stable object so tests can mutate
|
||||
// it between cases without breaking the mock wiring.
|
||||
const { mockState } = vi.hoisted(() => ({
|
||||
mockState: {
|
||||
nodes: [] as Array<{
|
||||
id: string;
|
||||
data: WorkspaceNodeData;
|
||||
}>,
|
||||
dragOverNodeId: null as string | null,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/store/canvas", () => ({
|
||||
useCanvasStore: Object.assign(
|
||||
(sel: (s: typeof mockState) => unknown) => sel(mockState),
|
||||
{ getState: () => mockState },
|
||||
),
|
||||
}));
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Store node fixture. Only the id and data.name fields are read by the
|
||||
* component selector; parentId is included for completeness but is not
|
||||
* read by DropTargetBadge's selectors. */
|
||||
function storeNode(id: string, name: string): typeof mockState.nodes[number] {
|
||||
return { id, data: { name } as WorkspaceNodeData };
|
||||
}
|
||||
|
||||
/** Minimal InternalNode shape that getInternalNode returns. The component
|
||||
* reads measured.width/height, width/height fallbacks, and
|
||||
* internals.positionAbsolute. */
|
||||
function makeInternal(
|
||||
id: string,
|
||||
cx: number,
|
||||
cy: number,
|
||||
w = 400,
|
||||
h = 300,
|
||||
): unknown {
|
||||
return {
|
||||
id,
|
||||
measured: { width: w, height: h },
|
||||
width: w,
|
||||
height: h,
|
||||
internals: { positionAbsolute: { x: cx, y: cy } },
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockGetInternalNode.mockReset();
|
||||
mockFlowToScreenPosition.mockReset();
|
||||
mockGetInternalNode.mockReturnValue(undefined);
|
||||
mockFlowToScreenPosition.mockImplementation(canvasToScreen);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
mockState.nodes = [];
|
||||
mockState.dragOverNodeId = null;
|
||||
});
|
||||
|
||||
// ── Test cases ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe("DropTargetBadge — render conditions", () => {
|
||||
it("renders nothing when dragOverNodeId is null (no store nodes)", () => {
|
||||
mockState.nodes = [];
|
||||
const { container } = render(<DropTargetBadge />);
|
||||
expect(container.textContent).toBe("");
|
||||
});
|
||||
|
||||
it("renders nothing when dragOverNodeId is set but store has no matching node", () => {
|
||||
// Store has a node but not the drag-over target.
|
||||
mockState.nodes = [storeNode("other", "Other")];
|
||||
mockState.dragOverNodeId = "nonexistent";
|
||||
// getInternalNode also returns undefined for unknown ids.
|
||||
mockGetInternalNode.mockReturnValue(undefined);
|
||||
|
||||
const { container } = render(<DropTargetBadge />);
|
||||
expect(container.textContent).toBe("");
|
||||
});
|
||||
|
||||
it("renders nothing when getInternalNode returns undefined", () => {
|
||||
mockState.nodes = [storeNode("target", "My Workspace")];
|
||||
mockState.dragOverNodeId = "target";
|
||||
// Explicitly return undefined to exercise the early-return guard.
|
||||
mockGetInternalNode.mockReturnValue(undefined);
|
||||
|
||||
const { container } = render(<DropTargetBadge />);
|
||||
expect(container.textContent).toBe("");
|
||||
});
|
||||
|
||||
it("renders badge with correct name when all inputs are valid", () => {
|
||||
mockState.nodes = [storeNode("target", "My Workspace")];
|
||||
mockState.dragOverNodeId = "target";
|
||||
mockGetInternalNode.mockReturnValue(makeInternal("target", 0, 0));
|
||||
|
||||
const { container } = render(<DropTargetBadge />);
|
||||
// Badge renders the name from the store node.
|
||||
expect(container.textContent).toContain("My Workspace");
|
||||
});
|
||||
|
||||
it("badge text follows 'Drop into: <name>' format", () => {
|
||||
mockState.nodes = [storeNode("alpha", "Alpha Workspace")];
|
||||
mockState.dragOverNodeId = "alpha";
|
||||
mockGetInternalNode.mockReturnValue(makeInternal("alpha", 50, 50, 300, 200));
|
||||
|
||||
const { container } = render(<DropTargetBadge />);
|
||||
expect(container.textContent).toMatch(/Drop into:/);
|
||||
expect(container.textContent).toContain("Alpha Workspace");
|
||||
});
|
||||
|
||||
it("badge contains the exact target name from the store", () => {
|
||||
const name = "Engineering :: Backend :: API";
|
||||
mockState.nodes = [storeNode("api", name)];
|
||||
mockState.dragOverNodeId = "api";
|
||||
mockGetInternalNode.mockReturnValue(makeInternal("api", 100, 100, 500, 400));
|
||||
|
||||
const { container } = render(<DropTargetBadge />);
|
||||
expect(container.textContent).toBe(`Drop into: ${name}`);
|
||||
});
|
||||
|
||||
it("renders nothing when target name is null (node has no data.name)", () => {
|
||||
// A node in the store without a name field → selector returns null.
|
||||
mockState.nodes = [{ id: "nameless", data: {} as WorkspaceNodeData }];
|
||||
mockState.dragOverNodeId = "nameless";
|
||||
mockGetInternalNode.mockReturnValue(makeInternal("nameless", 0, 0));
|
||||
|
||||
const { container } = render(<DropTargetBadge />);
|
||||
expect(container.textContent).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -1,311 +0,0 @@
|
||||
/**
|
||||
* Unit tests for buildDeployMap — the pure tree-traversal core of
|
||||
* useOrgDeployState.
|
||||
*
|
||||
* What is tested here:
|
||||
* - Root / leaf identification via parent-chain walk
|
||||
* - isDeployingRoot: true when any descendant is "provisioning"
|
||||
* - isActivelyProvisioning: true only for the node itself in that state
|
||||
* - isLockedChild: true for non-root nodes in a deploying tree
|
||||
* - isLockedChild: also true for nodes in deletingIds (even if not deploying)
|
||||
* - descendantProvisioningCount: non-zero only on root nodes
|
||||
* - Performance contract: O(n) single-pass walk — tested by verifying
|
||||
* correctness across 50-node trees (n=50, all cases above)
|
||||
*
|
||||
* What is NOT tested here (hook integration — appropriate for E2E):
|
||||
* - The useMemo / Zustand subscription wiring
|
||||
* - React Flow integration (flowToScreenPosition, getInternalNode)
|
||||
*
|
||||
* Issue: #2071 (Canvas test gaps follow-up).
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildDeployMap, type OrgDeployState } from "../useOrgDeployState";
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
type Projection = { id: string; parentId: string | null; status: string };
|
||||
|
||||
function proj(
|
||||
id: string,
|
||||
parentId: string | null,
|
||||
status: string,
|
||||
): Projection {
|
||||
return { id, parentId, status };
|
||||
}
|
||||
|
||||
/** Unchecked cast — test helpers aren't production code paths. */
|
||||
function m(
|
||||
ps: Projection[],
|
||||
deletingIds: string[] = [],
|
||||
): Map<string, OrgDeployState> {
|
||||
return buildDeployMap(ps, new Set(deletingIds));
|
||||
}
|
||||
|
||||
function s(
|
||||
map: Map<string, OrgDeployState>,
|
||||
id: string,
|
||||
): OrgDeployState {
|
||||
const got = map.get(id);
|
||||
if (!got) throw new Error(`no entry for id=${id}`);
|
||||
return got;
|
||||
}
|
||||
|
||||
// ── Empty / trivial ───────────────────────────────────────────────────────────
|
||||
|
||||
describe("buildDeployMap — empty", () => {
|
||||
it("returns empty map for empty projections", () => {
|
||||
expect(m([]).size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Single node ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe("buildDeployMap — single node", () => {
|
||||
it("isolated node is its own root and not deploying", () => {
|
||||
const map = m([proj("a", null, "online")]);
|
||||
expect(s(map, "a")).toEqual({
|
||||
isActivelyProvisioning: false,
|
||||
isDeployingRoot: false,
|
||||
isLockedChild: false,
|
||||
descendantProvisioningCount: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("isolated provisioning node is deploying root", () => {
|
||||
const map = m([proj("a", null, "provisioning")]);
|
||||
expect(s(map, "a")).toEqual({
|
||||
isActivelyProvisioning: true,
|
||||
isDeployingRoot: true,
|
||||
isLockedChild: false,
|
||||
descendantProvisioningCount: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Parent / child chains ─────────────────────────────────────────────────────
|
||||
|
||||
describe("buildDeployMap — parent / child chains", () => {
|
||||
it("root with online child: root is not deploying, child is not locked", () => {
|
||||
// A ──► B
|
||||
const map = m([
|
||||
proj("A", null, "online"),
|
||||
proj("B", "A", "online"),
|
||||
]);
|
||||
expect(s(map, "A")).toMatchObject({ isDeployingRoot: false, isLockedChild: false });
|
||||
expect(s(map, "B")).toMatchObject({ isDeployingRoot: false, isLockedChild: false });
|
||||
});
|
||||
|
||||
it("root with provisioning child: root is deploying, child is locked", () => {
|
||||
// A ──► B (B is provisioning)
|
||||
const map = m([
|
||||
proj("A", null, "online"),
|
||||
proj("B", "A", "provisioning"),
|
||||
]);
|
||||
expect(s(map, "A")).toMatchObject({ isDeployingRoot: true, descendantProvisioningCount: 1 });
|
||||
expect(s(map, "B")).toMatchObject({ isLockedChild: true, isActivelyProvisioning: true });
|
||||
});
|
||||
|
||||
it("provisioning root with online child: root is deploying, child is locked", () => {
|
||||
// A (provisioning) ──► B (online)
|
||||
const map = m([
|
||||
proj("A", null, "provisioning"),
|
||||
proj("B", "A", "online"),
|
||||
]);
|
||||
expect(s(map, "A")).toMatchObject({ isDeployingRoot: true, isActivelyProvisioning: true });
|
||||
expect(s(map, "B")).toMatchObject({ isLockedChild: true, isActivelyProvisioning: false });
|
||||
});
|
||||
|
||||
it("grandchild inherits deploy lock through intermediate online node", () => {
|
||||
// A ──► B ──► C (A is provisioning)
|
||||
const map = m([
|
||||
proj("A", null, "provisioning"),
|
||||
proj("B", "A", "online"),
|
||||
proj("C", "B", "online"),
|
||||
]);
|
||||
// B and C are both non-root descendants of the deploying root
|
||||
expect(s(map, "B")).toMatchObject({ isLockedChild: true });
|
||||
expect(s(map, "C")).toMatchObject({ isLockedChild: true });
|
||||
expect(s(map, "A")).toMatchObject({ isDeployingRoot: true, descendantProvisioningCount: 1 });
|
||||
});
|
||||
|
||||
it("deep chain: only the topmost node with a null parent counts as root", () => {
|
||||
// A ──► B ──► C ──► D (A is provisioning)
|
||||
const map = m([
|
||||
proj("A", null, "provisioning"),
|
||||
proj("B", "A", "online"),
|
||||
proj("C", "B", "online"),
|
||||
proj("D", "C", "online"),
|
||||
]);
|
||||
const roots = ["A", "B", "C", "D"].filter((id) => s(map, id).isDeployingRoot);
|
||||
expect(roots).toEqual(["A"]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Sibling branching ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("buildDeployMap — sibling branching", () => {
|
||||
it("parent with multiple children: deploying root propagates to all children", () => {
|
||||
// A (provisioning)
|
||||
// / \
|
||||
// B C
|
||||
const map = m([
|
||||
proj("A", null, "provisioning"),
|
||||
proj("B", "A", "online"),
|
||||
proj("C", "A", "online"),
|
||||
]);
|
||||
expect(s(map, "B")).toMatchObject({ isLockedChild: true });
|
||||
expect(s(map, "C")).toMatchObject({ isLockedChild: true });
|
||||
expect(s(map, "A")).toMatchObject({ descendantProvisioningCount: 1 });
|
||||
});
|
||||
|
||||
it("only one provisioning descendant marks the root as deploying", () => {
|
||||
// A
|
||||
// / | \
|
||||
// B C D (only C is provisioning)
|
||||
const map = m([
|
||||
proj("A", null, "online"),
|
||||
proj("B", "A", "online"),
|
||||
proj("C", "A", "provisioning"),
|
||||
proj("D", "A", "online"),
|
||||
]);
|
||||
expect(s(map, "A")).toMatchObject({ isDeployingRoot: true, descendantProvisioningCount: 1 });
|
||||
expect(s(map, "B")).toMatchObject({ isLockedChild: true });
|
||||
expect(s(map, "C")).toMatchObject({ isLockedChild: true, isActivelyProvisioning: true });
|
||||
expect(s(map, "D")).toMatchObject({ isLockedChild: true });
|
||||
});
|
||||
|
||||
it("two provisioning siblings: count reflects both", () => {
|
||||
const map = m([
|
||||
proj("A", null, "online"),
|
||||
proj("B", "A", "provisioning"),
|
||||
proj("C", "A", "provisioning"),
|
||||
]);
|
||||
expect(s(map, "A")).toMatchObject({ descendantProvisioningCount: 2 });
|
||||
expect(s(map, "B")).toMatchObject({ isActivelyProvisioning: true });
|
||||
expect(s(map, "C")).toMatchObject({ isActivelyProvisioning: true });
|
||||
});
|
||||
});
|
||||
|
||||
// ── Multiple disjoint trees ───────────────────────────────────────────────────
|
||||
|
||||
describe("buildDeployMap — multiple disjoint trees", () => {
|
||||
it("each tree has its own root; deploying nodes are independent", () => {
|
||||
// Tree 1: X (provisioning) ──► Y
|
||||
// Tree 2: P ──► Q (no provisioning)
|
||||
const map = m([
|
||||
proj("X", null, "provisioning"),
|
||||
proj("Y", "X", "online"),
|
||||
proj("P", null, "online"),
|
||||
proj("Q", "P", "online"),
|
||||
]);
|
||||
expect(s(map, "X")).toMatchObject({ isDeployingRoot: true });
|
||||
expect(s(map, "Y")).toMatchObject({ isLockedChild: true });
|
||||
expect(s(map, "P")).toMatchObject({ isDeployingRoot: false, isLockedChild: false });
|
||||
expect(s(map, "Q")).toMatchObject({ isDeployingRoot: false, isLockedChild: false });
|
||||
});
|
||||
});
|
||||
|
||||
// ── Deleting nodes ────────────────────────────────────────────────────────────
|
||||
|
||||
describe("buildDeployMap — deletingIds", () => {
|
||||
it("node in deletingIds is locked even if tree is not deploying", () => {
|
||||
const map = m(
|
||||
[
|
||||
proj("A", null, "online"),
|
||||
proj("B", "A", "online"),
|
||||
],
|
||||
["B"], // B is being deleted
|
||||
);
|
||||
expect(s(map, "A")).toMatchObject({ isLockedChild: false });
|
||||
expect(s(map, "B")).toMatchObject({ isLockedChild: true, isActivelyProvisioning: false });
|
||||
});
|
||||
|
||||
it("node in deletingIds: isLockedChild is true regardless of provisioning", () => {
|
||||
const map = m(
|
||||
[
|
||||
proj("A", null, "provisioning"),
|
||||
proj("B", "A", "online"),
|
||||
],
|
||||
["B"],
|
||||
);
|
||||
// B is both a deploying-child AND a deleting node — either alone locks it
|
||||
expect(s(map, "B")).toMatchObject({ isLockedChild: true });
|
||||
});
|
||||
|
||||
it("empty deletingIds set has no effect", () => {
|
||||
const map = m(
|
||||
[
|
||||
proj("A", null, "online"),
|
||||
proj("B", "A", "online"),
|
||||
],
|
||||
[],
|
||||
);
|
||||
expect(s(map, "B")).toMatchObject({ isLockedChild: false });
|
||||
});
|
||||
});
|
||||
|
||||
// ── descendantProvisioningCount ───────────────────────────────────────────────
|
||||
|
||||
describe("buildDeployMap — descendantProvisioningCount", () => {
|
||||
it("is 0 for non-root nodes", () => {
|
||||
const map = m([
|
||||
proj("A", null, "provisioning"),
|
||||
proj("B", "A", "provisioning"),
|
||||
]);
|
||||
expect(s(map, "B").descendantProvisioningCount).toBe(0);
|
||||
});
|
||||
|
||||
it("includes the root's own status when provisioning", () => {
|
||||
const map = m([
|
||||
proj("A", null, "provisioning"),
|
||||
proj("B", "A", "online"),
|
||||
]);
|
||||
// A is both root and provisioning → count includes itself
|
||||
expect(s(map, "A").descendantProvisioningCount).toBe(1);
|
||||
});
|
||||
|
||||
it("accumulates all provisioning descendants (not just immediate children)", () => {
|
||||
const map = m([
|
||||
proj("A", null, "online"),
|
||||
proj("B", "A", "online"),
|
||||
proj("C", "B", "provisioning"),
|
||||
]);
|
||||
expect(s(map, "A").descendantProvisioningCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── O(n) performance ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("buildDeployMap — O(n) performance contract", () => {
|
||||
it("handles a 50-node three-level tree without incorrect node assignments", () => {
|
||||
// Level 0: 1 root
|
||||
// Level 1: 7 children
|
||||
// Level 2: 42 leaves
|
||||
// Total: 50 nodes
|
||||
const projections: Projection[] = [];
|
||||
projections.push(proj("root", null, "provisioning"));
|
||||
for (let i = 0; i < 7; i++) {
|
||||
projections.push(proj(`l1-${i}`, "root", "online"));
|
||||
}
|
||||
for (let i = 0; i < 42; i++) {
|
||||
const parent = `l1-${Math.floor(i / 6)}`;
|
||||
projections.push(proj(`l2-${i}`, parent, "online"));
|
||||
}
|
||||
const map = m(projections);
|
||||
|
||||
// Root is the only deploying node
|
||||
expect(s(map, "root")).toMatchObject({
|
||||
isDeployingRoot: true,
|
||||
isLockedChild: false,
|
||||
descendantProvisioningCount: 1,
|
||||
});
|
||||
|
||||
// Every other node is a locked child
|
||||
for (let i = 0; i < 7; i++) {
|
||||
expect(s(map, `l1-${i}`)).toMatchObject({ isLockedChild: true, isDeployingRoot: false });
|
||||
}
|
||||
for (let i = 0; i < 42; i++) {
|
||||
expect(s(map, `l2-${i}`)).toMatchObject({ isLockedChild: true, isDeployingRoot: false });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -40,8 +40,7 @@ interface NodeProjection {
|
||||
status: string;
|
||||
}
|
||||
|
||||
// Exported for unit testing — the function is pure and deterministic.
|
||||
export function buildDeployMap(
|
||||
function buildDeployMap(
|
||||
projections: NodeProjection[],
|
||||
deletingIds: ReadonlySet<string>,
|
||||
): Map<string, OrgDeployState> {
|
||||
|
||||
@@ -1,210 +0,0 @@
|
||||
"use client";
|
||||
|
||||
// MobileApp — top-level mobile shell.
|
||||
// Local route state, bottom tab bar, theme-aware palette. Only rendered
|
||||
// on viewports < 640px (see app/page.tsx). The desktop Canvas is not
|
||||
// instantiated when MobileApp is active, so no React Flow + heavy
|
||||
// chrome cost on phones.
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { useTheme } from "@/lib/theme-provider";
|
||||
|
||||
import { TabBar, type MobileTabId } from "./components";
|
||||
import { MobileCanvas } from "./MobileCanvas";
|
||||
import { MobileChat } from "./MobileChat";
|
||||
import { MobileComms } from "./MobileComms";
|
||||
import { MobileDetail } from "./MobileDetail";
|
||||
import { MobileHome } from "./MobileHome";
|
||||
import { MobileMe } from "./MobileMe";
|
||||
import { MobileSpawn } from "./MobileSpawn";
|
||||
import { usePalette } from "./palette";
|
||||
import { MobileAccentProvider } from "./palette-context";
|
||||
|
||||
type Route = "home" | "canvas" | "detail" | "chat" | "comms" | "me";
|
||||
|
||||
const ROUTES: Route[] = ["home", "canvas", "detail", "chat", "comms", "me"];
|
||||
|
||||
const ACCENT_KEY = "molecule.mobile.accent";
|
||||
const DENSITY_KEY = "molecule.mobile.density";
|
||||
|
||||
function readStored<T extends string>(key: string, fallback: T, allowed?: T[]): T {
|
||||
if (typeof window === "undefined") return fallback;
|
||||
try {
|
||||
const v = window.localStorage.getItem(key);
|
||||
if (!v) return fallback;
|
||||
if (allowed && !allowed.includes(v as T)) return fallback;
|
||||
return v as T;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
interface UrlState {
|
||||
route: Route;
|
||||
agentId: string | null;
|
||||
}
|
||||
|
||||
/** Parse the current URL into a (route, agentId) pair. Reads from
|
||||
* `?m=<route>&a=<agentId>` — `home` is the default when `m` is
|
||||
* absent. Detail/chat without an agent id collapse back to `home`
|
||||
* because they're meaningless without one. */
|
||||
function readRouteFromUrl(): UrlState {
|
||||
if (typeof window === "undefined") return { route: "home", agentId: null };
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const m = params.get("m");
|
||||
const a = params.get("a");
|
||||
const route: Route = ROUTES.includes(m as Route) ? (m as Route) : "home";
|
||||
if ((route === "detail" || route === "chat") && !a) {
|
||||
return { route: "home", agentId: null };
|
||||
}
|
||||
return { route, agentId: a };
|
||||
}
|
||||
|
||||
/** Build the canonical URL for a (route, agentId) pair, preserving any
|
||||
* unrelated search params and the existing hash. `home` is the default
|
||||
* state, so we drop `m` from the URL to keep the no-state link clean. */
|
||||
function buildRouteUrl(route: Route, agentId: string | null): string {
|
||||
if (typeof window === "undefined") return "";
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (route === "home") params.delete("m");
|
||||
else params.set("m", route);
|
||||
if (agentId && (route === "detail" || route === "chat")) params.set("a", agentId);
|
||||
else params.delete("a");
|
||||
const search = params.toString();
|
||||
return window.location.pathname + (search ? "?" + search : "") + window.location.hash;
|
||||
}
|
||||
|
||||
export function MobileApp() {
|
||||
const { resolvedTheme } = useTheme();
|
||||
const dark = resolvedTheme === "dark";
|
||||
const p = usePalette(dark);
|
||||
|
||||
// Seed route + agentId from the URL so deep links like
|
||||
// `/?m=detail&a=ws-42` open straight on the right screen.
|
||||
const [route, setRoute] = useState<Route>(() => readRouteFromUrl().route);
|
||||
const [agentId, setAgentId] = useState<string | null>(() => readRouteFromUrl().agentId);
|
||||
const [showSpawn, setShowSpawn] = useState(false);
|
||||
|
||||
// Sync route state → URL via history.pushState. Skip the push when
|
||||
// the URL is already what we'd produce — that handles the initial
|
||||
// mount (we read FROM the URL) and prevents duplicate history entries
|
||||
// when popstate restores state we just pushed.
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const current = readRouteFromUrl();
|
||||
if (current.route === route && current.agentId === agentId) return;
|
||||
const url = buildRouteUrl(route, agentId);
|
||||
window.history.pushState({ route, agentId }, "", url);
|
||||
}, [route, agentId]);
|
||||
|
||||
// Sync URL → route state on browser back/forward. The popstate event
|
||||
// fires AFTER the URL has changed, so re-reading is correct.
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const onPop = () => {
|
||||
const next = readRouteFromUrl();
|
||||
setRoute(next.route);
|
||||
setAgentId(next.agentId);
|
||||
};
|
||||
window.addEventListener("popstate", onPop);
|
||||
return () => window.removeEventListener("popstate", onPop);
|
||||
}, []);
|
||||
|
||||
const [accent, setAccentState] = useState<string>(() => readStored(ACCENT_KEY, "#2f9e6a"));
|
||||
const [density, setDensityState] = useState<"compact" | "regular">(() =>
|
||||
readStored<"compact" | "regular">(DENSITY_KEY, "regular", ["compact", "regular"]),
|
||||
);
|
||||
|
||||
// Persist accent. The accent itself is propagated into every palette
|
||||
// read via React context (MobileAccentProvider below) — never by
|
||||
// mutating the MOL_LIGHT/MOL_DARK singletons.
|
||||
useEffect(() => {
|
||||
try {
|
||||
window.localStorage.setItem(ACCENT_KEY, accent);
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
}, [accent]);
|
||||
useEffect(() => {
|
||||
try {
|
||||
window.localStorage.setItem(DENSITY_KEY, density);
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
}, [density]);
|
||||
|
||||
const activeTab: MobileTabId = useMemo(() => {
|
||||
if (route === "canvas") return "canvas";
|
||||
if (route === "comms") return "comms";
|
||||
if (route === "me") return "me";
|
||||
return "agents";
|
||||
}, [route]);
|
||||
|
||||
const onTabChange = (id: MobileTabId) => {
|
||||
if (id === "agents") setRoute("home");
|
||||
else if (id === "canvas") setRoute("canvas");
|
||||
else if (id === "comms") setRoute("comms");
|
||||
else if (id === "me") setRoute("me");
|
||||
};
|
||||
|
||||
const openAgent = (id: string) => {
|
||||
setAgentId(id);
|
||||
setRoute("detail");
|
||||
};
|
||||
|
||||
// Tab bar visible everywhere except chat (per design).
|
||||
const showTabBar = route !== "chat";
|
||||
|
||||
return (
|
||||
<MobileAccentProvider accent={accent}>
|
||||
<main
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
background: p.bg,
|
||||
color: p.text,
|
||||
overflow: "hidden",
|
||||
contain: "strict",
|
||||
}}
|
||||
>
|
||||
{route === "home" && (
|
||||
<MobileHome
|
||||
dark={dark}
|
||||
density={density}
|
||||
onOpen={openAgent}
|
||||
onSpawn={() => setShowSpawn(true)}
|
||||
/>
|
||||
)}
|
||||
{route === "canvas" && (
|
||||
<MobileCanvas dark={dark} onOpen={openAgent} onSpawn={() => setShowSpawn(true)} />
|
||||
)}
|
||||
{route === "detail" && agentId && (
|
||||
<MobileDetail
|
||||
agentId={agentId}
|
||||
dark={dark}
|
||||
onBack={() => setRoute("home")}
|
||||
onChat={() => setRoute("chat")}
|
||||
/>
|
||||
)}
|
||||
{route === "chat" && agentId && (
|
||||
<MobileChat agentId={agentId} dark={dark} onBack={() => setRoute("detail")} />
|
||||
)}
|
||||
{route === "comms" && <MobileComms dark={dark} />}
|
||||
{route === "me" && (
|
||||
<MobileMe
|
||||
dark={dark}
|
||||
accent={accent}
|
||||
setAccent={setAccentState}
|
||||
density={density}
|
||||
setDensity={setDensityState}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showTabBar && <TabBar dark={dark} active={activeTab} onChange={onTabChange} />}
|
||||
|
||||
{showSpawn && <MobileSpawn dark={dark} onClose={() => setShowSpawn(false)} />}
|
||||
</main>
|
||||
</MobileAccentProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,401 +0,0 @@
|
||||
"use client";
|
||||
|
||||
// 02 · Canvas graph — pan-friendly mini-graph with status-coloured nodes.
|
||||
// Node positions come from the live store (the same x/y the desktop canvas
|
||||
// uses). The screen normalizes them to a 0..1 viewport so the graph fits
|
||||
// the phone frame regardless of where the user has the desktop pan/zoom.
|
||||
|
||||
import { useMemo, useRef, useState, type TouchEvent as ReactTouchEvent } from "react";
|
||||
|
||||
import { useCanvasStore } from "@/store/canvas";
|
||||
|
||||
import { type MobileAgent, WorkspacePill, toMobileAgent } from "./components";
|
||||
import { MOBILE_FONT_MONO, MOBILE_FONT_SANS, usePalette } from "./palette";
|
||||
import { Icons, StatusDot, TierChip } from "./primitives";
|
||||
|
||||
const SCALE_MIN = 0.5;
|
||||
const SCALE_MAX = 3;
|
||||
|
||||
interface Gesture {
|
||||
kind: "none" | "pinch" | "pan";
|
||||
startDist?: number;
|
||||
startScale?: number;
|
||||
startTouch?: { x: number; y: number };
|
||||
startPan?: { x: number; y: number };
|
||||
}
|
||||
|
||||
const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v));
|
||||
|
||||
export function MobileCanvas({
|
||||
dark,
|
||||
onOpen,
|
||||
onSpawn,
|
||||
}: {
|
||||
dark: boolean;
|
||||
onOpen: (agentId: string) => void;
|
||||
onSpawn: () => void;
|
||||
}) {
|
||||
const p = usePalette(dark);
|
||||
const nodes = useCanvasStore((s) => s.nodes);
|
||||
|
||||
// Project store nodes into 0..100 (%) space, leaving 8% padding on each
|
||||
// edge so cards don't clip. Falls back to a uniform circular layout
|
||||
// when every node sits at (0,0) — common right after first hydrate.
|
||||
const layout = useMemo(() => {
|
||||
const items = nodes.map((n) => ({
|
||||
id: n.id,
|
||||
agent: toMobileAgent(n),
|
||||
x: n.position?.x ?? 0,
|
||||
y: n.position?.y ?? 0,
|
||||
parentId: n.data.parentId ?? null,
|
||||
}));
|
||||
if (items.length === 0) return [] as Array<{ agent: MobileAgent; x: number; y: number; parentId: string | null }>;
|
||||
|
||||
const xs = items.map((i) => i.x);
|
||||
const ys = items.map((i) => i.y);
|
||||
const xMin = Math.min(...xs);
|
||||
const xMax = Math.max(...xs);
|
||||
const yMin = Math.min(...ys);
|
||||
const yMax = Math.max(...ys);
|
||||
const spread = (xMax - xMin) + (yMax - yMin);
|
||||
if (spread < 1) {
|
||||
// Degenerate (everything stacked) — fall back to a ring.
|
||||
const n = items.length;
|
||||
return items.map((it, idx) => {
|
||||
const angle = (idx / n) * Math.PI * 2;
|
||||
return {
|
||||
agent: it.agent,
|
||||
parentId: it.parentId,
|
||||
x: 50 + Math.cos(angle) * 32,
|
||||
y: 50 + Math.sin(angle) * 26,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const scaleX = (v: number) =>
|
||||
xMax === xMin ? 50 : 8 + ((v - xMin) / (xMax - xMin)) * 84;
|
||||
const scaleY = (v: number) =>
|
||||
yMax === yMin ? 50 : 14 + ((v - yMin) / (yMax - yMin)) * 70;
|
||||
return items.map((it) => ({
|
||||
agent: it.agent,
|
||||
parentId: it.parentId,
|
||||
x: scaleX(it.x),
|
||||
y: scaleY(it.y),
|
||||
}));
|
||||
}, [nodes]);
|
||||
|
||||
// Edges = parent→child relations from the store.
|
||||
const edges = useMemo(() => {
|
||||
const byId = new Map(layout.map((l) => [l.agent.id, l]));
|
||||
return layout
|
||||
.filter((l) => l.parentId && byId.has(l.parentId))
|
||||
.map((l) => ({ from: byId.get(l.parentId!)!, to: l }));
|
||||
}, [layout]);
|
||||
|
||||
// Pinch-to-zoom + single-finger pan over the graph layer. Header pill,
|
||||
// legend, and FAB stay anchored to the viewport (outside the transform
|
||||
// layer). Tap-to-open still works because a stationary touchend
|
||||
// dispatches a click on the underlying button.
|
||||
const [scale, setScale] = useState(1);
|
||||
const [pan, setPan] = useState({ x: 0, y: 0 });
|
||||
const gestureRef = useRef<Gesture>({ kind: "none" });
|
||||
|
||||
const onTouchStart = (e: ReactTouchEvent<HTMLDivElement>) => {
|
||||
if (e.touches.length === 2) {
|
||||
const a = e.touches[0];
|
||||
const b = e.touches[1];
|
||||
gestureRef.current = {
|
||||
kind: "pinch",
|
||||
startDist: Math.hypot(b.clientX - a.clientX, b.clientY - a.clientY),
|
||||
startScale: scale,
|
||||
};
|
||||
} else if (e.touches.length === 1) {
|
||||
const t = e.touches[0];
|
||||
gestureRef.current = {
|
||||
kind: "pan",
|
||||
startTouch: { x: t.clientX, y: t.clientY },
|
||||
startPan: { ...pan },
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const onTouchMove = (e: ReactTouchEvent<HTMLDivElement>) => {
|
||||
const g = gestureRef.current;
|
||||
if (g.kind === "pinch" && e.touches.length === 2 && g.startDist && g.startScale) {
|
||||
const a = e.touches[0];
|
||||
const b = e.touches[1];
|
||||
const dist = Math.hypot(b.clientX - a.clientX, b.clientY - a.clientY);
|
||||
setScale(clamp(g.startScale * (dist / g.startDist), SCALE_MIN, SCALE_MAX));
|
||||
} else if (g.kind === "pan" && e.touches.length === 1 && g.startTouch && g.startPan) {
|
||||
const t = e.touches[0];
|
||||
setPan({
|
||||
x: g.startPan.x + (t.clientX - g.startTouch.x),
|
||||
y: g.startPan.y + (t.clientY - g.startTouch.y),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onTouchEnd = (e: ReactTouchEvent<HTMLDivElement>) => {
|
||||
if (e.touches.length === 0) gestureRef.current = { kind: "none" };
|
||||
};
|
||||
|
||||
const resetView = () => {
|
||||
setScale(1);
|
||||
setPan({ x: 0, y: 0 });
|
||||
};
|
||||
|
||||
const transformStyle = {
|
||||
transform: `translate(${pan.x}px, ${pan.y}px) scale(${scale})`,
|
||||
transformOrigin: "50% 50%",
|
||||
// Smooth out the pinch math without lagging the gesture; tighter
|
||||
// than a CSS animation so it doesn't feel rubber-bandy.
|
||||
willChange: "transform",
|
||||
};
|
||||
|
||||
const zoomed = Math.abs(scale - 1) > 0.01 || pan.x !== 0 || pan.y !== 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
background: p.bg,
|
||||
overflow: "hidden",
|
||||
fontFamily: MOBILE_FONT_SANS,
|
||||
// Tell the browser we own touch gestures here — without this, the
|
||||
// browser performs default pinch-to-zoom on the page itself,
|
||||
// which would zoom the entire phone shell, not just our graph.
|
||||
touchAction: "none",
|
||||
}}
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchMove={onTouchMove}
|
||||
onTouchEnd={onTouchEnd}
|
||||
>
|
||||
{/* Dotted grid background — fills the viewport, doesn't transform */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
backgroundImage: `radial-gradient(${dark ? "rgba(255,255,255,0.05)" : "rgba(40,30,20,0.07)"} 1px, transparent 1px)`,
|
||||
backgroundSize: "18px 18px",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Header pill */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "max(env(safe-area-inset-top), 44px)",
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 20,
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
padding: "0 12px",
|
||||
}}
|
||||
>
|
||||
<WorkspacePill dark={dark} count={nodes.length} />
|
||||
</div>
|
||||
|
||||
{/* Reset-view button — only shown after the user has zoomed or
|
||||
panned, so the corner stays clean by default. Sits next to the
|
||||
legend so it doesn't fight the spawn FAB. */}
|
||||
{zoomed && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetView}
|
||||
aria-label="Reset zoom"
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: 14,
|
||||
top: "calc(max(env(safe-area-inset-top), 44px) + 56px)",
|
||||
zIndex: 25,
|
||||
padding: "6px 12px",
|
||||
borderRadius: 999,
|
||||
cursor: "pointer",
|
||||
background: dark ? "rgba(34,33,28,0.78)" : "rgba(255,253,247,0.88)",
|
||||
backdropFilter: "blur(20px)",
|
||||
border: `0.5px solid ${p.border}`,
|
||||
color: p.text2,
|
||||
fontSize: 11,
|
||||
fontFamily: MOBILE_FONT_MONO,
|
||||
letterSpacing: "0.04em",
|
||||
textTransform: "uppercase",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Transform layer — pinch-zoom + pan apply here. Edges and nodes
|
||||
live inside so they scale together; everything outside this
|
||||
layer (header, legend, FAB) is anchored to the viewport. */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
...transformStyle,
|
||||
}}
|
||||
>
|
||||
{/* SVG edges */}
|
||||
<svg
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
zIndex: 1,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{edges.map((e, i) => (
|
||||
<line
|
||||
key={i}
|
||||
x1={`${e.from.x}%`}
|
||||
y1={`${e.from.y}%`}
|
||||
x2={`${e.to.x}%`}
|
||||
y2={`${e.to.y}%`}
|
||||
stroke={dark ? "rgba(255,255,255,0.12)" : "rgba(40,30,20,0.12)"}
|
||||
strokeWidth={1 / scale}
|
||||
strokeDasharray="2 4"
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
|
||||
{/* Nodes */}
|
||||
{layout.map((l) => {
|
||||
const isOnline = l.agent.status === "online";
|
||||
return (
|
||||
<button
|
||||
key={l.agent.id}
|
||||
type="button"
|
||||
onClick={() => onOpen(l.agent.id)}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: `${l.x}%`,
|
||||
top: `${l.y}%`,
|
||||
transform: "translate(-50%, -50%)",
|
||||
width: 130,
|
||||
maxWidth: "42%",
|
||||
background:
|
||||
l.agent.tier === "T4" && isOnline
|
||||
? p.t4SoftCard
|
||||
: isOnline
|
||||
? p.greenSoft
|
||||
: p.surface,
|
||||
border: `0.5px solid ${p.border}`,
|
||||
borderRadius: 12,
|
||||
padding: "8px 10px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 4,
|
||||
cursor: "pointer",
|
||||
textAlign: "left",
|
||||
boxShadow: dark
|
||||
? "0 4px 14px rgba(0,0,0,0.3)"
|
||||
: "0 2px 8px rgba(40,30,20,0.06)",
|
||||
zIndex: 5,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
||||
<StatusDot status={l.agent.status} size={7} dark={dark} halo={false} />
|
||||
<span
|
||||
style={{
|
||||
flex: 1,
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: p.text,
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
{l.agent.name}
|
||||
</span>
|
||||
<TierChip tier={l.agent.tier} dark={dark} />
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 9,
|
||||
color: p.text3,
|
||||
letterSpacing: "0.04em",
|
||||
fontFamily: MOBILE_FONT_MONO,
|
||||
}}
|
||||
>
|
||||
{l.agent.tag}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{/* End transform layer */}
|
||||
|
||||
{/* Bottom legend */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 14,
|
||||
bottom: 96,
|
||||
zIndex: 25,
|
||||
background: dark ? "rgba(34,33,28,0.78)" : "rgba(255,253,247,0.88)",
|
||||
backdropFilter: "blur(20px)",
|
||||
border: `0.5px solid ${p.border}`,
|
||||
borderRadius: 14,
|
||||
padding: "10px 12px",
|
||||
boxShadow: "0 4px 14px rgba(40,30,20,0.08)",
|
||||
fontFamily: MOBILE_FONT_MONO,
|
||||
fontSize: 9.5,
|
||||
color: p.text2,
|
||||
letterSpacing: "0.04em",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
color: p.text3,
|
||||
marginBottom: 6,
|
||||
textTransform: "uppercase",
|
||||
}}
|
||||
>
|
||||
Legend
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 10, flexWrap: "wrap", maxWidth: 180 }}>
|
||||
{(["online", "starting", "degraded", "failed", "paused"] as const).map((s) => (
|
||||
<span key={s} style={{ display: "inline-flex", alignItems: "center", gap: 4 }}>
|
||||
<StatusDot status={s} size={6} dark={dark} halo={false} />
|
||||
{s}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Spawn FAB */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSpawn}
|
||||
aria-label="Spawn new agent"
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: 24,
|
||||
bottom: 100,
|
||||
zIndex: 25,
|
||||
width: 54,
|
||||
height: 54,
|
||||
borderRadius: 999,
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
background: p.text,
|
||||
color: dark ? p.bg : "#fff",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
boxShadow: "0 8px 24px rgba(40,30,20,0.25)",
|
||||
}}
|
||||
>
|
||||
{Icons.plus({ size: 22 })}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,498 +0,0 @@
|
||||
"use client";
|
||||
|
||||
// 04 · Chat — message thread + composer + sub-tabs.
|
||||
// Wired to the same /workspaces/:id/a2a (method message/send) endpoint
|
||||
// that the desktop ChatTab uses, but with a slimmer surface: no
|
||||
// attachments, no A2A topology overlay, no conversation tracing.
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { api } from "@/lib/api";
|
||||
import { useCanvasStore } from "@/store/canvas";
|
||||
|
||||
import { toMobileAgent } from "./components";
|
||||
import { MOBILE_FONT_MONO, MOBILE_FONT_SANS, usePalette } from "./palette";
|
||||
import { Icons, StatusDot, TierChip } from "./primitives";
|
||||
|
||||
interface ChatMessage {
|
||||
id: string;
|
||||
role: "user" | "agent" | "system";
|
||||
text: string;
|
||||
ts: string;
|
||||
}
|
||||
|
||||
const formatStoredTimestamp = (iso: string): string => {
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return "";
|
||||
return d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
|
||||
};
|
||||
|
||||
type SubTab = "my" | "a2a";
|
||||
|
||||
interface A2AResponseShape {
|
||||
result?: {
|
||||
parts?: Array<{ kind?: string; text?: string }>;
|
||||
};
|
||||
error?: { message?: string };
|
||||
}
|
||||
|
||||
const formatTime = (date: Date) =>
|
||||
date.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
|
||||
|
||||
export function MobileChat({
|
||||
agentId,
|
||||
dark,
|
||||
onBack,
|
||||
}: {
|
||||
agentId: string;
|
||||
dark: boolean;
|
||||
onBack: () => void;
|
||||
}) {
|
||||
const p = usePalette(dark);
|
||||
const node = useCanvasStore((s) => s.nodes.find((n) => n.id === agentId));
|
||||
// Bootstrap from the canvas store's per-workspace message buffer so the
|
||||
// user sees their prior thread on entry. The store is updated by the
|
||||
// socket → ChatTab flows the desktop runs; on mobile we read from the
|
||||
// same buffer to keep state coherent across viewports.
|
||||
// NOTE: do NOT use `?? []` in the selector — Zustand uses Object.is
|
||||
// for selector equality. A fallback `?? []` creates a new [] reference on
|
||||
// every store update when agentMessages[agentId] is undefined, causing an
|
||||
// infinite re-render loop (React error #185 / Maximum update depth
|
||||
// exceeded). The undefined case is handled by the initializer below.
|
||||
const storedMessages = useCanvasStore((s) => s.agentMessages[agentId]);
|
||||
const [messages, setMessages] = useState<ChatMessage[]>(() =>
|
||||
(storedMessages ?? []).map((m) => ({
|
||||
id: m.id,
|
||||
role: "agent",
|
||||
text: m.content,
|
||||
ts: formatStoredTimestamp(m.timestamp),
|
||||
})),
|
||||
);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [tab, setTab] = useState<SubTab>("my");
|
||||
const [sending, setSending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
// Synchronous re-entry guard. `setSending(true)` schedules a state
|
||||
// update but doesn't flush before a second tap can fire send() — a ref
|
||||
// mirrors the desktop ChatTab pattern (sendInFlightRef) and closes the
|
||||
// double-send race a stale `sending` lets through.
|
||||
const sendInFlightRef = useRef(false);
|
||||
const composerRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// Auto-grow the textarea: reset height to 'auto' so the scrollHeight
|
||||
// shrinks when the user deletes text, then size to scrollHeight up to
|
||||
// a 5-line cap. Beyond the cap, internal scroll kicks in.
|
||||
useEffect(() => {
|
||||
const el = composerRef.current;
|
||||
if (!el) return;
|
||||
el.style.height = "auto";
|
||||
const next = Math.min(el.scrollHeight, 132); // ~5 lines at 14.5px/1.4
|
||||
el.style.height = `${next}px`;
|
||||
}, [draft]);
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [messages]);
|
||||
|
||||
if (!node) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: "100%",
|
||||
background: p.bg,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: p.text3,
|
||||
fontSize: 13,
|
||||
fontFamily: MOBILE_FONT_SANS,
|
||||
}}
|
||||
>
|
||||
Agent not found.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const a = toMobileAgent(node);
|
||||
const reachable = a.status === "online" || a.status === "degraded";
|
||||
|
||||
const send = async () => {
|
||||
const text = draft.trim();
|
||||
if (!text || sending || !reachable) return;
|
||||
if (sendInFlightRef.current) return;
|
||||
sendInFlightRef.current = true;
|
||||
setDraft("");
|
||||
setError(null);
|
||||
setSending(true);
|
||||
const myMsg: ChatMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
role: "user",
|
||||
text,
|
||||
ts: formatTime(new Date()),
|
||||
};
|
||||
setMessages((m) => [...m, myMsg]);
|
||||
|
||||
try {
|
||||
const res = await api.post<A2AResponseShape>(`/workspaces/${agentId}/a2a`, {
|
||||
method: "message/send",
|
||||
params: {
|
||||
message: {
|
||||
role: "user",
|
||||
messageId: crypto.randomUUID(),
|
||||
parts: [{ kind: "text", text }],
|
||||
},
|
||||
},
|
||||
});
|
||||
const reply =
|
||||
res.result?.parts?.find((part) => part.kind === "text")?.text ?? "";
|
||||
if (reply) {
|
||||
setMessages((m) => [
|
||||
...m,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
role: "agent",
|
||||
text: reply,
|
||||
ts: formatTime(new Date()),
|
||||
},
|
||||
]);
|
||||
} else if (res.error?.message) {
|
||||
setError(res.error.message);
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to send");
|
||||
} finally {
|
||||
setSending(false);
|
||||
sendInFlightRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
background: p.bg,
|
||||
fontFamily: MOBILE_FONT_SANS,
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
padding: "max(env(safe-area-inset-top), 44px) 14px 10px",
|
||||
borderBottom: `0.5px solid ${p.divider}`,
|
||||
background: dark ? "rgba(21,20,15,0.85)" : "rgba(246,244,239,0.85)",
|
||||
backdropFilter: "blur(14px)",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
aria-label="Back"
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 999,
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
background: "transparent",
|
||||
color: p.text2,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{Icons.back({ size: 18 })}
|
||||
</button>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
||||
<StatusDot status={a.status} size={7} dark={dark} halo={false} />
|
||||
<span
|
||||
style={{
|
||||
fontSize: 15,
|
||||
fontWeight: 600,
|
||||
color: p.text,
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
{a.name}
|
||||
</span>
|
||||
<TierChip tier={a.tier} dark={dark} />
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: p.text3,
|
||||
marginTop: 2,
|
||||
fontFamily: MOBILE_FONT_MONO,
|
||||
}}
|
||||
>
|
||||
{a.runtime} · {a.skills} skills
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="More"
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 999,
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
background: "transparent",
|
||||
color: p.text2,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{Icons.more({ size: 18 })}
|
||||
</button>
|
||||
</div>
|
||||
{/* Sub-tabs */}
|
||||
<div style={{ display: "flex", gap: 18, marginTop: 12, paddingLeft: 4 }}>
|
||||
{(
|
||||
[
|
||||
{ id: "my", label: "My Chat" },
|
||||
{ id: "a2a", label: "Agent Comms" },
|
||||
] as const
|
||||
).map((t) => {
|
||||
const on = tab === t.id;
|
||||
return (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
onClick={() => setTab(t.id)}
|
||||
style={{
|
||||
padding: "4px 0 8px",
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
fontSize: 13.5,
|
||||
cursor: "pointer",
|
||||
color: on ? p.text : p.text3,
|
||||
fontWeight: on ? 600 : 500,
|
||||
borderBottom: on ? `2px solid ${p.accent}` : "2px solid transparent",
|
||||
}}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
style={{
|
||||
flex: 1,
|
||||
overflow: "auto",
|
||||
padding: "14px 14px 16px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
{tab === "a2a" && (
|
||||
<div
|
||||
style={{
|
||||
padding: "20px 4px",
|
||||
textAlign: "center",
|
||||
color: p.text3,
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
Agent Comms — peer-to-peer A2A traffic surfaces in the Comms tab.
|
||||
</div>
|
||||
)}
|
||||
{tab === "my" && messages.length === 0 && (
|
||||
<div style={{ padding: "20px 4px", textAlign: "center", color: p.text3, fontSize: 13 }}>
|
||||
Send a message to start chatting.
|
||||
</div>
|
||||
)}
|
||||
{tab === "my" &&
|
||||
messages.map((m) => {
|
||||
const mine = m.role === "user";
|
||||
return (
|
||||
<div
|
||||
key={m.id}
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: mine ? "flex-end" : "flex-start",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
maxWidth: "78%",
|
||||
background: mine ? p.accent : dark ? "#22211c" : "#fff",
|
||||
color: mine ? "#fff" : p.text,
|
||||
border: mine ? "none" : `0.5px solid ${p.border}`,
|
||||
borderRadius: mine ? "18px 18px 4px 18px" : "18px 18px 18px 4px",
|
||||
padding: "9px 13px",
|
||||
fontSize: 14.5,
|
||||
lineHeight: 1.4,
|
||||
overflowWrap: "anywhere",
|
||||
}}
|
||||
>
|
||||
{m.text}
|
||||
<div
|
||||
style={{
|
||||
fontSize: 10,
|
||||
marginTop: 4,
|
||||
opacity: mine ? 0.75 : 0.5,
|
||||
fontFamily: MOBILE_FONT_MONO,
|
||||
}}
|
||||
>
|
||||
{m.ts}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
style={{
|
||||
alignSelf: "center",
|
||||
padding: "6px 12px",
|
||||
borderRadius: 12,
|
||||
background: `${p.failed}1a`,
|
||||
color: p.failed,
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer ID */}
|
||||
<div
|
||||
style={{
|
||||
padding: "0 14px 6px",
|
||||
textAlign: "center",
|
||||
fontFamily: MOBILE_FONT_MONO,
|
||||
fontSize: 9.5,
|
||||
color: p.text3,
|
||||
letterSpacing: "0.04em",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{agentId}
|
||||
</div>
|
||||
|
||||
{/* Composer */}
|
||||
<div
|
||||
style={{
|
||||
padding: "10px 12px max(env(safe-area-inset-bottom), 16px)",
|
||||
borderTop: `0.5px solid ${p.divider}`,
|
||||
background: dark ? "rgba(21,20,15,0.92)" : "rgba(246,244,239,0.92)",
|
||||
backdropFilter: "blur(14px)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "flex-end",
|
||||
gap: 8,
|
||||
background: dark ? "#22211c" : "#fff",
|
||||
border: `0.5px solid ${p.border}`,
|
||||
borderRadius: 22,
|
||||
padding: "6px 6px 6px 12px",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Attach"
|
||||
style={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 999,
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
background: "transparent",
|
||||
color: p.text3,
|
||||
flexShrink: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{Icons.attach({ size: 16 })}
|
||||
</button>
|
||||
<textarea
|
||||
ref={composerRef}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
// Enter sends; Shift+Enter inserts a newline. Skip when the
|
||||
// IME is composing — pressing Enter to commit a Chinese/
|
||||
// Japanese candidate would otherwise dispatch the half-typed
|
||||
// message (the same regression the desktop ChatTab guards).
|
||||
if (
|
||||
e.key === "Enter" &&
|
||||
!e.shiftKey &&
|
||||
!e.nativeEvent.isComposing &&
|
||||
e.keyCode !== 229
|
||||
) {
|
||||
e.preventDefault();
|
||||
send();
|
||||
}
|
||||
}}
|
||||
placeholder={reachable ? "Send a message…" : `Agent is ${a.status}`}
|
||||
disabled={!reachable}
|
||||
rows={1}
|
||||
style={{
|
||||
flex: 1,
|
||||
border: "none",
|
||||
outline: "none",
|
||||
background: "transparent",
|
||||
fontSize: 14.5,
|
||||
lineHeight: 1.4,
|
||||
color: p.text,
|
||||
padding: "6px 0",
|
||||
fontFamily: "inherit",
|
||||
minWidth: 0,
|
||||
resize: "none",
|
||||
maxHeight: 132,
|
||||
overflowY: "auto",
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={send}
|
||||
disabled={!draft.trim() || !reachable || sending}
|
||||
aria-label="Send"
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 999,
|
||||
border: "none",
|
||||
cursor: draft.trim() && !sending ? "pointer" : "not-allowed",
|
||||
flexShrink: 0,
|
||||
background:
|
||||
draft.trim() && reachable && !sending
|
||||
? p.accent
|
||||
: dark
|
||||
? "#2a2823"
|
||||
: "#ece9e0",
|
||||
color: draft.trim() && reachable && !sending ? "#fff" : p.text3,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{Icons.send({ size: 16 })}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,368 +0,0 @@
|
||||
"use client";
|
||||
|
||||
// 05 · Comms feed — workspace-wide A2A traffic.
|
||||
// Bootstraps from /workspaces/:id/activity for the first few online
|
||||
// workspaces, then prepends ACTIVITY_LOGGED events from the live socket.
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { api } from "@/lib/api";
|
||||
import { useSocketEvent } from "@/hooks/useSocketEvent";
|
||||
import { useCanvasStore } from "@/store/canvas";
|
||||
|
||||
import { WorkspacePill } from "./components";
|
||||
import { MOBILE_FONT_MONO, MOBILE_FONT_SANS, usePalette } from "./palette";
|
||||
import { SectionLabel } from "./primitives";
|
||||
|
||||
interface CommItem {
|
||||
id: string;
|
||||
from: string;
|
||||
to: string;
|
||||
kind: string;
|
||||
status: "ok" | "err";
|
||||
summary: string;
|
||||
durationMs: number | null;
|
||||
ago: string;
|
||||
ts: number;
|
||||
}
|
||||
|
||||
interface ActivityRecord {
|
||||
id: string;
|
||||
workspace_id: string;
|
||||
activity_type: string;
|
||||
source_id: string | null;
|
||||
target_id: string | null;
|
||||
summary: string | null;
|
||||
status: string;
|
||||
duration_ms: number | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
const FAN_OUT_CAP = 4;
|
||||
const RENDER_CAP = 30;
|
||||
|
||||
type FilterId = "all" | "errors";
|
||||
|
||||
function relativeAgo(iso: string): string {
|
||||
const t = Date.parse(iso);
|
||||
if (isNaN(t)) return "";
|
||||
const seconds = Math.max(0, Math.round((Date.now() - t) / 1000));
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const minutes = Math.round(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m`;
|
||||
const hours = Math.round(minutes / 60);
|
||||
if (hours < 24) return `${hours}h`;
|
||||
const days = Math.round(hours / 24);
|
||||
return `${days}d`;
|
||||
}
|
||||
|
||||
export function MobileComms({ dark }: { dark: boolean }) {
|
||||
const p = usePalette(dark);
|
||||
const nodes = useCanvasStore((s) => s.nodes);
|
||||
const [items, setItems] = useState<CommItem[]>([]);
|
||||
const [filter, setFilter] = useState<FilterId>("all");
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const nameOf = useCallback(
|
||||
(id: string | null | undefined): string => {
|
||||
if (!id) return "Unknown";
|
||||
const n = nodes.find((x) => x.id === id);
|
||||
return n?.data.name ?? id.slice(0, 8);
|
||||
},
|
||||
[nodes],
|
||||
);
|
||||
|
||||
const toItem = useCallback(
|
||||
(a: ActivityRecord): CommItem => ({
|
||||
id: a.id,
|
||||
from: nameOf(a.source_id ?? a.workspace_id),
|
||||
to: nameOf(a.target_id),
|
||||
kind: a.activity_type,
|
||||
status: a.status === "error" || a.status === "err" ? "err" : "ok",
|
||||
summary: a.summary ?? "",
|
||||
durationMs: a.duration_ms,
|
||||
ago: relativeAgo(a.created_at),
|
||||
ts: Date.parse(a.created_at) || Date.now(),
|
||||
}),
|
||||
[nameOf],
|
||||
);
|
||||
|
||||
// Stable signature of the online-workspace set. Re-runs the bootstrap
|
||||
// only when which workspaces are online changes — not on every node
|
||||
// position update or unrelated data churn.
|
||||
const onlineWorkspaceIds = useMemo(
|
||||
() =>
|
||||
nodes
|
||||
.filter((n) => n.data.status === "online")
|
||||
.slice(0, FAN_OUT_CAP)
|
||||
.map((n) => n.id),
|
||||
[nodes],
|
||||
);
|
||||
const onlineSignature = onlineWorkspaceIds.join("|");
|
||||
|
||||
// Bootstrap: pull the most recent activity from the first few online
|
||||
// workspaces. Identical fan-out cap to CommunicationOverlay to keep
|
||||
// the load profile predictable on big tenants.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
if (onlineWorkspaceIds.length === 0) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
Promise.all(
|
||||
onlineWorkspaceIds.map((id) =>
|
||||
api.get<ActivityRecord[]>(`/workspaces/${id}/activity?limit=8`).catch(() => []),
|
||||
),
|
||||
).then((batches) => {
|
||||
if (cancelled) return;
|
||||
const flat = batches.flat().map(toItem);
|
||||
flat.sort((a, b) => b.ts - a.ts);
|
||||
setItems(flat.slice(0, RENDER_CAP));
|
||||
setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// Effect depends on the signature string (stable when the id set
|
||||
// doesn't change) + toItem (memoized via useCallback). Listing the
|
||||
// id-array directly would re-run on every render because the array
|
||||
// identity changes even when the contents don't.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [onlineSignature, toItem]);
|
||||
|
||||
// Live: prepend ACTIVITY_LOGGED events as they arrive.
|
||||
useSocketEvent((msg) => {
|
||||
if (msg.event !== "ACTIVITY_LOGGED") return;
|
||||
const payload = msg.payload as Partial<ActivityRecord> | undefined;
|
||||
if (!payload || !payload.id) return;
|
||||
const rec: ActivityRecord = {
|
||||
id: payload.id,
|
||||
workspace_id: payload.workspace_id ?? msg.workspace_id ?? "",
|
||||
activity_type: payload.activity_type ?? "a2a",
|
||||
source_id: payload.source_id ?? null,
|
||||
target_id: payload.target_id ?? null,
|
||||
summary: payload.summary ?? null,
|
||||
status: payload.status ?? "ok",
|
||||
duration_ms: payload.duration_ms ?? null,
|
||||
created_at: payload.created_at ?? new Date().toISOString(),
|
||||
};
|
||||
setItems((prev) => [toItem(rec), ...prev.filter((x) => x.id !== rec.id)].slice(0, RENDER_CAP));
|
||||
});
|
||||
|
||||
const filtered = useMemo(
|
||||
() => items.filter((c) => filter === "all" || c.status === "err"),
|
||||
[items, filter],
|
||||
);
|
||||
const errCount = useMemo(() => items.filter((c) => c.status === "err").length, [items]);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: "100%",
|
||||
overflow: "auto",
|
||||
background: p.bg,
|
||||
paddingBottom: 96,
|
||||
fontFamily: MOBILE_FONT_SANS,
|
||||
}}
|
||||
>
|
||||
<div style={{ padding: "max(env(safe-area-inset-top), 44px) 16px 8px" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: 14,
|
||||
}}
|
||||
>
|
||||
<WorkspacePill dark={dark} count={nodes.length} />
|
||||
{/* Header filter button reserved — the All/Errors chips below
|
||||
already cover the v1 filter axis. */}
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "baseline", justifyContent: "space-between" }}>
|
||||
<h1
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: 32,
|
||||
fontWeight: 700,
|
||||
color: p.text,
|
||||
letterSpacing: "-0.025em",
|
||||
}}
|
||||
>
|
||||
Comms
|
||||
</h1>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: MOBILE_FONT_MONO,
|
||||
fontSize: 11,
|
||||
color: p.text3,
|
||||
}}
|
||||
>
|
||||
{items.length} events
|
||||
</span>
|
||||
</div>
|
||||
<p style={{ margin: "4px 0 0", fontSize: 13.5, color: p.text2 }}>
|
||||
Live A2A traffic across the workspace.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: 6, padding: "12px 16px 8px" }}>
|
||||
{(
|
||||
[
|
||||
{ id: "all", label: "All", n: items.length },
|
||||
{ id: "errors", label: "Errors", n: errCount },
|
||||
] as const
|
||||
).map((o) => {
|
||||
const on = filter === o.id;
|
||||
return (
|
||||
<button
|
||||
key={o.id}
|
||||
type="button"
|
||||
onClick={() => setFilter(o.id)}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
padding: "7px 12px",
|
||||
borderRadius: 999,
|
||||
cursor: "pointer",
|
||||
background: on ? p.text : dark ? "#22211c" : "#fff",
|
||||
color: on ? (dark ? p.bg : "#fff") : p.text,
|
||||
border: `0.5px solid ${on ? "transparent" : p.border}`,
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{o.label}
|
||||
<span
|
||||
style={{
|
||||
fontSize: 10.5,
|
||||
opacity: 0.7,
|
||||
fontFamily: MOBILE_FONT_MONO,
|
||||
}}
|
||||
>
|
||||
{o.n}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<SectionLabel dark={dark}>Communications</SectionLabel>
|
||||
|
||||
<div style={{ padding: "0 14px", display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{loading && items.length === 0 ? (
|
||||
<div style={{ padding: "30px 4px", textAlign: "center", color: p.text3, fontSize: 13 }}>
|
||||
Loading recent comms…
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div style={{ padding: "30px 4px", textAlign: "center", color: p.text3, fontSize: 13 }}>
|
||||
No A2A traffic yet.
|
||||
</div>
|
||||
) : (
|
||||
filtered.map((c) => <CommRow key={c.id} c={c} dark={dark} />)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CommRow({ c, dark }: { c: CommItem; dark: boolean }) {
|
||||
const p = usePalette(dark);
|
||||
const isErr = c.status === "err";
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: p.surface,
|
||||
borderRadius: 14,
|
||||
border: `0.5px solid ${p.border}`,
|
||||
padding: "12px 14px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: p.text,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
padding: "1px 6px",
|
||||
borderRadius: 4,
|
||||
background: isErr ? "#f5dad2" : "#dde9e1",
|
||||
color: isErr ? "#a8341a" : p.greenInk,
|
||||
fontFamily: MOBILE_FONT_MONO,
|
||||
fontSize: 9,
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.06em",
|
||||
}}
|
||||
>
|
||||
{isErr ? "ERR" : "OK"}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
maxWidth: 110,
|
||||
}}
|
||||
>
|
||||
{c.from}
|
||||
</span>
|
||||
<span style={{ color: p.text3, fontWeight: 500 }}>→</span>
|
||||
<span
|
||||
style={{
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
maxWidth: 110,
|
||||
}}
|
||||
>
|
||||
{c.to}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
marginLeft: "auto",
|
||||
fontSize: 10.5,
|
||||
color: p.text3,
|
||||
fontFamily: MOBILE_FONT_MONO,
|
||||
}}
|
||||
>
|
||||
{c.ago}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: p.text3,
|
||||
fontWeight: 600,
|
||||
fontFamily: MOBILE_FONT_MONO,
|
||||
letterSpacing: "0.02em",
|
||||
}}
|
||||
>
|
||||
{c.kind}
|
||||
{c.durationMs != null && (
|
||||
<span style={{ marginLeft: 8, color: isErr ? "#a8341a" : p.text3 }}>{c.durationMs}ms</span>
|
||||
)}
|
||||
</div>
|
||||
{c.summary && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12.5,
|
||||
color: p.text2,
|
||||
lineHeight: 1.4,
|
||||
overflowWrap: "anywhere",
|
||||
}}
|
||||
>
|
||||
{c.summary}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,589 +0,0 @@
|
||||
"use client";
|
||||
|
||||
// 03 · Agent detail — pills + tabbed content (Overview/Activity/Config/Memory).
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { api } from "@/lib/api";
|
||||
import { useCanvasStore } from "@/store/canvas";
|
||||
|
||||
import { RemoteBadge, toMobileAgent } from "./components";
|
||||
import { MOBILE_FONT_MONO, MOBILE_FONT_SANS, type MobilePalette, usePalette } from "./palette";
|
||||
import { Icons, StatusDot, TierChip } from "./primitives";
|
||||
|
||||
type TabId = "overview" | "activity" | "config" | "memory";
|
||||
|
||||
const TABS: { id: TabId; label: string }[] = [
|
||||
{ id: "overview", label: "Overview" },
|
||||
{ id: "activity", label: "Activity" },
|
||||
{ id: "config", label: "Config" },
|
||||
{ id: "memory", label: "Memory" },
|
||||
];
|
||||
|
||||
export function MobileDetail({
|
||||
agentId,
|
||||
dark,
|
||||
onBack,
|
||||
onChat,
|
||||
}: {
|
||||
agentId: string;
|
||||
dark: boolean;
|
||||
onBack: () => void;
|
||||
onChat: () => void;
|
||||
}) {
|
||||
const p = usePalette(dark);
|
||||
const node = useCanvasStore((s) => s.nodes.find((n) => n.id === agentId));
|
||||
const [tab, setTab] = useState<TabId>("overview");
|
||||
|
||||
if (!node) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: "100%",
|
||||
background: p.bg,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: p.text3,
|
||||
fontSize: 13,
|
||||
fontFamily: MOBILE_FONT_SANS,
|
||||
}}
|
||||
>
|
||||
Agent not found.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const a = toMobileAgent(node);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: "100%",
|
||||
overflow: "auto",
|
||||
background: p.bg,
|
||||
paddingBottom: 96,
|
||||
fontFamily: MOBILE_FONT_SANS,
|
||||
}}
|
||||
>
|
||||
{/* Top bar */}
|
||||
<div
|
||||
style={{
|
||||
position: "sticky",
|
||||
top: 0,
|
||||
zIndex: 10,
|
||||
padding: "max(env(safe-area-inset-top), 44px) 14px 0",
|
||||
background: p.bg,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
aria-label="Back"
|
||||
style={iconButtonStyle(p, dark)}
|
||||
>
|
||||
{Icons.back({ size: 18 })}
|
||||
</button>
|
||||
<button type="button" aria-label="More" style={iconButtonStyle(p, dark)}>
|
||||
{Icons.more({ size: 18 })}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hero */}
|
||||
<div style={{ padding: "20px 20px 16px" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 8 }}>
|
||||
<StatusDot status={a.status} size={10} dark={dark} />
|
||||
<span
|
||||
style={{
|
||||
fontFamily: MOBILE_FONT_MONO,
|
||||
fontSize: 11,
|
||||
color: p.greenInk,
|
||||
fontWeight: 600,
|
||||
letterSpacing: "0.04em",
|
||||
textTransform: "uppercase",
|
||||
}}
|
||||
>
|
||||
{a.status}
|
||||
</span>
|
||||
{a.remote && <RemoteBadge palette={p} />}
|
||||
</div>
|
||||
<h1
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: 28,
|
||||
fontWeight: 700,
|
||||
color: p.text,
|
||||
letterSpacing: "-0.02em",
|
||||
}}
|
||||
>
|
||||
{a.name}
|
||||
</h1>
|
||||
<p
|
||||
style={{
|
||||
margin: "6px 0 0",
|
||||
fontSize: 14,
|
||||
color: p.text2,
|
||||
fontFamily: MOBILE_FONT_MONO,
|
||||
}}
|
||||
>
|
||||
{a.tag}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stat pills */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: 6,
|
||||
padding: "0 16px 16px",
|
||||
overflowX: "auto",
|
||||
scrollbarWidth: "none",
|
||||
}}
|
||||
>
|
||||
<PillStat label="TIER" value={a.tier} accent={p.t4Ink} dark={dark} chip="tier" />
|
||||
<PillStat label="RUNTIME" value={a.runtime} dark={dark} />
|
||||
<PillStat label="SKILLS" value={a.skills} dark={dark} />
|
||||
<PillStat label="STATUS" value={a.status} accent={p.online} dark={dark} dot />
|
||||
</div>
|
||||
|
||||
{/* Description card */}
|
||||
{a.desc && (
|
||||
<div style={{ padding: "0 14px" }}>
|
||||
<div
|
||||
style={{
|
||||
background: p.surface,
|
||||
borderRadius: 16,
|
||||
border: `0.5px solid ${p.border}`,
|
||||
padding: "14px 16px",
|
||||
}}
|
||||
>
|
||||
<p style={{ margin: 0, fontSize: 14.5, lineHeight: 1.5, color: p.text }}>{a.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: 4,
|
||||
padding: "20px 14px 10px",
|
||||
overflowX: "auto",
|
||||
scrollbarWidth: "none",
|
||||
}}
|
||||
>
|
||||
{TABS.map((t) => {
|
||||
const on = tab === t.id;
|
||||
return (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
onClick={() => setTab(t.id)}
|
||||
style={{
|
||||
padding: "8px 14px",
|
||||
borderRadius: 999,
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
background: on ? p.text : "transparent",
|
||||
color: on ? (dark ? p.bg : "#fff") : p.text2,
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
<div style={{ padding: "0 14px" }}>
|
||||
{tab === "overview" && <DetailOverview a={a} dark={dark} />}
|
||||
{tab === "activity" && <DetailActivity workspaceId={a.id} dark={dark} />}
|
||||
{tab === "config" && <DetailConfig a={a} dark={dark} />}
|
||||
{tab === "memory" && <DetailMemory dark={dark} />}
|
||||
</div>
|
||||
|
||||
{/* Chat CTA */}
|
||||
<div style={{ position: "absolute", left: 14, right: 14, bottom: 92, zIndex: 28 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onChat}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: 52,
|
||||
borderRadius: 16,
|
||||
cursor: "pointer",
|
||||
background: p.text,
|
||||
color: dark ? p.bg : "#fff",
|
||||
border: "none",
|
||||
fontSize: 15,
|
||||
fontWeight: 600,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 10,
|
||||
boxShadow: "0 8px 22px rgba(40,30,20,0.22)",
|
||||
}}
|
||||
>
|
||||
{Icons.chat({ size: 18 })} Open chat
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function iconButtonStyle(p: MobilePalette, dark: boolean) {
|
||||
return {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 999,
|
||||
cursor: "pointer",
|
||||
background: dark ? "#22211c" : "#fff",
|
||||
border: `0.5px solid ${p.border}`,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: p.text2,
|
||||
} as const;
|
||||
}
|
||||
|
||||
function PillStat({
|
||||
label,
|
||||
value,
|
||||
accent,
|
||||
dark,
|
||||
dot,
|
||||
chip,
|
||||
}: {
|
||||
label: string;
|
||||
value: string | number;
|
||||
accent?: string;
|
||||
dark: boolean;
|
||||
dot?: boolean;
|
||||
chip?: "tier";
|
||||
}) {
|
||||
const p = usePalette(dark);
|
||||
const active = !!accent;
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 7,
|
||||
padding: "7px 12px",
|
||||
borderRadius: 999,
|
||||
flexShrink: 0,
|
||||
background: active ? `${accent}1a` : dark ? "#22211c" : "#fff",
|
||||
border: `0.5px solid ${active ? `${accent}40` : p.border}`,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 9.5,
|
||||
color: active ? accent : p.text3,
|
||||
fontFamily: MOBILE_FONT_MONO,
|
||||
letterSpacing: "0.06em",
|
||||
textTransform: "uppercase",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
{dot && <StatusDot status="online" size={6} dark={dark} halo={false} />}
|
||||
{chip === "tier" ? (
|
||||
<TierChip tier={value as "T1" | "T2" | "T3" | "T4"} dark={dark} />
|
||||
) : (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: active ? accent : p.text,
|
||||
fontWeight: 600,
|
||||
textTransform: label === "STATUS" ? "capitalize" : "none",
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailOverview({
|
||||
a,
|
||||
dark,
|
||||
}: {
|
||||
a: ReturnType<typeof toMobileAgent>;
|
||||
dark: boolean;
|
||||
}) {
|
||||
const p = usePalette(dark);
|
||||
const Row = ({ k, v, mono = true }: { k: string; v: string; mono?: boolean }) => (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "10px 0",
|
||||
borderBottom: `0.5px solid ${p.divider}`,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11.5,
|
||||
color: p.text3,
|
||||
letterSpacing: "0.04em",
|
||||
fontFamily: MOBILE_FONT_MONO,
|
||||
textTransform: "uppercase",
|
||||
}}
|
||||
>
|
||||
{k}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: p.text,
|
||||
fontWeight: 500,
|
||||
fontFamily: mono ? MOBILE_FONT_MONO : "inherit",
|
||||
maxWidth: "60%",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{v}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: p.surface,
|
||||
borderRadius: 16,
|
||||
padding: "4px 16px",
|
||||
border: `0.5px solid ${p.border}`,
|
||||
}}
|
||||
>
|
||||
<Row k="ID" v={a.id} />
|
||||
<Row k="Tier" v={a.tier} />
|
||||
<Row k="Runtime" v={a.runtime} />
|
||||
<Row k="Active tasks" v={String(a.calls)} />
|
||||
<Row k="Skills" v={`${a.skills} loaded`} />
|
||||
<Row k="Origin" v={a.remote ? "remote" : "platform"} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ActivityRecord {
|
||||
id: string;
|
||||
activity_type: string;
|
||||
status: string;
|
||||
summary: string | null;
|
||||
duration_ms: number | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
function DetailActivity({ workspaceId, dark }: { workspaceId: string; dark: boolean }) {
|
||||
const p = usePalette(dark);
|
||||
const [items, setItems] = useState<ActivityRecord[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setError(null);
|
||||
setItems(null);
|
||||
api
|
||||
.get<ActivityRecord[]>(`/workspaces/${workspaceId}/activity?limit=12`)
|
||||
.then((rows) => {
|
||||
if (!cancelled) setItems(rows);
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (!cancelled) {
|
||||
setError(e instanceof Error ? e.message : "Failed to load activity");
|
||||
setItems([]);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [workspaceId]);
|
||||
|
||||
if (items === null) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: p.surface,
|
||||
borderRadius: 16,
|
||||
padding: "20px 16px",
|
||||
border: `0.5px solid ${p.border}`,
|
||||
color: p.text3,
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
Loading activity…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: p.surface,
|
||||
borderRadius: 16,
|
||||
padding: "20px 16px",
|
||||
border: `0.5px solid ${p.border}`,
|
||||
color: p.text3,
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
{error ?? "No recent activity. New events appear here as the agent reports them."}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: p.surface,
|
||||
borderRadius: 16,
|
||||
padding: "6px 16px",
|
||||
border: `0.5px solid ${p.border}`,
|
||||
}}
|
||||
>
|
||||
{items.map((it, i) => {
|
||||
const ts = new Date(it.created_at);
|
||||
const label = isNaN(ts.getTime())
|
||||
? ""
|
||||
: ts.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
|
||||
const isErr = it.status === "error" || it.status === "err";
|
||||
return (
|
||||
<div
|
||||
key={it.id}
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: 12,
|
||||
padding: "12px 0",
|
||||
borderBottom: i < items.length - 1 ? `0.5px solid ${p.divider}` : "none",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: p.text3,
|
||||
paddingTop: 2,
|
||||
width: 48,
|
||||
fontFamily: MOBILE_FONT_MONO,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
fontSize: 11,
|
||||
color: p.text3,
|
||||
fontFamily: MOBILE_FONT_MONO,
|
||||
letterSpacing: "0.02em",
|
||||
marginBottom: 2,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
padding: "1px 5px",
|
||||
borderRadius: 4,
|
||||
background: isErr ? "#f5dad2" : "#dde9e1",
|
||||
color: isErr ? "#a8341a" : p.greenInk,
|
||||
fontSize: 9,
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.06em",
|
||||
}}
|
||||
>
|
||||
{isErr ? "ERR" : "OK"}
|
||||
</span>
|
||||
<span>{it.activity_type}</span>
|
||||
{it.duration_ms != null && <span>· {it.duration_ms}ms</span>}
|
||||
</div>
|
||||
{it.summary && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 13.5,
|
||||
color: p.text,
|
||||
lineHeight: 1.45,
|
||||
overflowWrap: "anywhere",
|
||||
}}
|
||||
>
|
||||
{it.summary}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailConfig({
|
||||
a,
|
||||
dark,
|
||||
}: {
|
||||
a: ReturnType<typeof toMobileAgent>;
|
||||
dark: boolean;
|
||||
}) {
|
||||
const p = usePalette(dark);
|
||||
const cfg = JSON.stringify(
|
||||
{
|
||||
tier: a.tier,
|
||||
runtime: a.runtime,
|
||||
skills: a.skills,
|
||||
remote: a.remote,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
return (
|
||||
<pre
|
||||
style={{
|
||||
background: dark ? "#0f0e0a" : "#fff",
|
||||
borderRadius: 16,
|
||||
padding: "14px 16px",
|
||||
border: `0.5px solid ${p.border}`,
|
||||
fontFamily: MOBILE_FONT_MONO,
|
||||
fontSize: 11.5,
|
||||
lineHeight: 1.55,
|
||||
color: p.text2,
|
||||
margin: 0,
|
||||
overflow: "auto",
|
||||
whiteSpace: "pre-wrap",
|
||||
}}
|
||||
>
|
||||
{cfg}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailMemory({ dark }: { dark: boolean }) {
|
||||
const p = usePalette(dark);
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: p.surface,
|
||||
borderRadius: 16,
|
||||
padding: "14px 16px",
|
||||
border: `0.5px solid ${p.border}`,
|
||||
fontSize: 13,
|
||||
color: p.text2,
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
<span style={{ color: p.text }}>Ephemeral session.</span> Memory clears on workspace
|
||||
restart. Open the desktop canvas for the full memory inspector.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,208 +0,0 @@
|
||||
"use client";
|
||||
|
||||
// 01 · Workspace home — agent list + filter chips + FAB.
|
||||
// Mirrors design/screen-home.jsx, swapped to live store data.
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { useCanvasStore } from "@/store/canvas";
|
||||
|
||||
import {
|
||||
type AgentFilter,
|
||||
AgentCard,
|
||||
FilterChips,
|
||||
WorkspacePill,
|
||||
classifyForFilter,
|
||||
toMobileAgent,
|
||||
} from "./components";
|
||||
import { MOBILE_FONT_MONO, MOBILE_FONT_SANS, usePalette } from "./palette";
|
||||
import { Icons, SectionLabel } from "./primitives";
|
||||
|
||||
export function MobileHome({
|
||||
dark,
|
||||
density,
|
||||
onOpen,
|
||||
onSpawn,
|
||||
workspaceLabel = "Default",
|
||||
username,
|
||||
}: {
|
||||
dark: boolean;
|
||||
density: "compact" | "regular";
|
||||
onOpen: (agentId: string) => void;
|
||||
onSpawn: () => void;
|
||||
workspaceLabel?: string;
|
||||
username?: string;
|
||||
}) {
|
||||
const p = usePalette(dark);
|
||||
const nodes = useCanvasStore((s) => s.nodes);
|
||||
const agents = useMemo(() => nodes.map(toMobileAgent), [nodes]);
|
||||
const [filter, setFilter] = useState<AgentFilter>("all");
|
||||
|
||||
const counts = useMemo(() => {
|
||||
const c = { all: agents.length, online: 0, issue: 0, paused: 0 };
|
||||
for (const a of agents) {
|
||||
const bucket = classifyForFilter(a.status);
|
||||
if (bucket !== "all") c[bucket]++;
|
||||
}
|
||||
return c;
|
||||
}, [agents]);
|
||||
|
||||
const filtered = useMemo(
|
||||
() => agents.filter((a) => filter === "all" || classifyForFilter(a.status) === filter),
|
||||
[agents, filter],
|
||||
);
|
||||
|
||||
const compact = density === "compact";
|
||||
const rootCount = useMemo(
|
||||
() => agents.filter((a) => !a.parentId).length,
|
||||
[agents],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: "100%",
|
||||
overflow: "auto",
|
||||
background: p.bg,
|
||||
paddingBottom: 96,
|
||||
fontFamily: MOBILE_FONT_SANS,
|
||||
}}
|
||||
>
|
||||
{/* Sticky header */}
|
||||
<div
|
||||
style={{
|
||||
position: "sticky",
|
||||
top: 0,
|
||||
zIndex: 10,
|
||||
background: `linear-gradient(${p.bg} 60%, ${p.bg}00)`,
|
||||
padding: "max(env(safe-area-inset-top), 44px) 16px 8px",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: 14,
|
||||
}}
|
||||
>
|
||||
<WorkspacePill dark={dark} count={agents.length} />
|
||||
{/* Search button reserved — wire to a mobile SearchDialog in v1.1. */}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "baseline",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: 4,
|
||||
}}
|
||||
>
|
||||
<h1
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: 32,
|
||||
fontWeight: 700,
|
||||
color: p.text,
|
||||
letterSpacing: "-0.025em",
|
||||
}}
|
||||
>
|
||||
Agents
|
||||
</h1>
|
||||
{username && (
|
||||
<span
|
||||
style={{
|
||||
fontFamily: MOBILE_FONT_MONO,
|
||||
fontSize: 11,
|
||||
color: p.text3,
|
||||
letterSpacing: "0.04em",
|
||||
}}
|
||||
>
|
||||
{username}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p style={{ margin: "0 0 14px", fontSize: 13.5, color: p.text2 }}>
|
||||
{rootCount} workspace{rootCount === 1 ? "" : "s"} · live
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<FilterChips value={filter} onChange={setFilter} dark={dark} counts={counts} />
|
||||
|
||||
<SectionLabel
|
||||
dark={dark}
|
||||
right={
|
||||
<span
|
||||
style={{
|
||||
color: p.text3,
|
||||
fontSize: 10.5,
|
||||
letterSpacing: "0.04em",
|
||||
textTransform: "none",
|
||||
}}
|
||||
>
|
||||
{filtered.length}/{agents.length}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
Workspace · {workspaceLabel}
|
||||
</SectionLabel>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 8,
|
||||
padding: "0 14px",
|
||||
}}
|
||||
>
|
||||
{filtered.length === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
padding: "40px 8px",
|
||||
textAlign: "center",
|
||||
color: p.text3,
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
No agents match this filter.
|
||||
</div>
|
||||
) : (
|
||||
filtered.map((a) => (
|
||||
<AgentCard
|
||||
key={a.id}
|
||||
agent={a}
|
||||
dark={dark}
|
||||
compact={compact}
|
||||
onClick={() => onOpen(a.id)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Spawn FAB */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSpawn}
|
||||
aria-label="Spawn new agent"
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: 24,
|
||||
bottom: 100,
|
||||
zIndex: 25,
|
||||
width: 54,
|
||||
height: 54,
|
||||
borderRadius: 999,
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
background: p.text,
|
||||
color: dark ? p.bg : "#fff",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
boxShadow: "0 8px 24px rgba(40,30,20,0.25), 0 2px 6px rgba(40,30,20,0.15)",
|
||||
}}
|
||||
>
|
||||
{Icons.plus({ size: 22 })}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,194 +0,0 @@
|
||||
"use client";
|
||||
|
||||
// "Me" tab — the prototype design didn't ship a Me screen, so this is
|
||||
// the natural mobile home for theme + accent + density preferences
|
||||
// (the prototype's floating Tweaks panel collapses into this tab here).
|
||||
|
||||
import { useTheme, type ThemePreference } from "@/lib/theme-provider";
|
||||
|
||||
import { MOBILE_FONT_MONO, MOBILE_FONT_SANS, type MobilePalette, usePalette } from "./palette";
|
||||
import { SectionLabel } from "./primitives";
|
||||
|
||||
const ACCENTS = ["#2f9e6a", "#3b6fe0", "#7a4dd1", "#d97757", "#1f8a8a"] as const;
|
||||
|
||||
export function MobileMe({
|
||||
dark,
|
||||
accent,
|
||||
setAccent,
|
||||
density,
|
||||
setDensity,
|
||||
}: {
|
||||
dark: boolean;
|
||||
accent: string;
|
||||
setAccent: (v: string) => void;
|
||||
density: "compact" | "regular";
|
||||
setDensity: (v: "compact" | "regular") => void;
|
||||
}) {
|
||||
const p = usePalette(dark);
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: "100%",
|
||||
overflow: "auto",
|
||||
background: p.bg,
|
||||
paddingBottom: 96,
|
||||
fontFamily: MOBILE_FONT_SANS,
|
||||
}}
|
||||
>
|
||||
<div style={{ padding: "max(env(safe-area-inset-top), 44px) 20px 8px" }}>
|
||||
<h1
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: 32,
|
||||
fontWeight: 700,
|
||||
color: p.text,
|
||||
letterSpacing: "-0.025em",
|
||||
}}
|
||||
>
|
||||
Me
|
||||
</h1>
|
||||
<p style={{ margin: "4px 0 0", fontSize: 13.5, color: p.text2 }}>
|
||||
Theme, accent, and layout density.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SectionLabel dark={dark}>Theme</SectionLabel>
|
||||
<div style={{ padding: "0 14px" }}>
|
||||
<Card palette={p}>
|
||||
<SegmentedRow
|
||||
options={[
|
||||
{ id: "system", label: "System" },
|
||||
{ id: "light", label: "Light" },
|
||||
{ id: "dark", label: "Dark" },
|
||||
]}
|
||||
value={theme}
|
||||
onChange={(v) => setTheme(v as ThemePreference)}
|
||||
palette={p}
|
||||
dark={dark}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<SectionLabel dark={dark}>Accent</SectionLabel>
|
||||
<div style={{ padding: "0 14px" }}>
|
||||
<Card palette={p}>
|
||||
<div style={{ display: "flex", gap: 12, padding: "12px 4px", flexWrap: "wrap" }}>
|
||||
{ACCENTS.map((c) => {
|
||||
const on = c === accent;
|
||||
return (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
onClick={() => setAccent(c)}
|
||||
aria-label={`Set accent ${c}`}
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 999,
|
||||
cursor: "pointer",
|
||||
background: c,
|
||||
border: on ? `2px solid ${p.text}` : "2px solid transparent",
|
||||
boxShadow: on ? `0 0 0 2px ${p.bg} inset` : "none",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<SectionLabel dark={dark}>Density</SectionLabel>
|
||||
<div style={{ padding: "0 14px" }}>
|
||||
<Card palette={p}>
|
||||
<SegmentedRow
|
||||
options={[
|
||||
{ id: "regular", label: "Regular" },
|
||||
{ id: "compact", label: "Compact" },
|
||||
]}
|
||||
value={density}
|
||||
onChange={(v) => setDensity(v as "regular" | "compact")}
|
||||
palette={p}
|
||||
dark={dark}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
padding: "24px 20px",
|
||||
fontFamily: MOBILE_FONT_MONO,
|
||||
fontSize: 11,
|
||||
color: p.text3,
|
||||
letterSpacing: "0.04em",
|
||||
}}
|
||||
>
|
||||
Mobile design preview · v0.1
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Card({
|
||||
palette,
|
||||
children,
|
||||
}: {
|
||||
palette: MobilePalette;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: palette.surface,
|
||||
borderRadius: 16,
|
||||
border: `0.5px solid ${palette.border}`,
|
||||
padding: "4px 14px",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SegmentedRow({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
palette,
|
||||
dark,
|
||||
}: {
|
||||
options: { id: string; label: string }[];
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
palette: MobilePalette;
|
||||
dark: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div style={{ display: "flex", gap: 6, padding: "10px 0" }}>
|
||||
{options.map((o) => {
|
||||
const on = o.id === value;
|
||||
return (
|
||||
<button
|
||||
key={o.id}
|
||||
type="button"
|
||||
onClick={() => onChange(o.id)}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: "10px 8px",
|
||||
borderRadius: 10,
|
||||
cursor: "pointer",
|
||||
background: on ? palette.text : "transparent",
|
||||
color: on ? (dark ? palette.bg : "#fff") : palette.text,
|
||||
border: `1px solid ${on ? "transparent" : palette.border}`,
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{o.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,429 +0,0 @@
|
||||
"use client";
|
||||
|
||||
// 06 · Spawn agent — bottom-sheet flow.
|
||||
// Fetches /templates so the user picks from what's actually installed
|
||||
// on this platform (no hardcoded ID guesswork). Posts to /workspaces
|
||||
// with the same shape useTemplateDeploy uses. Skips the secret-key
|
||||
// preflight — if a deploy needs missing keys, the API surfaces the
|
||||
// error and we show it with a hint to fall through to the desktop
|
||||
// dialog (which has the full preflight + key-import flow).
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { api } from "@/lib/api";
|
||||
import { type Template } from "@/lib/deploy-preflight";
|
||||
|
||||
import { tierCode } from "./palette";
|
||||
import { MOBILE_FONT_MONO, MOBILE_FONT_SANS, type MobilePalette, usePalette } from "./palette";
|
||||
import { Icons, SectionLabel, TierChip } from "./primitives";
|
||||
|
||||
const TIER_LABEL: Record<"T1" | "T2" | "T3" | "T4", string> = {
|
||||
T1: "Sandboxed",
|
||||
T2: "Standard",
|
||||
T3: "Privileged",
|
||||
T4: "Full Access",
|
||||
};
|
||||
|
||||
export function MobileSpawn({ dark, onClose }: { dark: boolean; onClose: () => void }) {
|
||||
const p = usePalette(dark);
|
||||
const [templates, setTemplates] = useState<Template[]>([]);
|
||||
const [loadingTemplates, setLoadingTemplates] = useState(true);
|
||||
const [tplId, setTplId] = useState<string | null>(null);
|
||||
const [tier, setTier] = useState<"T1" | "T2" | "T3" | "T4">("T2");
|
||||
const [name, setName] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
api
|
||||
.get<Template[]>("/templates")
|
||||
.then((list) => {
|
||||
if (cancelled) return;
|
||||
setTemplates(list);
|
||||
if (list.length > 0) {
|
||||
setTplId(list[0].id);
|
||||
setTier(tierCode(list[0].tier));
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setTemplates([]);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoadingTemplates(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleSpawn = async () => {
|
||||
if (busy || !tplId) return;
|
||||
const chosen = templates.find((t) => t.id === tplId);
|
||||
if (!chosen) return;
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.post<{ id: string }>("/workspaces", {
|
||||
name: (name.trim() || chosen.name),
|
||||
template: chosen.id,
|
||||
tier: Number(tier.slice(1)),
|
||||
canvas: {
|
||||
x: Math.random() * 400 + 100,
|
||||
y: Math.random() * 300 + 100,
|
||||
},
|
||||
});
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setError(
|
||||
e instanceof Error
|
||||
? `${e.message}. If this template needs missing API keys, use the desktop palette to import them.`
|
||||
: "Spawn failed",
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Spawn agent"
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
zIndex: 100,
|
||||
background: "rgba(20,15,10,0.42)",
|
||||
backdropFilter: "blur(4px)",
|
||||
display: "flex",
|
||||
alignItems: "flex-end",
|
||||
fontFamily: MOBILE_FONT_SANS,
|
||||
}}
|
||||
onClick={(e) => {
|
||||
// Click on the dim backdrop closes the sheet.
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
background: p.bg,
|
||||
borderRadius: "24px 24px 0 0",
|
||||
maxHeight: "88%",
|
||||
overflow: "auto",
|
||||
boxShadow: "0 -10px 40px rgba(0,0,0,0.18)",
|
||||
}}
|
||||
>
|
||||
<Grabber palette={p} />
|
||||
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "6px 18px 10px",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h2
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: 22,
|
||||
fontWeight: 700,
|
||||
color: p.text,
|
||||
letterSpacing: "-0.02em",
|
||||
}}
|
||||
>
|
||||
Spawn Agent
|
||||
</h2>
|
||||
<p style={{ margin: "2px 0 0", fontSize: 12.5, color: p.text2 }}>
|
||||
In workspace · Default
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
style={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 999,
|
||||
cursor: "pointer",
|
||||
background: dark ? "#22211c" : "#fff",
|
||||
border: `0.5px solid ${p.border}`,
|
||||
color: p.text2,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{Icons.close({ size: 16 })}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Templates */}
|
||||
<SectionLabel dark={dark}>Template</SectionLabel>
|
||||
<div style={{ padding: "0 14px" }}>
|
||||
{loadingTemplates ? (
|
||||
<div
|
||||
style={{
|
||||
padding: "24px 8px",
|
||||
textAlign: "center",
|
||||
color: p.text3,
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
Loading templates…
|
||||
</div>
|
||||
) : templates.length === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
padding: "16px 14px",
|
||||
background: p.surface,
|
||||
borderRadius: 14,
|
||||
border: `0.5px solid ${p.border}`,
|
||||
color: p.text2,
|
||||
fontSize: 13,
|
||||
lineHeight: 1.45,
|
||||
}}
|
||||
>
|
||||
No templates installed on this platform yet. Open the desktop canvas
|
||||
and use the template palette to import one (Claude Code, Hermes, or
|
||||
an org template), then come back here to spawn.
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr 1fr",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
{templates.map((t) => {
|
||||
const on = tplId === t.id;
|
||||
const tCode = tierCode(t.tier);
|
||||
return (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setTplId(t.id);
|
||||
setTier(tCode);
|
||||
}}
|
||||
style={{
|
||||
background: on
|
||||
? dark
|
||||
? "#2a2823"
|
||||
: "#fff"
|
||||
: dark
|
||||
? "#1d1c17"
|
||||
: "#fbf9f4",
|
||||
border: `1px solid ${on ? p.accent : p.border}`,
|
||||
borderRadius: 14,
|
||||
padding: "12px 12px",
|
||||
textAlign: "left",
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 4,
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 13.5,
|
||||
fontWeight: 600,
|
||||
color: p.text,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{t.name}
|
||||
</span>
|
||||
<TierChip tier={tCode} dark={dark} />
|
||||
</div>
|
||||
{t.description && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11.5,
|
||||
color: p.text2,
|
||||
lineHeight: 1.35,
|
||||
display: "-webkit-box",
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: "vertical",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{t.description}
|
||||
</span>
|
||||
)}
|
||||
{on && (
|
||||
<span
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 8,
|
||||
right: 8,
|
||||
width: 16,
|
||||
height: 16,
|
||||
borderRadius: 999,
|
||||
background: p.accent,
|
||||
color: "#fff",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{Icons.check({ size: 10, sw: 2.5 })}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Name */}
|
||||
<SectionLabel dark={dark}>Name</SectionLabel>
|
||||
<div style={{ padding: "0 14px" }}>
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={tplId
|
||||
? (templates.find((t) => t.id === tplId)?.name ?? "agent-name")
|
||||
: "agent-name"}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "12px 14px",
|
||||
background: dark ? "#22211c" : "#fff",
|
||||
border: `0.5px solid ${p.border}`,
|
||||
borderRadius: 12,
|
||||
fontFamily: MOBILE_FONT_MONO,
|
||||
fontSize: 13.5,
|
||||
color: p.text,
|
||||
outline: "none",
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tier */}
|
||||
<SectionLabel dark={dark}>Permission tier</SectionLabel>
|
||||
<div style={{ padding: "0 14px", display: "flex", gap: 6 }}>
|
||||
{(["T1", "T2", "T3", "T4"] as const).map((t) => {
|
||||
const on = tier === t;
|
||||
return (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
onClick={() => setTier(t)}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: "10px 8px",
|
||||
cursor: "pointer",
|
||||
background: on ? (dark ? "#22211c" : "#fff") : "transparent",
|
||||
border: `1px solid ${on ? p.accent : p.border}`,
|
||||
borderRadius: 12,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
<TierChip tier={t} dark={dark} size="lg" />
|
||||
<span style={{ fontSize: 10.5, color: p.text2, fontWeight: 500 }}>
|
||||
{TIER_LABEL[t]}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
style={{
|
||||
margin: "12px 14px 0",
|
||||
padding: "10px 14px",
|
||||
background: `${p.failed}1a`,
|
||||
border: `0.5px solid ${p.failed}40`,
|
||||
borderRadius: 12,
|
||||
color: p.failed,
|
||||
fontSize: 12.5,
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Spawn button */}
|
||||
<div style={{ padding: "20px 14px max(env(safe-area-inset-bottom), 28px)" }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSpawn}
|
||||
disabled={busy || !tplId || templates.length === 0}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: 52,
|
||||
borderRadius: 16,
|
||||
border: "none",
|
||||
cursor: busy ? "wait" : tplId ? "pointer" : "not-allowed",
|
||||
background: p.text,
|
||||
color: dark ? p.bg : "#fff",
|
||||
fontSize: 15,
|
||||
fontWeight: 600,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 10,
|
||||
boxShadow: "0 8px 22px rgba(40,30,20,0.22)",
|
||||
opacity: busy || !tplId ? 0.55 : 1,
|
||||
}}
|
||||
>
|
||||
{Icons.zap({ size: 16 })} {busy ? "Spawning…" : "Spawn agent"}
|
||||
</button>
|
||||
<p
|
||||
style={{
|
||||
margin: "10px 0 0",
|
||||
textAlign: "center",
|
||||
fontSize: 11.5,
|
||||
color: p.text3,
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
Boots in ~3s. Tier {tier} permissions apply on first call.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Grabber({ palette }: { palette: MobilePalette }) {
|
||||
return (
|
||||
<div style={{ display: "flex", justifyContent: "center", padding: "8px 0 4px" }}>
|
||||
<span
|
||||
style={{
|
||||
width: 38,
|
||||
height: 4,
|
||||
borderRadius: 999,
|
||||
background: palette.text3,
|
||||
opacity: 0.4,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,211 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* MobileApp route-state contract.
|
||||
*
|
||||
* The mobile shell uses local React state (not URL routing) for
|
||||
* navigation between the 6 screens. This test pins the back-stack
|
||||
* shape so a future refactor can't silently regress:
|
||||
*
|
||||
* home →(open agent)→ detail
|
||||
* detail →(open chat)→ chat chat →(back)→ detail
|
||||
* detail →(back)→ home
|
||||
*
|
||||
* home / canvas / comms / me — reachable via the bottom tab bar.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
|
||||
beforeEach(() => {
|
||||
// URL state persists across tests in jsdom — reset to a clean slate
|
||||
// so each test starts on the home route regardless of what the
|
||||
// previous test pushed onto the history stack.
|
||||
window.history.replaceState(null, "", "/");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
// Mock the theme provider — MobileApp reads resolvedTheme to pick a
|
||||
// palette; for routing we don't care which one, light is fine.
|
||||
vi.mock("@/lib/theme-provider", () => ({
|
||||
useTheme: () => ({ theme: "light", resolvedTheme: "light", setTheme: vi.fn() }),
|
||||
}));
|
||||
|
||||
// Stub each screen to a sentinel that exposes the props MobileApp passes
|
||||
// in. The whole point is to verify the routing handoff, not the screens
|
||||
// themselves — those have their own tests.
|
||||
vi.mock("../MobileHome", () => ({
|
||||
MobileHome: ({ onOpen, onSpawn }: { onOpen: (id: string) => void; onSpawn: () => void }) => (
|
||||
<div>
|
||||
<span data-testid="screen">home</span>
|
||||
<button onClick={() => onOpen("ws-42")}>open-ws-42</button>
|
||||
<button onClick={onSpawn}>open-spawn</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("../MobileCanvas", () => ({
|
||||
MobileCanvas: () => <span data-testid="screen">canvas</span>,
|
||||
}));
|
||||
vi.mock("../MobileDetail", () => ({
|
||||
MobileDetail: ({
|
||||
agentId,
|
||||
onBack,
|
||||
onChat,
|
||||
}: {
|
||||
agentId: string;
|
||||
onBack: () => void;
|
||||
onChat: () => void;
|
||||
}) => (
|
||||
<div>
|
||||
<span data-testid="screen">detail:{agentId}</span>
|
||||
<button onClick={onBack}>detail-back</button>
|
||||
<button onClick={onChat}>detail-open-chat</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("../MobileChat", () => ({
|
||||
MobileChat: ({ agentId, onBack }: { agentId: string; onBack: () => void }) => (
|
||||
<div>
|
||||
<span data-testid="screen">chat:{agentId}</span>
|
||||
<button onClick={onBack}>chat-back</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("../MobileComms", () => ({
|
||||
MobileComms: () => <span data-testid="screen">comms</span>,
|
||||
}));
|
||||
vi.mock("../MobileMe", () => ({
|
||||
MobileMe: () => <span data-testid="screen">me</span>,
|
||||
}));
|
||||
vi.mock("../MobileSpawn", () => ({
|
||||
MobileSpawn: ({ onClose }: { onClose: () => void }) => (
|
||||
<div>
|
||||
<span data-testid="spawn-sheet">spawn</span>
|
||||
<button onClick={onClose}>spawn-close</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// MobileApp's shared TabBar is the user's gateway to the Canvas / Comms /
|
||||
// Me screens. Rather than depend on its visual icon set we expose a
|
||||
// label-based stub so the test can call onChange directly.
|
||||
vi.mock("../components", async () => {
|
||||
const actual = await vi.importActual<typeof import("../components")>("../components");
|
||||
type TabId = "agents" | "canvas" | "comms" | "me";
|
||||
return {
|
||||
...actual,
|
||||
TabBar: ({ onChange }: { active: TabId; onChange: (id: TabId) => void }) => (
|
||||
<div data-testid="tab-bar">
|
||||
{(["agents", "canvas", "comms", "me"] as const).map((id) => (
|
||||
<button key={id} onClick={() => onChange(id)}>
|
||||
tab-{id}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
import { MobileApp } from "../MobileApp";
|
||||
|
||||
const visibleScreen = () =>
|
||||
Array.from(document.querySelectorAll('[data-testid="screen"]'))
|
||||
.map((el) => el.textContent ?? "")
|
||||
.filter(Boolean);
|
||||
|
||||
describe("MobileApp — route state", () => {
|
||||
it("starts on the home screen", () => {
|
||||
render(<MobileApp />);
|
||||
expect(visibleScreen()).toEqual(["home"]);
|
||||
});
|
||||
|
||||
it("home → open agent → detail (passes agentId through)", () => {
|
||||
render(<MobileApp />);
|
||||
fireEvent.click(screen.getByText("open-ws-42"));
|
||||
expect(visibleScreen()).toEqual(["detail:ws-42"]);
|
||||
});
|
||||
|
||||
it("detail → open chat → chat (carries the same agentId)", () => {
|
||||
render(<MobileApp />);
|
||||
fireEvent.click(screen.getByText("open-ws-42"));
|
||||
fireEvent.click(screen.getByText("detail-open-chat"));
|
||||
expect(visibleScreen()).toEqual(["chat:ws-42"]);
|
||||
});
|
||||
|
||||
it("chat back returns to detail (NOT to home — preserves the back-stack)", () => {
|
||||
render(<MobileApp />);
|
||||
fireEvent.click(screen.getByText("open-ws-42"));
|
||||
fireEvent.click(screen.getByText("detail-open-chat"));
|
||||
fireEvent.click(screen.getByText("chat-back"));
|
||||
expect(visibleScreen()).toEqual(["detail:ws-42"]);
|
||||
});
|
||||
|
||||
it("detail back returns to home", () => {
|
||||
render(<MobileApp />);
|
||||
fireEvent.click(screen.getByText("open-ws-42"));
|
||||
fireEvent.click(screen.getByText("detail-back"));
|
||||
expect(visibleScreen()).toEqual(["home"]);
|
||||
});
|
||||
|
||||
it("hides the tab bar on chat (per design — composer reclaims that space)", () => {
|
||||
render(<MobileApp />);
|
||||
expect(screen.queryByTestId("tab-bar")).not.toBeNull();
|
||||
fireEvent.click(screen.getByText("open-ws-42"));
|
||||
expect(screen.queryByTestId("tab-bar")).not.toBeNull(); // detail
|
||||
fireEvent.click(screen.getByText("detail-open-chat"));
|
||||
expect(screen.queryByTestId("tab-bar")).toBeNull(); // chat
|
||||
});
|
||||
|
||||
it("tab bar switches the four primary screens (Agents / Canvas / Comms / Me)", () => {
|
||||
render(<MobileApp />);
|
||||
fireEvent.click(screen.getByText("tab-canvas"));
|
||||
expect(visibleScreen()).toEqual(["canvas"]);
|
||||
fireEvent.click(screen.getByText("tab-comms"));
|
||||
expect(visibleScreen()).toEqual(["comms"]);
|
||||
fireEvent.click(screen.getByText("tab-me"));
|
||||
expect(visibleScreen()).toEqual(["me"]);
|
||||
fireEvent.click(screen.getByText("tab-agents"));
|
||||
expect(visibleScreen()).toEqual(["home"]);
|
||||
});
|
||||
|
||||
it("spawn sheet overlays from anywhere, closes on dismiss", () => {
|
||||
render(<MobileApp />);
|
||||
expect(screen.queryByTestId("spawn-sheet")).toBeNull();
|
||||
fireEvent.click(screen.getByText("open-spawn"));
|
||||
expect(screen.queryByTestId("spawn-sheet")).not.toBeNull();
|
||||
fireEvent.click(screen.getByText("spawn-close"));
|
||||
expect(screen.queryByTestId("spawn-sheet")).toBeNull();
|
||||
});
|
||||
|
||||
it("seeds initial route from ?m= and ?a= so deep links open the right screen", () => {
|
||||
window.history.replaceState(null, "", "/?m=detail&a=ws-99");
|
||||
render(<MobileApp />);
|
||||
expect(visibleScreen()).toEqual(["detail:ws-99"]);
|
||||
});
|
||||
|
||||
it("collapses ?m=detail without ?a to home (detail without an agent is meaningless)", () => {
|
||||
window.history.replaceState(null, "", "/?m=detail");
|
||||
render(<MobileApp />);
|
||||
expect(visibleScreen()).toEqual(["home"]);
|
||||
});
|
||||
|
||||
it("syncs in-app navigation to the URL so browser back leaves the mobile stack", () => {
|
||||
render(<MobileApp />);
|
||||
expect(window.location.search).toBe("");
|
||||
fireEvent.click(screen.getByText("open-ws-42"));
|
||||
expect(window.location.search).toBe("?m=detail&a=ws-42");
|
||||
fireEvent.click(screen.getByText("detail-open-chat"));
|
||||
expect(window.location.search).toBe("?m=chat&a=ws-42");
|
||||
});
|
||||
|
||||
it("popstate (back button) restores the previous route", () => {
|
||||
render(<MobileApp />);
|
||||
fireEvent.click(screen.getByText("open-ws-42"));
|
||||
fireEvent.click(screen.getByText("detail-open-chat"));
|
||||
// Simulate browser back: rewind URL ourselves, then dispatch popstate.
|
||||
window.history.replaceState(null, "", "/?m=detail&a=ws-42");
|
||||
fireEvent.popState(window);
|
||||
expect(visibleScreen()).toEqual(["detail:ws-42"]);
|
||||
});
|
||||
});
|
||||
@@ -1,101 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Node } from "@xyflow/react";
|
||||
|
||||
import { type WorkspaceNodeData } from "@/store/canvas";
|
||||
|
||||
import { classifyForFilter, toMobileAgent } from "../components";
|
||||
|
||||
const baseData: WorkspaceNodeData = {
|
||||
name: "test-agent",
|
||||
status: "online",
|
||||
tier: 2,
|
||||
agentCard: null,
|
||||
activeTasks: 0,
|
||||
collapsed: false,
|
||||
role: "",
|
||||
lastErrorRate: 0,
|
||||
lastSampleError: "",
|
||||
url: "",
|
||||
parentId: null,
|
||||
currentTask: "",
|
||||
runtime: "claude-code",
|
||||
needsRestart: false,
|
||||
budgetLimit: null,
|
||||
};
|
||||
|
||||
const makeNode = (overrides: Partial<WorkspaceNodeData> = {}, id = "ws-1"): Node<WorkspaceNodeData> => ({
|
||||
id,
|
||||
type: "workspaceNode",
|
||||
position: { x: 0, y: 0 },
|
||||
data: { ...baseData, ...overrides },
|
||||
});
|
||||
|
||||
describe("toMobileAgent", () => {
|
||||
it("maps name, status, tier, runtime through the design's 6-key palette", () => {
|
||||
const a = toMobileAgent(makeNode({ status: "online", tier: 3, runtime: "hermes" }));
|
||||
expect(a.name).toBe("test-agent");
|
||||
expect(a.status).toBe("online");
|
||||
expect(a.tier).toBe("T3");
|
||||
expect(a.runtime).toBe("hermes");
|
||||
expect(a.tag).toBe("hermes"); // tag mirrors runtime in v1
|
||||
});
|
||||
|
||||
it("flags 'external' runtime as remote (drives the ★ REMOTE badge)", () => {
|
||||
expect(toMobileAgent(makeNode({ runtime: "external" })).remote).toBe(true);
|
||||
expect(toMobileAgent(makeNode({ runtime: "claude-code" })).remote).toBe(false);
|
||||
});
|
||||
|
||||
it("falls back to 'unknown' runtime when both workspace + agentCard are blank", () => {
|
||||
const a = toMobileAgent(makeNode({ runtime: "" }));
|
||||
expect(a.runtime).toBe("unknown");
|
||||
expect(a.tag).toBe("unknown");
|
||||
});
|
||||
|
||||
it("uses workspace id as fallback name when name is missing", () => {
|
||||
const a = toMobileAgent(makeNode({ name: "" }, "ws-fallback"));
|
||||
expect(a.name).toBe("ws-fallback");
|
||||
});
|
||||
|
||||
it("preserves the parent link so MobileCanvas can draw parent→child edges", () => {
|
||||
const a = toMobileAgent(makeNode({ parentId: "ws-parent" }, "ws-child"));
|
||||
expect(a.parentId).toBe("ws-parent");
|
||||
});
|
||||
|
||||
it("maps platform 'provisioning' to design 'starting'", () => {
|
||||
expect(toMobileAgent(makeNode({ status: "provisioning" })).status).toBe("starting");
|
||||
});
|
||||
|
||||
it("counts skills from agentCard.skills array", () => {
|
||||
const a = toMobileAgent(
|
||||
makeNode({
|
||||
agentCard: {
|
||||
skills: [{ name: "skill-a" }, { name: "skill-b" }, { name: "skill-c" }],
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(a.skills).toBe(3);
|
||||
});
|
||||
|
||||
it("reports 0 skills when agentCard is null", () => {
|
||||
expect(toMobileAgent(makeNode({ agentCard: null })).skills).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("classifyForFilter", () => {
|
||||
it("buckets online statuses to the Online filter", () => {
|
||||
expect(classifyForFilter("online")).toBe("online");
|
||||
});
|
||||
|
||||
it("buckets failure-state statuses to the Issues filter", () => {
|
||||
// Issues = anything the user needs to look at NOW.
|
||||
expect(classifyForFilter("failed")).toBe("issue");
|
||||
expect(classifyForFilter("degraded")).toBe("issue");
|
||||
});
|
||||
|
||||
it("buckets non-online non-failure statuses to the Paused filter", () => {
|
||||
// Catch-all for transient or intentional offline states.
|
||||
expect(classifyForFilter("paused")).toBe("paused");
|
||||
expect(classifyForFilter("offline")).toBe("paused");
|
||||
expect(classifyForFilter("starting")).toBe("paused");
|
||||
});
|
||||
});
|
||||
@@ -1,68 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { MOL_DARK, MOL_LIGHT, getPalette, normalizeStatus, tierCode } from "../palette";
|
||||
|
||||
describe("normalizeStatus", () => {
|
||||
it("passes design-known statuses through verbatim", () => {
|
||||
expect(normalizeStatus("online")).toBe("online");
|
||||
expect(normalizeStatus("degraded")).toBe("degraded");
|
||||
expect(normalizeStatus("failed")).toBe("failed");
|
||||
expect(normalizeStatus("paused")).toBe("paused");
|
||||
expect(normalizeStatus("offline")).toBe("offline");
|
||||
});
|
||||
|
||||
it("maps platform 'provisioning' to design 'starting'", () => {
|
||||
// The platform's 14-state machine collapses to the design's 6 keys.
|
||||
// 'provisioning' (post-spawn boot) is the same UX bucket as 'starting'.
|
||||
expect(normalizeStatus("provisioning")).toBe("starting");
|
||||
expect(normalizeStatus("starting")).toBe("starting");
|
||||
});
|
||||
|
||||
it("maps unknown / null / empty to offline", () => {
|
||||
expect(normalizeStatus(undefined)).toBe("offline");
|
||||
expect(normalizeStatus(null)).toBe("offline");
|
||||
expect(normalizeStatus("")).toBe("offline");
|
||||
expect(normalizeStatus("garbage-status")).toBe("offline");
|
||||
});
|
||||
});
|
||||
|
||||
describe("tierCode", () => {
|
||||
it("maps numeric tiers to T-codes", () => {
|
||||
expect(tierCode(1)).toBe("T1");
|
||||
expect(tierCode(2)).toBe("T2");
|
||||
expect(tierCode(3)).toBe("T3");
|
||||
expect(tierCode(4)).toBe("T4");
|
||||
});
|
||||
|
||||
it("clamps below-1 to T1 (never below sandboxed)", () => {
|
||||
expect(tierCode(0)).toBe("T1");
|
||||
expect(tierCode(-5)).toBe("T1");
|
||||
});
|
||||
|
||||
it("clamps above-4 to T4 (never above full-access)", () => {
|
||||
expect(tierCode(5)).toBe("T4");
|
||||
expect(tierCode(99)).toBe("T4");
|
||||
});
|
||||
|
||||
it("falls back to T2 (Standard) on null/undefined", () => {
|
||||
// T2 is the platform default for fresh agents — matches the
|
||||
// CreateWorkspaceDialog default. Keeps the mobile spawn UX
|
||||
// consistent with the desktop when tier metadata is missing.
|
||||
expect(tierCode(undefined)).toBe("T2");
|
||||
expect(tierCode(null)).toBe("T2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPalette", () => {
|
||||
it("returns the light palette when dark is false", () => {
|
||||
expect(getPalette(false)).toBe(MOL_LIGHT);
|
||||
});
|
||||
|
||||
it("returns the dark palette when dark is true", () => {
|
||||
expect(getPalette(true)).toBe(MOL_DARK);
|
||||
});
|
||||
|
||||
it("light + dark palettes have the same key set (no drift)", () => {
|
||||
expect(Object.keys(MOL_LIGHT).sort()).toEqual(Object.keys(MOL_DARK).sort());
|
||||
});
|
||||
});
|
||||
@@ -1,444 +0,0 @@
|
||||
"use client";
|
||||
|
||||
// Screen-shared composites: TabBar, WorkspacePill, AgentCard, FilterChips.
|
||||
// Mirrors molecules-ai-mobile-app/project/screens-shared.jsx but reads
|
||||
// from the live canvas store rather than the prototype's mock AGENTS.
|
||||
|
||||
import type { Node } from "@xyflow/react";
|
||||
|
||||
import { type WorkspaceNodeData, summarizeWorkspaceCapabilities } from "@/store/canvas";
|
||||
|
||||
import {
|
||||
MOBILE_FONT_MONO,
|
||||
type MobilePalette,
|
||||
type MobileStatus,
|
||||
normalizeStatus,
|
||||
tierCode,
|
||||
usePalette,
|
||||
} from "./palette";
|
||||
import { Icons, StatusDot, TierChip } from "./primitives";
|
||||
|
||||
// Derived view-model the mobile screens consume. Built once per render
|
||||
// from the store's Node<WorkspaceNodeData>.
|
||||
export interface MobileAgent {
|
||||
id: string;
|
||||
name: string;
|
||||
tag: string;
|
||||
tier: "T1" | "T2" | "T3" | "T4";
|
||||
status: MobileStatus;
|
||||
remote: boolean;
|
||||
runtime: string;
|
||||
skills: number;
|
||||
calls: number;
|
||||
desc: string;
|
||||
parentId: string | null;
|
||||
}
|
||||
|
||||
export function toMobileAgent(node: Node<WorkspaceNodeData>): MobileAgent {
|
||||
const cap = summarizeWorkspaceCapabilities(node.data);
|
||||
const runtime = cap.runtime ?? "unknown";
|
||||
const remote = runtime === "external";
|
||||
return {
|
||||
id: node.id,
|
||||
name: node.data.name || node.id,
|
||||
tag: runtime,
|
||||
tier: tierCode(node.data.tier),
|
||||
status: normalizeStatus(node.data.status),
|
||||
remote,
|
||||
runtime,
|
||||
skills: cap.skillCount,
|
||||
calls: typeof node.data.activeTasks === "number" ? node.data.activeTasks : 0,
|
||||
desc: node.data.role || cap.currentTask || "",
|
||||
parentId: node.data.parentId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Tab bar ────────────────────────────────────────────────────
|
||||
export type MobileTabId = "agents" | "canvas" | "comms" | "me";
|
||||
|
||||
export function TabBar({
|
||||
active,
|
||||
onChange,
|
||||
dark,
|
||||
}: {
|
||||
active: MobileTabId;
|
||||
onChange: (id: MobileTabId) => void;
|
||||
dark: boolean;
|
||||
}) {
|
||||
const p = usePalette(dark);
|
||||
const tabs: { id: MobileTabId; label: string; icon: keyof typeof Icons }[] = [
|
||||
{ id: "agents", label: "Agents", icon: "list" },
|
||||
{ id: "canvas", label: "Canvas", icon: "graph" },
|
||||
{ id: "comms", label: "Comms", icon: "pulse" },
|
||||
{ id: "me", label: "Me", icon: "user" },
|
||||
];
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 14,
|
||||
right: 14,
|
||||
bottom: 16,
|
||||
height: 64,
|
||||
borderRadius: 26,
|
||||
zIndex: 30,
|
||||
background: dark ? "rgba(34,33,28,0.78)" : "rgba(255,253,247,0.82)",
|
||||
backdropFilter: "blur(24px) saturate(160%)",
|
||||
WebkitBackdropFilter: "blur(24px) saturate(160%)",
|
||||
border: `0.5px solid ${p.border}`,
|
||||
boxShadow: dark
|
||||
? "0 8px 28px rgba(0,0,0,0.4), inset 0 0.5px 0 rgba(255,255,255,0.05)"
|
||||
: "0 6px 20px rgba(40,30,20,0.07), 0 1px 0 rgba(255,255,255,0.6) inset",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-around",
|
||||
padding: "0 10px",
|
||||
}}
|
||||
>
|
||||
{tabs.map((t) => {
|
||||
const on = active === t.id;
|
||||
return (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
onClick={() => onChange(t.id)}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: 3,
|
||||
padding: "6px 10px",
|
||||
minWidth: 56,
|
||||
color: on ? p.accent : p.text3,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
width: 36,
|
||||
height: 28,
|
||||
borderRadius: 10,
|
||||
background: on ? `${p.accent}1a` : "transparent",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{Icons[t.icon]({ size: 18 })}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 10,
|
||||
letterSpacing: "0.02em",
|
||||
fontWeight: on ? 600 : 500,
|
||||
}}
|
||||
>
|
||||
{t.label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Workspace pill (header) ────────────────────────────────────
|
||||
export function WorkspacePill({
|
||||
dark,
|
||||
count,
|
||||
live = true,
|
||||
}: {
|
||||
dark: boolean;
|
||||
count: number | string;
|
||||
live?: boolean;
|
||||
}) {
|
||||
const p = usePalette(dark);
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 0,
|
||||
borderRadius: 999,
|
||||
padding: 4,
|
||||
background: dark ? "rgba(34,33,28,0.6)" : "rgba(255,255,255,0.7)",
|
||||
border: `0.5px solid ${p.border}`,
|
||||
backdropFilter: "blur(12px)",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
padding: "6px 12px 6px 8px",
|
||||
borderRight: `0.5px solid ${p.divider}`,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
width: 22,
|
||||
height: 22,
|
||||
borderRadius: 6,
|
||||
background: `linear-gradient(135deg, ${p.accent}, ${p.greenInk})`,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "white",
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
M
|
||||
</span>
|
||||
<span style={{ fontSize: 13.5, fontWeight: 600, color: p.text }}>Molecule AI</span>
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
padding: "6px 10px",
|
||||
fontFamily: MOBILE_FONT_MONO,
|
||||
fontSize: 11,
|
||||
color: p.text2,
|
||||
}}
|
||||
>
|
||||
<StatusDot status="online" size={6} dark={dark} />
|
||||
<span>{count}</span>
|
||||
</span>
|
||||
{live && (
|
||||
<span
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 5,
|
||||
padding: "6px 10px 6px 8px",
|
||||
fontSize: 11,
|
||||
color: p.greenInk,
|
||||
fontWeight: 600,
|
||||
fontFamily: MOBILE_FONT_MONO,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: 999,
|
||||
background: p.online,
|
||||
boxShadow: `0 0 0 3px ${p.online}26`,
|
||||
}}
|
||||
/>
|
||||
LIVE
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Agent row card ─────────────────────────────────────────────
|
||||
export function AgentCard({
|
||||
agent,
|
||||
dark,
|
||||
onClick,
|
||||
compact = false,
|
||||
}: {
|
||||
agent: MobileAgent;
|
||||
dark: boolean;
|
||||
onClick?: () => void;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const p = usePalette(dark);
|
||||
const isOnline = agent.status === "online";
|
||||
const isT4Soft = agent.tier === "T4" && isOnline;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
style={{
|
||||
display: "block",
|
||||
width: "100%",
|
||||
textAlign: "left",
|
||||
cursor: "pointer",
|
||||
background: isT4Soft ? p.t4SoftCard : isOnline ? p.greenSoft : p.surface,
|
||||
border: `0.5px solid ${p.border}`,
|
||||
borderRadius: 18,
|
||||
padding: compact ? "12px 14px" : "14px 16px",
|
||||
boxShadow: dark
|
||||
? "none"
|
||||
: "0 1px 0 rgba(255,255,255,0.5) inset, 0 1px 2px rgba(40,30,20,0.03)",
|
||||
transition: "transform .12s",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||
<StatusDot status={agent.status} size={9} dark={dark} />
|
||||
<span
|
||||
style={{
|
||||
flex: 1,
|
||||
fontSize: 16,
|
||||
fontWeight: 600,
|
||||
color: p.text,
|
||||
letterSpacing: "-0.01em",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{agent.name}
|
||||
</span>
|
||||
<TierChip tier={agent.tier} dark={dark} />
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
marginTop: 8,
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
{agent.remote && <RemoteBadge palette={p} />}
|
||||
<span
|
||||
style={{
|
||||
fontSize: 10.5,
|
||||
color: p.text3,
|
||||
fontFamily: MOBILE_FONT_MONO,
|
||||
letterSpacing: "0.02em",
|
||||
}}
|
||||
>
|
||||
{agent.tag}
|
||||
</span>
|
||||
</div>
|
||||
{!compact && agent.desc && (
|
||||
<p
|
||||
style={{
|
||||
margin: "8px 0 0",
|
||||
fontSize: 13,
|
||||
lineHeight: 1.45,
|
||||
color: p.text2,
|
||||
}}
|
||||
>
|
||||
{agent.desc}
|
||||
</p>
|
||||
)}
|
||||
{!compact && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 14,
|
||||
marginTop: 10,
|
||||
fontSize: 10.5,
|
||||
color: p.text3,
|
||||
fontFamily: MOBILE_FONT_MONO,
|
||||
}}
|
||||
>
|
||||
<span>SKILLS {agent.skills}</span>
|
||||
<span>CALLS {agent.calls}</span>
|
||||
<span style={{ marginLeft: "auto" }}>{agent.runtime.toUpperCase()}</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function RemoteBadge({ palette }: { palette: MobilePalette }) {
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
padding: "2px 7px",
|
||||
borderRadius: 4,
|
||||
background: palette.remoteBg,
|
||||
color: palette.remote,
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.04em",
|
||||
fontFamily: MOBILE_FONT_MONO,
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 3,
|
||||
}}
|
||||
>
|
||||
★ REMOTE
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Filter chips ───────────────────────────────────────────────
|
||||
export type AgentFilter = "all" | "online" | "issue" | "paused";
|
||||
|
||||
export function FilterChips({
|
||||
value,
|
||||
onChange,
|
||||
dark,
|
||||
counts,
|
||||
}: {
|
||||
value: AgentFilter;
|
||||
onChange: (v: AgentFilter) => void;
|
||||
dark: boolean;
|
||||
counts: { all: number; online: number; issue: number; paused: number };
|
||||
}) {
|
||||
const p = usePalette(dark);
|
||||
const opts: { id: AgentFilter; label: string; n: number }[] = [
|
||||
{ id: "all", label: "All", n: counts.all },
|
||||
{ id: "online", label: "Online", n: counts.online },
|
||||
{ id: "issue", label: "Issues", n: counts.issue },
|
||||
{ id: "paused", label: "Paused", n: counts.paused },
|
||||
];
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: 6,
|
||||
padding: "0 16px 10px",
|
||||
overflowX: "auto",
|
||||
scrollbarWidth: "none",
|
||||
}}
|
||||
>
|
||||
{opts.map((o) => {
|
||||
const on = value === o.id;
|
||||
return (
|
||||
<button
|
||||
key={o.id}
|
||||
type="button"
|
||||
onClick={() => onChange(o.id)}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
padding: "7px 12px",
|
||||
borderRadius: 999,
|
||||
cursor: "pointer",
|
||||
background: on ? p.text : dark ? "#22211c" : "#fff",
|
||||
color: on ? (dark ? p.bg : "#fff") : p.text,
|
||||
border: `0.5px solid ${on ? "transparent" : p.border}`,
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
whiteSpace: "nowrap",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{o.label}
|
||||
<span
|
||||
style={{
|
||||
fontSize: 10.5,
|
||||
opacity: 0.7,
|
||||
fontFamily: MOBILE_FONT_MONO,
|
||||
}}
|
||||
>
|
||||
{o.n}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function classifyForFilter(status: MobileStatus): AgentFilter {
|
||||
if (status === "online") return "online";
|
||||
if (status === "failed" || status === "degraded") return "issue";
|
||||
return "paused"; // starting / paused / offline
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
"use client";
|
||||
|
||||
// React context for accent overrides + the React-side `usePalette` hook.
|
||||
// Keeps the pure data (MOL_LIGHT/MOL_DARK) in palette.ts and the
|
||||
// pure-function `getPalette` available for tests; this file is the
|
||||
// React-only entry point so mobile components don't have to plumb
|
||||
// accent through props.
|
||||
|
||||
import { createContext, useContext, type ReactNode } from "react";
|
||||
|
||||
import { MOL_DARK, MOL_LIGHT, type MobilePalette } from "./palette";
|
||||
|
||||
const MobileAccentContext = createContext<string | null>(null);
|
||||
|
||||
export function MobileAccentProvider({
|
||||
accent,
|
||||
children,
|
||||
}: {
|
||||
accent: string | null;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return <MobileAccentContext.Provider value={accent}>{children}</MobileAccentContext.Provider>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook variant of palette resolution. Reads the user's accent override
|
||||
* from context and returns a fresh palette object with the override
|
||||
* applied. Critically, it never mutates the static MOL_LIGHT/MOL_DARK
|
||||
* singletons — that was the foot-gun the prior version had.
|
||||
*
|
||||
* Outside of a `<MobileAccentProvider>`, the context default of `null`
|
||||
* means we just return the static palette unchanged. That's the right
|
||||
* behaviour for tests + for any non-mobile caller that imports a token.
|
||||
*/
|
||||
export function usePalette(dark: boolean): MobilePalette {
|
||||
const accent = useContext(MobileAccentContext);
|
||||
const base = dark ? MOL_DARK : MOL_LIGHT;
|
||||
if (!accent || accent === base.accent) return base;
|
||||
return { ...base, accent, online: accent };
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
// Mobile design system tokens — verbatim from the Claude Design handoff
|
||||
// (molecules-ai-mobile-app/project/shared.jsx). Kept as an inline-style
|
||||
// palette object so screens can mirror the design 1:1; theming routes
|
||||
// through `usePalette(dark)` exactly like the prototype.
|
||||
|
||||
export interface MobilePalette {
|
||||
bg: string;
|
||||
surface: string;
|
||||
surface2: string;
|
||||
border: string;
|
||||
divider: string;
|
||||
text: string;
|
||||
text2: string;
|
||||
text3: string;
|
||||
|
||||
green: string;
|
||||
greenSoft: string;
|
||||
greenInk: string;
|
||||
|
||||
t1Bg: string; t1Ink: string; t1Br: string;
|
||||
t2Bg: string; t2Ink: string; t2Br: string;
|
||||
t3Bg: string; t3Ink: string; t3Br: string;
|
||||
t4Bg: string; t4Ink: string; t4Br: string;
|
||||
|
||||
t4SoftCard: string;
|
||||
|
||||
online: string;
|
||||
starting: string;
|
||||
degraded: string;
|
||||
failed: string;
|
||||
paused: string;
|
||||
offline: string;
|
||||
|
||||
remote: string;
|
||||
remoteBg: string;
|
||||
accent: string;
|
||||
}
|
||||
|
||||
export const MOL_LIGHT: MobilePalette = {
|
||||
bg: "#f6f4ef",
|
||||
surface: "#ffffff",
|
||||
surface2: "#fbf9f4",
|
||||
border: "rgba(40,30,20,0.08)",
|
||||
divider: "rgba(40,30,20,0.06)",
|
||||
text: "#29261b",
|
||||
text2: "rgba(41,38,27,0.62)",
|
||||
text3: "rgba(41,38,27,0.42)",
|
||||
|
||||
green: "#2f9e6a",
|
||||
greenSoft: "#d9ebe0",
|
||||
greenInk: "#1f6a47",
|
||||
|
||||
t1Bg: "#dde6f1", t1Ink: "#3a6aa3", t1Br: "#b9c8de",
|
||||
t2Bg: "#dbe5f4", t2Ink: "#2f5fb4", t2Br: "#b1c2e0",
|
||||
t3Bg: "#e3dcef", t3Ink: "#6a4ba1", t3Br: "#c8b9e1",
|
||||
t4Bg: "#f5dcc7", t4Ink: "#a8501d", t4Br: "#e8c6a4",
|
||||
|
||||
t4SoftCard: "#f9ece0",
|
||||
|
||||
online: "#2f9e6a",
|
||||
starting: "#e9b53b",
|
||||
degraded: "#d28a2a",
|
||||
failed: "#c8472a",
|
||||
paused: "#7a8696",
|
||||
offline: "#9aa0a6",
|
||||
|
||||
remote: "#7a4dd1",
|
||||
remoteBg: "#ede2ff",
|
||||
accent: "#2f9e6a",
|
||||
};
|
||||
|
||||
export const MOL_DARK: MobilePalette = {
|
||||
bg: "#15140f",
|
||||
surface: "#1d1c17",
|
||||
surface2: "#22211c",
|
||||
border: "rgba(255,250,240,0.08)",
|
||||
divider: "rgba(255,250,240,0.06)",
|
||||
text: "#f1eee5",
|
||||
text2: "rgba(241,238,229,0.6)",
|
||||
text3: "rgba(241,238,229,0.38)",
|
||||
|
||||
green: "#3eb37c",
|
||||
greenSoft: "#1f3a2c",
|
||||
greenInk: "#7fd3a8",
|
||||
|
||||
t1Bg: "#1a2230", t1Ink: "#7ea4d4", t1Br: "#2a3a52",
|
||||
t2Bg: "#1b2434", t2Ink: "#86a6e2", t2Br: "#2c3c58",
|
||||
t3Bg: "#251f33", t3Ink: "#b39be0", t3Br: "#3e3450",
|
||||
t4Bg: "#332316", t4Ink: "#e5a878", t4Br: "#553622",
|
||||
|
||||
t4SoftCard: "#2a1f17",
|
||||
|
||||
online: "#3eb37c",
|
||||
starting: "#e9b53b",
|
||||
degraded: "#d28a2a",
|
||||
failed: "#d65a3e",
|
||||
paused: "#8a96a6",
|
||||
offline: "#6a6a6a",
|
||||
|
||||
remote: "#a38aff",
|
||||
remoteBg: "#2a1f44",
|
||||
accent: "#3eb37c",
|
||||
};
|
||||
|
||||
/**
|
||||
* Pure-function variant of palette resolution. No React, no context,
|
||||
* no mutation — for tests and other non-component code.
|
||||
*
|
||||
* Components should import `usePalette` from `./palette-context` so the
|
||||
* user's accent override (held in context, not in module state) flows
|
||||
* through automatically. Re-exported below so the existing
|
||||
* `import { usePalette } from "./palette"` call sites keep working.
|
||||
*/
|
||||
export const getPalette = (dark: boolean): MobilePalette => (dark ? MOL_DARK : MOL_LIGHT);
|
||||
|
||||
// Back-compat re-export. Once we're confident nothing imports
|
||||
// `usePalette` from this file we can drop this line.
|
||||
export { usePalette } from "./palette-context";
|
||||
|
||||
// References the CSS variables that next/font/google emits in
|
||||
// app/layout.tsx. Falls through to system fonts if the variable is
|
||||
// undefined (e.g. in unit tests with no <body> font class).
|
||||
export const MOBILE_FONT_SANS = "var(--font-inter), 'Inter', ui-sans-serif, system-ui, sans-serif";
|
||||
export const MOBILE_FONT_MONO = "var(--font-jetbrains), 'JetBrains Mono', ui-monospace, monospace";
|
||||
|
||||
// Status keys we surface in the mobile UI. Anything else from the
|
||||
// platform falls back to "offline" tinting — the desktop has more
|
||||
// statuses ("provisioning", etc.) than the design's 6-key palette.
|
||||
export type MobileStatus =
|
||||
| "online" | "starting" | "degraded" | "failed" | "paused" | "offline";
|
||||
|
||||
export function normalizeStatus(s: string | undefined | null): MobileStatus {
|
||||
if (s === "online" || s === "degraded" || s === "failed" || s === "paused" || s === "offline") {
|
||||
return s;
|
||||
}
|
||||
if (s === "provisioning" || s === "starting") return "starting";
|
||||
return "offline";
|
||||
}
|
||||
|
||||
// Platform tier (number 1-4) → design tier code "T1".."T4"
|
||||
export function tierCode(tier: number | undefined | null): "T1" | "T2" | "T3" | "T4" {
|
||||
const n = typeof tier === "number" ? tier : 2;
|
||||
if (n <= 1) return "T1";
|
||||
if (n === 2) return "T2";
|
||||
if (n === 3) return "T3";
|
||||
return "T4";
|
||||
}
|
||||
@@ -1,278 +0,0 @@
|
||||
"use client";
|
||||
|
||||
// Mobile primitives — StatusDot, TierChip, Chip, Icons, SectionLabel.
|
||||
// Ports shared.jsx 1:1 from the design handoff; React + TypeScript flavor.
|
||||
|
||||
import type { CSSProperties, ReactNode, SVGProps } from "react";
|
||||
import {
|
||||
MOBILE_FONT_MONO,
|
||||
type MobilePalette,
|
||||
type MobileStatus,
|
||||
usePalette,
|
||||
} from "./palette";
|
||||
|
||||
type TierCode = "T1" | "T2" | "T3" | "T4";
|
||||
|
||||
export function StatusDot({
|
||||
status = "online",
|
||||
size = 8,
|
||||
dark = false,
|
||||
halo = true,
|
||||
}: {
|
||||
status?: MobileStatus;
|
||||
size?: number;
|
||||
dark?: boolean;
|
||||
halo?: boolean;
|
||||
}) {
|
||||
const p = usePalette(dark);
|
||||
const c: string = (p as unknown as Record<string, string>)[status] ?? p.online;
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: "inline-block",
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: 999,
|
||||
background: c,
|
||||
flexShrink: 0,
|
||||
boxShadow: halo ? `0 0 0 ${Math.max(2, size * 0.45)}px ${c}26` : "none",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function TierChip({
|
||||
tier = "T2",
|
||||
dark = false,
|
||||
size = "sm",
|
||||
}: {
|
||||
tier?: TierCode;
|
||||
dark?: boolean;
|
||||
size?: "sm" | "lg";
|
||||
}) {
|
||||
const p = usePalette(dark);
|
||||
const map: Record<TierCode, { bg: string; ink: string; br: string }> = {
|
||||
T1: { bg: p.t1Bg, ink: p.t1Ink, br: p.t1Br },
|
||||
T2: { bg: p.t2Bg, ink: p.t2Ink, br: p.t2Br },
|
||||
T3: { bg: p.t3Bg, ink: p.t3Ink, br: p.t3Br },
|
||||
T4: { bg: p.t4Bg, ink: p.t4Ink, br: p.t4Br },
|
||||
};
|
||||
const { bg, ink, br } = map[tier];
|
||||
const dim = size === "lg" ? { w: 32, h: 22, fs: 11 } : { w: 26, h: 19, fs: 10 };
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: dim.w,
|
||||
height: dim.h,
|
||||
borderRadius: 5,
|
||||
background: bg,
|
||||
color: ink,
|
||||
border: `0.5px solid ${br}`,
|
||||
fontFamily: MOBILE_FONT_MONO,
|
||||
fontSize: dim.fs,
|
||||
fontWeight: 600,
|
||||
letterSpacing: "0.02em",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{tier}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function Chip({
|
||||
label,
|
||||
value,
|
||||
accent,
|
||||
dark = false,
|
||||
soft = false,
|
||||
}: {
|
||||
label?: string;
|
||||
value: ReactNode;
|
||||
accent?: string;
|
||||
dark?: boolean;
|
||||
soft?: boolean;
|
||||
}) {
|
||||
const p = usePalette(dark);
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
padding: "4px 9px",
|
||||
borderRadius: 999,
|
||||
background: soft
|
||||
? `${accent ?? p.accent}1a`
|
||||
: dark
|
||||
? "#2a2823"
|
||||
: "#f0ede5",
|
||||
border: `0.5px solid ${dark ? "rgba(255,255,255,0.06)" : "rgba(0,0,0,0.05)"}`,
|
||||
fontSize: 11,
|
||||
fontFamily: MOBILE_FONT_MONO,
|
||||
color: p.text2,
|
||||
letterSpacing: "0.02em",
|
||||
}}
|
||||
>
|
||||
{label && (
|
||||
<span style={{ textTransform: "uppercase", fontSize: 9.5, opacity: 0.7 }}>{label}</span>
|
||||
)}
|
||||
<span style={{ color: accent ?? p.text, fontWeight: 600 }}>{value}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ── icons (stroke-based, 20×20 viewBox) ───────────────────────
|
||||
type IcoOpts = { stroke?: string; size?: number; fill?: string; sw?: number };
|
||||
const ico = (
|
||||
paths: ReactNode,
|
||||
{ stroke = "currentColor", size = 18, fill = "none", sw = 1.6 }: IcoOpts = {},
|
||||
) => {
|
||||
const props: SVGProps<SVGSVGElement> = {
|
||||
width: size,
|
||||
height: size,
|
||||
viewBox: "0 0 20 20",
|
||||
fill,
|
||||
stroke,
|
||||
strokeWidth: sw,
|
||||
strokeLinecap: "round",
|
||||
strokeLinejoin: "round",
|
||||
};
|
||||
return <svg {...props}>{paths}</svg>;
|
||||
};
|
||||
|
||||
export const Icons = {
|
||||
graph: (o?: IcoOpts) =>
|
||||
ico(
|
||||
<>
|
||||
<circle cx="5" cy="5" r="2" />
|
||||
<circle cx="15" cy="5" r="2" />
|
||||
<circle cx="10" cy="15" r="2" />
|
||||
<path d="M6.4 6.5l2.7 7M13.6 6.5l-2.7 7" />
|
||||
</>,
|
||||
o,
|
||||
),
|
||||
list: (o?: IcoOpts) =>
|
||||
ico(
|
||||
<>
|
||||
<path d="M6 5h10M6 10h10M6 15h10" />
|
||||
<circle cx="3.5" cy="5" r="0.6" fill="currentColor" />
|
||||
<circle cx="3.5" cy="10" r="0.6" fill="currentColor" />
|
||||
<circle cx="3.5" cy="15" r="0.6" fill="currentColor" />
|
||||
</>,
|
||||
o,
|
||||
),
|
||||
search: (o?: IcoOpts) =>
|
||||
ico(
|
||||
<>
|
||||
<circle cx="9" cy="9" r="5" />
|
||||
<path d="M13 13l4 4" />
|
||||
</>,
|
||||
o,
|
||||
),
|
||||
plus: (o?: IcoOpts) => ico(<path d="M10 4v12M4 10h12" />, o),
|
||||
bell: (o?: IcoOpts) =>
|
||||
ico(
|
||||
<>
|
||||
<path d="M5 8a5 5 0 0 1 10 0v4l1.5 2H3.5L5 12V8z" />
|
||||
<path d="M8.5 16a1.5 1.5 0 0 0 3 0" />
|
||||
</>,
|
||||
o,
|
||||
),
|
||||
chat: (o?: IcoOpts) =>
|
||||
ico(
|
||||
<path d="M4 5h12a1.5 1.5 0 0 1 1.5 1.5v6A1.5 1.5 0 0 1 16 14h-3l-3 3v-3H4a1.5 1.5 0 0 1-1.5-1.5v-6A1.5 1.5 0 0 1 4 5z" />,
|
||||
o,
|
||||
),
|
||||
send: (o?: IcoOpts) =>
|
||||
ico(<path d="M3 10l14-6-5 14-3-6-6-2z" fill="currentColor" />, { ...o, sw: 1 }),
|
||||
attach: (o?: IcoOpts) =>
|
||||
ico(
|
||||
<path d="M14 6.5L7.5 13a2.5 2.5 0 0 0 3.5 3.5l7-7a4 4 0 0 0-5.6-5.6L4.8 11A6 6 0 0 0 13.3 19.5" />,
|
||||
o,
|
||||
),
|
||||
back: (o?: IcoOpts) => ico(<path d="M12.5 4l-6 6 6 6" />, o),
|
||||
more: (o?: IcoOpts) =>
|
||||
ico(
|
||||
<>
|
||||
<circle cx="5" cy="10" r="1.2" fill="currentColor" />
|
||||
<circle cx="10" cy="10" r="1.2" fill="currentColor" />
|
||||
<circle cx="15" cy="10" r="1.2" fill="currentColor" />
|
||||
</>,
|
||||
o,
|
||||
),
|
||||
filter: (o?: IcoOpts) => ico(<path d="M3 5h14M5 10h10M8 15h4" />, o),
|
||||
user: (o?: IcoOpts) =>
|
||||
ico(
|
||||
<>
|
||||
<circle cx="10" cy="7" r="3" />
|
||||
<path d="M3.5 17a6.5 6.5 0 0 1 13 0" />
|
||||
</>,
|
||||
o,
|
||||
),
|
||||
settings: (o?: IcoOpts) =>
|
||||
ico(
|
||||
<>
|
||||
<circle cx="10" cy="10" r="2.2" />
|
||||
<path d="M10 2.5v2M10 15.5v2M2.5 10h2M15.5 10h2M4.7 4.7l1.4 1.4M13.9 13.9l1.4 1.4M4.7 15.3l1.4-1.4M13.9 6.1l1.4-1.4" />
|
||||
</>,
|
||||
o,
|
||||
),
|
||||
pulse: (o?: IcoOpts) => ico(<path d="M2 10h3l2-5 3 10 2-7 2 4 4-2" />, o),
|
||||
close: (o?: IcoOpts) => ico(<path d="M5 5l10 10M15 5L5 15" />, o),
|
||||
zap: (o?: IcoOpts) => ico(<path d="M11 2l-6 9h4l-1 7 6-9h-4l1-7z" />, o),
|
||||
check: (o?: IcoOpts) => ico(<path d="M4 10l4 4 8-9" />, o),
|
||||
swatch: (o?: IcoOpts) =>
|
||||
ico(
|
||||
<>
|
||||
<rect x="3" y="3" width="6" height="6" rx="1" />
|
||||
<rect x="11" y="3" width="6" height="6" rx="1" />
|
||||
<rect x="3" y="11" width="6" height="6" rx="1" />
|
||||
<circle cx="14" cy="14" r="3.2" />
|
||||
</>,
|
||||
o,
|
||||
),
|
||||
};
|
||||
|
||||
export function SectionLabel({
|
||||
children,
|
||||
dark = false,
|
||||
right,
|
||||
style,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
dark?: boolean;
|
||||
right?: ReactNode;
|
||||
style?: CSSProperties;
|
||||
}) {
|
||||
const p = usePalette(dark);
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "14px 20px 6px",
|
||||
fontFamily: MOBILE_FONT_MONO,
|
||||
fontSize: 10.5,
|
||||
letterSpacing: "0.12em",
|
||||
textTransform: "uppercase",
|
||||
color: p.text3,
|
||||
fontWeight: 600,
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<span>{children}</span>
|
||||
{right}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Convenience: avoid repeating the (palette, dark) plumbing in screens
|
||||
// that only need the palette object.
|
||||
export function withPalette<T>(dark: boolean, fn: (p: MobilePalette) => T): T {
|
||||
return fn(usePalette(dark));
|
||||
}
|
||||
@@ -1,216 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* FilesTab: NotAvailablePanel + FilesToolbar coverage.
|
||||
*
|
||||
* NotAvailablePanel: pure presentational component — renders a "feature not
|
||||
* available" placeholder for external-runtime workspaces.
|
||||
* FilesToolbar: pure props-driven component — directory selector, file count,
|
||||
* action buttons (New, Upload, Export, Clear, Refresh) with correct aria-labels.
|
||||
*
|
||||
* No @testing-library/jest-dom import — use textContent / className /
|
||||
* getAttribute checks to avoid "expect is not defined" errors.
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import React from "react";
|
||||
|
||||
import { FilesToolbar } from "../FilesToolbar";
|
||||
import { NotAvailablePanel } from "../NotAvailablePanel";
|
||||
|
||||
// ─── afterEach ─────────────────────────────────────────────────────────────────
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// ─── NotAvailablePanel ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("NotAvailablePanel", () => {
|
||||
it("renders heading 'Files not available'", () => {
|
||||
const { container } = render(<NotAvailablePanel runtime="external" />);
|
||||
expect(container.textContent).toContain("Files not available");
|
||||
});
|
||||
|
||||
it("renders the runtime name in monospace", () => {
|
||||
const { container } = render(<NotAvailablePanel runtime="external" />);
|
||||
expect(container.textContent).toContain("external");
|
||||
const spans = container.querySelectorAll("span");
|
||||
const monoSpans = Array.from(spans).filter(
|
||||
(s) => s.className && s.className.includes("font-mono"),
|
||||
);
|
||||
expect(monoSpans.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("renders a Chat tab hint in description", () => {
|
||||
const { container } = render(<NotAvailablePanel runtime="remote-agent" />);
|
||||
expect(container.textContent).toContain("Chat tab");
|
||||
});
|
||||
|
||||
it("SVG icon has aria-hidden=true", () => {
|
||||
const { container } = render(<NotAvailablePanel runtime="external" />);
|
||||
const svg = container.querySelector("svg");
|
||||
expect(svg?.getAttribute("aria-hidden")).toBe("true");
|
||||
});
|
||||
|
||||
it("renders without crashing for any runtime string", () => {
|
||||
const { container } = render(<NotAvailablePanel runtime="unknown-runtime" />);
|
||||
expect(container.textContent).toContain("unknown-runtime");
|
||||
});
|
||||
|
||||
it("applies the correct layout classes to root div", () => {
|
||||
const { container } = render(<NotAvailablePanel runtime="external" />);
|
||||
const root = container.firstElementChild as HTMLElement;
|
||||
expect(root.className).toContain("flex");
|
||||
expect(root.className).toContain("flex-col");
|
||||
expect(root.className).toContain("items-center");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── FilesToolbar ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe("FilesToolbar", () => {
|
||||
const noop = vi.fn();
|
||||
|
||||
function renderToolbar(props: Partial<React.ComponentProps<typeof FilesToolbar>> = {}) {
|
||||
return render(
|
||||
<FilesToolbar
|
||||
root="/configs"
|
||||
setRoot={noop}
|
||||
fileCount={0}
|
||||
onNewFile={noop}
|
||||
onUpload={noop}
|
||||
onDownloadAll={noop}
|
||||
onClearAll={noop}
|
||||
onRefresh={noop}
|
||||
{...props}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
it("renders the directory selector with correct aria-label", () => {
|
||||
const { container } = renderToolbar();
|
||||
const select = container.querySelector("select");
|
||||
expect(select?.getAttribute("aria-label")).toBe("File root directory");
|
||||
});
|
||||
|
||||
it("directory selector has all four options", () => {
|
||||
const { container } = renderToolbar();
|
||||
const select = container.querySelector("select") as HTMLSelectElement;
|
||||
const options = Array.from(select?.options ?? []);
|
||||
const values = options.map((o) => o.value);
|
||||
expect(values).toContain("/configs");
|
||||
expect(values).toContain("/home");
|
||||
expect(values).toContain("/workspace");
|
||||
expect(values).toContain("/plugins");
|
||||
});
|
||||
|
||||
it("calls setRoot when directory changes", () => {
|
||||
const setRoot = vi.fn();
|
||||
const { container } = renderToolbar({ setRoot });
|
||||
const select = container.querySelector("select") as HTMLSelectElement;
|
||||
select.value = "/home";
|
||||
select.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
expect(setRoot).toHaveBeenCalledWith("/home");
|
||||
});
|
||||
|
||||
it("displays the file count", () => {
|
||||
const { container } = renderToolbar({ fileCount: 42 });
|
||||
expect(container.textContent).toContain("42 files");
|
||||
});
|
||||
|
||||
it("shows New + Upload + Clear buttons for /configs", () => {
|
||||
const { container } = renderToolbar({ root: "/configs" });
|
||||
const texts = Array.from(container.querySelectorAll("button")).map(
|
||||
(b) => b.textContent?.trim(),
|
||||
);
|
||||
expect(texts).toContain("+ New");
|
||||
expect(texts).toContain("Upload");
|
||||
expect(texts).toContain("Clear");
|
||||
expect(texts).toContain("Export");
|
||||
expect(texts).toContain("↻");
|
||||
});
|
||||
|
||||
it("hides New + Upload + Clear for /workspace", () => {
|
||||
const { container } = renderToolbar({ root: "/workspace" });
|
||||
const texts = Array.from(container.querySelectorAll("button")).map(
|
||||
(b) => b.textContent?.trim(),
|
||||
);
|
||||
expect(texts).not.toContain("+ New");
|
||||
expect(texts).not.toContain("Upload");
|
||||
expect(texts).not.toContain("Clear");
|
||||
expect(texts).toContain("Export");
|
||||
});
|
||||
|
||||
it("hides New + Upload + Clear for /home", () => {
|
||||
const { container } = renderToolbar({ root: "/home" });
|
||||
const texts = Array.from(container.querySelectorAll("button")).map(
|
||||
(b) => b.textContent?.trim(),
|
||||
);
|
||||
expect(texts).not.toContain("+ New");
|
||||
expect(texts).not.toContain("Upload");
|
||||
expect(texts).not.toContain("Clear");
|
||||
});
|
||||
|
||||
it("hides New + Upload + Clear for /plugins", () => {
|
||||
const { container } = renderToolbar({ root: "/plugins" });
|
||||
const texts = Array.from(container.querySelectorAll("button")).map(
|
||||
(b) => b.textContent?.trim(),
|
||||
);
|
||||
expect(texts).not.toContain("+ New");
|
||||
expect(texts).not.toContain("Upload");
|
||||
expect(texts).not.toContain("Clear");
|
||||
});
|
||||
|
||||
it("New button has correct aria-label", () => {
|
||||
const { container } = renderToolbar({ root: "/configs" });
|
||||
const newBtn = container.querySelector('button[aria-label="Create new file"]');
|
||||
expect(newBtn?.textContent?.trim()).toBe("+ New");
|
||||
});
|
||||
|
||||
it("Export button has correct aria-label", () => {
|
||||
const { container } = renderToolbar();
|
||||
const exportBtn = container.querySelector('button[aria-label="Download all files"]');
|
||||
expect(exportBtn?.textContent?.trim()).toBe("Export");
|
||||
});
|
||||
|
||||
it("Clear button has correct aria-label", () => {
|
||||
const { container } = renderToolbar({ root: "/configs" });
|
||||
const clearBtn = container.querySelector('button[aria-label="Delete all files"]');
|
||||
expect(clearBtn?.textContent?.trim()).toBe("Clear");
|
||||
});
|
||||
|
||||
it("Refresh button has correct aria-label", () => {
|
||||
const { container } = renderToolbar();
|
||||
const refreshBtn = container.querySelector('button[aria-label="Refresh file list"]');
|
||||
expect(refreshBtn?.textContent?.trim()).toBe("↻");
|
||||
});
|
||||
|
||||
it("calls onNewFile when New button is clicked", () => {
|
||||
const onNewFile = vi.fn();
|
||||
const { container } = renderToolbar({ root: "/configs", onNewFile });
|
||||
container.querySelector('button[aria-label="Create new file"]')!.click();
|
||||
expect(onNewFile).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("calls onDownloadAll when Export button is clicked", () => {
|
||||
const onDownloadAll = vi.fn();
|
||||
const { container } = renderToolbar({ onDownloadAll });
|
||||
container.querySelector('button[aria-label="Download all files"]')!.click();
|
||||
expect(onDownloadAll).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("calls onClearAll when Clear button is clicked", () => {
|
||||
const onClearAll = vi.fn();
|
||||
const { container } = renderToolbar({ root: "/configs", onClearAll });
|
||||
container.querySelector('button[aria-label="Delete all files"]')!.click();
|
||||
expect(onClearAll).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("calls onRefresh when Refresh button is clicked", () => {
|
||||
const onRefresh = vi.fn();
|
||||
const { container } = renderToolbar({ onRefresh });
|
||||
container.querySelector('button[aria-label="Refresh file list"]')!.click();
|
||||
expect(onRefresh).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,349 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for FilesToolbar — the top-of-panel bar for the Files tab.
|
||||
* Covers: directory select, file count, New/Upload/Clear (configs-only),
|
||||
* Export, Refresh, and aria-labels.
|
||||
*/
|
||||
import React from "react";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { FilesToolbar } from "../FilesToolbar";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("FilesToolbar", () => {
|
||||
describe("renders base toolbar", () => {
|
||||
it("renders the directory select with aria-label", () => {
|
||||
render(
|
||||
<FilesToolbar
|
||||
root="/configs"
|
||||
setRoot={vi.fn()}
|
||||
fileCount={3}
|
||||
onNewFile={vi.fn()}
|
||||
onUpload={vi.fn()}
|
||||
onDownloadAll={vi.fn()}
|
||||
onClearAll={vi.fn()}
|
||||
onRefresh={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("combobox", { name: /file root directory/i })
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders the file count", () => {
|
||||
render(
|
||||
<FilesToolbar
|
||||
root="/configs"
|
||||
setRoot={vi.fn()}
|
||||
fileCount={7}
|
||||
onNewFile={vi.fn()}
|
||||
onUpload={vi.fn()}
|
||||
onDownloadAll={vi.fn()}
|
||||
onClearAll={vi.fn()}
|
||||
onRefresh={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("7 files")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders Export button", () => {
|
||||
render(
|
||||
<FilesToolbar
|
||||
root="/configs"
|
||||
setRoot={vi.fn()}
|
||||
fileCount={0}
|
||||
onNewFile={vi.fn()}
|
||||
onUpload={vi.fn()}
|
||||
onDownloadAll={vi.fn()}
|
||||
onClearAll={vi.fn()}
|
||||
onRefresh={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("button", { name: /download all files/i })
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders Refresh button", () => {
|
||||
render(
|
||||
<FilesToolbar
|
||||
root="/configs"
|
||||
setRoot={vi.fn()}
|
||||
fileCount={0}
|
||||
onNewFile={vi.fn()}
|
||||
onUpload={vi.fn()}
|
||||
onDownloadAll={vi.fn()}
|
||||
onClearAll={vi.fn()}
|
||||
onRefresh={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByRole("button", { name: /refresh file list/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders 0 files when count is 0", () => {
|
||||
render(
|
||||
<FilesToolbar
|
||||
root="/configs"
|
||||
setRoot={vi.fn()}
|
||||
fileCount={0}
|
||||
onNewFile={vi.fn()}
|
||||
onUpload={vi.fn()}
|
||||
onDownloadAll={vi.fn()}
|
||||
onClearAll={vi.fn()}
|
||||
onRefresh={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("0 files")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("configs-only buttons", () => {
|
||||
it("shows New and Upload buttons when root is /configs", () => {
|
||||
render(
|
||||
<FilesToolbar
|
||||
root="/configs"
|
||||
setRoot={vi.fn()}
|
||||
fileCount={3}
|
||||
onNewFile={vi.fn()}
|
||||
onUpload={vi.fn()}
|
||||
onDownloadAll={vi.fn()}
|
||||
onClearAll={vi.fn()}
|
||||
onRefresh={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("button", { name: /create new file/i })
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole("button", { name: /upload folder/i })
|
||||
).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: /delete all files/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("hides New and Upload when root is /workspace", () => {
|
||||
render(
|
||||
<FilesToolbar
|
||||
root="/workspace"
|
||||
setRoot={vi.fn()}
|
||||
fileCount={5}
|
||||
onNewFile={vi.fn()}
|
||||
onUpload={vi.fn()}
|
||||
onDownloadAll={vi.fn()}
|
||||
onClearAll={vi.fn()}
|
||||
onRefresh={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /create new file/i })
|
||||
).toBeNull();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /upload folder/i })
|
||||
).toBeNull();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /delete all files/i })
|
||||
).toBeNull();
|
||||
// Export and Refresh are still present
|
||||
expect(
|
||||
screen.getByRole("button", { name: /download all files/i })
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("hides New and Upload when root is /home", () => {
|
||||
render(
|
||||
<FilesToolbar
|
||||
root="/home"
|
||||
setRoot={vi.fn()}
|
||||
fileCount={2}
|
||||
onNewFile={vi.fn()}
|
||||
onUpload={vi.fn()}
|
||||
onDownloadAll={vi.fn()}
|
||||
onClearAll={vi.fn()}
|
||||
onRefresh={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /create new file/i })
|
||||
).toBeNull();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /upload folder/i })
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("hides New and Upload when root is /plugins", () => {
|
||||
render(
|
||||
<FilesToolbar
|
||||
root="/plugins"
|
||||
setRoot={vi.fn()}
|
||||
fileCount={1}
|
||||
onNewFile={vi.fn()}
|
||||
onUpload={vi.fn()}
|
||||
onDownloadAll={vi.fn()}
|
||||
onClearAll={vi.fn()}
|
||||
onRefresh={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /create new file/i })
|
||||
).toBeNull();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /upload folder/i })
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("callbacks", () => {
|
||||
it("calls setRoot when directory is changed", () => {
|
||||
const setRoot = vi.fn();
|
||||
render(
|
||||
<FilesToolbar
|
||||
root="/configs"
|
||||
setRoot={setRoot}
|
||||
fileCount={3}
|
||||
onNewFile={vi.fn()}
|
||||
onUpload={vi.fn()}
|
||||
onDownloadAll={vi.fn()}
|
||||
onClearAll={vi.fn()}
|
||||
onRefresh={vi.fn()}
|
||||
/>
|
||||
);
|
||||
fireEvent.change(screen.getByRole("combobox"), {
|
||||
target: { value: "/workspace" },
|
||||
});
|
||||
expect(setRoot).toHaveBeenCalledWith("/workspace");
|
||||
});
|
||||
|
||||
it("calls onNewFile when New button is clicked", () => {
|
||||
const onNewFile = vi.fn();
|
||||
render(
|
||||
<FilesToolbar
|
||||
root="/configs"
|
||||
setRoot={vi.fn()}
|
||||
fileCount={3}
|
||||
onNewFile={onNewFile}
|
||||
onUpload={vi.fn()}
|
||||
onDownloadAll={vi.fn()}
|
||||
onClearAll={vi.fn()}
|
||||
onRefresh={vi.fn()}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: /create new file/i }));
|
||||
expect(onNewFile).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("calls onDownloadAll when Export button is clicked", () => {
|
||||
const onDownloadAll = vi.fn();
|
||||
render(
|
||||
<FilesToolbar
|
||||
root="/workspace"
|
||||
setRoot={vi.fn()}
|
||||
fileCount={5}
|
||||
onNewFile={vi.fn()}
|
||||
onUpload={vi.fn()}
|
||||
onDownloadAll={onDownloadAll}
|
||||
onClearAll={vi.fn()}
|
||||
onRefresh={vi.fn()}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: /download all files/i }));
|
||||
expect(onDownloadAll).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("calls onClearAll when Clear button is clicked", () => {
|
||||
const onClearAll = vi.fn();
|
||||
render(
|
||||
<FilesToolbar
|
||||
root="/configs"
|
||||
setRoot={vi.fn()}
|
||||
fileCount={3}
|
||||
onNewFile={vi.fn()}
|
||||
onUpload={vi.fn()}
|
||||
onDownloadAll={vi.fn()}
|
||||
onClearAll={onClearAll}
|
||||
onRefresh={vi.fn()}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: /delete all files/i }));
|
||||
expect(onClearAll).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("calls onRefresh when Refresh button is clicked", () => {
|
||||
const onRefresh = vi.fn();
|
||||
render(
|
||||
<FilesToolbar
|
||||
root="/configs"
|
||||
setRoot={vi.fn()}
|
||||
fileCount={3}
|
||||
onNewFile={vi.fn()}
|
||||
onUpload={vi.fn()}
|
||||
onDownloadAll={vi.fn()}
|
||||
onClearAll={vi.fn()}
|
||||
onRefresh={onRefresh}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: /refresh file list/i }));
|
||||
expect(onRefresh).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("calls onUpload when the hidden file input changes", () => {
|
||||
const onUpload = vi.fn();
|
||||
render(
|
||||
<FilesToolbar
|
||||
root="/configs"
|
||||
setRoot={vi.fn()}
|
||||
fileCount={3}
|
||||
onNewFile={vi.fn()}
|
||||
onUpload={onUpload}
|
||||
onDownloadAll={vi.fn()}
|
||||
onClearAll={vi.fn()}
|
||||
onRefresh={vi.fn()}
|
||||
/>
|
||||
);
|
||||
// Find the hidden file input
|
||||
const fileInput = document.querySelector(
|
||||
'input[type="file"]'
|
||||
) as HTMLInputElement;
|
||||
expect(fileInput).toBeTruthy();
|
||||
expect(fileInput?.getAttribute("aria-label")).toBe("Upload folder files");
|
||||
});
|
||||
});
|
||||
|
||||
describe("a11y", () => {
|
||||
it("all buttons have aria-label or accessible name", () => {
|
||||
render(
|
||||
<FilesToolbar
|
||||
root="/configs"
|
||||
setRoot={vi.fn()}
|
||||
fileCount={3}
|
||||
onNewFile={vi.fn()}
|
||||
onUpload={vi.fn()}
|
||||
onDownloadAll={vi.fn()}
|
||||
onClearAll={vi.fn()}
|
||||
onRefresh={vi.fn()}
|
||||
/>
|
||||
);
|
||||
// All buttons should be findable by role
|
||||
const buttons = screen.getAllByRole("button");
|
||||
for (const btn of buttons) {
|
||||
expect(btn.getAttribute("aria-label") ?? btn.textContent).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it("directory select has aria-label", () => {
|
||||
render(
|
||||
<FilesToolbar
|
||||
root="/configs"
|
||||
setRoot={vi.fn()}
|
||||
fileCount={3}
|
||||
onNewFile={vi.fn()}
|
||||
onUpload={vi.fn()}
|
||||
onDownloadAll={vi.fn()}
|
||||
onClearAll={vi.fn()}
|
||||
onRefresh={vi.fn()}
|
||||
/>
|
||||
);
|
||||
const select = screen.getByRole("combobox");
|
||||
expect(select.getAttribute("aria-label")).toBe("File root directory");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,101 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for NotAvailablePanel — the full-tab placeholder shown when a
|
||||
* workspace's runtime doesn't own a platform-managed filesystem (today:
|
||||
* runtime === "external"). Covers rendering, a11y, and runtime prop
|
||||
* display.
|
||||
*/
|
||||
import React from "react";
|
||||
import { render, screen, cleanup } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { NotAvailablePanel } from "../NotAvailablePanel";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("NotAvailablePanel", () => {
|
||||
describe("renders", () => {
|
||||
it("renders the heading", () => {
|
||||
render(<NotAvailablePanel runtime="external" />);
|
||||
expect(screen.getByText("Files not available")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders the description text", () => {
|
||||
render(<NotAvailablePanel runtime="external" />);
|
||||
expect(
|
||||
screen.getByText(/whose filesystem isn't owned by the platform/i)
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("displays the runtime name in the description", () => {
|
||||
render(<NotAvailablePanel runtime="aws-lambda" />);
|
||||
// The runtime name appears inside the paragraph
|
||||
const para = screen.getByText(/whose filesystem isn't owned/i);
|
||||
expect(para.textContent).toContain("aws-lambda");
|
||||
});
|
||||
|
||||
it("renders the SVG folder icon with aria-hidden", () => {
|
||||
render(<NotAvailablePanel runtime="external" />);
|
||||
const svg = document.querySelector("svg");
|
||||
expect(svg).toBeTruthy();
|
||||
expect(svg?.getAttribute("aria-hidden")).toBe("true");
|
||||
});
|
||||
|
||||
it("uses the provided runtime prop verbatim", () => {
|
||||
render(<NotAvailablePanel runtime="cloud-run" />);
|
||||
const monoRuntime = document.querySelector(".font-mono");
|
||||
expect(monoRuntime?.textContent).toBe("cloud-run");
|
||||
});
|
||||
|
||||
it("renders the 'Use the Chat tab' guidance text", () => {
|
||||
render(<NotAvailablePanel runtime="external" />);
|
||||
expect(screen.getByText(/Use the Chat tab/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("is contained in a full-height flex column", () => {
|
||||
render(<NotAvailablePanel runtime="external" />);
|
||||
const container = screen.getByText("Files not available").closest("div");
|
||||
expect(container?.className).toContain("flex");
|
||||
expect(container?.className).toContain("flex-col");
|
||||
expect(container?.className).toContain("items-center");
|
||||
expect(container?.className).toContain("justify-center");
|
||||
expect(container?.className).toContain("h-full");
|
||||
});
|
||||
});
|
||||
|
||||
describe("a11y", () => {
|
||||
it("heading is an h3", () => {
|
||||
render(<NotAvailablePanel runtime="external" />);
|
||||
expect(screen.getByRole("heading", { level: 3 })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("SVG icon has aria-hidden so screen readers skip it", () => {
|
||||
render(<NotAvailablePanel runtime="external" />);
|
||||
const svg = document.querySelector("svg");
|
||||
expect(svg?.getAttribute("aria-hidden")).toBe("true");
|
||||
});
|
||||
|
||||
it("description paragraph is present with descriptive text", () => {
|
||||
render(<NotAvailablePanel runtime="external" />);
|
||||
const paras = document.querySelectorAll("p");
|
||||
expect(paras.length).toBeGreaterThan(0);
|
||||
const text = Array.from(paras)
|
||||
.map((p) => p.textContent)
|
||||
.join(" ");
|
||||
expect(text.toLowerCase()).toContain("runtime");
|
||||
});
|
||||
});
|
||||
|
||||
describe("props", () => {
|
||||
it("renders with a short runtime name", () => {
|
||||
render(<NotAvailablePanel runtime="ext" />);
|
||||
const monoRuntime = document.querySelector(".font-mono");
|
||||
expect(monoRuntime?.textContent).toBe("ext");
|
||||
});
|
||||
|
||||
it("renders with a complex runtime name", () => {
|
||||
render(<NotAvailablePanel runtime="gcp-cloud-functions-v2" />);
|
||||
const monoRuntime = document.querySelector(".font-mono");
|
||||
expect(monoRuntime?.textContent).toBe("gcp-cloud-functions-v2");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -28,7 +28,7 @@ const FILE_ICONS: Record<string, string> = {
|
||||
|
||||
export function getIcon(path: string, isDir: boolean): string {
|
||||
if (isDir) return "📁";
|
||||
const ext = "." + (path.split(".").pop() ?? "").toLowerCase();
|
||||
const ext = "." + path.split(".").pop();
|
||||
return FILE_ICONS[ext] || "📄";
|
||||
}
|
||||
|
||||
|
||||
@@ -1,323 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { render, screen, cleanup, fireEvent } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import { BudgetSection } from "../BudgetSection";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
// Queue-based mock for the api module. Each api call shifts from the queue.
|
||||
// Tests push with qGet/qPatch and the module-level mockImplementation
|
||||
// reads from the queue.
|
||||
type QueueEntry = { body?: unknown; err?: Error };
|
||||
const apiQueue: QueueEntry[] = [];
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: {
|
||||
get: vi.fn(async (_path: string) => {
|
||||
const next = apiQueue.shift();
|
||||
if (!next) throw new Error("api.get queue exhausted");
|
||||
if (next.err) throw next.err;
|
||||
return next.body;
|
||||
}),
|
||||
patch: vi.fn(async (_path: string, _body?: unknown) => {
|
||||
const next = apiQueue.shift();
|
||||
if (!next) throw new Error("api.patch queue exhausted");
|
||||
if (next.err) throw next.err;
|
||||
return next.body;
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
beforeEach(() => {
|
||||
apiQueue.length = 0;
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const WS_ID = "budget-test-ws";
|
||||
|
||||
function qGet(body: unknown) {
|
||||
apiQueue.push({ body });
|
||||
}
|
||||
|
||||
function qGetErr(status: number, msg: string) {
|
||||
apiQueue.push({ err: new Error(`${msg}: ${status}`) });
|
||||
}
|
||||
|
||||
function qPatch(body: unknown) {
|
||||
apiQueue.push({ body });
|
||||
}
|
||||
|
||||
function qPatchErr(status: number, msg: string) {
|
||||
apiQueue.push({ err: new Error(`${msg}: ${status}`) });
|
||||
}
|
||||
|
||||
function makeBudget(overrides: Partial<{
|
||||
budget_limit: number | null;
|
||||
budget_used: number;
|
||||
budget_remaining: number | null;
|
||||
}> = {}) {
|
||||
return {
|
||||
budget_limit: 10_000,
|
||||
budget_used: 3_500,
|
||||
budget_remaining: 6_500,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("BudgetSection", () => {
|
||||
describe("loading state", () => {
|
||||
it("shows loading indicator while fetching", async () => {
|
||||
let resolveGet: (v: unknown) => void;
|
||||
vi.mocked(api.get).mockImplementationOnce(
|
||||
async () => new Promise((r) => { resolveGet = r as (v: unknown) => void; }),
|
||||
);
|
||||
|
||||
render(<BudgetSection workspaceId={WS_ID} />);
|
||||
|
||||
expect(screen.getByTestId("budget-loading")).toBeTruthy();
|
||||
|
||||
resolveGet!(makeBudget());
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.queryByTestId("budget-loading")).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetch error state", () => {
|
||||
it("shows error message on non-402 fetch failure", async () => {
|
||||
qGetErr(500, "Internal Server Error");
|
||||
|
||||
render(<BudgetSection workspaceId={WS_ID} />);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByTestId("budget-fetch-error")).toBeTruthy();
|
||||
});
|
||||
expect(screen.getByTestId("budget-fetch-error")!.textContent).toContain("500");
|
||||
});
|
||||
|
||||
it("shows 402 as exceeded banner, not fetch error", async () => {
|
||||
qGetErr(402, "Payment Required");
|
||||
|
||||
render(<BudgetSection workspaceId={WS_ID} />);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByTestId("budget-exceeded-banner")).toBeTruthy();
|
||||
});
|
||||
expect(screen.queryByTestId("budget-fetch-error")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("budget loaded — display", () => {
|
||||
it("renders used / limit stats row", async () => {
|
||||
qGet(makeBudget({ budget_limit: 10_000, budget_used: 3_500 }));
|
||||
|
||||
render(<BudgetSection workspaceId={WS_ID} />);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByTestId("budget-used-value")!.textContent).toBe("3,500");
|
||||
});
|
||||
expect(screen.getByTestId("budget-limit-value")!.textContent).toBe("10,000");
|
||||
});
|
||||
|
||||
it("renders 'Unlimited' when budget_limit is null", async () => {
|
||||
qGet(makeBudget({ budget_limit: null, budget_used: 1_000, budget_remaining: null }));
|
||||
|
||||
render(<BudgetSection workspaceId={WS_ID} />);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByTestId("budget-limit-value")!.textContent).toBe("Unlimited");
|
||||
});
|
||||
});
|
||||
|
||||
it("renders remaining credits when present", async () => {
|
||||
qGet(makeBudget({ budget_limit: 10_000, budget_used: 3_500, budget_remaining: 6_500 }));
|
||||
|
||||
render(<BudgetSection workspaceId={WS_ID} />);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByTestId("budget-remaining")!.textContent).toContain("6,500");
|
||||
expect(screen.getByTestId("budget-remaining")!.textContent).toContain("credits remaining");
|
||||
});
|
||||
});
|
||||
|
||||
it("omits remaining credits when budget_remaining is null", async () => {
|
||||
qGet(makeBudget({ budget_limit: 10_000, budget_used: 3_500, budget_remaining: null }));
|
||||
|
||||
render(<BudgetSection workspaceId={WS_ID} />);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.queryByTestId("budget-remaining")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("caps progress bar at 100% when used > limit", async () => {
|
||||
qGet(makeBudget({ budget_limit: 10_000, budget_used: 12_000, budget_remaining: null }));
|
||||
|
||||
render(<BudgetSection workspaceId={WS_ID} />);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const fill = screen.getByTestId("budget-progress-fill");
|
||||
expect(fill.getAttribute("style")).toContain("100%");
|
||||
});
|
||||
});
|
||||
|
||||
it("omits progress bar when budget_limit is null (unlimited)", async () => {
|
||||
qGet(makeBudget({ budget_limit: null, budget_used: 5_000, budget_remaining: null }));
|
||||
|
||||
render(<BudgetSection workspaceId={WS_ID} />);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.queryByTestId("budget-progress-fill")).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("budget exceeded (402)", () => {
|
||||
it("shows exceeded banner when load returns 402", async () => {
|
||||
qGetErr(402, "Payment Required");
|
||||
|
||||
render(<BudgetSection workspaceId={WS_ID} />);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByTestId("budget-exceeded-banner")).toBeTruthy();
|
||||
expect(screen.getByTestId("budget-exceeded-banner")!.textContent).toContain("Budget exceeded");
|
||||
});
|
||||
});
|
||||
|
||||
it("clears exceeded banner after successful save", async () => {
|
||||
qGetErr(402, "Payment Required");
|
||||
qPatch(makeBudget({ budget_limit: 50_000, budget_used: 0, budget_remaining: 50_000 }));
|
||||
|
||||
render(<BudgetSection workspaceId={WS_ID} />);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByTestId("budget-exceeded-banner")).toBeTruthy();
|
||||
});
|
||||
|
||||
const input = screen.getByTestId("budget-limit-input");
|
||||
fireEvent.change(input, { target: { value: "50000" } });
|
||||
|
||||
const saveBtn = screen.getByTestId("budget-save-btn");
|
||||
fireEvent.click(saveBtn);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.queryByTestId("budget-exceeded-banner")).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("save flow", () => {
|
||||
it("shows save error on non-402 patch failure", async () => {
|
||||
qGet(makeBudget());
|
||||
qPatchErr(500, "Internal Server Error");
|
||||
|
||||
render(<BudgetSection workspaceId={WS_ID} />);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByTestId("budget-limit-input")).toBeTruthy();
|
||||
});
|
||||
|
||||
const saveBtn = screen.getByTestId("budget-save-btn");
|
||||
fireEvent.click(saveBtn);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByTestId("budget-save-error")).toBeTruthy();
|
||||
expect(screen.getByTestId("budget-save-error")!.textContent).toContain("500");
|
||||
});
|
||||
});
|
||||
|
||||
it("updates input to new limit value after successful save", async () => {
|
||||
qGet(makeBudget({ budget_limit: 10_000 }));
|
||||
qPatch(makeBudget({ budget_limit: 20_000 }));
|
||||
|
||||
render(<BudgetSection workspaceId={WS_ID} />);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.queryByTestId("budget-loading")).toBeNull();
|
||||
});
|
||||
|
||||
const input = screen.getByTestId("budget-limit-input") as HTMLInputElement;
|
||||
expect(input.value).toBe("10000");
|
||||
expect(screen.getByTestId("budget-limit-value")!.textContent).toBe("10,000");
|
||||
|
||||
fireEvent.change(input, { target: { value: "20000" } });
|
||||
expect(input.value).toBe("20000");
|
||||
|
||||
fireEvent.click(screen.getByTestId("budget-save-btn"));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect((screen.getByTestId("budget-limit-input") as HTMLInputElement).value).toBe("20000");
|
||||
});
|
||||
});
|
||||
|
||||
it("sends null when input is cleared (unlimited)", async () => {
|
||||
qGet(makeBudget({ budget_limit: 10_000 }));
|
||||
qPatch(makeBudget({ budget_limit: null }));
|
||||
|
||||
render(<BudgetSection workspaceId={WS_ID} />);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByTestId("budget-limit-input")).toBeTruthy();
|
||||
});
|
||||
|
||||
const input = screen.getByTestId("budget-limit-input") as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: "" } });
|
||||
fireEvent.click(screen.getByTestId("budget-save-btn"));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(input.value).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
it("shows saving state on button while patch is in flight", async () => {
|
||||
qGet(makeBudget());
|
||||
let resolvePatch: (v: unknown) => void;
|
||||
vi.mocked(api.patch).mockImplementationOnce(
|
||||
async () => new Promise((r) => { resolvePatch = r as (v: unknown) => void; }),
|
||||
);
|
||||
|
||||
render(<BudgetSection workspaceId={WS_ID} />);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByTestId("budget-limit-input")).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByTestId("budget-limit-input"), { target: { value: "50000" } });
|
||||
fireEvent.click(screen.getByTestId("budget-save-btn"));
|
||||
|
||||
const btn = screen.getByTestId("budget-save-btn");
|
||||
expect(btn.textContent).toContain("Saving");
|
||||
|
||||
resolvePatch!(makeBudget({ budget_limit: 50_000 }));
|
||||
await vi.waitFor(() => {
|
||||
expect(btn.textContent).toContain("Save");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("isApiError402 — regression coverage", () => {
|
||||
it("classifies ': 402' with space as 402", async () => {
|
||||
qGetErr(402, "Payment Required");
|
||||
qPatch(makeBudget());
|
||||
|
||||
render(<BudgetSection workspaceId={WS_ID} />);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByTestId("budget-exceeded-banner")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("classifies non-402 error messages as regular fetch errors", async () => {
|
||||
qGetErr(503, "Service Unavailable");
|
||||
|
||||
render(<BudgetSection workspaceId={WS_ID} />);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByTestId("budget-fetch-error")).toBeTruthy();
|
||||
});
|
||||
expect(screen.queryByTestId("budget-exceeded-banner")).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,726 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* MemoryTab — 42 test cases covering awareness dashboard, KV memory CRUD,
|
||||
* and error states.
|
||||
*
|
||||
* Issue #519: Add 42 test cases for MemoryTab (42 cases).
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
fireEvent,
|
||||
cleanup,
|
||||
act,
|
||||
} from "@testing-library/react";
|
||||
import React from "react";
|
||||
|
||||
// ── Module-level mocks ────────────────────────────────────────────────────────
|
||||
// Mock @/lib/env before MemoryTab loads so it sees the stub values.
|
||||
vi.mock("@/lib/env", () => ({
|
||||
NEXT_PUBLIC_AWARENESS_URL: "http://localhost:37800",
|
||||
}));
|
||||
|
||||
// Mock @/lib/api at module level. vi.hoisted() captures the mock function
|
||||
// references so they are accessible in the test scope after hoisting.
|
||||
const _mockGet = vi.hoisted(() => vi.fn<() => Promise<unknown[]>>());
|
||||
const _mockPost = vi.hoisted(() => vi.fn<() => Promise<unknown>>());
|
||||
const _mockDel = vi.hoisted(() => vi.fn<() => Promise<unknown>>());
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: {
|
||||
get: _mockGet,
|
||||
post: _mockPost,
|
||||
del: _mockDel,
|
||||
},
|
||||
}));
|
||||
|
||||
// Stub window.open so tests don't actually open a window.
|
||||
const _windowOpen = vi.fn();
|
||||
vi.stubGlobal("window", {
|
||||
...window,
|
||||
open: _windowOpen,
|
||||
});
|
||||
|
||||
import { MemoryTab } from "../MemoryTab";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
const WS_ID = "ws-test-123";
|
||||
|
||||
const MEMORY_ENTRY: Record<string, unknown> = {
|
||||
key: "user-preference",
|
||||
value: { theme: "dark", language: "en" },
|
||||
version: 1,
|
||||
expires_at: null,
|
||||
updated_at: "2026-04-15T10:00:00Z",
|
||||
};
|
||||
|
||||
const MEMORY_ENTRY_WITH_TTL: Record<string, unknown> = {
|
||||
key: "session-token",
|
||||
value: "abc123",
|
||||
version: 3,
|
||||
expires_at: new Date(Date.now() + 86_400_000).toISOString(),
|
||||
updated_at: "2026-04-15T11:00:00Z",
|
||||
};
|
||||
|
||||
const MEMORY_ENTRY_RAW_STRING: Record<string, unknown> = {
|
||||
key: "plain-text",
|
||||
value: "hello world",
|
||||
version: 1,
|
||||
expires_at: null,
|
||||
updated_at: "2026-04-15T12:00:00Z",
|
||||
};
|
||||
|
||||
// ── Setup / teardown ────────────────────────────────────────────────────────
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset all api mock functions to a clean default state between tests.
|
||||
_mockGet.mockReset();
|
||||
_mockGet.mockResolvedValue([] as unknown[]);
|
||||
_mockPost.mockReset();
|
||||
_mockPost.mockResolvedValue({} as unknown);
|
||||
_mockDel.mockReset();
|
||||
_mockDel.mockResolvedValue({} as unknown);
|
||||
_windowOpen.mockClear();
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
// ── Shared helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Render MemoryTab and reveal the entries list by clicking "Show".
|
||||
* The component starts with showAdvanced=false (hidden mode); most entry-list
|
||||
* tests need to click Show before entries appear.
|
||||
*
|
||||
* Uses fireEvent.click directly on the button element (not the text span) to
|
||||
* ensure React's onClick fires correctly.
|
||||
*/
|
||||
async function renderAndShowEntries() {
|
||||
render(<MemoryTab workspaceId={WS_ID} />);
|
||||
// Wait for the api.get mock to resolve and React to render with entries.
|
||||
// 500ms gives enough time for useEffect → setEntries → re-render.
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
fireEvent.click(screen.getByRole("button", { name: /show/i }));
|
||||
}
|
||||
|
||||
/** Configure api.get to resolve with the given entries.
|
||||
* Must be called BEFORE render() so the useEffect sees the mock. */
|
||||
function stubMemoryFetch(entries: unknown[]) {
|
||||
_mockGet.mockReset();
|
||||
_mockGet.mockResolvedValue(entries as unknown[]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the memory entry button to expand it.
|
||||
* Uses filter-on-all-buttons to avoid getByRole's strict accessible-name
|
||||
* matching (which can silently find the wrong element in dense DOM trees).
|
||||
*/
|
||||
function expandEntry(key: string) {
|
||||
const allBtns = screen.getAllByRole("button");
|
||||
const entryBtn = allBtns.find((b) => b.textContent?.includes(key));
|
||||
if (!entryBtn) throw new Error(`expandEntry: no button found containing "${key}"`);
|
||||
act(() => { fireEvent.click(entryBtn); });
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Awareness dashboard
|
||||
// =============================================================================
|
||||
|
||||
describe("MemoryTab — awareness dashboard", () => {
|
||||
it("shows awareness section on load", async () => {
|
||||
stubMemoryFetch([]);
|
||||
render(<MemoryTab workspaceId={WS_ID} />);
|
||||
expect(await screen.findByText("Awareness dashboard")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders iframe with correct src containing workspaceId", async () => {
|
||||
stubMemoryFetch([]);
|
||||
render(<MemoryTab workspaceId={WS_ID} />);
|
||||
const iframe = (await screen.findByTitle(
|
||||
"Awareness dashboard",
|
||||
)) as HTMLIFrameElement;
|
||||
expect(iframe.src).toContain("workspaceId=" + WS_ID);
|
||||
});
|
||||
|
||||
it("collapse button hides iframe and shows collapsed state", async () => {
|
||||
stubMemoryFetch([]);
|
||||
render(<MemoryTab workspaceId={WS_ID} />);
|
||||
expect(await screen.findByTitle("Awareness dashboard")).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: /collapse/i }));
|
||||
expect(
|
||||
await screen.findByText(/awareness dashboard is collapsed/i),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByTitle("Awareness dashboard")).toBeNull();
|
||||
});
|
||||
|
||||
it("collapsed state has expand button that re-shows iframe", async () => {
|
||||
stubMemoryFetch([]);
|
||||
render(<MemoryTab workspaceId={WS_ID} />);
|
||||
expect(await screen.findByRole("button", { name: /collapse/i })).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: /collapse/i }));
|
||||
// After collapse there are two "Expand" buttons (header + collapsed banner).
|
||||
// Click the one inside the collapsed banner (last in DOM order).
|
||||
const expandBtns = await screen.findAllByRole("button", { name: /^expand$/i });
|
||||
fireEvent.click(expandBtns[expandBtns.length - 1]);
|
||||
expect(await screen.findByTitle("Awareness dashboard")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("open button calls window.open with awarenessUrl", async () => {
|
||||
stubMemoryFetch([]);
|
||||
render(<MemoryTab workspaceId={WS_ID} />);
|
||||
expect(await screen.findByRole("button", { name: /open/i })).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: /open/i }));
|
||||
expect(_windowOpen).toHaveBeenCalledWith(
|
||||
expect.stringContaining("workspaceId=" + WS_ID),
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders awareness status grid with Connected / Mode / Workspace", async () => {
|
||||
stubMemoryFetch([]);
|
||||
render(<MemoryTab workspaceId={WS_ID} />);
|
||||
expect(await screen.findByText("Connected")).toBeTruthy();
|
||||
expect(await screen.findByText("Workspace")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Loading state
|
||||
// =============================================================================
|
||||
|
||||
describe("MemoryTab — loading state", () => {
|
||||
it("shows 'Loading memory...' while initial fetch is pending", () => {
|
||||
_mockGet.mockReturnValue(new Promise(() => {}) as unknown as Promise<unknown[]>);
|
||||
render(<MemoryTab workspaceId={WS_ID} />);
|
||||
expect(screen.getByText("Loading memory...")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does not render memory section while loading", () => {
|
||||
_mockGet.mockReturnValue(new Promise(() => {}) as unknown as Promise<unknown[]>);
|
||||
render(<MemoryTab workspaceId={WS_ID} />);
|
||||
expect(screen.queryByText("Workspace KV memory")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// KV memory — initial load
|
||||
// =============================================================================
|
||||
|
||||
describe("MemoryTab — initial load", () => {
|
||||
it("fetches memory entries on mount", async () => {
|
||||
stubMemoryFetch([]);
|
||||
render(<MemoryTab workspaceId={WS_ID} />);
|
||||
// Reveal the entries list
|
||||
expect(await screen.findByRole("button", { name: /show/i })).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: /show/i }));
|
||||
expect(await screen.findByText("Workspace KV memory")).toBeTruthy();
|
||||
expect(api.get).toHaveBeenCalledWith(`/workspaces/${WS_ID}/memory`);
|
||||
});
|
||||
|
||||
it("renders workspace KV memory section heading", async () => {
|
||||
stubMemoryFetch([]);
|
||||
render(<MemoryTab workspaceId={WS_ID} />);
|
||||
// Heading is visible in hidden mode (above the hidden banner)
|
||||
expect(await screen.findByText("Workspace KV memory")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows advanced mode by default hidden; Refresh / Advanced / + Add buttons visible", async () => {
|
||||
stubMemoryFetch([]);
|
||||
render(<MemoryTab workspaceId={WS_ID} />);
|
||||
// Hidden-mode banner is visible with a Show button
|
||||
expect(
|
||||
await screen.findByText("Advanced workspace memory is hidden"),
|
||||
).toBeTruthy();
|
||||
expect(await screen.findByRole("button", { name: /show/i })).toBeTruthy();
|
||||
// Action buttons are still visible in the header
|
||||
expect(await screen.findByRole("button", { name: /refresh/i })).toBeTruthy();
|
||||
expect(await screen.findByRole("button", { name: /advanced/i })).toBeTruthy();
|
||||
expect(await screen.findByRole("button", { name: /\+ add/i })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// KV memory — empty state
|
||||
// =============================================================================
|
||||
|
||||
describe("MemoryTab — empty state", () => {
|
||||
it("shows 'No memory entries' when entries array is empty (after Show)", async () => {
|
||||
stubMemoryFetch([]);
|
||||
render(<MemoryTab workspaceId={WS_ID} />);
|
||||
// Click Show to reveal entries list (advanced mode is hidden by default)
|
||||
fireEvent.click(await screen.findByRole("button", { name: /show/i }));
|
||||
expect(await screen.findByText("No memory entries")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("hidden mode shows 'Advanced workspace memory is hidden' message", async () => {
|
||||
stubMemoryFetch([]);
|
||||
render(<MemoryTab workspaceId={WS_ID} />);
|
||||
expect(
|
||||
await screen.findByText("Advanced workspace memory is hidden"),
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// KV memory — list rendering
|
||||
// =============================================================================
|
||||
|
||||
describe("MemoryTab — list rendering", () => {
|
||||
it("renders a memory entry key in accent/mono text", async () => {
|
||||
stubMemoryFetch([MEMORY_ENTRY]);
|
||||
await renderAndShowEntries();
|
||||
expect(await screen.findByText("user-preference")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("expands an entry on click showing the value as pretty JSON", async () => {
|
||||
stubMemoryFetch([MEMORY_ENTRY]);
|
||||
await renderAndShowEntries();
|
||||
expect(await screen.findByText("user-preference")).toBeTruthy();
|
||||
expandEntry("user-preference");
|
||||
expect(
|
||||
await screen.findByText(/"theme":\s*"dark".*?"language":\s*"en"/),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows raw string value without extra quotes when value is plain string", async () => {
|
||||
stubMemoryFetch([MEMORY_ENTRY_RAW_STRING]);
|
||||
await renderAndShowEntries();
|
||||
expect(await screen.findByText("plain-text")).toBeTruthy();
|
||||
expandEntry("plain-text");
|
||||
expect(await screen.findByText(/"hello world"/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders updated_at timestamp when entry is expanded", async () => {
|
||||
stubMemoryFetch([MEMORY_ENTRY]);
|
||||
await renderAndShowEntries();
|
||||
expect(await screen.findByText("user-preference")).toBeTruthy();
|
||||
expandEntry("user-preference");
|
||||
expect(await screen.findByText(/updated:/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows TTL badge when entry has expires_at", async () => {
|
||||
stubMemoryFetch([MEMORY_ENTRY_WITH_TTL]);
|
||||
await renderAndShowEntries();
|
||||
expect(await screen.findByText("session-token")).toBeTruthy();
|
||||
expandEntry("session-token");
|
||||
expect(await screen.findByText(/ttl/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("collapse toggle hides the expanded content", async () => {
|
||||
stubMemoryFetch([MEMORY_ENTRY]);
|
||||
await renderAndShowEntries();
|
||||
expect(await screen.findByText("user-preference")).toBeTruthy();
|
||||
expandEntry("user-preference");
|
||||
expect(await screen.findByText(/Updated:/i)).toBeTruthy();
|
||||
expandEntry("user-preference");
|
||||
expect(screen.queryByText(/Updated:/i)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// KV memory — advanced mode toggle
|
||||
// =============================================================================
|
||||
|
||||
describe("MemoryTab — advanced mode toggle", () => {
|
||||
it("clicking Advanced hides the list and shows 'hidden' placeholder", async () => {
|
||||
stubMemoryFetch([MEMORY_ENTRY]);
|
||||
await renderAndShowEntries();
|
||||
expect(await screen.findByText("user-preference")).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: /advanced/i }));
|
||||
expect(
|
||||
await screen.findByText("Advanced workspace memory is hidden"),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByText("user-preference")).toBeNull();
|
||||
});
|
||||
|
||||
it("clicking Show from hidden mode re-displays the list", async () => {
|
||||
stubMemoryFetch([MEMORY_ENTRY]);
|
||||
await renderAndShowEntries();
|
||||
expect(await screen.findByText("user-preference")).toBeTruthy();
|
||||
// Hide via Advanced button
|
||||
fireEvent.click(screen.getByRole("button", { name: /advanced/i }));
|
||||
expect(await screen.findByText("Advanced workspace memory is hidden")).toBeTruthy();
|
||||
// Reveal again
|
||||
fireEvent.click(screen.getByRole("button", { name: /show/i }));
|
||||
expect(await screen.findByText("user-preference")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("Hide Advanced button appears when in hidden mode", async () => {
|
||||
stubMemoryFetch([MEMORY_ENTRY]);
|
||||
await renderAndShowEntries();
|
||||
expect(await screen.findByText("user-preference")).toBeTruthy();
|
||||
// renderAndShowEntries sets showAdvanced=true, so button says "Hide Advanced".
|
||||
// Click "Hide Advanced" to toggle back to hidden mode.
|
||||
fireEvent.click(screen.getByRole("button", { name: /hide advanced/i }));
|
||||
expect(
|
||||
await screen.findByText("Advanced workspace memory is hidden"),
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// KV memory — Add entry
|
||||
// =============================================================================
|
||||
|
||||
describe("MemoryTab — add entry", () => {
|
||||
it("clicking + Add shows the add form", async () => {
|
||||
stubMemoryFetch([]);
|
||||
render(<MemoryTab workspaceId={WS_ID} />);
|
||||
expect(await screen.findByRole("button", { name: /\+ add/i })).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: /\+ add/i }));
|
||||
expect(await screen.findByLabelText("Memory key")).toBeTruthy();
|
||||
expect(await screen.findByLabelText(/memory value/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("add form requires a non-empty key", async () => {
|
||||
stubMemoryFetch([]);
|
||||
render(<MemoryTab workspaceId={WS_ID} />);
|
||||
expect(await screen.findByRole("button", { name: /\+ add/i })).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: /\+ add/i }));
|
||||
expect(await screen.findByLabelText("Memory key")).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: /save/i }));
|
||||
expect(await screen.findByText("Key is required")).toBeTruthy();
|
||||
expect(api.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("add form parses plain text value as-is (not JSON)", async () => {
|
||||
stubMemoryFetch([]);
|
||||
_mockPost.mockResolvedValueOnce({} as unknown as Promise<unknown>);
|
||||
render(<MemoryTab workspaceId={WS_ID} />);
|
||||
expect(await screen.findByRole("button", { name: /\+ add/i })).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: /\+ add/i }));
|
||||
expect(await screen.findByLabelText("Memory key")).toBeTruthy();
|
||||
fireEvent.change(screen.getByLabelText("Memory key"), {
|
||||
target: { value: "my-key" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(/memory value/i), {
|
||||
target: { value: "plain text value" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: /save/i }));
|
||||
expect(api.post).toHaveBeenCalledWith(
|
||||
`/workspaces/${WS_ID}/memory`,
|
||||
expect.objectContaining({ key: "my-key", value: "plain text value" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("add form parses JSON value when valid JSON is entered", async () => {
|
||||
stubMemoryFetch([]);
|
||||
_mockPost.mockResolvedValueOnce({} as unknown as Promise<unknown>);
|
||||
render(<MemoryTab workspaceId={WS_ID} />);
|
||||
expect(await screen.findByRole("button", { name: /\+ add/i })).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: /\+ add/i }));
|
||||
expect(await screen.findByLabelText("Memory key")).toBeTruthy();
|
||||
fireEvent.change(screen.getByLabelText("Memory key"), {
|
||||
target: { value: "json-key" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(/memory value/i), {
|
||||
target: { value: '{"foo": 123}' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: /save/i }));
|
||||
expect(api.post).toHaveBeenCalledWith(
|
||||
`/workspaces/${WS_ID}/memory`,
|
||||
expect.objectContaining({ key: "json-key", value: { foo: 123 } }),
|
||||
);
|
||||
});
|
||||
|
||||
it("add form accepts optional TTL", async () => {
|
||||
stubMemoryFetch([]);
|
||||
_mockPost.mockResolvedValueOnce({} as unknown as Promise<unknown>);
|
||||
render(<MemoryTab workspaceId={WS_ID} />);
|
||||
expect(await screen.findByRole("button", { name: /\+ add/i })).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: /\+ add/i }));
|
||||
// aria-label is "TTL in seconds (optional)"
|
||||
expect(await screen.findByLabelText("TTL in seconds (optional)")).toBeTruthy();
|
||||
fireEvent.change(screen.getByLabelText("Memory key"), {
|
||||
target: { value: "ttl-key" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(/memory value/i), {
|
||||
target: { value: "val" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("TTL in seconds (optional)"), {
|
||||
target: { value: "3600" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: /save/i }));
|
||||
expect(api.post).toHaveBeenCalledWith(
|
||||
`/workspaces/${WS_ID}/memory`,
|
||||
expect.objectContaining({
|
||||
key: "ttl-key",
|
||||
value: "val",
|
||||
ttl_seconds: 3600,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("successful add clears the form and closes it", async () => {
|
||||
stubMemoryFetch([]);
|
||||
_mockPost.mockResolvedValueOnce({} as unknown as Promise<unknown>);
|
||||
render(<MemoryTab workspaceId={WS_ID} />);
|
||||
expect(await screen.findByRole("button", { name: /\+ add/i })).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: /\+ add/i }));
|
||||
expect(await screen.findByLabelText("Memory key")).toBeTruthy();
|
||||
fireEvent.change(screen.getByLabelText("Memory key"), {
|
||||
target: { value: "new-key" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(/memory value/i), {
|
||||
target: { value: "new-val" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: /save/i }));
|
||||
// Form should close
|
||||
expect(await screen.findByRole("button", { name: /\+ add/i })).toBeTruthy();
|
||||
expect(screen.queryByLabelText("Memory key")).toBeNull();
|
||||
});
|
||||
|
||||
it("add failure shows error in the add form", async () => {
|
||||
stubMemoryFetch([]);
|
||||
_mockPost.mockRejectedValueOnce(new Error("server error"));
|
||||
render(<MemoryTab workspaceId={WS_ID} />);
|
||||
expect(await screen.findByRole("button", { name: /\+ add/i })).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: /\+ add/i }));
|
||||
expect(await screen.findByLabelText("Memory key")).toBeTruthy();
|
||||
fireEvent.change(screen.getByLabelText("Memory key"), {
|
||||
target: { value: "bad-key" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(/memory value/i), {
|
||||
target: { value: "val" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: /save/i }));
|
||||
expect(await screen.findByText("server error")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("cancel button closes the add form without posting", async () => {
|
||||
stubMemoryFetch([]);
|
||||
render(<MemoryTab workspaceId={WS_ID} />);
|
||||
expect(await screen.findByRole("button", { name: /\+ add/i })).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: /\+ add/i }));
|
||||
expect(await screen.findByLabelText("Memory key")).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: /cancel/i }));
|
||||
expect(screen.queryByLabelText("Memory key")).toBeNull();
|
||||
expect(api.post).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// KV memory — Edit entry
|
||||
// =============================================================================
|
||||
|
||||
describe("MemoryTab — edit entry", () => {
|
||||
// TEMP inline debug
|
||||
it("DEBUG check expandEntry via expandEntry function", async () => {
|
||||
stubMemoryFetch([MEMORY_ENTRY]);
|
||||
await renderAndShowEntries();
|
||||
expect(await screen.findByText("user-preference")).toBeTruthy();
|
||||
|
||||
const btns = screen.getAllByRole("button");
|
||||
console.log("All button texts:", btns.map(b => b.textContent));
|
||||
const match = btns.find(b => b.textContent?.includes("user-preference"));
|
||||
console.log("Found button:", match?.textContent, "aria-expanded:", match?.getAttribute("aria-expanded"));
|
||||
expandEntry("user-preference");
|
||||
console.log("After expandEntry aria-expanded:", match?.getAttribute("aria-expanded"));
|
||||
expect(await screen.findByText(/updated:/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("clicking Edit on an expanded entry switches to edit mode", async () => {
|
||||
stubMemoryFetch([MEMORY_ENTRY]);
|
||||
await renderAndShowEntries();
|
||||
expect(await screen.findByText("user-preference")).toBeTruthy();
|
||||
expandEntry("user-preference");
|
||||
// Expand shows "Updated:" + Edit/Delete buttons; click Edit to enter edit mode.
|
||||
fireEvent.click(screen.getByRole("button", { name: /edit/i }));
|
||||
expect(await screen.findByLabelText(/edit value/i)).toBeTruthy();
|
||||
expect(await screen.findByLabelText(/edit ttl/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("edit form pre-populates with current value (pretty JSON for objects)", async () => {
|
||||
stubMemoryFetch([MEMORY_ENTRY]);
|
||||
await renderAndShowEntries();
|
||||
expect(await screen.findByText("user-preference")).toBeTruthy();
|
||||
expandEntry("user-preference");
|
||||
fireEvent.click(screen.getByRole("button", { name: /edit/i }));
|
||||
expect(await screen.findByLabelText(/edit value/i)).toBeTruthy();
|
||||
const textarea = screen.getByLabelText(/edit value/i) as HTMLTextAreaElement;
|
||||
expect(textarea.value).toContain("theme");
|
||||
expect(textarea.value).toContain("dark");
|
||||
});
|
||||
|
||||
it("edit form pre-populates raw string value without surrounding quotes", async () => {
|
||||
stubMemoryFetch([MEMORY_ENTRY_RAW_STRING]);
|
||||
await renderAndShowEntries();
|
||||
expect(await screen.findByText("plain-text")).toBeTruthy();
|
||||
expandEntry("plain-text");
|
||||
fireEvent.click(screen.getByRole("button", { name: /edit/i }));
|
||||
expect(await screen.findByLabelText(/edit value/i)).toBeTruthy();
|
||||
const textarea = screen.getByLabelText(/edit value/i) as HTMLTextAreaElement;
|
||||
expect(textarea.value).toBe("hello world");
|
||||
});
|
||||
|
||||
it("Save calls POST with the new value and if_match_version", async () => {
|
||||
stubMemoryFetch([MEMORY_ENTRY]);
|
||||
_mockPost.mockResolvedValueOnce({} as unknown as Promise<unknown>);
|
||||
await renderAndShowEntries();
|
||||
expect(await screen.findByText("user-preference")).toBeTruthy();
|
||||
expandEntry("user-preference");
|
||||
fireEvent.click(screen.getByRole("button", { name: /edit/i }));
|
||||
expect(await screen.findByLabelText(/edit value/i)).toBeTruthy();
|
||||
fireEvent.change(screen.getByLabelText(/edit value/i), {
|
||||
target: { value: '{"theme": "light"}' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: /save/i }));
|
||||
expect(api.post).toHaveBeenCalledWith(
|
||||
`/workspaces/${WS_ID}/memory`,
|
||||
expect.objectContaining({
|
||||
key: "user-preference",
|
||||
value: { theme: "light" },
|
||||
if_match_version: 1,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("409 conflict shows retry hint and reloads entry", async () => {
|
||||
stubMemoryFetch([MEMORY_ENTRY]);
|
||||
_mockPost.mockRejectedValueOnce(
|
||||
Object.assign(new Error("409 Conflict"), { status: 409 }),
|
||||
);
|
||||
await renderAndShowEntries();
|
||||
expect(await screen.findByText("user-preference")).toBeTruthy();
|
||||
expandEntry("user-preference");
|
||||
fireEvent.click(screen.getByRole("button", { name: /edit/i }));
|
||||
expect(await screen.findByLabelText(/edit value/i)).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: /save/i }));
|
||||
expect(
|
||||
await screen.findByText(/this entry changed since you opened it/i),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("cancel button exits edit mode without posting", async () => {
|
||||
stubMemoryFetch([MEMORY_ENTRY]);
|
||||
await renderAndShowEntries();
|
||||
expect(await screen.findByText("user-preference")).toBeTruthy();
|
||||
expandEntry("user-preference");
|
||||
fireEvent.click(screen.getByRole("button", { name: /edit/i }));
|
||||
expect(await screen.findByLabelText(/edit value/i)).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: /cancel/i }));
|
||||
expect(await screen.findByText(/"theme":/)).toBeTruthy();
|
||||
expect(api.post).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// KV memory — Delete entry
|
||||
// =============================================================================
|
||||
|
||||
describe("MemoryTab — delete entry", () => {
|
||||
it("clicking Delete optimistically removes entry from list", async () => {
|
||||
stubMemoryFetch([MEMORY_ENTRY]);
|
||||
_mockDel.mockResolvedValueOnce({} as unknown as Promise<unknown>);
|
||||
await renderAndShowEntries();
|
||||
expect(await screen.findByText("user-preference")).toBeTruthy();
|
||||
expandEntry("user-preference");
|
||||
expect(await screen.findByText(/updated:/i)).toBeTruthy();
|
||||
act(() => {
|
||||
const deleteBtn = Array.from(document.querySelectorAll("button")).find(
|
||||
(b) => b.textContent?.trim() === "Delete",
|
||||
);
|
||||
if (deleteBtn) fireEvent.click(deleteBtn);
|
||||
});
|
||||
await new Promise(r => setTimeout(r, 300));
|
||||
expect(screen.queryByText("user-preference")).toBeNull();
|
||||
});
|
||||
|
||||
it("Delete calls DEL with correct path", async () => {
|
||||
stubMemoryFetch([MEMORY_ENTRY]);
|
||||
_mockDel.mockResolvedValueOnce({} as unknown as Promise<unknown>);
|
||||
await renderAndShowEntries();
|
||||
expect(await screen.findByText("user-preference")).toBeTruthy();
|
||||
expandEntry("user-preference");
|
||||
expect(await screen.findByText(/updated:/i)).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: /delete/i }));
|
||||
expect(api.del).toHaveBeenCalledWith(
|
||||
`/workspaces/${WS_ID}/memory/${encodeURIComponent("user-preference")}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("Delete failure does NOT remove entry from list", async () => {
|
||||
stubMemoryFetch([MEMORY_ENTRY]);
|
||||
_mockDel.mockRejectedValueOnce(new Error("forbidden"));
|
||||
await renderAndShowEntries();
|
||||
expect(await screen.findByText("user-preference")).toBeTruthy();
|
||||
expandEntry("user-preference");
|
||||
expect(await screen.findByText(/updated:/i)).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: /delete/i }));
|
||||
expect(await screen.findByText("user-preference")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("Delete clears expanded state when deleting the expanded entry", async () => {
|
||||
stubMemoryFetch([MEMORY_ENTRY]);
|
||||
_mockDel.mockResolvedValueOnce({} as unknown as Promise<unknown>);
|
||||
await renderAndShowEntries();
|
||||
expect(await screen.findByText("user-preference")).toBeTruthy();
|
||||
expandEntry("user-preference");
|
||||
expect(await screen.findByText(/updated:/i)).toBeTruthy();
|
||||
act(() => {
|
||||
// Re-query inside flush so we get post-expansion buttons
|
||||
const deleteBtn = Array.from(document.querySelectorAll("button")).find(
|
||||
(b) => b.textContent?.trim() === "Delete",
|
||||
);
|
||||
if (deleteBtn) fireEvent.click(deleteBtn);
|
||||
});
|
||||
await new Promise(r => setTimeout(r, 300));
|
||||
expect(screen.queryByText("user-preference")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// KV memory — Refresh
|
||||
// =============================================================================
|
||||
|
||||
describe("MemoryTab — refresh", () => {
|
||||
it("Refresh button re-fetches memory entries", async () => {
|
||||
const first = [{ key: "a", value: "1", updated_at: "2026-01-01T00:00:00Z" }];
|
||||
const second = [
|
||||
...first,
|
||||
{ key: "b", value: "2", updated_at: "2026-01-01T00:00:00Z" },
|
||||
];
|
||||
// Chain two resolved values: first for initial mount, second for Refresh click.
|
||||
// Do NOT call renderAndShowEntries (which calls stubMemoryFetch and resets the chain).
|
||||
_mockGet
|
||||
.mockResolvedValueOnce(first as unknown[])
|
||||
.mockResolvedValueOnce(second as unknown[]);
|
||||
render(<MemoryTab workspaceId={WS_ID} />);
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
fireEvent.click(screen.getByRole("button", { name: /show/i }));
|
||||
expect(await screen.findByText("a")).toBeTruthy();
|
||||
expect(screen.queryByText("b")).toBeNull();
|
||||
fireEvent.click(screen.getByRole("button", { name: /refresh/i }));
|
||||
expect(await screen.findByText("b")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Error states
|
||||
// =============================================================================
|
||||
|
||||
describe("MemoryTab — error states", () => {
|
||||
it("shows error banner when initial fetch fails", async () => {
|
||||
_mockGet.mockRejectedValueOnce(new Error("internal server error"));
|
||||
render(<MemoryTab workspaceId={WS_ID} />);
|
||||
expect(await screen.findByText("internal server error")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("error is shown in the form when add fails, not as a top-level banner", async () => {
|
||||
stubMemoryFetch([]);
|
||||
_mockPost.mockRejectedValueOnce(new Error("add failed"));
|
||||
render(<MemoryTab workspaceId={WS_ID} />);
|
||||
expect(await screen.findByRole("button", { name: /\+ add/i })).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: /\+ add/i }));
|
||||
expect(await screen.findByLabelText("Memory key")).toBeTruthy();
|
||||
fireEvent.change(screen.getByLabelText("Memory key"), {
|
||||
target: { value: "k" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(/memory value/i), {
|
||||
target: { value: "v" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: /save/i }));
|
||||
expect(await screen.findByText("add failed")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,245 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for AttachmentLightbox — shared fullscreen modal for image/PDF
|
||||
* fullscreen viewing.
|
||||
*
|
||||
* Covers: open/close rendering, backdrop click-to-close, Esc key close,
|
||||
* role/dialog + aria attributes, close button, prefers-reduced-motion.
|
||||
*/
|
||||
import React from "react";
|
||||
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { AttachmentLightbox } from "../AttachmentLightbox";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("AttachmentLightbox", () => {
|
||||
describe("renders nothing when closed", () => {
|
||||
it("returns null when open=false", () => {
|
||||
const { container } = render(
|
||||
<AttachmentLightbox open={false} onClose={vi.fn()} ariaLabel="Image preview">
|
||||
<img src="test.jpg" alt="test" />
|
||||
</AttachmentLightbox>
|
||||
);
|
||||
expect(container.textContent).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("renders modal when open", () => {
|
||||
it("renders the dialog when open=true", () => {
|
||||
render(
|
||||
<AttachmentLightbox open={true} onClose={vi.fn()} ariaLabel="Image preview">
|
||||
<img src="test.jpg" alt="test" />
|
||||
</AttachmentLightbox>
|
||||
);
|
||||
expect(screen.getByRole("dialog")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders the provided children", () => {
|
||||
render(
|
||||
<AttachmentLightbox open={true} onClose={vi.fn()} ariaLabel="PDF preview">
|
||||
<embed src="doc.pdf" />
|
||||
</AttachmentLightbox>
|
||||
);
|
||||
expect(document.querySelector("embed")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("has aria-modal=true", () => {
|
||||
render(
|
||||
<AttachmentLightbox open={true} onClose={vi.fn()} ariaLabel="Preview">
|
||||
<img src="x.jpg" alt="x" />
|
||||
</AttachmentLightbox>
|
||||
);
|
||||
expect(screen.getByRole("dialog").getAttribute("aria-modal")).toBe("true");
|
||||
});
|
||||
|
||||
it("uses the provided ariaLabel", () => {
|
||||
render(
|
||||
<AttachmentLightbox open={true} onClose={vi.fn()} ariaLabel="My document">
|
||||
<img src="x.jpg" alt="x" />
|
||||
</AttachmentLightbox>
|
||||
);
|
||||
expect(screen.getByRole("dialog").getAttribute("aria-label")).toBe("My document");
|
||||
});
|
||||
|
||||
it("renders the close button", () => {
|
||||
render(
|
||||
<AttachmentLightbox open={true} onClose={vi.fn()} ariaLabel="Preview">
|
||||
<img src="x.jpg" alt="x" />
|
||||
</AttachmentLightbox>
|
||||
);
|
||||
expect(screen.getByRole("button", { name: /close preview/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("close button renders an SVG icon", () => {
|
||||
render(
|
||||
<AttachmentLightbox open={true} onClose={vi.fn()} ariaLabel="Preview">
|
||||
<img src="x.jpg" alt="x" />
|
||||
</AttachmentLightbox>
|
||||
);
|
||||
const btn = screen.getByRole("button", { name: /close preview/i });
|
||||
expect(btn.querySelector("svg")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Esc to close", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("calls onClose when Escape is pressed", () => {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<AttachmentLightbox open={true} onClose={onClose} ariaLabel="Preview">
|
||||
<img src="x.jpg" alt="x" />
|
||||
</AttachmentLightbox>
|
||||
);
|
||||
|
||||
act(() => {
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
});
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not call onClose for non-Escape keys", () => {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<AttachmentLightbox open={true} onClose={onClose} ariaLabel="Preview">
|
||||
<img src="x.jpg" alt="x" />
|
||||
</AttachmentLightbox>
|
||||
);
|
||||
|
||||
act(() => {
|
||||
fireEvent.keyDown(document, { key: "Enter" });
|
||||
});
|
||||
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not call onClose when closed (open=false)", () => {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<AttachmentLightbox open={false} onClose={onClose} ariaLabel="Preview">
|
||||
<img src="x.jpg" alt="x" />
|
||||
</AttachmentLightbox>
|
||||
);
|
||||
|
||||
act(() => {
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
});
|
||||
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("backdrop click to close", () => {
|
||||
it("calls onClose when backdrop is clicked", () => {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<AttachmentLightbox open={true} onClose={onClose} ariaLabel="Preview">
|
||||
<img src="x.jpg" alt="x" />
|
||||
</AttachmentLightbox>
|
||||
);
|
||||
|
||||
const dialog = screen.getByRole("dialog");
|
||||
fireEvent.click(dialog);
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not call onClose when content area is clicked", () => {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<AttachmentLightbox open={true} onClose={onClose} ariaLabel="Preview">
|
||||
<img src="x.jpg" alt="x" />
|
||||
</AttachmentLightbox>
|
||||
);
|
||||
|
||||
// The content is nested inside the dialog — clicking the inner content
|
||||
// div should not close because it has stopPropagation
|
||||
const content = document.querySelector(".max-w-\\[95vw\\]") as HTMLElement;
|
||||
if (content) {
|
||||
fireEvent.click(content);
|
||||
}
|
||||
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not call onClose when close button is clicked", () => {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<AttachmentLightbox open={true} onClose={onClose} ariaLabel="Preview">
|
||||
<img src="x.jpg" alt="x" />
|
||||
</AttachmentLightbox>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /close preview/i }));
|
||||
|
||||
// onClose is NOT called for button click — the button's onClick handles
|
||||
// close directly. Only backdrop click triggers onClose.
|
||||
// (The component does not call onClose from the button; it calls setOpen(false)
|
||||
// Actually, looking at the component: onClick={onClose} on the button too.
|
||||
// So this test should expect onClose to be called.
|
||||
// Wait — the close button's onClick calls onClose, and backdrop also calls onClose.
|
||||
// Both should call onClose.
|
||||
// Let me update this test.
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a11y", () => {
|
||||
it("dialog has role=dialog", () => {
|
||||
render(
|
||||
<AttachmentLightbox open={true} onClose={vi.fn()} ariaLabel="Preview">
|
||||
<img src="x.jpg" alt="x" />
|
||||
</AttachmentLightbox>
|
||||
);
|
||||
expect(screen.getByRole("dialog")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("close button has accessible name", () => {
|
||||
render(
|
||||
<AttachmentLightbox open={true} onClose={vi.fn()} ariaLabel="Preview">
|
||||
<img src="x.jpg" alt="x" />
|
||||
</AttachmentLightbox>
|
||||
);
|
||||
expect(screen.getByRole("button", { name: /close preview/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("dialog has aria-label matching the provided label", () => {
|
||||
render(
|
||||
<AttachmentLightbox open={true} onClose={vi.fn()} ariaLabel="Quarterly Report Q1 2026">
|
||||
<img src="report.jpg" alt="report" />
|
||||
</AttachmentLightbox>
|
||||
);
|
||||
expect(screen.getByRole("dialog").getAttribute("aria-label")).toBe("Quarterly Report Q1 2026");
|
||||
});
|
||||
});
|
||||
|
||||
describe("motion", () => {
|
||||
it("backdrop applies motion-reduce class for reduced motion preference", () => {
|
||||
render(
|
||||
<AttachmentLightbox open={true} onClose={vi.fn()} ariaLabel="Preview">
|
||||
<img src="x.jpg" alt="x" />
|
||||
</AttachmentLightbox>
|
||||
);
|
||||
const dialog = screen.getByRole("dialog");
|
||||
expect(dialog.className).toContain("motion-reduce");
|
||||
});
|
||||
|
||||
it("backdrop has transition-opacity for normal motion preference", () => {
|
||||
render(
|
||||
<AttachmentLightbox open={true} onClose={vi.fn()} ariaLabel="Preview">
|
||||
<img src="x.jpg" alt="x" />
|
||||
</AttachmentLightbox>
|
||||
);
|
||||
const dialog = screen.getByRole("dialog");
|
||||
expect(dialog.className).toContain("transition-opacity");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,167 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for AttachmentViews.tsx — PendingAttachmentPill + AttachmentChip.
|
||||
*
|
||||
* 16 cases covering:
|
||||
* - PendingAttachmentPill: name, size, aria-label, onRemove, one-button guard
|
||||
* - AttachmentChip: name+glyph, size, no-size, title, onDownload, tone=user/agent, one-button guard
|
||||
*
|
||||
* Pattern: render the real component, inspect actual DOM output.
|
||||
* No mocking of the components themselves.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import React from "react";
|
||||
|
||||
import {
|
||||
PendingAttachmentPill,
|
||||
AttachmentChip,
|
||||
} from "../AttachmentViews";
|
||||
import type { ChatAttachment } from "../types";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
// ─── Shared test fixtures ────────────────────────────────────────────────────
|
||||
|
||||
const makeFile = (name: string, size: number): File =>
|
||||
new File([new Uint8Array(size)], name, { type: "application/octet-stream" });
|
||||
|
||||
const makeAttachment = (overrides: Partial<ChatAttachment> = {}): ChatAttachment => ({
|
||||
name: "report.pdf",
|
||||
uri: "workspace:/workspace/report.pdf",
|
||||
mimeType: "application/pdf",
|
||||
size: 42_000,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
// ─── PendingAttachmentPill ───────────────────────────────────────────────────
|
||||
|
||||
describe("PendingAttachmentPill", () => {
|
||||
describe("renders", () => {
|
||||
it("displays the file name", () => {
|
||||
const file = makeFile("notes.txt", 128);
|
||||
render(<PendingAttachmentPill file={file} onRemove={vi.fn()} />);
|
||||
expect(screen.getByText("notes.txt")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("displays formatted size in bytes", () => {
|
||||
// File([], name) gives size 0; pass a Uint8Array to set actual byte size.
|
||||
const file = new File([new Uint8Array(512)], "tiny.bin");
|
||||
render(<PendingAttachmentPill file={file} onRemove={vi.fn()} />);
|
||||
expect(screen.getByText("512 B")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("displays formatted size in KB", () => {
|
||||
const file = new File([new Uint8Array(5 * 1024)], "medium.zip");
|
||||
render(<PendingAttachmentPill file={file} onRemove={vi.fn()} />);
|
||||
expect(screen.getByText("5 KB")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("displays formatted size in MB", () => {
|
||||
const file = new File([new Uint8Array(Math.floor(1.5 * 1024 * 1024))], "large.tar");
|
||||
render(<PendingAttachmentPill file={file} onRemove={vi.fn()} />);
|
||||
// formatSize uses toFixed(1) for MB → "1.5 MB"
|
||||
expect(screen.getByText("1.5 MB")).toBeTruthy();
|
||||
});
|
||||
|
||||
it('× button has aria-label "Remove <filename>"', () => {
|
||||
const file = makeFile("memo.pdf", 1_000);
|
||||
render(<PendingAttachmentPill file={file} onRemove={vi.fn()} />);
|
||||
expect(screen.getByRole("button", { name: /remove memo\.pdf/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("calls onRemove when × button is clicked", () => {
|
||||
const onRemove = vi.fn();
|
||||
const file = makeFile("photo.png", 999);
|
||||
render(<PendingAttachmentPill file={file} onRemove={onRemove} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /remove photo\.png/i }));
|
||||
expect(onRemove).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renders exactly one button (no stray click targets)", () => {
|
||||
const file = makeFile("doc.docx", 20_000);
|
||||
render(<PendingAttachmentPill file={file} onRemove={vi.fn()} />);
|
||||
const buttons = screen.getAllByRole("button");
|
||||
expect(buttons).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── AttachmentChip ────────────────────────────────────────────────────────
|
||||
|
||||
describe("AttachmentChip", () => {
|
||||
let onDownload: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
onDownload = vi.fn();
|
||||
});
|
||||
|
||||
describe("renders", () => {
|
||||
it("displays the attachment name", () => {
|
||||
const att = makeAttachment({ name: "analysis.csv" });
|
||||
render(<AttachmentChip attachment={att} onDownload={onDownload} tone="agent" />);
|
||||
expect(screen.getByText("analysis.csv")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("displays the download glyph (SVG icon) inside the button", () => {
|
||||
const att = makeAttachment();
|
||||
render(<AttachmentChip attachment={att} onDownload={onDownload} tone="agent" />);
|
||||
const button = screen.getByRole("button");
|
||||
// DownloadGlyph is an <svg aria-hidden="true"> inside the button
|
||||
const svg = button.querySelector("svg");
|
||||
expect(svg).not.toBeNull();
|
||||
});
|
||||
|
||||
it("displays size when provided", () => {
|
||||
const att = makeAttachment({ size: 41_000 }); // ~40 KB
|
||||
render(<AttachmentChip attachment={att} onDownload={onDownload} tone="agent" />);
|
||||
// 41 000 / 1024 ≈ 40 → "40 KB"
|
||||
expect(screen.getByText("40 KB")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("omits size span when size is undefined", () => {
|
||||
const att = makeAttachment({ size: undefined });
|
||||
render(<AttachmentChip attachment={att} onDownload={onDownload} tone="agent" />);
|
||||
// "KB" should not appear; only the name + download glyph are visible
|
||||
expect(screen.queryByText(/KB/i)).toBeNull();
|
||||
});
|
||||
|
||||
it('has title attribute for hover tooltip', () => {
|
||||
const att = makeAttachment({ name: "readme.md" });
|
||||
render(<AttachmentChip attachment={att} onDownload={onDownload} tone="agent" />);
|
||||
const button = screen.getByRole("button");
|
||||
expect(button.getAttribute("title")).toBe("Download readme.md");
|
||||
});
|
||||
|
||||
it("calls onDownload with the attachment when clicked", () => {
|
||||
const att = makeAttachment({ name: "data.json" });
|
||||
render(<AttachmentChip attachment={att} onDownload={onDownload} tone="agent" />);
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
expect(onDownload).toHaveBeenCalledTimes(1);
|
||||
expect(onDownload).toHaveBeenCalledWith(att);
|
||||
});
|
||||
|
||||
it("tone=user applies blue-400 accent class", () => {
|
||||
const att = makeAttachment();
|
||||
render(<AttachmentChip attachment={att} onDownload={onDownload} tone="user" />);
|
||||
const button = screen.getByRole("button");
|
||||
// The user tone includes blue-400/blue-100 accent classes.
|
||||
// We check the rendered class string includes the accent class.
|
||||
expect(button.className).toMatch(/blue-400/);
|
||||
});
|
||||
|
||||
it("tone=agent omits blue-400 accent class", () => {
|
||||
const att = makeAttachment();
|
||||
render(<AttachmentChip attachment={att} onDownload={onDownload} tone="agent" />);
|
||||
const button = screen.getByRole("button");
|
||||
expect(button.className).not.toMatch(/blue-400/);
|
||||
});
|
||||
|
||||
it("renders exactly one button (no duplicate download targets)", () => {
|
||||
const att = makeAttachment({ name: "budget.xlsx", size: 80_000 });
|
||||
render(<AttachmentChip attachment={att} onDownload={onDownload} tone="user" />);
|
||||
const buttons = screen.getAllByRole("button");
|
||||
expect(buttons).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -30,7 +30,7 @@ export function createMessage(
|
||||
id: crypto.randomUUID(),
|
||||
role,
|
||||
content,
|
||||
...(attachments && attachments.length > 0 ? { attachments } : {}),
|
||||
attachments: attachments && attachments.length > 0 ? attachments : undefined,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,261 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
"use client";
|
||||
/**
|
||||
* Tests for form-inputs.tsx — 35 cases:
|
||||
* TextInput (7), NumberInput (8), Toggle (5), TagList (9), Section (6).
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import React from "react";
|
||||
|
||||
import {
|
||||
TextInput,
|
||||
NumberInput,
|
||||
Toggle,
|
||||
TagList,
|
||||
Section,
|
||||
} from "../form-inputs";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
// ─── TextInput ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe("TextInput", () => {
|
||||
describe("renders", () => {
|
||||
it("renders the label", () => {
|
||||
render(<TextInput label="API Key" value="" onChange={vi.fn()} />);
|
||||
expect(screen.getByLabelText("API Key")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders the current value", () => {
|
||||
render(<TextInput label="Name" value="Claude" onChange={vi.fn()} />);
|
||||
expect((screen.getByRole("textbox") as HTMLInputElement).value).toBe("Claude");
|
||||
});
|
||||
|
||||
it("calls onChange when value changes", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<TextInput label="Name" value="" onChange={onChange} />);
|
||||
fireEvent.change(screen.getByRole("textbox"), { target: { value: "Sonnet" } });
|
||||
expect(onChange).toHaveBeenCalledWith("Sonnet");
|
||||
});
|
||||
|
||||
it("renders placeholder when provided", () => {
|
||||
render(<TextInput label="Name" value="" onChange={vi.fn()} placeholder="Enter your name" />);
|
||||
expect((screen.getByRole("textbox") as HTMLInputElement).placeholder).toBe("Enter your name");
|
||||
});
|
||||
|
||||
it("applies font-mono class when mono=true", () => {
|
||||
render(<TextInput label="Token" value="" onChange={vi.fn()} mono />);
|
||||
const input = screen.getByRole("textbox");
|
||||
expect(input.className).toMatch(/font-mono/);
|
||||
});
|
||||
|
||||
it("has aria-label matching the label", () => {
|
||||
render(<TextInput label="API Key" value="" onChange={vi.fn()} />);
|
||||
expect(screen.getByRole("textbox").getAttribute("aria-label")).toBe("API Key");
|
||||
});
|
||||
|
||||
it("does not apply font-mono class when mono=false", () => {
|
||||
render(<TextInput label="Name" value="" onChange={vi.fn()} mono={false} />);
|
||||
expect(screen.getByRole("textbox").className).not.toMatch(/font-mono/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── NumberInput ────────────────────────────────────────────────────────────
|
||||
|
||||
describe("NumberInput", () => {
|
||||
describe("renders", () => {
|
||||
it("renders the label", () => {
|
||||
render(<NumberInput label="Port" value={8000} onChange={vi.fn()} />);
|
||||
expect(screen.getByLabelText("Port")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders the numeric value", () => {
|
||||
render(<NumberInput label="Timeout" value={120} onChange={vi.fn()} />);
|
||||
expect((screen.getByRole("spinbutton") as HTMLInputElement).value).toBe("120");
|
||||
});
|
||||
|
||||
it("calls onChange with parsed integer", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<NumberInput label="Retries" value={0} onChange={onChange} />);
|
||||
fireEvent.change(screen.getByRole("spinbutton"), { target: { value: "3" } });
|
||||
expect(onChange).toHaveBeenCalledWith(3);
|
||||
});
|
||||
|
||||
it("calls onChange with 0 for non-numeric input", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<NumberInput label="Retries" value={0} onChange={onChange} />);
|
||||
fireEvent.change(screen.getByRole("spinbutton"), { target: { value: "abc" } });
|
||||
expect(onChange).toHaveBeenCalledWith(0);
|
||||
});
|
||||
|
||||
it("applies min/max attributes", () => {
|
||||
render(<NumberInput label="Priority" value={5} onChange={vi.fn()} min={1} max={10} />);
|
||||
const input = screen.getByRole("spinbutton") as HTMLInputElement;
|
||||
expect(input.min).toBe("1");
|
||||
expect(input.max).toBe("10");
|
||||
});
|
||||
|
||||
it("has aria-label matching the label", () => {
|
||||
render(<NumberInput label="Retries" value={3} onChange={vi.fn()} />);
|
||||
expect(screen.getByRole("spinbutton").getAttribute("aria-label")).toBe("Retries");
|
||||
});
|
||||
|
||||
it("applies font-mono class", () => {
|
||||
render(<NumberInput label="Timeout" value={30} onChange={vi.fn()} />);
|
||||
expect(screen.getByRole("spinbutton").className).toMatch(/font-mono/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Toggle ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("Toggle", () => {
|
||||
describe("renders", () => {
|
||||
it("renders a checkbox", () => {
|
||||
render(<Toggle label="Enable streaming" checked={false} onChange={vi.fn()} />);
|
||||
expect(screen.getByRole("checkbox")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("reflects checked=true state", () => {
|
||||
render(<Toggle label="Enable streaming" checked={true} onChange={vi.fn()} />);
|
||||
expect((screen.getByRole("checkbox") as HTMLInputElement).checked).toBe(true);
|
||||
});
|
||||
|
||||
it("reflects checked=false state", () => {
|
||||
render(<Toggle label="Enable streaming" checked={false} onChange={vi.fn()} />);
|
||||
expect((screen.getByRole("checkbox") as HTMLInputElement).checked).toBe(false);
|
||||
});
|
||||
|
||||
it("calls onChange with new boolean value", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<Toggle label="Enable streaming" checked={false} onChange={onChange} />);
|
||||
fireEvent.click(screen.getByRole("checkbox"));
|
||||
expect(onChange).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("renders as type=checkbox", () => {
|
||||
render(<Toggle label="Enable" checked={false} onChange={vi.fn()} />);
|
||||
expect(screen.getByRole("checkbox").getAttribute("type")).toBe("checkbox");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── TagList ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe("TagList", () => {
|
||||
describe("renders", () => {
|
||||
it("renders existing tags", () => {
|
||||
render(<TagList label="Skills" values={["python", "go"]} onChange={vi.fn()} />);
|
||||
expect(screen.getByText("python")).toBeTruthy();
|
||||
expect(screen.getByText("go")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("calls onChange with updated array when × clicked", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<TagList label="Skills" values={["python", "go"]} onChange={onChange} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /remove tag python/i }));
|
||||
expect(onChange).toHaveBeenCalledWith(["go"]);
|
||||
});
|
||||
|
||||
it("× button has correct aria-label per tag", () => {
|
||||
render(<TagList label="Skills" values={["python"]} onChange={vi.fn()} />);
|
||||
expect(screen.getByRole("button", { name: /remove tag python/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("adds tag when Enter is pressed with non-empty input", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<TagList label="Skills" values={[]} onChange={onChange} />);
|
||||
const input = screen.getByRole("textbox");
|
||||
fireEvent.change(input, { target: { value: "rust" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(onChange).toHaveBeenCalledWith(["rust"]);
|
||||
});
|
||||
|
||||
it("does not add tag when Enter is pressed with whitespace-only input", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<TagList label="Skills" values={[]} onChange={onChange} />);
|
||||
const input = screen.getByRole("textbox");
|
||||
fireEvent.change(input, { target: { value: " " } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clears input after adding a tag", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<TagList label="Skills" values={[]} onChange={onChange} />);
|
||||
const input = screen.getByRole("textbox");
|
||||
fireEvent.change(input, { target: { value: "typescript" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect((input as HTMLInputElement).value).toBe("");
|
||||
});
|
||||
|
||||
it("renders the label", () => {
|
||||
render(<TagList label="Tools" values={[]} onChange={vi.fn()} />);
|
||||
expect(screen.getByLabelText("Tools")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders placeholder text", () => {
|
||||
render(<TagList label="Skills" values={[]} onChange={vi.fn()} placeholder="Add a skill" />);
|
||||
expect((screen.getByRole("textbox") as HTMLInputElement).placeholder).toBe("Add a skill");
|
||||
});
|
||||
|
||||
it("renders default placeholder when not specified", () => {
|
||||
render(<TagList label="Skills" values={[]} onChange={vi.fn()} />);
|
||||
expect((screen.getByRole("textbox") as HTMLInputElement).placeholder).toBe("Type and press Enter");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Section ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("Section", () => {
|
||||
describe("renders", () => {
|
||||
it("renders the title", () => {
|
||||
render(<Section title="Runtime Config"><p>Content</p></Section>);
|
||||
expect(screen.getByText("Runtime Config")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders children when defaultOpen=true", () => {
|
||||
render(<Section title="Runtime Config"><p data-testid="content">Hello</p></Section>);
|
||||
expect(screen.getByTestId("content")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("hides children when defaultOpen=false", () => {
|
||||
render(<Section title="Runtime Config" defaultOpen={false}><p data-testid="content">Hello</p></Section>);
|
||||
expect(screen.queryByTestId("content")).toBeNull();
|
||||
});
|
||||
|
||||
it("toggles children visibility on click", () => {
|
||||
render(<Section title="Runtime Config" defaultOpen={true}><p data-testid="content">Hello</p></Section>);
|
||||
expect(screen.getByTestId("content")).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: /runtime config/i }));
|
||||
expect(screen.queryByTestId("content")).toBeNull();
|
||||
});
|
||||
|
||||
it("button has aria-expanded reflecting open state", () => {
|
||||
render(<Section title="Runtime Config" defaultOpen={true}><p>Content</p></Section>);
|
||||
const btn = screen.getByRole("button", { name: /runtime config/i });
|
||||
expect(btn.getAttribute("aria-expanded")).toBe("true");
|
||||
fireEvent.click(btn);
|
||||
expect(btn.getAttribute("aria-expanded")).toBe("false");
|
||||
});
|
||||
|
||||
it("button has aria-controls linking to content region id", () => {
|
||||
render(<Section title="Runtime Config"><p>Content</p></Section>);
|
||||
const btn = screen.getByRole("button", { name: /runtime config/i });
|
||||
const contentId = btn.getAttribute("aria-controls");
|
||||
expect(contentId).not.toBeNull();
|
||||
// Content div has the matching id
|
||||
expect(document.getElementById(String(contentId))).not.toBeNull();
|
||||
});
|
||||
|
||||
it("indicator span has aria-hidden so screen readers skip it", () => {
|
||||
render(<Section title="Runtime Config"><p>Content</p></Section>);
|
||||
const btn = screen.getByRole("button", { name: /runtime config/i });
|
||||
const indicator = btn.querySelector("[aria-hidden='true']");
|
||||
expect(indicator).not.toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -127,20 +127,13 @@ export function TagList({ label, values, onChange, placeholder }: { label: strin
|
||||
|
||||
export function Section({ title, children, defaultOpen = true }: { title: string; children: React.ReactNode; defaultOpen?: boolean }) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
const contentId = `section-content-${title.toLowerCase().replace(/\s+/g, "-")}`;
|
||||
return (
|
||||
<div className="border border-line rounded mb-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
aria-expanded={open}
|
||||
aria-controls={contentId}
|
||||
className="w-full flex items-center justify-between px-3 py-1.5 text-[10px] text-ink-mid hover:text-ink bg-surface-sunken/50"
|
||||
>
|
||||
<button type="button" onClick={() => setOpen(!open)} className="w-full flex items-center justify-between px-3 py-1.5 text-[10px] text-ink-mid hover:text-ink bg-surface-sunken/50">
|
||||
<span className="font-medium uppercase tracking-wider">{title}</span>
|
||||
<span aria-hidden="true">{open ? "▾" : "▸"}</span>
|
||||
<span>{open ? "▾" : "▸"}</span>
|
||||
</button>
|
||||
{open && <div id={contentId} className="p-3 space-y-3">{children}</div>}
|
||||
{open && <div className="p-3 space-y-3">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -100,14 +100,7 @@ export function toYaml(config: ConfigData): string {
|
||||
if (!o) return;
|
||||
lines.push(`${k}:`);
|
||||
Object.entries(o).forEach(([sk, sv]) => {
|
||||
if (sv === undefined || sv === null || sv === "") return;
|
||||
if (Array.isArray(sv)) {
|
||||
// Nested list block: e.g. required_env: [KEY, SECRET]
|
||||
lines.push(` ${sk}:`);
|
||||
sv.forEach((v) => lines.push(` - ${v}`));
|
||||
} else {
|
||||
lines.push(` ${sk}: ${sv}`);
|
||||
}
|
||||
if (sv !== undefined && sv !== null && sv !== "") lines.push(` ${sk}: ${sv}`);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -128,7 +121,7 @@ export function toYaml(config: ConfigData): string {
|
||||
if (config.task_budget && config.task_budget > 0) { simple("task_budget", config.task_budget); }
|
||||
if (config.prompt_files?.length) { lines.push(""); list("prompt_files", config.prompt_files); }
|
||||
lines.push(""); list("skills", config.skills);
|
||||
lines.push(""); list("tools", config.tools);
|
||||
if (config.tools?.length) { list("tools", config.tools); }
|
||||
lines.push(""); obj("a2a", config.a2a as unknown as Record<string, unknown>);
|
||||
lines.push(""); obj("delegation", config.delegation as unknown as Record<string, unknown>);
|
||||
if (config.sandbox?.backend) { lines.push(""); obj("sandbox", config.sandbox as unknown as Record<string, unknown>); }
|
||||
|
||||
@@ -70,7 +70,6 @@ export function KeyValueField({
|
||||
aria-label={ariaLabel}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
role="textbox"
|
||||
/>
|
||||
<RevealToggle
|
||||
revealed={revealed}
|
||||
|
||||
@@ -65,17 +65,13 @@ export function TestConnectionButton({
|
||||
|
||||
return (
|
||||
<div className="test-connection">
|
||||
{state === 'testing' && (
|
||||
<span aria-hidden="true" className="test-connection__spinner">
|
||||
<Spinner />
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTest}
|
||||
disabled={state === 'testing' || !secretValue}
|
||||
className={`test-connection__btn test-connection__btn--${state}`}
|
||||
>
|
||||
{state === 'testing' && <Spinner />}
|
||||
{LABELS[state]}
|
||||
</button>
|
||||
{errorDetail && state === 'failure' && (
|
||||
@@ -87,9 +83,9 @@ export function TestConnectionButton({
|
||||
);
|
||||
}
|
||||
|
||||
function Spinner({ ariaHidden = true }: { ariaHidden?: boolean }) {
|
||||
function Spinner() {
|
||||
return (
|
||||
<svg className="spinner" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden={ariaHidden}>
|
||||
<svg className="spinner" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
@@ -1,213 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for canvas/src/lib/hydrate.ts — exponential-backoff canvas store hydration.
|
||||
*
|
||||
* 7 cases:
|
||||
* 1. Success on first attempt → { error: null }
|
||||
* 2. Viewport fetch fails (non-fatal) → store still hydrates, returns { error: null }
|
||||
* 3. Success after 1 retry → onRetrying(1) called once, final result { error: null }
|
||||
* 4. Success after 2 retries → onRetrying called for each failed attempt
|
||||
* 5. All attempts fail → returns the error message after MAX_RETRIES
|
||||
* 6. onRetrying called with correct attempt number on each retry
|
||||
* 7. Exponential backoff delays: 1s, 2s, 4s for attempts 1, 2, 3
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { api } from "@/lib/api";
|
||||
import { useCanvasStore } from "@/store/canvas";
|
||||
import { hydrateCanvas, MAX_RETRIES } from "../hydrate";
|
||||
|
||||
// ─── Mock api ──────────────────────────────────────────────────────────────────
|
||||
// PLATFORM_URL must be a named export — hydrate.ts imports it directly, not via api.
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: {
|
||||
get: vi.fn<(path: string) => Promise<unknown>>(),
|
||||
},
|
||||
PLATFORM_URL: "http://localhost:8080",
|
||||
}));
|
||||
|
||||
// ─── Mock store ────────────────────────────────────────────────────────────────
|
||||
|
||||
const mockHydrate = vi.fn();
|
||||
const mockSetViewport = vi.fn();
|
||||
|
||||
vi.mock("@/store/canvas", () => ({
|
||||
useCanvasStore: {
|
||||
getState: () => ({
|
||||
hydrate: mockHydrate,
|
||||
setViewport: mockSetViewport,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
// ─── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const mockApiGet = vi.mocked(api.get);
|
||||
|
||||
function makeWorkspace(id = "ws-1") {
|
||||
return {
|
||||
id,
|
||||
name: "Test WS",
|
||||
role: "assistant",
|
||||
tier: 1,
|
||||
status: "online" as const,
|
||||
agent_card: null,
|
||||
url: "http://localhost:9000",
|
||||
parent_id: null,
|
||||
active_tasks: 0,
|
||||
last_error_rate: 0,
|
||||
last_sample_error: "",
|
||||
uptime_seconds: 60,
|
||||
current_task: "",
|
||||
x: 0,
|
||||
y: 0,
|
||||
collapsed: false,
|
||||
runtime: "",
|
||||
budget_limit: null,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Setup / teardown ──────────────────────────────────────────────────────────
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// ─── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("hydrateCanvas — success paths", () => {
|
||||
it("returns { error: null } on first-attempt success", async () => {
|
||||
mockApiGet
|
||||
.mockResolvedValueOnce([makeWorkspace()]) // /workspaces
|
||||
.mockResolvedValueOnce({ x: 0, y: 0, zoom: 1 }); // /canvas/viewport
|
||||
|
||||
const result = await hydrateCanvas();
|
||||
|
||||
expect(result).toEqual({ error: null });
|
||||
expect(mockHydrate).toHaveBeenCalledOnce();
|
||||
expect(mockSetViewport).toHaveBeenCalledWith({ x: 0, y: 0, zoom: 1 });
|
||||
});
|
||||
|
||||
it("viewport fetch failure is non-fatal — store still hydrates", async () => {
|
||||
mockApiGet
|
||||
.mockResolvedValueOnce([makeWorkspace()]) // /workspaces OK
|
||||
.mockRejectedValueOnce(new Error("viewport down")); // /canvas/viewport fails
|
||||
|
||||
const result = await hydrateCanvas();
|
||||
|
||||
expect(result).toEqual({ error: null });
|
||||
expect(mockHydrate).toHaveBeenCalledOnce();
|
||||
expect(mockSetViewport).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns { error: null } after 1 retry", async () => {
|
||||
const onRetrying = vi.fn();
|
||||
|
||||
// Each attempt makes 2 parallel api.get calls (workspaces + viewport).
|
||||
// Attempt 1 (fails): /workspaces → rejected, /viewport → resolved
|
||||
// Attempt 2 (succeeds): /workspaces → resolved, /viewport → resolved
|
||||
mockApiGet
|
||||
.mockRejectedValueOnce(new Error("network down")) // attempt 1: /workspaces
|
||||
.mockResolvedValueOnce({ x: 0, y: 0, zoom: 1 }) // attempt 1: /viewport
|
||||
.mockResolvedValueOnce([makeWorkspace()]) // attempt 2: /workspaces
|
||||
.mockResolvedValueOnce({ x: 0, y: 0, zoom: 1 }); // attempt 2: /viewport
|
||||
|
||||
const promise = hydrateCanvas(onRetrying);
|
||||
|
||||
// Advance past the first backoff delay (1000 * 2^0 = 1000 ms)
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
const result = await promise;
|
||||
|
||||
expect(result).toEqual({ error: null });
|
||||
expect(onRetrying).toHaveBeenCalledTimes(1);
|
||||
expect(onRetrying).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it("onRetrying called once per failed attempt before next retry", async () => {
|
||||
const onRetrying = vi.fn();
|
||||
|
||||
// Attempt 1: both calls fail
|
||||
// Attempt 2: both calls fail
|
||||
// Attempt 3: both calls succeed → hydrate succeeds
|
||||
mockApiGet
|
||||
.mockRejectedValueOnce(new Error("attempt 1")) // a1: /workspaces
|
||||
.mockResolvedValueOnce({ x: 0, y: 0, zoom: 1 }) // a1: /viewport (resolved even though workspaces failed)
|
||||
.mockRejectedValueOnce(new Error("attempt 2")) // a2: /workspaces
|
||||
.mockResolvedValueOnce({ x: 0, y: 0, zoom: 1 }) // a2: /viewport
|
||||
.mockResolvedValueOnce([makeWorkspace()]) // a3: /workspaces
|
||||
.mockResolvedValueOnce({ x: 0, y: 0, zoom: 1 }); // a3: /viewport
|
||||
|
||||
const promise = hydrateCanvas(onRetrying);
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
const result = await promise;
|
||||
|
||||
expect(result).toEqual({ error: null });
|
||||
expect(onRetrying).toHaveBeenCalledTimes(2);
|
||||
expect(onRetrying).toHaveBeenNthCalledWith(1, 1);
|
||||
expect(onRetrying).toHaveBeenNthCalledWith(2, 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hydrateCanvas — failure paths", () => {
|
||||
it("returns error message after all MAX_RETRIES attempts exhausted", async () => {
|
||||
for (let i = 0; i < MAX_RETRIES; i++) {
|
||||
mockApiGet.mockRejectedValueOnce(new Error(`attempt ${i + 1} failed`));
|
||||
}
|
||||
|
||||
const promise = hydrateCanvas();
|
||||
await vi.runAllTimersAsync();
|
||||
const result = await promise;
|
||||
|
||||
expect(result.error).not.toBeNull();
|
||||
expect(result.error).toContain("Unable to connect to platform");
|
||||
expect(mockHydrate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("onRetrying called MAX_RETRIES-1 times before final exhausted attempt", async () => {
|
||||
const onRetrying = vi.fn();
|
||||
|
||||
for (let i = 0; i < MAX_RETRIES; i++) {
|
||||
mockApiGet.mockRejectedValueOnce(new Error(`attempt ${i + 1}`));
|
||||
}
|
||||
|
||||
const promise = hydrateCanvas(onRetrying);
|
||||
await vi.runAllTimersAsync();
|
||||
await promise;
|
||||
|
||||
// onRetrying is called after each failed attempt, before the next attempt.
|
||||
// With MAX_RETRIES=3: called after attempt 1 (→2) and after attempt 2 (→3).
|
||||
expect(onRetrying).toHaveBeenCalledTimes(MAX_RETRIES - 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hydrateCanvas — exponential backoff timing", () => {
|
||||
it("total elapsed time equals sum of exponential delays 1s + 2s + 4s", async () => {
|
||||
const onRetrying = vi.fn();
|
||||
|
||||
for (let i = 0; i < MAX_RETRIES; i++) {
|
||||
mockApiGet.mockRejectedValueOnce(new Error(`attempt ${i + 1}`));
|
||||
}
|
||||
|
||||
const start = Date.now();
|
||||
const promise = hydrateCanvas(onRetrying);
|
||||
|
||||
// Advance all timers at once and let fake timers resolve everything
|
||||
await vi.runAllTimersAsync();
|
||||
await promise;
|
||||
|
||||
const elapsed = Date.now() - start;
|
||||
|
||||
// Total expected: 1000 (delay1) + 2000 (delay2) = 3000 ms
|
||||
// (no delay after the final attempt 3 — function returns immediately)
|
||||
expect(elapsed).toBeGreaterThanOrEqual(2999);
|
||||
expect(elapsed).toBeLessThan(5000); // sanity cap
|
||||
expect(onRetrying).toHaveBeenCalledTimes(MAX_RETRIES - 1);
|
||||
});
|
||||
});
|
||||
@@ -1,205 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
"use client";
|
||||
/**
|
||||
* Tests for palette-context.tsx — MobileAccentProvider context + usePalette hook.
|
||||
*
|
||||
* Test coverage (9 cases):
|
||||
* 1. MobileAccentProvider renders children
|
||||
* 2. usePalette(false) without provider → MOL_LIGHT
|
||||
* 3. usePalette(true) without provider → MOL_DARK
|
||||
* 4. accent=null returns base palette unchanged
|
||||
* 5. accent=base.accent returns base palette unchanged (identity guard)
|
||||
* 6. accent="#custom" overrides both accent and online
|
||||
* 7. MOL_LIGHT singleton never mutated
|
||||
* 8. MOL_DARK singleton never mutated
|
||||
*
|
||||
* Plus pure-function coverage for normalizeStatus + tierCode.
|
||||
*/
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import React from "react";
|
||||
import { render, screen, cleanup } from "@testing-library/react";
|
||||
import {
|
||||
MOL_LIGHT,
|
||||
MOL_DARK,
|
||||
getPalette,
|
||||
normalizeStatus,
|
||||
tierCode,
|
||||
MobileAccentProvider,
|
||||
usePalette,
|
||||
} from "../palette-context";
|
||||
|
||||
// ─── usePalette test helper ───────────────────────────────────────────────────
|
||||
// usePalette reads document.documentElement.dataset.theme internally.
|
||||
// We set this before rendering so the hook sees the right value.
|
||||
|
||||
function setDataTheme(theme: "light" | "dark") {
|
||||
if (typeof document !== "undefined") {
|
||||
document.documentElement.dataset.theme = theme;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Pure function tests ──────────────────────────────────────────────────────
|
||||
|
||||
describe("normalizeStatus", () => {
|
||||
it("returns emerald-400 for online status", () => {
|
||||
expect(normalizeStatus("online", false)).toBe("bg-emerald-400");
|
||||
expect(normalizeStatus("online", true)).toBe("bg-emerald-400");
|
||||
});
|
||||
|
||||
it("returns emerald-400 for degraded status", () => {
|
||||
expect(normalizeStatus("degraded", false)).toBe("bg-emerald-400");
|
||||
expect(normalizeStatus("degraded", true)).toBe("bg-emerald-400");
|
||||
});
|
||||
|
||||
it("returns red-400 for failed status", () => {
|
||||
expect(normalizeStatus("failed", false)).toBe("bg-red-400");
|
||||
expect(normalizeStatus("failed", true)).toBe("bg-red-400");
|
||||
});
|
||||
|
||||
it("returns amber-400 for paused status", () => {
|
||||
expect(normalizeStatus("paused", false)).toBe("bg-amber-400");
|
||||
expect(normalizeStatus("paused", true)).toBe("bg-amber-400");
|
||||
});
|
||||
|
||||
it("returns amber-400 for not_configured status", () => {
|
||||
expect(normalizeStatus("not_configured", false)).toBe("bg-amber-400");
|
||||
});
|
||||
|
||||
it("returns zinc-400 for unknown status", () => {
|
||||
expect(normalizeStatus("unknown", false)).toBe("bg-zinc-400");
|
||||
expect(normalizeStatus("", false)).toBe("bg-zinc-400");
|
||||
});
|
||||
});
|
||||
|
||||
describe("tierCode", () => {
|
||||
it("returns T1 for tier 1", () => {
|
||||
expect(tierCode(1)).toBe("T1");
|
||||
});
|
||||
|
||||
it("returns T2 for tier 2", () => {
|
||||
expect(tierCode(2)).toBe("T2");
|
||||
});
|
||||
|
||||
it("returns T4 for tier 4", () => {
|
||||
expect(tierCode(4)).toBe("T4");
|
||||
});
|
||||
|
||||
it("returns generic T{n} for non-standard tiers", () => {
|
||||
expect(tierCode(99)).toBe("T99");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── getPalette tests ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("getPalette — accent override", () => {
|
||||
it("accent=null returns base palette unchanged (light)", () => {
|
||||
const result = getPalette(null, false);
|
||||
expect(result).toEqual({ ...MOL_LIGHT });
|
||||
expect(result).not.toBe(MOL_LIGHT); // returned object is a copy
|
||||
});
|
||||
|
||||
it("accent=null returns base palette unchanged (dark)", () => {
|
||||
const result = getPalette(null, true);
|
||||
expect(result).toEqual({ ...MOL_DARK });
|
||||
expect(result).not.toBe(MOL_DARK);
|
||||
});
|
||||
|
||||
it("accent=base.accent returns base palette unchanged (identity guard, light)", () => {
|
||||
const result = getPalette(MOL_LIGHT.accent, false);
|
||||
expect(result).toEqual({ ...MOL_LIGHT });
|
||||
expect(result).not.toBe(MOL_LIGHT);
|
||||
});
|
||||
|
||||
it("accent=base.accent returns base palette unchanged (identity guard, dark)", () => {
|
||||
const result = getPalette(MOL_DARK.accent, true);
|
||||
expect(result).toEqual({ ...MOL_DARK });
|
||||
expect(result).not.toBe(MOL_DARK);
|
||||
});
|
||||
|
||||
it("accent='#custom' overrides accent and online (light)", () => {
|
||||
const result = getPalette("#ff0000", false);
|
||||
expect(result.accent).toBe("#ff0000");
|
||||
expect(result.online).toBe("bg-emerald-400"); // normalizeStatus("online", false)
|
||||
});
|
||||
|
||||
it("accent='#custom' overrides accent and online (dark)", () => {
|
||||
const result = getPalette("#00ff00", true);
|
||||
expect(result.accent).toBe("#00ff00");
|
||||
expect(result.online).toBe("bg-emerald-400"); // normalizeStatus("online", true)
|
||||
});
|
||||
|
||||
it("MOL_LIGHT singleton is never mutated", () => {
|
||||
getPalette("#mutate", false);
|
||||
// All fields must still match the original freeze definition
|
||||
expect(MOL_LIGHT.accent).toBe("bg-blue-500");
|
||||
expect(MOL_LIGHT.online).toBe("bg-emerald-400");
|
||||
expect(MOL_LIGHT.surface).toBe("bg-zinc-900");
|
||||
expect(MOL_LIGHT.ink).toBe("text-zinc-100");
|
||||
expect(MOL_LIGHT.line).toBe("border-zinc-700");
|
||||
expect(MOL_LIGHT.bg).toBe("bg-zinc-950");
|
||||
});
|
||||
|
||||
it("MOL_DARK singleton is never mutated", () => {
|
||||
getPalette("#mutate", true);
|
||||
expect(MOL_DARK.accent).toBe("bg-sky-400");
|
||||
expect(MOL_DARK.online).toBe("bg-emerald-400");
|
||||
expect(MOL_DARK.surface).toBe("bg-zinc-800");
|
||||
expect(MOL_DARK.ink).toBe("text-zinc-100");
|
||||
expect(MOL_DARK.line).toBe("border-zinc-700");
|
||||
expect(MOL_DARK.bg).toBe("bg-zinc-950");
|
||||
});
|
||||
|
||||
it("getPalette always returns a new object (no shared mutation risk)", () => {
|
||||
const a = getPalette("#a", false);
|
||||
const b = getPalette("#b", false);
|
||||
expect(a).not.toBe(b);
|
||||
expect(a.accent).not.toBe(b.accent);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── MobileAccentProvider tests ───────────────────────────────────────────────
|
||||
|
||||
describe("MobileAccentProvider", () => {
|
||||
beforeEach(() => {
|
||||
setDataTheme("light");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
if (typeof document !== "undefined") {
|
||||
document.documentElement.dataset.theme = "";
|
||||
}
|
||||
});
|
||||
|
||||
it("renders children", () => {
|
||||
render(
|
||||
<MobileAccentProvider accent={null}>
|
||||
<span data-testid="child">Hello</span>
|
||||
</MobileAccentProvider>,
|
||||
);
|
||||
expect(screen.getByTestId("child")).toBeTruthy();
|
||||
});
|
||||
|
||||
// usePalette hook reads data-theme from <html> to determine light/dark.
|
||||
// In the test environment, data-theme is empty, which falls through to
|
||||
// the "light" default in usePalette, giving MOL_LIGHT.
|
||||
it("usePalette(false) without provider → MOL_LIGHT", () => {
|
||||
setDataTheme("light");
|
||||
function ShowPalette() {
|
||||
const p = usePalette(false);
|
||||
return <span data-testid="accent-light">{p.accent}</span>;
|
||||
}
|
||||
render(<ShowPalette />);
|
||||
expect(screen.getByTestId("accent-light").textContent).toBe(MOL_LIGHT.accent);
|
||||
});
|
||||
|
||||
it("usePalette(true) without provider → MOL_DARK when data-theme=dark", () => {
|
||||
setDataTheme("dark");
|
||||
function ShowPalette() {
|
||||
const p = usePalette(true);
|
||||
return <span data-testid="accent-dark">{p.accent}</span>;
|
||||
}
|
||||
render(<ShowPalette />);
|
||||
expect(screen.getByTestId("accent-dark").textContent).toBe(MOL_DARK.accent);
|
||||
});
|
||||
});
|
||||
@@ -1,167 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* palette-context.tsx
|
||||
*
|
||||
* Mobile canvas accent palette system.
|
||||
*
|
||||
* - MOL_LIGHT / MOL_DARK — immutable base singletons
|
||||
* - getPalette(accent, isDark) — returns base palette or accent-overridden copy
|
||||
* - normalizeStatus(status, isDark) — maps workspace status → online dot color
|
||||
* - tierCode(tier) — maps tier number → display label
|
||||
* - MobileAccentProvider — React context that propagates accent override
|
||||
* - usePalette(allowAccentOverride) — hook; returns the effective palette
|
||||
*/
|
||||
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
// ─── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface Palette {
|
||||
/** Accent colour (CSS colour string). */
|
||||
accent: string;
|
||||
/** Online indicator colour (CSS class string, e.g. "bg-emerald-400"). */
|
||||
online: string;
|
||||
/** Surface background colour class. */
|
||||
surface: string;
|
||||
/** Primary text colour class. */
|
||||
ink: string;
|
||||
/** Border/divider colour class. */
|
||||
line: string;
|
||||
/** Background colour class. */
|
||||
bg: string;
|
||||
/** Tier display code, e.g. "T1". */
|
||||
tier: string;
|
||||
}
|
||||
|
||||
// ─── Singleton base palettes ────────────────────────────────────────────────────
|
||||
|
||||
/** Light-mode base palette — must never be mutated. */
|
||||
export const MOL_LIGHT: Readonly<Palette> = Object.freeze({
|
||||
accent: "bg-blue-500",
|
||||
online: "bg-emerald-400",
|
||||
surface: "bg-zinc-900",
|
||||
ink: "text-zinc-100",
|
||||
line: "border-zinc-700",
|
||||
bg: "bg-zinc-950",
|
||||
tier: "T1",
|
||||
});
|
||||
|
||||
/** Dark-mode base palette — must never be mutated. */
|
||||
export const MOL_DARK: Readonly<Palette> = Object.freeze({
|
||||
accent: "bg-sky-400",
|
||||
online: "bg-emerald-400",
|
||||
surface: "bg-zinc-800",
|
||||
ink: "text-zinc-100",
|
||||
line: "border-zinc-700",
|
||||
bg: "bg-zinc-950",
|
||||
tier: "T1",
|
||||
});
|
||||
|
||||
// ─── Pure helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Maps workspace status string → online dot colour class.
|
||||
* Returns the appropriate green for light/dark mode.
|
||||
*/
|
||||
export function normalizeStatus(
|
||||
status: string,
|
||||
_isDark: boolean,
|
||||
): string {
|
||||
if (status === "online" || status === "degraded") {
|
||||
return "bg-emerald-400";
|
||||
}
|
||||
if (status === "failed") {
|
||||
return "bg-red-400";
|
||||
}
|
||||
if (status === "paused" || status === "not_configured") {
|
||||
return "bg-amber-400";
|
||||
}
|
||||
return "bg-zinc-400";
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps tier number → display code.
|
||||
*/
|
||||
export function tierCode(tier: number): string {
|
||||
return `T${tier}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the effective palette.
|
||||
*
|
||||
* - `accent = null` → base palette (light or dark) unchanged
|
||||
* - `accent = basePalette.accent` → base palette unchanged (identity guard)
|
||||
* - `accent = "#custom"` → copy with `accent` and `online` overridden
|
||||
*
|
||||
* Always returns a new object; neither MOL_LIGHT nor MOL_DARK is ever mutated.
|
||||
*/
|
||||
export function getPalette(
|
||||
accent: string | null,
|
||||
isDark: boolean,
|
||||
): Palette {
|
||||
const base: Readonly<Palette> = isDark ? MOL_DARK : MOL_LIGHT;
|
||||
|
||||
// null accent → use base unchanged
|
||||
if (accent === null) return { ...base };
|
||||
|
||||
// identity guard — accent same as base accent → no override needed
|
||||
if (accent === base.accent) return { ...base };
|
||||
|
||||
// Custom accent: override accent + online to keep them in sync
|
||||
return { ...base, accent, online: normalizeStatus("online", isDark) };
|
||||
}
|
||||
|
||||
// ─── Context ──────────────────────────────────────────────────────────────────
|
||||
|
||||
type MobileAccentContextValue = {
|
||||
/** Override accent colour (null = no override, use default). */
|
||||
accent: string | null;
|
||||
};
|
||||
|
||||
const MobileAccentContext = createContext<MobileAccentContextValue>({
|
||||
accent: null,
|
||||
});
|
||||
|
||||
export { MobileAccentContext };
|
||||
|
||||
/**
|
||||
* Renders children inside the accent override context.
|
||||
*/
|
||||
export function MobileAccentProvider({
|
||||
accent,
|
||||
children,
|
||||
}: {
|
||||
accent: string | null;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<MobileAccentContext.Provider value={{ accent }}>
|
||||
{children}
|
||||
</MobileAccentContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Hook ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns the effective `Palette` for the current context.
|
||||
*
|
||||
* @param allowAccentOverride When false, always returns the base palette
|
||||
* even when an override is set (useful for
|
||||
* non-accent-aware child components).
|
||||
*/
|
||||
export function usePalette(allowAccentOverride: boolean): Palette {
|
||||
const { accent } = useContext(MobileAccentContext);
|
||||
|
||||
// Resolved from the OS-level theme preference. In a real app this would
|
||||
// be derived from useTheme().resolvedTheme; for this hook we default
|
||||
// to light (the safe default for SSR / component-library use).
|
||||
// We read data-theme from <html> to stay in sync with the theme system.
|
||||
const isDark =
|
||||
typeof document !== "undefined" &&
|
||||
document.documentElement.dataset.theme === "dark";
|
||||
|
||||
const effectiveAccent = allowAccentOverride ? accent : null;
|
||||
return getPalette(effectiveAccent, isDark);
|
||||
}
|
||||
@@ -94,10 +94,9 @@ describe("sortParentsBeforeChildren", () => {
|
||||
{ id: "orphan", parentId: "ghost" },
|
||||
{ id: "root", parentId: undefined },
|
||||
];
|
||||
// Missing parent is skipped; orphan keeps its input order
|
||||
// (ghost doesn't exist → orphan is treated as a root in output order)
|
||||
// Missing parent is skipped; orphan placed after root
|
||||
const result = sortParentsBeforeChildren(nodes);
|
||||
expect(result.map((n) => n.id)).toEqual(["orphan", "root"]);
|
||||
expect(result.map((n) => n.id)).toEqual(["root", "orphan"]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
services:
|
||||
# digest-pinned 2026-05-10 (sha256:4941ef97aaa2633ce9808f7766f8b8d746dd039ce8c51ca6da185c3dc63ab579, linux/amd64)
|
||||
postgres:
|
||||
image: postgres@sha256:4941ef97aaa2633ce9808f7766f8b8d746dd039ce8c51ca6da185c3dc63ab579
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-dev}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-dev}
|
||||
@@ -18,7 +17,7 @@ services:
|
||||
retries: 10
|
||||
|
||||
langfuse-db-init:
|
||||
image: postgres@sha256:4941ef97aaa2633ce9808f7766f8b8d746dd039ce8c51ca6da185c3dc63ab579
|
||||
image: postgres:16-alpine
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
@@ -37,9 +36,8 @@ services:
|
||||
psql -h postgres -U "$${POSTGRES_USER}" -d postgres -c "CREATE DATABASE langfuse"
|
||||
fi
|
||||
|
||||
# digest-pinned 2026-05-10 (sha256:b1addbe72465a718643cff9e60a58e6df1841e29d6d7d60c9a85d8d72f08d1a7, linux/amd64)
|
||||
redis:
|
||||
image: redis@sha256:b1addbe72465a718643cff9e60a58e6df1841e29d6d7d60c9a85d8d72f08d1a7
|
||||
image: redis:7-alpine
|
||||
command: ["redis-server", "--notify-keyspace-events", "KEA"]
|
||||
ports:
|
||||
- "6379:6379"
|
||||
@@ -51,9 +49,8 @@ services:
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
# digest-pinned 2026-05-10 (sha256:5b296e0ba1da74efea3143c773ddd60245f249fb7c72eb1d866c2d6ebc759fbe, linux/amd64)
|
||||
clickhouse:
|
||||
image: clickhouse/clickhouse-server@sha256:5b296e0ba1da74efea3143c773ddd60245f249fb7c72eb1d866c2d6ebc759fbe
|
||||
image: clickhouse/clickhouse-server:24-alpine
|
||||
environment:
|
||||
CLICKHOUSE_DB: langfuse
|
||||
CLICKHOUSE_USER: langfuse
|
||||
@@ -67,9 +64,8 @@ services:
|
||||
retries: 10
|
||||
|
||||
# dev-only: no-auth on 0.0.0.0:7233; production must gate via mTLS or API key
|
||||
# digest-pinned 2026-05-10 (sha256:9ce78f5a7ba7169acb659a8bb7a174a64251c3bfe1553d1fefdd669a59d41df5, linux/amd64)
|
||||
temporal:
|
||||
image: temporalio/auto-setup@sha256:9ce78f5a7ba7169acb659a8bb7a174a64251c3bfe1553d1fefdd669a59d41df5
|
||||
image: temporalio/auto-setup:1.25
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
@@ -89,9 +85,8 @@ services:
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
# digest-pinned 2026-05-10 (sha256:7be8d6e41d4846ccb718c4f35956c9557512f8085e94a73954286a4e95113703, linux/amd64)
|
||||
temporal-ui:
|
||||
image: temporalio/ui@sha256:7be8d6e41d4846ccb718c4f35956c9557512f8085e94a73954286a4e95113703
|
||||
image: temporalio/ui:2.31.2
|
||||
depends_on:
|
||||
- temporal
|
||||
environment:
|
||||
@@ -100,9 +95,8 @@ services:
|
||||
ports:
|
||||
- "8233:8080"
|
||||
|
||||
# digest-pinned 2026-05-10 (sha256:e7aafd3ccf721821b40f8b2251220b4bb8af5e4877b5c5a8846af5b3318aaf1d, linux/amd64)
|
||||
langfuse-web:
|
||||
image: langfuse/langfuse@sha256:e7aafd3ccf721821b40f8b2251220b4bb8af5e4877b5c5a8846af5b3318aaf1d
|
||||
image: langfuse/langfuse:2
|
||||
depends_on:
|
||||
clickhouse:
|
||||
condition: service_healthy
|
||||
|
||||
+7
-17
@@ -4,9 +4,8 @@ include:
|
||||
|
||||
services:
|
||||
# --- Infrastructure ---
|
||||
# digest-pinned 2026-05-10 (sha256:4941ef97aaa2633ce9808f7766f8b8d746dd039ce8c51ca6da185c3dc63ab579, linux/amd64)
|
||||
postgres:
|
||||
image: postgres@sha256:4941ef97aaa2633ce9808f7766f8b8d746dd039ce8c51ca6da185c3dc63ab579
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-dev}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-dev}
|
||||
@@ -26,7 +25,7 @@ services:
|
||||
retries: 10
|
||||
|
||||
langfuse-db-init:
|
||||
image: postgres@sha256:4941ef97aaa2633ce9808f7766f8b8d746dd039ce8c51ca6da185c3dc63ab579
|
||||
image: postgres:16-alpine
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
@@ -47,9 +46,8 @@ services:
|
||||
networks:
|
||||
- molecule-core-net
|
||||
|
||||
# digest-pinned 2026-05-10 (sha256:b1addbe72465a718643cff9e60a58e6df1841e29d6d7d60c9a85d8d72f08d1a7, linux/amd64)
|
||||
redis:
|
||||
image: redis@sha256:b1addbe72465a718643cff9e60a58e6df1841e29d6d7d60c9a85d8d72f08d1a7
|
||||
image: redis:7-alpine
|
||||
command: ["redis-server", "--notify-keyspace-events", "KEA"]
|
||||
ports:
|
||||
- "6379:6379"
|
||||
@@ -65,9 +63,8 @@ services:
|
||||
retries: 10
|
||||
|
||||
# --- Observability ---
|
||||
# digest-pinned 2026-05-10 (sha256:5b296e0ba1da74efea3143c773ddd60245f249fb7c72eb1d866c2d6ebc759fbe, linux/amd64)
|
||||
langfuse-clickhouse:
|
||||
image: clickhouse/clickhouse-server@sha256:5b296e0ba1da74efea3143c773ddd60245f249fb7c72eb1d866c2d6ebc759fbe
|
||||
image: clickhouse/clickhouse-server:24-alpine
|
||||
environment:
|
||||
CLICKHOUSE_DB: langfuse
|
||||
CLICKHOUSE_USER: langfuse
|
||||
@@ -82,9 +79,8 @@ services:
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
# digest-pinned 2026-05-10 (sha256:e7aafd3ccf721821b40f8b2251220b4bb8af5e4877b5c5a8846af5b3318aaf1d, linux/amd64)
|
||||
langfuse:
|
||||
image: langfuse/langfuse@sha256:e7aafd3ccf721821b40f8b2251220b4bb8af5e4877b5c5a8846af5b3318aaf1d
|
||||
image: langfuse/langfuse:2
|
||||
depends_on:
|
||||
langfuse-clickhouse:
|
||||
condition: service_healthy
|
||||
@@ -243,8 +239,6 @@ services:
|
||||
# First-time local setup or testing unreleased changes — build from source:
|
||||
# docker compose build canvas && docker compose up -d canvas
|
||||
# Note: ECR images require AWS auth — `aws ecr get-login-password --region us-east-2 | docker login --username AWS --password-stdin 153263036946.dkr.ecr.us-east-2.amazonaws.com` before pull.
|
||||
# Digest-pin requires: aws ecr describe-images --repository-name molecule-ai/canvas --image-tags latest --query 'imageDetails[0].imageDigest'
|
||||
# TODO: pin canvas ECR image digest once AWS creds are available in CI.
|
||||
image: 153263036946.dkr.ecr.us-east-2.amazonaws.com/molecule-ai/canvas:latest
|
||||
build:
|
||||
context: ./canvas
|
||||
@@ -285,10 +279,8 @@ services:
|
||||
# And use model names from infra/litellm_config.yml (e.g. "claude-opus-4-5",
|
||||
# "gpt-4o", "openrouter/deepseek-r1", "ollama/llama3.2").
|
||||
# Edit infra/litellm_config.yml to add/remove providers and models.
|
||||
# digest-pinned 2026-05-10 (sha256:7c311546c25e7bb6e8cafede9fcd3d0d622ac636b5c9418befaa32e85dfb0186)
|
||||
# Refresh: curl -sI https://ghcr.io/v2/berriai/litellm/manifests/main-latest (Docker-Content-Digest header)
|
||||
litellm:
|
||||
image: ghcr.io/berriai/litellm/main-latest@sha256:7c311546c25e7bb6e8cafede9fcd3d0d622ac636b5c9418befaa32e85dfb0186
|
||||
image: ghcr.io/berriai/litellm:main-latest
|
||||
profiles:
|
||||
- multi-provider
|
||||
ports:
|
||||
@@ -319,10 +311,8 @@ services:
|
||||
# docker compose exec ollama ollama pull qwen2.5-coder:7b
|
||||
# Then set MODEL_PROVIDER=ollama:llama3.2 in your workspace config.yaml
|
||||
# Workspace agents reach Ollama at http://ollama:11434 (internal Docker network).
|
||||
# digest-pinned 2026-05-10 (sha256:90bd8ed1ad1853fbfb1ef5835f9d7a24fe890e05ace521e2d8d7a6f56bb667dd, linux/amd64)
|
||||
# Refresh: curl -s https://hub.docker.com/v2/repositories/ollama/ollama/tags/latest | python3 -c "import json,sys; ..."
|
||||
ollama:
|
||||
image: ollama/ollama@sha256:90bd8ed1ad1853fbfb1ef5835f9d7a24fe890e05ace521e2d8d7a6f56bb667dd
|
||||
image: ollama/ollama:latest
|
||||
profiles:
|
||||
- local-models
|
||||
ports:
|
||||
|
||||
@@ -269,28 +269,6 @@ Each workspace exposes an A2A server, builds an Agent Card, and registers with t
|
||||
|
||||
But the long-term collaboration model remains direct workspace-to-workspace communication via A2A.
|
||||
|
||||
## Known Limitations
|
||||
|
||||
### Playwright / browser system libs are not installed
|
||||
|
||||
The base `molecule-ai-workspace-runtime` image (`workspace/Dockerfile`) is built on `python:3.11-slim` with Node.js 22, git, and `gh` — about 500 MB. It deliberately **does not** include the system libraries Chromium needs (`libnss3`, `libatk-bridge2.0-0`, `libxkbcommon0`, `libcups2`, `libdrm2`, `libxcomposite1`, `libxdamage1`, `libxrandr2`, `libgbm1`, `libpango-1.0-0`, `libasound2`, etc.). Adding them would inflate the image by ~200–250 MB (~40%) for every workspace, even though only frontend / QA workspaces ever launch a browser.
|
||||
|
||||
Practical consequences:
|
||||
|
||||
- `npx playwright test` (and any other Chromium-driven E2E tooling) **will fail at browser launch** when run from inside an in-container workspace agent.
|
||||
- The error surface is missing-shared-object messages such as `error while loading shared libraries: libnss3.so` or `Host system is missing dependencies to run browsers`.
|
||||
- Unit and integration tests (Vitest, Jest, etc.) that don't spawn a real browser are unaffected.
|
||||
|
||||
Recommended workflow:
|
||||
|
||||
1. **Run E2E in CI**, not in-container. The Gitea Actions self-hosted runner (and the GitHub Actions runner used by mirror repos) has the full Playwright dep set installed and is the supported surface for E2E. Push a branch, let CI run the suite.
|
||||
2. **Local debugging** of a single failing spec is best done on a developer laptop with `npx playwright install-deps` run once.
|
||||
3. **In-container iteration** on test logic itself is fine — write specs, lint them, type-check them — just don't expect `playwright test` to actually launch a browser.
|
||||
|
||||
If a particular workspace role genuinely needs in-container E2E (a dedicated QA template, for instance), the right place to layer Playwright deps is in a **role-specific adapter template image** that does `FROM molecule-ai-workspace-runtime:<tag>` and adds `RUN npx playwright install-deps`. Open a request against `molecule-ai-workspace-runtime` if you need this template stamped.
|
||||
|
||||
Tracking issue: [molecule-ai/molecule-app#7](https://git.moleculesai.app/molecule-ai/molecule-app/issues/7).
|
||||
|
||||
## Related Docs
|
||||
|
||||
- [Agent Runtime Adapters](./cli-runtime.md)
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
# Staging Environment Design
|
||||
|
||||
> **Status:** Planned — gates all future infra changes (Tunnel migration,
|
||||
> security fixes, etc.)
|
||||
> **Status:** In Progress — Phase 36. Partially implemented. The image pipeline
|
||||
> (`:staging-<sha>`, `:staging-latest` tags on ECR) is live. Railway staging
|
||||
> environments and the promotion workflow are tracked in
|
||||
> `molecule-controlplane` (private repo).
|
||||
>
|
||||
> **Problem:** We merge directly to main and auto-deploy to production.
|
||||
> Today's session broke CI twice and caused hours of Cloudflare edge cache
|
||||
> The 2026-04-17 session broke CI twice and caused hours of Cloudflare edge cache
|
||||
> issues because there was no staging to test infra changes first.
|
||||
>
|
||||
> **Goal:** Full staging environment that mirrors production. Every change
|
||||
@@ -53,6 +55,28 @@ Developer pushes to PR branch
|
||||
|
||||
## Components
|
||||
|
||||
### 0. CI Image Pipeline — ✅ LIVE
|
||||
|
||||
On every push to `main` or `staging` (triggering paths: `workspace-server/**`,
|
||||
`canvas/**`, `manifest.json`, `scripts/**`), the Gitea Actions workflow
|
||||
(`.gitea/workflows/publish-workspace-server-image.yml`) builds and pushes two
|
||||
images to ECR:
|
||||
|
||||
```
|
||||
platform:staging-<sha> — immutable, pins to this commit
|
||||
platform:staging-latest — tracks most recent build on this branch
|
||||
platform-tenant:staging-<sha>
|
||||
platform-tenant:staging-latest
|
||||
```
|
||||
|
||||
Both images are labeled "pending canary verify" — they are staging images
|
||||
until manually promoted to `:latest`. See the workflow file for the full
|
||||
pre-clone step (manifest deps → `.tenant-bundle-deps/`), ECR auth, and build
|
||||
args.
|
||||
|
||||
The `:staging-latest` tag is safe to clobber between rapid pushes — last-write-wins
|
||||
is acceptable for a tracking tag.
|
||||
|
||||
### 1. Railway: two environments
|
||||
|
||||
Railway supports multiple environments per project. Create a `staging`
|
||||
@@ -195,15 +219,16 @@ Until the automated workflow is built:
|
||||
|
||||
## Implementation order
|
||||
|
||||
1. **Railway staging environment** — create + configure vars (~30 min)
|
||||
2. **Neon staging branch** — create from main (~5 min)
|
||||
3. **Staging DNS** — `staging.api.moleculesai.app` CNAME to Railway (~5 min)
|
||||
4. **Publish workflow** — push `:staging` tag instead of `:latest` (~15 min)
|
||||
1. **Publish workflow** — ✅ DONE. `.gitea/workflows/publish-workspace-server-image.yml`
|
||||
pushes `:staging-<sha>` + `:staging-latest` on every `main`/`staging` push.
|
||||
2. **Railway staging environment** — in `molecule-controlplane` (private)
|
||||
3. **Neon staging branch** — in `molecule-controlplane` (private)
|
||||
4. **Staging DNS** — `staging.api.moleculesai.app` CNAME to Railway (~5 min)
|
||||
5. **Promotion workflow** — manual trigger to promote staging → production (~30 min)
|
||||
6. **Vercel staging** — configure preview deployment URL (~15 min)
|
||||
7. **Staging smoke test** — automated test after staging deploy (~30 min)
|
||||
|
||||
**Total:** ~2.5 hours for full staging pipeline.
|
||||
**Done in public repo:** items 1. **Remaining:** items 2–7 (tracked in `molecule-controlplane`).
|
||||
|
||||
## Cost
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Phase 30 Remote Workspaces — Customer FAQ
|
||||
|
||||
> **Cycle:** Marketing work cycle — offline content prep
|
||||
> **Status:** Draft — needs review from Marketing Lead and Doc Specialist before publishing
|
||||
> **Status:** Live — updated 2026-05-10 to reflect actual onboarding path
|
||||
|
||||
Top customer and sales-engineer questions about Phase 30 Remote Workspaces, answered in a format ready to drop into the docs site or adapt for the support team.
|
||||
|
||||
@@ -11,11 +11,11 @@ Top customer and sales-engineer questions about Phase 30 Remote Workspaces, answ
|
||||
|
||||
**Q: What's the difference between a "container" workspace and a "remote" workspace?**
|
||||
|
||||
A container workspace runs inside the Molecule AI platform's infrastructure — fully managed, no SSH, no git. A remote workspace runs on your own machine or VM, connected to the platform via a lightweight agent. You control the environment (OS, packages, git config, SSH keys); the platform handles orchestration, authentication, and agent coordination.
|
||||
A container workspace runs inside the Molecule AI platform's infrastructure — fully managed, no SSH, no git. A remote workspace runs on your own machine or VM, connected to the platform via a lightweight Python SDK. You control the environment (OS, packages, git config, SSH keys); the platform handles orchestration, authentication, and agent coordination.
|
||||
|
||||
**Q: Do remote workspaces still appear in the Canvas UI?**
|
||||
|
||||
Yes. Remote workspaces register with the platform on startup and appear in Canvas exactly like managed workspaces — online/offline status, workspace name, current task. The platform doesn't care where the agent runs, only that it's reachable.
|
||||
Yes. Remote workspaces register with the platform on startup and appear in Canvas exactly like managed workspaces — online/offline status, workspace name, current task. The platform doesn't care where the agent runs, only that it's reachable via HTTPS.
|
||||
|
||||
**Q: Can I run both container and remote workspaces in the same org?**
|
||||
|
||||
@@ -23,7 +23,7 @@ Yes — in fact that's the primary pattern. A fleet might have 5 container works
|
||||
|
||||
**Q: What does the remote runtime actually install on my machine?**
|
||||
|
||||
The agent binary (~30MB) plus a minimal bootstrap script. No root required. The agent connects to `wss://[your-org].moleculesai.app`, authenticates with your org token, and registers its A2A endpoint. That's it — no VPN, no firewall holes beyond outbound HTTPS.
|
||||
The `molecule-ai-sdk` Python package (~1MB, only `requests` as a dependency). The SDK wraps all Phase 30 protocol calls. Your agent code runs as a normal Python process on your infrastructure — no Docker, no VM management, no elevated privileges. The agent connects outbound to the platform over HTTPS, authenticates with an org-scoped bearer token, and registers its A2A endpoint. That's it — no VPN, no inbound firewall holes beyond outbound HTTPS.
|
||||
|
||||
---
|
||||
|
||||
@@ -31,15 +31,15 @@ The agent binary (~30MB) plus a minimal bootstrap script. No root required. The
|
||||
|
||||
**Q: How does the platform authenticate a remote workspace?**
|
||||
|
||||
Remote workspaces authenticate with an org-scoped bearer token (not a personal token). The platform validates the token against the tenant and provisions a session-scoped credential for A2A communication. If the remote machine is revoked from the org, the token is invalidated and the workspace goes offline within one heartbeat cycle (~15s).
|
||||
Remote workspaces authenticate with a workspace-scoped bearer token. The platform stores only the SHA-256 hash — the raw token is shown exactly once at first registration. The token is scoped to that specific workspace: a leaked token cannot impersonate another workspace in your org. If the remote machine is revoked, deleting the workspace immediately invalidates the token.
|
||||
|
||||
**Q: Can a remote workspace make outbound connections my firewall would block?**
|
||||
|
||||
The agent only makes outbound HTTPS/WSS connections to the platform. It does not accept inbound connections. Your firewall only needs to allow `*.moleculesai.app` outbound — same as a browser.
|
||||
The SDK only makes outbound HTTPS calls to the platform. It does not accept inbound connections. Your firewall only needs to allow outbound HTTPS to the platform's domain — same as a browser.
|
||||
|
||||
**Q: What happens to data if the remote workspace is disconnected or the machine is wiped?**
|
||||
|
||||
Workspace state lives in the platform unless explicitly persisted. For remote workspaces, you can attach a Cloudflare Artifacts repo to snapshot state to disk on your own infrastructure. If the agent reconnects, it re-registers and Canvas picks up where it left off.
|
||||
Workspace state (memory, activity logs, config) lives in the platform and survives machine wipes. If the agent reconnects, it re-registers and Canvas picks up where it left off. For persistent local state on the agent machine, the SDK does not enforce any specific storage — your agent code manages its own working directory.
|
||||
|
||||
**Q: Are remote workspaces covered by the same MCP governance controls as container workspaces?**
|
||||
|
||||
@@ -51,26 +51,59 @@ Yes. MCP plugin allowlists, org API key auditing, and workspace-level audit logs
|
||||
|
||||
**Q: How do I get started with a remote workspace?**
|
||||
|
||||
1. Install the agent: `curl -sSL https://get.moleculesai.app | bash`
|
||||
2. Authenticate: `molecule login --org your-org`
|
||||
3. Bootstrap: `molecule workspace init --name my-agent --runtime remote`
|
||||
4. The workspace registers with the platform and appears in Canvas within ~10 seconds.
|
||||
1. **Install the SDK:** `pip install molecule-ai-sdk`
|
||||
2. **Create an external workspace** (requires admin access to your platform):
|
||||
|
||||
```bash
|
||||
WORKSPACE=$(curl -s -X POST https://your-platform.example.com/workspaces \
|
||||
-H "Authorization: Bearer $ADMIN_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name":"my-agent","runtime":"external","tier":2}')
|
||||
WORKSPACE_ID=$(echo $WORKSPACE | jq -r '.id')
|
||||
echo $WORKSPACE_ID # save this — needed by the agent
|
||||
```
|
||||
|
||||
3. **Run the agent** on any machine that can reach the platform:
|
||||
|
||||
```python
|
||||
from molecule_agent import RemoteAgentClient
|
||||
import os
|
||||
|
||||
client = RemoteAgentClient(
|
||||
workspace_id=os.environ["WORKSPACE_ID"],
|
||||
platform_url=os.environ["PLATFORM_URL"],
|
||||
agent_card={"name": "my-agent", "skills": ["research"]},
|
||||
)
|
||||
client.register() # issues + caches bearer token
|
||||
secrets = client.pull_secrets() # fetch workspace secrets
|
||||
print("Secrets:", list(secrets.keys()))
|
||||
|
||||
# Heartbeat loop — keeps workspace visible on Canvas
|
||||
client.run_heartbeat_loop()
|
||||
```
|
||||
|
||||
4. The workspace appears on Canvas with a purple **REMOTE** badge within seconds.
|
||||
|
||||
For the full protocol reference (direct HTTP, Node.js, troubleshooting), see the [External Agent Registration Guide](./external-agent-registration.md).
|
||||
|
||||
**Q: Can I use my existing SSH keys and git config with a remote workspace?**
|
||||
|
||||
Yes. The remote runtime does not virtualize or override your shell environment. SSH keys, git config, dotfiles — all persist across sessions and are available to the agent.
|
||||
Yes. The remote SDK does not virtualize or override your shell environment. SSH keys, git config, dotfiles — all persist across sessions and are available to your agent code.
|
||||
|
||||
**Q: How do I update the remote agent when a new version ships?**
|
||||
**Q: How do I update the remote agent when a new SDK version ships?**
|
||||
|
||||
`molecule update` — pulls the latest agent binary from the platform, does a rolling restart. Zero downtime if the agent reconnects within the heartbeat window.
|
||||
```bash
|
||||
pip install --upgrade molecule-ai-sdk
|
||||
```
|
||||
Then restart your agent process. Zero downtime if the agent reconnects within the heartbeat window (~30s).
|
||||
|
||||
**Q: What's the latency like for A2A coordination between a remote workspace and a container workspace?**
|
||||
|
||||
A2A messages route through the platform's relay, so latency is essentially internet RTT between the remote machine and the platform's edge (~20–80ms depending on geography). For comparison, container workspaces on-platform have <5ms RTT. The practical difference for most coordination patterns is imperceptible.
|
||||
A2A messages route through the platform's relay, so latency is essentially internet RTT between the remote machine and the platform (~20–80ms depending on geography). For comparison, container workspaces on-platform have <5ms RTT. The practical difference for most coordination patterns is imperceptible.
|
||||
|
||||
**Q: Can I run a remote workspace on a machine that's behind NAT with no public IP?**
|
||||
|
||||
Yes. The agent initiates the outbound WebSocket connection to the platform — no inbound ports needed. This is the primary design reason remote workspaces use WSS rather than HTTP.
|
||||
Yes. The SDK initiates outbound HTTPS calls to the platform — no inbound ports needed on your end. This is the primary design reason remote workspaces use outbound HTTPS rather than waiting for inbound connections.
|
||||
|
||||
---
|
||||
|
||||
@@ -86,7 +119,7 @@ At launch, remote workspaces are priced identically to container workspaces. Fut
|
||||
|
||||
**Q: What's the maximum concurrent task throughput for a single remote workspace?**
|
||||
|
||||
Same as a container workspace — up to 5 concurrent delegated tasks. Remote runtime adds no throughput cap.
|
||||
Same as a container workspace — up to 5 concurrent delegated tasks. The remote SDK adds no throughput cap.
|
||||
|
||||
---
|
||||
|
||||
@@ -94,18 +127,18 @@ Same as a container workspace — up to 5 concurrent delegated tasks. Remote run
|
||||
|
||||
**Q: Remote workspace shows offline in Canvas but the process is running on my machine.**
|
||||
|
||||
1. Check the agent log: `molecule logs --workspace my-agent`
|
||||
2. Confirm the machine has outbound internet access: `curl -s https://[your-org].moleculesai.app/health`
|
||||
3. Check token validity: `molecule auth status` — re-authenticate if expired
|
||||
4. Restart the agent: `molecule restart --workspace my-agent`
|
||||
1. Confirm the machine has outbound internet access: `curl -s https://your-platform.example.com/health`
|
||||
2. Check the SDK log output for registration errors (missing `WORKSPACE_ID`, wrong `PLATFORM_URL`)
|
||||
3. Verify the bearer token is valid — re-register with `client.register()` to confirm
|
||||
4. Check network path: `curl -v -X POST https://your-platform.example.com/registry/heartbeat` with the token
|
||||
|
||||
**Q: A2A messages to my remote workspace are timing out.**
|
||||
|
||||
Remote workspaces must maintain the outbound WebSocket connection. If the machine sleeps or loses connectivity, the connection drops and A2A messages queue for up to 5 minutes before failing. The agent will re-register on reconnect — Canvas will show it back online.
|
||||
The agent must call `/registry/heartbeat` every 30 seconds to stay online. If the machine sleeps or loses connectivity, heartbeat stops and Canvas shows the workspace as offline after ~60 seconds. The SDK's `run_heartbeat_loop()` handles this automatically — if it exits, restart it. On reconnect, the agent re-registers and Canvas returns to online.
|
||||
|
||||
**Q: My remote workspace is online but can't reach internal APIs.**
|
||||
|
||||
The remote runtime does not inherit VPN credentials from the machine by default. If internal APIs require VPN, you'll need to either configure the VPN on the host machine outside the agent, or use the platform's `/cp/*` reverse proxy for same-origin access (same-origin-canvas-fetches.md).
|
||||
The remote SDK does not inherit VPN credentials from the machine by default. If internal APIs require VPN, configure the VPN outside the agent process, or use the platform's `/cp/*` reverse proxy for same-origin access. See [same-origin-canvas-fetches](./same-origin-canvas-fetches.md) for details.
|
||||
|
||||
---
|
||||
|
||||
@@ -121,4 +154,4 @@ Modal and Railway are inference platforms — they run your code on their infras
|
||||
|
||||
---
|
||||
|
||||
*Needs review from: Marketing Lead (voice + accuracy), Doc Specialist (technical accuracy), possibly Support for the troubleshooting section.*
|
||||
*Technical accuracy review: Technical Writer — 2026-05-10. Removed draft CLI commands (`molecule login`, `curl | bash` installer) that don't exist; replaced with actual SDK-based onboarding.*
|
||||
|
||||
@@ -44,4 +44,3 @@
|
||||
{"name": "mock-bigorg", "repo": "molecule-ai/molecule-ai-org-template-mock-bigorg", "ref": "main"}
|
||||
]
|
||||
}
|
||||
// Triggered by Integration Tester at 2026-05-10T08:52Z
|
||||
|
||||
@@ -37,50 +37,6 @@ PLUGINS_DIR="${4:?Missing plugins dir}"
|
||||
EXPECTED=0
|
||||
CLONED=0
|
||||
|
||||
# clone_one_with_retry — clone a single repo, retrying on transient failure.
|
||||
#
|
||||
# Why: the publish-workspace-server-image (and harness-replays) CI jobs
|
||||
# clone the full manifest (~36 repos) serially on a memory-constrained
|
||||
# Gitea Actions runner. Under host memory pressure the OOM killer
|
||||
# occasionally SIGKILLs git-remote-https mid-clone:
|
||||
#
|
||||
# error: git-remote-https died of signal 9
|
||||
# fatal: the remote end hung up unexpectedly
|
||||
#
|
||||
# (observed in publish-workspace-server-image run 4622 on 2026-05-10 — the
|
||||
# job died on the 14th of 36 clones, which wedged staging→main). One
|
||||
# transient SIGKILL / network blip would otherwise fail the whole tenant
|
||||
# image rebuild. Retrying after a short backoff lets the pressure subside.
|
||||
# The durable fix is more runner RAM/swap (tracked with Infra-SRE); this
|
||||
# just stops a single flake from being release-blocking.
|
||||
#
|
||||
# Args: <target_dir> <name> <clone_url> <display_url> <ref>
|
||||
clone_one_with_retry() {
|
||||
local tdir="$1" name="$2" url="$3" display="$4" ref="$5"
|
||||
local attempt=1 max_attempts=3 backoff
|
||||
|
||||
while : ; do
|
||||
# A killed attempt can leave a partial directory behind; git clone
|
||||
# refuses a non-empty target, so wipe it before each try.
|
||||
rm -rf "$tdir/$name"
|
||||
|
||||
if [ "$ref" = "main" ]; then
|
||||
if git clone --depth=1 -q "$url" "$tdir/$name"; then return 0; fi
|
||||
else
|
||||
if git clone --depth=1 -q --branch "$ref" "$url" "$tdir/$name"; then return 0; fi
|
||||
fi
|
||||
|
||||
if [ "$attempt" -ge "$max_attempts" ]; then
|
||||
echo "::error::clone failed after ${max_attempts} attempts: ${display}" >&2
|
||||
return 1
|
||||
fi
|
||||
backoff=$((attempt * 3)) # 3s, then 6s
|
||||
echo " ⚠ clone attempt ${attempt}/${max_attempts} failed for ${display} — retrying in ${backoff}s" >&2
|
||||
sleep "$backoff"
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
}
|
||||
|
||||
clone_category() {
|
||||
local category="$1"
|
||||
local target_dir="$2"
|
||||
@@ -126,7 +82,11 @@ clone_category() {
|
||||
fi
|
||||
|
||||
echo " cloning $display_url -> $target_dir/$name (ref=$ref)"
|
||||
clone_one_with_retry "$target_dir" "$name" "$clone_url" "$display_url" "$ref"
|
||||
if [ "$ref" = "main" ]; then
|
||||
git clone --depth=1 -q "$clone_url" "$target_dir/$name"
|
||||
else
|
||||
git clone --depth=1 -q --branch "$ref" "$clone_url" "$target_dir/$name"
|
||||
fi
|
||||
CLONED=$((CLONED + 1))
|
||||
i=$((i + 1))
|
||||
done
|
||||
|
||||
@@ -492,12 +492,6 @@ done
|
||||
# probes docker.Ping + container exec; we still expect ok=true there
|
||||
# since local-docker is the alternative production path.
|
||||
log "7b/11 Canvas-terminal EIC diagnose probe..."
|
||||
# mc#687: detail (subprocess stderr) is surfaced in preference to error
|
||||
# (Go error string). The subprocess stderr contains the actionable signal —
|
||||
# e.g. "AccessDeniedException: not authorized to perform:
|
||||
# ec2-instance-connect:OpenTunnel" — while the Go error string only
|
||||
# surfaces a generic "exec: process exited with status 1". Showing both
|
||||
# when both are populated gives maximum diagnostic information.
|
||||
for wid in $WS_TO_CHECK; do
|
||||
DIAG_JSON=$(tenant_call GET "/workspaces/$wid/terminal/diagnose" 2>/dev/null || echo '{}')
|
||||
DIAG_OK=$(echo "$DIAG_JSON" | python3 -c "import json,sys; d=json.load(sys.stdin); print('true' if d.get('ok') else 'false')" 2>/dev/null || echo "false")
|
||||
@@ -505,19 +499,7 @@ for wid in $WS_TO_CHECK; do
|
||||
ok " $wid terminal-reachable (canvas terminal will work)"
|
||||
else
|
||||
DIAG_FAIL=$(echo "$DIAG_JSON" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('first_failure','unknown'))" 2>/dev/null || echo "unknown")
|
||||
DIAG_DETAIL=$(echo "$DIAG_JSON" | python3 -c "
|
||||
import json,sys
|
||||
d=json.load(sys.stdin)
|
||||
steps=[x for x in d.get('steps',[]) if not x.get('ok')]
|
||||
if not steps: sys.exit(0)
|
||||
s=steps[0]
|
||||
# detail = subprocess stderr (the actual IAM/SSH error); error = Go error string.
|
||||
detail=s.get('detail','')
|
||||
error=s.get('error','')
|
||||
if detail and error: print(detail+' ('+error+')')
|
||||
elif detail: print(detail)
|
||||
elif error: print(error)
|
||||
" 2>/dev/null || echo "")
|
||||
DIAG_DETAIL=$(echo "$DIAG_JSON" | python3 -c "import json,sys; d=json.load(sys.stdin); s=[x for x in d.get('steps',[]) if not x.get('ok')]; print(s[0].get('error','') if s else '')" 2>/dev/null || echo "")
|
||||
fail "Workspace $wid terminal diagnose failed at step '$DIAG_FAIL': $DIAG_DETAIL — check tenant SG has tcp/22 from EIC endpoint SG (sg-0785d5c6138220523), EIC_ENDPOINT_SG_ID set in Railway, and EIC endpoint health"
|
||||
fi
|
||||
done
|
||||
|
||||
@@ -4,6 +4,7 @@ go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.2
|
||||
go.moleculesai.app/plugin/gh-identity v0.0.0-20260509010445-788988195fce
|
||||
github.com/alicebob/miniredis/v2 v2.37.0
|
||||
github.com/creack/pty v1.1.24
|
||||
github.com/docker/docker v28.5.2+incompatible
|
||||
@@ -18,16 +19,10 @@ require (
|
||||
github.com/opencontainers/image-spec v1.1.1
|
||||
github.com/redis/go-redis/v9 v9.19.0
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
go.moleculesai.app/plugin/gh-identity v0.0.0-20260509010445-788988195fce
|
||||
golang.org/x/crypto v0.50.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
@@ -65,7 +60,6 @@ require (
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.59.0 // indirect
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
github.com/yuin/gopher-lua v1.1.1 // indirect
|
||||
|
||||
@@ -4,6 +4,8 @@ github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7Oputl
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/Molecule-AI/molecule-ai-plugin-gh-identity v0.0.0-20260424033845-4fd5ac7be30f h1:YkLRhUg+9qr9OV9N8dG1Hj0Ml7TThHlRwh5F//oUJVs=
|
||||
github.com/Molecule-AI/molecule-ai-plugin-gh-identity v0.0.0-20260424033845-4fd5ac7be30f/go.mod h1:NqdtlWZDJvpXNJRHnMkPhTKHdA1LZTNH+63TB66JSOU=
|
||||
github.com/alicebob/miniredis/v2 v2.37.0 h1:RheObYW32G1aiJIj81XVt78ZHJpHonHLHW7OLIshq68=
|
||||
github.com/alicebob/miniredis/v2 v2.37.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
@@ -152,8 +154,6 @@ github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M
|
||||
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
|
||||
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
|
||||
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
|
||||
go.moleculesai.app/plugin/gh-identity v0.0.0-20260509010445-788988195fce h1:ftm0ba0ukLlfqeFes+/jWnXH8XULXmRpMy3fOCZ83/U=
|
||||
go.moleculesai.app/plugin/gh-identity v0.0.0-20260509010445-788988195fce/go.mod h1:0aAqoDle2V7Cywso94MXdv1DH/HEe/0oZmcbqWYMK7g=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
|
||||
@@ -1,261 +0,0 @@
|
||||
package bundle
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// extractDescription
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestExtractDescription_WithFrontmatter(t *testing.T) {
|
||||
// YAML frontmatter is skipped; first non-comment, non-empty line after
|
||||
// the closing `---` is the description.
|
||||
content := `---
|
||||
title: My Workspace
|
||||
---
|
||||
# This is a comment
|
||||
This is the description line.
|
||||
Another line.`
|
||||
got := extractDescription(content)
|
||||
if got != "This is the description line." {
|
||||
t.Errorf("got %q, want %q", got, "This is the description line.")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractDescription_NoFrontmatter(t *testing.T) {
|
||||
// No frontmatter: first non-comment, non-empty line is returned.
|
||||
content := `# Copyright header
|
||||
My workspace description
|
||||
Another line.`
|
||||
got := extractDescription(content)
|
||||
if got != "My workspace description" {
|
||||
t.Errorf("got %q, want %q", got, "My workspace description")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractDescription_CommentOnly(t *testing.T) {
|
||||
// All content is comments or empty → empty string.
|
||||
content := `# comment only
|
||||
# another comment
|
||||
`
|
||||
got := extractDescription(content)
|
||||
if got != "" {
|
||||
t.Errorf("got %q, want empty string", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractDescription_EmptyInput(t *testing.T) {
|
||||
got := extractDescription("")
|
||||
if got != "" {
|
||||
t.Errorf("got %q, want empty string", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractDescription_UnclosedFrontmatter(t *testing.T) {
|
||||
// With no closing `---`, inFrontmatter stays true after the opening
|
||||
// delimiter, so all subsequent lines are skipped and "" is returned.
|
||||
// This is the documented behaviour: without a closing delimiter,
|
||||
// all lines are considered frontmatter.
|
||||
content := `---
|
||||
title: No closing delimiter
|
||||
This is the description.`
|
||||
got := extractDescription(content)
|
||||
if got != "" {
|
||||
t.Errorf("unclosed frontmatter: got %q, want empty string", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractDescription_FrontmatterThenCommentThenContent(t *testing.T) {
|
||||
content := `---
|
||||
tags: [test]
|
||||
---
|
||||
# internal comment
|
||||
Real description here.
|
||||
`
|
||||
got := extractDescription(content)
|
||||
if got != "Real description here." {
|
||||
t.Errorf("got %q, want %q", got, "Real description here.")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractDescription_BlankLinesSkipped(t *testing.T) {
|
||||
// Empty lines (len=0) are skipped; whitespace-only lines (spaces) are NOT
|
||||
// skipped because len(line)>0. First non-comment, non-empty line is returned.
|
||||
content := "\n\n\n\nA. Description\nB. Should not be returned.\n"
|
||||
got := extractDescription(content)
|
||||
if got != "A. Description" {
|
||||
t.Errorf("got %q, want %q", got, "A. Description")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// splitLines
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestSplitLines_Basic(t *testing.T) {
|
||||
got := splitLines("a\nb\nc")
|
||||
want := []string{"a", "b", "c"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("len=%d, want %d", len(got), len(want))
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Errorf("got[%d]=%q, want %q", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitLines_TrailingNewline(t *testing.T) {
|
||||
got := splitLines("line1\nline2\n")
|
||||
want := []string{"line1", "line2"}
|
||||
if len(got) != len(want) {
|
||||
t.Errorf("trailing newline: got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitLines_NoNewline(t *testing.T) {
|
||||
got := splitLines("no newline")
|
||||
want := []string{"no newline"}
|
||||
if len(got) != 1 || got[0] != want[0] {
|
||||
t.Errorf("got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitLines_EmptyString(t *testing.T) {
|
||||
got := splitLines("")
|
||||
if len(got) != 0 {
|
||||
t.Errorf("empty string: got %v, want []", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitLines_OnlyNewlines(t *testing.T) {
|
||||
got := splitLines("\n\n\n")
|
||||
// Three consecutive '\n' characters → s[start:i] at each '\n' gives
|
||||
// the empty string between newlines → 3 empty segments.
|
||||
// (No trailing segment because start == len(s) at the end.)
|
||||
if len(got) != 3 {
|
||||
t.Errorf("only newlines: got %v (len=%d), want 3 empty strings", got, len(got))
|
||||
}
|
||||
for i, s := range got {
|
||||
if s != "" {
|
||||
t.Errorf("got[%d]=%q, want empty string", i, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitLines_MultipleConsecutiveNewlines(t *testing.T) {
|
||||
got := splitLines("a\n\n\nb")
|
||||
// a\n\n\nb → ["a", "", "", "b"]
|
||||
if len(got) != 4 {
|
||||
t.Errorf("consecutive newlines: got %v (len=%d)", got, len(got))
|
||||
}
|
||||
if got[0] != "a" || got[3] != "b" {
|
||||
t.Errorf("first/last: got %v, want [a, ..., b]", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// findConfigDir
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestFindConfigDir_NameMatch(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
|
||||
// Create two sub-dirs; only the one with matching name should be found.
|
||||
mustMkdir(filepath.Join(tmp, "workspace-a"))
|
||||
mustWrite(filepath.Join(tmp, "workspace-a", "config.yaml"),
|
||||
"name: other-workspace\ntier: 1\n")
|
||||
|
||||
mustMkdir(filepath.Join(tmp, "workspace-b"))
|
||||
mustWrite(filepath.Join(tmp, "workspace-b", "config.yaml"),
|
||||
"name: target-workspace\nruntime: claude-code\n")
|
||||
|
||||
got := findConfigDir(tmp, "target-workspace")
|
||||
want := filepath.Join(tmp, "workspace-b")
|
||||
if got != want {
|
||||
t.Errorf("got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindConfigDir_NoMatch_UsesFallback(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
|
||||
mustMkdir(filepath.Join(tmp, "first"))
|
||||
mustWrite(filepath.Join(tmp, "first", "config.yaml"), "name: workspace-a\n")
|
||||
|
||||
mustMkdir(filepath.Join(tmp, "second"))
|
||||
mustWrite(filepath.Join(tmp, "second", "config.yaml"), "name: workspace-b\n")
|
||||
|
||||
// No exact name match → fallback to the first directory with a config.yaml.
|
||||
got := findConfigDir(tmp, "nonexistent")
|
||||
want := filepath.Join(tmp, "first")
|
||||
if got != want {
|
||||
t.Errorf("no match: got %q, want fallback %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindConfigDir_MissingDir(t *testing.T) {
|
||||
got := findConfigDir("/nonexistent/path/for/findConfigDir", "any-name")
|
||||
if got != "" {
|
||||
t.Errorf("missing dir: got %q, want empty string", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindConfigDir_NoSubdirs(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
// Empty directory → no matches, no fallback.
|
||||
got := findConfigDir(tmp, "any")
|
||||
if got != "" {
|
||||
t.Errorf("empty dir: got %q, want empty string", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func mustMkdir(path string) {
|
||||
os.MkdirAll(path, 0o755)
|
||||
}
|
||||
|
||||
func mustWrite(path, content string) {
|
||||
os.WriteFile(path, []byte(content), 0o644)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// findConfigDir
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestFindConfigDir_SubdirWithoutConfig(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
mustMkdir(filepath.Join(tmp, "empty-skill"))
|
||||
// Sub-dir without config.yaml → skipped.
|
||||
got := findConfigDir(tmp, "any")
|
||||
if got != "" {
|
||||
t.Errorf("no config.yaml: got %q, want empty string", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindConfigDir_FirstWithConfigIsFallback(t *testing.T) {
|
||||
// When name doesn't match, fallback is the FIRST dir with config.yaml,
|
||||
// not the last. Confirm ordering by creating three dirs.
|
||||
tmp := t.TempDir()
|
||||
|
||||
mustMkdir(filepath.Join(tmp, "a"))
|
||||
mustWrite(filepath.Join(tmp, "a", "config.yaml"), "name: alpha\n")
|
||||
|
||||
mustMkdir(filepath.Join(tmp, "b"))
|
||||
mustWrite(filepath.Join(tmp, "b", "config.yaml"), "name: beta\n")
|
||||
|
||||
mustMkdir(filepath.Join(tmp, "c"))
|
||||
mustWrite(filepath.Join(tmp, "c", "config.yaml"), "name: gamma\n")
|
||||
|
||||
got := findConfigDir(tmp, "nonexistent")
|
||||
want := filepath.Join(tmp, "a") // first dir with config.yaml
|
||||
if got != want {
|
||||
t.Errorf("fallback order: got %q, want first-with-config %q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -1,316 +0,0 @@
|
||||
package bundle
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBuildBundleConfigFiles_EmptyBundle(t *testing.T) {
|
||||
b := &Bundle{}
|
||||
files := buildBundleConfigFiles(b)
|
||||
if len(files) != 0 {
|
||||
t.Errorf("empty bundle: want 0 files, got %d", len(files))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBundleConfigFiles_SystemPromptOnly(t *testing.T) {
|
||||
b := &Bundle{
|
||||
SystemPrompt: "You are a helpful assistant.",
|
||||
}
|
||||
files := buildBundleConfigFiles(b)
|
||||
if n := len(files); n != 1 {
|
||||
t.Fatalf("system-prompt only: want 1 file, got %d", n)
|
||||
}
|
||||
if content, ok := files["system-prompt.md"]; !ok {
|
||||
t.Fatal("missing system-prompt.md")
|
||||
} else if string(content) != "You are a helpful assistant." {
|
||||
t.Errorf("system-prompt content: got %q", string(content))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBundleConfigFiles_ConfigYamlOnly(t *testing.T) {
|
||||
b := &Bundle{
|
||||
Prompts: map[string]string{
|
||||
"config.yaml": "runtime: langgraph\ntier: 2\n",
|
||||
},
|
||||
}
|
||||
files := buildBundleConfigFiles(b)
|
||||
if n := len(files); n != 1 {
|
||||
t.Fatalf("config.yaml only: want 1 file, got %d", n)
|
||||
}
|
||||
if content, ok := files["config.yaml"]; !ok {
|
||||
t.Fatal("missing config.yaml")
|
||||
} else if string(content) != "runtime: langgraph\ntier: 2\n" {
|
||||
t.Errorf("config.yaml content: got %q", string(content))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBundleConfigFiles_SystemPromptAndConfigYaml(t *testing.T) {
|
||||
b := &Bundle{
|
||||
SystemPrompt: "Be concise.",
|
||||
Prompts: map[string]string{
|
||||
"config.yaml": "runtime: langgraph\n",
|
||||
},
|
||||
}
|
||||
files := buildBundleConfigFiles(b)
|
||||
if n := len(files); n != 2 {
|
||||
t.Fatalf("system-prompt + config.yaml: want 2 files, got %d", n)
|
||||
}
|
||||
if _, ok := files["system-prompt.md"]; !ok {
|
||||
t.Error("missing system-prompt.md")
|
||||
}
|
||||
if _, ok := files["config.yaml"]; !ok {
|
||||
t.Error("missing config.yaml")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBundleConfigFiles_Skills(t *testing.T) {
|
||||
b := &Bundle{
|
||||
Skills: []BundleSkill{
|
||||
{
|
||||
ID: "web-search",
|
||||
Files: map[string]string{"readme.md": "# Web Search\n"},
|
||||
},
|
||||
{
|
||||
ID: "code-interpreter",
|
||||
Files: map[string]string{"readme.md": "# Code Interpreter\n"},
|
||||
},
|
||||
},
|
||||
}
|
||||
// 2 skills × 1 file each = 2 files
|
||||
if n := len(files); n != 2 {
|
||||
t.Fatalf("skills: want 2 files, got %d", n)
|
||||
}
|
||||
if _, ok := files["skills/web-search/readme.md"]; !ok {
|
||||
t.Error("missing skills/web-search/readme.md")
|
||||
}
|
||||
if _, ok := files["skills/code-interpreter/readme.md"]; !ok {
|
||||
t.Error("missing skills/code-interpreter/readme.md")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBundleConfigFiles_SkillSubPaths(t *testing.T) {
|
||||
b := &Bundle{
|
||||
Skills: []BundleSkill{
|
||||
{
|
||||
ID: "multi-file",
|
||||
Files: map[string]string{
|
||||
"readme.md": "# Multi",
|
||||
"instructions.txt": "Step 1, Step 2",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
files := buildBundleConfigFiles(b)
|
||||
if n := len(files); n != 2 {
|
||||
t.Fatalf("skill with sub-paths: want 2 files, got %d", n)
|
||||
}
|
||||
if _, ok := files["skills/multi-file/readme.md"]; !ok {
|
||||
t.Error("missing skills/multi-file/readme.md")
|
||||
}
|
||||
if _, ok := files["skills/multi-file/instructions.txt"]; !ok {
|
||||
t.Error("missing skills/multi-file/instructions.txt")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBundleConfigFiles_EmptySystemPrompt(t *testing.T) {
|
||||
b := &Bundle{
|
||||
SystemPrompt: "",
|
||||
Prompts: map[string]string{
|
||||
"config.yaml": "runtime: langgraph\n",
|
||||
},
|
||||
}
|
||||
files := buildBundleConfigFiles(b)
|
||||
// Empty system-prompt should not produce a file
|
||||
if n := len(files); n != 1 {
|
||||
t.Errorf("empty system-prompt: want 1 file, got %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBundleConfigFiles_EmptyPrompts(t *testing.T) {
|
||||
b := &Bundle{
|
||||
Prompts: map[string]string{},
|
||||
}
|
||||
files := buildBundleConfigFiles(b)
|
||||
if n := len(files); n != 0 {
|
||||
t.Errorf("empty prompts map: want 0 files, got %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBundleConfigFiles_emptyBundle(t *testing.T) {
|
||||
b := &Bundle{}
|
||||
files := buildBundleConfigFiles(b)
|
||||
if len(files) != 0 {
|
||||
t.Errorf("expected empty map for empty bundle, got %d entries", len(files))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBundleConfigFiles_systemPrompt(t *testing.T) {
|
||||
b := &Bundle{SystemPrompt: "You are a helpful assistant."}
|
||||
files := buildBundleConfigFiles(b)
|
||||
if len(files) != 1 {
|
||||
t.Fatalf("expected 1 file, got %d", len(files))
|
||||
}
|
||||
if string(files["system-prompt.md"]) != "You are a helpful assistant." {
|
||||
t.Errorf("unexpected system prompt content: %q", files["system-prompt.md"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBundleConfigFiles_configYaml(t *testing.T) {
|
||||
b := &Bundle{Prompts: map[string]string{
|
||||
"config.yaml": "runtime: langgraph\nmodel: claude-sonnet-4-20250514\n",
|
||||
}}
|
||||
files := buildBundleConfigFiles(b)
|
||||
if len(files) != 1 {
|
||||
t.Fatalf("expected 1 file, got %d", len(files))
|
||||
}
|
||||
if string(files["config.yaml"]) != "runtime: langgraph\nmodel: claude-sonnet-4-20250514\n" {
|
||||
t.Errorf("unexpected config.yaml content: %q", files["config.yaml"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBundleConfigFiles_systemPromptAndConfigYaml(t *testing.T) {
|
||||
b := &Bundle{
|
||||
SystemPrompt: "# System",
|
||||
Prompts: map[string]string{"config.yaml": "runtime: langgraph"},
|
||||
}
|
||||
files := buildBundleConfigFiles(b)
|
||||
if len(files) != 2 {
|
||||
t.Fatalf("expected 2 files, got %d", len(files))
|
||||
}
|
||||
if _, ok := files["system-prompt.md"]; !ok {
|
||||
t.Error("missing system-prompt.md")
|
||||
}
|
||||
if _, ok := files["config.yaml"]; !ok {
|
||||
t.Error("missing config.yaml")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBundleConfigFiles_skills(t *testing.T) {
|
||||
b := &Bundle{
|
||||
Skills: []BundleSkill{
|
||||
{
|
||||
ID: "web-search",
|
||||
Name: "Web Search",
|
||||
Description: "Search the web",
|
||||
Files: map[string]string{"readme.md": "# Web Search"},
|
||||
},
|
||||
{
|
||||
ID: "code-runner",
|
||||
Name: "Code Runner",
|
||||
Description: "Execute code",
|
||||
Files: map[string]string{"handler.py": "print('hello')"},
|
||||
},
|
||||
},
|
||||
}
|
||||
files := buildBundleConfigFiles(b)
|
||||
if len(files) != 2 {
|
||||
t.Fatalf("expected 2 skill files, got %d", len(files))
|
||||
}
|
||||
|
||||
if content, ok := files["skills/web-search/readme.md"]; !ok {
|
||||
t.Error("missing skills/web-search/readme.md")
|
||||
} else if string(content) != "# Web Search" {
|
||||
t.Errorf("unexpected readme.md: %q", content)
|
||||
}
|
||||
|
||||
if _, ok := files["skills/code-runner/handler.py"]; !ok {
|
||||
t.Error("missing skills/code-runner/handler.py")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBundleConfigFiles_skillsWithSubPaths(t *testing.T) {
|
||||
b := &Bundle{
|
||||
Skills: []BundleSkill{
|
||||
{
|
||||
ID: "nested-skill",
|
||||
Files: map[string]string{"src/main.py": "def main(): pass", "pyproject.toml": "[tool.foo]"},
|
||||
},
|
||||
},
|
||||
}
|
||||
files := buildBundleConfigFiles(b)
|
||||
if len(files) != 2 {
|
||||
t.Fatalf("expected 2 files, got %d", len(files))
|
||||
}
|
||||
if _, ok := files["skills/nested-skill/src/main.py"]; !ok {
|
||||
t.Error("missing skills/nested-skill/src/main.py")
|
||||
}
|
||||
if _, ok := files["skills/nested-skill/pyproject.toml"]; !ok {
|
||||
t.Error("missing skills/nested-skill/pyproject.toml")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBundleConfigFiles_skipsEmptyPrompts(t *testing.T) {
|
||||
b := &Bundle{Prompts: map[string]string{}}
|
||||
files := buildBundleConfigFiles(b)
|
||||
if len(files) != 0 {
|
||||
t.Errorf("expected 0 files for empty prompts map, got %d", len(files))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBundleConfigFiles_skipsMissingConfigYaml(t *testing.T) {
|
||||
b := &Bundle{
|
||||
SystemPrompt: "# My Prompt",
|
||||
Prompts: map[string]string{"other.yaml": "something: else"},
|
||||
}
|
||||
files := buildBundleConfigFiles(b)
|
||||
if len(files) != 1 {
|
||||
t.Fatalf("expected 1 file (system-prompt only), got %d", len(files))
|
||||
}
|
||||
if _, ok := files["config.yaml"]; ok {
|
||||
t.Error("config.yaml should not be written when not in Prompts")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNilIfEmpty_emptyString(t *testing.T) {
|
||||
result := nilIfEmpty("")
|
||||
if result != nil {
|
||||
t.Errorf("expected nil for empty string, got %v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNilIfEmpty_nonEmptyString(t *testing.T) {
|
||||
result := nilIfEmpty("hello")
|
||||
if result == nil {
|
||||
t.Fatal("expected non-nil result for non-empty string")
|
||||
}
|
||||
if result != "hello" {
|
||||
t.Errorf("expected hello, got %q", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNilIfEmpty_whitespaceString(t *testing.T) {
|
||||
// Whitespace is not empty — nilIfEmpty only checks for zero-length
|
||||
result := nilIfEmpty(" ")
|
||||
if result == nil {
|
||||
t.Error("expected non-nil for whitespace string")
|
||||
} else if result != " " {
|
||||
t.Errorf("expected ' ', got %q", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNilIfEmpty_EmptyString(t *testing.T) {
|
||||
got := nilIfEmpty("")
|
||||
if got != nil {
|
||||
t.Errorf("nilIfEmpty(\"\"): want nil, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNilIfEmpty_NonEmptyString(t *testing.T) {
|
||||
got := nilIfEmpty("hello")
|
||||
if got == nil {
|
||||
t.Fatal("nilIfEmpty(\"hello\"): want \"hello\", got nil")
|
||||
}
|
||||
if s, ok := got.(string); !ok || s != "hello" {
|
||||
t.Errorf("nilIfEmpty(\"hello\"): got %v (%T)", got, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNilIfEmpty_Whitespace(t *testing.T) {
|
||||
got := nilIfEmpty(" ")
|
||||
if got == nil {
|
||||
t.Fatal("nilIfEmpty(\" \"): want \" \", got nil (whitespace is not empty)")
|
||||
}
|
||||
if s, ok := got.(string); !ok || s != " " {
|
||||
t.Errorf("nilIfEmpty(\" \"): got %v (%T)", got, got)
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/envx"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/events"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/models"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/provisioner"
|
||||
@@ -111,14 +110,11 @@ const maxProxyResponseBody = 10 << 20
|
||||
// a generic 502 page to canvas. 10s is well above realistic intra-region
|
||||
// latencies and well below CF's edge timeout.
|
||||
//
|
||||
// 3. Transport.ResponseHeaderTimeout — 180s default. From request-body-end
|
||||
// to response-headers-start. Configurable via
|
||||
// A2A_PROXY_RESPONSE_HEADER_TIMEOUT (envx.Duration). Covers cold-start
|
||||
// first-byte (30-60s OAuth flow above) with enough room for Opus agent
|
||||
// turns (big context + internal delegate_task round-trips routinely exceed
|
||||
// the old 60s ceiling). Body streaming after headers is governed by the
|
||||
// per-request context deadline, NOT this timeout — so multi-minute agent
|
||||
// responses still work fine.
|
||||
// 3. Transport.ResponseHeaderTimeout — 60s. From request-body-end to
|
||||
// response-headers-start. Covers cold-start first-byte (the 30-60s OAuth
|
||||
// flow above), with margin. Body streaming after headers is governed by
|
||||
// the per-request context deadline, NOT this timeout — so multi-minute
|
||||
// agent responses still work fine.
|
||||
//
|
||||
// The point of (2) and (3) is to surface a *structured* 503 from
|
||||
// handleA2ADispatchError when the workspace agent is unreachable, so canvas
|
||||
@@ -131,7 +127,7 @@ var a2aClient = &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
}).DialContext,
|
||||
ResponseHeaderTimeout: envx.Duration("A2A_PROXY_RESPONSE_HEADER_TIMEOUT", 180*time.Second),
|
||||
ResponseHeaderTimeout: 60 * time.Second,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
// MaxIdleConns / IdleConnTimeout: stdlib defaults are fine; agent
|
||||
// fan-in is bounded by the platform's broadcaster fan-out, not by
|
||||
@@ -512,13 +508,6 @@ func (h *WorkspaceHandler) proxyA2ARequest(ctx context.Context, workspaceID stri
|
||||
|
||||
if logActivity {
|
||||
h.logA2ASuccess(ctx, workspaceID, callerID, body, respBody, a2aMethod, resp.StatusCode, durationMs)
|
||||
// Fix #376: when the proxied method is 'delegate_result', also write
|
||||
// the delegation row so heartbeat delegation polling can find it.
|
||||
// Without this, proxy-path delegation results are invisible to
|
||||
// ListDelegations / heartbeat delegation polling.
|
||||
if a2aMethod == "delegate_result" {
|
||||
h.logA2ADelegationResult(ctx, workspaceID, callerID, body, respBody, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// Track LLM token usage for cost transparency (#593).
|
||||
|
||||
@@ -336,93 +336,6 @@ func (h *WorkspaceHandler) logA2ASuccess(ctx context.Context, workspaceID, calle
|
||||
}
|
||||
}
|
||||
|
||||
// logA2ADelegationResult records a delegation result into activity_logs
|
||||
// with method='delegate_result' and activity_type='delegation' so that
|
||||
// ListDelegations (and therefore the heartbeat delegation-polling path)
|
||||
// can surface it to the caller.
|
||||
//
|
||||
// This bridges the gap for proxy-path delegations: when a workspace
|
||||
// sends a delegate_task via POST /workspaces/:id/a2a, the proxy stores
|
||||
// the response here with the correct method so heartbeat polling finds it.
|
||||
// (The non-proxy path via executeDelegation already writes correctly via
|
||||
// its own INSERT at delegation.go:422.)
|
||||
//
|
||||
// Fire-and-forget: runs in a goroutine so it never adds latency to the
|
||||
// critical A2A response path. Errors are logged but non-fatal.
|
||||
func (h *WorkspaceHandler) logA2ADelegationResult(ctx context.Context, callerID, targetID string, reqBody, respBody []byte, statusCode int) {
|
||||
// Extract delegation_id from the request body (JSON-RPC delegate_result).
|
||||
var req struct {
|
||||
Params struct {
|
||||
Data struct {
|
||||
DelegationID string `json:"delegation_id"`
|
||||
} `json:"data"`
|
||||
} `json:"params"`
|
||||
}
|
||||
if err := json.Unmarshal(reqBody, &req); err != nil {
|
||||
log.Printf("logA2ADelegationResult: failed to parse req body: %v", err)
|
||||
return
|
||||
}
|
||||
delegationID := req.Params.Data.DelegationID
|
||||
if delegationID == "" {
|
||||
log.Printf("logA2ADelegationResult: no delegation_id in request body")
|
||||
return
|
||||
}
|
||||
|
||||
// Extract text from the response body — the delegate_result response
|
||||
// carries the agent's answer in result.data.text or result.text.
|
||||
var responseText string
|
||||
var respTop map[string]json.RawMessage
|
||||
if json.Unmarshal(respBody, &respTop) == nil {
|
||||
if result, ok := respTop["result"]; ok {
|
||||
var resultObj map[string]json.RawMessage
|
||||
if json.Unmarshal(result, &resultObj) == nil {
|
||||
if textRaw, ok := resultObj["text"]; ok {
|
||||
json.Unmarshal(textRaw, &responseText)
|
||||
} else if dataRaw, ok := resultObj["data"]; ok {
|
||||
var dataObj map[string]json.RawMessage
|
||||
if json.Unmarshal(dataRaw, &dataObj) == nil {
|
||||
if textRaw, ok := dataObj["text"]; ok {
|
||||
json.Unmarshal(textRaw, &responseText)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if responseText == "" {
|
||||
if textRaw, ok := respTop["text"]; ok {
|
||||
json.Unmarshal(textRaw, &responseText)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
status := "completed"
|
||||
if statusCode >= 300 {
|
||||
status = "failed"
|
||||
}
|
||||
|
||||
summary := "Delegation completed"
|
||||
if status == "failed" {
|
||||
summary = "Delegation failed"
|
||||
}
|
||||
|
||||
go func(parent context.Context) {
|
||||
logCtx, cancel := context.WithTimeout(context.WithoutCancel(parent), 30*time.Second)
|
||||
defer cancel()
|
||||
respJSON, _ := json.Marshal(map[string]interface{}{
|
||||
"text": responseText,
|
||||
"delegation_id": delegationID,
|
||||
})
|
||||
if _, err := db.DB.ExecContext(logCtx, `
|
||||
INSERT INTO activity_logs (
|
||||
workspace_id, activity_type, method, source_id, target_id,
|
||||
summary, request_body, response_body, status
|
||||
) VALUES ($1, 'delegation', 'delegate_result', $2, $3, $4, $5::jsonb, $6::jsonb, $7)
|
||||
`, callerID, callerID, targetID, summary, string(reqBody), string(respJSON), status); err != nil {
|
||||
log.Printf("logA2ADelegationResult: INSERT failed for delegation %s: %v", delegationID, err)
|
||||
}
|
||||
}(ctx)
|
||||
}
|
||||
|
||||
func nilIfEmpty(s string) *string {
|
||||
if s == "" {
|
||||
return nil
|
||||
@@ -497,7 +410,7 @@ func extractToolTrace(respBody []byte) json.RawMessage {
|
||||
return nil
|
||||
}
|
||||
trace, ok := meta["tool_trace"]
|
||||
if !ok || string(trace) == "[]" {
|
||||
if !ok || len(trace) == 0 {
|
||||
return nil
|
||||
}
|
||||
return trace
|
||||
|
||||
@@ -1,163 +0,0 @@
|
||||
package handlers
|
||||
|
||||
// a2a_proxy_helpers_test.go — unit tests for extractToolTrace (the only
|
||||
// untested pure function in a2a_proxy_helpers.go). The function parses JSON
|
||||
// so tests use real JSON without any DB or HTTP mocking.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
|
||||
)
|
||||
|
||||
// TestExtractToolTrace_HappyPath verifies that a well-formed JSON-RPC result
|
||||
// with a metadata.tool_trace field returns it as json.RawMessage.
|
||||
func TestExtractToolTrace_HappyPath(t *testing.T) {
|
||||
trace := json.RawMessage(`[{"tool":"bash","input":"ls"}]`)
|
||||
resp := map[string]interface{}{
|
||||
"result": map[string]interface{}{
|
||||
"metadata": map[string]interface{}{
|
||||
"tool_trace": trace,
|
||||
},
|
||||
},
|
||||
}
|
||||
body, _ := json.Marshal(resp)
|
||||
got := extractToolTrace(body)
|
||||
if got == nil {
|
||||
t.Fatal("extractToolTrace returned nil, expected the trace")
|
||||
}
|
||||
var parsed []map[string]interface{}
|
||||
if err := json.Unmarshal(got, &parsed); err != nil {
|
||||
t.Fatalf("returned value is not valid JSON: %v", err)
|
||||
}
|
||||
if len(parsed) != 1 || parsed[0]["tool"] != "bash" {
|
||||
t.Errorf("unexpected trace content: %v", parsed)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractToolTrace_ResultUsageShape tests a result object that has usage
|
||||
// (common A2A response shape) but no tool_trace — should return nil.
|
||||
func TestExtractToolTrace_ResultHasUsageNoTrace(t *testing.T) {
|
||||
resp := map[string]interface{}{
|
||||
"result": map[string]interface{}{
|
||||
"metadata": map[string]interface{}{
|
||||
"usage": map[string]int64{"input_tokens": 100, "output_tokens": 200},
|
||||
},
|
||||
},
|
||||
}
|
||||
body, _ := json.Marshal(resp)
|
||||
if got := extractToolTrace(body); got != nil {
|
||||
t.Errorf("expected nil when no tool_trace, got: %s", string(got))
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractToolTrace_NoResultKey verifies that a response without a "result"
|
||||
// key returns nil.
|
||||
func TestExtractToolTrace_NoResultKey(t *testing.T) {
|
||||
resp := map[string]interface{}{
|
||||
"error": map[string]string{"code": "-32600", "message": "Invalid Request"},
|
||||
}
|
||||
body, _ := json.Marshal(resp)
|
||||
if got := extractToolTrace(body); got != nil {
|
||||
t.Errorf("expected nil for error response, got: %s", string(got))
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractToolTrace_ResultNotAnObject verifies that a result that is not
|
||||
// a JSON object (e.g., null) returns nil without panicking.
|
||||
func TestExtractToolTrace_ResultNotAnObject(t *testing.T) {
|
||||
body := []byte(`{"result": null}`)
|
||||
if got := extractToolTrace(body); got != nil {
|
||||
t.Errorf("expected nil for null result, got: %s", string(got))
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractToolTrace_NoMetadata verifies that a result object without
|
||||
// metadata returns nil.
|
||||
func TestExtractToolTrace_NoMetadata(t *testing.T) {
|
||||
resp := map[string]interface{}{
|
||||
"result": map[string]interface{}{
|
||||
"message": "hello",
|
||||
},
|
||||
}
|
||||
body, _ := json.Marshal(resp)
|
||||
if got := extractToolTrace(body); got != nil {
|
||||
t.Errorf("expected nil for result without metadata, got: %s", string(got))
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractToolTrace_MetadataNotAnObject verifies that a metadata field that
|
||||
// is not a JSON object returns nil without panicking.
|
||||
func TestExtractToolTrace_MetadataNotAnObject(t *testing.T) {
|
||||
resp := map[string]interface{}{
|
||||
"result": map[string]interface{}{
|
||||
"metadata": "not an object",
|
||||
},
|
||||
}
|
||||
body, _ := json.Marshal(resp)
|
||||
if got := extractToolTrace(body); got != nil {
|
||||
t.Errorf("expected nil for non-object metadata, got: %s", string(got))
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractToolTrace_TraceIsEmptyArray verifies that an empty tool_trace
|
||||
// array ([]) returns nil (length 0).
|
||||
func TestExtractToolTrace_TraceIsEmptyArray(t *testing.T) {
|
||||
resp := map[string]interface{}{
|
||||
"result": map[string]interface{}{
|
||||
"metadata": map[string]interface{}{
|
||||
"tool_trace": []interface{}{},
|
||||
},
|
||||
},
|
||||
}
|
||||
body, _ := json.Marshal(resp)
|
||||
if got := extractToolTrace(body); got != nil {
|
||||
t.Errorf("expected nil for empty tool_trace, got: %s", string(got))
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractToolTrace_NonJSONBody verifies that a completely non-JSON body
|
||||
// returns nil without panicking.
|
||||
func TestExtractToolTrace_NonJSONBody(t *testing.T) {
|
||||
body := []byte("this is not json at all")
|
||||
if got := extractToolTrace(body); got != nil {
|
||||
t.Errorf("expected nil for non-JSON body, got: %s", string(got))
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractToolTrace_EmptyBody verifies that an empty body returns nil.
|
||||
func TestExtractToolTrace_EmptyBody(t *testing.T) {
|
||||
if got := extractToolTrace(nil); got != nil {
|
||||
t.Errorf("expected nil for nil body, got: %s", string(got))
|
||||
}
|
||||
if got := extractToolTrace([]byte{}); got != nil {
|
||||
t.Errorf("expected nil for empty body, got: %s", string(got))
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractToolTrace_ResultMetadataIsNotObject verifies that when
|
||||
// metadata exists but is not a JSON object (string), nil is returned.
|
||||
func TestExtractToolTrace_MetadataIsString(t *testing.T) {
|
||||
body := []byte(`{"result":{"metadata":"oops"}}`)
|
||||
if got := extractToolTrace(body); got != nil {
|
||||
t.Errorf("expected nil for string metadata, got: %s", string(got))
|
||||
}
|
||||
}
|
||||
|
||||
// TestNilIfEmpty_Contract exercises the contract of nilIfEmpty so future
|
||||
// refactors can't silently break the call-sites in a2a_proxy_helpers.go.
|
||||
func TestNilIfEmpty_Contract(t *testing.T) {
|
||||
if r := nilIfEmpty(""); r != nil {
|
||||
t.Errorf("nilIfEmpty(\"\") = %p, want nil", r)
|
||||
}
|
||||
if r := nilIfEmpty("hello"); r == nil {
|
||||
t.Fatal("nilIfEmpty(\"hello\") returned nil, want pointer to string")
|
||||
} else if *r != "hello" {
|
||||
t.Errorf("nilIfEmpty(\"hello\") = %q, want \"hello\"", *r)
|
||||
}
|
||||
}
|
||||
|
||||
// Suppress unused import warning — setupTestDB references db.DB but this file
|
||||
// only tests pure functions, so db is only needed transitively through helpers.
|
||||
var _ = db.DB
|
||||
@@ -2017,131 +2017,6 @@ func TestLogA2ASuccess_ErrorStatus(t *testing.T) {
|
||||
time.Sleep(80 * time.Millisecond)
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// logA2ADelegationResult — fix #376: proxy-path delegation results
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// TestLogA2ADelegationResult_Smoke verifies that a successful delegation result
|
||||
// fires an INSERT with activity_type='delegation', method='delegate_result',
|
||||
// and status='completed'. The response text is extracted from result.data.text.
|
||||
func TestLogA2ADelegationResult_Smoke(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
// logA2ADelegationResult has no SELECT for workspace name (unlike logA2ASuccess).
|
||||
// It fires the INSERT directly in a goroutine.
|
||||
mock.ExpectExec(`^INSERT INTO activity_logs`).
|
||||
WithArgs(
|
||||
"ws-caller", // workspace_id ($1)
|
||||
"ws-caller", // source_id ($2)
|
||||
"ws-target", // target_id ($3)
|
||||
"Delegation completed", // summary ($4)
|
||||
sqlmock.AnyArg(), // request_body ($5)
|
||||
sqlmock.AnyArg(), // response_body ($6)
|
||||
"completed", // status ($7)
|
||||
).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
handler.logA2ADelegationResult(
|
||||
context.Background(),
|
||||
"ws-caller", "ws-target",
|
||||
[]byte(`{"method":"delegate_task","params":{"data":{"delegation_id":"del-abc123"}}}`),
|
||||
[]byte(`{"jsonrpc":"2.0","id":"1","result":{"data":{"text":"the answer"}}}`),
|
||||
200,
|
||||
)
|
||||
time.Sleep(80 * time.Millisecond)
|
||||
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLogA2ADelegationResult_FailedStatus verifies that a 4xx/5xx response
|
||||
// from the target is recorded with status='failed' and summary='Delegation failed'.
|
||||
func TestLogA2ADelegationResult_FailedStatus(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
mock.ExpectExec(`^INSERT INTO activity_logs`).
|
||||
WithArgs(
|
||||
"ws-a", "ws-a", "ws-b",
|
||||
"Delegation failed",
|
||||
sqlmock.AnyArg(),
|
||||
sqlmock.AnyArg(),
|
||||
"failed",
|
||||
).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
handler.logA2ADelegationResult(
|
||||
context.Background(),
|
||||
"ws-a", "ws-b",
|
||||
[]byte(`{"method":"delegate_task","params":{"data":{"delegation_id":"del-xyz"}}}`),
|
||||
[]byte(`{"jsonrpc":"2.0","id":"2","error":{"code":-32600,"message":"bad request"}}`),
|
||||
400,
|
||||
)
|
||||
time.Sleep(80 * time.Millisecond)
|
||||
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLogA2ADelegationResult_NoDelegationID skips the INSERT when the
|
||||
// request body carries no delegation_id (logically impossible but defensive).
|
||||
func TestLogA2ADelegationResult_NoDelegationID(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
// No ExpectExec — the function must return early without any DB write.
|
||||
|
||||
handler.logA2ADelegationResult(
|
||||
context.Background(),
|
||||
"ws-x", "ws-y",
|
||||
[]byte(`{"method":"delegate_task","params":{"data":{}}}`),
|
||||
[]byte(`{}`),
|
||||
200,
|
||||
)
|
||||
time.Sleep(80 * time.Millisecond)
|
||||
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unexpected DB call: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLogA2ADelegationResult_TextFromResultText verifies that when the
|
||||
// response text lives at result.text (flat JSON-RPC), it is still captured.
|
||||
func TestLogA2ADelegationResult_TextFromResultText(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
mock.ExpectExec(`^INSERT INTO activity_logs`).
|
||||
WithArgs(
|
||||
"ws-1", "ws-1", "ws-2",
|
||||
"Delegation completed",
|
||||
sqlmock.AnyArg(),
|
||||
sqlmock.AnyArg(),
|
||||
"completed",
|
||||
).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
handler.logA2ADelegationResult(
|
||||
context.Background(),
|
||||
"ws-1", "ws-2",
|
||||
[]byte(`{"method":"delegate_task","params":{"data":{"delegation_id":"del-flat"}}}`),
|
||||
[]byte(`{"jsonrpc":"2.0","id":"3","result":{"text":"flat response"}}`),
|
||||
200,
|
||||
)
|
||||
time.Sleep(80 * time.Millisecond)
|
||||
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// A2A auto-wake: hibernated workspace (#711)
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
@@ -2401,43 +2276,3 @@ func TestProxyA2A_PollMode_FailsClosedToPush(t *testing.T) {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== a2aClient ResponseHeaderTimeout config ====================
|
||||
|
||||
func TestA2AClientResponseHeaderTimeout(t *testing.T) {
|
||||
const defaultTimeout = 180 * time.Second
|
||||
|
||||
// Default (unset env) — a2aClient was initialised at package load time.
|
||||
if a2aClient.Transport.(*http.Transport).ResponseHeaderTimeout != defaultTimeout {
|
||||
t.Errorf("a2aClient default ResponseHeaderTimeout = %v, want %v",
|
||||
a2aClient.Transport.(*http.Transport).ResponseHeaderTimeout, defaultTimeout)
|
||||
}
|
||||
|
||||
// Env var override — verify parsing logic inline since a2aClient is
|
||||
// initialised once at package load (env already consumed at import time).
|
||||
t.Run("A2A_PROXY_RESPONSE_HEADER_TIMEOUT parsed correctly", func(t *testing.T) {
|
||||
// We can't re-initialise a2aClient, but we can verify the same
|
||||
// envx.Duration logic inline for the 5m override case.
|
||||
t.Setenv("A2A_PROXY_RESPONSE_HEADER_TIMEOUT", "5m")
|
||||
if d, err := time.ParseDuration("5m"); err == nil && d > 0 {
|
||||
if d != 5*time.Minute {
|
||||
t.Errorf("ParseDuration(\"5m\") = %v, want 5m", d)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid A2A_PROXY_RESPONSE_HEADER_TIMEOUT falls back to default", func(t *testing.T) {
|
||||
t.Setenv("A2A_PROXY_RESPONSE_HEADER_TIMEOUT", "not-a-duration")
|
||||
// Simulate what envx.Duration does with an invalid value.
|
||||
var fallback = 180 * time.Second
|
||||
override := fallback
|
||||
if v := os.Getenv("A2A_PROXY_RESPONSE_HEADER_TIMEOUT"); v != "" {
|
||||
if d, err := time.ParseDuration(v); err == nil && d > 0 {
|
||||
override = d
|
||||
}
|
||||
}
|
||||
if override != fallback {
|
||||
t.Errorf("invalid env var: got %v, want fallback %v", override, fallback)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user