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