@skillcdn/console 0.1.16 → 0.1.18

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 (143) hide show
  1. package/README.md +4 -3
  2. package/dist/api.d.ts +4 -3
  3. package/dist/api.d.ts.map +1 -1
  4. package/dist/api.js +3 -2
  5. package/dist/api.js.map +1 -1
  6. package/dist/cli/cli.d.ts +12 -0
  7. package/dist/cli/cli.d.ts.map +1 -1
  8. package/dist/cli/cli.js +226 -21
  9. package/dist/cli/cli.js.map +1 -1
  10. package/dist/cli/format.d.ts +8 -3
  11. package/dist/cli/format.d.ts.map +1 -1
  12. package/dist/cli/format.js +31 -5
  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 +34 -8
  16. package/dist/cli/help.js.map +1 -1
  17. package/dist/cli/install.d.ts +21 -0
  18. package/dist/cli/install.d.ts.map +1 -0
  19. package/dist/cli/install.js +87 -0
  20. package/dist/cli/install.js.map +1 -0
  21. package/dist/cli/main.js +24 -4
  22. package/dist/cli/main.js.map +1 -1
  23. package/dist/cli/notices.d.ts +13 -0
  24. package/dist/cli/notices.d.ts.map +1 -0
  25. package/dist/cli/notices.js +31 -0
  26. package/dist/cli/notices.js.map +1 -0
  27. package/dist/cli/terminal.d.ts +5 -0
  28. package/dist/cli/terminal.d.ts.map +1 -1
  29. package/dist/cli/terminal.js +11 -1
  30. package/dist/cli/terminal.js.map +1 -1
  31. package/dist/client.d.ts +43 -5
  32. package/dist/client.d.ts.map +1 -1
  33. package/dist/client.js +69 -4
  34. package/dist/client.js.map +1 -1
  35. package/dist/compat.d.ts +66 -0
  36. package/dist/compat.d.ts.map +1 -0
  37. package/dist/compat.js +115 -0
  38. package/dist/compat.js.map +1 -0
  39. package/dist/components/calendar.d.ts +3 -1
  40. package/dist/components/calendar.d.ts.map +1 -1
  41. package/dist/components/calendar.js +21 -2
  42. package/dist/components/calendar.js.map +1 -1
  43. package/dist/components/comments.d.ts +59 -0
  44. package/dist/components/comments.d.ts.map +1 -0
  45. package/dist/components/comments.js +138 -0
  46. package/dist/components/comments.js.map +1 -0
  47. package/dist/components/event-feed.d.ts.map +1 -1
  48. package/dist/components/event-feed.js +2 -0
  49. package/dist/components/event-feed.js.map +1 -1
  50. package/dist/components/goals.d.ts +3 -1
  51. package/dist/components/goals.d.ts.map +1 -1
  52. package/dist/components/goals.js +23 -11
  53. package/dist/components/goals.js.map +1 -1
  54. package/dist/components/markdown.d.ts +6 -0
  55. package/dist/components/markdown.d.ts.map +1 -1
  56. package/dist/components/markdown.js +2 -1
  57. package/dist/components/markdown.js.map +1 -1
  58. package/dist/components/projects.d.ts.map +1 -1
  59. package/dist/components/projects.js +6 -1
  60. package/dist/components/projects.js.map +1 -1
  61. package/dist/components/roadmap.d.ts +37 -0
  62. package/dist/components/roadmap.d.ts.map +1 -0
  63. package/dist/components/roadmap.js +76 -0
  64. package/dist/components/roadmap.js.map +1 -0
  65. package/dist/components/task-view.d.ts +8 -3
  66. package/dist/components/task-view.d.ts.map +1 -1
  67. package/dist/components/task-view.js +6 -4
  68. package/dist/components/task-view.js.map +1 -1
  69. package/dist/components/tokens.d.ts +2 -0
  70. package/dist/components/tokens.d.ts.map +1 -1
  71. package/dist/components/tokens.js +13 -3
  72. package/dist/components/tokens.js.map +1 -1
  73. package/dist/console.css +314 -2
  74. package/dist/console.d.ts.map +1 -1
  75. package/dist/console.js +30 -11
  76. package/dist/console.js.map +1 -1
  77. package/dist/data.d.ts +5 -1
  78. package/dist/data.d.ts.map +1 -1
  79. package/dist/data.js +3 -1
  80. package/dist/data.js.map +1 -1
  81. package/dist/i18n/en.d.ts +38 -2
  82. package/dist/i18n/en.d.ts.map +1 -1
  83. package/dist/i18n/en.js +38 -2
  84. package/dist/i18n/en.js.map +1 -1
  85. package/dist/i18n/ko.d.ts.map +1 -1
  86. package/dist/i18n/ko.js +38 -2
  87. package/dist/i18n/ko.js.map +1 -1
  88. package/dist/index.d.ts +3 -1
  89. package/dist/index.d.ts.map +1 -1
  90. package/dist/index.js +3 -1
  91. package/dist/index.js.map +1 -1
  92. package/dist/limits.d.ts +3 -0
  93. package/dist/limits.d.ts.map +1 -1
  94. package/dist/limits.js +3 -0
  95. package/dist/limits.js.map +1 -1
  96. package/dist/router.d.ts +6 -1
  97. package/dist/router.d.ts.map +1 -1
  98. package/dist/router.js +10 -1
  99. package/dist/router.js.map +1 -1
  100. package/dist/rules.js +2 -2
  101. package/dist/rules.js.map +1 -1
  102. package/dist/schemas.d.ts +147 -0
  103. package/dist/schemas.d.ts.map +1 -1
  104. package/dist/schemas.js +50 -1
  105. package/dist/schemas.js.map +1 -1
  106. package/dist/vocabulary.d.ts +1 -1
  107. package/dist/vocabulary.d.ts.map +1 -1
  108. package/dist/vocabulary.js +1 -0
  109. package/dist/vocabulary.js.map +1 -1
  110. package/docs/specs/cli.md +17 -10
  111. package/docs/specs/rest.md +17 -6
  112. package/package.json +3 -2
  113. package/skills/working-the-board/SKILL.md +8 -5
  114. package/src/api.ts +35 -0
  115. package/src/cli/cli.ts +273 -22
  116. package/src/cli/format.ts +36 -4
  117. package/src/cli/help.ts +38 -7
  118. package/src/cli/install.ts +111 -0
  119. package/src/cli/main.ts +25 -4
  120. package/src/cli/notices.ts +48 -0
  121. package/src/cli/terminal.ts +12 -1
  122. package/src/client.ts +142 -8
  123. package/src/compat.ts +173 -0
  124. package/src/components/calendar.tsx +41 -1
  125. package/src/components/comments.tsx +342 -0
  126. package/src/components/event-feed.tsx +2 -0
  127. package/src/components/goals.tsx +86 -7
  128. package/src/components/markdown.tsx +9 -1
  129. package/src/components/projects.tsx +16 -3
  130. package/src/components/roadmap.tsx +242 -0
  131. package/src/components/task-view.tsx +32 -33
  132. package/src/components/tokens.tsx +38 -1
  133. package/src/console.tsx +45 -3
  134. package/src/data.ts +9 -1
  135. package/src/i18n/en.ts +40 -2
  136. package/src/i18n/ko.ts +40 -2
  137. package/src/index.ts +20 -0
  138. package/src/limits.ts +3 -0
  139. package/src/router.ts +13 -2
  140. package/src/rules.ts +2 -2
  141. package/src/schemas.ts +60 -1
  142. package/src/styles/console.css +314 -2
  143. package/src/vocabulary.ts +1 -0
