@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.
- package/README.md +2 -1
- package/dist/api.d.ts +3 -3
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +2 -2
- package/dist/api.js.map +1 -1
- package/dist/cli/cli.d.ts +2 -0
- package/dist/cli/cli.d.ts.map +1 -1
- package/dist/cli/cli.js +81 -7
- package/dist/cli/cli.js.map +1 -1
- package/dist/cli/format.d.ts +7 -2
- package/dist/cli/format.d.ts.map +1 -1
- package/dist/cli/format.js +23 -2
- package/dist/cli/format.js.map +1 -1
- package/dist/cli/help.d.ts.map +1 -1
- package/dist/cli/help.js +27 -3
- package/dist/cli/help.js.map +1 -1
- package/dist/cli/main.js +2 -1
- package/dist/cli/main.js.map +1 -1
- package/dist/cli/terminal.d.ts +5 -0
- package/dist/cli/terminal.d.ts.map +1 -1
- package/dist/cli/terminal.js +11 -1
- package/dist/cli/terminal.js.map +1 -1
- package/dist/client.d.ts +28 -3
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +62 -2
- package/dist/client.js.map +1 -1
- package/dist/components/comments.d.ts +59 -0
- package/dist/components/comments.d.ts.map +1 -0
- package/dist/components/comments.js +138 -0
- package/dist/components/comments.js.map +1 -0
- package/dist/components/event-feed.d.ts.map +1 -1
- package/dist/components/event-feed.js +2 -0
- package/dist/components/event-feed.js.map +1 -1
- package/dist/components/markdown.d.ts +6 -0
- package/dist/components/markdown.d.ts.map +1 -1
- package/dist/components/markdown.js +2 -1
- package/dist/components/markdown.js.map +1 -1
- package/dist/components/projects.d.ts.map +1 -1
- package/dist/components/projects.js +6 -1
- package/dist/components/projects.js.map +1 -1
- package/dist/components/task-view.d.ts +8 -3
- package/dist/components/task-view.d.ts.map +1 -1
- package/dist/components/task-view.js +6 -4
- package/dist/components/task-view.js.map +1 -1
- package/dist/console.css +110 -2
- package/dist/console.d.ts.map +1 -1
- package/dist/console.js +22 -4
- package/dist/console.js.map +1 -1
- package/dist/data.d.ts +5 -1
- package/dist/data.d.ts.map +1 -1
- package/dist/data.js +2 -0
- package/dist/data.js.map +1 -1
- package/dist/i18n/en.d.ts +14 -2
- package/dist/i18n/en.d.ts.map +1 -1
- package/dist/i18n/en.js +14 -2
- package/dist/i18n/en.js.map +1 -1
- package/dist/i18n/ko.d.ts.map +1 -1
- package/dist/i18n/ko.js +14 -2
- package/dist/i18n/ko.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/limits.d.ts +3 -0
- package/dist/limits.d.ts.map +1 -1
- package/dist/limits.js +3 -0
- package/dist/limits.js.map +1 -1
- package/dist/rules.js +2 -2
- package/dist/rules.js.map +1 -1
- package/dist/schemas.d.ts +105 -0
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +32 -1
- package/dist/schemas.js.map +1 -1
- package/dist/vocabulary.d.ts +1 -1
- package/dist/vocabulary.d.ts.map +1 -1
- package/dist/vocabulary.js +1 -0
- package/dist/vocabulary.js.map +1 -1
- package/docs/specs/cli.md +8 -4
- package/docs/specs/rest.md +8 -1
- package/package.json +1 -1
- package/skills/working-the-board/SKILL.md +6 -4
- package/src/api.ts +12 -0
- package/src/cli/cli.ts +95 -7
- package/src/cli/format.ts +28 -1
- package/src/cli/help.ts +31 -2
- package/src/cli/main.ts +2 -1
- package/src/cli/terminal.ts +12 -1
- package/src/client.ts +112 -3
- package/src/components/comments.tsx +342 -0
- package/src/components/event-feed.tsx +2 -0
- package/src/components/markdown.tsx +9 -1
- package/src/components/projects.tsx +16 -3
- package/src/components/task-view.tsx +32 -33
- package/src/console.tsx +26 -3
- package/src/data.ts +8 -0
- package/src/i18n/en.ts +16 -2
- package/src/i18n/ko.ts +16 -2
- package/src/index.ts +12 -0
- package/src/limits.ts +3 -0
- package/src/rules.ts +2 -2
- package/src/schemas.ts +40 -1
- package/src/styles/console.css +110 -2
- 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
|
|
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 `,
|
|
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
|
-
{
|
|
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)),
|
package/src/cli/terminal.ts
CHANGED
|
@@ -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
|
|
249
|
-
fileUrl(
|
|
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: (
|
|
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 `` or ``. */
|
|
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) => ``).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 ``, 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
|
|
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
|
-
|
|
60
|
+
pictured && "sc-mark-pictured",
|
|
56
61
|
)}
|
|
57
62
|
aria-hidden="true"
|
|
58
63
|
>
|
|
59
|
-
{projectSymbolOf(props.project)}
|
|
60
|
-
{
|
|
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
|
}
|