@skillcdn/console 0.1.14 → 0.1.15

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 (160) hide show
  1. package/README.md +5 -4
  2. package/SKILLCDN.md +2 -0
  3. package/dist/agent-name.d.ts +3 -0
  4. package/dist/agent-name.d.ts.map +1 -0
  5. package/dist/agent-name.js +9 -0
  6. package/dist/agent-name.js.map +1 -0
  7. package/dist/api.d.ts +6 -4
  8. package/dist/api.d.ts.map +1 -1
  9. package/dist/api.js +6 -4
  10. package/dist/api.js.map +1 -1
  11. package/dist/cli/cli.d.ts +2 -0
  12. package/dist/cli/cli.d.ts.map +1 -1
  13. package/dist/cli/cli.js +215 -22
  14. package/dist/cli/cli.js.map +1 -1
  15. package/dist/cli/format.d.ts +14 -4
  16. package/dist/cli/format.d.ts.map +1 -1
  17. package/dist/cli/format.js +59 -10
  18. package/dist/cli/format.js.map +1 -1
  19. package/dist/cli/help.d.ts.map +1 -1
  20. package/dist/cli/help.js +27 -11
  21. package/dist/cli/help.js.map +1 -1
  22. package/dist/cli/main.js +5 -0
  23. package/dist/cli/main.js.map +1 -1
  24. package/dist/client.d.ts +20 -2
  25. package/dist/client.d.ts.map +1 -1
  26. package/dist/client.js +11 -2
  27. package/dist/client.js.map +1 -1
  28. package/dist/components/calendar.d.ts.map +1 -1
  29. package/dist/components/calendar.js +39 -29
  30. package/dist/components/calendar.js.map +1 -1
  31. package/dist/components/connect.d.ts +4 -1
  32. package/dist/components/connect.d.ts.map +1 -1
  33. package/dist/components/connect.js +7 -3
  34. package/dist/components/connect.js.map +1 -1
  35. package/dist/components/decision-list.d.ts.map +1 -1
  36. package/dist/components/decision-list.js +2 -2
  37. package/dist/components/decision-list.js.map +1 -1
  38. package/dist/components/documents.d.ts +5 -0
  39. package/dist/components/documents.d.ts.map +1 -1
  40. package/dist/components/documents.js +3 -2
  41. package/dist/components/documents.js.map +1 -1
  42. package/dist/components/event-feed.d.ts +1 -1
  43. package/dist/components/event-feed.d.ts.map +1 -1
  44. package/dist/components/event-feed.js +10 -1
  45. package/dist/components/event-feed.js.map +1 -1
  46. package/dist/components/people.d.ts +24 -1
  47. package/dist/components/people.d.ts.map +1 -1
  48. package/dist/components/people.js +35 -3
  49. package/dist/components/people.js.map +1 -1
  50. package/dist/components/projects.d.ts +6 -0
  51. package/dist/components/projects.d.ts.map +1 -1
  52. package/dist/components/projects.js +3 -3
  53. package/dist/components/projects.js.map +1 -1
  54. package/dist/components/rules.d.ts +40 -0
  55. package/dist/components/rules.d.ts.map +1 -0
  56. package/dist/components/rules.js +46 -0
  57. package/dist/components/rules.js.map +1 -0
  58. package/dist/components/runs.d.ts +1 -1
  59. package/dist/components/runs.d.ts.map +1 -1
  60. package/dist/components/runs.js +2 -1
  61. package/dist/components/runs.js.map +1 -1
  62. package/dist/components/skills.d.ts.map +1 -1
  63. package/dist/components/skills.js +5 -1
  64. package/dist/components/skills.js.map +1 -1
  65. package/dist/components/tokens.d.ts.map +1 -1
  66. package/dist/components/tokens.js +2 -9
  67. package/dist/components/tokens.js.map +1 -1
  68. package/dist/components/ui.d.ts +7 -2
  69. package/dist/components/ui.d.ts.map +1 -1
  70. package/dist/components/ui.js +13 -0
  71. package/dist/components/ui.js.map +1 -1
  72. package/dist/components/work.d.ts +5 -1
  73. package/dist/components/work.d.ts.map +1 -1
  74. package/dist/components/work.js +1 -1
  75. package/dist/components/work.js.map +1 -1
  76. package/dist/console.css +219 -13
  77. package/dist/console.d.ts.map +1 -1
  78. package/dist/console.js +197 -43
  79. package/dist/console.js.map +1 -1
  80. package/dist/data.d.ts +19 -2
  81. package/dist/data.d.ts.map +1 -1
  82. package/dist/data.js +27 -1
  83. package/dist/data.js.map +1 -1
  84. package/dist/i18n/en.d.ts +83 -9
  85. package/dist/i18n/en.d.ts.map +1 -1
  86. package/dist/i18n/en.js +81 -11
  87. package/dist/i18n/en.js.map +1 -1
  88. package/dist/i18n/ko.d.ts.map +1 -1
  89. package/dist/i18n/ko.js +81 -11
  90. package/dist/i18n/ko.js.map +1 -1
  91. package/dist/index.d.ts +4 -3
  92. package/dist/index.d.ts.map +1 -1
  93. package/dist/index.js +4 -3
  94. package/dist/index.js.map +1 -1
  95. package/dist/limits.d.ts +4 -0
  96. package/dist/limits.d.ts.map +1 -1
  97. package/dist/limits.js +4 -0
  98. package/dist/limits.js.map +1 -1
  99. package/dist/router.d.ts +30 -2
  100. package/dist/router.d.ts.map +1 -1
  101. package/dist/router.js +50 -10
  102. package/dist/router.js.map +1 -1
  103. package/dist/routes.d.ts +14 -4
  104. package/dist/routes.d.ts.map +1 -1
  105. package/dist/routes.js +17 -2
  106. package/dist/routes.js.map +1 -1
  107. package/dist/rules.d.ts +28 -0
  108. package/dist/rules.d.ts.map +1 -0
  109. package/dist/rules.js +102 -0
  110. package/dist/rules.js.map +1 -0
  111. package/dist/schemas.d.ts +652 -4
  112. package/dist/schemas.d.ts.map +1 -1
  113. package/dist/schemas.js +96 -6
  114. package/dist/schemas.js.map +1 -1
  115. package/dist/vocabulary.d.ts +21 -1
  116. package/dist/vocabulary.d.ts.map +1 -1
  117. package/dist/vocabulary.js +21 -0
  118. package/dist/vocabulary.js.map +1 -1
  119. package/docs/specs/cli.md +13 -9
  120. package/docs/specs/rest.md +32 -8
  121. package/package.json +1 -1
  122. package/skills/building-a-console/SKILL.md +1 -1
  123. package/skills/deploying-a-console/SKILL.md +5 -5
  124. package/skills/deploying-a-console/assets/secrets.example.json +2 -1
  125. package/skills/deploying-a-console/references/access.md +10 -6
  126. package/skills/deploying-a-console/references/resources.md +10 -7
  127. package/skills/working-the-board/SKILL.md +10 -9
  128. package/skills/working-the-board/references/project-rules.md +36 -0
  129. package/src/agent-name.ts +9 -0
  130. package/src/api.ts +37 -0
  131. package/src/cli/cli.ts +253 -24
  132. package/src/cli/format.ts +74 -10
  133. package/src/cli/help.ts +30 -10
  134. package/src/cli/main.ts +5 -0
  135. package/src/client.ts +47 -1
  136. package/src/components/calendar.tsx +63 -50
  137. package/src/components/connect.tsx +12 -6
  138. package/src/components/decision-list.tsx +2 -1
  139. package/src/components/documents.tsx +7 -3
  140. package/src/components/event-feed.tsx +16 -3
  141. package/src/components/people.tsx +244 -35
  142. package/src/components/projects.tsx +31 -8
  143. package/src/components/rules.tsx +167 -0
  144. package/src/components/runs.tsx +4 -2
  145. package/src/components/skills.tsx +22 -10
  146. package/src/components/tokens.tsx +2 -14
  147. package/src/components/ui.tsx +25 -2
  148. package/src/components/work.tsx +34 -28
  149. package/src/console.tsx +523 -45
  150. package/src/data.ts +52 -2
  151. package/src/i18n/en.ts +91 -12
  152. package/src/i18n/ko.ts +87 -12
  153. package/src/index.ts +16 -1
  154. package/src/limits.ts +4 -0
  155. package/src/router.ts +67 -12
  156. package/src/routes.ts +23 -3
  157. package/src/rules.ts +109 -0
  158. package/src/schemas.ts +121 -4
  159. package/src/styles/console.css +219 -13
  160. package/src/vocabulary.ts +30 -0
