@skillcdn/console 0.1.5 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/README.md +50 -2
  2. package/SKILLCDN.md +29 -0
  3. package/dist/cli/cli.d.ts +3 -0
  4. package/dist/cli/cli.d.ts.map +1 -1
  5. package/dist/cli/cli.js +61 -0
  6. package/dist/cli/cli.js.map +1 -1
  7. package/dist/cli/help.d.ts.map +1 -1
  8. package/dist/cli/help.js +5 -0
  9. package/dist/cli/help.js.map +1 -1
  10. package/dist/cli/main.js +2 -0
  11. package/dist/cli/main.js.map +1 -1
  12. package/dist/cli/serve.d.ts +37 -0
  13. package/dist/cli/serve.d.ts.map +1 -0
  14. package/dist/cli/serve.js +218 -0
  15. package/dist/cli/serve.js.map +1 -0
  16. package/dist/console.css +33 -10
  17. package/dist/console.d.ts +6 -0
  18. package/dist/console.d.ts.map +1 -1
  19. package/dist/console.js +78 -13
  20. package/dist/console.js.map +1 -1
  21. package/dist/data.d.ts.map +1 -1
  22. package/dist/data.js +5 -1
  23. package/dist/data.js.map +1 -1
  24. package/dist/i18n/en.d.ts +4 -0
  25. package/dist/i18n/en.d.ts.map +1 -1
  26. package/dist/i18n/en.js +4 -0
  27. package/dist/i18n/en.js.map +1 -1
  28. package/dist/i18n/ko.d.ts.map +1 -1
  29. package/dist/i18n/ko.js +4 -0
  30. package/dist/i18n/ko.js.map +1 -1
  31. package/dist/schemas.d.ts +1 -0
  32. package/dist/schemas.d.ts.map +1 -1
  33. package/dist/schemas.js +5 -0
  34. package/dist/schemas.js.map +1 -1
  35. package/dist/web.d.ts +16 -0
  36. package/dist/web.d.ts.map +1 -0
  37. package/dist/web.js +149 -0
  38. package/dist/web.js.map +1 -0
  39. package/docs/specs/cli.md +92 -0
  40. package/docs/specs/rest.md +220 -0
  41. package/package.json +14 -3
  42. package/skills/building-a-console/SKILL.md +79 -0
  43. package/skills/working-the-board/SKILL.md +78 -0
  44. package/src/api.ts +235 -0
  45. package/src/cli/cli.ts +1114 -0
  46. package/src/cli/credentials.ts +68 -0
  47. package/src/cli/directory.ts +59 -0
  48. package/src/cli/format.ts +284 -0
  49. package/src/cli/help.ts +326 -0
  50. package/src/cli/main.ts +39 -0
  51. package/src/cli/serve.ts +276 -0
  52. package/src/cli/terminal.ts +28 -0
  53. package/src/client.ts +533 -0
  54. package/src/components/board.tsx +112 -0
  55. package/src/components/connect.tsx +126 -0
  56. package/src/components/decision-form.tsx +136 -0
  57. package/src/components/decision-list.tsx +278 -0
  58. package/src/components/documents.tsx +569 -0
  59. package/src/components/event-feed.tsx +129 -0
  60. package/src/components/markdown.tsx +72 -0
  61. package/src/components/people.tsx +59 -0
  62. package/src/components/projects.tsx +364 -0
  63. package/src/components/runs.tsx +175 -0
  64. package/src/components/shell.tsx +284 -0
  65. package/src/components/sign-in.tsx +61 -0
  66. package/src/components/skills.tsx +78 -0
  67. package/src/components/task-form.tsx +229 -0
  68. package/src/components/task-view.tsx +272 -0
  69. package/src/components/tokens.tsx +223 -0
  70. package/src/components/ui.tsx +219 -0
  71. package/src/connect-page.tsx +148 -0
  72. package/src/connect.ts +27 -0
  73. package/src/console.tsx +1166 -0
  74. package/src/data.ts +493 -0
  75. package/src/docs-page.tsx +334 -0
  76. package/src/documents.ts +76 -0
  77. package/src/i18n/en.ts +537 -0
  78. package/src/i18n/index.ts +76 -0
  79. package/src/i18n/ko.ts +511 -0
  80. package/src/index.ts +142 -0
  81. package/src/limits.ts +61 -0
  82. package/src/router.ts +295 -0
  83. package/src/routes.ts +106 -0
  84. package/src/schemas.ts +776 -0
  85. package/src/styles/console.css +2432 -0
  86. package/src/text.ts +33 -0
  87. package/src/use-action.ts +37 -0
  88. package/src/vocabulary.ts +109 -0
  89. package/src/web.ts +170 -0
