@dxos/react-ui-task 0.12.0-next-73daef452f1464b62efdbca75fb0067c1066f264 → 0.13.0

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 (112) hide show
  1. package/dist/lib/index.mjs +2024 -572
  2. package/dist/lib/index.mjs.map +1 -1
  3. package/dist/lib/translations.mjs +31 -1
  4. package/dist/lib/translations.mjs.map +1 -1
  5. package/dist/types/src/components/TaskHistory/TaskHistory.d.ts +13 -0
  6. package/dist/types/src/components/TaskHistory/TaskHistory.d.ts.map +1 -0
  7. package/dist/types/src/components/TaskHistory/TaskHistory.stories.d.ts +68 -0
  8. package/dist/types/src/components/TaskHistory/TaskHistory.stories.d.ts.map +1 -0
  9. package/dist/types/src/components/TaskHistory/index.d.ts +2 -0
  10. package/dist/types/src/components/TaskHistory/index.d.ts.map +1 -0
  11. package/dist/types/src/components/TaskList/TaskDescription.d.ts +20 -0
  12. package/dist/types/src/components/TaskList/TaskDescription.d.ts.map +1 -0
  13. package/dist/types/src/components/TaskList/TaskList.d.ts +122 -57
  14. package/dist/types/src/components/TaskList/TaskList.d.ts.map +1 -1
  15. package/dist/types/src/components/TaskList/TaskList.stories.d.ts +163 -5
  16. package/dist/types/src/components/TaskList/TaskList.stories.d.ts.map +1 -1
  17. package/dist/types/src/components/TaskList/TaskListContext.d.ts +53 -0
  18. package/dist/types/src/components/TaskList/TaskListContext.d.ts.map +1 -0
  19. package/dist/types/src/components/TaskList/TaskListEditor.d.ts +42 -0
  20. package/dist/types/src/components/TaskList/TaskListEditor.d.ts.map +1 -0
  21. package/dist/types/src/components/TaskList/TaskRowCells.d.ts +86 -0
  22. package/dist/types/src/components/TaskList/TaskRowCells.d.ts.map +1 -0
  23. package/dist/types/src/components/TaskList/TaskTreeNode.d.ts +65 -0
  24. package/dist/types/src/components/TaskList/TaskTreeNode.d.ts.map +1 -0
  25. package/dist/types/src/components/TaskList/anchor-hover.d.ts +21 -0
  26. package/dist/types/src/components/TaskList/anchor-hover.d.ts.map +1 -0
  27. package/dist/types/src/components/TaskList/anchor-hover.test.d.ts +2 -0
  28. package/dist/types/src/components/TaskList/anchor-hover.test.d.ts.map +1 -0
  29. package/dist/types/src/components/TaskList/hierarchy.d.ts +13 -26
  30. package/dist/types/src/components/TaskList/hierarchy.d.ts.map +1 -1
  31. package/dist/types/src/components/TaskList/index.d.ts +4 -2
  32. package/dist/types/src/components/TaskList/index.d.ts.map +1 -1
  33. package/dist/types/src/components/TaskList/tracks.d.ts +16 -0
  34. package/dist/types/src/components/TaskList/tracks.d.ts.map +1 -0
  35. package/dist/types/src/components/TaskList/tree-model.d.ts +107 -0
  36. package/dist/types/src/components/TaskList/tree-model.d.ts.map +1 -0
  37. package/dist/types/src/components/TaskList/tree-model.test.d.ts +2 -0
  38. package/dist/types/src/components/TaskList/tree-model.test.d.ts.map +1 -0
  39. package/dist/types/src/components/TaskList/usePreviewAnchor.d.ts +21 -0
  40. package/dist/types/src/components/TaskList/usePreviewAnchor.d.ts.map +1 -0
  41. package/dist/types/src/components/TaskProperties/TaskProperties.d.ts +23 -0
  42. package/dist/types/src/components/TaskProperties/TaskProperties.d.ts.map +1 -0
  43. package/dist/types/src/components/TaskProperties/TaskProperties.stories.d.ts +76 -0
  44. package/dist/types/src/components/TaskProperties/TaskProperties.stories.d.ts.map +1 -0
  45. package/dist/types/src/components/TaskProperties/index.d.ts +2 -0
  46. package/dist/types/src/components/TaskProperties/index.d.ts.map +1 -0
  47. package/dist/types/src/components/TaskQuestion/TaskQuestion.d.ts +22 -0
  48. package/dist/types/src/components/TaskQuestion/TaskQuestion.d.ts.map +1 -0
  49. package/dist/types/src/components/TaskQuestion/index.d.ts +2 -0
  50. package/dist/types/src/components/TaskQuestion/index.d.ts.map +1 -0
  51. package/dist/types/src/components/task-grid.d.ts +18 -0
  52. package/dist/types/src/components/task-grid.d.ts.map +1 -0
  53. package/dist/types/src/hooks/index.d.ts +2 -0
  54. package/dist/types/src/hooks/index.d.ts.map +1 -0
  55. package/dist/types/src/hooks/useAssigneeDisplay.d.ts +8 -0
  56. package/dist/types/src/hooks/useAssigneeDisplay.d.ts.map +1 -0
  57. package/dist/types/src/index.d.ts +6 -1
  58. package/dist/types/src/index.d.ts.map +1 -1
  59. package/dist/types/src/translations.d.ts +31 -1
  60. package/dist/types/src/translations.d.ts.map +1 -1
  61. package/dist/types/src/util/assignee.d.ts +34 -0
  62. package/dist/types/src/util/assignee.d.ts.map +1 -0
  63. package/dist/types/src/util/assignee.test.d.ts +2 -0
  64. package/dist/types/src/util/assignee.test.d.ts.map +1 -0
  65. package/dist/types/src/util/format-date.d.ts +18 -0
  66. package/dist/types/src/util/format-date.d.ts.map +1 -0
  67. package/dist/types/src/util/format-date.test.d.ts +2 -0
  68. package/dist/types/src/util/format-date.test.d.ts.map +1 -0
  69. package/dist/types/src/util/index.d.ts +3 -0
  70. package/dist/types/src/util/index.d.ts.map +1 -0
  71. package/dist/types/src/util/status-icons.d.ts +28 -0
  72. package/dist/types/src/util/status-icons.d.ts.map +1 -0
  73. package/dist/types/tsconfig.tsbuildinfo +1 -1
  74. package/package.json +27 -20
  75. package/src/components/TaskHistory/TaskHistory.stories.tsx +103 -0
  76. package/src/components/TaskHistory/TaskHistory.tsx +169 -0
  77. package/src/components/TaskHistory/index.ts +5 -0
  78. package/src/components/TaskList/TaskDescription.tsx +39 -0
  79. package/src/components/TaskList/TaskList.stories.tsx +1882 -181
  80. package/src/components/TaskList/TaskList.tsx +670 -789
  81. package/src/components/TaskList/TaskListContext.ts +68 -0
  82. package/src/components/TaskList/TaskListEditor.tsx +510 -0
  83. package/src/components/TaskList/TaskRowCells.tsx +362 -0
  84. package/src/components/TaskList/TaskTreeNode.tsx +387 -0
  85. package/src/components/TaskList/anchor-hover.test.ts +84 -0
  86. package/src/components/TaskList/anchor-hover.ts +129 -0
  87. package/src/components/TaskList/hierarchy.test.ts +92 -51
  88. package/src/components/TaskList/hierarchy.ts +45 -63
  89. package/src/components/TaskList/index.ts +5 -2
  90. package/src/components/TaskList/tracks.ts +19 -0
  91. package/src/components/TaskList/tree-model.test.ts +233 -0
  92. package/src/components/TaskList/tree-model.ts +238 -0
  93. package/src/components/TaskList/usePreviewAnchor.ts +85 -0
  94. package/src/components/TaskProperties/TaskProperties.stories.tsx +121 -0
  95. package/src/components/TaskProperties/TaskProperties.tsx +281 -0
  96. package/src/components/TaskProperties/index.ts +5 -0
  97. package/src/components/TaskQuestion/TaskQuestion.tsx +223 -0
  98. package/src/components/TaskQuestion/index.ts +5 -0
  99. package/src/components/task-grid.ts +23 -0
  100. package/src/hooks/index.ts +5 -0
  101. package/src/hooks/useAssigneeDisplay.ts +26 -0
  102. package/src/index.ts +6 -1
  103. package/src/translations.ts +33 -3
  104. package/src/util/assignee.test.ts +73 -0
  105. package/src/util/assignee.ts +69 -0
  106. package/src/util/format-date.test.ts +36 -0
  107. package/src/util/format-date.ts +43 -0
  108. package/src/util/index.ts +6 -0
  109. package/src/util/status-icons.ts +54 -0
  110. package/dist/types/src/components/TaskList/dnd.d.ts +0 -27
  111. package/dist/types/src/components/TaskList/dnd.d.ts.map +0 -1
  112. package/src/components/TaskList/dnd.ts +0 -46
