@skillcdn/console 0.1.16 → 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 (103) 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 +81 -7
  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 +27 -3
  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/comments.d.ts +59 -0
  28. package/dist/components/comments.d.ts.map +1 -0
  29. package/dist/components/comments.js +138 -0
  30. package/dist/components/comments.js.map +1 -0
  31. package/dist/components/event-feed.d.ts.map +1 -1
  32. package/dist/components/event-feed.js +2 -0
  33. package/dist/components/event-feed.js.map +1 -1
  34. package/dist/components/markdown.d.ts +6 -0
  35. package/dist/components/markdown.d.ts.map +1 -1
  36. package/dist/components/markdown.js +2 -1
  37. package/dist/components/markdown.js.map +1 -1
  38. package/dist/components/projects.d.ts.map +1 -1
  39. package/dist/components/projects.js +6 -1
  40. package/dist/components/projects.js.map +1 -1
  41. package/dist/components/task-view.d.ts +8 -3
  42. package/dist/components/task-view.d.ts.map +1 -1
  43. package/dist/components/task-view.js +6 -4
  44. package/dist/components/task-view.js.map +1 -1
  45. package/dist/console.css +110 -2
  46. package/dist/console.d.ts.map +1 -1
  47. package/dist/console.js +22 -4
  48. package/dist/console.js.map +1 -1
  49. package/dist/data.d.ts +5 -1
  50. package/dist/data.d.ts.map +1 -1
  51. package/dist/data.js +2 -0
  52. package/dist/data.js.map +1 -1
  53. package/dist/i18n/en.d.ts +14 -2
  54. package/dist/i18n/en.d.ts.map +1 -1
  55. package/dist/i18n/en.js +14 -2
  56. package/dist/i18n/en.js.map +1 -1
  57. package/dist/i18n/ko.d.ts.map +1 -1
  58. package/dist/i18n/ko.js +14 -2
  59. package/dist/i18n/ko.js.map +1 -1
  60. package/dist/index.d.ts +1 -0
  61. package/dist/index.d.ts.map +1 -1
  62. package/dist/index.js +1 -0
  63. package/dist/index.js.map +1 -1
  64. package/dist/limits.d.ts +3 -0
  65. package/dist/limits.d.ts.map +1 -1
  66. package/dist/limits.js +3 -0
  67. package/dist/limits.js.map +1 -1
  68. package/dist/rules.js +2 -2
  69. package/dist/rules.js.map +1 -1
  70. package/dist/schemas.d.ts +105 -0
  71. package/dist/schemas.d.ts.map +1 -1
  72. package/dist/schemas.js +32 -1
  73. package/dist/schemas.js.map +1 -1
  74. package/dist/vocabulary.d.ts +1 -1
  75. package/dist/vocabulary.d.ts.map +1 -1
  76. package/dist/vocabulary.js +1 -0
  77. package/dist/vocabulary.js.map +1 -1
  78. package/docs/specs/cli.md +8 -4
  79. package/docs/specs/rest.md +8 -1
  80. package/package.json +1 -1
  81. package/skills/working-the-board/SKILL.md +6 -4
  82. package/src/api.ts +12 -0
  83. package/src/cli/cli.ts +95 -7
  84. package/src/cli/format.ts +28 -1
  85. package/src/cli/help.ts +31 -2
  86. package/src/cli/main.ts +2 -1
  87. package/src/cli/terminal.ts +12 -1
  88. package/src/client.ts +112 -3
  89. package/src/components/comments.tsx +342 -0
  90. package/src/components/event-feed.tsx +2 -0
  91. package/src/components/markdown.tsx +9 -1
  92. package/src/components/projects.tsx +16 -3
  93. package/src/components/task-view.tsx +32 -33
  94. package/src/console.tsx +26 -3
  95. package/src/data.ts +8 -0
  96. package/src/i18n/en.ts +16 -2
  97. package/src/i18n/ko.ts +16 -2
  98. package/src/index.ts +12 -0
  99. package/src/limits.ts +3 -0
  100. package/src/rules.ts +2 -2
  101. package/src/schemas.ts +40 -1
  102. package/src/styles/console.css +110 -2
  103. package/src/vocabulary.ts +1 -0