@@ -0,0 +1,242 @@
1
+ import type { RestCategory, RestGoal } from "../api.js";
2
+ import { addMonths, daysBetween, formatMonth, monthOf, today as todayOf } from "../dates.js";
3
+ import { useLanguage, useWords } from "../i18n/index.js";
4
+ import { cx, GoalStateBadge } from "./ui.js";
5
+
6
+ // The roadmap as months (ADR-0026): the months as columns, and each goal planned in days a bar
7
+ // from its start to its target in the colour of its category, filled as far as its tasks are
8
+ // done, with today a line down the months. A goal with one day alone sits on it as a point; one
9
+ // with no day is named below the months, since a plan without days is on none of them. Takes
10
+ // its data as props and nothing from the network; the months shown are the address's (ADR-0010).
11
+
12
+ /** How many months the roadmap shows at once when the address names no other span. */
13
+ export const DEFAULT_TIMELINE_MONTHS = 6;
14
+
15
+ export interface TimelineBar {
16
+ readonly goal: RestGoal;
17
+ /** Where the bar begins and ends, as shares of the whole span, 0 to 1. */
18
+ readonly from: number;
19
+ readonly to: number;
20
+ /** Whether the goal begins within the span, and whether it ends within it. */
21
+ readonly begins: boolean;
22
+ readonly ends: boolean;
23
+ /** Whether the goal has one day only: a point on it, not a bar. */
24
+ readonly point: boolean;
25
+ }
26
+
27
+ /** The first day of a month, as a day. */
28
+ const firstDayOf = (month: string): string => `${month}-01`;
29
+
30
+ /**
31
+ * The bars of the goals that touch the span of `months` from `from` on, cut to it: from the
32
+ * goal's start, or the span's first day when it began before, to its target day whole, or the
33
+ * span's end when it ends after. A goal with a start or a target alone sits on that day; a goal
34
+ * with neither is left out.
35
+ */
36
+ export function timelineBars(
37
+ goals: readonly RestGoal[],
38
+ from: string,
39
+ months: number,
40
+ ): TimelineBar[] {
41
+ const first = firstDayOf(from);
42
+ const end = firstDayOf(addMonths(from, months));
43
+ const total = daysBetween(first, end);
44
+ if (total <= 0) {
45
+ return [];
46
+ }
47
+ return goals.flatMap((goal) => {
48
+ const startsOn = goal.startsOn ?? goal.dueOn;
49
+ const dueOn = goal.dueOn ?? goal.startsOn;
50
+ if (startsOn === null || dueOn === null || dueOn < first || startsOn >= end) {
51
+ return [];
52
+ }
53
+ const begins = startsOn >= first;
54
+ const ends = dueOn < end;
55
+ const fromDay = begins ? daysBetween(first, startsOn) : 0;
56
+ const toDay = ends ? daysBetween(first, dueOn) + 1 : total;
57
+ return [
58
+ {
59
+ goal,
60
+ from: fromDay / total,
61
+ to: toDay / total,
62
+ begins,
63
+ ends,
64
+ point: startsOn === dueOn,
65
+ },
66
+ ];
67
+ });
68
+ }
69
+
70
+ export interface GoalTimelineProps {
71
+ readonly goals: readonly RestGoal[];
72
+ /** The first month shown: `2026-10`. */
73
+ readonly from: string;
74
+ /** How many months are shown; `DEFAULT_TIMELINE_MONTHS` when left out. */
75
+ readonly months?: number | undefined;
76
+ /** The day to count from; the browser's today when left out. */
77
+ readonly today?: string | undefined;
78
+ /** The workspace's categories, for the legend to say what the colours mean. */
79
+ readonly categories?: readonly RestCategory[] | undefined;
80
+ readonly goalHref: (goal: RestGoal) => string;
81
+ /** Where the roadmap is from another month on. */
82
+ readonly fromHref: (month: string) => string;
83
+ }
84
+
85
+ const percent = (share: number): string => `${(share * 100).toFixed(3)}%`;
86
+
87
+ export function GoalTimeline(props: GoalTimelineProps) {
88
+ const words = useWords().roadmap;
89
+ const { tag } = useLanguage();
90
+ const months = Math.max(1, props.months ?? DEFAULT_TIMELINE_MONTHS);
91
+ const now = props.today ?? todayOf();
92
+ const first = firstDayOf(props.from);
93
+ const end = firstDayOf(addMonths(props.from, months));
94
+ const total = daysBetween(first, end);
95
+ const columns = Array.from({ length: months }, (_, index) => addMonths(props.from, index));
96
+ const shares = columns.map(
97
+ (month) => daysBetween(firstDayOf(month), firstDayOf(addMonths(month, 1))) / total,
98
+ );
99
+ const bars = timelineBars(props.goals, props.from, months);
100
+ const todayAt = now >= first && now < end ? daysBetween(first, now) / total : undefined;
101
+ const undated = props.goals.filter((goal) => goal.startsOn === null && goal.dueOn === null);
102
+ const doneShare = (goal: RestGoal): number => {
103
+ const counts = goal.taskCounts;
104
+ const all = counts.idea + counts.ready + counts.in_progress + counts.in_review + counts.done;
105
+ return all === 0 ? 0 : counts.done / all;
106
+ };
107
+ return (
108
+ <div className="sc-timeline">
109
+ <div className="sc-timeline-head">
110
+ <h2 className="sc-timeline-title">{words.timeline.title}</h2>
111
+ <nav className="sc-calendar-nav" aria-label={words.timeline.title}>
112
+ <a
113
+ className="sc-button sc-button-ghost sc-button-sm"
114
+ href={props.fromHref(addMonths(props.from, -1))}
115
+ aria-label={words.timeline.earlier}
116
+ >
117
+ {"<"}
118
+ </a>
119
+ <a className="sc-button sc-button-ghost sc-button-sm" href={props.fromHref(monthOf(now))}>
120
+ {words.timeline.thisMonth}
121
+ </a>
122
+ <a
123
+ className="sc-button sc-button-ghost sc-button-sm"
124
+ href={props.fromHref(addMonths(props.from, 1))}
125
+ aria-label={words.timeline.later}
126
+ >
127
+ {">"}
128
+ </a>
129
+ </nav>
130
+ </div>
131
+ <div className="sc-timeline-grid">
132
+ <div className="sc-timeline-row sc-timeline-months">
133
+ <div className="sc-timeline-label" aria-hidden="true" />
134
+ <div className="sc-timeline-track">
135
+ {columns.map((month, index) => (
136
+ <span
137
+ key={month}
138
+ className="sc-timeline-month"
139
+ style={{ width: percent(shares[index] ?? 0) }}
140
+ >
141
+ {formatMonth(month, tag)}
142
+ </span>
143
+ ))}
144
+ {todayAt !== undefined && (
145
+ <span
146
+ className="sc-timeline-today sc-timeline-today-mark"
147
+ style={{ left: percent(todayAt) }}
148
+ title={words.timeline.today}
149
+ />
150
+ )}
151
+ </div>
152
+ </div>
153
+ {bars.map(({ goal, from, to, begins, ends, point }) => {
154
+ const counts = goal.taskCounts;
155
+ const all =
156
+ counts.idea + counts.ready + counts.in_progress + counts.in_review + counts.done;
157
+ return (
158
+ <div key={goal.id} className={cx("sc-timeline-row", `sc-goal-${goal.state}`)}>
159
+ <div className="sc-timeline-label">
160
+ <a className="sc-timeline-goal" href={props.goalHref(goal)}>
161
+ {goal.title}
162
+ </a>
163
+ <GoalStateBadge state={goal.state} />
164
+ </div>
165
+ <div className="sc-timeline-track">
166
+ {columns.map((month, index) => (
167
+ <span
168
+ key={month}
169
+ className="sc-timeline-column"
170
+ style={{ width: percent(shares[index] ?? 0) }}
171
+ aria-hidden="true"
172
+ />
173
+ ))}
174
+ {todayAt !== undefined && (
175
+ <span
176
+ className="sc-timeline-today"
177
+ style={{ left: percent(todayAt) }}
178
+ aria-hidden="true"
179
+ />
180
+ )}
181
+ <a
182
+ className={cx(
183
+ point ? "sc-timeline-point" : "sc-timeline-bar",
184
+ `sc-category-${goal.category?.color ?? "gray"}`,
185
+ begins && "sc-timeline-bar-begins",
186
+ ends && "sc-timeline-bar-ends",
187
+ )}
188
+ style={
189
+ point
190
+ ? { left: percent(from) }
191
+ : { left: percent(from), width: percent(to - from) }
192
+ }
193
+ href={props.goalHref(goal)}
194
+ aria-label={`${goal.title}: ${words.progress(counts.done, all)}`}
195
+ >
196
+ {!point && (
197
+ <>
198
+ <span
199
+ className="sc-timeline-fill"
200
+ style={{ width: percent(doneShare(goal)) }}
201
+ aria-hidden="true"
202
+ />
203
+ <span className="sc-timeline-words">
204
+ {counts.done}/{all}
205
+ </span>
206
+ </>
207
+ )}
208
+ </a>
209
+ </div>
210
+ </div>
211
+ );
212
+ })}
213
+ </div>
214
+ {bars.length === 0 && <p className="sc-muted">{words.timeline.nothing}</p>}
215
+ {(props.categories ?? []).length > 0 && (
216
+ <dl className="sc-calendar-legend" aria-label={words.timeline.legend}>
217
+ {(props.categories ?? []).map((category) => (
218
+ <div key={category.key}>
219
+ <dt>
220
+ <span className={cx("sc-calendar-dot", `sc-category-${category.color}`)} />
221
+ </dt>
222
+ <dd>{category.name}</dd>
223
+ </div>
224
+ ))}
225
+ </dl>
226
+ )}
227
+ {undated.length > 0 && (
228
+ <p className="sc-timeline-undated">
229
+ <span className="sc-muted">{words.timeline.noDays}: </span>
230
+ {undated.map((goal, index) => (
231
+ <span key={goal.id}>
232
+ {index > 0 && ", "}
233
+ <a className="sc-timeline-goal" href={props.goalHref(goal)}>
234
+ {goal.title}
235
+ </a>
236
+ </span>
237
+ ))}
238
+ </p>
239
+ )}
240
+ </div>
241
+ );
242
+ }
@@ -1,7 +1,7 @@
1
1
  import type {
2
2
  RestAnswerInput,
3
- RestArtifact,
4
3
  RestCategory,
4
+ RestComment,
5
5
  RestDecision,
6
6
  RestDecisionInput,
7
7
  RestDecisionPatch,
@@ -17,19 +17,20 @@ import { formatDay } from "../dates.js";
17
17
  import { useLanguage, useWords } from "../i18n/index.js";
18
18
  import type { TaskFormKind } from "../router.js";
19
19
  import { TASK_STATES } from "../vocabulary.js";
20
+ import { type CommentDraft, TaskThread } from "./comments.js";
20
21
  import { DecisionForm } from "./decision-form.js";
21
- import { DecisionCard } from "./decision-list.js";
22
22
  import { EventFeed } from "./event-feed.js";
23
23
  import { Markdown } from "./markdown.js";
24
- import { RunList } from "./runs.js";
25
24
  import { TaskForm } from "./task-form.js";
26
25
  import { CategoryBadge, DueBadge, PersonChip, PriorityBadge, StateBadge, Time } from "./ui.js";
27
26
 
28
- // One task: what it is, who it is on, what it links to, the agents at work on it, the decisions
29
- // about it, the tasks that are part of it, everything that happened to it, and the ways to
30
- // change it. Takes its data as props and nothing from the network; which of its forms is open
31
- // is told by the page, since each form has an address of its own (ADR-0010). The facts beside
32
- // the text say only what is set: a plan, a category or a goal the task has none of is not a row.
27
+ // One task: what it is, who it is on, what it links to, the discussion on it, which is what was
28
+ // said about it with the agents at work on it and the decisions about it in the order they
29
+ // came (ADR-0024), the tasks that are part of it, everything that happened to it, and the ways
30
+ // to change it. Takes its data as props and nothing from the network; which of its forms is
31
+ // open is told by the page, since each form has an address of its own (ADR-0010). The facts
32
+ // beside the text say only what is set: a plan, a category or a goal the task has none of is
33
+ // not a row.
33
34
 
34
35
  export interface TaskViewProps {
35
36
  readonly task: RestTask;
@@ -39,6 +40,8 @@ export interface TaskViewProps {
39
40
  readonly decisions: readonly RestDecision[];
40
41
  /** The runs of the board, or of this task; the ones on this task are shown. */
41
42
  readonly runs?: readonly RestRun[] | undefined;
43
+ /** The comments on the task, oldest first, when the page has read them. */
44
+ readonly comments?: readonly RestComment[] | undefined;
42
45
  /** Everything that happened to the task, oldest first, when the page has read it. */
43
46
  readonly history?: readonly RestEvent[] | undefined;
44
47
  /** The categories of the workspace, for the form. */
@@ -50,8 +53,8 @@ export interface TaskViewProps {
50
53
  readonly decisionHref?: ((decisionId: string) => string) | undefined;
51
54
  /** Where a goal of the roadmap is read. */
52
55
  readonly goalHref?: ((goalId: string) => string) | undefined;
53
- /** Where a file a run handed in is read. */
54
- readonly fileHref?: ((artifact: RestArtifact) => string) | undefined;
56
+ /** Where a file of the project is read, by its id: one a run handed in, or one put in a comment. */
57
+ readonly fileHref?: ((fileId: string) => string) | undefined;
55
58
  /** Where a document of the project is read, by its path, for the links in what was written. */
56
59
  readonly docHref?: ((path: string) => string) | undefined;
57
60
  /** The day to count due days from; the browser's today when left out. */
@@ -73,6 +76,8 @@ export interface TaskViewProps {
73
76
  | ((decision: RestDecision, patch: RestDecisionPatch) => void)
74
77
  | undefined;
75
78
  readonly onAbandonRun?: ((run: RestRun) => void) | undefined;
79
+ /** Called with a word for the discussion, and the files put in it. Left out, nothing can be said here. */
80
+ readonly onComment?: ((draft: CommentDraft) => void) | undefined;
76
81
  }
77
82
 
78
83
  export function TaskView(props: TaskViewProps) {
@@ -86,7 +91,7 @@ export function TaskView(props: TaskViewProps) {
86
91
  const subtasks = props.tasks.filter((candidate) => candidate.parentId === task.id);
87
92
  const about = props.decisions.filter((decision) => decision.taskId === task.id);
88
93
  const runs = (props.runs ?? []).filter((run) => run.taskId === task.id);
89
- const tasksById = new Map(props.tasks.map((candidate) => [candidate.id, candidate]));
94
+ const comments = (props.comments ?? []).filter((comment) => comment.taskId === task.id);
90
95
  const goal =
91
96
  task.goalId === null
92
97
  ? undefined
@@ -175,35 +180,29 @@ export function TaskView(props: TaskViewProps) {
175
180
  />
176
181
  </section>
177
182
  )}
178
- {runs.length > 0 && (
179
- <section className="sc-task-section" aria-label={words.task.runs}>
180
- <h2 className="sc-section-title">{words.task.runs}</h2>
181
- <RunList
183
+ {(runs.length > 0 ||
184
+ about.length > 0 ||
185
+ comments.length > 0 ||
186
+ props.onComment !== undefined) && (
187
+ <section className="sc-task-section" aria-label={words.comments.title}>
188
+ <h2 className="sc-section-title">{words.comments.title}</h2>
189
+ <TaskThread
190
+ task={task}
191
+ comments={comments}
182
192
  runs={runs}
183
- decisionHref={props.decisionHref}
193
+ decisions={about}
184
194
  fileHref={props.fileHref}
195
+ decisionHref={props.decisionHref}
185
196
  docHref={props.docHref}
186
- onAbandon={props.onAbandonRun}
197
+ onAnswer={props.onAnswer}
198
+ onUpdateDecision={props.onUpdateDecision}
199
+ onAbandonRun={props.onAbandonRun}
200
+ onComment={props.onComment}
187
201
  busy={props.busy}
202
+ empty={<p className="sc-muted">{words.comments.empty}</p>}
188
203
  />
189
204
  </section>
190
205
  )}
191
- {about.length > 0 && (
192
- <section className="sc-task-section" aria-label={words.task.decisions}>
193
- <h2 className="sc-section-title">{words.task.decisions}</h2>
194
- {about.map((decision) => (
195
- <DecisionCard
196
- key={decision.id}
197
- decision={decision}
198
- task={decision.taskId === null ? undefined : tasksById.get(decision.taskId)}
199
- docHref={props.docHref}
200
- onAnswer={props.onAnswer}
201
- onUpdate={props.onUpdateDecision}
202
- busy={props.busy}
203
- />
204
- ))}
205
- </section>
206
- )}
207
206
  {props.history !== undefined && props.history.length > 0 && (
208
207
  <section className="sc-task-section" aria-label={words.task.history}>
209
208
  <h2 className="sc-section-title">{words.task.history}</h2>
@@ -1,12 +1,15 @@
1
1
  import { type FormEvent, type ReactNode, useState } from "react";
2
2
  import {
3
+ type ClientVerdict,
3
4
  DEFAULT_TOKEN_DAYS,
5
+ judgeClient,
4
6
  MAX_TOKEN_NAME_LENGTH,
7
+ parseVersion,
5
8
  type RestToken,
6
9
  type RestTokenInput,
7
10
  } from "../api.js";
8
11
  import { useWords } from "../i18n/index.js";
9
- import { Button, Callout, CopyButton, Time } from "./ui.js";
12
+ import { Badge, Button, Callout, CopyButton, Time } from "./ui.js";
10
13
 
11
14
  // The tokens a person holds for their agents, scripts and consoles of their own, shown as the
12
15
  // agents that hold them: the list, the way to make one by hand, and the one just made, whose
@@ -36,6 +39,22 @@ export interface TokenListProps {
36
39
  readonly onRevoke?: ((token: RestToken) => void) | undefined;
37
40
  readonly busy?: boolean | undefined;
38
41
  readonly empty?: ReactNode;
42
+ /** The console's version (ADR-0025), so that the version of the command an agent runs is said to be behind or ahead; left out, the version alone is shown. */
43
+ readonly consoleVersion?: string | undefined;
44
+ }
45
+
46
+ /** What a command's version is to the console's: nothing to say when they go together, else which way they differ. */
47
+ function verdictOf(
48
+ client: string,
49
+ consoleVersion: string | undefined,
50
+ ): Exclude<ClientVerdict, "current"> | undefined {
51
+ const command = parseVersion(client);
52
+ const server = parseVersion(consoleVersion);
53
+ if (command === undefined || server === undefined) {
54
+ return undefined;
55
+ }
56
+ const { verdict } = judgeClient(command, server);
57
+ return verdict === "current" ? undefined : verdict;
39
58
  }
40
59
 
41
60
  export function TokenList(props: TokenListProps) {
@@ -53,6 +72,24 @@ export function TokenList(props: TokenListProps) {
53
72
  <span>
54
73
  {words.connected} <Time iso={token.createdAt} />
55
74
  </span>
75
+ {token.client !== null && (
76
+ <span>
77
+ {words.command(token.client)}
78
+ {((verdict) =>
79
+ verdict === undefined ? null : (
80
+ <>
81
+ {" "}
82
+ <Badge
83
+ tone={
84
+ verdict === "outdated" || verdict === "too_new" ? "danger" : "warning"
85
+ }
86
+ >
87
+ {words.commandVerdict[verdict]}
88
+ </Badge>
89
+ </>
90
+ ))(verdictOf(token.client, props.consoleVersion))}
91
+ </span>
92
+ )}
56
93
  <span>
57
94
  {token.expiresAt === null ? (
58
95
  words.doesNotExpire
package/src/console.tsx CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  PROJECT_RULES_TEMPLATE,
18
18
  REFERENCE_RULES,
19
19
  type RestAnswerInput,
20
+ type RestComment,
20
21
  type RestDecision,
21
22
  type RestDecisionInput,
22
23
  type RestDecisionPatch,
@@ -52,6 +53,7 @@ import {
52
53
  type ProjectListProps,
53
54
  ProjectMark,
54
55
  } from "./components/projects.js";
56
+ import { GoalTimeline } from "./components/roadmap.js";
55
57
  import { RulesForm, RulesView } from "./components/rules.js";
56
58
  import {
57
59
  type Brand,
@@ -105,6 +107,7 @@ import {
105
107
  projectHref,
106
108
  projectSettingsHref,
107
109
  type Route,
110
+ roadmapHref,
108
111
  settingsHref,
109
112
  signInFailureOf,
110
113
  taskHref,
@@ -457,7 +460,8 @@ function TaskPage(props: {
457
460
  ? data.tasks.find((candidate) => candidate.id === route.id)
458
461
  : data.tasks.find((candidate) => candidate.number === route.number);
459
462
  const [history, setHistory] = useState<readonly RestEvent[] | undefined>(undefined);
460
- const { taskHistory } = data.actions;
463
+ const [comments, setComments] = useState<readonly RestComment[] | undefined>(undefined);
464
+ const { taskHistory, taskComments } = data.actions;
461
465
  const taskId = task?.id;
462
466
  const number = task?.number;
463
467
  // A task named by its id is shown at its number's address.
@@ -481,8 +485,15 @@ function TaskPage(props: {
481
485
  }
482
486
  })
483
487
  .catch(() => undefined);
488
+ taskComments(taskId, controller.signal)
489
+ .then((items) => {
490
+ if (!controller.signal.aborted) {
491
+ setComments(items);
492
+ }
493
+ })
494
+ .catch(() => undefined);
484
495
  return () => controller.abort();
485
- }, [taskId, taskHistory, feedLength]);
496
+ }, [taskId, taskHistory, taskComments, feedLength]);
486
497
  if (task === undefined) {
487
498
  return (
488
499
  <EmptyState
@@ -500,13 +511,14 @@ function TaskPage(props: {
500
511
  tasks={data.tasks}
501
512
  decisions={data.decisions}
502
513
  runs={data.runs}
514
+ comments={comments}
503
515
  history={history}
504
516
  categories={data.categories}
505
517
  goals={data.goals}
506
518
  taskHref={(candidate) => taskHref(key, candidate.number)}
507
519
  decisionHref={(decisionId) => decisionHref(key, decisionId)}
508
520
  goalHref={(goalId) => goalHref(key, goalId)}
509
- fileHref={(artifact) => projectPath(key, "files", artifact.id)}
521
+ fileHref={(fileId) => projectPath(key, "files", fileId)}
510
522
  docHref={(path) => docHref(key, path)}
511
523
  form={route.form}
512
524
  formHref={(form) => taskHref(key, task.number, form)}
@@ -533,6 +545,19 @@ function TaskPage(props: {
533
545
  void act(() => data.actions.updateDecision(decision.id, patch))
534
546
  }
535
547
  onAbandonRun={(run) => void act(() => data.actions.abandonRun(run.id))}
548
+ onComment={(draft) =>
549
+ void act(async () => {
550
+ await data.actions.comment(task.id, {
551
+ body: draft.body,
552
+ files: draft.files.map((file) => ({
553
+ name: file.name,
554
+ bytes: file,
555
+ ...(file.type === "" ? {} : { contentType: file.type }),
556
+ })),
557
+ });
558
+ setComments(await taskComments(task.id));
559
+ })
560
+ }
536
561
  />
537
562
  );
538
563
  }
@@ -679,6 +704,7 @@ function ProjectPage(props: {
679
704
  categories={data.categories}
680
705
  goals={data.goals}
681
706
  taskHref={href}
707
+ goalHref={(goal) => goalHref(key, goal.id)}
682
708
  monthHref={(wanted) => calendarHref(key, wanted, route.filter)}
683
709
  />
684
710
  <div className="sc-calendar-below">
@@ -742,6 +768,7 @@ function ProjectPage(props: {
742
768
  {making && (
743
769
  <section className="sc-panel sc-panel-form" aria-label={roadmap.setGoalAria}>
744
770
  <GoalForm
771
+ categories={data.categories}
745
772
  busy={busy}
746
773
  onSubmit={(input) =>
747
774
  void act(async () => {
@@ -753,6 +780,18 @@ function ProjectPage(props: {
753
780
  />
754
781
  </section>
755
782
  )}
783
+ {data.goals.length > 0 && (
784
+ <GoalTimeline
785
+ goals={data.goals}
786
+ from={
787
+ route.name === "roadmap" && route.from !== undefined ? route.from : monthOf(today())
788
+ }
789
+ today={today()}
790
+ categories={data.categories}
791
+ goalHref={(goal) => goalHref(key, goal.id)}
792
+ fromHref={(month) => roadmapHref(key, month)}
793
+ />
794
+ )}
756
795
  <components.GoalList
757
796
  goals={data.goals}
758
797
  goalHref={(goal) => goalHref(key, goal.id)}
@@ -797,6 +836,7 @@ function ProjectPage(props: {
797
836
  <section className="sc-panel sc-panel-form" aria-label={roadmap.editAria}>
798
837
  <GoalForm
799
838
  goal={goal}
839
+ categories={data.categories}
800
840
  busy={busy}
801
841
  onSubmit={(input) =>
802
842
  void act(async () => {
@@ -1428,6 +1468,7 @@ function PersonAgentsPage(props: {
1428
1468
  <components.TokenList
1429
1469
  tokens={tokens}
1430
1470
  busy={busy}
1471
+ consoleVersion={data.me?.console?.version}
1431
1472
  onRevoke={(token) =>
1432
1473
  void act(async () => {
1433
1474
  await data.actions.revokePersonToken(id, token.id);
@@ -1740,6 +1781,7 @@ function WorkspacePage(props: {
1740
1781
  <components.TokenList
1741
1782
  tokens={data.tokens}
1742
1783
  busy={busy}
1784
+ consoleVersion={data.me?.console?.version}
1743
1785
  onRevoke={(token) => void act(() => data.actions.revokeToken(token.id))}
1744
1786
  empty={<EmptyState title={agents.empty.title} body={agents.empty.body} />}
1745
1787
  />
package/src/data.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
2
  import {
3
3
  ApiError,
4
+ type CommentInput,
4
5
  type ConsoleClient,
5
6
  type DocumentFilter,
6
7
  EVENT_KINDS,
@@ -9,6 +10,7 @@ import {
9
10
  type RestCategory,
10
11
  type RestCategoryInput,
11
12
  type RestCategoryPatch,
13
+ type RestComment,
12
14
  type RestConnectRequest,
13
15
  type RestDecision,
14
16
  type RestDecisionInput,
@@ -117,6 +119,10 @@ export interface ConsoleActions {
117
119
  abandonRun(id: string): Promise<RestRun>;
118
120
  /** Everything that happened to one task of the project, oldest first. */
119
121
  taskHistory(taskId: string, signal?: AbortSignal): Promise<RestEvent[]>;
122
+ /** The discussion on one task of the project, oldest first (ADR-0024). */
123
+ taskComments(taskId: string, signal?: AbortSignal): Promise<RestComment[]>;
124
+ /** A word in the discussion on a task, with the files put in it. */
125
+ comment(taskId: string, input: CommentInput): Promise<RestComment>;
120
126
  /** Makes a token for whoever is signed in; the answer carries the secret, this once. */
121
127
  createToken(input: RestTokenInput): Promise<RestTokenCreated>;
122
128
  revokeToken(id: string): Promise<void>;
@@ -261,7 +267,7 @@ export function useConsoleData(client: ConsoleClient, projectKey: string | undef
261
267
  scope.get(signal),
262
268
  scope.tasks(undefined, signal),
263
269
  scope.decisions(undefined, signal),
264
- scope.goals(signal),
270
+ scope.goals({}, signal),
265
271
  scope.runs(undefined, signal),
266
272
  scope.members(signal),
267
273
  ]);
@@ -560,6 +566,8 @@ export function useConsoleData(client: ConsoleClient, projectKey: string | undef
560
566
  }
561
567
  return history;
562
568
  },
569
+ taskComments: async (taskId, signal) => (await scope().comments(taskId, signal)).items,
570
+ comment: (taskId, input) => scope().comment(taskId, input),
563
571
  async createToken(input) {
564
572
  const made = await client.createToken(input);
565
573
  setTokens((await client.tokens()).items);