package/src/console.tsx CHANGED
@@ -9,10 +9,13 @@ import {
9
9
  } from "react";
10
10
  import { createRoot, type Root } from "react-dom/client";
11
11
  import {
12
+ ApiError,
12
13
  type ConsoleClient,
13
14
  createClient,
14
15
  type FileUpload,
15
16
  OPEN_TASK_STATES,
17
+ PROJECT_RULES_TEMPLATE,
18
+ REFERENCE_RULES,
16
19
  type RestAnswerInput,
17
20
  type RestDecision,
18
21
  type RestDecisionInput,
@@ -21,11 +24,15 @@ import {
21
24
  type RestProject,
22
25
  type RestProjectInput,
23
26
  type RestProjectPatch,
27
+ type RestProjectRules,
28
+ type RestRules,
24
29
  type RestTask,
25
30
  type RestTaskInput,
26
31
  type RestToken,
27
32
  type RestTokenCreated,
28
33
  type RestWork,
34
+ RULES_PATH,
35
+ RULES_TITLE,
29
36
  type TaskState,
30
37
  } from "./api.js";
31
38
  import { Board, BoardFilters, type BoardProps, byDue, filterTasks } from "./components/board.js";
@@ -36,7 +43,7 @@ import { DecisionForm } from "./components/decision-form.js";
36
43
  import { DecisionCard, DecisionList, type DecisionListProps } from "./components/decision-list.js";
37
44
  import { EventFeed, type EventFeedProps } from "./components/event-feed.js";
38
45
  import { GoalForm, GoalList, type GoalListProps, GoalView } from "./components/goals.js";
39
- import { PeopleList, type PeopleListProps } from "./components/people.js";
46
+ import { ListingForm, PeopleList, type PeopleListProps } from "./components/people.js";
40
47
  import {
41
48
  MemberList,
42
49
  type MemberListProps,
@@ -45,6 +52,7 @@ import {
45
52
  type ProjectListProps,
46
53
  ProjectMark,
47
54
  } from "./components/projects.js";
55
+ import { RulesForm, RulesView } from "./components/rules.js";
48
56
  import {
49
57
  type Brand,
50
58
  isPlainClick,
@@ -58,7 +66,14 @@ import { TaskForm } from "./components/task-form.js";
58
66
  import { TaskList } from "./components/task-list.js";
59
67
  import { TaskView, type TaskViewProps } from "./components/task-view.js";
60
68
  import { NewToken, TokenForm, TokenList, type TokenListProps } from "./components/tokens.js";
61
- import { Button, Callout, EmptyState, personName, Spinner } from "./components/ui.js";
69
+ import {
70
+ Button,
71
+ Callout,
72
+ CategoryBadge,
73
+ EmptyState,
74
+ personName,
75
+ Spinner,
76
+ } from "./components/ui.js";
62
77
  import { WorkList, type WorkListProps } from "./components/work.js";
63
78
  import { ConnectPage } from "./connect-page.js";
64
79
  import { type ConsoleData, useConsoleData } from "./data.js";
@@ -86,8 +101,11 @@ import {
86
101
  newTaskHref,
87
102
  PATHS,
88
103
  personAgentsHref,
104
+ personWorkHref,
89
105
  projectHref,
106
+ projectSettingsHref,
90
107
  type Route,
108
+ settingsHref,
91
109
  signInFailureOf,
92
110
  taskHref,
93
111
  withoutSignInParam,
@@ -390,6 +408,8 @@ function ProjectsPage(props: {
390
408
  <section className="sc-panel sc-panel-form" aria-label={words.form.aria}>
391
409
  <ProjectForm
392
410
  busy={busy}
411
+ skillsSource={data.me?.workspace.skills.source}
412
+ organizationAddress={data.me?.workspace.skills.address ?? null}
393
413
  onSubmit={(input, icon) =>
394
414
  void act(async () => {
395
415
  const made = await data.actions.createProject(
@@ -476,7 +496,7 @@ function TaskPage(props: {
476
496
  return (
477
497
  <components.TaskView
478
498
  task={task}
479
- people={data.people}
499
+ people={data.people.filter(inWorkspace)}
480
500
  tasks={data.tasks}
481
501
  decisions={data.decisions}
482
502
  runs={data.runs}
@@ -558,7 +578,7 @@ function ProjectPage(props: {
558
578
  filter={route.filter}
559
579
  categories={data.categories}
560
580
  goals={data.goals}
561
- people={data.people}
581
+ people={data.people.filter(inWorkspace)}
562
582
  me={me}
563
583
  shown={shown.length}
564
584
  total={data.tasks.length}
@@ -592,7 +612,7 @@ function ProjectPage(props: {
592
612
  {error !== undefined && <Callout tone="danger">{error}</Callout>}
593
613
  <section className="sc-panel sc-panel-form" aria-label={words.task.newAria}>
594
614
  <TaskForm
595
- people={data.people}
615
+ people={data.people.filter(inWorkspace)}
596
616
  parents={data.tasks}
597
617
  categories={data.categories}
598
618
  goals={data.goals}
@@ -645,7 +665,7 @@ function ProjectPage(props: {
645
665
  filter={route.filter}
646
666
  categories={data.categories}
647
667
  goals={data.goals}
648
- people={data.people}
668
+ people={data.people.filter(inWorkspace)}
649
669
  me={me}
650
670
  shown={shown.length}
651
671
  total={data.tasks.length}
@@ -916,6 +936,10 @@ function ProjectPage(props: {
916
936
  if (event.goalId !== null) {
917
937
  return goalHref(key, event.goalId);
918
938
  }
939
+ // The rules are a page shown on the settings, not among the documents (ADR-0023).
940
+ if (event.data.path === RULES_PATH) {
941
+ return projectSettingsHref(key);
942
+ }
919
943
  return event.data.path === undefined ? undefined : toDoc(event.data.path);
920
944
  }}
921
945
  empty={<EmptyState title={words.feed.empty} />}
@@ -946,7 +970,7 @@ function ProjectPage(props: {
946
970
  {error !== undefined && <Callout tone="danger">{error}</Callout>}
947
971
  <components.MemberList
948
972
  members={data.members}
949
- people={data.people}
973
+ people={data.people.filter(inWorkspace)}
950
974
  me={me}
951
975
  busy={busy}
952
976
  onAdd={configures ? (input) => void act(() => data.actions.addMember(input)) : undefined}
@@ -961,6 +985,7 @@ function ProjectPage(props: {
961
985
  ? (member) => void act(() => data.actions.removeMember(member.person.id))
962
986
  : undefined
963
987
  }
988
+ workHref={owner ? (member) => personWorkHref(member.person.id) : undefined}
964
989
  empty={
965
990
  <EmptyState
966
991
  title={members.empty.title}
@@ -974,20 +999,71 @@ function ProjectPage(props: {
974
999
  );
975
1000
  }
976
1001
  if (route.name === "settings") {
977
- const settings = words.projects.settings;
1002
+ return (
1003
+ <ProjectSettingsPage route={route} project={project} data={data} navigation={navigation} />
1004
+ );
1005
+ }
1006
+ return <NothingHere />;
1007
+ }
1008
+
1009
+ /**
1010
+ * A project's settings (ADR-0023): read by every member, so that everyone sees what the project
1011
+ * is and what its agents are asked; changed by an owner, on the forms at their own addresses.
1012
+ */
1013
+ function ProjectSettingsPage(props: {
1014
+ readonly route: Extract<Route, { name: "settings" }>;
1015
+ readonly project: RestProject;
1016
+ readonly data: ConsoleData;
1017
+ readonly navigation: Navigation;
1018
+ }) {
1019
+ const words = useWords();
1020
+ const { route, project, data, navigation } = props;
1021
+ const { busy, error, act } = useAction();
1022
+ const settings = words.projects.settings;
1023
+ const form = words.projects.form;
1024
+ const owner = project.role === "owner";
1025
+ const key = project.key;
1026
+ const here = projectSettingsHref(key);
1027
+ const skills = data.me?.workspace.skills;
1028
+ const [rules, setRules] = useState<RestProjectRules | undefined>(undefined);
1029
+ const feedLength = data.events.length;
1030
+ // The rules, asked for again whenever the project's feed grows.
1031
+ // biome-ignore lint/correctness/useExhaustiveDependencies: the feed's length is the trigger
1032
+ useEffect(() => {
1033
+ const controller = new AbortController();
1034
+ data.actions
1035
+ .projectRules(controller.signal)
1036
+ .then((found) => {
1037
+ if (!controller.signal.aborted) {
1038
+ setRules(found);
1039
+ }
1040
+ })
1041
+ .catch(() => undefined);
1042
+ return () => controller.abort();
1043
+ }, [data.actions, feedLength]);
1044
+
1045
+ const head = (
1046
+ <div className="sc-page-head">
1047
+ <h1 className="sc-page-title">{settings.title}</h1>
1048
+ {owner && route.form === undefined && (
1049
+ <LinkButton href={projectSettingsHref(key, "edit")}>{words.common.edit}</LinkButton>
1050
+ )}
1051
+ </div>
1052
+ );
1053
+ if (route.form === "edit") {
978
1054
  if (!owner) {
979
- return <EmptyState title={settings.onlyOwner.title} body={settings.onlyOwner.body} />;
1055
+ return <NothingHere />;
980
1056
  }
981
1057
  return (
982
1058
  <>
983
- <div className="sc-page-head">
984
- <h1 className="sc-page-title">{settings.title}</h1>
985
- </div>
1059
+ {head}
986
1060
  {error !== undefined && <Callout tone="danger">{error}</Callout>}
987
1061
  <section className="sc-panel sc-panel-form" aria-label={settings.aria}>
988
1062
  <ProjectForm
989
1063
  project={project}
990
1064
  busy={busy}
1065
+ skillsSource={skills?.source}
1066
+ organizationAddress={skills?.address ?? null}
991
1067
  onSubmit={(input, icon) =>
992
1068
  void act(async () => {
993
1069
  await data.actions.updateProject(input as RestProjectPatch);
@@ -996,17 +1072,288 @@ function ProjectPage(props: {
996
1072
  } else if (icon !== undefined) {
997
1073
  await data.actions.setProjectIcon(uploadOf(icon));
998
1074
  }
1075
+ navigation.replace(here);
999
1076
  })
1000
1077
  }
1078
+ onCancel={() => navigation.back(here)}
1001
1079
  />
1002
1080
  </section>
1003
1081
  </>
1004
1082
  );
1005
1083
  }
1006
- return <NothingHere />;
1084
+ if (route.form === "rules") {
1085
+ if (!owner) {
1086
+ return <NothingHere />;
1087
+ }
1088
+ return (
1089
+ <>
1090
+ {head}
1091
+ {error !== undefined && <Callout tone="danger">{error}</Callout>}
1092
+ <section className="sc-panel sc-panel-form" aria-label={words.rules.projectRules}>
1093
+ {rules === undefined ? (
1094
+ <Spinner label={words.rules.loading} />
1095
+ ) : (
1096
+ <RulesForm
1097
+ rules={rules.project}
1098
+ scope="project"
1099
+ template={PROJECT_RULES_TEMPLATE}
1100
+ busy={busy}
1101
+ onSubmit={(input) =>
1102
+ void act(async () => {
1103
+ await data.actions.writeDocument(RULES_PATH, {
1104
+ title: RULES_TITLE,
1105
+ body: input.body,
1106
+ ...(input.baseVersion === undefined ? {} : { baseVersion: input.baseVersion }),
1107
+ });
1108
+ navigation.replace(here);
1109
+ })
1110
+ }
1111
+ onCancel={() => navigation.back(here)}
1112
+ />
1113
+ )}
1114
+ </section>
1115
+ </>
1116
+ );
1117
+ }
1118
+ return (
1119
+ <>
1120
+ {head}
1121
+ {error !== undefined && <Callout tone="danger">{error}</Callout>}
1122
+ <section className="sc-panel" aria-label={settings.aria}>
1123
+ <dl className="sc-facts sc-facts-plain">
1124
+ <div>
1125
+ <dt>{form.name}</dt>
1126
+ <dd>{project.name}</dd>
1127
+ </div>
1128
+ <div>
1129
+ <dt>{form.key}</dt>
1130
+ <dd>
1131
+ <code className="sc-project-key">{project.key}</code>
1132
+ </dd>
1133
+ </div>
1134
+ <div>
1135
+ <dt>{form.whoIsMember}</dt>
1136
+ <dd>{words.vocabulary.visibility[project.visibility]}</dd>
1137
+ </div>
1138
+ <div>
1139
+ <dt>{form.skillsAddress}</dt>
1140
+ <dd>
1141
+ {project.skillsAddress !== null ? (
1142
+ <code>
1143
+ {skills?.source ?? ""}
1144
+ {project.skillsAddress}
1145
+ </code>
1146
+ ) : skills?.address != null ? (
1147
+ settings.organizationsSkills(skills.address)
1148
+ ) : (
1149
+ settings.noSkills
1150
+ )}
1151
+ </dd>
1152
+ </div>
1153
+ <div>
1154
+ <dt>{form.symbol}</dt>
1155
+ <dd>
1156
+ <ProjectMark project={project} size="sm" />
1157
+ </dd>
1158
+ </div>
1159
+ {project.description.length > 0 && (
1160
+ <div className="sc-facts-wide">
1161
+ <dt>{form.description}</dt>
1162
+ <dd>{project.description}</dd>
1163
+ </div>
1164
+ )}
1165
+ </dl>
1166
+ </section>
1167
+ <section className="sc-panel" aria-label={words.rules.projectRules}>
1168
+ <h2 className="sc-section-title">{words.rules.projectRules}</h2>
1169
+ {rules === undefined ? (
1170
+ <Spinner label={words.rules.loading} />
1171
+ ) : (
1172
+ <RulesView
1173
+ rules={rules.project}
1174
+ scope="project"
1175
+ docHref={(path) => docHref(key, path)}
1176
+ editHref={owner ? projectSettingsHref(key, "rules") : undefined}
1177
+ onArchive={
1178
+ owner
1179
+ ? () =>
1180
+ void act(async () => {
1181
+ await data.actions.archiveDocument(RULES_PATH);
1182
+ setRules(await data.actions.projectRules());
1183
+ })
1184
+ : undefined
1185
+ }
1186
+ busy={busy}
1187
+ />
1188
+ )}
1189
+ </section>
1190
+ <section className="sc-panel" aria-label={words.rules.workspaceRules}>
1191
+ <div className="sc-section-head">
1192
+ <h2 className="sc-section-title">{words.rules.workspaceRules}</h2>
1193
+ <a className="sc-muted" href={settingsHref()}>
1194
+ {words.rules.onWorkspaceSettings}
1195
+ </a>
1196
+ </div>
1197
+ {rules !== undefined && <RulesView rules={rules.workspace} scope="workspace" folded />}
1198
+ </section>
1199
+ </>
1200
+ );
1201
+ }
1202
+
1203
+ /**
1204
+ * The workspace's settings (ADR-0023): its rules, the console's own until an administrator
1205
+ * writes the organization's; its categories, kept on their own page; and where its skills come
1206
+ * from, which whoever runs the console set. Read by everyone; the rules written by an administrator.
1207
+ */
1208
+ function WorkspaceSettingsPage(props: {
1209
+ readonly form: "rules" | undefined;
1210
+ readonly data: ConsoleData;
1211
+ readonly navigation: Navigation;
1212
+ }) {
1213
+ const words = useWords();
1214
+ const { form, data, navigation } = props;
1215
+ const { busy, error, act } = useAction();
1216
+ const administrator = data.me?.person?.role === "admin";
1217
+ const skills = data.me?.workspace.skills;
1218
+ const [rules, setRules] = useState<RestRules | undefined>(undefined);
1219
+ useEffect(() => {
1220
+ const controller = new AbortController();
1221
+ data.actions
1222
+ .rules(controller.signal)
1223
+ .then((found) => {
1224
+ if (!controller.signal.aborted) {
1225
+ setRules(found);
1226
+ }
1227
+ })
1228
+ .catch(() => undefined);
1229
+ return () => controller.abort();
1230
+ }, [data.actions]);
1231
+
1232
+ const head = (
1233
+ <div className="sc-page-head">
1234
+ <h1 className="sc-page-title">{words.settings.title}</h1>
1235
+ </div>
1236
+ );
1237
+ if (form === "rules") {
1238
+ if (!administrator) {
1239
+ return <NothingHere />;
1240
+ }
1241
+ return (
1242
+ <>
1243
+ {head}
1244
+ {error !== undefined && <Callout tone="danger">{error}</Callout>}
1245
+ <section className="sc-panel sc-panel-form" aria-label={words.rules.title}>
1246
+ {rules === undefined ? (
1247
+ <Spinner label={words.rules.loading} />
1248
+ ) : (
1249
+ <RulesForm
1250
+ rules={rules}
1251
+ scope="workspace"
1252
+ template={REFERENCE_RULES}
1253
+ busy={busy}
1254
+ onSubmit={(input) =>
1255
+ void act(async () => {
1256
+ setRules(
1257
+ await data.actions.writeRules({
1258
+ body: input.body,
1259
+ ...(input.baseVersion === undefined
1260
+ ? {}
1261
+ : { baseVersion: input.baseVersion }),
1262
+ }),
1263
+ );
1264
+ navigation.replace(PATHS.settings);
1265
+ })
1266
+ }
1267
+ onCancel={() => navigation.back(PATHS.settings)}
1268
+ onReset={() =>
1269
+ void act(async () => {
1270
+ setRules(await data.actions.archiveRules());
1271
+ navigation.replace(PATHS.settings);
1272
+ })
1273
+ }
1274
+ />
1275
+ )}
1276
+ </section>
1277
+ </>
1278
+ );
1279
+ }
1280
+ return (
1281
+ <>
1282
+ {head}
1283
+ {error !== undefined && <Callout tone="danger">{error}</Callout>}
1284
+ <section className="sc-panel" aria-label={words.rules.title}>
1285
+ <h2 className="sc-section-title">{words.rules.title}</h2>
1286
+ {rules === undefined ? (
1287
+ <Spinner label={words.rules.loading} />
1288
+ ) : (
1289
+ <RulesView
1290
+ rules={rules}
1291
+ scope="workspace"
1292
+ editHref={administrator ? settingsHref("rules") : undefined}
1293
+ onArchive={
1294
+ administrator
1295
+ ? () =>
1296
+ void act(async () => {
1297
+ setRules(await data.actions.archiveRules());
1298
+ })
1299
+ : undefined
1300
+ }
1301
+ busy={busy}
1302
+ />
1303
+ )}
1304
+ </section>
1305
+ <section className="sc-panel" aria-label={words.settings.categories}>
1306
+ <h2 className="sc-section-title">{words.settings.categories}</h2>
1307
+ {data.categories.length > 0 && (
1308
+ <p className="sc-category-chips">
1309
+ {data.categories.map((category) => (
1310
+ <CategoryBadge key={category.key} category={category} />
1311
+ ))}
1312
+ </p>
1313
+ )}
1314
+ <LinkButton href={PATHS.categories}>{words.settings.manageCategories}</LinkButton>
1315
+ </section>
1316
+ {skills !== undefined && (
1317
+ <section className="sc-panel" aria-label={words.settings.skills}>
1318
+ <h2 className="sc-section-title">{words.settings.skills}</h2>
1319
+ <dl className="sc-facts sc-facts-plain">
1320
+ <div>
1321
+ <dt>{words.settings.skillsSource}</dt>
1322
+ <dd>
1323
+ <a href={skills.source} target="_blank" rel="noopener noreferrer">
1324
+ {skills.source}
1325
+ </a>
1326
+ </dd>
1327
+ </div>
1328
+ <div>
1329
+ <dt>{words.settings.skillsAddress}</dt>
1330
+ <dd>
1331
+ {skills.address === null ? (
1332
+ words.settings.noSkillsAddress
1333
+ ) : (
1334
+ <a
1335
+ href={`${skills.source}${skills.address}`}
1336
+ target="_blank"
1337
+ rel="noopener noreferrer"
1338
+ >
1339
+ <code>{skills.address}</code>
1340
+ </a>
1341
+ )}
1342
+ </dd>
1343
+ </div>
1344
+ </dl>
1345
+ <p className="sc-muted">{words.settings.skillsFromConfiguration}</p>
1346
+ </section>
1347
+ )}
1348
+ </>
1349
+ );
1007
1350
  }
1008
1351
 
1009
1352
  /** A file a person chose, as the client sends it: its name, its bytes and the type the browser says. */
1353
+ /** Whether a person is in: who a picker of assignees and of members offers (ADR-0022). */
1354
+ const inWorkspace = (person: { readonly status: "active" | "deactivated" }): boolean =>
1355
+ person.status === "active";
1356
+
1010
1357
  const uploadOf = (file: File): FileUpload => ({
1011
1358
  name: file.name,
1012
1359
  bytes: file,
@@ -1093,61 +1440,149 @@ function PersonAgentsPage(props: {
1093
1440
  );
1094
1441
  }
1095
1442
 
1096
- /** What is on the person's plate, across their projects: read when the page is opened. */
1097
- function WorkPage(props: {
1098
- readonly data: ConsoleData;
1443
+ /** What a plate's page shows: the tasks, and the decisions waiting on the person's own. */
1444
+ interface Plate {
1445
+ readonly tasks: RestWork["tasks"];
1446
+ readonly decisions?: RestWork["decisions"] | undefined;
1447
+ }
1448
+
1449
+ /**
1450
+ * A plate, read when the page is opened: the person's own, or another person's for whoever
1451
+ * oversees them. The week at the top is the address's.
1452
+ */
1453
+ function PlatePage(props: {
1099
1454
  readonly components: ConsoleComponents;
1455
+ /** Reads the plate; stable across renders, since the page reads again whenever it changes. */
1456
+ readonly load: (signal: AbortSignal) => Promise<Plate>;
1457
+ readonly title: string;
1458
+ /** What stands beside the title: the way back, on another person's plate. */
1459
+ readonly aside?: ReactNode;
1100
1460
  /** A day of the week shown at the top, from the address; today's week when it names none. */
1101
1461
  readonly week: string | undefined;
1462
+ readonly weekHref: (day: string) => string;
1463
+ readonly loading: string;
1464
+ readonly couldNotLoad: string;
1465
+ readonly empty: { readonly title: string; readonly body: string };
1466
+ /** What a refusal says where the plate may not be the asker's to see; the server's words otherwise. */
1467
+ readonly refused?: { readonly title: string; readonly body: string } | undefined;
1102
1468
  }) {
1103
1469
  const words = useWords();
1104
- const { data, components } = props;
1105
- const [work, setWork] = useState<RestWork | undefined>(undefined);
1470
+ const { components, load, couldNotLoad, refused } = props;
1471
+ const [plate, setPlate] = useState<Plate | undefined>(undefined);
1106
1472
  const [problem, setProblem] = useState<string | undefined>(undefined);
1107
- const { work: load } = data.actions;
1108
- const couldNotLoad = words.work.couldNotLoad;
1473
+ const [forbidden, setForbidden] = useState(false);
1109
1474
  useEffect(() => {
1110
1475
  const controller = new AbortController();
1476
+ setPlate(undefined);
1477
+ setProblem(undefined);
1478
+ setForbidden(false);
1111
1479
  load(controller.signal)
1112
1480
  .then((answer) => {
1113
1481
  if (!controller.signal.aborted) {
1114
- setWork(answer);
1482
+ setPlate(answer);
1115
1483
  }
1116
1484
  })
1117
1485
  .catch((failure: unknown) => {
1118
- if (!controller.signal.aborted) {
1486
+ if (controller.signal.aborted) {
1487
+ return;
1488
+ }
1489
+ if (
1490
+ refused !== undefined &&
1491
+ failure instanceof ApiError &&
1492
+ failure.code === "auth.forbidden"
1493
+ ) {
1494
+ setForbidden(true);
1495
+ } else {
1119
1496
  setProblem(errorWords(failure, couldNotLoad));
1120
1497
  }
1121
1498
  });
1122
1499
  return () => controller.abort();
1123
- }, [load, couldNotLoad]);
1500
+ }, [load, couldNotLoad, refused]);
1124
1501
  return (
1125
1502
  <>
1126
1503
  <div className="sc-page-head">
1127
- <h1 className="sc-page-title">{words.work.title}</h1>
1504
+ <h1 className="sc-page-title">{props.title}</h1>
1505
+ {props.aside}
1128
1506
  </div>
1129
1507
  {problem !== undefined && <Callout tone="danger">{problem}</Callout>}
1130
- {work === undefined ? (
1508
+ {forbidden && refused !== undefined ? (
1509
+ <EmptyState
1510
+ title={refused.title}
1511
+ body={refused.body}
1512
+ action={<LinkButton href={PATHS.work}>{words.nav.work}</LinkButton>}
1513
+ />
1514
+ ) : plate === undefined ? (
1131
1515
  problem === undefined && (
1132
1516
  <div className="sc-loading">
1133
- <Spinner label={words.work.loading} />
1517
+ <Spinner label={props.loading} />
1134
1518
  </div>
1135
1519
  )
1136
- ) : work.tasks.length === 0 && work.decisions.length === 0 ? (
1137
- <EmptyState title={words.work.empty.title} body={words.work.empty.body} />
1520
+ ) : plate.tasks.length === 0 && (plate.decisions?.length ?? 0) === 0 ? (
1521
+ <EmptyState title={props.empty.title} body={props.empty.body} />
1138
1522
  ) : (
1139
1523
  <components.WorkList
1140
- work={work}
1524
+ work={plate}
1141
1525
  week={props.week}
1142
1526
  taskHref={(projectKey, task) => taskHref(projectKey, task.number)}
1143
1527
  decisionHref={(projectKey, decisionId) => decisionHref(projectKey, decisionId)}
1144
- weekHref={workHref}
1528
+ weekHref={props.weekHref}
1145
1529
  />
1146
1530
  )}
1147
1531
  </>
1148
1532
  );
1149
1533
  }
1150
1534
 
1535
+ /** What is on the person's own plate, across their projects. */
1536
+ function WorkPage(props: {
1537
+ readonly data: ConsoleData;
1538
+ readonly components: ConsoleComponents;
1539
+ readonly week: string | undefined;
1540
+ }) {
1541
+ const words = useWords();
1542
+ const { work } = props.data.actions;
1543
+ return (
1544
+ <PlatePage
1545
+ components={props.components}
1546
+ load={work}
1547
+ title={words.work.title}
1548
+ week={props.week}
1549
+ weekHref={workHref}
1550
+ loading={words.work.loading}
1551
+ couldNotLoad={words.work.couldNotLoad}
1552
+ empty={words.work.empty}
1553
+ />
1554
+ );
1555
+ }
1556
+
1557
+ /** Another person's plate, for whoever oversees them: an administrator, or an owner of a project they are in. */
1558
+ function PersonWorkPage(props: {
1559
+ readonly id: string;
1560
+ readonly data: ConsoleData;
1561
+ readonly components: ConsoleComponents;
1562
+ readonly week: string | undefined;
1563
+ }) {
1564
+ const words = useWords();
1565
+ const { id, data } = props;
1566
+ const person = data.people.find((candidate) => candidate.id === id);
1567
+ const name = person === undefined ? "?" : personName(person);
1568
+ const { personWork } = data.actions;
1569
+ const load = useCallback((signal: AbortSignal) => personWork(id, signal), [personWork, id]);
1570
+ return (
1571
+ <PlatePage
1572
+ components={props.components}
1573
+ load={load}
1574
+ title={words.people.workOf(name)}
1575
+ aside={<LinkButton href={PATHS.people}>{words.nav.people}</LinkButton>}
1576
+ week={props.week}
1577
+ weekHref={(day) => personWorkHref(id, day)}
1578
+ loading={words.people.loadingWork}
1579
+ couldNotLoad={words.people.couldNotLoadWork}
1580
+ empty={{ title: words.people.noWork.title(name), body: words.people.noWork.body }}
1581
+ refused={words.people.notOverseen}
1582
+ />
1583
+ );
1584
+ }
1585
+
1151
1586
  function WorkspacePage(props: {
1152
1587
  readonly route: Route;
1153
1588
  readonly data: ConsoleData;
@@ -1162,6 +1597,12 @@ function WorkspacePage(props: {
1162
1597
  if (route.name === "work") {
1163
1598
  return <WorkPage data={data} components={components} week={route.week} />;
1164
1599
  }
1600
+ if (route.name === "person-work") {
1601
+ return <PersonWorkPage id={route.id} data={data} components={components} week={route.week} />;
1602
+ }
1603
+ if (route.name === "workspace-settings") {
1604
+ return <WorkspaceSettingsPage form={route.form} data={data} navigation={navigation} />;
1605
+ }
1165
1606
  if (route.name === "categories") {
1166
1607
  const me = data.me?.person ?? undefined;
1167
1608
  const administrator = me?.role === "admin";
@@ -1197,17 +1638,42 @@ function WorkspacePage(props: {
1197
1638
  );
1198
1639
  }
1199
1640
 
1200
- if (route.name === "people") {
1641
+ if (route.name === "people" || route.name === "new-listing") {
1201
1642
  const me = data.me?.person ?? undefined;
1202
1643
  const administrator = me?.role === "admin";
1644
+ const making = route.name === "new-listing" && administrator;
1645
+ // An owner sees the work of the people in their projects; the console refuses the rest.
1646
+ const oversees = administrator || data.projects.some((project) => project.role === "owner");
1203
1647
  return (
1204
1648
  <>
1205
1649
  <div className="sc-page-head">
1206
1650
  <h1 className="sc-page-title">{words.people.title}</h1>
1651
+ {administrator && !making && (
1652
+ <LinkButton href={PATHS.newListing} variant="primary">
1653
+ {words.people.letIn}
1654
+ </LinkButton>
1655
+ )}
1207
1656
  </div>
1208
1657
  {error !== undefined && <Callout tone="danger">{error}</Callout>}
1658
+ {making && (
1659
+ <section className="sc-panel sc-panel-form" aria-label={words.people.letInAria}>
1660
+ <ListingForm
1661
+ providers={data.me?.signIn ?? []}
1662
+ busy={busy}
1663
+ onSubmit={(input) =>
1664
+ void act(async () => {
1665
+ await data.actions.createListing(input);
1666
+ navigation.replace(PATHS.people);
1667
+ })
1668
+ }
1669
+ onCancel={() => navigation.back(PATHS.people)}
1670
+ />
1671
+ </section>
1672
+ )}
1209
1673
  <components.PeopleList
1210
1674
  people={data.people}
1675
+ listed={data.listed}
1676
+ organization={data.organization}
1211
1677
  me={me}
1212
1678
  busy={busy}
1213
1679
  onChangeRole={
@@ -1215,6 +1681,17 @@ function WorkspacePage(props: {
1215
1681
  ? (person, role) => void act(() => data.actions.updatePerson(person.id, { role }))
1216
1682
  : undefined
1217
1683
  }
1684
+ onChangeStatus={
1685
+ administrator
1686
+ ? (person, status) => void act(() => data.actions.updatePerson(person.id, { status }))
1687
+ : undefined
1688
+ }
1689
+ onUnlist={
1690
+ administrator
1691
+ ? (listing) => void act(() => data.actions.removeListing(listing.id))
1692
+ : undefined
1693
+ }
1694
+ workHref={oversees ? (person) => personWorkHref(person.id) : undefined}
1218
1695
  agentsHref={administrator ? (person) => personAgentsHref(person.id) : undefined}
1219
1696
  />
1220
1697
  </>
@@ -1362,7 +1839,10 @@ export function createConsole(config: ConsoleConfig = {}): ConsoleApp {
1362
1839
  {
1363
1840
  href: PATHS.people,
1364
1841
  label: words.nav.people,
1365
- current: route.name === "people" || route.name === "person-agents",
1842
+ current:
1843
+ route.name === "people" ||
1844
+ route.name === "new-listing" ||
1845
+ route.name === "person-agents",
1366
1846
  },
1367
1847
  ...(viaToken
1368
1848
  ? []
@@ -1373,11 +1853,12 @@ export function createConsole(config: ConsoleConfig = {}): ConsoleApp {
1373
1853
  current: route.name === "agents" || route.name === "new-token",
1374
1854
  },
1375
1855
  ]),
1376
- // The categories are read by everyone, for what the colours mean, and kept by an administrator.
1856
+ // The settings are read by everyone: the rules every agent follows, the categories and
1857
+ // what the colours mean, where the skills come from; kept by an administrator.
1377
1858
  {
1378
- href: PATHS.categories,
1379
- label: words.nav.categories,
1380
- current: route.name === "categories",
1859
+ href: PATHS.settings,
1860
+ label: words.nav.settings,
1861
+ current: route.name === "workspace-settings" || route.name === "categories",
1381
1862
  },
1382
1863
  ...pages
1383
1864
  .filter((candidate) => candidate.level === "workspace")
@@ -1440,15 +1921,12 @@ export function createConsole(config: ConsoleConfig = {}): ConsoleApp {
1440
1921
  label: words.nav.members,
1441
1922
  current: route.name === "members",
1442
1923
  },
1443
- ...(project?.role === "owner"
1444
- ? [
1445
- {
1446
- href: projectHref(projectKey, "settings"),
1447
- label: words.nav.settings,
1448
- current: route.name === "settings",
1449
- },
1450
- ]
1451
- : []),
1924
+ // Read by every member: what the project is, and what its agents are asked (ADR-0023).
1925
+ {
1926
+ href: projectHref(projectKey, "settings"),
1927
+ label: words.nav.settings,
1928
+ current: route.name === "settings",
1929
+ },
1452
1930
  ...pages
1453
1931
  .filter((candidate) => candidate.level === "project")
1454
1932
  .map((candidate) => ({