package/src/cli/help.ts CHANGED
@@ -6,9 +6,11 @@ import {
6
6
  MAX_CATEGORIES,
7
7
  MAX_CATEGORY_KEY_LENGTH,
8
8
  MAX_CATEGORY_NAME_LENGTH,
9
+ MAX_COMMENTS_PER_TASK,
9
10
  MAX_DOCUMENT_LENGTH,
10
11
  MAX_DOCUMENT_PATH_LENGTH,
11
12
  MAX_FILE_BYTES,
13
+ MAX_FILES_PER_COMMENT,
12
14
  MAX_FILES_PER_DOCUMENT,
13
15
  MAX_LINK_LABEL_LENGTH,
14
16
  MAX_OPTION_LABEL_LENGTH,
@@ -124,7 +126,7 @@ export const COMMAND_HELP: Readonly<Record<string, CommandHelp>> = {
124
126
  task: {
125
127
  usage: `task <number|id> | task new <title> [--body <markdown>|--file <path>] ${TASK_FIELD_FLAGS} | task edit <number|id> [--title <title>] [--body <markdown>|--file <path>] ${TASK_FIELD_FLAGS}`,
126
128
  about:
127
- "One task in full, with its plan (category, goal, start and due days), what happened to it (when it was started and done, which the board keeps itself), the decisions about it and the runs on it; or a new task written down; or a task changed, only in what the flags say, where none clears a field (priorities: low, normal, high, urgent).",
129
+ "One task in full, with its plan (category, goal, start and due days), what happened to it (when it was started and done, which the board keeps itself), the decisions about it, the runs on it and the discussion on it, each comment whole with its files; or a new task written down; or a task changed, only in what the flags say, where none clears a field (priorities: low, normal, high, urgent).",
128
130
  limits: [
129
131
  `title: one line, up to ${MAX_TITLE_LENGTH} characters, saying what is to be done on its own; what it refers to goes in the body`,
130
132
  `body: ${MARKDOWN}`,
@@ -336,6 +338,30 @@ export const COMMAND_HELP: Readonly<Record<string, CommandHelp>> = {
336
338
  about: "One run in full: its reports, what it handed in, what it waits for, how it ended.",
337
339
  refusals: ["run.not_found: no run has that id"],
338
340
  },
341
+ comment: {
342
+ usage:
343
+ 'comment <number|id> "<markdown>" [--attach <path>]... | comment <number|id> --file <path> | comment <number|id> -',
344
+ about:
345
+ "Says something in the discussion on a task, for its people: an answer to what they asked there, a question that is not a decision, a remark; with files from this machine put in it, which the console keeps and shows on the task, pictures in place. Needs no run. A report is the run's record; a comment is a word to people. Never rewritten: a mistake is corrected in the next one.",
346
+ limits: [
347
+ `the comment: ${MARKDOWN}; blank only when a file comes with it`,
348
+ `a file: up to ${MAX_FILE_BYTES} bytes, not empty; called by its name, without the path; a picture is placed in the text as ![words](its name)`,
349
+ `at most ${MAX_FILES_PER_COMMENT} files on one comment, together up to ${MAX_FILE_BYTES} bytes`,
350
+ `at most ${MAX_COMMENTS_PER_TASK} comments on one task`,
351
+ ],
352
+ refusals: [
353
+ "task.not_found: no task has that number or id",
354
+ "comment.too_many: the task carries as many comments as one may",
355
+ "comment.too_many_files: more files than one comment may carry",
356
+ "request.too_large: the files are over the limit together",
357
+ ],
358
+ },
359
+ file: {
360
+ usage: "file <id> [--out <path>]",
361
+ about:
362
+ "Reads a file of the project from the console and writes it on this machine: one a run handed in, one put in a comment, or one attached to a page, by the id the task, the run or the page shows. Written under its own name in the working directory, at --out <path>, or into --out <directory>. Then look at it with your own tools.",
363
+ refusals: ["file.not_found: no file of this project has that id"],
364
+ },
339
365
  docs: {
340
366
  usage: "docs [<folder>] [--search <words>] [--archived]",
341
367
  about:
@@ -447,7 +473,10 @@ const SECTIONS: readonly { readonly title: string; readonly commands: readonly s
447
473
  "rules",
448
474
  ],
449
475
  },
450
- { title: "Working", commands: ["take", "report", "hand-in", "ask", "finish", "fail", "abandon"] },
476
+ {
477
+ title: "Working",
478
+ commands: ["take", "report", "hand-in", "comment", "file", "ask", "finish", "fail", "abandon"],
479
+ },
451
480
  {
452
481
  title: "The documents: the project's pages of Markdown, in folders, by path",
453
482
  commands: ["docs", "doc", "write", "attach", "archive", "restore"],
package/src/cli/main.ts CHANGED
@@ -9,7 +9,7 @@ import { runCli } from "./cli.js";
9
9
  import { createFileStore, defaultConfigDir } from "./credentials.js";
10
10
  import { createDirectoryStore } from "./directory.js";
11
11
  import { listenOn } from "./serve.js";
12
- import { readBytesAt, readFileAt, readStdin } from "./terminal.js";
12
+ import { readBytesAt, readFileAt, readStdin, writeBytesAt } from "./terminal.js";
13
13
 
14
14
  // The `console` command: the only place the command reads the process. Everything it does is in
15
15
  // cli.ts, which takes what it needs and is what the tests run.
@@ -34,6 +34,7 @@ process.exitCode = await runCli(process.argv.slice(2), {
34
34
  hostname: hostname(),
35
35
  readFile: readFileAt,
36
36
  readBytes: readBytesAt,
37
+ writeBytes: writeBytesAt,
37
38
  store: createFileStore(defaultConfigDir(process.env)),
38
39
  directory: createDirectoryStore(process.cwd()),
39
40
  sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
@@ -1,4 +1,5 @@
1
- import { readFile } from "node:fs/promises";
1
+ import { readFile, stat, writeFile } from "node:fs/promises";
2
+ import { join, resolve } from "node:path";
2
3
  import process from "node:process";
3
4
 
4
5
  // The terminal's side of the command: what is read from it. Nothing here is reached by tests,
@@ -26,3 +27,13 @@ export function readFileAt(path: string): Promise<string> {
26
27
  export function readBytesAt(path: string): Promise<Uint8Array> {
27
28
  return readFile(path);
28
29
  }
30
+
31
+ /**
32
+ * A file read from the console, written at the path, or into it under the file's name when
33
+ * the path is a directory; says where it went, in full.
34
+ */
35
+ export async function writeBytesAt(path: string, bytes: Uint8Array, name: string): Promise<string> {
36
+ const target = (await stat(path).catch(() => undefined))?.isDirectory() ? join(path, name) : path;
37
+ await writeFile(target, bytes);
38
+ return resolve(target);
39
+ }
package/src/client.ts CHANGED
@@ -18,6 +18,8 @@ import {
18
18
  type RestCategoryInput,
19
19
  type RestCategoryPatch,
20
20
  type RestClaim,
21
+ type RestComment,
22
+ type RestComments,
21
23
  type RestConnectInput,
22
24
  type RestConnection,
23
25
  type RestConnectRequest,
@@ -71,6 +73,8 @@ import {
71
73
  restCategoriesSchema,
72
74
  restCategorySchema,
73
75
  restClaimSchema,
76
+ restCommentSchema,
77
+ restCommentsSchema,
74
78
  restConnectionSchema,
75
79
  restConnectRequestSchema,
76
80
  restDecisionSchema,
@@ -123,6 +127,22 @@ export interface FileUpload {
123
127
  readonly label?: string | undefined;
124
128
  }
125
129
 
130
+ /** A comment to write on a task: the word, and the files put in it, which go in the one request. */
131
+ export interface CommentInput {
132
+ /** Markdown; may be blank when a file comes with it. */
133
+ readonly body: string;
134
+ readonly files?: readonly FileUpload[] | undefined;
135
+ }
136
+
137
+ /** The bytes of a file of the project, as the console hands them back, with what it calls them. */
138
+ export interface FileRead {
139
+ /** The file's name, as the console says it; the id when it says none. */
140
+ readonly name: string;
141
+ /** The media type the console answered with, without its parameters. */
142
+ readonly contentType: string;
143
+ readonly bytes: Uint8Array;
144
+ }
145
+
126
146
  export interface ClientOptions {
127
147
  /** The origin of the console, without a path. Empty: the origin of the page. */
128
148
  readonly baseUrl?: string;
@@ -245,8 +265,14 @@ export interface ProjectClient {
245
265
  handIn(runId: string, input: RestArtifactInput): Promise<RestRun>;
246
266
  /** Hands in a file, on a run of the asker's: the console keeps the bytes. */
247
267
  handInFile(runId: string, input: FileUpload): Promise<RestRun>;
248
- /** Where the bytes of a file handed in are read, by the artifact's id. */
249
- fileUrl(artifactId: string): string;
268
+ /** Where the bytes of a file of the project are read, by its id: handed in by a run, put in a comment, or attached to a page. */
269
+ fileUrl(fileId: string): string;
270
+ /** The bytes of a file of the project, by its id, with the name and the type the console says. */
271
+ readFile(fileId: string, signal?: AbortSignal): Promise<FileRead>;
272
+ /** The discussion on a task: its comments, oldest first, whole. */
273
+ comments(taskId: string, signal?: AbortSignal): Promise<RestComments>;
274
+ /** Writes a comment on a task, with its files when given, in one request. */
275
+ comment(taskId: string, input: CommentInput): Promise<RestComment>;
250
276
  /** Ends a run: finished or failed by the agent; abandoned by the person it is for, or an administrator. */
251
277
  endRun(runId: string, input: RestRunEndInput): Promise<RestRun>;
252
278
  /** What happened in the project after event number `after`; `0` for the beginning. Narrowed by `filter`. */
@@ -419,6 +445,37 @@ export function createClient(options: ClientOptions = {}): ConsoleClient {
419
445
  return parsed.data;
420
446
  };
421
447
 
448
+ /** The bytes at `url`, with the name and the type the console says, for a file of the project. */
449
+ const read = async (
450
+ url: string,
451
+ fallbackName: string,
452
+ signal?: AbortSignal,
453
+ ): Promise<FileRead> => {
454
+ let response: Response;
455
+ try {
456
+ response = await send(url, {
457
+ method: "GET",
458
+ ...(signal === undefined ? {} : { signal }),
459
+ credentials: token === undefined ? "same-origin" : "omit",
460
+ headers: token === undefined ? {} : { authorization: `Bearer ${token}` },
461
+ });
462
+ } catch (error) {
463
+ if (signal?.aborted === true) {
464
+ throw error;
465
+ }
466
+ throw new ApiError(0, "network", "The server could not be reached.");
467
+ }
468
+ if (!response.ok) {
469
+ throw await failure(response);
470
+ }
471
+ const type = response.headers.get("content-type") ?? "application/octet-stream";
472
+ return {
473
+ name: fileNameOf(response.headers.get("content-disposition") ?? "") ?? fallbackName,
474
+ contentType: type.split(";")[0]?.trim() ?? "application/octet-stream",
475
+ bytes: new Uint8Array(await response.arrayBuffer()),
476
+ };
477
+ };
478
+
422
479
  const flag = (value: boolean | undefined): string | undefined =>
423
480
  value === undefined ? undefined : String(value);
424
481
  const number = (value: number | undefined): string | undefined =>
@@ -440,6 +497,22 @@ export function createClient(options: ClientOptions = {}): ConsoleClient {
440
497
  return form;
441
498
  };
442
499
 
500
+ /** A comment with files as the parts of a form: the part `body`, and a part `file` for each file. */
501
+ const commentFormOf = (input: CommentInput): FormData => {
502
+ const form = new FormData();
503
+ form.set("body", input.body);
504
+ for (const file of input.files ?? []) {
505
+ const bytes =
506
+ file.bytes instanceof Blob ? file.bytes : new Blob([Uint8Array.from(file.bytes)]);
507
+ form.append(
508
+ "file",
509
+ new File([bytes], file.name, { type: file.contentType ?? "application/octet-stream" }),
510
+ file.name,
511
+ );
512
+ }
513
+ return form;
514
+ };
515
+
443
516
  const projectClient = (key: string): ProjectClient => {
444
517
  const at = (collection?: Parameters<typeof projectPath>[1], id?: string) =>
445
518
  projectPath(key, collection, id);
@@ -530,7 +603,23 @@ export function createClient(options: ClientOptions = {}): ConsoleClient {
530
603
  request("POST", `${base}${at("runs", runId)}/artifacts`, input, restRunSchema),
531
604
  handInFile: (runId, input) =>
532
605
  request("POST", `${base}${at("runs", runId)}/files`, formOf(input), restRunSchema),
533
- fileUrl: (artifactId) => `${base}${at("files", artifactId)}`,
606
+ fileUrl: (fileId) => `${base}${at("files", fileId)}`,
607
+ readFile: (fileId, signal) => read(`${base}${at("files", fileId)}`, fileId, signal),
608
+ comments: (taskId, signal) =>
609
+ request(
610
+ "GET",
611
+ `${base}${at("tasks", taskId)}/comments`,
612
+ undefined,
613
+ restCommentsSchema,
614
+ signal,
615
+ ),
616
+ comment: (taskId, input) =>
617
+ request(
618
+ "POST",
619
+ `${base}${at("tasks", taskId)}/comments`,
620
+ (input.files?.length ?? 0) > 0 ? commentFormOf(input) : { body: input.body },
621
+ restCommentSchema,
622
+ ),
534
623
  endRun: (runId, input) =>
535
624
  request("POST", `${base}${at("runs", runId)}/end`, input, restRunSchema),
536
625
  events: (after, filter = {}, signal) =>
@@ -668,3 +757,23 @@ export function createClient(options: ClientOptions = {}): ConsoleClient {
668
757
  signOut: () => request("POST", `${base}${AUTH_ROUTES.logout}`, undefined, undefined),
669
758
  };
670
759
  }
760
+
761
+ /**
762
+ * The file's name as a `content-disposition` header says it: the exact one (`filename*`, UTF-8,
763
+ * percent-encoded) when it is readable, else the plain one in quotes; nothing when neither is.
764
+ */
765
+ export function fileNameOf(disposition: string): string | undefined {
766
+ const exact = /filename\*=UTF-8''([^;]+)/i.exec(disposition);
767
+ if (exact?.[1] !== undefined) {
768
+ try {
769
+ const decoded = decodeURIComponent(exact[1].trim());
770
+ if (decoded.length > 0) {
771
+ return decoded;
772
+ }
773
+ } catch {
774
+ // Not percent-encoded as it should be: the plain name below is what there is.
775
+ }
776
+ }
777
+ const plain = /filename="([^"]*)"/i.exec(disposition);
778
+ return plain?.[1] === undefined || plain[1].length === 0 ? undefined : plain[1];
779
+ }
@@ -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
+ }
@@ -76,6 +76,8 @@ export function describeEvent(event: RestEvent, words: Messages = en): string {
76
76
  );
77
77
  case "task.updated":
78
78
  return feed.event.taskUpdated(fields, task);
79
+ case "task.commented":
80
+ return feed.event.taskCommented(task, data.excerpt);
79
81
  case "decision.raised":
80
82
  return feed.event.decisionRaised(question);
81
83
  case "decision.answered":
@@ -14,6 +14,12 @@ export interface MarkdownProps {
14
14
  readonly source: string;
15
15
  /** Where a document of the project is read, by its path; left out, such links stay text. */
16
16
  readonly docHref?: ((path: string) => string) | undefined;
17
+ /**
18
+ * Where a picture named in the text is read, when the text comes with files: given the
19
+ * source of `![words](source)`, the address of the file it names, or nothing. A picture that
20
+ * is neither one of those nor over https stays text.
21
+ */
22
+ readonly fileSrc?: ((source: string) => string | undefined) | undefined;
17
23
  }
18
24
 
19
25
  export function Markdown(props: MarkdownProps) {
@@ -48,7 +54,9 @@ export function Markdown(props: MarkdownProps) {
48
54
  return <span>{children}</span>;
49
55
  },
50
56
  img({ src, alt, title }) {
51
- const url = typeof src === "string" && SAFE_IMAGE.test(src) ? src : undefined;
57
+ const named = typeof src === "string" ? props.fileSrc?.(src) : undefined;
58
+ const url =
59
+ named ?? (typeof src === "string" && SAFE_IMAGE.test(src) ? src : undefined);
52
60
  if (url === undefined) {
53
61
  return <span className="sc-prose-image-text">{alt ?? ""}</span>;
54
62
  }
@@ -46,18 +46,31 @@ export function ProjectMark(props: {
46
46
  const icon = props.project.icon ?? null;
47
47
  const src =
48
48
  props.iconSrc ?? (icon === null ? undefined : projectIconPath(props.project.key, icon));
49
+ // The symbol stands in until the image is there, and comes back should the image fail; once
50
+ // the image is shown, the symbol goes, since an image with clear parts would show it through.
51
+ const [shown, setShown] = useState<string | undefined>(undefined);
52
+ const [failed, setFailed] = useState<string | undefined>(undefined);
53
+ const pictured = src !== undefined && failed !== src;
49
54
  return (
50
55
  <span
51
56
  className={cx(
52
57
  "sc-mark",
53
58
  `sc-mark-${size}`,
54
59
  `sc-category-${projectColorOf(props.project)}`,
55
- src !== undefined && "sc-mark-pictured",
60
+ pictured && "sc-mark-pictured",
56
61
  )}
57
62
  aria-hidden="true"
58
63
  >
59
- {projectSymbolOf(props.project)}
60
- {src !== undefined && <img className="sc-mark-image" src={src} alt="" />}
64
+ {(!pictured || shown !== src) && projectSymbolOf(props.project)}
65
+ {pictured && (
66
+ <img
67
+ className="sc-mark-image"
68
+ src={src}
69
+ alt=""
70
+ onLoad={() => setShown(src)}
71
+ onError={() => setFailed(src)}
72
+ />
73
+ )}
61
74
  </span>
62
75
  );
63
76
  }