@skillcdn/console 0.1.17 → 0.1.19

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 (154) hide show
  1. package/README.md +4 -4
  2. package/SKILLCDN.md +1 -1
  3. package/dist/api.d.ts +3 -2
  4. package/dist/api.d.ts.map +1 -1
  5. package/dist/api.js +2 -1
  6. package/dist/api.js.map +1 -1
  7. package/dist/cli/cli.d.ts +11 -1
  8. package/dist/cli/cli.d.ts.map +1 -1
  9. package/dist/cli/cli.js +496 -51
  10. package/dist/cli/cli.js.map +1 -1
  11. package/dist/cli/credentials.d.ts +102 -3
  12. package/dist/cli/credentials.d.ts.map +1 -1
  13. package/dist/cli/credentials.js +177 -8
  14. package/dist/cli/credentials.js.map +1 -1
  15. package/dist/cli/directory.d.ts +6 -0
  16. package/dist/cli/directory.d.ts.map +1 -1
  17. package/dist/cli/directory.js +37 -6
  18. package/dist/cli/directory.js.map +1 -1
  19. package/dist/cli/format.d.ts +11 -2
  20. package/dist/cli/format.d.ts.map +1 -1
  21. package/dist/cli/format.js +114 -5
  22. package/dist/cli/format.js.map +1 -1
  23. package/dist/cli/help.d.ts.map +1 -1
  24. package/dist/cli/help.js +49 -25
  25. package/dist/cli/help.js.map +1 -1
  26. package/dist/cli/install.d.ts +21 -0
  27. package/dist/cli/install.d.ts.map +1 -0
  28. package/dist/cli/install.js +87 -0
  29. package/dist/cli/install.js.map +1 -0
  30. package/dist/cli/main.js +22 -3
  31. package/dist/cli/main.js.map +1 -1
  32. package/dist/cli/notices.d.ts +13 -0
  33. package/dist/cli/notices.d.ts.map +1 -0
  34. package/dist/cli/notices.js +31 -0
  35. package/dist/cli/notices.js.map +1 -0
  36. package/dist/client.d.ts +36 -3
  37. package/dist/client.d.ts.map +1 -1
  38. package/dist/client.js +22 -4
  39. package/dist/client.js.map +1 -1
  40. package/dist/compat.d.ts +66 -0
  41. package/dist/compat.d.ts.map +1 -0
  42. package/dist/compat.js +115 -0
  43. package/dist/compat.js.map +1 -0
  44. package/dist/components/board.d.ts +9 -1
  45. package/dist/components/board.d.ts.map +1 -1
  46. package/dist/components/board.js +82 -5
  47. package/dist/components/board.js.map +1 -1
  48. package/dist/components/calendar.d.ts +3 -1
  49. package/dist/components/calendar.d.ts.map +1 -1
  50. package/dist/components/calendar.js +21 -2
  51. package/dist/components/calendar.js.map +1 -1
  52. package/dist/components/comments.d.ts.map +1 -1
  53. package/dist/components/comments.js +12 -4
  54. package/dist/components/comments.js.map +1 -1
  55. package/dist/components/event-feed.d.ts.map +1 -1
  56. package/dist/components/event-feed.js +4 -0
  57. package/dist/components/event-feed.js.map +1 -1
  58. package/dist/components/goals.d.ts +20 -2
  59. package/dist/components/goals.d.ts.map +1 -1
  60. package/dist/components/goals.js +50 -13
  61. package/dist/components/goals.js.map +1 -1
  62. package/dist/components/markdown.d.ts +6 -0
  63. package/dist/components/markdown.d.ts.map +1 -1
  64. package/dist/components/markdown.js +7 -0
  65. package/dist/components/markdown.js.map +1 -1
  66. package/dist/components/pictures.d.ts +68 -0
  67. package/dist/components/pictures.d.ts.map +1 -0
  68. package/dist/components/pictures.js +213 -0
  69. package/dist/components/pictures.js.map +1 -0
  70. package/dist/components/projects.d.ts +12 -0
  71. package/dist/components/projects.d.ts.map +1 -1
  72. package/dist/components/projects.js +15 -0
  73. package/dist/components/projects.js.map +1 -1
  74. package/dist/components/roadmap.d.ts +37 -0
  75. package/dist/components/roadmap.d.ts.map +1 -0
  76. package/dist/components/roadmap.js +76 -0
  77. package/dist/components/roadmap.js.map +1 -0
  78. package/dist/components/task-view.d.ts +3 -0
  79. package/dist/components/task-view.d.ts.map +1 -1
  80. package/dist/components/task-view.js +6 -4
  81. package/dist/components/task-view.js.map +1 -1
  82. package/dist/components/tokens.d.ts +2 -0
  83. package/dist/components/tokens.d.ts.map +1 -1
  84. package/dist/components/tokens.js +13 -3
  85. package/dist/components/tokens.js.map +1 -1
  86. package/dist/console.css +486 -3
  87. package/dist/console.d.ts.map +1 -1
  88. package/dist/console.js +132 -17
  89. package/dist/console.js.map +1 -1
  90. package/dist/data.d.ts +16 -1
  91. package/dist/data.d.ts.map +1 -1
  92. package/dist/data.js +48 -3
  93. package/dist/data.js.map +1 -1
  94. package/dist/i18n/en.d.ts +76 -0
  95. package/dist/i18n/en.d.ts.map +1 -1
  96. package/dist/i18n/en.js +74 -2
  97. package/dist/i18n/en.js.map +1 -1
  98. package/dist/i18n/ko.d.ts.map +1 -1
  99. package/dist/i18n/ko.js +74 -2
  100. package/dist/i18n/ko.js.map +1 -1
  101. package/dist/index.d.ts +6 -4
  102. package/dist/index.d.ts.map +1 -1
  103. package/dist/index.js +6 -4
  104. package/dist/index.js.map +1 -1
  105. package/dist/router.d.ts +31 -7
  106. package/dist/router.d.ts.map +1 -1
  107. package/dist/router.js +48 -8
  108. package/dist/router.js.map +1 -1
  109. package/dist/rules.js +5 -5
  110. package/dist/rules.js.map +1 -1
  111. package/dist/schemas.d.ts +200 -0
  112. package/dist/schemas.d.ts.map +1 -1
  113. package/dist/schemas.js +96 -0
  114. package/dist/schemas.js.map +1 -1
  115. package/dist/vocabulary.d.ts +2 -2
  116. package/dist/vocabulary.d.ts.map +1 -1
  117. package/dist/vocabulary.js +3 -0
  118. package/dist/vocabulary.js.map +1 -1
  119. package/docs/specs/cli.md +26 -15
  120. package/docs/specs/rest.md +31 -9
  121. package/package.json +3 -2
  122. package/skills/working-the-board/SKILL.md +11 -4
  123. package/src/api.ts +36 -0
  124. package/src/cli/cli.ts +616 -54
  125. package/src/cli/credentials.ts +276 -14
  126. package/src/cli/directory.ts +43 -6
  127. package/src/cli/format.ts +146 -5
  128. package/src/cli/help.ts +51 -25
  129. package/src/cli/install.ts +111 -0
  130. package/src/cli/main.ts +23 -3
  131. package/src/cli/notices.ts +48 -0
  132. package/src/client.ts +91 -6
  133. package/src/compat.ts +173 -0
  134. package/src/components/board.tsx +154 -8
  135. package/src/components/calendar.tsx +41 -1
  136. package/src/components/comments.tsx +20 -10
  137. package/src/components/event-feed.tsx +4 -0
  138. package/src/components/goals.tsx +234 -88
  139. package/src/components/markdown.tsx +19 -0
  140. package/src/components/pictures.tsx +389 -0
  141. package/src/components/projects.tsx +68 -0
  142. package/src/components/roadmap.tsx +242 -0
  143. package/src/components/task-view.tsx +43 -2
  144. package/src/components/tokens.tsx +38 -1
  145. package/src/console.tsx +278 -4
  146. package/src/data.ts +73 -2
  147. package/src/i18n/en.ts +77 -2
  148. package/src/i18n/ko.ts +77 -2
  149. package/src/index.ts +35 -0
  150. package/src/router.ts +70 -11
  151. package/src/rules.ts +5 -5
  152. package/src/schemas.ts +117 -0
  153. package/src/styles/console.css +486 -3
  154. package/src/vocabulary.ts +3 -0
