@skillcdn/console 0.1.15 → 0.1.17

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 (113) hide show
  1. package/README.md +2 -1
  2. package/dist/api.d.ts +3 -3
  3. package/dist/api.d.ts.map +1 -1
  4. package/dist/api.js +2 -2
  5. package/dist/api.js.map +1 -1
  6. package/dist/cli/cli.d.ts +2 -0
  7. package/dist/cli/cli.d.ts.map +1 -1
  8. package/dist/cli/cli.js +91 -9
  9. package/dist/cli/cli.js.map +1 -1
  10. package/dist/cli/format.d.ts +7 -2
  11. package/dist/cli/format.d.ts.map +1 -1
  12. package/dist/cli/format.js +23 -2
  13. package/dist/cli/format.js.map +1 -1
  14. package/dist/cli/help.d.ts.map +1 -1
  15. package/dist/cli/help.js +28 -4
  16. package/dist/cli/help.js.map +1 -1
  17. package/dist/cli/main.js +2 -1
  18. package/dist/cli/main.js.map +1 -1
  19. package/dist/cli/terminal.d.ts +5 -0
  20. package/dist/cli/terminal.d.ts.map +1 -1
  21. package/dist/cli/terminal.js +11 -1
  22. package/dist/cli/terminal.js.map +1 -1
  23. package/dist/client.d.ts +28 -3
  24. package/dist/client.d.ts.map +1 -1
  25. package/dist/client.js +62 -2
  26. package/dist/client.js.map +1 -1
  27. package/dist/components/calendar.d.ts +35 -2
  28. package/dist/components/calendar.d.ts.map +1 -1
  29. package/dist/components/calendar.js +58 -19
  30. package/dist/components/calendar.js.map +1 -1
  31. package/dist/components/comments.d.ts +59 -0
  32. package/dist/components/comments.d.ts.map +1 -0
  33. package/dist/components/comments.js +138 -0
  34. package/dist/components/comments.js.map +1 -0
  35. package/dist/components/event-feed.d.ts.map +1 -1
  36. package/dist/components/event-feed.js +2 -0
  37. package/dist/components/event-feed.js.map +1 -1
  38. package/dist/components/markdown.d.ts +6 -0
  39. package/dist/components/markdown.d.ts.map +1 -1
  40. package/dist/components/markdown.js +2 -1
  41. package/dist/components/markdown.js.map +1 -1
  42. package/dist/components/projects.d.ts +4 -1
  43. package/dist/components/projects.d.ts.map +1 -1
  44. package/dist/components/projects.js +10 -2
  45. package/dist/components/projects.js.map +1 -1
  46. package/dist/components/task-form.d.ts.map +1 -1
  47. package/dist/components/task-form.js +1 -1
  48. package/dist/components/task-form.js.map +1 -1
  49. package/dist/components/task-view.d.ts +8 -3
  50. package/dist/components/task-view.d.ts.map +1 -1
  51. package/dist/components/task-view.js +6 -4
  52. package/dist/components/task-view.js.map +1 -1
  53. package/dist/console.css +180 -32
  54. package/dist/console.d.ts.map +1 -1
  55. package/dist/console.js +23 -5
  56. package/dist/console.js.map +1 -1
  57. package/dist/data.d.ts +5 -1
  58. package/dist/data.d.ts.map +1 -1
  59. package/dist/data.js +2 -0
  60. package/dist/data.js.map +1 -1
  61. package/dist/i18n/en.d.ts +15 -2
  62. package/dist/i18n/en.d.ts.map +1 -1
  63. package/dist/i18n/en.js +17 -4
  64. package/dist/i18n/en.js.map +1 -1
  65. package/dist/i18n/ko.d.ts.map +1 -1
  66. package/dist/i18n/ko.js +17 -4
  67. package/dist/i18n/ko.js.map +1 -1
  68. package/dist/index.d.ts +1 -0
  69. package/dist/index.d.ts.map +1 -1
  70. package/dist/index.js +1 -0
  71. package/dist/index.js.map +1 -1
  72. package/dist/limits.d.ts +3 -0
  73. package/dist/limits.d.ts.map +1 -1
  74. package/dist/limits.js +3 -0
  75. package/dist/limits.js.map +1 -1
  76. package/dist/rules.js +2 -2
  77. package/dist/rules.js.map +1 -1
  78. package/dist/schemas.d.ts +105 -0
  79. package/dist/schemas.d.ts.map +1 -1
  80. package/dist/schemas.js +32 -1
  81. package/dist/schemas.js.map +1 -1
  82. package/dist/vocabulary.d.ts +1 -1
  83. package/dist/vocabulary.d.ts.map +1 -1
  84. package/dist/vocabulary.js +1 -0
  85. package/dist/vocabulary.js.map +1 -1
  86. package/docs/specs/cli.md +8 -4
  87. package/docs/specs/rest.md +9 -2
  88. package/package.json +1 -1
  89. package/skills/working-the-board/SKILL.md +6 -4
  90. package/src/api.ts +12 -0
  91. package/src/cli/cli.ts +106 -9
  92. package/src/cli/format.ts +28 -1
  93. package/src/cli/help.ts +32 -3
  94. package/src/cli/main.ts +2 -1
  95. package/src/cli/terminal.ts +12 -1
  96. package/src/client.ts +112 -3
  97. package/src/components/calendar.tsx +216 -28
  98. package/src/components/comments.tsx +342 -0
  99. package/src/components/event-feed.tsx +2 -0
  100. package/src/components/markdown.tsx +9 -1
  101. package/src/components/projects.tsx +19 -6
  102. package/src/components/task-form.tsx +1 -0
  103. package/src/components/task-view.tsx +32 -33
  104. package/src/console.tsx +27 -3
  105. package/src/data.ts +8 -0
  106. package/src/i18n/en.ts +19 -4
  107. package/src/i18n/ko.ts +19 -4
  108. package/src/index.ts +12 -0
  109. package/src/limits.ts +3 -0
  110. package/src/rules.ts +2 -2
  111. package/src/schemas.ts +40 -1
  112. package/src/styles/console.css +180 -32
  113. package/src/vocabulary.ts +1 -0
