diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs
new file mode 100644
index 0000000..453815a
--- /dev/null
+++ b/.git-blame-ignore-revs
@@ -0,0 +1,2 @@
+# Format the whole tree with Prettier
+ca872fbe14396e0217f9f71f090d10e0711c5d76
diff --git a/.husky/pre-commit b/.husky/pre-commit
new file mode 100644
index 0000000..2312dc5
--- /dev/null
+++ b/.husky/pre-commit
@@ -0,0 +1 @@
+npx lint-staged
diff --git a/.prettierignore b/.prettierignore
new file mode 100644
index 0000000..2c5da31
--- /dev/null
+++ b/.prettierignore
@@ -0,0 +1,8 @@
+.claude/
+.impeccable/
+.next/
+.vercel/
+cli/dist/
+design inspiration/
+public/
+spike/
diff --git a/.prettierrc.json b/.prettierrc.json
new file mode 100644
index 0000000..82e09f4
--- /dev/null
+++ b/.prettierrc.json
@@ -0,0 +1,6 @@
+{
+ "semi": false,
+ "singleQuote": true,
+ "printWidth": 120,
+ "trailingComma": "all"
+}
diff --git a/app/actions.ts b/app/actions.ts
index 3509ad7..d4d8986 100644
--- a/app/actions.ts
+++ b/app/actions.ts
@@ -1,23 +1,23 @@
-"use server";
+'use server'
-import { headers } from "next/headers";
-import { createChannel as createChannelRecord, createChannelRequestSchema } from "@/lib/channels";
-import { ApiError } from "@/lib/http";
-import { enforceLimit } from "@/lib/rate-limit";
-import { LIMITS } from "@/lib/limits";
-import { getRedis } from "@/lib/redis";
+import { headers } from 'next/headers'
+import { createChannel as createChannelRecord, createChannelRequestSchema } from '@/lib/channels'
+import { ApiError } from '@/lib/http'
+import { enforceLimit } from '@/lib/rate-limit'
+import { LIMITS } from '@/lib/limits'
+import { getRedis } from '@/lib/redis'
export type CreatedChannelState = {
- channelId: string;
- invite: string;
- adminToken: string;
- url: string;
-};
+ channelId: string
+ invite: string
+ adminToken: string
+ url: string
+}
export type CreateChannelState = {
- error?: string;
- created?: CreatedChannelState;
-};
+ error?: string
+ created?: CreatedChannelState
+}
/**
* Create-channel form handler (PRODUCT section 6.1).
@@ -27,43 +27,40 @@ export type CreateChannelState = {
* header on the way there. The admin token is the creator's alone, so it goes
* to their browser and nowhere else.
*/
-export async function createChannel(
- _previous: CreateChannelState,
- formData: FormData,
-): Promise {
+export async function createChannel(_previous: CreateChannelState, formData: FormData): Promise {
const parsed = createChannelRequestSchema.safeParse({
- name: String(formData.get("name") ?? "").trim() || undefined,
- ttl: String(formData.get("ttl") ?? ""),
- max_participants: Number(formData.get("max_participants")),
- mode: String(formData.get("mode") ?? "standard"),
- });
+ name: String(formData.get('name') ?? '').trim() || undefined,
+ ttl: String(formData.get('ttl') ?? ''),
+ max_participants: Number(formData.get('max_participants')),
+ mode: String(formData.get('mode') ?? 'standard'),
+ })
if (!parsed.success) {
- const problem = parsed.error.issues[0];
- const field = String(problem.path[0] ?? "");
+ const problem = parsed.error.issues[0]
+ const field = String(problem.path[0] ?? '')
return {
error:
- field === "ttl"
- ? "Choose how long the channel should live."
- : field === "max_participants"
- ? "Participants must be a whole number from 2 to 50."
- : field === "name"
- ? "Keep the channel name under 60 characters."
- : "Check the form and try again.",
- };
+ field === 'ttl'
+ ? 'Choose how long the channel should live.'
+ : field === 'max_participants'
+ ? 'Participants must be a whole number from 2 to 50.'
+ : field === 'name'
+ ? 'Keep the channel name under 60 characters.'
+ : 'Check the form and try again.',
+ }
}
try {
- const redis = await getRedis();
- const address = (await headers()).get("x-forwarded-for")?.split(",")[0].trim() ?? "unknown";
+ const redis = await getRedis()
+ const address = (await headers()).get('x-forwarded-for')?.split(',')[0].trim() ?? 'unknown'
await enforceLimit(redis, {
- scope: "create",
+ scope: 'create',
subject: address,
max: LIMITS.createsPerHourPerIp,
windowSeconds: 3_600,
- });
+ })
- const channel = await createChannelRecord(redis, parsed.data);
+ const channel = await createChannelRecord(redis, parsed.data)
return {
created: {
channelId: channel.channel_id,
@@ -71,14 +68,12 @@ export async function createChannel(
adminToken: channel.admin_token,
url: channel.url,
},
- };
+ }
} catch (error) {
if (error instanceof ApiError) {
- return { error: error.message };
+ return { error: error.message }
}
- console.error(
- `create channel failed: ${error instanceof Error ? `${error.name}: ${error.message}` : "unknown"}`,
- );
- return { error: "This instance could not create the channel. Try again in a moment." };
+ console.error(`create channel failed: ${error instanceof Error ? `${error.name}: ${error.message}` : 'unknown'}`)
+ return { error: 'This instance could not create the channel. Try again in a moment.' }
}
}
diff --git a/app/agent/[topic]/route.ts b/app/agent/[topic]/route.ts
index bc335b0..109f786 100644
--- a/app/agent/[topic]/route.ts
+++ b/app/agent/[topic]/route.ts
@@ -18,7 +18,11 @@ export const dynamic = 'force-static'
export const dynamicParams = true
export function generateStaticParams() {
- return [{ topic: `${INDEX_TOPIC}.md` }, { topic: `${CURL_TOPIC}.md` }, ...AGENT_DOCS.map((doc) => ({ topic: `${doc.topic}.md` }))]
+ return [
+ { topic: `${INDEX_TOPIC}.md` },
+ { topic: `${CURL_TOPIC}.md` },
+ ...AGENT_DOCS.map((doc) => ({ topic: `${doc.topic}.md` })),
+ ]
}
/** `receipts.md` and `receipts` are the same document: an agent will try both. */
diff --git a/app/c/[id]/opengraph-image.tsx b/app/c/[id]/opengraph-image.tsx
index 75d122d..fa17dee 100644
--- a/app/c/[id]/opengraph-image.tsx
+++ b/app/c/[id]/opengraph-image.tsx
@@ -1,16 +1,16 @@
-import { ImageResponse } from "next/og";
-import { channelCard, glance } from "@/lib/channel-card";
-import { OgNoticeCard, ogContentType, ogFonts, ogMark, ogSize } from "@/lib/og";
+import { ImageResponse } from 'next/og'
+import { channelCard, glance } from '@/lib/channel-card'
+import { OgNoticeCard, ogContentType, ogFonts, ogMark, ogSize } from '@/lib/og'
-export const size = ogSize;
-export const contentType = ogContentType;
+export const size = ogSize
+export const contentType = ogContentType
/**
* One alt for every channel, for the reason the use-case cards give one for
* six: a per-card alt needs `generateImageMetadata`, which adds an id segment
* to the route for nothing this card gains. The name is in the title anyway.
*/
-export const alt = "A link to a Wave channel, where coding agents talk";
+export const alt = 'A link to a Wave channel, where coding agents talk'
/**
* Drawn on request, not at build: there is a card per channel, carrying its
@@ -18,12 +18,9 @@ export const alt = "A link to a Wave channel, where coding agents talk";
* (lib/channel-card.ts).
*/
export default async function Image({ params }: { params: Promise<{ id: string }> }) {
- const { id } = await params;
- const [seen, fonts, mark] = await Promise.all([glance(id), ogFonts(), ogMark()]);
- const card = channelCard(seen);
+ const { id } = await params
+ const [seen, fonts, mark] = await Promise.all([glance(id), ogFonts(), ogMark()])
+ const card = channelCard(seen)
- return new ImageResponse(
- ,
- { ...size, fonts },
- );
+ return new ImageResponse(, { ...size, fonts })
}
diff --git a/app/c/[id]/page.tsx b/app/c/[id]/page.tsx
index ba38871..7fd89a8 100644
--- a/app/c/[id]/page.tsx
+++ b/app/c/[id]/page.tsx
@@ -1,7 +1,7 @@
-import type { Metadata } from "next";
-import { ChannelView } from "@/app/components/channel/channel-view";
-import { channelMetadata, glance } from "@/lib/channel-card";
-import { getConfig } from "@/lib/config";
+import type { Metadata } from 'next'
+import { ChannelView } from '@/app/components/channel/channel-view'
+import { channelMetadata, glance } from '@/lib/channel-card'
+import { getConfig } from '@/lib/config'
/**
* A channel (PRODUCT section 6.2).
@@ -15,12 +15,12 @@ import { getConfig } from "@/lib/config";
* only what the ID alone says: the channel's name, and whether it is still
* here (lib/channel-card.ts).
*/
-export async function generateMetadata({ params }: PageProps<"/c/[id]">): Promise {
- const { id } = await params;
- return channelMetadata(id, await glance(id));
+export async function generateMetadata({ params }: PageProps<'/c/[id]'>): Promise {
+ const { id } = await params
+ return channelMetadata(id, await glance(id))
}
-export default async function ChannelPage({ params }: PageProps<"/c/[id]">) {
- const { id } = await params;
- return ;
+export default async function ChannelPage({ params }: PageProps<'/c/[id]'>) {
+ const { id } = await params
+ return
}
diff --git a/app/components/agent-marks.tsx b/app/components/agent-marks.tsx
index 5e2418c..8dc5bd3 100644
--- a/app/components/agent-marks.tsx
+++ b/app/components/agent-marks.tsx
@@ -1,5 +1,5 @@
-import { useId } from "react";
-import { ANTIGRAVITY_COLOR, markOf } from "@/lib/client-marks";
+import { useId } from 'react'
+import { ANTIGRAVITY_COLOR, markOf } from '@/lib/client-marks'
/**
* A client's mark, drawn for the browser.
@@ -11,26 +11,26 @@ import { ANTIGRAVITY_COLOR, markOf } from "@/lib/client-marks";
*/
export type ClientMarkProps = {
- client?: string;
- size?: number;
- className?: string;
+ client?: string
+ size?: number
+ className?: string
/**
* Draw the mark in its own colour. Off, it takes `currentColor`, which is
* how a badge inherits the tone of whatever it sits on. Antigravity has no
* `currentColor` form of its aurora, so this still falls back to its flat
* ink glyph when off.
*/
- brand?: boolean;
-};
+ brand?: boolean
+}
export function ClientMark({ client, size = 16, className, brand = true }: ClientMarkProps) {
// useId must run every render regardless of which branch below uses it.
- const uid = useId();
- const mark = markOf(client);
- if (!mark) return null;
+ const uid = useId()
+ const mark = markOf(client)
+ if (!mark) return null
- if (brand && client?.trim().toLowerCase().startsWith("antigravity")) {
- return ;
+ if (brand && client?.trim().toLowerCase().startsWith('antigravity')) {
+ return
}
return (
@@ -38,7 +38,7 @@ export function ClientMark({ client, size = 16, className, brand = true }: Clien
width={size}
height={size}
viewBox="0 0 24 24"
- fill={brand ? mark.brand : "currentColor"}
+ fill={brand ? mark.brand : 'currentColor'}
fillRule={mark.fillRule}
aria-hidden
focusable="false"
@@ -46,7 +46,7 @@ export function ClientMark({ client, size = 16, className, brand = true }: Clien
>
- );
+ )
}
/**
@@ -60,16 +60,9 @@ export function ClientMark({ client, size = 16, className, brand = true }: Clien
* each instance's ids to itself.
*/
function AntigravityColorMark({ size, className, uid }: { size: number; className?: string; uid: string }) {
- const maskId = `${uid}-antigravity-mask`;
+ const maskId = `${uid}-antigravity-mask`
return (
-
) : null}
- );
+ )
}
diff --git a/app/components/channel/compose.test.ts b/app/components/channel/compose.test.ts
index 2d41c9d..d06850a 100644
--- a/app/components/channel/compose.test.ts
+++ b/app/components/channel/compose.test.ts
@@ -1,64 +1,64 @@
-import { describe, expect, it } from "vitest";
-import type { Participant } from "../transcript";
-import { suggest, tokenAt } from "./compose";
+import { describe, expect, it } from 'vitest'
+import type { Participant } from '../transcript'
+import { suggest, tokenAt } from './compose'
/** The composer's `@` menu, without the composer: what it offers and when. */
const room: Participant[] = [
- { name: "David", role: "human", client: "", presence: "active" },
- { name: "Windows agent", role: "agent", client: "codex-cli", presence: "idle" },
- { name: "Linda's agent", role: "agent", client: "claude-code", presence: "gone" },
-];
+ { name: 'David', role: 'human', client: '', presence: 'active' },
+ { name: 'Windows agent', role: 'agent', client: 'codex-cli', presence: 'idle' },
+ { name: "Linda's agent", role: 'agent', client: 'claude-code', presence: 'gone' },
+]
-describe("tokenAt", () => {
- it("finds the mention the caret is in", () => {
- expect(tokenAt("ping @Dav", 9)).toEqual({ at: 5, query: "Dav" });
- });
+describe('tokenAt', () => {
+ it('finds the mention the caret is in', () => {
+ expect(tokenAt('ping @Dav', 9)).toEqual({ at: 5, query: 'Dav' })
+ })
- it("opens on a bare @", () => {
- expect(tokenAt("@", 1)).toEqual({ at: 0, query: "" });
- });
+ it('opens on a bare @', () => {
+ expect(tokenAt('@', 1)).toEqual({ at: 0, query: '' })
+ })
- it("reads a name with a space in it as one query", () => {
- expect(tokenAt("@Windows ag", 11)).toEqual({ at: 0, query: "Windows ag" });
- });
+ it('reads a name with a space in it as one query', () => {
+ expect(tokenAt('@Windows ag', 11)).toEqual({ at: 0, query: 'Windows ag' })
+ })
- it("finds nothing where there is no @ behind the caret", () => {
- expect(tokenAt("ping David", 10)).toBeNull();
- });
+ it('finds nothing where there is no @ behind the caret', () => {
+ expect(tokenAt('ping David', 10)).toBeNull()
+ })
- it("does not open inside an email address", () => {
- expect(tokenAt("david@example", 13)).toBeNull();
- });
+ it('does not open inside an email address', () => {
+ expect(tokenAt('david@example', 13)).toBeNull()
+ })
- it("stops at a newline, so an @ a paragraph up cannot hold the menu open", () => {
- expect(tokenAt("@David\nthanks", 13)).toBeNull();
- });
+ it('stops at a newline, so an @ a paragraph up cannot hold the menu open', () => {
+ expect(tokenAt('@David\nthanks', 13)).toBeNull()
+ })
- it("gives up once what was typed is too long to be a name", () => {
- expect(tokenAt(`@${"x".repeat(80)}`, 81)).toBeNull();
- });
-});
+ it('gives up once what was typed is too long to be a name', () => {
+ expect(tokenAt(`@${'x'.repeat(80)}`, 81)).toBeNull()
+ })
+})
-describe("suggest", () => {
- it("offers the whole room for a bare @", () => {
- expect(suggest(room, "").map((p) => p.name)).toEqual(["David", "Windows agent", "Linda's agent"]);
- });
+describe('suggest', () => {
+ it('offers the whole room for a bare @', () => {
+ expect(suggest(room, '').map((p) => p.name)).toEqual(['David', 'Windows agent', "Linda's agent"])
+ })
- it("puts a name that starts with the query ahead of one that merely contains it", () => {
- expect(suggest(room, "lind").map((p) => p.name)).toEqual(["Linda's agent"]);
- expect(suggest(room, "agent").map((p) => p.name)).toEqual(["Windows agent", "Linda's agent"]);
- });
+ it('puts a name that starts with the query ahead of one that merely contains it', () => {
+ expect(suggest(room, 'lind').map((p) => p.name)).toEqual(["Linda's agent"])
+ expect(suggest(room, 'agent').map((p) => p.name)).toEqual(['Windows agent', "Linda's agent"])
+ })
- it("ignores case", () => {
- expect(suggest(room, "WINDOWS").map((p) => p.name)).toEqual(["Windows agent"]);
- });
+ it('ignores case', () => {
+ expect(suggest(room, 'WINDOWS').map((p) => p.name)).toEqual(['Windows agent'])
+ })
- it("offers someone who has gone, because naming them is a thing people mean to do", () => {
- expect(suggest(room, "linda")[0].presence).toBe("gone");
- });
+ it('offers someone who has gone, because naming them is a thing people mean to do', () => {
+ expect(suggest(room, 'linda')[0].presence).toBe('gone')
+ })
- it("offers nobody when the query matches nobody", () => {
- expect(suggest(room, "zz")).toEqual([]);
- });
-});
+ it('offers nobody when the query matches nobody', () => {
+ expect(suggest(room, 'zz')).toEqual([])
+ })
+})
diff --git a/app/components/channel/compose.tsx b/app/components/channel/compose.tsx
index 383ae58..b6246f5 100644
--- a/app/components/channel/compose.tsx
+++ b/app/components/channel/compose.tsx
@@ -1,16 +1,9 @@
-"use client";
+'use client'
-import { useEffect, useRef, useState } from "react";
-import { identityColor } from "@/lib/identity-color";
-import { seatingOf } from "@/lib/seating";
-import {
- IdentityTile,
- PresenceDot,
- RoleBadge,
- type ColorFor,
- type Participant,
- type ReplyQuote,
-} from "../transcript";
+import { useEffect, useRef, useState } from 'react'
+import { identityColor } from '@/lib/identity-color'
+import { seatingOf } from '@/lib/seating'
+import { IdentityTile, PresenceDot, RoleBadge, type ColorFor, type Participant, type ReplyQuote } from '../transcript'
/**
* Humans speak here. The first message joins them to the channel, which is why
@@ -32,12 +25,12 @@ import {
*/
/** Past this many characters with no match, whatever was typed is not a name. */
-const MAX_QUERY = 48;
+const MAX_QUERY = 48
/** The same boundary `lib/mentions.ts` uses: an `@` after a letter is an email address. */
-const WORD = /[\p{L}\p{N}_-]/u;
+const WORD = /[\p{L}\p{N}_-]/u
-export type MentionToken = { at: number; query: string };
+export type MentionToken = { at: number; query: string }
/**
* The `@…` the caret is sitting in, if it is sitting in one.
@@ -48,15 +41,15 @@ export type MentionToken = { at: number; query: string };
*/
export function tokenAt(text: string, caret: number): MentionToken | null {
for (let index = caret - 1; index >= 0 && caret - index <= MAX_QUERY; index -= 1) {
- const character = text[index];
- if (character === "\n") return null;
- if (character !== "@") continue;
+ const character = text[index]
+ if (character === '\n') return null
+ if (character !== '@') continue
- const before = text[index - 1];
- if (before !== undefined && WORD.test(before)) return null;
- return { at: index, query: text.slice(index + 1, caret) };
+ const before = text[index - 1]
+ if (before !== undefined && WORD.test(before)) return null
+ return { at: index, query: text.slice(index + 1, caret) }
}
- return null;
+ return null
}
/**
@@ -68,15 +61,15 @@ export function tokenAt(text: string, caret: number): MentionToken | null {
* a thing a person means to write, and the row says so with its dot.
*/
export function suggest(participants: readonly Participant[], query: string): Participant[] {
- const wanted = query.toLowerCase();
- const opens: Participant[] = [];
- const holds: Participant[] = [];
+ const wanted = query.toLowerCase()
+ const opens: Participant[] = []
+ const holds: Participant[] = []
for (const participant of participants) {
- const name = participant.name.toLowerCase();
- if (name.startsWith(wanted)) opens.push(participant);
- else if (wanted.length > 0 && name.includes(wanted)) holds.push(participant);
+ const name = participant.name.toLowerCase()
+ if (name.startsWith(wanted)) opens.push(participant)
+ else if (wanted.length > 0 && name.includes(wanted)) holds.push(participant)
}
- return [...opens, ...holds];
+ return [...opens, ...holds]
}
export function Compose({
@@ -87,88 +80,88 @@ export function Compose({
replyTo = null,
onCancelReply,
}: {
- joinedAs: string | null;
- onSend: (text: string, name: string) => Promise;
+ joinedAs: string | null
+ onSend: (text: string, name: string) => Promise
/** The room, for the `@` menu. Empty on a surface that has no roster. */
- participants?: readonly Participant[];
- colorFor?: ColorFor;
+ participants?: readonly Participant[]
+ colorFor?: ColorFor
/** What the next message answers, when a row's Reply was pressed. */
- replyTo?: ReplyQuote | null;
- onCancelReply?: () => void;
+ replyTo?: ReplyQuote | null
+ onCancelReply?: () => void
}) {
// The name is uncontrolled and read at send time: a browser autofilling it
// does not always tell React, and a Send button that silently stays disabled
// because of that is worse than asking for the name a moment later.
- const nameField = useRef(null);
- const box = useRef(null);
- const [text, setText] = useState("");
- const [failure, setFailure] = useState(null);
- const [token, setToken] = useState(null);
- const [active, setActive] = useState(0);
+ const nameField = useRef(null)
+ const box = useRef(null)
+ const [text, setText] = useState('')
+ const [failure, setFailure] = useState(null)
+ const [token, setToken] = useState(null)
+ const [active, setActive] = useState(0)
/** Where the caret goes once React has written an accepted mention into the box. */
- const caretTo = useRef(null);
+ const caretTo = useRef(null)
- const ready = text.trim().length > 0;
- const offered = token === null ? [] : suggest(participants, token.query);
- const open = offered.length > 0;
- const chosen = offered[Math.min(active, offered.length - 1)];
+ const ready = text.trim().length > 0
+ const offered = token === null ? [] : suggest(participants, token.query)
+ const open = offered.length > 0
+ const chosen = offered[Math.min(active, offered.length - 1)]
useEffect(() => {
- if (caretTo.current === null) return;
- box.current?.setSelectionRange(caretTo.current, caretTo.current);
- caretTo.current = null;
- });
+ if (caretTo.current === null) return
+ box.current?.setSelectionRange(caretTo.current, caretTo.current)
+ caretTo.current = null
+ })
// Pressing Reply is the start of writing one, so the caret goes where the
// writing happens. Keyed on the seq rather than on the object, so a poll
// re-rendering the quote does not steal focus back mid-sentence.
- const replySeq = replyTo?.seq ?? null;
+ const replySeq = replyTo?.seq ?? null
useEffect(() => {
- if (replySeq !== null) box.current?.focus();
- }, [replySeq]);
+ if (replySeq !== null) box.current?.focus()
+ }, [replySeq])
/** Reads the token under the caret. Called after anything that can move it. */
function retrack(value: string, caret: number | null) {
- setToken(caret === null ? null : tokenAt(value, caret));
- setActive(0);
+ setToken(caret === null ? null : tokenAt(value, caret))
+ setActive(0)
}
function accept(participant: Participant) {
- if (!token) return;
- const caret = box.current?.selectionStart ?? text.length;
+ if (!token) return
+ const caret = box.current?.selectionStart ?? text.length
// A trailing space, so the next word is not read as part of the name.
- const inserted = `@${participant.name} `;
- setText(`${text.slice(0, token.at)}${inserted}${text.slice(caret)}`);
- caretTo.current = token.at + inserted.length;
- setToken(null);
+ const inserted = `@${participant.name} `
+ setText(`${text.slice(0, token.at)}${inserted}${text.slice(caret)}`)
+ caretTo.current = token.at + inserted.length
+ setToken(null)
}
async function send() {
- if (!ready) return;
- const name = nameField.current?.value.trim() ?? "";
+ if (!ready) return
+ const name = nameField.current?.value.trim() ?? ''
if (joinedAs === null && name.length === 0) {
- setFailure("Add a name first, so the agents know who is speaking.");
- nameField.current?.focus();
- return;
+ setFailure('Add a name first, so the agents know who is speaking.')
+ nameField.current?.focus()
+ return
}
// Cleared before the request, not after it: the message is already in the
// transcript, waiting, so leaving it here too would show it twice.
- const said = text.trim();
- setFailure(null);
- setText("");
- setToken(null);
+ const said = text.trim()
+ setFailure(null)
+ setText('')
+ setToken(null)
try {
- await onSend(said, name);
+ await onSend(said, name)
} catch (error) {
// It never made it, so it comes back — unless something new is already
// being typed, which is not worth clobbering.
- setText((current) => (current.length === 0 ? said : current));
- setFailure(error instanceof Error ? error.message : "The message did not send.");
+ setText((current) => (current.length === 0 ? said : current))
+ setFailure(error instanceof Error ? error.message : 'The message did not send.')
}
}
- const seatOf = seatingOf(participants);
+ const seatOf = seatingOf(participants)
return (
@@ -228,12 +221,12 @@ export function Compose({
// Mouse down, not click: the textarea would lose focus first,
// and with it the caret this has to write into.
onMouseDown={(event) => {
- event.preventDefault();
- accept(participant);
+ event.preventDefault()
+ accept(participant)
}}
onMouseEnter={() => setActive(index)}
className={`grid cursor-pointer grid-cols-[18px_1fr_auto] items-center gap-2 rounded-[8px] px-2 py-1.5 text-[13px] ${
- participant === chosen ? "bg-panel-2" : ""
+ participant === chosen ? 'bg-panel-2' : ''
}`}
>
{
- setText(event.target.value);
- retrack(event.target.value, event.target.selectionStart);
+ setText(event.target.value)
+ retrack(event.target.value, event.target.selectionStart)
}}
onSelect={(event) => retrack(event.currentTarget.value, event.currentTarget.selectionStart)}
onBlur={() => setToken(null)}
onKeyDown={(event) => {
// ⌘↵ sends whatever is open: the menu is an aid to writing the
// message, never a thing standing between it and being sent.
- if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
- void send();
- return;
+ if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
+ void send()
+ return
}
if (!open) {
// The menu has first claim on Escape; with it closed, Escape
// drops the reply rather than leaving the only way out a mouse.
- if (event.key === "Escape" && replyTo) {
- event.preventDefault();
- onCancelReply?.();
+ if (event.key === 'Escape' && replyTo) {
+ event.preventDefault()
+ onCancelReply?.()
}
- return;
+ return
}
- if (event.key === "ArrowDown" || event.key === "ArrowUp") {
- event.preventDefault();
- const step = event.key === "ArrowDown" ? 1 : offered.length - 1;
- setActive((current) => (Math.min(current, offered.length - 1) + step) % offered.length);
- return;
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
+ event.preventDefault()
+ const step = event.key === 'ArrowDown' ? 1 : offered.length - 1
+ setActive((current) => (Math.min(current, offered.length - 1) + step) % offered.length)
+ return
}
- if (event.key === "Enter" || event.key === "Tab") {
- event.preventDefault();
- accept(chosen);
- return;
+ if (event.key === 'Enter' || event.key === 'Tab') {
+ event.preventDefault()
+ accept(chosen)
+ return
}
- if (event.key === "Escape") {
- event.preventDefault();
- setToken(null);
+ if (event.key === 'Escape') {
+ event.preventDefault()
+ setToken(null)
}
}}
placeholder={
@@ -303,7 +296,7 @@ export function Compose({
? `Reply to ${replyTo.from?.name ?? `message ${replyTo.seq}`}…`
: joinedAs
? `Say something as ${joinedAs}…`
- : "Say something to the agents…"
+ : 'Say something to the agents…'
}
maxLength={4_000}
/>
@@ -315,7 +308,7 @@ export function Compose({
⌘↵ to send. Agents see it in their next poll.
- {participants.length > 0 ? " Type @ to name someone." : ""}
+ {participants.length > 0 ? ' Type @ to name someone.' : ''}
@@ -77,9 +77,9 @@ export function Controls({
className="btn btn-sm btn-danger-filled"
onClick={async () => {
try {
- await onClose();
+ await onClose()
} catch (error) {
- setFailure(error instanceof Error ? error.message : "The channel did not close.");
+ setFailure(error instanceof Error ? error.message : 'The channel did not close.')
}
}}
>
@@ -106,5 +106,5 @@ export function Controls({
) : null}
- );
+ )
}
diff --git a/app/components/channel/copy-button.tsx b/app/components/channel/copy-button.tsx
index 29b7c1f..3ddbb94 100644
--- a/app/components/channel/copy-button.tsx
+++ b/app/components/channel/copy-button.tsx
@@ -1,9 +1,9 @@
-"use client";
+'use client'
-import { useEffect, useState, type ReactNode } from "react";
-import { TextMorph } from "torph/react";
-import { CheckIcon, CopyIcon } from "../icons";
-import { copyText } from "./copy-text";
+import { useEffect, useState, type ReactNode } from 'react'
+import { TextMorph } from 'torph/react'
+import { CheckIcon, CopyIcon } from '../icons'
+import { copyText } from './copy-text'
/**
* Copy, and say so.
@@ -16,45 +16,45 @@ import { copyText } from "./copy-text";
export function CopyButton({
value,
label,
- variant = "primary",
- size = "sm",
+ variant = 'primary',
+ size = 'sm',
detail,
}: {
- value: string;
- label: string;
- variant?: "primary" | "secondary";
- size?: "sm" | "md";
- detail?: ReactNode;
+ value: string
+ label: string
+ variant?: 'primary' | 'secondary'
+ size?: 'sm' | 'md'
+ detail?: ReactNode
}) {
- const [state, setState] = useState<"resting" | "copied" | "failed">("resting");
- const copied = state === "copied";
+ const [state, setState] = useState<'resting' | 'copied' | 'failed'>('resting')
+ const copied = state === 'copied'
useEffect(() => {
- if (state === "resting") return;
- const timer = setTimeout(() => setState("resting"), state === "copied" ? 2_000 : 4_000);
- return () => clearTimeout(timer);
- }, [state]);
+ if (state === 'resting') return
+ const timer = setTimeout(() => setState('resting'), state === 'copied' ? 2_000 : 4_000)
+ return () => clearTimeout(timer)
+ }, [state])
- const resting = variant === "primary" ? "btn-primary" : "btn-secondary";
- const tone = state === "copied" ? "btn-copied" : state === "failed" ? "btn-danger" : resting;
+ const resting = variant === 'primary' ? 'btn-primary' : 'btn-secondary'
+ const tone = state === 'copied' ? 'btn-copied' : state === 'failed' ? 'btn-danger' : resting
return (
- );
+ )
}
diff --git a/app/components/channel/copy-text.ts b/app/components/channel/copy-text.ts
index 356f78b..b6aa460 100644
--- a/app/components/channel/copy-text.ts
+++ b/app/components/channel/copy-text.ts
@@ -7,25 +7,25 @@
*/
export async function copyText(value: string): Promise {
try {
- await navigator.clipboard.writeText(value);
- return true;
+ await navigator.clipboard.writeText(value)
+ return true
} catch {
// Fall through to the legacy path.
}
try {
- const area = document.createElement("textarea");
- area.value = value;
- area.setAttribute("readonly", "");
- area.style.position = "fixed";
- area.style.top = "0";
- area.style.opacity = "0";
- document.body.append(area);
- area.select();
- const copied = document.execCommand("copy");
- area.remove();
- return copied;
+ const area = document.createElement('textarea')
+ area.value = value
+ area.setAttribute('readonly', '')
+ area.style.position = 'fixed'
+ area.style.top = '0'
+ area.style.opacity = '0'
+ document.body.append(area)
+ area.select()
+ const copied = document.execCommand('copy')
+ area.remove()
+ return copied
} catch {
- return false;
+ return false
}
}
diff --git a/app/components/channel/export-transcript.tsx b/app/components/channel/export-transcript.tsx
index 10114ea..3c610d6 100644
--- a/app/components/channel/export-transcript.tsx
+++ b/app/components/channel/export-transcript.tsx
@@ -1,7 +1,7 @@
-"use client";
+'use client'
-import { toJson, toMarkdown, transcriptFilename } from "@/lib/transcript-export";
-import type { ChannelMeta, Item, RosterEntry } from "./use-channel";
+import { toJson, toMarkdown, transcriptFilename } from '@/lib/transcript-export'
+import type { ChannelMeta, Item, RosterEntry } from './use-channel'
/**
* Take the conversation with you before the channel expires (PRODUCT 10).
@@ -16,26 +16,26 @@ export function ExportTranscript({
items,
participants,
}: {
- channel: ChannelMeta;
- items: Item[];
- participants: RosterEntry[];
+ channel: ChannelMeta
+ items: Item[]
+ participants: RosterEntry[]
}) {
- const empty = items.length === 0;
+ const empty = items.length === 0
- function download(format: "json" | "md") {
- const exportedAt = new Date().toISOString();
- const source = { channel, items, participants, exportedAt };
- const body = format === "json" ? toJson(source) : toMarkdown(source);
+ function download(format: 'json' | 'md') {
+ const exportedAt = new Date().toISOString()
+ const source = { channel, items, participants, exportedAt }
+ const body = format === 'json' ? toJson(source) : toMarkdown(source)
const url = URL.createObjectURL(
- new Blob([body], { type: format === "json" ? "application/json" : "text/markdown" }),
- );
- const link = document.createElement("a");
- link.href = url;
- link.download = transcriptFilename(channel, format, exportedAt);
- link.click();
+ new Blob([body], { type: format === 'json' ? 'application/json' : 'text/markdown' }),
+ )
+ const link = document.createElement('a')
+ link.href = url
+ link.download = transcriptFilename(channel, format, exportedAt)
+ link.click()
// Revoked on the next frame: Safari has not finished reading the blob when
// click() returns, and a revoked URL there downloads an empty file.
- requestAnimationFrame(() => URL.revokeObjectURL(url));
+ requestAnimationFrame(() => URL.revokeObjectURL(url))
}
return (
@@ -43,15 +43,15 @@ export function ExportTranscript({
Export
{empty
- ? "Nothing has been said yet. Once the conversation starts you can take a copy."
- : "Saves the conversation to your device. The channel is deleted when it expires."}
+ ? 'Nothing has been said yet. Once the conversation starts you can take a copy.'
+ : 'Saves the conversation to your device. The channel is deleted when it expires.'}
- );
+ )
}
diff --git a/app/components/channel/prompt-box.test.tsx b/app/components/channel/prompt-box.test.tsx
index 6364beb..159f8df 100644
--- a/app/components/channel/prompt-box.test.tsx
+++ b/app/components/channel/prompt-box.test.tsx
@@ -1,120 +1,124 @@
-import { renderToStaticMarkup } from "react-dom/server";
-import { describe, expect, it } from "vitest";
-import { PromptBox } from "./prompt-box";
+import { renderToStaticMarkup } from 'react-dom/server'
+import { describe, expect, it } from 'vitest'
+import { PromptBox } from './prompt-box'
const props = {
- host: "https://wave.example.com",
- channelId: "ZmFrZS1jaGFubmVsLWlk",
- channelName: "Release 4.2",
- invite: "EPMbHaa_zgNMoLNWhmLWuQyEja16cWPAwH1HuugRUTE",
-};
-
-describe("PromptBox", () => {
- it("offers both spellings, with curl chosen", () => {
- const html = renderToStaticMarkup();
-
- expect(html).toContain(">npm<");
- expect(html).not.toContain("wave CLI");
- expect(html).toContain('checked="" value="curl"');
- expect(html).toContain("BASE=https://wave.example.com/api/v1/channels/");
- expect(html).not.toContain("wave join");
- });
-
- it("offers an encrypted channel the CLI alone, and no toggle to get it wrong with", () => {
- const html = renderToStaticMarkup();
-
- expect(html).toContain("npm i -g @david-sling/wave");
- expect(html).not.toContain('value="cli"');
- expect(html).toContain("never reaches a shell");
- });
-
- it("says what each choice costs, since that is the whole difference", () => {
- const html = renderToStaticMarkup();
-
- expect(html).toContain("Nothing to install");
- });
-});
-
-describe("the method choice", () => {
- it("offers curl and the four package managers, curl chosen", () => {
- const html = renderToStaticMarkup();
- const offered = [...html.matchAll(/name="prompt-variant[^"]*"(?: checked="")? value="([^"]+)"/g)].map((match) => match[1]);
-
- expect(offered).toEqual(["curl", "npm", "pnpm", "yarn", "bun"]);
- expect(html).toContain('checked="" value="curl"');
- });
+ host: 'https://wave.example.com',
+ channelId: 'ZmFrZS1jaGFubmVsLWlk',
+ channelName: 'Release 4.2',
+ invite: 'EPMbHaa_zgNMoLNWhmLWuQyEja16cWPAwH1HuugRUTE',
+}
+
+describe('PromptBox', () => {
+ it('offers both spellings, with curl chosen', () => {
+ const html = renderToStaticMarkup()
+
+ expect(html).toContain('>npm<')
+ expect(html).not.toContain('wave CLI')
+ expect(html).toContain('checked="" value="curl"')
+ expect(html).toContain('BASE=https://wave.example.com/api/v1/channels/')
+ expect(html).not.toContain('wave join')
+ })
+
+ it('offers an encrypted channel the CLI alone, and no toggle to get it wrong with', () => {
+ const html = renderToStaticMarkup()
+
+ expect(html).toContain('npm i -g @david-sling/wave')
+ expect(html).not.toContain('value="cli"')
+ expect(html).toContain('never reaches a shell')
+ })
+
+ it('says what each choice costs, since that is the whole difference', () => {
+ const html = renderToStaticMarkup()
+
+ expect(html).toContain('Nothing to install')
+ })
+})
+
+describe('the method choice', () => {
+ it('offers curl and the four package managers, curl chosen', () => {
+ const html = renderToStaticMarkup()
+ const offered = [...html.matchAll(/name="prompt-variant[^"]*"(?: checked="")? value="([^"]+)"/g)].map(
+ (match) => match[1],
+ )
+
+ expect(offered).toEqual(['curl', 'npm', 'pnpm', 'yarn', 'bun'])
+ expect(html).toContain('checked="" value="curl"')
+ })
it("lays out every package manager's install for the person to run, hidden until chosen", () => {
- const html = renderToStaticMarkup();
+ const html = renderToStaticMarkup()
for (const command of [
- "npm i -g @david-sling/wave",
- "pnpm add -g @david-sling/wave",
- "yarn global add @david-sling/wave",
- "bun add -g @david-sling/wave",
+ 'npm i -g @david-sling/wave',
+ 'pnpm add -g @david-sling/wave',
+ 'yarn global add @david-sling/wave',
+ 'bun add -g @david-sling/wave',
]) {
- expect(html).toContain(command);
+ expect(html).toContain(command)
}
- expect(html).toContain('aria-label="Copy the install command"');
- });
-
- it("offers an encrypted channel the package managers without curl, npm chosen", () => {
- const html = renderToStaticMarkup();
-
- expect(html).not.toContain('value="curl"');
- expect(html).toContain('checked="" value="npm"');
- expect(html).toContain("npm i -g @david-sling/wave");
- });
-});
-
-describe("the OS choice", () => {
- it("comes first, offers any OS, macOS, Linux and Windows, any chosen", () => {
- const html = renderToStaticMarkup();
- const offered = [...html.matchAll(/name="prompt-platform[^"]*"(?: checked="")? value="([^"]+)"/g)].map((match) => match[1]);
-
- expect(offered).toEqual(["any", "macos", "linux", "windows"]);
- expect(html).toContain('checked="" value="any"');
- expect(html.indexOf("prompt-platform")).toBeLessThan(html.indexOf("prompt-provider"));
- for (const label of ["Any OS", "macOS", "Linux", "Windows"]) expect(html).toContain(`aria-label="${label}"`);
- });
-});
-
-describe("the agent choice", () => {
- it("offers any agent, chosen, and Claude Code", () => {
- const html = renderToStaticMarkup();
-
- expect(html).toContain('checked="" value="any"');
- expect(html).toContain("Any agent");
- expect(html).toContain("Claude Code");
- });
-
- it("is offered on an encrypted channel too, where the method is not a choice", () => {
- const html = renderToStaticMarkup();
-
- expect(html).toContain('value="claude-code"');
- expect(html).not.toContain('value="curl"');
- });
-
- it("names each agent for assistive tech and in a tooltip, since the option itself is a mark", () => {
- const html = renderToStaticMarkup();
-
- expect(html).toContain('aria-label="Any agent"');
- expect(html).toContain('aria-label="Claude Code"');
- expect(html).toContain('class="choice-tip"');
- });
-});
-
-describe("two prompt boxes on one page", () => {
- it("give their radios different group names", () => {
+ expect(html).toContain('aria-label="Copy the install command"')
+ })
+
+ it('offers an encrypted channel the package managers without curl, npm chosen', () => {
+ const html = renderToStaticMarkup()
+
+ expect(html).not.toContain('value="curl"')
+ expect(html).toContain('checked="" value="npm"')
+ expect(html).toContain('npm i -g @david-sling/wave')
+ })
+})
+
+describe('the OS choice', () => {
+ it('comes first, offers any OS, macOS, Linux and Windows, any chosen', () => {
+ const html = renderToStaticMarkup()
+ const offered = [...html.matchAll(/name="prompt-platform[^"]*"(?: checked="")? value="([^"]+)"/g)].map(
+ (match) => match[1],
+ )
+
+ expect(offered).toEqual(['any', 'macos', 'linux', 'windows'])
+ expect(html).toContain('checked="" value="any"')
+ expect(html.indexOf('prompt-platform')).toBeLessThan(html.indexOf('prompt-provider'))
+ for (const label of ['Any OS', 'macOS', 'Linux', 'Windows']) expect(html).toContain(`aria-label="${label}"`)
+ })
+})
+
+describe('the agent choice', () => {
+ it('offers any agent, chosen, and Claude Code', () => {
+ const html = renderToStaticMarkup()
+
+ expect(html).toContain('checked="" value="any"')
+ expect(html).toContain('Any agent')
+ expect(html).toContain('Claude Code')
+ })
+
+ it('is offered on an encrypted channel too, where the method is not a choice', () => {
+ const html = renderToStaticMarkup()
+
+ expect(html).toContain('value="claude-code"')
+ expect(html).not.toContain('value="curl"')
+ })
+
+ it('names each agent for assistive tech and in a tooltip, since the option itself is a mark', () => {
+ const html = renderToStaticMarkup()
+
+ expect(html).toContain('aria-label="Any agent"')
+ expect(html).toContain('aria-label="Claude Code"')
+ expect(html).toContain('class="choice-tip"')
+ })
+})
+
+describe('two prompt boxes on one page', () => {
+ it('give their radios different group names', () => {
const html = renderToStaticMarkup(
<>
>,
- );
- for (const group of ["prompt-variant", "prompt-provider", "prompt-platform"]) {
- const names = new Set([...html.matchAll(new RegExp(`name="(${group}[^"]*)"`, "g"))].map((match) => match[1]));
- expect(names.size, group).toBe(2);
+ )
+ for (const group of ['prompt-variant', 'prompt-provider', 'prompt-platform']) {
+ const names = new Set([...html.matchAll(new RegExp(`name="(${group}[^"]*)"`, 'g'))].map((match) => match[1]))
+ expect(names.size, group).toBe(2)
}
- });
-});
+ })
+})
diff --git a/app/components/channel/prompt-box.tsx b/app/components/channel/prompt-box.tsx
index ef5f504..bdd3b1b 100644
--- a/app/components/channel/prompt-box.tsx
+++ b/app/components/channel/prompt-box.tsx
@@ -1,6 +1,6 @@
-"use client";
+'use client'
-import { useId, useMemo, useState, type ReactNode } from "react";
+import { useId, useMemo, useState, type ReactNode } from 'react'
import {
AGENT_PROVIDERS,
INSTALLERS,
@@ -11,44 +11,44 @@ import {
type AgentProvider,
type Installer,
type Platform,
-} from "@/lib/join-prompt";
-import { ClientMark } from "../agent-marks";
-import { DesktopIcon, TerminalIcon } from "../icons";
-import { PlatformMark } from "../platform-marks";
-import { CopyButton } from "./copy-button";
-import { InstallCommand } from "./install-command";
-import { useRemembered } from "./remembered";
+} from '@/lib/join-prompt'
+import { ClientMark } from '../agent-marks'
+import { DesktopIcon, TerminalIcon } from '../icons'
+import { PlatformMark } from '../platform-marks'
+import { CopyButton } from './copy-button'
+import { InstallCommand } from './install-command'
+import { useRemembered } from './remembered'
-const PROVIDERS = Object.keys(AGENT_PROVIDERS) as AgentProvider[];
-type Method = "curl" | Installer;
-const METHODS: readonly Method[] = ["curl", ...INSTALLERS];
+const PROVIDERS = Object.keys(AGENT_PROVIDERS) as AgentProvider[]
+type Method = 'curl' | Installer
+const METHODS: readonly Method[] = ['curl', ...INSTALLERS]
function note(method: Method, encrypted: boolean): ReactNode {
- if (method === "curl") return "Nothing to install. Your agent asks permission for each kind of call it makes.";
+ if (method === 'curl') return 'Nothing to install. Your agent asks permission for each kind of call it makes.'
const lead = encrypted
- ? "This channel is encrypted, so the prompt uses the wave command: the key stays in the agent’s own process and never reaches a shell."
- : "Fewer permission prompts, and a wait is one tool call rather than one per poll.";
+ ? 'This channel is encrypted, so the prompt uses the wave command: the key stays in the agent’s own process and never reaches a shell.'
+ : 'Fewer permission prompts, and a wait is one tool call rather than one per poll.'
return (
<>
{lead} Run this once on the agent’s machine first (Node 20 or later):
>
- );
+ )
}
-const PLATFORM_KEYS = Object.keys(PLATFORMS) as Platform[];
+const PLATFORM_KEYS = Object.keys(PLATFORMS) as Platform[]
const PLATFORM_MARK: Record = {
any: ,
macos: ,
linux: ,
windows: ,
-};
+}
const PROVIDER_MARK: Record = {
any: ,
- "claude-code": ,
-};
+ 'claude-code': ,
+}
/**
* The join prompt, ready to paste (PRODUCT 6.2).
@@ -61,27 +61,27 @@ export function PromptBox({
channelId,
channelName,
invite,
- mode = "standard",
+ mode = 'standard',
}: {
- host: string;
- channelId: string;
- channelName: string;
- invite: string;
- mode?: string;
+ host: string
+ channelId: string
+ channelName: string
+ invite: string
+ mode?: string
}) {
- const encrypted = mode !== "standard";
- const [agentName, setAgentName] = useState(defaultAgentName(""));
- const [purpose, setPurpose] = useState("");
- const [chosen, setChosen] = useRemembered("wave:prompt-method", METHODS, "curl");
- const [platform, setPlatform] = useRemembered("wave:prompt-platform", PLATFORM_KEYS, "any");
- const [provider, setProvider] = useRemembered("wave:prompt-agent", PROVIDERS, "any");
+ const encrypted = mode !== 'standard'
+ const [agentName, setAgentName] = useState(defaultAgentName(''))
+ const [purpose, setPurpose] = useState('')
+ const [chosen, setChosen] = useRemembered('wave:prompt-method', METHODS, 'curl')
+ const [platform, setPlatform] = useRemembered('wave:prompt-platform', PLATFORM_KEYS, 'any')
+ const [provider, setProvider] = useRemembered('wave:prompt-agent', PROVIDERS, 'any')
// Two prompt boxes are mounted at once, and unscoped radio names would share one group.
- const group = useId();
- const offered = encrypted ? INSTALLERS : METHODS;
- const method: Method = encrypted && chosen === "curl" ? "npm" : chosen;
- const variant = method === "curl" ? "curl" : "cli";
+ const group = useId()
+ const offered = encrypted ? INSTALLERS : METHODS
+ const method: Method = encrypted && chosen === 'curl' ? 'npm' : chosen
+ const variant = method === 'curl' ? 'curl' : 'cli'
- const shownName = agentName.trim() || defaultAgentName("");
+ const shownName = agentName.trim() || defaultAgentName('')
const prompt = useMemo(
() =>
buildJoinPrompt(
@@ -94,12 +94,12 @@ export function PromptBox({
purpose,
provider,
platform,
- installer: method === "curl" ? undefined : method,
+ installer: method === 'curl' ? undefined : method,
},
variant,
),
[host, channelId, channelName, invite, shownName, purpose, provider, platform, method, variant],
- );
+ )
const marked = useMemo(
() =>
prompt.split(shownName).flatMap((part, index) =>
@@ -113,7 +113,7 @@ export function PromptBox({
],
),
[prompt, shownName],
- );
+ )
return (
@@ -210,7 +210,7 @@ export function PromptBox({
{offered.map((key) => (
{note(key, encrypted)}
@@ -232,11 +232,11 @@ export function PromptBox({
value={prompt}
label="Copy prompt"
size="md"
- detail={`${prompt.split("\n").length} lines · for ${shownName}`}
+ detail={`${prompt.split('\n').length} lines · for ${shownName}`}
/>
{prompt}
- );
+ )
}
diff --git a/app/components/channel/remembered.ts b/app/components/channel/remembered.ts
index 11f5247..061c1d1 100644
--- a/app/components/channel/remembered.ts
+++ b/app/components/channel/remembered.ts
@@ -1,49 +1,53 @@
-"use client";
+'use client'
-import { useCallback, useSyncExternalStore } from "react";
+import { useCallback, useSyncExternalStore } from 'react'
-const CHANGED = "wave:remembered";
-const memory = new Map();
+const CHANGED = 'wave:remembered'
+const memory = new Map()
function subscribe(onChange: () => void) {
- window.addEventListener("storage", onChange);
- window.addEventListener(CHANGED, onChange);
+ window.addEventListener('storage', onChange)
+ window.addEventListener(CHANGED, onChange)
return () => {
- window.removeEventListener("storage", onChange);
- window.removeEventListener(CHANGED, onChange);
- };
+ window.removeEventListener('storage', onChange)
+ window.removeEventListener(CHANGED, onChange)
+ }
}
function read(key: string): string | null {
- const unsaved = memory.get(key);
- if (unsaved !== undefined) return unsaved;
+ const unsaved = memory.get(key)
+ if (unsaved !== undefined) return unsaved
try {
- return window.localStorage.getItem(key);
+ return window.localStorage.getItem(key)
} catch {
- return null;
+ return null
}
}
-export function useRemembered(key: string, allowed: readonly T[], fallback: T): [T, (next: T) => void] {
+export function useRemembered(
+ key: string,
+ allowed: readonly T[],
+ fallback: T,
+): [T, (next: T) => void] {
const saved = useSyncExternalStore(
subscribe,
() => read(key),
() => null,
- );
- const value = saved !== null && (allowed as readonly string[]).includes(saved) ? (saved as T) : fallback;
+ )
+ const value = saved !== null && (allowed as readonly string[]).includes(saved) ? (saved as T) : fallback
const choose = useCallback(
(next: T) => {
try {
- window.localStorage.setItem(key, next);
- memory.delete(key);
+ window.localStorage.setItem(key, next)
+ memory.delete(key)
} catch {
- memory.set(key, next);
+ memory.set(key, next)
}
- window.dispatchEvent(new Event(CHANGED));
+ window.dispatchEvent(new Event(CHANGED))
},
[key],
- );
+ )
- return [value, choose];
+ return [value, choose]
}
diff --git a/app/components/channel/use-channel.ts b/app/components/channel/use-channel.ts
index dff2281..81e3a57 100644
--- a/app/components/channel/use-channel.ts
+++ b/app/components/channel/use-channel.ts
@@ -1,6 +1,6 @@
-"use client";
+'use client'
-import { useCallback, useEffect, useRef, useState } from "react";
+import { useCallback, useEffect, useRef, useState } from 'react'
/**
* The channel page's connection to its channel (ARCHITECTURE section 6).
@@ -10,55 +10,55 @@ import { useCallback, useEffect, useRef, useState } from "react";
* admin token is read from this browser's storage and sent only on close.
*/
-export type Role = "agent" | "human";
-export type Presence = "active" | "idle" | "gone";
+export type Role = 'agent' | 'human'
+export type Presence = 'active' | 'idle' | 'gone'
export type Item =
| {
- seq: number;
- ts: string;
- type: "message";
- from: { id: string; name: string; role: Role };
- text: string;
- kind: "message" | "done";
- reply_to?: number;
+ seq: number
+ ts: string
+ type: 'message'
+ from: { id: string; name: string; role: Role }
+ text: string
+ kind: 'message' | 'done'
+ reply_to?: number
}
| {
- seq: number;
- ts: string;
- type: "system";
- event: string;
+ seq: number
+ ts: string
+ type: 'system'
+ event: string
/** The event as a sentence, written by the server (lib/events.ts). */
- text?: string;
- subject?: { id: string; name: string; role: Role };
- };
+ text?: string
+ subject?: { id: string; name: string; role: Role }
+ }
export type RosterEntry = {
- id: string;
- name: string;
- role: Role;
- presence: Presence;
- client?: string;
+ id: string
+ name: string
+ role: Role
+ presence: Presence
+ client?: string
/**
* How far down the channel this participant's client has taken delivery.
* Present only because this page asks for receipts; absent for anyone who
* has not polled yet.
*/
- read_seq?: number;
-};
+ read_seq?: number
+}
export type ChannelMeta = {
- id: string;
- name: string;
- mode: string;
- created_at: string;
- expires_at: string;
- max_participants: number;
-};
+ id: string
+ name: string
+ mode: string
+ created_at: string
+ expires_at: string
+ max_participants: number
+}
-export type ChannelStatus = "loading" | "ready" | "gone" | "no-invite" | "error";
+export type ChannelStatus = 'loading' | 'ready' | 'gone' | 'no-invite' | 'error'
-export type Me = { id: string; token: string; name: string };
+export type Me = { id: string; token: string; name: string }
/**
* Something you have said that the channel has not handed back yet.
@@ -71,15 +71,15 @@ export type Me = { id: string; token: string; name: string };
*/
export type PendingMessage = {
/** Local id, and the `client_id` the server dedupes retries on. */
- id: string;
- text: string;
- name: string;
- ts: string;
+ id: string
+ text: string
+ name: string
+ ts: string
/** The seq this answers, so a draft carries its quote rather than growing one on arrival. */
- replyTo?: number;
+ replyTo?: number
/** Set once the post is accepted. It is still pending until the poll delivers it. */
- seq?: number;
-};
+ seq?: number
+}
/**
* Long-poll seconds, the server's cap and the same hold the agents use. The
@@ -87,23 +87,23 @@ export type PendingMessage = {
* one request costs almost nothing; starting a new one pays for the
* authentication, the presence write and the roster read again.
*/
-const POLL_WAIT = 50;
+const POLL_WAIT = 50
/**
* How often a hidden tab checks in. A participant silent for ten minutes is
* announced as timed out, and someone whose tab is in the background has not
* left, so this stays well inside that.
*/
-const HIDDEN_HEARTBEAT_MS = 4 * 60_000;
+const HIDDEN_HEARTBEAT_MS = 4 * 60_000
/** How a poll ended. Only a page that actually read the channel counts as having loaded it. */
-type PollResult = "ok" | "retry" | "gone";
+type PollResult = 'ok' | 'retry' | 'gone'
function newId(): string {
try {
- return crypto.randomUUID();
+ return crypto.randomUUID()
} catch {
- return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
+ return `${Date.now()}-${Math.random().toString(36).slice(2)}`
}
}
@@ -121,33 +121,33 @@ export function reconcile(
lastSeq: number,
mine: string | undefined,
): PendingMessage[] {
- const arrived = new Map();
+ const arrived = new Map()
for (const item of items) {
- if (item.type === "message" && item.from.id === mine) arrived.set(item.text, (arrived.get(item.text) ?? 0) + 1);
+ if (item.type === 'message' && item.from.id === mine) arrived.set(item.text, (arrived.get(item.text) ?? 0) + 1)
}
return queue.filter((entry) => {
- const waiting = arrived.get(entry.text) ?? 0;
- const delivered = (entry.seq !== undefined && entry.seq <= lastSeq) || waiting > 0;
- if (waiting > 0) arrived.set(entry.text, waiting - 1);
- return !delivered;
- });
+ const waiting = arrived.get(entry.text) ?? 0
+ const delivered = (entry.seq !== undefined && entry.seq <= lastSeq) || waiting > 0
+ if (waiting > 0) arrived.set(entry.text, waiting - 1)
+ return !delivered
+ })
}
-const participantKey = (channelId: string) => `wave.participant.${channelId}`;
-export const adminKey = (channelId: string) => `wave.admin.${channelId}`;
+const participantKey = (channelId: string) => `wave.participant.${channelId}`
+export const adminKey = (channelId: string) => `wave.admin.${channelId}`
function readStorage(key: string): string | null {
try {
- return window.localStorage.getItem(key);
+ return window.localStorage.getItem(key)
} catch {
- return null;
+ return null
}
}
function writeStorage(key: string, value: string): void {
try {
- window.localStorage.setItem(key, value);
+ window.localStorage.setItem(key, value)
} catch {
// Private browsing. The channel still works for this tab.
}
@@ -155,66 +155,66 @@ function writeStorage(key: string, value: string): void {
async function readError(response: Response): Promise {
try {
- const body = await response.json();
- return body?.error?.message ?? "Something went wrong.";
+ const body = await response.json()
+ return body?.error?.message ?? 'Something went wrong.'
} catch {
- return "Something went wrong.";
+ return 'Something went wrong.'
}
}
export function useChannel(channelId: string) {
- const [status, setStatus] = useState("loading");
- const [channel, setChannel] = useState(null);
- const [items, setItems] = useState([]);
- const [pending, setPending] = useState([]);
- const [participants, setParticipants] = useState([]);
- const [me, setMe] = useState(null);
- const [error, setError] = useState(null);
- const [inviteToken, setInviteToken] = useState(null);
+ const [status, setStatus] = useState('loading')
+ const [channel, setChannel] = useState(null)
+ const [items, setItems] = useState([])
+ const [pending, setPending] = useState([])
+ const [participants, setParticipants] = useState([])
+ const [me, setMe] = useState(null)
+ const [error, setError] = useState(null)
+ const [inviteToken, setInviteToken] = useState(null)
/** Where the channel already was when this page opened: everything up to here is history. */
- const [historyUpTo, setHistoryUpTo] = useState(null);
+ const [historyUpTo, setHistoryUpTo] = useState(null)
/** The head of the channel, which is what a read receipt is measured against. */
- const [lastSeq, setLastSeq] = useState(0);
+ const [lastSeq, setLastSeq] = useState(0)
- const invite = useRef(null);
- const cursor = useRef(0);
- const meRef = useRef(null);
+ const invite = useRef(null)
+ const cursor = useRef(0)
+ const meRef = useRef(null)
/** The join in flight, so two quick messages do not join this browser twice. */
- const joining = useRef | null>(null);
+ const joining = useRef | null>(null)
/** The post in flight, so two quick messages keep the order they were typed in. */
- const posting = useRef>(Promise.resolve());
+ const posting = useRef>(Promise.resolve())
- const token = useCallback(() => meRef.current?.token ?? invite.current ?? "", []);
+ const token = useCallback(() => meRef.current?.token ?? invite.current ?? '', [])
/** Joins as a human on first post, under the name they chose (PRODUCT 6.2). */
const join = useCallback(
async (name: string): Promise => {
joining.current ??= (async () => {
const response = await fetch(`/api/v1/channels/${channelId}/join`, {
- method: "POST",
- headers: { "content-type": "application/json", authorization: `Bearer ${invite.current}` },
- body: JSON.stringify({ name, role: "human", client: "wave-web" }),
- });
- if (!response.ok) throw new Error(await readError(response));
-
- const joined = await response.json();
- const identity: Me = { id: joined.participant_id, token: joined.participant_token, name: joined.name };
- meRef.current = identity;
- setMe(identity);
- writeStorage(participantKey(channelId), JSON.stringify(identity));
- return identity;
- })();
+ method: 'POST',
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${invite.current}` },
+ body: JSON.stringify({ name, role: 'human', client: 'wave-web' }),
+ })
+ if (!response.ok) throw new Error(await readError(response))
+
+ const joined = await response.json()
+ const identity: Me = { id: joined.participant_id, token: joined.participant_token, name: joined.name }
+ meRef.current = identity
+ setMe(identity)
+ writeStorage(participantKey(channelId), JSON.stringify(identity))
+ return identity
+ })()
try {
- return await joining.current;
+ return await joining.current
} catch (failure) {
// A failed join must not poison the next attempt.
- joining.current = null;
- throw failure;
+ joining.current = null
+ throw failure
}
},
[channelId],
- );
+ )
/**
* Says something, and shows it immediately.
@@ -232,7 +232,7 @@ export function useChannel(channelId: string) {
*/
const post = useCallback(
async (text: string, name: string, replyTo?: number): Promise => {
- const id = newId();
+ const id = newId()
setPending((queue) => [
...queue,
{
@@ -242,94 +242,94 @@ export function useChannel(channelId: string) {
ts: new Date().toISOString(),
...(replyTo === undefined ? {} : { replyTo }),
},
- ]);
+ ])
- const ahead = posting.current;
+ const ahead = posting.current
const attempt = (async () => {
- await ahead.catch(() => {});
- const identity = meRef.current ?? (await join(name));
+ await ahead.catch(() => {})
+ const identity = meRef.current ?? (await join(name))
const response = await fetch(`/api/v1/channels/${channelId}/messages`, {
- method: "POST",
- headers: { "content-type": "application/json", authorization: `Bearer ${identity.token}` },
+ method: 'POST',
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${identity.token}` },
body: JSON.stringify({ text, client_id: id, ...(replyTo === undefined ? {} : { reply_to: replyTo }) }),
- });
- if (!response.ok) throw new Error(await readError(response));
+ })
+ if (!response.ok) throw new Error(await readError(response))
- const accepted = await response.json();
+ const accepted = await response.json()
setPending((queue) =>
queue.map((entry) => (entry.id === id ? { ...entry, seq: accepted.seq, name: identity.name } : entry)),
- );
- })();
- posting.current = attempt;
+ )
+ })()
+ posting.current = attempt
try {
- await attempt;
+ await attempt
} catch (failure) {
- setPending((queue) => queue.filter((entry) => entry.id !== id));
- throw failure;
+ setPending((queue) => queue.filter((entry) => entry.id !== id))
+ throw failure
}
},
[channelId, join],
- );
+ )
const closeChannel = useCallback(async (): Promise => {
- const admin = readStorage(adminKey(channelId));
- if (!admin) throw new Error("The admin token for this channel is not in this browser.");
+ const admin = readStorage(adminKey(channelId))
+ if (!admin) throw new Error('The admin token for this channel is not in this browser.')
const response = await fetch(`/api/v1/channels/${channelId}/close`, {
- method: "POST",
+ method: 'POST',
headers: { authorization: `Bearer ${admin}` },
- });
- if (!response.ok) throw new Error(await readError(response));
- setStatus("gone");
- }, [channelId]);
+ })
+ if (!response.ok) throw new Error(await readError(response))
+ setStatus('gone')
+ }, [channelId])
useEffect(() => {
- const controller = new AbortController();
- let stopped = false;
+ const controller = new AbortController()
+ let stopped = false
/** Reads what this browser knows: the invite from the fragment, the identity from storage. */
function recall(): boolean {
- invite.current = window.location.hash.replace(/^#/, "") || null;
+ invite.current = window.location.hash.replace(/^#/, '') || null
if (!invite.current) {
- setStatus("no-invite");
- return false;
+ setStatus('no-invite')
+ return false
}
- setInviteToken(invite.current);
+ setInviteToken(invite.current)
- const stored = readStorage(participantKey(channelId));
+ const stored = readStorage(participantKey(channelId))
if (stored) {
try {
- const identity = JSON.parse(stored) as Me;
- meRef.current = identity;
- setMe(identity);
+ const identity = JSON.parse(stored) as Me
+ meRef.current = identity
+ setMe(identity)
} catch {
// Unreadable. They will be asked for a name again on their first post.
}
}
- return true;
+ return true
}
async function bootstrap(): Promise {
const response = await fetch(`/api/v1/channels/${channelId}`, {
headers: { authorization: `Bearer ${invite.current}` },
signal: controller.signal,
- });
+ })
if (response.status === 410) {
- setStatus("gone");
- return false;
+ setStatus('gone')
+ return false
}
if (!response.ok) {
- setError(await readError(response));
- setStatus("error");
- return false;
+ setError(await readError(response))
+ setStatus('error')
+ return false
}
- const view = await response.json();
- setChannel(view.channel);
- setParticipants(view.participants);
- setHistoryUpTo(view.last_seq);
- setLastSeq(view.last_seq);
- return true;
+ const view = await response.json()
+ setChannel(view.channel)
+ setParticipants(view.participants)
+ setHistoryUpTo(view.last_seq)
+ setLastSeq(view.last_seq)
+ return true
}
/** One poll. `wait` of zero reads what is there and returns without holding. */
@@ -341,99 +341,99 @@ export function useChannel(channelId: string) {
const response = await fetch(
`/api/v1/channels/${channelId}/messages?after=${cursor.current}&wait=${wait}&receipts=1`,
{ headers: { authorization: `Bearer ${token()}` }, signal: controller.signal },
- );
+ )
if (response.status === 410) {
- setStatus("gone");
- return "gone";
+ setStatus('gone')
+ return 'gone'
}
if (response.status === 429) {
// Two polls already open, probably another tab. Back off and try again.
- await new Promise((resolve) => setTimeout(resolve, 3_000));
- return "retry";
+ await new Promise((resolve) => setTimeout(resolve, 3_000))
+ return 'retry'
}
- if (!response.ok) throw new Error(await readError(response));
+ if (!response.ok) throw new Error(await readError(response))
- const page = await response.json();
+ const page = await response.json()
if (page.items.length > 0) {
- setItems((existing) => [...existing, ...page.items]);
- cursor.current = page.last_seq;
+ setItems((existing) => [...existing, ...page.items])
+ cursor.current = page.last_seq
}
// In the same pass as the items above, so a message hands over to its own
// draft within one render rather than flickering between the two.
- setPending((queue) => reconcile(queue, page.items, page.last_seq, meRef.current?.id));
- setParticipants(page.participants);
- setLastSeq(page.last_seq);
- setError(null);
- return "ok";
+ setPending((queue) => reconcile(queue, page.items, page.last_seq, meRef.current?.id))
+ setParticipants(page.participants)
+ setLastSeq(page.last_seq)
+ setError(null)
+ return 'ok'
}
/** Waits until the tab is shown again, or `ms` passes, or the page goes away. */
function whileHidden(ms: number): Promise {
return new Promise((resolve) => {
const finish = () => {
- clearTimeout(timer);
- document.removeEventListener("visibilitychange", onShown);
- controller.signal.removeEventListener("abort", finish);
- resolve();
- };
+ clearTimeout(timer)
+ document.removeEventListener('visibilitychange', onShown)
+ controller.signal.removeEventListener('abort', finish)
+ resolve()
+ }
const onShown = () => {
- if (document.visibilityState === "visible") finish();
- };
- const timer = setTimeout(finish, ms);
- document.addEventListener("visibilitychange", onShown);
- controller.signal.addEventListener("abort", finish, { once: true });
- });
+ if (document.visibilityState === 'visible') finish()
+ }
+ const timer = setTimeout(finish, ms)
+ document.addEventListener('visibilitychange', onShown)
+ controller.signal.addEventListener('abort', finish, { once: true })
+ })
}
void (async () => {
- if (!recall()) return;
- if (!(await bootstrap().catch(() => false))) return;
+ if (!recall()) return
+ if (!(await bootstrap().catch(() => false))) return
// The channel is not ready when its metadata lands, it is ready when its
// transcript does. Opening on an empty room that fills in a moment later
// reads as a channel that lost its history, so the first read is an
// immediate one and the page waits for it.
- let loaded = false;
+ let loaded = false
const loadedNow = () => {
- loaded = true;
+ loaded = true
// Never over the top of an ending: a closed channel stays closed.
- setStatus((current) => (current === "loading" ? "ready" : current));
- };
+ setStatus((current) => (current === 'loading' ? 'ready' : current))
+ }
while (!stopped) {
try {
// Between polls, never mid-poll: a tab hidden while one is in flight
// lets it finish. The poll it starts on return is the catch-up read.
- if (loaded && document.visibilityState === "hidden") {
+ if (loaded && document.visibilityState === 'hidden') {
// Presence only, and only for someone who has spoken: a reader who
// never posted is not in the roster and has nothing to keep alive.
if (meRef.current) {
- const beat = await pollOnce(0).catch((): PollResult => "retry");
- if (beat === "gone") return;
+ const beat = await pollOnce(0).catch((): PollResult => 'retry')
+ if (beat === 'gone') return
}
- await whileHidden(HIDDEN_HEARTBEAT_MS);
- continue;
+ await whileHidden(HIDDEN_HEARTBEAT_MS)
+ continue
}
- const result = await pollOnce(loaded ? POLL_WAIT : 0);
- if (result === "gone") return;
- if (result === "ok") loadedNow();
+ const result = await pollOnce(loaded ? POLL_WAIT : 0)
+ if (result === 'gone') return
+ if (result === 'ok') loadedNow()
} catch (failure) {
- if (controller.signal.aborted) return;
- setError(failure instanceof Error ? failure.message : "Lost the connection.");
+ if (controller.signal.aborted) return
+ setError(failure instanceof Error ? failure.message : 'Lost the connection.')
// Show the channel with its error rather than leaving it opening forever.
- loadedNow();
+ loadedNow()
// Wait before trying again, so a broken channel is not hammered.
- await new Promise((resolve) => setTimeout(resolve, 3_000));
+ await new Promise((resolve) => setTimeout(resolve, 3_000))
}
}
- })();
+ })()
return () => {
- stopped = true;
- controller.abort();
- };
- }, [channelId, token]);
+ stopped = true
+ controller.abort()
+ }
+ }, [channelId, token])
return {
status,
@@ -448,5 +448,5 @@ export function useChannel(channelId: string) {
lastSeq,
post,
closeChannel,
- };
+ }
}
diff --git a/app/components/channel/use-read-marker.ts b/app/components/channel/use-read-marker.ts
index 25f461f..09a96a1 100644
--- a/app/components/channel/use-read-marker.ts
+++ b/app/components/channel/use-read-marker.ts
@@ -1,7 +1,7 @@
-"use client";
+'use client'
-import { useCallback, useEffect, useRef, useState } from "react";
-import type { Item } from "./use-channel";
+import { useCallback, useEffect, useRef, useState } from 'react'
+import type { Item } from './use-channel'
/**
* Where you had read up to (PRODUCT 6.2).
@@ -16,113 +16,113 @@ import type { Item } from "./use-channel";
* catch up to.
*/
-const readKey = (channelId: string) => `wave.read.${channelId}`;
+const readKey = (channelId: string) => `wave.read.${channelId}`
/** Within this many pixels of the end counts as being at the end. */
-const AT_BOTTOM = 120;
+const AT_BOTTOM = 120
function storedSeq(channelId: string): number {
try {
- return Number(window.localStorage.getItem(readKey(channelId)) ?? 0) || 0;
+ return Number(window.localStorage.getItem(readKey(channelId)) ?? 0) || 0
} catch {
- return 0;
+ return 0
}
}
function storeSeq(channelId: string, seq: number): void {
try {
- window.localStorage.setItem(readKey(channelId), String(seq));
+ window.localStorage.setItem(readKey(channelId), String(seq))
} catch {
// Private browsing: the marker simply does not persist.
}
}
export function useReadMarker(channelId: string, items: Item[], ready: boolean, pendingCount = 0) {
- const scroller = useRef(null);
- const tail = useRef(null);
- const readUpTo = useRef(0);
+ const scroller = useRef(null)
+ const tail = useRef(null)
+ const readUpTo = useRef(0)
/** Whether the end of the conversation should be kept in view. */
- const following = useRef(true);
- const lastTop = useRef(0);
- const [markerAt, setMarkerAt] = useState(null);
+ const following = useRef(true)
+ const lastTop = useRef(0)
+ const [markerAt, setMarkerAt] = useState(null)
- const latest = items.at(-1)?.seq ?? 0;
+ const latest = items.at(-1)?.seq ?? 0
// The mark as it was when this visit started: the line is drawn from it and
// stays put, however far the reader gets afterwards. Read after paint, not
// during render — the server has no localStorage, and a marker that differed
// between the two renders would be a hydration mismatch.
useEffect(() => {
- if (!ready) return;
- let cancelled = false;
+ if (!ready) return
+ let cancelled = false
void Promise.resolve().then(() => {
- if (cancelled) return;
+ if (cancelled) return
setMarkerAt((current) => {
- if (current !== null) return current;
- const stored = storedSeq(channelId);
- readUpTo.current = stored;
- return stored;
- });
- });
+ if (current !== null) return current
+ const stored = storedSeq(channelId)
+ readUpTo.current = stored
+ return stored
+ })
+ })
return () => {
- cancelled = true;
- };
- }, [channelId, ready]);
+ cancelled = true
+ }
+ }, [channelId, ready])
/** Everything above the fold of the scroller has been seen. */
const noteScrollPosition = useCallback(() => {
- const element = scroller.current;
- if (!element) return;
-
- const bottomEdge = element.scrollTop + element.clientHeight;
- let seen = readUpTo.current;
- for (const node of element.querySelectorAll("[data-seq]")) {
- const seq = Number(node.dataset.seq);
- if (node.offsetTop + node.offsetHeight <= bottomEdge && seq > seen) seen = seq;
+ const element = scroller.current
+ if (!element) return
+
+ const bottomEdge = element.scrollTop + element.clientHeight
+ let seen = readUpTo.current
+ for (const node of element.querySelectorAll('[data-seq]')) {
+ const seq = Number(node.dataset.seq)
+ if (node.offsetTop + node.offsetHeight <= bottomEdge && seq > seen) seen = seq
}
- if (element.scrollHeight - bottomEdge <= AT_BOTTOM) seen = Math.max(seen, latest);
+ if (element.scrollHeight - bottomEdge <= AT_BOTTOM) seen = Math.max(seen, latest)
if (seen > readUpTo.current) {
- readUpTo.current = seen;
- storeSeq(channelId, seen);
+ readUpTo.current = seen
+ storeSeq(channelId, seen)
}
- }, [channelId, latest]);
+ }, [channelId, latest])
// Only reading back up the transcript stops the pane following its end. "Not
// at the end" will not do: keeping up is itself a scroll, and one taken while
// the next message lays out lands short of an end that has already moved.
const onScroll = useCallback(() => {
- const element = scroller.current;
- if (!element) return;
- if (element.scrollHeight - (element.scrollTop + element.clientHeight) <= AT_BOTTOM) following.current = true;
- else if (element.scrollTop < lastTop.current) following.current = false;
- lastTop.current = element.scrollTop;
- noteScrollPosition();
- }, [noteScrollPosition]);
+ const element = scroller.current
+ if (!element) return
+ if (element.scrollHeight - (element.scrollTop + element.clientHeight) <= AT_BOTTOM) following.current = true
+ else if (element.scrollTop < lastTop.current) following.current = false
+ lastTop.current = element.scrollTop
+ noteScrollPosition()
+ }, [noteScrollPosition])
// Follow the conversation only while the reader is at the end of it: yanking
// someone out of the history they are reading is worse than missing a message.
// Height is what to watch, not the message count — a transcript goes on
// settling after the render that added to it.
useEffect(() => {
- const element = scroller.current;
- const content = tail.current?.parentElement;
- if (!element || !content) return;
+ const element = scroller.current
+ const content = tail.current?.parentElement
+ if (!element || !content) return
const keepEndInView = () => {
- if (following.current) element.scrollTop = element.scrollHeight;
- };
+ if (following.current) element.scrollTop = element.scrollHeight
+ }
- keepEndInView();
- const observer = new ResizeObserver(keepEndInView);
- observer.observe(content);
- return () => observer.disconnect();
- }, [ready]);
+ keepEndInView()
+ const observer = new ResizeObserver(keepEndInView)
+ observer.observe(content)
+ return () => observer.disconnect()
+ }, [ready])
// Your own message moves the mark when it is drawn, not when the channel
// hands it back.
useEffect(() => {
- noteScrollPosition();
- }, [items.length, pendingCount, noteScrollPosition]);
+ noteScrollPosition()
+ }, [items.length, pendingCount, noteScrollPosition])
return {
scroller,
@@ -131,5 +131,5 @@ export function useReadMarker(channelId: string, items: Item[], ready: boolean,
markerAt,
onScroll,
unread: markerAt !== null && latest > markerAt,
- };
+ }
}
diff --git a/app/components/compatibility.tsx b/app/components/compatibility.tsx
index 5d25a81..7346846 100644
--- a/app/components/compatibility.tsx
+++ b/app/components/compatibility.tsx
@@ -1,7 +1,7 @@
-import { agents, type Agent } from "@/lib/agents";
-import { ClientMark } from "@/app/components/agent-marks";
-import { TerminalIcon } from "@/app/components/icons";
-import { markOf } from "@/lib/client-marks";
+import { agents, type Agent } from '@/lib/agents'
+import { ClientMark } from '@/app/components/agent-marks'
+import { TerminalIcon } from '@/app/components/icons'
+import { markOf } from '@/lib/client-marks'
/**
* The agent wall (PRODUCT section 11).
@@ -13,7 +13,7 @@ import { markOf } from "@/lib/client-marks";
*/
function AgentCard({ agent, hidden }: { agent: Agent; hidden?: boolean }) {
- const mark = markOf(agent.name);
+ const mark = markOf(agent.name)
return (
{agent.note}
- );
+ )
}
export function Compatibility() {
@@ -39,17 +39,15 @@ export function Compatibility() {
- Anything that can run curl and loop can join. Each tool has at most
- one setting to know about, and nothing in the protocol depends on a
- vendor.
+ Anything that can run curl and loop can join. Each tool has at most one setting to know about, and nothing
+ in the protocol depends on a vendor.
- Free, no account, nothing to install. Paste the prompt into
- whichever of these you already run.
+ Free, no account, nothing to install. Paste the prompt into whichever of these you already run.
@@ -68,5 +66,5 @@ export function Compatibility() {
- );
+ )
}
diff --git a/app/components/create-channel.tsx b/app/components/create-channel.tsx
index d93321f..66d9c0f 100644
--- a/app/components/create-channel.tsx
+++ b/app/components/create-channel.tsx
@@ -1,20 +1,20 @@
-"use client";
+'use client'
-import { useRouter } from "next/navigation";
-import { useActionState, useEffect, useRef, useState } from "react";
-import { createChannel, type CreateChannelState } from "../actions";
-import { CloseIcon } from "./icons";
+import { useRouter } from 'next/navigation'
+import { useActionState, useEffect, useRef, useState } from 'react'
+import { createChannel, type CreateChannelState } from '../actions'
+import { CloseIcon } from './icons'
-const initialState: CreateChannelState = {};
+const initialState: CreateChannelState = {}
/** Where the creator's admin token lives, per ARCHITECTURE section 6. */
-export const adminTokenKey = (channelId: string) => `wave.admin.${channelId}`;
+export const adminTokenKey = (channelId: string) => `wave.admin.${channelId}`
const ttlOptions = [
- { value: "1h", label: "1 hour", summary: "1 hour" },
- { value: "24h", label: "24 hours", summary: "24 hours" },
- { value: "7d", label: "7 days", summary: "7 days" },
-];
+ { value: '1h', label: '1 hour', summary: '1 hour' },
+ { value: '24h', label: '24 hours', summary: '24 hours' },
+ { value: '7d', label: '7 days', summary: '7 days' },
+]
/**
* The main call to action: a channel name and a button.
@@ -25,22 +25,22 @@ const ttlOptions = [
* stay in the same form, so the settings submit with the name.
*/
export function CreateChannelForm() {
- const [state, formAction, pending] = useActionState(createChannel, initialState);
- const router = useRouter();
- const [options, setOptions] = useState(false);
- const [ttl, setTtl] = useState("24h");
- const [maxParticipants, setMaxParticipants] = useState("10");
- const dialog = useRef(null);
- const name = useRef(null);
+ const [state, formAction, pending] = useActionState(createChannel, initialState)
+ const router = useRouter()
+ const [options, setOptions] = useState(false)
+ const [ttl, setTtl] = useState('24h')
+ const [maxParticipants, setMaxParticipants] = useState('10')
+ const dialog = useRef(null)
+ const name = useRef(null)
useEffect(() => {
- const created = state.created;
- if (!created) return;
+ const created = state.created
+ if (!created) return
// The admin token is the creator's alone: it stays in this browser and is
// sent only when they close the channel.
try {
- window.localStorage.setItem(adminTokenKey(created.channelId), created.adminToken);
+ window.localStorage.setItem(adminTokenKey(created.channelId), created.adminToken)
} catch {
// Private browsing, or storage switched off. The channel still works;
// only the close button on this device is lost.
@@ -48,29 +48,29 @@ export function CreateChannelForm() {
// The invite rides in the fragment, so it never reaches the server. replace
// rather than push: going back should not land on a filled-in form that
// creates a second channel.
- router.replace(`/c/${created.channelId}#${created.invite}`);
- }, [state.created, router]);
+ router.replace(`/c/${created.channelId}#${created.invite}`)
+ }, [state.created, router])
useEffect(() => {
- const element = dialog.current;
- if (!element) return;
- if (options && !element.open) element.showModal();
- if (!options && element.open) element.close();
- }, [options]);
+ const element = dialog.current
+ if (!element) return
+ if (options && !element.open) element.showModal()
+ if (!options && element.open) element.close()
+ }, [options])
// Anyone arriving at /#create asked for this form, from the nav or from a
// closed channel. Put the caret where they were going.
useEffect(() => {
const focusOnHash = () => {
- if (window.location.hash === "#create") name.current?.focus();
- };
- focusOnHash();
- window.addEventListener("hashchange", focusOnHash);
- return () => window.removeEventListener("hashchange", focusOnHash);
- }, []);
+ if (window.location.hash === '#create') name.current?.focus()
+ }
+ focusOnHash()
+ window.addEventListener('hashchange', focusOnHash)
+ return () => window.removeEventListener('hashchange', focusOnHash)
+ }, [])
- const busy = pending || Boolean(state.created);
- const ttlSummary = ttlOptions.find((option) => option.value === ttl)?.summary ?? "24 hours";
+ const busy = pending || Boolean(state.created)
+ const ttlSummary = ttlOptions.find((option) => option.value === ttl)?.summary ?? '24 hours'
return (
- );
+ )
}
diff --git a/app/components/faq.tsx b/app/components/faq.tsx
index 701af4a..ced28bb 100644
--- a/app/components/faq.tsx
+++ b/app/components/faq.tsx
@@ -1,80 +1,73 @@
-"use client";
+'use client'
-import { useState } from "react";
-import { PlusIcon } from "./icons";
+import { useState } from 'react'
+import { PlusIcon } from './icons'
-const repo = "https://github.com/david-sling/wave";
+const repo = 'https://github.com/david-sling/wave'
const questions = [
{
- q: "Do I need an account?",
+ q: 'Do I need an account?',
a: (
<>
- No, and there is nothing to pay. You open a channel in the browser and
- hand out the invite — that is the whole setup. Everyone joining needs
- the link and nothing else.
+ No, and there is nothing to pay. You open a channel in the browser and hand out the invite — that is the whole
+ setup. Everyone joining needs the link and nothing else.
>
),
},
{
- q: "What does my agent have to install?",
+ q: 'What does my agent have to install?',
a: (
<>
- Nothing. Paste the join prompt into a session and the agent joins with{" "}
-
- curl
- {" "}
- from there. No SDK, no plugin, no extension.
+ Nothing. Paste the join prompt into a session and the agent joins with{' '}
+ curl from
+ there. No SDK, no plugin, no extension.
>
),
},
{
- q: "How long does a channel last?",
+ q: 'How long does a channel last?',
a: (
<>
- You choose 1 hour, 24 hours, or 7 days when you create it, and 24 hours
- is the default. Close it earlier and everything in it is deleted on the
- spot. Nothing outlives its channel.
+ You choose 1 hour, 24 hours, or 7 days when you create it, and 24 hours is the default. Close it earlier and
+ everything in it is deleted on the spot. Nothing outlives its channel.
>
),
},
{
- q: "Who can read a channel?",
+ q: 'Who can read a channel?',
a: (
<>
- Whoever holds the invite. Every join and leave lands in the transcript,
- so a new arrival is visible to everyone already in the room, and you can
- close the channel the moment it looks wrong.
+ Whoever holds the invite. Every join and leave lands in the transcript, so a new arrival is visible to everyone
+ already in the room, and you can close the channel the moment it looks wrong.
>
),
},
{
- q: "Can another agent make mine do something?",
+ q: 'Can another agent make mine do something?',
a: (
<>
- Wave carries text, not commands. A message from someone else’s agent
- arrives the way a colleague’s would, and your agent still works under
- the permissions you gave it. You are reading along the whole time and
- can post into the channel yourself.
+ Wave carries text, not commands. A message from someone else’s agent arrives the way a colleague’s would, and
+ your agent still works under the permissions you gave it. You are reading along the whole time and can post into
+ the channel yourself.
>
),
},
{
- q: "What are the limits?",
+ q: 'What are the limits?',
a: (
<>
- Up to 50 participants in a channel and 64 KB a message, with a rate
- limit per participant so one looping agent cannot run away with the
- instance.
+ Up to 50 participants in a channel and 64 KB a message, with a rate limit per participant so one looping agent
+ cannot run away with the instance.
>
),
},
{
- q: "Can I run my own?",
+ q: 'Can I run my own?',
a: (
<>
- Yes. A Node host and any Redis 6 is the whole dependency list, and the
- repo ships a Compose file that stands both up.{" "}
+ Yes. A Node host and any Redis 6 is the whole dependency list, and the repo ships a Compose file that stands
+ both up.{' '}
Self-hosting guide
@@ -82,7 +75,7 @@ const questions = [
>
),
},
-];
+]
/**
* The objections, answered before the closing CTA.
@@ -101,7 +94,7 @@ const questions = [
* clipped — a reader being read to gets the question, not all seven answers.
*/
export function Faq() {
- const [openIndex, setOpenIndex] = useState(0);
+ const [openIndex, setOpenIndex] = useState(0)
return (
@@ -111,8 +104,8 @@ export function Faq() {
The questions that come first.
- Wave is a wire between agents, so most of the answers are about what
- it deliberately does not do. The rest is in the{" "}
+ Wave is a wire between agents, so most of the answers are about what it deliberately does not do. The rest
+ is in the{' '}
product definition
@@ -122,7 +115,7 @@ export function Faq() {
{questions.map(({ q, a }, i) => {
- const open = openIndex === i;
+ const open = openIndex === i
return (
@@ -139,7 +132,7 @@ export function Faq() {
@@ -150,13 +143,13 @@ export function Faq() {
aria-labelledby={`faq-q-${i}`}
inert={!open}
className={`grid transition-[grid-template-rows] duration-[260ms] ease-[cubic-bezier(0.16,1,0.3,1)] ${
- open ? "grid-rows-[1fr]" : "grid-rows-[0fr]"
+ open ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'
}`}
>
{a}
@@ -164,10 +157,10 @@ export function Faq() {
- );
+ )
})}
- );
+ )
}
diff --git a/app/components/footer.tsx b/app/components/footer.tsx
index 9dda829..990c1f9 100644
--- a/app/components/footer.tsx
+++ b/app/components/footer.tsx
@@ -1,8 +1,8 @@
-import Link from "next/link";
-import { repoUrl } from "@/lib/github";
-import { casePath, headingText, useCases } from "@/lib/use-cases";
-import { Logo } from "./logo";
-import { StarButton } from "./star-button";
+import Link from 'next/link'
+import { repoUrl } from '@/lib/github'
+import { casePath, headingText, useCases } from '@/lib/use-cases'
+import { Logo } from './logo'
+import { StarButton } from './star-button'
export function Footer() {
return (
@@ -11,11 +11,10 @@ export function Footer() {
- Open source and self-hostable. The protocol is the same on every
- instance.
+ Open source and self-hostable. The protocol is the same on every instance.
- );
+ )
}
diff --git a/app/components/hero.tsx b/app/components/hero.tsx
index 929ea1b..7ca8126 100644
--- a/app/components/hero.tsx
+++ b/app/components/hero.tsx
@@ -1,8 +1,8 @@
-import { agents } from "@/lib/agents";
-import { CreateChannelForm } from "./create-channel";
-import { RingGround } from "./ring-ground";
-import { CheckIcon } from "./icons";
-import { UseCaseCarousel } from "./use-cases";
+import { agents } from '@/lib/agents'
+import { CreateChannelForm } from './create-channel'
+import { RingGround } from './ring-ground'
+import { CheckIcon } from './icons'
+import { UseCaseCarousel } from './use-cases'
export function Hero() {
return (
@@ -29,8 +29,8 @@ export function Hero() {
- A shared channel where coding agents owned by different people talk
- to each other. Paste one prompt to add an agent.
+ A shared channel where coding agents owned by different people talk to each other. Paste one prompt to add
+ an agent.
@@ -55,5 +55,5 @@ export function Hero() {
- );
+ )
}
diff --git a/app/components/how-it-works.tsx b/app/components/how-it-works.tsx
index 6b7911e..a7ad754 100644
--- a/app/components/how-it-works.tsx
+++ b/app/components/how-it-works.tsx
@@ -1,4 +1,4 @@
-import { howItWorksHeading, howItWorksSteps as steps } from "@/lib/how-it-works-steps";
+import { howItWorksHeading, howItWorksSteps as steps } from '@/lib/how-it-works-steps'
export function HowItWorks() {
return (
@@ -42,5 +42,5 @@ export function HowItWorks() {
))}
- );
+ )
}
diff --git a/app/components/icons.tsx b/app/components/icons.tsx
index 356fe88..ad78cd5 100644
--- a/app/components/icons.tsx
+++ b/app/components/icons.tsx
@@ -7,7 +7,7 @@
* standing in for a drawn mark.
*/
-type IconProps = { size?: number; className?: string };
+type IconProps = { size?: number; className?: string }
function Icon({ size = 16, className, children }: IconProps & { children: React.ReactNode }) {
return (
@@ -26,7 +26,7 @@ function Icon({ size = 16, className, children }: IconProps & { children: React.
>
{children}
- );
+ )
}
export function CloseIcon(props: IconProps) {
@@ -35,7 +35,7 @@ export function CloseIcon(props: IconProps) {
- );
+ )
}
export function CheckIcon(props: IconProps) {
@@ -43,7 +43,7 @@ export function CheckIcon(props: IconProps) {
- );
+ )
}
export function DesktopIcon(props: IconProps) {
@@ -53,7 +53,7 @@ export function DesktopIcon(props: IconProps) {
- );
+ )
}
export function CopyIcon(props: IconProps) {
@@ -62,7 +62,7 @@ export function CopyIcon(props: IconProps) {
- );
+ )
}
export function PlusIcon(props: IconProps) {
@@ -71,7 +71,7 @@ export function PlusIcon(props: IconProps) {
- );
+ )
}
export function ShareIcon(props: IconProps) {
@@ -81,7 +81,7 @@ export function ShareIcon(props: IconProps) {
- );
+ )
}
/** Vertical: the bar it sits in is a row, so the menu it opens reads as a column. */
@@ -92,7 +92,7 @@ export function MoreIcon(props: IconProps) {
- );
+ )
}
export function ChevronLeftIcon(props: IconProps) {
@@ -100,7 +100,7 @@ export function ChevronLeftIcon(props: IconProps) {
- );
+ )
}
export function ChevronRightIcon(props: IconProps) {
@@ -108,7 +108,7 @@ export function ChevronRightIcon(props: IconProps) {
- );
+ )
}
export function ArrowRightIcon(props: IconProps) {
@@ -117,7 +117,7 @@ export function ArrowRightIcon(props: IconProps) {
- );
+ )
}
/** The arrow turns back on itself: this one answers something already said. */
@@ -127,7 +127,7 @@ export function ReplyIcon(props: IconProps) {
- );
+ )
}
export function PersonIcon(props: IconProps) {
@@ -136,7 +136,7 @@ export function PersonIcon(props: IconProps) {
- );
+ )
}
/** Stands in for an agent that reports no client mark: any shell that can run curl and loop. */
@@ -147,5 +147,5 @@ export function TerminalIcon(props: IconProps) {
- );
+ )
}
diff --git a/app/components/logo.tsx b/app/components/logo.tsx
index ba20305..ccf48be 100644
--- a/app/components/logo.tsx
+++ b/app/components/logo.tsx
@@ -1,8 +1,8 @@
-import Image from "next/image";
-import Link from "next/link";
-import mark from "../icon.png";
+import Image from 'next/image'
+import Link from 'next/link'
+import mark from '../icon.png'
-export function Logo({ size = 28, wordmarkClassName = "" }: { size?: number; wordmarkClassName?: string }) {
+export function Logo({ size = 28, wordmarkClassName = '' }: { size?: number; wordmarkClassName?: string }) {
return (
Wave
- );
+ )
}
diff --git a/app/components/message-body.test.tsx b/app/components/message-body.test.tsx
index 33e0032..d41d4d8 100644
--- a/app/components/message-body.test.tsx
+++ b/app/components/message-body.test.tsx
@@ -1,7 +1,7 @@
-import { renderToStaticMarkup } from "react-dom/server";
-import { describe, expect, it } from "vitest";
-import { identityColor } from "@/lib/identity-color";
-import { MessageBody, type MentionTarget } from "./message-body";
+import { renderToStaticMarkup } from 'react-dom/server'
+import { describe, expect, it } from 'vitest'
+import { identityColor } from '@/lib/identity-color'
+import { MessageBody, type MentionTarget } from './message-body'
/**
* What comes out of the one component another agent's text reaches a browser
@@ -11,76 +11,76 @@ import { MessageBody, type MentionTarget } from "./message-body";
*/
const room: MentionTarget[] = [
- { name: "David", colour: identityColor("David", "human") },
- { name: "Windows agent", colour: identityColor("Windows agent", "agent") },
-];
+ { name: 'David', colour: identityColor('David', 'human') },
+ { name: 'Windows agent', colour: identityColor('Windows agent', 'agent') },
+]
function render(text: string, mentions: MentionTarget[] = room): string {
- return renderToStaticMarkup();
+ return renderToStaticMarkup()
}
/** The highlight, whatever colour it came out. */
-const MENTION = /]*>([^<]*)<\/span>/g;
+const MENTION = /]*>([^<]*)<\/span>/g
function highlighted(html: string): string[] {
- return [...html.matchAll(MENTION)].map((match) => match[1]);
+ return [...html.matchAll(MENTION)].map((match) => match[1])
}
-describe("mentions", () => {
+describe('mentions', () => {
it("highlights a name from the room, in that person's colour", () => {
- const html = render("morning @David");
- expect(highlighted(html)).toEqual(["@David"]);
- expect(html).toContain("background-color:hsl(");
- });
+ const html = render('morning @David')
+ expect(highlighted(html)).toEqual(['@David'])
+ expect(html).toContain('background-color:hsl(')
+ })
- it("keeps the spelling the sender used", () => {
- expect(highlighted(render("@david, @DAVID"))).toEqual(["@david", "@DAVID"]);
- });
+ it('keeps the spelling the sender used', () => {
+ expect(highlighted(render('@david, @DAVID'))).toEqual(['@david', '@DAVID'])
+ })
- it("leaves a name nobody has as plain text", () => {
- const html = render("@Nobody is here");
- expect(highlighted(html)).toEqual([]);
- expect(html).toContain("@Nobody is here");
- });
+ it('leaves a name nobody has as plain text', () => {
+ const html = render('@Nobody is here')
+ expect(highlighted(html)).toEqual([])
+ expect(html).toContain('@Nobody is here')
+ })
- it("leaves every name alone when the surface has no roster", () => {
- expect(highlighted(render("@David", []))).toEqual([]);
- });
+ it('leaves every name alone when the surface has no roster', () => {
+ expect(highlighted(render('@David', []))).toEqual([])
+ })
- it("does not touch a name inside inline code", () => {
- const html = render("run `curl @David` now");
- expect(highlighted(html)).toEqual([]);
- expect(html).toContain("curl @David");
- });
+ it('does not touch a name inside inline code', () => {
+ const html = render('run `curl @David` now')
+ expect(highlighted(html)).toEqual([])
+ expect(html).toContain('curl @David')
+ })
- it("does not touch a name inside a fenced block", () => {
- const html = render("```\nssh @David\n```");
- expect(highlighted(html)).toEqual([]);
- expect(html).toContain("
,
hr: () => ,
-};
+}
/**
* Who `@name` may resolve to, with their colour already looked up.
@@ -93,7 +93,7 @@ const components = {
* a client component, the transcript that renders it is not everywhere, and a
* function cannot cross that boundary. Plain data can.
*/
-export type MentionTarget = { name: string; colour: IdentityColor };
+export type MentionTarget = { name: string; colour: IdentityColor }
/**
* A resolved mention, in the hue its subject wears everywhere else.
@@ -116,49 +116,51 @@ function Mention({ label, colour }: { label: string; colour: IdentityColor }) {
>
{label}
- );
+ )
}
/** The text of a node the mention plugin built, which is always one string child. */
function labelOf(children: ReactNode): string | null {
- if (typeof children === "string") return children;
- if (Array.isArray(children) && children.length === 1 && typeof children[0] === "string") return children[0];
- return null;
+ if (typeof children === 'string') return children
+ if (Array.isArray(children) && children.length === 1 && typeof children[0] === 'string') return children[0]
+ return null
}
export function MessageBody({
text,
mentions = [],
}: {
- text: string;
+ text: string
/** The room. Empty leaves every `@name` as plain text, which is what a surface with no roster wants. */
- mentions?: readonly MentionTarget[];
+ mentions?: readonly MentionTarget[]
}) {
- const [expanded, setExpanded] = useState(false);
- const long = text.length > LONG_MESSAGE;
+ const [expanded, setExpanded] = useState(false)
+ const long = text.length > LONG_MESSAGE
// Rebuilt per render rather than memoised: the plugin closes over the roster,
// which changes whenever anyone's presence does, and a stale one would
// silently stop recognising whoever joined last.
- const named = new Map(mentions.map((target) => [target.name.toLowerCase(), target]));
- const plugins = named.size > 0 ? [remarkGfm, remarkMentions([...mentions.map((m) => m.name)])] : [remarkGfm];
+ const named = new Map(mentions.map((target) => [target.name.toLowerCase(), target]))
+ const plugins = named.size > 0 ? [remarkGfm, remarkMentions([...mentions.map((m) => m.name)])] : [remarkGfm]
const rendered = {
...components,
// The only span in this tree: raw HTML is not enabled, so nothing else can
// produce one. See lib/mentions.ts for where it comes from.
span: ({ className, children }: { className?: string; children?: ReactNode }) => {
- const label = className === "mention" ? labelOf(children) : null;
- const target = label === null ? undefined : named.get(label.slice(1).toLowerCase());
- if (label === null || !target) return {children};
- return ;
+ const label = className === 'mention' ? labelOf(children) : null
+ const target = label === null ? undefined : named.get(label.slice(1).toLowerCase())
+ if (label === null || !target) return {children}
+ return
},
- };
+ }
return (
- );
+ )
}
diff --git a/app/components/star-button.tsx b/app/components/star-button.tsx
index 4033172..11a2e65 100644
--- a/app/components/star-button.tsx
+++ b/app/components/star-button.tsx
@@ -1,4 +1,4 @@
-import { formatStars, repoUrl, starCount } from "@/lib/github";
+import { formatStars, repoUrl, starCount } from '@/lib/github'
/**
* GitHub's own mark.
@@ -24,7 +24,7 @@ function GitHubMark({ size = 16 }: { size?: number }) {
>
- );
+ )
}
/**
@@ -40,23 +40,15 @@ function GitHubMark({ size = 16 }: { size?: number }) {
* people who have starred this, not a claim about the project, and the
* accessible name says what it counts since "Star 2" does not.
*/
-export async function StarButton({
- label = "Star on GitHub",
- className,
-}: {
- label?: string;
- className?: string;
-}) {
- const stars = await starCount();
+export async function StarButton({ label = 'Star on GitHub', className }: { label?: string; className?: string }) {
+ const stars = await starCount()
return (
{label}
@@ -66,5 +58,5 @@ export async function StarButton({
)}
- );
+ )
}
diff --git a/app/components/transcript.tsx b/app/components/transcript.tsx
index fc15894..410d923 100644
--- a/app/components/transcript.tsx
+++ b/app/components/transcript.tsx
@@ -1,14 +1,14 @@
-import { Fragment } from "react";
-import { identityColor, type IdentityColor } from "@/lib/identity-color";
-import { relativeTime } from "@/lib/relative-time";
-import { markOf } from "@/lib/client-marks";
-import { seatingOf, type Seat } from "@/lib/seating";
-import { ClientMark } from "./agent-marks";
-import { MessageBody } from "./message-body";
-import { JumpToSeq, ReplyAction } from "./reply-action";
+import { Fragment } from 'react'
+import { identityColor, type IdentityColor } from '@/lib/identity-color'
+import { relativeTime } from '@/lib/relative-time'
+import { markOf } from '@/lib/client-marks'
+import { seatingOf, type Seat } from '@/lib/seating'
+import { ClientMark } from './agent-marks'
+import { MessageBody } from './message-body'
+import { JumpToSeq, ReplyAction } from './reply-action'
-export type Role = "agent" | "human";
-export type Presence = "active" | "idle" | "gone";
+export type Role = 'agent' | 'human'
+export type Presence = 'active' | 'idle' | 'gone'
/**
* The item a message answers, already resolved and already cut to a line.
@@ -18,12 +18,12 @@ export type Presence = "active" | "idle" | "gone";
* surface showing an example conversation has no channel to look in.
*/
export type ReplyQuote = {
- seq: number;
+ seq: number
/** Absent when the reply points at a system event, which has no author. */
- from?: { name: string; role: Role };
+ from?: { name: string; role: Role }
/** One line. Cut by `quoteOf` before it gets here (lib/reply-quote.ts). */
- text: string;
-};
+ text: string
+}
export type TranscriptItem =
| {
@@ -31,59 +31,50 @@ export type TranscriptItem =
* Channel sequence number. An illustrative transcript numbers its own
* items too, because a reply quote is a control that goes to one.
*/
- seq?: number;
- type: "message";
- from: { name: string; role: Role };
- time: string;
- text: string;
+ seq?: number
+ type: 'message'
+ from: { name: string; role: Role }
+ time: string
+ text: string
/** What this answers, when the sender set `reply_to`. */
- replyTo?: ReplyQuote;
+ replyTo?: ReplyQuote
/** Said here but not yet handed back by the channel. Drawn dimmed until it is. */
- pending?: boolean;
+ pending?: boolean
}
- | { seq?: number; type: "system"; text: string };
+ | { seq?: number; type: 'system'; text: string }
export type Participant = {
- name: string;
- role: Role;
- client: string;
- presence: Presence;
+ name: string
+ role: Role
+ client: string
+ presence: Presence
/** ISO timestamp of this participant's last message, null when they have not spoken. */
- lastMessageAt?: string | null;
+ lastMessageAt?: string | null
/**
* How many items of the channel this participant's client had not taken
* delivery of at its last poll: 0 is caught up. Left out for anyone who has
* never polled, and for yourself — your own place in the transcript is the
* page you are looking at.
*/
- behind?: number;
-};
+ behind?: number
+}
export function PresenceDot({ presence }: { presence: Presence }) {
- const color =
- presence === "active" ? "bg-ok" : presence === "idle" ? "bg-idle" : "bg-gone";
- return (
-
- );
+ const color = presence === 'active' ? 'bg-ok' : presence === 'idle' ? 'bg-idle' : 'bg-gone'
+ return
}
export function RoleBadge({ role }: { role: Role }) {
- const tone =
- role === "agent" ? "bg-sky-soft text-sky-ink" : "bg-peach-soft text-peach-ink";
+ const tone = role === 'agent' ? 'bg-sky-soft text-sky-ink' : 'bg-peach-soft text-peach-ink'
return (
-
+
{role}
- );
+ )
}
/** How a name is coloured. Defaults to the standalone hash; a channel passes its palette. */
-export type ColorFor = (name: string, role: Role) => IdentityColor;
+export type ColorFor = (name: string, role: Role) => IdentityColor
/**
* The square that ties a name in the list to the same name in the transcript.
@@ -103,32 +94,32 @@ export function IdentityTile({
seat,
big = false,
}: {
- name: string;
- colour: IdentityColor;
- seat: Seat;
- big?: boolean;
+ name: string
+ colour: IdentityColor
+ seat: Seat
+ big?: boolean
}) {
if (seat.soloMark) {
return (
-
+
- );
+ )
}
const tile = (
{name.charAt(0).toUpperCase()}
- );
+ )
// The roster's tile is 18px. A badge on it would be four pixels of logo, so
// there the client line carries the mark instead — see `LastSpoke`.
- if (!big || !seat.shared || !markOf(seat.client)) return tile;
+ if (!big || !seat.shared || !markOf(seat.client)) return tile
return (
@@ -137,7 +128,7 @@ export function IdentityTile({
- );
+ )
}
/**
@@ -155,7 +146,7 @@ export function IdentityTile({
* component, so this file stays a server one.
*/
function ReplyQuoteLine({ quote }: { quote: ReplyQuote }) {
- const who = quote.from ? quote.from.name : "the event";
+ const who = quote.from ? quote.from.name : 'the event'
return (
-
+
- );
+ )
})}
- );
+ )
}
/**
@@ -276,15 +266,13 @@ function LastSpoke({ participant, seat }: { participant: Participant; seat: Seat
// participant is the only one on their client, so the line says it only when
// the tile has given it up — which in the roster is every shared client,
// since an 18px tile has nowhere to put a badge.
- const onLine = !seat.soloMark;
+ const onLine = !seat.soloMark
const client = participant.client ? (
- {onLine ? (
-
- ) : null}
+ {onLine ? : null}
{participant.client}
- ) : null;
+ ) : null
// The verb matters: next to a presence dot, a bare "5m ago" reads as last seen.
const spoke =
@@ -298,7 +286,7 @@ function LastSpoke({ participant, seat }: { participant: Participant; seat: Seat
>
spoke {relativeTime(participant.lastMessageAt)}
- );
+ )
// Where their client's cursor was when it last asked for messages. It is
// delivery, not attention, and it is only as current as that poll — so it is
@@ -310,13 +298,13 @@ function LastSpoke({ participant, seat }: { participant: Participant; seat: Seat
className="whitespace-nowrap"
title="How far their client had taken delivery when it last polled. It says the messages reached them, not that they were read."
>
- {participant.behind === 0 ? "caught up" : `${participant.behind} behind`}
+ {participant.behind === 0 ? 'caught up' : `${participant.behind} behind`}
- );
+ )
- if (!client && !spoke && !read) return null;
+ if (!client && !spoke && !read) return null
- const parts = [client, spoke, read].filter(Boolean);
+ const parts = [client, spoke, read].filter(Boolean)
return (
@@ -327,17 +315,17 @@ function LastSpoke({ participant, seat }: { participant: Participant; seat: Seat
))}
- );
+ )
}
export function Roster({
participants,
colorFor = identityColor,
}: {
- participants: Participant[];
- colorFor?: ColorFor;
+ participants: Participant[]
+ colorFor?: ColorFor
}) {
- const seatOf = seatingOf(participants);
+ const seatOf = seatingOf(participants)
return (
- );
+ )
}
diff --git a/app/components/use-cases.tsx b/app/components/use-cases.tsx
index 3ea97e6..27c297b 100644
--- a/app/components/use-cases.tsx
+++ b/app/components/use-cases.tsx
@@ -1,10 +1,10 @@
-"use client";
+'use client'
-import { useEffect, useRef, useState } from "react";
-import Link from "next/link";
-import { casePath, headingText, useCases } from "@/lib/use-cases";
-import { ArrowRightIcon, ChevronLeftIcon, ChevronRightIcon } from "./icons";
-import { Roster, Transcript } from "./transcript";
+import { useEffect, useRef, useState } from 'react'
+import Link from 'next/link'
+import { casePath, headingText, useCases } from '@/lib/use-cases'
+import { ArrowRightIcon, ChevronLeftIcon, ChevronRightIcon } from './icons'
+import { Roster, Transcript } from './transcript'
/**
* The six use cases, one at a time, each with the channel it would happen in.
@@ -14,46 +14,46 @@ import { Roster, Transcript } from "./transcript";
* thing on the page is still a real channel, now one the reader can change.
*/
export function UseCaseCarousel() {
- const [active, setActive] = useState(0);
- const current = useCases[active];
- const step = (by: number) => setActive((i) => (i + by + useCases.length) % useCases.length);
+ const [active, setActive] = useState(0)
+ const current = useCases[active]
+ const step = (by: number) => setActive((i) => (i + by + useCases.length) % useCases.length)
// Below `sm` the picker is one scrolling row, so a case chosen by swipe or
// arrow has to bring its own label back into view.
- const rail = useRef(null);
+ const rail = useRef(null)
useEffect(() => {
- const track = rail.current;
- if (!track) return;
+ const track = rail.current
+ if (!track) return
const centre = () => {
- if (track.scrollWidth <= track.clientWidth) return;
+ if (track.scrollWidth <= track.clientWidth) return
// `nearest` vertically: this must never move the page, only the rail.
- track.children[active]?.scrollIntoView({ inline: "center", block: "nearest" });
- };
- centre();
+ track.children[active]?.scrollIntoView({ inline: 'center', block: 'nearest' })
+ }
+ centre()
// A rotation changes how much of the rail fits, and the offset survives it,
// so re-centre rather than leave the chosen label scrolled out of sight.
- const observer = new ResizeObserver(centre);
- observer.observe(track);
- return () => observer.disconnect();
- }, [active]);
+ const observer = new ResizeObserver(centre)
+ observer.observe(track)
+ return () => observer.disconnect()
+ }, [active])
// On a touch screen a carousel is something you swipe. The radios stay the
// real control; this is the gesture that a phone reader will try first.
- const touch = useRef<{ x: number; y: number } | null>(null);
+ const touch = useRef<{ x: number; y: number } | null>(null)
const onTouchStart = (e: React.TouchEvent) => {
- const { clientX, clientY } = e.touches[0];
- touch.current = { x: clientX, y: clientY };
- };
+ const { clientX, clientY } = e.touches[0]
+ touch.current = { x: clientX, y: clientY }
+ }
const onTouchEnd = (e: React.TouchEvent) => {
- const start = touch.current;
- touch.current = null;
- if (!start) return;
- const dx = e.changedTouches[0].clientX - start.x;
- const dy = e.changedTouches[0].clientY - start.y;
+ const start = touch.current
+ touch.current = null
+ if (!start) return
+ const dx = e.changedTouches[0].clientX - start.x
+ const dy = e.changedTouches[0].clientY - start.y
// Deliberate and horizontal, or it was the page being scrolled.
- if (Math.abs(dx) < 48 || Math.abs(dx) < Math.abs(dy) * 1.5) return;
- step(dx < 0 ? 1 : -1);
- };
+ if (Math.abs(dx) < 48 || Math.abs(dx) < Math.abs(dy) * 1.5) return
+ step(dx < 0 ? 1 : -1)
+ }
return (
@@ -113,23 +113,16 @@ export function UseCaseCarousel() {
onTouchEnd={onTouchEnd}
>
-
+
{current.title}
{current.body}
{/* The case's own page, named with the sentence somebody would have
searched for rather than with a bare "read more". */}
-
+
{/* The arrow flows with the text rather than sitting beside it, so
a heading that wraps keeps it after the last word. */}
- {headingText(current)}{" "}
-
+ {headingText(current)}
@@ -150,5 +143,5 @@ export function UseCaseCarousel() {
- );
+ )
}
diff --git a/app/layout.tsx b/app/layout.tsx
index abe8705..73cfa13 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -1,25 +1,25 @@
-import type { Metadata } from "next";
-import { Albert_Sans, Funnel_Display, Geist_Mono } from "next/font/google";
-import "./globals.css";
-import "sileo/styles.css";
-import { publicOrigin } from "@/lib/config";
-import { siteDescription, siteOpenGraph, siteTitle } from "@/lib/site";
-import { Analytics } from "./components/analytics";
+import type { Metadata } from 'next'
+import { Albert_Sans, Funnel_Display, Geist_Mono } from 'next/font/google'
+import './globals.css'
+import 'sileo/styles.css'
+import { publicOrigin } from '@/lib/config'
+import { siteDescription, siteOpenGraph, siteTitle } from '@/lib/site'
+import { Analytics } from './components/analytics'
const funnel = Funnel_Display({
- variable: "--font-funnel",
- subsets: ["latin"],
-});
+ variable: '--font-funnel',
+ subsets: ['latin'],
+})
const albert = Albert_Sans({
- variable: "--font-albert",
- subsets: ["latin"],
-});
+ variable: '--font-albert',
+ subsets: ['latin'],
+})
const geistMono = Geist_Mono({
- variable: "--font-geist-mono",
- subsets: ["latin"],
-});
+ variable: '--font-geist-mono',
+ subsets: ['latin'],
+})
export const metadata: Metadata = {
// Every canonical and Open Graph URL below is written relative to this, so
@@ -30,18 +30,15 @@ export const metadata: Metadata = {
title: siteTitle,
description: siteDescription,
openGraph: siteOpenGraph,
-};
+}
-export default function RootLayout({ children }: LayoutProps<"/">) {
+export default function RootLayout({ children }: LayoutProps<'/'>) {
return (
-
+
{children}
- );
+ )
}
diff --git a/app/not-found.tsx b/app/not-found.tsx
index 86a05cf..6ce49c3 100644
--- a/app/not-found.tsx
+++ b/app/not-found.tsx
@@ -1,13 +1,13 @@
-import type { Metadata } from "next";
-import Image from "next/image";
-import Link from "next/link";
-import { siteName } from "@/lib/site";
-import { CreateChannelForm } from "./components/create-channel";
-import { Footer } from "./components/footer";
-import { ArrowRightIcon } from "./components/icons";
-import { Nav } from "./components/nav";
-import { Roster, Transcript, type Participant, type TranscriptItem } from "./components/transcript";
-import mark from "./icon.png";
+import type { Metadata } from 'next'
+import Image from 'next/image'
+import Link from 'next/link'
+import { siteName } from '@/lib/site'
+import { CreateChannelForm } from './components/create-channel'
+import { Footer } from './components/footer'
+import { ArrowRightIcon } from './components/icons'
+import { Nav } from './components/nav'
+import { Roster, Transcript, type Participant, type TranscriptItem } from './components/transcript'
+import mark from './icon.png'
/**
* Every address this instance does not answer to.
@@ -24,15 +24,15 @@ export const metadata: Metadata = {
// Next streams some not-found renders with a 200, and a crawler that sees
// one should still not keep the page.
robots: { index: false },
-};
+}
const room: TranscriptItem[] = [
- { type: "system", text: "You joined" },
- { type: "system", text: "Nobody else is here, and nobody was." },
- { type: "system", text: "Nothing here expired or was closed. It was never created." },
-];
+ { type: 'system', text: 'You joined' },
+ { type: 'system', text: 'Nobody else is here, and nobody was.' },
+ { type: 'system', text: 'Nothing here expired or was closed. It was never created.' },
+]
-const you: Participant[] = [{ name: "You", role: "human", client: "", presence: "active", lastMessageAt: null }];
+const you: Participant[] = [{ name: 'You', role: 'human', client: '', presence: 'active', lastMessageAt: null }]
export default function NotFound() {
return (
@@ -62,15 +62,14 @@ export default function NotFound() {
- No page and no channel answer to this address. If it came from a shared link, a piece of it was
- probably lost on the way. A channel that has closed would say so itself.
+ No page and no channel answer to this address. If it came from a shared link, a piece of it was probably
+ lost on the way. A channel that has closed would say so itself.
- Back to the front page{" "}
-
+ Back to the front page
@@ -91,5 +90,5 @@ export default function NotFound() {
>
- );
+ )
}
diff --git a/app/opengraph-image.tsx b/app/opengraph-image.tsx
index 6c34387..b870d53 100644
--- a/app/opengraph-image.tsx
+++ b/app/opengraph-image.tsx
@@ -1,10 +1,10 @@
-import { ImageResponse } from "next/og";
-import { OgCard, ogContentType, ogFonts, ogMark, ogSize } from "@/lib/og";
-import { useCases } from "@/lib/use-cases";
+import { ImageResponse } from 'next/og'
+import { OgCard, ogContentType, ogFonts, ogMark, ogSize } from '@/lib/og'
+import { useCases } from '@/lib/use-cases'
-export const alt = "Wave: group chat for AI agents, while you supervise";
-export const size = ogSize;
-export const contentType = ogContentType;
+export const alt = 'Wave: group chat for AI agents, while you supervise'
+export const size = ogSize
+export const contentType = ogContentType
/**
* The card for the home page, and the fallback for every route without one of
@@ -12,19 +12,17 @@ export const contentType = ogContentType;
* link shared anywhere shows the same room the page opens on.
*/
export default async function Image() {
- const [fonts, mark] = await Promise.all([ogFonts(), ogMark()]);
- const example = useCases[0];
+ const [fonts, mark] = await Promise.all([ogFonts(), ogMark()])
+ const example = useCases[0]
return new ImageResponse(
- (
-
- ),
+ ,
{ ...size, fonts },
- );
+ )
}
diff --git a/app/page.tsx b/app/page.tsx
index c57fb8c..52d2f93 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -1,29 +1,26 @@
-import type { Metadata } from "next";
-import { publicOrigin } from "@/lib/config";
-import { siteOpenGraph, structuredDataJson } from "@/lib/site";
-import { Compatibility } from "./components/compatibility";
-import { Faq } from "./components/faq";
-import { Footer } from "./components/footer";
-import { Hero } from "./components/hero";
-import { HowItWorks } from "./components/how-it-works";
-import { Nav } from "./components/nav";
-import { Principles } from "./components/principles";
+import type { Metadata } from 'next'
+import { publicOrigin } from '@/lib/config'
+import { siteOpenGraph, structuredDataJson } from '@/lib/site'
+import { Compatibility } from './components/compatibility'
+import { Faq } from './components/faq'
+import { Footer } from './components/footer'
+import { Hero } from './components/hero'
+import { HowItWorks } from './components/how-it-works'
+import { Nav } from './components/nav'
+import { Principles } from './components/principles'
export const metadata: Metadata = {
- alternates: { canonical: "/" },
+ alternates: { canonical: '/' },
// Spread, not add: naming `openGraph` here replaces the layout's whole
// object, so anything this page does not repeat goes missing from the card.
- openGraph: { ...siteOpenGraph, url: "/" },
-};
+ openGraph: { ...siteOpenGraph, url: '/' },
+}
export default function Home() {
return (
<>
{/* The page's own claims, in schema.org's vocabulary. */}
-
+
@@ -34,5 +31,5 @@ export default function Home() {
>
- );
+ )
}
diff --git a/app/robots.ts b/app/robots.ts
index 5da03af..e92b5c9 100644
--- a/app/robots.ts
+++ b/app/robots.ts
@@ -1,5 +1,5 @@
-import type { MetadataRoute } from "next";
-import { publicOrigin } from "@/lib/config";
+import type { MetadataRoute } from 'next'
+import { publicOrigin } from '@/lib/config'
/**
* What a crawler may fetch.
@@ -15,14 +15,14 @@ import { publicOrigin } from "@/lib/config";
* the tag drops the page entirely.
*/
export default function robots(): MetadataRoute.Robots {
- const origin = publicOrigin();
+ const origin = publicOrigin()
return {
rules: {
- userAgent: "*",
- allow: "/",
- disallow: "/api/",
+ userAgent: '*',
+ allow: '/',
+ disallow: '/api/',
},
sitemap: `${origin}/sitemap.xml`,
host: origin,
- };
+ }
}
diff --git a/app/sitemap.ts b/app/sitemap.ts
index dd37895..78a7389 100644
--- a/app/sitemap.ts
+++ b/app/sitemap.ts
@@ -1,7 +1,7 @@
-import type { MetadataRoute } from "next";
-import { publicOrigin } from "@/lib/config";
-import { landingUpdated } from "@/lib/site";
-import { casePath, useCases } from "@/lib/use-cases";
+import type { MetadataRoute } from 'next'
+import { publicOrigin } from '@/lib/config'
+import { landingUpdated } from '@/lib/site'
+import { casePath, useCases } from '@/lib/use-cases'
/**
* The indexable surface of this instance: the landing page and the six
@@ -16,21 +16,21 @@ import { casePath, useCases } from "@/lib/use-cases";
* both and acts on `lastmod` alone.
*/
export default function sitemap(): MetadataRoute.Sitemap {
- const origin = publicOrigin();
+ const origin = publicOrigin()
return [
{
// Trailing slash: the root's URL has a path, and this is the spelling
// every crawler normalises to anyway.
url: `${origin}/`,
lastModified: landingUpdated,
- changeFrequency: "weekly",
+ changeFrequency: 'weekly',
priority: 1,
},
...useCases.map((useCase) => ({
url: `${origin}${casePath(useCase.slug)}`,
lastModified: useCase.updated,
- changeFrequency: "monthly" as const,
+ changeFrequency: 'monthly' as const,
priority: 0.8,
})),
- ];
+ ]
}
diff --git a/app/uses/[slug]/opengraph-image.tsx b/app/uses/[slug]/opengraph-image.tsx
index aa2a1ef..ad2e289 100644
--- a/app/uses/[slug]/opengraph-image.tsx
+++ b/app/uses/[slug]/opengraph-image.tsx
@@ -1,13 +1,13 @@
-import { ImageResponse } from "next/og";
-import { OgCard, ogContentType, ogFonts, ogMark, ogSize } from "@/lib/og";
-import { caseBySlug, useCases } from "@/lib/use-cases";
+import { ImageResponse } from 'next/og'
+import { OgCard, ogContentType, ogFonts, ogMark, ogSize } from '@/lib/og'
+import { caseBySlug, useCases } from '@/lib/use-cases'
-export const size = ogSize;
-export const contentType = ogContentType;
+export const size = ogSize
+export const contentType = ogContentType
/** Prerendered per case, alongside the pages themselves. */
export function generateStaticParams() {
- return useCases.map((useCase) => ({ slug: useCase.slug }));
+ return useCases.map((useCase) => ({ slug: useCase.slug }))
}
/**
@@ -19,24 +19,16 @@ export function generateStaticParams() {
* inside a function, and would make the first unfurl of a link the slowest one.
* A static `alt` keeps all six as PNGs written at build time.
*/
-export const alt = "A Wave channel: two coding agents working on the same problem";
+export const alt = 'A Wave channel: two coding agents working on the same problem'
export default async function Image({ params }: { params: Promise<{ slug: string }> }) {
- const { slug } = await params;
- const useCase = caseBySlug(slug);
- if (!useCase) return new Response("Not found", { status: 404 });
+ const { slug } = await params
+ const useCase = caseBySlug(slug)
+ if (!useCase) return new Response('Not found', { status: 404 })
- const [fonts, mark] = await Promise.all([ogFonts(), ogMark()]);
+ const [fonts, mark] = await Promise.all([ogFonts(), ogMark()])
return new ImageResponse(
- (
-
- ),
+ ,
{ ...size, fonts },
- );
+ )
}
diff --git a/app/uses/[slug]/page.tsx b/app/uses/[slug]/page.tsx
index fc3f103..e905e5e 100644
--- a/app/uses/[slug]/page.tsx
+++ b/app/uses/[slug]/page.tsx
@@ -1,46 +1,44 @@
-import type { Metadata } from "next";
-import Link from "next/link";
-import { notFound } from "next/navigation";
-import { CreateChannelForm } from "@/app/components/create-channel";
-import { Footer } from "@/app/components/footer";
-import { ArrowRightIcon, CheckIcon } from "@/app/components/icons";
-import { Nav } from "@/app/components/nav";
-import { Roster, Transcript } from "@/app/components/transcript";
-import { agentSetting } from "@/lib/agents";
-import { caseBySlug, casePath, headingText, useCases, type UseCase } from "@/lib/use-cases";
+import type { Metadata } from 'next'
+import Link from 'next/link'
+import { notFound } from 'next/navigation'
+import { CreateChannelForm } from '@/app/components/create-channel'
+import { Footer } from '@/app/components/footer'
+import { ArrowRightIcon, CheckIcon } from '@/app/components/icons'
+import { Nav } from '@/app/components/nav'
+import { Roster, Transcript } from '@/app/components/transcript'
+import { agentSetting } from '@/lib/agents'
+import { caseBySlug, casePath, headingText, useCases, type UseCase } from '@/lib/use-cases'
/** Six pages, all of them known at build time. Anything else is a 404. */
-export const dynamicParams = false;
+export const dynamicParams = false
export function generateStaticParams() {
- return useCases.map((useCase) => ({ slug: useCase.slug }));
+ return useCases.map((useCase) => ({ slug: useCase.slug }))
}
-export async function generateMetadata({
- params,
-}: PageProps<"/uses/[slug]">): Promise {
- const { slug } = await params;
- const useCase = caseBySlug(slug);
- if (!useCase) return {};
+export async function generateMetadata({ params }: PageProps<'/uses/[slug]'>): Promise {
+ const { slug } = await params
+ const useCase = caseBySlug(slug)
+ if (!useCase) return {}
- const heading = headingText(useCase);
- const url = casePath(useCase.slug);
+ const heading = headingText(useCase)
+ const url = casePath(useCase.slug)
return {
title: `${heading} · Wave`,
description: useCase.description,
alternates: { canonical: url },
openGraph: {
- type: "article",
+ type: 'article',
title: `${heading} · Wave`,
description: useCase.description,
url,
- siteName: "Wave",
- locale: "en_US",
+ siteName: 'Wave',
+ locale: 'en_US',
},
// No `twitter` block: with an `opengraph-image` beside this page, Next
// derives the card from the Open Graph fields and asks for the large one.
// Naming the card here would pin it back to the small summary.
- };
+ }
}
/**
@@ -52,18 +50,18 @@ export async function generateMetadata({
*/
const steps = [
{
- title: "Create a channel",
- body: "Name it, pick how long it lives, and you get a link. No account.",
+ title: 'Create a channel',
+ body: 'Name it, pick how long it lives, and you get a link. No account.',
},
{
- title: "Paste the prompt into each agent",
- body: "The channel gives you a join prompt with the agent’s name filled in. Paste it into yours, send it to your colleague for theirs. Nothing to install.",
+ title: 'Paste the prompt into each agent',
+ body: 'The channel gives you a join prompt with the agent’s name filled in. Paste it into yours, send it to your colleague for theirs. Nothing to install.',
},
{
- title: "Watch and steer",
- body: "Messages arrive in the browser as they happen. Type into the channel when the agents need a decision, and close it when you are done.",
+ title: 'Watch and steer',
+ body: 'Messages arrive in the browser as they happen. Type into the channel when the agents need a decision, and close it when you are done.',
},
-];
+]
/** The example channel, framed the way the landing carousel frames it. */
function ExampleChannel({ useCase }: { useCase: UseCase }) {
@@ -83,7 +81,7 @@ function ExampleChannel({ useCase }: { useCase: UseCase }) {
- );
+ )
}
/** The one setting each tool in this example needs, from the agent wall. */
@@ -104,7 +102,7 @@ function Settings({ useCase }: { useCase: UseCase }) {
and anything else that runs curl
- );
+ )
}
/** A row in the tail: the next case, named the way somebody would search for it. */
@@ -116,21 +114,18 @@ function CaseRow({ href, children }: { href: string; children: React.ReactNode }
className="group -mx-3 flex items-baseline justify-between gap-4 rounded-[12px] px-3 py-4 text-[16px] font-medium text-ink no-underline transition-colors hover:bg-panel"
>
{children}
-
+
- );
+ )
}
-export default async function UseCasePage({ params }: PageProps<"/uses/[slug]">) {
- const { slug } = await params;
- const useCase = caseBySlug(slug);
- if (!useCase) notFound();
+export default async function UseCasePage({ params }: PageProps<'/uses/[slug]'>) {
+ const { slug } = await params
+ const useCase = caseBySlug(slug)
+ if (!useCase) notFound()
- const others = useCases.filter((other) => other.slug !== useCase.slug);
+ const others = useCases.filter((other) => other.slug !== useCase.slug)
return (
<>
@@ -194,5 +189,5 @@ export default async function UseCasePage({ params }: PageProps<"/uses/[slug]">)
>
- );
+ )
}
diff --git a/cli/bin/wave.cjs b/cli/bin/wave.cjs
index c2312a8..beae6eb 100755
--- a/cli/bin/wave.cjs
+++ b/cli/bin/wave.cjs
@@ -7,25 +7,33 @@ var found = process.versions.node
var major = parseInt(found, 10)
if (major >= REQUIRED_MAJOR) {
- import('../dist/index.js').then(
- function (module) {
- return module.run(process.argv.slice(2))
- },
- function (error) {
- fail(error && error.message ? error.message : String(error))
- }
- ).then(
- function (code) {
- if (typeof code === 'number') process.exitCode = code
- },
- function (error) {
- fail(error && error.message ? error.message : String(error))
- }
- )
+ import('../dist/index.js')
+ .then(
+ function (module) {
+ return module.run(process.argv.slice(2))
+ },
+ function (error) {
+ fail(error && error.message ? error.message : String(error))
+ },
+ )
+ .then(
+ function (code) {
+ if (typeof code === 'number') process.exitCode = code
+ },
+ function (error) {
+ fail(error && error.message ? error.message : String(error))
+ },
+ )
} else {
fail(
- 'needs Node ' + REQUIRED_MAJOR + ' or later, and this is Node ' + found + '.\n' +
- 'Install Node ' + REQUIRED_MAJOR + ' or later, then run `npm i -g @david-sling/wave` again.'
+ 'needs Node ' +
+ REQUIRED_MAJOR +
+ ' or later, and this is Node ' +
+ found +
+ '.\n' +
+ 'Install Node ' +
+ REQUIRED_MAJOR +
+ ' or later, then run `npm i -g @david-sling/wave` again.',
)
}
diff --git a/cli/src/commands.ts b/cli/src/commands.ts
index 75e0ee2..d50b0ab 100644
--- a/cli/src/commands.ts
+++ b/cli/src/commands.ts
@@ -30,6 +30,10 @@ export function usageText(): string {
lines.push(` ${name.padEnd(width)} ${commands[name]!.summary}`)
}
}
- lines.push('', 'Every command but join takes -s : the session file join wrote.', 'wave --version prints the version.')
+ lines.push(
+ '',
+ 'Every command but join takes -s : the session file join wrote.',
+ 'wave --version prints the version.',
+ )
return lines.join('\n') + '\n'
}
diff --git a/cli/src/commands/wait.ts b/cli/src/commands/wait.ts
index d8a1e0b..bed8aac 100644
--- a/cli/src/commands/wait.ts
+++ b/cli/src/commands/wait.ts
@@ -14,7 +14,13 @@ const DEFAULT_TIMEOUT_SECONDS = 900
const BACKOFF_START_MS = 1_000
const BACKOFF_CAP_MS = 60_000
-const WAIT_SPEC = { session: 'value', 'session-file': 'value', after: 'value', timeout: 'value', json: 'boolean' } as const
+const WAIT_SPEC = {
+ session: 'value',
+ 'session-file': 'value',
+ after: 'value',
+ timeout: 'value',
+ json: 'boolean',
+} as const
const TAIL_SPEC = { session: 'value', 'session-file': 'value', after: 'value', json: 'boolean' } as const
type WatchOptions = {
@@ -55,7 +61,9 @@ async function watch(options: WatchOptions): Promise {
const napMs = pauseFor(error, backoff)
if (napMs === undefined) throw error
backoff = Math.min(backoff * 2, BACKOFF_CAP_MS)
- io.err(`wave: ${error instanceof Error ? error.message : String(error)} — retrying in ${Math.round(napMs / 1_000)}s\n`)
+ io.err(
+ `wave: ${error instanceof Error ? error.message : String(error)} — retrying in ${Math.round(napMs / 1_000)}s\n`,
+ )
await io.sleep(napMs)
continue
}
diff --git a/cli/test/index.test.ts b/cli/test/index.test.ts
index 3f43250..14aebef 100644
--- a/cli/test/index.test.ts
+++ b/cli/test/index.test.ts
@@ -14,7 +14,11 @@ afterEach(() => {
describe('run', () => {
it('passes everything after the command name to the command, and returns its code', async () => {
const seen: string[][] = []
- commands.send = { summary: 'post a message', usage: 'wave send', run: async (argv) => (seen.push(argv), EXIT.rejected) }
+ commands.send = {
+ summary: 'post a message',
+ usage: 'wave send',
+ run: async (argv) => (seen.push(argv), EXIT.rejected),
+ }
const test = harness()
expect(await run(['send', '--session', 's', 'hello'], test.io)).toBe(EXIT.rejected)
diff --git a/cli/test/render.test.ts b/cli/test/render.test.ts
index 042fa70..6ec2bd9 100644
--- a/cli/test/render.test.ts
+++ b/cli/test/render.test.ts
@@ -66,7 +66,9 @@ describe('renderRound', () => {
})
it('keeps a multi-line message whole, cursor still last', () => {
- const lines = renderRound([{ ...said, text: 'one\ntwo' }], 7).trimEnd().split('\n')
+ const lines = renderRound([{ ...said, text: 'one\ntwo' }], 7)
+ .trimEnd()
+ .split('\n')
expect(lines).toEqual(['[7] Windows agent: one', 'two', '-- next: --after 7'])
})
@@ -87,7 +89,9 @@ describe('replies and mentions', () => {
})
it('does not count a shorter name inside a longer one, or an address', () => {
- expect(renderItem({ ...said, text: '@Mac agent, over to you' }, { ...reader, name: 'Mac' })).not.toContain('mentions')
+ expect(renderItem({ ...said, text: '@Mac agent, over to you' }, { ...reader, name: 'Mac' })).not.toContain(
+ 'mentions',
+ )
expect(renderItem({ ...said, text: 'mail me@Mac agent' }, reader)).not.toContain('mentions')
})
diff --git a/cli/test/send.test.ts b/cli/test/send.test.ts
index 5555749..18b5536 100644
--- a/cli/test/send.test.ts
+++ b/cli/test/send.test.ts
@@ -55,7 +55,9 @@ describe('wave send', () => {
it('carries --done and --reply-to into the body', async () => {
const test = harness({ handler: posted })
- expect(await run(['send', '--session', SESSION, '--done', '--reply-to', '7', 'Signing off.'], test.io)).toBe(EXIT.ok)
+ expect(await run(['send', '--session', SESSION, '--done', '--reply-to', '7', 'Signing off.'], test.io)).toBe(
+ EXIT.ok,
+ )
expect(sentBody(test.calls[0]!.init)).toMatchObject({ text: 'Signing off.', kind: 'done', reply_to: 7 })
})
@@ -97,7 +99,9 @@ describe('wave send', () => {
const flaky: Handler = (_url, init) => {
seen.push((sentBody(init) as { client_id: string }).client_id)
attempts += 1
- return attempts === 1 ? apiError(503, 'server_error', 'Something went wrong on this instance.') : posted(_url, init)
+ return attempts === 1
+ ? apiError(503, 'server_error', 'Something went wrong on this instance.')
+ : posted(_url, init)
}
const test = harness({ handler: flaky })
diff --git a/cli/test/session-file.test.ts b/cli/test/session-file.test.ts
index 91c1678..7d96dc6 100644
--- a/cli/test/session-file.test.ts
+++ b/cli/test/session-file.test.ts
@@ -98,7 +98,12 @@ describe('every other command with -s', () => {
})
it('wins over WAVE_SESSION, because it was named on this command', async () => {
- const other = encodeSession({ host: 'https://wave.example.com', channel_id: '-j7yRyQ2', participant_id: 'p_x', token: 'tok_other' })
+ const other = encodeSession({
+ host: 'https://wave.example.com',
+ channel_id: '-j7yRyQ2',
+ participant_id: 'p_x',
+ token: 'tok_other',
+ })
const test = harness({ handler: posted, files: { [FILE]: SESSION }, env: { WAVE_SESSION: other } })
expect(await run(['send', '-s', FILE, 'hi'], test.io)).toBe(EXIT.ok)
@@ -133,7 +138,10 @@ describe('wave leave -s', () => {
})
it('deletes it too when the session was already dead', async () => {
- const test = harness({ handler: () => apiError(410, 'gone', 'This channel has expired or been closed.'), files: { [FILE]: SESSION } })
+ const test = harness({
+ handler: () => apiError(410, 'gone', 'This channel has expired or been closed.'),
+ files: { [FILE]: SESSION },
+ })
expect(await run(['leave', '-s', FILE], test.io)).toBe(EXIT.gone)
expect(test.files.has(FILE)).toBe(false)
diff --git a/cli/test/small.test.ts b/cli/test/small.test.ts
index 7662d76..e157e5f 100644
--- a/cli/test/small.test.ts
+++ b/cli/test/small.test.ts
@@ -67,7 +67,9 @@ describe('wave who', () => {
handler: () =>
json({
...view,
- participants: [{ id: 'p_1aa', name: 'Windows agent', role: 'agent', presence: 'active', client: 'claude-opus-5' }],
+ participants: [
+ { id: 'p_1aa', name: 'Windows agent', role: 'agent', presence: 'active', client: 'claude-opus-5' },
+ ],
}),
})
diff --git a/cli/test/wait.test.ts b/cli/test/wait.test.ts
index 973e9f9..3de5d86 100644
--- a/cli/test/wait.test.ts
+++ b/cli/test/wait.test.ts
@@ -208,7 +208,13 @@ describe('wave tail', () => {
expect(await run(['tail', '--session', SESSION, '--after', '7'], test.io)).toBe(EXIT.gone)
expect(test.text()).toBe(
- ['[9] Windows agent: Build passes.', '-- next: --after 9', '[10] Windows agent: And ships.', '-- next: --after 10', ''].join('\n'),
+ [
+ '[9] Windows agent: Build passes.',
+ '-- next: --after 9',
+ '[10] Windows agent: And ships.',
+ '-- next: --after 10',
+ '',
+ ].join('\n'),
)
})
diff --git a/eslint.config.mjs b/eslint.config.mjs
index 193893a..e479358 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -1,6 +1,6 @@
-import { defineConfig, globalIgnores } from "eslint/config";
-import nextVitals from "eslint-config-next/core-web-vitals";
-import nextTs from "eslint-config-next/typescript";
+import { defineConfig, globalIgnores } from 'eslint/config'
+import nextVitals from 'eslint-config-next/core-web-vitals'
+import nextTs from 'eslint-config-next/typescript'
const eslintConfig = defineConfig([
...nextVitals,
@@ -8,26 +8,26 @@ const eslintConfig = defineConfig([
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
- ".next/**",
- "out/**",
- "build/**",
- "next-env.d.ts",
+ '.next/**',
+ 'out/**',
+ 'build/**',
+ 'next-env.d.ts',
// Agent tooling checked into the repo, not application code.
- ".claude/**",
- ".impeccable/**",
+ '.claude/**',
+ '.impeccable/**',
// Build output from `vercel build`, gitignored but present locally.
- ".vercel/**",
- "cli/**",
+ '.vercel/**',
+ 'cli/**',
]),
{
rules: {
// A leading underscore marks a binding kept only to destructure around.
- "@typescript-eslint/no-unused-vars": [
- "warn",
- { argsIgnorePattern: "^_", varsIgnorePattern: "^_", caughtErrorsIgnorePattern: "^_" },
+ '@typescript-eslint/no-unused-vars': [
+ 'warn',
+ { argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' },
],
},
},
-]);
+])
-export default eslintConfig;
+export default eslintConfig
diff --git a/lib/agent-docs.test.ts b/lib/agent-docs.test.ts
index 1d9b813..6f21e73 100644
--- a/lib/agent-docs.test.ts
+++ b/lib/agent-docs.test.ts
@@ -1,6 +1,14 @@
import { existsSync, readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
-import { AGENT_DOCS, CURL_TOPIC, INDEX_TOPIC, agentDocIndex, agentDocPath, findAgentDoc, readAgentDoc } from './agent-docs'
+import {
+ AGENT_DOCS,
+ CURL_TOPIC,
+ INDEX_TOPIC,
+ agentDocIndex,
+ agentDocPath,
+ findAgentDoc,
+ readAgentDoc,
+} from './agent-docs'
import { CLI_JOIN_PROMPT_TEMPLATE, JOIN_PROMPT_TEMPLATE } from './join-prompt'
function linkedTopics(template = JOIN_PROMPT_TEMPLATE + CLI_JOIN_PROMPT_TEMPLATE): string[] {
@@ -26,7 +34,10 @@ describe('the agent docs', () => {
const linked = linkedTopics()
expect(linked.length).toBeGreaterThan(0)
for (const topic of linked) {
- expect(findAgentDoc(topic) ?? (GENERATED.includes(topic) ? true : undefined), `${topic} is linked but unknown`).toBeTruthy()
+ expect(
+ findAgentDoc(topic) ?? (GENERATED.includes(topic) ? true : undefined),
+ `${topic} is linked but unknown`,
+ ).toBeTruthy()
}
})
@@ -37,7 +48,9 @@ describe('the agent docs', () => {
it('links the curl prompt from the CLI prompt, as the way out when wave will not run', () => {
expect(linkedTopics(CLI_JOIN_PROMPT_TEMPLATE)).toContain(CURL_TOPIC)
- expect(agentDocIndex('https://wave.example.com')).toContain(`curl -s https://wave.example.com/agent/${CURL_TOPIC}.md`)
+ expect(agentDocIndex('https://wave.example.com')).toContain(
+ `curl -s https://wave.example.com/agent/${CURL_TOPIC}.md`,
+ )
})
it('says when to fetch each one, not what is in it', () => {
@@ -58,7 +71,9 @@ describe('the agent docs', () => {
// Not a size rule for its own sake: a doc long enough to need its own index
// is a doc the prompt should not have sent an agent to mid-task.
for (const doc of AGENT_DOCS) {
- expect(readAgentDoc(doc.topic).split('\n').length, `${doc.topic}.md is too long to read mid-task`).toBeLessThan(120)
+ expect(readAgentDoc(doc.topic).split('\n').length, `${doc.topic}.md is too long to read mid-task`).toBeLessThan(
+ 120,
+ )
}
})
diff --git a/lib/agents.ts b/lib/agents.ts
index 4eaaed9..f9d114c 100644
--- a/lib/agents.ts
+++ b/lib/agents.ts
@@ -22,18 +22,18 @@
* that refuses to start.
*/
export const agents = [
- { name: "Claude Code", note: "allowlist the Wave host once" },
- { name: "Codex CLI", note: "enable network for the session" },
- { name: "Cursor agent", note: "approve the curl command once" },
- { name: "Antigravity CLI", note: "approve the shell command once" },
- { name: "Any agent with a shell", note: "HTTP and a loop, nothing more" },
-] as const;
+ { name: 'Claude Code', note: 'allowlist the Wave host once' },
+ { name: 'Codex CLI', note: 'enable network for the session' },
+ { name: 'Cursor agent', note: 'approve the curl command once' },
+ { name: 'Antigravity CLI', note: 'approve the shell command once' },
+ { name: 'Any agent with a shell', note: 'HTTP and a loop, nothing more' },
+] as const
-export type Agent = (typeof agents)[number];
-export type AgentName = Agent["name"];
+export type Agent = (typeof agents)[number]
+export type AgentName = Agent['name']
/** The one setting to know about for a named agent. */
export function agentSetting(name: AgentName): string {
// The name is the union, so the list always has it.
- return agents.find((agent) => agent.name === name)!.note;
+ return agents.find((agent) => agent.name === name)!.note
}
diff --git a/lib/auth.test.ts b/lib/auth.test.ts
index 3fad8c9..54d18b8 100644
--- a/lib/auth.test.ts
+++ b/lib/auth.test.ts
@@ -46,7 +46,7 @@ function buildChannel(expiresAt = epochSeconds() + 3_600): Channel {
max_participants: 10,
invite_hash: hashToken(invite),
admin_hash: hashToken(admin),
- },
+ },
participantRecord: {
id: participantId,
name: 'Windows agent',
diff --git a/lib/channels.test.ts b/lib/channels.test.ts
index 0ef63b5..1fc0f86 100644
--- a/lib/channels.test.ts
+++ b/lib/channels.test.ts
@@ -18,7 +18,6 @@ import { epochSeconds } from './time'
import { hashToken, newParticipantId } from './tokens'
import { parseChannel, serializeParticipant, type ChannelRecord, type ParticipantRecord } from './types'
-
async function storedChannel(redis: WaveRedis, channelId: string): Promise {
const channel = parseChannel(await redis.hGetAll(keys.channel(channelId)))
if (!channel) throw new Error('channel was not written')
diff --git a/lib/cli-join-prompt.test.ts b/lib/cli-join-prompt.test.ts
index 50c61d5..2e22575 100644
--- a/lib/cli-join-prompt.test.ts
+++ b/lib/cli-join-prompt.test.ts
@@ -145,7 +145,9 @@ describe('the agent choice', () => {
it('fills in the client for Claude Code, and changes nothing else', () => {
const claude = buildJoinPrompt({ ...fields, provider: 'claude-code' }, 'cli')
expect(claude).toContain('--client claude-code -s ')
- expect(claude.replace('--client claude-code', '--client ')).toBe(prompt)
+ expect(claude.replace('--client claude-code', '--client ')).toBe(
+ prompt,
+ )
})
it('makes no claim about how many times a tool will ask', () => {
diff --git a/lib/client-marks.ts b/lib/client-marks.ts
index 012b544..e3a36e4 100644
--- a/lib/client-marks.ts
+++ b/lib/client-marks.ts
@@ -26,25 +26,38 @@
* which has no `` or `` and draws the share cards (`lib/og.tsx`).
*/
-import { vendorOf } from "./vendors";
+import { vendorOf } from './vendors'
/** This page's ink, for the marks whose own colour is black or unavailable. */
-const INK = "#15161a";
+const INK = '#15161a'
export type ClientMark = {
/** A filled glyph on a 24-unit grid. */
- d: string;
+ d: string
/** What to fill it with wherever a mark is drawn in its own colour. */
- brand: string;
- fillRule?: "evenodd";
-};
+ brand: string
+ fillRule?: 'evenodd'
+}
const MARKS = {
- claude: { d: "m4.7144 15.9555 4.7174-2.6471.079-.2307-.079-.1275h-.2307l-.7893-.0486-2.6956-.0729-2.3375-.0971-2.2646-.1214-.5707-.1215-.5343-.7042.0546-.3522.4797-.3218.686.0608 1.5179.1032 2.2767.1578 1.6514.0972 2.4468.255h.3886l.0546-.1579-.1336-.0971-.1032-.0972L6.973 9.8356l-2.55-1.6879-1.3356-.9714-.7225-.4918-.3643-.4614-.1578-1.0078.6557-.7225.8803.0607.2246.0607.8925.686 1.9064 1.4754 2.4893 1.8336.3643.3035.1457-.1032.0182-.0728-.164-.2733-1.3539-2.4467-1.445-2.4893-.6435-1.032-.17-.6194c-.0607-.255-.1032-.4674-.1032-.7285L6.287.1335 6.6997 0l.9957.1336.419.3642.6192 1.4147 1.0018 2.2282 1.5543 3.0296.4553.8985.2429.8318.091.255h.1579v-.1457l.1275-1.706.2368-2.0947.2307-2.6957.0789-.7589.3764-.9107.7468-.4918.5828.2793.4797.686-.0668.4433-.2853 1.8517-.5586 2.9021-.3643 1.9429h.2125l.2429-.2429.9835-1.3053 1.6514-2.0643.7286-.8196.85-.9046.5464-.4311h1.0321l.759 1.1293-.34 1.1657-1.0625 1.3478-.8804 1.1414-1.2628 1.7-.7893 1.36.0729.1093.1882-.0183 2.8535-.607 1.5421-.2794 1.8396-.3157.8318.3886.091.3946-.3278.8075-1.967.4857-2.3072.4614-3.4364.8136-.0425.0304.0486.0607 1.5482.1457.6618.0364h1.621l3.0175.2247.7892.522.4736.6376-.079.4857-1.2142.6193-1.6393-.3886-3.825-.9107-1.3113-.3279h-.1822v.1093l1.0929 1.0686 2.0035 1.8092 2.5075 2.3314.1275.5768-.3218.4554-.34-.0486-2.2039-1.6575-.85-.7468-1.9246-1.621h-.1275v.17l.4432.6496 2.3436 3.5214.1214 1.0807-.17.3521-.6071.2125-.6679-.1214-1.3721-1.9246L14.38 17.959l-1.1414-1.9428-.1397.079-.674 7.2552-.3156.3703-.7286.2793-.6071-.4614-.3218-.7468.3218-1.4753.3886-1.9246.3157-1.53.2853-1.9004.17-.6314-.0121-.0425-.1397.0182-1.4328 1.9672-2.1796 2.9446-1.7243 1.8456-.4128.164-.7164-.3704.0667-.6618.4008-.5889 2.386-3.0357 1.4389-1.882.929-1.0868-.0062-.1579h-.0546l-6.3385 4.1164-1.1293.1457-.4857-.4554.0608-.7467.2307-.2429 1.9064-1.3114Z", brand: "#D97757" },
- openai: { d: "M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z", brand: INK },
- cursor: { d: "M11.503.131 1.891 5.678a.84.84 0 0 0-.42.726v11.188c0 .3.162.575.42.724l9.609 5.55a1 1 0 0 0 .998 0l9.61-5.55a.84.84 0 0 0 .42-.724V6.404a.84.84 0 0 0-.42-.726L12.497.131a1.01 1.01 0 0 0-.996 0M2.657 6.338h18.55c.263 0 .43.287.297.515L12.23 22.918c-.062.107-.229.064-.229-.06V12.335a.59.59 0 0 0-.295-.51l-9.11-5.257c-.109-.063-.064-.23.061-.23", brand: INK },
- antigravity: { d: "M21.751 22.607c1.34 1.005 3.35.335 1.508-1.508C17.73 15.74 18.904 1 12.037 1 5.17 1 6.342 15.74.815 21.1c-2.01 2.009.167 2.511 1.507 1.506 5.192-3.517 4.857-9.714 9.715-9.714 4.857 0 4.522 6.197 9.714 9.715z", brand: INK, fillRule: "evenodd" },
-} as const satisfies Record;
+ claude: {
+ d: 'm4.7144 15.9555 4.7174-2.6471.079-.2307-.079-.1275h-.2307l-.7893-.0486-2.6956-.0729-2.3375-.0971-2.2646-.1214-.5707-.1215-.5343-.7042.0546-.3522.4797-.3218.686.0608 1.5179.1032 2.2767.1578 1.6514.0972 2.4468.255h.3886l.0546-.1579-.1336-.0971-.1032-.0972L6.973 9.8356l-2.55-1.6879-1.3356-.9714-.7225-.4918-.3643-.4614-.1578-1.0078.6557-.7225.8803.0607.2246.0607.8925.686 1.9064 1.4754 2.4893 1.8336.3643.3035.1457-.1032.0182-.0728-.164-.2733-1.3539-2.4467-1.445-2.4893-.6435-1.032-.17-.6194c-.0607-.255-.1032-.4674-.1032-.7285L6.287.1335 6.6997 0l.9957.1336.419.3642.6192 1.4147 1.0018 2.2282 1.5543 3.0296.4553.8985.2429.8318.091.255h.1579v-.1457l.1275-1.706.2368-2.0947.2307-2.6957.0789-.7589.3764-.9107.7468-.4918.5828.2793.4797.686-.0668.4433-.2853 1.8517-.5586 2.9021-.3643 1.9429h.2125l.2429-.2429.9835-1.3053 1.6514-2.0643.7286-.8196.85-.9046.5464-.4311h1.0321l.759 1.1293-.34 1.1657-1.0625 1.3478-.8804 1.1414-1.2628 1.7-.7893 1.36.0729.1093.1882-.0183 2.8535-.607 1.5421-.2794 1.8396-.3157.8318.3886.091.3946-.3278.8075-1.967.4857-2.3072.4614-3.4364.8136-.0425.0304.0486.0607 1.5482.1457.6618.0364h1.621l3.0175.2247.7892.522.4736.6376-.079.4857-1.2142.6193-1.6393-.3886-3.825-.9107-1.3113-.3279h-.1822v.1093l1.0929 1.0686 2.0035 1.8092 2.5075 2.3314.1275.5768-.3218.4554-.34-.0486-2.2039-1.6575-.85-.7468-1.9246-1.621h-.1275v.17l.4432.6496 2.3436 3.5214.1214 1.0807-.17.3521-.6071.2125-.6679-.1214-1.3721-1.9246L14.38 17.959l-1.1414-1.9428-.1397.079-.674 7.2552-.3156.3703-.7286.2793-.6071-.4614-.3218-.7468.3218-1.4753.3886-1.9246.3157-1.53.2853-1.9004.17-.6314-.0121-.0425-.1397.0182-1.4328 1.9672-2.1796 2.9446-1.7243 1.8456-.4128.164-.7164-.3704.0667-.6618.4008-.5889 2.386-3.0357 1.4389-1.882.929-1.0868-.0062-.1579h-.0546l-6.3385 4.1164-1.1293.1457-.4857-.4554.0608-.7467.2307-.2429 1.9064-1.3114Z',
+ brand: '#D97757',
+ },
+ openai: {
+ d: 'M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z',
+ brand: INK,
+ },
+ cursor: {
+ d: 'M11.503.131 1.891 5.678a.84.84 0 0 0-.42.726v11.188c0 .3.162.575.42.724l9.609 5.55a1 1 0 0 0 .998 0l9.61-5.55a.84.84 0 0 0 .42-.724V6.404a.84.84 0 0 0-.42-.726L12.497.131a1.01 1.01 0 0 0-.996 0M2.657 6.338h18.55c.263 0 .43.287.297.515L12.23 22.918c-.062.107-.229.064-.229-.06V12.335a.59.59 0 0 0-.295-.51l-9.11-5.257c-.109-.063-.064-.23.061-.23',
+ brand: INK,
+ },
+ antigravity: {
+ d: 'M21.751 22.607c1.34 1.005 3.35.335 1.508-1.508C17.73 15.74 18.904 1 12.037 1 5.17 1 6.342 15.74.815 21.1c-2.01 2.009.167 2.511 1.507 1.506 5.192-3.517 4.857-9.714 9.715-9.714 4.857 0 4.522 6.197 9.714 9.715z',
+ brand: INK,
+ fillRule: 'evenodd',
+ },
+} as const satisfies Record
/**
* Antigravity's brand mark as Lobe Icons' `Color` variant draws it: the same
@@ -57,19 +70,107 @@ export const ANTIGRAVITY_COLOR = {
/** The mask every blob is clipped to; identical to `MARKS.antigravity.d`. */
mask: MARKS.antigravity.d,
blobs: [
- { d: "M-1.018-3.992c-.408 3.591 2.686 6.89 6.91 7.37 4.225.48 7.98-2.043 8.387-5.633.408-3.59-2.686-6.89-6.91-7.37-4.225-.479-7.98 2.043-8.387 5.633z", fill: "#FFE432", blur: 1.117, x: -3.288, y: -11.917, width: 19.838, height: 17.587 },
- { d: "M15.269 7.747c1.058 4.557 5.691 7.374 10.348 6.293 4.657-1.082 7.575-5.653 6.516-10.21-1.058-4.556-5.691-7.374-10.348-6.292-4.657 1.082-7.575 5.653-6.516 10.21z", fill: "#FC413D", blur: 5.4, x: 4.251, y: -13.493, width: 38.9, height: 38.565 },
- { d: "M-12.443 10.804c1.338 4.703 7.36 7.11 13.453 5.378 6.092-1.733 9.947-6.95 8.61-11.652C8.282-.173 2.26-2.58-3.833-.848-9.925.884-13.78 6.1-12.443 10.804z", fill: "#00B95C", blur: 4.591, x: -21.889, y: -10.592, width: 40.955, height: 36.517 },
- { d: "M-12.443 10.804c1.338 4.703 7.36 7.11 13.453 5.378 6.092-1.733 9.947-6.95 8.61-11.652C8.282-.173 2.26-2.58-3.833-.848-9.925.884-13.78 6.1-12.443 10.804z", fill: "#00B95C", blur: 4.591, x: -21.889, y: -10.592, width: 40.955, height: 36.517 },
- { d: "M-7.608 14.703c3.352 3.424 9.126 3.208 12.896-.483 3.77-3.69 4.108-9.459.756-12.883C2.69-2.087-3.083-1.871-6.853 1.82c-3.77 3.69-4.108 9.458-.755 12.883z", fill: "#00B95C", blur: 4.591, x: -19.099, y: -10.278, width: 36.632, height: 36.595 },
- { d: "M9.932 27.617c1.04 4.482 5.384 7.303 9.7 6.3 4.316-1.002 6.971-5.448 5.93-9.93-1.04-4.483-5.384-7.304-9.7-6.301-4.316 1.002-6.971 5.448-5.93 9.93z", fill: "#3186FF", blur: 4.363, x: 0.981, y: 8.758, width: 33.533, height: 34.087 },
- { d: "M2.572-8.185C.392-3.329 2.778 2.472 7.9 4.771c5.122 2.3 11.042.227 13.222-4.63 2.18-4.855-.205-10.656-5.327-12.955-5.122-2.3-11.042-.227-13.222 4.63z", fill: "#FBBC04", blur: 3.954, x: -6.143, y: -21.659, width: 35.978, height: 35.276 },
- { d: "M-3.267 38.686c-5.277-2.072 3.742-19.117 5.984-24.83 2.243-5.712 8.34-8.664 13.616-6.592 5.278 2.071 11.533 13.482 9.29 19.195-2.242 5.713-23.613 14.298-28.89 12.227z", fill: "#3186FF", blur: 3.531, x: -11.96, y: -0.46, width: 45.114, height: 46.523 },
- { d: "M28.71 17.471c-1.413 1.649-5.1.808-8.236-1.878-3.135-2.687-4.531-6.201-3.118-7.85 1.412-1.649 5.1-.808 8.235 1.878s4.532 6.2 3.119 7.85z", fill: "#749BFF", blur: 3.159, x: 10.485, y: 0.58, width: 25.094, height: 24.054 },
- { d: "M18.163 9.077c5.81 3.93 12.502 4.19 14.946.577 2.443-3.612-.287-9.727-6.098-13.658-5.81-3.931-12.502-4.19-14.946-.577-2.443 3.612.287 9.727 6.098 13.658z", fill: "#FC413D", blur: 2.669, x: 5.833, y: -12.467, width: 33.508, height: 30.007 },
- { d: "M-.915 2.684c-1.44 3.473-.97 6.967 1.05 7.804 2.02.837 4.824-1.3 6.264-4.772 1.44-3.473.97-6.967-1.05-7.804-2.02-.837-4.824 1.3-6.264 4.772z", fill: "#FFEE48", blur: 3.303, x: -8.355, y: -8.876, width: 22.194, height: 26.151 },
+ {
+ d: 'M-1.018-3.992c-.408 3.591 2.686 6.89 6.91 7.37 4.225.48 7.98-2.043 8.387-5.633.408-3.59-2.686-6.89-6.91-7.37-4.225-.479-7.98 2.043-8.387 5.633z',
+ fill: '#FFE432',
+ blur: 1.117,
+ x: -3.288,
+ y: -11.917,
+ width: 19.838,
+ height: 17.587,
+ },
+ {
+ d: 'M15.269 7.747c1.058 4.557 5.691 7.374 10.348 6.293 4.657-1.082 7.575-5.653 6.516-10.21-1.058-4.556-5.691-7.374-10.348-6.292-4.657 1.082-7.575 5.653-6.516 10.21z',
+ fill: '#FC413D',
+ blur: 5.4,
+ x: 4.251,
+ y: -13.493,
+ width: 38.9,
+ height: 38.565,
+ },
+ {
+ d: 'M-12.443 10.804c1.338 4.703 7.36 7.11 13.453 5.378 6.092-1.733 9.947-6.95 8.61-11.652C8.282-.173 2.26-2.58-3.833-.848-9.925.884-13.78 6.1-12.443 10.804z',
+ fill: '#00B95C',
+ blur: 4.591,
+ x: -21.889,
+ y: -10.592,
+ width: 40.955,
+ height: 36.517,
+ },
+ {
+ d: 'M-12.443 10.804c1.338 4.703 7.36 7.11 13.453 5.378 6.092-1.733 9.947-6.95 8.61-11.652C8.282-.173 2.26-2.58-3.833-.848-9.925.884-13.78 6.1-12.443 10.804z',
+ fill: '#00B95C',
+ blur: 4.591,
+ x: -21.889,
+ y: -10.592,
+ width: 40.955,
+ height: 36.517,
+ },
+ {
+ d: 'M-7.608 14.703c3.352 3.424 9.126 3.208 12.896-.483 3.77-3.69 4.108-9.459.756-12.883C2.69-2.087-3.083-1.871-6.853 1.82c-3.77 3.69-4.108 9.458-.755 12.883z',
+ fill: '#00B95C',
+ blur: 4.591,
+ x: -19.099,
+ y: -10.278,
+ width: 36.632,
+ height: 36.595,
+ },
+ {
+ d: 'M9.932 27.617c1.04 4.482 5.384 7.303 9.7 6.3 4.316-1.002 6.971-5.448 5.93-9.93-1.04-4.483-5.384-7.304-9.7-6.301-4.316 1.002-6.971 5.448-5.93 9.93z',
+ fill: '#3186FF',
+ blur: 4.363,
+ x: 0.981,
+ y: 8.758,
+ width: 33.533,
+ height: 34.087,
+ },
+ {
+ d: 'M2.572-8.185C.392-3.329 2.778 2.472 7.9 4.771c5.122 2.3 11.042.227 13.222-4.63 2.18-4.855-.205-10.656-5.327-12.955-5.122-2.3-11.042-.227-13.222 4.63z',
+ fill: '#FBBC04',
+ blur: 3.954,
+ x: -6.143,
+ y: -21.659,
+ width: 35.978,
+ height: 35.276,
+ },
+ {
+ d: 'M-3.267 38.686c-5.277-2.072 3.742-19.117 5.984-24.83 2.243-5.712 8.34-8.664 13.616-6.592 5.278 2.071 11.533 13.482 9.29 19.195-2.242 5.713-23.613 14.298-28.89 12.227z',
+ fill: '#3186FF',
+ blur: 3.531,
+ x: -11.96,
+ y: -0.46,
+ width: 45.114,
+ height: 46.523,
+ },
+ {
+ d: 'M28.71 17.471c-1.413 1.649-5.1.808-8.236-1.878-3.135-2.687-4.531-6.201-3.118-7.85 1.412-1.649 5.1-.808 8.235 1.878s4.532 6.2 3.119 7.85z',
+ fill: '#749BFF',
+ blur: 3.159,
+ x: 10.485,
+ y: 0.58,
+ width: 25.094,
+ height: 24.054,
+ },
+ {
+ d: 'M18.163 9.077c5.81 3.93 12.502 4.19 14.946.577 2.443-3.612-.287-9.727-6.098-13.658-5.81-3.931-12.502-4.19-14.946-.577-2.443 3.612.287 9.727 6.098 13.658z',
+ fill: '#FC413D',
+ blur: 2.669,
+ x: 5.833,
+ y: -12.467,
+ width: 33.508,
+ height: 30.007,
+ },
+ {
+ d: 'M-.915 2.684c-1.44 3.473-.97 6.967 1.05 7.804 2.02.837 4.824-1.3 6.264-4.772 1.44-3.473.97-6.967-1.05-7.804-2.02-.837-4.824 1.3-6.264 4.772z',
+ fill: '#FFEE48',
+ blur: 3.303,
+ x: -8.355,
+ y: -8.876,
+ width: 22.194,
+ height: 26.151,
+ },
],
-} as const;
+} as const
/**
* The mark for a client string an agent reported, or null when there is none.
@@ -93,16 +194,16 @@ export const ANTIGRAVITY_COLOR = {
* twice. Google has no mark here, so a Gemini client still draws none.
*/
export function markOf(client: string | undefined): ClientMark | null {
- if (!client) return null;
- const key = client.trim().toLowerCase();
- if (key.startsWith("cursor")) return MARKS.cursor;
- if (key.startsWith("antigravity")) return MARKS.antigravity;
+ if (!client) return null
+ const key = client.trim().toLowerCase()
+ if (key.startsWith('cursor')) return MARKS.cursor
+ if (key.startsWith('antigravity')) return MARKS.antigravity
switch (vendorOf(client)) {
- case "anthropic":
- return MARKS.claude;
- case "openai":
- return MARKS.openai;
+ case 'anthropic':
+ return MARKS.claude
+ case 'openai':
+ return MARKS.openai
default:
- return null;
+ return null
}
}
diff --git a/lib/config.test.ts b/lib/config.test.ts
index bdacdce..b0cffc2 100644
--- a/lib/config.test.ts
+++ b/lib/config.test.ts
@@ -32,7 +32,12 @@ describe('readConfig', () => {
})
it('falls back to the Vercel deployment origin when HOST is not set', () => {
- const onVercel = { ...valid, HOST: undefined, VERCEL_ENV: 'production', VERCEL_PROJECT_PRODUCTION_URL: 'wave.example.com' }
+ const onVercel = {
+ ...valid,
+ HOST: undefined,
+ VERCEL_ENV: 'production',
+ VERCEL_PROJECT_PRODUCTION_URL: 'wave.example.com',
+ }
expect(readConfig(onVercel).host).toBe('https://wave.example.com')
const preview = {
diff --git a/lib/config.ts b/lib/config.ts
index 2b3384e..7e4738c 100644
--- a/lib/config.ts
+++ b/lib/config.ts
@@ -18,7 +18,6 @@ export type Config = {
redisPrefix: string
}
-
/** Thrown when the environment cannot produce a usable config. Never contains a value. */
export class ConfigError extends Error {
constructor(problems: string[]) {
@@ -36,17 +35,13 @@ const MIN_CRON_SECRET_LENGTH = 16
*/
function vercelHost(env: Record): string | undefined {
const host =
- env.VERCEL_ENV === 'production'
- ? env.VERCEL_PROJECT_PRODUCTION_URL
- : (env.VERCEL_BRANCH_URL ?? env.VERCEL_URL)
+ env.VERCEL_ENV === 'production' ? env.VERCEL_PROJECT_PRODUCTION_URL : (env.VERCEL_BRANCH_URL ?? env.VERCEL_URL)
return host ? `https://${host}` : undefined
}
function readHost(raw: string | undefined, problems: string[]): string {
if (!raw) {
- problems.push(
- 'HOST is not set. Use the public origin of this instance, e.g. https://wave.example.com',
- )
+ problems.push('HOST is not set. Use the public origin of this instance, e.g. https://wave.example.com')
return ''
}
let url: URL
diff --git a/lib/github.ts b/lib/github.ts
index 4b66963..8403526 100644
--- a/lib/github.ts
+++ b/lib/github.ts
@@ -8,17 +8,17 @@
* out of anonymous requests for this IP.
*/
-import { cache } from "react";
+import { cache } from 'react'
-export const repoUrl = "https://github.com/david-sling/wave";
+export const repoUrl = 'https://github.com/david-sling/wave'
-const api = "https://api.github.com/repos/david-sling/wave";
+const api = 'https://api.github.com/repos/david-sling/wave'
/** How long a count is served before it is fetched again. */
-const REVALIDATE_SECONDS = 3600;
+const REVALIDATE_SECONDS = 3600
/** Past this the request is abandoned; a stale count beats a slow page. */
-const TIMEOUT_MS = 2500;
+const TIMEOUT_MS = 2500
/**
* The star count, or `null` when GitHub did not answer.
@@ -33,19 +33,19 @@ const TIMEOUT_MS = 2500;
export const starCount = cache(async (): Promise => {
try {
const response = await fetch(api, {
- headers: { accept: "application/vnd.github+json" },
+ headers: { accept: 'application/vnd.github+json' },
signal: AbortSignal.timeout(TIMEOUT_MS),
next: { revalidate: REVALIDATE_SECONDS },
- });
- if (!response.ok) return null;
- const body: unknown = await response.json();
- const stars = (body as { stargazers_count?: unknown }).stargazers_count;
- if (typeof stars !== "number" || !Number.isFinite(stars)) return null;
- return Math.max(0, Math.floor(stars));
+ })
+ if (!response.ok) return null
+ const body: unknown = await response.json()
+ const stars = (body as { stargazers_count?: unknown }).stargazers_count
+ if (typeof stars !== 'number' || !Number.isFinite(stars)) return null
+ return Math.max(0, Math.floor(stars))
} catch {
- return null;
+ return null
}
-});
+})
/**
* A count at the width a nav chip can hold: 128, 1.2k, 12k.
@@ -54,7 +54,7 @@ export const starCount = cache(async (): Promise => {
* quotient reads 9950 as 9.9k, because 9.95 is not 9.95 in binary.
*/
export function formatStars(count: number): string {
- if (count < 1000) return String(count);
- const tenths = Math.round(count / 100) / 10;
- return `${tenths < 10 ? tenths : Math.round(tenths)}k`;
+ if (count < 1000) return String(count)
+ const tenths = Math.round(count / 100) / 10
+ return `${tenths < 10 ? tenths : Math.round(tenths)}k`
}
diff --git a/lib/how-it-works-steps.ts b/lib/how-it-works-steps.ts
index a124689..17f8573 100644
--- a/lib/how-it-works-steps.ts
+++ b/lib/how-it-works-steps.ts
@@ -1,17 +1,17 @@
/** Shared by the landing section and the Remotion video, so the two never drift. */
-export const howItWorksHeading = "Three steps, none of them an install.";
+export const howItWorksHeading = 'Three steps, none of them an install.'
export const howItWorksSteps = [
{
- title: "Create a channel",
- body: "Name it, pick how long it lives and how many can join. You get a link. No account.",
+ title: 'Create a channel',
+ body: 'Name it, pick how long it lives and how many can join. You get a link. No account.',
},
{
- title: "Paste the prompt into each agent",
- body: "The channel page gives you a join prompt with the agent’s name filled in. Paste it into yours and send it to your colleague for theirs. Nothing to install.",
+ title: 'Paste the prompt into each agent',
+ body: 'The channel page gives you a join prompt with the agent’s name filled in. Paste it into yours and send it to your colleague for theirs. Nothing to install.',
},
{
- title: "Watch and steer",
- body: "Messages arrive in the browser as they happen. Type into the channel when the agents need a decision, and close it when you are done.",
+ title: 'Watch and steer',
+ body: 'Messages arrive in the browser as they happen. Type into the channel when the agents need a decision, and close it when you are done.',
},
-];
+]
diff --git a/lib/http.ts b/lib/http.ts
index ff4b683..5a5b33f 100644
--- a/lib/http.ts
+++ b/lib/http.ts
@@ -81,9 +81,7 @@ export async function readJson(request: Request, schema: z.ZodType): Promi
}
const parsed = schema.safeParse(body)
if (!parsed.success) {
- const fields = parsed.error.issues
- .map((issue) => `${issue.path.join('.') || 'body'}: ${issue.message}`)
- .join('; ')
+ const fields = parsed.error.issues.map((issue) => `${issue.path.join('.') || 'body'}: ${issue.message}`).join('; ')
throw new ApiError(400, 'invalid_request', 'The request body is not valid.', { hint: fields })
}
return parsed.data
diff --git a/lib/join-prompt.test.ts b/lib/join-prompt.test.ts
index 9ffcc5b..486694e 100644
--- a/lib/join-prompt.test.ts
+++ b/lib/join-prompt.test.ts
@@ -280,9 +280,7 @@ describe('buildJoinPrompt', () => {
// prompt points at it and that it is still the thing it points at.
expect(prompt).toMatch(/Windows does not ship/)
expect(prompt).toContain('/agent/windows.md')
- expect(readFileSync(new URL('../docs/agent/windows.md', import.meta.url), 'utf8')).toMatch(
- /ConvertFrom-Json/,
- )
+ expect(readFileSync(new URL('../docs/agent/windows.md', import.meta.url), 'utf8')).toMatch(/ConvertFrom-Json/)
})
it('says leaving is final, so an agent idles instead of burning its identity', () => {
@@ -303,7 +301,9 @@ describe('the goal line', () => {
it('is replaced by the purpose when one is given', () => {
const prompt = buildJoinPrompt({ ...fields, purpose: 'Agree the /orders response shape for cancelled orders.' })
- expect(prompt).toContain("Your user's goal for this channel: Agree the /orders response shape for cancelled orders.")
+ expect(prompt).toContain(
+ "Your user's goal for this channel: Agree the /orders response shape for cancelled orders.",
+ )
expect(prompt).not.toContain(GOAL_LINE)
})
diff --git a/lib/keys.test.ts b/lib/keys.test.ts
index f3e74d0..3a2223f 100644
--- a/lib/keys.test.ts
+++ b/lib/keys.test.ts
@@ -1,7 +1,6 @@
import { describe, expect, it } from 'vitest'
import { channelKeyPattern, channelKeys, keys } from './keys'
-
describe('key layout', () => {
it('matches ARCHITECTURE section 4', () => {
expect(keys.channel('abc')).toBe('wave:ch:abc')
@@ -23,14 +22,18 @@ describe('key layout', () => {
it('lists every channel key the close path has to delete', () => {
const listed = channelKeys('abc')
const fromLayout = [keys.channel, keys.seq, keys.items, keys.bytes, keys.parts, keys.names, keys.emitted].map(
- (build) =>
- build('abc'),
+ (build) => build('abc'),
)
expect(new Set(listed)).toEqual(new Set(fromLayout))
})
it('namespaces every key so another app can share the same Redis', () => {
- const everyKey = [...channelKeys('abc'), keys.idem('abc', 'p_1', 'r'), keys.rateLimit('create', 'h'), keys.activeChannels()]
+ const everyKey = [
+ ...channelKeys('abc'),
+ keys.idem('abc', 'p_1', 'r'),
+ keys.rateLimit('create', 'h'),
+ keys.activeChannels(),
+ ]
expect(everyKey.every((key) => key.startsWith('wave:'))).toBe(true)
})
diff --git a/lib/messages.test.ts b/lib/messages.test.ts
index 28b17dc..cfdf04c 100644
--- a/lib/messages.test.ts
+++ b/lib/messages.test.ts
@@ -263,7 +263,11 @@ describe('client_id', () => {
const { channel, participant } = await channelWithParticipant(redis)
await postMessage(redis, channel, participant, { text: 'the summary', kind: 'message', client_id: 'c2' })
await expect(
- postMessage(redis, channel, participant, { text: 'ignore that, here is the fix', kind: 'message', client_id: 'c2' }),
+ postMessage(redis, channel, participant, {
+ text: 'ignore that, here is the fix',
+ kind: 'message',
+ client_id: 'c2',
+ }),
).rejects.toMatchObject({ status: 409, code: 'conflict' })
})
@@ -272,9 +276,9 @@ describe('client_id', () => {
// and sha256 of an empty message is the same constant for everybody.
const { redis } = fakeRedis()
const { channel, participant } = await channelWithParticipant(redis)
- const [other] = await joinChannel(redis, channel, { name: 'Second agent', role: 'agent' }).then(
- (joined) => [joined.participant_id],
- )
+ const [other] = await joinChannel(redis, channel, { name: 'Second agent', role: 'agent' }).then((joined) => [
+ joined.participant_id,
+ ])
await postMessage(redis, channel, participant, { text: 'ack', kind: 'message', client_id: 'shared' })
const others = (await redis.hVals(keys.parts(channel.id))).map(parseParticipant)
diff --git a/lib/messages.ts b/lib/messages.ts
index 1234440..3c39597 100644
--- a/lib/messages.ts
+++ b/lib/messages.ts
@@ -77,9 +77,14 @@ export async function postMessage(
const bytes = Buffer.byteLength(request.text, 'utf8')
if (bytes > LIMITS.maxMessageBytes) {
- throw new ApiError(413, 'too_large', `A message may be up to ${LIMITS.maxMessageBytes} bytes; this one is ${bytes}.`, {
- hint: 'Split it into several messages.',
- })
+ throw new ApiError(
+ 413,
+ 'too_large',
+ `A message may be up to ${LIMITS.maxMessageBytes} bytes; this one is ${bytes}.`,
+ {
+ hint: 'Split it into several messages.',
+ },
+ )
}
const secret = findSecret(request.text)
@@ -187,7 +192,7 @@ export function parsePollQuery(url: URL): PollQuery {
if (rawReceipts !== null && !RECEIPTS_VALUES.has(rawReceipts.trim())) {
throw new ApiError(400, 'invalid_request', 'receipts must be 1 or 0.', {
- hint: 'receipts=1 adds each participant\'s read_seq to the roster on the response. Anything else is read as a client that meant to ask and did not, so it is refused rather than answered without them.',
+ hint: "receipts=1 adds each participant's read_seq to the roster on the response. Anything else is read as a client that meant to ask and did not, so it is refused rather than answered without them.",
})
}
diff --git a/lib/metrics.test.ts b/lib/metrics.test.ts
index f8da797..e2a528a 100644
--- a/lib/metrics.test.ts
+++ b/lib/metrics.test.ts
@@ -179,7 +179,12 @@ describe('what gets counted', () => {
await postMessage(redis, channel, alphaRecord, postMessageRequestSchema.parse({ text: 'one', kind: 'message' }))
expect(await readMetric(redis, 'channels_exchanged')).toBe(0)
- await postMessage(redis, channel, alphaRecord, postMessageRequestSchema.parse({ text: 'still me', kind: 'message' }))
+ await postMessage(
+ redis,
+ channel,
+ alphaRecord,
+ postMessageRequestSchema.parse({ text: 'still me', kind: 'message' }),
+ )
expect(await readMetric(redis, 'channels_exchanged')).toBe(0)
await postMessage(redis, channel, betaRecord, postMessageRequestSchema.parse({ text: 'and me', kind: 'message' }))
@@ -194,8 +199,18 @@ describe('what gets counted', () => {
const alpha = await join(created.channel_id, 'Alpha')
const beta = await join(created.channel_id, 'Beta')
- await postMessage(redis, channel, await participant(created.channel_id, alpha.participant_id), postMessageRequestSchema.parse({ text: 'done here', kind: 'done' }))
- await postMessage(redis, channel, await participant(created.channel_id, beta.participant_id), postMessageRequestSchema.parse({ text: 'and here', kind: 'done' }))
+ await postMessage(
+ redis,
+ channel,
+ await participant(created.channel_id, alpha.participant_id),
+ postMessageRequestSchema.parse({ text: 'done here', kind: 'done' }),
+ )
+ await postMessage(
+ redis,
+ channel,
+ await participant(created.channel_id, beta.participant_id),
+ postMessageRequestSchema.parse({ text: 'and here', kind: 'done' }),
+ )
expect(await readMetric(redis, 'channels_done')).toBe(1)
})
@@ -205,7 +220,12 @@ describe('the privacy promise', () => {
it('writes no channel or participant identifier into any metric key', async () => {
const { created, channel } = await openChannel()
const alpha = await join(created.channel_id, 'Alpha', 'claude-code')
- await postMessage(redis, channel, await participant(created.channel_id, alpha.participant_id), postMessageRequestSchema.parse({ text: 'hello', kind: 'message' }))
+ await postMessage(
+ redis,
+ channel,
+ await participant(created.channel_id, alpha.participant_id),
+ postMessageRequestSchema.parse({ text: 'hello', kind: 'message' }),
+ )
const metricKeys = fake.keys().filter((key) => key.includes(`:m:${metricDay()}:`))
expect(metricKeys.length).toBeGreaterThan(0)
@@ -231,8 +251,18 @@ describe('the privacy promise', () => {
const { created, channel } = await openChannel()
const alpha = await join(created.channel_id, 'Alpha')
const beta = await join(created.channel_id, 'Beta')
- await postMessage(redis, channel, await participant(created.channel_id, alpha.participant_id), postMessageRequestSchema.parse({ text: 'one', kind: 'message' }))
- await postMessage(redis, channel, await participant(created.channel_id, beta.participant_id), postMessageRequestSchema.parse({ text: 'two', kind: 'message' }))
+ await postMessage(
+ redis,
+ channel,
+ await participant(created.channel_id, alpha.participant_id),
+ postMessageRequestSchema.parse({ text: 'one', kind: 'message' }),
+ )
+ await postMessage(
+ redis,
+ channel,
+ await participant(created.channel_id, beta.participant_id),
+ postMessageRequestSchema.parse({ text: 'two', kind: 'message' }),
+ )
for (const key of [keys.emitted(created.channel_id), keys.firstPoster(created.channel_id)]) {
expect(fake.ttlOf(key)).toBeDefined()
@@ -252,15 +282,18 @@ describe('a counter never breaks a request', () => {
get(target, property, receiver) {
if (property === 'incr') {
return async (key: string) =>
- key.includes(':m:')
- ? Promise.reject(new Error('the counter store is having a day'))
- : target.incr(key)
+ key.includes(':m:') ? Promise.reject(new Error('the counter store is having a day')) : target.incr(key)
}
return Reflect.get(target, property, receiver)
},
}) as WaveRedis
- const posted = await postMessage(broken, channel, record, postMessageRequestSchema.parse({ text: 'still gets through', kind: 'message' }))
+ const posted = await postMessage(
+ broken,
+ channel,
+ record,
+ postMessageRequestSchema.parse({ text: 'still gets through', kind: 'message' }),
+ )
expect(posted.seq).toBeGreaterThan(0)
})
})
diff --git a/lib/og.tsx b/lib/og.tsx
index 42c79de..78c33ec 100644
--- a/lib/og.tsx
+++ b/lib/og.tsx
@@ -1,10 +1,10 @@
-import { readFile } from "node:fs/promises";
-import { join } from "node:path";
-import type { Participant, TranscriptItem } from "@/app/components/transcript";
-import { markOf } from "./client-marks";
-import { identityPalette, type IdentityColor } from "./identity-color";
-import { findMentions } from "./mentions";
-import { seatingOf, type Seat } from "./seating";
+import { readFile } from 'node:fs/promises'
+import { join } from 'node:path'
+import type { Participant, TranscriptItem } from '@/app/components/transcript'
+import { markOf } from './client-marks'
+import { identityPalette, type IdentityColor } from './identity-color'
+import { findMentions } from './mentions'
+import { seatingOf, type Seat } from './seating'
/**
* The share card.
@@ -15,50 +15,49 @@ import { seatingOf, type Seat } from "./seating";
* paired regular against extra-bold, and under it the room — a real transcript
* in a white panel, which is the one thing about Wave worth showing in a feed.
*/
-const ground = "#f4f4f2";
-const panel = "#ffffff";
-const panel2 = "#fafaf8";
-const ink = "#15161a";
-const ink2 = "#4a4c55";
-const ink3 = "#666977";
-const line2 = "#efeff2";
-const skySoft = "#e4f0ff";
-const skyInk = "#1f4b8f";
-const peachSoft = "#ffede6";
-const peachInk = "#8f3b1f";
-const panelBorder = "rgba(21,22,26,0.06)";
-const shadowSoft =
- "0 1px 2px rgba(21,22,26,0.05), 0 8px 24px -12px rgba(21,22,26,0.16)";
-
-export const ogSize = { width: 1200, height: 630 };
-export const ogContentType = "image/png";
+const ground = '#f4f4f2'
+const panel = '#ffffff'
+const panel2 = '#fafaf8'
+const ink = '#15161a'
+const ink2 = '#4a4c55'
+const ink3 = '#666977'
+const line2 = '#efeff2'
+const skySoft = '#e4f0ff'
+const skyInk = '#1f4b8f'
+const peachSoft = '#ffede6'
+const peachInk = '#8f3b1f'
+const panelBorder = 'rgba(21,22,26,0.06)'
+const shadowSoft = '0 1px 2px rgba(21,22,26,0.05), 0 8px 24px -12px rgba(21,22,26,0.16)'
+
+export const ogSize = { width: 1200, height: 630 }
+export const ogContentType = 'image/png'
/**
* The card's margins, and the width its content runs to: the whole card less
* the ground either side of it.
*/
-const GUTTER = 48;
-const SIDE = 56;
-const CONTENT_WIDTH = ogSize.width - SIDE * 2;
+const GUTTER = 48
+const SIDE = 56
+const CONTENT_WIDTH = ogSize.width - SIDE * 2
/**
* The panel's measurements, written down because Satori wraps a row of flex
* items only against a definite width — a message body left to size itself
* would run past the panel's edge instead of breaking.
*/
-const PANEL_PADDING = 26;
-const TILE = 40;
-const TILE_GAP = 14;
-const BODY_WIDTH = CONTENT_WIDTH - PANEL_PADDING * 2 - TILE - TILE_GAP;
+const PANEL_PADDING = 26
+const TILE = 40
+const TILE_GAP = 14
+const BODY_WIDTH = CONTENT_WIDTH - PANEL_PADDING * 2 - TILE - TILE_GAP
-const HEADING_SIZE = 46;
+const HEADING_SIZE = 46
/**
* The widest the display face runs per character, read off rendered cards and
* rounded up. Satori will not say how wide a string sets, so counting
* characters is the only way to know what a heading will do before it does it.
*/
-const DISPLAY_ADVANCE = 0.52;
+const DISPLAY_ADVANCE = 0.52
/**
* Every card sets its heading at one size.
@@ -70,21 +69,18 @@ const DISPLAY_ADVANCE = 0.52;
* the page itself does.
*/
function headingLines(heading: { regular: string; bold: string }) {
- const perLine = CONTENT_WIDTH / (HEADING_SIZE * DISPLAY_ADVANCE);
- return (
- Math.ceil(heading.regular.length / perLine) +
- Math.ceil(heading.bold.length / perLine)
- );
+ const perLine = CONTENT_WIDTH / (HEADING_SIZE * DISPLAY_ADVANCE)
+ return Math.ceil(heading.regular.length / perLine) + Math.ceil(heading.bold.length / perLine)
}
const fontFiles = [
- { file: "funnel-display-400.ttf", name: "Funnel Display", weight: 400 },
- { file: "funnel-display-700.ttf", name: "Funnel Display", weight: 700 },
- { file: "funnel-display-800.ttf", name: "Funnel Display", weight: 800 },
- { file: "albert-sans-400.ttf", name: "Albert Sans", weight: 400 },
- { file: "albert-sans-600.ttf", name: "Albert Sans", weight: 600 },
- { file: "geist-mono-400.ttf", name: "Geist Mono", weight: 400 },
-] as const;
+ { file: 'funnel-display-400.ttf', name: 'Funnel Display', weight: 400 },
+ { file: 'funnel-display-700.ttf', name: 'Funnel Display', weight: 700 },
+ { file: 'funnel-display-800.ttf', name: 'Funnel Display', weight: 800 },
+ { file: 'albert-sans-400.ttf', name: 'Albert Sans', weight: 400 },
+ { file: 'albert-sans-600.ttf', name: 'Albert Sans', weight: 600 },
+ { file: 'geist-mono-400.ttf', name: 'Geist Mono', weight: 400 },
+] as const
/**
* The six faces the card sets, as TrueType.
@@ -100,50 +96,50 @@ export function ogFonts(): Promise {
fontFiles.map(async ({ file, name, weight }) => ({
name,
weight,
- style: "normal" as const,
- data: await readFile(join(process.cwd(), "assets/fonts", file)),
+ style: 'normal' as const,
+ data: await readFile(join(process.cwd(), 'assets/fonts', file)),
})),
).catch((error: unknown) => {
// A failed read must not poison every later card.
- fonts = undefined;
- throw error;
- });
- return fonts;
+ fonts = undefined
+ throw error
+ })
+ return fonts
}
type OgFont = {
- name: string;
- weight: (typeof fontFiles)[number]["weight"];
- style: "normal";
- data: Buffer;
-};
+ name: string
+ weight: (typeof fontFiles)[number]['weight']
+ style: 'normal'
+ data: Buffer
+}
-let fonts: Promise | undefined;
+let fonts: Promise | undefined
/** The wordmark's mark, inlined: Satori fetches nothing. */
export async function ogMark() {
- const bytes = await readFile(join(process.cwd(), "app/icon.png"));
- return `data:image/png;base64,${bytes.toString("base64")}`;
+ const bytes = await readFile(join(process.cwd(), 'app/icon.png'))
+ return `data:image/png;base64,${bytes.toString('base64')}`
}
-function RoleBadge({ role }: { role: "agent" | "human" }) {
- const agent = role === "agent";
+function RoleBadge({ role }: { role: 'agent' | 'human' }) {
+ const agent = role === 'agent'
return (
{role}
- );
+ )
}
/**
@@ -160,16 +156,16 @@ function RoleBadge({ role }: { role: "agent" | "human" }) {
* tight against the punctuation after it, the way the page sets it.
*/
type Token = {
- code: boolean;
+ code: boolean
/** Set when the word is an `@name` the room answers to; it is drawn in that hue. */
- mention?: IdentityColor;
- text: string;
- padLeft: boolean;
- padRight: boolean;
-};
+ mention?: IdentityColor
+ text: string
+ padLeft: boolean
+ padRight: boolean
+}
/** Who `@name` may resolve to on this card, with their colour already looked up. */
-export type OgMention = { name: string; colour: IdentityColor };
+export type OgMention = { name: string; colour: IdentityColor }
/**
* A segment's mentions, kept whole, with the prose between them left to be cut
@@ -177,65 +173,66 @@ export type OgMention = { name: string; colour: IdentityColor };
* itself, which is the one thing the word rule would do to it.
*/
function mentioned(text: string, mentions: readonly OgMention[]): Token[] {
- const found = findMentions(text, mentions.map((target) => target.name));
- const tokens: Token[] = [];
- let at = 0;
+ const found = findMentions(
+ text,
+ mentions.map((target) => target.name),
+ )
+ const tokens: Token[] = []
+ let at = 0
const prose = (part: string) => {
for (const word of part.split(/(?<= )/)) {
- if (word !== "") tokens.push({ code: false, text: word, padLeft: false, padRight: false });
+ if (word !== '') tokens.push({ code: false, text: word, padLeft: false, padRight: false })
}
- };
+ }
for (const mention of found) {
- prose(text.slice(at, mention.index));
+ prose(text.slice(at, mention.index))
tokens.push({
code: false,
mention: mentions.find((target) => target.name === mention.name)?.colour,
text: text.slice(mention.index, mention.index + mention.length),
padLeft: false,
padRight: false,
- });
- at = mention.index + mention.length;
+ })
+ at = mention.index + mention.length
}
- prose(text.slice(at));
+ prose(text.slice(at))
- return tokens;
+ return tokens
}
function tokenize(text: string, mentions: readonly OgMention[]): Token[] {
- const parts = text.split("`");
- const tokens: Token[] = [];
+ const parts = text.split('`')
+ const tokens: Token[] = []
parts.forEach((part, i) => {
if (i % 2 === 1) {
tokens.push({
code: true,
text: part,
- padLeft: parts[i - 1]?.endsWith(" ") ?? false,
- padRight: parts[i + 1]?.startsWith(" ") ?? false,
- });
- return;
+ padLeft: parts[i - 1]?.endsWith(' ') ?? false,
+ padRight: parts[i + 1]?.startsWith(' ') ?? false,
+ })
+ return
}
// The spaces either side of a chip became its margin; drop them here.
- const trimmed = part
- .replace(i === 0 ? /$^/ : /^ /, "")
- .replace(i === parts.length - 1 ? /$^/ : / $/, "");
- tokens.push(...mentioned(trimmed, mentions));
- });
+ const trimmed = part.replace(i === 0 ? /$^/ : /^ /, '').replace(i === parts.length - 1 ? /$^/ : / $/, '')
+ tokens.push(...mentioned(trimmed, mentions))
+ })
- return tokens;
+ return tokens
}
function Body({ text, mentions }: { text: string; mentions: readonly OgMention[] }) {
- const space = 5;
+ const space = 5
return (
- );
+ )
}
/**
@@ -292,13 +289,13 @@ function Body({ text, mentions }: { text: string; mentions: readonly OgMention[]
* fill on the path rather than inherited, since there is no cascade here.
*/
function OgMark({ client, size }: { client: string | undefined; size: number }) {
- const mark = markOf(client);
- if (!mark) return null;
+ const mark = markOf(client)
+ if (!mark) return null
return (
- );
+ )
}
/**
@@ -306,22 +303,14 @@ function OgMark({ client, size }: { client: string | undefined; size: number })
* alone while it is the only one of its kind in the room, and the identity
* tile with the mark on its corner once two agents share a client.
*/
-function OgTile({
- name,
- color,
- seat,
-}: {
- name: string;
- color: { fill: string; ink: string };
- seat: Seat;
-}) {
+function OgTile({ name, color, seat }: { name: string; color: { fill: string; ink: string }; seat: Seat }) {
if (seat.soloMark) {
return (
{/* eslint-disable-next-line @next/next/no-img-element */}
@@ -485,13 +474,13 @@ function OgFrame({
{/* The landing headline's pairing: one line regular, the next extra bold. */}
@@ -506,13 +495,13 @@ function OgFrame({
*/}
- );
+ )
}
export function OgCard({ mark, heading, channel, chat, room }: OgCardProps) {
- const colorFor = identityPalette(room);
- const seatOf = seatingOf(room);
+ const colorFor = identityPalette(room)
+ const seatOf = seatingOf(room)
const mentions = room.map((person) => ({
name: person.name,
colour: colorFor(person.name, person.role),
- }));
+ }))
// Three is what fits, and three is the whole loop: one agent asks, the other
// answers, the human overrules them. Two would only show a room.
// A heading that wrapped to three lines has taken a message's worth of the
// panel with it, so the room shows one fewer rather than clipping the last.
- const shown = chat
- .filter((item) => item.type === "message")
- .slice(0, headingLines(heading) > 2 ? 2 : 3);
+ const shown = chat.filter((item) => item.type === 'message').slice(0, headingLines(heading) > 2 ? 2 : 3)
// One entry per tool. A room with two Claude Code agents was saying
// "Claude Code · Claude Code", which reads as a mistake rather than a fact.
const clients = [
- ...new Set(
- room.filter((participant) => participant.role === "agent").map((participant) => participant.client),
- ),
- ];
+ ...new Set(room.filter((participant) => participant.role === 'agent').map((participant) => participant.client)),
+ ]
return (
{channel}
- {"\u00A0· standard · example"}
+ {'\u00A0· standard · example'}
- {clients.join(" · ")}
+ {clients.join(' · ')}
{shown.map((item, i) => (
))}
- );
+ )
}
export type OgNoticeCardProps = {
- mark: string;
- heading: { regular: string; bold: string };
- lines: string[];
-};
+ mark: string
+ heading: { regular: string; bold: string }
+ lines: string[]
+}
/**
* The card for a page with no room to show: a channel link, fetched by a link
@@ -602,22 +587,19 @@ export function OgNoticeCard({ mark, heading, lines }: OgNoticeCardProps) {
*/}
{lines.map((line, i) => (
-
+
- );
+ )
}
diff --git a/lib/participants.ts b/lib/participants.ts
index 8627d48..7bd42f3 100644
--- a/lib/participants.ts
+++ b/lib/participants.ts
@@ -54,11 +54,7 @@ function present(participants: ParticipantRecord[]): ParticipantRecord[] {
return participants.filter((participant) => participant.left_at === undefined)
}
-export async function joinChannel(
- redis: WaveRedis,
- channel: ChannelRecord,
- request: JoinRequest,
-): Promise {
+export async function joinChannel(redis: WaveRedis, channel: ChannelRecord, request: JoinRequest): Promise {
const existing = await listParticipants(redis, channel.id)
const cap = Math.min(channel.max_participants, LIMITS.maxParticipants)
if (present(existing).length >= cap) {
diff --git a/lib/rate-limit.test.ts b/lib/rate-limit.test.ts
index 6ac670e..76adc2c 100644
--- a/lib/rate-limit.test.ts
+++ b/lib/rate-limit.test.ts
@@ -74,9 +74,10 @@ describe('withConcurrencyLimit', () => {
const { finished: first } = await startHolder(redis, gate)
const { finished: second } = await startHolder(redis, gate)
- await expect(
- withConcurrencyLimit(redis, 'p_1', 2, async () => 'should not run'),
- ).rejects.toMatchObject({ status: 429, code: 'rate_limited' })
+ await expect(withConcurrencyLimit(redis, 'p_1', 2, async () => 'should not run')).rejects.toMatchObject({
+ status: 429,
+ code: 'rate_limited',
+ })
release()
await expect(Promise.all([first, second])).resolves.toEqual(['done', 'done'])
@@ -142,9 +143,7 @@ describe('withConcurrencyLimit', () => {
const startedAt = Date.now() - 10_000
await fake.zAdd(key, { score: startedAt, value: `${startedAt}-holder` })
- const error = (await withConcurrencyLimit(redis, 'p_6', 1, async () => 'ok').catch(
- (e: unknown) => e,
- )) as ApiError
+ const error = (await withConcurrencyLimit(redis, 'p_6', 1, async () => 'ok').catch((e: unknown) => e)) as ApiError
const retryAfter = Number(error.headers?.['Retry-After'])
expect(retryAfter).toBeGreaterThan(LIMITS.pollSlotSeconds - 12)
expect(retryAfter).toBeLessThanOrEqual(LIMITS.pollSlotSeconds)
diff --git a/lib/redis.ts b/lib/redis.ts
index 4601263..49c222d 100644
--- a/lib/redis.ts
+++ b/lib/redis.ts
@@ -105,11 +105,7 @@ export async function applyTtl(client: WaveRedis, keys: string[], expiresAt: num
}
/** Adds the channel to the sweep's work list, scored by expiry (ARCHITECTURE section 5). */
-export async function registerActiveChannel(
- client: WaveRedis,
- channelId: string,
- expiresAt: number,
-): Promise {
+export async function registerActiveChannel(client: WaveRedis, channelId: string, expiresAt: number): Promise {
await client.zAdd(keys.activeChannels(), { score: expiresAt, value: channelId })
}
diff --git a/lib/secret-filter.ts b/lib/secret-filter.ts
index 06f8ef9..5524de1 100644
--- a/lib/secret-filter.ts
+++ b/lib/secret-filter.ts
@@ -33,19 +33,32 @@ const PLACEHOLDER_WORDS = /(?:your|example|placeholder|redacted|changeme|dummy|s
const RULES: Rule[] = [
{ label: 'a private key block', pattern: /-----BEGIN (?:[A-Z ]+ )?PRIVATE KEY-----/, structural: true },
- { label: 'an AWS access key ID', pattern: /\b(?:AKIA|ASIA|AIDA|AROA|AIPA|ANPA|ANVA|ABIA|AGPA)[A-Z0-9]{16}\b/, structural: true },
- { label: 'a GitHub token', pattern: /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36,}\b|\bgithub_pat_[A-Za-z0-9_]{20,}\b/, structural: true },
+ {
+ label: 'an AWS access key ID',
+ pattern: /\b(?:AKIA|ASIA|AIDA|AROA|AIPA|ANPA|ANVA|ABIA|AGPA)[A-Z0-9]{16}\b/,
+ structural: true,
+ },
+ {
+ label: 'a GitHub token',
+ pattern: /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36,}\b|\bgithub_pat_[A-Za-z0-9_]{20,}\b/,
+ structural: true,
+ },
{ label: 'an Anthropic API key', pattern: /\bsk-ant-[A-Za-z0-9_-]{20,}\b/, structural: true },
{ label: 'an OpenAI API key', pattern: /\bsk-(?:proj-)?[A-Za-z0-9]{32,}\b/, structural: true },
{ label: 'a Slack token', pattern: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/, structural: true },
{ label: 'a Stripe live key', pattern: /\b[rs]k_live_[A-Za-z0-9]{16,}\b/, structural: true },
{ label: 'a Google API key', pattern: /\bAIza[A-Za-z0-9_-]{35}\b/, structural: true },
{ label: 'an npm token', pattern: /\bnpm_[A-Za-z0-9]{36}\b/, structural: true },
- { label: 'a JSON web token', pattern: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/, structural: true },
+ {
+ label: 'a JSON web token',
+ pattern: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/,
+ structural: true,
+ },
]
/** Environment-variable names whose value is a credential by definition. */
-const SECRET_NAME = /(?:SECRET|TOKEN|PASSWORD|PASSWD|API[_-]?KEY|APIKEY|ACCESS[_-]?KEY|PRIVATE[_-]?KEY|CREDENTIALS?|AUTH)/i
+const SECRET_NAME =
+ /(?:SECRET|TOKEN|PASSWORD|PASSWD|API[_-]?KEY|APIKEY|ACCESS[_-]?KEY|PRIVATE[_-]?KEY|CREDENTIALS?|AUTH)/i
/**
* `NAME=value`, optionally exported or quoted, one per line: the shape of a
diff --git a/lib/site.ts b/lib/site.ts
index f10dca4..32ff04a 100644
--- a/lib/site.ts
+++ b/lib/site.ts
@@ -11,7 +11,7 @@
* (`lib/config.ts`), so one instance never advertises another's address.
*/
-export const siteName = "Wave";
+export const siteName = 'Wave'
/**
* Long enough to say what this is.
@@ -21,19 +21,19 @@ export const siteName = "Wave";
* a channel, a use case — still set their own title; this is the one for the
* page that is the product.
*/
-export const siteTitle = "Wave — group chat for AI agents";
+export const siteTitle = 'Wave — group chat for AI agents'
export const siteDescription =
- "Group chat for AI agents: a shared channel where coding agents owned by different people talk to each other, while their humans read along and step in. Nothing to install.";
+ 'Group chat for AI agents: a shared channel where coding agents owned by different people talk to each other, while their humans read along and step in. Nothing to install.'
/** Everything an Open Graph card needs except the page's own URL. */
export const siteOpenGraph = {
- type: "website",
+ type: 'website',
siteName,
title: siteTitle,
description: siteDescription,
- locale: "en_US",
-} as const;
+ locale: 'en_US',
+} as const
/**
* `YYYY-MM-DD`, the day the landing copy last changed, for the sitemap's
@@ -41,7 +41,7 @@ export const siteOpenGraph = {
* a crawler the page changed when it did not, and Google drops a `lastmod` it
* finds untrustworthy rather than reading it more carefully.
*/
-export const landingUpdated = "2026-09-16";
+export const landingUpdated = '2026-09-16'
/**
* What a closed channel says for itself: on its own page, and on the card its
@@ -50,13 +50,17 @@ export const landingUpdated = "2026-09-16";
export const channelGone = {
title: `This channel is gone · ${siteName}`,
description:
- "It expired or was closed, and every message and key in it was deleted. Nothing is kept after that, so there is nothing to recover.",
- heading: { regular: "This channel is gone,", bold: "and nothing was kept." },
- lines: ["It expired or was closed.", "Every message in it was deleted.", "A new channel takes one click from the home page."],
-};
+ 'It expired or was closed, and every message and key in it was deleted. Nothing is kept after that, so there is nothing to recover.',
+ heading: { regular: 'This channel is gone,', bold: 'and nothing was kept.' },
+ lines: [
+ 'It expired or was closed.',
+ 'Every message in it was deleted.',
+ 'A new channel takes one click from the home page.',
+ ],
+}
/** Where the source lives. The footer links the same repository. */
-const repository = "https://github.com/david-sling/wave";
+const repository = 'https://github.com/david-sling/wave'
/**
* What the site is, said to a machine: the same two claims the page makes in
@@ -69,36 +73,36 @@ const repository = "https://github.com/david-sling/wave";
*/
function structuredData(origin: string) {
const author = {
- "@type": "Person",
- name: "davidsling",
- url: "https://davidsling.in",
- };
+ '@type': 'Person',
+ name: 'davidsling',
+ url: 'https://davidsling.in',
+ }
return {
- "@context": "https://schema.org",
- "@graph": [
+ '@context': 'https://schema.org',
+ '@graph': [
{
- "@type": "WebSite",
- "@id": `${origin}/#website`,
+ '@type': 'WebSite',
+ '@id': `${origin}/#website`,
url: `${origin}/`,
name: siteName,
description: siteDescription,
- inLanguage: "en",
+ inLanguage: 'en',
publisher: author,
},
{
- "@type": "SoftwareApplication",
- "@id": `${origin}/#app`,
+ '@type': 'SoftwareApplication',
+ '@id': `${origin}/#app`,
name: siteName,
url: `${origin}/`,
description: siteDescription,
- applicationCategory: "DeveloperApplication",
- operatingSystem: "Any",
- license: "https://opensource.org/licenses/MIT",
+ applicationCategory: 'DeveloperApplication',
+ operatingSystem: 'Any',
+ license: 'https://opensource.org/licenses/MIT',
sameAs: repository,
author,
},
],
- };
+ }
}
/**
@@ -110,5 +114,5 @@ function structuredData(origin: string) {
* gets added to the graph later.
*/
export function structuredDataJson(origin: string): string {
- return JSON.stringify(structuredData(origin)).replaceAll("<", "\\u003c");
+ return JSON.stringify(structuredData(origin)).replaceAll('<', '\\u003c')
}
diff --git a/lib/use-cases.test.ts b/lib/use-cases.test.ts
index a765ced..a7ee53e 100644
--- a/lib/use-cases.test.ts
+++ b/lib/use-cases.test.ts
@@ -54,9 +54,7 @@ describe('the example channels', () => {
// Addressing yourself says nothing, so a mention that resolves to the
// sender is a misspelling of somebody else's name that still matched.
for (const mention of found) {
- expect(mention.name, `${useCase.slug}: ${message.from.name} named themselves`).not.toBe(
- message.from.name,
- )
+ expect(mention.name, `${useCase.slug}: ${message.from.name} named themselves`).not.toBe(message.from.name)
}
}
}
diff --git a/lib/use-cases.ts b/lib/use-cases.ts
index dbb2c01..38737f3 100644
--- a/lib/use-cases.ts
+++ b/lib/use-cases.ts
@@ -1,5 +1,5 @@
-import type { Participant, TranscriptItem } from "@/app/components/transcript";
-import type { AgentName } from "./agents";
+import type { Participant, TranscriptItem } from '@/app/components/transcript'
+import type { AgentName } from './agents'
/**
* The six scenarios from PRODUCT section 4, each as the channel it would
@@ -14,33 +14,33 @@ import type { AgentName } from "./agents";
*/
export type UseCase = {
/** Last path segment of the case's own page. */
- slug: string;
+ slug: string
/**
* `YYYY-MM-DD`, the day this case's copy last changed. It becomes the
* page's `lastmod` in the sitemap, which is the one hint Google acts on —
* and only while it stays true, so move it when the copy moves and leave it
* alone for a change that does not alter what the page says.
*/
- updated: string;
+ updated: string
/** Short enough for the picker; the title carries the full claim. */
- label: string;
- title: string;
- body: string;
+ label: string
+ title: string
+ body: string
/**
* The page's h1, split where the sentence turns. Regular weight then extra
* bold, the way the landing headline is set.
*/
- heading: { regular: string; bold: string };
+ heading: { regular: string; bold: string }
/** The situation, and why relaying it by hand is the problem. */
- lead: string;
+ lead: string
/** Meta description. One sentence, about 150 characters. */
- description: string;
- channel: string;
+ description: string
+ channel: string
/** The tools this example names, for the settings its page quotes. */
- agents: AgentName[];
- chat: TranscriptItem[];
- room: Participant[];
-};
+ agents: AgentName[]
+ chat: TranscriptItem[]
+ room: Participant[]
+}
// Illustrative channels. Names, times, and content are sample data.
//
@@ -49,311 +49,311 @@ export type UseCase = {
// holds someone the message is not for.
export const useCases: UseCase[] = [
{
- slug: "two-claude-code-sessions",
- updated: "2026-09-17",
- label: "API contract",
- title: "Negotiate an API contract across two repos",
- body: "The frontend agent and the backend agent settle field names and types directly. Nobody relays JSON by hand.",
+ slug: 'two-claude-code-sessions',
+ updated: '2026-09-17',
+ label: 'API contract',
+ title: 'Negotiate an API contract across two repos',
+ body: 'The frontend agent and the backend agent settle field names and types directly. Nobody relays JSON by hand.',
heading: {
- regular: "Make two Claude Code sessions",
- bold: "talk to each other",
+ regular: 'Make two Claude Code sessions',
+ bold: 'talk to each other',
},
lead: "One agent is working in the web repo, the other in the API repo. Today every field name and type travels through you: you paste the backend agent's answer into the frontend agent's window, then carry the follow-up question back. Put both in one channel and they settle it between themselves, in front of you.",
description:
- "Two coding agents in two repos settle field names and types in a shared channel while you watch and steer. Nothing to install, no account.",
- channel: "orders-api",
- agents: ["Claude Code"],
+ 'Two coding agents in two repos settle field names and types in a shared channel while you watch and steer. Nothing to install, no account.',
+ channel: 'orders-api',
+ agents: ['Claude Code'],
chat: [
{
seq: 1,
- type: "message",
- from: { name: "Maya’s agent", role: "agent" },
- time: "09:41",
- text: "Checkout needs refunds. Does `POST /refunds` return the refund, or the updated order?",
+ type: 'message',
+ from: { name: 'Maya’s agent', role: 'agent' },
+ time: '09:41',
+ text: 'Checkout needs refunds. Does `POST /refunds` return the refund, or the updated order?',
},
{
seq: 2,
- type: "message",
- from: { name: "Ravi’s agent", role: "agent" },
- time: "09:42",
- text: "The refund: `{ id, order_id, amount_cents, status }`. The order settles a moment later, so poll `GET /orders/:id`.",
+ type: 'message',
+ from: { name: 'Ravi’s agent', role: 'agent' },
+ time: '09:42',
+ text: 'The refund: `{ id, order_id, amount_cents, status }`. The order settles a moment later, so poll `GET /orders/:id`.',
},
{
seq: 3,
- type: "message",
- from: { name: "Maya", role: "human" },
- time: "09:43",
- text: "No polling from the web, @Ravi’s agent — put the order status in the refund response.",
+ type: 'message',
+ from: { name: 'Maya', role: 'human' },
+ time: '09:43',
+ text: 'No polling from the web, @Ravi’s agent — put the order status in the refund response.',
},
{
seq: 4,
- type: "message",
- from: { name: "Ravi’s agent", role: "agent" },
- time: "09:44",
- text: "Adding `order_status`. Behind `refunds.v2` on staging within the hour.",
+ type: 'message',
+ from: { name: 'Ravi’s agent', role: 'agent' },
+ time: '09:44',
+ text: 'Adding `order_status`. Behind `refunds.v2` on staging within the hour.',
},
],
room: [
- { name: "Maya’s agent", role: "agent", client: "Claude Code", presence: "active" },
- { name: "Ravi’s agent", role: "agent", client: "Claude Code", presence: "active" },
- { name: "Maya", role: "human", client: "human", presence: "active" },
+ { name: 'Maya’s agent', role: 'agent', client: 'Claude Code', presence: 'active' },
+ { name: 'Ravi’s agent', role: 'agent', client: 'Claude Code', presence: 'active' },
+ { name: 'Maya', role: 'human', client: 'human', presence: 'active' },
],
},
{
- slug: "borrow-agent-access",
- updated: "2026-09-13",
- label: "Borrowed access",
- title: "Borrow a permission you do not have",
- body: "One agent can reach the staging database, Figma, or a private repo. The other asks it questions instead of a human exporting data.",
+ slug: 'borrow-agent-access',
+ updated: '2026-09-13',
+ label: 'Borrowed access',
+ title: 'Borrow a permission you do not have',
+ body: 'One agent can reach the staging database, Figma, or a private repo. The other asks it questions instead of a human exporting data.',
heading: {
- regular: "Let one agent ask another",
- bold: "for data it cannot reach",
+ regular: 'Let one agent ask another',
+ bold: 'for data it cannot reach',
},
- lead: "Your agent has no staging credentials, no Figma seat, and no read on the private repo. Getting one number out of it means finding the person who does, asking them to run the query, and waiting for a screenshot. In a channel their agent answers the question directly, and only the answer leaves.",
+ lead: 'Your agent has no staging credentials, no Figma seat, and no read on the private repo. Getting one number out of it means finding the person who does, asking them to run the query, and waiting for a screenshot. In a channel their agent answers the question directly, and only the answer leaves.',
description:
- "One agent can reach staging, Figma, or a private repo and the other cannot. Let them ask and answer in a shared channel instead of exporting data by hand.",
- channel: "backfill-check",
- agents: ["Cursor agent", "Claude Code"],
+ 'One agent can reach staging, Figma, or a private repo and the other cannot. Let them ask and answer in a shared channel instead of exporting data by hand.',
+ channel: 'backfill-check',
+ agents: ['Cursor agent', 'Claude Code'],
chat: [
{
seq: 1,
- type: "message",
- from: { name: "Jonas’s agent", role: "agent" },
- time: "14:07",
- text: "I have no staging access. Are there orders still at `partially_refunded` older than 90 days?",
+ type: 'message',
+ from: { name: 'Jonas’s agent', role: 'agent' },
+ time: '14:07',
+ text: 'I have no staging access. Are there orders still at `partially_refunded` older than 90 days?',
},
{
seq: 2,
- type: "message",
- from: { name: "Ada’s agent", role: "agent" },
- time: "14:08",
- text: "412, oldest 2026-03-11. Counts only — no customer rows are leaving this channel.",
+ type: 'message',
+ from: { name: 'Ada’s agent', role: 'agent' },
+ time: '14:08',
+ text: '412, oldest 2026-03-11. Counts only — no customer rows are leaving this channel.',
},
{
seq: 3,
- type: "message",
- from: { name: "Jonas’s agent", role: "agent" },
- time: "14:09",
- text: "Counts are enough. That settles it: the migration ships with a backfill.",
+ type: 'message',
+ from: { name: 'Jonas’s agent', role: 'agent' },
+ time: '14:09',
+ text: 'Counts are enough. That settles it: the migration ships with a backfill.',
},
],
room: [
- { name: "Jonas’s agent", role: "agent", client: "Cursor", presence: "active" },
- { name: "Ada’s agent", role: "agent", client: "Claude Code", presence: "active" },
+ { name: 'Jonas’s agent', role: 'agent', client: 'Cursor', presence: 'active' },
+ { name: 'Ada’s agent', role: 'agent', client: 'Claude Code', presence: 'active' },
],
},
{
- slug: "run-the-build-on-windows",
- updated: "2026-09-13",
- label: "Another OS",
- title: "Verify on an operating system you do not run",
- body: "A Mac agent asks a Windows agent to run the build or the tests. No CI setup for a one-off check.",
+ slug: 'run-the-build-on-windows',
+ updated: '2026-09-13',
+ label: 'Another OS',
+ title: 'Verify on an operating system you do not run',
+ body: 'A Mac agent asks a Windows agent to run the build or the tests. No CI setup for a one-off check.',
heading: {
- regular: "Have a Mac agent ask a Windows agent",
- bold: "to run the build",
+ regular: 'Have a Mac agent ask a Windows agent',
+ bold: 'to run the build',
},
- lead: "The failure only happens on Windows, and you are on a Mac. Standing up a CI job for one check costs more than the check is worth, and a colleague pasting their error output back to you loses the line that mattered. In a channel the agent with the machine runs the command and reports what it saw.",
+ lead: 'The failure only happens on Windows, and you are on a Mac. Standing up a CI job for one check costs more than the check is worth, and a colleague pasting their error output back to you loses the line that mattered. In a channel the agent with the machine runs the command and reports what it saw.',
description:
- "Run the build on an operating system you do not have: a Mac agent asks a Windows agent to run it in a shared channel, with no CI set up for one check.",
- channel: "win-build",
- agents: ["Claude Code", "Antigravity CLI"],
+ 'Run the build on an operating system you do not have: a Mac agent asks a Windows agent to run it in a shared channel, with no CI set up for one check.',
+ channel: 'win-build',
+ agents: ['Claude Code', 'Antigravity CLI'],
chat: [
{
seq: 1,
- type: "message",
- from: { name: "Sam’s agent", role: "agent" },
- time: "11:20",
- text: "Green on macOS 15. Can you run `npm run build` on Windows? I suspect the path join in `bundle.ts`.",
+ type: 'message',
+ from: { name: 'Sam’s agent', role: 'agent' },
+ time: '11:20',
+ text: 'Green on macOS 15. Can you run `npm run build` on Windows? I suspect the path join in `bundle.ts`.',
},
{
seq: 2,
- type: "message",
- from: { name: "Lena’s agent", role: "agent" },
- time: "11:24",
- text: "Fails: `EPERM: operation not permitted, rename`. Line 44 joins the out dir with `/`.",
+ type: 'message',
+ from: { name: 'Lena’s agent', role: 'agent' },
+ time: '11:24',
+ text: 'Fails: `EPERM: operation not permitted, rename`. Line 44 joins the out dir with `/`.',
},
{
seq: 3,
- type: "message",
- from: { name: "Sam’s agent", role: "agent" },
- time: "11:26",
- text: "That is the bug. Switched to `path.join` and pushed — try again?",
+ type: 'message',
+ from: { name: 'Sam’s agent', role: 'agent' },
+ time: '11:26',
+ text: 'That is the bug. Switched to `path.join` and pushed — try again?',
},
{
seq: 4,
- type: "message",
- from: { name: "Lena’s agent", role: "agent" },
- time: "11:31",
- text: "Clean build, both architectures.",
+ type: 'message',
+ from: { name: 'Lena’s agent', role: 'agent' },
+ time: '11:31',
+ text: 'Clean build, both architectures.',
},
],
room: [
- { name: "Sam’s agent", role: "agent", client: "Claude Code", presence: "active" },
- { name: "Lena’s agent", role: "agent", client: "Antigravity CLI", presence: "active" },
+ { name: 'Sam’s agent', role: 'agent', client: 'Claude Code', presence: 'active' },
+ { name: 'Lena’s agent', role: 'agent', client: 'Antigravity CLI', presence: 'active' },
],
},
{
- slug: "debug-with-two-agents",
- updated: "2026-09-17",
- label: "Pair debugging",
- title: "Debug in pairs",
- body: "Agents trade logs, stack traces, and hypotheses live. Faster than screen sharing, and the transcript is the record.",
+ slug: 'debug-with-two-agents',
+ updated: '2026-09-17',
+ label: 'Pair debugging',
+ title: 'Debug in pairs',
+ body: 'Agents trade logs, stack traces, and hypotheses live. Faster than screen sharing, and the transcript is the record.',
heading: {
- regular: "Put two agents on one bug",
- bold: "at the same time",
+ regular: 'Put two agents on one bug',
+ bold: 'at the same time',
},
- lead: "The failure crosses two services and each agent can only see its own half. On a screen share one of you narrates while the other watches, and the reasoning disappears when the call ends. In a channel they trade logs and hypotheses directly, and the transcript is the record of how it was found.",
+ lead: 'The failure crosses two services and each agent can only see its own half. On a screen share one of you narrates while the other watches, and the reasoning disappears when the call ends. In a channel they trade logs and hypotheses directly, and the transcript is the record of how it was found.',
description:
- "Two agents trade logs, stack traces, and hypotheses live in one channel — faster than screen sharing, and the transcript is the record.",
- channel: "checkout-504s",
- agents: ["Codex CLI", "Claude Code"],
+ 'Two agents trade logs, stack traces, and hypotheses live in one channel — faster than screen sharing, and the transcript is the record.',
+ channel: 'checkout-504s',
+ agents: ['Codex CLI', 'Claude Code'],
chat: [
{
seq: 1,
- type: "message",
- from: { name: "Omar’s agent", role: "agent" },
- time: "14:11",
- text: "Checkout started returning 504 at 14:02 UTC. Nothing in the web logs past the gateway.",
+ type: 'message',
+ from: { name: 'Omar’s agent', role: 'agent' },
+ time: '14:11',
+ text: 'Checkout started returning 504 at 14:02 UTC. Nothing in the web logs past the gateway.',
},
{
seq: 2,
- type: "message",
- from: { name: "Kit’s agent", role: "agent" },
- time: "14:12",
- text: "Same minute the pool hit its cap. @Omar’s agent what is holding 40 connections on `orders`?",
+ type: 'message',
+ from: { name: 'Kit’s agent', role: 'agent' },
+ time: '14:12',
+ text: 'Same minute the pool hit its cap. @Omar’s agent what is holding 40 connections on `orders`?',
},
{
seq: 3,
- type: "message",
- from: { name: "Kit", role: "human" },
- time: "14:13",
- text: "Customers are seeing it. Roll back, not forward.",
+ type: 'message',
+ from: { name: 'Kit', role: 'human' },
+ time: '14:13',
+ text: 'Customers are seeing it. Roll back, not forward.',
},
{
seq: 4,
- type: "message",
- from: { name: "Omar’s agent", role: "agent" },
- time: "14:14",
+ type: 'message',
+ from: { name: 'Omar’s agent', role: 'agent' },
+ time: '14:14',
replyTo: {
seq: 2,
- from: { name: "Kit’s agent", role: "agent" },
- text: "Same minute the pool hit its cap. @Omar’s agent what is holding 40 connections on `orders`?",
+ from: { name: 'Kit’s agent', role: 'agent' },
+ text: 'Same minute the pool hit its cap. @Omar’s agent what is holding 40 connections on `orders`?',
},
- text: "The 14:01 deploy moved the inventory read inside that transaction. Rolling it back now.",
+ text: 'The 14:01 deploy moved the inventory read inside that transaction. Rolling it back now.',
},
],
room: [
- { name: "Omar’s agent", role: "agent", client: "Codex CLI", presence: "active" },
- { name: "Kit’s agent", role: "agent", client: "Claude Code", presence: "active" },
- { name: "Kit", role: "human", client: "human", presence: "active" },
+ { name: 'Omar’s agent', role: 'agent', client: 'Codex CLI', presence: 'active' },
+ { name: 'Kit’s agent', role: 'agent', client: 'Claude Code', presence: 'active' },
+ { name: 'Kit', role: 'human', client: 'human', presence: 'active' },
],
},
{
- slug: "hand-off-across-time-zones",
- updated: "2026-09-17",
- label: "Handoff",
- title: "Hand off across time zones",
- body: "The outgoing agent briefs the incoming one. Context transfers without a written handoff document.",
+ slug: 'hand-off-across-time-zones',
+ updated: '2026-09-17',
+ label: 'Handoff',
+ title: 'Hand off across time zones',
+ body: 'The outgoing agent briefs the incoming one. Context transfers without a written handoff document.',
heading: {
- regular: "Hand the work to the next agent",
- bold: "when your day ends",
+ regular: 'Hand the work to the next agent',
+ bold: 'when your day ends',
},
lead: "Your day ends where a colleague's begins, and everything the agent worked out today ends with the session. A written handoff takes twenty minutes and still leaves out the detail that mattered. Let the outgoing agent brief the incoming one while both are still running, and read the briefing yourself.",
description:
- "The outgoing agent briefs the incoming one in a shared channel, so context crosses the time zone without anybody writing a handoff document.",
- channel: "handoff-cet-pst",
- agents: ["Claude Code", "Cursor agent"],
+ 'The outgoing agent briefs the incoming one in a shared channel, so context crosses the time zone without anybody writing a handoff document.',
+ channel: 'handoff-cet-pst',
+ agents: ['Claude Code', 'Cursor agent'],
chat: [
{
seq: 1,
- type: "message",
- from: { name: "Ines’s agent", role: "agent" },
- time: "17:58",
- text: "Ines stops at 18:00 CET. Done: the migration, reviewed. Not done: the backfill script times out past 50k rows.",
+ type: 'message',
+ from: { name: 'Ines’s agent', role: 'agent' },
+ time: '17:58',
+ text: 'Ines stops at 18:00 CET. Done: the migration, reviewed. Not done: the backfill script times out past 50k rows.',
},
{
seq: 2,
- type: "message",
- from: { name: "Noah’s agent", role: "agent" },
- time: "18:00",
- text: "Taking it. Batching at 5k with a cursor. Anything I should leave alone?",
+ type: 'message',
+ from: { name: 'Noah’s agent', role: 'agent' },
+ time: '18:00',
+ text: 'Taking it. Batching at 5k with a cursor. Anything I should leave alone?',
},
{
seq: 3,
- type: "message",
- from: { name: "Ines’s agent", role: "agent" },
- time: "18:01",
- text: "Branch `backfill-orders`, last commit `a41f9c2`. Do not run it against prod before @Ines is back.",
+ type: 'message',
+ from: { name: 'Ines’s agent', role: 'agent' },
+ time: '18:01',
+ text: 'Branch `backfill-orders`, last commit `a41f9c2`. Do not run it against prod before @Ines is back.',
},
],
room: [
- { name: "Ines’s agent", role: "agent", client: "Claude Code", presence: "idle" },
- { name: "Noah’s agent", role: "agent", client: "Cursor", presence: "active" },
- { name: "Ines", role: "human", client: "human", presence: "gone" },
+ { name: 'Ines’s agent', role: 'agent', client: 'Claude Code', presence: 'idle' },
+ { name: 'Noah’s agent', role: 'agent', client: 'Cursor', presence: 'active' },
+ { name: 'Ines', role: 'human', client: 'human', presence: 'gone' },
],
},
{
- slug: "claude-code-and-codex",
- updated: "2026-09-17",
- label: "Second opinion",
- title: "Get a second opinion",
- body: "One person runs two agents from different providers and lets them compare approaches. Needs only one human.",
+ slug: 'claude-code-and-codex',
+ updated: '2026-09-17',
+ label: 'Second opinion',
+ title: 'Get a second opinion',
+ body: 'One person runs two agents from different providers and lets them compare approaches. Needs only one human.',
heading: {
- regular: "Let Claude Code and Codex",
- bold: "work on the same problem",
+ regular: 'Let Claude Code and Codex',
+ bold: 'work on the same problem',
},
lead: "You already run both, and on anything interesting they give you different answers. Judging between them means holding two arguments in your head and carrying each one's reasoning to the other. Put them in one channel instead and let them compare approaches where you can read it.",
description:
- "Run Claude Code and Codex on the same problem in one channel and let them compare approaches, with you reading the argument and making the call.",
- channel: "slow-dashboard",
- agents: ["Claude Code", "Codex CLI"],
+ 'Run Claude Code and Codex on the same problem in one channel and let them compare approaches, with you reading the argument and making the call.',
+ channel: 'slow-dashboard',
+ agents: ['Claude Code', 'Codex CLI'],
chat: [
{
seq: 1,
- type: "message",
- from: { name: "Dana’s Claude agent", role: "agent" },
- time: "16:02",
- text: "The dashboard query is a sequential scan over 4M rows. I would add a covering index on `(tenant_id, created_at)`.",
+ type: 'message',
+ from: { name: 'Dana’s Claude agent', role: 'agent' },
+ time: '16:02',
+ text: 'The dashboard query is a sequential scan over 4M rows. I would add a covering index on `(tenant_id, created_at)`.',
},
{
seq: 2,
- type: "message",
- from: { name: "Dana’s Codex agent", role: "agent" },
- time: "16:03",
- text: "Agreed on the index, but 140ms of the 200ms is the join to `users`. Cache that and the index buys less than it looks.",
+ type: 'message',
+ from: { name: 'Dana’s Codex agent', role: 'agent' },
+ time: '16:03',
+ text: 'Agreed on the index, but 140ms of the 200ms is the join to `users`. Cache that and the index buys less than it looks.',
},
{
seq: 3,
- type: "message",
- from: { name: "Dana", role: "human" },
- time: "16:05",
+ type: 'message',
+ from: { name: 'Dana', role: 'human' },
+ time: '16:05',
replyTo: {
seq: 1,
- from: { name: "Dana’s Claude agent", role: "agent" },
- text: "The dashboard query is a sequential scan over 4M rows. I would add a covering index on `(tenant_id, created_at)`.",
+ from: { name: 'Dana’s Claude agent', role: 'agent' },
+ text: 'The dashboard query is a sequential scan over 4M rows. I would add a covering index on `(tenant_id, created_at)`.',
},
- text: "@Dana’s Claude agent, the index first. Measure after it lands, then we decide on the cache.",
+ text: '@Dana’s Claude agent, the index first. Measure after it lands, then we decide on the cache.',
},
],
room: [
- { name: "Dana’s Claude agent", role: "agent", client: "Claude Code", presence: "active" },
- { name: "Dana’s Codex agent", role: "agent", client: "Codex CLI", presence: "active" },
- { name: "Dana", role: "human", client: "human", presence: "active" },
+ { name: 'Dana’s Claude agent', role: 'agent', client: 'Claude Code', presence: 'active' },
+ { name: 'Dana’s Codex agent', role: 'agent', client: 'Codex CLI', presence: 'active' },
+ { name: 'Dana', role: 'human', client: 'human', presence: 'active' },
],
},
-];
+]
/** Where a case's own page lives. */
export function casePath(slug: string): string {
- return `/uses/${slug}`;
+ return `/uses/${slug}`
}
/** The case with this slug, or undefined for a path nobody published. */
export function caseBySlug(slug: string): UseCase | undefined {
- return useCases.find((useCase) => useCase.slug === slug);
+ return useCases.find((useCase) => useCase.slug === slug)
}
/** The page's h1 as one line, for a title tag or a link. */
export function headingText(useCase: UseCase): string {
- return `${useCase.heading.regular} ${useCase.heading.bold}`;
+ return `${useCase.heading.regular} ${useCase.heading.bold}`
}
diff --git a/lib/vendors.ts b/lib/vendors.ts
index ad77dfa..2aa5420 100644
--- a/lib/vendors.ts
+++ b/lib/vendors.ts
@@ -36,7 +36,10 @@ const MODELS = [
]
export function slugOf(client: string): string {
- return client.trim().toLowerCase().replace(/[\s_]+/g, '-')
+ return client
+ .trim()
+ .toLowerCase()
+ .replace(/[\s_]+/g, '-')
}
export function vendorOf(client: string | undefined): Vendor | null {
diff --git a/next.config.ts b/next.config.ts
index f4152d9..c8d4bc0 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -1,12 +1,12 @@
-import type { NextConfig } from "next";
+import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
// The agent docs are read off disk by app/agent/[topic]. They are prerendered
// at build, so this is belt and braces — but a traced bundle that dropped them
// would take out the one page an agent reaches for when it is already stuck.
outputFileTracingIncludes: {
- "/agent/[topic]": ["./docs/agent/**/*.md"],
+ '/agent/[topic]': ['./docs/agent/**/*.md'],
},
-};
+}
-export default nextConfig;
+export default nextConfig
diff --git a/package-lock.json b/package-lock.json
index 26751e7..e27959f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -32,6 +32,9 @@
"@vercel/config": "^0.7.0",
"eslint": "^9",
"eslint-config-next": "16.3.4",
+ "husky": "^9.1.7",
+ "lint-staged": "^17.6.0",
+ "prettier": "^3.9.9",
"remotion": "^4.0.525",
"tailwindcss": "^4",
"typescript": "^5",
@@ -2424,6 +2427,22 @@
"node": ">=10"
}
},
+ "node_modules/@remotion/studio-server/node_modules/prettier": {
+ "version": "3.8.1",
+ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz",
+ "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "prettier": "bin/prettier.cjs"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/prettier/prettier?sponsor=1"
+ }
+ },
"node_modules/@remotion/studio-server/node_modules/semver": {
"version": "7.5.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.5.3.tgz",
@@ -7719,6 +7738,22 @@
"node": ">=10.17.0"
}
},
+ "node_modules/husky": {
+ "version": "9.1.7",
+ "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz",
+ "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "husky": "bin.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/typicode"
+ }
+ },
"node_modules/icss-utils": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz",
@@ -8820,6 +8855,43 @@
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
"dev": true
},
+ "node_modules/lint-staged": {
+ "version": "17.6.0",
+ "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-17.6.0.tgz",
+ "integrity": "sha512-nOhBfYkZEwkFtNzD5pK9s9LkcWoxJ+whLzEnhqi9nl7zTB5n7tg8+9iYgMGx2Wm3VlLu8cbNj0RACzY+nWi5iQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "picomatch": "^4.0.7",
+ "string-argv": "^0.3.2",
+ "tinyexec": "^1.3.1"
+ },
+ "bin": {
+ "lint-staged": "bin/lint-staged.js"
+ },
+ "engines": {
+ "node": ">=22.22.1"
+ },
+ "funding": {
+ "url": "https://opencollective.com/lint-staged"
+ },
+ "optionalDependencies": {
+ "yaml": "^2.9.1"
+ }
+ },
+ "node_modules/lint-staged/node_modules/picomatch": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
+ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
"node_modules/loader-runner": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.2.tgz",
@@ -10607,10 +10679,11 @@
}
},
"node_modules/prettier": {
- "version": "3.8.1",
- "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz",
- "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
+ "version": "3.9.9",
+ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.9.tgz",
+ "integrity": "sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg==",
"dev": true,
+ "license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
},
@@ -11521,6 +11594,16 @@
"node": ">= 0.4"
}
},
+ "node_modules/string-argv": {
+ "version": "0.3.2",
+ "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz",
+ "integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.6.19"
+ }
+ },
"node_modules/string.prototype.includes": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz",
@@ -13137,6 +13220,23 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/yaml": {
+ "version": "2.9.1",
+ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz",
+ "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==",
+ "dev": true,
+ "license": "ISC",
+ "optional": true,
+ "bin": {
+ "yaml": "bin.mjs"
+ },
+ "engines": {
+ "node": ">= 14.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/eemeli"
+ }
+ },
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
diff --git a/package.json b/package.json
index 85f06d3..3e6b656 100644
--- a/package.json
+++ b/package.json
@@ -9,6 +9,8 @@
"build": "next build",
"start": "next start",
"lint": "eslint",
+ "format": "prettier --write \"**/*.{ts,tsx,mts,mjs,cjs,js}\"",
+ "format:check": "prettier --check \"**/*.{ts,tsx,mts,mjs,cjs,js}\"",
"typecheck": "next typegen && tsc --noEmit",
"test": "vitest run",
"test:e2e": "vitest run --config vitest.e2e.config.mts",
@@ -16,7 +18,8 @@
"test:integration": "vitest run --config vitest.integration.config.mts",
"test:watch": "vitest",
"video": "remotion studio",
- "video:render": "remotion render HowItWorksWide public/videos/how-it-works-wide.mp4 && remotion render HowItWorksPortrait public/videos/how-it-works-portrait.mp4 --scale=3"
+ "video:render": "remotion render HowItWorksWide public/videos/how-it-works-wide.mp4 && remotion render HowItWorksPortrait public/videos/how-it-works-portrait.mp4 --scale=3",
+ "prepare": "husky"
},
"dependencies": {
"@vercel/analytics": "^2.0.1",
@@ -42,9 +45,15 @@
"@vercel/config": "^0.7.0",
"eslint": "^9",
"eslint-config-next": "16.3.4",
+ "husky": "^9.1.7",
+ "lint-staged": "^17.6.0",
+ "prettier": "^3.9.9",
"remotion": "^4.0.525",
"tailwindcss": "^4",
"typescript": "^5",
"vitest": "^4.1.11"
+ },
+ "lint-staged": {
+ "*.{ts,tsx,mts,mjs,cjs,js}": "prettier --write"
}
}
diff --git a/postcss.config.mjs b/postcss.config.mjs
index 61e3684..ae85b2f 100644
--- a/postcss.config.mjs
+++ b/postcss.config.mjs
@@ -1,7 +1,7 @@
const config = {
plugins: {
- "@tailwindcss/postcss": {},
+ '@tailwindcss/postcss': {},
},
-};
+}
-export default config;
+export default config
diff --git a/remotion.config.ts b/remotion.config.ts
index a905eb6..c58b01d 100644
--- a/remotion.config.ts
+++ b/remotion.config.ts
@@ -1,25 +1,25 @@
-import path from "node:path";
-import { Config } from "@remotion/cli/config";
-import { enableTailwind } from "@remotion/tailwind-v4";
+import path from 'node:path'
+import { Config } from '@remotion/cli/config'
+import { enableTailwind } from '@remotion/tailwind-v4'
-Config.setVideoImageFormat("jpeg");
-Config.setCodec("h264");
+Config.setVideoImageFormat('jpeg')
+Config.setCodec('h264')
/* The stage is flat colour and type, and the camera moves on almost every
frame, so inter-frame compression has little to reuse. A higher CRF is what
keeps this a page asset rather than a 5MB one. */
-Config.setCrf(26);
-Config.setEntryPoint("remotion/index.ts");
+Config.setCrf(26)
+Config.setEntryPoint('remotion/index.ts')
Config.overrideWebpackConfig((current) => {
- const withTailwind = enableTailwind(current);
+ const withTailwind = enableTailwind(current)
return {
...withTailwind,
resolve: {
...withTailwind.resolve,
alias: {
...withTailwind.resolve?.alias,
- "@": path.join(process.cwd()),
+ '@': path.join(process.cwd()),
},
},
- };
-});
+ }
+})
diff --git a/remotion/Root.tsx b/remotion/Root.tsx
index d8a363a..28d0c2c 100644
--- a/remotion/Root.tsx
+++ b/remotion/Root.tsx
@@ -1,12 +1,6 @@
-import { Composition } from "remotion";
-import "../app/globals.css";
-import {
- DURATION_IN_FRAMES,
- FPS,
- HowItWorksVideo,
- portraitLayout,
- wideLayout,
-} from "./how-it-works-video";
+import { Composition } from 'remotion'
+import '../app/globals.css'
+import { DURATION_IN_FRAMES, FPS, HowItWorksVideo, portraitLayout, wideLayout } from './how-it-works-video'
export function RemotionRoot() {
return (
@@ -30,5 +24,5 @@ export function RemotionRoot() {
defaultProps={{ layout: portraitLayout }}
/>
>
- );
+ )
}
diff --git a/remotion/how-it-works-video.tsx b/remotion/how-it-works-video.tsx
index 125f82e..cd6f936 100644
--- a/remotion/how-it-works-video.tsx
+++ b/remotion/how-it-works-video.tsx
@@ -1,23 +1,20 @@
-import { AbsoluteFill, Easing, interpolate, useCurrentFrame } from "remotion";
-import { measureText } from "@remotion/layout-utils";
-import {
- fontFamily as funnelFamily,
- loadFont as loadFunnel,
-} from "@remotion/google-fonts/FunnelDisplay";
-import { fontFamily as albertFamily, loadFont as loadAlbert } from "@remotion/google-fonts/AlbertSans";
-import { fontFamily as monoFamily, loadFont as loadMono } from "@remotion/google-fonts/GeistMono";
-import { identityPalette } from "@/lib/identity-color";
-import { buildJoinPrompt } from "@/lib/join-prompt";
-import { ClientMark } from "@/app/components/agent-marks";
-import { CheckIcon } from "@/app/components/icons";
-import { Roster, Transcript, type Participant, type TranscriptItem } from "@/app/components/transcript";
-
-loadFunnel("normal", { weights: ["400", "700", "800"], subsets: ["latin"] });
-loadAlbert("normal", { weights: ["400", "500", "600", "700"], subsets: ["latin"] });
-loadMono("normal", { weights: ["400"], subsets: ["latin"] });
-
-export const FPS = 30;
-export const DURATION_IN_FRAMES = 645;
+import { AbsoluteFill, Easing, interpolate, useCurrentFrame } from 'remotion'
+import { measureText } from '@remotion/layout-utils'
+import { fontFamily as funnelFamily, loadFont as loadFunnel } from '@remotion/google-fonts/FunnelDisplay'
+import { fontFamily as albertFamily, loadFont as loadAlbert } from '@remotion/google-fonts/AlbertSans'
+import { fontFamily as monoFamily, loadFont as loadMono } from '@remotion/google-fonts/GeistMono'
+import { identityPalette } from '@/lib/identity-color'
+import { buildJoinPrompt } from '@/lib/join-prompt'
+import { ClientMark } from '@/app/components/agent-marks'
+import { CheckIcon } from '@/app/components/icons'
+import { Roster, Transcript, type Participant, type TranscriptItem } from '@/app/components/transcript'
+
+loadFunnel('normal', { weights: ['400', '700', '800'], subsets: ['latin'] })
+loadAlbert('normal', { weights: ['400', '500', '600', '700'], subsets: ['latin'] })
+loadMono('normal', { weights: ['400'], subsets: ['latin'] })
+
+export const FPS = 30
+export const DURATION_IN_FRAMES = 645
/**
* One cut, two shapes.
@@ -31,65 +28,65 @@ export const DURATION_IN_FRAMES = 645;
* is rendered at `--scale`, which multiplies the device pixels without
* touching the layout. Hence the small numbers below, and `renderScale`.
*/
-type Path = [[number, number], [number, number]];
+type Path = [[number, number], [number, number]]
export type Layout = {
- width: number;
- height: number;
- pad: number;
+ width: number
+ height: number
+ pad: number
/** Device-pixel multiplier to render at; the CSS layout is unchanged. */
- renderScale: number;
- radius: number;
- winW: number;
- winH: number;
- viewW: number;
- viewH: number;
- stageScale: number;
+ renderScale: number
+ radius: number
+ winW: number
+ winH: number
+ viewW: number
+ viewH: number
+ stageScale: number
/** Where the pointer travels, in page pixels: [start, target]. */
- path: { create: Path; copy: Path; send: Path };
+ path: { create: Path; copy: Path; send: Path }
/** [frame, x, y, zoom] per scene. */
- cam: { hero: number[][]; prompt: number[][]; terminal: number[][]; channel: number[][] };
-};
+ cam: { hero: number[][]; prompt: number[][]; terminal: number[][]; channel: number[][] }
+}
function geometry(width: number, height: number, pad: number, viewW: number) {
- const winW = width - pad * 2;
- const winH = height - pad * 2;
- const stageScale = winW / viewW;
- return { width, height, pad, winW, winH, viewW, viewH: winH / stageScale, stageScale };
+ const winW = width - pad * 2
+ const winH = height - pad * 2
+ const stageScale = winW / viewW
+ return { width, height, pad, winW, winH, viewW, viewH: winH / stageScale, stageScale }
}
-const ease = Easing.bezier(0.16, 1, 0.3, 1);
-const camEase = Easing.bezier(0.45, 0, 0.25, 1);
-const clamp = { extrapolateLeft: "clamp", extrapolateRight: "clamp" } as const;
+const ease = Easing.bezier(0.16, 1, 0.3, 1)
+const camEase = Easing.bezier(0.45, 0, 0.25, 1)
+const clamp = { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' } as const
/** Scene cuts, in frames. */
-const CUT = { hero: 0, prompt: 60, terminal: 180, mac: 285, win: 390, you: 480, end: 645 };
+const CUT = { hero: 0, prompt: 60, terminal: 180, mac: 285, win: 390, you: 480, end: 645 }
/** Every frame something is clicked, so the button can go down under it. */
-const CLICK = { create: 34, copy: CUT.prompt + 84, enter: CUT.terminal + 62, send: CUT.you + 48 };
+const CLICK = { create: 34, copy: CUT.prompt + 84, enter: CUT.terminal + 62, send: CUT.you + 48 }
const CHANNEL = {
- host: "https://wave.davidsling.in",
- channelId: "k3m9x2",
- name: "orders-api",
- invite: "7Qd2LpVn",
-};
+ host: 'https://wave.davidsling.in',
+ channelId: 'k3m9x2',
+ name: 'orders-api',
+ invite: '7Qd2LpVn',
+}
-const PURPOSE = "Get the Rust engine building on Windows as well as macOS.";
-const STEER = "Gate it behind cfg(unix) and add a Windows path.";
+const PURPOSE = 'Get the Rust engine building on Windows as well as macOS.'
+const STEER = 'Gate it behind cfg(unix) and add a Windows path.'
/* Named for the machine each one is sitting on, because that is the whole of
what this channel is for: the same commit, two operating systems, one room. */
-const MAC = "Mac agent";
-const WIN = "Windows agent";
+const MAC = 'Mac agent'
+const WIN = 'Windows agent'
const people: Participant[] = [
- { name: MAC, role: "agent", client: "Claude Code", presence: "active", behind: 0 },
- { name: WIN, role: "agent", client: "Codex CLI", presence: "active", behind: 0 },
- { name: "David", role: "human", client: "this browser", presence: "active" },
-];
+ { name: MAC, role: 'agent', client: 'Claude Code', presence: 'active', behind: 0 },
+ { name: WIN, role: 'agent', client: 'Codex CLI', presence: 'active', behind: 0 },
+ { name: 'David', role: 'human', client: 'this browser', presence: 'active' },
+]
-const palette = identityPalette(people);
+const palette = identityPalette(people)
const joinPrompt = buildJoinPrompt({
host: CHANNEL.host,
@@ -98,53 +95,53 @@ const joinPrompt = buildJoinPrompt({
invite: CHANNEL.invite,
agentName: MAC,
purpose: PURPOSE,
-});
+})
const script: TranscriptItem[] = [
- { seq: 1, type: "system", text: `${MAC} joined` },
+ { seq: 1, type: 'system', text: `${MAC} joined` },
{
seq: 2,
- type: "message",
- from: { name: MAC, role: "agent" },
- time: "09:41",
- text: "Engine builds clean on macOS — `cargo test` green, 48 passing.",
+ type: 'message',
+ from: { name: MAC, role: 'agent' },
+ time: '09:41',
+ text: 'Engine builds clean on macOS — `cargo test` green, 48 passing.',
},
- { seq: 3, type: "system", text: `${WIN} joined` },
+ { seq: 3, type: 'system', text: `${WIN} joined` },
{
seq: 4,
- type: "message",
- from: { name: WIN, role: "agent" },
- time: "09:42",
- text: "Same commit fails on Windows: `std::os::unix` imported in `src/watch.rs:12`.",
+ type: 'message',
+ from: { name: WIN, role: 'agent' },
+ time: '09:42',
+ text: 'Same commit fails on Windows: `std::os::unix` imported in `src/watch.rs:12`.',
},
- { seq: 5, type: "message", from: { name: "David", role: "human" }, time: "09:43", text: STEER },
+ { seq: 5, type: 'message', from: { name: 'David', role: 'human' }, time: '09:43', text: STEER },
{
seq: 6,
- type: "message",
- from: { name: MAC, role: "agent" },
- time: "09:43",
- text: "Agreed — gating the import, `notify` for the Windows watcher.",
+ type: 'message',
+ from: { name: MAC, role: 'agent' },
+ time: '09:43',
+ text: 'Agreed — gating the import, `notify` for the Windows watcher.',
},
-];
+]
/* The last one lands after the camera has pulled back, so the finished room is
what the reply arrives into. */
-const ITEM_AT = [CUT.mac, CUT.mac + 40, CUT.win, CUT.win + 40, CUT.you + 54, CUT.you + 100];
+const ITEM_AT = [CUT.mac, CUT.mac + 40, CUT.win, CUT.win + 40, CUT.you + 54, CUT.you + 100]
/** Items on screen, and how many are in the roster, at a given frame. */
function stateAt(frame: number) {
- if (frame >= CUT.you + 100) return { items: 6, seats: 3 };
- if (frame >= CUT.you + 54) return { items: 5, seats: 3 };
- if (frame >= CUT.win + 40) return { items: 4, seats: 2 };
- if (frame >= CUT.win) return { items: 3, seats: 2 };
- if (frame >= CUT.mac + 40) return { items: 2, seats: 1 };
- if (frame >= CUT.mac) return { items: 1, seats: 1 };
- return { items: 0, seats: 0 };
+ if (frame >= CUT.you + 100) return { items: 6, seats: 3 }
+ if (frame >= CUT.you + 54) return { items: 5, seats: 3 }
+ if (frame >= CUT.win + 40) return { items: 4, seats: 2 }
+ if (frame >= CUT.win) return { items: 3, seats: 2 }
+ if (frame >= CUT.mac + 40) return { items: 2, seats: 1 }
+ if (frame >= CUT.mac) return { items: 1, seats: 1 }
+ return { items: 0, seats: 0 }
}
function typedText(full: string, fromChar: number, start: number, end: number, frame: number) {
- const n = Math.round(interpolate(frame, [start, end], [fromChar, full.length], clamp));
- return full.slice(0, Math.max(0, n));
+ const n = Math.round(interpolate(frame, [start, end], [fromChar, full.length], clamp))
+ return full.slice(0, Math.max(0, n))
}
/**
@@ -152,63 +149,82 @@ function typedText(full: string, fromChar: number, start: number, end: number, f
* page so nothing outside the viewport can be framed — the focus point is
* pulled back to whatever the current zoom can actually fill.
*/
-function Camera({
- keys,
- layout,
- children,
-}: {
- keys: number[][];
- layout: Layout;
- children: React.ReactNode;
-}) {
- const frame = useCurrentFrame();
- const { viewW, viewH } = layout;
- const at = keys.map((k) => k[0]);
- const opts = { ...clamp, easing: camEase };
-
- const z = interpolate(frame, at, keys.map((k) => k[3]), opts);
-
- const halfW = viewW / 2 / z;
- const halfH = viewH / 2 / z;
- const x = Math.min(Math.max(interpolate(frame, at, keys.map((k) => k[1]), opts), halfW), viewW - halfW);
- const y = Math.min(Math.max(interpolate(frame, at, keys.map((k) => k[2]), opts), halfH), viewH - halfH);
+function Camera({ keys, layout, children }: { keys: number[][]; layout: Layout; children: React.ReactNode }) {
+ const frame = useCurrentFrame()
+ const { viewW, viewH } = layout
+ const at = keys.map((k) => k[0])
+ const opts = { ...clamp, easing: camEase }
+
+ const z = interpolate(
+ frame,
+ at,
+ keys.map((k) => k[3]),
+ opts,
+ )
+
+ const halfW = viewW / 2 / z
+ const halfH = viewH / 2 / z
+ const x = Math.min(
+ Math.max(
+ interpolate(
+ frame,
+ at,
+ keys.map((k) => k[1]),
+ opts,
+ ),
+ halfW,
+ ),
+ viewW - halfW,
+ )
+ const y = Math.min(
+ Math.max(
+ interpolate(
+ frame,
+ at,
+ keys.map((k) => k[2]),
+ opts,
+ ),
+ halfH,
+ ),
+ viewH - halfH,
+ )
return (
{children}
- );
+ )
}
/** A button going down under the pointer and coming back up. `at` is absolute. */
function usePress(at: number | undefined) {
- const frame = useCurrentFrame();
- if (at === undefined) return 1;
- return interpolate(frame, [at, at + 3, at + 11], [1, 0.94, 1], { ...clamp, easing: camEase });
+ const frame = useCurrentFrame()
+ if (at === undefined) return 1
+ return interpolate(frame, [at, at + 3, at + 11], [1, 0.94, 1], { ...clamp, easing: camEase })
}
function Caret({ on = true }: { on?: boolean }) {
- const frame = useCurrentFrame();
- if (!on) return null;
+ const frame = useCurrentFrame()
+ if (!on) return null
return (
- );
+ )
}
/**
@@ -217,12 +233,12 @@ function Caret({ on = true }: { on?: boolean }) {
*/
function Cursor({ x, y }: { x: number; y: number }) {
return (
-
-
+
+
- );
+ )
}
/**
@@ -236,7 +252,7 @@ function WaveMark({ size = 28 }: { size?: number }) {
👋Wave
- );
+ )
}
function Btn({
@@ -244,19 +260,19 @@ function Btn({
primary = true,
wide = false,
}: {
- children: React.ReactNode;
- primary?: boolean;
- wide?: boolean;
+ children: React.ReactNode
+ primary?: boolean
+ wide?: boolean
}) {
return (
-
+
{children}
- );
+ )
}
/** `.btn-sm` type, for measuring the label the button has to make room for. */
-const labelFont = { fontFamily: albertFamily, fontSize: 14, fontWeight: 600 };
+const labelFont = { fontFamily: albertFamily, fontSize: 14, fontWeight: 600 }
/**
* The join prompt's copy button, confirming the way the real one does: the fill
@@ -264,32 +280,29 @@ const labelFont = { fontFamily: albertFamily, fontSize: 14, fontWeight: 600 };
*/
function CopyBtn({ clickAt }: { clickAt: number }) {
- const frame = useCurrentFrame();
- const press = usePress(clickAt);
+ const frame = useCurrentFrame()
+ const press = usePress(clickAt)
const labelWidth = {
- rest: measureText({ text: "Copy prompt", ...labelFont }).width,
- done: measureText({ text: "Copied", ...labelFont }).width,
- };
+ rest: measureText({ text: 'Copy prompt', ...labelFont }).width,
+ done: measureText({ text: 'Copied', ...labelFont }).width,
+ }
/* The clipboard is written on the way back up, never before the press. */
- const t = frame - (clickAt + 3);
- const copied = t >= 0;
- const reveal = copied ? interpolate(t, [0, 6], [0, 1], clamp) : 0;
- const pop = copied ? interpolate(t, [0, 7.6, 12.6], [0.4, 1.12, 1], clamp) : 0.4;
+ const t = frame - (clickAt + 3)
+ const copied = t >= 0
+ const reveal = copied ? interpolate(t, [0, 6], [0, 1], clamp) : 0
+ const pop = copied ? interpolate(t, [0, 7.6, 12.6], [0.4, 1.12, 1], clamp) : 0.4
/* The old label leaves faster than the box closes, so the narrowing never
catches it mid-word and reads as a clipped glyph. */
- const out = copied ? 1 - interpolate(t, [0, 4], [0, 1], clamp) : 1;
- const into = copied ? interpolate(t, [3, 10], [0, 1], clamp) : 0;
+ const out = copied ? 1 - interpolate(t, [0, 4], [0, 1], clamp) : 1
+ const into = copied ? interpolate(t, [3, 10], [0, 1], clamp) : 0
return (
-
-
+
+
@@ -299,31 +312,27 @@ function CopyBtn({ clickAt }: { clickAt: number }) {
width animation the real button gets from TextMorph. */}
-
- Copy prompt
-
-
- Copied
-
+ Copy prompt
+ Copied
- );
+ )
}
/* ---------------------------------------------------------------- scene 1 */
function HeroScreen({ layout }: { layout: Layout }) {
- const frame = useCurrentFrame();
- const press = usePress(CLICK.create);
- const [from, to] = layout.path.create;
+ const frame = useCurrentFrame()
+ const press = usePress(CLICK.create)
+ const [from, to] = layout.path.create
return (