@@ -16,13 +16,22 @@ import type {
16
16
  import { formatDay } from "../dates.js";
17
17
  import { useLanguage, useWords } from "../i18n/index.js";
18
18
  import type { TaskFormKind } from "../router.js";
19
- import { TASK_STATES } from "../vocabulary.js";
19
+ import { OPEN_TASK_STATES, TASK_STATES } from "../vocabulary.js";
20
20
  import { type CommentDraft, TaskThread } from "./comments.js";
21
21
  import { DecisionForm } from "./decision-form.js";
22
22
  import { EventFeed } from "./event-feed.js";
23
23
  import { Markdown } from "./markdown.js";
24
24
  import { TaskForm } from "./task-form.js";
25
- import { CategoryBadge, DueBadge, PersonChip, PriorityBadge, StateBadge, Time } from "./ui.js";
25
+ import {
26
+ Badge,
27
+ Button,
28
+ CategoryBadge,
29
+ DueBadge,
30
+ PersonChip,
31
+ PriorityBadge,
32
+ StateBadge,
33
+ Time,
34
+ } from "./ui.js";
26
35
 
27
36
  // One task: what it is, who it is on, what it links to, the discussion on it, which is what was
28
37
  // said about it with the agents at work on it and the decisions about it in the order they
@@ -76,6 +85,9 @@ export interface TaskViewProps {
76
85
  | ((decision: RestDecision, patch: RestDecisionPatch) => void)
77
86
  | undefined;
78
87
  readonly onAbandonRun?: ((run: RestRun) => void) | undefined;
88
+ /** Called to put a done or dropped task away, and to bring one back (ADR-0029); left out, neither is offered. */
89
+ readonly onArchive?: ((task: RestTask) => void) | undefined;
90
+ readonly onRestore?: ((task: RestTask) => void) | undefined;
79
91
  /** Called with a word for the discussion, and the files put in it. Left out, nothing can be said here. */
80
92
  readonly onComment?: ((draft: CommentDraft) => void) | undefined;
81
93
  }
@@ -135,6 +147,7 @@ export function TaskView(props: TaskViewProps) {
135
147
  </h1>
136
148
  <div className="sc-task-badges">
137
149
  <StateBadge state={task.state} />
150
+ {task.archivedAt !== null && <Badge tone="neutral">{words.task.archived}</Badge>}
138
151
  {task.category !== null && <CategoryBadge category={task.category} />}
139
152
  <PriorityBadge priority={task.priority} />
140
153
  {task.dueOn !== null && (
@@ -312,6 +325,14 @@ export function TaskView(props: TaskViewProps) {
312
325
  </dd>
313
326
  </div>
314
327
  )}
328
+ {task.archivedAt !== null && (
329
+ <div>
330
+ <dt>{words.task.archivedAt}</dt>
331
+ <dd>
332
+ <Time iso={task.archivedAt} />
333
+ </dd>
334
+ </div>
335
+ )}
315
336
  <div>
316
337
  <dt>{words.task.written}</dt>
317
338
  <dd>
@@ -333,6 +354,26 @@ export function TaskView(props: TaskViewProps) {
333
354
  <a className="sc-button sc-button-secondary" href={props.formHref("ask")}>
334
355
  {words.task.raiseDecision}
335
356
  </a>
357
+ {props.onArchive !== undefined &&
358
+ task.archivedAt === null &&
359
+ !OPEN_TASK_STATES.includes(task.state) && (
360
+ <Button
361
+ variant="secondary"
362
+ disabled={props.busy === true}
363
+ onClick={() => props.onArchive?.(task)}
364
+ >
365
+ {words.task.archive}
366
+ </Button>
367
+ )}
368
+ {props.onRestore !== undefined && task.archivedAt !== null && (
369
+ <Button
370
+ variant="secondary"
371
+ disabled={props.busy === true}
372
+ onClick={() => props.onRestore?.(task)}
373
+ >
374
+ {words.task.restore}
375
+ </Button>
376
+ )}
336
377
  </div>
337
378
  )}
338
379
  </aside>
@@ -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
@@ -43,16 +43,19 @@ import { ConnectWords } from "./components/connect.js";
43
43
  import { DecisionForm } from "./components/decision-form.js";
44
44
  import { DecisionCard, DecisionList, type DecisionListProps } from "./components/decision-list.js";
45
45
  import { EventFeed, type EventFeedProps } from "./components/event-feed.js";
46
- import { GoalForm, GoalList, type GoalListProps, GoalView } from "./components/goals.js";
46
+ import { GoalForm, GoalList, type GoalListProps, GoalView, goalMonth } from "./components/goals.js";
47
+ import { PAGE_SIZE } from "./components/paged.js";
47
48
  import { ListingForm, PeopleList, type PeopleListProps } from "./components/people.js";
48
49
  import {
49
50
  MemberList,
50
51
  type MemberListProps,
52
+ PackTokenForm,
51
53
  ProjectForm,
52
54
  ProjectList,
53
55
  type ProjectListProps,
54
56
  ProjectMark,
55
57
  } from "./components/projects.js";
58
+ import { GoalTimeline } from "./components/roadmap.js";
56
59
  import { RulesForm, RulesView } from "./components/rules.js";
57
60
  import {
58
61
  type Brand,
@@ -74,6 +77,8 @@ import {
74
77
  EmptyState,
75
78
  personName,
76
79
  Spinner,
80
+ StateBadge,
81
+ Time,
77
82
  } from "./components/ui.js";
78
83
  import { WorkList, type WorkListProps } from "./components/work.js";
79
84
  import { ConnectPage } from "./connect-page.js";
@@ -94,6 +99,7 @@ import {
94
99
  customPageHref,
95
100
  decisionHref,
96
101
  docHref,
102
+ type GoalTaskView,
97
103
  goalHref,
98
104
  matchRoute,
99
105
  type Navigation,
@@ -106,6 +112,7 @@ import {
106
112
  projectHref,
107
113
  projectSettingsHref,
108
114
  type Route,
115
+ roadmapHref,
109
116
  settingsHref,
110
117
  signInFailureOf,
111
118
  taskHref,
@@ -440,6 +447,102 @@ function ProjectsPage(props: {
440
447
  );
441
448
  }
442
449
 
450
+ /**
451
+ * The archive of the board (ADR-0029): the tasks put away, newest first, a page at a time from
452
+ * the server, each brought back from here by whoever may work in the project.
453
+ */
454
+ function ArchivePage(props: { readonly project: RestProject; readonly data: ConsoleData }) {
455
+ const words = useWords();
456
+ const { project, data } = props;
457
+ const key = project.key;
458
+ const { busy, error, act } = useAction();
459
+ const [items, setItems] = useState<readonly RestTask[]>([]);
460
+ const [more, setMore] = useState(false);
461
+ const [loaded, setLoaded] = useState(false);
462
+ const { archivedTasks } = data.actions;
463
+ const feedLength = data.events.length;
464
+ // The first page, read again whenever the project's feed grows: something was put away or brought back.
465
+ // biome-ignore lint/correctness/useExhaustiveDependencies: the feed's length is what triggers the read
466
+ useEffect(() => {
467
+ const controller = new AbortController();
468
+ archivedTasks({ limit: PAGE_SIZE, offset: 0 }, controller.signal)
469
+ .then((page) => {
470
+ if (!controller.signal.aborted) {
471
+ setItems(page.items);
472
+ setMore(page.more);
473
+ setLoaded(true);
474
+ }
475
+ })
476
+ .catch(() => undefined);
477
+ return () => controller.abort();
478
+ }, [archivedTasks, feedLength]);
479
+ const onMore = () =>
480
+ void archivedTasks({ limit: PAGE_SIZE, offset: items.length })
481
+ .then((page) => {
482
+ setItems((current) => [...current, ...page.items]);
483
+ setMore(page.more);
484
+ })
485
+ .catch(() => undefined);
486
+ const archive = words.archive;
487
+ return (
488
+ <>
489
+ <div className="sc-page-head">
490
+ <h1 className="sc-page-title">{archive.title}</h1>
491
+ <LinkButton href={projectHref(key)}>{words.nav.board}</LinkButton>
492
+ </div>
493
+ <p className="sc-lead">{archive.lead}</p>
494
+ {error !== undefined && <Callout tone="danger">{error}</Callout>}
495
+ {!loaded ? (
496
+ <Spinner label={archive.loading} />
497
+ ) : items.length === 0 ? (
498
+ <EmptyState title={archive.empty.title} body={archive.empty.body} />
499
+ ) : (
500
+ <>
501
+ <ul className="sc-task-rows" aria-label={archive.title}>
502
+ {items.map((task) => (
503
+ <li key={task.id} className="sc-task-row">
504
+ <a className="sc-task-row-title" href={taskHref(key, task.number)}>
505
+ <span className="sc-card-number">#{task.number}</span> {task.title}
506
+ </a>
507
+ <span className="sc-task-row-meta">
508
+ <StateBadge state={task.state} />
509
+ {task.archivedAt !== null && (
510
+ <span className="sc-muted">
511
+ {archive.since} <Time iso={task.archivedAt} />
512
+ </span>
513
+ )}
514
+ <Button
515
+ size="sm"
516
+ variant="ghost"
517
+ disabled={busy}
518
+ onClick={() =>
519
+ void act(async () => {
520
+ await data.actions.restoreTask(task.id);
521
+ setItems((current) =>
522
+ current.filter((candidate) => candidate.id !== task.id),
523
+ );
524
+ })
525
+ }
526
+ >
527
+ {archive.restore}
528
+ </Button>
529
+ </span>
530
+ </li>
531
+ ))}
532
+ </ul>
533
+ {more && (
534
+ <div className="sc-show-more">
535
+ <Button variant="secondary" size="sm" onClick={onMore}>
536
+ {archive.more}
537
+ </Button>
538
+ </div>
539
+ )}
540
+ </>
541
+ )}
542
+ </>
543
+ );
544
+ }
545
+
443
546
  function TaskPage(props: {
444
547
  readonly route: Extract<Route, { name: "task" }>;
445
548
  readonly project: RestProject;
@@ -453,10 +556,43 @@ function TaskPage(props: {
453
556
  const words = useWords();
454
557
  const { route, project, data, components, navigation, busy, error, act } = props;
455
558
  const key = project.key;
456
- const task =
559
+ const current =
457
560
  route.number === undefined
458
561
  ? data.tasks.find((candidate) => candidate.id === route.id)
459
562
  : data.tasks.find((candidate) => candidate.number === route.number);
563
+ // A task put away is not among the board's (ADR-0029): it is asked for by itself.
564
+ const [fetched, setFetched] = useState<RestTask | undefined>(undefined);
565
+ const [missing, setMissing] = useState<string | undefined>(undefined);
566
+ const { fetchTask } = data.actions;
567
+ const wanted =
568
+ current === undefined && data.projectLoaded
569
+ ? route.number === undefined
570
+ ? route.id
571
+ : String(route.number)
572
+ : undefined;
573
+ useEffect(() => {
574
+ if (wanted === undefined) {
575
+ return;
576
+ }
577
+ const controller = new AbortController();
578
+ fetchTask(wanted, controller.signal)
579
+ .then((found) => {
580
+ if (!controller.signal.aborted) {
581
+ setFetched(found);
582
+ }
583
+ })
584
+ .catch(() => {
585
+ if (!controller.signal.aborted) {
586
+ setMissing(wanted);
587
+ }
588
+ });
589
+ return () => controller.abort();
590
+ }, [wanted, fetchTask]);
591
+ const task =
592
+ current ??
593
+ (fetched !== undefined && (fetched.id === route.id || fetched.number === route.number)
594
+ ? fetched
595
+ : undefined);
460
596
  const [history, setHistory] = useState<readonly RestEvent[] | undefined>(undefined);
461
597
  const [comments, setComments] = useState<readonly RestComment[] | undefined>(undefined);
462
598
  const { taskHistory, taskComments } = data.actions;
@@ -493,6 +629,9 @@ function TaskPage(props: {
493
629
  return () => controller.abort();
494
630
  }, [taskId, taskHistory, taskComments, feedLength]);
495
631
  if (task === undefined) {
632
+ if (wanted !== undefined && missing !== wanted) {
633
+ return <Spinner label={words.common.loading} />;
634
+ }
496
635
  return (
497
636
  <EmptyState
498
637
  title={words.task.noSuch.title}
@@ -543,6 +682,17 @@ function TaskPage(props: {
543
682
  void act(() => data.actions.updateDecision(decision.id, patch))
544
683
  }
545
684
  onAbandonRun={(run) => void act(() => data.actions.abandonRun(run.id))}
685
+ onArchive={(target) =>
686
+ void act(async () => {
687
+ await data.actions.archiveTask(target.id);
688
+ navigation.go(projectHref(key, "archive"));
689
+ })
690
+ }
691
+ onRestore={(target) =>
692
+ void act(async () => {
693
+ setFetched(await data.actions.restoreTask(target.id));
694
+ })
695
+ }
546
696
  onComment={(draft) =>
547
697
  void act(async () => {
548
698
  await data.actions.comment(task.id, {
@@ -613,6 +763,7 @@ function ProjectPage(props: {
613
763
  taskHref={href}
614
764
  onOpen={(task) => navigation.go(href(task))}
615
765
  onMove={onMove}
766
+ archiveHref={projectHref(key, "archive")}
616
767
  empty={
617
768
  data.tasks.length === 0 ? (
618
769
  <EmptyState
@@ -702,6 +853,7 @@ function ProjectPage(props: {
702
853
  categories={data.categories}
703
854
  goals={data.goals}
704
855
  taskHref={href}
856
+ goalHref={(goal) => goalHref(key, goal.id)}
705
857
  monthHref={(wanted) => calendarHref(key, wanted, route.filter)}
706
858
  />
707
859
  <div className="sc-calendar-below">
@@ -765,6 +917,7 @@ function ProjectPage(props: {
765
917
  {making && (
766
918
  <section className="sc-panel sc-panel-form" aria-label={roadmap.setGoalAria}>
767
919
  <GoalForm
920
+ categories={data.categories}
768
921
  busy={busy}
769
922
  onSubmit={(input) =>
770
923
  void act(async () => {
@@ -776,6 +929,18 @@ function ProjectPage(props: {
776
929
  />
777
930
  </section>
778
931
  )}
932
+ {data.goals.length > 0 && (
933
+ <GoalTimeline
934
+ goals={data.goals}
935
+ from={
936
+ route.name === "roadmap" && route.from !== undefined ? route.from : monthOf(today())
937
+ }
938
+ today={today()}
939
+ categories={data.categories}
940
+ goalHref={(goal) => goalHref(key, goal.id)}
941
+ fromHref={(month) => roadmapHref(key, month)}
942
+ />
943
+ )}
779
944
  <components.GoalList
780
945
  goals={data.goals}
781
946
  goalHref={(goal) => goalHref(key, goal.id)}
@@ -820,6 +985,7 @@ function ProjectPage(props: {
820
985
  <section className="sc-panel sc-panel-form" aria-label={roadmap.editAria}>
821
986
  <GoalForm
822
987
  goal={goal}
988
+ categories={data.categories}
823
989
  busy={busy}
824
990
  onSubmit={(input) =>
825
991
  void act(async () => {
@@ -833,6 +999,10 @@ function ProjectPage(props: {
833
999
  </>
834
1000
  );
835
1001
  }
1002
+ const now = today();
1003
+ const towards = data.tasks.filter((task) => task.goalId === goal.id);
1004
+ const month = route.month ?? goalMonth(goal, now);
1005
+ const viewHref = (view: GoalTaskView) => goalHref(key, goal.id, { view });
836
1006
  return (
837
1007
  <>
838
1008
  <div className="sc-page-head">
@@ -845,13 +1015,60 @@ function ProjectPage(props: {
845
1015
  tasks={data.tasks}
846
1016
  taskHref={href}
847
1017
  docHref={toDoc}
848
- editHref={owner ? goalHref(key, goal.id, "edit") : undefined}
1018
+ today={now}
1019
+ editHref={owner ? goalHref(key, goal.id, { form: "edit" }) : undefined}
849
1020
  onMove={
850
1021
  owner
851
- ? (target, state) => void act(() => data.actions.updateGoal(target.id, { state }))
1022
+ ? (target, state) =>
1023
+ void act(async () => {
1024
+ await data.actions.updateGoal(target.id, { state });
1025
+ // A goal closed takes its done and dropped tasks to the archive, if the person says so (ADR-0029).
1026
+ const closed = towards.filter(
1027
+ (task) => !OPEN_TASK_STATES.includes(task.state),
1028
+ ).length;
1029
+ if (
1030
+ (state === "done" || state === "dropped") &&
1031
+ closed > 0 &&
1032
+ window.confirm(roadmap.archiveAsk(closed))
1033
+ ) {
1034
+ await data.actions.archiveGoalTasks(target.id);
1035
+ }
1036
+ })
1037
+ : undefined
1038
+ }
1039
+ onArchiveTasks={
1040
+ owner && towards.some((task) => !OPEN_TASK_STATES.includes(task.state))
1041
+ ? () => void act(() => data.actions.archiveGoalTasks(goal.id))
852
1042
  : undefined
853
1043
  }
854
1044
  busy={busy}
1045
+ view={route.view}
1046
+ viewHref={viewHref}
1047
+ board={
1048
+ <components.Board
1049
+ tasks={towards}
1050
+ taskHref={href}
1051
+ onOpen={(task) => navigation.go(href(task))}
1052
+ onMove={onMove}
1053
+ today={now}
1054
+ archiveHref={projectHref(key, "archive")}
1055
+ empty={
1056
+ towards.length === 0 ? <p className="sc-muted">{roadmap.noTasks}</p> : undefined
1057
+ }
1058
+ />
1059
+ }
1060
+ calendar={
1061
+ <components.Calendar
1062
+ tasks={towards}
1063
+ month={month}
1064
+ today={now}
1065
+ categories={data.categories}
1066
+ goals={[goal]}
1067
+ taskHref={href}
1068
+ goalHref={(target) => goalHref(key, target.id)}
1069
+ monthHref={(wanted) => goalHref(key, goal.id, { view: "calendar", month: wanted })}
1070
+ />
1071
+ }
855
1072
  />
856
1073
  </>
857
1074
  );
@@ -941,6 +1158,9 @@ function ProjectPage(props: {
941
1158
  </>
942
1159
  );
943
1160
  }
1161
+ if (route.name === "archive") {
1162
+ return <ArchivePage project={project} data={data} />;
1163
+ }
944
1164
  if (route.name === "feed") {
945
1165
  return (
946
1166
  <>
@@ -1139,6 +1359,39 @@ function ProjectSettingsPage(props: {
1139
1359
  </>
1140
1360
  );
1141
1361
  }
1362
+ if (route.form === "pack-token") {
1363
+ if (!owner) {
1364
+ return <NothingHere />;
1365
+ }
1366
+ return (
1367
+ <>
1368
+ {head}
1369
+ {error !== undefined && <Callout tone="danger">{error}</Callout>}
1370
+ <section className="sc-panel sc-panel-form" aria-label={words.projects.packToken.aria}>
1371
+ <PackTokenForm
1372
+ set={project.packToken !== null}
1373
+ busy={busy}
1374
+ onSubmit={(token) =>
1375
+ void act(async () => {
1376
+ await data.actions.setPackToken(token);
1377
+ navigation.replace(here);
1378
+ })
1379
+ }
1380
+ onRemove={
1381
+ project.packToken === null
1382
+ ? undefined
1383
+ : () =>
1384
+ void act(async () => {
1385
+ await data.actions.removePackToken();
1386
+ navigation.replace(here);
1387
+ })
1388
+ }
1389
+ onCancel={() => navigation.back(here)}
1390
+ />
1391
+ </section>
1392
+ </>
1393
+ );
1394
+ }
1142
1395
  return (
1143
1396
  <>
1144
1397
  {head}
@@ -1174,6 +1427,24 @@ function ProjectSettingsPage(props: {
1174
1427
  )}
1175
1428
  </dd>
1176
1429
  </div>
1430
+ <div>
1431
+ <dt>{settings.packToken}</dt>
1432
+ <dd>
1433
+ {project.packToken === null ? (
1434
+ <span className="sc-muted">{settings.packTokenNone}</span>
1435
+ ) : (
1436
+ <>
1437
+ {settings.packTokenSet} <Time iso={project.packToken.setAt} />
1438
+ </>
1439
+ )}
1440
+ {owner && (
1441
+ <>
1442
+ {" "}
1443
+ <a href={projectSettingsHref(key, "pack-token")}>{settings.packTokenEdit}</a>
1444
+ </>
1445
+ )}
1446
+ </dd>
1447
+ </div>
1177
1448
  <div>
1178
1449
  <dt>{form.symbol}</dt>
1179
1450
  <dd>
@@ -1451,6 +1722,7 @@ function PersonAgentsPage(props: {
1451
1722
  <components.TokenList
1452
1723
  tokens={tokens}
1453
1724
  busy={busy}
1725
+ consoleVersion={data.me?.console?.version}
1454
1726
  onRevoke={(token) =>
1455
1727
  void act(async () => {
1456
1728
  await data.actions.revokePersonToken(id, token.id);
@@ -1763,6 +2035,7 @@ function WorkspacePage(props: {
1763
2035
  <components.TokenList
1764
2036
  tokens={data.tokens}
1765
2037
  busy={busy}
2038
+ consoleVersion={data.me?.console?.version}
1766
2039
  onRevoke={(token) => void act(() => data.actions.revokeToken(token.id))}
1767
2040
  empty={<EmptyState title={agents.empty.title} body={agents.empty.body} />}
1768
2041
  />
@@ -2054,6 +2327,7 @@ export function createConsole(config: ConsoleConfig = {}): ConsoleApp {
2054
2327
  wide={
2055
2328
  route.name === "board" ||
2056
2329
  route.name === "calendar" ||
2330
+ (route.name === "goal" && route.form === undefined) ||
2057
2331
  (route.name === "custom" &&
2058
2332
  pages.some((candidate) => candidate.key === route.page && candidate.wide === true))
2059
2333
  }