Compare commits
53 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4e992968da | |||
| 40777f0aa3 | |||
| dd9ae99748 | |||
| 3996ad987f | |||
| 66653c0e8e | |||
| 96eec447de | |||
| 90f9987e88 | |||
| 469f253c0d | |||
| 269c08a5a1 | |||
| 7ad26f4a7c | |||
| a9265f0a19 | |||
| ffb1b8eb35 | |||
| aded61038f | |||
| 9f263cec9b | |||
| 969edba572 | |||
| 75e6bfe7cc | |||
| 43844e0af0 | |||
| f34cc2783a | |||
| 6d94fd3077 | |||
| 8b6a11ccc7 | |||
| 40736a41e1 | |||
| 8af1eb6774 | |||
| 14287ab1e9 | |||
| 65f9df24b8 | |||
| a8bdeb033f | |||
| b34ec9f1e2 | |||
| d278c22a82 | |||
| b5d2ab88a6 | |||
| a355b6f0ad | |||
| 0846ebc1f6 | |||
| 9abbe82b15 | |||
| 5ecec3f253 | |||
| f58a11d171 | |||
| bc555aeb45 | |||
| 31ed137b74 | |||
| 79ced2e701 | |||
| fe1b3d9a82 | |||
| 9b930d8e39 | |||
| 7c1a595776 | |||
| a94382e86b | |||
| bea6d25543 | |||
| d9f484874a | |||
| d98a547af2 | |||
| e9b972d86a | |||
| a8074705a5 | |||
| 555c474cbe | |||
| cc4d7fc2c1 | |||
| 5216e781cd | |||
| e647efe7c5 | |||
| 2ba3af5330 | |||
| 736d9959bc | |||
| faa0ccf40f | |||
| 7d1a189f2e |
@@ -0,0 +1,100 @@
|
||||
name: publish-runtime-autobump
|
||||
|
||||
# Auto-bump-on-workspace-edit half of the publish pipeline.
|
||||
#
|
||||
# Why this file exists (issue #351):
|
||||
# Gitea Actions does not correctly disambiguate `paths:` from `tags:`
|
||||
# when both are bundled under a single `on.push` key. The result is
|
||||
# that tag pushes get filtered out and `publish-runtime.yml` never
|
||||
# fires — `action_run` rows: 0. This was unnoticed pre-2026-05-11
|
||||
# because PYPI_TOKEN was absent (publishes would have failed anyway).
|
||||
#
|
||||
# Split design:
|
||||
# - publish-runtime.yml : on.push.tags only (the publisher)
|
||||
# - publish-runtime-autobump.yml: on.push.branches+paths (this file — the version-bumper)
|
||||
#
|
||||
# This file computes the next version from PyPI's latest, pushes a
|
||||
# `runtime-v$VERSION` tag, and exits. The tag push then triggers
|
||||
# publish-runtime.yml via its tags-only trigger.
|
||||
#
|
||||
# Concurrency: shares the `publish-runtime` group with publish-runtime.yml
|
||||
# so concurrent workspace pushes serialize at the bump step. Without
|
||||
# this, two pushes minutes apart could both read PyPI latest=0.1.129
|
||||
# and try to tag 0.1.130 simultaneously, only one of which would land.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- staging
|
||||
paths:
|
||||
- "workspace/**"
|
||||
|
||||
permissions:
|
||||
contents: write # required to push tags back
|
||||
|
||||
concurrency:
|
||||
group: publish-runtime
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
autobump-and-tag:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
# Fetch full tag list so the bump logic can sanity-check against
|
||||
# what's already in this repo (catches collision with prior
|
||||
# manual tag pushes).
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Compute next version from PyPI latest
|
||||
id: bump
|
||||
run: |
|
||||
set -eu
|
||||
LATEST=$(curl -fsS --retry 3 https://pypi.org/pypi/molecule-ai-workspace-runtime/json \
|
||||
| python -c "import sys,json; print(json.load(sys.stdin)['info']['version'])")
|
||||
MAJOR=$(echo "$LATEST" | cut -d. -f1)
|
||||
MINOR=$(echo "$LATEST" | cut -d. -f2)
|
||||
PATCH=$(echo "$LATEST" | cut -d. -f3)
|
||||
VERSION="${MAJOR}.${MINOR}.$((PATCH+1))"
|
||||
echo "PyPI latest=$LATEST -> next=$VERSION"
|
||||
if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||
echo "::error::computed version $VERSION does not match PEP 440 X.Y.Z"
|
||||
exit 1
|
||||
fi
|
||||
if git tag --list | grep -qx "runtime-v$VERSION"; then
|
||||
echo "::error::tag runtime-v$VERSION already exists in this repo. Manual intervention required (PyPI and Gitea tag history are out of sync)."
|
||||
exit 1
|
||||
fi
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Push runtime-v$VERSION tag
|
||||
env:
|
||||
DISPATCH_TOKEN: ${{ secrets.DISPATCH_TOKEN }}
|
||||
VERSION: ${{ steps.bump.outputs.version }}
|
||||
GITEA_URL: https://git.moleculesai.app
|
||||
run: |
|
||||
set -eu
|
||||
if [ -z "$DISPATCH_TOKEN" ]; then
|
||||
echo "::error::DISPATCH_TOKEN secret is not set — needed to push the tag back to molecule-core."
|
||||
exit 1
|
||||
fi
|
||||
git config user.name "publish-runtime autobump"
|
||||
git config user.email "publish-runtime@moleculesai.app"
|
||||
git tag -a "runtime-v$VERSION" \
|
||||
-m "Auto-bump on workspace/** edit on $GITHUB_REF" \
|
||||
-m "Triggered by: $GITHUB_REF @ $GITHUB_SHA" \
|
||||
-m "publish-runtime.yml will pick up this tag and upload to PyPI"
|
||||
# Push via DISPATCH_TOKEN (a Gitea PAT). Using the bot identity
|
||||
# ensures the resulting tag-push event is dispatched to
|
||||
# publish-runtime.yml; act_runner's default GITHUB_TOKEN cannot
|
||||
# trigger downstream workflows.
|
||||
git remote set-url origin "${GITEA_URL#https://}"
|
||||
git remote set-url origin "https://x-access-token:${DISPATCH_TOKEN}@${GITEA_URL#https://}/molecule-ai/molecule-core.git"
|
||||
git push origin "runtime-v$VERSION"
|
||||
echo "✓ pushed runtime-v$VERSION — publish-runtime.yml should fire next"
|
||||
@@ -12,7 +12,24 @@ name: publish-runtime
|
||||
# - Replaced `github.ref_name` (GitHub-only) with `${GITHUB_REF#refs/tags/}`
|
||||
# — Gitea Actions exposes github.ref (the full ref) but not ref_name
|
||||
# - Dropped `merge_group` trigger (Gitea has no merge queue)
|
||||
# - Dropped `staging` branch trigger (no staging branch exists in this repo)
|
||||
#
|
||||
# 2026-05-10 (issue #348): originally restored `staging`/`main` branch +
|
||||
# `workspace/**` path-filter trigger in PR #349.
|
||||
#
|
||||
# 2026-05-11 (issue #351): REVERTED the branches+paths trigger from THIS
|
||||
# file. Bundling `paths` with `tags` under a single `on.push` key caused
|
||||
# Gitea Actions to never dispatch the workflow for tag-push events (0
|
||||
# runs in `action_run` for workflow_id='publish-runtime.yml' since the
|
||||
# port, including the runtime-v1.0.0 tag — which is why PyPI is still at
|
||||
# 0.1.129 despite a v1.0.0 Gitea tag existing).
|
||||
#
|
||||
# The auto-bump-on-workspace-edit trigger now lives in
|
||||
# `.gitea/workflows/publish-runtime-autobump.yml`. That file computes the
|
||||
# next version from PyPI's latest and pushes a `runtime-v$VERSION` tag,
|
||||
# which THIS file then picks up via the tags-only trigger below.
|
||||
#
|
||||
# This decoupling means Gitea's path-vs-tag evaluator never has to
|
||||
# disambiguate — each file has a single unambiguous trigger shape.
|
||||
#
|
||||
# PyPI publishing: requires PYPI_TOKEN repository secret (or org-level secret).
|
||||
# Set via: repo Settings → Actions → Variables and Secrets → New Secret.
|
||||
@@ -26,11 +43,17 @@ on:
|
||||
tags:
|
||||
- "runtime-v*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Version to publish (e.g. 0.1.6). Required for manual dispatch."
|
||||
required: true
|
||||
type: string
|
||||
# 2026-05-11 (root cause of #351 / 0 runs ever):
|
||||
# Gitea 1.22.6's workflow parser rejects `workflow_dispatch.inputs.version`
|
||||
# with "unknown on type" — it mis-treats the inputs sub-keys as top-level
|
||||
# `on:` event types. Log line:
|
||||
# actions/workflows.go:DetectWorkflows() [W] ignore invalid workflow
|
||||
# "publish-runtime.yml": unknown on type: map["version": {...}]
|
||||
# That `[W] ignore invalid workflow` is silent UX — the workflow never
|
||||
# registers, so it never fires for ANY event (push.tags included).
|
||||
# Removing the inputs block restores parsing. Manual dispatch from the
|
||||
# Gitea UI now triggers the PyPI auto-bump fallback in `Derive version`
|
||||
# below (no `inputs.version` to read).
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -55,20 +78,15 @@ jobs:
|
||||
python-version: "3.11"
|
||||
cache: pip
|
||||
|
||||
- name: Derive version (tag, manual input, or PyPI auto-bump)
|
||||
- name: Derive version (tag or PyPI auto-bump)
|
||||
id: version
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
VERSION="${{ inputs.version }}"
|
||||
elif echo "$GITHUB_REF" | grep -q "^refs/tags/runtime-v"; then
|
||||
if echo "$GITHUB_REF" | grep -q "^refs/tags/runtime-v"; then
|
||||
# Tag is `runtime-vX.Y.Z` — strip the prefix.
|
||||
VERSION="${GITHUB_REF#refs/tags/runtime-v}"
|
||||
else
|
||||
# Fallback: derive from PyPI latest + patch bump.
|
||||
# (The staging-push auto-bump trigger is dropped on Gitea —
|
||||
# no staging branch exists. This fallback path is kept for
|
||||
# robustness if a future automation uses workflow_dispatch without
|
||||
# an explicit version input.)
|
||||
# workflow_dispatch path (no inputs supported on Gitea 1.22.6) or
|
||||
# any other non-tag trigger: derive from PyPI latest + patch bump.
|
||||
LATEST=$(curl -fsS --retry 3 https://pypi.org/pypi/molecule-ai-workspace-runtime/json \
|
||||
| python -c "import sys,json; print(json.load(sys.stdin)['info']['version'])")
|
||||
MAJOR=$(echo "$LATEST" | cut -d. -f1)
|
||||
|
||||
@@ -23,7 +23,7 @@ name: publish-workspace-server-image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [staging, main]
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'workspace-server/**'
|
||||
- 'canvas/**'
|
||||
@@ -59,6 +59,25 @@ 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
|
||||
|
||||
@@ -54,6 +54,22 @@ 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
|
||||
|
||||
@@ -32,7 +32,7 @@ name: publish-workspace-server-image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [staging, main]
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'workspace-server/**'
|
||||
- 'canvas/**'
|
||||
@@ -107,6 +107,22 @@ 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
|
||||
|
||||
@@ -1,6 +1,22 @@
|
||||
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";
|
||||
@@ -79,7 +95,7 @@ export default async function RootLayout({
|
||||
dangerouslySetInnerHTML={{ __html: themeBootScript }}
|
||||
/>
|
||||
</head>
|
||||
<body className="bg-surface text-ink">
|
||||
<body className={`bg-surface text-ink ${interFont.variable} ${monoFont.variable}`}>
|
||||
<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
|
||||
|
||||
+48
-1
@@ -4,6 +4,7 @@ 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";
|
||||
@@ -14,6 +15,23 @@ 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
|
||||
@@ -51,7 +69,10 @@ export default function Home() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (hydrating) {
|
||||
// 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) {
|
||||
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">
|
||||
@@ -66,6 +87,32 @@ 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 />
|
||||
|
||||
@@ -308,7 +308,9 @@ function CanvasInner() {
|
||||
showInteractive={false}
|
||||
/>
|
||||
<MiniMap
|
||||
className="!bg-surface-sunken/90 !border-line/50 !rounded-lg !shadow-xl !shadow-black/20"
|
||||
// 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"
|
||||
// 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)"}
|
||||
|
||||
@@ -63,9 +63,21 @@ 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(() => {
|
||||
setSidePanelWidth(width);
|
||||
}, [width, setSidePanelWidth]);
|
||||
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]);
|
||||
const widthRef = useRef(width); // tracks live drag value for the mouseup handler
|
||||
const dragging = useRef(false);
|
||||
const startX = useRef(0);
|
||||
@@ -137,24 +149,28 @@ export function SidePanel() {
|
||||
|
||||
return (
|
||||
<div
|
||||
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 }}
|
||||
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 }}
|
||||
>
|
||||
{/* 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"
|
||||
/>
|
||||
{/* 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"
|
||||
/>
|
||||
)}
|
||||
{/* Header */}
|
||||
<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 justify-between px-4 sm: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" />
|
||||
@@ -190,7 +206,7 @@ export function SidePanel() {
|
||||
</div>
|
||||
|
||||
{/* Capability summary */}
|
||||
<div className="px-5 py-3 border-b border-line/40 bg-surface-sunken/20">
|
||||
<div className="px-4 sm: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"} />
|
||||
@@ -295,8 +311,8 @@ export function SidePanel() {
|
||||
</div>
|
||||
|
||||
{/* Footer — workspace ID */}
|
||||
<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">
|
||||
<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">
|
||||
{selectedNodeId}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -154,13 +154,13 @@ export function Toolbar() {
|
||||
|
||||
return (
|
||||
<div
|
||||
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"
|
||||
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"
|
||||
style={toolbarOffsetStyle}
|
||||
>
|
||||
{/* Logo / Title */}
|
||||
<div className="flex items-center gap-2 pr-3 border-r border-line/60">
|
||||
{/* 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">
|
||||
<img src="/molecule-icon.png" alt="Molecule AI" className="w-5 h-5" />
|
||||
<span className="text-[11px] font-semibold text-ink-mid tracking-wide">Molecule AI</span>
|
||||
<span className="hidden sm:inline 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="text-ink-mid" aria-hidden="true">·</span>
|
||||
<span className="text-[10px] text-ink-mid whitespace-nowrap">
|
||||
<span className="hidden sm:inline text-ink-mid" aria-hidden="true">·</span>
|
||||
<span className="hidden sm:inline 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="pl-3 border-l border-line/60">
|
||||
<div className="sm:pl-3 sm:border-l sm:border-line/60">
|
||||
<WsStatusPill status={wsStatus} />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,493 @@
|
||||
"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.
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,589 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
// @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"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
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());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,444 @@
|
||||
"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
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
"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 };
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
// 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";
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
"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));
|
||||
}
|
||||
@@ -100,7 +100,14 @@ export function toYaml(config: ConfigData): string {
|
||||
if (!o) return;
|
||||
lines.push(`${k}:`);
|
||||
Object.entries(o).forEach(([sk, sv]) => {
|
||||
if (sv !== undefined && sv !== null && sv !== "") lines.push(` ${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}`);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -121,7 +128,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);
|
||||
if (config.tools?.length) { list("tools", config.tools); }
|
||||
lines.push(""); 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>); }
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
services:
|
||||
# digest-pinned 2026-05-10 (sha256:4941ef97aaa2633ce9808f7766f8b8d746dd039ce8c51ca6da185c3dc63ab579, linux/amd64)
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
image: postgres@sha256:4941ef97aaa2633ce9808f7766f8b8d746dd039ce8c51ca6da185c3dc63ab579
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-dev}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-dev}
|
||||
@@ -17,7 +18,7 @@ services:
|
||||
retries: 10
|
||||
|
||||
langfuse-db-init:
|
||||
image: postgres:16-alpine
|
||||
image: postgres@sha256:4941ef97aaa2633ce9808f7766f8b8d746dd039ce8c51ca6da185c3dc63ab579
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
@@ -36,8 +37,9 @@ 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:7-alpine
|
||||
image: redis@sha256:b1addbe72465a718643cff9e60a58e6df1841e29d6d7d60c9a85d8d72f08d1a7
|
||||
command: ["redis-server", "--notify-keyspace-events", "KEA"]
|
||||
ports:
|
||||
- "6379:6379"
|
||||
@@ -49,8 +51,9 @@ services:
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
# digest-pinned 2026-05-10 (sha256:5b296e0ba1da74efea3143c773ddd60245f249fb7c72eb1d866c2d6ebc759fbe, linux/amd64)
|
||||
clickhouse:
|
||||
image: clickhouse/clickhouse-server:24-alpine
|
||||
image: clickhouse/clickhouse-server@sha256:5b296e0ba1da74efea3143c773ddd60245f249fb7c72eb1d866c2d6ebc759fbe
|
||||
environment:
|
||||
CLICKHOUSE_DB: langfuse
|
||||
CLICKHOUSE_USER: langfuse
|
||||
@@ -64,8 +67,9 @@ 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:1.25
|
||||
image: temporalio/auto-setup@sha256:9ce78f5a7ba7169acb659a8bb7a174a64251c3bfe1553d1fefdd669a59d41df5
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
@@ -85,8 +89,9 @@ services:
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
# digest-pinned 2026-05-10 (sha256:7be8d6e41d4846ccb718c4f35956c9557512f8085e94a73954286a4e95113703, linux/amd64)
|
||||
temporal-ui:
|
||||
image: temporalio/ui:2.31.2
|
||||
image: temporalio/ui@sha256:7be8d6e41d4846ccb718c4f35956c9557512f8085e94a73954286a4e95113703
|
||||
depends_on:
|
||||
- temporal
|
||||
environment:
|
||||
@@ -95,8 +100,9 @@ services:
|
||||
ports:
|
||||
- "8233:8080"
|
||||
|
||||
# digest-pinned 2026-05-10 (sha256:e7aafd3ccf721821b40f8b2251220b4bb8af5e4877b5c5a8846af5b3318aaf1d, linux/amd64)
|
||||
langfuse-web:
|
||||
image: langfuse/langfuse:2
|
||||
image: langfuse/langfuse@sha256:e7aafd3ccf721821b40f8b2251220b4bb8af5e4877b5c5a8846af5b3318aaf1d
|
||||
depends_on:
|
||||
clickhouse:
|
||||
condition: service_healthy
|
||||
|
||||
+17
-7
@@ -4,8 +4,9 @@ include:
|
||||
|
||||
services:
|
||||
# --- Infrastructure ---
|
||||
# digest-pinned 2026-05-10 (sha256:4941ef97aaa2633ce9808f7766f8b8d746dd039ce8c51ca6da185c3dc63ab579, linux/amd64)
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
image: postgres@sha256:4941ef97aaa2633ce9808f7766f8b8d746dd039ce8c51ca6da185c3dc63ab579
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-dev}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-dev}
|
||||
@@ -25,7 +26,7 @@ services:
|
||||
retries: 10
|
||||
|
||||
langfuse-db-init:
|
||||
image: postgres:16-alpine
|
||||
image: postgres@sha256:4941ef97aaa2633ce9808f7766f8b8d746dd039ce8c51ca6da185c3dc63ab579
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
@@ -46,8 +47,9 @@ services:
|
||||
networks:
|
||||
- molecule-core-net
|
||||
|
||||
# digest-pinned 2026-05-10 (sha256:b1addbe72465a718643cff9e60a58e6df1841e29d6d7d60c9a85d8d72f08d1a7, linux/amd64)
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
image: redis@sha256:b1addbe72465a718643cff9e60a58e6df1841e29d6d7d60c9a85d8d72f08d1a7
|
||||
command: ["redis-server", "--notify-keyspace-events", "KEA"]
|
||||
ports:
|
||||
- "6379:6379"
|
||||
@@ -63,8 +65,9 @@ services:
|
||||
retries: 10
|
||||
|
||||
# --- Observability ---
|
||||
# digest-pinned 2026-05-10 (sha256:5b296e0ba1da74efea3143c773ddd60245f249fb7c72eb1d866c2d6ebc759fbe, linux/amd64)
|
||||
langfuse-clickhouse:
|
||||
image: clickhouse/clickhouse-server:24-alpine
|
||||
image: clickhouse/clickhouse-server@sha256:5b296e0ba1da74efea3143c773ddd60245f249fb7c72eb1d866c2d6ebc759fbe
|
||||
environment:
|
||||
CLICKHOUSE_DB: langfuse
|
||||
CLICKHOUSE_USER: langfuse
|
||||
@@ -79,8 +82,9 @@ services:
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
# digest-pinned 2026-05-10 (sha256:e7aafd3ccf721821b40f8b2251220b4bb8af5e4877b5c5a8846af5b3318aaf1d, linux/amd64)
|
||||
langfuse:
|
||||
image: langfuse/langfuse:2
|
||||
image: langfuse/langfuse@sha256:e7aafd3ccf721821b40f8b2251220b4bb8af5e4877b5c5a8846af5b3318aaf1d
|
||||
depends_on:
|
||||
langfuse-clickhouse:
|
||||
condition: service_healthy
|
||||
@@ -239,6 +243,8 @@ 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
|
||||
@@ -279,8 +285,10 @@ 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
|
||||
image: ghcr.io/berriai/litellm/main-latest@sha256:7c311546c25e7bb6e8cafede9fcd3d0d622ac636b5c9418befaa32e85dfb0186
|
||||
profiles:
|
||||
- multi-provider
|
||||
ports:
|
||||
@@ -311,8 +319,10 @@ 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:latest
|
||||
image: ollama/ollama@sha256:90bd8ed1ad1853fbfb1ef5835f9d7a24fe890e05ace521e2d8d7a6f56bb667dd
|
||||
profiles:
|
||||
- local-models
|
||||
ports:
|
||||
|
||||
@@ -269,6 +269,28 @@ 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)
|
||||
|
||||
@@ -37,6 +37,50 @@ 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"
|
||||
@@ -82,11 +126,7 @@ clone_category() {
|
||||
fi
|
||||
|
||||
echo " cloning $display_url -> $target_dir/$name (ref=$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
|
||||
clone_one_with_retry "$target_dir" "$name" "$clone_url" "$display_url" "$ref"
|
||||
CLONED=$((CLONED + 1))
|
||||
i=$((i + 1))
|
||||
done
|
||||
|
||||
@@ -71,10 +71,17 @@ func TemplateImageRef(runtime string) string {
|
||||
|
||||
// ghcrAuthHeader returns the base64-encoded JSON auth payload Docker's
|
||||
// ImagePull expects in PullOptions.RegistryAuth, or empty string when no
|
||||
// GHCR_USER/GHCR_TOKEN env is set (lets public images pull through).
|
||||
// GHCR_USER/GHCR_TOKEN env is set (lets public images pull through and lets
|
||||
// ECR's credential-helper-driven flow take over without a stale GHCR
|
||||
// payload masking it).
|
||||
//
|
||||
// The Docker SDK doesn't read ~/.docker/config.json — every authenticated
|
||||
// pull needs an explicit RegistryAuth string.
|
||||
// pull needs an explicit RegistryAuth string. The serveraddress field is
|
||||
// resolved from provisioner.RegistryHost() so it tracks MOLECULE_IMAGE_REGISTRY
|
||||
// when the operator points the platform at a private mirror (e.g. ECR).
|
||||
// Leaving it hardcoded to "ghcr.io" caused the engine to match the wrong
|
||||
// auth entry post-suspension when MOLECULE_IMAGE_REGISTRY was flipped to
|
||||
// the AWS ECR mirror (RFC #229).
|
||||
func ghcrAuthHeader() string {
|
||||
user := strings.TrimSpace(os.Getenv("GHCR_USER"))
|
||||
token := strings.TrimSpace(os.Getenv("GHCR_TOKEN"))
|
||||
@@ -84,7 +91,7 @@ func ghcrAuthHeader() string {
|
||||
payload := map[string]string{
|
||||
"username": user,
|
||||
"password": token,
|
||||
"serveraddress": "ghcr.io",
|
||||
"serveraddress": provisioner.RegistryHost(),
|
||||
}
|
||||
js, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
func TestGHCRAuthHeader_NoEnvReturnsEmpty(t *testing.T) {
|
||||
t.Setenv("GHCR_USER", "")
|
||||
t.Setenv("GHCR_TOKEN", "")
|
||||
t.Setenv("MOLECULE_IMAGE_REGISTRY", "")
|
||||
if got := ghcrAuthHeader(); got != "" {
|
||||
t.Errorf("expected empty (no auth → public-only), got %q", got)
|
||||
}
|
||||
@@ -29,6 +30,10 @@ func TestGHCRAuthHeader_PartialEnvReturnsEmpty(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGHCRAuthHeader_EncodesDockerEnginePayload(t *testing.T) {
|
||||
// Default registry env (unset → ghcr.io/molecule-ai) means the
|
||||
// serveraddress field should resolve to ghcr.io. Pin both env vars so the
|
||||
// test is hermetic regardless of the host's MOLECULE_IMAGE_REGISTRY.
|
||||
t.Setenv("MOLECULE_IMAGE_REGISTRY", "")
|
||||
t.Setenv("GHCR_USER", "alice")
|
||||
t.Setenv("GHCR_TOKEN", "fake-tok-value")
|
||||
got := ghcrAuthHeader()
|
||||
@@ -54,7 +59,41 @@ func TestGHCRAuthHeader_EncodesDockerEnginePayload(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestGHCRAuthHeader_RespectsRegistryEnv pins the RFC #229 fix: when
|
||||
// MOLECULE_IMAGE_REGISTRY points at a private mirror (e.g. AWS ECR), the
|
||||
// Docker engine auth payload's serveraddress must reflect that mirror's
|
||||
// host so credential matching lands on the right entry. Pre-fix this was
|
||||
// hardcoded to "ghcr.io" and silently dropped the override.
|
||||
func TestGHCRAuthHeader_RespectsRegistryEnv(t *testing.T) {
|
||||
t.Setenv("GHCR_USER", "alice")
|
||||
t.Setenv("GHCR_TOKEN", "fake-tok-value")
|
||||
t.Setenv("MOLECULE_IMAGE_REGISTRY", "004947743811.dkr.ecr.us-east-2.amazonaws.com/molecule-ai")
|
||||
|
||||
got := ghcrAuthHeader()
|
||||
if got == "" {
|
||||
t.Fatal("expected non-empty auth header")
|
||||
}
|
||||
raw, err := base64.URLEncoding.DecodeString(got)
|
||||
if err != nil {
|
||||
t.Fatalf("auth header is not valid base64-url: %v", err)
|
||||
}
|
||||
var payload map[string]string
|
||||
if err := json.Unmarshal(raw, &payload); err != nil {
|
||||
t.Fatalf("decoded auth is not valid JSON: %v (raw=%s)", err, raw)
|
||||
}
|
||||
want := "004947743811.dkr.ecr.us-east-2.amazonaws.com"
|
||||
if payload["serveraddress"] != want {
|
||||
t.Errorf("serveraddress: got %q, want %q (must follow MOLECULE_IMAGE_REGISTRY host)",
|
||||
payload["serveraddress"], want)
|
||||
}
|
||||
// Sanity: the org-path portion must NOT leak into serveraddress.
|
||||
if payload["serveraddress"] == "004947743811.dkr.ecr.us-east-2.amazonaws.com/molecule-ai" {
|
||||
t.Error("serveraddress must be host-only, not host+org-path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGHCRAuthHeader_TrimsWhitespace(t *testing.T) {
|
||||
t.Setenv("MOLECULE_IMAGE_REGISTRY", "")
|
||||
// .env lines often have trailing newlines or accidental spaces. Without
|
||||
// trimming, a stray space would produce an auth payload the engine
|
||||
// rejects with a confusing 401.
|
||||
|
||||
@@ -121,7 +121,7 @@ curl -fsS -X POST "{{PLATFORM_URL}}/registry/register" \
|
||||
// operators whose external agent IS a Claude Code session (laptop or
|
||||
// remote dev VM); routes the workspace's A2A traffic into the running
|
||||
// Claude Code session as conversation turns via MCP. The plugin source
|
||||
// lives at github.com/Molecule-AI/molecule-mcp-claude-channel — polling
|
||||
// lives at git.moleculesai.app/molecule-ai/molecule-mcp-claude-channel — polling
|
||||
// based, no tunnel required (uses /workspaces/:id/activity?since_secs=,
|
||||
// platform-side support shipped in #2300).
|
||||
const externalChannelTemplate = `# Claude Code channel — bridges this workspace's A2A traffic into your
|
||||
@@ -134,8 +134,8 @@ const externalChannelTemplate = `# Claude Code channel — bridges this workspac
|
||||
# The plugin is NOT on Anthropic's default allowlist, so a one-time
|
||||
# marketplace-add is needed before install:
|
||||
#
|
||||
# /plugin marketplace add Molecule-AI/molecule-mcp-claude-channel
|
||||
# /plugin install molecule@molecule-mcp-claude-channel
|
||||
# /plugin marketplace add https://git.moleculesai.app/molecule-ai/molecule-mcp-claude-channel.git
|
||||
# /plugin install molecule@molecule-channel
|
||||
#
|
||||
# Then either run /reload-plugins or restart Claude Code so the
|
||||
# plugin is registered.
|
||||
@@ -154,7 +154,7 @@ chmod 600 ~/.claude/channels/molecule/.env
|
||||
# flag to opt in — without it, you'll see "not on the approved channels
|
||||
# allowlist" on startup.
|
||||
claude --dangerously-load-development-channels \
|
||||
--channels plugin:molecule@molecule-mcp-claude-channel
|
||||
--channels plugin:molecule@molecule-channel
|
||||
|
||||
# You should see on stderr:
|
||||
# molecule channel: connected — watching 1 workspace(s) at {{PLATFORM_URL}}
|
||||
@@ -176,7 +176,7 @@ claude --dangerously-load-development-channels \
|
||||
# add the plugin to allowedChannelPlugins in claude.ai admin settings.
|
||||
#
|
||||
# Multi-workspace: comma-separate IDs and tokens (same order). See
|
||||
# https://github.com/Molecule-AI/molecule-mcp-claude-channel for
|
||||
# https://git.moleculesai.app/molecule-ai/molecule-mcp-claude-channel for
|
||||
# pairing flow, push-mode upgrade, and v0.2 roadmap.
|
||||
|
||||
# Need help?
|
||||
@@ -258,7 +258,7 @@ claude mcp add molecule -s user -- env \
|
||||
// externalPythonTemplate uses molecule-sdk-python's RemoteAgentClient +
|
||||
// A2AServer (PR #13 in that repo). Until the SDK cuts a v0.y release
|
||||
// to PyPI the snippet pins git+main.
|
||||
const externalPythonTemplate = `# pip install 'git+https://github.com/Molecule-AI/molecule-sdk-python.git@main'
|
||||
const externalPythonTemplate = `# pip install 'git+https://git.moleculesai.app/molecule-ai/molecule-sdk-python.git@main'
|
||||
|
||||
import asyncio
|
||||
from molecule_agent import RemoteAgentClient, A2AServer
|
||||
@@ -307,7 +307,7 @@ if __name__ == "__main__":
|
||||
// A2A traffic into the running hermes gateway as platform messages
|
||||
// via the molecule-channel plugin.
|
||||
//
|
||||
// The plugin (Molecule-AI/hermes-channel-molecule) is a hermes
|
||||
// The plugin (molecule-ai/hermes-channel-molecule on Gitea) is a hermes
|
||||
// platform adapter that:
|
||||
// 1. Spawns ``python -m molecule_runtime.a2a_mcp_server`` as a
|
||||
// stdio MCP subprocess (separate from any hermes-side MCP
|
||||
@@ -336,7 +336,7 @@ const externalHermesChannelTemplate = `# Hermes channel — bridges this workspa
|
||||
#
|
||||
# 1. Install the runtime + plugin:
|
||||
pip install molecule-ai-workspace-runtime
|
||||
pip install 'git+https://github.com/Molecule-AI/hermes-channel-molecule.git'
|
||||
pip install 'git+https://git.moleculesai.app/molecule-ai/hermes-channel-molecule.git'
|
||||
|
||||
# 2. Export the workspace credentials:
|
||||
export MOLECULE_WORKSPACE_ID={{WORKSPACE_ID}}
|
||||
@@ -366,7 +366,7 @@ hermes gateway --replace
|
||||
# by the plugin's molecule_runtime MCP subprocess).
|
||||
#
|
||||
# Source + issue tracker:
|
||||
# https://github.com/Molecule-AI/hermes-channel-molecule
|
||||
# https://git.moleculesai.app/molecule-ai/hermes-channel-molecule
|
||||
|
||||
# Need help?
|
||||
# Documentation: https://doc.moleculesai.app/docs/guides/external-agent-registration
|
||||
|
||||
@@ -75,3 +75,46 @@ func TestExternalMcpTemplates_UseMoleculeMcpWrapper(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestExternalTemplates_NoBrokenMoleculeAIGitHubURLs pins the invariant
|
||||
// that operator-facing snippets never embed github.com URLs pointing at
|
||||
// Molecule-AI repos.
|
||||
//
|
||||
// Why: the Molecule-AI GitHub org was suspended 2026-05-06 and the
|
||||
// canonical SCM is now git.moleculesai.app. Any `pip install
|
||||
// git+https://github.com/Molecule-AI/...` or marketplace-add Molecule-AI/
|
||||
// URL emitted to an external operator hits a 404 / org-suspended page,
|
||||
// breaking onboarding silently. RFC #229 P2-5.
|
||||
//
|
||||
// Third-party github URLs (gin, openai/codex, NousResearch/hermes-agent
|
||||
// upstream issue trackers, npm @openai/codex) remain valid — only
|
||||
// Molecule-AI/ paths are broken.
|
||||
func TestExternalTemplates_NoBrokenMoleculeAIGitHubURLs(t *testing.T) {
|
||||
templates := map[string]string{
|
||||
"externalCurlTemplate": externalCurlTemplate,
|
||||
"externalChannelTemplate": externalChannelTemplate,
|
||||
"externalUniversalMcpTemplate": externalUniversalMcpTemplate,
|
||||
"externalPythonTemplate": externalPythonTemplate,
|
||||
"externalHermesChannelTemplate": externalHermesChannelTemplate,
|
||||
"externalCodexTemplate": externalCodexTemplate,
|
||||
"externalOpenClawTemplate": externalOpenClawTemplate,
|
||||
}
|
||||
// Substrings that imply the snippet is pointing an operator at the
|
||||
// suspended Molecule-AI GitHub org.
|
||||
bannedSubstrings := []string{
|
||||
"github.com/Molecule-AI/",
|
||||
"github.com/molecule-ai/",
|
||||
// Bare `Molecule-AI/<repo>` form used by `/plugin marketplace add`
|
||||
// resolves through GitHub by default — explicit Gitea URL is
|
||||
// required post-suspension.
|
||||
"marketplace add Molecule-AI/",
|
||||
"marketplace add molecule-ai/",
|
||||
}
|
||||
for name, body := range templates {
|
||||
for _, banned := range bannedSubstrings {
|
||||
if strings.Contains(body, banned) {
|
||||
t.Errorf("%s contains %q — Molecule-AI GitHub org is suspended; use git.moleculesai.app/molecule-ai/<repo> instead (RFC #229 P2-5)", name, banned)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -717,13 +717,16 @@ func deriveProviderFromModelSlug(model string) string {
|
||||
func applyRuntimeModelEnv(envVars map[string]string, runtime, model string) {
|
||||
// Resolution order (priority high → low):
|
||||
// 1. payload.Model (caller passed the canvas-picked model id verbatim)
|
||||
// 2. envVars["MODEL"] (workspace_secret persisted by /org/import via
|
||||
// 2. envVars["MOLECULE_MODEL"] (the canonical, unambiguous name)
|
||||
// 3. envVars["MODEL"] (workspace_secret persisted by /org/import via
|
||||
// the persona env file — MODEL=MiniMax-M2.7-highspeed etc.)
|
||||
// 3. envVars["MODEL_PROVIDER"] (legacy: this secret was historically a
|
||||
// *model id* set by canvas Save+Restart's PUT /model; on the
|
||||
// post-2026-05-08 persona-env convention it's a *provider slug*
|
||||
// (e.g. "minimax") which is NOT a valid model id, so this fallback
|
||||
// only fires when MODEL is absent.)
|
||||
// 4. envVars["MODEL_PROVIDER"] (legacy + misleadingly named: it carries
|
||||
// a *model id*, never the provider — that's LLM_PROVIDER. Historically
|
||||
// set by canvas Save+Restart's PUT /model; the post-2026-05-08
|
||||
// persona-env convention sometimes (mis)set it to a provider slug
|
||||
// ("minimax") or a runtime name ("claude-code"), neither a valid
|
||||
// model id — see internal#226. Only fires when the better-named
|
||||
// vars are absent.)
|
||||
//
|
||||
// Pre-fix bug: this function unconditionally OVERWROTE envVars["MODEL"]
|
||||
// with the MODEL_PROVIDER slug (when payload.Model was empty), wiping
|
||||
@@ -736,6 +739,9 @@ func applyRuntimeModelEnv(envVars map[string]string, runtime, model string) {
|
||||
// and the workspace template's adapter routed to providers[0]
|
||||
// (anthropic-oauth) and wedged at SDK initialize. Caught 2026-05-08
|
||||
// during Phase 4 verification of template-claude-code PR #9.
|
||||
if model == "" {
|
||||
model = envVars["MOLECULE_MODEL"]
|
||||
}
|
||||
if model == "" {
|
||||
model = envVars["MODEL"]
|
||||
}
|
||||
@@ -746,16 +752,18 @@ func applyRuntimeModelEnv(envVars map[string]string, runtime, model string) {
|
||||
return
|
||||
}
|
||||
|
||||
// Universal MODEL env var — every adapter that wants to honour the
|
||||
// canvas-picked model (instead of its template's default) reads this.
|
||||
// molecule-runtime's workspace/config.py already falls back to MODEL
|
||||
// for runtime_config.model (#194). Without this line, the user's
|
||||
// canvas selection is silently dropped on every templated provision —
|
||||
// confirmed via crash-loop diagnosis on 2026-05-02 where MiniMax
|
||||
// picks booted with model=sonnet (template default) and demanded
|
||||
// CLAUDE_CODE_OAUTH_TOKEN. Set it FIRST so the per-runtime branches
|
||||
// below can still layer on additional vendor-specific names without
|
||||
// fighting over the canonical one.
|
||||
// Canonical model env vars — molecule-runtime's workspace/config.py
|
||||
// resolves the picked model as MOLECULE_MODEL > MODEL > (legacy)
|
||||
// MODEL_PROVIDER (#280). Export both new names so adapters can read
|
||||
// either; MODEL stays for backwards compat with everything that
|
||||
// already reads os.environ["MODEL"] (the claude-code adapter does,
|
||||
// since #194). Without this, the user's canvas selection is silently
|
||||
// dropped on every templated provision — confirmed via crash-loop
|
||||
// diagnosis on 2026-05-02 where MiniMax picks booted with model=sonnet
|
||||
// (template default) and demanded CLAUDE_CODE_OAUTH_TOKEN. Set these
|
||||
// FIRST so the per-runtime branches below can layer on additional
|
||||
// vendor-specific names without fighting over the canonical one.
|
||||
envVars["MOLECULE_MODEL"] = model
|
||||
envVars["MODEL"] = model
|
||||
|
||||
switch runtime {
|
||||
|
||||
@@ -665,46 +665,62 @@ func TestApplyRuntimeModelEnv_SetsUniversalMODELForAllRuntimes(t *testing.T) {
|
||||
runtime string
|
||||
model string
|
||||
modelProviderEnv string
|
||||
moleculeModelEnv string
|
||||
wantMODEL string
|
||||
wantHermesDefault string // empty string = must be unset
|
||||
}{
|
||||
{
|
||||
name: "claude-code: picked model populates MODEL",
|
||||
name: "claude-code: picked model populates MODEL + MOLECULE_MODEL",
|
||||
runtime: "claude-code",
|
||||
model: "MiniMax-M2",
|
||||
wantMODEL: "MiniMax-M2",
|
||||
},
|
||||
{
|
||||
name: "hermes: picked model populates BOTH MODEL and HERMES_DEFAULT_MODEL",
|
||||
name: "hermes: picked model populates MODEL, MOLECULE_MODEL, HERMES_DEFAULT_MODEL",
|
||||
runtime: "hermes",
|
||||
model: "minimax/MiniMax-M2.7",
|
||||
wantMODEL: "minimax/MiniMax-M2.7",
|
||||
wantHermesDefault: "minimax/MiniMax-M2.7",
|
||||
},
|
||||
{
|
||||
name: "langgraph: picked model populates MODEL (no vendor-specific name)",
|
||||
name: "langgraph: picked model populates MODEL + MOLECULE_MODEL (no vendor-specific name)",
|
||||
runtime: "langgraph",
|
||||
model: "anthropic:claude-opus-4-7",
|
||||
wantMODEL: "anthropic:claude-opus-4-7",
|
||||
},
|
||||
{
|
||||
name: "crewai: picked model populates MODEL (no vendor-specific name)",
|
||||
name: "crewai: picked model populates MODEL + MOLECULE_MODEL (no vendor-specific name)",
|
||||
runtime: "crewai",
|
||||
model: "openai:gpt-4o",
|
||||
wantMODEL: "openai:gpt-4o",
|
||||
},
|
||||
{
|
||||
name: "empty model + empty MODEL_PROVIDER fallback: nothing set",
|
||||
name: "empty model + no env fallback: nothing set",
|
||||
runtime: "claude-code",
|
||||
model: "",
|
||||
},
|
||||
{
|
||||
name: "empty model + MODEL_PROVIDER fallback hits: MODEL set from secret",
|
||||
name: "empty model + MODEL_PROVIDER fallback hits: MODEL/MOLECULE_MODEL set from secret",
|
||||
runtime: "claude-code",
|
||||
model: "",
|
||||
modelProviderEnv: "MiniMax-M2",
|
||||
wantMODEL: "MiniMax-M2",
|
||||
},
|
||||
{
|
||||
name: "empty model + MOLECULE_MODEL env fallback hits (canonical name)",
|
||||
runtime: "claude-code",
|
||||
model: "",
|
||||
moleculeModelEnv: "opus",
|
||||
wantMODEL: "opus",
|
||||
},
|
||||
{
|
||||
name: "MOLECULE_MODEL beats MODEL_PROVIDER when both set (misnomer guard, internal#226)",
|
||||
runtime: "claude-code",
|
||||
model: "",
|
||||
moleculeModelEnv: "opus",
|
||||
modelProviderEnv: "claude-code",
|
||||
wantMODEL: "opus",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
@@ -713,11 +729,18 @@ func TestApplyRuntimeModelEnv_SetsUniversalMODELForAllRuntimes(t *testing.T) {
|
||||
if tc.modelProviderEnv != "" {
|
||||
envVars["MODEL_PROVIDER"] = tc.modelProviderEnv
|
||||
}
|
||||
if tc.moleculeModelEnv != "" {
|
||||
envVars["MOLECULE_MODEL"] = tc.moleculeModelEnv
|
||||
}
|
||||
applyRuntimeModelEnv(envVars, tc.runtime, tc.model)
|
||||
|
||||
if got := envVars["MODEL"]; got != tc.wantMODEL {
|
||||
t.Errorf("MODEL = %q, want %q", got, tc.wantMODEL)
|
||||
}
|
||||
// MOLECULE_MODEL (the canonical name) must mirror MODEL exactly.
|
||||
if got := envVars["MOLECULE_MODEL"]; got != tc.wantMODEL {
|
||||
t.Errorf("MOLECULE_MODEL = %q, want %q", got, tc.wantMODEL)
|
||||
}
|
||||
if got := envVars["HERMES_DEFAULT_MODEL"]; got != tc.wantHermesDefault {
|
||||
t.Errorf("HERMES_DEFAULT_MODEL = %q, want %q", got, tc.wantHermesDefault)
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/handlers"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/provisioner"
|
||||
)
|
||||
|
||||
// DefaultInterval is the polling cadence. Runtime publishes happen at most
|
||||
@@ -127,20 +128,32 @@ func (w *Watcher) tick(ctx context.Context, fetch digestFetcher) {
|
||||
}
|
||||
}
|
||||
|
||||
// remoteDigest queries GHCR for the current manifest digest of the
|
||||
// workspace-template-<runtime>:latest image. Uses the Docker Registry V2
|
||||
// HTTP API: get a bearer token, then HEAD the manifest.
|
||||
// remoteDigest queries the configured registry for the current manifest
|
||||
// digest of the workspace-template-<runtime>:latest image. Uses the Docker
|
||||
// Registry V2 HTTP API: get a bearer token, then HEAD the manifest.
|
||||
//
|
||||
// Registry host is resolved from provisioner.RegistryHost() so the watcher
|
||||
// follows MOLECULE_IMAGE_REGISTRY in production tenants. Pre-RFC #229 this
|
||||
// was hardcoded to ghcr.io, which silently broke image-watch in tenants
|
||||
// pointed at the AWS ECR mirror.
|
||||
//
|
||||
// Auth: if GHCR_USER+GHCR_TOKEN are set, basic-auth the token request
|
||||
// (works for both public and private images). If unset, anonymous token
|
||||
// (works for public images only — every workspace template is public).
|
||||
//
|
||||
// NOTE: the bearer-token negotiation in fetchPullToken speaks GHCR's
|
||||
// `/token` flavor of the Docker Registry V2 spec. ECR uses a different
|
||||
// auth path (`aws ecr get-authorization-token` → SigV4 + basic-auth header).
|
||||
// Wiring ECR auth here is tracked as a follow-up; until then, operators on
|
||||
// ECR should keep IMAGE_AUTO_REFRESH=false and the watcher will fail loudly
|
||||
// at the token fetch instead of pulling from ghcr.io behind their back.
|
||||
func (w *Watcher) remoteDigest(ctx context.Context, runtime string) (string, error) {
|
||||
repo := "molecule-ai/workspace-template-" + runtime
|
||||
tok, err := w.fetchPullToken(ctx, repo)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("pull token: %w", err)
|
||||
}
|
||||
manifestURL := fmt.Sprintf("https://ghcr.io/v2/%s/manifests/latest", repo)
|
||||
manifestURL := fmt.Sprintf("https://%s/v2/%s/manifests/latest", provisioner.RegistryHost(), repo)
|
||||
req, err := http.NewRequestWithContext(ctx, "HEAD", manifestURL, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -171,14 +184,22 @@ func (w *Watcher) remoteDigest(ctx context.Context, runtime string) (string, err
|
||||
return digest, nil
|
||||
}
|
||||
|
||||
// fetchPullToken negotiates a short-lived bearer token from GHCR's token
|
||||
// endpoint scoped to repo:pull. GHCR requires a token even for anonymous
|
||||
// pulls of public images.
|
||||
// fetchPullToken negotiates a short-lived bearer token from the registry's
|
||||
// `/token` endpoint scoped to repo:pull. GHCR requires a token even for
|
||||
// anonymous pulls of public images.
|
||||
//
|
||||
// Registry host follows provisioner.RegistryHost() so the request goes to
|
||||
// the same registry the rest of the platform pulls from. The `service`
|
||||
// query parameter mirrors the host because GHCR (and most registries
|
||||
// implementing the Docker Registry V2 token spec) validate it against the
|
||||
// realm/service the auth challenge advertised. ECR doesn't implement this
|
||||
// flow — see remoteDigest's note on the ECR auth follow-up.
|
||||
func (w *Watcher) fetchPullToken(ctx context.Context, repo string) (string, error) {
|
||||
host := provisioner.RegistryHost()
|
||||
q := url.Values{}
|
||||
q.Set("service", "ghcr.io")
|
||||
q.Set("service", host)
|
||||
q.Set("scope", "repository:"+repo+":pull")
|
||||
tokURL := "https://ghcr.io/token?" + q.Encode()
|
||||
tokURL := "https://" + host + "/token?" + q.Encode()
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", tokURL, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
|
||||
@@ -3,6 +3,9 @@ package imagewatch
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
@@ -160,6 +163,100 @@ func TestTick_DigestFetchErrorSkipsRuntime(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestRemoteDigest_RegistryHostFollowsEnv pins the RFC #229 fix: with
|
||||
// MOLECULE_IMAGE_REGISTRY pointed at a private mirror, the watcher's HTTP
|
||||
// calls (token endpoint + manifest HEAD) must hit that mirror's host, not
|
||||
// the hardcoded ghcr.io of the pre-fix code path. We stand up an httptest
|
||||
// server, point MOLECULE_IMAGE_REGISTRY at its host, and assert both
|
||||
// endpoints get hit on it.
|
||||
//
|
||||
// Without this test, a future refactor could revert the helper indirection
|
||||
// and the watcher would silently go back to talking to ghcr.io even when
|
||||
// the platform is configured for ECR — exactly the bug RFC #229 is closing.
|
||||
func TestRemoteDigest_RegistryHostFollowsEnv(t *testing.T) {
|
||||
var (
|
||||
mu sync.Mutex
|
||||
tokenHits int
|
||||
manifestHits int
|
||||
lastTokenURL string
|
||||
lastManifestURL string
|
||||
)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
switch {
|
||||
case strings.HasPrefix(r.URL.Path, "/token"):
|
||||
tokenHits++
|
||||
lastTokenURL = r.URL.String()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"token":"fake-bearer"}`))
|
||||
case strings.HasPrefix(r.URL.Path, "/v2/") && strings.Contains(r.URL.Path, "/manifests/latest"):
|
||||
manifestHits++
|
||||
lastManifestURL = r.URL.Path
|
||||
w.Header().Set("Docker-Content-Digest", "sha256:cafef00d")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
// httptest.Server.URL is "http://127.0.0.1:NNNN". RegistryHost() works
|
||||
// over the host:port portion (provisioner.RegistryPrefix takes the env
|
||||
// verbatim), so we strip the scheme and append "/molecule-ai" to mimic
|
||||
// the prefix shape MOLECULE_IMAGE_REGISTRY actually uses in production.
|
||||
host := strings.TrimPrefix(srv.URL, "http://")
|
||||
t.Setenv("MOLECULE_IMAGE_REGISTRY", host+"/molecule-ai")
|
||||
|
||||
w := newTestWatcher(&fakeRefresher{}, "claude-code")
|
||||
// Use the test-server URL scheme by overriding the http client only —
|
||||
// remoteDigest constructs https://<host>/... internally. We need the
|
||||
// watcher to hit our http server, so swap the URL scheme by injecting
|
||||
// a transport that rewrites https→http for this test.
|
||||
w.http = &http.Client{Transport: rewriteToHTTP{}}
|
||||
|
||||
digest, err := w.remoteDigest(context.Background(), "claude-code")
|
||||
if err != nil {
|
||||
t.Fatalf("remoteDigest failed: %v", err)
|
||||
}
|
||||
if digest != "sha256:cafef00d" {
|
||||
t.Errorf("digest: got %q, want sha256:cafef00d", digest)
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if tokenHits != 1 {
|
||||
t.Errorf("token endpoint hits: got %d, want 1 (watcher must hit configured registry, not ghcr.io)", tokenHits)
|
||||
}
|
||||
if manifestHits != 1 {
|
||||
t.Errorf("manifest HEAD hits: got %d, want 1 (watcher must hit configured registry, not ghcr.io)", manifestHits)
|
||||
}
|
||||
// service= query param must reflect the configured host so registries
|
||||
// that validate the param (GHCR-style spec) accept the request.
|
||||
if !strings.Contains(lastTokenURL, "service="+host) && !strings.Contains(lastTokenURL, "service=127.0.0.1") {
|
||||
t.Errorf("token URL service param not host-derived: got %q", lastTokenURL)
|
||||
}
|
||||
wantManifestPath := "/v2/molecule-ai/workspace-template-claude-code/manifests/latest"
|
||||
if lastManifestURL != wantManifestPath {
|
||||
t.Errorf("manifest path: got %q, want %q", lastManifestURL, wantManifestPath)
|
||||
}
|
||||
}
|
||||
|
||||
// rewriteToHTTP is a tiny RoundTripper that flips https→http so the watcher
|
||||
// (which builds https URLs from the configured registry host) can target an
|
||||
// httptest.Server that only speaks http. Production code paths still go
|
||||
// over https; this is a unit-test seam only.
|
||||
type rewriteToHTTP struct{}
|
||||
|
||||
func (rewriteToHTTP) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
if req.URL.Scheme == "https" {
|
||||
clone := req.Clone(req.Context())
|
||||
clone.URL.Scheme = "http"
|
||||
req = clone
|
||||
}
|
||||
return http.DefaultTransport.RoundTrip(req)
|
||||
}
|
||||
|
||||
func TestShortDigest(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"sha256:abcdef0123456789": "sha256:abcdef012345",
|
||||
|
||||
@@ -3,6 +3,7 @@ package provisioner
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// defaultRegistryPrefix is the upstream OSS face for all workspace template
|
||||
@@ -62,6 +63,32 @@ func RegistryPrefix() string {
|
||||
return defaultRegistryPrefix
|
||||
}
|
||||
|
||||
// RegistryHost returns just the registry host portion of RegistryPrefix() —
|
||||
// i.e. everything before the first "/" separator. This is the value that
|
||||
// belongs in:
|
||||
//
|
||||
// - Docker Engine PullOptions.RegistryAuth payloads (`serveraddress` field)
|
||||
// — the engine matches credentials against host, not host+org-path.
|
||||
// - Docker Registry V2 HTTP API base URLs (e.g. `https://<host>/v2/...`)
|
||||
// — the V2 API is host-rooted; the org-path lives in the manifest path.
|
||||
//
|
||||
// Examples:
|
||||
//
|
||||
// "ghcr.io/molecule-ai" → "ghcr.io"
|
||||
// "123456789012.dkr.ecr.us-east-2.amazonaws.com/molecule-ai" → "123456789012.dkr.ecr.us-east-2.amazonaws.com"
|
||||
// "git.moleculesai.app/molecule-ai" → "git.moleculesai.app"
|
||||
//
|
||||
// If RegistryPrefix() ever returns a bare host (no `/`), we return it as-is
|
||||
// rather than letting strings.SplitN produce an empty string — defensive
|
||||
// against a misconfiguration where the operator sets just the host.
|
||||
func RegistryHost() string {
|
||||
prefix := RegistryPrefix()
|
||||
if i := strings.IndexByte(prefix, '/'); i > 0 {
|
||||
return prefix[:i]
|
||||
}
|
||||
return prefix
|
||||
}
|
||||
|
||||
// RuntimeImage returns the canonical image reference for the given runtime,
|
||||
// using the current RegistryPrefix() and the moving `:latest` tag.
|
||||
//
|
||||
|
||||
@@ -127,6 +127,50 @@ func TestComputeRuntimeImages_ReflectsCurrentEnv(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegistryHost_SplitsHostFromOrgPath pins the contract that callers
|
||||
// (Docker auth payloads, registry V2 HTTP base URLs) need: the host portion
|
||||
// must be free of the "/molecule-ai" org suffix that appears in the
|
||||
// pull-prefix form. Pre-RFC #229, ghcr.io was hardcoded in two places
|
||||
// (imagewatch + admin_workspace_images auth payload); this helper is the
|
||||
// single source they should resolve from.
|
||||
func TestRegistryHost_SplitsHostFromOrgPath(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
env string
|
||||
want string
|
||||
}{
|
||||
{"default GHCR", "", "ghcr.io"},
|
||||
{"AWS ECR mirror", "004947743811.dkr.ecr.us-east-2.amazonaws.com/molecule-ai", "004947743811.dkr.ecr.us-east-2.amazonaws.com"},
|
||||
{"self-hosted Gitea", "git.moleculesai.app/molecule-ai", "git.moleculesai.app"},
|
||||
// Bare host (no /org) — defensive: return as-is rather than empty.
|
||||
{"bare host no org-path", "registry.example.com", "registry.example.com"},
|
||||
// Multi-level org path — split at the first "/" only.
|
||||
{"nested org path", "registry.example.com/org/sub", "registry.example.com"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Setenv("MOLECULE_IMAGE_REGISTRY", tc.env)
|
||||
got := RegistryHost()
|
||||
if got != tc.want {
|
||||
t.Errorf("RegistryHost() with env=%q: got %q, want %q", tc.env, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegistryHost_NeverEmpty — guard against a future refactor accidentally
|
||||
// returning "" for some edge env value. An empty serveraddress in the
|
||||
// Docker engine auth payload, or an empty host in `https:///v2/...`, would
|
||||
// silently break image operations.
|
||||
func TestRegistryHost_NeverEmpty(t *testing.T) {
|
||||
for _, env := range []string{"", "ghcr.io/molecule-ai", "/leading-slash", "host-only", "host/with/path"} {
|
||||
t.Setenv("MOLECULE_IMAGE_REGISTRY", env)
|
||||
if got := RegistryHost(); got == "" {
|
||||
t.Errorf("RegistryHost() with env=%q returned empty (would break Docker auth + V2 HTTP)", env)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestKnownRuntimes_AlphabeticalOrder — pin the order so test snapshots
|
||||
// (and human readers diffing the file) see deterministic output. Adding a
|
||||
// new runtime out of alphabetical order will fail this test, which is the
|
||||
|
||||
@@ -179,6 +179,23 @@ def parse(data: Any) -> Variant:
|
||||
)
|
||||
return Malformed(raw=data)
|
||||
|
||||
# Push-mode queue envelope — returned when a push-mode workspace
|
||||
# (one with a public URL) is at capacity. The platform queues the
|
||||
# request and returns {"queued": true, "message": "...", "queue_id": "..."}.
|
||||
# Unlike the poll-mode envelope (status=queued + delivery_mode=poll),
|
||||
# this shape has no delivery_mode key — it's distinguishable by
|
||||
# data.get("queued") is True alone. Checked before poll-mode so the
|
||||
# two cases are mutually exclusive even if a buggy server sends both.
|
||||
if data.get("queued") is True:
|
||||
method_raw = data.get(_KEY_METHOD)
|
||||
method = str(method_raw) if method_raw is not None else "message/send"
|
||||
logger.info(
|
||||
"a2a_response.parse: queued for busy push-mode peer (method=%s, queue_id=%s)",
|
||||
method,
|
||||
data.get("queue_id", "?"),
|
||||
)
|
||||
return Queued(method=method)
|
||||
|
||||
# Poll-queued envelope. Both keys must be present — the workspace
|
||||
# server sets them together; if only one is present the body is
|
||||
# ambiguous and we route to Malformed for visibility.
|
||||
|
||||
@@ -204,6 +204,20 @@ async def tool_delegate_task(
|
||||
if not workspace_id or not task:
|
||||
return "Error: workspace_id and task are required"
|
||||
|
||||
# Self-delegation guard: delegating to your own workspace ID deadlocks —
|
||||
# the sending turn holds _run_lock while the receive handler waits for the
|
||||
# same lock, the request 30s-times-out, and the whole cycle is wasted.
|
||||
# Reject immediately with an actionable message. (effective_src mirrors the
|
||||
# `src or WORKSPACE_ID` resolution used below for routing.)
|
||||
effective_src = source_workspace_id or _peer_to_source.get(workspace_id) or WORKSPACE_ID
|
||||
if workspace_id and workspace_id == effective_src:
|
||||
return (
|
||||
"Error: cannot delegate_task to your own workspace — self-delegation "
|
||||
"deadlocks _run_lock (your sending turn holds it, the receive handler "
|
||||
"waits for it, the request times out). There is no peer who is also you: "
|
||||
"just do the work yourself, or call commit_memory / send_message_to_user directly."
|
||||
)
|
||||
|
||||
# Auto-route: if source not specified, look up which registered
|
||||
# workspace last saw this peer (populated by tool_list_peers). Falls
|
||||
# back to the legacy WORKSPACE_ID for single-workspace operators.
|
||||
@@ -323,6 +337,16 @@ async def tool_delegate_task_async(
|
||||
|
||||
src = source_workspace_id or _peer_to_source.get(workspace_id) or WORKSPACE_ID
|
||||
|
||||
# Self-delegation guard: even on the async path, queuing a task to your own
|
||||
# workspace just makes you re-process your own dispatch — never useful, and
|
||||
# on the sync path it deadlocks (see tool_delegate_task). Reject early.
|
||||
if workspace_id and workspace_id == src:
|
||||
return (
|
||||
"Error: cannot delegate_task_async to your own workspace — there is no "
|
||||
"peer who is also you. Do the work yourself, or call commit_memory / "
|
||||
"send_message_to_user directly."
|
||||
)
|
||||
|
||||
# Idempotency key: SHA-256 of (source, target, task) so that a
|
||||
# restarted agent firing the same delegation gets the same key and
|
||||
# the platform returns the existing delegation_id instead of
|
||||
|
||||
@@ -66,10 +66,25 @@ async def delegate_task(workspace_id: str, task: str) -> str:
|
||||
)
|
||||
data = a2a_resp.json()
|
||||
if "result" in data:
|
||||
parts = data["result"].get("parts", [])
|
||||
return parts[0].get("text", "(no text)") if parts else str(data["result"])
|
||||
result = data["result"]
|
||||
parts = result.get("parts", []) if isinstance(result, dict) else []
|
||||
if parts and isinstance(parts[0], dict):
|
||||
return parts[0].get("text", "(no text)")
|
||||
# Empty parts list (e.g. {"parts": []}) should return str(result),
|
||||
# not "(no text)" — preserves pre-fix behavior (#279 regression fix).
|
||||
if isinstance(result, dict) and result.get("parts") == []:
|
||||
return str(result)
|
||||
return str(result) if isinstance(result, str) else "(no text)"
|
||||
elif "error" in data:
|
||||
return f"Error: {data['error'].get('message', str(data['error']))}"
|
||||
err = data["error"]
|
||||
msg = ""
|
||||
if isinstance(err, dict):
|
||||
msg = err.get("message", "")
|
||||
elif isinstance(err, str):
|
||||
msg = err
|
||||
else:
|
||||
msg = str(err)
|
||||
return f"Error: {msg}"
|
||||
return str(data)
|
||||
except Exception as e:
|
||||
return f"Error sending A2A message: {e}"
|
||||
|
||||
+54
-8
@@ -1,5 +1,6 @@
|
||||
"""Load workspace configuration from config.yaml."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
@@ -7,6 +8,8 @@ from typing import Optional
|
||||
|
||||
import yaml
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RBACConfig:
|
||||
@@ -381,6 +384,47 @@ def _derive_provider_from_model(model: str) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
_legacy_model_provider_warned = False
|
||||
|
||||
|
||||
def _picked_model_from_env(default: str) -> str:
|
||||
"""Resolve the operator-picked model id from env; newest name wins.
|
||||
|
||||
Precedence: ``MOLECULE_MODEL`` (canonical, unambiguous) → ``MODEL`` →
|
||||
``MODEL_PROVIDER`` (legacy) → ``default`` (the YAML ``model:`` field).
|
||||
|
||||
``MODEL_PROVIDER`` is **misleadingly named**: it carries the picked
|
||||
*model id*, never the LLM provider — the provider lives in
|
||||
``LLM_PROVIDER`` / the YAML ``provider:`` field. The legacy path stays
|
||||
so canvas Save+Restart, the workspace-server secret-mint path, and
|
||||
persona env files that set it keep working, but if it's the *only* one
|
||||
set we log a deprecation once — the misnomer keeps biting (e.g. setting
|
||||
``MODEL_PROVIDER=claude-code`` expecting it to select the claude-code
|
||||
*runtime* — it doesn't, ``runtime:`` does — after which the claude CLI
|
||||
404s on ``--model claude-code``). Set ``MODEL``/``MOLECULE_MODEL`` to
|
||||
an id from ``runtime_config.models[].id`` (e.g. ``opus``, ``sonnet``,
|
||||
``claude-opus-4-7``, ``MiniMax-M2.7-highspeed``) instead.
|
||||
"""
|
||||
global _legacy_model_provider_warned
|
||||
for name in ("MOLECULE_MODEL", "MODEL"):
|
||||
v = (os.environ.get(name) or "").strip()
|
||||
if v:
|
||||
return v
|
||||
legacy = (os.environ.get("MODEL_PROVIDER") or "").strip()
|
||||
if legacy:
|
||||
if not _legacy_model_provider_warned:
|
||||
logger.warning(
|
||||
"MODEL_PROVIDER=%r is deprecated and misleadingly named — it "
|
||||
"sets the picked *model id*, not the LLM provider (that's "
|
||||
"LLM_PROVIDER / the YAML `provider:` field). Set MODEL (or "
|
||||
"MOLECULE_MODEL) to an id from runtime_config.models instead.",
|
||||
legacy,
|
||||
)
|
||||
_legacy_model_provider_warned = True
|
||||
return legacy
|
||||
return default
|
||||
|
||||
|
||||
_EVENT_LOG_VALID_BACKENDS = {"memory", "disabled"}
|
||||
|
||||
|
||||
@@ -445,8 +489,10 @@ def load_config(config_path: Optional[str] = None) -> WorkspaceConfig:
|
||||
with open(config_file) as f:
|
||||
raw = yaml.safe_load(f) or {}
|
||||
|
||||
# Override model from env if provided
|
||||
model = os.environ.get("MODEL_PROVIDER", raw.get("model", "anthropic:claude-opus-4-7"))
|
||||
# Operator-picked model from env (canvas / secret-mint / persona env),
|
||||
# falling back to the YAML `model:` field. See _picked_model_from_env for
|
||||
# the precedence (MOLECULE_MODEL > MODEL > legacy MODEL_PROVIDER).
|
||||
model = _picked_model_from_env(raw.get("model", "anthropic:claude-opus-4-7"))
|
||||
|
||||
# Resolve top-level provider with this priority chain:
|
||||
# 1. ``LLM_PROVIDER`` env var (canvas Save+Restart sets this so the
|
||||
@@ -517,8 +563,9 @@ def load_config(config_path: Optional[str] = None) -> WorkspaceConfig:
|
||||
required_env=runtime_raw.get("required_env", []),
|
||||
timeout=runtime_raw.get("timeout", 0),
|
||||
# Picked-model precedence (priority order):
|
||||
# 1. MODEL_PROVIDER env var — canvas-picked model, plumbed via
|
||||
# workspace-server's secret-mint path or the universal
|
||||
# 1. operator-picked model from env — MOLECULE_MODEL > MODEL >
|
||||
# (legacy) MODEL_PROVIDER, plumbed via canvas Save+Restart,
|
||||
# workspace-server's secret-mint path, or the universal
|
||||
# MODEL/MODEL_PROVIDER env from applyRuntimeModelEnv. The
|
||||
# operator's canvas selection MUST win over the template's
|
||||
# baked-in default; previously the template's
|
||||
@@ -527,13 +574,12 @@ def load_config(config_path: Optional[str] = None) -> WorkspaceConfig:
|
||||
# surfaced 2026-05-02 during E2E).
|
||||
# 2. runtime_raw.model — explicit YAML override in the
|
||||
# template's runtime_config.
|
||||
# 3. top-level `model` — already honors MODEL_PROVIDER (line
|
||||
# 359) but only when YAML lacks a top-level `model:`. This
|
||||
# is the SaaS restart case (CP regenerates a minimal
|
||||
# 3. top-level `model` (already env-resolved above). This is
|
||||
# the SaaS restart case (CP regenerates a minimal
|
||||
# config.yaml on every boot, dropping runtime_config.model).
|
||||
# Centralising here means EVERY adapter gets the override for
|
||||
# free — no per-adapter env-reading code required.
|
||||
model=os.environ.get("MODEL_PROVIDER") or runtime_raw.get("model") or model,
|
||||
model=_picked_model_from_env(runtime_raw.get("model") or model),
|
||||
# Same fallback shape as ``model`` above: an explicit
|
||||
# ``runtime_config.provider`` wins; otherwise inherit the
|
||||
# top-level resolved provider so adapters see a single
|
||||
|
||||
@@ -127,3 +127,51 @@ class TestPollBudgetEnvOverride:
|
||||
# numeric and >= the documented floor (180s healthsweep budget).
|
||||
assert isinstance(a2a_tools_delegation._SYNC_POLL_BUDGET_S, float)
|
||||
assert a2a_tools_delegation._SYNC_POLL_BUDGET_S >= 180.0
|
||||
|
||||
|
||||
# ============== Self-delegation guard ==============
|
||||
|
||||
class TestSelfDelegationGuard:
|
||||
"""delegate_task / delegate_task_async to your own workspace ID must be
|
||||
rejected immediately (it deadlocks _run_lock on the sync path — the
|
||||
sending turn holds the lock, the receive handler waits for it, the
|
||||
request 30s-times-out). A genuinely different target must NOT be
|
||||
short-circuited by the guard."""
|
||||
|
||||
def _fresh(self, monkeypatch, own_id):
|
||||
import a2a_tools_delegation as d
|
||||
monkeypatch.setattr(d, "WORKSPACE_ID", own_id)
|
||||
monkeypatch.setattr(d, "_peer_to_source", {}, raising=False)
|
||||
return d
|
||||
|
||||
def test_delegate_task_rejects_self(self, monkeypatch):
|
||||
import asyncio
|
||||
d = self._fresh(monkeypatch, "ws-self-abc")
|
||||
out = asyncio.run(d.tool_delegate_task("ws-self-abc", "do a thing"))
|
||||
assert "your own workspace" in out.lower()
|
||||
|
||||
def test_delegate_task_rejects_self_via_explicit_source(self, monkeypatch):
|
||||
import asyncio
|
||||
d = self._fresh(monkeypatch, "ws-other-default")
|
||||
out = asyncio.run(
|
||||
d.tool_delegate_task("ws-X", "do a thing", source_workspace_id="ws-X")
|
||||
)
|
||||
assert "your own workspace" in out.lower()
|
||||
|
||||
def test_delegate_task_async_rejects_self(self, monkeypatch):
|
||||
import asyncio
|
||||
d = self._fresh(monkeypatch, "ws-self-abc")
|
||||
out = asyncio.run(d.tool_delegate_task_async("ws-self-abc", "do a thing"))
|
||||
assert "your own workspace" in out.lower()
|
||||
|
||||
def test_delegate_task_allows_different_target(self, monkeypatch):
|
||||
"""Guard passes through for a real peer — it reaches discover_peer
|
||||
(stubbed to 'not found' here) rather than returning the self message."""
|
||||
import asyncio
|
||||
d = self._fresh(monkeypatch, "ws-self-abc")
|
||||
async def _no_peer(*_a, **_kw):
|
||||
return None
|
||||
monkeypatch.setattr(d, "discover_peer", _no_peer)
|
||||
out = asyncio.run(d.tool_delegate_task("ws-OTHER-xyz", "do a thing"))
|
||||
assert "your own workspace" not in out.lower()
|
||||
assert "not found" in out.lower()
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"""Tests for config.py — workspace configuration loading."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
import config
|
||||
from config import (
|
||||
A2AConfig,
|
||||
ComplianceConfig,
|
||||
@@ -17,6 +19,17 @@ from config import (
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_model_env(monkeypatch):
|
||||
"""Every test starts with no MODEL* env vars set and the legacy-name
|
||||
deprecation latch reset, so picked-model resolution is deterministic
|
||||
regardless of the CI shell environment or test ordering."""
|
||||
for name in ("MOLECULE_MODEL", "MODEL", "MODEL_PROVIDER"):
|
||||
monkeypatch.delenv(name, raising=False)
|
||||
monkeypatch.setattr(config, "_legacy_model_provider_warned", False, raising=False)
|
||||
yield
|
||||
|
||||
|
||||
def test_load_config_basic(tmp_path):
|
||||
"""load_config reads a YAML file and returns a WorkspaceConfig."""
|
||||
config_yaml = tmp_path / "config.yaml"
|
||||
@@ -164,6 +177,80 @@ def test_runtime_config_model_env_wins_over_explicit_yaml(tmp_path, monkeypatch)
|
||||
assert cfg.runtime_config.model == "minimax/MiniMax-M2.7"
|
||||
|
||||
|
||||
def test_picked_model_MODEL_env_wins_over_legacy_MODEL_PROVIDER(tmp_path, monkeypatch):
|
||||
"""MODEL (the correctly-named env var) beats the legacy MODEL_PROVIDER.
|
||||
|
||||
Regression for the 2026-05-10 dev-team incident: lead persona env files
|
||||
set MODEL=claude-opus-4-7 (the intended model) AND MODEL_PROVIDER=claude-code
|
||||
(mistaking MODEL_PROVIDER for "the runtime"). The old code read
|
||||
MODEL_PROVIDER → the claude CLI got `--model claude-code` → 404. MODEL must
|
||||
win so the operator's intended value lands at both levels.
|
||||
"""
|
||||
monkeypatch.setenv("MODEL", "opus")
|
||||
monkeypatch.setenv("MODEL_PROVIDER", "claude-code")
|
||||
config_yaml = tmp_path / "config.yaml"
|
||||
config_yaml.write_text(
|
||||
yaml.dump({"model": "anthropic:claude-opus-4-7",
|
||||
"runtime_config": {"model": "sonnet"}})
|
||||
)
|
||||
cfg = load_config(str(tmp_path))
|
||||
assert cfg.model == "opus"
|
||||
assert cfg.runtime_config.model == "opus"
|
||||
|
||||
|
||||
def test_picked_model_MOLECULE_MODEL_wins_over_MODEL(tmp_path, monkeypatch):
|
||||
"""MOLECULE_MODEL (the unambiguous canonical name) wins over MODEL, which
|
||||
in turn wins over the legacy MODEL_PROVIDER."""
|
||||
monkeypatch.setenv("MOLECULE_MODEL", "claude-opus-4-7")
|
||||
monkeypatch.setenv("MODEL", "sonnet")
|
||||
monkeypatch.setenv("MODEL_PROVIDER", "claude-code")
|
||||
config_yaml = tmp_path / "config.yaml"
|
||||
config_yaml.write_text(yaml.dump({"model": "openai:gpt-4o"}))
|
||||
cfg = load_config(str(tmp_path))
|
||||
assert cfg.model == "claude-opus-4-7"
|
||||
assert cfg.runtime_config.model == "claude-opus-4-7"
|
||||
|
||||
|
||||
def test_picked_model_MODEL_env_overrides_yaml(tmp_path, monkeypatch):
|
||||
"""MODEL env overrides the YAML `model:` field — same role MODEL_PROVIDER
|
||||
had, now under the correctly-named var."""
|
||||
config_yaml = tmp_path / "config.yaml"
|
||||
config_yaml.write_text(yaml.dump({"model": "openai:gpt-4o"}))
|
||||
monkeypatch.setenv("MODEL", "google:gemini-2.0-flash")
|
||||
cfg = load_config(str(tmp_path))
|
||||
assert cfg.model == "google:gemini-2.0-flash"
|
||||
|
||||
|
||||
def test_legacy_MODEL_PROVIDER_still_honored_but_warns(tmp_path, monkeypatch, caplog):
|
||||
"""MODEL_PROVIDER alone still resolves the model (back-compat: canvas
|
||||
Save+Restart, secret-mint, existing persona env files keep working) but
|
||||
logs a one-time deprecation pointing at the misnomer."""
|
||||
config_yaml = tmp_path / "config.yaml"
|
||||
config_yaml.write_text(yaml.dump({"model": "openai:gpt-4o"}))
|
||||
monkeypatch.setenv("MODEL_PROVIDER", "MiniMax-M2.7-highspeed")
|
||||
with caplog.at_level(logging.WARNING):
|
||||
cfg = load_config(str(tmp_path))
|
||||
assert cfg.model == "MiniMax-M2.7-highspeed"
|
||||
assert cfg.runtime_config.model == "MiniMax-M2.7-highspeed"
|
||||
assert any(
|
||||
"MODEL_PROVIDER" in r.getMessage() and "deprecated" in r.getMessage()
|
||||
for r in caplog.records
|
||||
)
|
||||
|
||||
|
||||
def test_no_deprecation_when_MODEL_is_set(tmp_path, monkeypatch, caplog):
|
||||
"""When MODEL is set, MODEL_PROVIDER is ignored entirely and NOT warned
|
||||
about — a workspace that already does it right shouldn't get nagged."""
|
||||
config_yaml = tmp_path / "config.yaml"
|
||||
config_yaml.write_text(yaml.dump({"model": "openai:gpt-4o"}))
|
||||
monkeypatch.setenv("MODEL", "opus")
|
||||
monkeypatch.setenv("MODEL_PROVIDER", "claude-code")
|
||||
with caplog.at_level(logging.WARNING):
|
||||
cfg = load_config(str(tmp_path))
|
||||
assert cfg.model == "opus"
|
||||
assert not any("MODEL_PROVIDER" in r.getMessage() for r in caplog.records)
|
||||
|
||||
|
||||
def test_runtime_config_model_picks_up_env_via_top_level(tmp_path, monkeypatch):
|
||||
"""End-to-end path the canvas Save+Restart relies on: user picks
|
||||
a model → workspace_secrets.MODEL_PROVIDER updated → CP user-data
|
||||
|
||||
Reference in New Issue
Block a user