@skillcdn/console 0.4.2 → 0.4.3

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 (100) hide show
  1. package/dist/api.d.ts +2 -2
  2. package/dist/api.d.ts.map +1 -1
  3. package/dist/api.js +2 -2
  4. package/dist/api.js.map +1 -1
  5. package/dist/cli/cli.d.ts.map +1 -1
  6. package/dist/cli/cli.js +26 -14
  7. package/dist/cli/cli.js.map +1 -1
  8. package/dist/cli/format.d.ts.map +1 -1
  9. package/dist/cli/format.js +6 -1
  10. package/dist/cli/format.js.map +1 -1
  11. package/dist/cli/help.d.ts.map +1 -1
  12. package/dist/cli/help.js +9 -5
  13. package/dist/cli/help.js.map +1 -1
  14. package/dist/cli/plugin-check.d.ts +5 -0
  15. package/dist/cli/plugin-check.d.ts.map +1 -1
  16. package/dist/cli/plugin-check.js +22 -0
  17. package/dist/cli/plugin-check.js.map +1 -1
  18. package/dist/cli/plugin-cli.d.ts +1 -1
  19. package/dist/cli/plugin-cli.d.ts.map +1 -1
  20. package/dist/cli/plugin-cli.js +70 -4
  21. package/dist/cli/plugin-cli.js.map +1 -1
  22. package/dist/cli/plugin-init.d.ts +17 -0
  23. package/dist/cli/plugin-init.d.ts.map +1 -1
  24. package/dist/cli/plugin-init.js +135 -3
  25. package/dist/cli/plugin-init.js.map +1 -1
  26. package/dist/client.d.ts +3 -2
  27. package/dist/client.d.ts.map +1 -1
  28. package/dist/client.js +16 -5
  29. package/dist/client.js.map +1 -1
  30. package/dist/components/comments.d.ts.map +1 -1
  31. package/dist/components/comments.js +1 -1
  32. package/dist/components/comments.js.map +1 -1
  33. package/dist/components/decision-form.d.ts +2 -1
  34. package/dist/components/decision-form.d.ts.map +1 -1
  35. package/dist/components/decision-form.js +36 -4
  36. package/dist/components/decision-form.js.map +1 -1
  37. package/dist/components/decision-list.d.ts +4 -0
  38. package/dist/components/decision-list.d.ts.map +1 -1
  39. package/dist/components/decision-list.js +29 -3
  40. package/dist/components/decision-list.js.map +1 -1
  41. package/dist/components/my-decisions.d.ts +2 -0
  42. package/dist/components/my-decisions.d.ts.map +1 -1
  43. package/dist/components/my-decisions.js +2 -2
  44. package/dist/components/my-decisions.js.map +1 -1
  45. package/dist/components/task-view.d.ts +2 -1
  46. package/dist/components/task-view.d.ts.map +1 -1
  47. package/dist/components/task-view.js.map +1 -1
  48. package/dist/console.css +3 -1
  49. package/dist/console.d.ts.map +1 -1
  50. package/dist/console.js +16 -15
  51. package/dist/console.js.map +1 -1
  52. package/dist/data.d.ts +2 -1
  53. package/dist/data.d.ts.map +1 -1
  54. package/dist/data.js +26 -7
  55. package/dist/data.js.map +1 -1
  56. package/dist/i18n/en.d.ts +6 -0
  57. package/dist/i18n/en.d.ts.map +1 -1
  58. package/dist/i18n/en.js +6 -0
  59. package/dist/i18n/en.js.map +1 -1
  60. package/dist/i18n/ko.d.ts.map +1 -1
  61. package/dist/i18n/ko.js +6 -0
  62. package/dist/i18n/ko.js.map +1 -1
  63. package/dist/limits.d.ts +2 -0
  64. package/dist/limits.d.ts.map +1 -1
  65. package/dist/limits.js +2 -0
  66. package/dist/limits.js.map +1 -1
  67. package/dist/plugin/context.d.ts +6 -0
  68. package/dist/plugin/context.d.ts.map +1 -1
  69. package/dist/schemas.d.ts +77 -9
  70. package/dist/schemas.d.ts.map +1 -1
  71. package/dist/schemas.js +58 -16
  72. package/dist/schemas.js.map +1 -1
  73. package/docs/specs/cli.md +1 -1
  74. package/docs/specs/plugins.md +7 -6
  75. package/docs/specs/rest.md +8 -7
  76. package/package.json +7 -7
  77. package/skills/building-a-plugin/SKILL.md +4 -4
  78. package/skills/working-the-board/SKILL.md +1 -1
  79. package/src/api.ts +4 -0
  80. package/src/cli/cli.ts +40 -22
  81. package/src/cli/format.ts +12 -1
  82. package/src/cli/help.ts +9 -4
  83. package/src/cli/plugin-check.ts +27 -0
  84. package/src/cli/plugin-cli.ts +94 -5
  85. package/src/cli/plugin-init.ts +170 -3
  86. package/src/client.ts +28 -9
  87. package/src/components/comments.tsx +1 -0
  88. package/src/components/decision-form.tsx +92 -11
  89. package/src/components/decision-list.tsx +71 -2
  90. package/src/components/my-decisions.tsx +4 -1
  91. package/src/components/task-view.tsx +2 -1
  92. package/src/console.tsx +18 -12
  93. package/src/data.ts +32 -9
  94. package/src/i18n/en.ts +7 -0
  95. package/src/i18n/ko.ts +7 -0
  96. package/src/limits.ts +2 -0
  97. package/src/plugin/context.ts +6 -1
  98. package/src/schemas.ts +72 -18
  99. package/src/styles/console.css +3 -1
  100. package/template/plugin/package.json +3 -3
