@skillcdn/console 0.1.13 → 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 (170) 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 +15 -0
  29. package/dist/components/calendar.d.ts.map +1 -1
  30. package/dist/components/calendar.js +78 -41
  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 +3 -3
  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 +6 -0
  52. package/dist/components/projects.d.ts.map +1 -1
  53. package/dist/components/projects.js +3 -3
  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/tokens.d.ts.map +1 -1
  67. package/dist/components/tokens.js +2 -9
  68. package/dist/components/tokens.js.map +1 -1
  69. package/dist/components/ui.d.ts +7 -2
  70. package/dist/components/ui.d.ts.map +1 -1
  71. package/dist/components/ui.js +13 -0
  72. package/dist/components/ui.js.map +1 -1
  73. package/dist/components/work.d.ts +9 -1
  74. package/dist/components/work.d.ts.map +1 -1
  75. package/dist/components/work.js +8 -3
  76. package/dist/components/work.js.map +1 -1
  77. package/dist/connect-page.d.ts.map +1 -1
  78. package/dist/connect-page.js +1 -1
  79. package/dist/connect-page.js.map +1 -1
  80. package/dist/console.css +253 -13
  81. package/dist/console.d.ts.map +1 -1
  82. package/dist/console.js +203 -49
  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/dates.d.ts +4 -0
  89. package/dist/dates.d.ts.map +1 -1
  90. package/dist/dates.js +26 -0
  91. package/dist/dates.js.map +1 -1
  92. package/dist/i18n/en.d.ts +90 -28
  93. package/dist/i18n/en.d.ts.map +1 -1
  94. package/dist/i18n/en.js +89 -31
  95. package/dist/i18n/en.js.map +1 -1
  96. package/dist/i18n/ko.d.ts.map +1 -1
  97. package/dist/i18n/ko.js +89 -31
  98. package/dist/i18n/ko.js.map +1 -1
  99. package/dist/index.d.ts +6 -5
  100. package/dist/index.d.ts.map +1 -1
  101. package/dist/index.js +6 -5
  102. package/dist/index.js.map +1 -1
  103. package/dist/limits.d.ts +4 -0
  104. package/dist/limits.d.ts.map +1 -1
  105. package/dist/limits.js +4 -0
  106. package/dist/limits.js.map +1 -1
  107. package/dist/router.d.ts +34 -2
  108. package/dist/router.d.ts.map +1 -1
  109. package/dist/router.js +55 -7
  110. package/dist/router.js.map +1 -1
  111. package/dist/routes.d.ts +14 -4
  112. package/dist/routes.d.ts.map +1 -1
  113. package/dist/routes.js +17 -2
  114. package/dist/routes.js.map +1 -1
  115. package/dist/rules.d.ts +28 -0
  116. package/dist/rules.d.ts.map +1 -0
  117. package/dist/rules.js +102 -0
  118. package/dist/rules.js.map +1 -0
  119. package/dist/schemas.d.ts +652 -4
  120. package/dist/schemas.d.ts.map +1 -1
  121. package/dist/schemas.js +96 -6
  122. package/dist/schemas.js.map +1 -1
  123. package/dist/vocabulary.d.ts +21 -1
  124. package/dist/vocabulary.d.ts.map +1 -1
  125. package/dist/vocabulary.js +21 -0
  126. package/dist/vocabulary.js.map +1 -1
  127. package/docs/specs/cli.md +13 -9
  128. package/docs/specs/rest.md +32 -8
  129. package/package.json +1 -1
  130. package/skills/building-a-console/SKILL.md +1 -1
  131. package/skills/deploying-a-console/SKILL.md +5 -5
  132. package/skills/deploying-a-console/assets/secrets.example.json +2 -1
  133. package/skills/deploying-a-console/references/access.md +10 -6
  134. package/skills/deploying-a-console/references/resources.md +10 -7
  135. package/skills/working-the-board/SKILL.md +10 -9
  136. package/skills/working-the-board/references/project-rules.md +36 -0
  137. package/src/agent-name.ts +9 -0
  138. package/src/api.ts +37 -0
  139. package/src/cli/cli.ts +253 -24
  140. package/src/cli/format.ts +74 -10
  141. package/src/cli/help.ts +30 -10
  142. package/src/cli/main.ts +5 -0
  143. package/src/client.ts +47 -1
  144. package/src/components/calendar.tsx +242 -101
  145. package/src/components/connect.tsx +12 -6
  146. package/src/components/decision-list.tsx +2 -2
  147. package/src/components/documents.tsx +7 -3
  148. package/src/components/event-feed.tsx +16 -3
  149. package/src/components/people.tsx +244 -35
  150. package/src/components/projects.tsx +31 -8
  151. package/src/components/rules.tsx +167 -0
  152. package/src/components/runs.tsx +4 -2
  153. package/src/components/skills.tsx +22 -11
  154. package/src/components/tokens.tsx +2 -14
  155. package/src/components/ui.tsx +25 -2
  156. package/src/components/work.tsx +69 -43
  157. package/src/connect-page.tsx +0 -1
  158. package/src/console.tsx +531 -67
  159. package/src/data.ts +52 -2
  160. package/src/dates.ts +28 -0
  161. package/src/i18n/en.ts +99 -38
  162. package/src/i18n/ko.ts +95 -38
  163. package/src/index.ts +21 -1
  164. package/src/limits.ts +4 -0
  165. package/src/router.ts +77 -12
  166. package/src/routes.ts +23 -3
  167. package/src/rules.ts +109 -0
  168. package/src/schemas.ts +121 -4
  169. package/src/styles/console.css +253 -13
  170. 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,11 +101,15 @@ 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,
