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