Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Docs: the quickstart as steps, with both ways to put an agent on an issue | 1 | import { |
| 2 | ChevronDown, | |
| 3 | ChevronRight, | |
| 4 | Columns2, | |
| 5 | FileDiff as FileIcon, | |
| 6 | FileMinus, | |
| 7 | FilePlus, | |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 8 | Files, |
| Docs: the quickstart as steps, with both ways to put an agent on an issue | 9 | Folder, |
| 10 | MessageSquarePlus, | |
| 11 | Rows3, | |
| 12 | Search, | |
| 13 | } from "lucide-react"; | |
| 14 | import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react"; | |
| 15 | import { Form, Link } from "react-router"; | |
| 16 | ||
| 17 | import type { Comment, Comparison, DiffLine } from "@g1t/contracts"; | |
| 18 | ||
| 19 | import type { | |
| 20 | HighlightedComparison, | |
| 21 | HighlightedFile, | |
| 22 | HighlightedLine, | |
| 23 | } from "../lib/diff"; | |
| 24 | import { Markdown } from "./markdown"; | |
| Merge branch 'worktree-agent-a985c01f43538af01' | 25 | import { Avatar, Button, EmptyState, SubmitButton, Textarea, TimeAgo } from "./ui"; |
| Workspace names and icons, and a component kit for every control | 26 | import { Checkbox } from "./ui/checkbox"; |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 27 | import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "./ui/sheet"; |
| Docs: the quickstart as steps, with both ways to put an agent on an issue | 28 | |
| 29 | const ROW_STYLES: Record<DiffLine["kind"], string> = { | |
| 30 | context: "", | |
| Merge branch 'worktree-agent-ad7c6d88d93adc817' | 31 | add: "bg-success/[0.09]", |
| Docs: the quickstart as steps, with both ways to put an agent on an issue | 32 | delete: "bg-danger/[0.09]", |
| 33 | }; | |
| 34 | ||
| 35 | const GUTTER_STYLES: Record<DiffLine["kind"], string> = { | |
| 36 | context: "text-faint", | |
| Merge branch 'worktree-agent-ad7c6d88d93adc817' | 37 | add: "bg-success/[0.14] text-success/70", |
| Docs: the quickstart as steps, with both ways to put an agent on an issue | 38 | delete: "bg-danger/[0.14] text-danger/70", |
| 39 | }; | |
| 40 | ||
| 41 | const MARKERS: Record<DiffLine["kind"], string> = { | |
| 42 | context: " ", | |
| 43 | add: "+", | |
| 44 | delete: "−", | |
| 45 | }; | |
| 46 | ||
| 47 | /** Comments on lines of the change, and whether the viewer may add one. */ | |
| 48 | export type LineReview = { comments: Comment[]; canComment: boolean }; | |
| 49 | ||
| 50 | type Layout = "unified" | "split"; | |
| 51 | ||
| 52 | /** Reads a per-viewer preference; storage can be missing or refuse. */ | |
| 53 | function stored(key: string): string | null { | |
| 54 | try { | |
| 55 | return window.localStorage.getItem(key); | |
| 56 | } catch { | |
| 57 | return null; | |
| 58 | } | |
| 59 | } | |
| 60 | ||
| 61 | function store(key: string, value: string | null) { | |
| 62 | try { | |
| 63 | if (value == null) window.localStorage.removeItem(key); | |
| 64 | else window.localStorage.setItem(key, value); | |
| 65 | } catch { | |
| 66 | // A private window: the preference lasts until the page is left. | |
| 67 | } | |
| 68 | } | |
| 69 | ||
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 70 | /** Whether the screen is phone-sized: a change is shown unified there, never split. */ |
| 71 | function useNarrow(): boolean { | |
| 72 | const [narrow, setNarrow] = useState(false); | |
| 73 | useEffect(() => { | |
| 74 | const query = window.matchMedia("(max-width: 639px)"); | |
| 75 | const update = () => setNarrow(query.matches); | |
| 76 | update(); | |
| 77 | query.addEventListener("change", update); | |
| 78 | return () => query.removeEventListener("change", update); | |
| 79 | }, []); | |
| 80 | return narrow; | |
| 81 | } | |
| 82 | ||
| 83 | /** `+12 −3` with a five-block bar (the bar from `sm` up). */ | |
| Docs: the quickstart as steps, with both ways to put an agent on an issue | 84 | export function Stat({ additions, deletions }: { additions: number; deletions: number }) { |
| 85 | const total = additions + deletions; | |
| 86 | const green = total === 0 ? 0 : Math.round((additions / total) * 5); | |
| 87 | return ( | |
| 88 | <span className="flex shrink-0 items-center gap-2 font-mono text-xs"> | |
| Merge branch 'worktree-agent-ad7c6d88d93adc817' | 89 | <span className="text-success">+{additions}</span> |
| Docs: the quickstart as steps, with both ways to put an agent on an issue | 90 | <span className="text-danger">−{deletions}</span> |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 91 | <span className="hidden gap-px sm:flex" aria-hidden="true"> |
| Docs: the quickstart as steps, with both ways to put an agent on an issue | 92 | {Array.from({ length: 5 }, (_, i) => ( |
| 93 | <span | |
| 94 | key={i} | |
| 95 | className={`size-2 rounded-xs ${ | |
| Merge branch 'worktree-agent-ad7c6d88d93adc817' | 96 | total === 0 ? "bg-line" : i < green ? "bg-success" : "bg-danger" |
| Docs: the quickstart as steps, with both ways to put an agent on an issue | 97 | }`} |
| 98 | /> | |
| 99 | ))} | |
| 100 | </span> | |
| 101 | </span> | |
| 102 | ); | |
| 103 | } | |
| 104 | ||
| 105 | function fileIcon(status: HighlightedFile["status"]) { | |
| 106 | return status === "added" ? FilePlus : status === "deleted" ? FileMinus : FileIcon; | |
| 107 | } | |
| 108 | ||
| 109 | function fileTone(status: HighlightedFile["status"]) { | |
| Merge branch 'worktree-agent-ad7c6d88d93adc817' | 110 | return status === "added" ? "text-success" : status === "deleted" ? "text-danger" : "text-faint"; |
| Docs: the quickstart as steps, with both ways to put an agent on an issue | 111 | } |
| 112 | ||
| 113 | /** One comment made on a line, shown under that line. */ | |
| 114 | function LineComment({ comment }: { comment: Comment }) { | |
| 115 | return ( | |
| 116 | <div className="rounded-lg border border-line bg-bg font-sans"> | |
| 117 | <p className="flex items-center gap-2 border-b border-line px-3 py-1.5 text-xs text-muted"> | |
| 118 | <Avatar name={comment.author.username} size={16} /> | |
| 119 | <span className="font-medium text-fg">{comment.author.username}</span> | |
| 120 | <TimeAgo at={comment.createdAt} /> | |
| 121 | </p> | |
| 122 | <div className="px-3 py-2 text-sm"> | |
| 123 | <Markdown source={comment.body} /> | |
| 124 | </div> | |
| 125 | </div> | |
| 126 | ); | |
| 127 | } | |
| 128 | ||
| 129 | /** A line's text, highlighted when the server could. */ | |
| 130 | function Code({ line }: { line: HighlightedLine | null }) { | |
| 131 | if (!line) return null; | |
| 132 | if (line.html != null) { | |
| 133 | return <span dangerouslySetInnerHTML={{ __html: line.html || " " }} />; | |
| 134 | } | |
| 135 | return <>{line.text || " "}</>; | |
| 136 | } | |
| 137 | ||
| 138 | /** The comments on a line and, when open, the box to add one. */ | |
| 139 | function Thread({ | |
| 140 | path, | |
| 141 | line, | |
| 142 | comments, | |
| 143 | open, | |
| 144 | onClose, | |
| 145 | }: { | |
| 146 | path: string; | |
| 147 | line: number; | |
| 148 | comments: Comment[]; | |
| 149 | open: boolean; | |
| 150 | onClose: () => void; | |
| 151 | }) { | |
| Merge branch 'worktree-agent-a985c01f43538af01' | 152 | // The box stays, saying it is posting, until the new comment shows above it. |
| 153 | const count = useRef(comments.length); | |
| 154 | useEffect(() => { | |
| 155 | if (comments.length > count.current) onClose(); | |
| 156 | count.current = comments.length; | |
| 157 | // eslint-disable-next-line react-hooks/exhaustive-deps -- only a new comment closes it | |
| 158 | }, [comments.length]); | |
| Docs: the quickstart as steps, with both ways to put an agent on an issue | 159 | return ( |
| 160 | <div className="max-w-2xl space-y-2"> | |
| 161 | {comments.map((comment) => ( | |
| 162 | <LineComment key={comment.id} comment={comment} /> | |
| 163 | ))} | |
| 164 | {open && ( | |
| Merge branch 'worktree-agent-a985c01f43538af01' | 165 | <Form method="post" className="space-y-2 font-sans"> |
| Docs: the quickstart as steps, with both ways to put an agent on an issue | 166 | <input type="hidden" name="action" value="comment" /> |
| 167 | <input type="hidden" name="path" value={path} /> | |
| 168 | <input type="hidden" name="line" value={line} /> | |
| 169 | <Textarea name="body" rows={3} required autoFocus placeholder={`Comment on line ${line}`} /> | |
| 170 | <div className="flex gap-2"> | |
| Merge branch 'worktree-agent-a985c01f43538af01' | 171 | <SubmitButton match={{ action: "comment", path, line: String(line) }} pending="Commenting…"> |
| 172 | Comment | |
| 173 | </SubmitButton> | |
| Docs: the quickstart as steps, with both ways to put an agent on an issue | 174 | <Button variant="quiet" type="button" onClick={onClose}> |
| 175 | Cancel | |
| 176 | </Button> | |
| 177 | </div> | |
| 178 | </Form> | |
| 179 | )} | |
| 180 | </div> | |
| 181 | ); | |
| 182 | } | |
| 183 | ||
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 184 | function CommentButton({ line, onClick }: { line: number; onClick: (event: React.MouseEvent) => void }) { |
| Docs: the quickstart as steps, with both ways to put an agent on an issue | 185 | return ( |
| 186 | <button | |
| 187 | type="button" | |
| 188 | aria-label={`Comment on line ${line}`} | |
| 189 | onClick={onClick} | |
| Phones reach every page: each mode's menu from the top bar or by tapping the tab you're on, Docs first in the avatar sheet, a project's pages as a swipeable strip; dialogs as bottom sheets above the keyboard, safe areas, menus kept on screen, 40px targets, touch-visible controls, save bars above the tab bar | 190 | className="absolute top-0 -right-2.5 z-10 hidden size-5 items-center justify-center rounded bg-accent text-bg group-hover:flex focus-visible:flex pointer-coarse:-right-1 pointer-coarse:flex pointer-coarse:size-7" |
| Docs: the quickstart as steps, with both ways to put an agent on an issue | 191 | > |
| 192 | <MessageSquarePlus size={12} /> | |
| 193 | </button> | |
| 194 | ); | |
| 195 | } | |
| 196 | ||
| 197 | type FileProps = { | |
| 198 | file: HighlightedFile; | |
| 199 | comments: Comment[]; | |
| 200 | canComment: boolean; | |
| 201 | }; | |
| 202 | ||
| 203 | function UnifiedRows({ file, comments, canComment }: FileProps) { | |
| 204 | const [writing, setWriting] = useState<number | null>(null); | |
| 205 | return ( | |
| 206 | <table className="w-full border-collapse font-mono text-xs leading-5"> | |
| 207 | <tbody> | |
| 208 | {file.hunks.map((hunk, h) => ( | |
| 209 | <Fragment key={h}> | |
| 210 | {h > 0 && <HunkGap colSpan={4} />} | |
| 211 | {hunk.lines.map((line, index) => { | |
| 212 | const here = comments.filter((c) => line.new != null && c.line === line.new); | |
| 213 | const open = line.new != null && writing === line.new; | |
| 214 | return ( | |
| 215 | <Fragment key={index}> | |
| 216 | <tr className={`group ${ROW_STYLES[line.kind]}`}> | |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 217 | <td className={`w-9 px-1 text-right select-none sm:w-12 sm:px-2 ${GUTTER_STYLES[line.kind]}`}> |
| Docs: the quickstart as steps, with both ways to put an agent on an issue | 218 | {line.old} |
| 219 | </td> | |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 220 | <td |
| 221 | // A tap on the number comments on the line: a touch screen has no hover for the button. | |
| 222 | onClick={canComment && line.new != null ? () => setWriting(open ? null : line.new) : undefined} | |
| 223 | className={`relative w-9 px-1 text-right select-none sm:w-12 sm:px-2 ${canComment && line.new != null ? "cursor-pointer" : ""} ${GUTTER_STYLES[line.kind]}`} | |
| 224 | > | |
| Docs: the quickstart as steps, with both ways to put an agent on an issue | 225 | {line.new} |
| 226 | {canComment && line.new != null && ( | |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 227 | <CommentButton |
| 228 | line={line.new} | |
| 229 | onClick={(event) => { | |
| 230 | event.stopPropagation(); | |
| 231 | setWriting(open ? null : line.new); | |
| 232 | }} | |
| 233 | /> | |
| Docs: the quickstart as steps, with both ways to put an agent on an issue | 234 | )} |
| 235 | </td> | |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 236 | <td className={`w-4 text-center select-none sm:w-5 ${fileToneFor(line.kind)}`}> |
| Docs: the quickstart as steps, with both ways to put an agent on an issue | 237 | {MARKERS[line.kind]} |
| 238 | </td> | |
| 239 | <td className="pr-4 whitespace-pre"> | |
| 240 | <Code line={line} /> | |
| 241 | </td> | |
| 242 | </tr> | |
| 243 | {(here.length > 0 || open) && line.new != null && ( | |
| 244 | <tr> | |
| 245 | <td colSpan={4} className="border-y border-line bg-surface p-3"> | |
| 246 | <Thread | |
| 247 | path={file.path} | |
| 248 | line={line.new} | |
| 249 | comments={here} | |
| 250 | open={open} | |
| 251 | onClose={() => setWriting(null)} | |
| 252 | /> | |
| 253 | </td> | |
| 254 | </tr> | |
| 255 | )} | |
| 256 | </Fragment> | |
| 257 | ); | |
| 258 | })} | |
| 259 | </Fragment> | |
| 260 | ))} | |
| 261 | </tbody> | |
| 262 | </table> | |
| 263 | ); | |
| 264 | } | |
| 265 | ||
| 266 | function fileToneFor(kind: DiffLine["kind"]) { | |
| Merge branch 'worktree-agent-ad7c6d88d93adc817' | 267 | return kind === "add" ? "text-success" : kind === "delete" ? "text-danger" : "text-faint"; |
| Docs: the quickstart as steps, with both ways to put an agent on an issue | 268 | } |
| 269 | ||
| 270 | /** Lines side by side: a run of deletions beside the additions that replaced it. */ | |
| 271 | function pairs(lines: HighlightedLine[]): [HighlightedLine | null, HighlightedLine | null][] { | |
| 272 | const rows: [HighlightedLine | null, HighlightedLine | null][] = []; | |
| 273 | let index = 0; | |
| 274 | while (index < lines.length) { | |
| 275 | const line = lines[index]!; | |
| 276 | if (line.kind === "context") { | |
| 277 | rows.push([line, line]); | |
| 278 | index++; | |
| 279 | continue; | |
| 280 | } | |
| 281 | const removed: HighlightedLine[] = []; | |
| 282 | const added: HighlightedLine[] = []; | |
| 283 | while (index < lines.length && lines[index]!.kind === "delete") removed.push(lines[index++]!); | |
| 284 | while (index < lines.length && lines[index]!.kind === "add") added.push(lines[index++]!); | |
| 285 | for (let n = 0; n < Math.max(removed.length, added.length); n++) { | |
| 286 | rows.push([removed[n] ?? null, added[n] ?? null]); | |
| 287 | } | |
| 288 | } | |
| 289 | return rows; | |
| 290 | } | |
| 291 | ||
| 292 | function SplitRows({ file, comments, canComment }: FileProps) { | |
| 293 | const [writing, setWriting] = useState<number | null>(null); | |
| 294 | const side = (line: HighlightedLine | null, which: "old" | "new") => { | |
| 295 | const kind = line ? (line.kind === "context" ? "context" : line.kind) : null; | |
| 296 | const empty = "bg-surface/60"; | |
| 297 | return ( | |
| 298 | <> | |
| 299 | <td | |
| 300 | className={`relative w-12 px-2 text-right align-top select-none ${kind ? GUTTER_STYLES[kind] : empty} ${ | |
| 301 | which === "new" ? "border-l border-line" : "" | |
| 302 | }`} | |
| 303 | > | |
| 304 | {line?.[which]} | |
| 305 | {which === "new" && canComment && line?.new != null && ( | |
| 306 | <CommentButton line={line.new} onClick={() => setWriting(writing === line.new ? null : line.new)} /> | |
| 307 | )} | |
| 308 | </td> | |
| 309 | <td className={`pr-3 pl-2 align-top break-all whitespace-pre-wrap ${kind ? ROW_STYLES[kind] : empty}`}> | |
| 310 | <Code line={line} /> | |
| 311 | </td> | |
| 312 | </> | |
| 313 | ); | |
| 314 | }; | |
| 315 | return ( | |
| 316 | <table className="w-full table-fixed border-collapse font-mono text-xs leading-5"> | |
| 317 | <colgroup> | |
| 318 | <col className="w-12" /> | |
| 319 | <col /> | |
| 320 | <col className="w-12" /> | |
| 321 | <col /> | |
| 322 | </colgroup> | |
| 323 | <tbody> | |
| 324 | {file.hunks.map((hunk, h) => ( | |
| 325 | <Fragment key={h}> | |
| 326 | {h > 0 && <HunkGap colSpan={4} />} | |
| 327 | {pairs(hunk.lines).map(([before, after], index) => { | |
| 328 | const line = after?.new ?? null; | |
| 329 | const here = comments.filter((c) => line != null && c.line === line); | |
| 330 | const open = line != null && writing === line; | |
| 331 | return ( | |
| 332 | <Fragment key={index}> | |
| 333 | <tr className="group"> | |
| 334 | {side(before && before.kind !== "add" ? before : null, "old")} | |
| 335 | {side(after && after.kind !== "delete" ? after : null, "new")} | |
| 336 | </tr> | |
| 337 | {(here.length > 0 || open) && line != null && ( | |
| 338 | <tr> | |
| 339 | <td colSpan={4} className="border-y border-line bg-surface p-3"> | |
| 340 | <Thread | |
| 341 | path={file.path} | |
| 342 | line={line} | |
| 343 | comments={here} | |
| 344 | open={open} | |
| 345 | onClose={() => setWriting(null)} | |
| 346 | /> | |
| 347 | </td> | |
| 348 | </tr> | |
| 349 | )} | |
| 350 | </Fragment> | |
| 351 | ); | |
| 352 | })} | |
| 353 | </Fragment> | |
| 354 | ))} | |
| 355 | </tbody> | |
| 356 | </table> | |
| 357 | ); | |
| 358 | } | |
| 359 | ||
| 360 | function HunkGap({ colSpan }: { colSpan: number }) { | |
| 361 | return ( | |
| 362 | <tr aria-hidden="true"> | |
| 363 | <td colSpan={colSpan} className="border-y border-line bg-surface py-0.5 text-center text-faint"> | |
| 364 | ⋯ | |
| 365 | </td> | |
| 366 | </tr> | |
| 367 | ); | |
| 368 | } | |
| 369 | ||
| 370 | function FileSection({ | |
| 371 | file, | |
| 372 | review, | |
| 373 | layout, | |
| 374 | collapsed, | |
| 375 | viewed, | |
| 376 | onToggle, | |
| 377 | onViewed, | |
| 378 | fileBase, | |
| 379 | }: { | |
| 380 | file: HighlightedFile; | |
| 381 | review?: LineReview; | |
| 382 | layout: Layout; | |
| 383 | collapsed: boolean; | |
| 384 | viewed: boolean; | |
| 385 | onToggle: () => void; | |
| 386 | onViewed: (viewed: boolean) => void; | |
| 387 | /** Where the file can be read as of the change, e.g. `/acme/web/blob/<hash>`. */ | |
| 388 | fileBase?: string; | |
| 389 | }) { | |
| 390 | const Icon = fileIcon(file.status); | |
| 391 | const comments = review?.comments.filter((comment) => comment.path === file.path) ?? []; | |
| 392 | const shown = new Set(file.hunks.flatMap((hunk) => hunk.lines.map((line) => line.new))); | |
| 393 | // Made against an earlier version of the change, on a line no longer in it. | |
| 394 | const outdated = comments.filter((comment) => comment.line == null || !shown.has(comment.line)); | |
| 395 | // Shown plain at once, and highlighted in the browser when it comes near | |
| 396 | // the screen: the page never waits for colours. | |
| 397 | const section = useRef<HTMLElement>(null); | |
| 398 | const [highlighted, setHighlighted] = useState<HighlightedFile | null>(null); | |
| 399 | useEffect(() => { | |
| 400 | setHighlighted(null); | |
| 401 | const element = section.current; | |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 402 | // Highlighted by the server already: one of the change's first files. |
| 403 | const done = file.hunks.some((hunk) => hunk.lines.some((line) => (line as HighlightedLine).html != null)); | |
| 404 | if (!element || collapsed || done) return; | |
| Docs: the quickstart as steps, with both ways to put an agent on an issue | 405 | let cancelled = false; |
| 406 | const observer = new IntersectionObserver( | |
| 407 | ([entry]) => { | |
| 408 | if (!entry?.isIntersecting) return; | |
| 409 | observer.disconnect(); | |
| 410 | void import("../lib/shiki") | |
| 411 | .then(({ highlightFile }) => highlightFile(file)) | |
| 412 | .then((result) => { | |
| 413 | if (!cancelled && result) setHighlighted(result); | |
| 414 | }) | |
| 415 | .catch(() => {}); | |
| 416 | }, | |
| 417 | { rootMargin: "600px 0px" }, | |
| 418 | ); | |
| 419 | observer.observe(element); | |
| 420 | return () => { | |
| 421 | cancelled = true; | |
| 422 | observer.disconnect(); | |
| 423 | }; | |
| 424 | }, [file, collapsed]); | |
| 425 | const props = { file: highlighted ?? file, comments, canComment: review?.canComment ?? false }; | |
| 426 | return ( | |
| 427 | <section | |
| 428 | ref={section} | |
| 429 | id={`file-${file.path}`} | |
| 430 | data-diff-file={file.path} | |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 431 | // Lands below the site bar and the change's toolbar, whatever their height. |
| 432 | className="scroll-mt-[calc(4rem+var(--diff-toolbar,3rem))] rounded-xl border border-line" | |
| Docs: the quickstart as steps, with both ways to put an agent on an issue | 433 | > |
| 434 | <header | |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 435 | // Sticks under the change's toolbar (its height is --diff-toolbar), not behind it. |
| g1t is described as the product it is in alpha, and its shell takes the new shape: the README, the docs home and docs/PLAN.md label every feature Live, Preview or Coming; a floating dock with the g1t mark, Today, Chat, Notifications, Agents, Code, Artifacts, your pinned apps and the Apps launcher, with People, Workspace and the account menu (now holding help) at its foot; the workspace's logo and switcher heading a flat sidebar that folds away with Ctrl B, or leading the header's breadcrumbs where there is none; the page in a rounded panel with a full-width header; Today as the front page, with first-time acceptance of agents' pull requests, what needs you and what's at stake, spend today and one item to start with, every number from a service; Notifications in place of the Inbox at /notifications; a bottom bar and More sheet on phones; and sign-in, sign-up, two-factor, reset, invites and choosing a workspace on standalone pages with no app around them. | 436 | className={`sticky top-[calc(var(--topbar-h)+var(--diff-toolbar,0px))] z-20 flex items-center gap-2 border-line bg-surface/95 px-2 py-2 backdrop-blur sm:gap-2.5 sm:px-3 ${ |
| Docs: the quickstart as steps, with both ways to put an agent on an issue | 437 | collapsed ? "rounded-xl" : "rounded-t-xl border-b" |
| 438 | }`} | |
| 439 | > | |
| 440 | <button | |
| 441 | type="button" | |
| 442 | onClick={onToggle} | |
| 443 | aria-label={collapsed ? `Show ${file.path}` : `Hide ${file.path}`} | |
| 444 | aria-expanded={!collapsed} | |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 445 | className="-m-1.5 shrink-0 rounded p-2 text-faint hover:bg-raised hover:text-fg sm:m-0 sm:p-0.5" |
| Docs: the quickstart as steps, with both ways to put an agent on an issue | 446 | > |
| 447 | {collapsed ? <ChevronRight size={15} /> : <ChevronDown size={15} />} | |
| 448 | </button> | |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 449 | <Icon size={15} className={`hidden shrink-0 sm:block ${fileTone(file.status)}`} /> |
| 450 | {/* A long path is cut at its start, so the file's own name shows. */} | |
| Docs: the quickstart as steps, with both ways to put an agent on an issue | 451 | <button |
| 452 | type="button" | |
| 453 | onClick={onToggle} | |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 454 | dir="rtl" |
| Docs: the quickstart as steps, with both ways to put an agent on an issue | 455 | className={`min-w-0 grow truncate text-left font-mono text-[0.8125rem] ${viewed ? "text-muted" : ""}`} |
| 456 | > | |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 457 | <bdi>{file.path}</bdi> |
| Docs: the quickstart as steps, with both ways to put an agent on an issue | 458 | </button> |
| 459 | {comments.length > 0 && ( | |
| 460 | <span className="flex items-center gap-1 text-xs text-muted"> | |
| 461 | <MessageSquarePlus size={12} /> | |
| 462 | {comments.length} | |
| 463 | </span> | |
| 464 | )} | |
| 465 | <Stat additions={file.additions} deletions={file.deletions} /> | |
| 466 | {fileBase && file.status !== "deleted" && ( | |
| 467 | <span className="hidden items-center gap-0.5 text-xs sm:flex"> | |
| 468 | <Link to={`${fileBase}/${file.path}`} className="rounded px-1.5 py-0.5 text-muted hover:bg-raised hover:text-fg"> | |
| 469 | View | |
| 470 | </Link> | |
| 471 | <Link | |
| 472 | to={`${fileBase}/${file.path}?blame=1`} | |
| 473 | className="rounded px-1.5 py-0.5 text-muted hover:bg-raised hover:text-fg" | |
| 474 | > | |
| 475 | Blame | |
| 476 | </Link> | |
| 477 | </span> | |
| 478 | )} | |
| 479 | <label | |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 480 | className={`ml-1 flex shrink-0 cursor-pointer items-center gap-1.5 rounded-md border px-2 py-1.5 text-xs transition-colors select-none sm:py-0.5 ${ |
| Docs: the quickstart as steps, with both ways to put an agent on an issue | 481 | viewed ? "border-accent/40 bg-accent/10 text-accent" : "border-line text-muted hover:text-fg" |
| 482 | }`} | |
| 483 | > | |
| Workspace names and icons, and a component kit for every control | 484 | <Checkbox |
| Docs: the quickstart as steps, with both ways to put an agent on an issue | 485 | checked={viewed} |
| Workspace names and icons, and a component kit for every control | 486 | onCheckedChange={(checked) => onViewed(checked === true)} |
| 487 | className="size-3.5 rounded-[4px] [&_svg]:size-2.5" | |
| Docs: the quickstart as steps, with both ways to put an agent on an issue | 488 | /> |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 489 | <span className="sr-only sm:not-sr-only">Viewed</span> |
| Docs: the quickstart as steps, with both ways to put an agent on an issue | 490 | </label> |
| 491 | </header> | |
| 492 | {!collapsed && ( | |
| 493 | <div className="overflow-hidden rounded-b-xl"> | |
| 494 | {outdated.length > 0 && ( | |
| 495 | <div className="space-y-2 border-b border-line bg-surface/50 p-3"> | |
| 496 | <p className="text-xs text-faint">On lines that have since changed</p> | |
| 497 | {outdated.map((comment) => ( | |
| 498 | <LineComment key={comment.id} comment={comment} /> | |
| 499 | ))} | |
| 500 | </div> | |
| 501 | )} | |
| 502 | {file.binary ? ( | |
| 503 | <p className="px-4 py-6 text-sm text-muted">Binary or large file; its contents are not shown.</p> | |
| 504 | ) : file.hunks.length === 0 ? ( | |
| 505 | <p className="px-4 py-6 text-sm text-muted">No line changes{file.status === "added" ? ": an empty file" : ""}.</p> | |
| 506 | ) : ( | |
| 507 | <div className="overflow-x-auto"> | |
| 508 | {layout === "split" ? <SplitRows {...props} /> : <UnifiedRows {...props} />} | |
| 509 | </div> | |
| 510 | )} | |
| 511 | </div> | |
| 512 | )} | |
| 513 | </section> | |
| 514 | ); | |
| 515 | } | |
| 516 | ||
| 517 | type TreeNode = { name: string; path: string; children: TreeNode[]; file?: HighlightedFile }; | |
| 518 | ||
| 519 | /** The changed files as folders, with folders of one folder merged: `src/lib`. */ | |
| 520 | function treeOf(files: HighlightedFile[]): TreeNode[] { | |
| 521 | const root: TreeNode = { name: "", path: "", children: [] }; | |
| 522 | for (const file of files) { | |
| 523 | const parts = file.path.split("/"); | |
| 524 | let node = root; | |
| 525 | parts.forEach((part, index) => { | |
| 526 | const path = parts.slice(0, index + 1).join("/"); | |
| 527 | let child = node.children.find((c) => c.name === part && !c.file === (index < parts.length - 1)); | |
| 528 | if (!child) { | |
| 529 | child = { name: part, path, children: [] }; | |
| 530 | if (index === parts.length - 1) child.file = file; | |
| 531 | node.children.push(child); | |
| 532 | } | |
| 533 | node = child; | |
| 534 | }); | |
| 535 | } | |
| 536 | const squash = (node: TreeNode): TreeNode => { | |
| 537 | let current = node; | |
| 538 | while (!current.file && current.children.length === 1 && !current.children[0]!.file) { | |
| 539 | const only = current.children[0]!; | |
| 540 | current = { ...only, name: `${current.name}/${only.name}` }; | |
| 541 | } | |
| 542 | const children = current.children | |
| 543 | .map(squash) | |
| 544 | .sort((a, b) => Number(!!a.file) - Number(!!b.file) || a.name.localeCompare(b.name)); | |
| 545 | return { ...current, children }; | |
| 546 | }; | |
| 547 | return squash(root).children; | |
| 548 | } | |
| 549 | ||
| 550 | function Tree({ | |
| 551 | nodes, | |
| 552 | depth, | |
| 553 | active, | |
| 554 | viewed, | |
| 555 | onPick, | |
| 556 | }: { | |
| 557 | nodes: TreeNode[]; | |
| 558 | depth: number; | |
| 559 | active: string | null; | |
| 560 | viewed: Set<string>; | |
| 561 | onPick: (path: string) => void; | |
| 562 | }) { | |
| 563 | return ( | |
| 564 | <ul> | |
| 565 | {nodes.map((node) => { | |
| 566 | if (!node.file) { | |
| 567 | return ( | |
| 568 | <li key={node.path}> | |
| 569 | <p | |
| 570 | className="flex items-center gap-1.5 truncate py-1 font-mono text-xs text-faint" | |
| 571 | style={{ paddingLeft: depth * 12 + 8 }} | |
| 572 | > | |
| 573 | <Folder size={13} className="shrink-0" /> | |
| 574 | {node.name} | |
| 575 | </p> | |
| 576 | <Tree nodes={node.children} depth={depth + 1} active={active} viewed={viewed} onPick={onPick} /> | |
| 577 | </li> | |
| 578 | ); | |
| 579 | } | |
| 580 | const file = node.file; | |
| 581 | const Icon = fileIcon(file.status); | |
| 582 | return ( | |
| 583 | <li key={node.path}> | |
| 584 | <button | |
| 585 | type="button" | |
| 586 | onClick={() => onPick(file.path)} | |
| 587 | style={{ paddingLeft: depth * 12 + 8 }} | |
| 588 | className={`flex w-full items-center gap-1.5 rounded-md py-1 pr-2 text-left font-mono text-xs transition-colors ${ | |
| 589 | active === file.path ? "bg-raised text-fg" : "text-muted hover:bg-raised/60 hover:text-fg" | |
| 590 | } ${viewed.has(file.path) ? "opacity-55" : ""}`} | |
| 591 | > | |
| 592 | <Icon size={13} className={`shrink-0 ${fileTone(file.status)}`} /> | |
| 593 | <span className="min-w-0 grow truncate">{node.name}</span> | |
| 594 | <span className="shrink-0 text-[0.625rem] tabular-nums"> | |
| Merge branch 'worktree-agent-ad7c6d88d93adc817' | 595 | {file.additions > 0 && <span className="text-success">+{file.additions}</span>} |
| Docs: the quickstart as steps, with both ways to put an agent on an issue | 596 | {file.deletions > 0 && <span className="ml-1 text-danger">−{file.deletions}</span>} |
| 597 | </span> | |
| 598 | </button> | |
| 599 | </li> | |
| 600 | ); | |
| 601 | })} | |
| 602 | </ul> | |
| 603 | ); | |
| 604 | } | |
| 605 | ||
| 606 | /** | |
| 607 | * A change, file by file: a tree of what changed beside the diffs, unified | |
| 608 | * or side by side, each file foldable and markable as viewed. `j` and `k` | |
| 609 | * move between files and `t` filters them. | |
| 610 | */ | |
| 611 | export function DiffView({ | |
| 612 | comparison, | |
| 613 | review, | |
| 614 | empty, | |
| 615 | fileBase, | |
| 616 | }: { | |
| 617 | comparison: Comparison; | |
| 618 | review?: LineReview; | |
| 619 | /** What to say when nothing changed. */ | |
| 620 | empty?: React.ReactNode; | |
| 621 | /** Where each file can be read as of the change, to link to it. */ | |
| 622 | fileBase?: string; | |
| 623 | }) { | |
| 624 | const { files, truncated } = comparison; | |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 625 | const [chosenLayout, setLayout] = useState<Layout>("unified"); |
| 626 | // Side by side has no room on a phone. | |
| 627 | const narrow = useNarrow(); | |
| 628 | const layout: Layout = narrow ? "unified" : chosenLayout; | |
| 629 | const [picking, setPicking] = useState(false); | |
| Docs: the quickstart as steps, with both ways to put an agent on an issue | 630 | const [collapsed, setCollapsed] = useState<Set<string>>(new Set()); |
| 631 | const [viewed, setViewed] = useState<Set<string>>(new Set()); | |
| 632 | const [active, setActive] = useState<string | null>(files[0]?.path ?? null); | |
| 633 | const [filter, setFilter] = useState(""); | |
| 634 | const filterInput = useRef<HTMLInputElement>(null); | |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 635 | // The toolbar's height, as it wraps at each width, so the files' own |
| 636 | // sticky headers sit below it rather than under it. | |
| 637 | const frame = useRef<HTMLDivElement>(null); | |
| 638 | const toolbar = useRef<HTMLDivElement>(null); | |
| 639 | const noFiles = files.length === 0; | |
| 640 | useEffect(() => { | |
| 641 | const bar = toolbar.current; | |
| 642 | const root = frame.current; | |
| 643 | if (!bar || !root) return; | |
| 644 | const measure = () => root.style.setProperty("--diff-toolbar", `${bar.offsetHeight}px`); | |
| 645 | measure(); | |
| 646 | if (typeof ResizeObserver === "undefined") return; | |
| 647 | const observer = new ResizeObserver(measure); | |
| 648 | observer.observe(bar); | |
| 649 | return () => observer.disconnect(); | |
| 650 | }, [noFiles]); | |
| Docs: the quickstart as steps, with both ways to put an agent on an issue | 651 | const viewedKey = `g1t:viewed:${comparison.head}`; |
| 652 | ||
| 653 | // Preferences live in the browser: read them once it is there. | |
| 654 | useEffect(() => { | |
| 655 | if (stored("g1t:diff-layout") === "split") setLayout("split"); | |
| 656 | const saved = stored(viewedKey); | |
| 657 | if (saved) { | |
| 658 | try { | |
| 659 | const paths = new Set<string>(JSON.parse(saved)); | |
| 660 | setViewed(paths); | |
| 661 | setCollapsed(new Set(paths)); | |
| 662 | } catch { | |
| 663 | // Something else wrote it. | |
| 664 | } | |
| 665 | } | |
| 666 | }, [viewedKey]); | |
| 667 | ||
| 668 | const changeLayout = (next: Layout) => { | |
| 669 | setLayout(next); | |
| 670 | store("g1t:diff-layout", next); | |
| 671 | }; | |
| 672 | const markViewed = (path: string, on: boolean) => { | |
| 673 | setViewed((previous) => { | |
| 674 | const next = new Set(previous); | |
| 675 | if (on) next.add(path); | |
| 676 | else next.delete(path); | |
| 677 | store(viewedKey, next.size ? JSON.stringify([...next]) : null); | |
| 678 | return next; | |
| 679 | }); | |
| 680 | // Marking a file viewed folds it away, as a reviewer moves on. | |
| 681 | setCollapsed((previous) => { | |
| 682 | const next = new Set(previous); | |
| 683 | if (on) next.add(path); | |
| 684 | else next.delete(path); | |
| 685 | return next; | |
| 686 | }); | |
| 687 | }; | |
| 688 | const toggle = (path: string) => | |
| 689 | setCollapsed((previous) => { | |
| 690 | const next = new Set(previous); | |
| 691 | if (next.has(path)) next.delete(path); | |
| 692 | else next.add(path); | |
| 693 | return next; | |
| 694 | }); | |
| 695 | ||
| 696 | const shownFiles = useMemo(() => { | |
| 697 | const words = filter.toLowerCase().split(/\s+/).filter(Boolean); | |
| 698 | return words.length === 0 | |
| 699 | ? files | |
| 700 | : files.filter((file) => words.every((word) => file.path.toLowerCase().includes(word))); | |
| 701 | }, [files, filter]); | |
| 702 | const tree = useMemo(() => treeOf(shownFiles), [shownFiles]); | |
| 703 | ||
| 704 | const jump = useCallback((path: string) => { | |
| 705 | setCollapsed((previous) => { | |
| 706 | if (!previous.has(path)) return previous; | |
| 707 | const next = new Set(previous); | |
| 708 | next.delete(path); | |
| 709 | return next; | |
| 710 | }); | |
| 711 | setActive(path); | |
| 712 | requestAnimationFrame(() => | |
| 713 | document.getElementById(`file-${path}`)?.scrollIntoView({ behavior: "smooth", block: "start" }), | |
| 714 | ); | |
| 715 | }, []); | |
| 716 | ||
| 717 | // The file at the top of the screen is the current one. | |
| 718 | useEffect(() => { | |
| 719 | const sections = [...document.querySelectorAll<HTMLElement>("[data-diff-file]")]; | |
| 720 | if (sections.length === 0) return; | |
| 721 | const observer = new IntersectionObserver( | |
| 722 | (entries) => { | |
| 723 | const top = entries | |
| 724 | .filter((entry) => entry.isIntersecting) | |
| 725 | .sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)[0]; | |
| 726 | if (top) setActive((top.target as HTMLElement).dataset.diffFile ?? null); | |
| 727 | }, | |
| 728 | { rootMargin: "-112px 0px -60% 0px" }, | |
| 729 | ); | |
| 730 | sections.forEach((section) => observer.observe(section)); | |
| 731 | return () => observer.disconnect(); | |
| 732 | }, [shownFiles, collapsed]); | |
| 733 | ||
| 734 | useEffect(() => { | |
| 735 | const onKey = (event: KeyboardEvent) => { | |
| 736 | const target = event.target as HTMLElement | null; | |
| 737 | if (event.metaKey || event.ctrlKey || event.altKey) return; | |
| 738 | if (target && (target.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName))) return; | |
| 739 | const index = shownFiles.findIndex((file) => file.path === active); | |
| 740 | if (event.key === "j" || event.key === "k") { | |
| 741 | const next = shownFiles[Math.max(0, Math.min(shownFiles.length - 1, index + (event.key === "j" ? 1 : -1)))]; | |
| 742 | if (next) { | |
| 743 | event.preventDefault(); | |
| 744 | jump(next.path); | |
| 745 | } | |
| 746 | } else if (event.key === "t") { | |
| 747 | event.preventDefault(); | |
| 748 | filterInput.current?.focus(); | |
| 749 | } else if (event.key === "v" && active) { | |
| 750 | event.preventDefault(); | |
| 751 | markViewed(active, !viewed.has(active)); | |
| 752 | } | |
| 753 | }; | |
| 754 | window.addEventListener("keydown", onKey); | |
| 755 | return () => window.removeEventListener("keydown", onKey); | |
| 756 | }); | |
| 757 | ||
| 758 | if (files.length === 0) { | |
| 759 | return ( | |
| 760 | <EmptyState title="No changes"> | |
| 761 | {empty ?? "Nothing has been pushed to this pull request yet, or it matches the branch it would merge into."} | |
| 762 | </EmptyState> | |
| 763 | ); | |
| 764 | } | |
| 765 | const additions = files.reduce((sum, file) => sum + file.additions, 0); | |
| 766 | const deletions = files.reduce((sum, file) => sum + file.deletions, 0); | |
| 767 | const allCollapsed = files.every((file) => collapsed.has(file.path)); | |
| 768 | return ( | |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 769 | <div ref={frame}> |
| Phones reach every page: each mode's menu from the top bar or by tapping the tab you're on, Docs first in the avatar sheet, a project's pages as a swipeable strip; dialogs as bottom sheets above the keyboard, safe areas, menus kept on screen, 40px targets, touch-visible controls, save bars above the tab bar | 770 | <div ref={toolbar} className="sticky top-(--topbar-h) z-30 -mx-1 mb-3 flex flex-wrap items-center gap-x-4 gap-y-2 bg-bg/90 px-1 py-2 backdrop-blur"> |
| Docs: the quickstart as steps, with both ways to put an agent on an issue | 771 | <span className="text-sm text-muted"> |
| 772 | <span className="font-medium text-fg">{files.length}</span> {files.length === 1 ? "file" : "files"} | |
| 773 | </span> | |
| 774 | <Stat additions={additions} deletions={deletions} /> | |
| 775 | <span className="flex items-center gap-2 text-xs text-muted"> | |
| 776 | <span className="h-1.5 w-20 overflow-hidden rounded-full bg-line"> | |
| 777 | <span | |
| 778 | className="block h-full rounded-full bg-accent transition-all" | |
| 779 | style={{ width: `${(viewed.size / files.length) * 100}%` }} | |
| 780 | /> | |
| 781 | </span> | |
| 782 | {viewed.size}/{files.length} viewed | |
| 783 | </span> | |
| 784 | <div className="ml-auto flex items-center gap-2"> | |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 785 | {files.length > 1 && ( |
| 786 | // Below xl the tree is not beside the diffs: it opens as a sheet. | |
| 787 | <button | |
| 788 | type="button" | |
| 789 | onClick={() => setPicking(true)} | |
| 790 | className="flex min-h-9 items-center gap-1.5 rounded-md border border-line px-2.5 text-xs text-muted transition-colors hover:border-line-strong hover:text-fg xl:hidden" | |
| 791 | > | |
| 792 | <Files size={13} /> | |
| 793 | Files | |
| 794 | </button> | |
| 795 | )} | |
| Docs: the quickstart as steps, with both ways to put an agent on an issue | 796 | <button |
| 797 | type="button" | |
| 798 | onClick={() => setCollapsed(allCollapsed ? new Set() : new Set(files.map((file) => file.path)))} | |
| 799 | className="rounded-md px-2 py-1 text-xs text-muted hover:bg-raised hover:text-fg" | |
| 800 | > | |
| 801 | {allCollapsed ? "Expand all" : "Collapse all"} | |
| 802 | </button> | |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 803 | <div className="hidden rounded-md border border-line p-0.5 sm:flex" role="radiogroup" aria-label="Layout"> |
| Docs: the quickstart as steps, with both ways to put an agent on an issue | 804 | {( |
| 805 | [ | |
| 806 | ["unified", <Rows3 key="u" size={13} />, "Unified"], | |
| 807 | ["split", <Columns2 key="s" size={13} />, "Split"], | |
| 808 | ] as const | |
| 809 | ).map(([value, icon, label]) => ( | |
| 810 | <button | |
| 811 | key={value} | |
| 812 | type="button" | |
| 813 | role="radio" | |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 814 | aria-checked={chosenLayout === value} |
| Docs: the quickstart as steps, with both ways to put an agent on an issue | 815 | onClick={() => changeLayout(value)} |
| 816 | className={`flex items-center gap-1.5 rounded px-2 py-0.5 text-xs transition-colors ${ | |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 817 | chosenLayout === value ? "bg-raised text-fg" : "text-muted hover:text-fg" |
| Docs: the quickstart as steps, with both ways to put an agent on an issue | 818 | }`} |
| 819 | > | |
| 820 | {icon} | |
| 821 | {label} | |
| 822 | </button> | |
| 823 | ))} | |
| 824 | </div> | |
| 825 | </div> | |
| 826 | </div> | |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 827 | {files.length > 1 && ( |
| 828 | <Sheet open={picking} onOpenChange={setPicking}> | |
| 829 | <SheetContent | |
| 830 | side="left" | |
| 831 | // No keyboard popping up over the list on a phone: the filter is a tap away. | |
| 832 | onOpenAutoFocus={(event) => event.preventDefault()} | |
| Phones reach every page: each mode's menu from the top bar or by tapping the tab you're on, Docs first in the avatar sheet, a project's pages as a swipeable strip; dialogs as bottom sheets above the keyboard, safe areas, menus kept on screen, 40px targets, touch-visible controls, save bars above the tab bar | 833 | className="inset-x-0 top-auto bottom-0 h-[80dvh] max-w-none rounded-t-2xl pt-0 border-t border-r-0 data-[state=closed]:animate-fade-out data-[state=open]:animate-fade-in sm:max-w-none xl:hidden" |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 834 | > |
| 835 | <SheetHeader> | |
| 836 | <SheetTitle>Files changed</SheetTitle> | |
| 837 | <SheetDescription> | |
| 838 | {files.length} files · {viewed.size} viewed | |
| 839 | </SheetDescription> | |
| 840 | </SheetHeader> | |
| 841 | <div className="relative px-4 pt-3"> | |
| 842 | <Search size={14} className="pointer-events-none absolute top-1/2 left-6.5 mt-1.5 -translate-y-1/2 text-faint" /> | |
| 843 | <input | |
| 844 | value={filter} | |
| 845 | onChange={(event) => setFilter(event.target.value)} | |
| 846 | placeholder="Filter files" | |
| 847 | aria-label="Filter files" | |
| 848 | autoComplete="off" | |
| 849 | data-1p-ignore | |
| 850 | className="h-10 w-full rounded-md border border-line bg-bg pr-3 pl-8 text-sm outline-none placeholder:text-faint focus:border-accent-dim" | |
| 851 | /> | |
| 852 | </div> | |
| 853 | <nav aria-label="Files changed" className="min-h-0 grow overflow-y-auto px-2 py-2 [&_button]:min-h-10 [&_button]:text-[0.8125rem] [&_p]:min-h-8 [&_p]:text-[0.8125rem]"> | |
| 854 | <Tree | |
| 855 | nodes={tree} | |
| 856 | depth={0} | |
| 857 | active={active} | |
| 858 | viewed={viewed} | |
| 859 | onPick={(path) => { | |
| 860 | setPicking(false); | |
| 861 | // Once the sheet has let the page go, so its scroll is not undone. | |
| 862 | setTimeout(() => jump(path), 50); | |
| 863 | }} | |
| 864 | /> | |
| 865 | {shownFiles.length === 0 && <p className="px-2 py-6 text-center text-sm text-muted">No file matches.</p>} | |
| 866 | </nav> | |
| 867 | </SheetContent> | |
| 868 | </Sheet> | |
| 869 | )} | |
| Docs: the quickstart as steps, with both ways to put an agent on an issue | 870 | <div className="flex gap-4"> |
| 871 | {files.length > 1 && ( | |
| g1t is described as the product it is in alpha, and its shell takes the new shape: the README, the docs home and docs/PLAN.md label every feature Live, Preview or Coming; a floating dock with the g1t mark, Today, Chat, Notifications, Agents, Code, Artifacts, your pinned apps and the Apps launcher, with People, Workspace and the account menu (now holding help) at its foot; the workspace's logo and switcher heading a flat sidebar that folds away with Ctrl B, or leading the header's breadcrumbs where there is none; the page in a rounded panel with a full-width header; Today as the front page, with first-time acceptance of agents' pull requests, what needs you and what's at stake, spend today and one item to start with, every number from a service; Notifications in place of the Inbox at /notifications; a bottom bar and More sheet on phones; and sign-in, sign-up, two-factor, reset, invites and choosing a workspace on standalone pages with no app around them. | 872 | <aside className="sticky top-[calc(var(--topbar-h)+3.5rem)] hidden max-h-[calc(100vh-var(--topbar-h)-4.5rem)] w-60 shrink-0 flex-col self-start xl:flex"> |
| Docs: the quickstart as steps, with both ways to put an agent on an issue | 873 | <div className="relative"> |
| 874 | <Search size={13} className="pointer-events-none absolute top-1/2 left-2.5 -translate-y-1/2 text-faint" /> | |
| 875 | <input | |
| 876 | ref={filterInput} | |
| 877 | value={filter} | |
| 878 | onChange={(event) => setFilter(event.target.value)} | |
| 879 | onKeyDown={(event) => { | |
| 880 | if (event.key === "Escape") { | |
| 881 | setFilter(""); | |
| 882 | event.currentTarget.blur(); | |
| 883 | } else if (event.key === "Enter" && shownFiles[0]) { | |
| 884 | jump(shownFiles[0].path); | |
| 885 | event.currentTarget.blur(); | |
| 886 | } | |
| 887 | }} | |
| 888 | placeholder="Filter files" | |
| 889 | autoComplete="off" | |
| 890 | data-1p-ignore | |
| 891 | className="h-8 w-full rounded-md border border-line bg-surface/60 pr-7 pl-7 text-xs outline-none placeholder:text-faint focus:border-accent-dim" | |
| 892 | /> | |
| 893 | <kbd className="absolute top-1/2 right-2 -translate-y-1/2 rounded border border-line px-1 font-mono text-[0.625rem] text-faint"> | |
| 894 | t | |
| 895 | </kbd> | |
| 896 | </div> | |
| 897 | <nav className="mt-2 min-h-0 overflow-y-auto pb-2"> | |
| 898 | <Tree nodes={tree} depth={0} active={active} viewed={viewed} onPick={jump} /> | |
| 899 | </nav> | |
| 900 | <p className="mt-auto border-t border-line pt-2 text-[0.6875rem] text-faint"> | |
| 901 | <kbd className="font-mono">j</kbd>/<kbd className="font-mono">k</kbd> next and previous file,{" "} | |
| 902 | <kbd className="font-mono">v</kbd> mark viewed | |
| 903 | </p> | |
| 904 | </aside> | |
| 905 | )} | |
| 906 | <div className="min-w-0 grow space-y-3"> | |
| 907 | {review?.canComment && ( | |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 908 | <p className="text-xs text-faint"> |
| 909 | <span className="pointer-coarse:hidden">Hover a line and press the button beside its number to comment on it.</span> | |
| 910 | <span className="hidden pointer-coarse:inline">Tap a line number to comment on it.</span> | |
| 911 | </p> | |
| Docs: the quickstart as steps, with both ways to put an agent on an issue | 912 | )} |
| 913 | {shownFiles.map((file) => ( | |
| 914 | <FileSection | |
| 915 | key={file.path} | |
| 916 | file={file} | |
| 917 | review={review} | |
| 918 | layout={layout} | |
| 919 | collapsed={collapsed.has(file.path)} | |
| 920 | viewed={viewed.has(file.path)} | |
| 921 | onToggle={() => toggle(file.path)} | |
| 922 | onViewed={(on) => markViewed(file.path, on)} | |
| 923 | fileBase={fileBase} | |
| 924 | /> | |
| 925 | ))} | |
| 926 | {shownFiles.length === 0 && <p className="py-8 text-center text-sm text-muted">No file matches.</p>} | |
| 927 | {truncated && <p className="text-center text-sm text-muted">This change is too large to show in full.</p>} | |
| 928 | </div> | |
| 929 | </div> | |
| 930 | </div> | |
| 931 | ); | |
| 932 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.