@@ -1,6 +1,7 @@
1
- import { type FormEvent, useMemo, useState } from "react";
1
+ import { type ClipboardEvent, type FormEvent, useMemo, useState } from "react";
2
2
  import {
3
3
  MAX_BODY_LENGTH,
4
+ MAX_FILES_PER_DECISION,
4
5
  MAX_OPTION_LABEL_LENGTH,
5
6
  MAX_OPTIONS,
6
7
  MAX_QUESTION_LENGTH,
@@ -10,14 +11,17 @@ import {
10
11
  type RestTask,
11
12
  } from "../api.js";
12
13
  import { useWords } from "../i18n/index.js";
14
+ import { isPicture, placing, unusedName } from "./files.js";
13
15
  import { Picker, type PickerSearch } from "./picker.js";
14
16
  import { type TaskSearch, taskLabel } from "./task-form.js";
15
- import { Button, PersonChip, personName } from "./ui.js";
17
+ import { Button, formatBytes, PersonChip, personName } from "./ui.js";
16
18
 
17
19
  // Raising a decision: the question, what a person needs to know, the options to choose from,
18
20
  // and who is to answer (ADR-0042): the person raising it, unless they name another of the
19
21
  // project. The task it is about is the page's, when raised from a task; otherwise it is found
20
- // by searching the board a page at a time (ADR-0037), never picked from the board whole.
22
+ // by searching the board a page at a time (ADR-0037), never picked from the board whole. The
23
+ // files put on the decision (ADR-0055) are chosen or pasted here, a pasted picture placed in
24
+ // the context where pictures are read, as a comment's are.
21
25
 
22
26
  export interface DecisionFormProps {
23
27
  /** The task it is about from the start, when raised from a task's page; the field is not shown then. */
@@ -30,7 +34,8 @@ export interface DecisionFormProps {
30
34
  readonly me?: RestPerson | undefined;
31
35
  readonly busy?: boolean | undefined;
32
36
  readonly error?: string | undefined;
33
- readonly onSubmit: (input: RestDecisionInput) => void;
37
+ /** Called with the decision as the API takes it, and the files put on it (ADR-0055), none when none were chosen. */
38
+ readonly onSubmit: (input: RestDecisionInput, files: readonly File[]) => void;
34
39
  readonly onCancel?: (() => void) | undefined;
35
40
  }
36
41
 
@@ -59,20 +64,61 @@ export function DecisionForm(props: DecisionFormProps) {
59
64
  const [about, setAbout] = useState<RestTask | undefined>(undefined);
60
65
  const [options, setOptions] = useState<readonly string[]>(["", ""]);
61
66
  const [decider, setDecider] = useState<RestPerson | undefined>(props.me);
67
+ const [files, setFiles] = useState<readonly File[]>([]);
62
68
  const findDeciders = useMemo(() => peopleSearch(props.deciders ?? []), [props.deciders]);
63
69
 
64
70
  const labels = options.map((option) => option.trim()).filter((option) => option.length > 0);
65
71
  const ready = question.trim().length > 0 && labels.length >= MIN_OPTIONS;
66
72
 
73
+ const add = (chosen: readonly File[]): File[] => {
74
+ const added: File[] = [];
75
+ let held = files;
76
+ for (const file of chosen) {
77
+ if (held.length >= MAX_FILES_PER_DECISION) {
78
+ break;
79
+ }
80
+ const name = unusedName(
81
+ file.name,
82
+ held.map((kept) => kept.name),
83
+ );
84
+ const kept = name === file.name ? file : new File([file], name, { type: file.type });
85
+ held = [...held, kept];
86
+ added.push(kept);
87
+ }
88
+ setFiles(held);
89
+ return added;
90
+ };
91
+
92
+ // A picture pasted into the context goes with the decision, placed where pictures are read.
93
+ const paste = (event: ClipboardEvent<HTMLTextAreaElement>) => {
94
+ const pasted = [...event.clipboardData.files].filter((file) => isPicture(file.type));
95
+ if (pasted.length === 0) {
96
+ return;
97
+ }
98
+ event.preventDefault();
99
+ const added = add(pasted);
100
+ if (added.length > 0) {
101
+ setBody((current) =>
102
+ placing(
103
+ current,
104
+ added.map((file) => file.name),
105
+ ),
106
+ );
107
+ }
108
+ };
109
+
67
110
  const submit = (event: FormEvent) => {
68
111
  event.preventDefault();
69
- props.onSubmit({
70
- question: question.trim(),
71
- body,
72
- options: labels.map((label) => ({ label })),
73
- taskId: props.taskId ?? about?.id ?? null,
74
- ...(decider === undefined || decider.id === props.me?.id ? {} : { deciderId: decider.id }),
75
- });
112
+ props.onSubmit(
113
+ {
114
+ question: question.trim(),
115
+ body,
116
+ options: labels.map((label) => ({ label })),
117
+ taskId: props.taskId ?? about?.id ?? null,
118
+ ...(decider === undefined || decider.id === props.me?.id ? {} : { deciderId: decider.id }),
119
+ },
120
+ files,
121
+ );
76
122
  };
77
123
 
78
124
  return (
@@ -96,8 +142,43 @@ export function DecisionForm(props: DecisionFormProps) {
96
142
  rows={4}
97
143
  placeholder={form.contextPlaceholder}
98
144
  onChange={(event) => setBody(event.target.value)}
145
+ onPaste={paste}
99
146
  />
100
147
  </label>
148
+ <div className="sc-field">
149
+ <span className="sc-field-label">{form.files}</span>
150
+ {files.length > 0 && (
151
+ <ul className="sc-chosen-files" aria-label={form.files}>
152
+ {files.map((file) => (
153
+ <li key={file.name} className="sc-chosen-file">
154
+ <span>{file.name}</span> <span className="sc-muted">{formatBytes(file.size)}</span>{" "}
155
+ <Button
156
+ variant="ghost"
157
+ size="sm"
158
+ aria-label={form.removeFile(file.name)}
159
+ onClick={() => setFiles((current) => current.filter((held) => held !== file))}
160
+ >
161
+ ×
162
+ </Button>
163
+ </li>
164
+ ))}
165
+ </ul>
166
+ )}
167
+ <label className="sc-button sc-button-ghost sc-button-sm sc-file-choice">
168
+ {form.chooseFiles}
169
+ <input
170
+ className="sc-file-input"
171
+ type="file"
172
+ multiple
173
+ disabled={props.busy === true || files.length >= MAX_FILES_PER_DECISION}
174
+ onChange={(event) => {
175
+ add([...(event.target.files ?? [])]);
176
+ event.target.value = "";
177
+ }}
178
+ />
179
+ </label>
180
+ <p className="sc-field-hint">{form.filesHint}</p>
181
+ </div>
101
182
  {props.taskId === undefined && props.findTasks !== undefined && (
102
183
  <div className="sc-field">
103
184
  <span className="sc-field-label">{form.about}</span>
@@ -6,13 +6,16 @@ import {
6
6
  MAX_OPTION_LABEL_LENGTH,
7
7
  type RestAnswerInput,
8
8
  type RestDecision,
9
+ type RestDecisionFile,
9
10
  type RestDecisionPatch,
10
11
  type RestTask,
11
12
  } from "../api.js";
12
13
  import { useWords } from "../i18n/index.js";
14
+ import { isPicture, namedFile, placed } from "./files.js";
13
15
  import { Markdown } from "./markdown.js";
14
16
  import { Pager, pageOf, RECORD_PAGE_SIZE } from "./paged.js";
15
- import { Badge, Button, cx, PersonChip, personName, Time } from "./ui.js";
17
+ import { Lightbox, PictureThumb, usePicture } from "./pictures.js";
18
+ import { Badge, Button, cx, formatBytes, PersonChip, personName, Time } from "./ui.js";
16
19
 
17
20
  // The decisions: the ones that wait, each a card with the way to answer it, since they are what
18
21
  // a person came for; and the ones answered, the record, a row each, since they are read back
@@ -31,6 +34,8 @@ export interface DecisionCardProps {
31
34
  readonly href?: string | undefined;
32
35
  /** The project the decision is in, named where a page shows the decisions of several. */
33
36
  readonly project?: React.ReactNode;
37
+ /** Where a file put on the decision is read, by its id (ADR-0055); left out, the files are named and not linked. */
38
+ readonly fileHref?: ((fileId: string) => string) | undefined;
34
39
  /** Called with the person's answer. Left out, a waiting decision cannot be answered here. */
35
40
  readonly onAnswer?: ((decision: RestDecision, input: RestAnswerInput) => void) | undefined;
36
41
  /** Called to grow the record, with what followed. Left out, it cannot be written here. */
@@ -255,6 +260,20 @@ export function DecisionCard(props: DecisionCardProps) {
255
260
  const words = useWords();
256
261
  const { decision, task } = props;
257
262
  const answered = decision.answer !== null;
263
+ // The files put on the decision (ADR-0055): a picture the context names by its file name is
264
+ // shown where the text puts it; the rest follow the context, pictures in place and
265
+ // everything else as a link.
266
+ const files = decision.files ?? [];
267
+ const pictures = usePicture();
268
+ const hrefOf = (file: RestDecisionFile) => props.fileHref?.(file.id);
269
+ const named = (source: string): string | undefined => {
270
+ const name = namedFile(source);
271
+ const file = files.find(
272
+ (candidate) => candidate.file.name === name && isPicture(candidate.file.contentType),
273
+ );
274
+ return file === undefined ? undefined : hrefOf(file);
275
+ };
276
+ const following = files.filter((file) => !placed(decision.body, file.file.name));
258
277
 
259
278
  return (
260
279
  <article id={decision.id} className={cx("sc-decision", answered && "sc-decision-answered")}>
@@ -289,7 +308,53 @@ export function DecisionCard(props: DecisionCardProps) {
289
308
  {decision.body.length > 0 && (
290
309
  <section className="sc-decision-section" aria-label={words.decisions.context}>
291
310
  <h4 className="sc-decision-label">{words.decisions.context}</h4>
292
- <Markdown source={decision.body} docHref={props.docHref} />
311
+ <Markdown
312
+ source={decision.body}
313
+ docHref={props.docHref}
314
+ fileSrc={named}
315
+ onOpenPicture={pictures.open}
316
+ />
317
+ </section>
318
+ )}
319
+ {following.length > 0 && (
320
+ <section className="sc-decision-section" aria-label={words.decisions.files}>
321
+ <h4 className="sc-decision-label">{words.decisions.files}</h4>
322
+ <ul className="sc-decision-files">
323
+ {following.map((attached) => {
324
+ const href = hrefOf(attached);
325
+ const { file } = attached;
326
+ if (href !== undefined && isPicture(file.contentType)) {
327
+ return (
328
+ <li key={attached.id} className="sc-decision-picture">
329
+ <PictureThumb
330
+ picture={{
331
+ src: href,
332
+ alt: words.decisions.picture(file.name),
333
+ name: file.name,
334
+ size: file.size,
335
+ }}
336
+ onOpen={pictures.open}
337
+ />
338
+ <span className="sc-muted">
339
+ {file.name}, {formatBytes(file.size)}
340
+ </span>
341
+ </li>
342
+ );
343
+ }
344
+ return (
345
+ <li key={attached.id}>
346
+ {href === undefined ? (
347
+ <span>{file.name}</span>
348
+ ) : (
349
+ <a href={href} rel="nofollow ugc">
350
+ {file.name}
351
+ </a>
352
+ )}{" "}
353
+ <span className="sc-muted">{formatBytes(file.size)}</span>
354
+ </li>
355
+ );
356
+ })}
357
+ </ul>
293
358
  </section>
294
359
  )}
295
360
  {decision.answer !== null ? (
@@ -322,6 +387,7 @@ export function DecisionCard(props: DecisionCardProps) {
322
387
  busy={props.busy}
323
388
  />
324
389
  )}
390
+ <Lightbox picture={pictures.shown} onClose={pictures.close} />
325
391
  </article>
326
392
  );
327
393
  }
@@ -332,6 +398,8 @@ export interface DecisionListProps {
332
398
  readonly tasks: ReadonlyMap<string, RestTask>;
333
399
  readonly taskHref?: ((task: RestTask) => string) | undefined;
334
400
  readonly docHref?: ((path: string) => string) | undefined;
401
+ /** Where a file put on a decision is read, by its id (ADR-0055). */
402
+ readonly fileHref?: ((fileId: string) => string) | undefined;
335
403
  /** Where each decision's own page is. */
336
404
  readonly decisionHref?: ((decision: RestDecision) => string) | undefined;
337
405
  /** The page of the record shown, from 1, and where each page is (ADR-0010); left out, the record turns its pages in place. */
@@ -465,6 +533,7 @@ export function DecisionList(props: DecisionListProps) {
465
533
  task={decision.taskId === null ? undefined : props.tasks.get(decision.taskId)}
466
534
  taskHref={props.taskHref}
467
535
  docHref={props.docHref}
536
+ fileHref={props.fileHref}
468
537
  href={props.decisionHref?.(decision)}
469
538
  onAnswer={props.onAnswer}
470
539
  onUpdate={props.onUpdate}
@@ -27,6 +27,8 @@ export interface MyDecisionsProps {
27
27
  /** The decisions the person asked of someone else: waiting and answered. */
28
28
  readonly raised: DecisionsPage;
29
29
  readonly decisionHref: (projectKey: string, decisionId: string) => string;
30
+ /** Where a file put on a decision is read, by its project's key and its id (ADR-0055). */
31
+ readonly fileHref?: ((projectKey: string, fileId: string) => string) | undefined;
30
32
  /** Called with the person's answer on a decision of theirs, in its project. Left out, a waiting decision is answered on its own page. */
31
33
  readonly onAnswer?:
32
34
  | ((projectKey: string, decision: RestDecision, input: RestAnswerInput) => void)
@@ -67,7 +69,7 @@ function More(props: {
67
69
  export function MyDecisions(props: MyDecisionsProps) {
68
70
  const words = useWords();
69
71
  const own = words.myDecisions;
70
- const { onAnswer } = props;
72
+ const { onAnswer, fileHref } = props;
71
73
  const waiting = props.addressed.items.filter(({ decision }) => decision.answer === null);
72
74
  const asked = props.raised.items.filter(({ decision }) => decision.answer === null);
73
75
  const record = [...props.addressed.items, ...props.raised.items]
@@ -80,6 +82,7 @@ export function MyDecisions(props: MyDecisionsProps) {
80
82
  decision={item.decision}
81
83
  href={props.decisionHref(item.project.key, item.decision.id)}
82
84
  project={<InProject project={item.project} />}
85
+ fileHref={fileHref === undefined ? undefined : (fileId) => fileHref(item.project.key, fileId)}
83
86
  onAnswer={
84
87
  answerable && onAnswer !== undefined
85
88
  ? (decision, input) => onAnswer(item.project.key, decision, input)
@@ -83,7 +83,8 @@ export interface TaskViewProps {
83
83
  readonly error?: string | undefined;
84
84
  readonly onChange: (task: RestTask, patch: RestTaskInput) => void;
85
85
  readonly onMove: (task: RestTask, state: TaskState) => void;
86
- readonly onRaiseDecision: (input: RestDecisionInput) => void;
86
+ /** Called with a decision raised about the task, and the files put on it (ADR-0055). */
87
+ readonly onRaiseDecision: (input: RestDecisionInput, files: readonly File[]) => void;
87
88
  readonly onAnswer: (decision: RestDecision, input: RestAnswerInput) => void;
88
89
  /** Called to grow a decision's record with what followed. */
89
90
  readonly onUpdateDecision?:
package/src/console.tsx CHANGED
@@ -356,6 +356,14 @@ function useFollowLinks(navigation: Navigation): void {
356
356
  }
357
357
 
358
358
  /** The key of the project a route is on, or nothing. */
359
+ /** The files chosen or pasted on a page, as the client sends them: each by its name, its bytes and its type where the browser knows one. */
360
+ const uploadsOf = (files: readonly File[]): FileUpload[] =>
361
+ files.map((file) => ({
362
+ name: file.name,
363
+ bytes: file,
364
+ ...(file.type === "" ? {} : { contentType: file.type }),
365
+ }));
366
+
359
367
  const projectOf = (route: Route): string | undefined =>
360
368
  "project" in route ? route.project : undefined;
361
369
 
@@ -863,9 +871,9 @@ function TaskPage(props: {
863
871
  })
864
872
  }
865
873
  onMove={(target, state) => void act(() => data.actions.updateTask(target.id, { state }))}
866
- onRaiseDecision={(input) =>
874
+ onRaiseDecision={(input, files) =>
867
875
  void act(async () => {
868
- await data.actions.raiseDecision(input);
876
+ await data.actions.raiseDecision(input, uploadsOf(files));
869
877
  navigation.replace(here);
870
878
  })
871
879
  }
@@ -889,14 +897,7 @@ function TaskPage(props: {
889
897
  }
890
898
  onComment={(draft) =>
891
899
  void act(async () => {
892
- await data.actions.comment(task.id, {
893
- body: draft.body,
894
- files: draft.files.map((file) => ({
895
- name: file.name,
896
- bytes: file,
897
- ...(file.type === "" ? {} : { contentType: file.type }),
898
- })),
899
- });
900
+ await data.actions.comment(task.id, { body: draft.body, files: uploadsOf(draft.files) });
900
901
  setComments(await taskComments(task.id));
901
902
  })
902
903
  }
@@ -1311,6 +1312,7 @@ function ProjectPage(props: {
1311
1312
  tasks={tasksById}
1312
1313
  taskHref={href}
1313
1314
  docHref={toDoc}
1315
+ fileHref={(fileId) => projectPath(key, "files", fileId)}
1314
1316
  decisionHref={(decision) => decisionHref(key, decision.id)}
1315
1317
  page={route.page}
1316
1318
  pageHref={(page) => decisionsHref(key, page)}
@@ -1337,9 +1339,9 @@ function ProjectPage(props: {
1337
1339
  deciders={data.members.map((member) => member.person)}
1338
1340
  me={data.me?.person ?? undefined}
1339
1341
  busy={busy}
1340
- onSubmit={(input: RestDecisionInput) =>
1342
+ onSubmit={(input: RestDecisionInput, files: readonly File[]) =>
1341
1343
  void act(async () => {
1342
- const made = await data.actions.raiseDecision(input);
1344
+ const made = await data.actions.raiseDecision(input, uploadsOf(files));
1343
1345
  navigation.replace(decisionHref(key, made.id));
1344
1346
  })
1345
1347
  }
@@ -1366,6 +1368,7 @@ function ProjectPage(props: {
1366
1368
  task={decision.taskId === null ? undefined : tasksById.get(decision.taskId)}
1367
1369
  taskHref={href}
1368
1370
  docHref={toDoc}
1371
+ fileHref={(fileId) => projectPath(key, "files", fileId)}
1369
1372
  onAnswer={onAnswer}
1370
1373
  onUpdate={onUpdate}
1371
1374
  busy={busy}
@@ -2301,6 +2304,7 @@ function MyDecisionsPage(props: { readonly data: ConsoleData }) {
2301
2304
  addressed={{ items: addressed.items, more: addressed.more, onMore: addressed.readMore }}
2302
2305
  raised={{ items: raised.items, more: raised.more, onMore: raised.readMore }}
2303
2306
  decisionHref={decisionHref}
2307
+ fileHref={(projectKey, fileId) => projectPath(projectKey, "files", fileId)}
2304
2308
  onAnswer={onAnswer}
2305
2309
  busy={busy || addressed.busy || raised.busy}
2306
2310
  />
@@ -2575,6 +2579,7 @@ export function createConsole(config: ConsoleConfig = {}): ConsoleApp {
2575
2579
  const languages = config.languages ?? DEFAULT_LANGUAGES;
2576
2580
  const pages = config.pages ?? [];
2577
2581
 
2582
+ // biome-ignore lint/correctness/noComponentHookFactories: createConsole runs once, when a console is built, never during a render; App is that console's one root component, closing over its config.
2578
2583
  function App(props: { readonly initialPath?: string | undefined }) {
2579
2584
  const { location, navigation } = useNavigation(props.initialPath);
2580
2585
  useFollowLinks(navigation);
@@ -2659,6 +2664,7 @@ export function createConsole(config: ConsoleConfig = {}): ConsoleApp {
2659
2664
  href: PATHS.decisions,
2660
2665
  label: words.nav.myDecisions,
2661
2666
  current: route.name === "my-decisions",
2667
+ count: data.me?.decisions?.waiting,
2662
2668
  },
2663
2669
  {
2664
2670
  href: PATHS.people,
package/src/data.ts CHANGED
@@ -125,7 +125,8 @@ export interface ConsoleActions {
125
125
  page: { readonly limit: number; readonly offset: number },
126
126
  signal?: AbortSignal,
127
127
  ): Promise<RestGoals>;
128
- raiseDecision(input: RestDecisionInput): Promise<RestDecision>;
128
+ /** Raises a decision in the project; with `files`, the files put on it (ADR-0055). */
129
+ raiseDecision(input: RestDecisionInput, files?: readonly FileUpload[]): Promise<RestDecision>;
129
130
  answerDecision(id: string, input: RestAnswerInput): Promise<RestDecision>;
130
131
  /** Answers a decision of any project the person may see, by its project's key: for the person's own page of decisions (ADR-0042). */
131
132
  answerDecisionIn(projectKey: string, id: string, input: RestAnswerInput): Promise<RestDecision>;
@@ -382,6 +383,20 @@ export function useConsoleData(client: ConsoleClient, projectKey: string | undef
382
383
 
383
384
  const reload = useCallback(() => setGeneration((current) => current + 1), []);
384
385
 
386
+ /**
387
+ * Asks again who is signed in, for what moves with the board: the decisions waiting for the
388
+ * person, said beside My decisions. What fails stays as it was.
389
+ */
390
+ const refreshMe = useCallback(
391
+ async (signal?: AbortSignal) => {
392
+ const answer = await client.me(signal);
393
+ if (signal?.aborted !== true) {
394
+ setMe(answer);
395
+ }
396
+ },
397
+ [client],
398
+ );
399
+
385
400
  /** Loads the workspace's lists again. What fails stays as it was. */
386
401
  const refreshWorkspace = useCallback(
387
402
  async (signal?: AbortSignal) => {
@@ -571,6 +586,11 @@ export function useConsoleData(client: ConsoleClient, projectKey: string | undef
571
586
  if (event.kind.startsWith("project.")) {
572
587
  refreshWorkspace(signal).catch(() => undefined);
573
588
  }
589
+ // A decision raised or answered here may be one of the person's own: the count beside
590
+ // My decisions follows it.
591
+ if (event.kind.startsWith("decision.")) {
592
+ refreshMe(signal).catch(() => undefined);
593
+ }
574
594
  }, RELOAD_DELAY_MS);
575
595
  };
576
596
  for (const kind of EVENT_KINDS) {
@@ -584,7 +604,7 @@ export function useConsoleData(client: ConsoleClient, projectKey: string | undef
584
604
  source?.close();
585
605
  setLive(false);
586
606
  };
587
- }, [client, projectKey, signedIn, generation, refreshProject, refreshWorkspace]);
607
+ }, [client, projectKey, signedIn, generation, refreshProject, refreshWorkspace, refreshMe]);
588
608
 
589
609
  const actions = useMemo<ConsoleActions>(() => {
590
610
  /** The project the page is on, for an action that needs one. */
@@ -684,16 +704,19 @@ export function useConsoleData(client: ConsoleClient, projectKey: string | undef
684
704
  scope().tasks({ archived: false, ...searchWordsOf(query), ...page }, signal),
685
705
  findGoals: (query, page, signal) =>
686
706
  scope().goals({ open: true, ...searchWordsOf(query), ...page }, signal),
687
- async raiseDecision(input) {
688
- const decision = await scope().raiseDecision(input);
689
- await refreshProject();
707
+ async raiseDecision(input, files) {
708
+ const decision = await scope().raiseDecision(input, files);
709
+ await Promise.all([refreshProject(), refreshMe().catch(() => undefined)]);
710
+ return decision;
711
+ },
712
+ async answerDecisionIn(projectKey, id, input) {
713
+ const decision = await client.project(projectKey).answerDecision(id, input);
714
+ await refreshMe().catch(() => undefined);
690
715
  return decision;
691
716
  },
692
- answerDecisionIn: (projectKey, id, input) =>
693
- client.project(projectKey).answerDecision(id, input),
694
717
  async answerDecision(id, input) {
695
718
  const decision = await scope().answerDecision(id, input);
696
- await refreshProject();
719
+ await Promise.all([refreshProject(), refreshMe().catch(() => undefined)]);
697
720
  return decision;
698
721
  },
699
722
  async updateDecision(id, patch) {
@@ -834,7 +857,7 @@ export function useConsoleData(client: ConsoleClient, projectKey: string | undef
834
857
  setLoaded(false);
835
858
  },
836
859
  };
837
- }, [client, projectKey, refreshProject, refreshWorkspace]);
860
+ }, [client, projectKey, refreshProject, refreshWorkspace, refreshMe]);
838
861
 
839
862
  return {
840
863
  me,
package/src/i18n/en.ts CHANGED
@@ -548,6 +548,8 @@ export const en = {
548
548
  about: "about",
549
549
  answeredBy: "answered",
550
550
  context: "Context",
551
+ files: "Files",
552
+ picture: (name: string) => `The picture ${name}`,
551
553
  whatFollowed: "What followed",
552
554
  nothingWritten: "Nothing written yet.",
553
555
  outcomePlaceholder: "What was done with the answer, and what came of it, in Markdown.",
@@ -557,6 +559,11 @@ export const en = {
557
559
  question: "Question",
558
560
  context: "Context",
559
561
  contextPlaceholder: "What a person needs to know to answer, in Markdown.",
562
+ files: "Files",
563
+ chooseFiles: "Add files",
564
+ filesHint:
565
+ "A picture pasted or chosen here goes with the decision; ![words](its name) places it in the context.",
566
+ removeFile: (name: string) => `Take ${name} out`,
560
567
  about: "About",
561
568
  noTask: "No task in particular",
562
569
  decider: "Who decides",
package/src/i18n/ko.ts CHANGED
@@ -489,6 +489,8 @@ export const ko: Messages = {
489
489
  about: "관련 작업:",
490
490
  answeredBy: "답변",
491
491
  context: "맥락",
492
+ files: "파일",
493
+ picture: (name) => `그림 ${name}`,
492
494
  whatFollowed: "그 뒤에 일어난 일",
493
495
  nothingWritten: "아직 적힌 것이 없습니다.",
494
496
  outcomePlaceholder: "답변으로 무엇을 했고 무엇이 됐는지, Markdown으로.",
@@ -498,6 +500,11 @@ export const ko: Messages = {
498
500
  question: "질문",
499
501
  context: "맥락",
500
502
  contextPlaceholder: "답하려면 알아야 할 것, Markdown으로.",
503
+ files: "파일",
504
+ chooseFiles: "파일 추가",
505
+ filesHint:
506
+ "여기에 붙여 넣거나 고른 그림은 결정과 함께 올라갑니다. ![설명](파일 이름)으로 맥락에 넣을 수 있습니다.",
507
+ removeFile: (name) => `${name} 빼기`,
501
508
  about: "관련 작업",
502
509
  noTask: "특정 작업 없음",
503
510
  decider: "결정할 사람",
package/src/limits.ts CHANGED
@@ -33,6 +33,8 @@ export const MAX_ARTIFACTS_PER_RUN = 50;
33
33
  /** How many comments one task carries, and how many files one comment carries, together within `MAX_FILE_BYTES`. */
34
34
  export const MAX_COMMENTS_PER_TASK = 500;
35
35
  export const MAX_FILES_PER_COMMENT = 10;
36
+ /** How many files one decision carries, put on it when it is raised (ADR-0055), together within `MAX_FILE_BYTES`. */
37
+ export const MAX_FILES_PER_DECISION = 10;
36
38
  /** A file a run hands in: how many bytes, and its name, one line without a path. */
37
39
  export const MAX_FILE_BYTES = 10 * 1024 * 1024;
38
40
  export const MAX_FILE_NAME_LENGTH = 200;
@@ -255,7 +255,12 @@ export interface PluginWorkspace {
255
255
  export interface DecisionAsk {
256
256
  readonly question: string;
257
257
  readonly body?: string;
258
- readonly options: readonly { readonly label: string }[];
258
+ /**
259
+ * The options, each a label and optionally a key (spelled as a plugin's key is, each once),
260
+ * which the answered event carries back as `optionKey`, so that the plugin reads the answer
261
+ * without comparing labels.
262
+ */
263
+ readonly options: readonly { readonly label: string; readonly key?: string }[];
259
264
  /** Who is to answer: a person who may work in the project; the project's owners when left out. */
260
265
  readonly deciderId?: string;
261
266
  readonly taskId?: string;