@@ -1,10 +1,17 @@
1
- import type { ReactNode, SyntheticEvent } from "react";
2
- import { OPEN_TASK_STATES, type RestCategory, type RestPerson, type RestTask } from "../api.js";
1
+ import { type ReactNode, type SyntheticEvent, useState } from "react";
2
+ import {
3
+ OPEN_TASK_STATES,
4
+ type RestCategory,
5
+ type RestGoal,
6
+ type RestPerson,
7
+ type RestTask,
8
+ } from "../api.js";
3
9
  import {
4
10
  addDays,
5
11
  addMonths,
6
12
  daysBetween,
7
13
  firstDayOfWeek,
14
+ formatDay,
8
15
  formatDays,
9
16
  formatMonth,
10
17
  formatWeekday,
@@ -16,15 +23,16 @@ import {
16
23
  weekOf,
17
24
  } from "../dates.js";
18
25
  import { useLanguage, useWords } from "../i18n/index.js";
19
- import { Avatar, cx, PersonChip, personName } from "./ui.js";
26
+ import { Avatar, CategoryBadge, cx, PersonChip, personName, StateBadge, Time } from "./ui.js";
20
27
 
21
28
  // The calendar of a project (ADR-0019): the month as a grid, or one week on its own. A task
22
29
  // planned from a start to a due day lies across its days as a bar, in a lane of its own within
23
30
  // each week so that the bars of one week never cross; a task with one day alone, a due day or
24
31
  // a start, sits on that day as a dot. On a project's calendar the person who has a task stands
25
32
  // before its title, since the days hold several people's work. Who has what says whether the
26
- // work is on its days. Takes its data as props and nothing from the network; the month or the
27
- // week shown is the address's (ADR-0010).
33
+ // work is on its days. The task under the pointer is shown in full within its item, with the
34
+ // facts its own page lists, above or below it where there is more room. Takes its data as props
35
+ // and nothing from the network; the month or the week shown is the address's (ADR-0010).
28
36
 
29
37
  const isOpen = (task: RestTask): boolean => OPEN_TASK_STATES.includes(task.state);
30
38
 
@@ -40,6 +48,8 @@ export interface CalendarProps {
40
48
  readonly today?: string | undefined;
41
49
  /** The workspace's categories, for the legend to say what the colours mean. */
42
50
  readonly categories?: readonly RestCategory[] | undefined;
51
+ /** The project's goals, so that the task under the pointer is shown with its goal's title. */
52
+ readonly goals?: readonly RestGoal[] | undefined;
43
53
  readonly taskHref: (task: RestTask) => string;
44
54
  /** Where the calendar is on another month. */
45
55
  readonly monthHref: (month: string) => string;
@@ -131,6 +141,174 @@ const tone = (task: RestTask, now: string): string =>
131
141
  isOpen(task) && task.dueOn !== null && task.dueOn < now && "sc-calendar-late",
132
142
  );
133
143
 
144
+ /** Where the card of the item under the pointer goes: the side with more room, kept within the window. */
145
+ export interface TipPlace {
146
+ readonly side: "above" | "below";
147
+ /** How far the card moves from the item's left edge, in pixels, to stay within the window: zero or less. */
148
+ readonly shift: number;
149
+ }
150
+
151
+ /** What the card may grow to, in pixels: 20rem at the usual root size, as the style says. */
152
+ const TIP_WIDTH = 320;
153
+ /** What stays between the card and the window's edge, in pixels. */
154
+ const TIP_MARGIN = 8;
155
+
156
+ /**
157
+ * Places the card beside an item, from the item's box and the window's size, both in pixels from
158
+ * the window's top left corner: above the item where the room above is at least the room below,
159
+ * else below it, and moved left where it would otherwise leave the window on the right.
160
+ */
161
+ export function placeTip(
162
+ item: { readonly top: number; readonly bottom: number; readonly left: number },
163
+ window: { readonly width: number; readonly height: number },
164
+ ): TipPlace {
165
+ const side = item.top >= window.height - item.bottom ? "above" : "below";
166
+ const left = Math.max(TIP_MARGIN, Math.min(item.left, window.width - TIP_WIDTH - TIP_MARGIN));
167
+ return { side, shift: Math.min(0, left - item.left) };
168
+ }
169
+
170
+ /** The item the pointer is on: a task on a day, since a bar has an item on each of its days, and where its card goes. */
171
+ interface Pointed {
172
+ readonly task: RestTask;
173
+ readonly day: string;
174
+ readonly place: TipPlace;
175
+ }
176
+
177
+ /** The item under the pointer, measured as the pointer arrives, since only the browser knows where it is. */
178
+ function usePointed(): [
179
+ Pointed | null,
180
+ (task: RestTask, day: string, item: HTMLElement) => void,
181
+ () => void,
182
+ ] {
183
+ const [pointed, setPointed] = useState<Pointed | null>(null);
184
+ const point = (task: RestTask, day: string, item: HTMLElement): void =>
185
+ setPointed({
186
+ task,
187
+ day,
188
+ place: placeTip(item.getBoundingClientRect(), {
189
+ width: window.innerWidth,
190
+ height: window.innerHeight,
191
+ }),
192
+ });
193
+ const leave = (): void => setPointed(null);
194
+ return [pointed, point, leave];
195
+ }
196
+
197
+ export interface TaskTipProps {
198
+ readonly task: RestTask;
199
+ /** The project's goals, so that the task's goal is named by its title; without them the row is left out. */
200
+ readonly goals?: readonly RestGoal[] | undefined;
201
+ readonly place: TipPlace;
202
+ }
203
+
204
+ /**
205
+ * The task under the pointer in full: its number and its title, then the facts its own page lists
206
+ * beside the text, those that are set. Lies within the item it belongs to, above or below it, and
207
+ * out of the accessible tree: the item's text is the title, and the facts are on the task's page.
208
+ */
209
+ export function TaskTip(props: TaskTipProps) {
210
+ const words = useWords();
211
+ const { tag } = useLanguage();
212
+ const { task, place } = props;
213
+ const goal = props.goals?.find((candidate) => candidate.id === task.goalId);
214
+ const day = (value: string): string => formatDay(value, tag);
215
+ return (
216
+ <div
217
+ className={cx(
218
+ "sc-task-tip",
219
+ place.side === "above" ? "sc-task-tip-above" : "sc-task-tip-below",
220
+ )}
221
+ style={{ left: place.shift }}
222
+ aria-hidden="true"
223
+ >
224
+ <p className="sc-task-tip-title">
225
+ #{task.number} {task.title}
226
+ </p>
227
+ <dl className="sc-task-tip-facts">
228
+ <div>
229
+ <dt>{words.task.state}</dt>
230
+ <dd>
231
+ <StateBadge state={task.state} />
232
+ </dd>
233
+ </div>
234
+ <div>
235
+ <dt>{words.task.form.priority}</dt>
236
+ <dd>{words.vocabulary.priority[task.priority]}</dd>
237
+ </div>
238
+ {task.category !== null && (
239
+ <div>
240
+ <dt>{words.task.category}</dt>
241
+ <dd>
242
+ <CategoryBadge category={task.category} />
243
+ </dd>
244
+ </div>
245
+ )}
246
+ {goal !== undefined && (
247
+ <div>
248
+ <dt>{words.task.goal}</dt>
249
+ <dd>{goal.title}</dd>
250
+ </div>
251
+ )}
252
+ <div>
253
+ <dt>{words.task.owner}</dt>
254
+ <dd>
255
+ <PersonChip person={task.owner} />
256
+ </dd>
257
+ </div>
258
+ <div>
259
+ <dt>{words.task.assignee}</dt>
260
+ <dd>
261
+ {task.assignee === null ? (
262
+ <span className="sc-muted">{words.common.nobody}</span>
263
+ ) : (
264
+ <PersonChip person={task.assignee} />
265
+ )}
266
+ </dd>
267
+ </div>
268
+ {(task.startsOn !== null || task.dueOn !== null) && (
269
+ <div>
270
+ <dt>{words.task.plan}</dt>
271
+ <dd>
272
+ {words.task.planned(
273
+ task.startsOn === null ? undefined : day(task.startsOn),
274
+ task.dueOn === null ? undefined : day(task.dueOn),
275
+ )}
276
+ </dd>
277
+ </div>
278
+ )}
279
+ {task.startedAt !== null && (
280
+ <div>
281
+ <dt>{words.task.started}</dt>
282
+ <dd>
283
+ <Time iso={task.startedAt} />
284
+ </dd>
285
+ </div>
286
+ )}
287
+ {task.doneAt !== null && (
288
+ <div>
289
+ <dt>{words.task.done}</dt>
290
+ <dd>
291
+ <Time iso={task.doneAt} />
292
+ </dd>
293
+ </div>
294
+ )}
295
+ <div>
296
+ <dt>{words.task.written}</dt>
297
+ <dd>
298
+ <Time iso={task.createdAt} />
299
+ </dd>
300
+ </div>
301
+ <div>
302
+ <dt>{words.task.changed}</dt>
303
+ <dd>
304
+ <Time iso={task.updatedAt} />
305
+ </dd>
306
+ </div>
307
+ </dl>
308
+ </div>
309
+ );
310
+ }
311
+
134
312
  interface WeekRowProps {
135
313
  readonly week: readonly GridDay[];
136
314
  readonly segments: readonly WeekSegment[];
@@ -142,26 +320,30 @@ interface WeekRowProps {
142
320
  readonly markOf?: ((task: RestTask) => ReactNode) | undefined;
143
321
  /** Whether the person who has a task stands before its title, where the days hold several people's work. */
144
322
  readonly people?: boolean | undefined;
145
- }
146
-
147
- /**
148
- * Marks an item whose title its line cut short, or which shows none (a bar on its later days),
149
- * so that the tip it carries in `data-tip` is shown at once while the pointer is on it. Measured
150
- * as the pointer arrives, since only the browser knows where the line ended.
151
- */
152
- function reveal(event: SyntheticEvent<HTMLElement>): void {
153
- const item = event.currentTarget;
154
- const title = item.querySelector(".sc-calendar-task-title");
155
- item.toggleAttribute("data-clipped", title === null || title.scrollWidth > title.clientWidth);
323
+ /** The item under the pointer, shown in full within that item, or none. */
324
+ readonly pointed: Pointed | null;
325
+ /** The project's goals, so that the item under the pointer names its goal; none across projects. */
326
+ readonly goals?: readonly RestGoal[] | undefined;
327
+ /** The pointer, or the focus, arrived on a task's item on a day. */
328
+ readonly onPoint: (task: RestTask, day: string, item: HTMLElement) => void;
329
+ /** It left. */
330
+ readonly onLeave: () => void;
156
331
  }
157
332
 
158
333
  /** One week as a row of cells: the bars across the days in their lanes, then the tasks on each day. */
159
334
  function WeekRow(props: WeekRowProps) {
160
335
  const { week, segments, now } = props;
161
- const tip = (task: RestTask): string =>
162
- props.people === true && task.assignee !== null
163
- ? `#${task.number} ${task.title} \u00B7 ${personName(task.assignee)}`
164
- : `#${task.number} ${task.title}`;
336
+ const pointing = (task: RestTask, day: string) => ({
337
+ onMouseEnter: (event: SyntheticEvent<HTMLElement>) =>
338
+ props.onPoint(task, day, event.currentTarget),
339
+ onMouseLeave: props.onLeave,
340
+ onFocus: (event: SyntheticEvent<HTMLElement>) => props.onPoint(task, day, event.currentTarget),
341
+ onBlur: props.onLeave,
342
+ });
343
+ const tipIn = (task: RestTask, day: string): ReactNode =>
344
+ props.pointed !== null && props.pointed.task.id === task.id && props.pointed.day === day ? (
345
+ <TaskTip task={task} goals={props.goals} place={props.pointed.place} />
346
+ ) : null;
165
347
  const who = (task: RestTask): ReactNode =>
166
348
  props.people === true && task.assignee !== null ? (
167
349
  <Avatar person={task.assignee} size="xs" />
@@ -199,12 +381,9 @@ function WeekRow(props: WeekRowProps) {
199
381
  segment.from === column && segment.begins && "sc-calendar-bar-begins",
200
382
  segment.to === column && segment.ends && "sc-calendar-bar-ends",
201
383
  tone(task, now),
202
- "sc-tip",
203
384
  )}
204
385
  href={props.taskHref(task)}
205
- data-tip={tip(task)}
206
- onMouseEnter={reveal}
207
- onFocus={reveal}
386
+ {...pointing(task, cell.day)}
208
387
  >
209
388
  {segment.from === column && (
210
389
  <>
@@ -213,6 +392,7 @@ function WeekRow(props: WeekRowProps) {
213
392
  <span className="sc-calendar-task-title">{task.title}</span>
214
393
  </>
215
394
  )}
395
+ {tipIn(task, cell.day)}
216
396
  </a>
217
397
  );
218
398
  })}
