@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,1166 @@
1
+ import { type ComponentType, StrictMode, useCallback, useEffect, useMemo, useState } from "react";
2
+ import { createRoot, type Root } from "react-dom/client";
3
+ import {
4
+ type ConsoleClient,
5
+ createClient,
6
+ type RestAnswerInput,
7
+ type RestDecision,
8
+ type RestDecisionInput,
9
+ type RestDecisionPatch,
10
+ type RestEvent,
11
+ type RestProject,
12
+ type RestProjectInput,
13
+ type RestProjectPatch,
14
+ type RestTask,
15
+ type RestTaskInput,
16
+ type RestToken,
17
+ type RestTokenCreated,
18
+ type TaskState,
19
+ } from "./api.js";
20
+ import { Board, type BoardProps } from "./components/board.js";
21
+ import { ConnectWords } from "./components/connect.js";
22
+ import { DecisionForm } from "./components/decision-form.js";
23
+ import { DecisionCard, DecisionList, type DecisionListProps } from "./components/decision-list.js";
24
+ import { EventFeed, type EventFeedProps } from "./components/event-feed.js";
25
+ import { PeopleList, type PeopleListProps } from "./components/people.js";
26
+ import {
27
+ MemberList,
28
+ type MemberListProps,
29
+ ProjectForm,
30
+ ProjectList,
31
+ type ProjectListProps,
32
+ } from "./components/projects.js";
33
+ import {
34
+ type Brand,
35
+ isPlainClick,
36
+ type NavItem,
37
+ Shell,
38
+ type ShellProps,
39
+ } from "./components/shell.js";
40
+ import { SignIn, type SignInProps } from "./components/sign-in.js";
41
+ import { SkillList, type SkillListProps } from "./components/skills.js";
42
+ import { TaskForm } from "./components/task-form.js";
43
+ import { TaskView, type TaskViewProps } from "./components/task-view.js";
44
+ import { NewToken, TokenForm, TokenList, type TokenListProps } from "./components/tokens.js";
45
+ import { Button, Callout, EmptyState, Spinner } from "./components/ui.js";
46
+ import { ConnectPage } from "./connect-page.js";
47
+ import { type ConsoleData, useConsoleData } from "./data.js";
48
+ import { DocsPage } from "./docs-page.js";
49
+ import {
50
+ chooseLanguage,
51
+ DEFAULT_LANGUAGES,
52
+ LanguageContext,
53
+ type LanguagePack,
54
+ useWords,
55
+ } from "./i18n/index.js";
56
+ import {
57
+ decisionHref,
58
+ docHref,
59
+ matchRoute,
60
+ type Navigation,
61
+ newDecisionHref,
62
+ newTaskHref,
63
+ PATHS,
64
+ personAgentsHref,
65
+ projectHref,
66
+ type Route,
67
+ signInFailureOf,
68
+ taskHref,
69
+ withoutSignInParam,
70
+ } from "./router.js";
71
+ import { projectPath } from "./routes.js";
72
+ import { useAction } from "./use-action.js";
73
+
74
+ // The composition of the default console: the pages assembled from the components, with the
75
+ // places a team may replace named. The UI the image serves is this, with the default config.
76
+ // The front page is the projects; a project's pages are under its key, every form has an
77
+ // address, and the history is one entry per step (ADR-0010). The pages speak the person's
78
+ // language, from the packs the console is given (ADR-0012).
79
+
80
+ /** The components a custom console may replace, each with the props the default one takes. */
81
+ export interface ConsoleComponents {
82
+ readonly Shell: ComponentType<ShellProps>;
83
+ readonly Board: ComponentType<BoardProps>;
84
+ readonly TaskView: ComponentType<TaskViewProps>;
85
+ readonly DecisionList: ComponentType<DecisionListProps>;
86
+ readonly EventFeed: ComponentType<EventFeedProps>;
87
+ readonly SignIn: ComponentType<SignInProps>;
88
+ readonly TokenList: ComponentType<TokenListProps>;
89
+ readonly PeopleList: ComponentType<PeopleListProps>;
90
+ readonly SkillList: ComponentType<SkillListProps>;
91
+ readonly ProjectList: ComponentType<ProjectListProps>;
92
+ readonly MemberList: ComponentType<MemberListProps>;
93
+ }
94
+
95
+ export const DEFAULT_COMPONENTS: ConsoleComponents = {
96
+ Shell,
97
+ Board,
98
+ TaskView,
99
+ DecisionList,
100
+ EventFeed,
101
+ SignIn,
102
+ TokenList,
103
+ PeopleList,
104
+ SkillList,
105
+ ProjectList,
106
+ MemberList,
107
+ };
108
+
109
+ export interface ConsoleConfig {
110
+ /** The origin of the console's API. Left out, the page's own origin. */
111
+ readonly baseUrl?: string | undefined;
112
+ /** What the board is called before the server says; the server's name wins once it answers. */
113
+ readonly title?: string | undefined;
114
+ /** The components to use in place of the default ones. */
115
+ readonly components?: Partial<ConsoleComponents> | undefined;
116
+ /** The languages the pages speak, the first being the one spoken when nothing else decides; the package's when left out. */
117
+ readonly languages?: readonly LanguagePack[] | undefined;
118
+ /** The marks beside the name (ADR-0013); none shows the name alone. A custom console brings its own. */
119
+ readonly brand?: Brand | undefined;
120
+ /** The client to talk to the server with; made from `baseUrl` when left out. For tests. */
121
+ readonly client?: ConsoleClient | undefined;
122
+ }
123
+
124
+ export interface ConsoleApp {
125
+ /** The whole console as one component, for a custom page to place. */
126
+ readonly App: ComponentType<{ readonly initialPath?: string | undefined }>;
127
+ /** Renders the console into `container`. Returns the way to take it down again. */
128
+ mount(container: Element): () => void;
129
+ }
130
+
131
+ /** How many steps back from a history entry stay within the app: kept on the entry itself. */
132
+ function depthOf(state: unknown): number {
133
+ return typeof state === "object" &&
134
+ state !== null &&
135
+ "scDepth" in state &&
136
+ typeof state.scDepth === "number"
137
+ ? state.scDepth
138
+ : 0;
139
+ }
140
+
141
+ /**
142
+ * The browser's location as the app reads it, and the ways to move: one history entry per step
143
+ * the person takes, none for the same address again, a replacement for a redirect or for what a
144
+ * form made, and back to where a form was opened from when that is within the app.
145
+ */
146
+ function useNavigation(initialPath: string | undefined) {
147
+ const read = useCallback(
148
+ () =>
149
+ typeof window === "undefined"
150
+ ? { pathname: initialPath ?? "/", search: "" }
151
+ : { pathname: window.location.pathname, search: window.location.search },
152
+ [initialPath],
153
+ );
154
+ const [location, setLocation] = useState(read);
155
+ useEffect(() => {
156
+ const update = () => setLocation(read());
157
+ window.addEventListener("popstate", update);
158
+ return () => window.removeEventListener("popstate", update);
159
+ }, [read]);
160
+ const navigation = useMemo<Navigation>(() => {
161
+ const show = (href: string, replace: boolean) => {
162
+ if (typeof window === "undefined") {
163
+ return;
164
+ }
165
+ const { pathname, search, hash } = window.location;
166
+ if (href === `${pathname}${search}${hash}`) {
167
+ return;
168
+ }
169
+ const depth = depthOf(window.history.state);
170
+ if (replace) {
171
+ window.history.replaceState({ scDepth: depth }, "", href);
172
+ } else {
173
+ window.history.pushState({ scDepth: depth + 1 }, "", href);
174
+ window.scrollTo({ top: 0, left: 0, behavior: "instant" });
175
+ }
176
+ setLocation(read());
177
+ };
178
+ return {
179
+ go: (href) => show(href, false),
180
+ replace: (href) => show(href, true),
181
+ back: (fallback) => {
182
+ if (typeof window !== "undefined" && depthOf(window.history.state) > 0) {
183
+ window.history.back();
184
+ } else {
185
+ show(fallback, true);
186
+ }
187
+ },
188
+ };
189
+ }, [read]);
190
+ return { location, navigation };
191
+ }
192
+
193
+ /**
194
+ * Every plain click on a link to one of the console's own addresses is followed in place, from
195
+ * here, wherever the link is: in a document, in a feed line, in a task. A modified click, a link
196
+ * elsewhere, a file of the API, a sign-in, and a jump within the page stay the browser's.
197
+ */
198
+ function useFollowLinks(navigation: Navigation): void {
199
+ useEffect(() => {
200
+ if (typeof document === "undefined") {
201
+ return;
202
+ }
203
+ const onClick = (event: MouseEvent) => {
204
+ if (event.defaultPrevented || !isPlainClick(event)) {
205
+ return;
206
+ }
207
+ const anchor = event.target instanceof Element ? event.target.closest("a[href]") : null;
208
+ if (!(anchor instanceof HTMLAnchorElement)) {
209
+ return;
210
+ }
211
+ if ((anchor.target !== "" && anchor.target !== "_self") || anchor.hasAttribute("download")) {
212
+ return;
213
+ }
214
+ const url = new URL(anchor.href, window.location.href);
215
+ if (url.origin !== window.location.origin) {
216
+ return;
217
+ }
218
+ const { pathname, search } = window.location;
219
+ if (url.pathname === pathname && url.search === search && url.hash !== "") {
220
+ return;
221
+ }
222
+ if (matchRoute(url.pathname, url.search).name === "not-found") {
223
+ return;
224
+ }
225
+ event.preventDefault();
226
+ navigation.go(`${url.pathname}${url.search}${url.hash}`);
227
+ };
228
+ document.addEventListener("click", onClick);
229
+ return () => document.removeEventListener("click", onClick);
230
+ }, [navigation]);
231
+ }
232
+
233
+ /** The key of the project a route is on, or nothing. */
234
+ const projectOf = (route: Route): string | undefined =>
235
+ "project" in route ? route.project : undefined;
236
+
237
+ /** The field the pages lie on, for a page without the shell: signing in, and the wait before it. */
238
+ function Ground(props: { readonly children: React.ReactNode }) {
239
+ return (
240
+ <div className="sc-shell">
241
+ <main className="sc-main">{props.children}</main>
242
+ </div>
243
+ );
244
+ }
245
+
246
+ /**
247
+ * Whether the console holds a token rather than a session: a person's own console, served from
248
+ * their machine with the token the command keeps (ADR-0014). The API says so with `me.agent`.
249
+ */
250
+ function holdsToken(data: ConsoleData): boolean {
251
+ return data.me !== undefined && data.me.agent !== null;
252
+ }
253
+
254
+ /**
255
+ * The pages a console that holds a token does not offer: the tokens themselves and
256
+ * configuring, which are a person's own doing on the console's own pages, signed in.
257
+ */
258
+ export function onlyOnOwnPages(route: Route): boolean {
259
+ return (
260
+ route.name === "new-project" ||
261
+ route.name === "settings" ||
262
+ route.name === "agents" ||
263
+ route.name === "new-token" ||
264
+ route.name === "person-agents" ||
265
+ route.name === "connect"
266
+ );
267
+ }
268
+
269
+ function NotWithToken() {
270
+ const words = useWords().common.notWithToken;
271
+ return <EmptyState title={words.title} body={words.body} />;
272
+ }
273
+
274
+ /**
275
+ * Where a console that holds a token keeps the person's choice of language: the browser's own
276
+ * storage, which may be absent or refuse, and then the browser's languages decide.
277
+ */
278
+ const LANGUAGE_KEY = "sc-language";
279
+
280
+ function storedLanguage(): string | undefined {
281
+ try {
282
+ return globalThis.localStorage?.getItem(LANGUAGE_KEY) ?? undefined;
283
+ } catch {
284
+ return undefined;
285
+ }
286
+ }
287
+
288
+ function storeLanguage(tag: string): void {
289
+ try {
290
+ globalThis.localStorage?.setItem(LANGUAGE_KEY, tag);
291
+ } catch {
292
+ // A browser that keeps nothing: the choice holds for this page.
293
+ }
294
+ }
295
+
296
+ /** A link that reads as a button: where a form is, for instance. */
297
+ function LinkButton(props: {
298
+ readonly href: string;
299
+ readonly variant?: "primary" | "secondary";
300
+ readonly children: React.ReactNode;
301
+ }) {
302
+ return (
303
+ <a className={`sc-button sc-button-${props.variant ?? "secondary"}`} href={props.href}>
304
+ {props.children}
305
+ </a>
306
+ );
307
+ }
308
+
309
+ function ProjectsPage(props: {
310
+ readonly data: ConsoleData;
311
+ readonly components: ConsoleComponents;
312
+ readonly navigation: Navigation;
313
+ /** Whether the form for a new project is open: the page is at its address. */
314
+ readonly making: boolean;
315
+ }) {
316
+ const words = useWords().projects;
317
+ const { data, components, navigation, making } = props;
318
+ const { busy, error, act } = useAction();
319
+ const href = (project: RestProject) => projectHref(project.key);
320
+ const configures = !holdsToken(data);
321
+ return (
322
+ <>
323
+ <div className="sc-page-head">
324
+ <h1 className="sc-page-title">{words.title}</h1>
325
+ {!making && configures && (
326
+ <LinkButton href={PATHS.newProject} variant="primary">
327
+ {words.newProject}
328
+ </LinkButton>
329
+ )}
330
+ </div>
331
+ <p className="sc-lead">{words.lead}</p>
332
+ {error !== undefined && <Callout tone="danger">{error}</Callout>}
333
+ {making && (
334
+ <section className="sc-panel sc-panel-form" aria-label={words.form.aria}>
335
+ <ProjectForm
336
+ busy={busy}
337
+ onSubmit={(input) =>
338
+ void act(async () => {
339
+ const made = await data.actions.createProject(input as RestProjectInput);
340
+ navigation.replace(projectHref(made.key));
341
+ })
342
+ }
343
+ onCancel={() => navigation.back(PATHS.projects)}
344
+ />
345
+ </section>
346
+ )}
347
+ <components.ProjectList
348
+ projects={data.projects}
349
+ projectHref={href}
350
+ onOpen={(project) => navigation.go(href(project))}
351
+ empty={
352
+ <EmptyState
353
+ title={words.empty.title}
354
+ body={words.empty.body}
355
+ action={
356
+ configures ? (
357
+ <LinkButton href={PATHS.newProject}>{words.newProject}</LinkButton>
358
+ ) : undefined
359
+ }
360
+ />
361
+ }
362
+ />
363
+ </>
364
+ );
365
+ }
366
+
367
+ function TaskPage(props: {
368
+ readonly route: Extract<Route, { name: "task" }>;
369
+ readonly project: RestProject;
370
+ readonly data: ConsoleData;
371
+ readonly components: ConsoleComponents;
372
+ readonly navigation: Navigation;
373
+ readonly busy: boolean;
374
+ readonly error: string | undefined;
375
+ readonly act: (work: () => Promise<unknown>) => Promise<void>;
376
+ }) {
377
+ const words = useWords();
378
+ const { route, project, data, components, navigation, busy, error, act } = props;
379
+ const key = project.key;
380
+ const task =
381
+ route.number === undefined
382
+ ? data.tasks.find((candidate) => candidate.id === route.id)
383
+ : data.tasks.find((candidate) => candidate.number === route.number);
384
+ const [history, setHistory] = useState<readonly RestEvent[] | undefined>(undefined);
385
+ const { taskHistory } = data.actions;
386
+ const taskId = task?.id;
387
+ const number = task?.number;
388
+ // A task named by its id is shown at its number's address.
389
+ useEffect(() => {
390
+ if (route.id !== undefined && number !== undefined) {
391
+ navigation.replace(taskHref(key, number, route.form));
392
+ }
393
+ }, [route.id, route.form, number, key, navigation]);
394
+ // Everything that happened to the task, asked for again whenever the project's feed grows.
395
+ const feedLength = data.events.length;
396
+ // biome-ignore lint/correctness/useExhaustiveDependencies: the feed's length is the trigger
397
+ useEffect(() => {
398
+ if (taskId === undefined) {
399
+ return;
400
+ }
401
+ const controller = new AbortController();
402
+ taskHistory(taskId, controller.signal)
403
+ .then((events) => {
404
+ if (!controller.signal.aborted) {
405
+ setHistory(events);
406
+ }
407
+ })
408
+ .catch(() => undefined);
409
+ return () => controller.abort();
410
+ }, [taskId, taskHistory, feedLength]);
411
+ if (task === undefined) {
412
+ return (
413
+ <EmptyState
414
+ title={words.task.noSuch.title}
415
+ body={words.task.noSuch.body}
416
+ action={<LinkButton href={projectHref(key)}>{words.nav.board}</LinkButton>}
417
+ />
418
+ );
419
+ }
420
+ const here = taskHref(key, task.number);
421
+ return (
422
+ <components.TaskView
423
+ task={task}
424
+ people={data.people}
425
+ tasks={data.tasks}
426
+ decisions={data.decisions}
427
+ runs={data.runs}
428
+ history={history}
429
+ taskHref={(candidate) => taskHref(key, candidate.number)}
430
+ decisionHref={(decisionId) => decisionHref(key, decisionId)}
431
+ fileHref={(artifact) => projectPath(key, "files", artifact.id)}
432
+ docHref={(path) => docHref(key, path)}
433
+ form={route.form}
434
+ formHref={(form) => taskHref(key, task.number, form)}
435
+ onCancel={() => navigation.back(here)}
436
+ busy={busy}
437
+ error={error}
438
+ onChange={(target, patch) =>
439
+ void act(async () => {
440
+ await data.actions.updateTask(target.id, patch);
441
+ navigation.replace(here);
442
+ })
443
+ }
444
+ onMove={(target, state) => void act(() => data.actions.updateTask(target.id, { state }))}
445
+ onRaiseDecision={(input) =>
446
+ void act(async () => {
447
+ await data.actions.raiseDecision(input);
448
+ navigation.replace(here);
449
+ })
450
+ }
451
+ onAnswer={(decision, input) =>
452
+ void act(() => data.actions.answerDecision(decision.id, input))
453
+ }
454
+ onUpdateDecision={(decision, patch) =>
455
+ void act(() => data.actions.updateDecision(decision.id, patch))
456
+ }
457
+ onAbandonRun={(run) => void act(() => data.actions.abandonRun(run.id))}
458
+ />
459
+ );
460
+ }
461
+
462
+ function ProjectPage(props: {
463
+ readonly route: Route;
464
+ readonly project: RestProject;
465
+ readonly data: ConsoleData;
466
+ readonly components: ConsoleComponents;
467
+ readonly navigation: Navigation;
468
+ }) {
469
+ const words = useWords();
470
+ const { route, project, data, components, navigation } = props;
471
+ const { busy, error, act } = useAction();
472
+ const tasksById = useMemo(() => new Map(data.tasks.map((task) => [task.id, task])), [data.tasks]);
473
+ const key = project.key;
474
+ const href = (task: RestTask) => taskHref(key, task.number);
475
+ const toDoc = (path: string) => docHref(key, path);
476
+ const owner = project.role === "owner";
477
+
478
+ const onMove = (task: RestTask, state: TaskState) =>
479
+ void act(() => data.actions.updateTask(task.id, { state }));
480
+ const onAnswer = (decision: RestDecision, input: RestAnswerInput) =>
481
+ void act(() => data.actions.answerDecision(decision.id, input));
482
+ const onUpdate = (decision: RestDecision, patch: RestDecisionPatch) =>
483
+ void act(() => data.actions.updateDecision(decision.id, patch));
484
+
485
+ if (route.name === "board") {
486
+ return (
487
+ <>
488
+ <div className="sc-page-head">
489
+ <h1 className="sc-page-title">{project.name}</h1>
490
+ <LinkButton href={newTaskHref(key)} variant="primary">
491
+ {words.board.writeTask}
492
+ </LinkButton>
493
+ </div>
494
+ {project.description.length > 0 && <p className="sc-lead">{project.description}</p>}
495
+ {error !== undefined && <Callout tone="danger">{error}</Callout>}
496
+ <components.Board
497
+ tasks={data.tasks}
498
+ taskHref={href}
499
+ onOpen={(task) => navigation.go(href(task))}
500
+ onMove={onMove}
501
+ empty={
502
+ data.tasks.length === 0 ? (
503
+ <EmptyState
504
+ title={words.board.empty.title}
505
+ body={words.board.empty.body}
506
+ action={<LinkButton href={newTaskHref(key)}>{words.board.writeTask}</LinkButton>}
507
+ />
508
+ ) : undefined
509
+ }
510
+ />
511
+ </>
512
+ );
513
+ }
514
+ if (route.name === "new-task") {
515
+ return (
516
+ <>
517
+ <div className="sc-page-head">
518
+ <h1 className="sc-page-title">{words.task.newTitle}</h1>
519
+ </div>
520
+ {error !== undefined && <Callout tone="danger">{error}</Callout>}
521
+ <section className="sc-panel sc-panel-form" aria-label={words.task.newAria}>
522
+ <TaskForm
523
+ people={data.people}
524
+ parents={data.tasks}
525
+ busy={busy}
526
+ onSubmit={(input: RestTaskInput) =>
527
+ void act(async () => {
528
+ const made = await data.actions.createTask(input);
529
+ navigation.replace(taskHref(key, made.number));
530
+ })
531
+ }
532
+ onCancel={() => navigation.back(projectHref(key))}
533
+ />
534
+ </section>
535
+ </>
536
+ );
537
+ }
538
+ if (route.name === "task") {
539
+ return (
540
+ <TaskPage
541
+ route={route}
542
+ project={project}
543
+ data={data}
544
+ components={components}
545
+ navigation={navigation}
546
+ busy={busy}
547
+ error={error}
548
+ act={act}
549
+ />
550
+ );
551
+ }
552
+ if (route.name === "docs") {
553
+ return (
554
+ <DocsPage
555
+ project={project}
556
+ path={route.path}
557
+ view={route.view}
558
+ data={data}
559
+ navigation={navigation}
560
+ />
561
+ );
562
+ }
563
+ if (route.name === "decisions") {
564
+ return (
565
+ <>
566
+ <div className="sc-page-head">
567
+ <h1 className="sc-page-title">{words.decisions.title}</h1>
568
+ <LinkButton href={newDecisionHref(key)} variant="primary">
569
+ {words.decisions.raise}
570
+ </LinkButton>
571
+ </div>
572
+ {error !== undefined && <Callout tone="danger">{error}</Callout>}
573
+ <components.DecisionList
574
+ decisions={data.decisions}
575
+ tasks={tasksById}
576
+ taskHref={href}
577
+ docHref={toDoc}
578
+ decisionHref={(decision) => decisionHref(key, decision.id)}
579
+ onAnswer={onAnswer}
580
+ onUpdate={onUpdate}
581
+ busy={busy}
582
+ empty={
583
+ <EmptyState title={words.decisions.empty.title} body={words.decisions.empty.body} />
584
+ }
585
+ />
586
+ </>
587
+ );
588
+ }
589
+ if (route.name === "new-decision") {
590
+ return (
591
+ <>
592
+ <div className="sc-page-head">
593
+ <h1 className="sc-page-title">{words.decisions.raise}</h1>
594
+ </div>
595
+ {error !== undefined && <Callout tone="danger">{error}</Callout>}
596
+ <section className="sc-panel sc-panel-form" aria-label={words.decisions.raiseAria}>
597
+ <DecisionForm
598
+ tasks={data.tasks}
599
+ busy={busy}
600
+ onSubmit={(input: RestDecisionInput) =>
601
+ void act(async () => {
602
+ const made = await data.actions.raiseDecision(input);
603
+ navigation.replace(decisionHref(key, made.id));
604
+ })
605
+ }
606
+ onCancel={() => navigation.back(projectHref(key, "decisions"))}
607
+ />
608
+ </section>
609
+ </>
610
+ );
611
+ }
612
+ if (route.name === "decision") {
613
+ const decision = data.decisions.find((candidate) => candidate.id === route.id);
614
+ return (
615
+ <>
616
+ <div className="sc-page-head">
617
+ <h1 className="sc-page-title">{words.decisions.one}</h1>
618
+ <LinkButton href={projectHref(key, "decisions")}>{words.decisions.all}</LinkButton>
619
+ </div>
620
+ {error !== undefined && <Callout tone="danger">{error}</Callout>}
621
+ {decision === undefined ? (
622
+ <EmptyState title={words.decisions.noSuch.title} body={words.decisions.noSuch.body} />
623
+ ) : (
624
+ <DecisionCard
625
+ decision={decision}
626
+ task={decision.taskId === null ? undefined : tasksById.get(decision.taskId)}
627
+ taskHref={href}
628
+ docHref={toDoc}
629
+ onAnswer={onAnswer}
630
+ onUpdate={onUpdate}
631
+ busy={busy}
632
+ />
633
+ )}
634
+ </>
635
+ );
636
+ }
637
+ if (route.name === "feed") {
638
+ return (
639
+ <>
640
+ <div className="sc-page-head">
641
+ <h1 className="sc-page-title">{words.feed.title}</h1>
642
+ </div>
643
+ <components.EventFeed
644
+ events={data.events}
645
+ href={(event) => {
646
+ const task = event.taskId === null ? undefined : tasksById.get(event.taskId);
647
+ if (task !== undefined) {
648
+ return href(task);
649
+ }
650
+ if (event.decisionId !== null) {
651
+ return decisionHref(key, event.decisionId);
652
+ }
653
+ return event.data.path === undefined ? undefined : toDoc(event.data.path);
654
+ }}
655
+ empty={<EmptyState title={words.feed.empty} />}
656
+ />
657
+ </>
658
+ );
659
+ }
660
+ if (route.name === "skills") {
661
+ return (
662
+ <>
663
+ <div className="sc-page-head">
664
+ <h1 className="sc-page-title">{words.nav.skills}</h1>
665
+ </div>
666
+ <components.SkillList skills={data.skills} />
667
+ </>
668
+ );
669
+ }
670
+ if (route.name === "members") {
671
+ const me = data.me?.person ?? undefined;
672
+ const configures = owner && !holdsToken(data);
673
+ const members = words.projects.members;
674
+ return (
675
+ <>
676
+ <div className="sc-page-head">
677
+ <h1 className="sc-page-title">{members.title}</h1>
678
+ </div>
679
+ <p className="sc-lead">
680
+ {project.visibility === "workspace" ? members.leadWorkspace : members.leadPrivate}{" "}
681
+ {members.leadRoles}
682
+ </p>
683
+ {error !== undefined && <Callout tone="danger">{error}</Callout>}
684
+ <components.MemberList
685
+ members={data.members}
686
+ people={data.people}
687
+ me={me}
688
+ busy={busy}
689
+ onAdd={configures ? (input) => void act(() => data.actions.addMember(input)) : undefined}
690
+ onChangeRole={
691
+ configures
692
+ ? (member, role) =>
693
+ void act(() => data.actions.updateMember(member.person.id, { role }))
694
+ : undefined
695
+ }
696
+ onRemove={
697
+ configures
698
+ ? (member) => void act(() => data.actions.removeMember(member.person.id))
699
+ : undefined
700
+ }
701
+ empty={
702
+ <EmptyState
703
+ title={members.empty.title}
704
+ body={
705
+ project.visibility === "workspace" ? members.empty.workspace : members.empty.private
706
+ }
707
+ />
708
+ }
709
+ />
710
+ </>
711
+ );
712
+ }
713
+ if (route.name === "settings") {
714
+ const settings = words.projects.settings;
715
+ if (!owner) {
716
+ return <EmptyState title={settings.onlyOwner.title} body={settings.onlyOwner.body} />;
717
+ }
718
+ return (
719
+ <>
720
+ <div className="sc-page-head">
721
+ <h1 className="sc-page-title">{settings.title}</h1>
722
+ </div>
723
+ <p className="sc-lead">
724
+ {settings.leadBefore}
725
+ <code>{project.key}</code>
726
+ {settings.leadAfter}
727
+ </p>
728
+ {error !== undefined && <Callout tone="danger">{error}</Callout>}
729
+ <section className="sc-panel sc-panel-form" aria-label={settings.aria}>
730
+ <ProjectForm
731
+ project={project}
732
+ busy={busy}
733
+ onSubmit={(input) =>
734
+ void act(() => data.actions.updateProject(input as RestProjectPatch))
735
+ }
736
+ />
737
+ </section>
738
+ </>
739
+ );
740
+ }
741
+ return <NothingHere />;
742
+ }
743
+
744
+ function NothingHere() {
745
+ const words = useWords();
746
+ return (
747
+ <EmptyState
748
+ title={words.common.nothingHere.title}
749
+ body={words.common.nothingHere.body}
750
+ action={<LinkButton href={PATHS.projects}>{words.nav.projects}</LinkButton>}
751
+ />
752
+ );
753
+ }
754
+
755
+ /** One person's agents, for an administrator: read on their own, and taken away from here. */
756
+ function PersonAgentsPage(props: {
757
+ readonly id: string;
758
+ readonly data: ConsoleData;
759
+ readonly components: ConsoleComponents;
760
+ }) {
761
+ const words = useWords();
762
+ const { id, data, components } = props;
763
+ const me = data.me?.person ?? undefined;
764
+ const administrator = me?.role === "admin";
765
+ const person = data.people.find((candidate) => candidate.id === id);
766
+ const [tokens, setTokens] = useState<readonly RestToken[] | undefined>(undefined);
767
+ const [generation, setGeneration] = useState(0);
768
+ const { busy, error, act } = useAction();
769
+ const { personTokens } = data.actions;
770
+ // biome-ignore lint/correctness/useExhaustiveDependencies: `generation` is the trigger
771
+ useEffect(() => {
772
+ if (!administrator) {
773
+ return;
774
+ }
775
+ const controller = new AbortController();
776
+ personTokens(id, controller.signal)
777
+ .then((answer) => {
778
+ if (!controller.signal.aborted) {
779
+ setTokens(answer.items);
780
+ }
781
+ })
782
+ .catch(() => undefined);
783
+ return () => controller.abort();
784
+ }, [id, administrator, personTokens, generation]);
785
+ if (!administrator) {
786
+ return (
787
+ <EmptyState
788
+ title={words.people.onlyAdmin.title}
789
+ body={words.people.onlyAdmin.body}
790
+ action={<LinkButton href={PATHS.agents}>{words.nav.agents}</LinkButton>}
791
+ />
792
+ );
793
+ }
794
+ return (
795
+ <>
796
+ <div className="sc-page-head">
797
+ <h1 className="sc-page-title">{words.people.agentsOf(person?.login ?? "?")}</h1>
798
+ <LinkButton href={PATHS.people}>{words.nav.people}</LinkButton>
799
+ </div>
800
+ <p className="sc-lead">
801
+ {words.people.agentsLead(person?.name ?? person?.login ?? words.feed.someone)}
802
+ </p>
803
+ {error !== undefined && <Callout tone="danger">{error}</Callout>}
804
+ {tokens === undefined ? (
805
+ <div className="sc-loading">
806
+ <Spinner label={words.people.loadingAgents} />
807
+ </div>
808
+ ) : (
809
+ <components.TokenList
810
+ tokens={tokens}
811
+ busy={busy}
812
+ onRevoke={(token) =>
813
+ void act(async () => {
814
+ await data.actions.revokePersonToken(id, token.id);
815
+ setGeneration((current) => current + 1);
816
+ })
817
+ }
818
+ empty={<EmptyState title={words.people.noAgent} />}
819
+ />
820
+ )}
821
+ </>
822
+ );
823
+ }
824
+
825
+ function WorkspacePage(props: {
826
+ readonly route: Route;
827
+ readonly data: ConsoleData;
828
+ readonly components: ConsoleComponents;
829
+ readonly navigation: Navigation;
830
+ }) {
831
+ const words = useWords();
832
+ const { route, data, components, navigation } = props;
833
+ const [fresh, setFresh] = useState<RestTokenCreated | undefined>(undefined);
834
+ const { busy, error, act } = useAction();
835
+
836
+ if (route.name === "people") {
837
+ const me = data.me?.person ?? undefined;
838
+ const administrator = me?.role === "admin" && !holdsToken(data);
839
+ return (
840
+ <>
841
+ <div className="sc-page-head">
842
+ <h1 className="sc-page-title">{words.people.title}</h1>
843
+ </div>
844
+ <p className="sc-lead">{words.people.lead}</p>
845
+ {error !== undefined && <Callout tone="danger">{error}</Callout>}
846
+ <components.PeopleList
847
+ people={data.people}
848
+ me={me}
849
+ busy={busy}
850
+ onChangeRole={
851
+ administrator
852
+ ? (person, role) => void act(() => data.actions.updatePerson(person.id, { role }))
853
+ : undefined
854
+ }
855
+ agentsHref={administrator ? (person) => personAgentsHref(person.id) : undefined}
856
+ />
857
+ </>
858
+ );
859
+ }
860
+ if (route.name === "person-agents") {
861
+ return <PersonAgentsPage id={route.id} data={data} components={components} />;
862
+ }
863
+ if (route.name === "connect") {
864
+ return <ConnectPage code={route.code} data={data} navigation={navigation} />;
865
+ }
866
+ if (route.name === "agents" || route.name === "new-token") {
867
+ const agents = words.agents;
868
+ const making = route.name === "new-token";
869
+ const origin = typeof window === "undefined" ? "" : window.location.origin;
870
+ return (
871
+ <>
872
+ <div className="sc-page-head">
873
+ <h1 className="sc-page-title">{agents.title}</h1>
874
+ {!making && (
875
+ <LinkButton href={PATHS.newToken} variant="primary">
876
+ {agents.makeByHand}
877
+ </LinkButton>
878
+ )}
879
+ </div>
880
+ <p className="sc-lead">{agents.lead}</p>
881
+ {error !== undefined && <Callout tone="danger">{error}</Callout>}
882
+ {fresh !== undefined && (
883
+ <NewToken token={fresh.token} secret={fresh.secret} onDone={() => setFresh(undefined)} />
884
+ )}
885
+ {making && (
886
+ <section className="sc-panel sc-panel-form" aria-label={agents.makeAria}>
887
+ <TokenForm
888
+ daysAtMost={data.me?.workspace.tokenDaysAtMost}
889
+ busy={busy}
890
+ onSubmit={(input) =>
891
+ void act(async () => {
892
+ setFresh(await data.actions.createToken(input));
893
+ navigation.replace(PATHS.agents);
894
+ })
895
+ }
896
+ onCancel={() => navigation.back(PATHS.agents)}
897
+ />
898
+ </section>
899
+ )}
900
+ <components.TokenList
901
+ tokens={data.tokens}
902
+ busy={busy}
903
+ onRevoke={(token) => void act(() => data.actions.revokeToken(token.id))}
904
+ empty={<EmptyState title={agents.empty.title} body={agents.empty.body} />}
905
+ />
906
+ <ConnectWords origin={origin} />
907
+ </>
908
+ );
909
+ }
910
+ return <NothingHere />;
911
+ }
912
+
913
+ export function createConsole(config: ConsoleConfig = {}): ConsoleApp {
914
+ const components: ConsoleComponents = { ...DEFAULT_COMPONENTS, ...config.components };
915
+ const client = config.client ?? createClient({ baseUrl: config.baseUrl ?? "" });
916
+ const fallbackTitle = config.title ?? "Console";
917
+ const languages = config.languages ?? DEFAULT_LANGUAGES;
918
+
919
+ function App(props: { readonly initialPath?: string | undefined }) {
920
+ const { location, navigation } = useNavigation(props.initialPath);
921
+ useFollowLinks(navigation);
922
+ const route = useMemo(
923
+ () => matchRoute(location.pathname, location.search),
924
+ [location.pathname, location.search],
925
+ );
926
+ const projectKey = projectOf(route);
927
+ const data = useConsoleData(client, projectKey);
928
+ const failure = signInFailureOf(location.search);
929
+ const title = data.me?.workspace.name ?? fallbackTitle;
930
+ const viaToken = holdsToken(data);
931
+ // The language: the person's choice, else the browser's, else the first the console speaks.
932
+ // A console that holds a token cannot keep the choice on the person, and keeps it in the
933
+ // browser instead.
934
+ const [ownChoice, setOwnChoice] = useState<string | undefined>(storedLanguage);
935
+ const chosen = viaToken ? (ownChoice ?? data.me?.language) : data.me?.language;
936
+ const pack = useMemo(
937
+ () =>
938
+ chooseLanguage(
939
+ chosen,
940
+ typeof navigator === "undefined" ? [] : navigator.languages,
941
+ languages,
942
+ ),
943
+ [chosen],
944
+ );
945
+ const words = pack.messages;
946
+
947
+ // An address that moved is shown at its new one, in its place.
948
+ useEffect(() => {
949
+ if (route.name === "moved") {
950
+ navigation.replace(route.to);
951
+ }
952
+ }, [route, navigation]);
953
+
954
+ useEffect(() => {
955
+ if (typeof document !== "undefined") {
956
+ document.title = data.project === undefined ? title : `${data.project.name} · ${title}`;
957
+ document.documentElement.lang = pack.tag;
958
+ }
959
+ }, [title, data.project, pack.tag]);
960
+
961
+ let content: React.ReactNode;
962
+ if (data.me === undefined) {
963
+ content = (
964
+ <Ground>
965
+ <div className="sc-loading">
966
+ {data.error === undefined ? (
967
+ <Spinner label={words.common.loading} />
968
+ ) : (
969
+ <Callout
970
+ tone="danger"
971
+ title={words.common.couldNotReach}
972
+ action={<Button onClick={data.reload}>{words.common.tryAgain}</Button>}
973
+ >
974
+ {data.error}
975
+ </Callout>
976
+ )}
977
+ </div>
978
+ </Ground>
979
+ );
980
+ } else if (data.me.person === null) {
981
+ content = (
982
+ <Ground>
983
+ <components.SignIn
984
+ title={title}
985
+ brand={config.brand}
986
+ providers={data.me.signIn}
987
+ returnTo={withoutSignInParam(location.pathname, location.search)}
988
+ failure={failure}
989
+ />
990
+ </Ground>
991
+ );
992
+ } else {
993
+ const project = data.project;
994
+ const waiting = data.decisions.filter((decision) => decision.answer === null).length;
995
+ const workspaceNav: NavItem[] = [
996
+ {
997
+ href: PATHS.projects,
998
+ label: words.nav.projects,
999
+ current: route.name === "projects" || route.name === "new-project",
1000
+ },
1001
+ {
1002
+ href: PATHS.people,
1003
+ label: words.nav.people,
1004
+ current: route.name === "people" || route.name === "person-agents",
1005
+ },
1006
+ ...(viaToken
1007
+ ? []
1008
+ : [
1009
+ {
1010
+ href: PATHS.agents,
1011
+ label: words.nav.agents,
1012
+ current: route.name === "agents" || route.name === "new-token",
1013
+ },
1014
+ ]),
1015
+ ];
1016
+ const nav: NavItem[] =
1017
+ projectKey === undefined
1018
+ ? workspaceNav
1019
+ : [
1020
+ {
1021
+ href: projectHref(projectKey),
1022
+ label: words.nav.board,
1023
+ current:
1024
+ route.name === "board" || route.name === "task" || route.name === "new-task",
1025
+ },
1026
+ {
1027
+ href: projectHref(projectKey, "docs"),
1028
+ label: words.nav.docs,
1029
+ current: route.name === "docs",
1030
+ },
1031
+ {
1032
+ href: projectHref(projectKey, "decisions"),
1033
+ label: words.nav.decisions,
1034
+ current:
1035
+ route.name === "decisions" ||
1036
+ route.name === "decision" ||
1037
+ route.name === "new-decision",
1038
+ count: waiting,
1039
+ },
1040
+ {
1041
+ href: projectHref(projectKey, "feed"),
1042
+ label: words.nav.feed,
1043
+ current: route.name === "feed",
1044
+ },
1045
+ {
1046
+ href: projectHref(projectKey, "skills"),
1047
+ label: words.nav.skills,
1048
+ current: route.name === "skills",
1049
+ },
1050
+ {
1051
+ href: projectHref(projectKey, "members"),
1052
+ label: words.nav.members,
1053
+ current: route.name === "members",
1054
+ },
1055
+ ...(project?.role === "owner" && !viaToken
1056
+ ? [
1057
+ {
1058
+ href: projectHref(projectKey, "settings"),
1059
+ label: words.nav.settings,
1060
+ current: route.name === "settings",
1061
+ },
1062
+ ]
1063
+ : []),
1064
+ ];
1065
+ let page: React.ReactNode;
1066
+ if (!data.loaded || route.name === "moved") {
1067
+ page = (
1068
+ <div className="sc-loading">
1069
+ <Spinner label={words.common.loadingWorkspace} />
1070
+ </div>
1071
+ );
1072
+ } else if (viaToken && onlyOnOwnPages(route)) {
1073
+ page = <NotWithToken />;
1074
+ } else if (route.name === "projects" || route.name === "new-project") {
1075
+ page = (
1076
+ <ProjectsPage
1077
+ data={data}
1078
+ components={components}
1079
+ navigation={navigation}
1080
+ making={route.name === "new-project"}
1081
+ />
1082
+ );
1083
+ } else if (projectKey === undefined) {
1084
+ page = (
1085
+ <WorkspacePage
1086
+ route={route}
1087
+ data={data}
1088
+ components={components}
1089
+ navigation={navigation}
1090
+ />
1091
+ );
1092
+ } else if (data.projectError !== undefined) {
1093
+ page = (
1094
+ <EmptyState
1095
+ title={words.common.noSuchProject.title}
1096
+ body={words.common.noSuchProject.body}
1097
+ action={<LinkButton href={PATHS.projects}>{words.nav.projects}</LinkButton>}
1098
+ />
1099
+ );
1100
+ } else if (!data.projectLoaded || project === undefined) {
1101
+ page = (
1102
+ <div className="sc-loading">
1103
+ <Spinner label={words.common.loadingProject} />
1104
+ </div>
1105
+ );
1106
+ } else {
1107
+ page = (
1108
+ <ProjectPage
1109
+ route={route}
1110
+ project={project}
1111
+ data={data}
1112
+ components={components}
1113
+ navigation={navigation}
1114
+ />
1115
+ );
1116
+ }
1117
+ content = (
1118
+ <components.Shell
1119
+ title={title}
1120
+ brand={config.brand}
1121
+ project={
1122
+ projectKey === undefined
1123
+ ? undefined
1124
+ : { name: project?.name ?? projectKey, href: projectHref(projectKey) }
1125
+ }
1126
+ nav={nav}
1127
+ menu={workspaceNav}
1128
+ languages={languages.map((candidate) => ({
1129
+ tag: candidate.tag,
1130
+ label: candidate.label,
1131
+ current: candidate.tag === pack.tag,
1132
+ }))}
1133
+ onLanguage={(tag) => {
1134
+ if (viaToken) {
1135
+ storeLanguage(tag);
1136
+ setOwnChoice(tag);
1137
+ } else {
1138
+ void data.actions.updateMe({ language: tag }).catch(() => undefined);
1139
+ }
1140
+ }}
1141
+ person={data.me.person}
1142
+ live={projectKey === undefined ? undefined : data.live}
1143
+ wide={route.name === "board"}
1144
+ onNavigate={(href) => navigation.go(href)}
1145
+ onSignOut={viaToken ? undefined : () => void data.actions.signOut()}
1146
+ >
1147
+ {page}
1148
+ </components.Shell>
1149
+ );
1150
+ }
1151
+ return <LanguageContext.Provider value={pack}>{content}</LanguageContext.Provider>;
1152
+ }
1153
+
1154
+ return {
1155
+ App,
1156
+ mount(container) {
1157
+ const root: Root = createRoot(container);
1158
+ root.render(
1159
+ <StrictMode>
1160
+ <App />
1161
+ </StrictMode>,
1162
+ );
1163
+ return () => root.unmount();
1164
+ },
1165
+ };
1166
+ }