@skillcdn/console 0.1.11 → 0.1.12

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 (55) hide show
  1. package/README.md +3 -2
  2. package/dist/components/board.d.ts.map +1 -1
  3. package/dist/components/board.js +2 -2
  4. package/dist/components/board.js.map +1 -1
  5. package/dist/components/calendar.js +2 -2
  6. package/dist/components/calendar.js.map +1 -1
  7. package/dist/components/people.js +2 -2
  8. package/dist/components/people.js.map +1 -1
  9. package/dist/components/projects.d.ts.map +1 -1
  10. package/dist/components/projects.js +3 -3
  11. package/dist/components/projects.js.map +1 -1
  12. package/dist/components/runs.js +2 -2
  13. package/dist/components/runs.js.map +1 -1
  14. package/dist/components/shell.js +2 -2
  15. package/dist/components/shell.js.map +1 -1
  16. package/dist/components/task-form.js +2 -2
  17. package/dist/components/task-form.js.map +1 -1
  18. package/dist/components/ui.d.ts +7 -2
  19. package/dist/components/ui.d.ts.map +1 -1
  20. package/dist/components/ui.js +9 -4
  21. package/dist/components/ui.js.map +1 -1
  22. package/dist/console.d.ts +38 -4
  23. package/dist/console.d.ts.map +1 -1
  24. package/dist/console.js +50 -5
  25. package/dist/console.js.map +1 -1
  26. package/dist/i18n/en.d.ts +5 -5
  27. package/dist/i18n/en.d.ts.map +1 -1
  28. package/dist/i18n/en.js +6 -6
  29. package/dist/i18n/en.js.map +1 -1
  30. package/dist/i18n/ko.d.ts.map +1 -1
  31. package/dist/i18n/ko.js +6 -6
  32. package/dist/i18n/ko.js.map +1 -1
  33. package/dist/index.d.ts +3 -3
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +3 -3
  36. package/dist/index.js.map +1 -1
  37. package/dist/router.d.ts +15 -0
  38. package/dist/router.d.ts.map +1 -1
  39. package/dist/router.js +17 -0
  40. package/dist/router.js.map +1 -1
  41. package/package.json +1 -1
  42. package/skills/building-a-console/SKILL.md +2 -2
  43. package/src/components/board.tsx +11 -2
  44. package/src/components/calendar.tsx +2 -2
  45. package/src/components/people.tsx +5 -5
  46. package/src/components/projects.tsx +5 -3
  47. package/src/components/runs.tsx +2 -2
  48. package/src/components/shell.tsx +3 -3
  49. package/src/components/task-form.tsx +2 -2
  50. package/src/components/ui.tsx +14 -5
  51. package/src/console.tsx +104 -5
  52. package/src/i18n/en.ts +7 -6
  53. package/src/i18n/ko.ts +7 -6
  54. package/src/index.ts +5 -0
  55. package/src/router.ts +26 -0
@@ -1,7 +1,7 @@
1
1
  import type { PersonRole, RestPerson } from "../api.js";
2
2
  import { useWords } from "../i18n/index.js";
3
3
  import { PERSON_ROLES } from "../vocabulary.js";
4
- import { PersonChip, RoleBadge } from "./ui.js";
4
+ import { PersonChip, personName, RoleBadge } from "./ui.js";
5
5
 
6
6
  // The people of the board, with what each may do. An administrator changes roles here; a
7
7
  // member reads. Takes its data as props and nothing from the network.
