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