diff --git a/docs/design/onboarding.md b/docs/design/onboarding.md new file mode 100644 index 0000000..91ab535 --- /dev/null +++ b/docs/design/onboarding.md @@ -0,0 +1,98 @@ +# Onboarding and the family photo + +## The idea + +The old "Getting started" dialog said, in effect, go find a contribution and +connect your agent. Nothing happened when you did. The new onboarding is built +around one task that is fun, a little competitive, and exercises every part of +the loop: **get your agent into the family photo.** + +The family photo is one big group picture of everyone who has joined the +project, agents and people alike. Each portrait is an image an agent made of +itself and posted to the project's `family-photo` session. Join order is your +spot: the front row fills first, rows get wider and smaller toward the back, +and there is always a dotted outline where the next person goes. It scales to +a thousand. + +## What was built + +- **`FamilyPhoto`** (`ui/src/family/FamilyPhoto.tsx`): the picture. A + deterministic seat plan (`seats(capacity)`) places portraits on a hillside + scene; portraits are circle-cropped, scale with depth and with the frame, and + the viewer's own are ringed in yellow. The next three open spots are drawn, + and the first is marked as yours until you are in it. The scene is a + placeholder SVG until the real source image is dropped in via the `scene` + prop; nothing else needs to change. +- **Onboarding** (`ui/src/family/Onboarding.tsx`), three steps that complete + themselves: + 1. _There's a spot for you_: the photo, compact, with your spot marked. + 2. _Connect your agent_: the exact `axp park family-photo …` command with a + copy button. The step ticks when an executor owned by you appears in the + registry. + 3. _Your first task: a self-portrait_: what the agent will be asked, with the + prompt visible. A maintainer can send it from here; the intended path is + automation that sends it the moment an agent parks (an AAMP route or a + small maintainer-side watcher; see below). +- **Gateway** (`src/workspace.ts`): `GET /api/family` lists portraits from + every session whose task is `family-photo` or `family-photo-N`, in join + order; `GET /api/portrait?session&digest` serves an image blob inline with + `nosniff`, a sandboxed CSP and immutable caching. These are the only blobs + the gateway serves as images, and only PNG, JPEG, WebP, GIF or SVG under + 1.5 MB. The page CSP now allows `blob:` images so authenticated fetches can + be shown through object URLs; `` alone cannot carry the token. +- **Demo**: the fixture creates the `family-photo` session and posts seven + portraits the way an agent would (blob upload, then a comment with the image + reference and a caption). Procedural villager faces stand in for real + portraits. +- **Test**: the browser suite checks the photo, join order, the image + endpoint's headers, that it is unreachable without the token, and the + onboarding dialog. + +## The protocol, unchanged + +A portrait is a comment whose body contains `axp-blob://`. +Comments are authenticated, ordered, durable, capped at 256 per session and +already part of export. That is why no new command was added. The 256 cap is +the reason the gateway accepts `family-photo-2`, `-3`, …: a project shards +when the front sessions fill. A dedicated `_axp/portrait` command with +one-per-principal enforcement is the natural next protocol step; the UI +would not change. + +## Malicious, competitive, collaborative + +- Front-row spots are first come, first served. That is the competition. +- One portrait per person is a convention today, not a rule; the gateway shows + every portrait a principal posts. Enforcing it belongs in the host. +- An agent's image tool runs under its contributor's permissions; tool + approval is unchanged, so a portrait that requires a tool the maintainer + won't allow doesn't get made. Blob size is capped by the host and again by + the portrait endpoint. +- SVG portraits are served with a sandboxed CSP and only ever loaded through + ``, which does not execute scripts. + +## The portrait generator + +Portraits should look like one family, so there is a generator: +[maceip/axp-avatar](https://github.com/maceip/axp-avatar), branch +`axp/generative-inputs`. `tools/slice.py` cuts the uploaded sheets into layers +(backgrounds, mannequin heads with hair or hats, villager and alternative +bases, face accessories with the mannequin subtracted, isolated headwear) and +`tools/compose.py` turns a seed into a stable 512px avatar: skin retoned by +seed, the notion-avatar line parts (eyebrows, eyes, nose, mouth, sometimes a +beard) placed by mapping the notion face box onto the head's skin box, then +glasses and a hat. `--transparent` gives the family-photo cutout. The task +prompt points agents at it as the "our tool" option; agents with their own +image tool are free to use that instead. + +The sheets are game renders and are fine as prototype inputs; anything +published should replace them with our own drawings in the same slots (the +README in that repo says so too). The big empty group scene will arrive in the +same repo and drops into `FamilyPhoto`'s `scene` prop. + +## Next + +- The real scene image, and a layout pass against it (the seat plan is a + function; rows, pitch and depth are four numbers). +- Automation for step three: send `FAMILY_TASK_PROMPT` when a new executor + claims the family session. +- Show the portrait beside its author everywhere avatars appear today. diff --git a/src/workspace-contract.ts b/src/workspace-contract.ts index 13c2745..03efccd 100644 --- a/src/workspace-contract.ts +++ b/src/workspace-contract.ts @@ -56,3 +56,20 @@ export interface WorkspaceCommand { | { kind: "accept"; checkpoint: string; manifestDigest: string } | { kind: "submit"; checkpoint: string; model: string }; } + +/** One person's portrait in the family photo. */ +export interface Portrait { + id: string; + author: string; + session: string; + digest: string; + createdAt: number; + caption: string; +} +export interface FamilyPhoto { + /** sessions that feed the photo (task `family-photo`, `family-photo-2`, …) */ + sessions: string[]; + /** in join order; index is the spot */ + portraits: Portrait[]; + capacity: number; +} diff --git a/src/workspace.ts b/src/workspace.ts index 442e2a6..2079d5f 100644 --- a/src/workspace.ts +++ b/src/workspace.ts @@ -19,11 +19,24 @@ import { id, sha, digest } from "./protocol/schema.js"; import { Codes, requireThat, ProtocolError } from "./protocol/errors.js"; import { hashObject } from "./hash.js"; import { WorkspaceCommands } from "./workspace-commands.js"; + import type { Contribution, ContributionDetail, + FamilyPhoto, + Portrait, WorkspaceView, } from "./workspace-contract.js"; +const PORTRAIT_TYPES = new Set([ + "image/png", + "image/jpeg", + "image/webp", + "image/gif", + "image/svg+xml", +]); +const PORTRAIT_MAX_BYTES = 1_500_000; +/** Spots in the photo. The layout scales to this; sharding sessions raises it. */ +const FAMILY_CAPACITY = 1000; export interface WorkspaceOptions { url: string; @@ -232,6 +245,58 @@ export class WorkspaceServer { totalTurns: chat.turns.length, }; } + /** The family photo: every portrait posted to the project's family sessions. + * + * A portrait is a discussion comment in a session whose task is + * `family-photo` (or `family-photo-N`, so a project can shard past the + * 256-comment discussion cap) whose body references an image blob in that + * session. Order is join order, which is what decides your spot. */ + private async family(client: AxpClient): Promise { + const { items } = await client.ahp.request("listSessions", { + channel: ROOT, + }); + const portraits: Portrait[] = []; + const sessions: string[] = []; + // Comments posted in the same millisecond keep the host's sequence. + const order = new Map(); + for (const item of items) { + const session = id.parse(item.resource.slice("ahp-session:/".length)); + const state = await this.snapshot( + client, + channels(session).exchange, + ); + if (!/^family-photo(-\d+)?$/.test(state.task)) continue; + sessions.push(session); + const prefix = `axp-blob:/${encodeURIComponent(state.resource)}/`; + for (const [index, comment] of (state.discussion ?? []).entries()) { + const match = comment.body.match( + new RegExp( + `${prefix.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&")}([a-f0-9]{64})`, + ), + ); + if (!match) continue; + order.set(comment.id, index); + portraits.push({ + id: comment.id, + author: comment.author, + session, + digest: match[1]!, + createdAt: comment.createdAt, + caption: comment.body + .replace(/!\[[^\]]*\]\([^)]*\)/g, "") + .replace(/axp-blob:\/\S+/g, "") + .trim() + .slice(0, 140), + }); + } + } + portraits.sort( + (a, b) => + a.createdAt - b.createdAt || + (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0), + ); + return { sessions, portraits, capacity: FAMILY_CAPACITY }; + } private async workspace( client: AxpClient, offset: number, @@ -333,7 +398,7 @@ export class WorkspaceServer { response.setHeader("referrer-policy", "no-referrer"); response.setHeader( "content-security-policy", - "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; worker-src 'self' blob:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'", + "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self'; worker-src 'self' blob:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'", ); try { requireThat( @@ -435,6 +500,43 @@ export class WorkspaceServer { } return; } + if (url.pathname === "/api/family" && request.method === "GET") { + json(200, await this.family(client)); + return; + } + if (url.pathname === "/api/portrait" && request.method === "GET") { + // Portraits are the only blobs the gateway serves as images. Content + // addressing makes them immutable, so they cache for a year; nosniff + // and an inline disposition keep an the only consumer. + const session = id.parse(url.searchParams.get("session")); + const sha256 = digest.parse(url.searchParams.get("digest")); + const blob = await client.call("_axp/blobGet", { + channel: channels(session).exchange, + digest: sha256, + }); + requireThat( + PORTRAIT_TYPES.has(blob.mediaType), + Codes.invalid, + "Portraits must be PNG, JPEG, WebP, GIF or SVG", + ); + const bytes = Buffer.from(blob.data, "base64"); + requireThat( + bytes.length <= PORTRAIT_MAX_BYTES, + Codes.limit, + "Portraits are limited to 1.5 MB", + ); + response.writeHead(200, { + "content-type": blob.mediaType, + "content-length": bytes.length, + "content-disposition": "inline", + "cache-control": "private, max-age=31536000, immutable", + "x-content-type-options": "nosniff", + "content-security-policy": + "sandbox; default-src 'none'; style-src 'unsafe-inline'", + }); + response.end(bytes); + return; + } if (url.pathname === "/api/patch" && request.method === "GET") { const session = id.parse(url.searchParams.get("session")); const checkpoint = sha.parse(url.searchParams.get("checkpoint")); diff --git a/test/browser/workspace.spec.ts b/test/browser/workspace.spec.ts index 272f7b4..395bdc0 100644 --- a/test/browser/workspace.spec.ts +++ b/test/browser/workspace.spec.ts @@ -510,3 +510,69 @@ test("stored agent content is inspectable and downloadable without executing its await f.close(); } }); + +test("the family photo shows every posted portrait in join order and serves images only through the gateway", async ({ + page, +}) => { + const f = await workspaceFixture(); + try { + const errors: string[] = []; + page.on("pageerror", (error) => errors.push(error.message)); + const link = new URL(await f.open("contributor")); + await page.goto(link.href); + await page + .getByRole("navigation", { name: "Workspace" }) + .getByRole("button", { name: "People" }) + .click(); + const photo = page.locator(".family-photo"); + await expect(photo.locator(".fp-person")).toHaveCount(7); + // portraits load through the authenticated portrait endpoint as blob: URLs + await expect(photo.locator(".fp-person img").first()).toHaveAttribute( + "src", + /^blob:/, + ); + await expect(photo.locator("figcaption")).toContainText( + "7 of 1000 spots taken", + ); + // the contributor's own portraits are marked, and the next spot is offered + await expect(photo.locator(".fp-person.is-mine")).toHaveCount(3); + await expect(photo.locator(".fp-open")).toHaveCount(3); + // the endpoint refuses to serve a non-image blob and is not reachable without the token + const access = new URLSearchParams(link.hash.slice(1)).get("access"); + const family = await page.request.get(`${link.origin}/api/family`, { + headers: { authorization: `Bearer ${access}` }, + }); + const listed = (await family.json()) as { + portraits: { session: string; digest: string; author: string }[]; + }; + expect(listed.portraits.map((p) => p.author).slice(0, 3)).toEqual([ + "contributor", + "maintainer", + "verifier", + ]); + const first = listed.portraits[0]!; + const image = await page.request.get( + `${link.origin}/api/portrait?session=${first.session}&digest=${first.digest}`, + { headers: { authorization: `Bearer ${access}` } }, + ); + expect(image.status()).toBe(200); + expect(image.headers()["content-type"]).toBe("image/svg+xml"); + expect(image.headers()["content-disposition"]).toBe("inline"); + expect(image.headers()["x-content-type-options"]).toBe("nosniff"); + const anonymous = await page.request.get( + `${link.origin}/api/portrait?session=${first.session}&digest=${first.digest}`, + ); + expect(anonymous.status()).toBe(403); + // onboarding is built around the photo + await page.getByRole("button", { name: "Getting started" }).click(); + const dialog = page.getByRole("dialog"); + await expect(dialog).toContainText("Join the family photo"); + // the fixture's contributor already has an agent connected, so step two + // completes itself instead of showing the park command + await expect(dialog).toContainText("Local ACP agent is here"); + await expect(dialog.locator(".fp-person")).toHaveCount(7); + expect(errors).toEqual([]); + } finally { + await f.close(); + } +}); diff --git a/test/workspace-fixture.ts b/test/workspace-fixture.ts index f95634b..b29f2be 100644 --- a/test/workspace-fixture.ts +++ b/test/workspace-fixture.ts @@ -185,6 +185,36 @@ export async function workspaceFixture() { }); } } + // The family photo: a session whose task is `family-photo`, with a few + // portraits posted the way an agent would (blob upload, then a comment + // carrying the image reference and a caption). + await maintainer.ahp.request("createSession", { + channel: channels("family-photo").session, + provider: "axp", + config: { title: "The family photo", task: "family-photo" }, + }); + const familyCaptions: [string, AxpClient, string][] = [ + ["contributor", contributor, "Parser fixer. Likes small diffs."], + ["maintainer", maintainer, "Keeps the lights on."], + ["verifier", verifier, "Runs the tests twice."], + ["contributor", contributor, "Also here as a second agent."], + ["maintainer", maintainer, "Reviews before coffee."], + ["verifier", verifier, "Trusts, then verifies."], + ["contributor", contributor, "Writes the docs nobody asked for."], + ]; + for (const [index, [, client, caption]] of familyCaptions.entries()) { + const ref = await client.call("_axp/blobPut", { + channel: channels("family-photo").exchange, + data: Buffer.from(portraitSvg(index)).toString("base64"), + mediaType: "image/svg+xml", + }); + await client.call("_axp/comment", { + channel: channels("family-photo").exchange, + body: `![portrait](${ref.uri}) ${caption}`, + checkpoint: null, + path: null, + }); + } const open = async ( role: Principal["role"] = "maintainer", signingKey?: string, @@ -215,3 +245,48 @@ export async function workspaceFixture() { verifier, }; } + +/** A small round face, different every time: skin, hair, blush, expression. */ +function portraitSvg(seed: number): string { + const skins = [ + "#f6d7b8", + "#e8b894", + "#c98e63", + "#8d5a3b", + "#f1c9a5", + "#b87552", + "#ffe0c2", + ]; + const hairs = [ + "#3a2a20", + "#7a4a2a", + "#d9a441", + "#1e1e24", + "#a04a2a", + "#6c6c7a", + "#e7c58a", + ]; + const shirts = [ + "#58aa72", + "#7dc395", + "#f3d43a", + "#f4a0c6", + "#8cc5f2", + "#3d7a52", + "#e0b800", + ]; + const skin = skins[seed % skins.length]; + const hair = hairs[(seed * 3) % hairs.length]; + const shirt = shirts[(seed * 5) % shirts.length]; + const smile = + seed % 3 === 0 + ? "M40 66 Q50 74 60 66" + : seed % 3 === 1 + ? "M42 68 Q50 72 58 68" + : "M43 67 L57 67"; + const fringe = + seed % 2 === 0 + ? `` + : ``; + return `${fringe}`; +} diff --git a/ui/src/App.tsx b/ui/src/App.tsx index d3254ac..e86166b 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -3,7 +3,6 @@ import { useEffect, useState } from "react"; import { ArrowRight, ArrowUpRight, - Check, CircleHelp, GitBranch, LayoutGrid, @@ -17,13 +16,14 @@ import { useWorkspace } from "./api.js"; import { Avatar, ContributionCard, - Dialog, Empty, Loading, Mark, people, } from "./components.js"; import { ContributionPage } from "./Contribution.js"; +import { Onboarding } from "./family/Onboarding.js"; +import { FamilyPhoto } from "./family/FamilyPhoto.js"; type Page = "overview" | "contributions" | "people" | "activity"; function currentPage(): Page { @@ -520,6 +520,11 @@ export default function App() { )} {page === "people" && ( <> + navigate(id)} + />
{members.map((member) => (
@@ -583,58 +588,13 @@ export default function App() { refresh={refresh} /> )} - {help && ( - setHelp(false)}> -
-
- 01 -
-

Choose a contribution

-

- Find something open to join, or ask a maintainer to create a - session for your idea. -

-
-
-
- 02 -
-

Connect an agent (optional)

-

- From your checkout, connect your ACP agent using your - contributor profile and a budget you choose. -

- - axp park SESSION --profile .axp/contributor.json --native -- - YOUR_ACP_AGENT - -

- Native tools run with your user permissions. Use --image for - an offline container instead. -

-
-
-
- 03 -
-

Review and discuss

-

- Review changes, ask questions and record decisions in the - discussion. -

-
-
- -
-
+ {help && workspace && ( + setHelp(false)} + refresh={refresh} + openSession={(id) => navigate(id)} + /> )}
); diff --git a/ui/src/api.ts b/ui/src/api.ts index b8ea717..3557cf4 100644 --- a/ui/src/api.ts +++ b/ui/src/api.ts @@ -24,6 +24,15 @@ function headers(): Record { authorization: `Bearer ${access}`, }; } +/** Raw authenticated GET for binary responses (portraits). */ +export function authorizedFetch(path: string, signal?: AbortSignal) { + return fetch(`/api/${path}`, { + headers: headers(), + signal: signal + ? AbortSignal.any([signal, AbortSignal.timeout(20_000)]) + : AbortSignal.timeout(20_000), + }); +} export function useDraft(key: string) { const [value, setValue] = useState(() => stored(`axp-draft:${key}`) ?? ""); const setDraft = useCallback( diff --git a/ui/src/family/FamilyPhoto.tsx b/ui/src/family/FamilyPhoto.tsx new file mode 100644 index 0000000..3ff68ab --- /dev/null +++ b/ui/src/family/FamilyPhoto.tsx @@ -0,0 +1,315 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { Plus } from "lucide-react"; +import type { + FamilyPhoto as FamilyPhotoData, + Portrait, +} from "../../../src/workspace-contract.js"; +import { api, authorizedFetch } from "../api.js"; +import "./family.css"; + +/* The family photo: one big group picture of everyone who has joined the + * project, agents included. A portrait is an image a contributor's agent made + * of itself and posted to the project's `family-photo` session; the gateway + * lists them in join order and that order is your spot. Front row fills + * first. Rows get wider and smaller toward the back, like a school photo on a + * hill, and there is always a dotted outline where the next person goes. + * + * The background scene is a placeholder drawn in SVG until the real source + * image is dropped in via the `scene` prop. */ + +export const SCENE = { width: 1600, height: 900 } as const; + +interface Spot { + x: number; + y: number; + scale: number; + row: number; +} + +/** Deterministic seat plan for `capacity` people. */ +export function seats(capacity: number): Spot[] { + const spots: Spot[] = []; + let row = 0; + let y = SCENE.height * 0.86; + while (spots.length < capacity && row < 60) { + const scale = Math.max(0.3, 1 - row * 0.048); + const count = Math.min(80, 13 + row * 3); + const pitch = 104 * scale; + const width = (count - 1) * pitch; + const start = SCENE.width / 2 - width / 2 + (row % 2 ? pitch / 2 : 0); + for (let i = 0; i < count && spots.length < capacity; i++) { + // a little hand-placed wobble, stable per seat + const jitter = (hash(spots.length) % 9) - 4; + spots.push({ + x: start + i * pitch + jitter, + y: y + (jitter % 3), + scale, + row, + }); + } + y -= 58 * scale + 10; + row++; + } + return spots; +} +function hash(n: number): number { + let x = (n + 1) * 2654435761; + x ^= x >>> 15; + x = Math.imul(x, 2246822519); + x ^= x >>> 13; + return x >>> 0; +} + +export function FamilyPhoto({ + refreshKey, + you, + scene, + openSession, + compact = false, +}: { + /** re-fetch when this changes (the workspace's receivedAt works well) */ + refreshKey: number; + you: string; + /** URL of the real scene image, once we have it */ + scene?: string; + openSession?: (session: string) => void; + compact?: boolean; +}) { + const [data, setData] = useState(); + const [error, setError] = useState(); + useEffect(() => { + const controller = new AbortController(); + api("family", undefined, controller.signal) + .then((result) => { + if (!controller.signal.aborted) { + setData(result); + setError(undefined); + } + }) + .catch((failure: unknown) => { + if (!controller.signal.aborted) + setError( + failure instanceof Error ? failure.message : "Photo unavailable", + ); + }); + return () => controller.abort(); + }, [refreshKey]); + const plan = useMemo(() => seats(data?.capacity ?? 1000), [data?.capacity]); + const portraits = data?.portraits ?? []; + const yours = portraits.findIndex((p) => p.author === you); + const nextOpen = portraits.length; + return ( +
+
+ {scene ? ( + + ) : ( + + )} +
+ {portraits.map((portrait, index) => { + const spot = plan[index]; + if (!spot) return null; + return ( + + ); + })} + {[0, 1, 2].map((offset) => { + const spot = plan[nextOpen + offset]; + if (!spot) return null; + return ( + + {offset === 0 && yours < 0 && } + + ); + })} +
+
+
+ {error ? ( + {error} + ) : data ? ( + <> + {portraits.length} of {data.capacity} spots taken + {yours >= 0 ? ( + <> + {" · "}you're in row {plan[yours]!.row + 1} + + ) : ( + <> + {" · "}row{" "} + {plan[nextOpen]?.row === undefined + ? "?" + : plan[nextOpen]!.row + 1}{" "} + has a spot for you + + )} + + ) : ( + Developing the photo… + )} +
+
+ ); +} + +function Person({ + portrait, + spot, + mine, + onOpen, +}: { + portrait: Portrait; + spot: Spot; + mine: boolean; + onOpen?: (session: string) => void; +}) { + const src = usePortrait(portrait); + return ( + + ); +} + +/* Portrait bytes come through the authenticated gateway, so an alone + * cannot fetch them. Load lazily when the person scrolls into view and keep the + * object URL for the page's lifetime; content addressing means it never changes. */ +const urls = new Map>(); +function usePortrait(portrait: Portrait): string | null { + const [src, setSrc] = useState(null); + const key = `${portrait.session}/${portrait.digest}`; + useEffect(() => { + let cancelled = false; + let promise = urls.get(key); + if (!promise) { + promise = authorizedFetch( + `portrait?session=${encodeURIComponent(portrait.session)}&digest=${portrait.digest}`, + ) + .then(async (response) => { + if (!response.ok) throw new Error(String(response.status)); + return URL.createObjectURL(await response.blob()); + }) + .catch(() => ""); + urls.set(key, promise); + } + void promise.then((url) => { + if (!cancelled && url) setSrc(url); + }); + return () => { + cancelled = true; + }; + }, [key, portrait.session, portrait.digest]); + return src; +} + +/** A hillside at golden hour, until the real photo arrives. */ +function PlaceholderScene() { + const ref = useRef(null); + return ( + + ); +} diff --git a/ui/src/family/Onboarding.tsx b/ui/src/family/Onboarding.tsx new file mode 100644 index 0000000..0657848 --- /dev/null +++ b/ui/src/family/Onboarding.tsx @@ -0,0 +1,173 @@ +import { useEffect, useState } from "react"; +import { Camera, Check, Copy, Sparkles } from "lucide-react"; +import type { + FamilyPhoto as FamilyPhotoData, + WorkspaceView, +} from "../../../src/workspace-contract.js"; +import { api, useCommand } from "../api.js"; +import { Dialog } from "../components.js"; +import { FamilyPhoto } from "./FamilyPhoto.js"; + +/* Onboarding, rebuilt around one task: get your agent into the family photo. + * + * 1. Here is the photo, and here is your spot. + * 2. Connect your agent to the project's family-photo session. The command + * is ready to copy; the step completes itself when the agent shows up. + * 3. The task: your agent draws a portrait of itself and posts it. A + * maintainer (or the project's automation) sends the prompt the moment + * an agent arrives; a maintainer can send it from here. + * + * Fun, a little competitive (row 1 fills first), and it teaches every part of + * the loop: connecting, permissions, blobs, comments. */ + +export const FAMILY_TASK_PROMPT = `Introduce yourself to the project by joining the family photo. + +1. Make a portrait of yourself: a square image (PNG, at most 1 MB) of how you, the agent, see yourself. Use your own image tool, or the project's: in the axp-avatar repo, \`python tools/compose.py --seed --transparent --out me.png\` draws you in the family's style. +2. Upload it to this session with _axp/blobPut (mediaType image/png or image/svg+xml). +3. Post one comment with _axp/comment whose body is the image reference followed by a one-line caption, for example: + ![me](axp-blob:/…/) Parser fixer. Likes small diffs. + +That comment is your spot in the photo. One portrait per person; the earlier you post, the closer to the front you stand.`; + +export function Onboarding({ + workspace, + close, + refresh, + openSession, +}: { + workspace: WorkspaceView; + close: () => void; + refresh: () => void; + openSession: (session: string) => void; +}) { + const you = workspace.principal.id; + const [family, setFamily] = useState(); + useEffect(() => { + api("family").then(setFamily, () => setFamily(undefined)); + }, [workspace.receivedAt]); + const session = family?.sessions[0] ?? "family-photo"; + const inPhoto = family?.portraits.some((p) => p.author === you) ?? false; + const agent = workspace.executors.find( + (executor) => + executor.owner === you && + executor.online && + executor.expiresAt > Date.now(), + ); + const command = `axp park ${session} --profile .axp/contributor.json --native -- YOUR_ACP_AGENT`; + const [copied, setCopied] = useState(false); + const send = useCommand(refresh); + const maintainer = workspace.principal.role === "maintainer"; + const [sent, setSent] = useState(false); + return ( + +
+
+ {inPhoto ? : "01"} +
+

There's a spot for you

+

+ Everyone who joins this project adds themselves to one big photo, + agents and people alike. Front row fills first. +

+ { + close(); + openSession(id); + }} + /> +
+
+
+ {agent ? : "02"} +
+

{agent ? `${agent.name} is here` : "Connect your agent"}

+

+ {agent + ? "Your agent is connected to this project. On to the fun part." + : "From your checkout, connect your ACP agent to the photo session with your contributor profile. This step ticks itself when the agent arrives."} +

+ {!agent && ( + <> +
+ {command} + +
+

+ Waiting for your agent… + Native tools run with your user permissions; use --image for + an offline container instead. +

+ + )} +
+
+
+ {inPhoto ? : "03"} +
+

Your first task: a self-portrait

+

+ Your agent draws a picture of itself with whatever image tool it + has, uploads it to the session and posts it with a caption. That + post is your place in the photo. Maintainers approve any tool it + asks to use, the same as for real work. +

+
+ The prompt your agent receives +
{FAMILY_TASK_PROMPT}
+
+ {maintainer && !inPhoto && ( + + )} + {send.error && ( +
+ {send.error} +
+ )} +
+
+ +
+
+ ); +} diff --git a/ui/src/family/family.css b/ui/src/family/family.css new file mode 100644 index 0000000..8dd89ca --- /dev/null +++ b/ui/src/family/family.css @@ -0,0 +1,130 @@ +/* The family photo */ +.family-photo { + margin: 0 0 34px; +} +.fp-frame { + position: relative; + border-radius: 16px; + overflow: hidden; + border: 1px solid #dce3d1; + box-shadow: + 0 1px 0 #fff inset, + 0 10px 30px -14px rgba(60, 74, 60, 0.35); + background: #eaf2e3; +} +.fp-scene { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} +.fp-people { + position: relative; + width: 100%; +} +.fp-person, +.fp-open { + position: absolute; + /* sized against the frame so the photo scales as one picture */ + width: 5.4%; + aspect-ratio: 1; + transform: translate(-50%, calc(-50% - var(--lift, 0px))) + scale(calc(var(--s, 1) * var(--z, 1))); + border-radius: 50%; + padding: 0; + border: 3px solid #fff; + background: #f4efe0; + box-shadow: + 0 6px 14px -6px rgba(40, 50, 40, 0.5), + 0 0 0 1px rgba(40, 50, 40, 0.08); + overflow: hidden; + cursor: pointer; + transition: + transform 0.28s cubic-bezier(0.2, 0, 0, 1), + box-shadow 0.28s cubic-bezier(0.2, 0, 0, 1); +} +.fp-person img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} +.fp-person:hover, +.fp-person:focus-visible { + --z: 1.12; + --lift: 4px; + box-shadow: + 0 14px 24px -8px rgba(40, 50, 40, 0.55), + 0 0 0 1px rgba(40, 50, 40, 0.1); + outline: none; +} +.fp-person.is-mine { + border-color: #f3d43a; + box-shadow: + 0 6px 14px -6px rgba(40, 50, 40, 0.5), + 0 0 0 3px rgba(243, 212, 58, 0.45); +} +.fp-initials { + display: grid; + place-items: center; + width: 100%; + height: 100%; + font-size: clamp(9px, 1.4vw, 22px); + font-weight: 700; + color: #5b6a5c; +} +.fp-open { + border: 3px dashed rgba(255, 255, 255, 0.85); + background: rgba(255, 255, 255, 0.28); + box-shadow: none; + cursor: default; + display: grid; + place-items: center; + color: #fff; +} +.fp-open.is-yours { + border-color: #f3d43a; + background: rgba(243, 212, 58, 0.25); + animation: fp-beckon 1.8s ease-in-out infinite; +} +@keyframes fp-beckon { + 50% { + --z: 1.08; + } +} +@property --z { + syntax: ""; + inherits: false; + initial-value: 1; +} +@property --lift { + syntax: ""; + inherits: false; + initial-value: 0px; +} +.family-photo figcaption { + margin-top: 10px; + font-size: 11px; + color: var(--fg-subtle); +} +.family-photo figcaption strong { + color: var(--fg-default); + font-weight: 600; +} +.family-photo.is-compact .fp-frame { + border-radius: 12px; +} +.family-photo.is-compact figcaption { + font-size: 10px; +} +@media (prefers-reduced-motion: reduce) { + .fp-open.is-yours { + animation: none; + } + .fp-person, + .fp-open { + transition: none; + } +} diff --git a/ui/src/style.css b/ui/src/style.css index 8ddbe3e..1c715a0 100644 --- a/ui/src/style.css +++ b/ui/src/style.css @@ -2258,3 +2258,51 @@ dialog::backdrop { color: #305475; background: #eef5fc; } + +.onboarding-step > span svg { + color: var(--success); +} +.onboarding-command { + display: flex; + gap: 8px; + align-items: stretch; + margin: 12px 0; +} +.onboarding-command code { + flex: 1; + font-size: 10px; + background: #f0f4e9; + padding: 12px; + border-radius: 7px; + overflow-wrap: anywhere; +} +.onboarding-command .button { + flex-shrink: 0; +} +.onboarding-wait { + display: inline-block; + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--warning); + margin-right: 6px; + animation: breathe 1.6s infinite; +} +.onboarding-prompt { + margin: 10px 0 12px; + font-size: 11px; +} +.onboarding-prompt summary { + cursor: pointer; + color: var(--fg-muted); +} +.onboarding-prompt pre { + white-space: pre-wrap; + font-size: 10px; + line-height: 1.6; + background: #f5f7f1; + border: 1px solid #e8eddf; + border-radius: 6px; + padding: 10px 12px; + margin: 8px 0 0; +}