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.
| Commit pages credit co-authors instead of printing their trailers | 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, |
| Commit pages credit co-authors instead of printing their trailers | 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"; |
| Commit pages credit co-authors instead of printing their trailers | 28 | |
| 29 | const ROW_STYLES: Record<DiffLine["kind"], string> = { | |
| 30 | context: "", | |
| Merge branch 'worktree-agent-ad7c6d88d93adc817' | 31 | add: "bg-success/[0.09]", |
| Commit pages credit co-authors instead of printing their trailers | 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", |
| Commit pages credit co-authors instead of printing their trailers | 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). */ | |
| Commit pages credit co-authors instead of printing their trailers | 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> |
| Commit pages credit co-authors instead of printing their trailers | 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"> |
| Commit pages credit co-authors instead of printing their trailers | 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" |
| Commit pages credit co-authors instead of printing their trailers | 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"; |
| Commit pages credit co-authors instead of printing their trailers | 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]); | |
| Commit pages credit co-authors instead of printing their trailers | 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"> |
| Commit pages credit co-authors instead of printing their trailers | 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> | |
| Commit pages credit co-authors instead of printing their trailers | 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 }) { |
| Commit pages credit co-authors instead of printing their trailers | 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" |
| Commit pages credit co-authors instead of printing their trailers | 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]}`}> |
| Commit pages credit co-authors instead of printing their trailers | 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 | > | |
| Commit pages credit co-authors instead of printing their trailers | 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 | /> | |
| Commit pages credit co-authors instead of printing their trailers | 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)}`}> |
| Commit pages credit co-authors instead of printing their trailers | 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"; |
| Commit pages credit co-authors instead of printing their trailers | 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; | |
| Commit pages credit co-authors instead of printing their trailers | 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" | |
| Commit pages credit co-authors instead of printing their trailers | 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 ${ |
| Commit pages credit co-authors instead of printing their trailers | 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" |
| Commit pages credit co-authors instead of printing their trailers | 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. */} | |
| Commit pages credit co-authors instead of printing their trailers | 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" |
| Commit pages credit co-authors instead of printing their trailers | 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> |
| Commit pages credit co-authors instead of printing their trailers | 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 ${ |
| Commit pages credit co-authors instead of printing their trailers | 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 |
| Commit pages credit co-authors instead of printing their trailers | 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" | |
| Commit pages credit co-authors instead of printing their trailers | 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> |
| Commit pages credit co-authors instead of printing their trailers | 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>} |
| Commit pages credit co-authors instead of printing their trailers | 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); | |
| Commit pages credit co-authors instead of printing their trailers | 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]); | |
| Commit pages credit co-authors instead of printing their trailers | 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"> |
| Commit pages credit co-authors instead of printing their trailers | 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 | )} | |
| Commit pages credit co-authors instead of printing their trailers | 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"> |
| Commit pages credit co-authors instead of printing their trailers | 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} |
| Commit pages credit co-authors instead of printing their trailers | 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" |
| Commit pages credit co-authors instead of printing their trailers | 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 | )} | |
| Commit pages credit co-authors instead of printing their trailers | 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"> |
| Commit pages credit co-authors instead of printing their trailers | 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> | |
| Commit pages credit co-authors instead of printing their trailers | 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.