Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .git-blame-ignore-revs
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Format the whole tree with Prettier
ca872fbe14396e0217f9f71f090d10e0711c5d76
1 change: 1 addition & 0 deletions .husky/pre-commit
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
npx lint-staged
8 changes: 8 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
.claude/
.impeccable/
.next/
.vercel/
cli/dist/
design inspiration/
public/
spike/
6 changes: 6 additions & 0 deletions .prettierrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"semi": false,
"singleQuote": true,
"printWidth": 120,
"trailingComma": "all"
}
85 changes: 40 additions & 45 deletions app/actions.ts
Original file line number Diff line number Diff line change
@@ -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).
Expand All @@ -27,58 +27,53 @@ 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<CreateChannelState> {
export async function createChannel(_previous: CreateChannelState, formData: FormData): Promise<CreateChannelState> {
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,
invite: channel.invite_token,
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.' }
}
}
6 changes: 5 additions & 1 deletion app/agent/[topic]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
23 changes: 10 additions & 13 deletions app/c/[id]/opengraph-image.tsx
Original file line number Diff line number Diff line change
@@ -1,29 +1,26 @@
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
* name and whether it is still there. It reads nothing the invite protects
* (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(
<OgNoticeCard mark={mark} heading={card.heading} lines={card.lines} />,
{ ...size, fonts },
);
return new ImageResponse(<OgNoticeCard mark={mark} heading={card.heading} lines={card.lines} />, { ...size, fonts })
}
20 changes: 10 additions & 10 deletions app/c/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -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).
Expand All @@ -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<Metadata> {
const { id } = await params;
return channelMetadata(id, await glance(id));
export async function generateMetadata({ params }: PageProps<'/c/[id]'>): Promise<Metadata> {
const { id } = await params
return channelMetadata(id, await glance(id))
}

export default async function ChannelPage({ params }: PageProps<"/c/[id]">) {
const { id } = await params;
return <ChannelView channelId={id} host={getConfig().host} />;
export default async function ChannelPage({ params }: PageProps<'/c/[id]'>) {
const { id } = await params
return <ChannelView channelId={id} host={getConfig().host} />
}
41 changes: 17 additions & 24 deletions app/components/agent-marks.tsx
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -11,42 +11,42 @@ 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 <AntigravityColorMark size={size} className={className} uid={uid} />;
if (brand && client?.trim().toLowerCase().startsWith('antigravity')) {
return <AntigravityColorMark size={size} className={className} uid={uid} />
}

return (
<svg
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"
className={className}
>
<path d={mark.d} />
</svg>
);
)
}

/**
Expand All @@ -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 (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
aria-hidden
focusable="false"
className={className}
>
<svg width={size} height={size} viewBox="0 0 24 24" aria-hidden focusable="false" className={className}>
<mask id={maskId} maskUnits="userSpaceOnUse" x="0" y="1" width="24" height="23">
<path d={ANTIGRAVITY_COLOR.mask} fill="#fff" />
</mask>
Expand Down Expand Up @@ -99,5 +92,5 @@ function AntigravityColorMark({ size, className, uid }: { size: number; classNam
))}
</defs>
</svg>
);
)
}
14 changes: 7 additions & 7 deletions app/components/analytics.tsx
Original file line number Diff line number Diff line change
@@ -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).
Expand All @@ -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 <VercelAnalytics beforeSend={redact} />;
return <VercelAnalytics beforeSend={redact} />
}
Loading
Loading