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 ( - + @@ -99,5 +92,5 @@ function AntigravityColorMark({ size, className, uid }: { size: number; classNam ))} - ); + ) } diff --git a/app/components/analytics.tsx b/app/components/analytics.tsx index fec4547..a78c162 100644 --- a/app/components/analytics.tsx +++ b/app/components/analytics.tsx @@ -1,7 +1,7 @@ -"use client"; +'use client' -import { Analytics as VercelAnalytics } from "@vercel/analytics/next"; -import type { BeforeSendEvent } from "@vercel/analytics"; +import { Analytics as VercelAnalytics } from '@vercel/analytics/next' +import type { BeforeSendEvent } from '@vercel/analytics' /** * Web analytics, held to the counts-only promise (PRODUCT section 9). @@ -17,14 +17,14 @@ import type { BeforeSendEvent } from "@vercel/analytics"; * the visitor already has. */ function redact(event: BeforeSendEvent): BeforeSendEvent | null { - const url = new URL(event.url); - if (!url.pathname.startsWith("/c/")) return event; + const url = new URL(event.url) + if (!url.pathname.startsWith('/c/')) return event return { ...event, url: `${url.origin}/c/[id]`, - }; + } } export function Analytics() { - return ; + return } diff --git a/app/components/channel/add-agent-dialog.tsx b/app/components/channel/add-agent-dialog.tsx index 027c9d7..87cdd2e 100644 --- a/app/components/channel/add-agent-dialog.tsx +++ b/app/components/channel/add-agent-dialog.tsx @@ -1,8 +1,8 @@ -"use client"; +'use client' -import { useEffect, useRef } from "react"; -import { CloseIcon } from "../icons"; -import { PromptBox } from "./prompt-box"; +import { useEffect, useRef } from 'react' +import { CloseIcon } from '../icons' +import { PromptBox } from './prompt-box' /** * The join prompt, once a channel is under way. @@ -22,22 +22,22 @@ export function AddAgentDialog({ invite, mode, }: { - open: boolean; - onClose: () => void; - host: string; - channelId: string; - channelName: string; - invite: string; - mode?: string; + open: boolean + onClose: () => void + host: string + channelId: string + channelName: string + invite: string + mode?: string }) { - const dialog = useRef(null); + const dialog = useRef(null) useEffect(() => { - const element = dialog.current; - if (!element) return; - if (open && !element.open) element.showModal(); - if (!open && element.open) element.close(); - }, [open]); + const element = dialog.current + if (!element) return + if (open && !element.open) element.showModal() + if (!open && element.open) element.close() + }, [open]) return ( { // A click on the dialog element itself is a click on the backdrop: // the content sits in a child that stops it. - if (event.target === dialog.current) onClose(); + if (event.target === dialog.current) onClose() }} className="dialog-modal dialog-adaptive m-auto w-[min(92vw,600px)] overflow-hidden rounded-[28px] border border-line bg-panel p-0 text-ink [--frame-radius:28px] max-sm:mx-0 max-sm:mb-0 max-sm:mt-auto max-sm:w-full max-sm:max-w-none max-sm:rounded-b-none max-sm:border-b-0 max-sm:pb-[env(safe-area-inset-bottom)]" aria-labelledby="add-agent-heading" @@ -73,5 +73,5 @@ export function AddAgentDialog({ - ); + ) } diff --git a/app/components/channel/channel-events.ts b/app/components/channel/channel-events.ts index eba3bf8..db85de7 100644 --- a/app/components/channel/channel-events.ts +++ b/app/components/channel/channel-events.ts @@ -1,4 +1,4 @@ -import type { Item } from "./use-channel"; +import type { Item } from './use-channel' /** * Channel events, announced (PRODUCT 6.5). @@ -10,27 +10,27 @@ import type { Item } from "./use-channel"; */ /** How loud each event is. What it says comes from the server (lib/events.ts). */ -const KIND: Record = { - "participant.joined": "info", - "participant.left": "info", - "participant.rejoined": "info", - "participant.timed_out": "warning", - "channel.expiring": "warning", - "channel.closing": "warning", -}; +const KIND: Record = { + 'participant.joined': 'info', + 'participant.left': 'info', + 'participant.rejoined': 'info', + 'participant.timed_out': 'warning', + 'channel.expiring': 'warning', + 'channel.closing': 'warning', +} -function announcement(item: Extract): Announcement | null { - const kind = KIND[item.event]; - return kind && item.text ? { title: item.text, kind } : null; +function announcement(item: Extract): Announcement | null { + const kind = KIND[item.event] + return kind && item.text ? { title: item.text, kind } : null } /** * @param historyUpTo the channel's last seq when the page opened. Everything up * to it is history — announcing it would replay an hour of joins on every load. */ -export type Announcement = { title: string; kind: "info" | "warning" }; +export type Announcement = { title: string; kind: 'info' | 'warning' } /** What to say about an event, or nothing when it does not deserve a toast. */ export function announcementFor(item: Item): Announcement | null { - return item.type === "system" ? announcement(item) : null; + return item.type === 'system' ? announcement(item) : null } diff --git a/app/components/channel/channel-menu.tsx b/app/components/channel/channel-menu.tsx index 90bf2e6..f139524 100644 --- a/app/components/channel/channel-menu.tsx +++ b/app/components/channel/channel-menu.tsx @@ -1,9 +1,9 @@ -"use client"; +'use client' -import { useEffect, useRef } from "react"; -import { sileo } from "sileo"; -import { CloseIcon, MoreIcon, PlusIcon, ShareIcon } from "../icons"; -import { copyText } from "./copy-text"; +import { useEffect, useRef } from 'react' +import { sileo } from 'sileo' +import { CloseIcon, MoreIcon, PlusIcon, ShareIcon } from '../icons' +import { copyText } from './copy-text' /** * Everything that is not the conversation, on a small screen. @@ -18,7 +18,7 @@ import { copyText } from "./copy-text"; /** One shape for every control in the bar, so the three read as a set. */ const barButton = - "grid size-9 shrink-0 place-items-center rounded-[10px] text-ink-2 transition-colors hover:bg-panel-2 hover:text-ink lg:hidden"; + 'grid size-9 shrink-0 place-items-center rounded-[10px] text-ink-2 transition-colors hover:bg-panel-2 hover:text-ink lg:hidden' /** The opener. Lives in the top bar, where a phone expects its menu. */ export function ChannelMenuButton({ onOpen }: { onOpen: () => void }) { @@ -26,7 +26,7 @@ export function ChannelMenuButton({ onOpen }: { onOpen: () => void }) { - ); + ) } export function ChannelAddButton({ onOpen }: { onOpen: () => void }) { @@ -34,7 +34,7 @@ export function ChannelAddButton({ onOpen }: { onOpen: () => void }) { - ); + ) } /** @@ -52,21 +52,21 @@ export function ChannelShareButton({ url, channelName }: { url: string; channelN onClick={async () => { if (navigator.share) { try { - await navigator.share({ title: channelName || "Wave channel", url }); - return; + await navigator.share({ title: channelName || 'Wave channel', url }) + return } catch (error) { // Dismissing the sheet is a decision, not a failure to route around. - if (error instanceof DOMException && error.name === "AbortError") return; + if (error instanceof DOMException && error.name === 'AbortError') return } } - if (await copyText(url)) sileo.success({ title: "Channel link copied", duration: 3_000 }); - else sileo.warning({ title: "The link could not be copied", duration: 5_000 }); + if (await copyText(url)) sileo.success({ title: 'Channel link copied', duration: 3_000 }) + else sileo.warning({ title: 'The link could not be copied', duration: 5_000 }) }} > - ); + ) } export function ChannelMenu({ @@ -75,27 +75,27 @@ export function ChannelMenu({ meta, children, }: { - open: boolean; - onClose: () => void; + open: boolean + onClose: () => void /** What the bar says about the channel on a screen with room for it. */ - meta?: React.ReactNode; - children: React.ReactNode; + meta?: React.ReactNode + children: React.ReactNode }) { - const sheet = useRef(null); + const sheet = useRef(null) useEffect(() => { - const element = sheet.current; - if (!element) return; - if (open && !element.open) element.showModal(); - if (!open && element.open) element.close(); - }, [open]); + const element = sheet.current + if (!element) return + if (open && !element.open) element.showModal() + if (!open && element.open) element.close() + }, [open]) return ( { - if (event.target === sheet.current) onClose(); + if (event.target === sheet.current) onClose() }} aria-label="Channel" className="dialog-sheet m-0 mt-auto w-full max-w-none rounded-t-[20px] border border-line bg-panel p-0 text-ink lg:hidden" @@ -116,5 +116,5 @@ export function ChannelMenu({
{children}
- ); + ) } diff --git a/app/components/channel/channel-view.tsx b/app/components/channel/channel-view.tsx index 17f4367..0aedcac 100644 --- a/app/components/channel/channel-view.tsx +++ b/app/components/channel/channel-view.tsx @@ -1,26 +1,26 @@ -"use client"; - -import Image from "next/image"; -import Link from "next/link"; -import { useEffect, useRef, useState } from "react"; -import { sileo, Toaster } from "sileo"; -import { identityPalette } from "@/lib/identity-color"; -import { quoteOf } from "@/lib/reply-quote"; -import { channelGone } from "@/lib/site"; -import mark from "../../icon.png"; -import { CreateChannelForm } from "../create-channel"; -import { ArrowRightIcon } from "../icons"; -import { Logo } from "../logo"; -import { ReplyProvider } from "../reply-action"; -import { Roster, Transcript, type ReplyQuote, type TranscriptItem } from "../transcript"; -import { AddAgentDialog } from "./add-agent-dialog"; -import { announcementFor } from "./channel-events"; -import { ChannelAddButton, ChannelMenu, ChannelMenuButton, ChannelShareButton } from "./channel-menu"; -import { Compose } from "./compose"; -import { Controls, ExpiryCountdown } from "./controls"; -import { PromptBox } from "./prompt-box"; -import { useReadMarker } from "./use-read-marker"; -import { adminKey, useChannel, type Item, type PendingMessage, type RosterEntry } from "./use-channel"; +'use client' + +import Image from 'next/image' +import Link from 'next/link' +import { useEffect, useRef, useState } from 'react' +import { sileo, Toaster } from 'sileo' +import { identityPalette } from '@/lib/identity-color' +import { quoteOf } from '@/lib/reply-quote' +import { channelGone } from '@/lib/site' +import mark from '../../icon.png' +import { CreateChannelForm } from '../create-channel' +import { ArrowRightIcon } from '../icons' +import { Logo } from '../logo' +import { ReplyProvider } from '../reply-action' +import { Roster, Transcript, type ReplyQuote, type TranscriptItem } from '../transcript' +import { AddAgentDialog } from './add-agent-dialog' +import { announcementFor } from './channel-events' +import { ChannelAddButton, ChannelMenu, ChannelMenuButton, ChannelShareButton } from './channel-menu' +import { Compose } from './compose' +import { Controls, ExpiryCountdown } from './controls' +import { PromptBox } from './prompt-box' +import { useReadMarker } from './use-read-marker' +import { adminKey, useChannel, type Item, type PendingMessage, type RosterEntry } from './use-channel' /** * The channel, as an application surface rather than a document (PRODUCT 6.2). @@ -40,12 +40,12 @@ import { adminKey, useChannel, type Item, type PendingMessage, type RosterEntry * this page shows; the event name is a fallback for an instance older than * that field. */ -function describe(item: Extract): string { - return item.text ?? item.event; +function describe(item: Extract): string { + return item.text ?? item.event } function clockTime(ts: string): string { - return new Date(ts).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); + return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) } /** @@ -58,14 +58,14 @@ function clockTime(ts: string): string { * build whose retention rules have changed, not for anything reachable today. */ function quoteFor(bySeq: Map, seq: number): ReplyQuote | undefined { - const answered = bySeq.get(seq); - if (!answered) return undefined; - if (answered.type === "system") return { seq, text: quoteOf(describe(answered)) }; + const answered = bySeq.get(seq) + if (!answered) return undefined + if (answered.type === 'system') return { seq, text: quoteOf(describe(answered)) } return { seq, from: { name: answered.from.name, role: answered.from.role }, text: quoteOf(answered.text), - }; + } } /** @@ -76,40 +76,40 @@ function quoteFor(bySeq: Map, seq: number): ReplyQuote | undefined * React keeps the node and only the dimming changes. */ function toTranscript(items: Item[], pending: PendingMessage[]): TranscriptItem[] { - const bySeq = new Map(items.map((item) => [item.seq, item])); + const bySeq = new Map(items.map((item) => [item.seq, item])) const said: TranscriptItem[] = items.map((item) => - item.type === "system" - ? { seq: item.seq, type: "system", text: describe(item) } + item.type === 'system' + ? { seq: item.seq, type: 'system', text: describe(item) } : { seq: item.seq, - type: "message", + type: 'message', from: { name: item.from.name, role: item.from.role }, time: clockTime(item.ts), text: item.text, ...(item.reply_to === undefined ? {} : { replyTo: quoteFor(bySeq, item.reply_to) }), }, - ); + ) for (const draft of pending) { said.push({ - type: "message", - from: { name: draft.name, role: "human" }, + type: 'message', + from: { name: draft.name, role: 'human' }, time: clockTime(draft.ts), text: draft.text, ...(draft.replyTo === undefined ? {} : { replyTo: quoteFor(bySeq, draft.replyTo) }), pending: true, - }); + }) } - return said; + return said } /** When each participant last spoke, read off the transcript the page already holds. */ function lastMessageByParticipant(items: Item[]): Map { - const spoken = new Map(); + const spoken = new Map() for (const item of items) { - if (item.type === "message") spoken.set(item.from.id, item.ts); + if (item.type === 'message') spoken.set(item.from.id, item.ts) } - return spoken; + return spoken } /** @@ -121,20 +121,20 @@ function lastMessageByParticipant(items: Item[]): Map { * "caught up" would be a claim nobody made. */ function behind(participant: RosterEntry, lastSeq: number, meId: string | undefined): number | undefined { - if (participant.read_seq === undefined || participant.id === meId) return undefined; - return Math.max(0, lastSeq - participant.read_seq); + if (participant.read_seq === undefined || participant.id === meId) return undefined + return Math.max(0, lastSeq - participant.read_seq) } function toRoster(participants: RosterEntry[], items: Item[], lastSeq: number, meId: string | undefined) { - const spoken = lastMessageByParticipant(items); + const spoken = lastMessageByParticipant(items) return participants.map((participant) => ({ name: participant.name, role: participant.role, - client: participant.client ?? "", + client: participant.client ?? '', presence: participant.presence, lastMessageAt: spoken.get(participant.id) ?? null, behind: behind(participant, lastSeq, meId), - })); + })) } function Shell({ children, ground = false }: { children: React.ReactNode; ground?: boolean }) { @@ -145,9 +145,7 @@ function Shell({ children, ground = false }: { children: React.ReactNode; ground // White is the channel: an application surface, edge to edge. The pages that // stand in for a channel are documents instead, so they take the ground the // landing page and the 404 stand on, and their panels read as panels again. - return ( -
{children}
- ); + return
{children}
} /** The bar across the top: what this channel is, and how long it has left. */ @@ -156,13 +154,13 @@ function TopBar({ menu, rule = true, }: { - children?: React.ReactNode; - menu?: React.ReactNode; - rule?: boolean; + children?: React.ReactNode + menu?: React.ReactNode + rule?: boolean }) { return (
@@ -178,7 +176,7 @@ function TopBar({ {menu}
- ); + ) } /** @@ -192,17 +190,17 @@ function NoticePage({ children, aside, }: { - hand?: boolean; - heading: { regular: string; bold: string }; - children: React.ReactNode; - aside?: React.ReactNode; + hand?: boolean + heading: { regular: string; bold: string } + children: React.ReactNode + aside?: React.ReactNode }) { return (
{hand ? ( @@ -223,14 +221,14 @@ function NoticePage({
- ); + ) } const closedRoom: TranscriptItem[] = [ - { type: "system", text: "The channel closed" }, - { type: "system", text: "Every message and key in it was deleted" }, - { type: "system", text: "Nothing is kept after that" }, -]; + { type: 'system', text: 'The channel closed' }, + { type: 'system', text: 'Every message and key in it was deleted' }, + { type: 'system', text: 'Nothing is kept after that' }, +] /** The room as it is now: its last three lines, and nobody left in it. */ function ClosedRoom() { @@ -247,7 +245,7 @@ function ClosedRoom() {

Nobody. The keys went with the room.

- ); + ) } /** @@ -267,18 +265,13 @@ function ShyHands() { // A gap rather than letter-spacing: spacing trails the last glyph too, and // would push the pair off the centre it is meant to sit on. In `em`, so the // fingertips keep their distance as the hands scale. -
+
👉 👈
- ); + ) } - - /** A whole-page state with nothing to show but a sentence: opening, or failing to. */ function Notice({ title, children }: { title: string; children: React.ReactNode }) { return ( @@ -291,91 +284,93 @@ function Notice({ title, children }: { title: string; children: React.ReactNode
- ); + ) } export function ChannelView({ channelId, host }: { channelId: string; host: string }) { const { status, channel, items, pending, participants, me, error, invite, historyUpTo, lastSeq, post, closeChannel } = - useChannel(channelId); - const [adding, setAdding] = useState(false); - const [menuOpen, setMenuOpen] = useState(false); + useChannel(channelId) + const [adding, setAdding] = useState(false) + const [menuOpen, setMenuOpen] = useState(false) /** The seq the next message answers, set by a row's Reply and cleared once it is sent. */ - const [replyingTo, setReplyingTo] = useState(null); - const { scroller, tail, markerAt, onScroll } = useReadMarker(channelId, items, status === "ready", pending.length); + const [replyingTo, setReplyingTo] = useState(null) + const { scroller, tail, markerAt, onScroll } = useReadMarker(channelId, items, status === 'ready', pending.length) // The toast store is a module singleton, and Next hands each client boundary // its own copy, so the calls have to be made from the module that renders the // Toaster. Everything up to historyUpTo was already there when the page // opened: announcing it would replay an hour of joins on every load. - const announcedUpTo = useRef(null); + const announcedUpTo = useRef(null) useEffect(() => { - if (historyUpTo === null) return; - announcedUpTo.current ??= historyUpTo; + if (historyUpTo === null) return + announcedUpTo.current ??= historyUpTo for (const item of items) { - if (item.seq <= announcedUpTo.current) continue; - const said = announcementFor(item); - if (!said) continue; - if (said.kind === "warning") sileo.warning({ title: said.title, duration: 6_000 }); - else sileo.info({ title: said.title, duration: 4_000 }); + if (item.seq <= announcedUpTo.current) continue + const said = announcementFor(item) + if (!said) continue + if (said.kind === 'warning') sileo.warning({ title: said.title, duration: 6_000 }) + else sileo.info({ title: said.title, duration: 4_000 }) } - announcedUpTo.current = items.at(-1)?.seq ?? announcedUpTo.current; - }, [items, historyUpTo]); + announcedUpTo.current = items.at(-1)?.seq ?? announcedUpTo.current + }, [items, historyUpTo]) - if (status === "no-invite") { + if (status === 'no-invite') { return ( - }> + } + >

Ask whoever shared the channel to send it again, or open a room of your own.

- ); + ) } - if (status === "gone") { + if (status === 'gone') { return ( }>

{channelGone.description}

- Back to the front page{" "} - + Back to the front page
- ); + ) } - if (status === "error") { + if (status === 'error') { return ( -

{error ?? "The instance did not answer."}

+

{error ?? 'The instance did not answer.'}

- ); + ) } - if (status === "loading" || !channel || !invite) { + if (status === 'loading' || !channel || !invite) { return (

Reading the roster and the transcript.

- ); + ) } // One palette for the whole page, so a name reads the same in the room and the transcript. - const colorFor = identityPalette(participants); + const colorFor = identityPalette(participants) // Joins alone do not start a conversation: the prompt stays put while agents // are still arriving, and steps aside once one of them says something. - const started = pending.length > 0 || items.some((item) => item.type === "message"); - const room = toRoster(participants, items, lastSeq, me?.id); - const replyQuote = replyingTo === null ? null : (quoteFor(new Map(items.map((i) => [i.seq, i])), replyingTo) ?? null); + const started = pending.length > 0 || items.some((item) => item.type === 'message') + const room = toRoster(participants, items, lastSeq, me?.id) + const replyQuote = replyingTo === null ? null : (quoteFor(new Map(items.map((i) => [i.seq, i])), replyingTo) ?? null) // Cleared on success only: a post that failed hands its text back to the // composer, and it would be handing back a reply that no longer knows what // it answers. const send = async (text: string, name: string) => { - await post(text, name, replyingTo ?? undefined); - setReplyingTo(null); - }; - const shareUrl = `${host}/c/${channelId}#${invite}`; - const canClose = typeof window !== "undefined" && window.localStorage.getItem(adminKey(channelId)) !== null; + await post(text, name, replyingTo ?? undefined) + setReplyingTo(null) + } + const shareUrl = `${host}/c/${channelId}#${invite}` + const canClose = typeof window !== 'undefined' && window.localStorage.getItem(adminKey(channelId)) !== null return ( @@ -389,7 +384,7 @@ export function ChannelView({ channelId, host }: { channelId: string; host: stri } >
- {channel.name || "Unnamed channel"} + {channel.name || 'Unnamed channel'} · {channel.mode} @@ -416,7 +411,7 @@ export function ChannelView({ channelId, host }: { channelId: string; host: stri > {/* A running conversation sits on the composer; an empty channel centres its one piece of business instead. */} -
+
{started ? (
- +
)} @@ -480,11 +481,11 @@ export function ChannelView({ channelId, host }: { channelId: string; host: stri ) : null} + shareUrl={shareUrl} + canClose={canClose} + onClose={closeChannel} + transcript={{ channel, items, participants }} + />
@@ -518,8 +519,8 @@ export function ChannelView({ channelId, host }: { channelId: string; host: stri type="button" className="btn btn-sm btn-primary mb-4 w-full" onClick={() => { - setMenuOpen(false); - setAdding(true); + setMenuOpen(false) + setAdding(true) }} > Add an agent @@ -558,5 +559,5 @@ export function ChannelView({ channelId, host }: { channelId: string; host: stri

) : 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.' : ''}
@@ -325,5 +318,5 @@ export function Compose({

) : null} - ); + ) } diff --git a/app/components/channel/controls.tsx b/app/components/channel/controls.tsx index bf40f5e..f097c11 100644 --- a/app/components/channel/controls.tsx +++ b/app/components/channel/controls.tsx @@ -1,35 +1,35 @@ -"use client"; +'use client' -import { useEffect, useState } from "react"; -import { CopyButton } from "./copy-button"; -import { ExportTranscript } from "./export-transcript"; -import type { ChannelMeta, Item, RosterEntry } from "./use-channel"; +import { useEffect, useState } from 'react' +import { CopyButton } from './copy-button' +import { ExportTranscript } from './export-transcript' +import type { ChannelMeta, Item, RosterEntry } from './use-channel' /** Counts down to expiry, in the coarsest unit that is still honest. */ function remaining(expiresAt: string, now: number): string { - const seconds = Math.max(0, Math.floor((new Date(expiresAt).getTime() - now) / 1000)); - if (seconds === 0) return "expired"; - if (seconds < 60) return `${seconds}s left`; - const minutes = Math.floor(seconds / 60); - if (minutes < 60) return `${minutes}m left`; - const hours = Math.floor(minutes / 60); - if (hours < 48) return `${hours}h ${minutes % 60}m left`; - return `${Math.floor(hours / 24)}d left`; + const seconds = Math.max(0, Math.floor((new Date(expiresAt).getTime() - now) / 1000)) + if (seconds === 0) return 'expired' + if (seconds < 60) return `${seconds}s left` + const minutes = Math.floor(seconds / 60) + if (minutes < 60) return `${minutes}m left` + const hours = Math.floor(minutes / 60) + if (hours < 48) return `${hours}h ${minutes % 60}m left` + return `${Math.floor(hours / 24)}d left` } export function ExpiryCountdown({ expiresAt }: { expiresAt: string }) { - const [now, setNow] = useState(() => Date.now()); + const [now, setNow] = useState(() => Date.now()) useEffect(() => { - const timer = setInterval(() => setNow(Date.now()), 1_000); - return () => clearInterval(timer); - }, []); + const timer = setInterval(() => setNow(Date.now()), 1_000) + return () => clearInterval(timer) + }, []) return ( {remaining(expiresAt, now)} - ); + ) } /** @@ -42,14 +42,14 @@ export function Controls({ onClose, transcript, }: { - shareUrl: string; - canClose: boolean; - onClose: () => Promise; + shareUrl: string + canClose: boolean + onClose: () => Promise /** Everything the export needs. Passed down rather than re-read: the page already has it. */ - transcript: { channel: ChannelMeta; items: Item[]; participants: RosterEntry[] }; + transcript: { channel: ChannelMeta; items: Item[]; participants: RosterEntry[] } }) { - const [confirming, setConfirming] = useState(false); - const [failure, setFailure] = useState(null); + const [confirming, setConfirming] = useState(false) + const [failure, setFailure] = useState(null) return (
@@ -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.'}

@@ -59,11 +59,11 @@ export function ExportTranscript({ type="button" className="btn btn-sm btn-secondary flex-1" disabled={empty} - onClick={() => download("json")} + onClick={() => download('json')} > JSON
- ); + ) } diff --git a/app/components/channel/install-command.tsx b/app/components/channel/install-command.tsx index 9a13de3..2a18be7 100644 --- a/app/components/channel/install-command.tsx +++ b/app/components/channel/install-command.tsx @@ -1,34 +1,34 @@ -"use client"; +'use client' -import { useEffect, useState } from "react"; -import { CheckIcon, CopyIcon } from "../icons"; -import { copyText } from "./copy-text"; +import { useEffect, useState } from 'react' +import { CheckIcon, CopyIcon } from '../icons' +import { copyText } from './copy-text' export function InstallCommand({ command }: { command: string }) { - const [copied, setCopied] = useState(false); + const [copied, setCopied] = useState(false) useEffect(() => { - if (!copied) return; - const timer = setTimeout(() => setCopied(false), 2_000); - return () => clearTimeout(timer); - }, [copied]); + if (!copied) return + const timer = setTimeout(() => setCopied(false), 2_000) + return () => clearTimeout(timer) + }, [copied]) return (
- ${" "} + ${' '} {command}
- ); + ) } 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 ( - ); + ) } 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.

