The top of every page is one surface: the breadcrumb, search, spend, Ask g1t, bell and + share one height, corners and spacing, Ask g1t is a plain tinted button with the g1t mark drawn on whole pixels, a page's own header sits directly under the bar at the same left edge, and every section, conversations and open artifacts included, draws the same rounded panel. Tables written in chat render as tables, and the site's file, checkbox, radio, number and date controls are the design system's. Button, Separator, Breadcrumb, Table and FileDrop join the shared components; the chat guide says how tables work.
35 files+892−2170/35 viewed
| 298 | 298 | address on its own becomes a link, and links open in a new tab. Headings | |
| 299 | 299 | an agent writes show as a bold line, and a rule as a line across. | |
| 300 | 300 | ||
| 301 | + | A table written in Markdown shows as a table: a header row, a row of | |
| 302 | + | `---` under it (`:--`, `:-:` or `--:` to line a column up left, centre | |
| 303 | + | or right), then a row per line. A wide table scrolls sideways inside the | |
| 304 | + | message. The composer has no table to edit, so editing a message with | |
| 305 | + | one shows its rows as the lines they were written as. | |
| 306 | + | ||
| 307 | + | ```md | |
| 308 | + | | Session | Length | Who | | |
| 309 | + | | --- | --: | --- | | |
| 310 | + | | Welcome and tour | 45 min | Ana | | |
| 311 | + | | Your first issue | 60 min | Margo | | |
| 312 | + | ``` | |
| 313 | + | ||
| 301 | 314 | Chat never shows HTML: `<b>` reads as typed. An image written in Markdown | |
| 302 | 315 | shows as a link to it, not the image. | |
| 303 | 316 |
| 274 | 274 | :root { | |
| 275 | 275 | accent-color: var(--color-accent); | |
| 276 | 276 | } | |
| 277 | + | ||
| 278 | + | /* | |
| 279 | + | * Number fields have no spin buttons: the arrow keys still step them, and | |
| 280 | + | * their digits line up in columns. | |
| 281 | + | */ | |
| 282 | + | input[type="number"] { | |
| 283 | + | -moz-appearance: textfield; | |
| 284 | + | appearance: textfield; | |
| 285 | + | font-variant-numeric: tabular-nums; | |
| 286 | + | } | |
| 287 | + | input[type="number"]::-webkit-inner-spin-button, | |
| 288 | + | input[type="number"]::-webkit-outer-spin-button { | |
| 289 | + | -webkit-appearance: none; | |
| 290 | + | appearance: none; | |
| 291 | + | margin: 0; | |
| 292 | + | } | |
| 293 | + | ||
| 294 | + | /* | |
| 295 | + | * Date and time fields: the picker's icon is drawn by the browser in the | |
| 296 | + | * page's color-scheme (set on <html> above), so it is right in both | |
| 297 | + | * themes; it sits quiet until hovered. The part being edited is marked | |
| 298 | + | * in the accent, not the system's blue. | |
| 299 | + | */ | |
| 300 | + | input:is([type="date"], [type="time"], [type="datetime-local"], [type="month"], [type="week"]) { | |
| 301 | + | font-variant-numeric: tabular-nums; | |
| 302 | + | } | |
| 303 | + | ::-webkit-calendar-picker-indicator { | |
| 304 | + | cursor: pointer; | |
| 305 | + | border-radius: 0.25rem; | |
| 306 | + | opacity: 0.55; | |
| 307 | + | transition: opacity 150ms; | |
| 308 | + | } | |
| 309 | + | ::-webkit-calendar-picker-indicator:hover, | |
| 310 | + | ::-webkit-calendar-picker-indicator:focus-visible { | |
| 311 | + | opacity: 1; | |
| 312 | + | } | |
| 313 | + | ::-webkit-datetime-edit-fields-wrapper { | |
| 314 | + | padding: 0; | |
| 315 | + | } | |
| 316 | + | ::-webkit-datetime-edit-text { | |
| 317 | + | color: var(--color-faint); | |
| 318 | + | } | |
| 319 | + | ::-webkit-datetime-edit-year-field:focus, | |
| 320 | + | ::-webkit-datetime-edit-month-field:focus, | |
| 321 | + | ::-webkit-datetime-edit-day-field:focus, | |
| 322 | + | ::-webkit-datetime-edit-hour-field:focus, | |
| 323 | + | ::-webkit-datetime-edit-minute-field:focus, | |
| 324 | + | ::-webkit-datetime-edit-second-field:focus, | |
| 325 | + | ::-webkit-datetime-edit-millisecond-field:focus, | |
| 326 | + | ::-webkit-datetime-edit-meridiem-field:focus { | |
| 327 | + | border-radius: 0.1875rem; | |
| 328 | + | background-color: color-mix(in srgb, var(--color-accent) 25%, transparent); | |
| 329 | + | color: var(--color-fg); | |
| 330 | + | } | |
| 331 | + | ||
| 332 | + | /* A colour field is a rounded swatch with a hairline, not a grey button. */ | |
| 333 | + | input[type="color"] { | |
| 334 | + | -webkit-appearance: none; | |
| 335 | + | appearance: none; | |
| 336 | + | cursor: pointer; | |
| 337 | + | padding: 0.1875rem; | |
| 338 | + | border: 1px solid var(--color-line); | |
| 339 | + | border-radius: 0.375rem; | |
| 340 | + | background-color: var(--color-bg); | |
| 341 | + | } | |
| 342 | + | input[type="color"]:hover { | |
| 343 | + | border-color: var(--color-line-strong); | |
| 344 | + | } | |
| 345 | + | input[type="color"]::-webkit-color-swatch-wrapper { | |
| 346 | + | padding: 0; | |
| 347 | + | } | |
| 348 | + | input[type="color"]::-webkit-color-swatch { | |
| 349 | + | border: none; | |
| 350 | + | border-radius: 0.25rem; | |
| 351 | + | } | |
| 352 | + | input[type="color"]::-moz-color-swatch { | |
| 353 | + | border: none; | |
| 354 | + | border-radius: 0.25rem; | |
| 355 | + | } | |
| 356 | + | ||
| 357 | + | /* A search field's clear button is the browser's own: g1t's fields don't show it. */ | |
| 358 | + | input[type="search"]::-webkit-search-cancel-button, | |
| 359 | + | input[type="search"]::-webkit-search-decoration { | |
| 360 | + | -webkit-appearance: none; | |
| 361 | + | appearance: none; | |
| 362 | + | } | |
| 363 | + | ||
| 364 | + | /* | |
| 365 | + | * A <summary> that keeps the browser's disclosure triangle has it quiet, | |
| 366 | + | * in the faint text colour; one that draws its own hides it with list-none. | |
| 367 | + | */ | |
| 368 | + | summary { | |
| 369 | + | cursor: pointer; | |
| 370 | + | } | |
| 371 | + | summary::marker { | |
| 372 | + | color: var(--color-faint); | |
| 373 | + | } | |
| 374 | + | summary::-webkit-details-marker { | |
| 375 | + | color: var(--color-faint); | |
| 376 | + | } | |
| 277 | 377 | } | |
| 278 | 378 | ||
| 279 | 379 | @media (prefers-reduced-motion: reduce) { |
| 4 | 4 | import type { AgentRouting, ModelTier, SubagentDef } from "@g1t/contracts"; | |
| 5 | 5 | ||
| 6 | 6 | import { Hint } from "./ui/hint"; | |
| 7 | + | import { Input } from "./ui/input"; | |
| 7 | 8 | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"; | |
| 8 | 9 | import { Sheet, SheetContent, SheetDescription, SheetTitle } from "./ui/sheet"; | |
| 9 | 10 | import { MAX_RESPONSIBILITIES, TIER_LABELS, clampRouting, cleanSubagentName } from "../lib/agent-form"; | |
| ⋯ | |||
| 326 | 327 | </div> | |
| 327 | 328 | <label className="block max-w-40"> | |
| 328 | 329 | <span className="mb-1.5 block text-sm font-medium text-fg-soft">At once, in one task</span> | |
| 329 | − | <input | |
| 330 | + | <Input | |
| 330 | 331 | type="number" | |
| 331 | 332 | min={1} | |
| 332 | 333 | max={8} | |
| ⋯ | |||
| 334 | 335 | onChange={(event) => | |
| 335 | 336 | setEditing({ ...editing, draft: { ...editing.draft, max_parallel: Math.max(1, Math.min(8, Number(event.target.value) || 1)) } }) | |
| 336 | 337 | } | |
| 337 | − | className={`${FIELD} tabular-nums`} | |
| 338 | 338 | /> | |
| 339 | 339 | </label> | |
| 340 | 340 | </div> | |
| 10 | 10 | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"; | |
| 11 | 11 | import { Skeleton } from "./ui/skeleton"; | |
| 12 | 12 | import { AUTONOMY, type AgentDraft, PRESETS, TIER_LABELS, cleanHandle, dollarsField } from "../lib/agent-form"; | |
| 13 | + | import { Checkbox } from "./ui/checkbox"; | |
| 13 | 14 | import { Hint } from "./ui/hint"; | |
| 15 | + | import { Input } from "./ui/input"; | |
| 14 | 16 | import { EffortPicker } from "./effort"; | |
| 15 | 17 | import { effortSetting } from "../lib/effort"; | |
| 16 | 18 | import { DEPARTMENTS, RoleFields, SubagentsField } from "./agent-role"; | |
| ⋯ | |||
| 633 | 635 | {spaces.map((space) => ( | |
| 634 | 636 | <label | |
| 635 | 637 | key={space.id} | |
| 636 | − | className="flex min-h-10 cursor-pointer items-center gap-2.5 rounded-lg border border-line bg-surface px-3 py-2 text-sm has-[:checked]:border-accent/50 has-[:checked]:bg-accent/5" | |
| 638 | + | className="flex min-h-10 cursor-pointer items-center gap-2.5 rounded-lg border border-line bg-surface px-3 py-2 text-sm has-[[data-state=checked]]:border-accent/50 has-[[data-state=checked]]:bg-accent/5" | |
| 637 | 639 | > | |
| 638 | − | <input type="checkbox" name="reading" value={space.id} defaultChecked={(draft.reading ?? []).includes(space.id)} className="size-4 accent-[var(--color-accent)]" /> | |
| 640 | + | <Checkbox name="reading" value={space.id} defaultChecked={(draft.reading ?? []).includes(space.id)} /> | |
| 639 | 641 | <span className="min-w-0 grow truncate">{space.name}</span> | |
| 640 | 642 | <span className="shrink-0 text-xs text-faint capitalize">{space.kind}</span> | |
| 641 | 643 | </label> | |
| ⋯ | |||
| 798 | 800 | <Label htmlFor="capacity" error={e.capacity}> | |
| 799 | 801 | Sessions at once | |
| 800 | 802 | </Label> | |
| 801 | − | <input id="capacity" name="capacity" type="number" min={1} max={20} defaultValue={draft.capacity} className={`${FIELD} tabular-nums`} /> | |
| 803 | + | <Input id="capacity" name="capacity" type="number" min={1} max={20} defaultValue={draft.capacity} /> | |
| 802 | 804 | </div> | |
| 803 | 805 | </FormSection> | |
| 804 | 806 | ||
| 13 | 13 | import { AgentAvatar, PixelCreature } from "../agent-avatar"; | |
| 14 | 14 | import { Markdown } from "../markdown"; | |
| 15 | 15 | import { Badge } from "../ui/badge"; | |
| 16 | + | import { Checkbox } from "../ui/checkbox"; | |
| 16 | 17 | import { Hint } from "../ui/hint"; | |
| 17 | 18 | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select"; | |
| 18 | 19 | import { type BuilderDefinition, type ChangeRow, changeRows, integrationHint } from "../../lib/agent-builder"; | |
| ⋯ | |||
| 369 | 370 | return ( | |
| 370 | 371 | <label | |
| 371 | 372 | key={skill.id} | |
| 372 | − | className="flex min-h-10 cursor-pointer items-center gap-2.5 rounded-lg border border-line bg-bg px-3 py-2 text-sm has-checked:border-accent/50 has-checked:bg-accent/5" | |
| 373 | + | className="flex min-h-10 cursor-pointer items-center gap-2.5 rounded-lg border border-line bg-bg px-3 py-2 text-sm has-[[data-state=checked]]:border-accent/50 has-[[data-state=checked]]:bg-accent/5" | |
| 373 | 374 | > | |
| 374 | − | <input | |
| 375 | − | type="checkbox" | |
| 375 | + | <Checkbox | |
| 376 | 376 | checked={on} | |
| 377 | − | onChange={() => { | |
| 377 | + | onCheckedChange={() => { | |
| 378 | 378 | const next = new Set(off); | |
| 379 | 379 | if (on) next.add(skill.id); | |
| 380 | 380 | else next.delete(skill.id); | |
| 381 | 381 | set({ skills_off: FOUNDATIONAL_SKILLS.map((s) => s.id).filter((id) => next.has(id)) }); | |
| 382 | 382 | }} | |
| 383 | − | className="size-4 accent-(--color-accent)" | |
| 384 | 383 | /> | |
| 385 | 384 | <span className="min-w-0 grow truncate">{skill.name}</span> | |
| 386 | 385 | {suggested.has(skill.id) && suggested.size < FOUNDATIONAL_SKILLS.length && ( | |
| 39 | 39 | <Form method="post" encType="multipart/form-data" ref={form}> | |
| 40 | 40 | <input type="hidden" name="intent" value="avatar" /> | |
| 41 | 41 | <label | |
| 42 | − | className={`inline-flex cursor-pointer items-center gap-2 rounded-md px-3.5 py-2 text-sm font-medium ring-1 ring-line transition-colors hover:ring-line-strong ${ | |
| 42 | + | className={`inline-flex cursor-pointer items-center gap-2 rounded-md px-3.5 py-2 text-sm font-medium ring-1 ring-line transition-colors hover:ring-line-strong has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-accent ${ | |
| 43 | 43 | busy ? "pointer-events-none opacity-50" : "" | |
| 44 | 44 | }`} | |
| 45 | 45 | > |
| 16 | 16 | import { money } from "../lib/usage"; | |
| 17 | 17 | import { Card } from "./billing"; | |
| 18 | 18 | import { ErrorText, SubmitButton } from "./ui"; | |
| 19 | + | import { Checkbox } from "./ui/checkbox"; | |
| 19 | 20 | import { SelectField } from "./ui/select"; | |
| 20 | 21 | import { Skeleton } from "./ui/skeleton"; | |
| 21 | 22 | ||
| ⋯ | |||
| 329 | 330 | {owner ? ( | |
| 330 | 331 | <Form method="post" className="mt-3 space-y-3 text-sm"> | |
| 331 | 332 | <input type="hidden" name="intent" value="ai-reload" /> | |
| 332 | − | <label className="flex items-center gap-2"> | |
| 333 | − | <input type="checkbox" name="enabled" defaultChecked={r.enabled} className="accent-[var(--color-accent)]" /> | |
| 333 | + | <label className="flex cursor-pointer items-center gap-2"> | |
| 334 | + | <Checkbox name="enabled" defaultChecked={r.enabled} /> | |
| 334 | 335 | Reload from the saved card | |
| 335 | 336 | </label> | |
| 336 | 337 | <div className="flex flex-wrap items-center gap-2 text-muted"> | |
| ⋯ | |||
| 387 | 388 | <fieldset className="flex flex-wrap items-center gap-3"> | |
| 388 | 389 | <legend className="mb-1.5 text-xs text-muted">Alert at</legend> | |
| 389 | 390 | {[50, 75, 90, 100].map((level) => ( | |
| 390 | − | <label key={level} className="flex items-center gap-1.5"> | |
| 391 | − | <input type="checkbox" name="alert" value={String(level)} defaultChecked={levels.includes(level)} className="accent-[var(--color-accent)]" /> | |
| 391 | + | <label key={level} className="flex cursor-pointer items-center gap-1.5 tabular-nums"> | |
| 392 | + | <Checkbox name="alert" value={String(level)} defaultChecked={levels.includes(level)} /> | |
| 392 | 393 | {level}% | |
| 393 | 394 | </label> | |
| 394 | 395 | ))} | |
| 395 | 396 | </fieldset> | |
| 396 | − | <label className="flex items-center gap-2"> | |
| 397 | − | <input type="checkbox" name="pause" defaultChecked={pause} className="accent-[var(--color-accent)]" /> | |
| 397 | + | <label className="flex cursor-pointer flex-wrap items-center gap-2"> | |
| 398 | + | <Checkbox name="pause" defaultChecked={pause} /> | |
| 398 | 399 | Pause usage at 100% | |
| 399 | 400 | <span className="text-xs text-faint">Off, the budget only alerts; g1t's own limit still applies.</span> | |
| 400 | 401 | </label> | |
| 60 | 60 | } from "../ui/alert-dialog"; | |
| 61 | 61 | import { Badge } from "../ui/badge"; | |
| 62 | 62 | import { Hint } from "../ui/hint"; | |
| 63 | + | import { Button } from "../ui/button"; | |
| 63 | 64 | import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; | |
| 64 | 65 | import { type Mentionable, type ShownMessage, channelPath, mentionNames, mergeMessages, shownHandle, shownName, timeline } from "../../lib/chat"; | |
| 65 | 66 | import { codeAccessPath } from "../../lib/workspace-nav"; | |
| ⋯ | |||
| 822 | 823 | const channel = data.channel; | |
| 823 | 824 | const agentDm = channel.kind === "dm" && others.length === 1 && others[0]!.kind === "agent" ? others[0]! : null; | |
| 824 | 825 | return ( | |
| 825 | − | <header className="flex h-14 shrink-0 items-center gap-3 border-b border-line px-4 sm:px-5 max-md:h-[calc(3.5rem+env(safe-area-inset-top))] max-md:gap-2 max-md:pt-[env(safe-area-inset-top)] max-md:pl-1.5"> | |
| 826 | − | <button | |
| 827 | − | type="button" | |
| 828 | − | onClick={onBack} | |
| 829 | − | aria-label="Back to conversations" | |
| 830 | − | className="flex size-10 shrink-0 items-center justify-center rounded-full text-muted active:bg-raised md:hidden" | |
| 831 | − | > | |
| 826 | + | <header data-page-head className="flex h-14 shrink-0 items-center gap-3 border-b border-line px-4 sm:px-5 max-md:h-[calc(3.5rem+env(safe-area-inset-top))] max-md:gap-2 max-md:pt-[env(safe-area-inset-top)] max-md:pl-1.5"> | |
| 827 | + | <Button variant="ghost" size="icon-bar" onClick={onBack} aria-label="Back to conversations" className="rounded-full md:hidden"> | |
| 832 | 828 | <ChevronLeft size={22} /> | |
| 833 | − | </button> | |
| 829 | + | </Button> | |
| 834 | 830 | <div className="flex min-w-0 grow items-center gap-2.5"> | |
| 835 | 831 | {channel.kind === "channel" ? ( | |
| 836 | 832 | <> | |
| ⋯ | |||
| 899 | 895 | )} | |
| 900 | 896 | {channel.kind === "channel" && ( | |
| 901 | 897 | <Hint label={info ? "Hide details" : "Members and details"}> | |
| 902 | − | <button | |
| 903 | − | type="button" | |
| 904 | − | onClick={onInfo} | |
| 905 | − | aria-pressed={info} | |
| 906 | − | aria-label="Members and details" | |
| 907 | − | className={`flex h-8 items-center gap-1.5 rounded-md border px-2 text-xs tabular-nums transition-colors ${ | |
| 908 | − | info ? "border-line-strong bg-raised text-fg" : "border-line text-muted hover:border-line-strong hover:text-fg" | |
| 909 | − | }`} | |
| 910 | − | > | |
| 898 | + | <Button variant="outline" size="sm" onClick={onInfo} aria-pressed={info} aria-label="Members and details" className="px-2 text-xs text-muted tabular-nums aria-pressed:border-line-strong aria-pressed:bg-raised aria-pressed:text-fg max-md:h-11"> | |
| 911 | 899 | <UsersStack count={memberCount} /> | |
| 912 | − | </button> | |
| 900 | + | </Button> | |
| 913 | 901 | </Hint> | |
| 914 | 902 | )} | |
| 915 | 903 | {channel.kind === "dm" && ( | |
| 916 | 904 | <Hint label={info ? "Hide details" : "Details"}> | |
| 917 | − | <button | |
| 918 | − | type="button" | |
| 919 | − | onClick={onInfo} | |
| 920 | − | aria-pressed={info} | |
| 921 | − | aria-label="Details" | |
| 922 | − | className={`flex size-8 items-center justify-center rounded-md transition-colors ${info ? "bg-raised text-fg" : "text-muted hover:bg-raised hover:text-fg"}`} | |
| 923 | − | > | |
| 905 | + | <Button variant="ghost" size="icon-bar" onClick={onInfo} aria-pressed={info} aria-label="Details"> | |
| 924 | 906 | <PanelRight size={16} /> | |
| 925 | − | </button> | |
| 907 | + | </Button> | |
| 926 | 908 | </Hint> | |
| 927 | 909 | )} | |
| 928 | 910 | </header> | |
| ⋯ | |||
| 941 | 923 | function StarButton({ starred, onClick }: { starred: boolean; onClick: () => void }) { | |
| 942 | 924 | return ( | |
| 943 | 925 | <Hint label={starred ? "Unstar" : "Star: keep it at the top of the sidebar"}> | |
| 944 | − | <button | |
| 945 | − | type="button" | |
| 926 | + | <Button | |
| 927 | + | variant="ghost" | |
| 928 | + | size="icon-sm" | |
| 946 | 929 | aria-label={starred ? "Unstar" : "Star"} | |
| 947 | 930 | aria-pressed={starred} | |
| 948 | 931 | onClick={onClick} | |
| 949 | − | className={`flex size-7 shrink-0 items-center justify-center rounded-md transition-colors hover:bg-raised ${starred ? "text-warn" : "text-faint hover:text-fg"}`} | |
| 932 | + | className="text-faint aria-pressed:bg-transparent aria-pressed:text-warn aria-pressed:hover:bg-raised max-md:size-10" | |
| 950 | 933 | > | |
| 951 | 934 | <Star size={15} fill={starred ? "currentColor" : "none"} /> | |
| 952 | − | </button> | |
| 935 | + | </Button> | |
| 953 | 936 | </Hint> | |
| 954 | 937 | ); | |
| 955 | 938 | } | |
| 4 | 4 | ||
| 5 | 5 | import { MemberCard } from "./profile-card"; | |
| 6 | 6 | import { Hint } from "../ui/hint"; | |
| 7 | + | import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../ui/table"; | |
| 7 | 8 | import { type Block, type Span, blocks, onlyEmoji } from "../../lib/chat"; | |
| 8 | 9 | // The workspace's own emoji, drawn where `:name:` is written (components/emoji). | |
| 9 | 10 | import { useEmojiContext } from "../emoji/context"; | |
| ⋯ | |||
| 142 | 143 | case "p": | |
| 143 | 144 | return <p>{lines(block.lines, context)}</p>; | |
| 144 | 145 | case "heading": | |
| 145 | − | // Chat, not a document: a heading is a bold line, the top two a touch larger. | |
| 146 | − | return <p className={`font-semibold text-fg ${block.level <= 2 ? "text-base" : ""}`}>{spans(block.c, context)}</p>; | |
| 146 | + | // Chat, not a document: a heading is a bold line with room above it, the top two a touch larger. | |
| 147 | + | return <p className={`pt-1.5 font-semibold tracking-[-0.005em] text-fg first:pt-0 ${block.level <= 2 ? "text-base" : ""}`}>{spans(block.c, context)}</p>; | |
| 148 | + | case "table": | |
| 149 | + | return <TableView block={block} context={context} />; | |
| 147 | 150 | case "hr": | |
| 148 | 151 | return <hr className="my-2 border-line" />; | |
| 149 | 152 | case "code": | |
| ⋯ | |||
| 167 | 170 | } | |
| 168 | 171 | } | |
| 169 | 172 | ||
| 173 | + | /** | |
| 174 | + | * A table, as the theme draws tables in rendered Markdown: a quiet header | |
| 175 | + | * row, lines between rows, and its columns aligned as written. A wide one | |
| 176 | + | * scrolls sideways inside the message, never the page, and takes focus so | |
| 177 | + | * it can be scrolled from the keyboard. | |
| 178 | + | */ | |
| 179 | + | function TableView({ block, context }: { block: Extract<Block, { t: "table" }>; context: TextContext }) { | |
| 180 | + | const align = (index: number) => block.align[index] ?? undefined; | |
| 181 | + | return ( | |
| 182 | + | <Table frameProps={{ role: "region", "aria-label": "Table", tabIndex: 0, className: "my-1 rounded-lg border border-line" }} className="min-w-max text-[0.8125rem] leading-snug sm:min-w-full"> | |
| 183 | + | <TableHeader> | |
| 184 | + | <TableRow> | |
| 185 | + | {block.head.map((cell, index) => ( | |
| 186 | + | <TableHead key={index} scope="col" style={{ textAlign: align(index) }}> | |
| 187 | + | {spans(cell, context)} | |
| 188 | + | </TableHead> | |
| 189 | + | ))} | |
| 190 | + | </TableRow> | |
| 191 | + | </TableHeader> | |
| 192 | + | <TableBody> | |
| 193 | + | {block.rows.map((row, at) => ( | |
| 194 | + | <TableRow key={at}> | |
| 195 | + | {row.map((cell, index) => ( | |
| 196 | + | <TableCell key={index} style={{ textAlign: align(index) }} className={align(index) === "right" ? "whitespace-nowrap tabular-nums" : "max-w-lg"}> | |
| 197 | + | {spans(cell, context)} | |
| 198 | + | </TableCell> | |
| 199 | + | ))} | |
| 200 | + | </TableRow> | |
| 201 | + | ))} | |
| 202 | + | </TableBody> | |
| 203 | + | </Table> | |
| 204 | + | ); | |
| 205 | + | } | |
| 206 | + | ||
| 170 | 207 | type Token = { content: string; color?: string }; | |
| 171 | 208 | ||
| 172 | 209 | /** | |
| 121 | 121 | aria-label={`${displayName(workspace)}: switch workspace`} | |
| 122 | 122 | className={cn( | |
| 123 | 123 | "flex min-w-0 items-center rounded-lg outline-none transition-colors hover:bg-raised focus-visible:ring-2 focus-visible:ring-accent data-[state=open]:bg-raised", | |
| 124 | − | compact ? "h-8 shrink-0 gap-1.5 px-1 max-sm:max-w-40" : "h-9 grow gap-2 px-1.5", | |
| 124 | + | compact ? "h-8 shrink-0 gap-1.5 px-1 max-md:h-11 max-sm:gap-1" : "h-9 grow gap-2 px-1.5", | |
| 125 | 125 | )} | |
| 126 | 126 | > | |
| 127 | 127 | <Avatar name={workspace.slug} image={workspace.avatar} size={compact ? 22 : 26} square /> | |
| 128 | − | <span className={cn("min-w-0 truncate font-semibold", compact ? "text-sm" : "text-[0.875rem]")}>{displayName(workspace)}</span> | |
| 128 | + | <span className={cn("min-w-0 truncate font-semibold", compact ? "text-sm max-sm:hidden" : "text-[0.875rem]")}>{displayName(workspace)}</span> | |
| 129 | 129 | <ChevronDown size={14} className="shrink-0 text-faint" /> | |
| 130 | 130 | </DropdownMenuTrigger> | |
| 131 | 131 | <DropdownMenuContent align="start" className="w-72 p-1.5"> |
| 63 | 63 | }, | |
| 64 | 64 | ]; | |
| 65 | 65 | return ( | |
| 66 | − | <div className="sticky top-(--topbar-h) z-20 -mx-4 flex h-12 items-center gap-2 border-b border-line bg-bg/85 px-4 backdrop-blur sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8"> | |
| 66 | + | // Right under the top bar, edge to edge, and one block with it (data-page-head in components/shell.tsx). | |
| 67 | + | <div data-page-head="sticky" className="sticky top-(--topbar-h) z-20 -mx-4 flex h-14 items-center gap-2 border-b border-line bg-bg/90 px-4 backdrop-blur sm:-mx-6 sm:px-5 lg:-mx-8"> | |
| 67 | 68 | <div className="min-w-0 grow"> | |
| 68 | 69 | <Crumbs items={crumbs} /> | |
| 69 | 70 | </div> |
| 7 | 7 | import { WaitingCards } from "./notifications/card-actions"; | |
| 8 | 8 | import { SubmitButton } from "./ui"; | |
| 9 | 9 | import { Badge, type BadgeTone } from "./ui/badge"; | |
| 10 | + | import { Button } from "./ui/button"; | |
| 10 | 11 | import { Hint } from "./ui/hint"; | |
| 11 | 12 | import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "./ui/dropdown-menu"; | |
| 12 | 13 | import { Sheet, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, SheetTrigger } from "./ui/sheet"; | |
| ⋯ | |||
| 272 | 273 | ||
| 273 | 274 | return ( | |
| 274 | 275 | <Sheet open={open} onOpenChange={setOpen}> | |
| 275 | − | <SheetTrigger | |
| 276 | − | aria-label={unread ? `Notifications, ${unread} unread` : "Notifications"} | |
| 277 | − | className="relative flex size-9 items-center justify-center rounded-md text-muted transition-colors hover:bg-raised hover:text-fg" | |
| 278 | − | > | |
| 276 | + | <SheetTrigger asChild> | |
| 277 | + | <Button variant="ghost" size="icon-sm" aria-label={unread ? `Notifications, ${unread} unread` : "Notifications"} className="relative"> | |
| 279 | 278 | <Bell size={17} /> | |
| 280 | 279 | {shown && ( | |
| 281 | 280 | // Amber while an agent waits on them, red for a failure, else the accent. | |
| ⋯ | |||
| 288 | 287 | {shown} | |
| 289 | 288 | </span> | |
| 290 | 289 | )} | |
| 290 | + | </Button> | |
| 291 | 291 | </SheetTrigger> | |
| 292 | 292 | <SheetContent | |
| 293 | 293 | // Focus starts on the tab being looked at, not on Mark all read. | |
| 46 | 46 | } | |
| 47 | 47 | ||
| 48 | 48 | /** | |
| 49 | + | * The 1 set beside type in a control, such as Ask g1t: whole cells, so at | |
| 50 | + | * 14px tall each one lands on 2px (4 on a sharp screen) and every square | |
| 51 | + | * stays sharp, lavender on its own with nothing around it. | |
| 52 | + | */ | |
| 53 | + | export function MarkGlyph({ className = "h-3.5" }: { className?: string }) { | |
| 54 | + | return ( | |
| 55 | + | <svg viewBox="0 0 50 70" className={`w-auto shrink-0 ${className}`} shapeRendering="crispEdges" aria-hidden="true"> | |
| 56 | + | <Pixels of={ICON} /> | |
| 57 | + | </svg> | |
| 58 | + | ); | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | /** | |
| 49 | 62 | * The wordmark as one picture, sized by its font size: a cell is 0.125em, | |
| 50 | 63 | * so the letters stand 0.85em tall on the baseline, about the height of | |
| 51 | 64 | * the type around them. |
| 7 | 7 | import { dueInWords, isOverdue, percentDone } from "../lib/labels"; | |
| 8 | 8 | import { cn } from "../lib/cn"; | |
| 9 | 9 | import { Button, ErrorText, SubmitButton, Textarea, usePending } from "./ui"; | |
| 10 | + | import { Input } from "./ui/input"; | |
| 10 | 11 | import { | |
| 11 | 12 | AlertDialog, | |
| 12 | 13 | AlertDialogAction, | |
| ⋯ | |||
| 65 | 66 | <div className="grid gap-3 sm:grid-cols-[1fr_12rem]"> | |
| 66 | 67 | <label className="block"> | |
| 67 | 68 | <span className="mb-1.5 block text-xs font-medium text-muted">Title</span> | |
| 68 | − | <input | |
| 69 | + | <Input | |
| 69 | 70 | name="title" | |
| 70 | 71 | defaultValue={milestone?.title ?? ""} | |
| 71 | 72 | required | |
| ⋯ | |||
| 74 | 75 | autoComplete="off" | |
| 75 | 76 | data-1p-ignore | |
| 76 | 77 | placeholder="Launch" | |
| 77 | − | className="h-9 w-full rounded-md border border-line bg-bg px-3 text-sm outline-none hover:border-line-strong focus:border-accent-dim" | |
| 78 | 78 | /> | |
| 79 | 79 | </label> | |
| 80 | 80 | <label className="block"> | |
| 81 | 81 | <span className="mb-1.5 block text-xs font-medium text-muted">Due date (optional)</span> | |
| 82 | − | <input | |
| 83 | − | type="date" | |
| 84 | − | name="dueOn" | |
| 85 | − | defaultValue={milestone?.dueOn ?? ""} | |
| 86 | − | className="h-9 w-full rounded-md border border-line bg-bg px-3 text-sm outline-none hover:border-line-strong focus:border-accent-dim" | |
| 87 | − | /> | |
| 82 | + | <Input type="date" name="dueOn" defaultValue={milestone?.dueOn ?? ""} /> | |
| 88 | 83 | </label> | |
| 89 | 84 | </div> | |
| 90 | 85 | <label className="block"> | |
| 7 | 7 | import { agentRunHref, workflowRunHref } from "../lib/runners"; | |
| 8 | 8 | import type { RunnersAction, RunnersData } from "../lib/runners.server"; | |
| 9 | 9 | import { Button, CopyLine, EmptyState, ErrorText, Field, Input, Pill, SubmitButton, TimeAgo } from "./ui"; | |
| 10 | + | import { Checkbox } from "./ui/checkbox"; | |
| 11 | + | import { RadioGroup, RadioOption } from "./ui/radio-group"; | |
| 10 | 12 | import { SelectField } from "./ui/select"; | |
| 11 | 13 | import { Switch } from "./ui/switch"; | |
| 12 | 14 | import { Tabs, TabsList, TabsTrigger } from "./ui/tabs"; | |
| ⋯ | |||
| 316 | 318 | </Field> | |
| 317 | 319 | <fieldset className="space-y-2 text-sm"> | |
| 318 | 320 | <legend className="mb-1 text-xs text-muted">Repositories that may use its runners</legend> | |
| 319 | − | <label className="flex items-center gap-2"> | |
| 320 | − | <input type="radio" name="reach" value="all" checked={reach === "all"} onChange={() => setReach("all")} /> | |
| 321 | − | Every repository in the workspace | |
| 322 | − | </label> | |
| 323 | − | <label className="flex items-center gap-2"> | |
| 324 | − | <input type="radio" name="reach" value="some" checked={reach === "some"} onChange={() => setReach("some")} /> | |
| 325 | − | Only these | |
| 326 | − | </label> | |
| 321 | + | <RadioGroup name="reach" value={reach} onValueChange={setReach}> | |
| 322 | + | <RadioOption value="all" label="Every repository in the workspace" /> | |
| 323 | + | <RadioOption value="some" label="Only these" /> | |
| 324 | + | </RadioGroup> | |
| 327 | 325 | {reach === "some" && ( | |
| 328 | 326 | <div className="grid gap-1 pl-6 sm:grid-cols-2"> | |
| 329 | 327 | {repositories.map((name) => ( | |
| 330 | − | <label key={name} className="flex items-center gap-2 font-mono text-xs"> | |
| 331 | − | <input type="checkbox" name="repository" value={name} defaultChecked={group?.repositories.includes(name)} /> | |
| 328 | + | <label key={name} className="flex min-h-8 cursor-pointer items-center gap-2 font-mono text-xs"> | |
| 329 | + | <Checkbox name="repository" value={name} defaultChecked={group?.repositories.includes(name)} /> | |
| 332 | 330 | {name} | |
| 333 | 331 | </label> | |
| 334 | 332 | ))} | |
| 1 | 1 | import { Activity, BarChart3, Building2, MessagesSquare, Bell, BookMarked, BookOpen, Bookmark, Blocks, Bot, Box, Check, ChevronDown, ChevronLeft, ChevronRight, CircleDot, GripVertical, CircleUserRound, Code2, Coins, Compass, CreditCard, Fingerprint, GanttChart, Gauge, GitBranch, GitPullRequest, Globe, History, KanbanSquare, Keyboard, KeyRound, Layers, LayoutDashboard, LayoutGrid, LifeBuoy, ListTree, Lock, LogOut, Mail, Network, Package, PanelLeft, PlayCircle, Plug, Plus, Rocket, Search, ServerCog, Settings, Shapes, ShieldCheck, Scale, Smile, Sparkles, Store, House, Ticket, TrendingUp, UserPlus, UserRoundKey, Users, UsersRound, Webhook, X, ArrowLeftRight } from "lucide-react"; | |
| 2 | − | import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; | |
| 2 | + | import { Fragment, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; | |
| 3 | 3 | import { Link, NavLink, useFetcher, useLocation, useNavigation, useRouteLoaderData, useSubmit } from "react-router"; | |
| 4 | 4 | ||
| 5 | 5 | import { type Abilities, type ChatSidebarEntry, type InboxCounts, type WorkspaceAgent, type Membership, type Spike, type User, hasCodeAccess, mayCreateTeams, shownUsername } from "@g1t/contracts"; | |
| ⋯ | |||
| 12 | 12 | import { Hint } from "./ui/hint"; | |
| 13 | 13 | import { Sheet, SheetContent, SheetTitle } from "./ui/sheet"; | |
| 14 | 14 | import { StatusDot, useSiteStatus } from "./footer"; | |
| 15 | − | import { Logo } from "./logo"; | |
| 15 | + | import { Logo, MarkGlyph } from "./logo"; | |
| 16 | 16 | import { Avatar, SoonPill } from "./ui"; | |
| 17 | 17 | import { Skeleton } from "./ui/skeleton"; | |
| 18 | + | import { Button } from "./ui/button"; | |
| 19 | + | import { Separator } from "./ui/separator"; | |
| 20 | + | import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from "./ui/breadcrumb"; | |
| 18 | 21 | import { | |
| 19 | 22 | DropdownMenu, | |
| 20 | 23 | DropdownMenuContent, | |
| ⋯ | |||
| 30 | 33 | import { ChatSidebar } from "./chat/sidebar"; | |
| 31 | 34 | import { FoliosSidebar } from "./folios/sidebar"; | |
| 32 | 35 | import { Dock, ShortcutsDialog, WorkspaceSwitcher, sidebarKeyLabel } from "./dock"; | |
| 33 | − | import { G1tMark } from "./orchestrator"; | |
| 34 | 36 | import { BottomBar, isConversation, useVisualViewport } from "./mobile"; | |
| 35 | 37 | import { useChatSidebar } from "./chat/actions"; | |
| 36 | 38 | import { unreadTotals } from "../lib/chat"; | |
| ⋯ | |||
| 1451 | 1453 | // visible and only something inside it is missing. | |
| 1452 | 1454 | const visibleRepo = /^\/([^/]+)\/([^/-][^/]*)(\/|$)/.exec(pathname); | |
| 1453 | 1455 | if (missing && !(visibleRepo && sameRepo(repo ?? null, { namespace: visibleRepo[1]!, name: visibleRepo[2]! }))) { | |
| 1454 | − | return <span className="text-sm font-medium">Not found</span>; | |
| 1456 | + | return <Here>Not found</Here>; | |
| 1455 | 1457 | } | |
| 1456 | 1458 | const parts = pathname.split("/").filter(Boolean); | |
| 1457 | 1459 | const reserved = ["settings", "explore", "new", "search", "workspaces", "policies", "security", "support", "status", "invite", "notifications"]; | |
| 1458 | − | if (parts.length === 0) return <span className="text-sm font-medium">Home</span>; | |
| 1460 | + | if (parts.length === 0) return <Here>Home</Here>; | |
| 1459 | 1461 | // Your settings: Settings / Emails. | |
| 1460 | 1462 | if (parts[0] === "settings") { | |
| 1461 | 1463 | const page = accountSettingsPage(pathname); | |
| ⋯ | |||
| 1475 | 1477 | invite: "Invite", | |
| 1476 | 1478 | notifications: "Notifications", | |
| 1477 | 1479 | }; | |
| 1478 | − | return <span className="text-sm font-medium">{words[parts[0]!]}</span>; | |
| 1480 | + | return <Here>{words[parts[0]!]}</Here>; | |
| 1479 | 1481 | } | |
| 1480 | 1482 | // A person's profile, by their handle. | |
| 1481 | 1483 | if (parts[0] === "u" && parts[1]) return <ProfileCrumb username={parts[1]} />; | |
| ⋯ | |||
| 1518 | 1520 | const page = useRouteLoaderData("routes/user") as { profile?: { username: string; displayUsername?: string | null; avatar: string | null } } | undefined; | |
| 1519 | 1521 | const profile = page?.profile?.username.toLowerCase() === username.toLowerCase() ? page.profile : null; | |
| 1520 | 1522 | return ( | |
| 1521 | − | <Link to={`/u/${profile?.username ?? username}`} aria-current="page" className="flex min-w-0 items-center gap-2 rounded px-1 py-0.5 transition-colors hover:bg-raised"> | |
| 1522 | − | <Avatar name={profile?.username ?? username} image={profile?.avatar ?? null} size={20} /> | |
| 1523 | − | <span className="truncate font-mono text-[0.8125rem] font-medium">@{profile ? shownUsername(profile) : username}</span> | |
| 1524 | − | </Link> | |
| 1523 | + | <Breadcrumb className="min-w-0"> | |
| 1524 | + | <BreadcrumbList> | |
| 1525 | + | <BreadcrumbItem> | |
| 1526 | + | <BreadcrumbPage asChild className="flex items-center gap-2 hover:bg-raised"> | |
| 1527 | + | <Link to={`/u/${profile?.username ?? username}`}> | |
| 1528 | + | <Avatar name={profile?.username ?? username} image={profile?.avatar ?? null} size={20} /> | |
| 1529 | + | <span className="truncate font-mono text-[0.8125rem]">@{profile ? shownUsername(profile) : username}</span> | |
| 1530 | + | </Link> | |
| 1531 | + | </BreadcrumbPage> | |
| 1532 | + | </BreadcrumbItem> | |
| 1533 | + | </BreadcrumbList> | |
| 1534 | + | </Breadcrumb> | |
| 1535 | + | ); | |
| 1536 | + | } | |
| 1537 | + | ||
| 1538 | + | /** A page with no trail to it, such as Today or Search: its name alone. */ | |
| 1539 | + | function Here({ children }: { children: ReactNode }) { | |
| 1540 | + | return ( | |
| 1541 | + | <Breadcrumb className="min-w-0"> | |
| 1542 | + | <BreadcrumbList> | |
| 1543 | + | <BreadcrumbItem> | |
| 1544 | + | <BreadcrumbPage>{children}</BreadcrumbPage> | |
| 1545 | + | </BreadcrumbItem> | |
| 1546 | + | </BreadcrumbList> | |
| 1547 | + | </Breadcrumb> | |
| 1525 | 1548 | ); | |
| 1526 | 1549 | } | |
| 1527 | 1550 | ||
| ⋯ | |||
| 1536 | 1559 | // Up on a phone, unless up is the workspace itself: its pages are the tabs. | |
| 1537 | 1560 | const up = trail.length > 1 && !/^\/[^/]+$/.test(trail[trail.length - 2]!.to) ? trail[trail.length - 2]! : null; | |
| 1538 | 1561 | return ( | |
| 1539 | − | <nav aria-label="Where you are" className="flex min-w-0 items-center gap-1.5 text-sm"> | |
| 1562 | + | <Breadcrumb className="flex min-w-0 items-center"> | |
| 1540 | 1563 | {up && ( | |
| 1541 | − | <Link | |
| 1542 | − | to={up.to} | |
| 1543 | − | prefetch="intent" | |
| 1544 | − | aria-label={`Back to ${up.label}`} | |
| 1545 | − | className="-ml-1 flex size-8 shrink-0 items-center justify-center rounded-md text-muted transition-colors hover:bg-raised hover:text-fg sm:hidden" | |
| 1546 | − | > | |
| 1547 | − | <ChevronLeft size={16} /> | |
| 1548 | − | </Link> | |
| 1564 | + | <Button asChild variant="ghost" size="icon-bar" className="-ml-1 sm:hidden"> | |
| 1565 | + | <Link to={up.to} prefetch="intent" aria-label={`Back to ${up.label}`}> | |
| 1566 | + | <ChevronLeft size={16} /> | |
| 1567 | + | </Link> | |
| 1568 | + | </Button> | |
| 1549 | 1569 | )} | |
| 1550 | − | {trail.map((crumb, index) => { | |
| 1551 | − | const last = index === trail.length - 1; | |
| 1552 | − | return ( | |
| 1553 | − | <span key={index} className={`min-w-0 items-center gap-1.5 ${last ? "flex" : "hidden sm:flex"}`}> | |
| 1554 | − | {index > 0 && <span className="hidden text-line-strong sm:inline">/</span>} | |
| 1555 | − | <Link | |
| 1556 | − | to={crumb.to} | |
| 1557 | − | prefetch="intent" | |
| 1558 | − | aria-current={last ? "page" : undefined} | |
| 1559 | − | className={`truncate rounded px-1 py-0.5 transition-colors hover:bg-raised ${ | |
| 1560 | − | last ? "font-medium text-fg" : "text-muted hover:text-fg" | |
| 1561 | − | } ${crumb.mono ? "font-mono text-[0.8125rem]" : ""}`} | |
| 1562 | − | > | |
| 1563 | − | {last && crumb.short ? ( | |
| 1564 | − | <> | |
| 1565 | − | <span className="sm:hidden">{crumb.short}</span> | |
| 1566 | − | <span className="hidden sm:inline">{crumb.label}</span> | |
| 1567 | − | </> | |
| 1568 | − | ) : ( | |
| 1569 | − | crumb.label | |
| 1570 | − | )} | |
| 1571 | − | </Link> | |
| 1572 | − | </span> | |
| 1573 | − | ); | |
| 1574 | − | })} | |
| 1575 | − | </nav> | |
| 1570 | + | <BreadcrumbList> | |
| 1571 | + | {trail.map((crumb, index) => { | |
| 1572 | + | const last = index === trail.length - 1; | |
| 1573 | + | const label = | |
| 1574 | + | last && crumb.short ? ( | |
| 1575 | + | <> | |
| 1576 | + | <span className="sm:hidden">{crumb.short}</span> | |
| 1577 | + | <span className="hidden sm:inline">{crumb.label}</span> | |
| 1578 | + | </> | |
| 1579 | + | ) : ( | |
| 1580 | + | crumb.label | |
| 1581 | + | ); | |
| 1582 | + | const mono = crumb.mono ? "font-mono text-[0.8125rem]" : ""; | |
| 1583 | + | return ( | |
| 1584 | + | <Fragment key={index}> | |
| 1585 | + | {index > 0 && <BreadcrumbSeparator className="max-sm:hidden" />} | |
| 1586 | + | <BreadcrumbItem className={last ? "" : "max-sm:hidden"}> | |
| 1587 | + | {last ? ( | |
| 1588 | + | // The page you are on is still a link: to its own top, as the trail always was. | |
| 1589 | + | <BreadcrumbPage asChild className={`hover:bg-raised ${mono}`}> | |
| 1590 | + | <Link to={crumb.to} prefetch="intent"> | |
| 1591 | + | {label} | |
| 1592 | + | </Link> | |
| 1593 | + | </BreadcrumbPage> | |
| 1594 | + | ) : ( | |
| 1595 | + | <BreadcrumbLink asChild className={mono}> | |
| 1596 | + | <Link to={crumb.to} prefetch="intent"> | |
| 1597 | + | {label} | |
| 1598 | + | </Link> | |
| 1599 | + | </BreadcrumbLink> | |
| 1600 | + | )} | |
| 1601 | + | </BreadcrumbItem> | |
| 1602 | + | </Fragment> | |
| 1603 | + | ); | |
| 1604 | + | })} | |
| 1605 | + | </BreadcrumbList> | |
| 1606 | + | </Breadcrumb> | |
| 1576 | 1607 | ); | |
| 1577 | 1608 | } | |
| 1578 | 1609 | ||
| ⋯ | |||
| 1725 | 1756 | ); | |
| 1726 | 1757 | } | |
| 1727 | 1758 | ||
| 1728 | − | /** The top bar's way to g1t: the direct message with the workspace's orchestrator, opened or made. */ | |
| 1759 | + | /** | |
| 1760 | + | * The top bar's way to g1t: the direct message with the workspace's | |
| 1761 | + | * orchestrator, opened or made. The one tinted control in the bar: the | |
| 1762 | + | * lavender pixel 1 bare beside its words, on a wash of the accent. | |
| 1763 | + | */ | |
| 1729 | 1764 | function AskG1tButton({ slug }: { slug: string }) { | |
| 1730 | 1765 | return ( | |
| 1731 | 1766 | <Hint label="Ask g1t"> | |
| 1732 | − | <Link | |
| 1733 | − | to={`/${slug}/-/chat?agent=g1t`} | |
| 1734 | − | aria-label="Ask g1t" | |
| 1735 | − | className="flex h-8 items-center gap-1.5 rounded-md border border-line px-1.5 text-sm text-fg/90 transition-colors hover:border-line-strong hover:bg-raised hover:text-fg sm:px-2.5" | |
| 1736 | − | > | |
| 1737 | − | <G1tMark size={18} /> | |
| 1738 | − | <span className="hidden lg:inline">Ask g1t</span> | |
| 1739 | − | </Link> | |
| 1767 | + | <Button asChild variant="soft" size="bar" className="gap-2 lg:px-3"> | |
| 1768 | + | <Link to={`/${slug}/-/chat?agent=g1t`} aria-label="Ask g1t"> | |
| 1769 | + | <MarkGlyph className="h-3.5" /> | |
| 1770 | + | <span className="max-lg:hidden">Ask g1t</span> | |
| 1771 | + | </Link> | |
| 1772 | + | </Button> | |
| 1740 | 1773 | </Hint> | |
| 1741 | 1774 | ); | |
| 1742 | 1775 | } | |
| ⋯ | |||
| 1788 | 1821 | }, [toggle]); | |
| 1789 | 1822 | } | |
| 1790 | 1823 | ||
| 1824 | + | /** | |
| 1825 | + | * Search or jump to: the command palette. On a computer it looks like the | |
| 1826 | + | * field it opens, with its shortcut; on a phone, a magnifier. | |
| 1827 | + | */ | |
| 1828 | + | function SearchButton({ onClick }: { onClick: () => void }) { | |
| 1829 | + | return ( | |
| 1830 | + | <> | |
| 1831 | + | <Button variant="ghost" size="icon-bar" aria-label="Search or jump to" onClick={onClick} className="md:hidden"> | |
| 1832 | + | <Search size={18} /> | |
| 1833 | + | </Button> | |
| 1834 | + | <Button | |
| 1835 | + | variant="outline" | |
| 1836 | + | size="sm" | |
| 1837 | + | aria-label="Search or jump to" | |
| 1838 | + | onClick={onClick} | |
| 1839 | + | className="mr-1 w-[min(18rem,26vw)] justify-start bg-surface font-normal text-faint hover:bg-surface hover:text-muted max-md:hidden" | |
| 1840 | + | > | |
| 1841 | + | <Search size={15} /> | |
| 1842 | + | <span className="grow truncate text-left">Search or jump to</span> | |
| 1843 | + | <PaletteKey className="rounded border border-line bg-bg px-1.5 font-mono text-[0.625rem] leading-4 text-muted" /> | |
| 1844 | + | </Button> | |
| 1845 | + | </> | |
| 1846 | + | ); | |
| 1847 | + | } | |
| 1848 | + | ||
| 1791 | 1849 | /** The button that shows or hides the sidebar. */ | |
| 1792 | 1850 | function SidebarToggle({ onClick, open, className = "" }: { onClick: () => void; open: boolean; className?: string }) { | |
| 1793 | 1851 | const key = sidebarKeyLabel(typeof navigator === "undefined" ? null : navigator.platform); | |
| 1794 | 1852 | return ( | |
| 1795 | 1853 | <Hint label={`Toggle sidebar (${key})`}> | |
| 1796 | − | <button | |
| 1797 | − | type="button" | |
| 1798 | − | aria-label={open ? "Hide the sidebar" : "Show the sidebar"} | |
| 1799 | − | aria-expanded={open} | |
| 1800 | − | onClick={onClick} | |
| 1801 | − | className={`flex size-8 shrink-0 items-center justify-center rounded-md text-muted outline-none transition-colors hover:bg-raised hover:text-fg focus-visible:ring-2 focus-visible:ring-accent ${className}`} | |
| 1802 | − | > | |
| 1854 | + | <Button variant="ghost" size="icon-sm" aria-label={open ? "Hide the sidebar" : "Show the sidebar"} aria-expanded={open} onClick={onClick} className={className}> | |
| 1803 | 1855 | <PanelLeft size={17} /> | |
| 1804 | − | </button> | |
| 1856 | + | </Button> | |
| 1805 | 1857 | </Hint> | |
| 1806 | 1858 | ); | |
| 1807 | 1859 | } | |
| ⋯ | |||
| 1844 | 1896 | usePaletteShortcut(() => setPalette((open) => !open)); | |
| 1845 | 1897 | const leaving = useLeaving(); | |
| 1846 | 1898 | useVisualViewport(); | |
| 1899 | + | // Whether the page has scrolled under the top bar, which then draws its line (see data-page-head). | |
| 1900 | + | const [scrolled, setScrolled] = useState(false); | |
| 1901 | + | useEffect(() => { | |
| 1902 | + | const onScroll = () => setScrolled(window.scrollY > 2); | |
| 1903 | + | onScroll(); | |
| 1904 | + | window.addEventListener("scroll", onScroll, { passive: true }); | |
| 1905 | + | return () => window.removeEventListener("scroll", onScroll); | |
| 1906 | + | }, []); | |
| 1847 | 1907 | ||
| 1848 | 1908 | const going = useNavigation().location?.pathname; | |
| 1849 | 1909 | const ws = shell.workspace; | |
| ⋯ | |||
| 1966 | 2026 | </SheetContent> | |
| 1967 | 2027 | ||
| 1968 | 2028 | <div className={`min-h-dvh px-1.5 pt-(--frame-top) pb-(--tabbar-h) md:pr-2 ${pad}`}> | |
| 1969 | − | <div className="flex min-h-[calc(100dvh-var(--frame-top)-var(--tabbar-h))] min-w-0 flex-col rounded-[14px] bg-bg"> | |
| 2029 | + | {/* A page whose own header sits right under this bar (data-page-head: a conversation's, a project's) reads as one | |
| 2030 | + | block with it: no line between them until the page scrolls under the bar. A sticky one (data-page-head="sticky", | |
| 2031 | + | an open artifact's) stays under the bar, so the line is always its. */} | |
| 2032 | + | <div className="flex min-h-[calc(100dvh-var(--frame-top)-var(--tabbar-h))] min-w-0 flex-col rounded-[14px] bg-bg [&:has([data-page-head])>header:not([data-scrolled])]:border-b-transparent [&:has([data-page-head=sticky])>header]:border-b-transparent"> | |
| 1970 | 2033 | <header | |
| 1971 | − | className={`sticky top-(--frame-top) z-30 flex h-14 items-center gap-1.5 rounded-t-[14px] border-b border-line bg-bg pr-2 pl-2 sm:gap-2 sm:pr-3 ${conversation ? "max-md:hidden" : ""}`} | |
| 2034 | + | data-scrolled={scrolled || undefined} | |
| 2035 | + | className={`sticky top-(--frame-top) z-30 flex h-14 items-center gap-1 rounded-t-[14px] border-b border-line bg-bg pr-2 pl-2 transition-[border-color] duration-150 sm:pr-3 ${inline ? "lg:pl-3.5" : ""} ${conversation ? "max-md:hidden" : ""}`} | |
| 1972 | 2036 | > | |
| 1973 | 2037 | {panel && ( | |
| 1974 | − | <SidebarToggle open={drawer} onClick={toggleSidebar} className={inline ? "lg:hidden" : ""} /> | |
| 2038 | + | <SidebarToggle open={drawer} onClick={toggleSidebar} className={`max-md:size-11 ${inline ? "lg:hidden" : ""}`} /> | |
| 1975 | 2039 | )} | |
| 1976 | 2040 | {ws && ( | |
| 1977 | 2041 | <span className={`flex min-w-0 shrink items-center gap-1.5 ${inline ? "lg:hidden" : ""}`}> | |
| ⋯ | |||
| 1982 | 2046 | </span> | |
| 1983 | 2047 | )} | |
| 1984 | 2048 | <Breadcrumbs pathname={pathname} missing={missing} repo={shell.repo} workspace={ws?.slug ?? null} /> | |
| 1985 | − | <div className="ml-auto flex shrink-0 items-center gap-1.5"> | |
| 1986 | − | <button | |
| 1987 | − | type="button" | |
| 1988 | − | aria-label="Search or jump to" | |
| 1989 | − | onClick={() => setPalette(true)} | |
| 1990 | − | className="flex h-8 items-center gap-2 rounded-md text-faint transition-colors hover:text-muted max-md:w-8 max-md:justify-center max-md:hover:bg-raised md:w-[min(17rem,26vw)] md:border md:border-line md:bg-surface md:px-2.5 md:hover:border-line-strong" | |
| 1991 | − | > | |
| 1992 | − | <Search size={15} className="shrink-0" /> | |
| 1993 | − | <span className="hidden grow truncate text-left text-[0.8125rem] md:inline">Search or jump to</span> | |
| 1994 | − | <PaletteKey className="hidden rounded bg-raised px-1.5 font-mono text-[0.625rem] text-muted ring-1 ring-line md:inline" /> | |
| 1995 | − | </button> | |
| 2049 | + | {/* Search, then what's yours (spend, Ask g1t), then the bell and Create new: one height, one rhythm. */} | |
| 2050 | + | <div className="ml-auto flex shrink-0 items-center gap-1 md:gap-1.5"> | |
| 2051 | + | <SearchButton onClick={() => setPalette(true)} /> | |
| 1996 | 2052 | {/* Your spend this month, or the workspace's for owners and billing managers; on a phone it is on Spend. */} | |
| 1997 | 2053 | {ws && ( | |
| 1998 | − | <span className="max-md:hidden"> | |
| 2054 | + | <span className="flex max-md:hidden"> | |
| 1999 | 2055 | <SpendPill slug={ws.slug} mayWorkspace={ws.role === "owner" || !!ws.org_roles?.includes("billing_manager")} /> | |
| 2000 | 2056 | </span> | |
| 2001 | 2057 | )} | |
| 2002 | 2058 | {ws && <AskG1tButton slug={ws.slug} />} | |
| 2059 | + | <Separator orientation="vertical" className="mx-1 data-[orientation=vertical]:h-5 max-md:hidden" /> | |
| 2003 | 2060 | {/* On a phone, Notifications is in the bottom bar. */} | |
| 2004 | − | <span className="max-md:hidden"> | |
| 2061 | + | <span className="flex max-md:hidden"> | |
| 2005 | 2062 | <NotificationsBell counts={shell.inbox ? { ...shell.inbox, unread: notificationsUnread } : null} /> | |
| 2006 | 2063 | </span> | |
| 2007 | 2064 | <CreateMenu shell={shell} /> | |
| ⋯ | |||
| 2015 | 2072 | </main> | |
| 2016 | 2073 | </div> | |
| 2017 | 2074 | </div> | |
| 2018 | − | {/* The panel's edge: a rounded frame over the page, with the background beyond it, so what scrolls stays inside. */} | |
| 2019 | − | {!conversation && ( | |
| 2020 | − | <div | |
| 2021 | − | aria-hidden="true" | |
| 2022 | − | className={`pointer-events-none fixed top-(--frame-top) right-1.5 bottom-(--tabbar-h) left-1.5 z-[35] rounded-[14px] shadow-[0_0_0_1px_var(--color-line),0_0_0_100vmax_var(--color-shell)] md:right-2 ${left}`} | |
| 2023 | − | /> | |
| 2024 | − | )} | |
| 2075 | + | {/* The panel's edge: a rounded frame over the page, with the background beyond it, so what scrolls stays inside. | |
| 2076 | + | Every page has it, a conversation too; only a phone's conversation, full screen over everything, goes without. */} | |
| 2077 | + | <div | |
| 2078 | + | aria-hidden="true" | |
| 2079 | + | className={`pointer-events-none fixed top-(--frame-top) right-1.5 bottom-(--tabbar-h) left-1.5 z-[35] rounded-[14px] shadow-[0_0_0_1px_var(--color-line),0_0_0_100vmax_var(--color-shell)] md:right-2 ${left} ${conversation ? "max-md:hidden" : ""}`} | |
| 2080 | + | /> | |
| 2025 | 2081 | {ws && <BottomBar user={user} workspace={ws} pins={shell.pins ?? []} unread={unread} onReselect={panel ? () => setDrawer(true) : undefined} />} | |
| 2026 | 2082 | <CommandPalette open={palette} onOpenChange={setPalette} commands={commands} repo={shell.repo ? `${shell.repo.namespace}/${shell.repo.name}` : null} /> | |
| 2027 | 2083 | </Sheet> | |
| ⋯ | |||
| 2033 | 2089 | return ( | |
| 2034 | 2090 | <DropdownMenu> | |
| 2035 | 2091 | <Hint label="Create new"> | |
| 2036 | − | <DropdownMenuTrigger | |
| 2037 | − | aria-label="Create new" | |
| 2038 | − | className="flex h-8 items-center gap-1 rounded-md border border-line px-1.5 text-fg/90 outline-none transition-colors hover:border-line-strong hover:bg-raised hover:text-fg focus-visible:ring-2 focus-visible:ring-accent data-[state=open]:bg-raised sm:px-2" | |
| 2039 | − | > | |
| 2040 | − | <Plus size={16} /> | |
| 2041 | − | <ChevronDown size={13} className="text-muted max-sm:hidden" /> | |
| 2092 | + | <DropdownMenuTrigger asChild> | |
| 2093 | + | <Button variant="ghost" size="bar" aria-label="Create new" className="gap-0.5 px-1.5 text-fg/90"> | |
| 2094 | + | <Plus size={17} className="max-md:size-5" /> | |
| 2095 | + | <ChevronDown size={12} className="text-faint max-md:hidden" /> | |
| 2096 | + | </Button> | |
| 2042 | 2097 | </DropdownMenuTrigger> | |
| 2043 | 2098 | </Hint> | |
| 2044 | 2099 | <DropdownMenuContent align="end" className="min-w-52"> | |
| 15 | 15 | import { Meter, PrivateTitle, SliceList, stepsLine } from "./agents/parts"; | |
| 16 | 16 | import { monthName, shortDay } from "./agents/format"; | |
| 17 | 17 | import { Badge } from "./ui/badge"; | |
| 18 | + | import { Button } from "./ui/button"; | |
| 18 | 19 | import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "./ui/dialog"; | |
| 19 | 20 | import { Field, FieldDescription, FieldError, FieldLabel } from "./ui/field"; | |
| 20 | 21 | import { Hint } from "./ui/hint"; | |
| ⋯ | |||
| 589 | 590 | const label = spent == null ? (data ? "Spend" : "") : money(spent); | |
| 590 | 591 | return ( | |
| 591 | 592 | <Popover open={open} onOpenChange={setOpen}> | |
| 592 | − | <PopoverTrigger | |
| 593 | − | aria-label={spent == null ? "Spend this month" : `${ws ? "The workspace's" : "Your"} spend this month: ${money(spent)}${budget != null ? ` of ${money(budget)}` : ""}`} | |
| 594 | − | className="flex h-8 items-center gap-2 rounded-md border border-line px-2.5 text-[0.8125rem] text-fg/90 tabular-nums transition-colors outline-none hover:border-line-strong hover:bg-raised hover:text-fg focus-visible:ring-2 focus-visible:ring-accent data-[state=open]:bg-raised" | |
| 595 | − | > | |
| 593 | + | <PopoverTrigger asChild> | |
| 594 | + | <Button | |
| 595 | + | variant="ghost" | |
| 596 | + | size="sm" | |
| 597 | + | aria-label={spent == null ? "Spend this month" : `${ws ? "The workspace's" : "Your"} spend this month: ${money(spent)}${budget != null ? ` of ${money(budget)}` : ""}`} | |
| 598 | + | className="gap-2 text-fg-soft tabular-nums" | |
| 599 | + | > | |
| 596 | 600 | {ws ? <Building2 size={14} className="shrink-0 text-faint" /> : <Coins size={14} className="shrink-0 text-faint" />} | |
| 597 | 601 | {data ? <span>{label}</span> : <Skeleton className="h-3 w-10" />} | |
| 598 | 602 | {share != null && ( | |
| ⋯ | |||
| 600 | 604 | <Meter spent={spent!} cap={budget} label="Spent of the budget" size="sm" /> | |
| 601 | 605 | </span> | |
| 602 | 606 | )} | |
| 607 | + | </Button> | |
| 603 | 608 | </PopoverTrigger> | |
| 604 | 609 | <PopoverContent align="end" className="w-80 p-0"> | |
| 605 | 610 | <SpendPopover slug={slug} data={data ?? null} scope={scope} mayWorkspace={mayWorkspace} onScope={choose} onClose={() => setOpen(false)} /> | |
| 1 | − | import { ShieldAlert, TriangleAlert } from "lucide-react"; | |
| 1 | + | import { Check, ShieldAlert, TriangleAlert } from "lucide-react"; | |
| 2 | 2 | import { useState } from "react"; | |
| 3 | 3 | ||
| 4 | 4 | import { | |
| ⋯ | |||
| 47 | 47 | return ( | |
| 48 | 48 | <Hint label={by ? `Included in ${by}` : undefined}> | |
| 49 | 49 | <label className={cn("flex min-w-0 items-start gap-2.5 py-1", by ? "cursor-default" : "cursor-pointer")}> | |
| 50 | − | <input | |
| 51 | − | type="checkbox" | |
| 52 | − | name="scope" | |
| 53 | − | value={scope} | |
| 54 | − | checked={checked} | |
| 55 | − | disabled={by !== null} | |
| 56 | − | onChange={(event) => onToggle(scope, event.target.checked)} | |
| 57 | − | className={cn("mt-0.5 size-4 shrink-0", isDangerous(scope) ? "accent-danger" : "accent-accent")} | |
| 58 | − | /> | |
| 50 | + | {/* The real checkbox, drawn as the ui Checkbox is, so it still posts without script. */} | |
| 51 | + | <span className="relative mt-0.5 flex size-4 shrink-0"> | |
| 52 | + | <input | |
| 53 | + | type="checkbox" | |
| 54 | + | name="scope" | |
| 55 | + | value={scope} | |
| 56 | + | checked={checked} | |
| 57 | + | disabled={by !== null} | |
| 58 | + | onChange={(event) => onToggle(scope, event.target.checked)} | |
| 59 | + | className={cn( | |
| 60 | + | "peer size-4 cursor-[inherit] appearance-none rounded-[5px] border border-line-strong bg-bg transition-colors hover:border-faint disabled:opacity-50", | |
| 61 | + | "focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:ring-offset-1 focus-visible:ring-offset-bg focus-visible:outline-none", | |
| 62 | + | isDangerous(scope) ? "checked:border-danger checked:bg-danger" : "checked:border-accent checked:bg-accent", | |
| 63 | + | )} | |
| 64 | + | /> | |
| 65 | + | <Check size={12} strokeWidth={3} aria-hidden className="pointer-events-none absolute inset-0 m-auto hidden text-bg peer-checked:block peer-disabled:opacity-50" /> | |
| 66 | + | </span> | |
| 59 | 67 | <span className="min-w-0"> | |
| 60 | 68 | <span className={cn("block font-mono text-[0.8125rem]", isDangerous(scope) ? "text-danger" : "text-fg", by && "opacity-60")}> | |
| 61 | 69 | {scope} | |
| 1 | + | import { MoreHorizontal } from "lucide-react"; | |
| 2 | + | import { Slot } from "radix-ui"; | |
| 3 | + | import type { ComponentProps } from "react"; | |
| 4 | + | ||
| 5 | + | import { cn } from "../../lib/cn"; | |
| 6 | + | ||
| 7 | + | // shadcn/ui's breadcrumb, styled with g1t's tokens: where you are as a | |
| 8 | + | // trail of links, the page you are on last and not a link of its own | |
| 9 | + | // (BreadcrumbPage), with a quiet `/` between. `asChild` on a link puts its | |
| 10 | + | // look on a router <Link>. | |
| 11 | + | ||
| 12 | + | export function Breadcrumb(props: ComponentProps<"nav">) { | |
| 13 | + | return <nav aria-label="Breadcrumb" data-slot="breadcrumb" {...props} />; | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | export function BreadcrumbList({ className, ...props }: ComponentProps<"ol">) { | |
| 17 | + | return <ol data-slot="breadcrumb-list" className={cn("flex min-w-0 items-center gap-1 text-sm text-muted", className)} {...props} />; | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | export function BreadcrumbItem({ className, ...props }: ComponentProps<"li">) { | |
| 21 | + | return <li data-slot="breadcrumb-item" className={cn("inline-flex min-w-0 items-center gap-1", className)} {...props} />; | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | export function BreadcrumbLink({ asChild, className, ...props }: ComponentProps<"a"> & { asChild?: boolean }) { | |
| 25 | + | const Comp = asChild ? Slot.Root : "a"; | |
| 26 | + | return ( | |
| 27 | + | <Comp | |
| 28 | + | data-slot="breadcrumb-link" | |
| 29 | + | className={cn("truncate rounded-md px-1.5 py-1 outline-none transition-colors hover:bg-raised hover:text-fg focus-visible:ring-2 focus-visible:ring-accent", className)} | |
| 30 | + | {...props} | |
| 31 | + | /> | |
| 32 | + | ); | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | /** The page you are on: the trail's end, in the foreground. */ | |
| 36 | + | export function BreadcrumbPage({ asChild, className, ...props }: ComponentProps<"span"> & { asChild?: boolean }) { | |
| 37 | + | const Comp = asChild ? Slot.Root : "span"; | |
| 38 | + | return ( | |
| 39 | + | <Comp | |
| 40 | + | data-slot="breadcrumb-page" | |
| 41 | + | aria-current="page" | |
| 42 | + | className={cn("truncate rounded-md px-1.5 py-1 font-medium text-fg outline-none focus-visible:ring-2 focus-visible:ring-accent", className)} | |
| 43 | + | {...props} | |
| 44 | + | /> | |
| 45 | + | ); | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | export function BreadcrumbSeparator({ children, className, ...props }: ComponentProps<"li">) { | |
| 49 | + | return ( | |
| 50 | + | <li data-slot="breadcrumb-separator" role="presentation" aria-hidden="true" className={cn("text-line-strong select-none", className)} {...props}> | |
| 51 | + | {children ?? "/"} | |
| 52 | + | </li> | |
| 53 | + | ); | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | export function BreadcrumbEllipsis({ className, ...props }: ComponentProps<"span">) { | |
| 57 | + | return ( | |
| 58 | + | <span data-slot="breadcrumb-ellipsis" role="presentation" aria-hidden="true" className={cn("flex size-8 items-center justify-center", className)} {...props}> | |
| 59 | + | <MoreHorizontal size={16} /> | |
| 60 | + | <span className="sr-only">More</span> | |
| 61 | + | </span> | |
| 62 | + | ); | |
| 63 | + | } |
| 1 | + | import { type VariantProps, cva } from "class-variance-authority"; | |
| 2 | + | import { Slot } from "radix-ui"; | |
| 3 | + | import type { ComponentProps } from "react"; | |
| 4 | + | ||
| 5 | + | import { cn } from "../../lib/cn"; | |
| 6 | + | ||
| 7 | + | // shadcn/ui's button, styled with g1t's tokens. `asChild` puts its look on | |
| 8 | + | // a <Link> or a menu's trigger. The older `Button` in ./index stays until | |
| 9 | + | // its callers move here. | |
| 10 | + | // | |
| 11 | + | // Sizes: `default` 36px, `sm` 32px, `lg` 40px; the icon sizes are squares | |
| 12 | + | // of the same. `bar` is the top bar's: 32px on a computer, 44px to a finger | |
| 13 | + | // on a phone, and `icon-bar` its square. | |
| 14 | + | ||
| 15 | + | export const buttonVariants = cva( | |
| 16 | + | "inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap outline-none transition-[color,background-color,border-color,box-shadow] focus-visible:ring-2 focus-visible:ring-accent disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0", | |
| 17 | + | { | |
| 18 | + | variants: { | |
| 19 | + | variant: { | |
| 20 | + | default: "bg-fg text-bg hover:bg-fg-hover", | |
| 21 | + | accent: "bg-accent text-bg hover:bg-accent-hover", | |
| 22 | + | outline: "border border-line bg-bg text-fg/90 hover:border-line-strong hover:bg-surface hover:text-fg", | |
| 23 | + | secondary: "bg-raised text-fg hover:bg-raised/80", | |
| 24 | + | ghost: "text-muted hover:bg-raised hover:text-fg data-[state=open]:bg-raised data-[state=open]:text-fg aria-pressed:bg-raised aria-pressed:text-fg", | |
| 25 | + | // The accent as a wash: the one tinted control in a row, such as Ask g1t. | |
| 26 | + | soft: "bg-accent/10 text-fg ring-1 ring-accent/20 ring-inset hover:bg-accent/15 hover:ring-accent/35", | |
| 27 | + | destructive: "border border-danger/40 text-danger hover:border-danger hover:bg-danger/10", | |
| 28 | + | link: "text-accent underline-offset-4 hover:underline", | |
| 29 | + | }, | |
| 30 | + | size: { | |
| 31 | + | default: "h-9 px-3.5", | |
| 32 | + | sm: "h-8 gap-1.5 px-2.5 text-[0.8125rem]", | |
| 33 | + | lg: "h-10 px-5", | |
| 34 | + | bar: "h-8 gap-1.5 px-2 text-[0.8125rem] max-md:h-11 max-md:min-w-11", | |
| 35 | + | icon: "size-9", | |
| 36 | + | "icon-sm": "size-8", | |
| 37 | + | "icon-lg": "size-10", | |
| 38 | + | "icon-bar": "size-8 max-md:size-11", | |
| 39 | + | }, | |
| 40 | + | }, | |
| 41 | + | defaultVariants: { variant: "default", size: "default" }, | |
| 42 | + | }, | |
| 43 | + | ); | |
| 44 | + | ||
| 45 | + | export type ButtonProps = ComponentProps<"button"> & VariantProps<typeof buttonVariants> & { asChild?: boolean }; | |
| 46 | + | ||
| 47 | + | export function Button({ className, variant, size, asChild = false, ...props }: ButtonProps) { | |
| 48 | + | const Comp = asChild ? Slot.Root : "button"; | |
| 49 | + | return <Comp data-slot="button" className={cn(buttonVariants({ variant, size }), className)} {...props} />; | |
| 50 | + | } |
| 1 | + | import { FileText, Upload, X } from "lucide-react"; | |
| 2 | + | import { type ChangeEvent, type DragEvent, type ReactNode, useEffect, useId, useRef, useState } from "react"; | |
| 3 | + | ||
| 4 | + | import { cn } from "../../lib/cn"; | |
| 5 | + | ||
| 6 | + | // A file field as a dropzone. The real <input type="file"> is inside it, | |
| 7 | + | // visually hidden, so its name, accept, multiple and required work as they | |
| 8 | + | // always do and the form posts the files exactly as a bare input would. A | |
| 9 | + | // click or Enter on the zone opens the picker; files dropped on it are put | |
| 10 | + | // on the input and a change event is sent, as if they had been picked. | |
| 11 | + | ||
| 12 | + | type Chosen = { name: string; size: number }; | |
| 13 | + | ||
| 14 | + | /** Bytes as a person reads them: 812 B, 14.2 KB, 1.3 MB. */ | |
| 15 | + | function fileSize(bytes: number): string { | |
| 16 | + | if (bytes < 1024) return `${bytes} B`; | |
| 17 | + | if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; | |
| 18 | + | return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | export function FileDrop({ | |
| 22 | + | id, | |
| 23 | + | name, | |
| 24 | + | accept, | |
| 25 | + | multiple = false, | |
| 26 | + | required, | |
| 27 | + | hint, | |
| 28 | + | label, | |
| 29 | + | compact = false, | |
| 30 | + | className, | |
| 31 | + | onChange, | |
| 32 | + | "aria-describedby": describedBy, | |
| 33 | + | }: { | |
| 34 | + | id?: string; | |
| 35 | + | name?: string; | |
| 36 | + | accept?: string; | |
| 37 | + | multiple?: boolean; | |
| 38 | + | required?: boolean; | |
| 39 | + | /** A line under the prompt, such as the types and the size allowed. */ | |
| 40 | + | hint?: ReactNode; | |
| 41 | + | /** The field's name for a screen reader, when nothing else labels it. */ | |
| 42 | + | label?: string; | |
| 43 | + | /** One row, for a tight spot beside other controls. */ | |
| 44 | + | compact?: boolean; | |
| 45 | + | className?: string; | |
| 46 | + | onChange?: (event: ChangeEvent<HTMLInputElement>) => void; | |
| 47 | + | "aria-describedby"?: string; | |
| 48 | + | }) { | |
| 49 | + | const generated = useId(); | |
| 50 | + | const inputId = id ?? generated; | |
| 51 | + | const hintId = `${inputId}-hint`; | |
| 52 | + | const input = useRef<HTMLInputElement>(null); | |
| 53 | + | const [files, setFiles] = useState<Chosen[]>([]); | |
| 54 | + | const [dragging, setDragging] = useState(false); | |
| 55 | + | ||
| 56 | + | // A form reset empties the input; the list follows it. | |
| 57 | + | useEffect(() => { | |
| 58 | + | const form = input.current?.form; | |
| 59 | + | if (!form) return; | |
| 60 | + | const onReset = () => setFiles([]); | |
| 61 | + | form.addEventListener("reset", onReset); | |
| 62 | + | return () => form.removeEventListener("reset", onReset); | |
| 63 | + | }, []); | |
| 64 | + | ||
| 65 | + | const changed = (event: ChangeEvent<HTMLInputElement>) => { | |
| 66 | + | setFiles(Array.from(event.currentTarget.files ?? [], (file) => ({ name: file.name, size: file.size }))); | |
| 67 | + | onChange?.(event); | |
| 68 | + | }; | |
| 69 | + | ||
| 70 | + | const drop = (event: DragEvent<HTMLLabelElement>) => { | |
| 71 | + | event.preventDefault(); | |
| 72 | + | setDragging(false); | |
| 73 | + | const field = input.current; | |
| 74 | + | const dropped = event.dataTransfer.files; | |
| 75 | + | if (!field || dropped.length === 0) return; | |
| 76 | + | if (multiple || dropped.length === 1) { | |
| 77 | + | field.files = dropped; | |
| 78 | + | } else { | |
| 79 | + | const one = new DataTransfer(); | |
| 80 | + | one.items.add(dropped[0]); | |
| 81 | + | field.files = one.files; | |
| 82 | + | } | |
| 83 | + | field.dispatchEvent(new Event("change", { bubbles: true })); | |
| 84 | + | }; | |
| 85 | + | ||
| 86 | + | const clear = () => { | |
| 87 | + | const field = input.current; | |
| 88 | + | if (!field) return; | |
| 89 | + | field.value = ""; | |
| 90 | + | field.dispatchEvent(new Event("change", { bubbles: true })); | |
| 91 | + | field.focus(); | |
| 92 | + | }; | |
| 93 | + | ||
| 94 | + | const describedByIds = [describedBy, hint ? hintId : null].filter(Boolean).join(" ") || undefined; | |
| 95 | + | const plural = multiple; | |
| 96 | + | ||
| 97 | + | return ( | |
| 98 | + | <div className={cn("min-w-0", className)}> | |
| 99 | + | <label | |
| 100 | + | htmlFor={inputId} | |
| 101 | + | onDragEnter={(event) => { | |
| 102 | + | event.preventDefault(); | |
| 103 | + | setDragging(true); | |
| 104 | + | }} | |
| 105 | + | onDragOver={(event) => { | |
| 106 | + | event.preventDefault(); | |
| 107 | + | event.dataTransfer.dropEffect = "copy"; | |
| 108 | + | }} | |
| 109 | + | onDragLeave={(event) => { | |
| 110 | + | if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setDragging(false); | |
| 111 | + | }} | |
| 112 | + | onDrop={drop} | |
| 113 | + | className={cn( | |
| 114 | + | "relative flex cursor-pointer rounded-lg border border-dashed border-line-strong bg-bg text-sm text-muted transition-colors", | |
| 115 | + | "hover:border-accent/50 hover:bg-surface has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-accent has-[:focus-visible]:ring-offset-1 has-[:focus-visible]:ring-offset-bg", | |
| 116 | + | compact ? "min-h-11 items-center gap-2 px-3 py-1.5 sm:min-h-9" : "flex-col items-center justify-center gap-1.5 px-4 py-6 text-center", | |
| 117 | + | dragging && "border-accent bg-accent/5 hover:border-accent hover:bg-accent/5", | |
| 118 | + | )} | |
| 119 | + | > | |
| 120 | + | <input | |
| 121 | + | ref={input} | |
| 122 | + | id={inputId} | |
| 123 | + | type="file" | |
| 124 | + | name={name} | |
| 125 | + | accept={accept} | |
| 126 | + | multiple={multiple} | |
| 127 | + | required={required} | |
| 128 | + | aria-describedby={describedByIds} | |
| 129 | + | onChange={changed} | |
| 130 | + | className="sr-only" | |
| 131 | + | /> | |
| 132 | + | {label && <span className="sr-only">{label}</span>} | |
| 133 | + | <Upload size={compact ? 14 : 18} aria-hidden className={cn("shrink-0", dragging ? "text-accent" : "text-faint")} /> | |
| 134 | + | <span className="min-w-0"> | |
| 135 | + | <span className="font-medium text-accent">{plural ? "Choose files" : "Choose a file"}</span> or drop {plural ? "them" : "it"} here | |
| 136 | + | </span> | |
| 137 | + | {hint && ( | |
| 138 | + | <span id={hintId} className={cn("text-xs text-faint", compact && "sr-only")}> | |
| 139 | + | {hint} | |
| 140 | + | </span> | |
| 141 | + | )} | |
| 142 | + | </label> | |
| 143 | + | {files.length > 0 && ( | |
| 144 | + | <div className="mt-2 flex items-start gap-2 rounded-lg border border-line bg-surface py-1 pr-1 pl-3"> | |
| 145 | + | <ul aria-label="Chosen files" className="min-w-0 grow"> | |
| 146 | + | {files.map((file, index) => ( | |
| 147 | + | <li key={`${file.name}-${index}`} className="flex min-h-8 items-center gap-2 text-sm"> | |
| 148 | + | <FileText size={14} aria-hidden className="shrink-0 text-faint" /> | |
| 149 | + | <span className="min-w-0 grow truncate">{file.name}</span> | |
| 150 | + | <span className="shrink-0 text-xs text-faint tabular-nums">{fileSize(file.size)}</span> | |
| 151 | + | </li> | |
| 152 | + | ))} | |
| 153 | + | </ul> | |
| 154 | + | <button | |
| 155 | + | type="button" | |
| 156 | + | onClick={clear} | |
| 157 | + | className="inline-flex h-8 shrink-0 items-center gap-1 rounded-md px-2 text-xs text-muted outline-none hover:bg-raised hover:text-fg focus-visible:ring-2 focus-visible:ring-accent" | |
| 158 | + | > | |
| 159 | + | <X size={13} aria-hidden /> | |
| 160 | + | Clear | |
| 161 | + | </button> | |
| 162 | + | </div> | |
| 163 | + | )} | |
| 164 | + | </div> | |
| 165 | + | ); | |
| 166 | + | } |
| 1 | + | import { Separator as Primitive } from "radix-ui"; | |
| 2 | + | import type { ComponentProps } from "react"; | |
| 3 | + | ||
| 4 | + | import { cn } from "../../lib/cn"; | |
| 5 | + | ||
| 6 | + | // shadcn/ui's separator, styled with g1t's tokens: a hairline in `line`, | |
| 7 | + | // across or (vertical) up and down. Decorative unless told otherwise, so a | |
| 8 | + | // screen reader passes over it. | |
| 9 | + | ||
| 10 | + | export function Separator({ className, orientation = "horizontal", decorative = true, ...props }: ComponentProps<typeof Primitive.Root>) { | |
| 11 | + | return ( | |
| 12 | + | <Primitive.Root | |
| 13 | + | data-slot="separator" | |
| 14 | + | decorative={decorative} | |
| 15 | + | orientation={orientation} | |
| 16 | + | className={cn("shrink-0 bg-line data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px", className)} | |
| 17 | + | {...props} | |
| 18 | + | /> | |
| 19 | + | ); | |
| 20 | + | } |
| 1 | + | import type { ComponentProps } from "react"; | |
| 2 | + | ||
| 3 | + | import { cn } from "../../lib/cn"; | |
| 4 | + | ||
| 5 | + | // shadcn/ui's table, styled with g1t's tokens. The table sits in a frame | |
| 6 | + | // that scrolls sideways on its own, so a wide one never widens the page; | |
| 7 | + | // the frame takes focus when it has to scroll, for the keyboard. | |
| 8 | + | ||
| 9 | + | export function Table({ className, frameProps, ...props }: Omit<ComponentProps<"table">, "frame"> & { frameProps?: ComponentProps<"div"> }) { | |
| 10 | + | return ( | |
| 11 | + | <div | |
| 12 | + | data-slot="table-frame" | |
| 13 | + | {...frameProps} | |
| 14 | + | className={cn("relative w-full overflow-x-auto outline-none [scrollbar-width:thin] focus-visible:ring-2 focus-visible:ring-accent", frameProps?.className)} | |
| 15 | + | > | |
| 16 | + | <table data-slot="table" className={cn("w-full caption-bottom border-collapse text-sm", className)} {...props} /> | |
| 17 | + | </div> | |
| 18 | + | ); | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | export function TableHeader({ className, ...props }: ComponentProps<"thead">) { | |
| 22 | + | return <thead data-slot="table-header" className={cn("bg-surface [&_tr]:border-b [&_tr]:border-line", className)} {...props} />; | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | export function TableBody({ className, ...props }: ComponentProps<"tbody">) { | |
| 26 | + | return <tbody data-slot="table-body" className={cn("[&_tr:last-child]:border-0", className)} {...props} />; | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | export function TableRow({ className, ...props }: ComponentProps<"tr">) { | |
| 30 | + | return <tr data-slot="table-row" className={cn("border-b border-line transition-colors data-[state=selected]:bg-raised", className)} {...props} />; | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | export function TableHead({ className, ...props }: ComponentProps<"th">) { | |
| 34 | + | return <th data-slot="table-head" className={cn("h-9 px-3 text-left align-middle font-medium whitespace-nowrap text-muted", className)} {...props} />; | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | export function TableCell({ className, ...props }: ComponentProps<"td">) { | |
| 38 | + | return <td data-slot="table-cell" className={cn("px-3 py-2 align-top", className)} {...props} />; | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | export function TableCaption({ className, ...props }: ComponentProps<"caption">) { | |
| 42 | + | return <caption data-slot="table-caption" className={cn("mt-3 text-xs text-faint", className)} {...props} />; | |
| 43 | + | } |
| 30 | 30 | usageCsv, | |
| 31 | 31 | } from "../lib/usage"; | |
| 32 | 32 | import { cn } from "../lib/cn"; | |
| 33 | + | import { Checkbox } from "./ui/checkbox"; | |
| 33 | 34 | import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "./ui/dropdown-menu"; | |
| 34 | 35 | import { Hint } from "./ui/hint"; | |
| 36 | + | import { Input } from "./ui/input"; | |
| 35 | 37 | import { SelectField } from "./ui/select"; | |
| 36 | 38 | import { Skeleton } from "./ui/skeleton"; | |
| 37 | 39 | ||
| ⋯ | |||
| 69 | 71 | {options.map((option) => ( | |
| 70 | 72 | <li key={option.value}> | |
| 71 | 73 | <label className="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-surface"> | |
| 72 | − | <input type="checkbox" name={name} value={option.value} defaultChecked={chosen.includes(option.value)} className="accent-[var(--color-accent)]" /> | |
| 74 | + | <Checkbox name={name} value={option.value} defaultChecked={chosen.includes(option.value)} /> | |
| 73 | 75 | <span className="truncate">{option.label}</span> | |
| 74 | 76 | </label> | |
| 75 | 77 | </li> | |
| ⋯ | |||
| 130 | 132 | /> | |
| 131 | 133 | {filters.period === "custom" ? ( | |
| 132 | 134 | <span className="flex items-center gap-1 text-sm"> | |
| 133 | − | <input type="date" name="from" defaultValue={filters.from} aria-label="From" className={SELECT} /> | |
| 135 | + | <Input type="date" name="from" defaultValue={filters.from} aria-label="From" className="h-8 w-auto px-2" /> | |
| 134 | 136 | <span className="text-faint">to</span> | |
| 135 | − | <input type="date" name="until" defaultValue={filters.until} aria-label="Until" className={SELECT} /> | |
| 137 | + | <Input type="date" name="until" defaultValue={filters.until} aria-label="Until" className="h-8 w-auto px-2" /> | |
| 136 | 138 | </span> | |
| 137 | 139 | ) : ( | |
| 138 | 140 | <span className="px-1 text-sm text-muted tabular-nums">{rangeLabel(filters.from, filters.until)}</span> | |
| 71 | 71 | "ask @ana in #web about g1t#12", | |
| 72 | 72 | "\\- not a list and 2\\*3 and snake_case", | |
| 73 | 73 | "**a _b_ c**", | |
| 74 | + | "| Session | Length |\n| --- | --: |\n| **Welcome** | `45` min |", | |
| 74 | 75 | ]; | |
| 75 | 76 | for (const sample of samples) assert.equal(docToMarkdown(markdownToDoc(sample)), sample, sample); | |
| 76 | 77 | }); |
| 87 | 87 | break; | |
| 88 | 88 | case "hr": | |
| 89 | 89 | break; | |
| 90 | + | case "table": { | |
| 91 | + | // The composer has no tables: each row comes back as the line it was written as. | |
| 92 | + | const row = (cells: Span[][]): Span[] => [{ t: "text", v: "| " }, ...cells.flatMap((cell, at) => [...(at ? [{ t: "text" as const, v: " | " }] : []), ...cell]), { t: "text", v: " |" }]; | |
| 93 | + | const rule = block.align.map((align) => (align === "center" ? ":-:" : align === "right" ? "--:" : align === "left" ? ":--" : "---")); | |
| 94 | + | out.push(paragraph(row(block.head)), paragraph([{ t: "text", v: `| ${rule.join(" | ")} |` }]), ...block.rows.map((cells) => paragraph(row(cells)))); | |
| 95 | + | break; | |
| 96 | + | } | |
| 90 | 97 | case "code": | |
| 91 | 98 | out.push({ type: "codeBlock", attrs: { language: block.lang }, ...(block.v ? { content: [{ type: "text", text: block.v }] } : {}) }); | |
| 92 | 99 | break; |
| 202 | 202 | // the page shows its name, and the views of the page it is on as tabs. | |
| 203 | 203 | return ( | |
| 204 | 204 | <> | |
| 205 | − | <div className="border-b border-line"> | |
| 206 | − | <div className={`mx-auto max-w-6xl px-4 sm:px-6 ${tabs ? "pt-3" : "py-3"}`}> | |
| 205 | + | <div data-page-head className="border-b border-line"> | |
| 206 | + | <div className={`mx-auto max-w-6xl px-4 sm:px-5 ${tabs ? "pt-3" : "py-3"}`}> | |
| 207 | 207 | <Header | |
| 208 | 208 | project={project} | |
| 209 | 209 | isPrivate={repo.isPrivate} | |
| ⋯ | |||
| 238 | 238 | )} | |
| 239 | 239 | </div> | |
| 240 | 240 | </div> | |
| 241 | − | <div className="mx-auto max-w-6xl px-4 py-6 sm:px-6"> | |
| 241 | + | <div className="mx-auto max-w-6xl px-4 py-6 sm:px-5"> | |
| 242 | 242 | {welcome && ( | |
| 243 | 243 | <div className="mb-6"> | |
| 244 | 244 | <WelcomeBanner title={`You're in ${repo.namespace}/${repo.name}`}> | |
| 5 | 5 | import type { Route } from "./+types/release-new"; | |
| 6 | 6 | import { encodeTag } from "../../components/releases"; | |
| 7 | 7 | import { ErrorText, Field, Input, SubmitButton, Textarea } from "../../components/ui"; | |
| 8 | + | import { CheckboxOption } from "../../components/ui/checkbox"; | |
| 8 | 9 | import { requireRepo } from "../../lib/access.server"; | |
| 9 | 10 | import { page } from "../../lib/meta"; | |
| 10 | 11 | import { repos } from "../../lib/services.server"; | |
| ⋯ | |||
| 78 | 79 | <Field label="Notes" hint="Markdown: what changed, and anything people need to do."> | |
| 79 | 80 | <Textarea name="body" rows={12} maxLength={MAX_RELEASE_BODY_CHARS} placeholder={"## What changed\n\n- "} /> | |
| 80 | 81 | </Field> | |
| 81 | − | <label className="flex items-center gap-2 text-sm"> | |
| 82 | − | <input type="checkbox" name="prerelease" className="size-4" /> | |
| 83 | − | Set as a pre-release: not ready for everyone, never the latest | |
| 84 | − | </label> | |
| 82 | + | <CheckboxOption name="prerelease" label="Set as a pre-release: not ready for everyone, never the latest" /> | |
| 85 | 83 | {actionData?.error && <ErrorText>{actionData.error}</ErrorText>} | |
| 86 | 84 | <div className="flex flex-wrap items-center gap-2"> | |
| 87 | 85 | <SubmitButton name="intent" value="publish" pending="Publishing…"> | |
| 18 | 18 | import { Badge } from "../../../components/ui/badge"; | |
| 19 | 19 | import { CheckboxOption } from "../../../components/ui/checkbox"; | |
| 20 | 20 | import { Field, FieldDescription, FieldError, FieldLabel } from "../../../components/ui/field"; | |
| 21 | + | import { FileDrop } from "../../../components/ui/file-drop"; | |
| 21 | 22 | import { Input } from "../../../components/ui/input"; | |
| 22 | 23 | import { SelectField } from "../../../components/ui/select"; | |
| 23 | 24 | import { Textarea } from "../../../components/ui/textarea"; | |
| ⋯ | |||
| 219 | 220 | })} | |
| 220 | 221 | </ul> | |
| 221 | 222 | )} | |
| 222 | − | <div className="mt-3 flex flex-wrap items-center gap-2"> | |
| 223 | + | <div className="mt-3 flex flex-wrap items-start gap-2"> | |
| 223 | 224 | <input type="hidden" name="folder" value={folder} /> | |
| 224 | 225 | <SelectField | |
| 225 | 226 | aria-label="Folder" | |
| ⋯ | |||
| 231 | 232 | onValueChange={setFolder} | |
| 232 | 233 | className="w-36 font-mono" | |
| 233 | 234 | /> | |
| 234 | − | <label className="flex min-w-0 grow cursor-pointer items-center rounded-md border border-dashed border-line px-3 py-1.5 text-sm text-muted hover:border-line-strong hover:text-fg"> | |
| 235 | − | <span className="sr-only">Add files</span> | |
| 236 | − | <input type="file" name="files" multiple className="w-full min-w-0 text-xs file:mr-3 file:rounded file:border-0 file:bg-raised file:px-2 file:py-1 file:text-xs file:text-fg" /> | |
| 237 | − | </label> | |
| 235 | + | <FileDrop name="files" multiple compact label="Add files" className="min-w-0 grow basis-56" /> | |
| 238 | 236 | </div> | |
| 239 | 237 | <FieldError>{result && !result.ok && result.field === "files" ? result.error : null}</FieldError> | |
| 240 | 238 | </fieldset> | |
| 15 | 15 | import { skillsPath } from "../../../components/agents/skills"; | |
| 16 | 16 | import { CheckboxOption } from "../../../components/ui/checkbox"; | |
| 17 | 17 | import { Field, FieldDescription, FieldLabel } from "../../../components/ui/field"; | |
| 18 | + | import { FileDrop } from "../../../components/ui/file-drop"; | |
| 18 | 19 | import { Input } from "../../../components/ui/input"; | |
| 19 | 20 | import { cn } from "../../../lib/cn"; | |
| 20 | 21 | import { page } from "../../../lib/meta"; | |
| ⋯ | |||
| 110 | 111 | <input type="hidden" name="intent" value="upload" /> | |
| 111 | 112 | <Field> | |
| 112 | 113 | <FieldLabel htmlFor={`${id}-file`}>SKILL.md or zip</FieldLabel> | |
| 113 | − | <input | |
| 114 | − | id={`${id}-file`} | |
| 115 | − | type="file" | |
| 116 | − | name="file" | |
| 117 | − | required | |
| 118 | − | accept=".md,.zip,text/markdown,application/zip" | |
| 119 | − | className="w-full min-w-0 rounded-md border border-dashed border-line bg-bg px-3 py-3 text-sm text-muted file:mr-3 file:rounded file:border-0 file:bg-raised file:px-2.5 file:py-1 file:text-sm file:text-fg hover:border-line-strong" | |
| 120 | − | /> | |
| 121 | − | <FieldDescription>A zip of the skill's folder, as zipping the folder makes it. At most 1 MB once unpacked and 200 files.</FieldDescription> | |
| 114 | + | <FileDrop id={`${id}-file`} name="file" required accept=".md,.zip,text/markdown,application/zip" aria-describedby={`${id}-file-about`} /> | |
| 115 | + | <FieldDescription id={`${id}-file-about`}>A zip of the skill's folder, as zipping the folder makes it. At most 1 MB once unpacked and 200 files.</FieldDescription> | |
| 122 | 116 | </Field> | |
| 123 | 117 | {replace} | |
| 124 | 118 | {error("upload")} | |
| 40 | 40 | const folder = file.path.includes("/") ? file.path.slice(0, file.path.lastIndexOf("/")) : ""; | |
| 41 | 41 | const encode = (path: string) => path.split("/").map(encodeURIComponent).join("/"); | |
| 42 | 42 | return ( | |
| 43 | − | <div className="-mt-6"> | |
| 44 | − | <div className="sticky top-(--topbar-h) z-20 -mx-4 flex h-12 items-center gap-3 border-b border-line bg-bg/85 px-4 backdrop-blur sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8"> | |
| 43 | + | <div> | |
| 44 | + | <div data-page-head="sticky" className="sticky top-(--topbar-h) z-20 -mx-4 flex h-14 items-center gap-3 border-b border-line bg-bg/90 px-4 backdrop-blur sm:-mx-6 sm:px-5 lg:-mx-8"> | |
| 45 | 45 | <div className="min-w-0 grow"> | |
| 46 | 46 | <Crumbs items={[{ label: "Artifacts", to: `/${slug}/-/artifacts` }, { label: space.repo }, ...file.path.split("/").map((part) => ({ label: part }))]} /> | |
| 47 | 47 | </div> |
| 140 | 140 | ); | |
| 141 | 141 | } | |
| 142 | 142 | ||
| 143 | + | /** Artifacts' pages that are lists, not one artifact. */ | |
| 144 | + | const ARTIFACT_LISTS = new Set(["new", "templates", "trash", "stale", "spaces"]); | |
| 145 | + | ||
| 146 | + | /** Whether the page (its path after `/-/`, in parts) is one artifact or a project's doc file. */ | |
| 147 | + | function isArtifactPage(parts: string[]): boolean { | |
| 148 | + | if (parts[0] !== "artifacts" || parts.length < 2) return false; | |
| 149 | + | if (parts[1] === "repo") return true; | |
| 150 | + | return !ARTIFACT_LISTS.has(parts[1]!) && parts.length === 2; | |
| 151 | + | } | |
| 152 | + | ||
| 143 | 153 | export default function WorkspaceLayout({ loaderData }: Route.ComponentProps) { | |
| 144 | 154 | const { workspace, role, welcome } = loaderData; | |
| 145 | 155 | const { pathname } = useLocation(); | |
| ⋯ | |||
| 158 | 168 | if (parts[0] === "chat" && !next) return <Outlet />; | |
| 159 | 169 | // Code's Overview lays itself out, as Mission control did. | |
| 160 | 170 | if (parts[0] === "overview" && parts.length === 1 && !next) return <Outlet />; | |
| 171 | + | // An open artifact, or a project's doc, fills the panel: its header runs edge to edge right under | |
| 172 | + | // the top bar (components/folios/shell.tsx), and its page sets its own width. | |
| 173 | + | if (isArtifactPage(parts) && !next) { | |
| 174 | + | return ( | |
| 175 | + | <div className="px-4 pb-10 sm:px-6 lg:px-8"> | |
| 176 | + | <Outlet /> | |
| 177 | + | </div> | |
| 178 | + | ); | |
| 179 | + | } | |
| 161 | 180 | if (shown !== "overview") { | |
| 162 | 181 | const key = shown ?? parts[0] ?? ""; | |
| 163 | 182 | const heading = PAGES[key]; | |
| 1 | 1 | import { ArrowUpRight, Box, ChevronLeft, ChevronRight, LayoutGrid, List, Lock, Rocket, Search } from "lucide-react"; | |
| 2 | − | import { type KeyboardEvent, useEffect, useRef } from "react"; | |
| 2 | + | import { type KeyboardEvent, useEffect, useRef, useState } from "react"; | |
| 3 | 3 | import { Form, Link, useLocation, useNavigation, useSubmit } from "react-router"; | |
| 4 | 4 | ||
| 5 | 5 | import type { Route } from "./+types/projects"; | |
| 6 | 6 | import { host } from "../../components/deploy"; | |
| 7 | 7 | import { PinButton } from "../../components/pin-button"; | |
| 8 | 8 | import { EmptyState, Pill, TimeAgo, notACredential } from "../../components/ui"; | |
| 9 | + | import { Checkbox } from "../../components/ui/checkbox"; | |
| 9 | 10 | import { SelectField } from "../../components/ui/select"; | |
| 10 | 11 | import { page as pageMeta } from "../../lib/meta"; | |
| 11 | 12 | import { | |
| ⋯ | |||
| 239 | 240 | const form = useRef<HTMLFormElement>(null); | |
| 240 | 241 | const searchBox = useRef<HTMLInputElement>(null); | |
| 241 | 242 | const typing = useRef<ReturnType<typeof setTimeout> | null>(null); | |
| 243 | + | const deploysBox = useRef<HTMLButtonElement>(null); | |
| 244 | + | const [deploys, setDeploys] = useState(query.deployments); | |
| 242 | 245 | const member = role != null; | |
| 243 | 246 | const pins = new Set(pinned); | |
| 244 | 247 | // A filter or page of this list on its way: the list says it is busy. | |
| ⋯ | |||
| 268 | 271 | const field = fields.namedItem(name) as HTMLInputElement | HTMLSelectElement | null; | |
| 269 | 272 | if (field && field !== document.activeElement && "value" in field) field.value = value; | |
| 270 | 273 | } | |
| 271 | − | const deploys = fields.namedItem("deployments") as HTMLInputElement | null; | |
| 272 | − | if (deploys && deploys !== document.activeElement) deploys.checked = query.deployments; | |
| 274 | + | if (deploysBox.current !== document.activeElement) setDeploys(query.deployments); | |
| 273 | 275 | }, [query]); | |
| 274 | 276 | ||
| 275 | 277 | // `/` goes to the search, from anywhere on the page that is not a field. | |
| ⋯ | |||
| 394 | 396 | )} | |
| 395 | 397 | {member && ( | |
| 396 | 398 | <label className={`${CONTROL} flex cursor-pointer items-center gap-2 select-none`}> | |
| 397 | − | <input | |
| 398 | − | type="checkbox" | |
| 399 | + | <Checkbox | |
| 400 | + | ref={deploysBox} | |
| 399 | 401 | name="deployments" | |
| 400 | 402 | value="on" | |
| 401 | − | defaultChecked={query.deployments} | |
| 402 | − | onChange={() => apply()} | |
| 403 | − | className="accent-[var(--color-accent)]" | |
| 403 | + | checked={deploys} | |
| 404 | + | onCheckedChange={(checked) => { | |
| 405 | + | setDeploys(checked === true); | |
| 406 | + | apply(); | |
| 407 | + | }} | |
| 404 | 408 | /> | |
| 405 | 409 | Deploys | |
| 406 | 410 | <span className="text-xs tabular-nums text-faint">{facets.deploying}</span> | |
| 17 | 17 | else if (block.t === "heading") walk(block.c); | |
| 18 | 18 | else if (block.t === "quote") block.c.forEach(each); | |
| 19 | 19 | else if (block.t === "list") block.items.forEach((item) => item.forEach(each)); | |
| 20 | + | else if (block.t === "table") [block.head, ...block.rows].forEach((row) => row.forEach(walk)); | |
| 20 | 21 | }; | |
| 21 | 22 | list.forEach(each); | |
| 22 | 23 | return out; | |
| ⋯ | |||
| 173 | 174 | assert.equal(plainText("x".repeat(300), 140).length, 140); | |
| 174 | 175 | assert.ok(plainText("x".repeat(300), 140).endsWith("…")); | |
| 175 | 176 | }); | |
| 177 | + | ||
| 178 | + | test("tables: a header, a --- row with its alignment, then rows", () => { | |
| 179 | + | const [table, after] = blocks("| Session | Length | Notes |\n| --- | ---: | :-: |\n| 1. Welcome | 45 min | `web` \\| `api` |\n| 2. Queue | 30 min |\n\nAfter."); | |
| 180 | + | assert.ok(table!.t === "table"); | |
| 181 | + | assert.deepEqual(table.align, [null, "right", "center"]); | |
| 182 | + | assert.deepEqual(table.head, [[{ t: "text", v: "Session" }], [{ t: "text", v: "Length" }], [{ t: "text", v: "Notes" }]]); | |
| 183 | + | assert.equal(table.rows.length, 2); | |
| 184 | + | // An escaped pipe stays in its cell; a short row is filled to the header's width. | |
| 185 | + | assert.deepEqual(table.rows[0]![2], [{ t: "code", v: "web" }, { t: "text", v: " | " }, { t: "code", v: "api" }]); | |
| 186 | + | assert.deepEqual(table.rows[1]![2], []); | |
| 187 | + | assert.equal(after!.t, "p"); | |
| 188 | + | // Without outer pipes, and right after a paragraph. | |
| 189 | + | assert.deepEqual(blocks("Plan:\na | b\n--|--\n1 | 2").map((b) => b.t), ["p", "table"]); | |
| 190 | + | // A pipe inside backticks is not a column. | |
| 191 | + | const [code] = blocks("| a |\n| - |\n| `x | y` |"); | |
| 192 | + | assert.ok(code!.t === "table" && code.rows[0]!.length === 1); | |
| 193 | + | // Not tables: a rule under text, columns that don't match, a line with a pipe alone. | |
| 194 | + | assert.deepEqual(blocks("a | b\n---").map((b) => b.t), ["p", "hr"]); | |
| 195 | + | assert.deepEqual(blocks("| a | b |\n| --- |\n| 1 | 2 |").map((b) => b.t), ["p"]); | |
| 196 | + | assert.deepEqual(blocks("either | or").map((b) => b.t), ["p"]); | |
| 197 | + | // Links in cells are checked like any other. | |
| 198 | + | assert.deepEqual(links(blocks("| x |\n| - |\n| [ok](https://x.example) [no](javascript:alert(1)) |")), ["https://x.example"]); | |
| 199 | + | assert.equal(plainText("| a | b |\n|---|---|\n| 1 | 2 |"), "a · b 1 · 2"); | |
| 200 | + | }); | |
| 7 | 7 | * | |
| 8 | 8 | * The dialect is what people and agents write in chat: bold, italic, | |
| 9 | 9 | * strikethrough (`~~x~~` or `~x~`), inline and fenced code, links and bare | |
| 10 | − | * URLs, lists (nested by indenting), quotes, headings and rules, plus what | |
| 10 | + | * URLs, lists (nested by indenting), quotes, headings, rules and tables | |
| 11 | + | * (a header row, a `---` row with optional `:` alignment, then rows), plus what | |
| 11 | 12 | * g1t adds: `@mentions`, `#channels` and `#123` references. A line break | |
| 12 | 13 | * is kept where it was typed. | |
| 13 | 14 | */ | |
| ⋯ | |||
| 29 | 30 | | { t: "code"; lang: string | null; v: string } | |
| 30 | 31 | | { t: "list"; ordered: boolean; start: number; items: Block[][] } | |
| 31 | 32 | | { t: "quote"; c: Block[] } | |
| 32 | − | | { t: "hr" }; | |
| 33 | + | | { t: "hr" } | |
| 34 | + | | { t: "table"; align: Align[]; head: Span[][]; rows: Span[][][] }; | |
| 35 | + | ||
| 36 | + | /** How a table's column lines up, from its `---` row: `:--`, `:-:` or `--:`. */ | |
| 37 | + | export type Align = "left" | "center" | "right" | null; | |
| 33 | 38 | ||
| 34 | 39 | /** Where a link may go: the web, mail, or a page on this site. */ | |
| 35 | 40 | export function safeHref(href: string): string | null { | |
| ⋯ | |||
| 152 | 157 | const HEADED = /^ {0,3}#{1,6}[ \t]+\S/; | |
| 153 | 158 | const ITEM = /^([ \t]*)([-*+•]|\d{1,9}[.)])(?:([ \t]+)(.*)|[ \t]*$)/; | |
| 154 | 159 | ||
| 160 | + | /** A table's `---` row: cells of dashes, each with an optional `:` either side. */ | |
| 161 | + | const DELIMITER = /^ {0,3}\|?[ \t]*:?-+:?[ \t]*(?:\|[ \t]*:?-+:?[ \t]*)*\|?[ \t]*$/; | |
| 162 | + | ||
| 163 | + | /** | |
| 164 | + | * A table row's cells: split on `|`, but not on `\|` or a `|` inside | |
| 165 | + | * backticks, with the pipes at either end optional. | |
| 166 | + | */ | |
| 167 | + | function cells(line: string): string[] { | |
| 168 | + | let text = line.trim(); | |
| 169 | + | if (text.startsWith("|")) text = text.slice(1); | |
| 170 | + | if (text.endsWith("|") && !text.endsWith("\\|")) text = text.slice(0, -1); | |
| 171 | + | const out: string[] = []; | |
| 172 | + | let cell = ""; | |
| 173 | + | let ticks = 0; | |
| 174 | + | for (let i = 0; i < text.length; i++) { | |
| 175 | + | const char = text[i]!; | |
| 176 | + | if (char === "\\" && text[i + 1] === "|") { | |
| 177 | + | cell += "|"; | |
| 178 | + | i++; | |
| 179 | + | } else if (char === "`") { | |
| 180 | + | let run = 1; | |
| 181 | + | while (text[i + run] === "`") run++; | |
| 182 | + | ticks = ticks === 0 ? run : ticks === run ? 0 : ticks; | |
| 183 | + | cell += "`".repeat(run); | |
| 184 | + | i += run - 1; | |
| 185 | + | } else if (char === "|" && ticks === 0) { | |
| 186 | + | out.push(cell.trim()); | |
| 187 | + | cell = ""; | |
| 188 | + | } else cell += char; | |
| 189 | + | } | |
| 190 | + | out.push(cell.trim()); | |
| 191 | + | return out; | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | /** Whether `line` and the one after it start a table: a row with a pipe, then a `---` row as wide. */ | |
| 195 | + | function tableAt(lines: string[], i: number): boolean { | |
| 196 | + | const head = lines[i]!; | |
| 197 | + | const rule = lines[i + 1]; | |
| 198 | + | if (rule == null || !head.includes("|") || !rule.includes("-") || !DELIMITER.test(rule)) return false; | |
| 199 | + | // One column needs its pipes, so a `---` under a line of text stays a paragraph and a rule. | |
| 200 | + | if (!rule.includes("|") && !head.trim().startsWith("|")) return false; | |
| 201 | + | return cells(head).length === cells(rule).length; | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | function alignOf(cell: string): Align { | |
| 205 | + | const left = cell.startsWith(":"); | |
| 206 | + | const right = cell.endsWith(":"); | |
| 207 | + | return left && right ? "center" : right ? "right" : left ? "left" : null; | |
| 208 | + | } | |
| 209 | + | ||
| 155 | 210 | function indentOf(line: string): number { | |
| 156 | 211 | let width = 0; | |
| 157 | 212 | for (const char of line) { | |
| ⋯ | |||
| 204 | 259 | i++; | |
| 205 | 260 | continue; | |
| 206 | 261 | } | |
| 262 | + | if (tableAt(lines, i)) { | |
| 263 | + | flush(); | |
| 264 | + | const align = cells(lines[i + 1]!).map(alignOf); | |
| 265 | + | const width = align.length; | |
| 266 | + | // Every row has the header's columns: cut what is beyond, fill what is missing. | |
| 267 | + | const row = (text: string) => { | |
| 268 | + | const list = cells(text).slice(0, width); | |
| 269 | + | while (list.length < width) list.push(""); | |
| 270 | + | return list.map((cell) => inline(cell)); | |
| 271 | + | }; | |
| 272 | + | const head = row(line); | |
| 273 | + | const rows: Span[][][] = []; | |
| 274 | + | i += 2; | |
| 275 | + | while (i < lines.length && lines[i]!.trim() !== "" && lines[i]!.includes("|") && !startsBlock(lines[i]!)) rows.push(row(lines[i++]!)); | |
| 276 | + | out.push({ t: "table", align, head, rows }); | |
| 277 | + | continue; | |
| 278 | + | } | |
| 207 | 279 | const fence = FENCE.exec(line); | |
| 208 | 280 | if (fence) { | |
| 209 | 281 | flush(); | |
| ⋯ | |||
| 278 | 350 | out.push({ t: "list", ordered, start, items }); | |
| 279 | 351 | continue; | |
| 280 | 352 | } | |
| 281 | − | if (paragraph.length && startsBlock(line)) flush(); | |
| 353 | + | if (paragraph.length && (startsBlock(line) || tableAt(lines, i))) flush(); | |
| 282 | 354 | paragraph.push(inline(lineText(line))); | |
| 283 | 355 | i++; | |
| 284 | 356 | } | |
| ⋯ | |||
| 286 | 358 | return out; | |
| 287 | 359 | } | |
| 288 | 360 | ||
| 289 | − | /** A message's whole text as blocks: paragraphs, headings, code, lists, quotes and rules. */ | |
| 361 | + | /** A message's whole text as blocks: paragraphs, headings, code, lists, quotes, rules and tables. */ | |
| 290 | 362 | export function blocks(text: string): Block[] { | |
| 291 | 363 | return parse(text.replace(/\r\n?/g, "\n").split("\n"), 0); | |
| 292 | 364 | } | |
| ⋯ | |||
| 342 | 414 | break; | |
| 343 | 415 | case "hr": | |
| 344 | 416 | break; | |
| 417 | + | case "table": | |
| 418 | + | for (const row of [block.head, ...block.rows]) out.push(row.map(spansText).join(" · ")); | |
| 419 | + | break; | |
| 345 | 420 | } | |
| 346 | 421 | } | |
| 347 | 422 | return out.filter((line) => line.trim() !== ""); | |