@skillcdn/console 0.1.14 → 0.1.16

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