Create a channel

- 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 (
@@ -89,20 +89,22 @@ export function CreateChannelForm() { autoComplete="off" />

Free, no account. The name is optional. - · + + · +

@@ -112,13 +114,13 @@ export function CreateChannelForm() { ref={dialog} onClose={() => setOptions(false)} onClick={(event) => { - if (event.target === dialog.current) setOptions(false); + if (event.target === dialog.current) setOptions(false) }} onKeyDown={(event) => { // Enter in a settings field means "done here", not "create now". - if (event.key === "Enter") { - event.preventDefault(); - setOptions(false); + if (event.key === 'Enter') { + event.preventDefault() + setOptions(false) } }} className="dialog-modal m-auto w-[min(92vw,480px)] rounded-[20px] border border-line bg-panel p-0 text-left text-ink" @@ -188,8 +190,7 @@ export function CreateChannelForm() { - Standard: TLS in transit, deleted when the channel expires or is closed. End-to-end - encryption is planned. + Standard: TLS in transit, deleted when the channel expires or is closed. End-to-end encryption is planned. @@ -204,10 +205,10 @@ export function CreateChannelForm() {

{state.error}

- ); + ) } 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.

- Built by{" "} + Built by{' '}

- ); + ) } 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(" { + const html = render('```\nssh @David\n```') + expect(highlighted(html)).toEqual([]) + expect(html).toContain(' { - const bold = render("**ask @David**"); - expect(bold).toContain(""); - expect(highlighted(bold)).toEqual(["@David"]); + it('highlights inside the Markdown it finds the name in', () => { + const bold = render('**ask @David**') + expect(bold).toContain('') + expect(highlighted(bold)).toEqual(['@David']) - const item = render("- @Windows agent to confirm"); - expect(item).toContain(" { - const html = render("# Status\n\n@David: `ok`\n\n- one\n- two"); - expect(html).toContain("Status"); - expect(html).toContain(" { + const html = render('# Status\n\n@David: `ok`\n\n- one\n- two') + expect(html).toContain('Status') + expect(html).toContain(' { - const html = render(' @David hi'); - expect(html).not.toContain("hi"); - expect(highlighted(html)).toEqual(["@David"]); - }); -}); + it('renders no HTML from the message, mention or not', () => { + const html = render(' @David hi') + expect(html).not.toContain('hi') + expect(highlighted(html)).toEqual(['@David']) + }) +}) diff --git a/app/components/message-body.tsx b/app/components/message-body.tsx index 00e8940..01bc010 100644 --- a/app/components/message-body.tsx +++ b/app/components/message-body.tsx @@ -1,10 +1,10 @@ -"use client"; +'use client' -import { useState, type ReactNode } from "react"; -import Markdown from "react-markdown"; -import remarkGfm from "remark-gfm"; -import type { IdentityColor } from "@/lib/identity-color"; -import { remarkMentions } from "@/lib/mentions"; +import { useState, type ReactNode } from 'react' +import Markdown from 'react-markdown' +import remarkGfm from 'remark-gfm' +import type { IdentityColor } from '@/lib/identity-color' +import { remarkMentions } from '@/lib/mentions' /** * A message body, rendered as Markdown (PRODUCT section 6.2). @@ -24,7 +24,7 @@ import { remarkMentions } from "@/lib/mentions"; */ /** Past this, a message is folded until asked for. Roughly fifteen lines of body text. */ -const LONG_MESSAGE = 900; +const LONG_MESSAGE = 900 const components = { p: ({ children }: { children?: ReactNode }) =>

{children}

, @@ -59,15 +59,15 @@ const components = {
{children}
), code: ({ className, children }: { className?: string; children?: ReactNode }) => { - const fenced = typeof className === "string" && className.startsWith("language-"); + const fenced = typeof className === 'string' && className.startsWith('language-') if (fenced) { - return {children}; + return {children} } return ( {children} - ); + ) }, pre: ({ children }: { children?: ReactNode }) => (
@@ -84,7 +84,7 @@ const components = {
   ),
   td: ({ children }: { children?: ReactNode }) => {children},
   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 (
@@ -173,9 +175,9 @@ export function MessageBody({ className="mt-1 text-[13px] font-semibold text-ink-2 underline-offset-2 hover:text-ink hover:underline" aria-expanded={expanded} > - {expanded ? "Show less" : `Show more (${Math.round(text.length / 100) / 10}k characters)`} + {expanded ? 'Show less' : `Show more (${Math.round(text.length / 100) / 10}k characters)`} ) : null}
- ); + ) } diff --git a/app/components/nav-veil.tsx b/app/components/nav-veil.tsx index 839eccd..e8d1758 100644 --- a/app/components/nav-veil.tsx +++ b/app/components/nav-veil.tsx @@ -1,6 +1,6 @@ -"use client"; +'use client' -import { useEffect, useState } from "react"; +import { useEffect, useState } from 'react' /** * The blurred layer behind the nav, and the one thing on it that reacts to @@ -28,19 +28,19 @@ import { useEffect, useState } from "react"; * scroll-linked keyframe parked at one end. */ export function NavVeil() { - const [stuck, setStuck] = useState(false); + const [stuck, setStuck] = useState(false) useEffect(() => { - const onScroll = () => setStuck(window.scrollY > 8); - onScroll(); - window.addEventListener("scroll", onScroll, { passive: true }); - return () => window.removeEventListener("scroll", onScroll); - }, []); + const onScroll = () => setStuck(window.scrollY > 8) + onScroll() + window.addEventListener('scroll', onScroll, { passive: true }) + return () => window.removeEventListener('scroll', onScroll) + }, []) return (
-
-
+
+
- ); + ) } diff --git a/app/components/nav.tsx b/app/components/nav.tsx index ebec53b..19d82c8 100644 --- a/app/components/nav.tsx +++ b/app/components/nav.tsx @@ -1,12 +1,12 @@ -import { Logo } from "./logo"; -import { NavVeil } from "./nav-veil"; -import { StarButton } from "./star-button"; +import { Logo } from './logo' +import { NavVeil } from './nav-veil' +import { StarButton } from './star-button' const links = [ - { hash: "#how", label: "How it works" }, - { hash: "#uses", label: "Use cases" }, - { hash: "#agents", label: "Agents" }, -]; + { hash: '#how', label: 'How it works' }, + { hash: '#uses', label: 'Use cases' }, + { hash: '#agents', label: 'Agents' }, +] /** * The primary nav. @@ -22,7 +22,7 @@ const links = [ * rather than with the pill, so a phone keeps the wordmark and the one action. */ export function Nav({ atHome = true }: { atHome?: boolean }) { - const home = atHome ? "" : "/"; + const home = atHome ? '' : '/' return (
@@ -43,5 +43,5 @@ export function Nav({ atHome = true }: { atHome?: boolean }) {
- ); + ) } diff --git a/app/components/platform-marks.tsx b/app/components/platform-marks.tsx index e39b2c1..54e0c0e 100644 --- a/app/components/platform-marks.tsx +++ b/app/components/platform-marks.tsx @@ -1,15 +1,24 @@ // Simple Icons glyphs (CC0 1.0), unaltered, used referentially: Apple and Linux from 16.33.0, Windows from 12.4.0, the last release that carried it. const MARKS = { - macos: { d: "M12.152 6.896c-.948 0-2.415-1.078-3.96-1.04-2.04.027-3.91 1.183-4.961 3.014-2.117 3.675-.546 9.103 1.519 12.09 1.013 1.454 2.208 3.09 3.792 3.039 1.52-.065 2.09-.987 3.935-.987 1.831 0 2.35.987 3.96.948 1.637-.026 2.676-1.48 3.676-2.948 1.156-1.688 1.636-3.325 1.662-3.415-.039-.013-3.182-1.221-3.22-4.857-.026-3.04 2.48-4.494 2.597-4.559-1.429-2.09-3.623-2.324-4.39-2.376-2-.156-3.675 1.09-4.61 1.09zM15.53 3.83c.843-1.012 1.4-2.427 1.245-3.83-1.207.052-2.662.805-3.532 1.818-.78.896-1.454 2.338-1.273 3.714 1.338.104 2.715-.688 3.559-1.701", brand: "#15161a" }, - linux: { d: "M12.504 0c-.155 0-.315.008-.48.021-4.226.333-3.105 4.807-3.17 6.298-.076 1.092-.3 1.953-1.05 3.02-.885 1.051-2.127 2.75-2.716 4.521-.278.832-.41 1.684-.287 2.489a.424.424 0 00-.11.135c-.26.268-.45.6-.663.839-.199.199-.485.267-.797.4-.313.136-.658.269-.864.68-.09.189-.136.394-.132.602 0 .199.027.4.055.536.058.399.116.728.04.97-.249.68-.28 1.145-.106 1.484.174.334.535.47.94.601.81.2 1.91.135 2.774.6.926.466 1.866.67 2.616.47.526-.116.97-.464 1.208-.946.587-.003 1.23-.269 2.26-.334.699-.058 1.574.267 2.577.2.025.134.063.198.114.333l.003.003c.391.778 1.113 1.132 1.884 1.071.771-.06 1.592-.536 2.257-1.306.631-.765 1.683-1.084 2.378-1.503.348-.199.629-.469.649-.853.023-.4-.2-.811-.714-1.376v-.097l-.003-.003c-.17-.2-.25-.535-.338-.926-.085-.401-.182-.786-.492-1.046h-.003c-.059-.054-.123-.067-.188-.135a.357.357 0 00-.19-.064c.431-1.278.264-2.55-.173-3.694-.533-1.41-1.465-2.638-2.175-3.483-.796-1.005-1.576-1.957-1.56-3.368.026-2.152.236-6.133-3.544-6.139zm.529 3.405h.013c.213 0 .396.062.584.198.19.135.33.332.438.533.105.259.158.459.166.724 0-.02.006-.04.006-.06v.105a.086.086 0 01-.004-.021l-.004-.024a1.807 1.807 0 01-.15.706.953.953 0 01-.213.335.71.71 0 00-.088-.042c-.104-.045-.198-.064-.284-.133a1.312 1.312 0 00-.22-.066c.05-.06.146-.133.183-.198.053-.128.082-.264.088-.402v-.02a1.21 1.21 0 00-.061-.4c-.045-.134-.101-.2-.183-.333-.084-.066-.167-.132-.267-.132h-.016c-.093 0-.176.03-.262.132a.8.8 0 00-.205.334 1.18 1.18 0 00-.09.4v.019c.002.089.008.179.02.267-.193-.067-.438-.135-.607-.202a1.635 1.635 0 01-.018-.2v-.02a1.772 1.772 0 01.15-.768c.082-.22.232-.406.43-.533a.985.985 0 01.594-.2zm-2.962.059h.036c.142 0 .27.048.399.135.146.129.264.288.344.465.09.199.14.4.153.667v.004c.007.134.006.2-.002.266v.08c-.03.007-.056.018-.083.024-.152.055-.274.135-.393.2.012-.09.013-.18.003-.267v-.015c-.012-.133-.04-.2-.082-.333a.613.613 0 00-.166-.267.248.248 0 00-.183-.064h-.021c-.071.006-.13.04-.186.132a.552.552 0 00-.12.27.944.944 0 00-.023.33v.015c.012.135.037.2.08.334.046.134.098.2.166.268.01.009.02.018.034.024-.07.057-.117.07-.176.136a.304.304 0 01-.131.068 2.62 2.62 0 01-.275-.402 1.772 1.772 0 01-.155-.667 1.759 1.759 0 01.08-.668 1.43 1.43 0 01.283-.535c.128-.133.26-.2.418-.2zm1.37 1.706c.332 0 .733.065 1.216.399.293.2.523.269 1.052.468h.003c.255.136.405.266.478.399v-.131a.571.571 0 01.016.47c-.123.31-.516.643-1.063.842v.002c-.268.135-.501.333-.775.465-.276.135-.588.292-1.012.267a1.139 1.139 0 01-.448-.067 3.566 3.566 0 01-.322-.198c-.195-.135-.363-.332-.612-.465v-.005h-.005c-.4-.246-.616-.512-.686-.71-.07-.268-.005-.47.193-.6.224-.135.38-.271.483-.336.104-.074.143-.102.176-.131h.002v-.003c.169-.202.436-.47.839-.601.139-.036.294-.065.466-.065zm2.8 2.142c.358 1.417 1.196 3.475 1.735 4.473.286.534.855 1.659 1.102 3.024.156-.005.33.018.513.064.646-1.671-.546-3.467-1.089-3.966-.22-.2-.232-.335-.123-.335.59.534 1.365 1.572 1.646 2.757.13.535.16 1.104.021 1.67.067.028.135.06.205.067 1.032.534 1.413.938 1.23 1.537v-.043c-.06-.003-.12 0-.18 0h-.016c.151-.467-.182-.825-1.065-1.224-.915-.4-1.646-.336-1.77.465-.008.043-.013.066-.018.135-.068.023-.139.053-.209.064-.43.268-.662.669-.793 1.187-.13.533-.17 1.156-.205 1.869v.003c-.02.334-.17.838-.319 1.35-1.5 1.072-3.58 1.538-5.348.334a2.645 2.645 0 00-.402-.533 1.45 1.45 0 00-.275-.333c.182 0 .338-.03.465-.067a.615.615 0 00.314-.334c.108-.267 0-.697-.345-1.163-.345-.467-.931-.995-1.788-1.521-.63-.4-.986-.87-1.15-1.396-.165-.534-.143-1.085-.015-1.645.245-1.07.873-2.11 1.274-2.763.107-.065.037.135-.408.974-.396.751-1.14 2.497-.122 3.854a8.123 8.123 0 01.647-2.876c.564-1.278 1.743-3.504 1.836-5.268.048.036.217.135.289.202.218.133.38.333.59.465.21.201.477.335.876.335.039.003.075.006.11.006.412 0 .73-.134.997-.268.29-.134.52-.334.74-.4h.005c.467-.135.835-.402 1.044-.7zm2.185 8.958c.037.6.343 1.245.882 1.377.588.134 1.434-.333 1.791-.765l.211-.01c.315-.007.577.01.847.268l.003.003c.208.199.305.53.391.876.085.4.154.78.409 1.066.486.527.645.906.636 1.14l.003-.007v.018l-.003-.012c-.015.262-.185.396-.498.595-.63.401-1.746.712-2.457 1.57-.618.737-1.37 1.14-2.036 1.191-.664.053-1.237-.2-1.574-.898l-.005-.003c-.21-.4-.12-1.025.056-1.69.176-.668.428-1.344.463-1.897.037-.714.076-1.335.195-1.814.12-.465.308-.797.641-.984l.045-.022zm-10.814.049h.01c.053 0 .105.005.157.014.376.055.706.333 1.023.752l.91 1.664.003.003c.243.533.754 1.064 1.189 1.637.434.598.77 1.131.729 1.57v.006c-.057.744-.48 1.148-1.125 1.294-.645.135-1.52.002-2.395-.464-.968-.536-2.118-.469-2.857-.602-.369-.066-.61-.2-.723-.4-.11-.2-.113-.602.123-1.23v-.004l.002-.003c.117-.334.03-.752-.027-1.118-.055-.401-.083-.71.043-.94.16-.334.396-.4.69-.533.294-.135.64-.202.915-.47h.002v-.002c.256-.268.445-.601.668-.838.19-.201.38-.336.663-.336zm7.159-9.074c-.435.201-.945.535-1.488.535-.542 0-.97-.267-1.28-.466-.154-.134-.28-.268-.373-.335-.164-.134-.144-.333-.074-.333.109.016.129.134.199.2.096.066.215.2.36.333.292.2.68.467 1.167.467.485 0 1.053-.267 1.398-.466.195-.135.445-.334.648-.467.156-.136.149-.267.279-.267.128.016.034.134-.147.332a8.097 8.097 0 01-.69.468zm-1.082-1.583V5.64c-.006-.02.013-.042.029-.05.074-.043.18-.027.26.004.063 0 .16.067.15.135-.006.049-.085.066-.135.066-.055 0-.092-.043-.141-.068-.052-.018-.146-.008-.163-.065zm-.551 0c-.02.058-.113.049-.166.066-.047.025-.086.068-.14.068-.05 0-.13-.02-.136-.068-.01-.066.088-.133.15-.133.08-.031.184-.047.259-.005.019.009.036.03.03.05v.02h.003z", brand: "#15161a" }, - windows: { d: "M0,0H11.377V11.372H0ZM12.623,0H24V11.372H12.623ZM0,12.623H11.377V24H0Zm12.623,0H24V24H12.623", brand: "#0078D4" }, -} as const; + macos: { + d: 'M12.152 6.896c-.948 0-2.415-1.078-3.96-1.04-2.04.027-3.91 1.183-4.961 3.014-2.117 3.675-.546 9.103 1.519 12.09 1.013 1.454 2.208 3.09 3.792 3.039 1.52-.065 2.09-.987 3.935-.987 1.831 0 2.35.987 3.96.948 1.637-.026 2.676-1.48 3.676-2.948 1.156-1.688 1.636-3.325 1.662-3.415-.039-.013-3.182-1.221-3.22-4.857-.026-3.04 2.48-4.494 2.597-4.559-1.429-2.09-3.623-2.324-4.39-2.376-2-.156-3.675 1.09-4.61 1.09zM15.53 3.83c.843-1.012 1.4-2.427 1.245-3.83-1.207.052-2.662.805-3.532 1.818-.78.896-1.454 2.338-1.273 3.714 1.338.104 2.715-.688 3.559-1.701', + brand: '#15161a', + }, + linux: { + d: 'M12.504 0c-.155 0-.315.008-.48.021-4.226.333-3.105 4.807-3.17 6.298-.076 1.092-.3 1.953-1.05 3.02-.885 1.051-2.127 2.75-2.716 4.521-.278.832-.41 1.684-.287 2.489a.424.424 0 00-.11.135c-.26.268-.45.6-.663.839-.199.199-.485.267-.797.4-.313.136-.658.269-.864.68-.09.189-.136.394-.132.602 0 .199.027.4.055.536.058.399.116.728.04.97-.249.68-.28 1.145-.106 1.484.174.334.535.47.94.601.81.2 1.91.135 2.774.6.926.466 1.866.67 2.616.47.526-.116.97-.464 1.208-.946.587-.003 1.23-.269 2.26-.334.699-.058 1.574.267 2.577.2.025.134.063.198.114.333l.003.003c.391.778 1.113 1.132 1.884 1.071.771-.06 1.592-.536 2.257-1.306.631-.765 1.683-1.084 2.378-1.503.348-.199.629-.469.649-.853.023-.4-.2-.811-.714-1.376v-.097l-.003-.003c-.17-.2-.25-.535-.338-.926-.085-.401-.182-.786-.492-1.046h-.003c-.059-.054-.123-.067-.188-.135a.357.357 0 00-.19-.064c.431-1.278.264-2.55-.173-3.694-.533-1.41-1.465-2.638-2.175-3.483-.796-1.005-1.576-1.957-1.56-3.368.026-2.152.236-6.133-3.544-6.139zm.529 3.405h.013c.213 0 .396.062.584.198.19.135.33.332.438.533.105.259.158.459.166.724 0-.02.006-.04.006-.06v.105a.086.086 0 01-.004-.021l-.004-.024a1.807 1.807 0 01-.15.706.953.953 0 01-.213.335.71.71 0 00-.088-.042c-.104-.045-.198-.064-.284-.133a1.312 1.312 0 00-.22-.066c.05-.06.146-.133.183-.198.053-.128.082-.264.088-.402v-.02a1.21 1.21 0 00-.061-.4c-.045-.134-.101-.2-.183-.333-.084-.066-.167-.132-.267-.132h-.016c-.093 0-.176.03-.262.132a.8.8 0 00-.205.334 1.18 1.18 0 00-.09.4v.019c.002.089.008.179.02.267-.193-.067-.438-.135-.607-.202a1.635 1.635 0 01-.018-.2v-.02a1.772 1.772 0 01.15-.768c.082-.22.232-.406.43-.533a.985.985 0 01.594-.2zm-2.962.059h.036c.142 0 .27.048.399.135.146.129.264.288.344.465.09.199.14.4.153.667v.004c.007.134.006.2-.002.266v.08c-.03.007-.056.018-.083.024-.152.055-.274.135-.393.2.012-.09.013-.18.003-.267v-.015c-.012-.133-.04-.2-.082-.333a.613.613 0 00-.166-.267.248.248 0 00-.183-.064h-.021c-.071.006-.13.04-.186.132a.552.552 0 00-.12.27.944.944 0 00-.023.33v.015c.012.135.037.2.08.334.046.134.098.2.166.268.01.009.02.018.034.024-.07.057-.117.07-.176.136a.304.304 0 01-.131.068 2.62 2.62 0 01-.275-.402 1.772 1.772 0 01-.155-.667 1.759 1.759 0 01.08-.668 1.43 1.43 0 01.283-.535c.128-.133.26-.2.418-.2zm1.37 1.706c.332 0 .733.065 1.216.399.293.2.523.269 1.052.468h.003c.255.136.405.266.478.399v-.131a.571.571 0 01.016.47c-.123.31-.516.643-1.063.842v.002c-.268.135-.501.333-.775.465-.276.135-.588.292-1.012.267a1.139 1.139 0 01-.448-.067 3.566 3.566 0 01-.322-.198c-.195-.135-.363-.332-.612-.465v-.005h-.005c-.4-.246-.616-.512-.686-.71-.07-.268-.005-.47.193-.6.224-.135.38-.271.483-.336.104-.074.143-.102.176-.131h.002v-.003c.169-.202.436-.47.839-.601.139-.036.294-.065.466-.065zm2.8 2.142c.358 1.417 1.196 3.475 1.735 4.473.286.534.855 1.659 1.102 3.024.156-.005.33.018.513.064.646-1.671-.546-3.467-1.089-3.966-.22-.2-.232-.335-.123-.335.59.534 1.365 1.572 1.646 2.757.13.535.16 1.104.021 1.67.067.028.135.06.205.067 1.032.534 1.413.938 1.23 1.537v-.043c-.06-.003-.12 0-.18 0h-.016c.151-.467-.182-.825-1.065-1.224-.915-.4-1.646-.336-1.77.465-.008.043-.013.066-.018.135-.068.023-.139.053-.209.064-.43.268-.662.669-.793 1.187-.13.533-.17 1.156-.205 1.869v.003c-.02.334-.17.838-.319 1.35-1.5 1.072-3.58 1.538-5.348.334a2.645 2.645 0 00-.402-.533 1.45 1.45 0 00-.275-.333c.182 0 .338-.03.465-.067a.615.615 0 00.314-.334c.108-.267 0-.697-.345-1.163-.345-.467-.931-.995-1.788-1.521-.63-.4-.986-.87-1.15-1.396-.165-.534-.143-1.085-.015-1.645.245-1.07.873-2.11 1.274-2.763.107-.065.037.135-.408.974-.396.751-1.14 2.497-.122 3.854a8.123 8.123 0 01.647-2.876c.564-1.278 1.743-3.504 1.836-5.268.048.036.217.135.289.202.218.133.38.333.59.465.21.201.477.335.876.335.039.003.075.006.11.006.412 0 .73-.134.997-.268.29-.134.52-.334.74-.4h.005c.467-.135.835-.402 1.044-.7zm2.185 8.958c.037.6.343 1.245.882 1.377.588.134 1.434-.333 1.791-.765l.211-.01c.315-.007.577.01.847.268l.003.003c.208.199.305.53.391.876.085.4.154.78.409 1.066.486.527.645.906.636 1.14l.003-.007v.018l-.003-.012c-.015.262-.185.396-.498.595-.63.401-1.746.712-2.457 1.57-.618.737-1.37 1.14-2.036 1.191-.664.053-1.237-.2-1.574-.898l-.005-.003c-.21-.4-.12-1.025.056-1.69.176-.668.428-1.344.463-1.897.037-.714.076-1.335.195-1.814.12-.465.308-.797.641-.984l.045-.022zm-10.814.049h.01c.053 0 .105.005.157.014.376.055.706.333 1.023.752l.91 1.664.003.003c.243.533.754 1.064 1.189 1.637.434.598.77 1.131.729 1.57v.006c-.057.744-.48 1.148-1.125 1.294-.645.135-1.52.002-2.395-.464-.968-.536-2.118-.469-2.857-.602-.369-.066-.61-.2-.723-.4-.11-.2-.113-.602.123-1.23v-.004l.002-.003c.117-.334.03-.752-.027-1.118-.055-.401-.083-.71.043-.94.16-.334.396-.4.69-.533.294-.135.64-.202.915-.47h.002v-.002c.256-.268.445-.601.668-.838.19-.201.38-.336.663-.336zm7.159-9.074c-.435.201-.945.535-1.488.535-.542 0-.97-.267-1.28-.466-.154-.134-.28-.268-.373-.335-.164-.134-.144-.333-.074-.333.109.016.129.134.199.2.096.066.215.2.36.333.292.2.68.467 1.167.467.485 0 1.053-.267 1.398-.466.195-.135.445-.334.648-.467.156-.136.149-.267.279-.267.128.016.034.134-.147.332a8.097 8.097 0 01-.69.468zm-1.082-1.583V5.64c-.006-.02.013-.042.029-.05.074-.043.18-.027.26.004.063 0 .16.067.15.135-.006.049-.085.066-.135.066-.055 0-.092-.043-.141-.068-.052-.018-.146-.008-.163-.065zm-.551 0c-.02.058-.113.049-.166.066-.047.025-.086.068-.14.068-.05 0-.13-.02-.136-.068-.01-.066.088-.133.15-.133.08-.031.184-.047.259-.005.019.009.036.03.03.05v.02h.003z', + brand: '#15161a', + }, + windows: { + d: 'M0,0H11.377V11.372H0ZM12.623,0H24V11.372H12.623ZM0,12.623H11.377V24H0Zm12.623,0H24V24H12.623', + brand: '#0078D4', + }, +} as const export function PlatformMark({ platform, size = 16 }: { platform: keyof typeof MARKS; size?: number }) { - const mark = MARKS[platform]; + const mark = MARKS[platform] return ( - ); + ) } diff --git a/app/components/principles.tsx b/app/components/principles.tsx index 557da9c..62b770e 100644 --- a/app/components/principles.tsx +++ b/app/components/principles.tsx @@ -1,25 +1,25 @@ const principles = [ { - title: "Humans stay in the loop.", - body: "Every channel has a live transcript. You can post into it, correct an agent mid-conversation, and close it whenever you want.", + title: 'Humans stay in the loop.', + body: 'Every channel has a live transcript. You can post into it, correct an agent mid-conversation, and close it whenever you want.', }, { - title: "Transport, not orchestration.", - body: "Wave never decides what agents should do. The goal comes from each human. Other agents’ messages arrive as requests from a colleague, not as commands.", + title: 'Transport, not orchestration.', + body: 'Wave never decides what agents should do. The goal comes from each human. Other agents’ messages arrive as requests from a colleague, not as commands.', }, { - title: "Minimal data, minimal time.", - body: "Messages are held only until the channel expires or is closed, then everything is deleted. Message bodies never appear in logs or analytics.", + title: 'Minimal data, minimal time.', + body: 'Messages are held only until the channel expires or is closed, then everything is deleted. Message bodies never appear in logs or analytics.', }, { - title: "Secure by default.", - body: "Every request carries a bearer credential the server checks. A channel ID on its own grants nothing. Tokens travel in headers, never in URLs.", + title: 'Secure by default.', + body: 'Every request carries a bearer credential the server checks. A channel ID on its own grants nothing. Tokens travel in headers, never in URLs.', }, { - title: "Open source, self-hostable.", - body: "Run your own on any Node.js host with a Redis. Nothing in the prompt or the protocol points back at this instance.", + title: 'Open source, self-hostable.', + body: 'Run your own on any Node.js host with a Redis. Nothing in the prompt or the protocol points back at this instance.', }, -]; +] export function Principles() { return ( @@ -38,5 +38,5 @@ export function Principles() {
- ); + ) } diff --git a/app/components/reply-action.test.tsx b/app/components/reply-action.test.tsx index 7545287..27fd3f4 100644 --- a/app/components/reply-action.test.tsx +++ b/app/components/reply-action.test.tsx @@ -1,6 +1,6 @@ -import { renderToStaticMarkup } from "react-dom/server"; -import { describe, expect, it } from "vitest"; -import { JumpToSeq, ReplyAction, ReplyProvider } from "./reply-action"; +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it } from 'vitest' +import { JumpToSeq, ReplyAction, ReplyProvider } from './reply-action' /** * The property this component exists for: it is inert outside a provider. @@ -10,42 +10,42 @@ import { JumpToSeq, ReplyAction, ReplyProvider } from "./reply-action"; * nowhere to use. A default context value would quietly undo it, so the * absence is asserted rather than assumed. */ -describe("ReplyAction", () => { - it("renders nothing with no composer to reply into", () => { - expect(renderToStaticMarkup()).toBe(""); - }); +describe('ReplyAction', () => { + it('renders nothing with no composer to reply into', () => { + expect(renderToStaticMarkup()).toBe('') + }) - it("renders nothing for an item with no seq, which is every illustrative transcript", () => { + it('renders nothing for an item with no seq, which is every illustrative transcript', () => { const html = renderToStaticMarkup( {}}> , - ); - expect(html).toBe(""); - }); + ) + expect(html).toBe('') + }) - it("names the author it would answer, since the icon alone says nothing about who", () => { + it('names the author it would answer, since the icon alone says nothing about who', () => { const html = renderToStaticMarkup( {}}> , - ); + ) // A column of identical arrows, so the name has to be in the accessible // name and in the tooltip rather than on screen. - expect(html).toContain('aria-label="Reply to Windows agent"'); - expect(html).toContain('title="Reply to Windows agent"'); - expect(html).toContain(" { - it("says where it goes, since the quote it wraps is already cut short", () => { +describe('JumpToSeq', () => { + it('says where it goes, since the quote it wraps is already cut short', () => { const html = renderToStaticMarkup( Build passes. , - ); - expect(html).toContain('aria-label="Replying to Windows agent. Go to it."'); - expect(html).toContain("Build passes."); - }); -}); + ) + expect(html).toContain('aria-label="Replying to Windows agent. Go to it."') + expect(html).toContain('Build passes.') + }) +}) diff --git a/app/components/reply-action.tsx b/app/components/reply-action.tsx index 917646a..dd50ad7 100644 --- a/app/components/reply-action.tsx +++ b/app/components/reply-action.tsx @@ -1,7 +1,7 @@ -"use client"; +'use client' -import { createContext, useContext, type ReactNode } from "react"; -import { ReplyIcon } from "./icons"; +import { createContext, useContext, type ReactNode } from 'react' +import { ReplyIcon } from './icons' /** * The "Reply" control on a transcript row, and the channel that carries the @@ -17,12 +17,12 @@ import { ReplyIcon } from "./icons"; * right for those pages: an example conversation has nowhere to put a reply. */ -type Replier = (seq: number) => void; +type Replier = (seq: number) => void -const ReplyContext = createContext(null); +const ReplyContext = createContext(null) export function ReplyProvider({ onReply, children }: { onReply: Replier; children: ReactNode }) { - return {children}; + return {children} } /** @@ -36,8 +36,8 @@ export function ReplyProvider({ onReply, children }: { onReply: Replier; childre * lighting up never reflows the message beside it. */ export function ReplyAction({ seq, author }: { seq?: number; author: string }) { - const onReply = useContext(ReplyContext); - if (!onReply || seq === undefined) return null; + const onReply = useContext(ReplyContext) + if (!onReply || seq === undefined) return null // The arrow, not the word. On a touch screen every row shows this control at // once, and eight instances of "Reply" down the edge compete with the names @@ -55,11 +55,11 @@ export function ReplyAction({ seq, author }: { seq?: number; author: string }) { > - ); + ) } /** How long the answered message keeps the highlight after you are taken to it. */ -const FLASH_MS = 1_400; +const FLASH_MS = 1_400 /** * Takes the reader to the message a quote points at, and says which one it is. @@ -74,24 +74,24 @@ const FLASH_MS = 1_400; * break the link the reader would go on to share. */ function reveal(seq: number): void { - const target = document.querySelector(`[data-seq="${seq}"]`); - if (!(target instanceof HTMLElement)) return; + const target = document.querySelector(`[data-seq="${seq}"]`) + if (!(target instanceof HTMLElement)) return - const still = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + const still = window.matchMedia('(prefers-reduced-motion: reduce)').matches // Only when it is not already there to be read. The whole of an example // channel is on screen at once, so centring a row a reader can already see // would move the page under them to show them what they were looking at. - const box = target.getBoundingClientRect(); + const box = target.getBoundingClientRect() if (box.top < 0 || box.bottom > window.innerHeight) { - target.scrollIntoView({ block: "center", behavior: still ? "auto" : "smooth" }); + target.scrollIntoView({ block: 'center', behavior: still ? 'auto' : 'smooth' }) } - target.classList.remove("seq-flash"); + target.classList.remove('seq-flash') // Reading a layout property between the two restarts the transition, so // clicking the same quote twice lights the row up twice. - void target.offsetWidth; - target.classList.add("seq-flash"); - window.setTimeout(() => target.classList.remove("seq-flash"), FLASH_MS); + void target.offsetWidth + target.classList.add('seq-flash') + window.setTimeout(() => target.classList.remove('seq-flash'), FLASH_MS) } export function JumpToSeq({ seq, label, children }: { seq: number; label: string; children: ReactNode }) { @@ -99,5 +99,5 @@ export function JumpToSeq({ seq, label, children }: { seq: number; label: string - ); + ) } diff --git a/app/components/ring-ground.tsx b/app/components/ring-ground.tsx index 0af5b64..2795614 100644 --- a/app/components/ring-ground.tsx +++ b/app/components/ring-ground.tsx @@ -1,5 +1,5 @@ -import { ClientMark } from "./agent-marks"; -import { PersonIcon } from "./icons"; +import { ClientMark } from './agent-marks' +import { PersonIcon } from './icons' /** * The incumbent ring scaled past the content and used as ground. @@ -15,7 +15,7 @@ import { PersonIcon } from "./icons"; * stand on it. The strands keep their animation in both, because a ring with * nothing moving on it is a border. */ -const BIG = { cx: 500, cy: 500, r: 430 } as const; +const BIG = { cx: 500, cy: 500, r: 430 } as const /** * Angles are chosen to keep every chip out of the centre column, where the @@ -24,47 +24,47 @@ const BIG = { cx: 500, cy: 500, r: 430 } as const; * crown of the arc clear for the type to pass under. */ const ringSeats = [ - { name: "Maya’s agent", under: "Claude Code", role: "agent" as const, full: 205, dome: 203 }, - { name: "Ravi’s agent", under: "Codex CLI", role: "agent" as const, full: 335, dome: 337 }, - { name: "Lena’s agent", under: "Cursor", role: "agent" as const, full: 155, dome: 186 }, - { name: "You", under: "this browser", role: "human" as const, full: 25, dome: 354 }, -]; + { name: 'Maya’s agent', under: 'Claude Code', role: 'agent' as const, full: 205, dome: 203 }, + { name: 'Ravi’s agent', under: 'Codex CLI', role: 'agent' as const, full: 335, dome: 337 }, + { name: 'Lena’s agent', under: 'Cursor', role: 'agent' as const, full: 155, dome: 186 }, + { name: 'You', under: 'this browser', role: 'human' as const, full: 25, dome: 354 }, +] function pointOn(angle: number) { - const radians = (angle * Math.PI) / 180; - return { x: BIG.cx + BIG.r * Math.cos(radians), y: BIG.cy + BIG.r * Math.sin(radians) }; + const radians = (angle * Math.PI) / 180 + return { x: BIG.cx + BIG.r * Math.cos(radians), y: BIG.cy + BIG.r * Math.sin(radians) } } function arc(from: number, to: number) { - const a = pointOn(from); - const b = pointOn(to); - return `M ${a.x.toFixed(1)} ${a.y.toFixed(1)} A ${BIG.r} ${BIG.r} 0 0 1 ${b.x.toFixed(1)} ${b.y.toFixed(1)}`; + const a = pointOn(from) + const b = pointOn(to) + return `M ${a.x.toFixed(1)} ${a.y.toFixed(1)} A ${BIG.r} ${BIG.r} 0 0 1 ${b.x.toFixed(1)} ${b.y.toFixed(1)}` } export function RingGround({ half = false }: { half?: boolean }) { - const seats = ringSeats.map((seat) => ({ ...seat, angle: half ? seat.dome : seat.full })); + const seats = ringSeats.map((seat) => ({ ...seat, angle: half ? seat.dome : seat.full })) // Arcs are drawn between neighbours going round, so the seats are ordered by // angle first. On a dome the ends do not meet, so the last pair is dropped // rather than sweeping a strand back across the open side. - const inOrder = [...seats].sort((a, b) => a.angle - b.angle); + const inOrder = [...seats].sort((a, b) => a.angle - b.angle) const strands = inOrder .map((seat, i) => { - const next = inOrder[(i + 1) % inOrder.length]; - if (half && i === inOrder.length - 1) return null; + const next = inOrder[(i + 1) % inOrder.length] + if (half && i === inOrder.length - 1) return null return { d: arc(seat.angle, next.angle), - warm: seat.role === "human" || next.role === "human", + warm: seat.role === 'human' || next.role === 'human', back: i % 2 === 1, delay: [0, 1.4, 2.6, 3.4][i], - }; + } }) - .filter((strand) => strand !== null); + .filter((strand) => strand !== null) 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 (
@@ -168,16 +159,19 @@ function ReplyQuoteLine({ quote }: { quote: ReplyQuote }) {
- ); + ) } /** The line you had read up to. Drawn above the first item past `unreadAfter`. */ function UnreadLine() { return ( -
  • +
  • New
  • - ); + ) } export function Transcript({ @@ -187,83 +181,79 @@ export function Transcript({ unreadAfter = null, room, }: { - items: TranscriptItem[]; - animate?: boolean; - colorFor?: ColorFor; + items: TranscriptItem[] + animate?: boolean + colorFor?: ColorFor /** Seq the reader had reached. The divider goes before the first item past it. */ - unreadAfter?: number | null; + unreadAfter?: number | null /** * Who is in the room. Two things are questions about the whole room rather * than about one item — whether a tile may be its client's mark, and whether * an `@name` is anybody — so a surface that has a roster passes it. Without * one every tile is the plain identity tile and every `@name` is prose. */ - room?: readonly Participant[]; + room?: readonly Participant[] }) { - const seatOf = seatingOf(room ?? []); + const seatOf = seatingOf(room ?? []) // Colours resolved once, here: `MessageBody` is a client component and this // one is not everywhere, so what crosses that boundary has to be data. const mentionable = (room ?? []).map((person) => ({ name: person.name, colour: colorFor(person.name, person.role), - })); + })) const firstUnread = - unreadAfter === null ? undefined : items.find((item) => item.seq !== undefined && item.seq > unreadAfter)?.seq; + unreadAfter === null ? undefined : items.find((item) => item.seq !== undefined && item.seq > unreadAfter)?.seq return (
      {items.map((item, i) => { - const motion = animate ? "arrive" : ""; - const delay = animate ? ({ ["--delay" as string]: `${60 + i * 70}ms` } as React.CSSProperties) : undefined; - const divider = firstUnread !== undefined && item.seq === firstUnread ? : null; + const motion = animate ? 'arrive' : '' + const delay = animate ? ({ ['--delay' as string]: `${60 + i * 70}ms` } as React.CSSProperties) : undefined + const divider = firstUnread !== undefined && item.seq === firstUnread ? : null - if (item.type === "system") { + if (item.type === 'system') { return ( {divider} -
    1. - {item.text} -
    2. +
    3. + {item.text} +
    4. - ); + ) } - const colour = colorFor(item.from.name, item.from.role); + const colour = colorFor(item.from.name, item.from.role) return ( {divider} -
    5. - -
      -
      - {item.from.name} - - {item.pending ? ( - Sending… - ) : ( - - )} - {item.pending ? null : } +
    6. + +
      +
      + {item.from.name} + + {item.pending ? ( + Sending… + ) : ( + + )} + {item.pending ? null : } +
      + {item.replyTo ? : null} +
      - {item.replyTo ? : null} - -
    - + - ); + ) })} - ); + ) } /** @@ -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 (
      {participants.map((p) => ( @@ -358,5 +346,5 @@ export function Roster({ ))}
    - ); + ) } 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() {