112
+ workHref,
94
113
  } from "./router.js";
95
114
  import { projectPath } from "./routes.js";
96
115
  import { errorWords, useAction } from "./use-action.js";
@@ -384,12 +403,13 @@ function ProjectsPage(props: {
384
403
  </LinkButton>
385
404
  )}
386
405
  </div>
387
- <p className="sc-lead">{words.lead}</p>
388
406
  {error !== undefined && <Callout tone="danger">{error}</Callout>}
389
407
  {making && (
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}
@@ -640,13 +660,12 @@ function ProjectPage(props: {
640
660
  {words.board.writeTask}
641
661
  </LinkButton>
642
662
  </div>
643
- <p className="sc-lead">{calendar.lead}</p>
644
663
  {data.tasks.length > 0 && (
645
664
  <BoardFilters
646
665
  filter={route.filter}
647
666
  categories={data.categories}
648
667
  goals={data.goals}
649
- people={data.people}
668
+ people={data.people.filter(inWorkspace)}
650
669
  me={me}
651
670
  shown={shown.length}
652
671
  total={data.tasks.length}
@@ -690,7 +709,6 @@ function ProjectPage(props: {
690
709
  {open.length > 0 && (
691
710
  <section className="sc-task-section" aria-label={calendar.workload.title}>
692
711
  <h2 className="sc-section-title">{calendar.workload.title}</h2>
693
- <p className="sc-lead">{calendar.workload.lead}</p>
694
712
  <Workload
695
713
  tasks={shown}
696
714
  today={now}
@@ -719,7 +737,6 @@ function ProjectPage(props: {
719
737
  </LinkButton>
720
738
  )}
721
739
  </div>
722
- <p className="sc-lead">{roadmap.lead}</p>
723
740
  {error !== undefined && <Callout tone="danger">{error}</Callout>}
724
741
  {making && (
725
742
  <section className="sc-panel sc-panel-form" aria-label={roadmap.setGoalAria}>
@@ -919,6 +936,10 @@ function ProjectPage(props: {
919
936
  if (event.goalId !== null) {
920
937
  return goalHref(key, event.goalId);
921
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
+ }
922
943
  return event.data.path === undefined ? undefined : toDoc(event.data.path);
923
944
  }}
924
945
  empty={<EmptyState title={words.feed.empty} />}
@@ -945,14 +966,11 @@ function ProjectPage(props: {
945
966
  <div className="sc-page-head">
946
967
  <h1 className="sc-page-title">{members.title}</h1>
947
968
  </div>
948
- <p className="sc-lead">
949
- {project.visibility === "workspace" ? members.leadWorkspace : members.leadPrivate}{" "}
950
- {members.leadRoles}
951
- </p>
969
+ <p className="sc-lead">{words.vocabulary.visibility[project.visibility]}</p>
952
970
  {error !== undefined && <Callout tone="danger">{error}</Callout>}
953
971
  <components.MemberList
954
972
  members={data.members}
955
- people={data.people}
973
+ people={data.people.filter(inWorkspace)}
956
974
  me={me}
957
975
  busy={busy}
958
976
  onAdd={configures ? (input) => void act(() => data.actions.addMember(input)) : undefined}
@@ -967,6 +985,7 @@ function ProjectPage(props: {
967
985
  ? (member) => void act(() => data.actions.removeMember(member.person.id))
968
986
  : undefined
969
987
  }
988
+ workHref={owner ? (member) => personWorkHref(member.person.id) : undefined}
970
989
  empty={
971
990
  <EmptyState
972
991
  title={members.empty.title}
@@ -980,25 +999,71 @@ function ProjectPage(props: {
980
999
  );
981
1000
  }
982
1001
  if (route.name === "settings") {
983
- 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") {
984
1054
  if (!owner) {
985
- return <EmptyState title={settings.onlyOwner.title} body={settings.onlyOwner.body} />;
1055
+ return <NothingHere />;
986
1056
  }
987
1057
  return (
988
1058
  <>
989
- <div className="sc-page-head">
990
- <h1 className="sc-page-title">{settings.title}</h1>
991
- </div>
992
- <p className="sc-lead">
993
- {settings.leadBefore}
994
- <code>{project.key}</code>
995
- {settings.leadAfter}
996
- </p>
1059
+ {head}
997
1060
  {error !== undefined && <Callout tone="danger">{error}</Callout>}
998
1061
  <section className="sc-panel sc-panel-form" aria-label={settings.aria}>
999
1062
  <ProjectForm
1000
1063
  project={project}
1001
1064
  busy={busy}
1065
+ skillsSource={skills?.source}
1066
+ organizationAddress={skills?.address ?? null}
1002
1067
  onSubmit={(input, icon) =>
1003
1068
  void act(async () => {
1004
1069
  await data.actions.updateProject(input as RestProjectPatch);
@@ -1007,17 +1072,288 @@ function ProjectPage(props: {
1007
1072
  } else if (icon !== undefined) {
1008
1073
  await data.actions.setProjectIcon(uploadOf(icon));
1009
1074
  }
1075
+ navigation.replace(here);
1010
1076
  })
1011
1077
  }
1078
+ onCancel={() => navigation.back(here)}
1012
1079
  />
1013
1080
  </section>
1014
1081
  </>
1015
1082
  );
1016
1083
  }
1017
- 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
+ );
1018
1350
  }
1019
1351
 
1020
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
+
1021
1357
  const uploadOf = (file: File): FileUpload => ({
1022
1358
  name: file.name,
1023
1359
  bytes: file,
@@ -1082,9 +1418,6 @@ function PersonAgentsPage(props: {
1082
1418
  </h1>
1083
1419
  <LinkButton href={PATHS.people}>{words.nav.people}</LinkButton>
1084
1420
  </div>
1085
- <p className="sc-lead">
1086
- {words.people.agentsLead(person?.name ?? person?.login ?? words.feed.someone)}
1087
- </p>
1088
1421
  {error !== undefined && <Callout tone="danger">{error}</Callout>}
1089
1422
  {tokens === undefined ? (
1090
1423
  <div className="sc-loading">
@@ -1107,55 +1440,149 @@ function PersonAgentsPage(props: {
1107
1440
  );
1108
1441
  }
1109
1442
 
1110
- /** What is on the person's plate, across their projects: read when the page is opened. */
1111
- function WorkPage(props: { readonly data: ConsoleData; readonly components: ConsoleComponents }) {
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: {
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;
1460
+ /** A day of the week shown at the top, from the address; today's week when it names none. */
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;
1468
+ }) {
1112
1469
  const words = useWords();
1113
- const { data, components } = props;
1114
- const [work, setWork] = useState<RestWork | undefined>(undefined);
1470
+ const { components, load, couldNotLoad, refused } = props;
1471
+ const [plate, setPlate] = useState<Plate | undefined>(undefined);
1115
1472
  const [problem, setProblem] = useState<string | undefined>(undefined);
1116
- const { work: load } = data.actions;
1117
- const couldNotLoad = words.work.couldNotLoad;
1473
+ const [forbidden, setForbidden] = useState(false);
1118
1474
  useEffect(() => {
1119
1475
  const controller = new AbortController();
1476
+ setPlate(undefined);
1477
+ setProblem(undefined);
1478
+ setForbidden(false);
1120
1479
  load(controller.signal)
1121
1480
  .then((answer) => {
1122
1481
  if (!controller.signal.aborted) {
1123
- setWork(answer);
1482
+ setPlate(answer);
1124
1483
  }
1125
1484
  })
1126
1485
  .catch((failure: unknown) => {
1127
- 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 {
1128
1496
  setProblem(errorWords(failure, couldNotLoad));
1129
1497
  }
1130
1498
  });
1131
1499
  return () => controller.abort();
1132
- }, [load, couldNotLoad]);
1500
+ }, [load, couldNotLoad, refused]);
1133
1501
  return (
1134
1502
  <>
1135
1503
  <div className="sc-page-head">
1136
- <h1 className="sc-page-title">{words.work.title}</h1>
1504
+ <h1 className="sc-page-title">{props.title}</h1>
1505
+ {props.aside}
1137
1506
  </div>
1138
- <p className="sc-lead">{words.work.lead}</p>
1139
1507
  {problem !== undefined && <Callout tone="danger">{problem}</Callout>}
1140
- {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 ? (
1141
1515
  problem === undefined && (
1142
1516
  <div className="sc-loading">
1143
- <Spinner label={words.work.loading} />
1517
+ <Spinner label={props.loading} />
1144
1518
  </div>
1145
1519
  )
1146
- ) : work.tasks.length === 0 && work.decisions.length === 0 ? (
1147
- <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} />
1148
1522
  ) : (
1149
1523
  <components.WorkList
1150
- work={work}
1524
+ work={plate}
1525
+ week={props.week}
1151
1526
  taskHref={(projectKey, task) => taskHref(projectKey, task.number)}
1152
1527
  decisionHref={(projectKey, decisionId) => decisionHref(projectKey, decisionId)}
1528
+ weekHref={props.weekHref}
1153
1529
  />
1154
1530
  )}
1155
1531
  </>
1156
1532
  );
1157
1533
  }
1158
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
+
1159
1586
  function WorkspacePage(props: {
1160
1587
  readonly route: Route;
1161
1588
  readonly data: ConsoleData;
@@ -1168,7 +1595,13 @@ function WorkspacePage(props: {
1168
1595
  const { busy, error, act } = useAction();
1169
1596
 
1170
1597
  if (route.name === "work") {
1171
- return <WorkPage data={data} components={components} />;
1598
+ return <WorkPage data={data} components={components} week={route.week} />;
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} />;
1172
1605
  }
1173
1606
  if (route.name === "categories") {
1174
1607
  const me = data.me?.person ?? undefined;
@@ -1179,9 +1612,6 @@ function WorkspacePage(props: {
1179
1612
  <div className="sc-page-head">
1180
1613
  <h1 className="sc-page-title">{categories.title}</h1>
1181
1614
  </div>
1182
- <p className="sc-lead">
1183
- {categories.lead} {categories.onlyAdmin}
1184
- </p>
1185
1615
  {error !== undefined && <Callout tone="danger">{error}</Callout>}
1186
1616
  <components.CategoryList
1187
1617
  categories={data.categories}
@@ -1204,23 +1634,46 @@ function WorkspacePage(props: {
1204
1634
  }
1205
1635
  empty={<EmptyState title={categories.empty.title} body={categories.empty.body} />}
1206
1636
  />
1207
- {administrator && <p className="sc-muted">{categories.inUse}</p>}
1208
1637
  </>
1209
1638
  );
1210
1639
  }
1211
1640
 
1212
- if (route.name === "people") {
1641
+ if (route.name === "people" || route.name === "new-listing") {
1213
1642
  const me = data.me?.person ?? undefined;
1214
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");
1215
1647
  return (
1216
1648
  <>
1217
1649
  <div className="sc-page-head">
1218
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
+ )}
1219
1656
  </div>
1220
- <p className="sc-lead">{words.people.lead}</p>
1221
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
+ )}
1222
1673
  <components.PeopleList
1223
1674
  people={data.people}
1675
+ listed={data.listed}
1676
+ organization={data.organization}
1224
1677
  me={me}
1225
1678
  busy={busy}
1226
1679
  onChangeRole={
@@ -1228,6 +1681,17 @@ function WorkspacePage(props: {
1228
1681
  ? (person, role) => void act(() => data.actions.updatePerson(person.id, { role }))
1229
1682
  : undefined
1230
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}
1231
1695
  agentsHref={administrator ? (person) => personAgentsHref(person.id) : undefined}
1232
1696
  />
1233
1697
  </>
@@ -1253,7 +1717,6 @@ function WorkspacePage(props: {
1253
1717
  </LinkButton>
1254
1718
  )}
1255
1719
  </div>
1256
- <p className="sc-lead">{agents.lead}</p>
1257
1720
  {error !== undefined && <Callout tone="danger">{error}</Callout>}
1258
1721
  {fresh !== undefined && (
1259
1722
  <NewToken token={fresh.token} secret={fresh.secret} onDone={() => setFresh(undefined)} />
@@ -1376,7 +1839,10 @@ export function createConsole(config: ConsoleConfig = {}): ConsoleApp {
1376
1839
  {
1377
1840
  href: PATHS.people,
1378
1841
  label: words.nav.people,
1379
- current: route.name === "people" || route.name === "person-agents",
1842
+ current:
1843
+ route.name === "people" ||
1844
+ route.name === "new-listing" ||
1845
+ route.name === "person-agents",
1380
1846
  },
1381
1847
  ...(viaToken
1382
1848
  ? []
@@ -1387,11 +1853,12 @@ export function createConsole(config: ConsoleConfig = {}): ConsoleApp {
1387
1853
  current: route.name === "agents" || route.name === "new-token",
1388
1854
  },
1389
1855
  ]),
1390
- // 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.
1391
1858
  {
1392
- href: PATHS.categories,
1393
- label: words.nav.categories,
1394
- current: route.name === "categories",
1859
+ href: PATHS.settings,
1860
+ label: words.nav.settings,
1861
+ current: route.name === "workspace-settings" || route.name === "categories",
1395
1862
  },
1396
1863
  ...pages
1397
1864
  .filter((candidate) => candidate.level === "workspace")
@@ -1454,15 +1921,12 @@ export function createConsole(config: ConsoleConfig = {}): ConsoleApp {
1454
1921
  label: words.nav.members,
1455
1922
  current: route.name === "members",
1456
1923
  },
1457
- ...(project?.role === "owner"
1458
- ? [
1459
- {
1460
- href: projectHref(projectKey, "settings"),
1461
- label: words.nav.settings,
1462
- current: route.name === "settings",
1463
- },
1464
- ]
1465
- : []),
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
+ },
1466
1930
  ...pages
1467
1931
  .filter((candidate) => candidate.level === "project")
1468
1932
  .map((candidate) => ({