From 44eca478e2a99de32c43650c2287680c8856839e Mon Sep 17 00:00:00 2001 From: Alkautsar F Date: Sat, 13 Jun 2026 16:25:32 +0700 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20web-app=20gateway=20=E2=80=94=20rea?= =?UTF-8?q?ch=20your=20on-chain=20agent=20from=20any=20browser?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - relay: dumb reverse-tunnel (daemon dials out), path allowlist, per-agent cleanup - plugin-webapp: TunnelClient piping browser requests to the daemon gateway - apps/web: SIWE-gated console chat (paced reveal, intent-aware scroll, approvals) - gateway: operator-signed /sync, webapp plugin wiring - site-wide Title Case typography sweep --- .github/workflows/release.yml | 21 +- apps/web/.env.local.example | 15 + .../api/agent/[tokenId]/connection/route.ts | 62 +++ .../app/console/[tokenId]/activity/page.tsx | 2 +- apps/web/app/console/[tokenId]/chat/page.tsx | 13 + apps/web/app/console/[tokenId]/layout.tsx | 2 +- apps/web/app/docs/page.tsx | 4 +- apps/web/app/globals.css | 135 ++++- apps/web/components/Footer.tsx | 16 +- apps/web/components/console/ActivityFeed.tsx | 44 +- .../components/console/AgentDetailHeader.tsx | 1 + apps/web/components/console/MemoryBrowser.tsx | 10 +- .../web/components/console/chat/ChatPanel.tsx | 436 +++++++++++++++ .../console/chat/MarkdownRenderer.tsx | 96 ++++ .../console/chat/ThinkingIndicator.tsx | 71 +++ apps/web/components/console/chat/parts.tsx | 523 ++++++++++++++++++ .../console/chat/useRevealedText.ts | 86 +++ apps/web/components/docs/DocsMobileNav.tsx | 6 +- apps/web/components/docs/DocsPrevNext.tsx | 6 +- apps/web/components/docs/DocsSidebar.tsx | 6 +- .../sections/section2/V2Identity.tsx | 34 +- .../components/sections/section2/V3Brain.tsx | 26 +- .../components/sections/section2/V4Memory.tsx | 21 +- .../components/sections/section2/V5Limbs.tsx | 13 +- .../components/sections/section2/V6Comms.tsx | 16 +- .../sections/section2/V7Economy.tsx | 50 +- .../components/sections/section3/Mindmap.tsx | 60 +- .../sections/section3/MindmapMobile.tsx | 24 +- .../sections/section4/ClosingCta.tsx | 8 +- apps/web/lib/gateway/hash.ts | 57 ++ apps/web/lib/gateway/relay-client.ts | 205 +++++++ apps/web/lib/siwe/authorize.ts | 39 ++ apps/web/package.json | 1 + bun.lock | 369 +++++++++--- package.json | 3 + packages/core/src/config.ts | 2 +- packages/core/src/plugins/context.ts | 9 + packages/gateway/package.json | 1 + packages/gateway/src/auth.ts | 3 +- packages/gateway/src/build-runtime.ts | 30 +- packages/gateway/src/real-runtime.ts | 6 + packages/gateway/src/secrets.ts | 16 + packages/gateway/src/server.test.ts | 33 +- packages/gateway/src/server.ts | 30 + packages/gateway/tsconfig.json | 3 +- packages/plugin-webapp/package.json | 33 ++ packages/plugin-webapp/src/index.ts | 37 ++ packages/plugin-webapp/src/listener.ts | 46 ++ packages/plugin-webapp/src/protocol.ts | 30 + .../plugin-webapp/src/tunnel-client.test.ts | 220 ++++++++ packages/plugin-webapp/src/tunnel-client.ts | 241 ++++++++ packages/plugin-webapp/src/types.ts | 42 ++ packages/plugin-webapp/tsconfig.json | 13 + packages/relay/bin/relay.ts | 41 ++ packages/relay/package.json | 34 ++ packages/relay/src/index.ts | 10 + packages/relay/src/protocol.ts | 97 ++++ packages/relay/src/relay.test.ts | 162 ++++++ packages/relay/src/relay.ts | 519 +++++++++++++++++ packages/relay/tsconfig.json | 12 + tsconfig.json | 4 +- 61 files changed, 3874 insertions(+), 281 deletions(-) create mode 100644 apps/web/app/api/agent/[tokenId]/connection/route.ts create mode 100644 apps/web/app/console/[tokenId]/chat/page.tsx create mode 100644 apps/web/components/console/chat/ChatPanel.tsx create mode 100644 apps/web/components/console/chat/MarkdownRenderer.tsx create mode 100644 apps/web/components/console/chat/ThinkingIndicator.tsx create mode 100644 apps/web/components/console/chat/parts.tsx create mode 100644 apps/web/components/console/chat/useRevealedText.ts create mode 100644 apps/web/lib/gateway/hash.ts create mode 100644 apps/web/lib/gateway/relay-client.ts create mode 100644 apps/web/lib/siwe/authorize.ts create mode 100644 packages/plugin-webapp/package.json create mode 100644 packages/plugin-webapp/src/index.ts create mode 100644 packages/plugin-webapp/src/listener.ts create mode 100644 packages/plugin-webapp/src/protocol.ts create mode 100644 packages/plugin-webapp/src/tunnel-client.test.ts create mode 100644 packages/plugin-webapp/src/tunnel-client.ts create mode 100644 packages/plugin-webapp/src/types.ts create mode 100644 packages/plugin-webapp/tsconfig.json create mode 100644 packages/relay/bin/relay.ts create mode 100644 packages/relay/package.json create mode 100644 packages/relay/src/index.ts create mode 100644 packages/relay/src/protocol.ts create mode 100644 packages/relay/src/relay.test.ts create mode 100644 packages/relay/src/relay.ts create mode 100644 packages/relay/tsconfig.json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index df5944e..0dfa378 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -152,6 +152,20 @@ jobs: NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }} run: bun publish --access=public + # plugin-webapp must publish BEFORE gateway (gateway deps it via workspace:*). + - name: Publish @s0nderlabs/anima-plugin-webapp + working-directory: packages/plugin-webapp + env: + NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }} + run: bun publish --access=public + + # relay is standalone infra (no internal deps); order is flexible. + - name: Publish @s0nderlabs/anima-relay + working-directory: packages/relay + env: + NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }} + run: bun publish --access=public + - name: Publish @s0nderlabs/anima-gateway working-directory: packages/gateway env: @@ -264,8 +278,11 @@ jobs: echo " ✓ $bin → $(command -v "$bin")" done # Verify version of every installed @s0nderlabs/anima-* package. - # 6 packages total: cli + gateway + core + 3 plugins (telegram lives under deps). - for pkg in anima anima-gateway anima-core anima-plugin-comms anima-plugin-onchain anima-plugin-system anima-plugin-telegram; do + # cli + gateway + core + 4 plugins (telegram + webapp live under gateway deps). + # anima-relay is standalone infra (not a cli/gateway dep) so it is NOT + # installed transitively here — it is published + verified by the + # auto-discovered PACKAGES list above, not this global-install loop. + for pkg in anima anima-gateway anima-core anima-plugin-comms anima-plugin-onchain anima-plugin-system anima-plugin-telegram anima-plugin-webapp; do PKG_JSON="$HOME/.bun/install/global/node_modules/@s0nderlabs/${pkg}/package.json" if [ ! -f "$PKG_JSON" ]; then echo "::error::@s0nderlabs/${pkg} not installed (missing $PKG_JSON)" diff --git a/apps/web/.env.local.example b/apps/web/.env.local.example index 8487ef4..f5927c9 100644 --- a/apps/web/.env.local.example +++ b/apps/web/.env.local.example @@ -8,3 +8,18 @@ SESSION_SECRET="replace-with-strong-random-32-plus-char-secret" # Add this origin + prod origin to the project allowlist at cloud.reown.com # or Reown will pop an "origin not on allowlist" modal. NEXT_PUBLIC_WC_PROJECT_ID="974ed7663d88e07086104fa9a73b2d87" + +# --- Web-app gateway (reach your agent from any browser) --- +# Public HTTP base of the dumb reverse-tunnel relay the agent daemon dials out +# to. The browser reaches the agent at /a//... +# Run the relay with: bun packages/relay/bin/relay.ts (or deploy it anywhere). +ANIMA_WEBAPP_RELAY_URL="http://localhost:8787" +# Per-agent SECRET routing slug map (tokenId -> slug). Only the verified owner is +# handed the slug (server-side ownerOf gate in /api/agent/[tokenId]/connection). +# Example: ANIMA_WEBAPP_SLUGS='{"16":"slug-abc123","17":"slug-xyz789"}' +ANIMA_WEBAPP_SLUGS="" +# Fallback slug for single-agent deployments. Honored ONLY for the token id in +# ANIMA_WEBAPP_DEFAULT_TOKEN_ID below — otherwise a verified owner of any +# unrelated iNFT would be handed this slug and reach the wrong agent. +ANIMA_WEBAPP_DEFAULT_SLUG="" +ANIMA_WEBAPP_DEFAULT_TOKEN_ID="" diff --git a/apps/web/app/api/agent/[tokenId]/connection/route.ts b/apps/web/app/api/agent/[tokenId]/connection/route.ts new file mode 100644 index 0000000..bfdbd78 --- /dev/null +++ b/apps/web/app/api/agent/[tokenId]/connection/route.ts @@ -0,0 +1,62 @@ +// GET /api/agent/[tokenId]/connection +// +// The server-side ownerOf gate + the web-gateway connection handshake. Only the +// verified owner of tokenId learns how to reach the agent: the relay's public +// base URL + the agent's SECRET routing slug. The browser then talks to the +// relay directly (reach-from-anywhere); writes are additionally protected by +// the operator's EIP-191 signature at the daemon, and SSE reads are gated by +// the secrecy of this slug (never the public tokenId). + +import { authorizeAgent } from '@/lib/siwe/authorize' +import type { NextRequest } from 'next/server' + +export const runtime = 'nodejs' + +function resolveSlug(tokenId: string): string | null { + // Per-agent slug map, e.g. ANIMA_WEBAPP_SLUGS={"16":"slug-abc","17":"slug-xyz"} + const raw = process.env.ANIMA_WEBAPP_SLUGS + if (raw) { + try { + const map = JSON.parse(raw) as Record + if (map[tokenId]) return map[tokenId] + } catch { + /* fall through to default */ + } + } + // Single-agent fallback: the default slug is honored ONLY for its own token. + // Without this binding, a verified owner of ANY unrelated iNFT would pass the + // ownerOf gate for their token and be handed a DIFFERENT agent's slug. + const defaultSlug = process.env.ANIMA_WEBAPP_DEFAULT_SLUG + const defaultToken = process.env.ANIMA_WEBAPP_DEFAULT_TOKEN_ID + if (defaultSlug && defaultToken && defaultToken === tokenId) return defaultSlug + return null +} + +export async function GET(_req: NextRequest, ctx: { params: Promise<{ tokenId: string }> }) { + const { tokenId: raw } = await ctx.params + let tokenId: bigint + try { + tokenId = BigInt(raw) + } catch { + return Response.json({ error: 'bad-token-id' }, { status: 400 }) + } + + // SERVER-SIDE OWNERSHIP GATE — runs before any connection info is revealed. + const auth = await authorizeAgent(tokenId) + if (!auth) return Response.json({ error: 'unauthorized' }, { status: 401 }) + + const relayUrl = process.env.ANIMA_WEBAPP_RELAY_URL + if (!relayUrl) { + return Response.json({ error: 'web-gateway-not-configured' }, { status: 503 }) + } + const slug = resolveSlug(raw) + if (!slug) { + return Response.json({ error: 'agent-not-web-enabled' }, { status: 503 }) + } + + return Response.json({ + relayUrl: relayUrl.replace(/\/$/, ''), + agentId: slug, + operator: auth.operator, + }) +} diff --git a/apps/web/app/console/[tokenId]/activity/page.tsx b/apps/web/app/console/[tokenId]/activity/page.tsx index fa5c85d..dec3473 100644 --- a/apps/web/app/console/[tokenId]/activity/page.tsx +++ b/apps/web/app/console/[tokenId]/activity/page.tsx @@ -9,7 +9,7 @@ export default function ActivityTab() { if (!ctx.agentEOA) { return (
- ACTIVITY · WAITING ON SUBNAME + Activity · Waiting on Subname