@@ -24,15 +24,15 @@ export function PeopleList(props: PeopleListProps) {
24
24
  {props.people.map((person) => (
25
25
  <li key={person.id} className="sc-person-row">
26
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>}
27
+ <span className="sc-person-name sc-muted">
28
+ {person.name === null ? "" : person.login}
29
+ {props.me?.id === person.id && <span> {words.common.you}</span>}
30
30
  </span>
31
31
  {props.onChangeRole === undefined ? (
32
32
  <RoleBadge role={person.role} />
33
33
  ) : (
34
34
  <label className="sc-field sc-field-inline">
35
- <span className="sc-visually-hidden">{words.people.roleOf(person.login)}</span>
35
+ <span className="sc-visually-hidden">{words.people.roleOf(personName(person))}</span>
36
36
  <select
37
37
  className="sc-input"
38
38
  value={person.role}
@@ -23,7 +23,7 @@ import {
23
23
  type RestProjectPatch,
24
24
  } from "../api.js";
25
25
  import { useWords } from "../i18n/index.js";
26
- import { Badge, Button, cx, PersonChip, Time } from "./ui.js";
26
+ import { Badge, Button, cx, PersonChip, personName, Time } from "./ui.js";
27
27
 
28
28
  /** A project's mark: its symbol on its colour, so that projects tell apart at a glance. */
29
29
  export function ProjectMark(props: {
@@ -392,7 +392,9 @@ export function MemberList(props: MemberListProps) {
392
392
  <ProjectRoleBadge role={member.role} />
393
393
  ) : (
394
394
  <label className="sc-field sc-field-inline">
395
- <span className="sc-visually-hidden">{members.roleOf(member.person.login)}</span>
395
+ <span className="sc-visually-hidden">
396
+ {members.roleOf(personName(member.person))}
397
+ </span>
396
398
  <select
397
399
  className="sc-input"
398
400
  value={member.role}
@@ -446,7 +448,7 @@ export function MemberList(props: MemberListProps) {
446
448
  <option value="">{members.chooseSomeone}</option>
447
449
  {candidates.map((person) => (
448
450
  <option key={person.id} value={person.id}>
449
- {person.login}
451
+ {personName(person)}
450
452
  </option>
451
453
  ))}
452
454
  </select>
@@ -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 {
@@ -1047,7 +1105,9 @@ function PersonAgentsPage(props: {
1047
1105
  return (
1048
1106
  <>
1049
1107
  <div className="sc-page-head">
1050
- <h1 className="sc-page-title">{words.people.agentsOf(person?.login ?? "?")}</h1>
1108
+ <h1 className="sc-page-title">
1109
+ {words.people.agentsOf(person === undefined ? "?" : personName(person))}
1110
+ </h1>
1051
1111
  <LinkButton href={PATHS.people}>{words.nav.people}</LinkButton>
1052
1112
  </div>
1053
1113
  <p className="sc-lead">
@@ -1259,6 +1319,7 @@ export function createConsole(config: ConsoleConfig = {}): ConsoleApp {
1259
1319
  const client = config.client ?? createClient({ baseUrl: config.baseUrl ?? "" });
1260
1320
  const fallbackTitle = config.title ?? "Console";
1261
1321
  const languages = config.languages ?? DEFAULT_LANGUAGES;
1322
+ const pages = config.pages ?? [];
1262
1323
 
1263
1324
  function App(props: { readonly initialPath?: string | undefined }) {
1264
1325
  const { location, navigation } = useNavigation(props.initialPath);
@@ -1363,6 +1424,16 @@ export function createConsole(config: ConsoleConfig = {}): ConsoleApp {
1363
1424
  label: words.nav.categories,
1364
1425
  current: route.name === "categories",
1365
1426
  },
1427
+ ...pages
1428
+ .filter((candidate) => candidate.level === "workspace")
1429
+ .map((candidate) => ({
1430
+ href: customPageHref(candidate.key),
1431
+ label: customPageLabel(candidate, pack.tag),
1432
+ current:
1433
+ route.name === "custom" &&
1434
+ route.level === "workspace" &&
1435
+ route.page === candidate.key,
1436
+ })),
1366
1437
  ];
1367
1438
  const nav: NavItem[] =
1368
1439
  projectKey === undefined
@@ -1423,8 +1494,27 @@ export function createConsole(config: ConsoleConfig = {}): ConsoleApp {
1423
1494
  },
1424
1495
  ]
1425
1496
  : []),
1497
+ ...pages
1498
+ .filter((candidate) => candidate.level === "project")
1499
+ .map((candidate) => ({
1500
+ href: customPageHref(candidate.key, projectKey),
1501
+ label: customPageLabel(candidate, pack.tag),
1502
+ current:
1503
+ route.name === "custom" &&
1504
+ route.level === "project" &&
1505
+ route.page === candidate.key,
1506
+ })),
1426
1507
  ];
1427
- let page: React.ReactNode;
1508
+ /** A page of the console's own at the address, or nothing there. */
1509
+ const customPageOf = (key: string, level: CustomPage["level"]): ReactNode => {
1510
+ const found = pages.find((candidate) => candidate.key === key && candidate.level === level);
1511
+ return found === undefined ? (
1512
+ <NothingHere />
1513
+ ) : (
1514
+ found.render({ data, project, client, navigation, words, language: pack.tag })
1515
+ );
1516
+ };
1517
+ let page: ReactNode;
1428
1518
  if (!data.loaded || route.name === "moved") {
1429
1519
  page = (
1430
1520
  <div className="sc-loading">
@@ -1442,6 +1532,8 @@ export function createConsole(config: ConsoleConfig = {}): ConsoleApp {
1442
1532
  making={route.name === "new-project"}
1443
1533
  />
1444
1534
  );
1535
+ } else if (route.name === "custom" && route.level === "workspace") {
1536
+ page = customPageOf(route.page, "workspace");
1445
1537
  } else if (projectKey === undefined) {
1446
1538
  page = (
1447
1539
  <WorkspacePage
@@ -1465,6 +1557,8 @@ export function createConsole(config: ConsoleConfig = {}): ConsoleApp {
1465
1557
  <Spinner label={words.common.loadingProject} />
1466
1558
  </div>
1467
1559
  );
1560
+ } else if (route.name === "custom") {
1561
+ page = customPageOf(route.page, "project");
1468
1562
  } else {
1469
1563
  page = (
1470
1564
  <ProjectPage
@@ -1507,7 +1601,12 @@ export function createConsole(config: ConsoleConfig = {}): ConsoleApp {
1507
1601
  }}
1508
1602
  person={data.me.person}
1509
1603
  live={projectKey === undefined ? undefined : data.live}
1510
- wide={route.name === "board" || route.name === "calendar"}
1604
+ wide={
1605
+ route.name === "board" ||
1606
+ route.name === "calendar" ||
1607
+ (route.name === "custom" &&
1608
+ pages.some((candidate) => candidate.key === route.page && candidate.wide === true))
1609
+ }
1511
1610
  onNavigate={(href) => navigation.go(href)}
1512
1611
  onSignOut={viaToken ? undefined : () => void data.actions.signOut()}
1513
1612
  >
package/src/i18n/en.ts CHANGED
@@ -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",
@@ -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
@@ -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: "에이전트를 불러오는 중",
@@ -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`;