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