@skillcdn/console 0.1.11 → 0.1.13

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 (71) hide show
  1. package/README.md +8 -5
  2. package/dist/cli/cli.d.ts.map +1 -1
  3. package/dist/cli/cli.js +260 -6
  4. package/dist/cli/cli.js.map +1 -1
  5. package/dist/cli/format.d.ts +7 -1
  6. package/dist/cli/format.d.ts.map +1 -1
  7. package/dist/cli/format.js +22 -0
  8. package/dist/cli/format.js.map +1 -1
  9. package/dist/cli/help.d.ts.map +1 -1
  10. package/dist/cli/help.js +69 -2
  11. package/dist/cli/help.js.map +1 -1
  12. package/dist/components/board.d.ts.map +1 -1
  13. package/dist/components/board.js +2 -2
  14. package/dist/components/board.js.map +1 -1
  15. package/dist/components/calendar.js +2 -2
  16. package/dist/components/calendar.js.map +1 -1
  17. package/dist/components/people.js +2 -2
  18. package/dist/components/people.js.map +1 -1
  19. package/dist/components/projects.d.ts.map +1 -1
  20. package/dist/components/projects.js +3 -3
  21. package/dist/components/projects.js.map +1 -1
  22. package/dist/components/runs.js +2 -2
  23. package/dist/components/runs.js.map +1 -1
  24. package/dist/components/shell.js +2 -2
  25. package/dist/components/shell.js.map +1 -1
  26. package/dist/components/task-form.js +2 -2
  27. package/dist/components/task-form.js.map +1 -1
  28. package/dist/components/ui.d.ts +7 -2
  29. package/dist/components/ui.d.ts.map +1 -1
  30. package/dist/components/ui.js +9 -4
  31. package/dist/components/ui.js.map +1 -1
  32. package/dist/console.d.ts +41 -6
  33. package/dist/console.d.ts.map +1 -1
  34. package/dist/console.js +61 -50
  35. package/dist/console.js.map +1 -1
  36. package/dist/i18n/en.d.ts +5 -5
  37. package/dist/i18n/en.d.ts.map +1 -1
  38. package/dist/i18n/en.js +8 -8
  39. package/dist/i18n/en.js.map +1 -1
  40. package/dist/i18n/ko.d.ts.map +1 -1
  41. package/dist/i18n/ko.js +8 -8
  42. package/dist/i18n/ko.js.map +1 -1
  43. package/dist/index.d.ts +3 -3
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js +3 -3
  46. package/dist/index.js.map +1 -1
  47. package/dist/router.d.ts +15 -0
  48. package/dist/router.d.ts.map +1 -1
  49. package/dist/router.js +17 -0
  50. package/dist/router.js.map +1 -1
  51. package/docs/specs/cli.md +15 -2
  52. package/docs/specs/rest.md +13 -13
  53. package/package.json +1 -1
  54. package/skills/building-a-console/SKILL.md +4 -4
  55. package/skills/working-the-board/SKILL.md +5 -1
  56. package/src/cli/cli.ts +333 -5
  57. package/src/cli/format.ts +27 -0
  58. package/src/cli/help.ts +86 -1
  59. package/src/components/board.tsx +11 -2
  60. package/src/components/calendar.tsx +2 -2
  61. package/src/components/people.tsx +5 -5
  62. package/src/components/projects.tsx +5 -3
  63. package/src/components/runs.tsx +2 -2
  64. package/src/components/shell.tsx +3 -3
  65. package/src/components/task-form.tsx +2 -2
  66. package/src/components/ui.tsx +14 -5
  67. package/src/console.tsx +115 -54
  68. package/src/i18n/en.ts +9 -8
  69. package/src/i18n/ko.ts +9 -8
  70. package/src/index.ts +5 -0
  71. package/src/router.ts +26 -0
@@ -2,7 +2,7 @@ import type { ReactNode } from "react";
2
2
  import type { RestArtifact, RestRun, RunStatus } from "../api.js";
3
3
  import { useWords } from "../i18n/index.js";
4
4
  import { Markdown } from "./markdown.js";
5
- import { Badge, Button, formatBytes, PersonChip, Time } from "./ui.js";
5
+ import { Badge, Button, formatBytes, PersonChip, personName, Time } from "./ui.js";
6
6
 
7
7
  // A run: one agent at work on one task for one person, with what it reported, what it handed
8
8
  // in, and what it waits for. Takes its data as props and nothing from the network.
