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