@skillcdn/console 0.4.0 → 0.4.1

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 (98) hide show
  1. package/README.md +1 -0
  2. package/dist/api.d.ts +1 -1
  3. package/dist/api.d.ts.map +1 -1
  4. package/dist/api.js +1 -1
  5. package/dist/api.js.map +1 -1
  6. package/dist/cli/cli.d.ts.map +1 -1
  7. package/dist/cli/cli.js +36 -11
  8. package/dist/cli/cli.js.map +1 -1
  9. package/dist/cli/help.d.ts.map +1 -1
  10. package/dist/cli/help.js +25 -5
  11. package/dist/cli/help.js.map +1 -1
  12. package/dist/client.d.ts +12 -1
  13. package/dist/client.d.ts.map +1 -1
  14. package/dist/client.js +4 -0
  15. package/dist/client.js.map +1 -1
  16. package/dist/compat.d.ts +1 -1
  17. package/dist/compat.js +2 -2
  18. package/dist/components/markdown.d.ts.map +1 -1
  19. package/dist/components/markdown.js +6 -3
  20. package/dist/components/markdown.js.map +1 -1
  21. package/dist/components/pack-connect.d.ts +3 -1
  22. package/dist/components/pack-connect.d.ts.map +1 -1
  23. package/dist/components/pack-connect.js +4 -4
  24. package/dist/components/pack-connect.js.map +1 -1
  25. package/dist/components/pack-token.d.ts +3 -1
  26. package/dist/components/pack-token.d.ts.map +1 -1
  27. package/dist/components/pack-token.js +1 -1
  28. package/dist/components/pack-token.js.map +1 -1
  29. package/dist/components/people.d.ts.map +1 -1
  30. package/dist/components/people.js +14 -3
  31. package/dist/components/people.js.map +1 -1
  32. package/dist/components/remote-pictures.d.ts +6 -0
  33. package/dist/components/remote-pictures.d.ts.map +1 -0
  34. package/dist/components/remote-pictures.js +8 -0
  35. package/dist/components/remote-pictures.js.map +1 -0
  36. package/dist/components/sign-in.d.ts.map +1 -1
  37. package/dist/components/sign-in.js +49 -2
  38. package/dist/components/sign-in.js.map +1 -1
  39. package/dist/components/ui.d.ts +3 -2
  40. package/dist/components/ui.d.ts.map +1 -1
  41. package/dist/components/ui.js +6 -3
  42. package/dist/components/ui.js.map +1 -1
  43. package/dist/console.css +57 -5
  44. package/dist/console.d.ts.map +1 -1
  45. package/dist/console.js +13 -6
  46. package/dist/console.js.map +1 -1
  47. package/dist/data.d.ts +4 -1
  48. package/dist/data.d.ts.map +1 -1
  49. package/dist/data.js +16 -19
  50. package/dist/data.js.map +1 -1
  51. package/dist/i18n/en.d.ts +14 -1
  52. package/dist/i18n/en.d.ts.map +1 -1
  53. package/dist/i18n/en.js +14 -1
  54. package/dist/i18n/en.js.map +1 -1
  55. package/dist/i18n/ko.d.ts.map +1 -1
  56. package/dist/i18n/ko.js +10 -1
  57. package/dist/i18n/ko.js.map +1 -1
  58. package/dist/limits.d.ts +14 -0
  59. package/dist/limits.d.ts.map +1 -1
  60. package/dist/limits.js +14 -0
  61. package/dist/limits.js.map +1 -1
  62. package/dist/routes.d.ts +2 -0
  63. package/dist/routes.d.ts.map +1 -1
  64. package/dist/routes.js +2 -0
  65. package/dist/routes.js.map +1 -1
  66. package/dist/schemas.d.ts +30 -1
  67. package/dist/schemas.d.ts.map +1 -1
  68. package/dist/schemas.js +15 -4
  69. package/dist/schemas.js.map +1 -1
  70. package/dist/web.d.ts.map +1 -1
  71. package/dist/web.js +3 -2
  72. package/dist/web.js.map +1 -1
  73. package/docs/specs/cli.md +5 -5
  74. package/docs/specs/rest.md +22 -11
  75. package/package.json +2 -2
  76. package/skills/building-a-console/SKILL.md +2 -2
  77. package/skills/working-the-board/references/setting-up.md +1 -1
  78. package/src/api.ts +8 -0
  79. package/src/cli/cli.ts +39 -12
  80. package/src/cli/help.ts +30 -4
  81. package/src/client.ts +30 -1
  82. package/src/compat.ts +2 -2
  83. package/src/components/markdown.tsx +6 -3
  84. package/src/components/pack-connect.tsx +10 -7
  85. package/src/components/pack-token.tsx +4 -1
  86. package/src/components/people.tsx +17 -7
  87. package/src/components/remote-pictures.ts +19 -0
  88. package/src/components/sign-in.tsx +114 -13
  89. package/src/components/ui.tsx +6 -3
  90. package/src/console.tsx +24 -5
  91. package/src/data.ts +20 -20
  92. package/src/i18n/en.ts +15 -1
  93. package/src/i18n/ko.ts +11 -1
  94. package/src/limits.ts +14 -0
  95. package/src/routes.ts +2 -0
  96. package/src/schemas.ts +17 -4
  97. package/src/styles/console.css +57 -5
  98. package/src/web.ts +3 -2