@@ -221,11 +401,9 @@ function WeekRow(props: WeekRowProps) {
221
401
  return (
222
402
  <a
223
403
  key={task.id}
224
- className={cx("sc-calendar-task", tone(task, now), "sc-tip")}
404
+ className={cx("sc-calendar-task", tone(task, now))}
225
405
  href={props.taskHref(task)}
226
- data-tip={tip(task)}
227
- onMouseEnter={reveal}
228
- onFocus={reveal}
406
+ {...pointing(task, cell.day)}
229
407
  >
230
408
  {mark === undefined || mark === null ? (
231
409
  <span
@@ -239,6 +417,7 @@ function WeekRow(props: WeekRowProps) {
239
417
  )}
240
418
  {who(task)}
241
419
  <span className="sc-calendar-task-title">{task.title}</span>
420
+ {tipIn(task, cell.day)}
242
421
  </a>
243
422
  );
244
423
  })}
@@ -251,6 +430,7 @@ function WeekRow(props: WeekRowProps) {
251
430
  export function Calendar(props: CalendarProps) {
252
431
  const words = useWords().calendar;
253
432
  const { tag } = useLanguage();
433
+ const [pointed, point, leave] = usePointed();
254
434
  const now = props.today ?? todayOf();
255
435
  const firstDay = firstDayOfWeek(tag);
256
436
  const weeks = monthGrid(props.month, firstDay);
@@ -310,6 +490,10 @@ export function Calendar(props: CalendarProps) {
310
490
  on={on}
311
491
  now={now}
312
492
  taskHref={props.taskHref}
493
+ pointed={pointed}
494
+ goals={props.goals}
495
+ onPoint={point}
496
+ onLeave={leave}
313
497
  people
314
498
  />
315
499
  ))}
