Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

102 linesCodeBlameRaw
1/**
2 * Chat's cards for artifact links (docs.g1t.sh/guides/artifacts/, "Links
3 * in chat"): each viewer asks the site for their own, so someone who can
4 * open the artifact sees its kind, title, space and last edit, and anyone
5 * else sees only "An artifact you don't have access to", with no title.
6 * Looking is not opening: a card never makes a link-shared artifact readable.
7 */
8import type { FolioKind, Result } from "@g1t/contracts";
9import { Lock } from "lucide-react";
10import { useEffect, useMemo, useState } from "react";
11import { Link } from "react-router";
12
13import { linkedArtifacts } from "../../lib/folios";
14import { TimeAgo } from "../ui";
15import { Card } from "../ui/card";
16import { KindIcon } from "./kinds";
17
18/** What the site answers for one link (routes/workspace/folios/api.ts `unfurl`); null: not for this viewer. */
19type Card = {
20 id: string;
21 kind: FolioKind;
22 title: string;
23 icon: string | null;
24 path: string;
25 space: string | null;
26 private: boolean;
27 excerpt: string;
28 edited_at: string;
29 edited_by: string | null;
30} | null;
31
32/** The most cards one message shows. */
33const MAX_CARDS = 3;
34/** How long a card is kept in this tab before it is asked for again. */
35const KEEP_MS = 60_000;
36
37const cards = new Map<string, { at: number; card: Promise<Card | undefined> }>();
38
39function cardFor(slug: string, id: string): Promise<Card | undefined> {
40 const key = `${slug}/${id}`;
41 const kept = cards.get(key);
42 if (kept && Date.now() - kept.at < KEEP_MS) return kept.card;
43 const card = fetch(`/${slug}/-/artifacts/api?unfurl=${encodeURIComponent(id)}`, { headers: { accept: "application/json" } })
44 .then((r) => r.json() as Promise<Result<Card>>)
45 .then((r) => (r.ok ? r.value : undefined))
46 // Not answered: no card at all, rather than a wrong one.
47 .catch(() => undefined);
48 cards.set(key, { at: Date.now(), card });
49 return card;
50}
51
52export function FolioUnfurls({ slug, body }: { slug: string; body: string }) {
53 const ids = useMemo(() => linkedArtifacts(body, slug).slice(0, MAX_CARDS), [body, slug]);
54 if (!ids.length) return null;
55 return (
56 <div className="mt-1.5 flex max-w-md flex-col gap-1.5">
57 {ids.map((id) => (
58 <Unfurl key={id} slug={slug} id={id} />
59 ))}
60 </div>
61 );
62}
63
64function Unfurl({ slug, id }: { slug: string; id: string }) {
65 const [card, setCard] = useState<Card | undefined | "loading">("loading");
66 useEffect(() => {
67 let live = true;
68 void cardFor(slug, id).then((c) => live && setCard(c));
69 return () => {
70 live = false;
71 };
72 }, [slug, id]);
73 if (card === "loading" || card === undefined) return null;
74 if (card === null) {
75 return (
76 <Card radius="lg" className="flex items-center gap-2.5 px-3 py-2 text-xs text-faint">
77 <Lock size={14} className="shrink-0" />
78 An artifact you don&apos;t have access to
79 </Card>
80 );
81 }
82 return (
83 <Link
84 to={card.path}
85 className="group flex items-start gap-2.5 rounded-lg border border-line bg-surface px-3 py-2 transition-colors hover:border-line-strong"
86 >
87 <KindIcon kind={card.kind} size={14} box={26} />
88 <span className="min-w-0 grow">
89 <span className="block truncate text-sm font-medium text-fg group-hover:text-accent">
90 {card.icon && <span className="mr-1">{card.icon}</span>}
91 {card.title || "Untitled"}
92 </span>
93 {card.excerpt && <span className="mt-0.5 line-clamp-2 text-xs text-muted">{card.excerpt}</span>}
94 <span className="mt-0.5 flex items-center gap-1 text-[0.6875rem] text-faint">
95 {card.private && <Lock size={10} aria-label="Only you can see this" />}
96 {card.space ?? "Private"} · Edited <TimeAgo at={card.edited_at} />
97 {card.edited_by ? ` by ${card.edited_by}` : ""}
98 </span>
99 </span>
100 </Link>
101 );
102}