@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.
- 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 +91 -9
- 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 +28 -4
- 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/calendar.d.ts +35 -2
- package/dist/components/calendar.d.ts.map +1 -1
- package/dist/components/calendar.js +58 -19
- package/dist/components/calendar.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 +4 -1
- package/dist/components/projects.d.ts.map +1 -1
- package/dist/components/projects.js +10 -2
- package/dist/components/projects.js.map +1 -1
- package/dist/components/task-form.d.ts.map +1 -1
- package/dist/components/task-form.js +1 -1
- package/dist/components/task-form.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 +180 -32
- package/dist/console.d.ts.map +1 -1
- package/dist/console.js +23 -5
- 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 +15 -2
- package/dist/i18n/en.d.ts.map +1 -1
- package/dist/i18n/en.js +17 -4
- package/dist/i18n/en.js.map +1 -1
- package/dist/i18n/ko.d.ts.map +1 -1
- package/dist/i18n/ko.js +17 -4
- 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 +9 -2
- 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 +106 -9
- package/src/cli/format.ts +28 -1
- package/src/cli/help.ts +32 -3
- package/src/cli/main.ts +2 -1
- package/src/cli/terminal.ts +12 -1
- package/src/client.ts +112 -3
- package/src/components/calendar.tsx +216 -28
- 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 +19 -6
- package/src/components/task-form.tsx +1 -0
- package/src/components/task-view.tsx +32 -33
- package/src/console.tsx +27 -3
- package/src/data.ts +8 -0
- package/src/i18n/en.ts +19 -4
- package/src/i18n/ko.ts +19 -4
- 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 +180 -32
- package/src/vocabulary.ts +1 -0
|
@@ -1,10 +1,17 @@
|
|
|
1
|
-
import type
|
|
2
|
-
import {
|
|
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.
|
|
27
|
-
//
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
*/
|
|
152
|
-
|
|
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
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
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)
|
|
404
|
+
className={cx("sc-calendar-task", tone(task, now))}
|
|
225
405
|
href={props.taskHref(task)}
|
|
226
|
-
|
|
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 `` 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
|
+
}
|