@@ -349,6 +533,7 @@ export interface WeekProps {
349
533
  export function Week(props: WeekProps) {
350
534
  const words = useWords().calendar.week;
351
535
  const { tag } = useLanguage();
536
+ const [pointed, point, leave] = usePointed();
352
537
  const now = props.today ?? todayOf();
353
538
  const firstDay = firstDayOfWeek(tag);
354
539
  const days = weekOf(props.day ?? now, firstDay);
@@ -400,6 +585,9 @@ export function Week(props: WeekProps) {
400
585
  now={now}
401
586
  taskHref={props.taskHref}
402
587
  markOf={props.markOf}
588
+ pointed={pointed}
589
+ onPoint={point}
590
+ onLeave={leave}
403
591
  />
404
592
  </tbody>
405
593
  </table>
@@ -0,0 +1,342 @@
1
+ import { type ClipboardEvent, type FormEvent, type ReactNode, useState } from "react";
2
+ import {
3
+ agentLabel,
4
+ MAX_BODY_LENGTH,
5
+ MAX_FILES_PER_COMMENT,
6
+ type RestComment,
7
+ type RestCommentFile,
8
+ type RestDecision,
9
+ type RestRun,
10
+ type RestTask,
11
+ } from "../api.js";
12
+ import { useWords } from "../i18n/index.js";
13
+ import { DecisionCard, type DecisionCardProps } from "./decision-list.js";
14
+ import { Markdown } from "./markdown.js";
15
+ import { RunCard } from "./runs.js";
16
+ import { Button, formatBytes, PersonChip, personName, Time } from "./ui.js";
17
+
18
+ // The discussion on a task (ADR-0024): what people and agents said about it, each word with
19
+ // the files put in it, read in one thread with the runs on the task and the decisions about
20
+ // it, in the order they came. Takes its data as props and nothing from the network.
21
+
22
+ /** The kinds of picture the pages show in place: what a browser renders and cannot run. */
23
+ const PICTURE_TYPES: ReadonlySet<string> = new Set([
24
+ "image/png",
25
+ "image/jpeg",
26
+ "image/gif",
27
+ "image/webp",
28
+ ]);
29
+
30
+ /** Whether a file is a picture the pages show in place, by the media type the console kept. */
31
+ export function isPicture(contentType: string): boolean {
32
+ return PICTURE_TYPES.has(contentType);
33
+ }
34
+
35
+ /** Whether the text places a file by its name, as `![words](name)` or `![words](./name)`. */
36
+ function placed(body: string, name: string): boolean {
37
+ return body.includes(`](${name})`) || body.includes(`](./${name})`);
38
+ }
39
+
40
+ export interface CommentCardProps {
41
+ readonly comment: RestComment;
42
+ /** Where a file put in the comment is read: the project's address for it. Left out, a file is named and not linked. */
43
+ readonly fileHref?: ((file: RestCommentFile) => string) | undefined;
44
+ /** Where a document of the project is read, by its path, for the links in what was written. */
45
+ readonly docHref?: ((path: string) => string) | undefined;
46
+ }
47
+
48
+ export function CommentCard(props: CommentCardProps) {
49
+ const words = useWords();
50
+ const { comment } = props;
51
+ const hrefOf = (file: RestCommentFile) => props.fileHref?.(file);
52
+ // A picture the text names by its file name is shown where the text puts it; the rest of
53
+ // the files follow the text, pictures in place and everything else as a link.
54
+ const named = (source: string): string | undefined => {
55
+ const name = source.startsWith("./") ? source.slice(2) : source;
56
+ const file = comment.files.find(
57
+ (candidate) => candidate.file.name === name && isPicture(candidate.file.contentType),
58
+ );
59
+ return file === undefined ? undefined : hrefOf(file);
60
+ };
61
+ const following = comment.files.filter((file) => !placed(comment.body, file.file.name));
62
+ return (
63
+ <article
64
+ className="sc-comment"
65
+ aria-label={words.comments.label(personName(comment.author))}
66
+ id={comment.id}
67
+ >
68
+ <header className="sc-comment-header">
69
+ <PersonChip person={comment.author} />
70
+ {comment.agent !== null && (
71
+ <span className="sc-feed-agent" title={comment.agent}>
72
+ {words.feed.as(agentLabel(comment.agent))}
73
+ </span>
74
+ )}
75
+ <span className="sc-comment-meta">
76
+ <Time iso={comment.createdAt} />
77
+ </span>
78
+ </header>
79
+ {comment.body.trim().length > 0 && (
80
+ <Markdown source={comment.body} docHref={props.docHref} fileSrc={named} />
81
+ )}
82
+ {following.length > 0 && (
83
+ <ul className="sc-comment-files" aria-label={words.comments.files}>
84
+ {following.map((attached) => {
85
+ const href = hrefOf(attached);
86
+ const { file } = attached;
87
+ if (href !== undefined && isPicture(file.contentType)) {
88
+ return (
89
+ <li key={attached.id} className="sc-comment-picture">
90
+ <a href={href} rel="noopener nofollow ugc" target="_blank">
91
+ <img
92
+ src={href}
93
+ alt={words.comments.picture(file.name)}
94
+ loading="lazy"
95
+ decoding="async"
96
+ />
97
+ </a>
98
+ <span className="sc-muted">
99
+ {file.name}, {formatBytes(file.size)}
100
+ </span>
101
+ </li>
102
+ );
103
+ }
104
+ return (
105
+ <li key={attached.id}>
106
+ {href === undefined ? (
107
+ <span>{file.name}</span>
108
+ ) : (
109
+ <a href={href} rel="nofollow ugc">
110
+ {file.name}
111
+ </a>
112
+ )}{" "}
113
+ <span className="sc-muted">{formatBytes(file.size)}</span>
114
+ </li>
115
+ );
116
+ })}
117
+ </ul>
118
+ )}
119
+ </article>
120
+ );
121
+ }
122
+
123
+ /** What a comment form hands over: the word, and the files chosen or pasted. */
124
+ export interface CommentDraft {
125
+ readonly body: string;
126
+ readonly files: readonly File[];
127
+ }
128
+
129
+ export interface CommentFormProps {
130
+ readonly onSubmit: (draft: CommentDraft) => void;
131
+ readonly busy?: boolean | undefined;
132
+ }
133
+
134
+ /** A name for a file among those chosen already: the same, or with a count before its extension. */
135
+ function unusedName(name: string, taken: readonly File[]): string {
136
+ const names = new Set(taken.map((file) => file.name));
137
+ if (!names.has(name)) {
138
+ return name;
139
+ }
140
+ const dot = name.lastIndexOf(".");
141
+ const stem = dot > 0 ? name.slice(0, dot) : name;
142
+ const extension = dot > 0 ? name.slice(dot) : "";
143
+ for (let n = 2; ; n += 1) {
144
+ const candidate = `${stem}-${n}${extension}`;
145
+ if (!names.has(candidate)) {
146
+ return candidate;
147
+ }
148
+ }
149
+ }
150
+
151
+ export function CommentForm(props: CommentFormProps) {
152
+ const words = useWords().comments;
153
+ const [body, setBody] = useState("");
154
+ const [files, setFiles] = useState<readonly File[]>([]);
155
+ const empty = body.trim().length === 0 && files.length === 0;
156
+
157
+ const add = (chosen: readonly File[]): File[] => {
158
+ const added: File[] = [];
159
+ let held = files;
160
+ for (const file of chosen) {
161
+ if (held.length >= MAX_FILES_PER_COMMENT) {
162
+ break;
163
+ }
164
+ const name = unusedName(file.name, held);
165
+ const kept = name === file.name ? file : new File([file], name, { type: file.type });
166
+ held = [...held, kept];
167
+ added.push(kept);
168
+ }
169
+ setFiles(held);
170
+ return added;
171
+ };
172
+
173
+ // A picture pasted in goes with the comment, placed in the text where pictures are read.
174
+ const paste = (event: ClipboardEvent<HTMLTextAreaElement>) => {
175
+ const pasted = [...event.clipboardData.files].filter((file) => isPicture(file.type));
176
+ if (pasted.length === 0) {
177
+ return;
178
+ }
179
+ event.preventDefault();
180
+ const added = add(pasted);
181
+ if (added.length > 0) {
182
+ const lines = added.map((file) => `![](${file.name})`).join("\n");
183
+ setBody((current) =>
184
+ current.trim().length === 0 ? lines : `${current.trimEnd()}\n\n${lines}`,
185
+ );
186
+ }
187
+ };
188
+
189
+ const submit = (event: FormEvent) => {
190
+ event.preventDefault();
191
+ if (empty || props.busy === true) {
192
+ return;
193
+ }
194
+ props.onSubmit({ body, files });
195
+ setBody("");
196
+ setFiles([]);
197
+ };
198
+
199
+ return (
200
+ <form className="sc-comment-form" aria-label={words.write} onSubmit={submit}>
201
+ <textarea
202
+ className="sc-input sc-textarea"
203
+ aria-label={words.write}
204
+ placeholder={words.placeholder}
205
+ maxLength={MAX_BODY_LENGTH}
206
+ rows={4}
207
+ value={body}
208
+ disabled={props.busy === true}
209
+ onChange={(event) => setBody(event.target.value)}
210
+ onPaste={paste}
211
+ />
212
+ {files.length > 0 && (
213
+ <ul className="sc-comment-chosen" aria-label={words.files}>
214
+ {files.map((file) => (
215
+ <li key={file.name} className="sc-comment-chosen-file">
216
+ <span>{file.name}</span> <span className="sc-muted">{formatBytes(file.size)}</span>{" "}
217
+ <Button
218
+ variant="ghost"
219
+ size="sm"
220
+ aria-label={words.removeFile(file.name)}
221
+ onClick={() => setFiles((current) => current.filter((held) => held !== file))}
222
+ >
223
+ ×
224
+ </Button>
225
+ </li>
226
+ ))}
227
+ </ul>
228
+ )}
229
+ <div className="sc-comment-form-actions">
230
+ <label className="sc-button sc-button-ghost sc-button-sm">
231
+ {words.chooseFiles}
232
+ <input
233
+ className="sc-comment-file-input"
234
+ type="file"
235
+ multiple
236
+ onChange={(event) => {
237
+ add([...(event.target.files ?? [])]);
238
+ event.target.value = "";
239
+ }}
240
+ />
241
+ </label>
242
+ <Button type="submit" variant="primary" disabled={empty || props.busy === true}>
243
+ {words.one}
244
+ </Button>
245
+ </div>
246
+ <p className="sc-field-hint">{words.hint}</p>
247
+ </form>
248
+ );
249
+ }
250
+
251
+ /** One thing said or done about a task, with when, for the thread's order. */
252
+ export type ThreadItem =
253
+ | { readonly kind: "comment"; readonly at: string; readonly comment: RestComment }
254
+ | { readonly kind: "run"; readonly at: string; readonly run: RestRun }
255
+ | { readonly kind: "decision"; readonly at: string; readonly decision: RestDecision };
256
+
257
+ /** The comments, the runs and the decisions of a task as one thread, in the order they came. */
258
+ export function threadOf(
259
+ comments: readonly RestComment[],
260
+ runs: readonly RestRun[],
261
+ decisions: readonly RestDecision[],
262
+ ): ThreadItem[] {
263
+ const items: ThreadItem[] = [
264
+ ...comments.map((comment) => ({ kind: "comment" as const, at: comment.createdAt, comment })),
265
+ ...runs.map((run) => ({ kind: "run" as const, at: run.startedAt, run })),
266
+ ...decisions.map((decision) => ({
267
+ kind: "decision" as const,
268
+ at: decision.createdAt,
269
+ decision,
270
+ })),
271
+ ];
272
+ return items.sort((a, b) => (a.at < b.at ? -1 : a.at > b.at ? 1 : 0));
273
+ }
274
+
275
+ export interface TaskThreadProps {
276
+ readonly task: RestTask;
277
+ readonly comments: readonly RestComment[];
278
+ readonly runs: readonly RestRun[];
279
+ readonly decisions: readonly RestDecision[];
280
+ /** Where a file of the project is read, by its id: a run's as a comment's. */
281
+ readonly fileHref?: ((fileId: string) => string) | undefined;
282
+ /** Where a decision is answered, for a run that waits for one. */
283
+ readonly decisionHref?: ((decisionId: string) => string) | undefined;
284
+ readonly docHref?: ((path: string) => string) | undefined;
285
+ readonly onAnswer?: DecisionCardProps["onAnswer"];
286
+ readonly onUpdateDecision?: DecisionCardProps["onUpdate"];
287
+ readonly onAbandonRun?: ((run: RestRun) => void) | undefined;
288
+ /** Called with what was written; left out, nothing can be said here. */
289
+ readonly onComment?: ((draft: CommentDraft) => void) | undefined;
290
+ readonly busy?: boolean | undefined;
291
+ readonly empty?: ReactNode;
292
+ }
293
+
294
+ export function TaskThread(props: TaskThreadProps) {
295
+ const items = threadOf(props.comments, props.runs, props.decisions);
296
+ const fileHref = props.fileHref;
297
+ const shown = (item: ThreadItem): ReactNode => {
298
+ if (item.kind === "comment") {
299
+ return (
300
+ <CommentCard
301
+ key={item.comment.id}
302
+ comment={item.comment}
303
+ fileHref={fileHref === undefined ? undefined : (file) => fileHref(file.id)}
304
+ docHref={props.docHref}
305
+ />
306
+ );
307
+ }
308
+ if (item.kind === "run") {
309
+ return (
310
+ <RunCard
311
+ key={item.run.id}
312
+ run={item.run}
313
+ decisionHref={props.decisionHref}
314
+ fileHref={fileHref === undefined ? undefined : (artifact) => fileHref(artifact.id)}
315
+ docHref={props.docHref}
316
+ onAbandon={props.onAbandonRun}
317
+ busy={props.busy}
318
+ />
319
+ );
320
+ }
321
+ return (
322
+ <DecisionCard
323
+ key={item.decision.id}
324
+ decision={item.decision}
325
+ task={props.task}
326
+ docHref={props.docHref}
327
+ onAnswer={props.onAnswer}
328
+ onUpdate={props.onUpdateDecision}
329
+ busy={props.busy}
330
+ />
331
+ );
332
+ };
333
+ return (
334
+ <div className="sc-thread">
335
+ {items.length === 0 && props.empty}
336
+ {items.map(shown)}
337
+ {props.onComment !== undefined && (
338
+ <CommentForm onSubmit={props.onComment} busy={props.busy} />
339
+ )}
340
+ </div>
341
+ );
342
+ }