Skip to content
1,619 linesCodeBlameRaw
1/**
2 * The landing page's product tour: g1t browsing itself. A faithful app
3 * frame (rail, sidebars, the real colours) plays one story on a loop, with
4 * a soft cursor that moves and clicks: the pull request going green and
5 * merging, the request in #web it came from and the ship note, the agent's
6 * sessions, the docs page.
7 *
8 * The frame is a pure function of time (lib/tour.ts). One rAF loop drives
9 * the clock and re-renders only when the frame changes; the step pills'
10 * progress bars are written straight to the DOM. The server renders t = 0
11 * and the clock starts after mount, so there is no hydration mismatch.
12 * Motion is transforms and opacity only. Reduced motion gets a still frame
13 * per step, chosen with the pills.
14 *
15 * It is an honest miniature of the app: the same rail (the workspace on
16 * the top bar's line, then Home, Code, Chat, Docs, Agents and the Inbox,
17 * with names), each mode's sidebar with its real sections and words, the
18 * same top bar (where you are, Explore, Docs, Ask g1t, the inbox and +),
19 * which Chat gives up to the conversation's own header as the app does,
20 * and the app's own cards. Parts of the story are not built yet (g1t
21 * splitting a request between colleagues, and Docs); the line under the
22 * frame says so as they play, and the caption under the steps.
23 */
24import {
25 Activity,
26 ArrowRight,
27 AtSign,
28 BarChart3,
29 Bell,
30 BookOpen,
31 Bot,
32 Box,
33 Brain,
34 Building2,
35 Check,
36 ChevronDown,
37 ChevronLeft,
38 CircleDot,
39 CircleHelp,
40 Code2,
41 Compass,
42 CornerDownRight,
43 FileText,
44 GitMerge,
45 GitPullRequest,
46 Hash,
47 House,
48 Inbox,
49 MessagesSquare,
50 Network,
51 Paperclip,
52 PlayCircle,
53 Plus,
54 Rocket,
55 Search,
56 SendHorizontal,
57 Settings,
58 ShieldCheck,
59 Shapes,
60 Sparkles,
61 SquarePen,
62} from "lucide-react";
63import { type CSSProperties, Fragment, type ReactNode, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
64
65import { cn } from "../lib/cn";
66import { ASK, CHECKS, type CursorTarget, type Frame, LOOP_MS, PILLS, STEPS, STILLS, type Scene, frameAt, pillAt, pillProgress, sameFrame } from "../lib/tour";
67import { AgentAvatar } from "./agent-avatar";
68import { G1tMark } from "./orchestrator";
69import { Avatar } from "./ui";
70import { Badge } from "./ui/badge";
71
72/** The frame's design size; it scales to fit, keeping its aspect. */
73const W = 992;
74const H = 600;
75
76const useIsoLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
77
78/* ------------------------------------------------------------------ */
79/* The clock */
80/* ------------------------------------------------------------------ */
81
82type Clock = {
83 frame: Frame;
84 reduced: boolean;
85 /** The pill shown as current. */
86 pill: number;
87 jump: (index: number) => void;
88 hover: (on: boolean) => void;
89 /** Observed for being on screen. */
90 rootRef: React.RefObject<HTMLDivElement | null>;
91 /** The pills' progress bars, written each tick. */
92 barRefs: React.RefObject<(HTMLSpanElement | null)[]>;
93};
94
95function useTourClock(): Clock {
96 const [frame, setFrame] = useState<Frame>(() => frameAt(0));
97 const [reduced, setReduced] = useState(false);
98 const [still, setStill] = useState(0);
99 const rootRef = useRef<HTMLDivElement | null>(null);
100 const barRefs = useRef<(HTMLSpanElement | null)[]>([]);
101 const time = useRef(0);
102 const shown = useRef(frame);
103 const paused = useRef({ hover: false, offscreen: true, hidden: false });
104
105 const paint = useCallback((t: number) => {
106 const next = frameAt(t);
107 if (!sameFrame(next, shown.current)) {
108 shown.current = next;
109 setFrame(next);
110 }
111 const current = pillAt(t);
112 const progress = pillProgress(t);
113 barRefs.current.forEach((bar, index) => {
114 if (!bar) return;
115 const amount = index === current ? progress : 0;
116 bar.style.transform = `scaleX(${amount})`;
117 });
118 }, []);
119
120 useEffect(() => {
121 const motion = window.matchMedia("(prefers-reduced-motion: reduce)");
122 if (motion.matches) {
123 setReduced(true);
124 const next = frameAt(STILLS.chat);
125 shown.current = next;
126 setFrame(next);
127 return;
128 }
129 const root = rootRef.current;
130 const observer =
131 typeof IntersectionObserver === "undefined"
132 ? null
133 : new IntersectionObserver(([entry]) => {
134 paused.current.offscreen = !entry?.isIntersecting;
135 });
136 if (root && observer) observer.observe(root);
137 else paused.current.offscreen = false;
138 const visibility = () => {
139 paused.current.hidden = document.hidden;
140 };
141 visibility();
142 document.addEventListener("visibilitychange", visibility);
143
144 let last: number | null = null;
145 let raf = 0;
146 const tick = (now: number) => {
147 const { hover, offscreen, hidden } = paused.current;
148 // A long gap (a background tab, a debugger) moves time on by one frame, not by the gap.
149 if (last !== null && !hover && !offscreen && !hidden) time.current = (time.current + Math.min(now - last, 64)) % LOOP_MS;
150 last = now;
151 paint(time.current);
152 raf = requestAnimationFrame(tick);
153 };
154 raf = requestAnimationFrame(tick);
155 return () => {
156 cancelAnimationFrame(raf);
157 observer?.disconnect();
158 document.removeEventListener("visibilitychange", visibility);
159 };
160 }, [paint]);
161
162 const jump = useCallback(
163 (index: number) => {
164 const pill = PILLS[index];
165 if (reduced) {
166 setStill(index);
167 const next = frameAt(STILLS[pill.scene]);
168 shown.current = next;
169 setFrame(next);
170 return;
171 }
172 time.current = pill.start + 1;
173 paint(time.current);
174 },
175 [paint, reduced],
176 );
177
178 const hover = useCallback((on: boolean) => {
179 paused.current.hover = on;
180 }, []);
181
182 return { frame, reduced, pill: reduced ? still : pillOf(frame), jump, hover, rootRef, barRefs };
183}
184
185/** The pill a frame belongs to, from what it shows (the clock's time is not React state). */
186function pillOf(frame: Frame): number {
187 return Math.max(0, PILLS.findIndex((pill) => pill.scene === frame.scene));
188}
189
190/** What the line under the frame says about the moment: working today, or a preview. */
191function honesty(frame: Frame): { today: boolean; label: string } {
192 if (frame.scene === "docs") return { today: false, label: "Docs in Artifacts work today; an agent updating one after a merge on its own is coming soon" };
193 if (frame.scene === "agents") return { today: true, label: "Sessions work today" };
194 if (frame.scene === "code") return { today: true, label: "Works today" };
195 if (frame.handoff && !frame.shipped) return { today: false, label: "g1t splitting work between colleagues: coming soon" };
196 return { today: true, label: "Works today" };
197}
198
199/** The line under the frame: today, or soon. Not part of the app, so it sits outside it. */
200function HonestyNote({ frame, className }: { frame: Frame; className?: string }) {
201 const note = honesty(frame);
202 return (
203 <span
204 key={note.label}
205 className={cn(
206 "tour-fade inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-medium ring-1",
207 note.today ? "bg-success/10 text-success ring-success/25" : "bg-raised text-muted ring-line-strong",
208 className,
209 )}
210 >
211 <span className={cn("size-1.5 rounded-full", note.today ? "bg-success" : "bg-faint")} />
212 {note.label}
213 </span>
214 );
215}
216
217/* ------------------------------------------------------------------ */
218/* Small parts */
219/* ------------------------------------------------------------------ */
220
221/** The people in the story; everyone else is an agent. */
222const PEOPLE: Record<string, string> = { Priya: "Priya Shah", Dana: "Dana Ruiz", Alex: "Alex Lee" };
223
224/** The agents in the story: a human name, a title, and a pixel creature of their own. */
225const AGENTS: Record<string, { title: string; team: string }> = {
226 g1t: { title: "Orchestrator", team: "" },
227 Otto: { title: "Software Engineer", team: "Engineering" },
228 Margo: { title: "QA Engineer", team: "QA" },
229 Inky: { title: "Technical Writer", team: "Docs" },
230 Sam: { title: "Support Specialist", team: "Customer Support" },
231 Dot: { title: "Product Manager", team: "Product" },
232 David: { title: "Sales Operations", team: "Sales" },
233 Bruno: { title: "Operations Engineer", team: "Operations" },
234};
235
236/** A face: an agent's own creature (g1t keeps its pixel 1), or a person's avatar, as the app draws them. */
237function Face({ who, size = 28 }: { who: string; size?: number }) {
238 if (!PEOPLE[who]) return <AgentAvatar agent={{ handle: who.toLowerCase(), name: who, builtin: who === "g1t" }} size={size} />;
239 return <Avatar name={who.toLowerCase()} size={size} />;
240}
241
242/** A person's presence, as the app shows it on their avatar: active, or away. */
243function PresenceDot({ away, ring = "var(--tour-side)" }: { away?: boolean; ring?: string }) {
244 return (
245 <span
246 className={cn("absolute -right-0.5 -bottom-0.5 size-[7px] rounded-full", away ? "bg-[var(--tour-side)] shadow-[inset_0_0_0_1.5px_var(--color-faint)]" : "bg-success")}
247 style={{ boxShadow: `0 0 0 1.5px ${ring}${away ? ", inset 0 0 0 1.5px var(--color-faint)" : ""}` }}
248 />
249 );
250}
251
252/** An agent's status dot, as chat/marks.tsx draws it: working glows green, idle is quiet. */
253function AgentDot({ working }: { working: boolean }) {
254 return (
255 <span
256 className={cn(
257 "size-1.5 shrink-0 rounded-full transition-colors duration-500",
258 working ? "bg-success shadow-[0_0_0_3px_color-mix(in_srgb,var(--color-success)_18%,transparent)]" : "bg-faint/70",
259 )}
260 />
261 );
262}
263
264function AgentTag() {
265 return <span className="rounded bg-accent/15 px-1 py-px font-mono text-[9px] tracking-wide text-accent uppercase">Agent</span>;
266}
267
268/** Text with @mentions, #channels and #numbers picked out, as chat shows them. */
269function Rich({ text }: { text: string }) {
270 const parts = text.split(/(@[a-z0-9-]+|#[a-z0-9]+)/gi);
271 return (
272 <>
273 {parts.map((part, index) =>
274 /^[@#]/.test(part) ? (
275 <span key={index} className={part.startsWith("@") ? "rounded bg-accent/15 px-0.5 text-accent" : "text-accent"}>
276 {part}
277 </span>
278 ) : (
279 part
280 ),
281 )}
282 </>
283 );
284}
285
286function Message({ who, role, time, children, enter }: { who: string; role?: string; time: string; children: ReactNode; enter?: boolean }) {
287 const agent = !PEOPLE[who];
288 return (
289 <div className={cn("flex gap-3 px-5 py-2", enter && "tour-in")}>
290 <Face who={who} />
291 <div className="min-w-0 flex-1">
292 <p className="flex flex-wrap items-baseline gap-x-1.5 text-[13px]">
293 <span className="font-semibold text-fg">{PEOPLE[who] ?? who}</span>
294 {agent && <AgentTag />}
295 {(role ?? AGENTS[who]?.title) && <span className="text-[11px] text-faint">{role ?? AGENTS[who]?.title}</span>}
296 <span className="text-[11px] text-faint">{time}</span>
297 </p>
298 <div className="mt-0.5 space-y-2 text-[13px] leading-[1.55] text-fg-soft">{children}</div>
299 </div>
300 </div>
301 );
302}
303
304function Dots() {
305 return (
306 <span className="inline-flex items-center gap-1" aria-hidden="true">
307 {[0, 1, 2].map((i) => (
308 <span key={i} className="tour-dot size-1.5 rounded-full bg-faint" style={{ animationDelay: `${i * 160}ms` }} />
309 ))}
310 </span>
311 );
312}
313
314/**
315 * The session's card in #web, drawn as chat draws an agent session's card
316 * (components/chat/card.tsx, CardBox): it changes in place as the
317 * session moves, wherever the story is.
318 */
319function TaskCard({ frame }: { frame: Frame }) {
320 const done = frame.cardMerged;
321 const cost = STEPS[frame.steps - 1]?.cost ?? 0;
322 return (
323 <div className="mt-1.5 flex max-w-[26rem] items-start gap-3 rounded-xl border border-line bg-surface px-3.5 py-3">
324 <span className={cn("mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-lg transition-colors", done ? "bg-raised text-muted" : "bg-accent/10 text-accent")}>
325 <Bot size={16} />
326 </span>
327 <span className="min-w-0 grow">
328 <span className="line-clamp-2 text-[13px] font-medium text-fg">CSV export times out for big accounts</span>
329 <span className="mt-0.5 block truncate font-mono text-[11px] text-muted tabular-nums">
330 @otto · {frame.steps} of {STEPS.length} steps · ${cost.toFixed(2)}
331 </span>
332 </span>
333 <Badge key={done ? "done" : "working"} tone={done ? "success" : "accent"} className="tour-pop mt-0.5">
334 {!done && (
335 <span aria-hidden="true" className="relative flex size-1.5">
336 <span className="absolute inline-flex size-full animate-ping rounded-full bg-current opacity-60 motion-reduce:animate-none" />
337 <span className="relative inline-flex size-1.5 rounded-full bg-current" />
338 </span>
339 )}
340 {done ? "Done" : "Working"}
341 </Badge>
342 </div>
343 );
344}
345
346/* ------------------------------------------------------------------ */
347/* The app frame */
348/* ------------------------------------------------------------------ */
349
350/** The rail's modes, in the app's order (components/rail.tsx), with its icons. */
351const RAIL: { key: string; icon: ReactNode; label: string; target?: CursorTarget; scene?: Scene; badge?: number }[] = [
352 { key: "home", icon: <House size={15} />, label: "Home" },
353 { key: "code", icon: <Code2 size={15} />, label: "Code", target: "rail-code", scene: "code" },
354 { key: "chat", icon: <MessagesSquare size={15} />, label: "Chat", target: "rail-chat", scene: "chat", badge: 3 },
355 { key: "artifacts", icon: <Shapes size={15} />, label: "Artifacts", target: "rail-docs", scene: "docs" },
356 { key: "agents", icon: <Sparkles size={15} />, label: "Agents", target: "rail-agents", scene: "agents" },
357 { key: "inbox", icon: <Inbox size={15} />, label: "Inbox", badge: 2 },
358];
359
360/** One rail item: the square behind its icon, filled for the mode you are in, and its name under it. */
361function RailItem({ icon, label, on, target, badge }: { icon: ReactNode; label: string; on: boolean; target?: CursorTarget; badge?: number }) {
362 return (
363 <span className="flex w-full flex-col items-center gap-[3px] py-0.5">
364 <span
365 data-tour={target}
366 className={cn(
367 "relative flex size-7 items-center justify-center rounded-[8px] transition-colors duration-500",
368 on ? "bg-[#2c2c33] text-fg shadow-[inset_0_0_0_1px_rgba(255,255,255,0.06)]" : "text-muted",
369 )}
370 >
371 {icon}
372 {badge ? (
373 <span className="absolute -top-1 -right-1.5 min-w-[0.875rem] rounded-full bg-accent px-[3px] text-center text-[8px] leading-[0.875rem] font-bold text-bg tabular-nums ring-[1.5px] ring-[#0b0b0d]">
374 {badge}
375 </span>
376 ) : null}
377 </span>
378 <span className={cn("text-[9px] leading-none font-medium transition-colors duration-500", on ? "text-fg" : "text-faint")}>{label}</span>
379 </span>
380 );
381}
382
383/**
384 * The rail, as the app has it: the workspace on the top bar's line (its
385 * switcher), the modes with their names, then the workspace's own pages,
386 * help, and you, with the dot that says how others see you.
387 */
388function Rail({ scene }: { scene: Scene }) {
389 return (
390 <nav aria-hidden="true" className="flex w-16 shrink-0 flex-col items-center bg-[#0b0b0d] pb-2.5 shadow-[inset_-1px_0_0_var(--color-line)]">
391 <div className="flex h-11 w-full shrink-0 items-center justify-center border-b border-line bg-bg shadow-[inset_-1px_0_0_var(--color-line)]">
392 <Avatar name="acme" size={28} square />
393 </div>
394 <div className="mt-2.5 flex w-full flex-col items-center gap-1.5 px-1">
395 {RAIL.map((item) => (
396 <RailItem key={item.key} icon={item.icon} label={item.label} on={item.scene === scene} target={item.target} badge={item.badge} />
397 ))}
398 </div>
399 <div className="mt-auto flex w-full flex-col items-center gap-1.5 px-1">
400 <RailItem icon={<Building2 size={15} />} label="Workspace" on={false} />
401 <RailItem icon={<CircleHelp size={15} />} label="Help" on={false} />
402 <span className="relative mt-1">
403 <Avatar name="priya" size={26} />
404 <PresenceDot ring="#0b0b0d" />
405 </span>
406 </div>
407 </nav>
408 );
409}
410
411/**
412 * The top bar, as the app has it (components/shell.tsx): where you are,
413 * then Explore, Docs, Ask g1t, the inbox and the + menu. Chat has none on
414 * a computer: the conversation's header takes its line.
415 */
416function TopBar({ crumbs }: { crumbs: { label: string; mono?: boolean }[] }) {
417 return (
418 <div className="flex h-11 shrink-0 items-center gap-2 border-b border-line bg-bg px-4">
419 <span className="flex min-w-0 items-center gap-1.5 text-[12.5px]">
420 {crumbs.map((crumb, index) => (
421 <Fragment key={crumb.label}>
422 {index > 0 && <span className="text-line-strong">/</span>}
423 <span className={cn("truncate", index === crumbs.length - 1 ? "font-medium text-fg" : "text-muted", crumb.mono && "font-mono text-[12px]")}>{crumb.label}</span>
424 </Fragment>
425 ))}
426 </span>
427 <span className="ml-auto flex items-center gap-1 text-[12px]">
428 <span className="px-1.5 text-muted">Explore</span>
429 <span className="px-1.5 text-muted">Docs</span>
430 <span className="flex h-7 items-center gap-1.5 rounded-md border border-line px-2 text-fg/90">
431 <G1tMark size={14} />
432 Ask g1t
433 </span>
434 <span className="relative flex size-7 items-center justify-center text-muted">
435 <Bell size={14} />
436 <span className="absolute top-1 right-1 size-1.5 rounded-full bg-accent" />
437 </span>
438 <span className="mx-1 h-4 w-px bg-line" />
439 <span className="flex h-7 items-center gap-0.5 rounded-md border border-line px-1.5 text-fg/90">
440 <Plus size={13} />
441 <ChevronDown size={11} className="text-muted" />
442 </span>
443 </span>
444 </div>
445 );
446}
447
448/** A mode's sidebar heading (ModeHeader): on the top bar's line, with what it makes. */
449function SideHead({ children, action, soon }: { children: ReactNode; action?: ReactNode; soon?: boolean }) {
450 return (
451 <div className="flex h-11 shrink-0 items-center justify-between gap-2 border-b border-line pr-2 pl-3.5">
452 <span className="flex items-center gap-2 text-[13px] font-semibold text-fg">
453 {children}
454 {soon && <Soon />}
455 </span>
456 {action && <span className="flex size-6 items-center justify-center text-faint">{action}</span>}
457 </div>
458 );
459}
460
461/** A section's heading, with its fold, as Chat's sections have it. */
462function SideLabel({ children, fold = true }: { children: ReactNode; fold?: boolean }) {
463 return (
464 <p className="mt-3 mb-0.5 flex items-center gap-1 px-1 text-[10.5px] font-medium text-faint">
465 {fold && <ChevronDown size={10} />}
466 {children}
467 </p>
468 );
469}
470
471function SideRow({ on, children, bold }: { on?: boolean; children: ReactNode; bold?: boolean }) {
472 return (
473 <div className={cn("flex h-7 items-center gap-2 rounded-md px-2 text-[12px]", on ? "bg-raised text-fg" : bold ? "text-fg" : "text-muted", bold && "font-semibold")}>
474 {children}
475 </div>
476 );
477}
478
479/** The search at the top of a sidebar. */
480function SideSearch({ children, keys }: { children: ReactNode; keys?: boolean }) {
481 return (
482 <span className="flex h-7 items-center gap-2 rounded-md bg-surface px-2 text-[11px] text-faint ring-1 ring-line">
483 <Search size={12} className="shrink-0" />
484 <span className="grow truncate">{children}</span>
485 {keys && <span className="rounded bg-raised px-1 font-mono text-[9px] text-muted ring-1 ring-line">Ctrl K</span>}
486 </span>
487 );
488}
489
490/* Chat ---------------------------------------------------------------- */
491
492function ChatSide({ frame }: { frame: Frame }) {
493 // Otto works from the handoff until the merge; everyone else is idle.
494 const ottoBusy = frame.handoff && !frame.merged;
495 const margoBusy = frame.consult && !frame.approved;
496 // As the app has it: direct messages with people and agents alike, the
497 // latest first; then the agents not talked to yet.
498 const dms: { who: string; away?: boolean; status?: string; working?: boolean }[] = [
499 { who: "g1t" },
500 { who: "Dana" },
501 { who: "Margo", working: margoBusy },
502 { who: "Alex", away: true, status: "🗓️" },
503 ];
504 const agents: { who: string; working: boolean }[] = [
505 { who: "Otto", working: ottoBusy },
506 { who: "Inky", working: frame.docUpdated },
507 { who: "Sam", working: frame.shipped && !frame.sam },
508 ];
509 return (
510 <>
511 <SideHead action={<SquarePen size={14} />}>Chat</SideHead>
512 <div className="space-y-2 px-2 pt-2.5">
513 <SideSearch>Jump to channel or person</SideSearch>
514 <span className="grid grid-cols-3 rounded-md bg-surface p-0.5 text-center text-[10px] font-medium ring-1 ring-line">
515 <span className="rounded-[4px] bg-raised py-0.5 text-fg ring-1 ring-line-strong">All</span>
516 <span className="py-0.5 text-muted">Unread</span>
517 <span className="py-0.5 text-muted">Mentions</span>
518 </span>
519 </div>
520 <div className="px-2">
521 <SideLabel>Pinned</SideLabel>
522 <SideRow>
523 <Hash size={12} className="text-faint" />
524 <span className="flex-1">releases</span>
525 </SideRow>
526 <SideLabel>Channels</SideLabel>
527 {["web", "support", "general"].map((name) => (
528 <SideRow key={name} on={name === "web"} bold={name === "support"}>
529 <Hash size={12} className="text-faint" />
530 <span className="flex-1">{name}</span>
531 {name === "support" && <span className="min-w-4 rounded-full bg-line-strong px-1 text-center text-[9.5px] font-semibold text-fg">3</span>}
532 </SideRow>
533 ))}
534 <div className="flex h-7 items-center gap-2 px-2 text-[12px] text-faint">
535 <Compass size={12} />
536 <span className="flex-1">Browse channels</span>
537 <span className="text-[10.5px]">3</span>
538 </div>
539 <SideLabel>Direct messages</SideLabel>
540 {dms.map((dm) => {
541 const agent = dm.who in AGENTS;
542 return (
543 <SideRow key={dm.who}>
544 <span className="relative flex">
545 <Face who={dm.who} size={16} />
546 {!agent && <PresenceDot away={dm.away} />}
547 {agent && dm.working && <PresenceDot />}
548 </span>
549 <span className="min-w-0 flex-1 truncate">
550 {agent ? dm.who : PEOPLE[dm.who]}
551 {agent && <span className="text-faint"> · {dm.who === "g1t" ? "orchestrator" : AGENTS[dm.who].title}</span>}
552 {dm.status && <span className="ml-1.5 text-[11px]">{dm.status}</span>}
553 </span>
554 </SideRow>
555 );
556 })}
557 <SideLabel>Agents</SideLabel>
558 {agents.map((agent) => (
559 <SideRow key={agent.who}>
560 <span className="relative flex">
561 <Face who={agent.who} size={16} />
562 {agent.working && <PresenceDot />}
563 </span>
564 <span className="min-w-0 flex-1 truncate">
565 {agent.who}
566 <span className="text-faint"> · {AGENTS[agent.who].title}</span>
567 </span>
568 </SideRow>
569 ))}
570 </div>
571 </>
572 );
573}
574
575function ChatMain({ frame }: { frame: Frame }) {
576 const typing = ASK.slice(0, frame.typed);
577 return (
578 <div className="flex h-full flex-col">
579 <div className="flex h-11 shrink-0 items-center gap-2 border-b border-line px-5">
580 <Hash size={15} className="text-faint" />
581 <span className="text-sm font-semibold text-fg">web</span>
582 <span aria-hidden="true" className="h-3.5 w-px bg-line" />
583 <span className="truncate text-xs text-muted">The web app, its releases and its bugs</span>
584 <span className="ml-auto flex -space-x-1.5" aria-hidden="true">
585 {["Priya", "g1t", "Otto", "Margo", "Dana"].map((who) => (
586 <span key={who} className="rounded-lg ring-2 ring-surface">
587 <Face who={who} size={20} />
588 </span>
589 ))}
590 </span>
591 </div>
592 <div className="flex min-h-0 flex-1 flex-col justify-end overflow-hidden [mask-image:linear-gradient(to_bottom,transparent,black_2.5rem)] pb-1">
593 <div className="flex items-center gap-3 px-5 pt-1 pb-2 text-[11px] text-faint">
594 <span className="h-px flex-1 bg-line" />
595 Today
596 <span className="h-px flex-1 bg-line" />
597 </div>
598 <Message who="Priya" time="09:02">
599 <p>Morning! Thursday&apos;s release closes Wednesday night, so anything for it needs to land by then.</p>
600 </Message>
601 <Message who="Dana" time="09:12">
602 <p>Two enterprise customers hit the CSV export timeout again this morning. Both have over 100k rows.</p>
603 </Message>
604 {frame.sent && (
605 <Message who="Priya" time="09:15" enter>
606 <p>
607 <Rich text={ASK} />
608 </p>
609 </Message>
610 )}
611 {frame.g1tTyping && (
612 <div className="tour-in flex items-center gap-3 px-5 py-2 text-[12px] text-faint">
613 <Face who="g1t" size={28} />
614 <Dots />
615 g1t is typing
616 </div>
617 )}
618 {frame.handoff && (
619 <Message who="g1t" time="09:15" enter>
620 <p>
621 <Rich text="On it. @otto will make the fix and @margo will review it. I'll post here when it ships." />
622 </p>
623 <TaskCard frame={frame} />
624 </Message>
625 )}
626 {frame.shipped && (
627 <Message who="g1t" time="09:41" enter>
628 <p>
629 <Rich text="Shipped. A 200,000-row export now finishes in about 3 s. @sam, can you let #support know?" />
630 </p>
631 </Message>
632 )}
633 {frame.sam && (
634 <Message who="Sam" time="09:42" enter>
635 <p>
636 <Rich text="Done. Told #support, and Dana's two customers can export again." />
637 </p>
638 </Message>
639 )}
640 </div>
641 <div className="px-5 pt-1 pb-4">
642 <div
643 data-tour="composer"
644 className={cn(
645 "flex items-center gap-2 rounded-xl bg-surface px-3 py-2.5 text-[13px] ring-1 transition-shadow duration-300",
646 frame.typed > 0 ? "ring-accent/50 shadow-[0_0_0_3px_rgb(182_168_255/0.08)]" : "ring-line",
647 )}
648 >
649 <span className="min-w-0 flex-1 truncate">
650 {frame.typed > 0 ? (
651 <span className="text-fg">
652 {typing}
653 <span className="tour-caret ml-px inline-block h-[14px] w-px translate-y-[2px] bg-accent" />
654 </span>
655 ) : (
656 <span className="text-faint">Message #web</span>
657 )}
658 </span>
659 <AtSign size={15} className="text-faint" />
660 <Paperclip size={15} className="text-faint" />
661 <span
662 data-tour="send"
663 className={cn(
664 "flex size-7 items-center justify-center rounded-md transition-colors duration-300",
665 frame.typed === ASK.length ? "bg-accent text-bg" : "text-faint",
666 )}
667 >
668 <SendHorizontal size={14} />
669 </span>
670 </div>
671 </div>
672 </div>
673 );
674}
675
676/* Agents -------------------------------------------------------------- */
677
678/** The specialists the demo workspace hired, from the role templates, by team (agents-mode.tsx, orgChart). */
679const SPECIALISTS: { who: string; working?: boolean }[] = [
680 { who: "Otto", working: true },
681 { who: "Margo" },
682 { who: "Inky" },
683 { who: "Sam" },
684 { who: "Dot" },
685];
686
687/** Agents mode's sidebar, as the app has it: the fleet's pages, g1t, then the specialists by team. */
688function AgentsSide({ frame }: { frame: Frame }) {
689 const link = (icon: ReactNode, label: string, on = false) => (
690 <SideRow on={on}>
691 <span className="text-faint">{icon}</span>
692 {label}
693 </SideRow>
694 );
695 return (
696 <>
697 <SideHead action={<Plus size={14} />}>Agents</SideHead>
698 <div className="px-2 pt-2.5">
699 {link(<Activity size={13} />, "Overview")}
700 {link(<Network size={13} />, "Context")}
701 {link(<Brain size={13} />, "Memory")}
702 <SideLabel fold={false}>Orchestrator</SideLabel>
703 <AgentRow who="g1t" line="Orchestrator" />
704 <p className="mt-2.5 px-2 text-[10px] leading-snug text-faint">Specialists are colleagues hired into a role. g1t hands them work.</p>
705 {SPECIALISTS.map((agent) => {
706 const working = agent.who === "Otto" ? !frame.merged : agent.who === "Margo" ? frame.consult && !frame.approved : false;
707 return (
708 <Fragment key={agent.who}>
709 <SideLabel>{AGENTS[agent.who].team} · 1</SideLabel>
710 <AgentRow who={agent.who} line={working ? `Working · ${AGENTS[agent.who].title}` : AGENTS[agent.who].title} working={working} on={agent.who === "Otto"} />
711 </Fragment>
712 );
713 })}
714 <div className="mt-2 flex h-7 items-center gap-2 rounded-md border border-dashed border-line px-2 text-[12px] text-muted">
715 <Plus size={13} />
716 New agent
717 </div>
718 </div>
719 </>
720 );
721}
722
723/** One agent in Agents mode's sidebar: its face and status, name, and what it is doing. */
724function AgentRow({ who, line, working = false, on = false }: { who: string; line: string; working?: boolean; on?: boolean }) {
725 return (
726 <div className={cn("flex h-9 items-center gap-2 rounded-md px-2", on && "bg-raised")}>
727 <span className="relative shrink-0">
728 <Face who={who} size={20} />
729 {who !== "g1t" && (
730 <span className="absolute -right-0.5 -bottom-0.5 flex rounded-full ring-[1.5px] ring-[var(--tour-side)]">
731 <AgentDot working={working} />
732 </span>
733 )}
734 </span>
735 <span className="min-w-0 flex-1 leading-tight">
736 <span className="block truncate text-[12px] font-medium text-fg">{who}</span>
737 <span className="block truncate text-[10px] text-faint">{line}</span>
738 </span>
739 </div>
740 );
741}
742
743const FILES: { path: string; from: number }[] = [
744 { path: "services/export/csv.py", from: 3 },
745 { path: "services/export/query.py", from: 3 },
746 { path: "tests/test_export.py", from: 4 },
747];
748
749/** A colleague brought into the session, in the open: a line anyone can open. */
750function Consult({ frame, compact }: { frame: Frame; compact?: boolean }) {
751 return (
752 <div
753 className={cn(
754 "flex items-center gap-2.5 rounded-xl border border-line bg-surface px-3.5 py-2.5 text-[12px] transition-[opacity,transform] duration-500",
755 frame.consult ? "translate-y-0 opacity-100" : "translate-y-1 opacity-0",
756 )}
757 >
758 <span className="flex -space-x-1">
759 <Face who="Otto" size={18} />
760 <Face who="Margo" size={18} />
761 </span>
762 <span className="min-w-0 flex-1 truncate text-muted">
763 <span className="text-fg-soft">Otto brought in Margo</span> · Helper session
764 {!compact && <span className="text-faint">: &ldquo;Is 1,000 a safe batch size for the 200k test?&rdquo;</span>}
765 </span>
766 <CornerDownRight size={13} className="shrink-0 text-faint" />
767 </div>
768 );
769}
770
771/** The quiet tag on parts of the story that are not built yet. */
772function Soon() {
773 return (
774 <span className="shrink-0 rounded-full bg-raised px-1.5 py-0.5 font-mono text-[9px] tracking-wide whitespace-nowrap text-muted uppercase ring-1 ring-line-strong">
775 Coming soon
776 </span>
777 );
778}
779
780function AgentsMain({ frame }: { frame: Frame }) {
781 const cost = STEPS[frame.steps - 1]?.cost ?? 0;
782 return (
783 <div className="flex h-full flex-col">
784 <div className="flex items-center gap-3 px-5 pt-4 pb-3">
785 <span className="relative">
786 <Face who="Otto" size={36} />
787 <span className="absolute -right-0.5 -bottom-0.5 flex rounded-full ring-2 ring-bg">
788 <AgentDot working={!frame.merged} />
789 </span>
790 </span>
791 <div className="min-w-0">
792 <p className="flex items-center gap-2 text-sm font-semibold text-fg">
793 Otto <AgentTag />
794 </p>
795 <p className="text-xs text-muted">
796 <span className="font-mono">@otto</span> · Software Engineer, Engineering
797 </p>
798 </div>
799 <span className="ml-auto flex h-7 items-center gap-1.5 rounded-md bg-accent px-2.5 text-[11.5px] font-medium text-bg">
800 <MessagesSquare size={12} />
801 Message
802 </span>
803 </div>
804 <div className="flex gap-5 border-b border-line px-5 text-[12px]">
805 {["Sessions", "Memory", "Routines", "Spend", "Profile"].map((tab) => (
806 <span key={tab} className={cn("py-2", tab === "Sessions" ? "border-b-2 border-accent text-fg" : "text-faint")}>
807 {tab}
808 </span>
809 ))}
810 </div>
811 <div className="flex-1 space-y-3 overflow-hidden px-5 py-4">
812 <div className="rounded-xl border border-line bg-surface">
813 <div className="flex items-start gap-3 px-4 pt-3.5 pb-3">
814 <div className="min-w-0 flex-1">
815 <p className="flex flex-wrap items-center gap-2 text-[13px] font-medium text-fg">
816 CSV export times out for big accounts
817 <span className="inline-flex h-4 items-center rounded-[5px] bg-raised px-1.5 text-[10px] font-medium text-muted ring-1 ring-line ring-inset">Chat</span>
818 <Badge key={frame.merged ? "done" : "working"} tone={frame.merged ? "success" : "accent"} className="tour-pop">
819 {frame.merged ? "Done" : "Working"}
820 </Badge>
821 </p>
822 <p className="mt-0.5 text-[11px] text-faint">
823 asked by @priya through @g1t · <span className="text-fg-soft">#web</span> · {frame.steps * 3} steps
824 </p>
825 </div>
826 <div className="w-32 text-right">
827 <p className="text-[12px] text-fg tabular-nums">
828 ${cost.toFixed(2)} <span className="text-faint">of $5.00</span>
829 </p>
830 <span className="mt-1.5 block h-1 overflow-hidden rounded-full bg-line">
831 <span
832 className="block h-full origin-left rounded-full bg-accent transition-transform duration-700"
833 style={{ transform: `scaleX(${cost / 5})` }}
834 />
835 </span>
836 </div>
837 </div>
838 <ol className="space-y-1.5 border-t border-line px-4 py-3">
839 {STEPS.map((step, index) => {
840 const done = index < frame.steps;
841 const now = index === frame.steps;
842 return (
843 <li key={step.text} className={cn("flex items-center gap-2.5 text-[12.5px] transition-colors duration-300", done ? "text-fg-soft" : now ? "text-fg" : "text-faint")}>
844 <span className="flex size-4 shrink-0 items-center justify-center">
845 {done ? (
846 <span className="tour-pop flex size-4 items-center justify-center rounded-full bg-success/15 text-success">
847 <Check size={10} strokeWidth={3} />
848 </span>
849 ) : now ? (
850 <span className="tour-spin size-3.5 rounded-full border-2 border-accent/25 border-t-accent" />
851 ) : (
852 <span className="size-1.5 rounded-full bg-line-strong" />
853 )}
854 </span>
855 {step.text}
856 </li>
857 );
858 })}
859 </ol>
860 <div className="flex items-center gap-2 border-t border-line px-4 py-2.5">
861 <span className="text-[11px] text-faint">Files</span>
862 <span className="flex min-h-[20px] flex-1 flex-wrap gap-1.5">
863 {FILES.filter((file) => frame.steps >= file.from).map((file) => (
864 <span key={file.path} className="tour-pop rounded bg-raised px-1.5 py-0.5 font-mono text-[10.5px] text-muted ring-1 ring-line">
865 {file.path}
866 </span>
867 ))}
868 </span>
869 <span
870 data-tour="task-pull"
871 className={cn(
872 "flex items-center gap-1.5 rounded-md px-2 py-1 text-[12px] font-medium transition-opacity duration-500",
873 frame.steps >= STEPS.length ? "bg-accent/15 text-accent opacity-100" : "text-faint opacity-0",
874 )}
875 >
876 <GitPullRequest size={13} />
877 Pull request #431
878 <ArrowRight size={12} />
879 </span>
880 </div>
881 </div>
882 <Consult frame={frame} />
883 <div className="flex items-center gap-3 rounded-xl border border-line bg-surface px-4 py-3 text-[12px] text-muted">
884 <span className="text-fg-soft">Upgrade the date library across the web app</span>
885 <Badge>Queued</Badge>
886 <span className="ml-auto text-faint">Routine · Mondays</span>
887 </div>
888 </div>
889 </div>
890 );
891}
892
893/* Code ---------------------------------------------------------------- */
894
895/** Code's sidebar in a project, as the app has it (shell.tsx, RepoMenu): its pages in the order people use them. */
896function CodeSide() {
897 const row = (icon: ReactNode, label: string, count?: string, on = false) => (
898 <SideRow on={on}>
899 <span className="text-faint">{icon}</span>
900 <span className="flex-1">{label}</span>
901 {count && <span className="text-[10.5px] text-faint">{count}</span>}
902 </SideRow>
903 );
904 return (
905 <>
906 <SideHead>Code</SideHead>
907 <div className="px-2 pt-2.5">
908 <SideSearch keys>Search or jump to…</SideSearch>
909 <div className="mt-2.5 flex h-7 items-center gap-1 px-1 text-[12px] font-medium text-fg">
910 <ChevronLeft size={14} className="text-faint" />
911 All projects
912 </div>
913 <div className="mt-1 flex items-center gap-2 rounded-md px-2 py-1">
914 <span className="flex size-5 items-center justify-center rounded-md bg-raised text-muted ring-1 ring-line">
915 <Box size={11} />
916 </span>
917 <span className="font-mono text-[12px]">
918 <span className="text-faint">acme/</span>
919 <span className="font-semibold text-fg">web</span>
920 </span>
921 </div>
922 <div className="mx-2 my-2 h-px bg-line" />
923 {row(<Code2 size={13} />, "Code")}
924 {row(<CircleDot size={13} />, "Issues", "12")}
925 {row(<GitPullRequest size={13} />, "Pull requests", "3", true)}
926 {row(<Bot size={13} />, "Agents")}
927 {row(<PlayCircle size={13} />, "Workflows")}
928 <div className="mx-2 my-2 h-px bg-line" />
929 {row(<Rocket size={13} />, "Deployments")}
930 <SideRow>
931 <span className="text-faint">
932 <Activity size={13} />
933 </span>
934 <span className="flex-1">Observability</span>
935 <span className="rounded-full px-1.5 text-[9px] text-faint ring-1 ring-line">Soon</span>
936 </SideRow>
937 {row(<ShieldCheck size={13} />, "Security")}
938 {row(<BarChart3 size={13} />, "Insights")}
939 <div className="mx-2 my-2 h-px bg-line" />
940 {row(<Settings size={13} />, "Settings")}
941 </div>
942 </>
943 );
944}
945
946const DIFF: { sign: " " | "-" | "+"; text: string }[] = [
947 { sign: " ", text: "def export_csv(account, writer):" },
948 { sign: "-", text: " rows = list(rows_for_export(account))" },
949 { sign: "-", text: " for row in rows:" },
950 { sign: "-", text: " writer.writerow(row)" },
951 { sign: "+", text: " for batch in rows_for_export(account, batch_size=1_000):" },
952 { sign: "+", text: " writer.writerows(batch)" },
953 { sign: "+", text: " yield writer.flush()" },
954];
955
956function CodeMain({ frame }: { frame: Frame }) {
957 return (
958 <div className="flex h-full flex-col">
959 <div className="border-b border-line px-5 pt-4 pb-3">
960 <p className="text-[15px] font-semibold text-fg">
961 Stream CSV exports in batches <span className="font-normal text-faint">#431</span>
962 </p>
963 <p className="mt-1.5 flex items-center gap-2 text-[12px] text-muted">
964 <span
965 key={frame.merged ? "merged" : "open"}
966 className={cn(
967 "tour-pop flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-medium",
968 frame.merged ? "bg-merged/15 text-merged" : "bg-success/15 text-success",
969 )}
970 >
971 {frame.merged ? <GitMerge size={11} /> : <GitPullRequest size={11} />}
972 {frame.merged ? "Merged" : "Open"}
973 </span>
974 <span className="text-fg-soft">@otto</span> wants to merge into <span className="font-mono text-[11px] text-fg-soft">main</span> · fixes the
975 request from <span className="text-accent">#web</span>
976 </p>
977 </div>
978 <div className="grid min-h-0 flex-1 grid-cols-[1fr_15rem] gap-4 px-5 py-4">
979 <div className="min-w-0 space-y-3">
980 <div className="flex gap-3 text-[12.5px] leading-[1.6] text-fg-soft">
981 <Face who="Otto" size={24} />
982 <p>
983 Exports loaded every row before sending the first byte, so big accounts hit the 60 s limit. Rows now stream in
984 batches of 1,000. A 200,000-row account takes 3.1 s.
985 </p>
986 </div>
987 <div className="overflow-hidden rounded-xl border border-line bg-surface">
988 <p className="flex items-center gap-2 border-b border-line px-3.5 py-2 font-mono text-[11px] text-muted">
989 <FileText size={12} />
990 services/export/csv.py
991 <span className="ml-auto">
992 <span className="text-success">+3</span> <span className="text-danger">−3</span>
993 </span>
994 </p>
995 <pre className="py-1.5 font-mono text-[11px] leading-[1.75]">
996 {DIFF.map((line, index) => (
997 <div
998 key={index}
999 className={cn(
1000 "px-3.5 transition-opacity duration-500",
1001 line.sign === "+" ? "bg-success/10 text-success" : line.sign === "-" ? "bg-danger/10 text-danger" : "text-muted",
1002 frame.diff ? "opacity-100" : "opacity-0",
1003 )}
1004 style={{ transitionDelay: frame.diff ? `${index * 70}ms` : "0ms" }}
1005 >
1006 <span className="mr-3 inline-block w-2 text-faint select-none">{line.sign}</span>
1007 {line.text}
1008 </div>
1009 ))}
1010 </pre>
1011 </div>
1012 <div
1013 className={cn(
1014 "flex items-center gap-3 rounded-xl px-3.5 py-2.5 text-[12px] ring-1 transition-opacity duration-500",
1015 frame.approved ? "bg-success/5 text-fg-soft opacity-100 ring-success/25" : "opacity-0 ring-line",
1016 )}
1017 >
1018 <Face who="Margo" size={22} />
1019 <span>
1020 <span className="text-fg">@margo</span> approved: batching keeps memory flat, and the 200k-row test covers it.
1021 </span>
1022 <Check size={14} className="ml-auto shrink-0 text-success" />
1023 </div>
1024 </div>
1025 <div className="space-y-3">
1026 <div className="rounded-xl border border-line bg-surface">
1027 <p className="border-b border-line px-3.5 py-2 text-[11px] font-medium text-muted">
1028 Checks · {frame.checks} of {CHECKS.length} passed
1029 </p>
1030 <ul className="space-y-2 px-3.5 py-2.5">
1031 {CHECKS.map((check, index) => {
1032 const done = index < frame.checks;
1033 return (
1034 <li key={check} className="flex items-center gap-2 text-[12px]">
1035 {done ? (
1036 <span className="tour-pop flex size-4 items-center justify-center rounded-full bg-success/15 text-success">
1037 <Check size={10} strokeWidth={3} />
1038 </span>
1039 ) : (
1040 <span className="tour-spin size-3.5 rounded-full border-2 border-warn/25 border-t-warn" />
1041 )}
1042 <span className={done ? "text-fg-soft" : "text-muted"}>{check}</span>
1043 <span className="ml-auto font-mono text-[10px] text-faint">{done ? `${18 + index * 23}s` : ""}</span>
1044 </li>
1045 );
1046 })}
1047 </ul>
1048 </div>
1049 <div
1050 className={cn(
1051 "rounded-xl px-3.5 py-3 text-[12px] ring-1 transition-colors duration-500",
1052 frame.merged ? "bg-merged/10 text-merged ring-merged/30" : frame.approved ? "bg-success/10 text-success ring-success/30" : "text-muted ring-line",
1053 )}
1054 >
1055 <p className="flex items-center gap-2 font-medium">
1056 <GitMerge size={13} />
1057 {frame.merged ? "Merged by the merge queue" : frame.approved ? "In the merge queue" : "Waiting on checks and review"}
1058 </p>
1059 <p className="mt-1 text-[11px] text-faint">{frame.merged ? "Deploying to production" : "Main only moves to what passed."}</p>
1060 </div>
1061 <div
1062 className={cn(
1063 "rounded-xl border border-line bg-surface px-3.5 py-3 text-[12px] transition-opacity duration-500",
1064 frame.checks >= CHECKS.length ? "opacity-100" : "opacity-0",
1065 )}
1066 >
1067 <p className="text-[11px] font-medium text-muted">Preview</p>
1068 <p className="mt-1 truncate font-mono text-[11px] text-accent">pr-431.web.acme.g1t.page</p>
1069 </div>
1070 </div>
1071 </div>
1072 </div>
1073 );
1074}
1075
1076/* Docs ---------------------------------------------------------------- */
1077
1078/** Docs' sidebar: search, then each space's pages. */
1079function DocsSide() {
1080 return (
1081 <>
1082 <SideHead action={<Plus size={14} />}>
1083 Docs
1084 </SideHead>
1085 <div className="px-2 pt-2.5">
1086 <SideSearch>Search docs</SideSearch>
1087 <SideLabel>Product</SideLabel>
1088 {["Getting started", "Billing", "Exporting data", "Integrations"].map((page) => (
1089 <SideRow key={page} on={page === "Exporting data"}>
1090 <FileText size={12} className="text-faint" />
1091 {page}
1092 </SideRow>
1093 ))}
1094 <SideLabel>Engineering</SideLabel>
1095 {["Runbooks", "Decisions"].map((page) => (
1096 <SideRow key={page}>
1097 <FileText size={12} className="text-faint" />
1098 {page}
1099 </SideRow>
1100 ))}
1101 </div>
1102 </>
1103 );
1104}
1105
1106function DocsMain({ frame }: { frame: Frame; tag?: boolean }) {
1107 return (
1108 <div className="relative h-full px-10 py-7">
1109 <p className="text-[11px] text-faint">Product / Exporting data</p>
1110 <h3 className="mt-2 text-[22px] font-semibold tracking-tight text-fg">Exporting data</h3>
1111 <p className="mt-1.5 flex items-center gap-2 text-[11px] text-faint">
1112 Owned by <span className="text-fg-soft">@inky</span> ·{" "}
1113 <span key={frame.docUpdated ? "now" : "then"} className="tour-fade">
1114 {frame.docUpdated ? "updated just now from #431" : "updated 3 weeks ago"}
1115 </span>
1116 </p>
1117 <div className="mt-5 max-w-xl space-y-3 text-[13px] leading-[1.7] text-fg-soft">
1118 <p>Export any table as CSV from Settings, then Data. An export includes every row you can see, in the order shown.</p>
1119 <p className="pt-2 text-[14px] font-semibold text-fg">Large accounts</p>
1120 <div className="relative">
1121 <p
1122 className={cn(
1123 "rounded-md px-3 py-2 transition-all duration-500",
1124 frame.docUpdated ? "bg-danger/5 text-faint line-through decoration-danger/50" : "text-fg-soft",
1125 )}
1126 >
1127 Exports over 50,000 rows can time out. Split them by date range.
1128 </p>
1129 <div
1130 className={cn(
1131 "mt-1.5 rounded-md border-l-2 border-success bg-success/10 px-3 py-2 text-fg transition-all duration-700",
1132 frame.docUpdated ? "translate-y-0 opacity-100" : "translate-y-1 opacity-0",
1133 )}
1134 >
1135 Exports of any size stream in batches of 1,000 rows. A 200,000-row account finishes in about 3 seconds.
1136 </div>
1137 </div>
1138 <div
1139 className={cn(
1140 "flex items-center gap-2 pt-1 text-[11px] text-muted transition-opacity delay-300 duration-500",
1141 frame.docUpdated ? "opacity-100" : "opacity-0",
1142 )}
1143 >
1144 <Face who="Inky" size={18} />
1145 <span>
1146 Updated by <span className="text-fg">@inky</span> after <span className="text-accent">#431</span> merged
1147 </span>
1148 </div>
1149 <p className="pt-3 text-[14px] font-semibold text-fg">Scheduled exports</p>
1150 <p>Owners can send an export to a storage bucket every day or week. Each run uses the same columns as the last.</p>
1151 </div>
1152 </div>
1153 );
1154}
1155
1156/* The cursor ------------------------------------------------------------ */
1157
1158function Cursor({ frame, canvas, scale }: { frame: Frame; canvas: React.RefObject<HTMLDivElement | null>; scale: number }) {
1159 const [at, setAt] = useState<{ x: number; y: number } | null>(null);
1160 useIsoLayoutEffect(() => {
1161 const root = canvas.current;
1162 if (!root) return;
1163 const box = root.getBoundingClientRect();
1164 const target = frame.cursor === "idle" ? null : root.querySelector<HTMLElement>(`[data-tour="${frame.cursor}"]`);
1165 if (!target) {
1166 setAt({ x: W * 0.64, y: H * 0.88 });
1167 return;
1168 }
1169 const rect = target.getBoundingClientRect();
1170 // The composer is clicked towards its right, clear of the words being typed; everything else in its middle.
1171 const across = frame.cursor === "composer" ? 0.82 : 0.5;
1172 setAt({ x: (rect.left - box.left + rect.width * across) / scale, y: (rect.top - box.top + rect.height * 0.55) / scale });
1173 }, [frame.cursor, scale, canvas]);
1174 if (!at) return null;
1175 return (
1176 <div
1177 aria-hidden="true"
1178 className={cn("pointer-events-none absolute top-0 left-0 z-20 transition-[transform,opacity] duration-[900ms] ease-[cubic-bezier(.45,.05,.2,1)]", frame.cursorShown ? "opacity-100" : "opacity-0")}
1179 style={{ transform: `translate(${at.x}px, ${at.y}px)` }}
1180 >
1181 <span
1182 className={cn(
1183 "absolute -top-4 -left-4 size-8 rounded-full bg-accent/30 transition-[transform,opacity] duration-300",
1184 frame.click ? "scale-100 opacity-100" : "scale-50 opacity-0",
1185 )}
1186 />
1187 <svg
1188 width="18"
1189 height="20"
1190 viewBox="0 0 18 20"
1191 className={cn("relative drop-shadow-[0_2px_6px_rgb(0_0_0/0.5)] transition-transform duration-150", frame.click && "scale-90")}
1192 >
1193 <path d="M1.5 1.5 L1.5 15.5 L5.5 12 L8.3 18 L11 16.8 L8.3 11 L13.5 11 Z" fill="#ededef" stroke="#0f0f11" strokeWidth="1.2" strokeLinejoin="round" />
1194 </svg>
1195 </div>
1196 );
1197}
1198
1199/* Desktop ------------------------------------------------------------- */
1200
1201const SIDES: Record<Scene, (props: { frame: Frame }) => ReactNode> = {
1202 chat: ChatSide,
1203 agents: AgentsSide,
1204 code: CodeSide,
1205 docs: DocsSide,
1206};
1207
1208/** Where each page is, as the top bar's trail says it; Chat has no top bar on a computer. */
1209const CRUMBS: Record<Scene, { label: string; mono?: boolean }[] | null> = {
1210 chat: null,
1211 agents: [{ label: "acme", mono: true }, { label: "Agents" }],
1212 code: [{ label: "acme", mono: true }, { label: "web", mono: true }, { label: "Pull request #431" }],
1213 docs: [{ label: "acme", mono: true }, { label: "Docs" }],
1214};
1215
1216/** The sidebar's colour, a shade lighter than the page (shell.tsx, SIDEBAR_BOX). */
1217const SIDE_BG = "color-mix(in srgb, var(--color-surface) 70%, var(--color-bg))";
1218
1219function Desktop({ frame, reduced }: { frame: Frame; reduced: boolean }) {
1220 const outer = useRef<HTMLDivElement | null>(null);
1221 const canvas = useRef<HTMLDivElement | null>(null);
1222 const [scale, setScale] = useState(1);
1223 useEffect(() => {
1224 const element = outer.current;
1225 if (!element || typeof ResizeObserver === "undefined") return;
1226 const observer = new ResizeObserver(([entry]) => {
1227 const width = entry?.contentRect.width ?? W;
1228 if (width > 0) setScale(width / W);
1229 });
1230 observer.observe(element);
1231 return () => observer.disconnect();
1232 }, []);
1233 const scenes: Scene[] = ["code", "chat", "agents", "docs"];
1234 const mains: Record<Scene, ReactNode> = {
1235 chat: <ChatMain frame={frame} />,
1236 agents: <AgentsMain frame={frame} />,
1237 code: <CodeMain frame={frame} />,
1238 docs: <DocsMain frame={frame} />,
1239 };
1240 return (
1241 <div ref={outer} className="relative w-full overflow-hidden rounded-2xl shadow-2xl shadow-black/50 ring-1 ring-line" style={{ aspectRatio: `${W} / ${H}` }}>
1242 <div
1243 ref={canvas}
1244 className="absolute top-0 left-0 flex origin-top-left bg-bg text-left"
1245 style={{ width: W, height: H, transform: scale === 1 ? undefined : `scale(${scale})`, ["--tour-side" as string]: SIDE_BG } as CSSProperties}
1246 >
1247 <Rail scene={frame.scene} />
1248 <div className="relative min-w-0 flex-1">
1249 {scenes.map((scene) => {
1250 const Side = SIDES[scene];
1251 const on = frame.scene === scene;
1252 const crumbs = CRUMBS[scene];
1253 return (
1254 <div
1255 key={scene}
1256 aria-hidden={!on}
1257 className={cn(
1258 "absolute inset-0 flex transition-[opacity,transform] duration-500 ease-out",
1259 on ? "translate-y-0 opacity-100" : "pointer-events-none translate-y-1 opacity-0",
1260 )}
1261 >
1262 <div className="flex w-52 shrink-0 flex-col overflow-hidden border-r border-line" style={{ background: SIDE_BG }}>
1263 <Side frame={frame} />
1264 </div>
1265 <div className="flex min-w-0 flex-1 flex-col">
1266 {crumbs && <TopBar crumbs={crumbs} />}
1267 <div className="min-h-0 flex-1">{mains[scene]}</div>
1268 </div>
1269 </div>
1270 );
1271 })}
1272 </div>
1273 {!reduced && <Cursor frame={frame} canvas={canvas} scale={scale} />}
1274 </div>
1275 </div>
1276 );
1277}
1278
1279/* Phone --------------------------------------------------------------- */
1280
1281const MODE: Record<Scene, { icon: ReactNode; label: string }> = {
1282 chat: { icon: <Hash size={14} />, label: "web" },
1283 agents: { icon: <Sparkles size={14} />, label: "Otto · Sessions" },
1284 code: { icon: <GitPullRequest size={14} />, label: "Pull request #431" },
1285 docs: { icon: <BookOpen size={14} />, label: "Exporting data" },
1286};
1287
1288/** The phone's tab bar, as the app has it (components/mobile.tsx). */
1289const TABS: { scene: Scene | null; icon: ReactNode; label: string }[] = [
1290 { scene: null, icon: <House size={16} />, label: "Home" },
1291 { scene: "code", icon: <Code2 size={16} />, label: "Code" },
1292 { scene: "chat", icon: <MessagesSquare size={16} />, label: "Chat" },
1293 { scene: "agents", icon: <Sparkles size={16} />, label: "Agents" },
1294 { scene: null, icon: <Inbox size={16} />, label: "Inbox" },
1295];
1296
1297function Phone({ frame }: { frame: Frame }) {
1298 const scenes: Scene[] = ["code", "chat", "agents", "docs"];
1299 return (
1300 <div className="relative flex h-[34rem] w-full flex-col overflow-hidden rounded-2xl bg-bg text-left shadow-2xl shadow-black/50 ring-1 ring-line">
1301 <div className="flex h-12 shrink-0 items-center gap-2 border-b border-line px-3">
1302 <Avatar name="acme" size={26} square />
1303 <span key={frame.scene} className="tour-fade flex min-w-0 items-center gap-1.5 text-[13px] font-semibold text-fg">
1304 <span className="text-faint">{MODE[frame.scene].icon}</span>
1305 <span className="truncate">{MODE[frame.scene].label}</span>
1306 </span>
1307 </div>
1308 <div className="relative min-h-0 flex-1">
1309 {scenes.map((scene) => {
1310 const on = frame.scene === scene;
1311 return (
1312 <div
1313 key={scene}
1314 aria-hidden={!on}
1315 className={cn("absolute inset-0 transition-opacity duration-500", on ? "opacity-100" : "pointer-events-none opacity-0")}
1316 >
1317 {scene === "chat" && <PhoneChat frame={frame} />}
1318 {scene === "agents" && <PhoneAgents frame={frame} />}
1319 {scene === "code" && <PhoneCode frame={frame} />}
1320 {scene === "docs" && (
1321 <div className="origin-top-left scale-[0.86]" style={{ width: "116%" }}>
1322 <DocsMain frame={frame} tag={false} />
1323 </div>
1324 )}
1325 </div>
1326 );
1327 })}
1328 </div>
1329 {/* Inside a conversation the tab bar steps aside, as it does on a phone. */}
1330 {frame.scene !== "chat" && (
1331 <div className="grid h-12 shrink-0 grid-cols-5 border-t border-line bg-[#0b0b0d]">
1332 {TABS.map((tab) => {
1333 const on = tab.scene === frame.scene;
1334 return (
1335 <span key={tab.label} className="flex flex-col items-center justify-center gap-0.5">
1336 <span className={cn("flex h-6 w-10 items-center justify-center rounded-full", on ? "bg-[#2c2c33] text-fg" : "text-muted")}>{tab.icon}</span>
1337 <span className={cn("text-[9.5px] font-medium", on ? "text-fg" : "text-faint")}>{tab.label}</span>
1338 </span>
1339 );
1340 })}
1341 </div>
1342 )}
1343 </div>
1344 );
1345}
1346
1347function PhoneChat({ frame }: { frame: Frame }) {
1348 return (
1349 <div className="flex h-full flex-col">
1350 <div className="flex min-h-0 flex-1 flex-col justify-end overflow-hidden [mask-image:linear-gradient(to_bottom,transparent,black_2.5rem)] [&>div]:px-3">
1351 <Message who="Priya" time="09:02">
1352 <p>Morning! Thursday&apos;s release closes Wednesday night.</p>
1353 </Message>
1354 <Message who="Dana" time="09:12">
1355 <p>Two enterprise customers hit the CSV export timeout again this morning.</p>
1356 </Message>
1357 {frame.sent && (
1358 <Message who="Priya" time="09:15" enter>
1359 <p>
1360 <Rich text={ASK} />
1361 </p>
1362 </Message>
1363 )}
1364 {frame.g1tTyping && (
1365 <div className="tour-in flex items-center gap-3 py-2 text-[12px] text-faint">
1366 <Face who="g1t" />
1367 <Dots />
1368 </div>
1369 )}
1370 {frame.handoff && (
1371 <Message who="g1t" time="09:15" enter>
1372 <p>
1373 <Rich text="On it. @otto will fix it and @margo will review." />
1374 </p>
1375 <TaskCard frame={frame} />
1376 </Message>
1377 )}
1378 {frame.shipped && (
1379 <Message who="g1t" time="09:41" enter>
1380 <p>
1381 <Rich text="Shipped. @sam, can you tell #support?" />
1382 </p>
1383 </Message>
1384 )}
1385 {frame.sam && (
1386 <Message who="Sam" time="09:42" enter>
1387 <p>
1388 <Rich text="Done. Told #support." />
1389 </p>
1390 </Message>
1391 )}
1392 </div>
1393 <div className="px-3 pt-1 pb-3">
1394 <div className={cn("flex items-center gap-2 rounded-xl bg-surface px-3 py-2.5 text-[13px] ring-1", frame.typed > 0 ? "ring-accent/50" : "ring-line")}>
1395 <span className="min-w-0 flex-1 truncate">
1396 {frame.typed > 0 ? (
1397 <span className="text-fg">
1398 {ASK.slice(Math.max(0, frame.typed - 34), frame.typed)}
1399 <span className="tour-caret ml-px inline-block h-[14px] w-px translate-y-[2px] bg-accent" />
1400 </span>
1401 ) : (
1402 <span className="text-faint">Message #web</span>
1403 )}
1404 </span>
1405 <SendHorizontal size={15} className={frame.typed === ASK.length ? "text-accent" : "text-faint"} />
1406 </div>
1407 </div>
1408 </div>
1409 );
1410}
1411
1412function PhoneAgents({ frame }: { frame: Frame }) {
1413 const cost = STEPS[frame.steps - 1]?.cost ?? 0;
1414 return (
1415 <div className="space-y-3 px-3 py-4">
1416 <div className="flex items-center gap-3">
1417 <Face who="Otto" size={32} />
1418 <div>
1419 <p className="flex items-center gap-2 text-sm font-semibold text-fg">
1420 Otto <AgentTag />
1421 </p>
1422 <p className="text-[11px] text-muted">{frame.merged ? "Shipped #431" : "Working"} · ${cost.toFixed(2)} of $5</p>
1423 </div>
1424 </div>
1425 <div className="rounded-xl border border-line bg-surface">
1426 <p className="border-b border-line px-3.5 py-2.5 text-[13px] font-medium text-fg">CSV export times out for big accounts</p>
1427 <ol className="space-y-2 px-3.5 py-3">
1428 {STEPS.map((step, index) => {
1429 const done = index < frame.steps;
1430 const now = index === frame.steps;
1431 return (
1432 <li key={step.text} className={cn("flex items-start gap-2.5 text-[12.5px]", done ? "text-fg-soft" : now ? "text-fg" : "text-faint")}>
1433 <span className="mt-0.5 flex size-4 shrink-0 items-center justify-center">
1434 {done ? (
1435 <span className="tour-pop flex size-4 items-center justify-center rounded-full bg-success/15 text-success">
1436 <Check size={10} strokeWidth={3} />
1437 </span>
1438 ) : now ? (
1439 <span className="tour-spin size-3.5 rounded-full border-2 border-accent/25 border-t-accent" />
1440 ) : (
1441 <span className="size-1.5 rounded-full bg-line-strong" />
1442 )}
1443 </span>
1444 {step.text}
1445 </li>
1446 );
1447 })}
1448 </ol>
1449 <div className="flex flex-wrap gap-1.5 border-t border-line px-3.5 py-2.5">
1450 {FILES.filter((file) => frame.steps >= file.from).map((file) => (
1451 <span key={file.path} className="tour-pop rounded bg-raised px-1.5 py-0.5 font-mono text-[10px] text-muted ring-1 ring-line">
1452 {file.path}
1453 </span>
1454 ))}
1455 <span className="min-h-[18px]" />
1456 </div>
1457 </div>
1458 <div
1459 className={cn(
1460 "flex items-center gap-2 rounded-xl bg-accent/10 px-3.5 py-2.5 text-[12px] font-medium text-accent ring-1 ring-accent/25 transition-opacity duration-500",
1461 frame.steps >= STEPS.length ? "opacity-100" : "opacity-0",
1462 )}
1463 >
1464 <GitPullRequest size={13} />
1465 Pull request #431 opened
1466 <ArrowRight size={12} className="ml-auto" />
1467 </div>
1468 <Consult frame={frame} compact />
1469 <div className="flex items-center gap-2 rounded-xl px-3.5 py-2.5 text-[11.5px] text-faint ring-dashed">
1470 <span className="rounded bg-raised px-1.5 py-0.5 text-[9.5px] uppercase">Queued</span>
1471 Upgrade the date library
1472 </div>
1473 </div>
1474 );
1475}
1476
1477function PhoneCode({ frame }: { frame: Frame }) {
1478 return (
1479 <div className="space-y-3 px-3 py-4">
1480 <p className="text-[14px] font-semibold text-fg">
1481 Stream CSV exports in batches <span className="font-normal text-faint">#431</span>
1482 </p>
1483 <p className="text-[12px] leading-[1.6] text-muted">
1484 <span className="text-fg-soft">@otto</span> wants to merge into <span className="font-mono text-[11px] text-fg-soft">main</span>. Rows now
1485 stream in batches of 1,000; a 200,000-row account takes 3.1 s.
1486 </p>
1487 <div className="rounded-xl border border-line bg-surface">
1488 <ul className="space-y-2 px-3.5 py-3">
1489 {CHECKS.map((check, index) => {
1490 const done = index < frame.checks;
1491 return (
1492 <li key={check} className="flex items-center gap-2 text-[12px]">
1493 {done ? (
1494 <span className="tour-pop flex size-4 items-center justify-center rounded-full bg-success/15 text-success">
1495 <Check size={10} strokeWidth={3} />
1496 </span>
1497 ) : (
1498 <span className="tour-spin size-3.5 rounded-full border-2 border-warn/25 border-t-warn" />
1499 )}
1500 <span className={done ? "text-fg-soft" : "text-muted"}>{check}</span>
1501 </li>
1502 );
1503 })}
1504 </ul>
1505 </div>
1506 <pre className={cn("overflow-hidden rounded-xl border border-line bg-surface py-1.5 font-mono text-[10.5px] leading-[1.7] transition-opacity duration-500", frame.diff ? "opacity-100" : "opacity-0")}>
1507 {DIFF.slice(1).map((line, index) => (
1508 <div key={index} className={cn("truncate px-3", line.sign === "+" ? "bg-success/10 text-success" : "bg-danger/10 text-danger")}>
1509 {line.sign} {line.text.trim()}
1510 </div>
1511 ))}
1512 </pre>
1513 <div
1514 className={cn(
1515 "flex items-center gap-2 rounded-xl px-3.5 py-2.5 text-[12px] ring-1 transition-colors duration-500",
1516 frame.merged ? "bg-merged/10 text-merged ring-merged/30" : frame.approved ? "bg-success/10 text-success ring-success/30" : "text-muted ring-line",
1517 )}
1518 >
1519 <GitMerge size={13} />
1520 {frame.merged ? "Merged · deploying" : frame.approved ? "Approved by @margo" : "Waiting on checks and review"}
1521 </div>
1522 </div>
1523 );
1524}
1525
1526/* The tour -------------------------------------------------------------- */
1527
1528/** Keyframes the tour's parts use; transforms and opacity only. */
1529const STYLES = `
1530.tour-in { animation: tour-in 420ms cubic-bezier(.2,.7,.2,1) both; }
1531@keyframes tour-in { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
1532.tour-pop { animation: tour-pop 360ms cubic-bezier(.3,1.4,.5,1) both; }
1533@keyframes tour-pop { from { opacity: 0; transform: scale(.7); } to { opacity: 1; transform: none; } }
1534.tour-fade { animation: tour-fade 400ms ease-out both; }
1535@keyframes tour-fade { from { opacity: 0; } to { opacity: 1; } }
1536.tour-dot { animation: tour-dot 1.1s ease-in-out infinite; }
1537@keyframes tour-dot { 0%, 60%, 100% { opacity: .35; transform: translateY(0); } 30% { opacity: 1; transform: translateY(-2px); } }
1538.tour-caret { animation: tour-caret 1s steps(1) infinite; }
1539@keyframes tour-caret { 50% { opacity: 0; } }
1540.tour-spin { animation: tour-spin .9s linear infinite; }
1541@keyframes tour-spin { to { transform: rotate(360deg); } }
1542.tour-pulse { animation: tour-pulse 1.6s ease-in-out infinite; }
1543@keyframes tour-pulse { 50% { opacity: .35; } }
1544.ring-dashed { box-shadow: none; outline: 1px dashed var(--g1t-line-strong); outline-offset: -1px; }
1545@media (prefers-reduced-motion: reduce) {
1546 .tour-in, .tour-pop, .tour-fade, .tour-dot, .tour-caret, .tour-spin, .tour-pulse { animation: none; }
1547}
1548`;
1549
1550/**
1551 * The hero's tour: the frame (a full app on wider screens, one pane on a
1552 * phone), the step pills under it, and a line saying what is a preview.
1553 */
1554export function ProductTour({ className }: { className?: string }) {
1555 const clock = useTourClock();
1556 const { frame } = clock;
1557 return (
1558 <div ref={clock.rootRef} className={className}>
1559 <style dangerouslySetInnerHTML={{ __html: STYLES }} />
1560 <div
1561 role="img"
1562 aria-label="A tour of g1t: a person asks g1t in a channel to fix a slow export, an agent works the task, the pull request passes its checks and merges, g1t says it shipped, and the docs page updates."
1563 onMouseEnter={() => clock.hover(true)}
1564 onMouseLeave={() => clock.hover(false)}
1565 >
1566 <div className="hidden md:block">
1567 <Desktop frame={frame} reduced={clock.reduced} />
1568 </div>
1569 <div className="md:hidden">
1570 <Phone frame={frame} />
1571 </div>
1572 </div>
1573 <div className="mt-4 flex justify-center" aria-live="polite">
1574 <HonestyNote frame={frame} />
1575 </div>
1576 <div className="mx-auto mt-3 grid max-w-xl grid-cols-4 gap-2" role="tablist" aria-label="Steps of the tour">
1577 {PILLS.map((pill, index) => {
1578 const on = clock.pill === index;
1579 return (
1580 <button
1581 key={pill.scene}
1582 type="button"
1583 role="tab"
1584 aria-selected={on}
1585 onClick={() => clock.jump(index)}
1586 className={cn(
1587 "group relative overflow-hidden rounded-full px-3 py-1.5 text-xs font-medium ring-1 transition-colors",
1588 on ? "bg-surface text-fg ring-line-strong" : "text-muted ring-line hover:text-fg-soft",
1589 )}
1590 >
1591 <span
1592 ref={(element) => {
1593 clock.barRefs.current[index] = element;
1594 }}
1595 aria-hidden="true"
1596 className="absolute inset-0 origin-left bg-accent/15"
1597 style={{ transform: `scaleX(${clock.reduced && on ? 1 : 0})` }}
1598 />
1599 <span className="relative flex items-center justify-center gap-1.5">
1600 {pill.label}
1601 {pill.soon && (
1602 <>
1603 <span className="hidden text-[10px] font-normal text-faint sm:inline">soon</span>
1604 <span className="size-1 rounded-full bg-faint sm:hidden" aria-label="coming soon" />
1605 </>
1606 )}
1607 </span>
1608 </button>
1609 );
1610 })}
1611 </div>
1612 <p className="mx-auto mt-3 max-w-xl text-center text-xs leading-5 text-faint text-balance">
1613 Chat, agents and their sessions (colleagues brought in included), pull requests, checks and the merge queue work
1614 today, and so does Docs. g1t splitting one request between colleagues on its own, and an agent updating a page after a merge by itself, are coming soon. Otto, Margo and the others are
1615 agents this workspace hired from role templates; only @g1t comes built in.
1616 </p>
1617 </div>
1618 );
1619}