@@ -2,11 +2,13 @@ import ReactMarkdown from "react-markdown";
2
2
  import remarkGfm from "remark-gfm";
3
3
  import { isDocumentPath } from "../documents.js";
4
4
  import { type Picture, PictureThumb } from "./pictures.js";
5
+ import { useRemotePictureSrc } from "./remote-pictures.js";
5
6
 
6
7
  // What people and agents write is untrusted. It is rendered as React elements, never as HTML:
7
8
  // raw HTML in the source stays text, links are limited to a few schemes and open elsewhere,
8
- // and pictures come over https only, lazily and without a referrer. A link whose destination
9
- // is a document's path (ADR-0009) leads to the document's page, when the page knows where.
9
+ // and a picture from elsewhere, named over https, is read through the console (ADR-0048),
10
+ // lazily and without a referrer. A link whose destination is a document's path (ADR-0009)
11
+ // leads to the document's page, when the page knows where.
10
12
 
11
13
  const SAFE_LINK = /^(https?:|mailto:)/i;
12
14
  const SAFE_IMAGE = /^https:\/\//i;
@@ -29,6 +31,7 @@ export interface MarkdownProps {
29
31
  }
30
32
 
31
33
  export function Markdown(props: MarkdownProps) {
34
+ const remote = useRemotePictureSrc();
32
35
  return (
33
36
  <div className="sc-prose">
34
37
  <ReactMarkdown
@@ -62,7 +65,7 @@ export function Markdown(props: MarkdownProps) {
62
65
  img({ src, alt, title }) {
63
66
  const named = typeof src === "string" ? props.fileSrc?.(src) : undefined;
64
67
  const url =
65
- named ?? (typeof src === "string" && SAFE_IMAGE.test(src) ? src : undefined);
68
+ named ?? (typeof src === "string" && SAFE_IMAGE.test(src) ? remote(src) : undefined);
66
69
  if (url === undefined) {
67
70
  return <span className="sc-prose-image-text">{alt ?? ""}</span>;
68
71
  }
@@ -6,8 +6,9 @@ import { Button, Time } from "./ui.js";
6
6
  // A private pack connected with a code a person approves (ADR-0036): an owner, or for the
7
7
  // workspace's token an administrator, asks, the console asks the deployment and shows what it
8
8
  // answered, the code and the page, and the person approves there; the page asks the console
9
- // again at the interval the deployment named until the token is kept or the request ended.
10
- // Takes its data through the callbacks and keeps nothing itself.
9
+ // to claim the token at the interval the deployment named, until the token is kept or the
10
+ // request ended; reading how the request stands, when the page opens, changes nothing
11
+ // (ADR-0046). Takes its data through the callbacks and keeps nothing itself.
11
12
 
12
13
  /**
13
14
  * How a connection stands, as the component needs it: what the API answers for a project's
@@ -20,8 +21,10 @@ export type PackConnectState =
20
21
  export interface PackConnectProps {
21
22
  /** Asks the deployment to connect; answers what the person is shown. */
22
23
  readonly onStart: () => Promise<RestPackConnectStarted>;
23
- /** How the connection stands; the token is kept when the person approved. */
24
+ /** How the connection stands, read when the page opens; changes nothing. */
24
25
  readonly poll: (signal?: AbortSignal) => Promise<PackConnectState>;
26
+ /** Asks the console to claim the token at the deployment: pending still, connected, or ended. */
27
+ readonly claim: (signal?: AbortSignal) => Promise<PackConnectState>;
25
28
  /** Gives the connection up. */
26
29
  readonly onCancel: () => Promise<void>;
27
30
  /** Called once the token is kept. */
@@ -47,7 +50,7 @@ const MIN_INTERVAL_SECONDS = 2;
47
50
  export function PackConnect(props: PackConnectProps) {
48
51
  const words = useWords();
49
52
  const pack = words.projects.packToken;
50
- const { poll, onConnected } = props;
53
+ const { poll, claim, onConnected } = props;
51
54
  const [shown, setShown] = useState<Shown>({ kind: "idle" });
52
55
  const [problem, setProblem] = useState<string | undefined>(undefined);
53
56
 
@@ -64,7 +67,7 @@ export function PackConnect(props: PackConnectProps) {
64
67
  return () => controller.abort();
65
68
  }, [poll]);
66
69
 
67
- // While waiting, the console is asked again at the interval the deployment named; a new
70
+ // While waiting, the console is asked to claim at the interval the deployment named; a new
68
71
  // answer, pending still, is a new state, which schedules the next asking.
69
72
  useEffect(() => {
70
73
  if (shown.kind !== "waiting") {
@@ -72,7 +75,7 @@ export function PackConnect(props: PackConnectProps) {
72
75
  }
73
76
  const controller = new AbortController();
74
77
  const timer = setTimeout(() => {
75
- poll(controller.signal)
78
+ claim(controller.signal)
76
79
  .then((state) => {
77
80
  if (controller.signal.aborted) {
78
81
  return;
@@ -102,7 +105,7 @@ export function PackConnect(props: PackConnectProps) {
102
105
  controller.abort();
103
106
  clearTimeout(timer);
104
107
  };
105
- }, [shown, poll, onConnected]);
108
+ }, [shown, claim, onConnected]);
106
109
 
107
110
  const start = () => {
108
111
  setProblem(undefined);
@@ -20,8 +20,10 @@ export interface PackTokenWaysProps {
20
20
  readonly busy?: boolean | undefined;
21
21
  /** Asks the deployment to connect; answers what the person is shown. */
22
22
  readonly onStart: () => Promise<RestPackConnectStarted>;
23
- /** How the connection stands; the token is kept when the person approved. */
23
+ /** How the connection stands, read when the page opens; changes nothing. */
24
24
  readonly poll: (signal?: AbortSignal) => Promise<PackConnectState>;
25
+ /** Asks the console to claim the token at the deployment (ADR-0046). */
26
+ readonly claim: (signal?: AbortSignal) => Promise<PackConnectState>;
25
27
  /** Gives the connection up. */
26
28
  readonly onCancelConnect: () => Promise<void>;
27
29
  /** Called once the token is kept. */
@@ -46,6 +48,7 @@ export function PackTokenWays(props: PackTokenWaysProps) {
46
48
  <PackConnect
47
49
  onStart={props.onStart}
48
50
  poll={props.poll}
51
+ claim={props.claim}
49
52
  onCancel={props.onCancelConnect}
50
53
  onConnected={props.onConnected}
51
54
  busy={props.busy}
@@ -57,6 +57,19 @@ export function PeopleList(props: PeopleListProps) {
57
57
  person.source !== "organization" &&
58
58
  props.me?.id !== person.id;
59
59
  const organization = props.organization ?? undefined;
60
+ /** What let the person in, by its name: the organization, the domain of their login, or an administrator. */
61
+ const noteOf = (person: RestPerson): string => {
62
+ if (person.source === "organization") {
63
+ return organization === undefined
64
+ ? people.source.organization
65
+ : people.ofOrganization(organization.name);
66
+ }
67
+ if (person.source === "domain") {
68
+ const at = person.login.lastIndexOf("@");
69
+ return at === -1 ? people.source.domain : people.ofDomain(person.login.slice(at + 1));
70
+ }
71
+ return people.source.listed;
72
+ };
60
73
  return (
61
74
  <div className="sc-people-page">
62
75
  {organization !== undefined && (
@@ -79,8 +92,8 @@ export function PeopleList(props: PeopleListProps) {
79
92
  <span className="sc-person-you">{words.common.you}</span>
80
93
  )}
81
94
  <span className="sc-person-name sc-muted">
82
- {person.name === null ? "" : person.login}
83
- <span className="sc-person-note"> {people.source[person.source]}</span>
95
+ {person.name !== null && <>{person.login} · </>}
96
+ <span className="sc-person-note">{noteOf(person)}</span>
84
97
  </span>
85
98
  {props.onChangeRole === undefined ? (
86
99
  <RoleBadge role={person.role} />
@@ -163,12 +176,9 @@ export function PeopleList(props: PeopleListProps) {
163
176
  <li key={person.id} className="sc-person-row sc-person-row-out">
164
177
  <PersonChip person={person} size="md" />
165
178
  <span className="sc-person-name sc-muted">
166
- {person.name === null ? "" : person.login}
179
+ {person.name !== null && <>{person.login} · </>}
167
180
  <span className="sc-person-note">
168
- {" "}
169
- {person.source === "organization"
170
- ? people.leftOrganization
171
- : people.source[person.source]}
181
+ {person.source === "organization" ? people.leftOrganization : noteOf(person)}
172
182
  {person.deactivatedAt !== null && (
173
183
  <>
174
184
  {" "}
@@ -0,0 +1,19 @@
1
+ import { createContext, useContext } from "react";
2
+ import { REST_ROUTES } from "../routes.js";
3
+
4
+ // Where a picture from elsewhere is read (ADR-0048): through the console, which fetches it and
5
+ // keeps it a while, so that the person's browser reaches nobody's server and the pages load
6
+ // nothing from elsewhere. The composition provides the client's way to it; a component outside
7
+ // one reads it at the API's own path, which a console served beside its API answers.
8
+
9
+ /** The address a picture from elsewhere is read at, given the https URL that names it. */
10
+ export type RemotePictureSrc = (url: string) => string;
11
+
12
+ export const defaultRemotePictureSrc: RemotePictureSrc = (url) =>
13
+ `${REST_ROUTES.pictures}?src=${encodeURIComponent(url)}`;
14
+
15
+ export const RemotePicturesContext = createContext<RemotePictureSrc>(defaultRemotePictureSrc);
16
+
17
+ export function useRemotePictureSrc(): RemotePictureSrc {
18
+ return useContext(RemotePicturesContext);
19
+ }
@@ -1,10 +1,11 @@
1
- import { loginPath, type RestProvider, type SignInFailure } from "../api.js";
1
+ import { type MouseEvent, useEffect, useState } from "react";
2
+ import { loginPath, type ProviderKey, type RestProvider, type SignInFailure } from "../api.js";
2
3
  import { useWords } from "../i18n/index.js";
3
4
  import type { Brand } from "./shell.js";
4
- import { Callout } from "./ui.js";
5
+ import { Callout, cx } from "./ui.js";
5
6
 
6
7
  // The page a person sees before they are anyone: the way to each provider the deployment has,
7
- // and what went wrong the last time, when something did.
8
+ // who gets in through it, and what went wrong the last time, when something did.
8
9
 
9
10
  export interface SignInProps {
10
11
  readonly title: string;
@@ -19,6 +20,35 @@ export interface SignInProps {
19
20
 
20
21
  export function SignIn(props: SignInProps) {
21
22
  const words = useWords().signIn;
23
+ // The way being taken: its button says so from the click until the page leaves, and says so
24
+ // no more when the browser brings this page back from its cache, as on the way back.
25
+ const [taken, setTaken] = useState<ProviderKey | undefined>(undefined);
26
+ useEffect(() => {
27
+ const shown = (event: PageTransitionEvent): void => {
28
+ if (event.persisted) {
29
+ setTaken(undefined);
30
+ }
31
+ };
32
+ window.addEventListener("pageshow", shown);
33
+ return () => window.removeEventListener("pageshow", shown);
34
+ }, []);
35
+ const take = (provider: ProviderKey) => (event: MouseEvent<HTMLAnchorElement>) => {
36
+ if (taken !== undefined) {
37
+ // One way at a time: a second click, on any of the buttons, goes nowhere.
38
+ event.preventDefault();
39
+ return;
40
+ }
41
+ setTaken(provider);
42
+ };
43
+ const admitsOf = (admits: NonNullable<RestProvider["admits"]>): string => {
44
+ if (admits.organization !== null) {
45
+ return words.admits.organization(admits.organization);
46
+ }
47
+ if (admits.domains.length > 0) {
48
+ return words.admits.domains(admits.domains.join(", "));
49
+ }
50
+ return words.admits.listed;
51
+ };
22
52
  return (
23
53
  <div className="sc-sign-in">
24
54
  {props.brand?.symbol !== undefined && (
@@ -44,18 +74,89 @@ export function SignIn(props: SignInProps) {
44
74
  </Callout>
45
75
  ) : (
46
76
  <div className="sc-sign-in-ways">
47
- {props.providers.map((provider) => (
48
- // A real navigation: signing in happens at the provider, not in this page.
49
- <a
50
- key={provider.key}
51
- className="sc-button sc-button-primary sc-sign-in-button"
52
- href={loginPath(provider.key, props.returnTo)}
53
- >
54
- {words.continueWith(provider.label)}
55
- </a>
56
- ))}
77
+ {props.providers.map((provider) => {
78
+ const going = taken === provider.key;
79
+ return (
80
+ <div key={provider.key} className="sc-sign-in-way">
81
+ {/* A real navigation: signing in happens at the provider, not in this page. */}
82
+ <a
83
+ className={cx(
84
+ "sc-button sc-button-primary sc-sign-in-button",
85
+ going && "sc-sign-in-button-going",
86
+ )}
87
+ href={loginPath(provider.key, props.returnTo)}
88
+ aria-busy={going ? true : undefined}
89
+ aria-disabled={taken !== undefined && !going ? true : undefined}
90
+ onClick={take(provider.key)}
91
+ >
92
+ <ProviderMark provider={provider.key} />
93
+ <span className="sc-sign-in-button-label">
94
+ {going ? words.going(provider.label) : words.signInWith(provider.label)}
95
+ </span>
96
+ {going && (
97
+ <span className="sc-spinner sc-sign-in-spinner" aria-hidden="true">
98
+ <span className="sc-spinner-ring" />
99
+ </span>
100
+ )}
101
+ </a>
102
+ {provider.admits !== undefined && (
103
+ <p className="sc-sign-in-admits">{admitsOf(provider.admits)}</p>
104
+ )}
105
+ </div>
106
+ );
107
+ })}
57
108
  </div>
58
109
  )}
59
110
  </div>
60
111
  );
61
112
  }
113
+
114
+ /**
115
+ * The provider's mark, as people know it from sign-in buttons elsewhere, beside the label and
116
+ * decorative: the host's own mark in the button's text color, Google's lettermark on a light
117
+ * tile, since its colors are its own. A provider without a mark shows the label alone.
118
+ */
119
+ function ProviderMark(props: { readonly provider: ProviderKey }) {
120
+ if (props.provider === "gh") {
121
+ return (
122
+ <svg
123
+ className="sc-sign-in-mark"
124
+ viewBox="0 0 16 16"
125
+ width="20"
126
+ height="20"
127
+ aria-hidden="true"
128
+ focusable="false"
129
+ >
130
+ <path
131
+ fill="currentColor"
132
+ d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0 0 16 8c0-4.42-3.58-8-8-8z"
133
+ />
134
+ </svg>
135
+ );
136
+ }
137
+ if (props.provider === "google") {
138
+ return (
139
+ <span className="sc-sign-in-mark sc-sign-in-mark-tile" aria-hidden="true">
140
+ <svg viewBox="0 0 48 48" width="16" height="16" aria-hidden="true" focusable="false">
141
+ <path
142
+ fill="#FFC107"
143
+ d="M43.611 20.083H42V20H24v8h11.303c-1.649 4.657-6.08 8-11.303 8-6.627 0-12-5.373-12-12s5.373-12 12-12c3.059 0 5.842 1.154 7.961 3.039l5.657-5.657C34.046 6.053 29.268 4 24 4 12.955 4 4 12.955 4 24s8.955 20 20 20 20-8.955 20-20c0-1.341-.138-2.65-.389-3.917z"
144
+ />
145
+ <path
146
+ fill="#FF3D00"
147
+ d="M6.306 14.691l6.571 4.819C14.655 15.108 18.961 12 24 12c3.059 0 5.842 1.154 7.961 3.039l5.657-5.657C34.046 6.053 29.268 4 24 4 16.318 4 9.656 8.337 6.306 14.691z"
148
+ />
149
+ <path
150
+ fill="#4CAF50"
151
+ d="M24 44c5.166 0 9.86-1.977 13.409-5.192l-6.19-5.238A11.91 11.91 0 0 1 24 36c-5.202 0-9.619-3.317-11.283-7.946l-6.522 5.025C9.505 39.556 16.227 44 24 44z"
152
+ />
153
+ <path
154
+ fill="#1976D2"
155
+ d="M43.611 20.083H42V20H24v8h11.303a12.04 12.04 0 0 1-4.087 5.571l.003-.002 6.19 5.238C36.971 39.205 44 34 44 24c0-1.341-.138-2.65-.389-3.917z"
156
+ />
157
+ </svg>
158
+ </span>
159
+ );
160
+ }
161
+ return null;
162
+ }
@@ -11,6 +11,7 @@ import {
11
11
  } from "../api.js";
12
12
  import { dayOf, daysBetween, formatDay, today } from "../dates.js";
13
13
  import { useLanguage, useWords } from "../i18n/index.js";
14
+ import { useRemotePictureSrc } from "./remote-pictures.js";
14
15
 
15
16
  // Small building blocks. Each one is a class in styles/console.css and nothing more. Classes
16
17
  // are prefixed `sc-` so that a custom console's own styles never collide with them. The words
@@ -96,13 +97,15 @@ export function personName(person: {
96
97
  }
97
98
 
98
99
  /**
99
- * A person's picture as the git host serves it: loaded lazily, without a referrer, and gone as
100
- * a whole when there is none or it cannot be loaded, leaving the initial of the name instead.
100
+ * A person's picture as the git host serves it, read through the console (ADR-0048): loaded
101
+ * lazily, without a referrer, and gone as a whole when there is none or it cannot be loaded,
102
+ * leaving the initial of the name instead.
101
103
  */
102
104
  export function Avatar(props: {
103
105
  readonly person: RestPerson;
104
106
  readonly size?: "xs" | "sm" | "md" | "lg";
105
107
  }) {
108
+ const remote = useRemotePictureSrc();
106
109
  const size = props.size ?? "md";
107
110
  const initial = personName(props.person).slice(0, 1).toUpperCase();
108
111
  if (props.person.avatar === null) {
@@ -118,7 +121,7 @@ export function Avatar(props: {
118
121
  return (
119
122
  <img
120
123
  className={cx("sc-avatar", `sc-avatar-${size}`)}
121
- src={props.person.avatar}
124
+ src={remote(props.person.avatar)}
122
125
  alt=""
123
126
  loading="lazy"
124
127
  decoding="async"
package/src/console.tsx CHANGED
@@ -58,6 +58,7 @@ import {
58
58
  type ProjectListProps,
59
59
  ProjectMark,
60
60
  } from "./components/projects.js";
61
+ import { type RemotePictureSrc, RemotePicturesContext } from "./components/remote-pictures.js";
61
62
  import { GoalTimeline } from "./components/roadmap.js";
62
63
  import { RulesForm, RulesView } from "./components/rules.js";
63
64
  import {
@@ -396,14 +397,18 @@ function ProjectsPage(props: {
396
397
  readonly making: boolean;
397
398
  }) {
398
399
  const words = useWords().projects;
399
- const { data, components, navigation, making } = props;
400
+ const { data, components, navigation } = props;
400
401
  const { busy, error, act } = useAction();
401
402
  const href = (project: RestProject) => projectHref(project.key);
403
+ // A project is made by an administrator (ADR-0045): a member sees the list alone, the form's
404
+ // address included.
405
+ const administrator = data.me?.person?.role === "admin";
406
+ const making = props.making && administrator;
402
407
  return (
403
408
  <>
404
409
  <div className="sc-page-head">
405
410
  <h1 className="sc-page-title">{words.title}</h1>
406
- {!making && (
411
+ {administrator && !making && (
407
412
  <LinkButton href={PATHS.newProject} variant="primary">
408
413
  {words.newProject}
409
414
  </LinkButton>
@@ -436,8 +441,12 @@ function ProjectsPage(props: {
436
441
  empty={
437
442
  <EmptyState
438
443
  title={words.empty.title}
439
- body={words.empty.body}
440
- action={<LinkButton href={PATHS.newProject}>{words.newProject}</LinkButton>}
444
+ body={administrator ? words.empty.body : words.empty.bodyMember}
445
+ action={
446
+ administrator ? (
447
+ <LinkButton href={PATHS.newProject}>{words.newProject}</LinkButton>
448
+ ) : undefined
449
+ }
441
450
  />
442
451
  }
443
452
  />
@@ -1432,6 +1441,7 @@ function ProjectSettingsPage(props: {
1432
1441
  busy={busy}
1433
1442
  onStart={data.actions.connectPackToken}
1434
1443
  poll={data.actions.packTokenConnection}
1444
+ claim={data.actions.claimPackTokenConnection}
1435
1445
  onCancelConnect={data.actions.cancelPackTokenConnection}
1436
1446
  onConnected={() => navigation.replace(here)}
1437
1447
  onSubmit={(token) =>
@@ -1654,6 +1664,7 @@ function WorkspaceSettingsPage(props: {
1654
1664
  busy={busy}
1655
1665
  onStart={data.actions.connectWorkspacePackToken}
1656
1666
  poll={data.actions.workspacePackTokenConnection}
1667
+ claim={data.actions.claimWorkspacePackTokenConnection}
1657
1668
  onCancelConnect={data.actions.cancelWorkspacePackTokenConnection}
1658
1669
  onConnected={() => navigation.replace(PATHS.settings)}
1659
1670
  onSubmit={(token) =>
@@ -2334,6 +2345,8 @@ function WorkspacePage(props: {
2334
2345
  export function createConsole(config: ConsoleConfig = {}): ConsoleApp {
2335
2346
  const components: ConsoleComponents = { ...DEFAULT_COMPONENTS, ...config.components };
2336
2347
  const client = config.client ?? createClient({ baseUrl: config.baseUrl ?? "" });
2348
+ /** Where the pages read a picture from elsewhere (ADR-0048): through this client's console. */
2349
+ const pictureSrc: RemotePictureSrc = (url) => client.pictureUrl(url);
2337
2350
  const fallbackTitle = config.title ?? "Console";
2338
2351
  const languages = config.languages ?? DEFAULT_LANGUAGES;
2339
2352
  const pages = config.pages ?? [];
@@ -2628,7 +2641,13 @@ export function createConsole(config: ConsoleConfig = {}): ConsoleApp {
2628
2641
  </components.Shell>
2629
2642
  );
2630
2643
  }
2631
- return <LanguageContext.Provider value={pack}>{content}</LanguageContext.Provider>;
2644
+ return (
2645
+ <LanguageContext.Provider value={pack}>
2646
+ <RemotePicturesContext.Provider value={pictureSrc}>
2647
+ {content}
2648
+ </RemotePicturesContext.Provider>
2649
+ </LanguageContext.Provider>
2650
+ );
2632
2651
  }
2633
2652
 
2634
2653
  return {
package/src/data.ts CHANGED
@@ -66,12 +66,12 @@ import {
66
66
 
67
67
  // What the console shows, and how it stays current: the workspace (who is signed in, the
68
68
  // projects, the people, the tokens) is loaded once; the project the page is on is loaded whole,
69
- // its feed from the beginning, and from then on the project's stream says when something
69
+ // its feed as it stands, the newest page, and from then on the project's stream says when something
70
70
  // changed: on every event the lists are loaded again, which is the boring way to be right.
71
71
 
72
72
  /** How many events the feed keeps on the page. */
73
73
  const FEED_KEEP = 200;
74
- /** How many pages of the feed are read at the start before the stream takes over. */
74
+ /** How many pages of a task's own events are read for its thread, which is read from the beginning. */
75
75
  const FEED_MAX_PAGES = 20;
76
76
  /** How long changes that arrive together are gathered before the lists are loaded once. */
77
77
  const RELOAD_DELAY_MS = 150;
@@ -89,8 +89,10 @@ export interface ConsoleActions {
89
89
  removePackToken(): Promise<RestProject>;
90
90
  /** Asks the deployment to connect the project's pack with a code a person approves (ADR-0036); for an owner. */
91
91
  connectPackToken(): Promise<RestPackConnectStarted>;
92
- /** How that connection stands; once connected, the project is the one answered. */
92
+ /** How that connection stands, read and changed nothing. */
93
93
  packTokenConnection(signal?: AbortSignal): Promise<RestPackConnect>;
94
+ /** Asks the console to claim the token at the deployment (ADR-0046); once connected, the project is the one answered. */
95
+ claimPackTokenConnection(signal?: AbortSignal): Promise<RestPackConnect>;
94
96
  cancelPackTokenConnection(): Promise<void>;
95
97
  addMember(input: RestMemberInput): Promise<RestMember>;
96
98
  updateMember(personId: string, patch: RestMemberPatch): Promise<RestMember>;
@@ -177,6 +179,7 @@ export interface ConsoleActions {
177
179
  removeWorkspacePackToken(): Promise<RestWorkspace>;
178
180
  connectWorkspacePackToken(): Promise<RestPackConnectStarted>;
179
181
  workspacePackTokenConnection(signal?: AbortSignal): Promise<RestWorkspacePackConnect>;
182
+ claimWorkspacePackTokenConnection(signal?: AbortSignal): Promise<RestWorkspacePackConnect>;
180
183
  cancelWorkspacePackTokenConnection(): Promise<void>;
181
184
  /** One person of the workspace by their exact login, or nobody: what an owner lists someone the People page does not show by. */
182
185
  findPerson(login: string, signal?: AbortSignal): Promise<RestPerson | undefined>;
@@ -247,23 +250,18 @@ export interface ConsoleData {
247
250
  reload(): void;
248
251
  }
249
252
 
250
- /** The whole feed of a project from the beginning, page by page, bounded. */
253
+ /**
254
+ * The feed of a project as it stands: its newest page, oldest first, from which the stream
255
+ * goes on. Read in one request, where reading it from the beginning was a round trip per
256
+ * hundred events, each waited for before the board was shown.
257
+ */
251
258
  async function readFeed(
252
259
  client: ConsoleClient,
253
260
  key: string,
254
261
  signal: AbortSignal,
255
262
  ): Promise<RestEvent[]> {
256
- const events: RestEvent[] = [];
257
- let after = 0;
258
- for (let page = 0; page < FEED_MAX_PAGES; page += 1) {
259
- const result = await client.project(key).events(after, {}, signal);
260
- events.push(...result.items);
261
- after = result.items.at(-1)?.id ?? after;
262
- if (!result.more) {
263
- break;
264
- }
265
- }
266
- return events.slice(-FEED_KEEP);
263
+ const result = await client.project(key).events(0, { latest: true }, signal);
264
+ return result.items.slice(-FEED_KEEP);
267
265
  }
268
266
 
269
267
  /** How many pages of the current tasks are read before the board is shown as it stands. */
@@ -468,7 +466,7 @@ export function useConsoleData(client: ConsoleClient, projectKey: string | undef
468
466
  return () => controller.abort();
469
467
  }, [client, generation, refreshWorkspace]);
470
468
 
471
- // The project the page is on: its lists, its feed from the beginning, and its stream from
469
+ // The project the page is on: its lists, its feed as it stands, the newest page, and its stream from
472
470
  // where the feed ends. Another project, or none, starts over.
473
471
  // biome-ignore lint/correctness/useExhaustiveDependencies: `generation` is the trigger
474
472
  useEffect(() => {
@@ -612,8 +610,9 @@ export function useConsoleData(client: ConsoleClient, projectKey: string | undef
612
610
  return changed;
613
611
  },
614
612
  connectPackToken: () => scope().connectPackToken(),
615
- async packTokenConnection(signal) {
616
- const state = await scope().packTokenConnection(signal);
613
+ packTokenConnection: (signal) => scope().packTokenConnection(signal),
614
+ async claimPackTokenConnection(signal) {
615
+ const state = await scope().claimPackTokenConnection(signal);
617
616
  if (state.status === "connected") {
618
617
  setProject(state.project);
619
618
  }
@@ -756,8 +755,9 @@ export function useConsoleData(client: ConsoleClient, projectKey: string | undef
756
755
  return workspace;
757
756
  },
758
757
  connectWorkspacePackToken: () => client.connectWorkspacePackToken(),
759
- async workspacePackTokenConnection(signal) {
760
- const state = await client.workspacePackTokenConnection(signal);
758
+ workspacePackTokenConnection: (signal) => client.workspacePackTokenConnection(signal),
759
+ async claimWorkspacePackTokenConnection(signal) {
760
+ const state = await client.claimWorkspacePackTokenConnection(signal);
761
761
  if (state.status === "connected") {
762
762
  const { workspace } = state;
763
763
  setMe((current) => (current === undefined ? undefined : { ...current, workspace }));
package/src/i18n/en.ts CHANGED
@@ -157,7 +157,15 @@ export const en = {
157
157
 
158
158
  signIn: {
159
159
  lead: "Join an organization made of AI agents.",
160
- continueWith: (provider: string) => `Continue with ${provider}`,
160
+ signInWith: (provider: string) => `Sign in with ${provider}`,
161
+ /** The button while the browser is on its way to the provider. */
162
+ going: (provider: string) => `Going to ${provider}…`,
163
+ /** The line under a button: who gets in through that way. */
164
+ admits: {
165
+ organization: (name: string) => `For the members of ${name}, the organization at GitHub.`,
166
+ domains: (domains: string) => `For accounts at ${domains}.`,
167
+ listed: "For the people an administrator added.",
168
+ },
161
169
  nobody: {
162
170
  title: "Nobody can sign in here yet",
163
171
  body: "Signing in is not configured on this console. Whoever runs it sets it up as deploy/README.md describes.",
@@ -176,6 +184,8 @@ export const en = {
176
184
  empty: {
177
185
  title: "No projects yet",
178
186
  body: "Make the first one: what it is for, and who is in it. Or ask an owner to add you to theirs.",
187
+ bodyMember:
188
+ "An administrator makes the projects. Ask one for the first, or an owner to add you to theirs.",
179
189
  },
180
190
  list: "Projects",
181
191
  private: "Private",
@@ -889,6 +899,10 @@ export const en = {
889
899
  domain: "Of the domain",
890
900
  listed: "Added by an administrator",
891
901
  } satisfies Record<MembershipSource, string>,
902
+ /** A person the organization let in, by the organization's name: never read as their own. */
903
+ ofOrganization: (name: string) => `Member of ${name}`,
904
+ /** A person a Workspace domain let in, by the domain of their login. */
905
+ ofDomain: (domain: string) => `Account at ${domain}`,
892
906
  at: { gh: "at GitHub", google: "at Google" } satisfies Record<ProviderKey, string>,
893
907
  organization: (name: string) => `The organization ${name} at GitHub says who is in.`,
894
908
  organizationRead: "Last read",
package/src/i18n/ko.ts CHANGED
@@ -119,7 +119,13 @@ export const ko: Messages = {
119
119
 
120
120
  signIn: {
121
121
  lead: "AI 에이전트로 이뤄진 조직에 합류하세요.",
122
- continueWith: (provider) => `${provider}로 계속`,
122
+ signInWith: (provider) => `${provider}로 로그인`,
123
+ going: (provider) => `${provider}로 가는 중…`,
124
+ admits: {
125
+ organization: (name) => `GitHub 조직 ${name}의 멤버가 로그인할 수 있습니다.`,
126
+ domains: (domains) => `${domains} 계정이 로그인할 수 있습니다.`,
127
+ listed: "관리자가 추가한 사람이 로그인할 수 있습니다.",
128
+ },
123
129
  nobody: {
124
130
  title: "아직 아무도 로그인할 수 없습니다",
125
131
  body: "이 콘솔에는 로그인이 설정되어 있지 않습니다. 운영자가 deploy/README.md에 따라 설정합니다.",
@@ -138,6 +144,8 @@ export const ko: Messages = {
138
144
  empty: {
139
145
  title: "아직 프로젝트가 없습니다",
140
146
  body: "첫 프로젝트를 만드세요. 무엇을 위한 것인지, 누가 함께하는지. 또는 소유자에게 추가를 요청하세요.",
147
+ bodyMember:
148
+ "프로젝트는 관리자가 만듭니다. 관리자에게 첫 프로젝트를, 또는 소유자에게 추가를 요청하세요.",
141
149
  },
142
150
  list: "프로젝트",
143
151
  private: "비공개",
@@ -844,6 +852,8 @@ export const ko: Messages = {
844
852
  domain: "도메인의 계정",
845
853
  listed: "관리자가 추가함",
846
854
  },
855
+ ofOrganization: (name) => `${name} 조직의 구성원`,
856
+ ofDomain: (domain) => `${domain} 도메인의 계정`,
847
857
  at: { gh: "GitHub", google: "Google" },
848
858
  organization: (name) => `GitHub 조직 ${name}이(가) 구성원을 정합니다.`,
849
859
  organizationRead: "마지막으로 읽음",
package/src/limits.ts CHANGED
@@ -79,3 +79,17 @@ export const MAX_PROJECT_SYMBOL_LENGTH = 8;
79
79
  export const MAX_PROJECT_ICON_BYTES = 512 * 1024;
80
80
  /** How many items a listing answers with when none is asked for, and the most one may ask for. */
81
81
  export const DEFAULT_PAGE_LIMIT = 50;
82
+ /**
83
+ * How many projects one workspace holds, how many tasks, goals, decisions and pages one project
84
+ * holds, and how many runs one task carries, those put away included: bounds a runaway script
85
+ * meets and real work does not.
86
+ */
87
+ export const MAX_PROJECTS_PER_WORKSPACE = 500;
88
+ export const MAX_TASKS_PER_PROJECT = 20_000;
89
+ export const MAX_GOALS_PER_PROJECT = 1_000;
90
+ export const MAX_DECISIONS_PER_PROJECT = 20_000;
91
+ export const MAX_DOCUMENTS_PER_PROJECT = 5_000;
92
+ export const MAX_RUNS_PER_TASK = 500;
93
+ /** How many streams of the live feed one person holds at once, across their tabs, and how many one process of the console holds for everyone. */
94
+ export const MAX_STREAMS_PER_PERSON = 20;
95
+ export const MAX_STREAMS = 1_000;
package/src/routes.ts CHANGED
@@ -9,6 +9,8 @@ export const REST_ROUTES = {
9
9
  me: "/api/v1/me",
10
10
  /** The token a request is made with, for it to give itself up. */
11
11
  meToken: "/api/v1/me/token",
12
+ /** A picture from elsewhere, fetched by the console for a page (ADR-0048); `?src=` names it. */
13
+ pictures: "/api/v1/pictures",
12
14
  /** The workspace's settings (ADR-0035), changed by an administrator; read through `me`. */
13
15
  workspace: "/api/v1/workspace",
14
16
  /** The SkillCDN token the organization's pack is read with (ADR-0036), an administrator's; `/connect` under it connects one with a code. */