We could not resolve this agent’s wallet address from the SANN registry. Decrypt needs that address. Register a subname via the CLI and reload. diff --git a/apps/web/app/console/[tokenId]/chat/page.tsx b/apps/web/app/console/[tokenId]/chat/page.tsx new file mode 100644 index 0000000..8f49ded --- /dev/null +++ b/apps/web/app/console/[tokenId]/chat/page.tsx @@ -0,0 +1,13 @@ +'use client' + +import { useAgentContext } from '@/components/console/agent-context' +import { ChatPanel } from '@/components/console/chat/ChatPanel' + +// Live chat with the agent over the web gateway. Unlike the activity/memory +// tabs, this does NOT require a keystore unlock — chat talks to the live daemon +// (which holds the agent key) via wallet-signed messages, decrypting nothing +// locally. Auth is the SIWE session + the per-message EIP-191 signature. +export default function ChatTab() { + const ctx = useAgentContext() + return +} diff --git a/apps/web/app/console/[tokenId]/layout.tsx b/apps/web/app/console/[tokenId]/layout.tsx index 0fdf28b..f607e9e 100644 --- a/apps/web/app/console/[tokenId]/layout.tsx +++ b/apps/web/app/console/[tokenId]/layout.tsx @@ -94,7 +94,7 @@ function DetailShell({ tokenId, children }: { tokenId: bigint; children: ReactNo ) : error ? (

