Polish: phones, copy boxes, the plan page, the landing page, a real glide
- No page is wider than a phone any more: the shell's column and every grid's items may shrink below their content, so a long command scrolls or wraps inside its box instead of widening the page. Checked on every page by measuring, not by eye. - Copy boxes wrap instead of hiding half a command or address, and keep words like --transport whole. - A plan's brief renders as Markdown, and a plan that has fully landed says so instead of 'these are open'. - The landing page has a section on bringing your own models, alerts and tickets; its footer links the new guides. - Checkboxes and radios take g1t's accent everywhere. - The settings slide moves the whole sidebar as one track over about half a second, starting gently, instead of jumping most of the way at once. - Clippy's last seven collapsible ifs.
| 341 | 341 | // Outside systems reporting to a connection. They sign what they send | |
| 342 | 342 | // with the connection's own secret, which is not a g1t token, so this | |
| 343 | 343 | // comes before anything that would read one. | |
| 344 | − | if method == "POST" && !on_mcp { | |
| 345 | − | if let Some(id) = path.strip_prefix("/hooks/").filter(|id| !id.is_empty() && !id.contains('/')) { | |
| 344 | + | if method == "POST" && !on_mcp | |
| 345 | + | && let Some(id) = path.strip_prefix("/hooks/").filter(|id| !id.is_empty() && !id.contains('/')) { | |
| 346 | 346 | return receive_hook(&mut request, &services, id).await; | |
| 347 | 347 | } | |
| 348 | − | } | |
| 349 | 348 | ||
| 350 | 349 | let viewer = match authenticate(&request, &services).await? { | |
| 351 | 350 | Ok(viewer) => viewer, |
| 62 | 62 | outline: 2px solid var(--color-accent); | |
| 63 | 63 | outline-offset: 2px; | |
| 64 | 64 | } | |
| 65 | + | ||
| 66 | + | /* | |
| 67 | + | * A grid's items may shrink below their content. Without this, one long | |
| 68 | + | * line that does not wrap (a command, a commit hash, an address) widens | |
| 69 | + | * the whole column, and on a phone the page scrolls sideways. | |
| 70 | + | */ | |
| 71 | + | .grid > * { | |
| 72 | + | min-width: 0; | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | /* Checkboxes, radios and ranges in g1t's own colour, not the browser's. */ | |
| 76 | + | :root { | |
| 77 | + | accent-color: var(--color-accent); | |
| 78 | + | } | |
| 65 | 79 | } | |
| 66 | 80 | ||
| 67 | 81 | @media (prefers-reduced-motion: reduce) { |
| 2 | 2 | import type { ReactNode } from "react"; | |
| 3 | 3 | import { Link } from "react-router"; | |
| 4 | 4 | ||
| 5 | − | import type { Repo } from "@g1t/contracts"; | |
| 5 | + | import { type Provider, PROVIDERS, type Repo } from "@g1t/contracts"; | |
| 6 | 6 | ||
| 7 | 7 | import { ConvergeArt, PlanArt, QueueArt, TeamArt, WhyArt } from "./art"; | |
| 8 | + | import { ProviderMark } from "./model-providers"; | |
| 8 | 9 | import { RepoList } from "./repo-list"; | |
| 9 | 10 | import { ButtonLink, CopyLine } from "./ui"; | |
| 10 | 11 | ||
| 79 | 80 | "REST API", | |
| 80 | 81 | "MCP server", | |
| 81 | 82 | "OAuth sign-in", | |
| 83 | + | "Any model provider", | |
| 84 | + | "Sentry, Jira, Linear", | |
| 82 | 85 | ]; | |
| 83 | 86 | ||
| 84 | 87 | const COMPARISON: [string, string, string][] = [ | |
| 91 | 94 | ["You in the loop", "Reading every diff", "Asking for changes, approving, steering; the agents do the rest"], | |
| 92 | 95 | ]; | |
| 93 | 96 | ||
| 97 | + | /** The systems g1t connects to, shown on the landing page. */ | |
| 98 | + | const STACK: Provider[] = [ | |
| 99 | + | "anthropic", | |
| 100 | + | "openai", | |
| 101 | + | "gemini", | |
| 102 | + | "xai", | |
| 103 | + | "mistral", | |
| 104 | + | "deepseek", | |
| 105 | + | "azure_openai", | |
| 106 | + | "openrouter", | |
| 107 | + | "groq", | |
| 108 | + | "sentry", | |
| 109 | + | "jira", | |
| 110 | + | "linear", | |
| 111 | + | ]; | |
| 112 | + | ||
| 113 | + | /** What happens to a production error once Sentry is connected. */ | |
| 114 | + | const LOOP = [ | |
| 115 | + | "Sentry: TypeError in checkout", | |
| 116 | + | "g1t opens an issue, with the stack trace", | |
| 117 | + | "An agent fixes it; another reviews it", | |
| 118 | + | "The merge queue lands it on main", | |
| 119 | + | "Sentry marks the error resolved", | |
| 120 | + | ]; | |
| 121 | + | ||
| 94 | 122 | const PLATFORM = ["Workers", "Artifacts", "Containers", "D1", "Queues", "AI Gateway", "Rust"]; | |
| 95 | 123 | ||
| 96 | 124 | export function Landing({ repos }: { repos: Repo[] }) { | |
| 231 | 259 | </div> | |
| 232 | 260 | </section> | |
| 233 | 261 | ||
| 262 | + | {/* Your stack */} | |
| 263 | + | <section className="mx-auto grid max-w-6xl items-center gap-12 px-4 pb-24 lg:grid-cols-2"> | |
| 264 | + | <div className="lg:order-2"> | |
| 265 | + | <Eyebrow>Your stack</Eyebrow> | |
| 266 | + | <h2 className="mt-3 text-3xl font-semibold tracking-tight text-balance"> | |
| 267 | + | Your models. Your alerts. Your tickets. | |
| 268 | + | </h2> | |
| 269 | + | <p className="mt-4 max-w-md leading-7 text-muted"> | |
| 270 | + | Run g1t's agents on g1t's models, or on your own accounts with any of the labs and | |
| 271 | + | platforms, and choose which model does which work. Keys stay with g1t: an agent's | |
| 272 | + | sandbox only ever holds a token for its own run. | |
| 273 | + | </p> | |
| 274 | + | <p className="mt-4 max-w-md leading-7 text-muted"> | |
| 275 | + | Connect Sentry and a new error becomes an issue, an agent fixes it, and Sentry hears | |
| 276 | + | it was resolved, before anyone was paged. Mention TECH-1234 and the agent reads the | |
| 277 | + | Jira ticket. | |
| 278 | + | </p> | |
| 279 | + | <div className="mt-6"> | |
| 280 | + | <ButtonLink to="https://docs.g1t.sh/guides/integrations/" variant="quiet"> | |
| 281 | + | See the integrations | |
| 282 | + | <ArrowRight size={14} /> | |
| 283 | + | </ButtonLink> | |
| 284 | + | </div> | |
| 285 | + | </div> | |
| 286 | + | <div className="space-y-6 lg:order-1"> | |
| 287 | + | <div className="grid grid-cols-3 gap-2 sm:grid-cols-4"> | |
| 288 | + | {STACK.map((provider) => ( | |
| 289 | + | <div | |
| 290 | + | key={provider} | |
| 291 | + | className="flex flex-col items-center gap-2 rounded-xl bg-surface px-2 py-3 ring-1 ring-line" | |
| 292 | + | title={PROVIDERS[provider].label} | |
| 293 | + | > | |
| 294 | + | <ProviderMark provider={provider} size={30} /> | |
| 295 | + | <span className="w-full text-center text-xs leading-tight text-muted">{PROVIDERS[provider].label}</span> | |
| 296 | + | </div> | |
| 297 | + | ))} | |
| 298 | + | </div> | |
| 299 | + | <ol className="space-y-2 font-mono text-[0.8125rem]"> | |
| 300 | + | {LOOP.map((step, index) => ( | |
| 301 | + | <li key={step} className="flex items-center gap-3 text-fg-soft"> | |
| 302 | + | <span className="flex size-6 shrink-0 items-center justify-center rounded-full bg-surface text-xs text-faint ring-1 ring-line"> | |
| 303 | + | {index + 1} | |
| 304 | + | </span> | |
| 305 | + | {step} | |
| 306 | + | </li> | |
| 307 | + | ))} | |
| 308 | + | </ol> | |
| 309 | + | </div> | |
| 310 | + | </section> | |
| 311 | + | ||
| 234 | 312 | {/* Comparison */} | |
| 235 | 313 | <section className="mx-auto max-w-6xl px-4 pb-24"> | |
| 236 | 314 | <Eyebrow>What changes</Eyebrow> |
| 244 | 244 | /** A workspace's settings pages, which the sidebar slides over to. */ | |
| 245 | 245 | const SETTINGS_PAGE = /^\/([^/]+)\/-\/(settings|people|tokens|billing|integrations)(\/|$)/; | |
| 246 | 246 | ||
| 247 | − | /** How the sidebar's two menus slide past each other. */ | |
| 248 | − | const SLIDE = "absolute inset-0 overflow-y-auto px-2 pb-4 transition-[transform,opacity] duration-300 ease-[cubic-bezier(0.32,0.72,0,1)] motion-reduce:transition-none"; | |
| 247 | + | /** | |
| 248 | + | * The sidebar's menus sit side by side on one track, and the track slides: | |
| 249 | + | * the main menu leaves to the left as the settings arrive from the right, | |
| 250 | + | * together, with a long ease-out, the way a phone pushes a screen. | |
| 251 | + | */ | |
| 252 | + | const TRACK = "flex h-full w-[200%] transition-transform duration-[550ms] ease-[cubic-bezier(0.45,0,0.15,1)] will-change-transform motion-reduce:transition-none"; | |
| 253 | + | /** One menu on the track. */ | |
| 254 | + | const PANEL = "h-full w-1/2 shrink-0 overflow-y-auto px-2 pb-4"; | |
| 249 | 255 | ||
| 250 | 256 | /** A workspace's settings, as the sidebar shows them in place of everything else. */ | |
| 251 | 257 | function SettingsMenu({ slug, owner, open }: { slug: string; owner: boolean; open: boolean }) { | |
| 253 | 259 | <nav | |
| 254 | 260 | aria-label="Workspace settings" | |
| 255 | 261 | inert={!open} | |
| 256 | − | className={`${SLIDE} ${open ? "translate-x-0 opacity-100" : "pointer-events-none translate-x-full opacity-0"}`} | |
| 262 | + | className={PANEL} | |
| 257 | 263 | > | |
| 258 | 264 | <Link | |
| 259 | 265 | to={`/${slug}`} | |
| 303 | 309 | <nav | |
| 304 | 310 | aria-label="Your settings" | |
| 305 | 311 | inert={!open} | |
| 306 | − | className={`${SLIDE} ${open ? "translate-x-0 opacity-100" : "pointer-events-none translate-x-full opacity-0"}`} | |
| 312 | + | className={PANEL} | |
| 307 | 313 | > | |
| 308 | 314 | <Link | |
| 309 | 315 | to="/" | |
| 330 | 336 | const inSettings = ws != null && SETTINGS_PAGE.exec(going ?? pathname)?.[1]?.toLowerCase() === ws.slug; | |
| 331 | 337 | const inAccount = (going ?? pathname) === "/settings"; | |
| 332 | 338 | const away = inSettings || inAccount; | |
| 339 | + | // Which settings sit on the far side of the track. Kept while sliding back, | |
| 340 | + | // so they do not vanish on the way out. | |
| 341 | + | const side = useRef<"workspace" | "account">("workspace"); | |
| 342 | + | if (inAccount) side.current = "account"; | |
| 343 | + | else if (inSettings) side.current = "workspace"; | |
| 333 | 344 | const active = shell.repo; | |
| 334 | 345 | const repoBase = active ? `/${active.namespace}/${active.name}` : null; | |
| 335 | 346 | // The repository being looked at is listed even when it is someone else's. | |
| 360 | 371 | <kbd className="rounded bg-raised px-1.5 font-mono text-[0.625rem] text-muted ring-1 ring-line">⌘K</kbd> | |
| 361 | 372 | </button> | |
| 362 | 373 | </div> | |
| 363 | − | <div className="relative min-h-0 grow overflow-hidden"> | |
| 364 | − | <nav | |
| 365 | − | aria-label="g1t" | |
| 366 | − | inert={away} | |
| 367 | − | className={`${SLIDE} ${away ? "pointer-events-none -translate-x-1/3 opacity-0" : "translate-x-0 opacity-100"}`} | |
| 368 | − | > | |
| 374 | + | <div className="min-h-0 grow overflow-hidden"> | |
| 375 | + | <div className={`${TRACK} ${away ? "-translate-x-1/2" : "translate-x-0"}`}> | |
| 376 | + | <nav aria-label="g1t" inert={away} className={PANEL}> | |
| 369 | 377 | <div className="mt-3 space-y-px"> | |
| 370 | 378 | <SidebarLink to="/" end icon={<LayoutDashboard size={15} />}> | |
| 371 | 379 | Mission control | |
| 475 | 483 | })} | |
| 476 | 484 | </SidebarGroup> | |
| 477 | 485 | </nav> | |
| 478 | − | {ws && <SettingsMenu slug={ws.slug} owner={ws.role === "owner"} open={inSettings} />} | |
| 479 | − | <AccountSettingsMenu open={inAccount} /> | |
| 486 | + | {side.current === "account" || !ws ? ( | |
| 487 | + | <AccountSettingsMenu open={inAccount} /> | |
| 488 | + | ) : ( | |
| 489 | + | <SettingsMenu slug={ws.slug} owner={ws.role === "owner"} open={inSettings} /> | |
| 490 | + | )} | |
| 491 | + | </div> | |
| 480 | 492 | </div> | |
| 481 | 493 | ||
| 482 | 494 | <div className="space-y-2 p-2"> | |
| 805 | 817 | </div> | |
| 806 | 818 | )} | |
| 807 | 819 | ||
| 808 | − | <div className="flex min-h-screen flex-col lg:pl-64"> | |
| 820 | + | <div className="flex min-h-screen min-w-0 flex-col lg:pl-64"> | |
| 809 | 821 | <header className="sticky top-0 z-30 flex h-12 items-center gap-2 border-b border-line bg-bg/85 px-3 backdrop-blur sm:px-4"> | |
| 810 | 822 | <button | |
| 811 | 823 | type="button" | |
| 865 | 877 | </div> | |
| 866 | 878 | </header> | |
| 867 | 879 | {banner} | |
| 868 | − | <main className="grow">{children}</main> | |
| 880 | + | <main className="min-w-0 grow">{children}</main> | |
| 869 | 881 | </div> | |
| 870 | 882 | <CommandPalette open={palette} onClose={() => setPalette(false)} commands={commands} /> | |
| 871 | 883 | </div> |
| 210 | 210 | const [copied, setCopied] = useState(false); | |
| 211 | 211 | return ( | |
| 212 | 212 | <div className="group flex items-center gap-3 rounded-lg border border-line bg-surface py-2 pr-2 pl-3.5 font-mono text-[0.8125rem]"> | |
| 213 | − | <code className="min-w-0 grow overflow-x-auto whitespace-nowrap"> | |
| 213 | + | {/* Wraps rather than hides: a command or an address is no use half seen. */} | |
| 214 | + | <code className="min-w-0 grow whitespace-pre-wrap [overflow-wrap:anywhere]"> | |
| 214 | 215 | {prompt && <span className="mr-2 text-faint select-none">$</span>} | |
| 215 | − | {text} | |
| 216 | + | {/* Short words with hyphens, such as `--transport`, stay whole when | |
| 217 | + | the line wraps; long ones, such as addresses, may still break. */} | |
| 218 | + | {text.split(/(\s+)/).map((part, index) => | |
| 219 | + | part.includes("-") && part.length <= 24 ? ( | |
| 220 | + | <span key={index} className="whitespace-nowrap"> | |
| 221 | + | {part} | |
| 222 | + | </span> | |
| 223 | + | ) : ( | |
| 224 | + | part | |
| 225 | + | ), | |
| 226 | + | )} | |
| 216 | 227 | </code> | |
| 217 | 228 | <button | |
| 218 | 229 | type="button" |
| 252 | 252 | ["Explore repositories", "/explore"], | |
| 253 | 253 | ["g1t agents", "https://docs.g1t.sh/guides/g1t-agents/"], | |
| 254 | 254 | ["Bring your own agent", "https://docs.g1t.sh/guides/bring-your-own-agent/"], | |
| 255 | + | ["Integrations", "https://docs.g1t.sh/guides/integrations/"], | |
| 256 | + | ["Model providers", "https://docs.g1t.sh/guides/models/"], | |
| 255 | 257 | ["Sign up", "/register"], | |
| 256 | 258 | ], | |
| 257 | 259 | }, | |
| 259 | 261 | title: "Developers", | |
| 260 | 262 | links: [ | |
| 261 | 263 | ["Quickstart", "https://docs.g1t.sh/quickstart/"], | |
| 262 | − | ["Concepts", "https://docs.g1t.sh/concepts/overview/"], | |
| 264 | + | ["How g1t works", "https://docs.g1t.sh/concepts/overview/"], | |
| 263 | 265 | ["API reference", "https://docs.g1t.sh/api/reference/"], | |
| 264 | 266 | ["OpenAPI", "https://api.g1t.sh/openapi.json"], | |
| 265 | 267 | ["llms.txt", "/llms.txt"], |
| 112 | 112 | </Link> | |
| 113 | 113 | </p> | |
| 114 | 114 | <h2 className="mt-2 text-xl font-semibold tracking-tight">The outcome</h2> | |
| 115 | − | <div className="mt-3 rounded-xl border border-line bg-surface p-5 text-sm whitespace-pre-wrap"> | |
| 116 | − | {plan.brief} | |
| 115 | + | <div className="mt-3 rounded-xl border border-line bg-surface p-5 text-sm"> | |
| 116 | + | <Markdown source={plan.brief} repo={{ namespace: params.owner, name: params.repo }} /> | |
| 117 | 117 | </div> | |
| 118 | 118 | <p className="mt-2 text-xs text-faint"> | |
| 119 | 119 | Asked for by {plan.author.username} <TimeAgo at={plan.createdAt} /> | |
| 278 | 278 | </span> | |
| 279 | 279 | </div> | |
| 280 | 280 | )} | |
| 281 | − | {plan.status === "applied" && ( | |
| 281 | + | {plan.status === "applied" && !moving && ( | |
| 282 | + | <p className="mt-6 text-sm text-muted">Everything in this plan has landed.</p> | |
| 283 | + | )} | |
| 284 | + | {plan.status === "applied" && moving && ( | |
| 282 | 285 | <p className="mt-6 text-sm text-muted"> | |
| 283 | − | These are open. Follow them under{" "} | |
| 286 | + | Some are still open. Follow them under{" "} | |
| 284 | 287 | <Link to={`${base}/issues`} className="text-fg underline underline-offset-4"> | |
| 285 | 288 | Issues | |
| 286 | 289 | </Link>{" "} |
| 95 | 95 | let (mut from_ssh, mut to_ssh) = tokio::io::split(theirs); | |
| 96 | 96 | tokio::spawn(async move { | |
| 97 | 97 | while let Some(Ok(message)) = source.next().await { | |
| 98 | − | if let Message::Binary(bytes) = message { | |
| 99 | − | if to_ssh.write_all(&bytes).await.is_err() { | |
| 98 | + | if let Message::Binary(bytes) = message | |
| 99 | + | && to_ssh.write_all(&bytes).await.is_err() { | |
| 100 | 100 | break; | |
| 101 | 101 | } | |
| 102 | − | } | |
| 103 | 102 | } | |
| 104 | 103 | let _ = to_ssh.shutdown().await; | |
| 105 | 104 | }); |
| 76 | 76 | let diff = TextDiff::from_lines(&before, &after); | |
| 77 | 77 | let mut map = vec![None; new.len()]; | |
| 78 | 78 | for change in diff.iter_all_changes() { | |
| 79 | − | if change.tag() == ChangeTag::Equal { | |
| 80 | − | if let (Some(o), Some(n)) = (change.old_index(), change.new_index()) { | |
| 81 | − | if let Some(slot) = map.get_mut(n) { | |
| 79 | + | if change.tag() == ChangeTag::Equal | |
| 80 | + | && let (Some(o), Some(n)) = (change.old_index(), change.new_index()) | |
| 81 | + | && let Some(slot) = map.get_mut(n) { | |
| 82 | 82 | *slot = Some(o); | |
| 83 | 83 | } | |
| 84 | − | } | |
| 85 | − | } | |
| 86 | 84 | } | |
| 87 | 85 | map | |
| 88 | 86 | } | |
| 189 | 187 | } | |
| 190 | 188 | let mut commits: Vec<Commit> = Vec::new(); | |
| 191 | 189 | for range in &ranges { | |
| 192 | − | if !commits.iter().any(|commit| commit.hash == range.commit) { | |
| 193 | − | if let Some(commit) = by_hash.get(range.commit.as_str()) { | |
| 190 | + | if !commits.iter().any(|commit| commit.hash == range.commit) | |
| 191 | + | && let Some(commit) = by_hash.get(range.commit.as_str()) { | |
| 194 | 192 | commits.push((*commit).clone()); | |
| 195 | 193 | } | |
| 196 | − | } | |
| 197 | 194 | } | |
| 198 | 195 | Ok(Some(Blame { | |
| 199 | 196 | head: first.hash.clone(), |
| 235 | 235 | } | |
| 236 | 236 | ||
| 237 | 237 | async fn read_tree(&self, tree_hash: &str) -> Result<Option<Vec<TreeEntry>>> { | |
| 238 | − | if let Some(bytes) = self.cached("tree", tree_hash).await { | |
| 239 | − | if let Ok(entries) = serde_json::from_slice::<Vec<TreeEntry>>(&bytes) { | |
| 238 | + | if let Some(bytes) = self.cached("tree", tree_hash).await | |
| 239 | + | && let Ok(entries) = serde_json::from_slice::<Vec<TreeEntry>>(&bytes) { | |
| 240 | 240 | return Ok(Some(entries)); | |
| 241 | 241 | } | |
| 242 | − | } | |
| 243 | 242 | let entries: Option<Vec<RawEntry>> = | |
| 244 | 243 | js::from_js(&js::call(&self.handle, "readTree", &[tree_hash.into()]).await?)?; | |
| 245 | 244 | let entries: Option<Vec<TreeEntry>> = entries.map(|entries| { | |
| 252 | 251 | }) | |
| 253 | 252 | .collect() | |
| 254 | 253 | }); | |
| 255 | − | if let Some(entries) = &entries { | |
| 256 | − | if let Ok(bytes) = serde_json::to_vec(entries) { | |
| 254 | + | if let Some(entries) = &entries | |
| 255 | + | && let Ok(bytes) = serde_json::to_vec(entries) { | |
| 257 | 256 | self.keep("tree", tree_hash, bytes).await; | |
| 258 | 257 | } | |
| 259 | − | } | |
| 260 | 258 | Ok(entries) | |
| 261 | 259 | } | |
| 262 | 260 |