| 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 session its author's coding agent recorded onto it, the |
| 6 | * people in #web hearing it shipped, and 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, Chat, Code and Artifacts, with names), |
| 17 | * each mode's sidebar with its real sections and words, the same top bar |
| 18 | * (where you are, Explore, Docs, the inbox and +), which Chat gives up to |
| 19 | * the conversation's own header as the app does, and the app's own cards. |
| 20 | * The coding agent is one people bring: its commits carry its mark, as |
| 21 | * components/coding-agent.tsx draws it, and its session is the pull |
| 22 | * request's Session tab. Everything the tour shows works today. |
| 23 | */ |
| 24 | import { |
| 25 | Activity, |
| 26 | AtSign, |
| 27 | BarChart3, |
| 28 | Bell, |
| 29 | BookOpen, |
| 30 | Bot, |
| 31 | Box, |
| 32 | Building2, |
| 33 | Check, |
| 34 | ChevronDown, |
| 35 | ChevronLeft, |
| 36 | CircleDot, |
| 37 | CircleHelp, |
| 38 | Code2, |
| 39 | Compass, |
| 40 | FileDiff, |
| 41 | FileText, |
| 42 | GitCommitHorizontal, |
| 43 | GitMerge, |
| 44 | GitPullRequest, |
| 45 | Hash, |
| 46 | House, |
| 47 | MessageSquare, |
| 48 | MessagesSquare, |
| 49 | Paperclip, |
| 50 | PlayCircle, |
| 51 | Plus, |
| 52 | Rocket, |
| 53 | Search, |
| 54 | SendHorizontal, |
| 55 | Settings, |
| 56 | ShieldCheck, |
| 57 | Shapes, |
| 58 | SquarePen, |
| 59 | User, |
| 60 | Wrench, |
| 61 | } from "lucide-react"; |
| 62 | import { type CSSProperties, Fragment, type ReactNode, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; |
| 63 | |
| 64 | import { codingAgentById } from "@g1t/contracts/coding-agents"; |
| 65 | |
| 66 | import { cn } from "../lib/cn"; |
| 67 | import { CHECKS, type CursorTarget, type Frame, LOOP_MS, PILLS, SESSION, type SessionEntry, STILLS, type Scene, frameAt, pillAt, pillProgress, sameFrame } from "../lib/tour"; |
| 68 | import { AgentBadge, AgentMark } from "./coding-agent"; |
| 69 | |
| 70 | import { Avatar } from "./ui/avatar"; |
| 71 | import { Card } from "./ui/card"; |
| 72 | |
| 73 | /** The frame's design size; it scales to fit, keeping its aspect. */ |
| 74 | const W = 992; |
| 75 | const H = 600; |
| 76 | |
| 77 | const useIsoLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect; |
| 78 | |
| 79 | /* ------------------------------------------------------------------ */ |
| 80 | /* The clock */ |
| 81 | /* ------------------------------------------------------------------ */ |
| 82 | |
| 83 | type Clock = { |
| 84 | frame: Frame; |
| 85 | reduced: boolean; |
| 86 | /** The pill shown as current. */ |
| 87 | pill: number; |
| 88 | jump: (index: number) => void; |
| 89 | hover: (on: boolean) => void; |
| 90 | /** Observed for being on screen. */ |
| 91 | rootRef: React.RefObject<HTMLDivElement | null>; |
| 92 | /** The pills' progress bars, written each tick. */ |
| 93 | barRefs: React.RefObject<(HTMLSpanElement | null)[]>; |
| 94 | }; |
| 95 | |
| 96 | function useTourClock(): Clock { |
| 97 | const [frame, setFrame] = useState<Frame>(() => frameAt(0)); |
| 98 | const [reduced, setReduced] = useState(false); |
| 99 | const [still, setStill] = useState(0); |
| 100 | const rootRef = useRef<HTMLDivElement | null>(null); |
| 101 | const barRefs = useRef<(HTMLSpanElement | null)[]>([]); |
| 102 | const time = useRef(0); |
| 103 | const shown = useRef(frame); |
| 104 | const paused = useRef({ hover: false, offscreen: true, hidden: false }); |
| 105 | |
| 106 | const paint = useCallback((t: number) => { |
| 107 | const next = frameAt(t); |
| 108 | if (!sameFrame(next, shown.current)) { |
| 109 | shown.current = next; |
| 110 | setFrame(next); |
| 111 | } |
| 112 | const current = pillAt(t); |
| 113 | const progress = pillProgress(t); |
| 114 | barRefs.current.forEach((bar, index) => { |
| 115 | if (!bar) return; |
| 116 | const amount = index === current ? progress : 0; |
| 117 | bar.style.transform = `scaleX(${amount})`; |
| 118 | }); |
| 119 | }, []); |
| 120 | |
| 121 | useEffect(() => { |
| 122 | const motion = window.matchMedia("(prefers-reduced-motion: reduce)"); |
| 123 | if (motion.matches) { |
| 124 | setReduced(true); |
| 125 | const next = frameAt(STILLS.code); |
| 126 | shown.current = next; |
| 127 | setFrame(next); |
| 128 | return; |
| 129 | } |
| 130 | const root = rootRef.current; |
| 131 | const observer = |
| 132 | typeof IntersectionObserver === "undefined" |
| 133 | ? null |
| 134 | : new IntersectionObserver(([entry]) => { |
| 135 | paused.current.offscreen = !entry?.isIntersecting; |
| 136 | }); |
| 137 | if (root && observer) observer.observe(root); |
| 138 | else paused.current.offscreen = false; |
| 139 | const visibility = () => { |
| 140 | paused.current.hidden = document.hidden; |
| 141 | }; |
| 142 | visibility(); |
| 143 | document.addEventListener("visibilitychange", visibility); |
| 144 | |
| 145 | let last: number | null = null; |
| 146 | let raf = 0; |
| 147 | const tick = (now: number) => { |
| 148 | const { hover, offscreen, hidden } = paused.current; |
| 149 | // A long gap (a background tab, a debugger) moves time on by one frame, not by the gap. |
| 150 | if (last !== null && !hover && !offscreen && !hidden) time.current = (time.current + Math.min(now - last, 64)) % LOOP_MS; |
| 151 | last = now; |
| 152 | paint(time.current); |
| 153 | raf = requestAnimationFrame(tick); |
| 154 | }; |
| 155 | raf = requestAnimationFrame(tick); |
| 156 | return () => { |
| 157 | cancelAnimationFrame(raf); |
| 158 | observer?.disconnect(); |
| 159 | document.removeEventListener("visibilitychange", visibility); |
| 160 | }; |
| 161 | }, [paint]); |
| 162 | |
| 163 | const jump = useCallback( |
| 164 | (index: number) => { |
| 165 | const pill = PILLS[index]; |
| 166 | if (reduced) { |
| 167 | setStill(index); |
| 168 | const next = frameAt(STILLS[pill.scene]); |
| 169 | shown.current = next; |
| 170 | setFrame(next); |
| 171 | return; |
| 172 | } |
| 173 | time.current = pill.start + 1; |
| 174 | paint(time.current); |
| 175 | }, |
| 176 | [paint, reduced], |
| 177 | ); |
| 178 | |
| 179 | const hover = useCallback((on: boolean) => { |
| 180 | paused.current.hover = on; |
| 181 | }, []); |
| 182 | |
| 183 | return { frame, reduced, pill: reduced ? still : pillOf(frame), jump, hover, rootRef, barRefs }; |
| 184 | } |
| 185 | |
| 186 | /** The pill a frame belongs to, from what it shows (the clock's time is not React state). */ |
| 187 | function pillOf(frame: Frame): number { |
| 188 | return Math.max(0, PILLS.findIndex((pill) => pill.scene === frame.scene)); |
| 189 | } |
| 190 | |
| 191 | /** What the line under the frame says about the moment. Everything in the tour works today. */ |
| 192 | const HONESTY: Record<Scene, string> = { |
| 193 | code: "Works today", |
| 194 | session: "Works today: recorded by your coding agent through MCP", |
| 195 | chat: "Works today", |
| 196 | docs: "Works today", |
| 197 | }; |
| 198 | |
| 199 | /** The line under the frame. Not part of the app, so it sits outside it. */ |
| 200 | function HonestyNote({ frame, className }: { frame: Frame; className?: string }) { |
| 201 | const label = HONESTY[frame.scene]; |
| 202 | return ( |
| 203 | <span |
| 204 | key={label} |
| 205 | className={cn( |
| 206 | "tour-fade inline-flex items-center gap-1.5 rounded-full bg-success/10 px-2.5 py-1 text-[11px] font-medium text-success ring-1 ring-success/25", |
| 207 | className, |
| 208 | )} |
| 209 | > |
| 210 | <span className="size-1.5 rounded-full bg-success" /> |
| 211 | {label} |
| 212 | </span> |
| 213 | ); |
| 214 | } |
| 215 | |
| 216 | /* ------------------------------------------------------------------ */ |
| 217 | /* Small parts */ |
| 218 | /* ------------------------------------------------------------------ */ |
| 219 | |
| 220 | /** The people in the story. */ |
| 221 | const PEOPLE: Record<string, string> = { Priya: "Priya Shah", Dana: "Dana Ruiz", Alex: "Alex Lee", Jordan: "Jordan Kim" }; |
| 222 | |
| 223 | /** The coding agent Priya brought: known by the address on its commits, not an account on g1t. */ |
| 224 | const CLAUDE = codingAgentById("claude")!; |
| 225 | |
| 226 | /** A person's avatar, as the app draws it. */ |
| 227 | function Face({ who, size = 28 }: { who: string; size?: number }) { |
| 228 | return <Avatar name={who.toLowerCase()} size={size} />; |
| 229 | } |
| 230 | |
| 231 | /** The coding agent's mark, its name and its badge, as commits and sessions show them. */ |
| 232 | function WithAgent() { |
| 233 | return ( |
| 234 | <span className="inline-flex items-center gap-1"> |
| 235 | <AgentMark agent={CLAUDE} size={14} /> |
| 236 | <span className="text-fg-soft">{CLAUDE.name}</span> |
| 237 | <AgentBadge agent={CLAUDE} /> |
| 238 | </span> |
| 239 | ); |
| 240 | } |
| 241 | |
| 242 | /** A person's presence, as the app shows it on their avatar: active, or away. */ |
| 243 | function PresenceDot({ away, ring = "var(--tour-side)" }: { away?: boolean; ring?: string }) { |
| 244 | return ( |
| 245 | <span |
| 246 | className={cn("absolute -right-0.5 -bottom-0.5 size-[7px] rounded-full", away ? "bg-[var(--tour-side)] shadow-[inset_0_0_0_1.5px_var(--color-faint)]" : "bg-success")} |
| 247 | style={{ boxShadow: `0 0 0 1.5px ${ring}${away ? ", inset 0 0 0 1.5px var(--color-faint)" : ""}` }} |
| 248 | /> |
| 249 | ); |
| 250 | } |
| 251 | |
| 252 | /** Text with @mentions, #channels and #numbers picked out, as chat shows them. */ |
| 253 | function Rich({ text }: { text: string }) { |
| 254 | const parts = text.split(/(@[a-z0-9-]+|#[a-z0-9]+)/gi); |
| 255 | return ( |
| 256 | <> |
| 257 | {parts.map((part, index) => |
| 258 | /^[@#]/.test(part) ? ( |
| 259 | <span key={index} className={part.startsWith("@") ? "rounded bg-accent/15 px-0.5 text-accent" : "text-accent"}> |
| 260 | {part} |
| 261 | </span> |
| 262 | ) : ( |
| 263 | part |
| 264 | ), |
| 265 | )} |
| 266 | </> |
| 267 | ); |
| 268 | } |
| 269 | |
| 270 | function Message({ who, time, children, enter }: { who: string; time: string; children: ReactNode; enter?: boolean }) { |
| 271 | return ( |
| 272 | <div className={cn("flex gap-3 px-5 py-2", enter && "tour-in")}> |
| 273 | <Face who={who} /> |
| 274 | <div className="min-w-0 flex-1"> |
| 275 | <p className="flex flex-wrap items-baseline gap-x-1.5 text-[13px]"> |
| 276 | <span className="font-semibold text-fg">{PEOPLE[who] ?? who}</span> |
| 277 | <span className="text-[11px] text-faint">{time}</span> |
| 278 | </p> |
| 279 | <div className="mt-0.5 space-y-2 text-[13px] leading-[1.55] text-fg-soft">{children}</div> |
| 280 | </div> |
| 281 | </div> |
| 282 | ); |
| 283 | } |
| 284 | |
| 285 | /* ------------------------------------------------------------------ */ |
| 286 | /* The app frame */ |
| 287 | /* ------------------------------------------------------------------ */ |
| 288 | |
| 289 | /** The rail's modes, in the app's order (components/rail.tsx), with its icons. */ |
| 290 | const RAIL: { key: string; icon: ReactNode; label: string; target?: CursorTarget; scenes?: Scene[]; badge?: number }[] = [ |
| 291 | { key: "home", icon: <House size={15} />, label: "Home" }, |
| 292 | { key: "chat", icon: <MessagesSquare size={15} />, label: "Chat", target: "rail-chat", scenes: ["chat"], badge: 3 }, |
| 293 | { key: "code", icon: <Code2 size={15} />, label: "Code", target: "rail-code", scenes: ["code", "session"] }, |
| 294 | { key: "artifacts", icon: <Shapes size={15} />, label: "Artifacts", target: "rail-docs", scenes: ["docs"] }, |
| 295 | { key: "inbox", icon: <Bell size={15} />, label: "Notifications", badge: 2 }, |
| 296 | ]; |
| 297 | |
| 298 | /** One rail item: the square behind its icon, filled for the mode you are in, and its name under it. */ |
| 299 | function RailItem({ icon, label, on, target, badge }: { icon: ReactNode; label: string; on: boolean; target?: CursorTarget; badge?: number }) { |
| 300 | return ( |
| 301 | <span className="flex w-full flex-col items-center gap-[3px] py-0.5"> |
| 302 | <span |
| 303 | data-tour={target} |
| 304 | className={cn( |
| 305 | "relative flex size-7 items-center justify-center rounded-[8px] transition-colors duration-500", |
| 306 | on ? "bg-[#2c2c33] text-fg shadow-[inset_0_0_0_1px_rgba(255,255,255,0.06)]" : "text-muted", |
| 307 | )} |
| 308 | > |
| 309 | {icon} |
| 310 | {badge ? ( |
| 311 | <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]"> |
| 312 | {badge} |
| 313 | </span> |
| 314 | ) : null} |
| 315 | </span> |
| 316 | <span className={cn("text-[9px] leading-none font-medium transition-colors duration-500", on ? "text-fg" : "text-faint")}>{label}</span> |
| 317 | </span> |
| 318 | ); |
| 319 | } |
| 320 | |
| 321 | /** |
| 322 | * The rail, as the app has it: the workspace on the top bar's line (its |
| 323 | * switcher), the modes with their names, then the workspace's own pages, |
| 324 | * help, and you, with the dot that says how others see you. |
| 325 | */ |
| 326 | function Rail({ scene }: { scene: Scene }) { |
| 327 | return ( |
| 328 | <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)]"> |
| 329 | <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)]"> |
| 330 | <Avatar name="acme" size={28} square /> |
| 331 | </div> |
| 332 | <div className="mt-2.5 flex w-full flex-col items-center gap-1.5 px-1"> |
| 333 | {RAIL.map((item) => ( |
| 334 | <RailItem key={item.key} icon={item.icon} label={item.label} on={!!item.scenes?.includes(scene)} target={item.target} badge={item.badge} /> |
| 335 | ))} |
| 336 | </div> |
| 337 | <div className="mt-auto flex w-full flex-col items-center gap-1.5 px-1"> |
| 338 | <RailItem icon={<Building2 size={15} />} label="Workspace" on={false} /> |
| 339 | <RailItem icon={<CircleHelp size={15} />} label="Help" on={false} /> |
| 340 | <span className="relative mt-1"> |
| 341 | <Avatar name="priya" size={26} /> |
| 342 | <PresenceDot ring="#0b0b0d" /> |
| 343 | </span> |
| 344 | </div> |
| 345 | </nav> |
| 346 | ); |
| 347 | } |
| 348 | |
| 349 | /** |
| 350 | * The top bar, as the app has it (components/shell.tsx): where you are, |
| 351 | * then Explore, Docs, the inbox and the + menu. Chat has none on a |
| 352 | * computer: the conversation's header takes its line. |
| 353 | */ |
| 354 | function TopBar({ crumbs }: { crumbs: { label: string; mono?: boolean }[] }) { |
| 355 | return ( |
| 356 | <div className="flex h-11 shrink-0 items-center gap-2 border-b border-line bg-bg px-4"> |
| 357 | <span className="flex min-w-0 items-center gap-1.5 text-[12.5px]"> |
| 358 | {crumbs.map((crumb, index) => ( |
| 359 | <Fragment key={crumb.label}> |
| 360 | {index > 0 && <span className="text-line-strong">/</span>} |
| 361 | <span className={cn("truncate", index === crumbs.length - 1 ? "font-medium text-fg" : "text-muted", crumb.mono && "font-mono text-[12px]")}>{crumb.label}</span> |
| 362 | </Fragment> |
| 363 | ))} |
| 364 | </span> |
| 365 | <span className="ml-auto flex items-center gap-1 text-[12px]"> |
| 366 | <span className="px-1.5 text-muted">Explore</span> |
| 367 | <span className="px-1.5 text-muted">Docs</span> |
| 368 | <span className="relative flex size-7 items-center justify-center text-muted"> |
| 369 | <Bell size={14} /> |
| 370 | <span className="absolute top-1 right-1 size-1.5 rounded-full bg-accent" /> |
| 371 | </span> |
| 372 | <span className="mx-1 h-4 w-px bg-line" /> |
| 373 | <span className="flex h-7 items-center gap-0.5 rounded-md border border-line px-1.5 text-fg/90"> |
| 374 | <Plus size={13} /> |
| 375 | <ChevronDown size={11} className="text-muted" /> |
| 376 | </span> |
| 377 | </span> |
| 378 | </div> |
| 379 | ); |
| 380 | } |
| 381 | |
| 382 | /** A mode's sidebar heading (ModeHeader): on the top bar's line, with what it makes. */ |
| 383 | function SideHead({ children, action }: { children: ReactNode; action?: ReactNode }) { |
| 384 | return ( |
| 385 | <div className="flex h-11 shrink-0 items-center justify-between gap-2 border-b border-line pr-2 pl-3.5"> |
| 386 | <span className="flex items-center gap-2 text-[13px] font-semibold text-fg">{children}</span> |
| 387 | {action && <span className="flex size-6 items-center justify-center text-faint">{action}</span>} |
| 388 | </div> |
| 389 | ); |
| 390 | } |
| 391 | |
| 392 | /** A section's heading, with its fold, as Chat's sections have it. */ |
| 393 | function SideLabel({ children, fold = true }: { children: ReactNode; fold?: boolean }) { |
| 394 | return ( |
| 395 | <p className="mt-3 mb-0.5 flex items-center gap-1 px-1 text-[10.5px] font-medium text-faint"> |
| 396 | {fold && <ChevronDown size={10} />} |
| 397 | {children} |
| 398 | </p> |
| 399 | ); |
| 400 | } |
| 401 | |
| 402 | function SideRow({ on, children, bold }: { on?: boolean; children: ReactNode; bold?: boolean }) { |
| 403 | return ( |
| 404 | <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")}> |
| 405 | {children} |
| 406 | </div> |
| 407 | ); |
| 408 | } |
| 409 | |
| 410 | /** The search at the top of a sidebar. */ |
| 411 | function SideSearch({ children, keys }: { children: ReactNode; keys?: boolean }) { |
| 412 | return ( |
| 413 | <span className="flex h-7 items-center gap-2 rounded-md bg-surface px-2 text-[11px] text-faint ring-1 ring-line"> |
| 414 | <Search size={12} className="shrink-0" /> |
| 415 | <span className="grow truncate">{children}</span> |
| 416 | {keys && <span className="rounded bg-raised px-1 font-mono text-[9px] text-muted ring-1 ring-line">Ctrl K</span>} |
| 417 | </span> |
| 418 | ); |
| 419 | } |
| 420 | |
| 421 | /* Chat ---------------------------------------------------------------- */ |
| 422 | |
| 423 | function ChatSide() { |
| 424 | // As the app has it: direct messages with people, the latest first. |
| 425 | const dms: { who: string; away?: boolean; status?: string }[] = [ |
| 426 | { who: "Dana" }, |
| 427 | { who: "Jordan" }, |
| 428 | { who: "Alex", away: true, status: "🗓️" }, |
| 429 | ]; |
| 430 | return ( |
| 431 | <> |
| 432 | <SideHead action={<SquarePen size={14} />}>Chat</SideHead> |
| 433 | <div className="space-y-2 px-2 pt-2.5"> |
| 434 | <SideSearch>Jump to channel or person</SideSearch> |
| 435 | <span className="grid grid-cols-3 rounded-md bg-surface p-0.5 text-center text-[10px] font-medium ring-1 ring-line"> |
| 436 | <span className="rounded-[4px] bg-raised py-0.5 text-fg ring-1 ring-line-strong">All</span> |
| 437 | <span className="py-0.5 text-muted">Unread</span> |
| 438 | <span className="py-0.5 text-muted">Mentions</span> |
| 439 | </span> |
| 440 | </div> |
| 441 | <div className="px-2"> |
| 442 | <SideLabel>Pinned</SideLabel> |
| 443 | <SideRow> |
| 444 | <Hash size={12} className="text-faint" /> |
| 445 | <span className="flex-1">releases</span> |
| 446 | </SideRow> |
| 447 | <SideLabel>Channels</SideLabel> |
| 448 | {["web", "support", "general"].map((name) => ( |
| 449 | <SideRow key={name} on={name === "web"} bold={name === "support"}> |
| 450 | <Hash size={12} className="text-faint" /> |
| 451 | <span className="flex-1">{name}</span> |
| 452 | {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>} |
| 453 | </SideRow> |
| 454 | ))} |
| 455 | <div className="flex h-7 items-center gap-2 px-2 text-[12px] text-faint"> |
| 456 | <Compass size={12} /> |
| 457 | <span className="flex-1">Browse channels</span> |
| 458 | <span className="text-[10.5px]">3</span> |
| 459 | </div> |
| 460 | <SideLabel>Direct messages</SideLabel> |
| 461 | {dms.map((dm) => ( |
| 462 | <SideRow key={dm.who}> |
| 463 | <span className="relative flex"> |
| 464 | <Face who={dm.who} size={16} /> |
| 465 | <PresenceDot away={dm.away} /> |
| 466 | </span> |
| 467 | <span className="min-w-0 flex-1 truncate"> |
| 468 | {PEOPLE[dm.who]} |
| 469 | {dm.status && <span className="ml-1.5 text-[11px]">{dm.status}</span>} |
| 470 | </span> |
| 471 | </SideRow> |
| 472 | ))} |
| 473 | </div> |
| 474 | </> |
| 475 | ); |
| 476 | } |
| 477 | |
| 478 | function ChatMain({ frame }: { frame: Frame }) { |
| 479 | return ( |
| 480 | <div className="flex h-full flex-col"> |
| 481 | <div className="flex h-11 shrink-0 items-center gap-2 border-b border-line px-5"> |
| 482 | <Hash size={15} className="text-faint" /> |
| 483 | <span className="text-sm font-semibold text-fg">web</span> |
| 484 | <span aria-hidden="true" className="h-3.5 w-px bg-line" /> |
| 485 | <span className="truncate text-xs text-muted">The web app, its releases and its bugs</span> |
| 486 | <span className="ml-auto flex -space-x-1.5" aria-hidden="true"> |
| 487 | {["Priya", "Dana", "Jordan", "Alex"].map((who) => ( |
| 488 | <span key={who} className="rounded-full ring-2 ring-surface"> |
| 489 | <Face who={who} size={20} /> |
| 490 | </span> |
| 491 | ))} |
| 492 | </span> |
| 493 | </div> |
| 494 | <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"> |
| 495 | <div className="flex items-center gap-3 px-5 pt-1 pb-2 text-[11px] text-faint"> |
| 496 | <span className="h-px flex-1 bg-line" /> |
| 497 | Today |
| 498 | <span className="h-px flex-1 bg-line" /> |
| 499 | </div> |
| 500 | <Message who="Jordan" time="09:02"> |
| 501 | <p>Morning! Thursday's release closes Wednesday night, so anything for it needs to land by then.</p> |
| 502 | </Message> |
| 503 | <Message who="Dana" time="09:12"> |
| 504 | <p>Two enterprise customers hit the CSV export timeout again this morning. Both have over 100k rows.</p> |
| 505 | </Message> |
| 506 | <Message who="Priya" time="09:15"> |
| 507 | <p> |
| 508 | <Rich text="I'll take it, @dana. I'll post here when it ships." /> |
| 509 | </p> |
| 510 | </Message> |
| 511 | {frame.shipped && ( |
| 512 | <Message who="Priya" time="09:41" enter> |
| 513 | <p> |
| 514 | <Rich text="Shipped in #431. A 200,000-row export now finishes in about 3 s. @dana, can you let your two customers know?" /> |
| 515 | </p> |
| 516 | </Message> |
| 517 | )} |
| 518 | {frame.told && ( |
| 519 | <Message who="Dana" time="09:42" enter> |
| 520 | <p> |
| 521 | <Rich text="Done. Both can export again, and I told #support." /> |
| 522 | </p> |
| 523 | </Message> |
| 524 | )} |
| 525 | </div> |
| 526 | <div className="px-5 pt-1 pb-4"> |
| 527 | <div className="flex items-center gap-2 rounded-xl bg-surface px-3 py-2.5 text-[13px] ring-1 ring-line"> |
| 528 | <span className="min-w-0 flex-1 truncate text-faint">Message #web</span> |
| 529 | <AtSign size={15} className="text-faint" /> |
| 530 | <Paperclip size={15} className="text-faint" /> |
| 531 | <span className="flex size-7 items-center justify-center rounded-md text-faint"> |
| 532 | <SendHorizontal size={14} /> |
| 533 | </span> |
| 534 | </div> |
| 535 | </div> |
| 536 | </div> |
| 537 | ); |
| 538 | } |
| 539 | |
| 540 | /* Code ---------------------------------------------------------------- */ |
| 541 | |
| 542 | /** Code's sidebar in a project, as the app has it (shell.tsx, RepoMenu): its pages in the order people use them. */ |
| 543 | function CodeSide() { |
| 544 | const row = (icon: ReactNode, label: string, count?: string, on = false) => ( |
| 545 | <SideRow on={on}> |
| 546 | <span className="text-faint">{icon}</span> |
| 547 | <span className="flex-1">{label}</span> |
| 548 | {count && <span className="text-[10.5px] text-faint">{count}</span>} |
| 549 | </SideRow> |
| 550 | ); |
| 551 | return ( |
| 552 | <> |
| 553 | <SideHead>Code</SideHead> |
| 554 | <div className="px-2 pt-2.5"> |
| 555 | <SideSearch keys>Search or jump to…</SideSearch> |
| 556 | <div className="mt-2.5 flex h-7 items-center gap-1 px-1 text-[12px] font-medium text-fg"> |
| 557 | <ChevronLeft size={14} className="text-faint" /> |
| 558 | All projects |
| 559 | </div> |
| 560 | <div className="mt-1 flex items-center gap-2 rounded-md px-2 py-1"> |
| 561 | <span className="flex size-5 items-center justify-center rounded-md bg-raised text-muted ring-1 ring-line"> |
| 562 | <Box size={11} /> |
| 563 | </span> |
| 564 | <span className="font-mono text-[12px]"> |
| 565 | <span className="text-faint">acme/</span> |
| 566 | <span className="font-semibold text-fg">web</span> |
| 567 | </span> |
| 568 | </div> |
| 569 | <div className="mx-2 my-2 h-px bg-line" /> |
| 570 | {row(<Code2 size={13} />, "Code")} |
| 571 | {row(<CircleDot size={13} />, "Issues", "12")} |
| 572 | {row(<GitPullRequest size={13} />, "Pull requests", "3", true)} |
| 573 | {row(<PlayCircle size={13} />, "Workflows")} |
| 574 | <div className="mx-2 my-2 h-px bg-line" /> |
| 575 | {row(<Rocket size={13} />, "Deployments")} |
| 576 | {row(<Activity size={13} />, "Observability")} |
| 577 | {row(<ShieldCheck size={13} />, "Security")} |
| 578 | {row(<BarChart3 size={13} />, "Insights")} |
| 579 | <div className="mx-2 my-2 h-px bg-line" /> |
| 580 | {row(<Settings size={13} />, "Settings")} |
| 581 | </div> |
| 582 | </> |
| 583 | ); |
| 584 | } |
| 585 | |
| 586 | const DIFF: { sign: " " | "-" | "+"; text: string }[] = [ |
| 587 | { sign: " ", text: "def export_csv(account, writer):" }, |
| 588 | { sign: "-", text: " rows = list(rows_for_export(account))" }, |
| 589 | { sign: "-", text: " for row in rows:" }, |
| 590 | { sign: "-", text: " writer.writerow(row)" }, |
| 591 | { sign: "+", text: " for batch in rows_for_export(account, batch_size=1_000):" }, |
| 592 | { sign: "+", text: " writer.writerows(batch)" }, |
| 593 | { sign: "+", text: " yield writer.flush()" }, |
| 594 | ]; |
| 595 | |
| 596 | /** |
| 597 | * The pull request's header and tabs, as routes/repo/pull.tsx has them: |
| 598 | * its title, state and author (with the coding agent on its commits), then |
| 599 | * Conversation, Session and Files changed. |
| 600 | */ |
| 601 | function PullHead({ frame, tab }: { frame: Frame; tab: "conversation" | "session" }) { |
| 602 | const tabs: { key: string; icon: ReactNode; label: string; count?: number; target?: CursorTarget }[] = [ |
| 603 | { key: "conversation", icon: <MessageSquare size={13} />, label: "Conversation", count: 2 }, |
| 604 | { key: "session", icon: <MessagesSquare size={13} />, label: "Session", target: "pr-session" }, |
| 605 | { key: "changes", icon: <FileDiff size={13} />, label: "Files changed", count: 3 }, |
| 606 | ]; |
| 607 | return ( |
| 608 | <div className="px-5 pt-4"> |
| 609 | <p className="text-[15px] font-semibold text-fg"> |
| 610 | Stream CSV exports in batches <span className="font-normal text-faint">#431</span> |
| 611 | </p> |
| 612 | <p className="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-[12px] text-muted"> |
| 613 | <span |
| 614 | key={frame.merged ? "merged" : "open"} |
| 615 | className={cn( |
| 616 | "tour-pop flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-medium", |
| 617 | frame.merged ? "bg-merged/15 text-merged" : "bg-success/15 text-success", |
| 618 | )} |
| 619 | > |
| 620 | {frame.merged ? <GitMerge size={11} /> : <GitPullRequest size={11} />} |
| 621 | {frame.merged ? "Merged" : "Open"} |
| 622 | </span> |
| 623 | <span className="text-fg-soft">{PEOPLE.Priya}</span> wants to merge into <span className="font-mono text-[11px] text-fg-soft">main</span> · with |
| 624 | <WithAgent /> |
| 625 | </p> |
| 626 | <div className="mt-3 flex items-end gap-1 border-b border-line text-[12px]"> |
| 627 | {tabs.map((item) => { |
| 628 | const on = item.key === tab; |
| 629 | return ( |
| 630 | <span |
| 631 | key={item.key} |
| 632 | data-tour={item.target} |
| 633 | className={cn("-mb-px flex items-center gap-1.5 border-b-2 px-2.5 pb-2", on ? "border-accent font-medium text-fg" : "border-transparent text-muted")} |
| 634 | > |
| 635 | {item.icon} |
| 636 | {item.label} |
| 637 | {item.count ? <span className="rounded-full bg-raised px-1.5 text-[10px] text-muted tabular-nums ring-1 ring-line">{item.count}</span> : null} |
| 638 | </span> |
| 639 | ); |
| 640 | })} |
| 641 | <span className="ml-auto pb-2 font-mono text-[11px]"> |
| 642 | <span className="text-success">+41</span> <span className="text-danger">−12</span> |
| 643 | </span> |
| 644 | </div> |
| 645 | </div> |
| 646 | ); |
| 647 | } |
| 648 | |
| 649 | function CodeMain({ frame }: { frame: Frame }) { |
| 650 | return ( |
| 651 | <div className="flex h-full flex-col"> |
| 652 | <PullHead frame={frame} tab="conversation" /> |
| 653 | <div className="grid min-h-0 flex-1 grid-cols-[1fr_15rem] gap-4 px-5 py-4"> |
| 654 | <div className="min-w-0 space-y-3"> |
| 655 | <div className="flex gap-3 text-[12.5px] leading-[1.6] text-fg-soft"> |
| 656 | <Face who="Priya" size={24} /> |
| 657 | <p> |
| 658 | Exports loaded every row before sending the first byte, so big accounts hit the 60 s limit. Rows now stream in |
| 659 | batches of 1,000. A 200,000-row account takes 3.1 s. Fixes the timeout Dana reported in <span className="text-accent">#web</span>. |
| 660 | </p> |
| 661 | </div> |
| 662 | <Card className="overflow-hidden"> |
| 663 | <p className="flex items-center gap-2 border-b border-line px-3.5 py-2 font-mono text-[11px] text-muted"> |
| 664 | <FileText size={12} /> |
| 665 | services/export/csv.py |
| 666 | <span className="ml-auto"> |
| 667 | <span className="text-success">+3</span> <span className="text-danger">−3</span> |
| 668 | </span> |
| 669 | </p> |
| 670 | <pre className="py-1.5 font-mono text-[11px] leading-[1.75]"> |
| 671 | {DIFF.map((line, index) => ( |
| 672 | <div |
| 673 | key={index} |
| 674 | className={cn( |
| 675 | "px-3.5 transition-opacity duration-500", |
| 676 | line.sign === "+" ? "bg-success/10 text-success" : line.sign === "-" ? "bg-danger/10 text-danger" : "text-muted", |
| 677 | frame.diff ? "opacity-100" : "opacity-0", |
| 678 | )} |
| 679 | style={{ transitionDelay: frame.diff ? `${index * 70}ms` : "0ms" }} |
| 680 | > |
| 681 | <span className="mr-3 inline-block w-2 text-faint select-none">{line.sign}</span> |
| 682 | {line.text} |
| 683 | </div> |
| 684 | ))} |
| 685 | </pre> |
| 686 | </Card> |
| 687 | <div |
| 688 | className={cn( |
| 689 | "flex items-center gap-3 rounded-xl px-3.5 py-2.5 text-[12px] ring-1 transition-opacity duration-500", |
| 690 | frame.approved ? "bg-success/5 text-fg-soft opacity-100 ring-success/25" : "opacity-0 ring-line", |
| 691 | )} |
| 692 | > |
| 693 | <Face who="Dana" size={22} /> |
| 694 | <span> |
| 695 | <span className="text-fg">{PEOPLE.Dana}</span> approved: tried it on the preview with a 200k-row account. Done in seconds. |
| 696 | </span> |
| 697 | <Check size={14} className="ml-auto shrink-0 text-success" /> |
| 698 | </div> |
| 699 | </div> |
| 700 | <div className="space-y-3"> |
| 701 | <Card> |
| 702 | <p className="border-b border-line px-3.5 py-2 text-[11px] font-medium text-muted"> |
| 703 | Checks · {frame.checks} of {CHECKS.length} passed |
| 704 | </p> |
| 705 | <ul className="space-y-2 px-3.5 py-2.5"> |
| 706 | {CHECKS.map((check, index) => { |
| 707 | const done = index < frame.checks; |
| 708 | return ( |
| 709 | <li key={check} className="flex items-center gap-2 text-[12px]"> |
| 710 | {done ? ( |
| 711 | <span className="tour-pop flex size-4 items-center justify-center rounded-full bg-success/15 text-success"> |
| 712 | <Check size={10} strokeWidth={3} /> |
| 713 | </span> |
| 714 | ) : ( |
| 715 | <span className="tour-spin size-3.5 rounded-full border-2 border-warn/25 border-t-warn" /> |
| 716 | )} |
| 717 | <span className={done ? "text-fg-soft" : "text-muted"}>{check}</span> |
| 718 | <span className="ml-auto font-mono text-[10px] text-faint">{done ? `${18 + index * 23}s` : ""}</span> |
| 719 | </li> |
| 720 | ); |
| 721 | })} |
| 722 | </ul> |
| 723 | </Card> |
| 724 | <div |
| 725 | className={cn( |
| 726 | "rounded-xl px-3.5 py-3 text-[12px] ring-1 transition-colors duration-500", |
| 727 | frame.merged ? "bg-merged/10 text-merged ring-merged/30" : frame.approved ? "bg-success/10 text-success ring-success/30" : "text-muted ring-line", |
| 728 | )} |
| 729 | > |
| 730 | <p className="flex items-center gap-2 font-medium"> |
| 731 | <GitMerge size={13} /> |
| 732 | {frame.merged ? "Merged by the merge queue" : frame.approved ? "In the merge queue" : "Waiting on checks and review"} |
| 733 | </p> |
| 734 | <p className="mt-1 text-[11px] text-faint">{frame.merged ? "Deploying to production" : "Main only moves to what passed."}</p> |
| 735 | </div> |
| 736 | <div |
| 737 | className={cn( |
| 738 | "rounded-xl border border-line bg-surface px-3.5 py-3 text-[12px] transition-opacity duration-500", |
| 739 | frame.checks >= CHECKS.length ? "opacity-100" : "opacity-0", |
| 740 | )} |
| 741 | > |
| 742 | <p className="text-[11px] font-medium text-muted">Preview</p> |
| 743 | <p className="mt-1 truncate font-mono text-[11px] text-accent">web-git-csv-acme.g1t.page</p> |
| 744 | </div> |
| 745 | </div> |
| 746 | </div> |
| 747 | </div> |
| 748 | ); |
| 749 | } |
| 750 | |
| 751 | /** One entry of the session, on the timeline's rail, as the pull request's Session tab draws it. */ |
| 752 | function SessionLine({ entry, shown }: { entry: SessionEntry; shown: boolean }) { |
| 753 | const Icon = entry.kind === "prompt" ? User : entry.kind === "tool" ? Wrench : Bot; |
| 754 | return ( |
| 755 | <li className={cn("relative pl-9 transition-[opacity,transform] duration-500", shown ? "translate-y-0 opacity-100" : "translate-y-1 opacity-0")}> |
| 756 | <span |
| 757 | className={cn( |
| 758 | "absolute top-0 left-0 flex size-6 items-center justify-center rounded-full border bg-bg", |
| 759 | entry.kind === "prompt" ? "border-accent/50 text-accent" : "border-line text-faint", |
| 760 | )} |
| 761 | > |
| 762 | <Icon size={12} /> |
| 763 | </span> |
| 764 | {entry.kind === "tool" ? ( |
| 765 | <Card className="flex items-center gap-2 px-3 py-1.5"> |
| 766 | <span className="font-mono text-[11px] text-accent">{entry.tool}</span> |
| 767 | <span className="truncate font-mono text-[11px] text-muted">{entry.text}</span> |
| 768 | </Card> |
| 769 | ) : ( |
| 770 | <div> |
| 771 | <p className="text-[11px] font-medium text-faint">{entry.kind === "prompt" ? "Prompt" : CLAUDE.name}</p> |
| 772 | {entry.kind === "prompt" ? ( |
| 773 | <Card className="mt-1 px-3 py-2 text-[12.5px] leading-[1.55] text-fg-soft">{entry.text}</Card> |
| 774 | ) : ( |
| 775 | <p className="mt-0.5 text-[12.5px] leading-[1.55] text-fg-soft">{entry.text}</p> |
| 776 | )} |
| 777 | </div> |
| 778 | )} |
| 779 | {entry.commit && ( |
| 780 | <p className="mt-1 flex items-center gap-1 font-mono text-[11px] text-faint"> |
| 781 | <GitCommitHorizontal size={12} /> |
| 782 | {entry.commit} |
| 783 | </p> |
| 784 | )} |
| 785 | </li> |
| 786 | ); |
| 787 | } |
| 788 | |
| 789 | /** The pull request's Session tab: what the coding agent was asked, read, ran and decided, recorded as it worked. */ |
| 790 | function SessionMain({ frame }: { frame: Frame }) { |
| 791 | return ( |
| 792 | <div className="flex h-full flex-col"> |
| 793 | <PullHead frame={frame} tab="session" /> |
| 794 | <div className="min-h-0 flex-1 overflow-hidden px-5 py-4"> |
| 795 | <p className="mb-3 flex items-center gap-1.5 text-[11.5px] text-muted"> |
| 796 | Recorded by <WithAgent /> for {PEOPLE.Priya} through g1t's MCP server · {SESSION.length} entries |
| 797 | </p> |
| 798 | <ol className="space-y-3"> |
| 799 | {SESSION.map((entry, index) => ( |
| 800 | <SessionLine key={index} entry={entry} shown={index < frame.entries} /> |
| 801 | ))} |
| 802 | </ol> |
| 803 | </div> |
| 804 | </div> |
| 805 | ); |
| 806 | } |
| 807 | |
| 808 | /* Docs ---------------------------------------------------------------- */ |
| 809 | |
| 810 | /** Docs' sidebar: search, then each space's pages. */ |
| 811 | function DocsSide() { |
| 812 | return ( |
| 813 | <> |
| 814 | <SideHead action={<Plus size={14} />}> |
| 815 | Docs |
| 816 | </SideHead> |
| 817 | <div className="px-2 pt-2.5"> |
| 818 | <SideSearch>Search docs</SideSearch> |
| 819 | <SideLabel>Product</SideLabel> |
| 820 | {["Getting started", "Billing", "Exporting data", "Integrations"].map((page) => ( |
| 821 | <SideRow key={page} on={page === "Exporting data"}> |
| 822 | <FileText size={12} className="text-faint" /> |
| 823 | {page} |
| 824 | </SideRow> |
| 825 | ))} |
| 826 | <SideLabel>Engineering</SideLabel> |
| 827 | {["Runbooks", "Decisions"].map((page) => ( |
| 828 | <SideRow key={page}> |
| 829 | <FileText size={12} className="text-faint" /> |
| 830 | {page} |
| 831 | </SideRow> |
| 832 | ))} |
| 833 | </div> |
| 834 | </> |
| 835 | ); |
| 836 | } |
| 837 | |
| 838 | function DocsMain({ frame }: { frame: Frame }) { |
| 839 | return ( |
| 840 | <div className="relative h-full px-10 py-7"> |
| 841 | <p className="text-[11px] text-faint">Product / Exporting data</p> |
| 842 | <h3 className="mt-2 text-[22px] font-semibold tracking-tight text-fg">Exporting data</h3> |
| 843 | <p className="mt-1.5 flex items-center gap-2 text-[11px] text-faint"> |
| 844 | Owned by <span className="text-fg-soft">{PEOPLE.Dana}</span> ·{" "} |
| 845 | <span key={frame.docUpdated ? "now" : "then"} className="tour-fade"> |
| 846 | {frame.docUpdated ? "updated just now from #431" : "updated 3 weeks ago"} |
| 847 | </span> |
| 848 | </p> |
| 849 | <div className="mt-5 max-w-xl space-y-3 text-[13px] leading-[1.7] text-fg-soft"> |
| 850 | <p>Export any table as CSV from Settings, then Data. An export includes every row you can see, in the order shown.</p> |
| 851 | <p className="pt-2 text-[14px] font-semibold text-fg">Large accounts</p> |
| 852 | <div className="relative"> |
| 853 | <p |
| 854 | className={cn( |
| 855 | "rounded-md px-3 py-2 transition-all duration-500", |
| 856 | frame.docUpdated ? "bg-danger/5 text-faint line-through decoration-danger/50" : "text-fg-soft", |
| 857 | )} |
| 858 | > |
| 859 | Exports over 50,000 rows can time out. Split them by date range. |
| 860 | </p> |
| 861 | <div |
| 862 | className={cn( |
| 863 | "mt-1.5 rounded-md border-l-2 border-success bg-success/10 px-3 py-2 text-fg transition-all duration-700", |
| 864 | frame.docUpdated ? "translate-y-0 opacity-100" : "translate-y-1 opacity-0", |
| 865 | )} |
| 866 | > |
| 867 | Exports of any size stream in batches of 1,000 rows. A 200,000-row account finishes in about 3 seconds. |
| 868 | </div> |
| 869 | </div> |
| 870 | <div |
| 871 | className={cn( |
| 872 | "flex items-center gap-2 pt-1 text-[11px] text-muted transition-opacity delay-300 duration-500", |
| 873 | frame.docUpdated ? "opacity-100" : "opacity-0", |
| 874 | )} |
| 875 | > |
| 876 | <Face who="Dana" size={18} /> |
| 877 | <span> |
| 878 | Updated by <span className="text-fg">{PEOPLE.Dana}</span> after <span className="text-accent">#431</span> merged |
| 879 | </span> |
| 880 | </div> |
| 881 | <p className="pt-3 text-[14px] font-semibold text-fg">Scheduled exports</p> |
| 882 | <p>Owners can send an export to a storage bucket every day or week. Each run uses the same columns as the last.</p> |
| 883 | </div> |
| 884 | </div> |
| 885 | ); |
| 886 | } |
| 887 | |
| 888 | /* The cursor ------------------------------------------------------------ */ |
| 889 | |
| 890 | function Cursor({ frame, canvas, scale }: { frame: Frame; canvas: React.RefObject<HTMLDivElement | null>; scale: number }) { |
| 891 | const [at, setAt] = useState<{ x: number; y: number } | null>(null); |
| 892 | useIsoLayoutEffect(() => { |
| 893 | const root = canvas.current; |
| 894 | if (!root) return; |
| 895 | const box = root.getBoundingClientRect(); |
| 896 | const target = frame.cursor === "idle" ? null : root.querySelector<HTMLElement>(`[data-tour="${frame.cursor}"]`); |
| 897 | if (!target) { |
| 898 | setAt({ x: W * 0.64, y: H * 0.88 }); |
| 899 | return; |
| 900 | } |
| 901 | const rect = target.getBoundingClientRect(); |
| 902 | setAt({ x: (rect.left - box.left + rect.width * 0.5) / scale, y: (rect.top - box.top + rect.height * 0.55) / scale }); |
| 903 | }, [frame.cursor, scale, canvas]); |
| 904 | if (!at) return null; |
| 905 | return ( |
| 906 | <div |
| 907 | aria-hidden="true" |
| 908 | 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")} |
| 909 | style={{ transform: `translate(${at.x}px, ${at.y}px)` }} |
| 910 | > |
| 911 | <span |
| 912 | className={cn( |
| 913 | "absolute -top-4 -left-4 size-8 rounded-full bg-accent/30 transition-[transform,opacity] duration-300", |
| 914 | frame.click ? "scale-100 opacity-100" : "scale-50 opacity-0", |
| 915 | )} |
| 916 | /> |
| 917 | <svg |
| 918 | width="18" |
| 919 | height="20" |
| 920 | viewBox="0 0 18 20" |
| 921 | className={cn("relative drop-shadow-[0_2px_6px_rgb(0_0_0/0.5)] transition-transform duration-150", frame.click && "scale-90")} |
| 922 | > |
| 923 | <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" /> |
| 924 | </svg> |
| 925 | </div> |
| 926 | ); |
| 927 | } |
| 928 | |
| 929 | /* Desktop ------------------------------------------------------------- */ |
| 930 | |
| 931 | const SIDES: Record<Scene, (props: { frame: Frame }) => ReactNode> = { |
| 932 | code: CodeSide, |
| 933 | session: CodeSide, |
| 934 | chat: ChatSide, |
| 935 | docs: DocsSide, |
| 936 | }; |
| 937 | |
| 938 | /** Where each page is, as the top bar's trail says it; Chat has no top bar on a computer. */ |
| 939 | const CRUMBS: Record<Scene, { label: string; mono?: boolean }[] | null> = { |
| 940 | chat: null, |
| 941 | code: [{ label: "acme", mono: true }, { label: "web", mono: true }, { label: "Pull request #431" }], |
| 942 | session: [{ label: "acme", mono: true }, { label: "web", mono: true }, { label: "Pull request #431" }], |
| 943 | docs: [{ label: "acme", mono: true }, { label: "Docs" }], |
| 944 | }; |
| 945 | |
| 946 | /** The sidebar's colour, a shade lighter than the page (shell.tsx, SIDEBAR_BOX). */ |
| 947 | const SIDE_BG = "color-mix(in srgb, var(--color-surface) 70%, var(--color-bg))"; |
| 948 | |
| 949 | function Desktop({ frame, reduced }: { frame: Frame; reduced: boolean }) { |
| 950 | const outer = useRef<HTMLDivElement | null>(null); |
| 951 | const canvas = useRef<HTMLDivElement | null>(null); |
| 952 | const [scale, setScale] = useState(1); |
| 953 | useEffect(() => { |
| 954 | const element = outer.current; |
| 955 | if (!element || typeof ResizeObserver === "undefined") return; |
| 956 | const observer = new ResizeObserver(([entry]) => { |
| 957 | const width = entry?.contentRect.width ?? W; |
| 958 | if (width > 0) setScale(width / W); |
| 959 | }); |
| 960 | observer.observe(element); |
| 961 | return () => observer.disconnect(); |
| 962 | }, []); |
| 963 | const scenes: Scene[] = ["code", "session", "chat", "docs"]; |
| 964 | const mains: Record<Scene, ReactNode> = { |
| 965 | chat: <ChatMain frame={frame} />, |
| 966 | session: <SessionMain frame={frame} />, |
| 967 | code: <CodeMain frame={frame} />, |
| 968 | docs: <DocsMain frame={frame} />, |
| 969 | }; |
| 970 | return ( |
| 971 | <div ref={outer} className="relative w-full overflow-hidden rounded-2xl text-fg shadow-2xl shadow-black/50 ring-1 ring-line scheme-dark" style={{ aspectRatio: `${W} / ${H}` }}> |
| 972 | <div |
| 973 | ref={canvas} |
| 974 | className="absolute top-0 left-0 flex origin-top-left bg-bg text-left" |
| 975 | style={{ width: W, height: H, transform: scale === 1 ? undefined : `scale(${scale})`, ["--tour-side" as string]: SIDE_BG } as CSSProperties} |
| 976 | > |
| 977 | <Rail scene={frame.scene} /> |
| 978 | <div className="relative min-w-0 flex-1"> |
| 979 | {scenes.map((scene) => { |
| 980 | const Side = SIDES[scene]; |
| 981 | const on = frame.scene === scene; |
| 982 | const crumbs = CRUMBS[scene]; |
| 983 | return ( |
| 984 | <div |
| 985 | key={scene} |
| 986 | aria-hidden={!on} |
| 987 | className={cn( |
| 988 | "absolute inset-0 flex transition-[opacity,transform] duration-500 ease-out", |
| 989 | on ? "translate-y-0 opacity-100" : "pointer-events-none translate-y-1 opacity-0", |
| 990 | )} |
| 991 | > |
| 992 | <div className="flex w-52 shrink-0 flex-col overflow-hidden border-r border-line" style={{ background: SIDE_BG }}> |
| 993 | <Side frame={frame} /> |
| 994 | </div> |
| 995 | <div className="flex min-w-0 flex-1 flex-col"> |
| 996 | {crumbs && <TopBar crumbs={crumbs} />} |
| 997 | <div className="min-h-0 flex-1">{mains[scene]}</div> |
| 998 | </div> |
| 999 | </div> |
| 1000 | ); |
| 1001 | })} |
| 1002 | </div> |
| 1003 | {!reduced && <Cursor frame={frame} canvas={canvas} scale={scale} />} |
| 1004 | </div> |
| 1005 | </div> |
| 1006 | ); |
| 1007 | } |
| 1008 | |
| 1009 | /* Phone --------------------------------------------------------------- */ |
| 1010 | |
| 1011 | const MODE: Record<Scene, { icon: ReactNode; label: string }> = { |
| 1012 | code: { icon: <GitPullRequest size={14} />, label: "Pull request #431" }, |
| 1013 | session: { icon: <MessagesSquare size={14} />, label: "#431 · Session" }, |
| 1014 | chat: { icon: <Hash size={14} />, label: "web" }, |
| 1015 | docs: { icon: <BookOpen size={14} />, label: "Exporting data" }, |
| 1016 | }; |
| 1017 | |
| 1018 | /** The phone's tab bar, as the app has it (components/mobile.tsx). */ |
| 1019 | const TABS: { scenes: Scene[]; icon: ReactNode; label: string }[] = [ |
| 1020 | { scenes: [], icon: <House size={16} />, label: "Home" }, |
| 1021 | { scenes: ["chat"], icon: <MessagesSquare size={16} />, label: "Chat" }, |
| 1022 | { scenes: [], icon: <Bell size={16} />, label: "Notifications" }, |
| 1023 | { scenes: ["code", "session"], icon: <Code2 size={16} />, label: "Code" }, |
| 1024 | ]; |
| 1025 | |
| 1026 | function Phone({ frame }: { frame: Frame }) { |
| 1027 | const scenes: Scene[] = ["code", "session", "chat", "docs"]; |
| 1028 | return ( |
| 1029 | <div className="relative flex h-[34rem] w-full flex-col overflow-hidden rounded-2xl bg-bg text-left text-fg shadow-2xl shadow-black/50 ring-1 ring-line scheme-dark"> |
| 1030 | <div className="flex h-12 shrink-0 items-center gap-2 border-b border-line px-3"> |
| 1031 | <Avatar name="acme" size={26} square /> |
| 1032 | <span key={frame.scene} className="tour-fade flex min-w-0 items-center gap-1.5 text-[13px] font-semibold text-fg"> |
| 1033 | <span className="text-faint">{MODE[frame.scene].icon}</span> |
| 1034 | <span className="truncate">{MODE[frame.scene].label}</span> |
| 1035 | </span> |
| 1036 | </div> |
| 1037 | <div className="relative min-h-0 flex-1"> |
| 1038 | {scenes.map((scene) => { |
| 1039 | const on = frame.scene === scene; |
| 1040 | return ( |
| 1041 | <div |
| 1042 | key={scene} |
| 1043 | aria-hidden={!on} |
| 1044 | className={cn("absolute inset-0 transition-opacity duration-500", on ? "opacity-100" : "pointer-events-none opacity-0")} |
| 1045 | > |
| 1046 | {scene === "code" && <PhoneCode frame={frame} />} |
| 1047 | {scene === "session" && <PhoneSession frame={frame} />} |
| 1048 | {scene === "chat" && <PhoneChat frame={frame} />} |
| 1049 | {scene === "docs" && ( |
| 1050 | <div className="origin-top-left scale-[0.86]" style={{ width: "116%" }}> |
| 1051 | <DocsMain frame={frame} /> |
| 1052 | </div> |
| 1053 | )} |
| 1054 | </div> |
| 1055 | ); |
| 1056 | })} |
| 1057 | </div> |
| 1058 | {/* Inside a conversation the tab bar steps aside, as it does on a phone. */} |
| 1059 | {frame.scene !== "chat" && ( |
| 1060 | <div className="grid h-12 shrink-0 grid-cols-4 border-t border-line bg-[#0b0b0d]"> |
| 1061 | {TABS.map((tab) => { |
| 1062 | const on = tab.scenes.includes(frame.scene); |
| 1063 | return ( |
| 1064 | <span key={tab.label} className="flex flex-col items-center justify-center gap-0.5"> |
| 1065 | <span className={cn("flex h-6 w-10 items-center justify-center rounded-full", on ? "bg-[#2c2c33] text-fg" : "text-muted")}>{tab.icon}</span> |
| 1066 | <span className={cn("text-[9.5px] font-medium", on ? "text-fg" : "text-faint")}>{tab.label}</span> |
| 1067 | </span> |
| 1068 | ); |
| 1069 | })} |
| 1070 | </div> |
| 1071 | )} |
| 1072 | </div> |
| 1073 | ); |
| 1074 | } |
| 1075 | |
| 1076 | function PhoneChat({ frame }: { frame: Frame }) { |
| 1077 | return ( |
| 1078 | <div className="flex h-full flex-col"> |
| 1079 | <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"> |
| 1080 | <Message who="Dana" time="09:12"> |
| 1081 | <p>Two enterprise customers hit the CSV export timeout again this morning.</p> |
| 1082 | </Message> |
| 1083 | <Message who="Priya" time="09:15"> |
| 1084 | <p> |
| 1085 | <Rich text="I'll take it, @dana." /> |
| 1086 | </p> |
| 1087 | </Message> |
| 1088 | {frame.shipped && ( |
| 1089 | <Message who="Priya" time="09:41" enter> |
| 1090 | <p> |
| 1091 | <Rich text="Shipped in #431. @dana, can you tell your customers?" /> |
| 1092 | </p> |
| 1093 | </Message> |
| 1094 | )} |
| 1095 | {frame.told && ( |
| 1096 | <Message who="Dana" time="09:42" enter> |
| 1097 | <p> |
| 1098 | <Rich text="Done. Both can export again." /> |
| 1099 | </p> |
| 1100 | </Message> |
| 1101 | )} |
| 1102 | </div> |
| 1103 | <div className="px-3 pt-1 pb-3"> |
| 1104 | <div className="flex items-center gap-2 rounded-xl bg-surface px-3 py-2.5 text-[13px] ring-1 ring-line"> |
| 1105 | <span className="min-w-0 flex-1 truncate text-faint">Message #web</span> |
| 1106 | <SendHorizontal size={15} className="text-faint" /> |
| 1107 | </div> |
| 1108 | </div> |
| 1109 | </div> |
| 1110 | ); |
| 1111 | } |
| 1112 | |
| 1113 | function PhoneSession({ frame }: { frame: Frame }) { |
| 1114 | return ( |
| 1115 | <div className="h-full space-y-3 overflow-hidden px-3 py-4"> |
| 1116 | <p className="flex flex-wrap items-center gap-1.5 text-[12px] text-muted"> |
| 1117 | Recorded by <WithAgent /> through MCP |
| 1118 | </p> |
| 1119 | <ol className="space-y-3"> |
| 1120 | {SESSION.map((entry, index) => ( |
| 1121 | <SessionLine key={index} entry={entry} shown={index < frame.entries} /> |
| 1122 | ))} |
| 1123 | </ol> |
| 1124 | </div> |
| 1125 | ); |
| 1126 | } |
| 1127 | |
| 1128 | function PhoneCode({ frame }: { frame: Frame }) { |
| 1129 | return ( |
| 1130 | <div className="space-y-3 px-3 py-4"> |
| 1131 | <p className="text-[14px] font-semibold text-fg"> |
| 1132 | Stream CSV exports in batches <span className="font-normal text-faint">#431</span> |
| 1133 | </p> |
| 1134 | <p className="text-[12px] leading-[1.6] text-muted"> |
| 1135 | <span className="text-fg-soft">{PEOPLE.Priya}</span> wants to merge into <span className="font-mono text-[11px] text-fg-soft">main</span>. Rows now |
| 1136 | stream in batches of 1,000; a 200,000-row account takes 3.1 s. |
| 1137 | </p> |
| 1138 | <p className="flex flex-wrap items-center gap-1.5 text-[12px] text-muted"> |
| 1139 | Commits with <WithAgent /> |
| 1140 | </p> |
| 1141 | <Card> |
| 1142 | <ul className="space-y-2 px-3.5 py-3"> |
| 1143 | {CHECKS.map((check, index) => { |
| 1144 | const done = index < frame.checks; |
| 1145 | return ( |
| 1146 | <li key={check} className="flex items-center gap-2 text-[12px]"> |
| 1147 | {done ? ( |
| 1148 | <span className="tour-pop flex size-4 items-center justify-center rounded-full bg-success/15 text-success"> |
| 1149 | <Check size={10} strokeWidth={3} /> |
| 1150 | </span> |
| 1151 | ) : ( |
| 1152 | <span className="tour-spin size-3.5 rounded-full border-2 border-warn/25 border-t-warn" /> |
| 1153 | )} |
| 1154 | <span className={done ? "text-fg-soft" : "text-muted"}>{check}</span> |
| 1155 | </li> |
| 1156 | ); |
| 1157 | })} |
| 1158 | </ul> |
| 1159 | </Card> |
| 1160 | <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")}> |
| 1161 | {DIFF.slice(1).map((line, index) => ( |
| 1162 | <div key={index} className={cn("truncate px-3", line.sign === "+" ? "bg-success/10 text-success" : "bg-danger/10 text-danger")}> |
| 1163 | {line.sign} {line.text.trim()} |
| 1164 | </div> |
| 1165 | ))} |
| 1166 | </pre> |
| 1167 | <div |
| 1168 | className={cn( |
| 1169 | "flex items-center gap-2 rounded-xl px-3.5 py-2.5 text-[12px] ring-1 transition-colors duration-500", |
| 1170 | frame.merged ? "bg-merged/10 text-merged ring-merged/30" : frame.approved ? "bg-success/10 text-success ring-success/30" : "text-muted ring-line", |
| 1171 | )} |
| 1172 | > |
| 1173 | <GitMerge size={13} /> |
| 1174 | {frame.merged ? "Merged · deploying" : frame.approved ? `Approved by ${PEOPLE.Dana}` : "Waiting on checks and review"} |
| 1175 | </div> |
| 1176 | </div> |
| 1177 | ); |
| 1178 | } |
| 1179 | |
| 1180 | /* The tour -------------------------------------------------------------- */ |
| 1181 | |
| 1182 | /** Keyframes the tour's parts use; transforms and opacity only. */ |
| 1183 | const STYLES = ` |
| 1184 | .tour-in { animation: tour-in 420ms cubic-bezier(.2,.7,.2,1) both; } |
| 1185 | @keyframes tour-in { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } } |
| 1186 | .tour-pop { animation: tour-pop 360ms cubic-bezier(.3,1.4,.5,1) both; } |
| 1187 | @keyframes tour-pop { from { opacity: 0; transform: scale(.7); } to { opacity: 1; transform: none; } } |
| 1188 | .tour-fade { animation: tour-fade 400ms ease-out both; } |
| 1189 | @keyframes tour-fade { from { opacity: 0; } to { opacity: 1; } } |
| 1190 | .tour-spin { animation: tour-spin .9s linear infinite; } |
| 1191 | @keyframes tour-spin { to { transform: rotate(360deg); } } |
| 1192 | @media (prefers-reduced-motion: reduce) { |
| 1193 | .tour-in, .tour-pop, .tour-fade, .tour-spin { animation: none; } |
| 1194 | } |
| 1195 | `; |
| 1196 | |
| 1197 | /** |
| 1198 | * The hero's tour: the frame (a full app on wider screens, one pane on a |
| 1199 | * phone), the step pills under it, and a line saying what is a preview. |
| 1200 | */ |
| 1201 | export function ProductTour({ className }: { className?: string }) { |
| 1202 | const clock = useTourClock(); |
| 1203 | const { frame } = clock; |
| 1204 | return ( |
| 1205 | <div ref={clock.rootRef} className={className}> |
| 1206 | <style dangerouslySetInnerHTML={{ __html: STYLES }} /> |
| 1207 | <div |
| 1208 | role="img" |
| 1209 | aria-label="A tour of g1t: a pull request passes its checks, is approved and is merged by the merge queue, its session from the coding agent that helped write it is replayed, the team hears it shipped in a channel, and the docs page is updated." |
| 1210 | onMouseEnter={() => clock.hover(true)} |
| 1211 | onMouseLeave={() => clock.hover(false)} |
| 1212 | > |
| 1213 | <div className="hidden md:block"> |
| 1214 | <Desktop frame={frame} reduced={clock.reduced} /> |
| 1215 | </div> |
| 1216 | <div className="md:hidden"> |
| 1217 | <Phone frame={frame} /> |
| 1218 | </div> |
| 1219 | </div> |
| 1220 | <div className="mt-4 flex justify-center" aria-live="polite"> |
| 1221 | <HonestyNote frame={frame} /> |
| 1222 | </div> |
| 1223 | <div className="mx-auto mt-3 grid max-w-xl grid-cols-4 gap-2" role="tablist" aria-label="Steps of the tour"> |
| 1224 | {PILLS.map((pill, index) => { |
| 1225 | const on = clock.pill === index; |
| 1226 | return ( |
| 1227 | <button |
| 1228 | key={pill.scene} |
| 1229 | type="button" |
| 1230 | role="tab" |
| 1231 | aria-selected={on} |
| 1232 | onClick={() => clock.jump(index)} |
| 1233 | className={cn( |
| 1234 | "group relative overflow-hidden rounded-full px-2 py-1.5 text-[11px] font-medium whitespace-nowrap ring-1 transition-colors sm:px-3 sm:text-xs", |
| 1235 | on ? "bg-surface text-fg ring-line-strong" : "text-muted ring-line hover:text-fg-soft", |
| 1236 | )} |
| 1237 | > |
| 1238 | <span |
| 1239 | ref={(element) => { |
| 1240 | clock.barRefs.current[index] = element; |
| 1241 | }} |
| 1242 | aria-hidden="true" |
| 1243 | className="absolute inset-0 origin-left bg-accent/15" |
| 1244 | style={{ transform: `scaleX(${clock.reduced && on ? 1 : 0})` }} |
| 1245 | /> |
| 1246 | <span className="relative flex items-center justify-center gap-1.5"> |
| 1247 | {pill.label} |
| 1248 | </span> |
| 1249 | </button> |
| 1250 | ); |
| 1251 | })} |
| 1252 | </div> |
| 1253 | <p className="mx-auto mt-3 max-w-xl text-center text-xs leading-5 text-faint text-balance"> |
| 1254 | Everything here works today. Priya wrote the fix with Claude Code, connected to g1t through its MCP server: the |
| 1255 | commits carry its mark, and the session it recorded stays on the pull request. |
| 1256 | </p> |
| 1257 | </div> |
| 1258 | ); |
| 1259 | } |