- AGENT · ACCESS + Agent · Access

{error}

) : ( diff --git a/apps/web/app/docs/page.tsx b/apps/web/app/docs/page.tsx index 4d82b8d..894f50d 100644 --- a/apps/web/app/docs/page.tsx +++ b/apps/web/app/docs/page.tsx @@ -11,7 +11,7 @@ export default async function DocsOverviewPage() { const groups = await getNavTree() return (
- +

-
+
Chapter {String(gi + 1).padStart(2, '0')}
on every render and every concurrent message instance. */ +.anima-md { + font-size: 14px; + line-height: 1.65; + color: var(--color-ink); +} +.anima-md [data-streamdown="code-block"] { + margin: 0.75rem 0; + border-radius: 0.75rem; + overflow: hidden; + border: 1px solid var(--color-border) !important; + background-color: var(--color-paper); +} +.anima-md [data-streamdown="code-block-header"] { + background-color: transparent !important; + border: none !important; + box-shadow: none !important; + padding: 0.5rem 0.9rem; + font-family: var(--font-mono); + font-size: 11px; + letter-spacing: 0.04em; + text-transform: lowercase; + color: var(--color-ink-3); +} +.anima-md [data-streamdown="code-block-header"]:has(span:empty) { + display: none !important; +} +.anima-md [data-streamdown="code-block-body"] { + background-color: transparent !important; + border: none !important; + box-shadow: none !important; + margin: 0; + padding: 0.5rem 0.9rem 0.9rem; + font-size: 12.5px; + line-height: 1.6; +} +.anima-md [data-streamdown="code-block-body"] code { + background: transparent !important; + padding: 0; + display: block; + font-family: var(--font-mono); +} +.dark .anima-md [data-streamdown="code-block-body"] code { + filter: brightness(1.7); +} +.anima-md code:not([class*="language-"]) { + padding: 1px 5px; + border-radius: 4px; + font-size: 12.5px; + font-family: var(--font-mono); + background-color: color-mix(in oklab, var(--color-ink) 5%, transparent); + color: var(--color-ink-2); +} +.anima-md table { + width: 100%; + border-collapse: collapse; + font-family: var(--font-mono); + font-size: 12.5px; + font-feature-settings: "tnum" 1; + margin: 0.75rem 0; +} +.anima-md th, +.anima-md td { + border: 1px solid var(--color-border); + padding: 0.4rem 0.7rem; + text-align: left; +} +.anima-md thead { + background: color-mix(in oklab, var(--color-ink) 4%, transparent); +} +.anima-md [data-streamdown="mermaid-block"] { + font-family: var(--font-body); +} +.dark .anima-md [data-streamdown="mermaid-block"] svg { + filter: invert(0.92) hue-rotate(180deg) saturate(0.85) brightness(0.95) !important; +} diff --git a/apps/web/components/Footer.tsx b/apps/web/components/Footer.tsx index fe71937..e0f4867 100644 --- a/apps/web/components/Footer.tsx +++ b/apps/web/components/Footer.tsx @@ -1,6 +1,6 @@ -import Link from 'next/link' -import { CONTRACTS, addressUrl, truncate } from '@/lib/chainscan' import { ThemeToggle } from '@/components/theme/ThemeToggle' +import { CONTRACTS, addressUrl, truncate } from '@/lib/chainscan' +import Link from 'next/link' const PROJECT_LINKS = [ { label: 'GitHub', href: 'https://github.com/s0nderlabs/anima', external: true }, @@ -39,7 +39,7 @@ export function Footer() {

- + {PROJECT_LINKS.map(link => ( {link.label} ↗ @@ -47,7 +47,7 @@ export function Footer() { ))} - + {ON_CHAIN.map(item => (
{item.name} - ↗ + + ↗ +
{truncate(item.address, 7, 5)} @@ -67,7 +69,7 @@ export function Footer() { ))} - + {COMMUNITY_LINKS.map(link => ( {link.label} ↗ @@ -98,7 +100,7 @@ function FooterColumn({ }) { return (
-
+
{label}
{children}
diff --git a/apps/web/components/console/ActivityFeed.tsx b/apps/web/components/console/ActivityFeed.tsx index b17cdc3..45fe2cf 100644 --- a/apps/web/components/console/ActivityFeed.tsx +++ b/apps/web/components/console/ActivityFeed.tsx @@ -1,5 +1,6 @@ 'use client' +import { zgMainnet } from '@/lib/chain/chain' import { fetchSlots } from '@/lib/chain/inft' import { decryptMemoryToText } from '@/lib/crypto/memory' import { shortHash } from '@/lib/format' @@ -9,7 +10,6 @@ import { useEffect, useMemo, useState } from 'react' import ReactMarkdown, { type Components } from 'react-markdown' import rehypeSanitize from 'rehype-sanitize' import { usePublicClient } from 'wagmi' -import { zgMainnet } from '@/lib/chain/chain' const brainComponents: Components = { p: ({ children }) => ( @@ -68,11 +68,12 @@ type ActivityEntry = { } type AutoTopupKind = 'topup-fired' | 'topup-failed' | 'wallet-low' -const AUTO_TOPUP_STATUS: Record = { - 'topup-fired': { label: 'ok', tone: 'ok' }, - 'topup-failed': { label: 'failed', tone: 'failed' }, - 'wallet-low': { label: 'low', tone: 'warn' }, -} +const AUTO_TOPUP_STATUS: Record = + { + 'topup-fired': { label: 'ok', tone: 'ok' }, + 'topup-failed': { label: 'failed', tone: 'failed' }, + 'wallet-low': { label: 'low', tone: 'warn' }, + } type LoadState = | { kind: 'loading' } @@ -123,7 +124,7 @@ export function ActivityFeed({ if (state.kind === 'loading') { return ( -

+

Unsealing

@@ -156,7 +157,7 @@ export function ActivityFeed({ {grouped?.map((group, gi) => (
- + {group.label}
@@ -274,7 +275,9 @@ function renderEntry(entry: ActivityEntry) { ↳ {d.name ?? 'tool'}{' '} - + {ok ? 'ok' : 'failed'} · {summary} @@ -335,7 +338,7 @@ function renderEntry(entry: ActivityEntry) { {message} + {entry.kind} ) @@ -379,7 +382,7 @@ function groupByHour(entries: ActivityEntry[]) { let currentKey = '' for (const entry of entries) { const d = new Date(entry.ts) - const date = d.toLocaleString([], { month: 'short', day: 'numeric' }).toUpperCase() + const date = d.toLocaleString([], { month: 'short', day: 'numeric' }) const hour = `${String(d.getHours()).padStart(2, '0')}:00` const label = `${date} · ${hour}` if (label !== currentKey) { @@ -406,7 +409,8 @@ function summarizeArgs(v: unknown): string { // Skip noise: empty arrays/objects/null/undefined. if (val === null || val === undefined) continue if (Array.isArray(val) && val.length === 0) continue - if (typeof val === 'object' && !Array.isArray(val) && Object.keys(val as object).length === 0) continue + if (typeof val === 'object' && !Array.isArray(val) && Object.keys(val as object).length === 0) + continue parts.push(`${k}=${prettyArgValue(val)}`) if (parts.join(', ').length > 80) break } @@ -468,7 +472,9 @@ function ToolPairRow({ entry }: { entry: ActivityEntry }) { const name = call?.name ?? 'tool' const args = call?.args const argsHas = - args !== undefined && args !== null && !(typeof args === 'object' && Object.keys(args as object).length === 0) + args !== undefined && + args !== null && + !(typeof args === 'object' && Object.keys(args as object).length === 0) const argsShort = argsHas ? summarizeArgs(args) : '' const hasResult = !!result const ok = !result || result.ok !== false @@ -498,7 +504,7 @@ function ToolPairRow({ entry }: { entry: ActivityEntry }) { {!argsShort && } {hasResult && ( )} @@ -551,7 +553,7 @@ function ExpandBlock({ }) { return (
-

+

{label}

-        
-          memory · unsealing
+        
+          Memory · Unsealing
         
         

Pulling the encrypted index from 0G Storage and decrypting in this tab. @@ -374,7 +374,7 @@ function FileDetail({ + + +

+ + + ) +} + +function ChatNotice({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ {label} +

+ {children} +

+
+ ) +} diff --git a/apps/web/components/console/chat/MarkdownRenderer.tsx b/apps/web/components/console/chat/MarkdownRenderer.tsx new file mode 100644 index 0000000..ca0b9e8 --- /dev/null +++ b/apps/web/components/console/chat/MarkdownRenderer.tsx @@ -0,0 +1,96 @@ +'use client' + +import { Streamdown } from 'streamdown' +import type { MermaidOptions } from 'streamdown' + +// Streaming markdown for assistant replies, ported from pragma-v2-stable's +// Streamdown wrapper but restyled to anima's editorial "literary cream" palette +// (--color-* tokens, Geist Mono code, no terracotta, no red/green). Dark mode is +// driven by the `.dark` ancestor class (same as the rest of the app) so theme +// switches don't re-render and lose scroll. Mermaid is always rendered light and +// CSS-inverted in dark, matching the pragma approach. + +interface MarkdownRendererProps { + content: string + isAnimating?: boolean +} + +function preprocessContent(content: string): string { + let cleaned = content.replace(/\r\n/g, '\n') + // Drop agent-internal bracketed addresses + quote-id HTML comments. + cleaned = cleaned.replace(/\[0x[a-fA-F0-9]{40,}\]/g, '') + cleaned = cleaned.replace(//g, '') + return cleaned +} + +const mermaidOptions: MermaidOptions = { + config: { + theme: 'default', + themeVariables: { + primaryColor: '#f2f1ee', + primaryTextColor: '#100f09', + primaryBorderColor: '#100f09', + lineColor: '#525251', + secondaryColor: '#f0e8d6', + tertiaryColor: '#fbfbf9', + background: '#f9f8f6', + mainBkg: '#fbfbf9', + titleColor: '#100f09', + }, + }, +} + +export function MarkdownRenderer({ content, isAnimating = false }: MarkdownRendererProps) { + return ( +
+ ) +} diff --git a/apps/web/components/console/chat/ThinkingIndicator.tsx b/apps/web/components/console/chat/ThinkingIndicator.tsx new file mode 100644 index 0000000..6c9625a --- /dev/null +++ b/apps/web/components/console/chat/ThinkingIndicator.tsx @@ -0,0 +1,71 @@ +'use client' + +import { AnimatePresence, motion } from 'framer-motion' +import { useEffect, useState } from 'react' + +// Unicode star spinner, ported from pragma-v2-stable's ThinkingIndicator but +// re-voiced for anima's sovereign-agent context and restyled to the editorial +// palette (muted ink, no terracotta — failure/status never uses color here). +const SPINNER_FRAMES = ['✦', '✧', '✶', '✷', '✸', '✹', '✺', '✻'] + +const ANIMA_VIBES = [ + 'Reasoning', + 'Consulting memory', + 'Reaching for tools', + 'Reading the chain', + 'Weighing the options', + 'Composing a reply', + 'Checking permissions', + 'Recalling context', + 'Thinking it through', + 'Working on-chain', +] + +interface ThinkingIndicatorProps { + /** Override the rotating phrase (e.g. a tool name currently running). */ + label?: string +} + +export function ThinkingIndicator({ label }: ThinkingIndicatorProps) { + const [frameIndex, setFrameIndex] = useState(0) + const [phraseIndex, setPhraseIndex] = useState(0) + + useEffect(() => { + const id = setInterval(() => setFrameIndex(p => (p + 1) % SPINNER_FRAMES.length), 110) + return () => clearInterval(id) + }, []) + + useEffect(() => { + if (label) return + const id = setInterval(() => setPhraseIndex(p => (p + 1) % ANIMA_VIBES.length), 1600) + return () => clearInterval(id) + }, [label]) + + const message = label ?? ANIMA_VIBES[phraseIndex] + + return ( + + + {SPINNER_FRAMES[frameIndex]} + + + + {message}… + + + + ) +} diff --git a/apps/web/components/console/chat/parts.tsx b/apps/web/components/console/chat/parts.tsx new file mode 100644 index 0000000..3da9fa5 --- /dev/null +++ b/apps/web/components/console/chat/parts.tsx @@ -0,0 +1,523 @@ +'use client' + +// Shared, refined chat primitives used by the real console ChatPanel AND the +// /mocks design + live pages, so polish lands in one place. Editorial palette +// throughout (--color-* tokens, Fraunces/Outfit/Geist Mono); Claude/ChatGPT +// layout paradigm: centered column, full-width agent prose, understated user +// turns, one composer with the send control inside. +// +// Interaction logic ported from pragma-v2-stable, rendered entirely in anima's +// ink/cream vocabulary: paced reveal of the assistant blob, turn-boundary copy + +// retry, a state-aware composer whose send arrow morphs to a stop while a turn +// runs, and a warm (never-red) error row. + +import { AnimatePresence, motion } from 'framer-motion' +import { useEffect, useMemo, useState } from 'react' +import { MarkdownRenderer } from './MarkdownRenderer' +import { ThinkingIndicator } from './ThinkingIndicator' +import { useRevealedText } from './useRevealedText' + +export const EASE = [0.22, 1, 0.36, 1] as const + +export type ToolStatus = 'running' | 'ok' | 'failed' +export type ChatItem = + | { kind: 'op'; id: string; text: string } + | { kind: 'assistant'; id: string; text: string } + | { + kind: 'tool' + id: string + callId: string + name: string + status: ToolStatus + durationMs?: number + } + | { kind: 'note'; id: string; text: string } + | { kind: 'error'; id: string; text: string; retry?: string } + +export function AnimaMark({ size = 28 }: { size?: number }) { + return ( + + a + + ) +} + +function IconButton({ + label, + onClick, + children, +}: { + label: string + onClick: () => void + children: React.ReactNode +}) { + return ( + + ) +} + +// One quiet action row anchored to the END of an agent turn. Copy grabs the +// whole turn's prose (not a single fragment); retry re-runs the preceding op. +function TurnActions({ copyText, onRetry }: { copyText: string; onRetry?: () => void }) { + const [copied, setCopied] = useState(false) + return ( +
+ { + navigator.clipboard?.writeText(copyText).then(() => { + setCopied(true) + setTimeout(() => setCopied(false), 1400) + }) + }} + > + {copied ? ( + + ) : ( + + )} + + {onRetry ? ( + + + + ) : null} +
+ ) +} + +function ToolRow({ + name, + status, + durationMs, +}: { name: string; status: ToolStatus; durationMs?: number }) { + const glyph = status === 'running' ? '▸' : '↳' + const label = status === 'running' ? 'running' : status + return ( +
+ {glyph} + {name} + {status === 'failed' ? ( + + {label} + + ) : ( + {label} + )} + {status === 'ok' && typeof durationMs === 'number' ? ( + + · {formatDuration(durationMs)} + + ) : null} +
+ ) +} + +function formatDuration(ms: number): string { + if (ms < 1000) return `${Math.round(ms)}ms` + return `${(ms / 1000).toFixed(ms < 10_000 ? 1 : 0)}s` +} + +// Warm, never-red terminal state with an inline retry. Uses a frozen star glyph +// (motion decayed to rest = done) rather than an alarming colored note. +function ErrorRow({ text, onRetry }: { text: string; onRetry?: () => void }) { + return ( +
+ + {text} + {onRetry ? ( + + ) : null} +
+ ) +} + +// A single revealed assistant fragment (paces the final string into bursts). +function AssistantBody({ text }: { text: string }) { + const { shown, done } = useRevealedText(text) + return ( +
+ +
+ ) +} + +type Group = + | { side: 'user'; items: ChatItem[] } + | { side: 'agent'; items: ChatItem[]; retryText?: string } + +function groupTurns(items: ChatItem[]): Group[] { + const groups: Group[] = [] + let lastUserText: string | undefined + for (const it of items) { + if (it.kind === 'op') { + lastUserText = it.text + const last = groups[groups.length - 1] + if (last && last.side === 'user') last.items.push(it) + else groups.push({ side: 'user', items: [it] }) + } else { + const last = groups[groups.length - 1] + if (last && last.side === 'agent') last.items.push(it) + else groups.push({ side: 'agent', items: [it], retryText: lastUserText }) + } + } + return groups +} + +function AgentTurn({ + items, + thinking, + retryText, + onRetry, +}: { + items: ChatItem[] + thinking?: boolean + retryText?: string + onRetry?: (text: string) => void +}) { + const assistantText = items + .filter(it => it.kind === 'assistant') + .map(it => (it.kind === 'assistant' ? it.text : '')) + .join('\n\n') + const hasAssistant = assistantText.length > 0 + return ( + + +
+ {items.map(it => { + if (it.kind === 'tool') + return ( + + ) + if (it.kind === 'note') + return ( +
+ {it.text} +
+ ) + if (it.kind === 'error') + return ( + onRetry(it.retry as string) : undefined} + /> + ) + if (it.kind === 'assistant') return + return null + })} + {thinking ? : null} + {hasAssistant && !thinking ? ( + onRetry(retryText) : undefined} + /> + ) : null} +
+
+ ) +} + +function UserTurn({ items }: { items: ChatItem[] }) { + return ( +
+ {items.map(it => ( + + {it.kind === 'op' ? it.text : null} + + ))} +
+ ) +} + +export function Transcript({ + items, + pending, + onRetry, +}: { + items: ChatItem[] + pending: boolean + onRetry?: (text: string) => void +}) { + const groups = groupTurns(items) + const lastIsUser = items[items.length - 1]?.kind === 'op' + return ( +
+ {groups.map((g, i) => { + const key = g.items[0]?.id ?? `g${i}` + const isLast = i === groups.length - 1 + if (g.side === 'user') return + return ( + + ) + })} + {/* pending right after a user turn → fresh agent turn with just the spinner */} + {pending && lastIsUser ? ( + + +
+ +
+
+ ) : null} +
+ ) +} + +// Floating "jump to latest" affordance, shown by the caller when the operator +// has scrolled up mid-turn. Flat ink ring, no drop-shadow beyond the soft +// composer treatment — never a glassy floating glow. +export function ScrollToBottomPill({ onClick }: { onClick: () => void }) { + return ( + + + + ) +} + +export function EmptyState({ title, sub }: { title: string; sub: string }) { + return ( +
+ +

+ {title} +

+

{sub}

+
+ ) +} + +const FIRST_PROMPTS = [ + 'ask me to remember something', + "check the agent's onchain balance", + 'what did we decide yesterday?', + 'what time is it on the host?', + 'summarize the agent’s recent activity', +] + +export function Composer({ + value, + onChange, + onSend, + onStop, + pending = false, + blocked = false, + hasMessages = false, + hint, +}: { + value: string + onChange: (v: string) => void + onSend: () => void + onStop?: () => void + pending?: boolean + blocked?: boolean + hasMessages?: boolean + hint?: string +}) { + const empty = value.trim().length === 0 + const canSend = !blocked && !pending && !empty + + // Rotated literary prompt for the very first message, picked once on the + // client (null-then-set) to avoid a hydration mismatch. + const [firstPrompt, setFirstPrompt] = useState(null) + useEffect(() => { + setFirstPrompt(FIRST_PROMPTS[Math.floor(Math.random() * FIRST_PROMPTS.length)]) + }, []) + + const placeholder = useMemo(() => { + if (blocked) return 'connecting to your agent…' + if (hasMessages) return 'what else?' + return firstPrompt ? `Message your agent — e.g. ${firstPrompt}` : 'Message your agent…' + }, [blocked, hasMessages, firstPrompt]) + + return ( +
+
+