@@ -0,0 +1,72 @@
1
+ import ReactMarkdown from "react-markdown";
2
+ import remarkGfm from "remark-gfm";
3
+ import { isDocumentPath } from "../documents.js";
4
+
5
+ // What people and agents write is untrusted. It is rendered as React elements, never as HTML:
6
+ // raw HTML in the source stays text, links are limited to a few schemes and open elsewhere,
7
+ // and pictures come over https only, lazily and without a referrer. A link whose destination
8
+ // is a document's path (ADR-0009) leads to the document's page, when the page knows where.
9
+
10
+ const SAFE_LINK = /^(https?:|mailto:)/i;
11
+ const SAFE_IMAGE = /^https:\/\//i;
12
+
13
+ export interface MarkdownProps {
14
+ readonly source: string;
15
+ /** Where a document of the project is read, by its path; left out, such links stay text. */
16
+ readonly docHref?: ((path: string) => string) | undefined;
17
+ }
18
+
19
+ export function Markdown(props: MarkdownProps) {
20
+ return (
21
+ <div className="sc-prose">
22
+ <ReactMarkdown
23
+ remarkPlugins={[remarkGfm]}
24
+ // Everything passes through here unchanged; the components below decide what a URL may do.
25
+ urlTransform={(url) => url}
26
+ components={{
27
+ a({ href, children }) {
28
+ if (href === undefined || href === "") {
29
+ return <span>{children}</span>;
30
+ }
31
+ if (href.startsWith("#")) {
32
+ return <a href={href}>{children}</a>;
33
+ }
34
+ const hash = href.indexOf("#");
35
+ const path = hash < 0 ? href : href.slice(0, hash);
36
+ if (props.docHref !== undefined && isDocumentPath(path)) {
37
+ return (
38
+ <a href={`${props.docHref(path)}${hash < 0 ? "" : href.slice(hash)}`}>{children}</a>
39
+ );
40
+ }
41
+ if (SAFE_LINK.test(href)) {
42
+ return (
43
+ <a href={href} target="_blank" rel="noopener noreferrer nofollow ugc">
44
+ {children}
45
+ </a>
46
+ );
47
+ }
48
+ return <span>{children}</span>;
49
+ },
50
+ img({ src, alt, title }) {
51
+ const url = typeof src === "string" && SAFE_IMAGE.test(src) ? src : undefined;
52
+ if (url === undefined) {
53
+ return <span className="sc-prose-image-text">{alt ?? ""}</span>;
54
+ }
55
+ return (
56
+ <img
57
+ src={url}
58
+ alt={alt ?? ""}
59
+ title={title}
60
+ loading="lazy"
61
+ decoding="async"
62
+ referrerPolicy="no-referrer"
63
+ />
64
+ );
65
+ },
66
+ }}
67
+ >
68
+ {props.source}
69
+ </ReactMarkdown>
70
+ </div>
71
+ );
72
+ }
@@ -0,0 +1,59 @@
1
+ import type { PersonRole, RestPerson } from "../api.js";
2
+ import { useWords } from "../i18n/index.js";
3
+ import { PERSON_ROLES } from "../vocabulary.js";
4
+ import { PersonChip, RoleBadge } from "./ui.js";
5
+
6
+ // The people of the board, with what each may do. An administrator changes roles here; a
7
+ // member reads. Takes its data as props and nothing from the network.
8
+
9
+ export interface PeopleListProps {
10
+ readonly people: readonly RestPerson[];
11
+ /** Whoever is looking, so that their own row says so. */
12
+ readonly me?: RestPerson | undefined;
13
+ /** Called with the role an administrator chose. Left out, roles are read here, not changed. */
14
+ readonly onChangeRole?: ((person: RestPerson, role: PersonRole) => void) | undefined;
15
+ /** Where a person's agents are seen; given to an administrator. */
16
+ readonly agentsHref?: ((person: RestPerson) => string) | undefined;
17
+ readonly busy?: boolean | undefined;
18
+ }
19
+
20
+ export function PeopleList(props: PeopleListProps) {
21
+ const words = useWords();
22
+ return (
23
+ <ul className="sc-people" aria-label={words.people.list}>
24
+ {props.people.map((person) => (
25
+ <li key={person.id} className="sc-person-row">
26
+ <PersonChip person={person} size="md" />
27
+ <span className="sc-person-name">
28
+ {person.name ?? ""}
29
+ {props.me?.id === person.id && <span className="sc-muted"> {words.common.you}</span>}
30
+ </span>
31
+ {props.onChangeRole === undefined ? (
32
+ <RoleBadge role={person.role} />
33
+ ) : (
34
+ <label className="sc-field sc-field-inline">
35
+ <span className="sc-visually-hidden">{words.people.roleOf(person.login)}</span>
36
+ <select
37
+ className="sc-input"
38
+ value={person.role}
39
+ disabled={props.busy === true}
40
+ onChange={(event) => props.onChangeRole?.(person, event.target.value as PersonRole)}
41
+ >
42
+ {PERSON_ROLES.map((role) => (
43
+ <option key={role} value={role}>
44
+ {words.vocabulary.role[role]}
45
+ </option>
46
+ ))}
47
+ </select>
48
+ </label>
49
+ )}
50
+ {props.agentsHref !== undefined && (
51
+ <a className="sc-button sc-button-ghost sc-button-sm" href={props.agentsHref(person)}>
52
+ {words.people.agents}
53
+ </a>
54
+ )}
55
+ </li>
56
+ ))}
57
+ </ul>
58
+ );
59
+ }
@@ -0,0 +1,364 @@
1
+ import { type FormEvent, type ReactNode, useState } from "react";
2
+ import {
3
+ MAX_PROJECT_DESCRIPTION_LENGTH,
4
+ MAX_PROJECT_KEY_LENGTH,
5
+ MAX_PROJECT_NAME_LENGTH,
6
+ MAX_SKILLS_ADDRESS_LENGTH,
7
+ PROJECT_ROLES,
8
+ PROJECT_VISIBILITIES,
9
+ type ProjectRole,
10
+ type ProjectVisibility,
11
+ type RestMember,
12
+ type RestMemberInput,
13
+ type RestPerson,
14
+ type RestProject,
15
+ type RestProjectInput,
16
+ type RestProjectPatch,
17
+ } from "../api.js";
18
+ import { useWords } from "../i18n/index.js";
19
+ import { Badge, Button, PersonChip, Time } from "./ui.js";
20
+
21
+ // The projects of the workspace (ADR-0008): the list a person may see, the way to make one,
22
+ // a project's settings for its owners, and those listed in it. Each takes its data as props
23
+ // and nothing from the network; what people wrote is shown as text, never as HTML.
24
+
25
+ /** The words of a project's roles and visibilities in English: the default pack's. */
26
+ export const PROJECT_ROLE_LABELS: Readonly<Record<ProjectRole, string>> = {
27
+ owner: "Owner",
28
+ member: "Member",
29
+ };
30
+
31
+ export const VISIBILITY_LABELS: Readonly<Record<ProjectVisibility, string>> = {
32
+ workspace: "Everyone of the workspace",
33
+ private: "Only those listed",
34
+ };
35
+
36
+ export function ProjectRoleBadge(props: { readonly role: ProjectRole }) {
37
+ const words = useWords();
38
+ return (
39
+ <Badge tone={props.role === "owner" ? "point" : "neutral"}>
40
+ {words.vocabulary.projectRole[props.role]}
41
+ </Badge>
42
+ );
43
+ }
44
+
45
+ export interface ProjectListProps {
46
+ readonly projects: readonly RestProject[];
47
+ /** The link to a project's board, as the composition routes it. */
48
+ readonly projectHref: (project: RestProject) => string;
49
+ readonly onOpen: (project: RestProject) => void;
50
+ readonly empty?: ReactNode;
51
+ }
52
+
53
+ export function ProjectList(props: ProjectListProps) {
54
+ const words = useWords().projects;
55
+ if (props.projects.length === 0) {
56
+ return <>{props.empty ?? null}</>;
57
+ }
58
+ return (
59
+ <ul className="sc-projects" aria-label={words.list}>
60
+ {props.projects.map((project) => (
61
+ <li key={project.id} className="sc-project">
62
+ <div className="sc-project-head">
63
+ <a
64
+ className="sc-project-name"
65
+ href={props.projectHref(project)}
66
+ onClick={(event) => {
67
+ if (event.button === 0 && !event.metaKey && !event.ctrlKey && !event.shiftKey) {
68
+ event.preventDefault();
69
+ props.onOpen(project);
70
+ }
71
+ }}
72
+ >
73
+ {project.name}
74
+ </a>
75
+ <code className="sc-project-key">{project.key}</code>
76
+ <ProjectRoleBadge role={project.role} />
77
+ {project.visibility === "private" && <Badge tone="neutral">{words.private}</Badge>}
78
+ </div>
79
+ {project.description.length > 0 && (
80
+ <p className="sc-project-description">{project.description}</p>
81
+ )}
82
+ <p className="sc-project-meta">
83
+ {project.openDecisions > 0 && (
84
+ <Badge tone="warning">{words.decisionsWaiting(project.openDecisions)}</Badge>
85
+ )}
86
+ {project.openRuns > 0 && (
87
+ <Badge tone="accent">{words.agentsAtWork(project.openRuns)}</Badge>
88
+ )}
89
+ </p>
90
+ </li>
91
+ ))}
92
+ </ul>
93
+ );
94
+ }
95
+
96
+ export interface ProjectFormProps {
97
+ /** The project to change; left out, the form makes a new one, with a key. */
98
+ readonly project?: RestProject | undefined;
99
+ readonly busy?: boolean | undefined;
100
+ readonly error?: string | undefined;
101
+ readonly onSubmit: (input: RestProjectInput | RestProjectPatch) => void;
102
+ readonly onCancel?: (() => void) | undefined;
103
+ }
104
+
105
+ /** A key as a person types a name: lowercase letters, digits and hyphens, from what they typed. */
106
+ export function keyOf(name: string): string {
107
+ return name
108
+ .toLowerCase()
109
+ .replaceAll(/[^a-z0-9]+/g, "-")
110
+ .replaceAll(/^-+|-+$/g, "")
111
+ .slice(0, MAX_PROJECT_KEY_LENGTH);
112
+ }
113
+
114
+ export function ProjectForm(props: ProjectFormProps) {
115
+ const words = useWords();
116
+ const form = words.projects.form;
117
+ const { project } = props;
118
+ const [name, setName] = useState(project?.name ?? "");
119
+ const [key, setKey] = useState(project?.key ?? "");
120
+ const [keyTouched, setKeyTouched] = useState(project !== undefined);
121
+ const [description, setDescription] = useState(project?.description ?? "");
122
+ const [visibility, setVisibility] = useState<ProjectVisibility>(project?.visibility ?? "private");
123
+ const [skillsAddress, setSkillsAddress] = useState(project?.skillsAddress ?? "");
124
+
125
+ const submit = (event: FormEvent) => {
126
+ event.preventDefault();
127
+ const settings: RestProjectPatch = {
128
+ name: name.trim(),
129
+ description: description.trim(),
130
+ visibility,
131
+ skillsAddress: skillsAddress.trim() === "" ? null : skillsAddress.trim(),
132
+ };
133
+ props.onSubmit(
134
+ project === undefined ? { key: key.trim(), ...settings, name: name.trim() } : settings,
135
+ );
136
+ };
137
+
138
+ return (
139
+ <form className="sc-form" onSubmit={submit}>
140
+ <div className="sc-field-row">
141
+ <label className="sc-field">
142
+ <span className="sc-field-label">{form.name}</span>
143
+ <input
144
+ className="sc-input"
145
+ value={name}
146
+ maxLength={MAX_PROJECT_NAME_LENGTH}
147
+ required
148
+ onChange={(event) => {
149
+ setName(event.target.value);
150
+ if (!keyTouched) {
151
+ setKey(keyOf(event.target.value));
152
+ }
153
+ }}
154
+ />
155
+ </label>
156
+ <label className="sc-field">
157
+ <span className="sc-field-label">{form.key}</span>
158
+ <input
159
+ className="sc-input"
160
+ value={key}
161
+ maxLength={MAX_PROJECT_KEY_LENGTH}
162
+ pattern="[a-z0-9]([a-z0-9-]*[a-z0-9])?"
163
+ required
164
+ disabled={project !== undefined}
165
+ title={form.keyHint}
166
+ onChange={(event) => {
167
+ setKeyTouched(true);
168
+ setKey(event.target.value);
169
+ }}
170
+ />
171
+ </label>
172
+ </div>
173
+ <label className="sc-field">
174
+ <span className="sc-field-label">{form.description}</span>
175
+ <textarea
176
+ className="sc-input sc-textarea"
177
+ value={description}
178
+ maxLength={MAX_PROJECT_DESCRIPTION_LENGTH}
179
+ rows={2}
180
+ placeholder={form.descriptionPlaceholder}
181
+ onChange={(event) => setDescription(event.target.value)}
182
+ />
183
+ </label>
184
+ <div className="sc-field-row">
185
+ <label className="sc-field">
186
+ <span className="sc-field-label">{form.whoIsMember}</span>
187
+ <select
188
+ className="sc-input"
189
+ value={visibility}
190
+ onChange={(event) => setVisibility(event.target.value as ProjectVisibility)}
191
+ >
192
+ {PROJECT_VISIBILITIES.map((candidate) => (
193
+ <option key={candidate} value={candidate}>
194
+ {words.vocabulary.visibility[candidate]}
195
+ </option>
196
+ ))}
197
+ </select>
198
+ </label>
199
+ <label className="sc-field">
200
+ <span className="sc-field-label">{form.skillsAddress}</span>
201
+ <input
202
+ className="sc-input"
203
+ value={skillsAddress}
204
+ maxLength={MAX_SKILLS_ADDRESS_LENGTH}
205
+ placeholder={form.skillsAddressPlaceholder}
206
+ onChange={(event) => setSkillsAddress(event.target.value)}
207
+ />
208
+ </label>
209
+ </div>
210
+ {props.error !== undefined && (
211
+ <p className="sc-form-error" role="alert">
212
+ {props.error}
213
+ </p>
214
+ )}
215
+ <div className="sc-form-actions">
216
+ <Button
217
+ type="submit"
218
+ variant="primary"
219
+ disabled={props.busy === true || name.trim() === "" || key.trim() === ""}
220
+ >
221
+ {project === undefined ? form.make : words.common.save}
222
+ </Button>
223
+ {props.onCancel !== undefined && (
224
+ <Button variant="ghost" onClick={props.onCancel}>
225
+ {words.common.cancel}
226
+ </Button>
227
+ )}
228
+ </div>
229
+ </form>
230
+ );
231
+ }
232
+
233
+ export interface MemberListProps {
234
+ readonly members: readonly RestMember[];
235
+ /** The people of the workspace who may be listed: those not listed yet are offered. */
236
+ readonly people: readonly RestPerson[];
237
+ /** Whoever is looking, so that their own row says so. */
238
+ readonly me?: RestPerson | undefined;
239
+ /** Called with a person to list, and what they are to be. Left out, the list is read here, not changed. */
240
+ readonly onAdd?: ((input: RestMemberInput) => void) | undefined;
241
+ readonly onChangeRole?: ((member: RestMember, role: ProjectRole) => void) | undefined;
242
+ readonly onRemove?: ((member: RestMember) => void) | undefined;
243
+ readonly busy?: boolean | undefined;
244
+ readonly empty?: ReactNode;
245
+ }
246
+
247
+ export function MemberList(props: MemberListProps) {
248
+ const words = useWords();
249
+ const members = words.projects.members;
250
+ const listed = new Set(props.members.map((member) => member.person.id));
251
+ const candidates = props.people.filter((person) => !listed.has(person.id));
252
+ const [personId, setPersonId] = useState("");
253
+ const [role, setRole] = useState<ProjectRole>("member");
254
+ return (
255
+ <div className="sc-members">
256
+ {props.members.length === 0 ? (
257
+ (props.empty ?? null)
258
+ ) : (
259
+ <ul className="sc-people" aria-label={members.list}>
260
+ {props.members.map((member) => (
261
+ <li key={member.person.id} className="sc-person-row">
262
+ <PersonChip person={member.person} size="md" />
263
+ <span className="sc-person-name">
264
+ {member.person.name ?? ""}
265
+ {props.me?.id === member.person.id && (
266
+ <span className="sc-muted"> {words.common.you}</span>
267
+ )}
268
+ <span className="sc-muted">
269
+ {" "}
270
+ {members.since} <Time iso={member.addedAt} />
271
+ </span>
272
+ </span>
273
+ {props.onChangeRole === undefined ? (
274
+ <ProjectRoleBadge role={member.role} />
275
+ ) : (
276
+ <label className="sc-field sc-field-inline">
277
+ <span className="sc-visually-hidden">{members.roleOf(member.person.login)}</span>
278
+ <select
279
+ className="sc-input"
280
+ value={member.role}
281
+ disabled={props.busy === true}
282
+ onChange={(event) =>
283
+ props.onChangeRole?.(member, event.target.value as ProjectRole)
284
+ }
285
+ >
286
+ {PROJECT_ROLES.map((candidate) => (
287
+ <option key={candidate} value={candidate}>
288
+ {words.vocabulary.projectRole[candidate]}
289
+ </option>
290
+ ))}
291
+ </select>
292
+ </label>
293
+ )}
294
+ {props.onRemove !== undefined && (
295
+ <Button
296
+ variant="danger"
297
+ size="sm"
298
+ disabled={props.busy === true}
299
+ onClick={() => props.onRemove?.(member)}
300
+ >
301
+ {words.common.remove}
302
+ </Button>
303
+ )}
304
+ </li>
305
+ ))}
306
+ </ul>
307
+ )}
308
+ {props.onAdd !== undefined && candidates.length > 0 && (
309
+ <form
310
+ className="sc-form sc-member-form"
311
+ aria-label={members.addAria}
312
+ onSubmit={(event) => {
313
+ event.preventDefault();
314
+ if (personId !== "") {
315
+ props.onAdd?.({ personId, role });
316
+ setPersonId("");
317
+ }
318
+ }}
319
+ >
320
+ <div className="sc-field-row">
321
+ <label className="sc-field">
322
+ <span className="sc-field-label">{members.person}</span>
323
+ <select
324
+ className="sc-input"
325
+ value={personId}
326
+ onChange={(event) => setPersonId(event.target.value)}
327
+ >
328
+ <option value="">{members.chooseSomeone}</option>
329
+ {candidates.map((person) => (
330
+ <option key={person.id} value={person.id}>
331
+ {person.login}
332
+ </option>
333
+ ))}
334
+ </select>
335
+ </label>
336
+ <label className="sc-field">
337
+ <span className="sc-field-label">{members.as}</span>
338
+ <select
339
+ className="sc-input"
340
+ value={role}
341
+ onChange={(event) => setRole(event.target.value as ProjectRole)}
342
+ >
343
+ {PROJECT_ROLES.map((candidate) => (
344
+ <option key={candidate} value={candidate}>
345
+ {words.vocabulary.projectRole[candidate]}
346
+ </option>
347
+ ))}
348
+ </select>
349
+ </label>
350
+ <div className="sc-form-actions">
351
+ <Button
352
+ type="submit"
353
+ variant="primary"
354
+ disabled={props.busy === true || personId === ""}
355
+ >
356
+ {members.add}
357
+ </Button>
358
+ </div>
359
+ </div>
360
+ </form>
361
+ )}
362
+ </div>
363
+ );
364
+ }
@@ -0,0 +1,175 @@
1
+ import type { ReactNode } from "react";
2
+ import type { RestArtifact, RestRun, RunStatus } from "../api.js";
3
+ import { useWords } from "../i18n/index.js";
4
+ import { Markdown } from "./markdown.js";
5
+ import { Badge, Button, formatBytes, PersonChip, Time } from "./ui.js";
6
+
7
+ // A run: one agent at work on one task for one person, with what it reported, what it handed
8
+ // in, and what it waits for. Takes its data as props and nothing from the network.
9
+
10
+ /** The words of a run's status in English: the default pack's. */
11
+ export const RUN_STATUS_LABELS: Readonly<Record<RunStatus, string>> = {
12
+ running: "At work",
13
+ waiting: "Waiting for a decision",
14
+ finished: "Finished",
15
+ failed: "Failed",
16
+ abandoned: "Abandoned",
17
+ };
18
+
19
+ export function RunStatusBadge(props: { readonly status: RunStatus }) {
20
+ const words = useWords();
21
+ const tone =
22
+ props.status === "running"
23
+ ? "accent"
24
+ : props.status === "waiting"
25
+ ? "warning"
26
+ : props.status === "finished"
27
+ ? "success"
28
+ : props.status === "failed"
29
+ ? "danger"
30
+ : "neutral";
31
+ return <Badge tone={tone}>{words.vocabulary.runStatus[props.status]}</Badge>;
32
+ }
33
+
34
+ export interface RunCardProps {
35
+ readonly run: RestRun;
36
+ /** Where the decision the run waits for is answered, when the page knows. */
37
+ readonly decisionHref?: ((decisionId: string) => string) | undefined;
38
+ /** Where a file handed in is read: the project's path for it. Left out, a file is named and not linked. */
39
+ readonly fileHref?: ((artifact: RestArtifact) => string) | undefined;
40
+ /** Where a document of the project is read, by its path, for the links in what the agent wrote. */
41
+ readonly docHref?: ((path: string) => string) | undefined;
42
+ /** Called to mark a run that will not come back as abandoned. Left out, it cannot be here. */
43
+ readonly onAbandon?: ((run: RestRun) => void) | undefined;
44
+ readonly busy?: boolean | undefined;
45
+ }
46
+
47
+ export function RunCard(props: RunCardProps) {
48
+ const words = useWords().runs;
49
+ const { run } = props;
50
+ const open = run.status === "running" || run.status === "waiting";
51
+ return (
52
+ <article className="sc-run" aria-label={words.label(run.agent, run.person.login)}>
53
+ <header className="sc-run-header">
54
+ <span className="sc-run-agent">{run.agent}</span>
55
+ <RunStatusBadge status={run.status} />
56
+ <span className="sc-run-meta">
57
+ {words.for} <PersonChip person={run.person} /> {words.since} <Time iso={run.startedAt} />
58
+ {run.endedAt !== null && (
59
+ <>
60
+ {" "}
61
+ {words.until} <Time iso={run.endedAt} />
62
+ </>
63
+ )}
64
+ </span>
65
+ {open && props.onAbandon !== undefined && (
66
+ <Button
67
+ variant="ghost"
68
+ size="sm"
69
+ disabled={props.busy === true}
70
+ onClick={() => props.onAbandon?.(run)}
71
+ >
72
+ {words.markAbandoned}
73
+ </Button>
74
+ )}
75
+ </header>
76
+ {run.waitingFor !== null && (
77
+ <p className="sc-run-waiting">
78
+ {words.waitingForDecision}
79
+ {props.decisionHref !== undefined && (
80
+ <>
81
+ : <a href={props.decisionHref(run.waitingFor)}>{words.answerIt}</a>
82
+ </>
83
+ )}
84
+ .
85
+ </p>
86
+ )}
87
+ {run.reports.length > 0 && (
88
+ <ol className="sc-run-reports" aria-label={words.reports}>
89
+ {run.reports.map((report) => (
90
+ <li key={report.id} className="sc-report">
91
+ <Time iso={report.createdAt} />
92
+ <Markdown source={report.body} docHref={props.docHref} />
93
+ </li>
94
+ ))}
95
+ </ol>
96
+ )}
97
+ {run.artifacts.length > 0 && (
98
+ <ul className="sc-link-list" aria-label={words.handedIn}>
99
+ {run.artifacts.map((artifact) => (
100
+ <li key={artifact.id}>
101
+ <ArtifactLink artifact={artifact} fileHref={props.fileHref} />
102
+ </li>
103
+ ))}
104
+ </ul>
105
+ )}
106
+ {run.summary !== null && (
107
+ <div className="sc-run-summary">
108
+ <Markdown source={run.summary} docHref={props.docHref} />
109
+ </div>
110
+ )}
111
+ </article>
112
+ );
113
+ }
114
+
115
+ /** A link as it was handed in; a file by its name, with its size, read from the console. */
116
+ function ArtifactLink(props: {
117
+ readonly artifact: RestArtifact;
118
+ readonly fileHref: ((artifact: RestArtifact) => string) | undefined;
119
+ }) {
120
+ const { artifact } = props;
121
+ if (artifact.file === null) {
122
+ return (
123
+ <a href={artifact.url ?? ""} target="_blank" rel="noopener noreferrer nofollow ugc">
124
+ {artifact.label ?? artifact.url}
125
+ </a>
126
+ );
127
+ }
128
+ const href = props.fileHref?.(artifact);
129
+ return (
130
+ <>
131
+ {href === undefined ? (
132
+ <span>{artifact.label ?? artifact.file.name}</span>
133
+ ) : (
134
+ <a href={href} rel="nofollow ugc">
135
+ {artifact.label ?? artifact.file.name}
136
+ </a>
137
+ )}{" "}
138
+ <span className="sc-muted">
139
+ {artifact.label === null ? "" : `${artifact.file.name}, `}
140
+ {formatBytes(artifact.file.size)}
141
+ </span>
142
+ </>
143
+ );
144
+ }
145
+
146
+ export interface RunListProps {
147
+ readonly runs: readonly RestRun[];
148
+ readonly decisionHref?: ((decisionId: string) => string) | undefined;
149
+ readonly fileHref?: ((artifact: RestArtifact) => string) | undefined;
150
+ readonly docHref?: ((path: string) => string) | undefined;
151
+ readonly onAbandon?: ((run: RestRun) => void) | undefined;
152
+ readonly busy?: boolean | undefined;
153
+ readonly empty?: ReactNode;
154
+ }
155
+
156
+ export function RunList(props: RunListProps) {
157
+ if (props.runs.length === 0) {
158
+ return <>{props.empty ?? null}</>;
159
+ }
160
+ return (
161
+ <div className="sc-runs">
162
+ {props.runs.map((run) => (
163
+ <RunCard
164
+ key={run.id}
165
+ run={run}
166
+ decisionHref={props.decisionHref}
167
+ fileHref={props.fileHref}
168
+ docHref={props.docHref}
169
+ onAbandon={props.onAbandon}
170
+ busy={props.busy}
171
+ />
172
+ ))}
173
+ </div>
174
+ );
175
+ }