Skip to content
137 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Chat and workspace agents: channels, DMs and named agents you talk to1import { Fragment, type ReactNode, useMemo } from "react";
2import { Link } from "react-router";
3
4import { type Block, type Span, blocks, onlyEmoji } from "../../lib/chat";
5
6/** How the text's references resolve, from what the page knows. */
7export type TextContext = {
8 slug: string;
9 /** The viewer's username: a mention of them stands out more. */
10 me: string | null;
11 /** Handles of the workspace's agents: their mentions go to the agent. */
12 agents: ReadonlySet<string>;
13 /** Channels by name, for `#name`. */
14 channels: ReadonlySet<string>;
15 /** The project `#123` means when no repository is named, if any. */
16 project: string | null;
17 /** Where a link into Code goes instead, for a member without Code access. */
18 codeLink?: (href: string) => string;
19};
20
21const PILL = "rounded-[4px] px-1 py-px font-medium";
22
23function SpanView({ span, context }: { span: Span; context: TextContext }): ReactNode {
24 switch (span.t) {
25 case "text":
26 return span.v;
27 case "code":
28 return <code className="rounded-[4px] border border-line bg-raised px-1 py-px font-mono text-[0.84em] text-fg-soft">{span.v}</code>;
29 case "strong":
30 return <strong className="font-semibold text-fg">{spans(span.c, context)}</strong>;
31 case "em":
32 return <em>{spans(span.c, context)}</em>;
33 case "del":
34 return <del className="text-muted">{spans(span.c, context)}</del>;
35 case "link": {
36 if (span.href.startsWith("/")) {
37 return (
38 <Link to={context.codeLink ? context.codeLink(span.href) : span.href} className="text-accent underline-offset-2 hover:underline">
39 {spans(span.c, context)}
40 </Link>
41 );
42 }
43 return (
44 <a href={span.href} target="_blank" rel="noopener noreferrer nofollow ugc" className="text-accent underline-offset-2 hover:underline">
45 {spans(span.c, context)}
46 </a>
47 );
48 }
49 case "mention": {
50 const name = span.name.toLowerCase();
51 const self = context.me != null && name === context.me.toLowerCase();
52 const agent = context.agents.has(name);
53 const to = agent ? `/${context.slug}/-/agents/${name}` : name.includes("/") ? `/${context.slug}/-/teams/${name.split("/")[1]}` : `/u/${name}`;
54 return (
55 <Link
56 to={to}
57 className={`${PILL} transition-colors ${
58 self ? "bg-warn/20 text-warn hover:bg-warn/30" : "bg-accent/15 text-accent hover:bg-accent/25"
59 }`}
60 >
61 @{span.name}
62 </Link>
63 );
64 }
65 case "channel":
66 return context.channels.has(span.name) ? (
67 <Link to={`/${context.slug}/-/chat/${span.name}`} className={`${PILL} bg-accent/10 text-accent hover:bg-accent/20`}>
68 #{span.name}
69 </Link>
70 ) : (
71 `#${span.name}`
72 );
73 case "ref": {
74 const repo = span.repo ? (span.repo.includes("/") ? span.repo : `${context.slug}/${span.repo}`) : context.project ? `${context.slug}/${context.project}` : null;
75 const label = `${span.repo ? span.repo : ""}#${span.number}`;
76 if (!repo) return <span className="font-medium text-fg-soft">{label}</span>;
77 // Issues and pull requests share numbers; the issue page sends a pull on.
78 const href = `/${repo}/issues/${span.number}`;
79 return (
80 <Link to={context.codeLink ? context.codeLink(href) : href} className="font-medium text-accent hover:underline">
81 {label}
82 </Link>
83 );
84 }
85 }
86}
87
88function spans(list: Span[], context: TextContext): ReactNode {
89 return list.map((span, index) => <SpanView key={index} span={span} context={context} />);
90}
91
92function lines(list: Span[][], context: TextContext): ReactNode {
93 return list.map((line, index) => (
94 <Fragment key={index}>
95 {index > 0 && <br />}
96 {spans(line, context)}
97 </Fragment>
98 ));
99}
100
101function BlockView({ block, context }: { block: Block; context: TextContext }) {
102 switch (block.t) {
103 case "p":
104 return <p>{lines(block.lines, context)}</p>;
105 case "code":
106 return (
107 <pre className="my-1 overflow-x-auto rounded-md border border-line bg-bg px-3 py-2 font-mono text-[0.8125rem] leading-relaxed text-fg-soft [scrollbar-width:thin]">
108 <code>{block.v}</code>
109 </pre>
110 );
111 case "list": {
112 const Tag = block.ordered ? "ol" : "ul";
113 return (
114 <Tag className={`my-0.5 space-y-0.5 pl-5 ${block.ordered ? "list-decimal" : "list-disc"} marker:text-faint`}>
115 {block.items.map((item, index) => (
116 <li key={index}>{spans(item, context)}</li>
117 ))}
118 </Tag>
119 );
120 }
121 case "quote":
122 return <blockquote className="border-l-2 border-line-strong pl-3 text-muted">{lines(block.lines, context)}</blockquote>;
123 }
124}
125
126/** A message's text, rendered from parsed blocks: never as HTML. */
127export function MessageText({ body, context }: { body: string; context: TextContext }) {
128 const parsed = useMemo(() => blocks(body), [body]);
129 if (onlyEmoji(body)) return <p className="text-3xl leading-tight">{body.trim()}</p>;
130 return (
131 <div className="space-y-1.5 text-[0.9375rem] leading-[1.55] break-words text-fg-soft">
132 {parsed.map((block, index) => (
133 <BlockView key={index} block={block} context={context} />
134 ))}
135 </div>
136 );
137}