@@ -1,58 +1,249 @@
1
- import { TaskSet } from "@dxos/types";
2
- import { attachInstruction, extractInstruction } from "@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item";
3
- import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
4
- import { draggable, dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
5
- import { setCustomNativeDragPreview } from "@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview";
6
- import { useComposedRefs } from "@radix-ui/react-compose-refs";
7
- import { createContext } from "@radix-ui/react-context";
8
- import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
9
- import { useObject } from "@dxos/echo-react";
10
- import { Icon, IconButton, Input, Tag, composable, composableProps, useTranslation } from "@dxos/react-ui";
11
- import { Listbox, TreeDropIndicator, TreeItemToggle, paddingIndentation, useListDisclosure } from "@dxos/react-ui-list";
12
- import { MarkdownView } from "@dxos/react-ui-markdown";
13
- import { mx } from "@dxos/ui-theme";
1
+ import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
2
+ import * as Hooks from "@dxos/react-ui/Hooks";
3
+ import * as Icon from "@dxos/react-ui/Icon";
4
+ import * as Layout from "@dxos/react-ui/Layout";
5
+ import * as Typography from "@dxos/react-ui/Typography";
6
+ import { Person, PullRequest, RemoteSession, Task } from "@dxos/types";
7
+ import { getHashHue, getStyles, mx, toHue } from "@dxos/ui-theme";
14
8
  import { translationKey } from "#translations";
15
- import { jsx, jsxs } from "react/jsx-runtime";
16
- //#region src/components/TaskList/hierarchy.ts
9
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
10
+ import { Filter, Obj, Ref, Tag } from "@dxos/echo";
11
+ import { useObject, useQuery } from "@dxos/echo-react";
12
+ import { Tree, createStaticTreeModel } from "@dxos/react-ui-list";
13
+ import { ActionMenu, createMenuAction, executeMenuAction, fallbackIcon } from "@dxos/react-ui-menu";
14
+ import * as Button from "@dxos/react-ui/Button";
15
+ import * as Tag$1 from "@dxos/react-ui/Tag";
16
+ import * as Theme from "@dxos/react-ui/Theme";
17
+ import * as Util from "@dxos/react-ui/Util";
18
+ import { MarkdownEditable, MarkdownView } from "@dxos/react-ui-markdown";
19
+ import { log } from "@dxos/log";
20
+ import * as Field from "@dxos/react-ui/Field";
21
+ import * as Input from "@dxos/react-ui/Input";
22
+ import * as SystemButton from "@dxos/react-ui/SystemButton";
23
+ import { submitOnModEnter } from "@dxos/ui-editor";
24
+ import * as Tooltip from "@dxos/react-ui/Tooltip";
25
+ import { RegistryContext } from "@effect/atom-react/RegistryContext";
26
+ import { DX_POPOVER_CONTENT_ATTR, DxAnchorActivate } from "@dxos/ui-types";
27
+ import { differenceInCalendarDays, formatDistance } from "date-fns";
28
+ //#region src/components/task-grid.ts
17
29
  /**
18
- * Walks the set's tree in sibling order, omitting the descendants of any collapsed task.
19
- * Cycle-safe: a malformed `parentTask` loop is visited once and then skipped, so a corrupt set
20
- * renders short rather than hanging the list.
30
+ * The geometry a task's sections share: a fixed glyph column, then the content.
31
+ *
32
+ * Shared as a class list rather than as one grid, because the sections do not nest — properties,
33
+ * questions and history each own their own element and could not subgrid onto a common parent
34
+ * without becoming one component. A fixed first column is what makes them line up anyway: 24px is
35
+ * 24px in every section, where an `auto` or `min-content` column would size to each section's own
36
+ * widest glyph and land them a pixel or two apart.
21
37
  */
22
- var walkTaskTree = (tasks, collapsed) => {
23
- const rows = [];
24
- const seen = /* @__PURE__ */ new Set();
25
- const visit = (siblings, level, ancestors) => {
26
- siblings.forEach((task, index) => {
27
- if (seen.has(task.id)) return;
28
- seen.add(task.id);
29
- const children = TaskSet.subTasks(tasks, task);
30
- rows.push({
31
- task,
32
- level,
33
- branch: children.length > 0,
34
- position: index + 1,
35
- setSize: siblings.length,
36
- ancestors
38
+ var TASK_GRID = "grid grid-cols-[1.5rem_1fr] gap-x-1 items-start";
39
+ /** Content in the second column: a row with no glyph of its own (a question's options, its context). */
40
+ var TASK_GRID_CONTENT = "col-start-2";
41
+ /**
42
+ * The glyph cell: one line box tall, so what it centres is the centre of its row's FIRST line — a
43
+ * cell as tall as the row would float the glyph down a wrapped paragraph.
44
+ */
45
+ var TASK_GRID_ICON = "col-start-1 grid place-items-center h-[1lh]";
46
+ //#endregion
47
+ //#region src/util/status-icons.ts
48
+ /**
49
+ * Every status, most active first — the order the picker offers and the order groups render in.
50
+ *
51
+ * It must stay exhaustive: `buildStatusGroups` renders only the statuses named here, so a status
52
+ * left off this list would take its tasks out of a grouped view entirely rather than merely sorting
53
+ * them late.
54
+ */
55
+ var STATUS_ORDER = Task.StatusOptions.map(({ id }) => id);
56
+ /**
57
+ * The glyph for a field with no value set, whatever the field.
58
+ *
59
+ * A dot rather than a dash or an empty cell: every trailing control renders on every row so setting
60
+ * a value never depends on discovering a hover affordance, which means an unset one still has to
61
+ * occupy its column and read as a control. One glyph across priority, estimate and anything added
62
+ * later is what keeps a column of unset rows reading as one column.
63
+ */
64
+ var UNSET_ICON = "ph--asterisk--regular";
65
+ var priorityIcon = (priority) => priority != null ? PRIORITY_ICONS.get(priority) ?? "ph--asterisk--regular" : UNSET_ICON;
66
+ /**
67
+ * A status's glyph, from the same table that gives it a title and a hue, so the flat row, the tree,
68
+ * the picker and the form's select cannot drift apart.
69
+ */
70
+ var statusIcon = (status) => STATUS_ICONS.get(status) ?? "ph--asterisk--regular";
71
+ /**
72
+ * Hue lookup over a schema option table.
73
+ *
74
+ * Colour comes from the table rather than from a map of our own so a row, its picker and the form's
75
+ * select all paint a value the same way; `getStyles` falls back to neutral for a colour outside the
76
+ * palette (the tables use `gray`, which is not one).
77
+ */
78
+ var textStyleFor = (options) => {
79
+ const colors = new Map(options.map(({ id, color }) => [id, color]));
80
+ return (id) => getStyles(colors.get(id) ?? "neutral").text;
81
+ };
82
+ var statusTextStyle = textStyleFor(Task.StatusOptions);
83
+ var priorityTextStyle = textStyleFor(Task.PriorityOptions);
84
+ var estimateTextStyle = textStyleFor(Task.EstimateOptions);
85
+ /** A priority's glyph, from the same table that gives it a title and a hue. */
86
+ var PRIORITY_ICONS = new Map(Task.PriorityOptions.map(({ id, icon }) => [id, icon]));
87
+ var STATUS_ICONS = new Map(Task.StatusOptions.map(({ id, icon }) => [id, icon]));
88
+ //#endregion
89
+ //#region src/components/TaskHistory/TaskHistory.tsx
90
+ var EVENT_ICONS = {
91
+ created: {
92
+ icon: "ph--plant--regular",
93
+ hue: "emerald"
94
+ },
95
+ updated: {
96
+ icon: "ph--pencil-simple--regular",
97
+ hue: "indigo"
98
+ },
99
+ question: {
100
+ icon: "ph--question--regular",
101
+ hue: "amber"
102
+ },
103
+ answer: {
104
+ icon: "ph--check-circle--regular",
105
+ hue: "emerald"
106
+ }
107
+ };
108
+ /** The line an entry reads as: a change's own note. */
109
+ var entryText = (entry) => entry.event === "question" ? entry.text : entry.event === "answer" ? entry.answer : entry.description ?? entry.event;
110
+ /** Falls back to the unset glyph: an entry written by an older schema still renders as a row. */
111
+ var eventIcon = (event) => EVENT_ICONS[event] ?? {
112
+ icon: "ph--asterisk--regular",
113
+ hue: "neutral"
114
+ };
115
+ /**
116
+ * The log as the reader sees it, newest first. A question is an item only once answered — an open
117
+ * one is waiting on the reader and belongs with the task's open questions, not in its record — and
118
+ * then question and answer are one item, dated when the exchange closed.
119
+ */
120
+ var buildItems = (entries, limit) => {
121
+ const threads = new Map(Task.getQuestions(entries).map((thread) => [thread.question.id, thread]));
122
+ const items = [];
123
+ for (const [index, entry] of [...entries].reverse().entries()) {
124
+ if (Task.isQuestionEntry(entry)) continue;
125
+ const thread = Task.isAnswerEntry(entry) ? threads.get(entry.questionId) : void 0;
126
+ if (thread && thread.answer === entry) {
127
+ const { icon, hue } = eventIcon("question");
128
+ items.push({
129
+ key: entry.id,
130
+ icon,
131
+ hue: getStyles(hue).text,
132
+ text: thread.question.text,
133
+ answer: entry.answer,
134
+ date: entry.date
37
135
  });
38
- if (children.length > 0 && !collapsed?.has(task.id)) visit(children, level + 1, [...ancestors, task.id]);
136
+ continue;
137
+ }
138
+ const { icon, hue } = eventIcon(entry.event);
139
+ items.push({
140
+ key: entry.id ?? `${entry.date}-${index}`,
141
+ icon,
142
+ hue: getStyles(hue).text,
143
+ text: entryText(entry),
144
+ date: entry.date
39
145
  });
40
- };
41
- visit(TaskSet.rootTasks(tasks), 1, []);
42
- return rows;
146
+ }
147
+ return items.slice(0, limit);
43
148
  };
44
- /** Every task transitively under `task`, including `task` itself. Cycle-safe. */
149
+ /**
150
+ * A task's activity log, newest first.
151
+ *
152
+ * Each entry already carries the human-readable record of what happened, so a line is that sentence
153
+ * plus when it happened — the pane adds no interpretation of its own. An answered question reads as
154
+ * one line with its answer under it; an open one is not part of the record yet.
155
+ */
156
+ var TaskHistory = ({ entries, limit = 5, classNames }) => {
157
+ const { t } = Hooks.useTranslation(translationKey);
158
+ const items = useMemo(() => buildItems(entries, limit), [entries, limit]);
159
+ if (items.length === 0) return null;
160
+ return /* @__PURE__ */ jsx(Layout.Container, {
161
+ asChild: true,
162
+ gutter: "inherit",
163
+ gap: "sm",
164
+ role: "list",
165
+ "aria-label": t("task-history.label"),
166
+ "data-testid": "taskList.history",
167
+ classNames: mx("text-sm text-fg-muted", classNames),
168
+ children: /* @__PURE__ */ jsxs("section", { children: [/* @__PURE__ */ jsx(Typography.Text, {
169
+ asChild: true,
170
+ tone: "subtle",
171
+ classNames: "dx-label py-0",
172
+ children: /* @__PURE__ */ jsx("h2", { children: t("task-history.label") })
173
+ }), items.map((item) => /* @__PURE__ */ jsxs("div", {
174
+ role: "listitem",
175
+ className: mx(TASK_GRID, "min-w-0"),
176
+ children: [/* @__PURE__ */ jsx("div", {
177
+ className: TASK_GRID_ICON,
178
+ children: /* @__PURE__ */ jsx(Icon.Icon, {
179
+ icon: item.icon,
180
+ classNames: item.hue,
181
+ size: "md"
182
+ })
183
+ }), /* @__PURE__ */ jsxs("div", {
184
+ className: "flex gap-2 min-w-0",
185
+ children: [/* @__PURE__ */ jsxs("span", {
186
+ className: "grow min-w-0",
187
+ children: [item.text, item.answer && /* @__PURE__ */ jsx("span", {
188
+ className: "block text-fg",
189
+ "data-testid": "taskList.history.answer",
190
+ children: item.answer
191
+ })]
192
+ }), /* @__PURE__ */ jsx(Typography.Timestamp, {
193
+ date: item.date,
194
+ classNames: "shrink-0 text-right"
195
+ })]
196
+ })]
197
+ }, item.key))] })
198
+ });
199
+ };
200
+ TaskHistory.displayName = "TaskList.History";
201
+ //#endregion
202
+ //#region src/components/TaskList/hierarchy.ts
203
+ /**
204
+ * The tree the list renders, and where a gesture puts a task in it. Kept out of the components
205
+ * because this is the part a regression would break: a drop cannot be driven synthetically
206
+ * (pragmatic-drag-and-drop uses native HTML5 drag events), so the placement calculation is tested
207
+ * directly instead.
208
+ *
209
+ * `TaskSet.tasks` lists the roots and each `Task.subtasks` its own children, so every list orders
210
+ * one set of siblings only: a parent moves without touching its subtree's entries, and a peer
211
+ * reordering one branch cannot corrupt another.
212
+ */
213
+ /** One rendered row: the task, its depth, and where it sits among its siblings. */
45
214
  var subtreeIds = (tasks, task) => {
215
+ const childrenOf = Task.childIndex(tasks);
46
216
  const ids = /* @__PURE__ */ new Set();
47
217
  const visit = (current) => {
48
218
  if (ids.has(current.id)) return;
49
219
  ids.add(current.id);
50
- for (const child of TaskSet.subTasks(tasks, current)) visit(child);
220
+ for (const child of childrenOf(current)) visit(child);
51
221
  };
52
222
  visit(task);
53
223
  return ids;
54
224
  };
55
225
  /**
226
+ * Places a task after `target`'s ancestor `levels` steps up — the tree hitbox's `reparent`, which
227
+ * is how a drop past the last child escapes the subtree it would otherwise join. `levels` of 1 is
228
+ * "after my parent", 2 "after my grandparent", and so on; running out of ancestors rejects rather
229
+ * than silently landing at the root.
230
+ */
231
+ var resolveReparent = (tasks, source, target, levels) => {
232
+ if (levels < 1) return;
233
+ let ancestor = target;
234
+ for (let step = 0; step < levels; step++) {
235
+ const parentId = ancestor && Task.parentTaskId(ancestor);
236
+ ancestor = parentId === void 0 ? void 0 : tasks.find((task) => task.id === parentId);
237
+ if (!ancestor) return;
238
+ }
239
+ return subtreeIds(tasks, source).has(ancestor.id) ? void 0 : resolveTaskPlacement({
240
+ tasks,
241
+ source,
242
+ target: ancestor,
243
+ intent: "reorder-below"
244
+ });
245
+ };
246
+ /**
56
247
  * Resolves a drop into a single `MoveTask` call, or `undefined` when the gesture is rejected.
57
248
  *
58
249
  * Rejected rather than silently ignored, so the caller can say no with the cursor:
@@ -65,11 +256,11 @@ var resolveTaskPlacement = ({ tasks, source, target, intent }) => {
65
256
  if (subtreeIds(tasks, source).has(target.id)) return;
66
257
  if (intent === "make-child") return {
67
258
  parentTask: target,
68
- before: void 0
259
+ before: Task.subTasks(tasks, target).find((child) => child.id !== source.id)
69
260
  };
70
- const parentId = TaskSet.parentTaskId(target);
261
+ const parentId = Task.parentTaskId(target);
71
262
  const parent = parentId === void 0 ? null : tasks.find((task) => task.id === parentId) ?? null;
72
- const siblings = parent === null ? TaskSet.rootTasks(tasks) : TaskSet.subTasks(tasks, parent);
263
+ const siblings = parent === null ? Task.rootTasks(tasks) : Task.subTasks(tasks, parent);
73
264
  const index = siblings.findIndex((task) => task.id === target.id);
74
265
  if (intent === "reorder-above") return {
75
266
  parentTask: parent,
@@ -82,9 +273,9 @@ var resolveTaskPlacement = ({ tasks, source, target, intent }) => {
82
273
  };
83
274
  /** A task's siblings (in sibling order) and its index among them. */
84
275
  var siblingsOf = (tasks, task) => {
85
- const parentId = TaskSet.parentTaskId(task);
276
+ const parentId = Task.parentTaskId(task);
86
277
  const parent = parentId === void 0 ? null : tasks.find((candidate) => candidate.id === parentId) ?? null;
87
- const siblings = parent === null ? TaskSet.rootTasks(tasks) : TaskSet.subTasks(tasks, parent);
278
+ const siblings = parent === null ? Task.rootTasks(tasks) : Task.subTasks(tasks, parent);
88
279
  return {
89
280
  parent,
90
281
  siblings,
@@ -132,417 +323,841 @@ var resolveNudge = (tasks, task, direction) => {
132
323
  } : void 0;
133
324
  };
134
325
  //#endregion
135
- //#region src/components/TaskList/dnd.ts
136
- /**
137
- * Drag payload for a task row. Tagged so a drop target can tell a task from any other draggable
138
- * sharing the page (the navtree, a mosaic tile) rather than reading a bare `id` off it.
139
- */
140
- var TASK_DRAG_TYPE = "dxos/task";
141
- var isTaskDragData = (data) => data.type === "dxos/task" && typeof data.taskId === "string";
142
- /**
143
- * The hitbox's item mode, which decides where its bands sit: the last row of a group gets a
144
- * shallower `reorder-below` band (there is nothing beneath it to reorder against), and an expanded
145
- * branch gets none at all — below it is its own first child.
146
- */
147
- var itemMode = ({ branch, open, last }) => branch && open ? "expanded" : last ? "last-in-group" : "standard";
148
- /** The drop intent an instruction expresses, or undefined when it carries none (blocked). */
149
- var dropIntent = (instruction) => {
150
- switch (instruction?.type) {
151
- case "reorder-above":
152
- case "reorder-below":
153
- case "make-child": return instruction.type;
154
- default: return;
155
- }
326
+ //#region src/util/assignee.ts
327
+ /** The glyph for a person assignee. */
328
+ var PERSON_ICON = "ph--user--regular";
329
+ /** A DID is long and mostly shared prefix, so the start is the part that tells two apart. */
330
+ var shortDid = (did) => `${did.slice(0, 12)}…`;
331
+ /** An id's tail rather than its head: object ids share a timestamp prefix and harness ids a `session_` one. */
332
+ var shortId = (id) => `…${id.slice(-8)}`;
333
+ /**
334
+ * How an assignee reads wherever a task names it. An assistant's actor stands for a session rather
335
+ * than a person, so its name falls through the session's own fields before the actor's, and it
336
+ * always has one — an agent that reads as "Agent" says an assistant holds the task but never which.
337
+ */
338
+ var getAssigneeDisplay = ({ assignee, contact, subject, agentLabel }) => {
339
+ const session = Obj.instanceOf(RemoteSession.RemoteSession, subject) ? subject : void 0;
340
+ const agent = assignee.role === "assistant";
341
+ const sessionId = (session && RemoteSession.getSessionId(session)) ?? (session || subject === void 0 ? Task.refEntityId(assignee.subject) : void 0);
342
+ return {
343
+ label: contact?.fullName ?? session?.title ?? (session && RemoteSession.harnessName(session)) ?? assignee.name ?? assignee.email ?? (assignee.identityDid ? shortDid(assignee.identityDid) : void 0) ?? (sessionId ? shortId(sessionId) : void 0) ?? (agent ? agentLabel : void 0),
344
+ icon: (session && RemoteSession.harnessIcon(session)) ?? (agent ? "ph--robot--regular" : "ph--user--regular"),
345
+ agent,
346
+ session
347
+ };
156
348
  };
157
349
  //#endregion
158
- //#region src/components/TaskList/TaskList.tsx
159
- var TASK_LIST_NAME = "TaskList.Root";
160
- /** Linear-style status groups, most active first. */
161
- var STATUS_ORDER = [
162
- "started",
163
- "todo",
164
- "done",
165
- "failed",
166
- "cancelled"
167
- ];
168
- /** Fallback status labels. */
169
- var DEFAULT_STATUS_LABELS = {
170
- started: "Started",
171
- todo: "To Do",
172
- done: "Done",
173
- failed: "Failed",
174
- cancelled: "Cancelled"
175
- };
176
- var [TaskListProvider, useTaskListContext] = createContext(TASK_LIST_NAME);
177
- /** Shared empty set, so a list with nothing in flight does not allocate one per render. */
178
- var EMPTY_IDS = /* @__PURE__ */ new Set();
179
- var TaskListRoot = ({ children, tasks, groupByStatus = true, showGroupLabels = true, showOrdinals = false, showDescriptions = false, statusLabel = (status) => DEFAULT_STATUS_LABELS[status], onTaskCreate, onTaskUpdate, onTaskDelete, onTaskSelect, onTaskMove, selected: selectedProp, hierarchical = false, collapsed, onCollapsedChange }) => {
180
- const [selectedState, setSelectedState] = useState(selectedProp);
181
- const selected = selectedProp ?? selectedState;
182
- const selectable = !!onTaskSelect || selectedProp !== void 0;
183
- const handleValueChange = useCallback((id) => {
184
- setSelectedState(id);
185
- const task = tasks.find((task) => task.id === id);
186
- if (task) onTaskSelect?.(task);
187
- }, [tasks, onTaskSelect]);
188
- const disclosure = useListDisclosure({
189
- mode: "multi",
190
- ...collapsed !== void 0 || onCollapsedChange ? { value: collapsed } : {},
191
- defaultValue: /* @__PURE__ */ new Set(),
192
- onValueChange: onCollapsedChange
193
- });
194
- const isCollapsed = useCallback((id) => disclosure.bind(id).expanded, [disclosure]);
195
- const onCollapseToggle = useCallback((id) => disclosure.bind(id).toggle(), [disclosure]);
196
- const [draggingTask, setDraggingTask] = useState();
197
- const dragging = useMemo(() => draggingTask ? subtreeIds(tasks, draggingTask) : EMPTY_IDS, [tasks, draggingTask]);
198
- return /* @__PURE__ */ jsx(TaskListProvider, {
199
- tasks,
200
- groupByStatus: groupByStatus && !hierarchical,
201
- showGroupLabels,
202
- showOrdinals,
203
- showDescriptions,
204
- hierarchical,
205
- showGutter: showOrdinals || !!onTaskMove,
206
- statusLabel,
207
- isCollapsed,
208
- onCollapseToggle,
209
- dragging,
210
- onDraggingChange: setDraggingTask,
211
- onTaskCreate,
350
+ //#region src/hooks/useAssigneeDisplay.ts
351
+ /**
352
+ * The label and glyph for a task's assignee, resolving its contact and subject refs — shared by the
353
+ * list's pill and the properties row so the two cannot name the same assignee differently.
354
+ */
355
+ var useAssigneeDisplay = (assignee) => {
356
+ const { t } = Hooks.useTranslation(translationKey);
357
+ const [contact] = useObject(assignee?.contact);
358
+ const [subject] = useObject(assignee?.subject);
359
+ if (!assignee) return {
360
+ icon: PERSON_ICON,
361
+ agent: false
362
+ };
363
+ return getAssigneeDisplay({
364
+ assignee,
365
+ contact,
366
+ subject,
367
+ agentLabel: t("assignee-agent.label")
368
+ });
369
+ };
370
+ //#endregion
371
+ //#region src/components/TaskList/TaskDescription.tsx
372
+ /** A description is a line in a row, not a document: no paragraph block, no heading scale. */
373
+ var DESCRIPTION_COMPONENTS = { p: ({ children }) => /* @__PURE__ */ jsx("span", { children }) };
374
+ /**
375
+ * A task's description as it appears in a row. Shared by the flat list and the tree so the two
376
+ * paths cannot drift on type scale or clamping; only the placement differs, which is the caller's
377
+ * to supply — the flat row puts it in its own subgrid cell, the tree stacks it under the title
378
+ * inside the heading.
379
+ */
380
+ var TaskDescription = ({ content, components, classNames }) => /* @__PURE__ */ jsx(MarkdownView, {
381
+ "data-testid": "taskList.item.description",
382
+ content,
383
+ classNames: mx("text-sm text-fg-muted line-clamp-3", classNames),
384
+ uniformLineHeight: true,
385
+ components: {
386
+ ...DESCRIPTION_COMPONENTS,
387
+ ...components
388
+ }
389
+ });
390
+ var [TaskListProvider, useTaskListContext] = Hooks.createContext("TaskList.Root");
391
+ //#endregion
392
+ //#region src/components/TaskList/TaskRowCells.tsx
393
+ /** The status glyph, which is also the control that completes the task. */
394
+ var TaskStatusControl = ({ task, onTaskUpdate, active, classNames }) => {
395
+ const { t } = Hooks.useTranslation(translationKey);
396
+ const status = task.status ?? "todo";
397
+ const { icon, classNames: iconClassNames } = active ?? Task.isAgentWorking(task) ? {
398
+ icon: "ph--spinner--regular",
399
+ classNames: "text-info-text animate-spin"
400
+ } : {
401
+ icon: statusIcon(status),
402
+ classNames: statusTextStyle(status)
403
+ };
404
+ const actions = useCallback(() => Task.StatusOptions.map(({ id }) => createMenuAction(`status-${id}`, () => onTaskUpdate?.(task, { status: id }), {
405
+ label: t(`status-${id}.label`),
406
+ icon: statusIcon(id),
407
+ iconClassNames: statusTextStyle(id),
408
+ checked: status === id
409
+ })), [
212
410
  onTaskUpdate,
213
- onTaskDelete,
214
- onTaskSelect,
215
- onTaskMove,
216
- selected,
217
- children: /* @__PURE__ */ jsx(Listbox.Root, {
218
- ...selectable ? {
219
- value: selected,
220
- onValueChange: handleValueChange
221
- } : {},
222
- children
411
+ task,
412
+ status,
413
+ t
414
+ ]);
415
+ if (!onTaskUpdate) return /* @__PURE__ */ jsxs(Layout.Block, {
416
+ "aria-hidden": false,
417
+ "data-testid": "taskList.item.status",
418
+ classNames,
419
+ children: [/* @__PURE__ */ jsx(Icon.Icon, {
420
+ icon,
421
+ classNames: iconClassNames
422
+ }), /* @__PURE__ */ jsx("span", {
423
+ className: "sr-only",
424
+ children: t(`status-${status}.label`)
425
+ })]
426
+ });
427
+ const trigger = /* @__PURE__ */ jsx(Button.Root, {
428
+ "data-testid": "taskList.item.status",
429
+ iconClassNames,
430
+ variant: "ghost",
431
+ icon,
432
+ iconOnly: true,
433
+ label: t("task-status.label"),
434
+ onClick: (event) => event.stopPropagation()
435
+ });
436
+ return /* @__PURE__ */ jsx(Layout.Block, {
437
+ classNames,
438
+ children: /* @__PURE__ */ jsx(ActionMenu, {
439
+ deferUntilOpen: true,
440
+ actions,
441
+ children: trigger
223
442
  })
224
443
  });
225
444
  };
226
- TaskListRoot.displayName = "TaskList.Root";
227
- var TaskListViewport = composable(({ children, ...props }, forwardedRef) => {
228
- const { className, ...rest } = composableProps(props);
229
- return /* @__PURE__ */ jsx(Listbox.Viewport, {
230
- ...rest,
231
- classNames: mx("min-w-0 min-h-0", className),
232
- ref: forwardedRef,
233
- children
445
+ TaskStatusControl.displayName = "TaskList.StatusControl";
446
+ /**
447
+ * The task's reference: a button in the task's mnemonic hue that copies it. With an ordinal it shows the number, and
448
+ * the clipboard only on hover; without one, the clipboard. At least as wide as the clipboard button, so a one-digit
449
+ * number does not make a narrower target.
450
+ *
451
+ * Copies the task's full `echo://<space>/<id>` URI rather than the mnemonic it names: a mnemonic is only unique enough
452
+ * to read, while the URI resolves the task from anywhere it is pasted — a prompt, an MCP call, another space.
453
+ */
454
+ var TaskMnemonic = ({ task, ordinal, classNames }) => {
455
+ const mnemonic = Obj.getMnemonic(task);
456
+ const [copied, setCopied] = useState(false);
457
+ const timeoutRef = useRef(void 0);
458
+ useEffect(() => () => clearTimeout(timeoutRef.current), []);
459
+ const handleClick = (event) => {
460
+ event.stopPropagation();
461
+ navigator.clipboard.writeText(Obj.getURI(task, { prefer: "absolute" }).toString()).then(() => {
462
+ setCopied(true);
463
+ clearTimeout(timeoutRef.current);
464
+ timeoutRef.current = setTimeout(() => setCopied(false), 1e3);
465
+ }).catch(() => setCopied(false));
466
+ };
467
+ const icon = /* @__PURE__ */ jsx(Icon.Icon, { icon: copied ? "ph--check--regular" : "ph--clipboard--regular" });
468
+ return /* @__PURE__ */ jsx(Tooltip.Trigger, {
469
+ asChild: true,
470
+ content: mnemonic,
471
+ children: /* @__PURE__ */ jsx(Button.Root, {
472
+ size: "sm",
473
+ compact: true,
474
+ hue: getHashHue(mnemonic),
475
+ "aria-label": mnemonic,
476
+ "data-testid": "taskList.item.mnemonic",
477
+ classNames: mx("group justify-center min-w-(--dx-control-size) font-mono tabular-nums", classNames),
478
+ onClick: handleClick,
479
+ children: ordinal === void 0 || copied ? icon : /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("span", {
480
+ "data-testid": "taskList.item.ordinal",
481
+ className: "group-hover:hidden",
482
+ children: ordinal
483
+ }), /* @__PURE__ */ jsx("span", {
484
+ className: "hidden group-hover:contents",
485
+ children: icon
486
+ })] })
487
+ })
234
488
  });
235
- });
236
- TaskListViewport.displayName = "TaskList.Viewport";
489
+ };
490
+ TaskMnemonic.displayName = "TaskList.Mnemonic";
237
491
  /**
238
- * The rows and the create row are separate grids (the create row sits outside the scrolling
239
- * viewport), so their leading gutters — ordinal, then status — are declared once here: only
240
- * matching templates keep the `+` under the status control and the input under the titles.
241
- * Tailwind scans for whole class names, hence four literals rather than a composed prefix. The
242
- * status gutter is `1.5rem` because that is the width of the `density='sm'` icon button it holds;
243
- * a narrower track makes the button overflow it and align left instead of centring.
244
- */
245
- var GRID_COLS = {
246
- content: "grid-cols-[1.5rem_1fr_min-content_min-content_2rem]",
247
- contentWithOrdinals: "grid-cols-[2rem_1.5rem_1fr_min-content_min-content_2rem]",
248
- create: "grid-cols-[1.5rem_1fr]",
249
- createWithOrdinals: "grid-cols-[2rem_1.5rem_1fr]"
250
- };
251
- var TaskListContent = composable((props, forwardedRef) => {
252
- const { tasks, groupByStatus, showGroupLabels, showOrdinals, showDescriptions, hierarchical, showGutter, statusLabel, isCollapsed } = useTaskListContext("TaskList.Content");
253
- const ordinals = useMemo(() => new Map(tasks.map((task, index) => [task.id, index + 1])), [tasks]);
254
- const collapsed = useMemo(() => new Set(tasks.map((task) => task.id).filter(isCollapsed)), [tasks, isCollapsed]);
255
- const rows = useMemo(() => hierarchical ? walkTaskTree(tasks, collapsed) : void 0, [
256
- hierarchical,
257
- tasks,
258
- collapsed
259
- ]);
260
- const groups = useMemo(() => {
261
- if (!groupByStatus) return tasks.length > 0 ? [{
262
- status: void 0,
263
- tasks
264
- }] : [];
265
- return STATUS_ORDER.map((status) => ({
266
- status,
267
- tasks: tasks.filter((task) => (task.status ?? "todo") === status)
268
- })).filter((group) => group.tasks.length > 0);
269
- }, [tasks, groupByStatus]);
270
- return /* @__PURE__ */ jsx(Listbox.Content, {
271
- ...composableProps(props, { classNames: mx("group grid gap-x-1 w-full min-w-0", showDescriptions ? "auto-rows-min items-start" : "auto-rows-[2rem] items-center", showGutter ? GRID_COLS.contentWithOrdinals : GRID_COLS.content) }),
272
- "aria-label": "Tasks",
273
- ref: forwardedRef,
274
- children: rows ? rows.map((row) => /* @__PURE__ */ jsx(TaskListItem, {
275
- task: row.task,
276
- ordinal: showOrdinals ? ordinals.get(row.task.id) : void 0,
277
- row
278
- }, row.task.id)) : groups.map(({ status, tasks }) => /* @__PURE__ */ jsxs(Fragment, { children: [status && showGroupLabels && /* @__PURE__ */ jsx(TaskListGroupLabel, { children: statusLabel(status) }), tasks.map((task) => /* @__PURE__ */ jsx(TaskListItem, {
279
- task,
280
- ordinal: showOrdinals ? ordinals.get(task.id) : void 0
281
- }, task.id))] }, status ?? "all"))
492
+ * The gutter's checkbox: which rows an action will act on, never a status write — completing a task
493
+ * is what the status control does. It takes the ordinal's cell rather than a column of its own, so a
494
+ * list that offers it keeps one row geometry and the trailing controls do not shift.
495
+ */
496
+ var TaskCheckbox = ({ task, checked, onCheckedChange, classNames }) => {
497
+ const { t } = Hooks.useTranslation(translationKey);
498
+ return /* @__PURE__ */ jsx(Layout.Block, {
499
+ "aria-hidden": false,
500
+ classNames,
501
+ children: /* @__PURE__ */ jsx(Field.Root, { children: /* @__PURE__ */ jsx(Input.Checkbox, {
502
+ checked,
503
+ "data-testid": "taskList.item.checkbox",
504
+ "aria-label": t("task-check.label"),
505
+ onCheckedChange: () => onCheckedChange(task),
506
+ onClick: (event) => event.stopPropagation()
507
+ }) })
282
508
  });
283
- });
284
- TaskListContent.displayName = "TaskList.Content";
285
- var TaskListGroupLabel = composable(({ children, ...props }, forwardedRef) => {
286
- const { className, ...rest } = composableProps(props);
287
- return /* @__PURE__ */ jsx("div", {
288
- ...rest,
289
- className: mx("col-span-full min-h-(--dx-control) flex items-center text-sm text-description", className),
290
- ref: forwardedRef,
291
- children: /* @__PURE__ */ jsx("span", { children })
509
+ };
510
+ TaskCheckbox.displayName = "TaskList.Checkbox";
511
+ /**
512
+ * Estimate as its own label rather than a glyph: the sizes are a vocabulary a reader already knows
513
+ * (`XS`…`XL`), and two ordinal ramps side by side would be read as one. Rendered on every row so
514
+ * setting an estimate never depends on discovering a hover affordance, and falling back to
515
+ * {@link UNSET_ICON} when unset — the same dot the priority column shows, so a row with neither set
516
+ * reads as two empty controls rather than a dash beside a dot.
517
+ */
518
+ var TaskEstimateControl = ({ task, classNames }) => {
519
+ const { onTaskUpdate } = useTaskListContext("TaskList.EstimateControl");
520
+ return /* @__PURE__ */ jsx(TaskEstimatePicker, {
521
+ estimate: task.estimate,
522
+ onChange: onTaskUpdate && ((estimate) => onTaskUpdate(task, { estimate: estimate ?? null })),
523
+ testId: "taskList.item.estimate",
524
+ classNames
292
525
  });
293
- });
294
- TaskListGroupLabel.displayName = "TaskList.GroupLabel";
295
- /** How long the cursor must rest on a collapsed branch before it opens, so crossing one does not. */
296
- var EXPAND_DWELL = 600;
297
- /**
298
- * Distance from the title cell's own inline start to the title text: the disclosure toggle (`w-6`)
299
- * plus the cell's `gap-1`. The description sits in a different grid row and has to clear the same
300
- * distance to line up under the title.
301
- */
302
- var TOGGLE_INSET = "1.75rem";
303
- var useTaskDrag = ({ task, row, tasks, onTaskMove, isCollapsed, onCollapseToggle, onDraggingChange }) => {
304
- const rowRef = useRef(null);
305
- const dragHandleRef = useRef(null);
306
- const [instruction, setInstruction] = useState(null);
307
- const expandTimeout = useRef(null);
308
- const liftFrame = useRef(null);
309
- const latest = useRef({
310
- tasks,
311
- onTaskMove,
312
- isCollapsed,
313
- onCollapseToggle,
314
- onDraggingChange,
315
- row
526
+ };
527
+ /** The estimate label and its menu over a bare value, for a row's task or a draft that has none yet. */
528
+ var TaskEstimatePicker = ({ estimate, onChange, testId, classNames }) => {
529
+ const label = estimate?.toUpperCase() ?? /* @__PURE__ */ jsx(Icon.Icon, {
530
+ icon: "ph--asterisk--regular",
531
+ classNames: "text-neutral-500"
316
532
  });
317
- latest.current = {
533
+ if (!onChange) return /* @__PURE__ */ jsx(Layout.Block, {
534
+ classNames: mx(estimateTextStyle(estimate), classNames),
535
+ children: label
536
+ });
537
+ return /* @__PURE__ */ jsx(Layout.Block, {
538
+ classNames,
539
+ children: /* @__PURE__ */ jsx(ActionMenu, {
540
+ deferUntilOpen: true,
541
+ actions: () => [Task.NullOption, ...Task.EstimateOptions].map(({ id, title }) => createMenuAction(`estimate-${id}`, () => onChange(id === "none" ? void 0 : id), {
542
+ label: title,
543
+ checked: (estimate ?? "none") === id
544
+ })),
545
+ children: /* @__PURE__ */ jsx(Button.Root, {
546
+ variant: "ghost",
547
+ "data-testid": testId,
548
+ classNames: mx("w-8 px-0 text-xs tabular-nums", estimateTextStyle(estimate)),
549
+ onClick: (event) => event.stopPropagation(),
550
+ children: label
551
+ })
552
+ })
553
+ });
554
+ };
555
+ TaskEstimateControl.displayName = "TaskList.EstimateControl";
556
+ /**
557
+ * Priority as a signal-strength glyph rather than a word: the four levels are ordinal, so a ramp
558
+ * reads at a glance where four differently-worded tags do not. `urgent` breaks the ramp deliberately
559
+ * — it is a different kind of statement from "how much", and a filled mark carries that.
560
+ *
561
+ * The glyph is also the control: it opens a menu to set the level. It renders on every row —
562
+ * including one with no priority, which shows a dot — so setting a priority never depends on
563
+ * discovering a hover affordance.
564
+ */
565
+ var TaskPriorityIcon = ({ task, classNames }) => {
566
+ const { onTaskUpdate } = useTaskListContext("TaskList.PriorityIcon");
567
+ return /* @__PURE__ */ jsx(TaskPriorityPicker, {
568
+ priority: task.priority ?? void 0,
569
+ onChange: onTaskUpdate && ((priority) => onTaskUpdate(task, { priority: priority ?? null })),
570
+ testId: "taskList.item.priority",
571
+ classNames
572
+ });
573
+ };
574
+ /** The priority glyph and its menu over a bare value, for a row's task or a draft that has none yet. */
575
+ var TaskPriorityPicker = ({ priority, onChange, testId, classNames }) => {
576
+ const { t } = Hooks.useTranslation(translationKey);
577
+ const icon = priorityIcon(priority);
578
+ const styles = priorityTextStyle(priority);
579
+ if (!onChange) return /* @__PURE__ */ jsx(Layout.Block, {
580
+ classNames,
581
+ children: /* @__PURE__ */ jsx(Icon.Icon, {
582
+ icon,
583
+ classNames: mx(styles)
584
+ })
585
+ });
586
+ return /* @__PURE__ */ jsx(Layout.Block, {
587
+ classNames,
588
+ children: /* @__PURE__ */ jsx(ActionMenu, {
589
+ deferUntilOpen: true,
590
+ actions: () => [Task.NullOption, ...Task.PriorityOptions].map(({ id, icon: optionIcon }) => createMenuAction(`priority-${id}`, () => onChange(id === "none" ? void 0 : id), {
591
+ label: t(`priority-${id}.label`),
592
+ icon: optionIcon ?? "ph--asterisk--regular",
593
+ iconClassNames: priorityTextStyle(id),
594
+ checked: (priority ?? "none") === id
595
+ })),
596
+ children: /* @__PURE__ */ jsx(Button.Root, {
597
+ variant: "ghost",
598
+ icon,
599
+ iconOnly: true,
600
+ label: t("task-priority.label"),
601
+ "data-testid": testId,
602
+ iconClassNames: styles,
603
+ onClick: (event) => event.stopPropagation()
604
+ })
605
+ })
606
+ });
607
+ };
608
+ //#endregion
609
+ //#region src/components/TaskList/tracks.ts
610
+ /**
611
+ * A cell's place on the list's column template, by track name, on the title line. Shared by the tree's rows and the
612
+ * edit pane, which lay out on the same template, so a field sits exactly where the value it edits is read; a cell
613
+ * that is absent leaves its track empty. Literal so Tailwind finds the classes.
614
+ */
615
+ var TRACK = {
616
+ gutter: "row-start-1 col-[gutter]",
617
+ status: "row-start-1 col-[status]",
618
+ title: "row-start-1 col-[title]",
619
+ artifacts: "row-start-1 col-[artifacts]",
620
+ assignee: "row-start-1 col-[assignee]",
621
+ estimate: "row-start-1 col-[estimate]",
622
+ priority: "row-start-1 col-[priority]",
623
+ actions: "row-start-1 col-[actions]"
624
+ };
625
+ //#endregion
626
+ //#region src/components/TaskList/TaskListEditor.tsx
627
+ var __dxlog_file = "/__w/dxos/dxos/packages/ui/react-ui-task/src/components/TaskList/TaskListEditor.tsx";
628
+ /**
629
+ * The detail half of the list: it edits whichever task is selected, and creates one when none is.
630
+ *
631
+ * Editing lives here rather than in the row because a row is 32px of shared subgrid — a field
632
+ * opening inside it moves everything around it. A pane below the list has room to be a field.
633
+ *
634
+ * The fields, and nothing else. A task's questions and its history belong to the surface that has
635
+ * room to answer and to read — the detail article — and under a list they grew the strip by a line
636
+ * per entry, pushing the list itself off the screen.
637
+ */
638
+ var TaskListEditor = Util.composable(({ placeholder, showDescription = false, descriptionPlaceholder, descriptionExtensions, createOnly = false, acceptFiles = false, ...props }, forwardedRef) => {
639
+ const { t } = Hooks.useTranslation(translationKey);
640
+ const { className, ...rest } = Util.composableProps(props);
641
+ const descriptionRef = useRef(null);
642
+ const { tasks, selected, columns, showEstimates, flush, onTaskCreate, onTaskUpdate, onTaskSelect } = useTaskListContext("TaskList.Editor");
643
+ const task = useMemo(() => createOnly ? void 0 : tasks.find(({ id }) => id === selected), [
644
+ createOnly,
318
645
  tasks,
319
- onTaskMove,
320
- isCollapsed,
321
- onCollapseToggle,
322
- onDraggingChange,
323
- row
324
- };
325
- const cancelLift = useCallback(() => {
326
- if (liftFrame.current !== null) {
327
- cancelAnimationFrame(liftFrame.current);
328
- liftFrame.current = null;
329
- }
330
- }, []);
331
- const cancelExpand = useCallback(() => {
332
- if (expandTimeout.current) {
333
- clearTimeout(expandTimeout.current);
334
- expandTimeout.current = null;
335
- }
336
- }, []);
337
- const enabled = !!onTaskMove && !!row;
338
- useEffect(() => {
339
- const element = rowRef.current;
340
- const handle = dragHandleRef.current;
341
- if (!enabled || !element || !handle) return;
342
- const data = {
343
- type: TASK_DRAG_TYPE,
344
- taskId: task.id
345
- };
346
- /** The placement a drag over this row currently means, or undefined when the drop is refused. */
347
- const placementFor = (instruction) => {
348
- const intent = dropIntent(instruction);
349
- const source = latest.current.tasks.find(({ id }) => id === draggingId.current);
350
- if (!intent || !source) return;
351
- return resolveTaskPlacement({
352
- tasks: latest.current.tasks,
353
- source,
354
- target: task,
355
- intent
356
- });
357
- };
358
- return combine(draggable({
359
- element: handle,
360
- getInitialData: () => data,
361
- onGenerateDragPreview: ({ location, nativeSetDragImage }) => {
362
- const rows = subtreeRows(element, latest.current.tasks, task);
363
- const { left, top } = element.getBoundingClientRect();
364
- const { clientX, clientY } = location.initial.input;
365
- const offset = {
366
- x: clientX - left,
367
- y: clientY - top
368
- };
369
- if (rows.length < 2) {
370
- nativeSetDragImage?.(element, offset.x, offset.y);
646
+ selected
647
+ ]);
648
+ const [snapshot] = useObject(task);
649
+ const current = snapshot ?? task;
650
+ const draftDescription = useRef("");
651
+ const [draft, setDraft] = useState("");
652
+ const [draftPriority, setDraftPriority] = useState();
653
+ const [draftEstimate, setDraftEstimate] = useState();
654
+ const [files, setFiles] = useState([]);
655
+ const [dragOver, setDragOver] = useState(false);
656
+ const dragDepth = useRef(0);
657
+ const [createEpoch, setCreateEpoch] = useState(0);
658
+ const creating = useRef(false);
659
+ const [editingId, setEditingId] = useState(void 0);
660
+ if (editingId !== current?.id) {
661
+ setEditingId(current?.id);
662
+ setDraft(current?.title ?? "");
663
+ draftDescription.current = "";
664
+ }
665
+ const commitTitle = useCallback(() => {
666
+ const title = draft.trim();
667
+ if (task && current) {
668
+ if (title.length > 0 && title !== current.title) onTaskUpdate?.(task, { title });
669
+ } else if (title.length > 0 && !creating.current) {
670
+ descriptionRef.current?.commit();
671
+ const sentDescription = draftDescription.current;
672
+ const description = sentDescription.trim();
673
+ const sentTitle = draft;
674
+ const sent = files;
675
+ const settle = (result) => {
676
+ creating.current = false;
677
+ if (result?.error) {
678
+ log.warn("task create failed", { error: result.error }, {
679
+ "~LogMeta": "~LogMeta",
680
+ F: __dxlog_file,
681
+ L: 164,
682
+ S: void 0
683
+ });
371
684
  return;
372
685
  }
373
- setCustomNativeDragPreview({
374
- nativeSetDragImage,
375
- getOffset: () => offset,
376
- render: ({ container }) => renderSubtreePreview(container, element, rows)
686
+ const kept = new Set(result?.rejectedFiles ?? []);
687
+ setFiles((files) => files.filter((file) => !sent.includes(file) || kept.has(file)));
688
+ setDraft((draft) => draft === sentTitle ? "" : draft);
689
+ setDraftPriority(void 0);
690
+ setDraftEstimate(void 0);
691
+ if (draftDescription.current === sentDescription) {
692
+ draftDescription.current = "";
693
+ setCreateEpoch((epoch) => epoch + 1);
694
+ }
695
+ };
696
+ let result;
697
+ creating.current = true;
698
+ try {
699
+ result = onTaskCreate?.({
700
+ title,
701
+ ...description.length > 0 && { description },
702
+ ...draftPriority && { priority: draftPriority },
703
+ ...draftEstimate && { estimate: draftEstimate }
704
+ }, sent.length > 0 ? sent : void 0);
705
+ } catch (error) {
706
+ creating.current = false;
707
+ log.catch(error, void 0, {
708
+ "~LogMeta": "~LogMeta",
709
+ F: __dxlog_file,
710
+ L: 192,
711
+ S: void 0
377
712
  });
378
- },
379
- onDragStart: () => {
380
- draggingId.current = task.id;
381
- liftFrame.current = requestAnimationFrame(() => latest.current.onDraggingChange(task));
382
- },
383
- onDrop: () => {
384
- draggingId.current = void 0;
385
- cancelLift();
386
- latest.current.onDraggingChange(void 0);
713
+ return;
387
714
  }
388
- }), dropTargetForElements({
389
- element,
390
- getData: ({ input, element }) => attachInstruction(data, {
391
- input,
392
- element,
393
- indentPerLevel: 8,
394
- currentLevel: (latest.current.row?.level ?? 1) - 1,
395
- mode: itemMode({
396
- branch: latest.current.row?.branch ?? false,
397
- open: !latest.current.isCollapsed(task.id),
398
- last: (latest.current.row?.position ?? 1) === (latest.current.row?.setSize ?? 1)
399
- }),
400
- block: []
401
- }),
402
- canDrop: ({ source }) => isTaskDragData(source.data) && source.data.taskId !== task.id,
403
- getIsSticky: () => true,
404
- onDrag: ({ self }) => {
405
- const desired = extractInstruction(self.data);
406
- const next = desired !== null && desired.type !== "instruction-blocked" && !placementFor(desired) ? {
407
- type: "instruction-blocked",
408
- desired
409
- } : desired;
410
- setInstruction(next);
411
- const { row, isCollapsed, onCollapseToggle } = latest.current;
412
- if (next?.type === "make-child" && row?.branch && isCollapsed(task.id) && !expandTimeout.current) expandTimeout.current = setTimeout(() => {
413
- expandTimeout.current = null;
414
- onCollapseToggle(task.id);
415
- }, EXPAND_DWELL);
416
- else if (next?.type !== "make-child") cancelExpand();
417
- },
418
- onDragLeave: () => {
419
- cancelExpand();
420
- setInstruction(null);
421
- },
422
- onDrop: ({ self, source }) => {
423
- cancelExpand();
424
- setInstruction(null);
425
- if (!isTaskDragData(source.data)) return;
426
- const moved = latest.current.tasks.find(({ id }) => id === source.data.taskId);
427
- const intent = dropIntent(extractInstruction(self.data));
428
- if (!moved || !intent) return;
429
- const placement = resolveTaskPlacement({
430
- tasks: latest.current.tasks,
431
- source: moved,
432
- target: task,
433
- intent
715
+ Promise.resolve(result).then(settle, (error) => {
716
+ creating.current = false;
717
+ log.catch(error, void 0, {
718
+ "~LogMeta": "~LogMeta",
719
+ F: __dxlog_file,
720
+ L: 197,
721
+ S: void 0
434
722
  });
435
- if (placement) latest.current.onTaskMove?.(moved, placement);
436
- }
437
- }));
723
+ });
724
+ }
725
+ }, [
726
+ draft,
727
+ draftPriority,
728
+ draftEstimate,
729
+ files,
730
+ task,
731
+ current,
732
+ onTaskCreate,
733
+ onTaskUpdate
734
+ ]);
735
+ const handleTitleBlur = useCallback(() => {
736
+ if (task && current) commitTitle();
737
+ }, [
738
+ task,
739
+ current,
740
+ commitTitle
741
+ ]);
742
+ const handleTitleKeyDown = useCallback((event) => {
743
+ if (event.key === "Enter") commitTitle();
744
+ }, [commitTitle]);
745
+ const handleSave = useCallback(() => {
746
+ commitTitle();
747
+ if (task && current) descriptionRef.current?.commit();
748
+ onTaskSelect?.(void 0);
749
+ }, [
750
+ commitTitle,
751
+ task,
752
+ current,
753
+ onTaskSelect
754
+ ]);
755
+ const handleSubmit = useCallback(() => {
756
+ if (!(task && current) && draft.trim().length === 0) return;
757
+ handleSave();
758
+ }, [
759
+ task,
760
+ current,
761
+ draft,
762
+ handleSave
763
+ ]);
764
+ const submitRef = Hooks.useDynamicRef(handleSubmit);
765
+ const extensions = useMemo(() => [...descriptionExtensions ?? [], submitOnModEnter({ onSubmit: () => submitRef.current() })], [descriptionExtensions]);
766
+ const handleCancel = useCallback(() => {
767
+ if (task && current) descriptionRef.current?.revert();
768
+ else {
769
+ draftDescription.current = "";
770
+ setCreateEpoch((epoch) => epoch + 1);
771
+ }
772
+ setDraft("");
773
+ setFiles([]);
774
+ onTaskSelect?.(void 0);
438
775
  }, [
439
- enabled,
440
- task.id,
441
- cancelExpand,
442
- cancelLift
776
+ task,
777
+ current,
778
+ onTaskSelect
443
779
  ]);
444
- useEffect(() => () => {
445
- cancelExpand();
446
- cancelLift();
447
- }, [cancelExpand, cancelLift]);
780
+ const takesFiles = acceptFiles && !!onTaskCreate && !(task && current);
781
+ const handleFiles = useCallback((added) => setFiles((files) => [...files, ...added]), []);
782
+ const handleDragEnter = useCallback((event) => {
783
+ if (isFileDrag(event)) {
784
+ dragDepth.current++;
785
+ setDragOver(true);
786
+ }
787
+ }, []);
788
+ const handleDragLeave = useCallback((event) => {
789
+ if (isFileDrag(event) && --dragDepth.current <= 0) {
790
+ dragDepth.current = 0;
791
+ setDragOver(false);
792
+ }
793
+ }, []);
794
+ const handleDragOver = useCallback((event) => {
795
+ if (isFileDrag(event)) {
796
+ event.preventDefault();
797
+ event.stopPropagation();
798
+ event.dataTransfer.dropEffect = "copy";
799
+ }
800
+ }, []);
801
+ const handleDrop = useCallback((event) => {
802
+ dragDepth.current = 0;
803
+ setDragOver(false);
804
+ const dropped = Array.from(event.dataTransfer.files);
805
+ if (dropped.length > 0) {
806
+ event.preventDefault();
807
+ event.stopPropagation();
808
+ handleFiles(dropped);
809
+ }
810
+ }, [handleFiles]);
811
+ const handlePaste = useCallback((event) => {
812
+ const pasted = Array.from(event.clipboardData.files);
813
+ if (pasted.length > 0 && !event.clipboardData.types.includes("text/plain")) {
814
+ event.preventDefault();
815
+ event.stopPropagation();
816
+ handleFiles(pasted);
817
+ }
818
+ }, [handleFiles]);
819
+ if (!onTaskCreate && !(current && onTaskUpdate)) return null;
820
+ const hasDescription = !!(showDescription && (current ? onTaskUpdate : onTaskCreate));
821
+ const canSave = draft.trim().length > 0;
822
+ const canCancel = !!current || canSave;
823
+ const editing = !!(task && current);
824
+ return /* @__PURE__ */ jsxs("div", {
825
+ ...rest,
826
+ className: mx("grid w-full min-w-0 shrink-0", (hasDescription || takesFiles && files.length > 0) && "gap-y-2", dragOver && "ring-2 ring-inset ring-accent-bg", !flush && "px-(--dx-gap-size)", className),
827
+ style: {
828
+ "gridTemplateColumns": columns,
829
+ "--dx-col": "auto"
830
+ },
831
+ "data-testid": "taskList.edit",
832
+ ...takesFiles && {
833
+ onDragEnterCapture: handleDragEnter,
834
+ onDragLeaveCapture: handleDragLeave,
835
+ onDragOverCapture: handleDragOver,
836
+ onDropCapture: handleDrop,
837
+ onPasteCapture: handlePaste
838
+ },
839
+ ref: forwardedRef,
840
+ children: [
841
+ editing ? /* @__PURE__ */ jsx(TaskStatusControl, {
842
+ task,
843
+ classNames: mx(TRACK.status, "self-start"),
844
+ onTaskUpdate
845
+ }) : /* @__PURE__ */ jsx("span", {
846
+ className: mx(TRACK.status, "flex items-center justify-center h-(--dx-control) self-start"),
847
+ children: /* @__PURE__ */ jsx(Icon.Icon, {
848
+ icon: "ph--plus--regular",
849
+ tone: "subtle"
850
+ })
851
+ }),
852
+ /* @__PURE__ */ jsx(Field.Root, {
853
+ classNames: "row-start-1 col-start-[title] col-end-[assignee] min-w-0",
854
+ children: /* @__PURE__ */ jsx(Input.Root, {
855
+ classNames: "grow text-ellipsis",
856
+ "data-testid": "taskList.edit.title",
857
+ placeholder: current ? t("task-title.placeholder") : placeholder ?? t("add-task.placeholder"),
858
+ value: draft,
859
+ onChange: (event) => setDraft(event.target.value),
860
+ onKeyDown: handleTitleKeyDown,
861
+ onBlur: handleTitleBlur,
862
+ end: canSave && /* @__PURE__ */ jsx(SystemButton.Save, {
863
+ variant: "primary",
864
+ "data-testid": "taskList.edit.save",
865
+ tabIndex: -1,
866
+ onClick: handleSave,
867
+ onMouseDown: (event) => event.preventDefault()
868
+ })
869
+ })
870
+ }),
871
+ hasDescription && /* @__PURE__ */ jsx(Input.Frame, {
872
+ rows: 2,
873
+ "data-testid": "taskList.edit.description",
874
+ classNames: "row-start-2 col-[title/assignee] min-w-0",
875
+ children: /* @__PURE__ */ jsx(MarkdownEditable, {
876
+ ref: descriptionRef,
877
+ classNames: "[&_.cm-scroller]:!leading-normal [&_.cm-scroller]:max-h-[8lh] [&_.cm-scroller]:overflow-y-auto",
878
+ editing: true,
879
+ multiline: true,
880
+ placeholder: descriptionPlaceholder ?? t("task-description.placeholder"),
881
+ extensions,
882
+ autoFocus: false,
883
+ ...current && { value: current.description ?? "" },
884
+ onValueChange: (description) => {
885
+ if (task && current) onTaskUpdate?.(task, { description });
886
+ else draftDescription.current = description;
887
+ }
888
+ }, current?.id ?? `create-${createEpoch}`)
889
+ }),
890
+ showEstimates && (editing ? /* @__PURE__ */ jsx(TaskEstimateControl, {
891
+ task,
892
+ classNames: TRACK.estimate
893
+ }) : /* @__PURE__ */ jsx(TaskEstimatePicker, {
894
+ estimate: draftEstimate,
895
+ onChange: setDraftEstimate,
896
+ testId: "taskList.edit.estimate",
897
+ classNames: TRACK.estimate
898
+ })),
899
+ editing ? /* @__PURE__ */ jsx(TaskPriorityIcon, {
900
+ task,
901
+ classNames: TRACK.priority
902
+ }) : /* @__PURE__ */ jsx(TaskPriorityPicker, {
903
+ priority: draftPriority,
904
+ onChange: setDraftPriority,
905
+ testId: "taskList.edit.priority",
906
+ classNames: TRACK.priority
907
+ }),
908
+ canCancel && /* @__PURE__ */ jsx(SystemButton.Cancel, {
909
+ variant: "ghost",
910
+ classNames: TRACK.actions,
911
+ "data-testid": "taskList.edit.cancel",
912
+ onClick: handleCancel,
913
+ onMouseDown: (event) => event.preventDefault()
914
+ }),
915
+ takesFiles && files.length > 0 && /* @__PURE__ */ jsx("div", {
916
+ className: mx("col-start-[title] -col-end-1 flex flex-wrap items-center gap-1 min-w-0", hasDescription ? "row-start-3" : "row-start-2"),
917
+ children: files.map((file, index) => /* @__PURE__ */ jsxs(Tag$1.Tag, {
918
+ hue: "neutral",
919
+ classNames: "inline-flex items-center gap-1",
920
+ "data-testid": "taskList.edit.file",
921
+ children: [
922
+ /* @__PURE__ */ jsx(Icon.Icon, {
923
+ icon: "ph--paperclip--regular",
924
+ size: "xs"
925
+ }),
926
+ /* @__PURE__ */ jsx("span", {
927
+ "data-testid": "taskList.edit.file.name",
928
+ children: file.name
929
+ }),
930
+ /* @__PURE__ */ jsx(Button.Root, {
931
+ variant: "ghost",
932
+ size: "sm",
933
+ iconOnly: true,
934
+ icon: "ph--x--regular",
935
+ iconSize: "xs",
936
+ label: t("remove-file.label", { name: file.name }),
937
+ classNames: "p-0 min-h-0 h-auto",
938
+ onClick: () => setFiles((files) => files.filter((_, position) => position !== index))
939
+ })
940
+ ]
941
+ }, `${file.name}-${index}`))
942
+ })
943
+ ]
944
+ });
945
+ });
946
+ /** Whether a drag carries files from outside the page, rather than an element dragged within it. */
947
+ var isFileDrag = (event) => Array.from(event.dataTransfer.types).includes("Files");
948
+ TaskListEditor.displayName = "TaskList.Editor";
949
+ //#endregion
950
+ //#region src/components/TaskList/tree-model.ts
951
+ /** Prefix so a host group's node id cannot collide with a task's, nor with a status group's. */
952
+ var TASK_GROUP_ID_PREFIX = "group:";
953
+ /** The tree node id of a host group, which is also its key in the list's collapsed set. */
954
+ var taskGroupNodeId = (group) => `${TASK_GROUP_ID_PREFIX}${group.id}`;
955
+ /**
956
+ * One collapsible header per non-empty group, its tasks beneath. Nested within a group when
957
+ * `hierarchical`: a sub-task whose parent landed in another group is a root of its own group, since
958
+ * the forest is built from the group's tasks alone.
959
+ */
960
+ var buildTaskGroups = (groups, hierarchical = true) => ({
961
+ id: TASK_TREE_ROOT_ID,
962
+ children: groups.filter((group) => group.tasks.length > 0).map(({ tasks, ...header }) => ({
963
+ id: taskGroupNodeId(header),
964
+ group: {
965
+ ...header,
966
+ count: tasks.length
967
+ },
968
+ children: (hierarchical ? buildTaskForest(tasks) : buildFlatList(tasks)).children
969
+ }))
970
+ });
971
+ /** Synthetic root; the tree renders its children. */
972
+ var TASK_TREE_ROOT_ID = "tasks";
973
+ /**
974
+ * Builds the task forest. Cycle-safe: a malformed parent loop is visited once and then
975
+ * skipped, so a corrupt set renders short rather than hanging.
976
+ */
977
+ var buildTaskForest = (tasks) => {
978
+ const childrenOf = Task.childIndex(tasks);
979
+ const seen = /* @__PURE__ */ new Set();
980
+ const visit = (task) => {
981
+ seen.add(task.id);
982
+ const children = childrenOf(task).filter((child) => !seen.has(child.id));
983
+ return {
984
+ id: task.id,
985
+ task,
986
+ children: children.map(visit)
987
+ };
988
+ };
448
989
  return {
449
- instruction,
450
- rowRef,
451
- dragHandleRef,
452
- dragHandle: enabled
990
+ id: TASK_TREE_ROOT_ID,
991
+ children: Task.rootTasks(tasks).filter((task) => !seen.has(task.id)).map(visit)
453
992
  };
454
993
  };
455
994
  /**
456
- * The rendered rows of `task`'s subtree, in document order: the row itself plus every visible
457
- * descendant. A collapsed branch contributes only its own row, which is what the reader sees.
995
+ * Path to each task, keyed by id and including the synthetic root — what `Tree` addresses rows by.
996
+ * Unambiguous because a task has exactly one parent, so it appears at exactly one path.
458
997
  */
459
- var subtreeRows = (element, tasks, task) => {
460
- const list = element.parentElement;
461
- const ids = subtreeIds(tasks, task);
462
- return list ? Array.from(list.querySelectorAll("[data-task-id]")).filter((row) => ids.has(row.dataset.taskId ?? "")) : [element];
998
+ var buildTaskPaths = (root) => {
999
+ const paths = /* @__PURE__ */ new Map();
1000
+ const visit = (node, path) => {
1001
+ const next = [...path, node.id];
1002
+ if (node.task) paths.set(node.id, next);
1003
+ node.children.forEach((child) => visit(child, next));
1004
+ };
1005
+ visit(root, []);
1006
+ return paths;
1007
+ };
1008
+ /** Every task as a root: the flat list, expressed as a tree of depth one. */
1009
+ var buildFlatList = (tasks) => ({
1010
+ id: TASK_TREE_ROOT_ID,
1011
+ children: tasks.map((task) => ({
1012
+ id: task.id,
1013
+ task,
1014
+ children: []
1015
+ }))
1016
+ });
1017
+ /** Prefix so a group's node id cannot collide with a task's. */
1018
+ var GROUP_ID_PREFIX = "status:";
1019
+ /** Builds the status-grouped forest: one group node per non-empty status, tasks flat within it. */
1020
+ var buildStatusGroups = (tasks, order) => ({
1021
+ id: TASK_TREE_ROOT_ID,
1022
+ children: order.map((status) => ({
1023
+ id: `${GROUP_ID_PREFIX}${status}`,
1024
+ status,
1025
+ children: tasks.filter((task) => (task.status ?? "todo") === status).map((task) => ({
1026
+ id: task.id,
1027
+ task,
1028
+ children: []
1029
+ }))
1030
+ })).filter((group) => group.children.length > 0)
1031
+ });
1032
+ /** The forest a list renders under `options` — shared by the model and the paths into it. */
1033
+ var buildTaskTree = (tasks, { groups, groupByStatus, hierarchical = true } = {}) => groups ? buildTaskGroups(groups, hierarchical) : groupByStatus ? buildStatusGroups(tasks, groupByStatus) : hierarchical ? buildTaskForest(tasks) : buildFlatList(tasks);
1034
+ /** A task's title, a group's label or translated status, or the synthetic root's id. */
1035
+ var groupLabel = (node, translationKey) => {
1036
+ if (node.task) return node.task.title ?? node.id;
1037
+ if (node.group) return node.group.label;
1038
+ if (node.status) return translationKey ? [`status-${node.status}.label`, { ns: translationKey }] : node.status;
1039
+ return node.id;
463
1040
  };
464
1041
  /**
465
- * Clones the subtree into the drag preview. The container is made a grid carrying the list's own
466
- * track sizes: the rows are `grid-cols-subgrid`, so without them a detached copy collapses to its
467
- * content width and the columns no longer line up.
1042
+ * A {@link StaticTreeModel} over the task forest.
1043
+ *
1044
+ * The model is rebuilt whenever the task array changes, so open state is seeded from `collapsed`
1045
+ * rather than held inside it — a collapse kept only in the model would be dropped by the next
1046
+ * rebuild, and tasks are live ECHO objects.
468
1047
  */
469
- var renderSubtreePreview = (container, element, rows) => {
470
- const list = element.parentElement;
471
- const listStyle = list && getComputedStyle(list);
472
- container.style.display = "grid";
473
- container.style.width = `${element.getBoundingClientRect().width}px`;
474
- if (listStyle) {
475
- container.style.gridTemplateColumns = listStyle.gridTemplateColumns;
476
- container.style.columnGap = listStyle.columnGap;
477
- container.style.gridAutoRows = listStyle.gridAutoRows;
478
- container.style.alignItems = listStyle.alignItems;
479
- }
480
- container.classList.add("bg-base-surface");
481
- for (const row of rows) container.appendChild(row.cloneNode(true));
482
- };
1048
+ var createTaskTreeModel = (tasks, { collapsed, groups, groupByStatus, translationKey, hierarchical = true } = {}) => createStaticTreeModel(buildTaskTree(tasks, {
1049
+ groups,
1050
+ groupByStatus,
1051
+ hierarchical
1052
+ }), {
1053
+ getChildren: (node) => node.children,
1054
+ getProps: (node) => ({
1055
+ label: groupLabel(node, translationKey),
1056
+ ...node.status ? { disposition: "group" } : node.group ? { testId: "taskList.group" } : { testId: "taskList.item" }
1057
+ }),
1058
+ isOpen: (node) => !collapsed?.has(node.id)
1059
+ });
483
1060
  /**
484
- * Id of the task currently being dragged. Module-level because the drag source and the row under
485
- * the cursor are different components, and the hitbox reports only the target's own data mid-drag;
486
- * a rejected drop has to be refused while the cursor is over it, not after the drop.
1061
+ * Tasks in the order the tree shows them, a collapsed branch contributing only its own row.
1062
+ *
1063
+ * Derived from the forest the tree renders rather than from a second walk, so the ordinals a reader
1064
+ * counts down cannot drift from the rows they number.
487
1065
  */
488
- var draggingId = { current: void 0 };
489
- /** A description is a line in a row, not a document: no paragraph block, no heading scale. */
490
- var DESCRIPTION_COMPONENTS = { p: ({ children }) => /* @__PURE__ */ jsx("span", { children }) };
491
- var STATUS_ICONS = {
492
- todo: {
493
- icon: "ph--square--regular",
494
- classNames: "text-subdued"
495
- },
496
- started: {
497
- icon: "ph--hourglass--regular",
498
- classNames: "text-info-text"
499
- },
500
- done: {
501
- icon: "ph--check--regular",
502
- classNames: "text-success-text"
503
- },
504
- failed: {
505
- icon: "ph--x--regular",
506
- classNames: "text-error-text"
507
- },
508
- cancelled: {
509
- icon: "ph--x--regular",
510
- classNames: "text-error-text"
511
- }
1066
+ var flattenVisibleTasks = (root, collapsed) => {
1067
+ const tasks = [];
1068
+ const visit = (nodes) => {
1069
+ for (const node of nodes) {
1070
+ if (node.task) tasks.push(node.task);
1071
+ if (!collapsed?.has(node.id)) visit(node.children);
1072
+ }
1073
+ };
1074
+ visit(root.children);
1075
+ return tasks;
512
1076
  };
513
- var TaskListItem = composable(({ task, ordinal, row, ...props }, forwardedRef) => {
514
- const { t } = useTranslation(translationKey);
515
- const { tasks, showDescriptions, showGutter, onTaskUpdate, onTaskDelete, onTaskSelect, onTaskMove, isCollapsed, onCollapseToggle, dragging, onDraggingChange } = useTaskListContext("TaskList.Item");
516
- const { className, ...rest } = composableProps(props);
517
- const { instruction, rowRef, dragHandleRef, dragHandle } = useTaskDrag({
518
- task,
519
- row,
1077
+ //#endregion
1078
+ //#region src/components/TaskList/TaskTreeNode.tsx
1079
+ var TaskTreeNode = ({ groupByStatus, groups, hierarchical, tasks, collapsed, toggle, ordinals, selected, checked, columns, renderItem: Item, translationKey, showDescription = false, descriptionComponents, onCollapseToggle, onTaskCheck, onTaskSelect, onTaskUpdate, onTaskMove }) => {
1080
+ const { flush } = useTaskListContext("TaskList.TreeNode");
1081
+ const { t } = Hooks.useTranslation(translationKey);
1082
+ const registry = useContext(RegistryContext);
1083
+ const collapsedRef = useRef(collapsed);
1084
+ collapsedRef.current = collapsed;
1085
+ const model = useMemo(() => createTaskTreeModel(tasks, {
1086
+ collapsed: collapsedRef.current,
1087
+ groups,
1088
+ groupByStatus,
1089
+ translationKey,
1090
+ hierarchical
1091
+ }), [
520
1092
  tasks,
521
- onTaskMove,
522
- onCollapseToggle,
523
- isCollapsed,
524
- onDraggingChange
525
- });
526
- const open = row ? !isCollapsed(task.id) : void 0;
527
- const [snapshot] = useObject(task);
528
- const current = snapshot ?? task;
529
- const done = current.status === "done";
530
- const error = current.status === "failed";
531
- const description = showDescriptions ? current.description?.trim() || void 0 : void 0;
532
- const blocked = (current.status ?? "todo") === "todo" && !TaskSet.isTaskReady(tasks, task);
533
- const { icon, classNames: iconClassNames } = current.status === "started" && current.assignee?.role === "assistant" ? {
534
- icon: "ph--spinner--regular",
535
- classNames: "text-info-text animate-spin"
536
- } : STATUS_ICONS[current.status ?? "todo"];
537
- const handleToggle = useCallback(() => onTaskUpdate?.(task, { status: done ? "todo" : "done" }), [
538
- onTaskUpdate,
539
- task,
540
- done
1093
+ groups,
1094
+ groupByStatus,
1095
+ translationKey,
1096
+ hierarchical
1097
+ ]);
1098
+ const paths = useMemo(() => buildTaskPaths(buildTaskTree(tasks, {
1099
+ groups,
1100
+ groupByStatus,
1101
+ hierarchical
1102
+ })), [
1103
+ tasks,
1104
+ groups,
1105
+ groupByStatus,
1106
+ hierarchical
1107
+ ]);
1108
+ const previousSelected = useRef(void 0);
1109
+ useEffect(() => {
1110
+ const setCurrent = (id, current) => {
1111
+ const path = id && paths.get(id);
1112
+ if (!path) return;
1113
+ const atom = model.stateAtom(path);
1114
+ registry.set(atom, {
1115
+ ...registry.get(atom),
1116
+ current
1117
+ });
1118
+ };
1119
+ if (previousSelected.current !== selected) {
1120
+ setCurrent(previousSelected.current, false);
1121
+ previousSelected.current = selected;
1122
+ }
1123
+ setCurrent(selected, true);
1124
+ }, [
1125
+ selected,
1126
+ model,
1127
+ paths,
1128
+ registry
1129
+ ]);
1130
+ const handleOpenChange = useCallback(({ item, path, open }) => {
1131
+ const atom = model.stateAtom(path);
1132
+ registry.set(atom, {
1133
+ ...registry.get(atom),
1134
+ open
1135
+ });
1136
+ if (open === collapsedRef.current.has(item.id)) onCollapseToggle(item.id);
1137
+ }, [
1138
+ model,
1139
+ registry,
1140
+ onCollapseToggle
541
1141
  ]);
1142
+ const canSelect = useCallback(({ item }) => !!onTaskSelect && !!item.task, [onTaskSelect]);
1143
+ const handleSelect = useCallback(({ item, meta }) => item.task && onTaskSelect?.(item.task, { meta }), [onTaskSelect]);
542
1144
  const handleKeyDown = useCallback((event) => {
543
- if (!onTaskMove || !row || !event.altKey) return;
1145
+ if (event.key === "Escape" && selected) {
1146
+ event.preventDefault();
1147
+ onTaskSelect?.(void 0);
1148
+ return;
1149
+ }
1150
+ const tab = event.key === "Tab";
1151
+ if (!onTaskMove || !(event.shiftKey || tab)) return;
1152
+ const target = event.target instanceof HTMLElement ? event.target : void 0;
1153
+ const row = target?.closest("[data-object-id]");
1154
+ if (!row || tab && row !== target) return;
1155
+ const id = row.getAttribute("data-object-id");
1156
+ const task = id ? tasks.find((task) => task.id === id) : void 0;
1157
+ if (!task) return;
544
1158
  const placement = (() => {
545
1159
  switch (event.key) {
1160
+ case "Tab": return event.shiftKey ? resolveOutdent(tasks, task) : resolveIndent(tasks, task);
546
1161
  case "ArrowRight": return resolveIndent(tasks, task);
547
1162
  case "ArrowLeft": return resolveOutdent(tasks, task);
548
1163
  case "ArrowUp": return resolveNudge(tasks, task, "up");
@@ -556,185 +1171,1022 @@ var TaskListItem = composable(({ task, ordinal, row, ...props }, forwardedRef) =
556
1171
  onTaskMove(task, placement);
557
1172
  }
558
1173
  }, [
559
- onTaskMove,
560
- row,
561
1174
  tasks,
562
- task
1175
+ selected,
1176
+ onTaskMove,
1177
+ onTaskSelect
563
1178
  ]);
564
- return /* @__PURE__ */ jsxs(Listbox.Item, {
565
- ...rest,
566
- id: task.id,
567
- "data-testid": "taskList.item",
568
- "data-task-id": task.id,
569
- classNames: mx("group/row col-span-full grid grid-cols-subgrid px-0 items-start relative", dragging.has(task.id) && "hidden", className),
570
- "aria-level": row?.level,
571
- "aria-posinset": row && row.position,
572
- "aria-setsize": row && row.setSize,
573
- "aria-expanded": row?.branch ? open : void 0,
574
- style: row ? {
575
- "--task-indent": paddingIndentation(row.level, 8).paddingInlineStart,
576
- "--task-title-inset": `calc(var(--task-indent) + ${TOGGLE_INSET})`
577
- } : void 0,
1179
+ const handleDrop = useCallback(({ instruction, source, item, atEnd }) => {
1180
+ const sourceTask = tasks.find((task) => task.id === source.id);
1181
+ if (!onTaskMove || !sourceTask) return;
1182
+ if (atEnd) {
1183
+ onTaskMove(sourceTask, {
1184
+ parentTask: null,
1185
+ before: void 0
1186
+ });
1187
+ return;
1188
+ }
1189
+ const targetTask = item.task;
1190
+ if (!targetTask || instruction.type === "instruction-blocked") return;
1191
+ const placement = instruction.type === "reparent" ? resolveReparent(tasks, sourceTask, targetTask, instruction.currentLevel - instruction.desiredLevel) : resolveTaskPlacement({
1192
+ tasks,
1193
+ source: sourceTask,
1194
+ target: targetTask,
1195
+ intent: instruction.type
1196
+ });
1197
+ if (placement) onTaskMove(sourceTask, placement);
1198
+ }, [tasks, onTaskMove]);
1199
+ const renderRow = useCallback((node) => {
1200
+ const header = node.item?.group;
1201
+ if (node.group || !node.item) return /* @__PURE__ */ jsx(Tree.Item, { node });
1202
+ if (header) return /* @__PURE__ */ jsxs(Tree.Item, {
1203
+ node,
1204
+ children: [/* @__PURE__ */ jsx(Tree.ItemIndicator, {}), /* @__PURE__ */ jsx(TaskGroupHeading, {
1205
+ group: header,
1206
+ translationKey
1207
+ })]
1208
+ });
1209
+ const task = node.item.task;
1210
+ return /* @__PURE__ */ jsx(Tree.Item, {
1211
+ node,
1212
+ children: /* @__PURE__ */ jsx(Item, {
1213
+ node: node.item,
1214
+ indicator: toggle ? /* @__PURE__ */ jsx(Tree.ItemIndicator, {}) : void 0,
1215
+ ordinal: task && ordinals.get(task.id),
1216
+ checked: !!task && !!checked?.has(task.id),
1217
+ translationKey,
1218
+ showDescription,
1219
+ descriptionComponents,
1220
+ onTaskCheck,
1221
+ onTaskUpdate
1222
+ })
1223
+ });
1224
+ }, [
1225
+ toggle,
1226
+ ordinals,
1227
+ checked,
1228
+ translationKey,
1229
+ showDescription,
1230
+ descriptionComponents,
1231
+ onTaskCheck,
1232
+ onTaskUpdate,
1233
+ Item
1234
+ ]);
1235
+ return /* @__PURE__ */ jsxs(Tree.Root, {
1236
+ id: TASK_TREE_ROOT_ID,
1237
+ model,
1238
+ columns,
1239
+ multiline: true,
1240
+ virtual: "measured",
1241
+ draggable: !!onTaskMove,
1242
+ leavesAcceptChildren: true,
1243
+ dropAtEnd: true,
1244
+ indentGuides: true,
1245
+ canSelect,
1246
+ onOpenChange: handleOpenChange,
1247
+ onSelect: handleSelect,
578
1248
  onKeyDown: handleKeyDown,
579
- ref: useComposedRefs(rowRef, forwardedRef),
1249
+ onDrop: handleDrop,
1250
+ children: [/* @__PURE__ */ jsx(Tree.Label, {
1251
+ srOnly: true,
1252
+ children: t("task-list.label")
1253
+ }), /* @__PURE__ */ jsx(Tree.Content, {
1254
+ rowInset: !flush,
1255
+ children: renderRow
1256
+ })]
1257
+ });
1258
+ };
1259
+ /**
1260
+ * A group's header: its icon, label and how many tasks it holds, spanning the row from after the
1261
+ * disclosure up to the trailing controls. The count is of the group's tasks, sub-tasks included, so
1262
+ * it matches what expanding shows.
1263
+ */
1264
+ var TaskGroupHeading = ({ group, translationKey }) => {
1265
+ const { t } = Hooks.useTranslation(translationKey);
1266
+ return /* @__PURE__ */ jsxs("div", {
1267
+ className: "col-[2/assignee] flex min-w-0 items-center gap-2",
1268
+ "data-testid": "taskList.group.header",
580
1269
  children: [
581
- showGutter && /* @__PURE__ */ jsxs("div", {
582
- className: "relative flex h-8 items-center justify-center",
583
- children: [ordinal !== void 0 && /* @__PURE__ */ jsx(Tag, {
584
- hue: done ? "green" : error ? "rose" : "neutral",
585
- classNames: mx("tabular-nums", dragHandle && "group-hover/row:invisible group-has-[:focus-visible]/row:invisible"),
586
- children: ordinal
587
- }), dragHandle && /* @__PURE__ */ jsx("span", {
588
- "data-testid": "taskList.dragHandle",
589
- "aria-hidden": true,
590
- className: mx("absolute inset-0 grid place-items-center text-subdued cursor-grab active:cursor-grabbing", ordinal !== void 0 && "invisible group-hover/row:visible group-has-[:focus-visible]/row:visible"),
591
- ref: dragHandleRef,
592
- children: /* @__PURE__ */ jsx(Icon, {
593
- icon: "ph--dots-six-vertical--regular",
594
- size: 4
595
- })
596
- })]
1270
+ group.icon && /* @__PURE__ */ jsx(Icon.Icon, {
1271
+ icon: group.icon,
1272
+ size: "md",
1273
+ classNames: group.iconClassNames
597
1274
  }),
598
- onTaskUpdate ? /* @__PURE__ */ jsx(IconButton, {
599
- classNames: mx("justify-self-center my-1", iconClassNames),
1275
+ /* @__PURE__ */ jsx("span", {
1276
+ className: "truncate font-medium",
1277
+ children: Theme.toLocalizedString(group.label, t)
1278
+ }),
1279
+ /* @__PURE__ */ jsx("span", {
1280
+ className: "text-sm text-fg-muted",
1281
+ "data-testid": "taskList.group.count",
1282
+ children: group.count
1283
+ })
1284
+ ]
1285
+ });
1286
+ };
1287
+ //#endregion
1288
+ //#region src/components/TaskList/anchor-hover.ts
1289
+ /** Delay before hover opens the preview — long enough that crossing the anchor en route elsewhere does not fire it. */
1290
+ var HOVER_OPEN_DELAY = 100;
1291
+ /** Grace period after the pointer leaves the anchor/card before the preview closes, so it can travel between them. */
1292
+ var HOVER_CLOSE_DELAY = 300;
1293
+ /**
1294
+ * Hover intent for an element that previews an object in a popover card: opens after a short hover,
1295
+ * closes once the pointer has left both the anchor and the card, and stops closing on leave once the
1296
+ * reader interacts with the card.
1297
+ */
1298
+ var AnchorHover = class {
1299
+ #anchor;
1300
+ #open;
1301
+ #close;
1302
+ #openTimer;
1303
+ #closeTimer;
1304
+ /** True while a card opened by hover (not click) is showing; only then does leaving dismiss it. */
1305
+ #hoverOpen = false;
1306
+ constructor({ anchor, open, close }) {
1307
+ this.#anchor = anchor;
1308
+ this.#open = open;
1309
+ this.#close = close;
1310
+ }
1311
+ enter(pointerType) {
1312
+ if (pointerType === "touch") return;
1313
+ this.#cancelClose();
1314
+ if (this.#hoverOpen || this.#openTimer !== void 0) return;
1315
+ this.#openTimer = setTimeout(() => {
1316
+ this.#openTimer = void 0;
1317
+ this.#openFromHover();
1318
+ }, HOVER_OPEN_DELAY);
1319
+ }
1320
+ leave() {
1321
+ this.#cancelOpen();
1322
+ if (this.#hoverOpen) this.#scheduleClose();
1323
+ }
1324
+ blur() {
1325
+ if (this.#hoverOpen) this.#scheduleClose();
1326
+ }
1327
+ /** Cancels pending timers and stops tracking the pointer; a card already showing stays open. */
1328
+ reset() {
1329
+ this.#cancelOpen();
1330
+ this.#cancelClose();
1331
+ this.#hoverOpen = false;
1332
+ document.removeEventListener("pointerover", this.#handleDocumentPointerOver);
1333
+ document.removeEventListener("pointerdown", this.#handleDocumentPointerDown, { capture: true });
1334
+ }
1335
+ #openFromHover() {
1336
+ this.#hoverOpen = true;
1337
+ document.addEventListener("pointerover", this.#handleDocumentPointerOver);
1338
+ document.addEventListener("pointerdown", this.#handleDocumentPointerDown, { capture: true });
1339
+ this.#open();
1340
+ }
1341
+ #cancelOpen() {
1342
+ if (this.#openTimer !== void 0) {
1343
+ clearTimeout(this.#openTimer);
1344
+ this.#openTimer = void 0;
1345
+ }
1346
+ }
1347
+ #cancelClose() {
1348
+ if (this.#closeTimer !== void 0) {
1349
+ clearTimeout(this.#closeTimer);
1350
+ this.#closeTimer = void 0;
1351
+ }
1352
+ }
1353
+ #scheduleClose() {
1354
+ if (this.#closeTimer === void 0) this.#closeTimer = setTimeout(() => {
1355
+ this.#closeTimer = void 0;
1356
+ this.reset();
1357
+ this.#close();
1358
+ }, HOVER_CLOSE_DELAY);
1359
+ }
1360
+ #handleDocumentPointerOver = (event) => {
1361
+ const target = event.target;
1362
+ if (target instanceof Element && (this.#anchor.contains(target) || !!target.closest(`[${DX_POPOVER_CONTENT_ATTR}]`))) this.#cancelClose();
1363
+ else this.#scheduleClose();
1364
+ };
1365
+ #handleDocumentPointerDown = (event) => {
1366
+ const target = event.target;
1367
+ if (target instanceof Element && target.closest(`[${DX_POPOVER_CONTENT_ATTR}]`)) this.reset();
1368
+ };
1369
+ };
1370
+ //#endregion
1371
+ //#region src/components/TaskList/usePreviewAnchor.ts
1372
+ /**
1373
+ * Wires a chip to the object it names: hovering shows the object's card, leaving closes it, and a
1374
+ * click (or Enter/Space) opens the object itself. Spread the result onto the chip.
1375
+ */
1376
+ var usePreviewAnchor = ({ eid, label, title }) => {
1377
+ const elementRef = useRef(null);
1378
+ const hoverRef = useRef(void 0);
1379
+ const dispatch = useCallback((props) => {
1380
+ const trigger = elementRef.current;
1381
+ if (trigger && eid) trigger.dispatchEvent(new DxAnchorActivate({
1382
+ trigger,
1383
+ eid,
1384
+ label,
1385
+ kind: "card",
1386
+ title,
1387
+ ...props
1388
+ }));
1389
+ }, [
1390
+ eid,
1391
+ label,
1392
+ title
1393
+ ]);
1394
+ const dispatchRef = useRef(dispatch);
1395
+ useEffect(() => {
1396
+ dispatchRef.current = dispatch;
1397
+ }, [dispatch]);
1398
+ const ref = useCallback((element) => {
1399
+ hoverRef.current?.reset();
1400
+ elementRef.current = element;
1401
+ hoverRef.current = element ? new AnchorHover({
1402
+ anchor: element,
1403
+ open: () => dispatchRef.current(),
1404
+ close: () => dispatchRef.current({ state: false })
1405
+ }) : void 0;
1406
+ }, []);
1407
+ const openObject = useCallback(() => {
1408
+ hoverRef.current?.reset();
1409
+ dispatch({ navigate: true });
1410
+ }, [dispatch]);
1411
+ return useMemo(() => ({
1412
+ ref,
1413
+ onPointerEnter: (event) => {
1414
+ if (eid) hoverRef.current?.enter(event.pointerType);
1415
+ },
1416
+ onPointerLeave: () => hoverRef.current?.leave(),
1417
+ onBlur: () => hoverRef.current?.blur(),
1418
+ onClick: (event) => {
1419
+ event.stopPropagation();
1420
+ openObject();
1421
+ },
1422
+ onKeyDown: (event) => {
1423
+ if (event.key === "Enter" || event.key === " ") {
1424
+ event.preventDefault();
1425
+ event.stopPropagation();
1426
+ openObject();
1427
+ }
1428
+ }
1429
+ }), [
1430
+ ref,
1431
+ eid,
1432
+ openObject
1433
+ ]);
1434
+ };
1435
+ //#endregion
1436
+ //#region src/components/TaskList/TaskList.tsx
1437
+ /** Shared empty set, so a list with nothing in flight does not allocate one per render. */
1438
+ var EMPTY_IDS = /* @__PURE__ */ new Set();
1439
+ /** Shared empty map, for a tree rendered without the ordinal gutter. */
1440
+ var EMPTY_ORDINALS = /* @__PURE__ */ new Map();
1441
+ var TaskListRoot = ({ children, tasks, groupByStatus = true, groups, showGroupLabels = true, showOrdinals = false, showDescription = false, descriptionComponents, showEstimates = false, showAssignees = true, showMnemonics = true, flush = false, hierarchical = false, collapsed, selected: selectedProp, selectable: selectableProp, checked = EMPTY_IDS, getTaskActions, onTaskCreate, onTaskUpdate, onTaskSelect, onTaskCheck, onTaskMove, onCollapsedChange }) => {
1442
+ const [selectedState, setSelectedState] = useState(selectedProp);
1443
+ const selected = selectedProp ?? selectedState;
1444
+ const selectable = selectableProp ?? (!!onTaskSelect || selectedProp !== void 0);
1445
+ const handleSelect = useCallback((task, modifiers) => {
1446
+ setSelectedState(task?.id);
1447
+ onTaskSelect?.(task, modifiers);
1448
+ }, [onTaskSelect]);
1449
+ const collapsedControlled = collapsed !== void 0 || !!onCollapsedChange;
1450
+ const [collapsedState, setCollapsedState] = useState(EMPTY_IDS);
1451
+ const collapsedIds = collapsedControlled ? collapsed ?? EMPTY_IDS : collapsedState;
1452
+ const collapsedIdsRef = useRef(collapsedIds);
1453
+ collapsedIdsRef.current = collapsedIds;
1454
+ const isCollapsed = useCallback((id) => collapsedIds.has(id), [collapsedIds]);
1455
+ const onCollapseToggle = useCallback((id) => {
1456
+ const next = new Set(collapsedIdsRef.current);
1457
+ if (next.has(id)) next.delete(id);
1458
+ else next.add(id);
1459
+ collapsedIdsRef.current = next;
1460
+ if (!collapsedControlled) setCollapsedState(next);
1461
+ onCollapsedChange?.(next);
1462
+ }, [collapsedControlled, onCollapsedChange]);
1463
+ const showGutter = showOrdinals || showMnemonics || !!onTaskCheck;
1464
+ const hasActions = !!getTaskActions || !!onTaskCreate || !!onTaskUpdate;
1465
+ const columns = useMemo(() => buildGridTemplate({
1466
+ toggle: hierarchical || !!groups,
1467
+ gutter: (onTaskCheck ? 1 : 0) + (showOrdinals || showMnemonics ? 1 : 0),
1468
+ showEstimates,
1469
+ showAssignees,
1470
+ hasActions
1471
+ }), [
1472
+ hierarchical,
1473
+ groups,
1474
+ showOrdinals,
1475
+ onTaskCheck,
1476
+ showMnemonics,
1477
+ showEstimates,
1478
+ showAssignees,
1479
+ hasActions
1480
+ ]);
1481
+ return /* @__PURE__ */ jsx(TaskListProvider, {
1482
+ tasks,
1483
+ columns,
1484
+ groupByStatus,
1485
+ groups,
1486
+ showGroupLabels,
1487
+ showOrdinals,
1488
+ showDescription,
1489
+ descriptionComponents,
1490
+ showEstimates,
1491
+ hierarchical,
1492
+ showGutter,
1493
+ showAssignees,
1494
+ showMnemonics,
1495
+ flush,
1496
+ isCollapsed,
1497
+ selected,
1498
+ checked,
1499
+ getTaskActions,
1500
+ onCollapseToggle,
1501
+ onTaskCreate,
1502
+ onTaskUpdate,
1503
+ onTaskSelect: selectable ? handleSelect : void 0,
1504
+ onTaskCheck,
1505
+ onTaskMove,
1506
+ children
1507
+ });
1508
+ };
1509
+ TaskListRoot.displayName = "TaskList.Root";
1510
+ var TaskListViewport = Util.composable(({ children, rows: rowsProp, ...props }, forwardedRef) => {
1511
+ const { className, style, ...rest } = Util.composableProps(props);
1512
+ const rows = rowsProp === void 0 ? void 0 : Math.max(Math.floor(rowsProp), 0);
1513
+ return /* @__PURE__ */ jsx("div", {
1514
+ ...rest,
1515
+ className: mx("flex flex-col min-h-0 dx-shrink", className),
1516
+ style: rows === void 0 ? style : {
1517
+ ...style,
1518
+ maxHeight: `calc(${rows} * var(--dx-block-size))`
1519
+ },
1520
+ ref: forwardedRef,
1521
+ children
1522
+ });
1523
+ });
1524
+ TaskListViewport.displayName = "TaskList.Viewport";
1525
+ /** Ordinals stop at 99: the gutter is sized for two digits. */
1526
+ var MAX_ORDINAL = 99;
1527
+ /**
1528
+ * One column template per list, built from its options and shared by the tree's rows and the edit pane (the create row
1529
+ * sits outside the scrolling tree, in a grid of its own), so the pane's icon sits under the rows' status controls and
1530
+ * its field starts where their titles do.
1531
+ *
1532
+ * Every cell is placed by its track's name ({@link TRACK}), so a track exists only when its option is on and an absent
1533
+ * cell leaves its track empty. The first track is the tree's disclosure — at no width in a flat list — and goes unnamed:
1534
+ * the row wraps its columns in `[content-start] …`, and two bracketed names with no track between them invalidate the
1535
+ * template. The disclosure is the one cell not placed by name; it flows into that first track.
1536
+ */
1537
+ var buildGridTemplate = ({ toggle, gutter, showEstimates, showAssignees, hasActions }) => {
1538
+ return [
1539
+ [void 0, toggle ? "var(--dx-half-block-size)" : "0px"],
1540
+ gutter > 0 && ["gutter", `calc(${gutter} * var(--dx-block-size))`],
1541
+ ["status", "var(--dx-block-size)"],
1542
+ ["title", "minmax(0, 1fr)"],
1543
+ ["artifacts", "auto"],
1544
+ ["assignee", showAssignees ? "var(--dx-block-size)" : "0px"],
1545
+ showEstimates && ["estimate", "var(--dx-block-size)"],
1546
+ ["priority", "var(--dx-block-size)"],
1547
+ hasActions && ["actions", "var(--dx-block-size)"]
1548
+ ].filter((track) => track !== false).map(([name, size]) => name ? `[${name}] ${size}` : size).join(" ");
1549
+ };
1550
+ var TaskListContent = (_props) => {
1551
+ const { tasks, groupByStatus, groups, hierarchical, selected, checked, showGroupLabels, showOrdinals, showDescription, descriptionComponents, showGutter, columns, isCollapsed, onCollapseToggle, onTaskCheck, onTaskSelect, onTaskUpdate, onTaskMove } = useTaskListContext("TaskList.Content");
1552
+ const collapsed = useMemo(() => new Set([...tasks.map((task) => task.id), ...(groups ?? []).map((group) => taskGroupNodeId(group))].filter(isCollapsed)), [
1553
+ tasks,
1554
+ groups,
1555
+ isCollapsed
1556
+ ]);
1557
+ const grouping = !groups && !hierarchical && groupByStatus && showGroupLabels ? STATUS_ORDER : void 0;
1558
+ const ordinals = useMemo(() => {
1559
+ const ordered = groups ? flattenVisibleTasks(buildTaskGroups(groups, hierarchical), collapsed) : grouping ? grouping.flatMap((status) => tasks.filter((task) => (task.status ?? "todo") === status)) : hierarchical ? flattenVisibleTasks(buildTaskForest(tasks), collapsed) : tasks;
1560
+ return new Map(ordered.flatMap((task, index) => index < MAX_ORDINAL ? [[task.id, index + 1]] : []));
1561
+ }, [
1562
+ tasks,
1563
+ collapsed,
1564
+ groups,
1565
+ grouping,
1566
+ hierarchical
1567
+ ]);
1568
+ return /* @__PURE__ */ jsx(TaskTreeNode, {
1569
+ descriptionComponents,
1570
+ hierarchical,
1571
+ groupByStatus: grouping,
1572
+ groups,
1573
+ tasks,
1574
+ collapsed,
1575
+ toggle: hierarchical || !!groups,
1576
+ columns,
1577
+ ordinals: showOrdinals ? ordinals : EMPTY_ORDINALS,
1578
+ selected,
1579
+ checked,
1580
+ showDescription,
1581
+ renderItem: TaskListItem,
1582
+ translationKey,
1583
+ onCollapseToggle,
1584
+ onTaskCheck,
1585
+ onTaskSelect,
1586
+ onTaskUpdate,
1587
+ onTaskMove
1588
+ });
1589
+ };
1590
+ TaskListContent.displayName = "TaskList.Content";
1591
+ var TaskListGroupLabel = Util.composable(({ children, ...props }, forwardedRef) => {
1592
+ const { className, ...rest } = Util.composableProps(props);
1593
+ return /* @__PURE__ */ jsx("div", {
1594
+ ...rest,
1595
+ className: mx("col-span-full min-h-(--dx-control) flex items-center text-sm text-fg-muted", className),
1596
+ ref: forwardedRef,
1597
+ children: /* @__PURE__ */ jsx("span", { children })
1598
+ });
1599
+ });
1600
+ TaskListGroupLabel.displayName = "TaskList.GroupLabel";
1601
+ /**
1602
+ * A task's row in the tree, read-only apart from its own controls: each cell placed by its track's name, as the edit
1603
+ * pane's are, so the pane's fields sit where these values are read.
1604
+ */
1605
+ var TaskListItem = ({ node, indicator, ordinal, checked, translationKey: itemTranslationKey, showDescription, descriptionComponents, onTaskCheck, onTaskUpdate }) => {
1606
+ const { t } = Hooks.useTranslation(itemTranslationKey);
1607
+ const { showGutter, showMnemonics, showAssignees, showEstimates } = useTaskListContext("TaskList.Item");
1608
+ const task = node.task;
1609
+ const [snapshot] = useObject(task);
1610
+ const current = snapshot ?? task;
1611
+ if (!task || !current) return null;
1612
+ const description = showDescription ? current.description?.trim() || void 0 : void 0;
1613
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
1614
+ indicator,
1615
+ showGutter && /* @__PURE__ */ jsxs(Layout.Flex, {
1616
+ center: true,
1617
+ gap: "xs",
1618
+ classNames: TRACK.gutter,
1619
+ children: [onTaskCheck && /* @__PURE__ */ jsx(TaskCheckbox, {
1620
+ task,
1621
+ checked,
1622
+ onCheckedChange: onTaskCheck
1623
+ }), (showMnemonics || ordinal !== void 0) && /* @__PURE__ */ jsx(TaskMnemonic, {
1624
+ task,
1625
+ ordinal: onTaskCheck ? void 0 : ordinal
1626
+ })]
1627
+ }),
1628
+ /* @__PURE__ */ jsx(TaskStatusControl, {
1629
+ task,
1630
+ onTaskUpdate,
1631
+ classNames: TRACK.status
1632
+ }),
1633
+ /* @__PURE__ */ jsx("span", {
1634
+ "data-testid": "taskList.item.title",
1635
+ "data-placeholder": t("task-title.placeholder"),
1636
+ className: mx(TRACK.title, "min-w-0 truncate px-(--dx-gap-size) self-center", "empty:before:text-placeholder empty:before:content-[attr(data-placeholder)]"),
1637
+ children: current.title
1638
+ }),
1639
+ /* @__PURE__ */ jsx(Layout.Flex, {
1640
+ align: "center",
1641
+ gap: "xs",
1642
+ classNames: mx(TRACK.artifacts, "ps-1"),
1643
+ "data-testid": "taskList.item.artifacts",
1644
+ children: /* @__PURE__ */ jsx(TaskListItemArtifacts, {
1645
+ task,
1646
+ filter: (artifact) => PullRequest.instanceOf(artifact)
1647
+ })
1648
+ }),
1649
+ showAssignees && current.assignee && /* @__PURE__ */ jsx(Layout.Grid, {
1650
+ center: true,
1651
+ classNames: TRACK.assignee,
1652
+ children: /* @__PURE__ */ jsx(TaskListAssignee, {
1653
+ assignee: current.assignee,
1654
+ iconOnly: true
1655
+ })
1656
+ }),
1657
+ showEstimates && /* @__PURE__ */ jsx(TaskEstimateControl, {
1658
+ task,
1659
+ classNames: TRACK.estimate
1660
+ }),
1661
+ /* @__PURE__ */ jsx(TaskPriorityIcon, {
1662
+ task,
1663
+ classNames: TRACK.priority
1664
+ }),
1665
+ /* @__PURE__ */ jsx(TaskListItemActions, {
1666
+ task,
1667
+ classNames: TRACK.actions
1668
+ }),
1669
+ /* @__PURE__ */ jsx(Layout.Flex, {
1670
+ align: "center",
1671
+ classNames: "col-[title] row-start-2 empty:hidden",
1672
+ "data-testid": "taskList.item.chips",
1673
+ children: /* @__PURE__ */ jsx(TaskListItemTags, {
1674
+ task,
1675
+ tags: Obj.getMeta(task).tags
1676
+ })
1677
+ }),
1678
+ description && /* @__PURE__ */ jsx(TaskDescription, {
1679
+ content: description,
1680
+ components: descriptionComponents,
1681
+ classNames: "col-[title/assignee] row-start-3 min-w-0 px-(--dx-gap-size) mb-1"
1682
+ })
1683
+ ] });
1684
+ };
1685
+ var isMenuAction = (item) => "data" in item && typeof item.data === "function";
1686
+ /**
1687
+ * A row's contributed actions. One is a plain button — a `…` menu hiding a single item costs a click
1688
+ * to discover nothing — and several collapse into the overflow menu, matching the nav tree's rows.
1689
+ */
1690
+ var TaskListItemActions = ({ task, classNames }) => {
1691
+ const { t } = Hooks.useTranslation(translationKey);
1692
+ const { getTaskActions } = useTaskListContext("TaskList.ItemActions");
1693
+ const actions = useMemo(() => getTaskActions?.(task) ?? [], [getTaskActions, task]);
1694
+ if (actions.length === 0) return null;
1695
+ const [only] = actions;
1696
+ if (actions.length === 1 && isMenuAction(only)) return /* @__PURE__ */ jsx(Tree.ItemActions, { children: /* @__PURE__ */ jsx(Layout.Block, {
1697
+ classNames,
1698
+ children: /* @__PURE__ */ jsx(Button.Root, {
1699
+ variant: "ghost",
1700
+ iconOnly: true,
1701
+ icon: only.properties?.icon ?? fallbackIcon,
1702
+ label: Theme.toLocalizedString(only.properties?.label, t),
1703
+ "data-testid": only.properties?.testId,
1704
+ onClick: (event) => {
1705
+ event.stopPropagation();
1706
+ executeMenuAction(only);
1707
+ }
1708
+ })
1709
+ }) });
1710
+ return /* @__PURE__ */ jsx(Tree.ItemActions, { children: /* @__PURE__ */ jsx(Layout.Block, {
1711
+ classNames,
1712
+ children: /* @__PURE__ */ jsx(ActionMenu, {
1713
+ deferUntilOpen: true,
1714
+ actions,
1715
+ children: /* @__PURE__ */ jsx(Button.Root, {
600
1716
  variant: "ghost",
601
- density: "sm",
602
- icon,
603
1717
  iconOnly: true,
604
- label: done ? t("mark-todo.label") : t("mark-done.label"),
605
- onClick: handleToggle
606
- }) : /* @__PURE__ */ jsxs("span", {
607
- className: "grid h-8 place-items-center justify-self-center",
608
- children: [/* @__PURE__ */ jsx(Icon, {
609
- icon,
610
- classNames: iconClassNames,
611
- size: 4
612
- }), /* @__PURE__ */ jsx("span", {
613
- className: "sr-only",
614
- children: t(`status-${current.status ?? "todo"}.label`)
615
- })]
616
- }),
617
- /* @__PURE__ */ jsxs("span", {
618
- className: mx("flex h-8 items-center gap-1 min-w-0", row && "ps-(--task-indent)", onTaskSelect && "cursor-pointer"),
619
- children: [row && /* @__PURE__ */ jsx(TreeItemToggle, {
620
- isBranch: row.branch,
621
- open,
622
- onClick: (event) => {
623
- event.stopPropagation();
624
- onCollapseToggle(task.id);
625
- }
626
- }), /* @__PURE__ */ jsx("span", {
627
- className: "truncate",
628
- children: current.title
629
- })]
1718
+ icon: "ph--dots-three-vertical--regular",
1719
+ label: t("task-actions.label"),
1720
+ "data-testid": "taskList.item.actions",
1721
+ onClick: (event) => event.stopPropagation()
1722
+ })
1723
+ })
1724
+ }) });
1725
+ };
1726
+ TaskListItemActions.displayName = "TaskList.ItemActions";
1727
+ /**
1728
+ * What a task produced, one tag each. Queried rather than read off `ref.target`: on a cold load the
1729
+ * targets are not in memory yet, and a sync read would leave the row permanently empty.
1730
+ */
1731
+ var TaskListItemArtifacts = ({ task, filter }) => {
1732
+ const db = Obj.getDatabase(task);
1733
+ const ids = useMemo(() => (task.artifacts ?? []).flatMap((ref) => {
1734
+ const id = Task.refEntityId(ref);
1735
+ return id ? [id] : [];
1736
+ }), [task.artifacts]);
1737
+ const queried = useQuery(ids.length > 0 ? db : void 0, Filter.id(...ids));
1738
+ const resolved = db ? queried : (task.artifacts ?? []).flatMap((ref) => ref.target ? [ref.target] : []);
1739
+ const artifacts = filter ? resolved.filter(filter) : resolved;
1740
+ return /* @__PURE__ */ jsx(Fragment, { children: artifacts.map((artifact) => /* @__PURE__ */ jsx(ArtifactTag, { artifact }, artifact.id)) });
1741
+ };
1742
+ TaskListItemArtifacts.displayName = "TaskList.ItemArtifacts";
1743
+ /**
1744
+ * Everything a task carries as a chip: its tags, what it produced, and who has it.
1745
+ */
1746
+ var TaskTags = ({ task }) => {
1747
+ return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(TaskListItemTags, {
1748
+ task,
1749
+ tags: Obj.getMeta(task).tags
1750
+ }), /* @__PURE__ */ jsx(TaskListItemArtifacts, {
1751
+ task,
1752
+ filter: (artifact) => PullRequest.instanceOf(artifact)
1753
+ })] });
1754
+ };
1755
+ TaskTags.displayName = "TaskList.Tags";
1756
+ /**
1757
+ * The task's tags, as chips in the same cell as its artifacts and assignee. Queried by id for the
1758
+ * reason artifacts are: a tag's target is not in memory on a cold load.
1759
+ */
1760
+ var TaskListItemTags = ({ task, tags }) => {
1761
+ const db = Obj.getDatabase(task);
1762
+ const ids = useMemo(() => tags.flatMap((ref) => {
1763
+ const id = Task.refEntityId(ref);
1764
+ return id ? [id] : [];
1765
+ }), [tags]);
1766
+ const queried = useQuery(ids.length > 0 ? db : void 0, Filter.id(...ids));
1767
+ const resolved = db ? queried : tags.flatMap((ref) => ref.target ? [ref.target] : []);
1768
+ const labelled = useMemo(() => resolved.filter((object) => Obj.instanceOf(Tag.Tag, object)).sort(Tag.sortTags), [resolved]);
1769
+ return /* @__PURE__ */ jsx(Fragment, { children: labelled.map((tag) => /* @__PURE__ */ jsx(Tag$1.Tag, {
1770
+ hue: toHue(tag.hue),
1771
+ "data-testid": "taskList.item.tag",
1772
+ children: tag.label
1773
+ }, tag.id)) });
1774
+ };
1775
+ TaskListItemTags.displayName = "TaskList.ItemTags";
1776
+ /**
1777
+ * One artifact, as a tag: hovering shows the object's card and clicking opens the object. The tag
1778
+ * takes no tab stop of its own, since inside a listbox option it would split the row into several
1779
+ * arrow-key stops.
1780
+ *
1781
+ * A {@link PullRequest.PullRequest} renders as its `#number` pill — the form a PR link takes in
1782
+ * markdown — so a row reads the same as the text that references it.
1783
+ */
1784
+ var ArtifactTag = ({ artifact }) => {
1785
+ const label = Obj.getLabel(artifact) ?? Obj.getTypename(artifact) ?? "";
1786
+ const anchor = usePreviewAnchor({
1787
+ eid: Obj.getURI(artifact).toString(),
1788
+ label
1789
+ });
1790
+ if (PullRequest.instanceOf(artifact)) return /* @__PURE__ */ jsx(Button.Root, {
1791
+ ...anchor,
1792
+ hue: "neutral",
1793
+ size: "sm",
1794
+ classNames: "bg-input-surface text-fg font-normal ring-inset ring ring-neutral-border hover:bg-hover-surface hover:ring-info-border",
1795
+ icon: "ph--git-pull-request--regular",
1796
+ iconClassNames: pullRequestStateStyle[artifact.state],
1797
+ label: `#${artifact.number}`,
1798
+ tabIndex: -1
1799
+ });
1800
+ return /* @__PURE__ */ jsx(Tag$1.Tag, {
1801
+ ...anchor,
1802
+ hue: "amber",
1803
+ classNames: "cursor-pointer",
1804
+ children: label
1805
+ });
1806
+ };
1807
+ ArtifactTag.displayName = "TaskList.ArtifactTag";
1808
+ /** GitHub's own state colours, so the icon reads as open, merged or closed at a glance. */
1809
+ var pullRequestStateStyle = {
1810
+ open: "text-green-500",
1811
+ merged: "text-violet-500",
1812
+ closed: "text-red-500",
1813
+ draft: "text-fg-muted"
1814
+ };
1815
+ var TaskListAssignee = Util.composable(({ assignee, iconOnly }, _forwardedRef) => {
1816
+ const { label, icon, agent, session: harness } = useAssigneeDisplay(assignee);
1817
+ const [session] = useObject(assignee.subject);
1818
+ const anchor = usePreviewAnchor({
1819
+ eid: session && Obj.getURI(session).toString(),
1820
+ label: label ?? "",
1821
+ title: harness?.title ?? label
1822
+ });
1823
+ if (!label && !agent) return null;
1824
+ const hue = agent ? "purple" : "indigo";
1825
+ if (iconOnly && label) return /* @__PURE__ */ jsx(Button.Root, {
1826
+ iconOnly: true,
1827
+ icon,
1828
+ hue,
1829
+ label,
1830
+ showTooltip: !session,
1831
+ "data-testid": "taskList.item.assignee",
1832
+ ...session && anchor
1833
+ });
1834
+ return /* @__PURE__ */ jsxs(Tag$1.Tag, {
1835
+ hue,
1836
+ "data-testid": "taskList.item.assignee",
1837
+ ...session && {
1838
+ ...anchor,
1839
+ role: "button",
1840
+ tabIndex: 0
1841
+ },
1842
+ classNames: session && "cursor-pointer",
1843
+ children: [agent && /* @__PURE__ */ jsx(Icon.Icon, {
1844
+ icon,
1845
+ classNames: "inline-block size-4 me-1"
1846
+ }), label]
1847
+ });
1848
+ });
1849
+ TaskListAssignee.displayName = "TaskList.Assignee";
1850
+ var TaskList = {
1851
+ Root: TaskListRoot,
1852
+ Viewport: TaskListViewport,
1853
+ Content: TaskListContent,
1854
+ GroupLabel: TaskListGroupLabel,
1855
+ Assignee: TaskListAssignee,
1856
+ Editor: TaskListEditor
1857
+ };
1858
+ //#endregion
1859
+ //#region src/components/TaskProperties/TaskProperties.tsx
1860
+ /** The glyph for an estimate, which the list renders as letters and has none of its own. */
1861
+ var ESTIMATE_ICON = "ph--ruler--regular";
1862
+ /**
1863
+ * A task's own fields as a labelled list: one row each for status, priority and estimate, the glyph
1864
+ * naming the field and the value beside it.
1865
+ *
1866
+ * A list rather than the row of icon buttons the task list uses. A row has one line and many tasks,
1867
+ * so a glyph alone is the only thing that fits and the reader learns the ramp; a detail pane has one
1868
+ * task and the room to say what the glyph means — and an unset field can then invite the value
1869
+ * ("Set estimate") instead of showing a dot that reads as a value of its own.
1870
+ */
1871
+ var TaskProperties = ({ task, members = [], onTaskUpdate, classNames }) => {
1872
+ const { t } = Hooks.useTranslation(translationKey);
1873
+ const status = task.status ?? "todo";
1874
+ const priority = task.priority ?? void 0;
1875
+ const estimate = task.estimate ?? void 0;
1876
+ const assignee = task.assignee ?? void 0;
1877
+ const db = Obj.getDatabase(task);
1878
+ const people = useQuery(onTaskUpdate ? db : void 0, Filter.type(Person.Person));
1879
+ const { label: assigneeLabel, icon: assigneeIcon } = useAssigneeDisplay(assignee);
1880
+ const { createdAt } = Obj.getMeta(task);
1881
+ return /* @__PURE__ */ jsx(Layout.Container, {
1882
+ asChild: true,
1883
+ gutter: "inherit",
1884
+ gap: "sm",
1885
+ classNames,
1886
+ "data-testid": "taskList.properties",
1887
+ children: /* @__PURE__ */ jsxs("section", { children: [
1888
+ /* @__PURE__ */ jsx(Typography.Text, {
1889
+ asChild: true,
1890
+ tone: "subtle",
1891
+ classNames: "dx-label py-0",
1892
+ children: /* @__PURE__ */ jsx("h2", { children: t("task-properties.label") })
630
1893
  }),
631
- current.assignee ? /* @__PURE__ */ jsx("span", {
632
- className: "flex h-8 items-center",
633
- children: /* @__PURE__ */ jsx(TaskListAssignee, { assignee: current.assignee })
634
- }) : /* @__PURE__ */ jsx("div", {}),
635
1894
  /* @__PURE__ */ jsxs("div", {
636
- className: "flex h-8 items-center gap-1",
637
- children: [blocked && /* @__PURE__ */ jsx(Tag, {
638
- hue: "indigo",
639
- children: t("task-blocked.label")
640
- }), current.priority && current.priority !== "none" && /* @__PURE__ */ jsx(Tag, {
641
- hue: "neutral",
642
- children: current.priority
1895
+ className: mx(TASK_GRID, "items-center min-h-(--dx-control-sm) min-w-0"),
1896
+ "data-testid": "taskList.property.reference",
1897
+ children: [/* @__PURE__ */ jsx("div", {
1898
+ className: TASK_GRID_ICON,
1899
+ children: /* @__PURE__ */ jsx(TaskMnemonic, { task })
1900
+ }), /* @__PURE__ */ jsx(Typography.Text, {
1901
+ tone: "muted",
1902
+ classNames: "text-sm",
1903
+ children: Obj.getMnemonic(task)
643
1904
  })]
644
1905
  }),
645
- onTaskDelete && /* @__PURE__ */ jsx(CompactIconButton, {
646
- variant: "ghost",
647
- icon: "ph--x--regular",
648
- label: t("delete-task.label"),
649
- classNames: "invisible group-hover/row:visible group-has-[:focus-visible]/row:visible",
650
- onClick: () => onTaskDelete(task)
1906
+ createdAt !== void 0 && /* @__PURE__ */ jsx(TaskProperty, {
1907
+ icon: "ph--calendar-plus--regular",
1908
+ label: /* @__PURE__ */ jsx(Typography.Timestamp, { date: createdAt }),
1909
+ unset: true,
1910
+ testId: "taskList.property.created"
1911
+ }),
1912
+ /* @__PURE__ */ jsx(TaskProperty, {
1913
+ icon: statusIcon(status),
1914
+ iconClassNames: statusTextStyle(status),
1915
+ label: t(`status-${status}.label`),
1916
+ testId: "taskList.property.status",
1917
+ actions: onTaskUpdate && (() => Task.StatusOptions.map(({ id }) => createMenuAction(`status-${id}`, () => onTaskUpdate(task, { status: id }), {
1918
+ label: t(`status-${id}.label`),
1919
+ icon: statusIcon(id),
1920
+ iconClassNames: statusTextStyle(id),
1921
+ checked: status === id
1922
+ })))
1923
+ }),
1924
+ /* @__PURE__ */ jsx(TaskProperty, {
1925
+ icon: assignee ? assigneeIcon : UNSET_ICON,
1926
+ label: assigneeLabel ?? t("set-assignee.label"),
1927
+ unset: !assignee,
1928
+ testId: "taskList.property.assignee",
1929
+ actions: onTaskUpdate && (() => [
1930
+ createMenuAction("assignee-none", () => onTaskUpdate(task, { assignee: null }), {
1931
+ label: t("assignee-none.label"),
1932
+ icon: "ph--asterisk--regular",
1933
+ checked: !assignee
1934
+ }),
1935
+ ...assignee && !assignee.contact && !members.some((member) => member.did === assignee.identityDid) && assigneeLabel ? [createMenuAction("assignee-current", () => {}, {
1936
+ label: assigneeLabel,
1937
+ icon: assigneeIcon,
1938
+ checked: true,
1939
+ testId: "taskList.assignee.current"
1940
+ })] : [],
1941
+ ...members.map((member) => createMenuAction(`assignee-member-${member.did}`, () => onTaskUpdate(task, { assignee: {
1942
+ identityDid: member.did,
1943
+ name: member.name
1944
+ } }), {
1945
+ label: member.name ?? shortDid(member.did),
1946
+ icon: "ph--user--regular",
1947
+ checked: assignee?.identityDid === member.did,
1948
+ testId: "taskList.assignee.member"
1949
+ })),
1950
+ ...people.map((person) => createMenuAction(`assignee-${person.id}`, () => onTaskUpdate(task, { assignee: { contact: Ref.make(person) } }), {
1951
+ label: Obj.getLabel(person) ?? person.id,
1952
+ icon: "ph--user--regular",
1953
+ checked: Task.refEntityId(assignee?.contact) === person.id
1954
+ }))
1955
+ ])
651
1956
  }),
652
- instruction && /* @__PURE__ */ jsx(TreeDropIndicator, {
653
- instruction,
654
- gap: 0
1957
+ /* @__PURE__ */ jsx(TaskProperty, {
1958
+ icon: priorityIcon(priority),
1959
+ iconClassNames: priorityTextStyle(priority),
1960
+ label: priority ? t(`priority-${priority}.label`) : t("set-priority.label"),
1961
+ unset: !priority,
1962
+ testId: "taskList.property.priority",
1963
+ actions: onTaskUpdate && (() => [Task.NullOption, ...Task.PriorityOptions].map(({ id, icon }) => createMenuAction(`priority-${id}`, () => onTaskUpdate(task, { priority: id === "none" ? null : id }), {
1964
+ label: t(`priority-${id}.label`),
1965
+ icon: icon ?? "ph--asterisk--regular",
1966
+ iconClassNames: priorityTextStyle(id),
1967
+ checked: (priority ?? "none") === id
1968
+ })))
655
1969
  }),
656
- description && /* @__PURE__ */ jsx(MarkdownView, {
657
- content: description,
658
- classNames: mx(showGutter ? "col-start-3" : "col-start-2", row && "ps-(--task-title-inset)", "col-span-3 pb-1 text-sm text-description line-clamp-3"),
659
- components: DESCRIPTION_COMPONENTS
1970
+ /* @__PURE__ */ jsx(TaskProperty, {
1971
+ icon: estimate ? ESTIMATE_ICON : UNSET_ICON,
1972
+ iconClassNames: estimateTextStyle(estimate),
1973
+ label: estimate ? estimate.toUpperCase() : t("set-estimate.label"),
1974
+ unset: !estimate,
1975
+ testId: "taskList.property.estimate",
1976
+ actions: onTaskUpdate && (() => [Task.NullOption, ...Task.EstimateOptions].map(({ id, title }) => createMenuAction(`estimate-${id}`, () => onTaskUpdate(task, { estimate: id === "none" ? null : id }), {
1977
+ label: title,
1978
+ checked: (estimate ?? "none") === id
1979
+ })))
660
1980
  })
661
- ]
1981
+ ] })
662
1982
  });
663
- });
664
- TaskListItem.displayName = "TaskList.Item";
665
- var CompactIconButton = (props) => {
666
- return /* @__PURE__ */ jsx("span", {
667
- className: "grid size-8 shrink-0 place-items-center",
668
- children: /* @__PURE__ */ jsx(IconButton, {
1983
+ };
1984
+ TaskProperties.displayName = "TaskList.Properties";
1985
+ var TaskProperty = ({ icon, iconClassNames, label, unset, testId, actions }) => {
1986
+ const content = /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("div", {
1987
+ className: TASK_GRID_ICON,
1988
+ children: /* @__PURE__ */ jsx(Icon.Icon, {
1989
+ icon,
1990
+ classNames: mx(unset ? "text-fg-muted" : iconClassNames)
1991
+ })
1992
+ }), /* @__PURE__ */ jsx("span", {
1993
+ className: mx("min-w-0 pe-1.5 text-sm truncate", unset && "text-fg-muted"),
1994
+ children: label
1995
+ })] });
1996
+ if (!actions) return /* @__PURE__ */ jsx("div", {
1997
+ className: mx(TASK_GRID, "items-center min-h-(--dx-control-sm) min-w-0"),
1998
+ "data-testid": testId,
1999
+ children: content
2000
+ });
2001
+ return /* @__PURE__ */ jsx(ActionMenu, {
2002
+ deferUntilOpen: true,
2003
+ actions,
2004
+ children: /* @__PURE__ */ jsx(Button.Root, {
669
2005
  variant: "ghost",
670
- iconOnly: true,
671
- density: "sm",
672
- ...props
2006
+ size: "sm",
2007
+ classNames: mx(TASK_GRID, "items-center w-fit min-w-0 justify-start px-0"),
2008
+ "data-testid": testId,
2009
+ children: content
673
2010
  })
674
2011
  });
675
2012
  };
676
- var TaskListCreate = composable(({ placeholder = "Add task", ...props }, forwardedRef) => {
677
- const { onTaskCreate, showGutter } = useTaskListContext("TaskList.Create");
678
- const { className, ...rest } = composableProps(props);
679
- const [title, setTitle] = useState("");
2013
+ //#endregion
2014
+ //#region src/components/TaskQuestion/TaskQuestion.tsx
2015
+ /**
2016
+ * Stops an event at the question: it sits inside a listbox row, whose click selects the task and
2017
+ * whose arrow keys move the selection, and typing an answer must do neither.
2018
+ */
2019
+ var stop = (event) => event.stopPropagation();
2020
+ /**
2021
+ * A question from a task's history: why it was asked, and the means to answer it. Answered, it
2022
+ * collapses to the question and the answer, since the exchange is then a record rather than a
2023
+ * prompt.
2024
+ *
2025
+ * The free-form field is always present, never a fallback revealed by a "something else" option:
2026
+ * the options are the asker's guesses, and making the reader hunt for the escape hatch pressures
2027
+ * them into picking a wrong one.
2028
+ */
2029
+ var TaskQuestion = ({ classNames, thread: { question, answer }, busy, message, compact, onAnswer }) => {
2030
+ const { t } = Hooks.useTranslation(translationKey);
2031
+ const [text, setText] = useState("");
2032
+ const handleSubmit = useCallback((value) => {
2033
+ if (!busy && value.trim().length > 0) onAnswer?.(value);
2034
+ }, [busy, onAnswer]);
680
2035
  const handleKeyDown = useCallback((event) => {
681
- const trimmed = title.trim();
682
- if (event.key === "Enter" && trimmed.length > 0) {
683
- onTaskCreate?.(trimmed);
684
- setTitle("");
2036
+ event.stopPropagation();
2037
+ if (event.key === "Enter") {
2038
+ event.preventDefault();
2039
+ handleSubmit(text);
685
2040
  }
686
- }, [title, onTaskCreate]);
687
- if (!onTaskCreate) return null;
2041
+ }, [handleSubmit, text]);
2042
+ if (compact) return /* @__PURE__ */ jsxs("div", {
2043
+ role: "group",
2044
+ "aria-label": question.text,
2045
+ className: mx("flex flex-col w-full gap-1 text-sm", classNames),
2046
+ "data-testid": "task-question",
2047
+ children: [/* @__PURE__ */ jsxs("div", {
2048
+ className: "flex items-center gap-2 min-w-0",
2049
+ children: [/* @__PURE__ */ jsx(Icon.Icon, {
2050
+ icon: "ph--question--regular",
2051
+ valence: "warning"
2052
+ }), /* @__PURE__ */ jsx("span", {
2053
+ className: "font-medium truncate",
2054
+ title: question.text,
2055
+ children: question.text
2056
+ })]
2057
+ }), answer && /* @__PURE__ */ jsxs("div", {
2058
+ className: "flex items-center gap-2 min-w-0",
2059
+ "data-testid": "task-question.answer",
2060
+ children: [/* @__PURE__ */ jsx(Icon.Icon, {
2061
+ icon: "ph--check-circle--regular",
2062
+ valence: "success"
2063
+ }), /* @__PURE__ */ jsx("span", {
2064
+ className: "truncate",
2065
+ title: answer.answer,
2066
+ children: answer.answer
2067
+ })]
2068
+ })]
2069
+ });
688
2070
  return /* @__PURE__ */ jsxs("div", {
689
- ...rest,
690
- "data-testid": "taskList.create",
691
- className: mx("grid gap-x-1 items-center w-full min-w-0 h-8 shrink-0", showGutter ? GRID_COLS.createWithOrdinals : GRID_COLS.create, className),
692
- ref: forwardedRef,
2071
+ role: "group",
2072
+ "aria-label": question.text,
2073
+ className: mx(TASK_GRID, "gap-y-2 text-sm", classNames),
2074
+ "data-testid": "task-question",
2075
+ onClick: stop,
2076
+ onPointerDown: stop,
2077
+ onKeyDown: stop,
693
2078
  children: [
694
- showGutter && /* @__PURE__ */ jsx("span", {}),
695
- /* @__PURE__ */ jsx(Icon, {
696
- icon: "ph--plus--regular",
697
- size: 4,
698
- classNames: "justify-self-center text-subdued"
2079
+ /* @__PURE__ */ jsx("div", {
2080
+ className: TASK_GRID_ICON,
2081
+ children: /* @__PURE__ */ jsx(Icon.Icon, {
2082
+ icon: "ph--question--regular",
2083
+ valence: "warning"
2084
+ })
2085
+ }),
2086
+ /* @__PURE__ */ jsx("span", {
2087
+ className: "font-medium wrap-break-word min-w-0",
2088
+ children: question.text
2089
+ }),
2090
+ question.context && !answer && /* @__PURE__ */ jsx("p", {
2091
+ className: mx("col-start-2", "text-fg-muted wrap-break-word line-clamp-3 min-w-0"),
2092
+ children: question.context
2093
+ }),
2094
+ answer ? /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("div", {
2095
+ className: TASK_GRID_ICON,
2096
+ children: /* @__PURE__ */ jsx(Icon.Icon, {
2097
+ icon: "ph--check-circle--regular",
2098
+ valence: "success"
2099
+ })
2100
+ }), /* @__PURE__ */ jsx("span", {
2101
+ className: "wrap-break-word min-w-0",
2102
+ "data-testid": "task-question.answer",
2103
+ children: answer.answer
2104
+ })] }) : onAnswer && /* @__PURE__ */ jsxs("div", {
2105
+ className: mx("col-start-2", "flex flex-col gap-2 min-w-0"),
2106
+ children: [question.options && question.options.length > 0 && /* @__PURE__ */ jsx("div", {
2107
+ role: "list",
2108
+ "aria-label": question.text,
2109
+ className: "flex flex-col gap-1 min-w-0",
2110
+ children: question.options.map((option, index) => /* @__PURE__ */ jsx("div", {
2111
+ role: "listitem",
2112
+ className: "min-w-0",
2113
+ children: /* @__PURE__ */ jsxs(Button.Root, {
2114
+ variant: "default",
2115
+ disabled: busy,
2116
+ classNames: "w-full min-w-0 h-auto py-1.5 justify-start text-start whitespace-normal",
2117
+ "data-testid": "task-question.option",
2118
+ onClick: () => handleSubmit(option.title),
2119
+ children: [/* @__PURE__ */ jsxs("div", {
2120
+ className: "shrink-0 tabular-nums text-fg-muted self-start",
2121
+ children: [index + 1, "."]
2122
+ }), /* @__PURE__ */ jsxs("div", {
2123
+ className: "grow min-w-0 flex flex-col gap-0.5 text-start",
2124
+ children: [/* @__PURE__ */ jsx("div", {
2125
+ className: "font-medium wrap-break-word",
2126
+ children: option.title
2127
+ }), option.description && /* @__PURE__ */ jsx("div", {
2128
+ className: "text-xs text-fg-muted wrap-break-word leading-snug",
2129
+ children: option.description
2130
+ })]
2131
+ })]
2132
+ })
2133
+ }, option.title))
2134
+ }), /* @__PURE__ */ jsxs("div", {
2135
+ className: "flex flex-wrap gap-1",
2136
+ children: [/* @__PURE__ */ jsx("div", {
2137
+ className: "flex-[1_1_10rem] min-w-0",
2138
+ children: /* @__PURE__ */ jsxs(Field.Root, { children: [/* @__PURE__ */ jsx(Field.Label, {
2139
+ srOnly: true,
2140
+ children: t("question-answer.label")
2141
+ }), /* @__PURE__ */ jsx(Input.Root, {
2142
+ value: text,
2143
+ disabled: busy,
2144
+ placeholder: t("question-answer.placeholder"),
2145
+ "data-testid": "task-question.input",
2146
+ onChange: (event) => setText(event.target.value),
2147
+ onKeyDown: handleKeyDown
2148
+ })] })
2149
+ }), /* @__PURE__ */ jsx(Button.Root, {
2150
+ variant: "primary",
2151
+ disabled: busy || text.trim() === "",
2152
+ "data-testid": "task-question.submit",
2153
+ onClick: () => handleSubmit(text),
2154
+ children: t("question-submit.label")
2155
+ })]
2156
+ })]
699
2157
  }),
700
- /* @__PURE__ */ jsx(Input.Root, { children: /* @__PURE__ */ jsx(Input.TextInput, {
701
- variant: "subdued",
702
- classNames: "px-0",
703
- placeholder,
704
- value: title,
705
- onChange: (event) => setTitle(event.target.value),
706
- onKeyDown: handleKeyDown
707
- }) })
2158
+ message && /* @__PURE__ */ jsx("p", {
2159
+ className: mx("col-start-2", "text-fg-muted min-w-0"),
2160
+ "data-testid": "task-question.message",
2161
+ children: message
2162
+ })
708
2163
  ]
709
2164
  });
710
- });
711
- TaskListCreate.displayName = "TaskList.Create";
712
- var TaskListAssignee = composable(({ assignee }, _forwardedRef) => {
713
- const [contact] = useObject(assignee.contact);
714
- const label = contact?.fullName ?? assignee.name ?? assignee.email ?? (assignee.identityDid ? shortDid(assignee.identityDid) : void 0);
715
- const agent = assignee.role === "assistant";
716
- if (!label && !agent) return null;
717
- return /* @__PURE__ */ jsxs(Tag, {
718
- hue: agent ? "purple" : "indigo",
719
- children: [agent && /* @__PURE__ */ jsx(Icon, {
720
- icon: "ph--sparkle--regular",
721
- size: 3,
722
- classNames: "inline-block me-1"
723
- }), label ?? "agent"]
724
- });
725
- });
726
- TaskListAssignee.displayName = "TaskList.Assignee";
727
- var shortDid = (did) => `${did.slice(0, 12)}…`;
728
- var TaskList = {
729
- Root: TaskListRoot,
730
- Viewport: TaskListViewport,
731
- Content: TaskListContent,
732
- GroupLabel: TaskListGroupLabel,
733
- Item: TaskListItem,
734
- Create: TaskListCreate,
735
- Assignee: TaskListAssignee
2165
+ };
2166
+ TaskQuestion.displayName = "TaskQuestion";
2167
+ //#endregion
2168
+ //#region src/util/format-date.ts
2169
+ /** Beyond this, "x days ago" stops locating an event and the calendar date does it better. */
2170
+ var RELATIVE_DAYS = 3;
2171
+ /**
2172
+ * A timestamp as a reader scans it: recent activity as a distance ("2 hours ago"), older activity as
2173
+ * a local calendar date.
2174
+ *
2175
+ * The date is formatted in the reader's own locale and zone (`toLocaleDateString`), since it is read
2176
+ * as "when did this happen to me" rather than as a record to cite; an unparseable value is returned
2177
+ * as it came, because a log line is decoration and never the source of truth.
2178
+ */
2179
+ var formatRelative = (date, { relativeDays = 3, now } = {}) => {
2180
+ const parsed = typeof date === "string" ? new Date(date) : date;
2181
+ if (Number.isNaN(parsed.getTime())) return typeof date === "string" ? date : "";
2182
+ const reference = now ?? /* @__PURE__ */ new Date();
2183
+ return Math.abs(differenceInCalendarDays(reference, parsed)) > relativeDays ? parsed.toLocaleDateString(void 0, {
2184
+ year: "numeric",
2185
+ month: "short",
2186
+ day: "numeric"
2187
+ }) : formatDistance(parsed, reference, { addSuffix: true });
736
2188
  };
737
2189
  //#endregion
738
- export { STATUS_ORDER, TaskList, resolveIndent, resolveNudge, resolveOutdent, resolveTaskPlacement, subtreeIds, walkTaskTree };
2190
+ export { RELATIVE_DAYS, TASK_GRID, TASK_GRID_CONTENT, TASK_GRID_ICON, TaskHistory, TaskList, TaskProperties, TaskQuestion, TaskTags, buildTaskForest, flattenVisibleTasks, formatRelative, resolveIndent, resolveNudge, resolveOutdent, resolveReparent, resolveTaskPlacement, statusIcon, statusTextStyle, subtreeIds, taskGroupNodeId };
739
2191
 
740
2192
  //# sourceMappingURL=index.mjs.map