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