mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-29 07:20:43 +00:00
Every import, the catalog pin and the docs name @hanzo/app and github.com/hanzoai/app. lib/rooms.ts is lib/app.ts and components/rooms.tsx is components/app.tsx.
224 lines
11 KiB
TypeScript
224 lines
11 KiB
TypeScript
'use client'
|
|
|
|
/**
|
|
* The row at the foot of the first screen: every agent this already works with, wearing its own
|
|
* mark, and each one a door into the page that sets it up.
|
|
*
|
|
* IT WAS EIGHT PLAIN WORDS. "Claude Code · Codex · Gemini · Qwen" as bare text
|
|
* is the same failure the rest of this page just fixed one section at a time: a
|
|
* list of names a reader has to parse instead of a row of marks they recognise
|
|
* at a glance. The marks are the point — a stranger scanning this band should
|
|
* see the logo of the tool already on their machine.
|
|
*
|
|
* `ProviderMark` is the ONE mark table (@hanzo/app, fetched when a mark is first drawn: components/models/Mark.tsx),
|
|
* bodies copied unmodified from hanzoai/icons. Never draw a logo here: a lab
|
|
* with no mark in that table falls back to a monogram at the SAME footprint, so
|
|
* the row does not reflow, and that fallback is the documented last resort
|
|
* rather than an invitation to invent one.
|
|
*
|
|
* THE NAMES ARE LINKS AGAIN, and the reason they stopped being links is worth
|
|
* keeping: a predecessor pointed all nine at `docs.hanzo.ai/agents/<name>` and
|
|
* seven answered 404, so the whole row went back to plain text. The addresses
|
|
* below are not that pattern. Each was requested and answered 200, and each was
|
|
* then READ: `/docs/coding` is the page that names Claude Code and Codex,
|
|
* `/docs/apps/ides` is the one that names Cursor and Antigravity, `/docs/dev`
|
|
* is Hanzo Dev's own. Four rows — Gemini, Qwen, Hermes, OpenClaw — have no page
|
|
* of their own anywhere in docs' 2,882-URL sitemap, and they go to `/docs/mcp`,
|
|
* which is where an MCP client of any name is told what to do. That is a real
|
|
* answer rather than a near miss, and it is marked below so the next person
|
|
* does not have to measure it again.
|
|
*
|
|
* THE BUTTON AT THE END HANDS AN AGENT ITS INSTRUCTIONS. It copies `SETUP`
|
|
* (./prompts), the same sentence the fold's Copy setup prompt and every
|
|
* "Open in" row carry: read /skill.md, sign in, pick a starter, boot it. A
|
|
* reader who recognises their agent in this row pastes one line into it.
|
|
*/
|
|
import { useState } from 'react'
|
|
import { Check, Copy } from 'lucide-react'
|
|
import { Text, View, XStack, YStack } from '@hanzo/ui'
|
|
import { Mark } from '@/components/models/Mark'
|
|
import { SETUP } from './prompts'
|
|
|
|
/** The terminal's own face, for the two pieces of mono on the band: no
|
|
* ligatures and none of the body's stylistic sets, as the transcript sets it. */
|
|
const FACE = { fontFamily: '$mono', '$platform-web': { fontVariantLigatures: 'none', fontFeatureSettings: 'normal' } } as const
|
|
|
|
/** The window onto the track: names dissolve at either edge rather than being cut. */
|
|
const FADE = 'linear-gradient(90deg, transparent 0, #000 28px, #000 calc(100% - 28px), transparent 100%)'
|
|
|
|
/** What the button says at rest; `Copied` for two seconds after. */
|
|
const TAKE = 'Copy setup prompt'
|
|
|
|
/** One lap: the track travels one third of itself, one pass, so the row never snaps back. */
|
|
const LAP: Keyframe[] = [{ transform: 'translateX(0)' }, { transform: 'translateX(calc(-100% / 3))' }]
|
|
const LAP_MS = 42_000
|
|
|
|
/**
|
|
* The track's motion, run on the element by the Web Animations API.
|
|
*
|
|
* It holds while the pointer is over the window or focus is inside it, because
|
|
* every name is a click target and a sliding link is a game of catch, and it
|
|
* does not run at all for a reader who asked for no motion. A ref callback, so
|
|
* it starts and stops with the element.
|
|
*/
|
|
function lap(track: HTMLElement | null) {
|
|
const view = track?.parentElement
|
|
if (!track || !view || typeof track.animate !== 'function') return
|
|
const still = window.matchMedia('(prefers-reduced-motion: reduce)')
|
|
let run: Animation | null = null
|
|
const hold = () => {
|
|
if (!run) return
|
|
if (view.matches(':hover, :focus-within')) run.pause()
|
|
else run.play()
|
|
}
|
|
const start = () => {
|
|
run?.cancel()
|
|
run = still.matches ? null : track.animate(LAP, { duration: LAP_MS, iterations: Infinity, easing: 'linear' })
|
|
hold()
|
|
}
|
|
const watch = ['pointerenter', 'pointerleave', 'focusin', 'focusout'] as const
|
|
start()
|
|
still.addEventListener('change', start)
|
|
for (const e of watch) view.addEventListener(e, hold)
|
|
return () => {
|
|
still.removeEventListener('change', start)
|
|
for (const e of watch) view.removeEventListener(e, hold)
|
|
run?.cancel()
|
|
}
|
|
}
|
|
|
|
/** Declared once, so a render of the strip (the button's `Copied`) does not restart the lap. */
|
|
const track = (el: unknown) => lap(el instanceof HTMLElement ? el : null)
|
|
|
|
/**
|
|
* Each agent, its mark, and the page that sets it up.
|
|
*
|
|
* `mcp` marks the four with no page of their own. They are MCP clients, so
|
|
* `/docs/mcp` is their setup page in the same sense it is Cursor's — it just is
|
|
* not a page about THEM, and saying which is which here is cheaper than
|
|
* measuring the sitemap again.
|
|
*/
|
|
const AGENTS: Array<{ name: string; org: string; href: string; mcp?: true }> = [
|
|
{ name: 'Claude Code', org: 'claudecode', href: 'https://docs.hanzo.ai/docs/coding' },
|
|
{ name: 'Codex', org: 'codex', href: 'https://docs.hanzo.ai/docs/coding' },
|
|
{ name: 'Gemini', org: 'google', href: 'https://docs.hanzo.ai/docs/mcp', mcp: true },
|
|
{ name: 'Qwen', org: 'qwen', href: 'https://docs.hanzo.ai/docs/mcp', mcp: true },
|
|
{ name: 'Cursor', org: 'cursor', href: 'https://docs.hanzo.ai/docs/apps/ides' },
|
|
{ name: 'Antigravity', org: 'antigravity', href: 'https://docs.hanzo.ai/docs/apps/ides' },
|
|
{ name: 'Hermes', org: 'hermesagent', href: 'https://docs.hanzo.ai/docs/mcp', mcp: true },
|
|
{ name: 'OpenClaw', org: 'openclaw', href: 'https://docs.hanzo.ai/docs/mcp', mcp: true },
|
|
{ name: 'Hanzo Dev', org: 'hanzo', href: 'https://docs.hanzo.ai/docs/dev' },
|
|
{ name: 'MCP', org: 'mcp', href: 'https://docs.hanzo.ai/docs/mcp' },
|
|
{ name: 'Anthropic SDK', org: 'anthropic', href: 'https://docs.hanzo.ai/docs/guides/integrations/claude-agent-sdk' },
|
|
{ name: 'OpenAI SDK', org: 'openai', href: 'https://docs.hanzo.ai/docs/guides/integrations/openai-agents' },
|
|
{ name: 'Universal SDKs', org: 'enso', href: 'https://docs.hanzo.ai/docs/sdks' },
|
|
]
|
|
|
|
export default function AgentStrip() {
|
|
const [took, setTook] = useState(false)
|
|
|
|
const take = () => {
|
|
void navigator.clipboard?.writeText(SETUP).catch(() => {})
|
|
setTook(true)
|
|
window.setTimeout(() => setTook(false), 2000)
|
|
}
|
|
|
|
return (
|
|
<YStack data-slot="agent-strip" overflow="hidden" py={14} borderTopWidth={1} borderBottomWidth={1} borderColor="$edge">
|
|
{/* The row WRAPS, so the two fixed ends never squeeze the track down to
|
|
one and a half names. The track's window asks for the rest of the
|
|
line, and on a phone takes the whole one below them. */}
|
|
<XStack flexWrap="wrap" items="center" gap={16} px={16}>
|
|
<Text {...FACE} shrink={0} pr={4} fontSize="$1" color="var(--text-tertiary)" whiteSpace="nowrap">
|
|
Drop into any agent
|
|
</Text>
|
|
|
|
{/* THREE identical passes, and the track translates one third of
|
|
itself per lap. What leaves on the left is what the next pass
|
|
already put on the right, so the row never snaps back and never
|
|
shows a gap: a pass is about 1,500px, and three of them keep the
|
|
window full while one scrolls out, up to a 3,000px screen. Two
|
|
passes left the tail of the lap empty on any screen wider than one
|
|
pass. The copies are aria-hidden and out of the tab order: the same
|
|
names read three times is noise to a reader who never sees the
|
|
animation that makes them look continuous.
|
|
|
|
It is `overflow-x: auto` and never `hidden`, so the row is
|
|
scrollable by hand at any width. That matters most where the
|
|
animation is off — under prefers-reduced-motion nothing moves, and
|
|
a row that could only be read by waiting would be unreadable. The
|
|
track (`lap`) also pauses on hover and on focus-within, because
|
|
every name in it is a click target and a sliding link is a game of
|
|
catch.
|
|
|
|
Both ends FADE, because a hard cut mid-word reads as a typo rather
|
|
than as motion. On a phone the window takes the whole line under
|
|
the label and the setup pill: between them a 390px row leaves a
|
|
name and a half, which conveys nothing. */}
|
|
{/* @ts-expect-error gui applies `order` on the web; its types do not list it. */}
|
|
<View
|
|
data-slot="agent-window"
|
|
flex={1}
|
|
minW={0}
|
|
overflowX="auto"
|
|
maskImage={FADE}
|
|
$platform-web={{ scrollbarWidth: 'none' }}
|
|
$max-sm={{ order: 1, flexBasis: '100%' }}
|
|
>
|
|
<XStack data-slot="agent-track" ref={track} width="max-content">
|
|
{[0, 1, 2].map((pass) => (
|
|
<XStack key={pass} aria-hidden={pass > 0 ? true : undefined}>
|
|
{AGENTS.map((a) => (
|
|
<XStack
|
|
key={`${pass}-${a.name}`}
|
|
render={<a href={a.href} target="_blank" rel="noreferrer" tabIndex={pass > 0 ? -1 : undefined} />}
|
|
display="inline-flex"
|
|
items="center"
|
|
gap={8}
|
|
shrink={0}
|
|
pr={28}
|
|
$touchable={{ minH: 44 }}
|
|
>
|
|
{/* House ink. This row is a marketing band and every mark
|
|
has its name beside it, so the brand hue identifies
|
|
nothing here — it just puts a dozen palettes on a
|
|
monochrome page. The catalog and the charts still get
|
|
the real colours, where the colour is the data. */}
|
|
<Mark org={a.org} size={18} ink />
|
|
<Text fontSize="$2" color="var(--text-secondary)" whiteSpace="nowrap">
|
|
{a.name}
|
|
</Text>
|
|
</XStack>
|
|
))}
|
|
</XStack>
|
|
))}
|
|
</XStack>
|
|
</View>
|
|
|
|
<XStack
|
|
render={<button type="button" onClick={take} aria-live="polite" title={SETUP} />}
|
|
display="inline-flex"
|
|
items="center"
|
|
gap={8}
|
|
shrink={0}
|
|
py={5}
|
|
px={10}
|
|
rounded={8}
|
|
borderWidth={1}
|
|
borderColor="$edge"
|
|
bg="transparent"
|
|
cursor="pointer"
|
|
$touchable={{ minH: 44 }}
|
|
>
|
|
{took ? <Check size={12} strokeWidth={2} color="var(--text-secondary)" aria-hidden /> : <Copy size={12} strokeWidth={1.5} color="var(--text-secondary)" aria-hidden />}
|
|
{/* The label swaps for a shorter word, and the width is held so the
|
|
band does not shrug every time somebody clicks it. */}
|
|
<Text {...FACE} display="inline-flex" minW={`calc(${TAKE.length}ch)`} fontSize="$1" color="var(--text-secondary)">
|
|
{took ? 'Copied' : TAKE}
|
|
</Text>
|
|
</XStack>
|
|
</XStack>
|
|
</YStack>
|
|
)
|
|
}
|