@@ -49,7 +49,7 @@ export function RunCard(props: RunCardProps) {
49
49
  const { run } = props;
50
50
  const open = run.status === "running" || run.status === "waiting";
51
51
  return (
52
- <article className="sc-run" aria-label={words.label(run.agent, run.person.login)}>
52
+ <article className="sc-run" aria-label={words.label(run.agent, personName(run.person))}>
53
53
  <header className="sc-run-header">
54
54
  <span className="sc-run-agent">{run.agent}</span>
55
55
  <RunStatusBadge status={run.status} />
@@ -1,7 +1,7 @@
1
1
  import { type ReactNode, useEffect, useRef } from "react";
2
2
  import type { RestPerson } from "../api.js";
3
3
  import { useWords } from "../i18n/index.js";
4
- import { Avatar, cx } from "./ui.js";
4
+ import { Avatar, cx, personName } from "./ui.js";
5
5
 
6
6
  // The frame every page sits in (ADR-0010): a header that names the workspace and the project the
7
7
  // page is on, with the person's menu at its end; a row of tabs below it with the pages of the
@@ -109,9 +109,9 @@ function PersonMenu(props: {
109
109
  const { person } = props;
110
110
  return (
111
111
  <details className="sc-menu" ref={details}>
112
- <summary className="sc-menu-summary" aria-label={words.shell.menu(person.login)}>
112
+ <summary className="sc-menu-summary" aria-label={words.shell.menu(personName(person))}>
113
113
  <Avatar person={person} size="sm" />
114
- <span className="sc-person-login">{person.login}</span>
114
+ <span className="sc-person-login">{personName(person)}</span>
115
115
  <svg className="sc-menu-caret" viewBox="0 0 16 16" aria-hidden="true">
116
116
  <path
117
117
  d="M4 6l4 4 4-4"
@@ -14,7 +14,7 @@ import {
14
14
  TASK_STATES,
15
15
  } from "../api.js";
16
16
  import { useWords } from "../i18n/index.js";
17
- import { Button } from "./ui.js";
17
+ import { Button, personName } from "./ui.js";
18
18
 
19
19
  // Writing a task, or changing one. The bounds are the package's, said before a request is made.
20
20
  // The plan is two days (ADR-0019); what happened to the task is the board's to keep, not a
@@ -151,7 +151,7 @@ export function TaskForm(props: TaskFormProps) {
151
151
  <option value="">{words.common.nobody}</option>
152
152
  {props.people.map((person) => (
153
153
  <option key={person.id} value={person.id}>
154
- {person.login}
154
+ {personName(person)}
155
155
  </option>
156
156
  ))}
157
157
  </select>
@@ -74,13 +74,22 @@ export function Callout(props: {
74
74
  );
75
75
  }
76
76
 
77
+ /** What a person is called on the pages: the name the host gives, or the login where there is none. */
78
+ export function personName(person: {
79
+ readonly login: string;
80
+ readonly name: string | null;
81
+ }): string {
82
+ const name = person.name?.trim() ?? "";
83
+ return name === "" ? person.login : name;
84
+ }
85
+
77
86
  /**
78
87
  * A person's picture as the git host serves it: loaded lazily, without a referrer, and gone as
79
- * a whole when there is none or it cannot be loaded, leaving the initial of the login instead.
88
+ * a whole when there is none or it cannot be loaded, leaving the initial of the name instead.
80
89
  */
81
90
  export function Avatar(props: { readonly person: RestPerson; readonly size?: "sm" | "md" | "lg" }) {
82
91
  const size = props.size ?? "md";
83
- const initial = props.person.login.slice(0, 1).toUpperCase();
92
+ const initial = personName(props.person).slice(0, 1).toUpperCase();
84
93
  if (props.person.avatar === null) {
85
94
  return (
86
95
  <span
@@ -103,12 +112,12 @@ export function Avatar(props: { readonly person: RestPerson; readonly size?: "sm
103
112
  );
104
113
  }
105
114
 
106
- /** A person, as a picture and a name, wherever one is named. */
115
+ /** A person, as a picture and their name, wherever one is named; the login is there on hover. */
107
116
  export function PersonChip(props: { readonly person: RestPerson; readonly size?: "sm" | "md" }) {
108
117
  return (
109
- <span className="sc-person" title={props.person.name ?? props.person.login}>
118
+ <span className="sc-person" title={props.person.login}>
110
119
  <Avatar person={props.person} size={props.size ?? "sm"} />
111
- <span className="sc-person-login">{props.person.login}</span>
120
+ <span className="sc-person-login">{personName(props.person)}</span>
112
121
  </span>
113
122
  );
114
123
  }
package/src/console.tsx CHANGED
@@ -1,4 +1,12 @@
1
- import { type ComponentType, StrictMode, useCallback, useEffect, useMemo, useState } from "react";
1
+ import {
2
+ type ComponentType,
3
+ type ReactNode,
4
+ StrictMode,
5
+ useCallback,
6
+ useEffect,
7
+ useMemo,
8
+ useState,
9
+ } from "react";
2
10
  import { createRoot, type Root } from "react-dom/client";
3
11
  import {
4
12
  type ConsoleClient,
@@ -50,7 +58,7 @@ import { TaskForm } from "./components/task-form.js";
50
58
  import { TaskList } from "./components/task-list.js";
51
59
  import { TaskView, type TaskViewProps } from "./components/task-view.js";
52
60
  import { NewToken, TokenForm, TokenList, type TokenListProps } from "./components/tokens.js";
53
- import { Button, Callout, EmptyState, Spinner } from "./components/ui.js";
61
+ import { Button, Callout, EmptyState, personName, Spinner } from "./components/ui.js";
54
62
  import { WorkList, type WorkListProps } from "./components/work.js";
55
63
  import { ConnectPage } from "./connect-page.js";
56
64
  import { type ConsoleData, useConsoleData } from "./data.js";
@@ -61,11 +69,13 @@ import {
61
69
  DEFAULT_LANGUAGES,
62
70
  LanguageContext,
63
71
  type LanguagePack,
72
+ type Messages,
64
73
  useWords,
65
74
  } from "./i18n/index.js";
66
75
  import {
67
76
  boardHref,
68
77
  calendarHref,
78
+ customPageHref,
69
79
  decisionHref,
70
80
  docHref,
71
81
  goalHref,
@@ -128,6 +138,52 @@ export const DEFAULT_COMPONENTS: ConsoleComponents = {
128
138
  CategoryList,
129
139
  };
130
140
 
141
+ /**
142
+ * A page a console adds of its own, shown as a tab beside the console's: on the workspace, or
143
+ * on every project. The address is `/pages/<key>`, or `/projects/<project>/pages/<key>`.
144
+ */
145
+ export interface CustomPage {
146
+ /** What the address says, and what tells the page apart: lowercase letters, digits and hyphens. */
147
+ readonly key: string;
148
+ /** What the tab says: one word for every language, or a word by language tag, `en` standing in for a tag without one. */
149
+ readonly label: string | Readonly<Record<string, string>>;
150
+ /** Where the tab is: among the workspace's pages, or among each project's. */
151
+ readonly level: "workspace" | "project";
152
+ /** Whether the page takes the whole width of the window, as the board does. */
153
+ readonly wide?: boolean | undefined;
154
+ /** What the page shows, given what the default pages are given. */
155
+ readonly render: (context: CustomPageContext) => ReactNode;
156
+ }
157
+
158
+ /** What a page of a console's own is given: the data the default pages read, and the means they use. */
159
+ export interface CustomPageContext {
160
+ /** The workspace and, on a project, the project the page is on, kept current; with the actions. */
161
+ readonly data: ConsoleData;
162
+ /** The project the page is on, or nothing on the workspace. */
163
+ readonly project: RestProject | undefined;
164
+ /** The client, for what the data does not carry. */
165
+ readonly client: ConsoleClient;
166
+ readonly navigation: Navigation;
167
+ /** The words of the language the pages speak, and its tag. */
168
+ readonly words: Messages;
169
+ readonly language: string;
170
+ }
171
+
172
+ /** What the tab of a page of one's own says in a language: its word for the tag, for the language alone, for English, or the first it has. */
173
+ export function customPageLabel(page: CustomPage, tag: string): string {
174
+ if (typeof page.label === "string") {
175
+ return page.label;
176
+ }
177
+ const language = tag.split("-")[0] ?? tag;
178
+ return (
179
+ page.label[tag] ??
180
+ page.label[language] ??
181
+ page.label.en ??
182
+ Object.values(page.label)[0] ??
183
+ page.key
184
+ );
185
+ }
186
+
131
187
  export interface ConsoleConfig {
132
188
  /** The origin of the console's API. Left out, the page's own origin. */
133
189
  readonly baseUrl?: string | undefined;
@@ -141,6 +197,8 @@ export interface ConsoleConfig {
141
197
  readonly brand?: Brand | undefined;
142
198
  /** The client to talk to the server with; made from `baseUrl` when left out. For tests. */
143
199
  readonly client?: ConsoleClient | undefined;
200
+ /** Pages of the console's own, as tabs beside the default ones. */
201
+ readonly pages?: readonly CustomPage[] | undefined;
144
202
  }
145
203
 
146
204
  export interface ConsoleApp {
@@ -274,13 +332,12 @@ function holdsToken(data: ConsoleData): boolean {
274
332
  }
275
333
 
276
334
  /**
277
- * The pages a console that holds a token does not offer: the tokens themselves and
278
- * configuring, which are a person's own doing on the console's own pages, signed in.
335
+ * The pages a console that holds a token does not offer: the tokens themselves, and the
336
+ * approval of an agent's connection, which makes one; a person's own doing on the console's
337
+ * own pages, signed in. Everything else a person may do, their agent may (ADR-0021).
279
338
  */
280
339
  export function onlyOnOwnPages(route: Route): boolean {
281
340
  return (
282
- route.name === "new-project" ||
283
- route.name === "settings" ||
284
341
  route.name === "agents" ||
285
342
  route.name === "new-token" ||
286
343
  route.name === "person-agents" ||
@@ -293,28 +350,6 @@ function NotWithToken() {
293
350
  return <EmptyState title={words.title} body={words.body} />;
294
351
  }
295
352
 
296
- /**
297
- * Where a console that holds a token keeps the person's choice of language: the browser's own
298
- * storage, which may be absent or refuse, and then the browser's languages decide.
299
- */
300
- const LANGUAGE_KEY = "sc-language";
301
-
302
- function storedLanguage(): string | undefined {
303
- try {
304
- return globalThis.localStorage?.getItem(LANGUAGE_KEY) ?? undefined;
305
- } catch {
306
- return undefined;
307
- }
308
- }
309
-
310
- function storeLanguage(tag: string): void {
311
- try {
312
- globalThis.localStorage?.setItem(LANGUAGE_KEY, tag);
313
- } catch {
314
- // A browser that keeps nothing: the choice holds for this page.
315
- }
316
- }
317
-
318
353
  /** A link that reads as a button: where a form is, for instance. */
319
354
  function LinkButton(props: {
320
355
  readonly href: string;
@@ -339,12 +374,11 @@ function ProjectsPage(props: {
339
374
  const { data, components, navigation, making } = props;
340
375
  const { busy, error, act } = useAction();
341
376
  const href = (project: RestProject) => projectHref(project.key);
342
- const configures = !holdsToken(data);
343
377
  return (
344
378
  <>
345
379
  <div className="sc-page-head">
346
380
  <h1 className="sc-page-title">{words.title}</h1>
347
- {!making && configures && (
381
+ {!making && (
348
382
  <LinkButton href={PATHS.newProject} variant="primary">
349
383
  {words.newProject}
350
384
  </LinkButton>
@@ -377,11 +411,7 @@ function ProjectsPage(props: {
377
411
  <EmptyState
378
412
  title={words.empty.title}
379
413
  body={words.empty.body}
380
- action={
381
- configures ? (
382
- <LinkButton href={PATHS.newProject}>{words.newProject}</LinkButton>
383
- ) : undefined
384
- }
414
+ action={<LinkButton href={PATHS.newProject}>{words.newProject}</LinkButton>}
385
415
  />
386
416
  }
387
417
  />
@@ -908,7 +938,7 @@ function ProjectPage(props: {
908
938
  }
909
939
  if (route.name === "members") {
910
940
  const me = data.me?.person ?? undefined;
911
- const configures = owner && !holdsToken(data);
941
+ const configures = owner;
912
942
  const members = words.projects.members;
913
943
  return (
914
944
  <>
@@ -1047,7 +1077,9 @@ function PersonAgentsPage(props: {
1047
1077
  return (
1048
1078
  <>
1049
1079
  <div className="sc-page-head">
1050
- <h1 className="sc-page-title">{words.people.agentsOf(person?.login ?? "?")}</h1>
1080
+ <h1 className="sc-page-title">
1081
+ {words.people.agentsOf(person === undefined ? "?" : personName(person))}
1082
+ </h1>
1051
1083
  <LinkButton href={PATHS.people}>{words.nav.people}</LinkButton>
1052
1084
  </div>
1053
1085
  <p className="sc-lead">
@@ -1140,7 +1172,7 @@ function WorkspacePage(props: {
1140
1172
  }
1141
1173
  if (route.name === "categories") {
1142
1174
  const me = data.me?.person ?? undefined;
1143
- const administrator = me?.role === "admin" && !holdsToken(data);
1175
+ const administrator = me?.role === "admin";
1144
1176
  const categories = words.categories;
1145
1177
  return (
1146
1178
  <>
@@ -1179,7 +1211,7 @@ function WorkspacePage(props: {
1179
1211
 
1180
1212
  if (route.name === "people") {
1181
1213
  const me = data.me?.person ?? undefined;
1182
- const administrator = me?.role === "admin" && !holdsToken(data);
1214
+ const administrator = me?.role === "admin";
1183
1215
  return (
1184
1216
  <>
1185
1217
  <div className="sc-page-head">
@@ -1259,6 +1291,7 @@ export function createConsole(config: ConsoleConfig = {}): ConsoleApp {
1259
1291
  const client = config.client ?? createClient({ baseUrl: config.baseUrl ?? "" });
1260
1292
  const fallbackTitle = config.title ?? "Console";
1261
1293
  const languages = config.languages ?? DEFAULT_LANGUAGES;
1294
+ const pages = config.pages ?? [];
1262
1295
 
1263
1296
  function App(props: { readonly initialPath?: string | undefined }) {
1264
1297
  const { location, navigation } = useNavigation(props.initialPath);
@@ -1273,10 +1306,7 @@ export function createConsole(config: ConsoleConfig = {}): ConsoleApp {
1273
1306
  const title = data.me?.workspace.name ?? fallbackTitle;
1274
1307
  const viaToken = holdsToken(data);
1275
1308
  // The language: the person's choice, else the browser's, else the first the console speaks.
1276
- // A console that holds a token cannot keep the choice on the person, and keeps it in the
1277
- // browser instead.
1278
- const [ownChoice, setOwnChoice] = useState<string | undefined>(storedLanguage);
1279
- const chosen = viaToken ? (ownChoice ?? data.me?.language) : data.me?.language;
1309
+ const chosen = data.me?.language;
1280
1310
  const pack = useMemo(
1281
1311
  () =>
1282
1312
  chooseLanguage(
@@ -1363,6 +1393,16 @@ export function createConsole(config: ConsoleConfig = {}): ConsoleApp {
1363
1393
  label: words.nav.categories,
1364
1394
  current: route.name === "categories",
1365
1395
  },
1396
+ ...pages
1397
+ .filter((candidate) => candidate.level === "workspace")
1398
+ .map((candidate) => ({
1399
+ href: customPageHref(candidate.key),
1400
+ label: customPageLabel(candidate, pack.tag),
1401
+ current:
1402
+ route.name === "custom" &&
1403
+ route.level === "workspace" &&
1404
+ route.page === candidate.key,
1405
+ })),
1366
1406
  ];
1367
1407
  const nav: NavItem[] =
1368
1408
  projectKey === undefined
@@ -1414,7 +1454,7 @@ export function createConsole(config: ConsoleConfig = {}): ConsoleApp {
1414
1454
  label: words.nav.members,
1415
1455
  current: route.name === "members",
1416
1456
  },
1417
- ...(project?.role === "owner" && !viaToken
1457
+ ...(project?.role === "owner"
1418
1458
  ? [
1419
1459
  {
1420
1460
  href: projectHref(projectKey, "settings"),
@@ -1423,8 +1463,27 @@ export function createConsole(config: ConsoleConfig = {}): ConsoleApp {
1423
1463
  },
1424
1464
  ]
1425
1465
  : []),
1466
+ ...pages
1467
+ .filter((candidate) => candidate.level === "project")
1468
+ .map((candidate) => ({
1469
+ href: customPageHref(candidate.key, projectKey),
1470
+ label: customPageLabel(candidate, pack.tag),
1471
+ current:
1472
+ route.name === "custom" &&
1473
+ route.level === "project" &&
1474
+ route.page === candidate.key,
1475
+ })),
1426
1476
  ];
1427
- let page: React.ReactNode;
1477
+ /** A page of the console's own at the address, or nothing there. */
1478
+ const customPageOf = (key: string, level: CustomPage["level"]): ReactNode => {
1479
+ const found = pages.find((candidate) => candidate.key === key && candidate.level === level);
1480
+ return found === undefined ? (
1481
+ <NothingHere />
1482
+ ) : (
1483
+ found.render({ data, project, client, navigation, words, language: pack.tag })
1484
+ );
1485
+ };
1486
+ let page: ReactNode;
1428
1487
  if (!data.loaded || route.name === "moved") {
1429
1488
  page = (
1430
1489
  <div className="sc-loading">
@@ -1442,6 +1501,8 @@ export function createConsole(config: ConsoleConfig = {}): ConsoleApp {
1442
1501
  making={route.name === "new-project"}
1443
1502
  />
1444
1503
  );
1504
+ } else if (route.name === "custom" && route.level === "workspace") {
1505
+ page = customPageOf(route.page, "workspace");
1445
1506
  } else if (projectKey === undefined) {
1446
1507
  page = (
1447
1508
  <WorkspacePage
@@ -1465,6 +1526,8 @@ export function createConsole(config: ConsoleConfig = {}): ConsoleApp {
1465
1526
  <Spinner label={words.common.loadingProject} />
1466
1527
  </div>
1467
1528
  );
1529
+ } else if (route.name === "custom") {
1530
+ page = customPageOf(route.page, "project");
1468
1531
  } else {
1469
1532
  page = (
1470
1533
  <ProjectPage
@@ -1497,17 +1560,15 @@ export function createConsole(config: ConsoleConfig = {}): ConsoleApp {
1497
1560
  label: candidate.label,
1498
1561
  current: candidate.tag === pack.tag,
1499
1562
  }))}
1500
- onLanguage={(tag) => {
1501
- if (viaToken) {
1502
- storeLanguage(tag);
1503
- setOwnChoice(tag);
1504
- } else {
1505
- void data.actions.updateMe({ language: tag }).catch(() => undefined);
1506
- }
1507
- }}
1563
+ onLanguage={(tag) => void data.actions.updateMe({ language: tag }).catch(() => undefined)}
1508
1564
  person={data.me.person}
1509
1565
  live={projectKey === undefined ? undefined : data.live}
1510
- wide={route.name === "board" || route.name === "calendar"}
1566
+ wide={
1567
+ route.name === "board" ||
1568
+ route.name === "calendar" ||
1569
+ (route.name === "custom" &&
1570
+ pages.some((candidate) => candidate.key === route.page && candidate.wide === true))
1571
+ }
1511
1572
  onNavigate={(href) => navigation.go(href)}
1512
1573
  onSignOut={viaToken ? undefined : () => void data.actions.signOut()}
1513
1574
  >
package/src/i18n/en.ts CHANGED
@@ -89,7 +89,7 @@ export const en = {
89
89
  },
90
90
  notWithToken: {
91
91
  title: "Done on the console's own pages",
92
- body: "This console holds a token, which acts as you on the board. Connecting agents, configuring projects and people, and keeping a language are done on the organization's console, signed in.",
92
+ body: "This console holds a token, which acts as you on the board, configuring included. Agents are connected, and tokens made and removed, on the organization's console, signed in.",
93
93
  },
94
94
  you: "(you)",
95
95
  nobody: "Nobody",
@@ -122,7 +122,7 @@ export const en = {
122
122
  live: "Live",
123
123
  reconnecting: "Reconnecting",
124
124
  signOut: "Sign out",
125
- menu: (login: string) => `${login}: menu`,
125
+ menu: (name: string) => `${name}: menu`,
126
126
  },
127
127
 
128
128
  signIn: {
@@ -178,7 +178,7 @@ export const en = {
178
178
  leadRoles:
179
179
  "An owner configures the project; a member works on it. A workspace administrator is an owner of every project.",
180
180
  since: "since",
181
- roleOf: (login: string) => `Role of ${login}`,
181
+ roleOf: (name: string) => `Role of ${name}`,
182
182
  addAria: "Add a member",
183
183
  person: "Person",
184
184
  chooseSomeone: "Choose someone",
@@ -533,7 +533,7 @@ export const en = {
533
533
  answerIt: "answer it",
534
534
  reports: "Reports",
535
535
  handedIn: "Handed in",
536
- label: (agent: string, login: string) => `${agent} for ${login}`,
536
+ label: (agent: string, name: string) => `${agent} for ${name}`,
537
537
  },
538
538
 
539
539
  skills: {
@@ -625,9 +625,9 @@ export const en = {
625
625
  title: "People",
626
626
  list: "People",
627
627
  lead: "Everyone who has signed in. An administrator configures the workspace and says what each person is; a member works in the projects they are in. What a person may do, their agents may do.",
628
- roleOf: (login: string) => `Role of ${login}`,
628
+ roleOf: (name: string) => `Role of ${name}`,
629
629
  agents: "Agents",
630
- agentsOf: (login: string) => `Agents of ${login}`,
630
+ agentsOf: (name: string) => `Agents of ${name}`,
631
631
  agentsLead: (name: string) =>
632
632
  `What works here as ${name}, each with a token of its own. Disconnecting one takes its token away at once.`,
633
633
  loadingAgents: "Loading the agents",
@@ -684,7 +684,7 @@ export const en = {
684
684
  lookingUp: "Looking the code up",
685
685
  notACode: "That is not a code: eight letters and digits, in two groups.",
686
686
  noSuchCode:
687
- "No agent asks to connect with this code. A code is good for ten minutes and for one approval; ask your agent for a new one.",
687
+ "No agent asks to connect with this code. A code is good for thirty minutes and for one approval; ask your agent for a new one.",
688
688
  couldNotAsk: "The console could not be asked.",
689
689
  asks: {
690
690
  title: "An agent asks to connect as you",
@@ -711,7 +711,8 @@ export const en = {
711
711
  words: {
712
712
  title: "Connecting an agent",
713
713
  tellYourAgent: "Tell your agent, in its own chat:",
714
- quote: (origin: string) => `Install the console command and connect to ${origin}.`,
714
+ quote: (origin: string) =>
715
+ `Install @skillcdn/console from npm, run console login --url ${origin}, and tell me the address and the code it shows.`,
715
716
  doesTheRest:
716
717
  "An agent that can run commands does the rest: it installs the command, connects, and shows you an address with a code.",
717
718
  thenApprove:
package/src/i18n/ko.ts CHANGED
@@ -62,7 +62,7 @@ export const ko: Messages = {
62
62
  },
63
63
  notWithToken: {
64
64
  title: "콘솔 자체 페이지에서 하는 일입니다",
65
- body: "이 콘솔은 토큰으로 당신을 대신해 보드를 씁니다. 에이전트 연결, 프로젝트와 사람 설정, 언어 저장은 조직 콘솔에 로그인해서 합니다.",
65
+ body: "이 콘솔은 토큰으로 당신을 대신해 보드를 쓰며, 설정도 그렇게 합니다. 에이전트 연결과 토큰 관리는 조직 콘솔에 로그인해서 합니다.",
66
66
  },
67
67
  you: "(나)",
68
68
  nobody: "없음",
@@ -95,7 +95,7 @@ export const ko: Messages = {
95
95
  live: "실시간",
96
96
  reconnecting: "다시 연결하는 중",
97
97
  signOut: "로그아웃",
98
- menu: (login) => `${login}: 메뉴`,
98
+ menu: (name) => `${name}: 메뉴`,
99
99
  },
100
100
 
101
101
  signIn: {
@@ -150,7 +150,7 @@ export const ko: Messages = {
150
150
  leadRoles:
151
151
  "소유자는 프로젝트를 설정하고, 멤버는 프로젝트에서 일합니다. 워크스페이스 관리자는 모든 프로젝트의 소유자입니다.",
152
152
  since: "부터",
153
- roleOf: (login) => `${login}의 역할`,
153
+ roleOf: (name) => `${name}의 역할`,
154
154
  addAria: "멤버 추가",
155
155
  person: "사람",
156
156
  chooseSomeone: "선택하세요",
@@ -501,7 +501,7 @@ export const ko: Messages = {
501
501
  answerIt: "답변하기",
502
502
  reports: "보고",
503
503
  handedIn: "제출물",
504
- label: (agent, login) => `${login}을(를) 위한 ${agent}`,
504
+ label: (agent, name) => `${name}을(를) 위한 ${agent}`,
505
505
  },
506
506
 
507
507
  skills: {
@@ -593,9 +593,9 @@ export const ko: Messages = {
593
593
  title: "사람들",
594
594
  list: "사람들",
595
595
  lead: "로그인한 모든 사람. 관리자는 워크스페이스를 설정하고 각 사람의 역할을 정합니다. 멤버는 속한 프로젝트에서 일합니다. 사람이 할 수 있는 일은 그 사람의 에이전트도 할 수 있습니다.",
596
- roleOf: (login) => `${login}의 역할`,
596
+ roleOf: (name) => `${name}의 역할`,
597
597
  agents: "에이전트",
598
- agentsOf: (login) => `${login}의 에이전트`,
598
+ agentsOf: (name) => `${name}의 에이전트`,
599
599
  agentsLead: (name) =>
600
600
  `${name}(으)로 여기서 일하는 것들입니다. 각각 고유한 토큰을 가집니다. 연결을 끊으면 토큰이 즉시 사라집니다.`,
601
601
  loadingAgents: "에이전트를 불러오는 중",
@@ -652,7 +652,7 @@ export const ko: Messages = {
652
652
  lookingUp: "코드를 찾는 중",
653
653
  notACode: "코드가 아닙니다. 영문자와 숫자 여덟 자, 두 묶음입니다.",
654
654
  noSuchCode:
655
- "이 코드로 연결을 요청한 에이전트가 없습니다. 코드는 10분 동안, 한 번의 승인에만 유효합니다. 에이전트에게 새 코드를 요청하세요.",
655
+ "이 코드로 연결을 요청한 에이전트가 없습니다. 코드는 30분 동안, 한 번의 승인에만 유효합니다. 에이전트에게 새 코드를 요청하세요.",
656
656
  couldNotAsk: "콘솔에 물어볼 수 없습니다.",
657
657
  asks: {
658
658
  title: "에이전트가 나로서 연결하기를 요청합니다",
@@ -678,7 +678,8 @@ export const ko: Messages = {
678
678
  words: {
679
679
  title: "에이전트 연결하기",
680
680
  tellYourAgent: "에이전트의 대화창에서 이렇게 말하세요:",
681
- quote: (origin) => `콘솔 명령을 설치하고 ${origin}에 연결해.`,
681
+ quote: (origin) =>
682
+ `npm으로 @skillcdn/console을 설치하고, console login --url ${origin} 을 실행한 뒤 나오는 주소와 코드를 알려줘.`,
682
683
  doesTheRest:
683
684
  "명령을 실행할 수 있는 에이전트는 나머지를 알아서 합니다. 명령을 설치하고, 연결하고, 코드가 있는 주소를 보여 줍니다.",
684
685
  thenApprove:
package/src/index.ts CHANGED
@@ -127,6 +127,7 @@ export {
127
127
  PRIORITY_LABELS,
128
128
  PriorityBadge,
129
129
  ProgressBar,
130
+ personName,
130
131
  ROLE_LABELS,
131
132
  RoleBadge,
132
133
  Spinner,
@@ -140,7 +141,10 @@ export {
140
141
  type ConsoleApp,
141
142
  type ConsoleComponents,
142
143
  type ConsoleConfig,
144
+ type CustomPage,
145
+ type CustomPageContext,
143
146
  createConsole,
147
+ customPageLabel,
144
148
  DEFAULT_COMPONENTS,
145
149
  } from "./console.js";
146
150
  export { type ConsoleActions, type ConsoleData, useConsoleData } from "./data.js";
@@ -180,6 +184,7 @@ export {
180
184
  boardHref,
181
185
  calendarHref,
182
186
  connectHref,
187
+ customPageHref,
183
188
  type DocsView,
184
189
  decisionHref,
185
190
  docHref,
package/src/router.ts CHANGED
@@ -84,6 +84,14 @@ export type Route =
84
84
  | { readonly name: "connect"; readonly code: string | undefined }
85
85
  /** An address that moved: the page takes the person to `to` in its place. */
86
86
  | { readonly name: "moved"; readonly to: string }
87
+ /** A page a console adds of its own (`createConsole({ pages })`): on the workspace, or on a project. */
88
+ | { readonly name: "custom"; readonly level: "workspace"; readonly page: string }
89
+ | {
90
+ readonly name: "custom";
91
+ readonly level: "project";
92
+ readonly project: string;
93
+ readonly page: string;
94
+ }
87
95
  | { readonly name: "not-found" };
88
96
 
89
97
  /** The pages of a project, after its key. */
@@ -112,6 +120,8 @@ export const PATHS = {
112
120
  newToken: "/agents/new",
113
121
  /** The categories tasks are sorted into, kept by an administrator. */
114
122
  categories: "/categories",
123
+ /** Where the pages a console adds of its own are: `/pages/<key>`, and `/projects/<key>/pages/<page>` on a project. */
124
+ pages: "/pages",
115
125
  /** Where an agent's connection is approved (ADR-0011): `/connect/<code>`, or the code typed. */
116
126
  connect: "/connect",
117
127
  } as const;
@@ -223,6 +233,12 @@ export function matchRoute(pathname: string, search = ""): Route {
223
233
  ? { name: "person-agents", id }
224
234
  : NOT_FOUND;
225
235
  }
236
+ if (path.startsWith(`${PATHS.pages}/`)) {
237
+ const [page, ...more] = path.slice(PATHS.pages.length + 1).split("/");
238
+ return page !== undefined && PROJECT_KEY.test(page) && more.length === 0
239
+ ? { name: "custom", level: "workspace", page }
240
+ : NOT_FOUND;
241
+ }
226
242
  if (!path.startsWith(`${PATHS.project}/`)) {
227
243
  return NOT_FOUND;
228
244
  }
@@ -242,6 +258,11 @@ export function matchRoute(pathname: string, search = ""): Route {
242
258
  const view = docsViewOf(params, docPath);
243
259
  return view === undefined ? NOT_FOUND : { name: "docs", project, path: docPath, view };
244
260
  }
261
+ if (page === "pages") {
262
+ return third !== undefined && PROJECT_KEY.test(third) && rest.length === 0
263
+ ? { name: "custom", level: "project", project, page: third }
264
+ : NOT_FOUND;
265
+ }
245
266
  if (rest.length > 0) {
246
267
  return NOT_FOUND;
247
268
  }
@@ -363,6 +384,11 @@ export function calendarHref(key: string, month?: string, filter: BoardFilter =
363
384
  return parts.length === 0 ? base : `${base}?${parts.join("&")}`;
364
385
  }
365
386
 
387
+ /** Where a page a console adds of its own is: on the workspace, or on a project when its key is given. */
388
+ export function customPageHref(page: string, project?: string): string {
389
+ return project === undefined ? `${PATHS.pages}/${page}` : `${projectHref(project)}/pages/${page}`;
390
+ }
391
+
366
392
  /** Where a goal is set: the form. */
367
393
  export function newGoalHref(key: string): string {
368
394
  return `${PATHS.project}/${key}/roadmap/new`;