@webappwiz/arbor 0.0.28 → 0.0.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +7 -0
- package/README.md +34 -12
- package/add.test.ts +125 -0
- package/add.ts +192 -0
- package/age.ts +11 -0
- package/arbor.test.ts +145 -0
- package/arbor.ts +429 -0
- package/assets.d.ts +9 -0
- package/attachments.ts +87 -0
- package/build.ts +98 -0
- package/claim.test.ts +89 -0
- package/claim.ts +73 -0
- package/components.json +21 -0
- package/config.ts +58 -0
- package/dev/api.ts +120 -0
- package/dev/app.test.tsx +645 -0
- package/dev/app.tsx +80 -0
- package/dev/assets.ts +16 -0
- package/dev/build/main.txt +68 -0
- package/dev/build/shell.txt +21 -0
- package/dev/build/styles.txt +2263 -0
- package/dev/components/ui/badge.tsx +51 -0
- package/dev/components/ui/button.tsx +57 -0
- package/dev/components/ui/dialog.tsx +154 -0
- package/dev/components/ui/empty.tsx +100 -0
- package/dev/components/ui/input-group.tsx +155 -0
- package/dev/components/ui/input.tsx +19 -0
- package/dev/components/ui/progress.tsx +80 -0
- package/dev/components/ui/textarea.tsx +17 -0
- package/dev/components/ui/toast.tsx +227 -0
- package/dev/feed.ts +69 -0
- package/dev/files.tsx +191 -0
- package/dev/index.html +21 -0
- package/dev/lib/utils.ts +7 -0
- package/dev/main.tsx +22 -0
- package/dev/markdown.test.tsx +133 -0
- package/dev/markdown.tsx +124 -0
- package/dev/mentions.tsx +319 -0
- package/dev/shadcn.css +649 -0
- package/dev/styles.css +107 -0
- package/dev/tags.tsx +44 -0
- package/dev/tasks.tsx +151 -0
- package/dev/todos.tsx +759 -0
- package/dev.test.ts +439 -0
- package/dev.ts +386 -0
- package/e2e.test.ts +256 -0
- package/escalate.test.ts +76 -0
- package/escalate.ts +112 -0
- package/exit.test.ts +71 -0
- package/exit.ts +74 -0
- package/git.ts +294 -0
- package/index.ts +9 -0
- package/journal.ts +89 -0
- package/list.test.ts +82 -0
- package/list.ts +137 -0
- package/load-config.test.ts +121 -0
- package/load-config.ts +62 -0
- package/log.test.ts +65 -0
- package/log.ts +40 -0
- package/merge.test.ts +332 -0
- package/merge.ts +283 -0
- package/package.json +32 -15
- package/path.test.ts +35 -0
- package/path.ts +38 -0
- package/plan.test.ts +318 -0
- package/plan.ts +302 -0
- package/progress.test.ts +54 -0
- package/progress.ts +31 -0
- package/remove.test.ts +68 -0
- package/remove.ts +88 -0
- package/repository.ts +83 -0
- package/retry.test.ts +43 -0
- package/retry.ts +48 -0
- package/shell.ts +44 -0
- package/show.test.ts +91 -0
- package/show.ts +151 -0
- package/snapshot.test.ts +33 -0
- package/snapshot.ts +84 -0
- package/table.ts +20 -0
- package/testing.ts +229 -0
- package/todo.test.ts +490 -0
- package/todo.ts +834 -0
- package/wait.test.ts +61 -0
- package/wait.ts +77 -0
- package/worktree-service.test.ts +158 -0
- package/worktree-service.ts +193 -0
- package/worktree.ts +239 -0
- package/add.d.ts +0 -23
- package/age.d.ts +0 -2
- package/arbor.d.ts +0 -11
- package/attachments.d.ts +0 -36
- package/claim.d.ts +0 -11
- package/config.d.ts +0 -55
- package/config.js +0 -7
- package/dev/assets.d.ts +0 -11
- package/dev.d.ts +0 -47
- package/escalate.d.ts +0 -28
- package/exit.d.ts +0 -42
- package/git.d.ts +0 -77
- package/index.d.ts +0 -2
- package/index.js +0 -4669
- package/journal.d.ts +0 -29
- package/list.d.ts +0 -18
- package/load-config.d.ts +0 -15
- package/log.d.ts +0 -14
- package/merge.d.ts +0 -24
- package/path.d.ts +0 -13
- package/plan.d.ts +0 -83
- package/remove.d.ts +0 -19
- package/repository.d.ts +0 -25
- package/retry.d.ts +0 -17
- package/shell.d.ts +0 -17
- package/show.d.ts +0 -53
- package/snapshot.d.ts +0 -31
- package/table.d.ts +0 -5
- package/todo.d.ts +0 -230
- package/wait.d.ts +0 -22
- package/worktree-service.d.ts +0 -51
- package/worktree.d.ts +0 -95
package/dev/todos.tsx
ADDED
|
@@ -0,0 +1,759 @@
|
|
|
1
|
+
import {
|
|
2
|
+
closestCenter,
|
|
3
|
+
DndContext,
|
|
4
|
+
type DragEndEvent,
|
|
5
|
+
DragOverlay,
|
|
6
|
+
KeyboardSensor,
|
|
7
|
+
MouseSensor,
|
|
8
|
+
TouchSensor,
|
|
9
|
+
useSensor,
|
|
10
|
+
useSensors,
|
|
11
|
+
} from "@dnd-kit/core";
|
|
12
|
+
import {
|
|
13
|
+
restrictToParentElement,
|
|
14
|
+
restrictToVerticalAxis,
|
|
15
|
+
} from "@dnd-kit/modifiers";
|
|
16
|
+
import {
|
|
17
|
+
arrayMove,
|
|
18
|
+
SortableContext,
|
|
19
|
+
sortableKeyboardCoordinates,
|
|
20
|
+
useSortable,
|
|
21
|
+
verticalListSortingStrategy,
|
|
22
|
+
} from "@dnd-kit/sortable";
|
|
23
|
+
import { CSS } from "@dnd-kit/utilities";
|
|
24
|
+
import {
|
|
25
|
+
ClockIcon,
|
|
26
|
+
GitBranchIcon,
|
|
27
|
+
GripVerticalIcon,
|
|
28
|
+
LinkIcon,
|
|
29
|
+
ListTodoIcon,
|
|
30
|
+
type LucideIcon,
|
|
31
|
+
PaperclipIcon,
|
|
32
|
+
TagIcon,
|
|
33
|
+
Trash2Icon,
|
|
34
|
+
} from "lucide-react";
|
|
35
|
+
import {
|
|
36
|
+
createContext,
|
|
37
|
+
type FormEvent,
|
|
38
|
+
type HTMLAttributes,
|
|
39
|
+
type JSX,
|
|
40
|
+
type KeyboardEventHandler,
|
|
41
|
+
type MouseEventHandler,
|
|
42
|
+
type ReactNode,
|
|
43
|
+
type TouchEventHandler,
|
|
44
|
+
useContext,
|
|
45
|
+
useEffect,
|
|
46
|
+
useId,
|
|
47
|
+
useState,
|
|
48
|
+
} from "react";
|
|
49
|
+
import { Button } from "#dev/components/ui/button.tsx";
|
|
50
|
+
import {
|
|
51
|
+
Dialog,
|
|
52
|
+
DialogContent,
|
|
53
|
+
DialogDescription,
|
|
54
|
+
DialogFooter,
|
|
55
|
+
DialogHeader,
|
|
56
|
+
DialogTitle,
|
|
57
|
+
} from "#dev/components/ui/dialog.tsx";
|
|
58
|
+
import {
|
|
59
|
+
Empty,
|
|
60
|
+
EmptyDescription,
|
|
61
|
+
EmptyHeader,
|
|
62
|
+
EmptyMedia,
|
|
63
|
+
EmptyTitle,
|
|
64
|
+
} from "#dev/components/ui/empty.tsx";
|
|
65
|
+
import {
|
|
66
|
+
InputGroup,
|
|
67
|
+
InputGroupAddon,
|
|
68
|
+
InputGroupButton,
|
|
69
|
+
InputGroupInput,
|
|
70
|
+
InputGroupTextarea,
|
|
71
|
+
} from "#dev/components/ui/input-group.tsx";
|
|
72
|
+
import { toast } from "#dev/components/ui/toast.tsx";
|
|
73
|
+
import { cn } from "#dev/lib/utils.ts";
|
|
74
|
+
import { age } from "../age";
|
|
75
|
+
import type { Snapshot } from "../snapshot";
|
|
76
|
+
import type { TodoState } from "../todo";
|
|
77
|
+
import { addTodo, moveTodo, removeTodo, updateTodo } from "./api";
|
|
78
|
+
import { AttachButton, FileList, useFiles } from "./files";
|
|
79
|
+
import { Markdown } from "./markdown";
|
|
80
|
+
import { MentionAnchor, useMentions } from "./mentions";
|
|
81
|
+
import { TagFilter } from "./tags";
|
|
82
|
+
import { Task } from "./tasks";
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Work deferred for later, top of the list first, and a line to add to it.
|
|
86
|
+
* Picking one up takes an agent (`arbor add <task> --todo <id>`), and `merge`
|
|
87
|
+
* recommends the highest open one, so the list is the priority: drag a card
|
|
88
|
+
* to reorder it, or tap one to reword or remove it.
|
|
89
|
+
*/
|
|
90
|
+
export function Todos({ snapshot }: { snapshot: Snapshot }): JSX.Element {
|
|
91
|
+
const { todoStalenessMs, tasks } = snapshot;
|
|
92
|
+
const [picked, setPicked] = useState<string | null>(null);
|
|
93
|
+
const tags = [...new Set(snapshot.todos.flatMap((todo) => todo.tags))].sort();
|
|
94
|
+
// A tag whose last todo went has nothing left to show, and lets go.
|
|
95
|
+
const tag = picked !== null && tags.includes(picked) ? picked : null;
|
|
96
|
+
const todos = snapshot.todos.filter(
|
|
97
|
+
(todo) => tag === null || todo.tags.includes(tag),
|
|
98
|
+
);
|
|
99
|
+
const [opened, setOpened] = useState<number | null>(null);
|
|
100
|
+
const current = todos.find((todo) => todo.id === opened);
|
|
101
|
+
const [openedTask, setOpenedTask] = useState<string | null>(null);
|
|
102
|
+
const task = tasks.find((found) => found.task === openedTask);
|
|
103
|
+
// Only a task the page knows of opens: one merged a moment ago has no
|
|
104
|
+
// details left to show.
|
|
105
|
+
const openTask = (name: string) =>
|
|
106
|
+
tasks.some((found) => found.task === name) ? setOpenedTask(name) : null;
|
|
107
|
+
return (
|
|
108
|
+
<OpenTask value={openTask}>
|
|
109
|
+
<FilterTag value={setPicked}>
|
|
110
|
+
<div className="flex flex-col gap-6">
|
|
111
|
+
<Add />
|
|
112
|
+
{tags.length > 0 && (
|
|
113
|
+
<TagFilter tags={tags} selected={tag} onSelect={setPicked} />
|
|
114
|
+
)}
|
|
115
|
+
{todos.length === 0 ? (
|
|
116
|
+
<Empty>
|
|
117
|
+
<EmptyHeader>
|
|
118
|
+
<EmptyMedia variant="icon">
|
|
119
|
+
<ListTodoIcon />
|
|
120
|
+
</EmptyMedia>
|
|
121
|
+
<EmptyTitle>Nothing deferred</EmptyTitle>
|
|
122
|
+
<EmptyDescription>
|
|
123
|
+
Agents add what comes up outside their task here.
|
|
124
|
+
</EmptyDescription>
|
|
125
|
+
</EmptyHeader>
|
|
126
|
+
</Empty>
|
|
127
|
+
) : (
|
|
128
|
+
<Board
|
|
129
|
+
todos={todos}
|
|
130
|
+
staleness={todoStalenessMs}
|
|
131
|
+
onOpen={setOpened}
|
|
132
|
+
/>
|
|
133
|
+
)}
|
|
134
|
+
<Dialog
|
|
135
|
+
open={current !== undefined}
|
|
136
|
+
onOpenChange={(open) => {
|
|
137
|
+
if (!open) {
|
|
138
|
+
setOpened(null);
|
|
139
|
+
}
|
|
140
|
+
}}
|
|
141
|
+
>
|
|
142
|
+
{current && <Edit todo={current} onDone={() => setOpened(null)} />}
|
|
143
|
+
</Dialog>
|
|
144
|
+
<Dialog
|
|
145
|
+
open={task !== undefined}
|
|
146
|
+
onOpenChange={(open) => {
|
|
147
|
+
if (!open) {
|
|
148
|
+
setOpenedTask(null);
|
|
149
|
+
}
|
|
150
|
+
}}
|
|
151
|
+
>
|
|
152
|
+
{task && <Task task={task} />}
|
|
153
|
+
</Dialog>
|
|
154
|
+
</div>
|
|
155
|
+
</FilterTag>
|
|
156
|
+
</OpenTask>
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Shows only one tag's todos, from a tag on any card. */
|
|
161
|
+
const FilterTag = createContext<(tag: string) => void>(() => {});
|
|
162
|
+
|
|
163
|
+
/** Opens the task a card names, from wherever the card is drawn. */
|
|
164
|
+
const OpenTask = createContext<(task: string) => void>(() => {});
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The todos as cards in a column, each dragged to where it belongs. A mouse
|
|
168
|
+
* drags once it moves a few pixels and a finger after a short press, so a
|
|
169
|
+
* tap still opens the card and a swipe still scrolls; a keyboard picks one
|
|
170
|
+
* up by its grip with Space and moves it with the arrows.
|
|
171
|
+
*/
|
|
172
|
+
function Board({
|
|
173
|
+
todos,
|
|
174
|
+
staleness,
|
|
175
|
+
onOpen,
|
|
176
|
+
}: {
|
|
177
|
+
todos: TodoState[];
|
|
178
|
+
staleness: number;
|
|
179
|
+
onOpen: (id: number) => void;
|
|
180
|
+
}): JSX.Element {
|
|
181
|
+
// The order just dropped, shown until the server's catches up, so a card
|
|
182
|
+
// lands where it was let go instead of jumping back for a poll.
|
|
183
|
+
const [dropped, setDropped] = useState<number[] | null>(null);
|
|
184
|
+
const [dragging, setDragging] = useState<number | null>(null);
|
|
185
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: any new snapshot is the server's word
|
|
186
|
+
useEffect(() => setDropped(null), [todos]);
|
|
187
|
+
const sensors = useSensors(
|
|
188
|
+
useSensor(MouseSensor, { activationConstraint: { distance: 4 } }),
|
|
189
|
+
useSensor(TouchSensor, {
|
|
190
|
+
activationConstraint: { delay: 200, tolerance: 6 },
|
|
191
|
+
}),
|
|
192
|
+
useSensor(KeyboardSensor, {
|
|
193
|
+
coordinateGetter: sortableKeyboardCoordinates,
|
|
194
|
+
}),
|
|
195
|
+
);
|
|
196
|
+
const ids = dropped ?? todos.map((todo) => todo.id);
|
|
197
|
+
const shown = ids.flatMap((id) => todos.filter((todo) => todo.id === id));
|
|
198
|
+
const lifted = todos.find((todo) => todo.id === dragging);
|
|
199
|
+
|
|
200
|
+
const drop = ({ active, over }: DragEndEvent) => {
|
|
201
|
+
setDragging(null);
|
|
202
|
+
if (over === null || active.id === over.id) {
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
const to = ids.indexOf(Number(over.id));
|
|
206
|
+
setDropped(arrayMove(ids, ids.indexOf(Number(active.id)), to));
|
|
207
|
+
// The place of the card it landed on, which is not its index when the
|
|
208
|
+
// list shows only one tag's todos.
|
|
209
|
+
const position =
|
|
210
|
+
todos.find((todo) => todo.id === Number(over.id))?.position ?? to + 1;
|
|
211
|
+
moveTodo(Number(active.id), position).catch((error: unknown) => {
|
|
212
|
+
setDropped(null);
|
|
213
|
+
toast.add({
|
|
214
|
+
title: "Not moved",
|
|
215
|
+
description: error instanceof Error ? error.message : String(error),
|
|
216
|
+
type: "error",
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
return (
|
|
222
|
+
<DndContext
|
|
223
|
+
sensors={sensors}
|
|
224
|
+
collisionDetection={closestCenter}
|
|
225
|
+
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
|
|
226
|
+
onDragStart={({ active }) => setDragging(Number(active.id))}
|
|
227
|
+
onDragCancel={() => setDragging(null)}
|
|
228
|
+
onDragEnd={drop}
|
|
229
|
+
>
|
|
230
|
+
<SortableContext items={ids} strategy={verticalListSortingStrategy}>
|
|
231
|
+
<ul aria-label="todos" className="flex flex-col gap-2">
|
|
232
|
+
{shown.map((todo) => (
|
|
233
|
+
<Sortable
|
|
234
|
+
key={todo.id}
|
|
235
|
+
todo={todo}
|
|
236
|
+
staleness={staleness}
|
|
237
|
+
onOpen={() => onOpen(todo.id)}
|
|
238
|
+
/>
|
|
239
|
+
))}
|
|
240
|
+
</ul>
|
|
241
|
+
</SortableContext>
|
|
242
|
+
{/* Lifted off the column, tilted, the way a card in hand looks. */}
|
|
243
|
+
<DragOverlay>
|
|
244
|
+
{lifted && (
|
|
245
|
+
<Card
|
|
246
|
+
todo={lifted}
|
|
247
|
+
staleness={staleness}
|
|
248
|
+
className="rotate-2 cursor-grabbing shadow-lg"
|
|
249
|
+
/>
|
|
250
|
+
)}
|
|
251
|
+
</DragOverlay>
|
|
252
|
+
</DndContext>
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** One card in the column, holding its place while another is dragged over it. */
|
|
257
|
+
function Sortable({
|
|
258
|
+
todo,
|
|
259
|
+
staleness,
|
|
260
|
+
onOpen,
|
|
261
|
+
}: {
|
|
262
|
+
todo: TodoState;
|
|
263
|
+
staleness: number;
|
|
264
|
+
onOpen: () => void;
|
|
265
|
+
}): JSX.Element {
|
|
266
|
+
const {
|
|
267
|
+
attributes,
|
|
268
|
+
listeners,
|
|
269
|
+
setNodeRef,
|
|
270
|
+
setActivatorNodeRef,
|
|
271
|
+
transform,
|
|
272
|
+
transition,
|
|
273
|
+
isDragging,
|
|
274
|
+
} = useSortable({ id: todo.id });
|
|
275
|
+
return (
|
|
276
|
+
<li
|
|
277
|
+
ref={setNodeRef}
|
|
278
|
+
style={{ transform: CSS.Translate.toString(transform), transition }}
|
|
279
|
+
>
|
|
280
|
+
<Card
|
|
281
|
+
todo={todo}
|
|
282
|
+
staleness={staleness}
|
|
283
|
+
onOpen={onOpen}
|
|
284
|
+
// The card answers a mouse and a finger; its grip, a keyboard.
|
|
285
|
+
pointer={{
|
|
286
|
+
onMouseDown: listeners?.onMouseDown as MouseEventHandler,
|
|
287
|
+
onTouchStart: listeners?.onTouchStart as TouchEventHandler,
|
|
288
|
+
}}
|
|
289
|
+
grip={{
|
|
290
|
+
...attributes,
|
|
291
|
+
onKeyDown: listeners?.onKeyDown as KeyboardEventHandler,
|
|
292
|
+
ref: setActivatorNodeRef,
|
|
293
|
+
}}
|
|
294
|
+
// The slot it leaves while it is in hand, where it will land.
|
|
295
|
+
className={cn(
|
|
296
|
+
isDragging &&
|
|
297
|
+
"border-dashed bg-muted shadow-none *:invisible hover:shadow-none",
|
|
298
|
+
)}
|
|
299
|
+
/>
|
|
300
|
+
</li>
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function Card({
|
|
305
|
+
todo,
|
|
306
|
+
staleness,
|
|
307
|
+
onOpen,
|
|
308
|
+
pointer,
|
|
309
|
+
grip,
|
|
310
|
+
className,
|
|
311
|
+
}: {
|
|
312
|
+
todo: TodoState;
|
|
313
|
+
staleness: number;
|
|
314
|
+
onOpen?: () => void;
|
|
315
|
+
pointer?: HTMLAttributes<HTMLElement>;
|
|
316
|
+
grip?: HTMLAttributes<HTMLButtonElement> & {
|
|
317
|
+
ref?: (element: HTMLElement | null) => void;
|
|
318
|
+
};
|
|
319
|
+
className?: string;
|
|
320
|
+
}): JSX.Element {
|
|
321
|
+
const stale =
|
|
322
|
+
todo.takenBy === null &&
|
|
323
|
+
Date.now() - Date.parse(todo.createdAt) > staleness;
|
|
324
|
+
return (
|
|
325
|
+
<div
|
|
326
|
+
{...pointer}
|
|
327
|
+
className={cn(
|
|
328
|
+
// A pointer, since a tap opens it; a drag from anywhere still moves
|
|
329
|
+
// it, and the grip says so with its own cursor.
|
|
330
|
+
"group relative flex cursor-pointer touch-manipulation items-stretch rounded-lg border bg-card text-card-foreground shadow-xs transition-[background-color,border-color,box-shadow] select-none hover:border-foreground/25 hover:bg-muted/70 hover:shadow-sm",
|
|
331
|
+
// Taken: a task is at work on it, which should read from across
|
|
332
|
+
// the room.
|
|
333
|
+
todo.takenBy && "border-l-4 border-l-success",
|
|
334
|
+
className,
|
|
335
|
+
)}
|
|
336
|
+
>
|
|
337
|
+
<div
|
|
338
|
+
className={cn(
|
|
339
|
+
"flex min-w-0 flex-1 flex-col gap-1.5 py-2.5 pl-3",
|
|
340
|
+
stale && "opacity-60",
|
|
341
|
+
)}
|
|
342
|
+
>
|
|
343
|
+
{/* Stretched over the whole card, so a tap anywhere opens it; the
|
|
344
|
+
buttons on top of it keep their own. */}
|
|
345
|
+
<button
|
|
346
|
+
type="button"
|
|
347
|
+
onClick={onOpen}
|
|
348
|
+
className="cursor-pointer text-left font-semibold text-sm outline-none after:absolute after:inset-0 after:rounded-lg focus-visible:after:ring-2 focus-visible:after:ring-ring"
|
|
349
|
+
>
|
|
350
|
+
{todo.subject}
|
|
351
|
+
</button>
|
|
352
|
+
{todo.text !== "" && (
|
|
353
|
+
<Markdown
|
|
354
|
+
text={todo.text}
|
|
355
|
+
preview
|
|
356
|
+
className="text-muted-foreground text-xs"
|
|
357
|
+
/>
|
|
358
|
+
)}
|
|
359
|
+
{/* Badges, the way a Trello card has them: only what there is. */}
|
|
360
|
+
<span className="flex flex-wrap items-center gap-x-3 gap-y-1 text-muted-foreground text-xs">
|
|
361
|
+
<CopyLink id={todo.id} />
|
|
362
|
+
{todo.files.length > 0 && (
|
|
363
|
+
<Badge Icon={PaperclipIcon} label="files">
|
|
364
|
+
{todo.files.length}
|
|
365
|
+
</Badge>
|
|
366
|
+
)}
|
|
367
|
+
{todo.takenBy && <TakenBy task={todo.takenBy} />}
|
|
368
|
+
{todo.tags.map((tag) => (
|
|
369
|
+
<TagButton key={tag} tag={tag} />
|
|
370
|
+
))}
|
|
371
|
+
{stale && (
|
|
372
|
+
<Badge Icon={ClockIcon} label="stale">
|
|
373
|
+
stale, {age(todo.createdAt)}
|
|
374
|
+
</Badge>
|
|
375
|
+
)}
|
|
376
|
+
</span>
|
|
377
|
+
</div>
|
|
378
|
+
<button
|
|
379
|
+
type="button"
|
|
380
|
+
{...grip}
|
|
381
|
+
aria-label={`move ${todo.subject}`}
|
|
382
|
+
className="relative flex w-8 shrink-0 cursor-grab items-center active:cursor-grabbing justify-center rounded-r-lg text-muted-foreground opacity-40 outline-none group-hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-ring"
|
|
383
|
+
>
|
|
384
|
+
<GripVerticalIcon className="size-4" />
|
|
385
|
+
</button>
|
|
386
|
+
</div>
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* The task that took the todo, green so it reads from across the room. A tap
|
|
392
|
+
* opens the task; like the copy link, it sits above the card's own button and
|
|
393
|
+
* never starts a drag.
|
|
394
|
+
*/
|
|
395
|
+
function TakenBy({ task }: { task: string }): JSX.Element {
|
|
396
|
+
const openTask = useContext(OpenTask);
|
|
397
|
+
return (
|
|
398
|
+
<button
|
|
399
|
+
type="button"
|
|
400
|
+
onClick={() => openTask(task)}
|
|
401
|
+
onMouseDown={(event) => event.stopPropagation()}
|
|
402
|
+
onTouchStart={(event) => event.stopPropagation()}
|
|
403
|
+
className="relative inline-flex min-w-0 cursor-pointer items-center gap-1 rounded-full bg-success/15 px-2 py-0.5 font-medium text-success outline-none hover:bg-success/25 focus-visible:ring-2 focus-visible:ring-ring"
|
|
404
|
+
>
|
|
405
|
+
<GitBranchIcon
|
|
406
|
+
aria-label="taken by"
|
|
407
|
+
role="img"
|
|
408
|
+
className="size-3.5 shrink-0"
|
|
409
|
+
/>
|
|
410
|
+
<span className="truncate">Taken by {task}</span>
|
|
411
|
+
</button>
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** One of a card's tags, which shows only that tag's todos when tapped. */
|
|
416
|
+
function TagButton({ tag }: { tag: string }): JSX.Element {
|
|
417
|
+
const filter = useContext(FilterTag);
|
|
418
|
+
return (
|
|
419
|
+
<button
|
|
420
|
+
type="button"
|
|
421
|
+
aria-label={`show only ${tag}`}
|
|
422
|
+
onClick={() => filter(tag)}
|
|
423
|
+
onMouseDown={(event) => event.stopPropagation()}
|
|
424
|
+
onTouchStart={(event) => event.stopPropagation()}
|
|
425
|
+
className="relative inline-flex cursor-pointer items-center gap-1 rounded-full border px-2 py-0.5 outline-none hover:bg-background hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
|
426
|
+
>
|
|
427
|
+
<TagIcon aria-hidden className="size-3" />
|
|
428
|
+
{tag}
|
|
429
|
+
</button>
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** What a chat with an agent takes to mean this todo. */
|
|
434
|
+
function todoReference(id: number): string {
|
|
435
|
+
return `[ARBOR TODO #${id}]`;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* The card's id, which copies `todoReference` for pasting into a chat. Above
|
|
440
|
+
* the card's own button, and never the start of a drag.
|
|
441
|
+
*/
|
|
442
|
+
function CopyLink({ id }: { id: number }): JSX.Element {
|
|
443
|
+
const copy = async () => {
|
|
444
|
+
try {
|
|
445
|
+
await navigator.clipboard.writeText(todoReference(id));
|
|
446
|
+
toast.add({ title: `Copied ${todoReference(id)}` });
|
|
447
|
+
} catch (error) {
|
|
448
|
+
toast.add({
|
|
449
|
+
title: "Not copied",
|
|
450
|
+
description: error instanceof Error ? error.message : String(error),
|
|
451
|
+
type: "error",
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
};
|
|
455
|
+
return (
|
|
456
|
+
<button
|
|
457
|
+
type="button"
|
|
458
|
+
aria-label={`copy a link to todo ${id}`}
|
|
459
|
+
onClick={() => void copy()}
|
|
460
|
+
onMouseDown={(event) => event.stopPropagation()}
|
|
461
|
+
onTouchStart={(event) => event.stopPropagation()}
|
|
462
|
+
className="relative -mx-1 inline-flex cursor-pointer items-center gap-1 rounded px-1 tabular-nums outline-none hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
|
463
|
+
>
|
|
464
|
+
<LinkIcon className="size-3.5" />#{id}
|
|
465
|
+
</button>
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** One fact about a card, after an icon that says what kind. */
|
|
470
|
+
function Badge({
|
|
471
|
+
Icon,
|
|
472
|
+
label,
|
|
473
|
+
children,
|
|
474
|
+
}: {
|
|
475
|
+
Icon: LucideIcon;
|
|
476
|
+
label: string;
|
|
477
|
+
children: ReactNode;
|
|
478
|
+
}): JSX.Element {
|
|
479
|
+
return (
|
|
480
|
+
<span className="inline-flex items-center gap-1" title={label}>
|
|
481
|
+
<Icon aria-label={label} role="img" className="size-3.5" />
|
|
482
|
+
{children}
|
|
483
|
+
</span>
|
|
484
|
+
);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function Add(): JSX.Element {
|
|
488
|
+
const [subject, setSubject] = useState("");
|
|
489
|
+
const [text, setText] = useState("");
|
|
490
|
+
const files = useFiles();
|
|
491
|
+
const mentions = useMentions<HTMLInputElement>({
|
|
492
|
+
task: "",
|
|
493
|
+
text: subject,
|
|
494
|
+
setText: setSubject,
|
|
495
|
+
});
|
|
496
|
+
const detail = useMentions<HTMLTextAreaElement>({ task: "", text, setText });
|
|
497
|
+
const [sending, setSending] = useState(false);
|
|
498
|
+
|
|
499
|
+
const submit = async (event: FormEvent) => {
|
|
500
|
+
event.preventDefault();
|
|
501
|
+
if (subject.trim() === "" || sending) {
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
setSending(true);
|
|
505
|
+
try {
|
|
506
|
+
await addTodo({ subject, text }, files.files);
|
|
507
|
+
setSubject("");
|
|
508
|
+
setText("");
|
|
509
|
+
files.clear();
|
|
510
|
+
} catch (error) {
|
|
511
|
+
toast.add({
|
|
512
|
+
title: "Not added",
|
|
513
|
+
description: error instanceof Error ? error.message : String(error),
|
|
514
|
+
type: "error",
|
|
515
|
+
});
|
|
516
|
+
} finally {
|
|
517
|
+
setSending(false);
|
|
518
|
+
}
|
|
519
|
+
};
|
|
520
|
+
|
|
521
|
+
return (
|
|
522
|
+
<form onSubmit={submit} className="flex flex-col gap-2">
|
|
523
|
+
<MentionAnchor mentions={mentions}>
|
|
524
|
+
<InputGroup>
|
|
525
|
+
<InputGroupInput
|
|
526
|
+
ref={mentions.ref}
|
|
527
|
+
aria-label="new todo"
|
|
528
|
+
placeholder="Something to do later, @ for a file"
|
|
529
|
+
value={subject}
|
|
530
|
+
onChange={mentions.onChange}
|
|
531
|
+
onSelect={mentions.onSelect}
|
|
532
|
+
onClick={mentions.onClick}
|
|
533
|
+
onKeyDown={mentions.onKeyDown}
|
|
534
|
+
onPaste={files.paste}
|
|
535
|
+
/>
|
|
536
|
+
<InputGroupAddon align="inline-end">
|
|
537
|
+
<AttachButton files={files} />
|
|
538
|
+
<InputGroupButton
|
|
539
|
+
type="submit"
|
|
540
|
+
variant="secondary"
|
|
541
|
+
disabled={subject.trim() === "" || sending}
|
|
542
|
+
>
|
|
543
|
+
Add
|
|
544
|
+
</InputGroupButton>
|
|
545
|
+
</InputGroupAddon>
|
|
546
|
+
</InputGroup>
|
|
547
|
+
</MentionAnchor>
|
|
548
|
+
{/* Only once there is a line to add to, so the box stays one line. */}
|
|
549
|
+
{(subject !== "" || text !== "") && (
|
|
550
|
+
<MentionAnchor mentions={detail}>
|
|
551
|
+
<InputGroup>
|
|
552
|
+
<InputGroupTextarea
|
|
553
|
+
ref={detail.ref}
|
|
554
|
+
aria-label="new todo detail"
|
|
555
|
+
placeholder="More to say in markdown, if any"
|
|
556
|
+
value={text}
|
|
557
|
+
onChange={detail.onChange}
|
|
558
|
+
onSelect={detail.onSelect}
|
|
559
|
+
onClick={detail.onClick}
|
|
560
|
+
onKeyDown={detail.onKeyDown}
|
|
561
|
+
onPaste={files.paste}
|
|
562
|
+
rows={2}
|
|
563
|
+
/>
|
|
564
|
+
</InputGroup>
|
|
565
|
+
</MentionAnchor>
|
|
566
|
+
)}
|
|
567
|
+
<FileList files={files} />
|
|
568
|
+
</form>
|
|
569
|
+
);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/** One todo opened: its words and files to change, and a way to drop it. */
|
|
573
|
+
function Edit({
|
|
574
|
+
todo,
|
|
575
|
+
onDone,
|
|
576
|
+
}: {
|
|
577
|
+
todo: TodoState;
|
|
578
|
+
onDone: () => void;
|
|
579
|
+
}): JSX.Element {
|
|
580
|
+
const [subject, setSubject] = useState(todo.subject);
|
|
581
|
+
const [text, setText] = useState(todo.text);
|
|
582
|
+
const [tags, setTags] = useState(todo.tags.join(", "));
|
|
583
|
+
const tagsHint = useId();
|
|
584
|
+
const tagged = tags
|
|
585
|
+
.split(",")
|
|
586
|
+
.map((tag) => tag.trim())
|
|
587
|
+
.filter(Boolean);
|
|
588
|
+
// Read first when there is something to read, the way a Trello card opens.
|
|
589
|
+
const [writing, setWriting] = useState(todo.text === "");
|
|
590
|
+
const files = useFiles(todo.files);
|
|
591
|
+
const mentions = useMentions<HTMLInputElement>({
|
|
592
|
+
task: "",
|
|
593
|
+
text: subject,
|
|
594
|
+
setText: setSubject,
|
|
595
|
+
});
|
|
596
|
+
const detail = useMentions<HTMLTextAreaElement>({ task: "", text, setText });
|
|
597
|
+
const [busy, setBusy] = useState(false);
|
|
598
|
+
// Removing cannot be taken back, so it asks twice.
|
|
599
|
+
const [confirming, setConfirming] = useState(false);
|
|
600
|
+
|
|
601
|
+
const run = async (write: () => Promise<void>, failed: string) => {
|
|
602
|
+
setBusy(true);
|
|
603
|
+
try {
|
|
604
|
+
await write();
|
|
605
|
+
onDone();
|
|
606
|
+
} catch (error) {
|
|
607
|
+
toast.add({
|
|
608
|
+
title: failed,
|
|
609
|
+
description: error instanceof Error ? error.message : String(error),
|
|
610
|
+
type: "error",
|
|
611
|
+
});
|
|
612
|
+
setBusy(false);
|
|
613
|
+
}
|
|
614
|
+
};
|
|
615
|
+
|
|
616
|
+
const changed =
|
|
617
|
+
subject.trim() !== "" &&
|
|
618
|
+
(subject.trim() !== todo.subject ||
|
|
619
|
+
text.trim() !== todo.text ||
|
|
620
|
+
tagged.join(",") !== todo.tags.join(",") ||
|
|
621
|
+
files.files.length > 0 ||
|
|
622
|
+
files.keep.length !== todo.files.length);
|
|
623
|
+
return (
|
|
624
|
+
<DialogContent className="max-h-[85dvh] grid-cols-[minmax(0,1fr)] overflow-y-auto sm:max-w-lg">
|
|
625
|
+
<DialogHeader>
|
|
626
|
+
<DialogTitle className="flex items-center gap-2">
|
|
627
|
+
Todo {todo.id}
|
|
628
|
+
<span className="font-normal text-muted-foreground text-xs">
|
|
629
|
+
<CopyLink id={todo.id} />
|
|
630
|
+
</span>
|
|
631
|
+
</DialogTitle>
|
|
632
|
+
<DialogDescription>
|
|
633
|
+
{todo.takenBy
|
|
634
|
+
? `Taken by ${todo.takenBy}`
|
|
635
|
+
: `Number ${todo.position} on the list`}
|
|
636
|
+
</DialogDescription>
|
|
637
|
+
</DialogHeader>
|
|
638
|
+
<div className="flex flex-col gap-2">
|
|
639
|
+
<MentionAnchor mentions={mentions}>
|
|
640
|
+
<InputGroup>
|
|
641
|
+
<InputGroupInput
|
|
642
|
+
ref={mentions.ref}
|
|
643
|
+
aria-label="subject"
|
|
644
|
+
value={subject}
|
|
645
|
+
onChange={mentions.onChange}
|
|
646
|
+
onSelect={mentions.onSelect}
|
|
647
|
+
onClick={mentions.onClick}
|
|
648
|
+
onKeyDown={mentions.onKeyDown}
|
|
649
|
+
onPaste={files.paste}
|
|
650
|
+
/>
|
|
651
|
+
</InputGroup>
|
|
652
|
+
</MentionAnchor>
|
|
653
|
+
<div className="flex items-center justify-between">
|
|
654
|
+
<div className="flex gap-1">
|
|
655
|
+
<Button
|
|
656
|
+
size="xs"
|
|
657
|
+
variant={writing ? "secondary" : "ghost"}
|
|
658
|
+
aria-pressed={writing}
|
|
659
|
+
onClick={() => setWriting(true)}
|
|
660
|
+
>
|
|
661
|
+
Write
|
|
662
|
+
</Button>
|
|
663
|
+
<Button
|
|
664
|
+
size="xs"
|
|
665
|
+
variant={writing ? "ghost" : "secondary"}
|
|
666
|
+
aria-pressed={!writing}
|
|
667
|
+
onClick={() => setWriting(false)}
|
|
668
|
+
>
|
|
669
|
+
Preview
|
|
670
|
+
</Button>
|
|
671
|
+
</div>
|
|
672
|
+
<span className="text-muted-foreground text-xs">
|
|
673
|
+
Markdown supported
|
|
674
|
+
</span>
|
|
675
|
+
</div>
|
|
676
|
+
{writing ? (
|
|
677
|
+
<MentionAnchor mentions={detail}>
|
|
678
|
+
<InputGroup>
|
|
679
|
+
<InputGroupTextarea
|
|
680
|
+
ref={detail.ref}
|
|
681
|
+
aria-label="detail"
|
|
682
|
+
placeholder="More to say in markdown, if any"
|
|
683
|
+
value={text}
|
|
684
|
+
onChange={detail.onChange}
|
|
685
|
+
onSelect={detail.onSelect}
|
|
686
|
+
onClick={detail.onClick}
|
|
687
|
+
onKeyDown={detail.onKeyDown}
|
|
688
|
+
onPaste={files.paste}
|
|
689
|
+
rows={6}
|
|
690
|
+
/>
|
|
691
|
+
<InputGroupAddon align="block-end">
|
|
692
|
+
<AttachButton files={files} />
|
|
693
|
+
</InputGroupAddon>
|
|
694
|
+
</InputGroup>
|
|
695
|
+
</MentionAnchor>
|
|
696
|
+
) : (
|
|
697
|
+
<div className="min-h-20 rounded-md border px-3 py-2 text-sm">
|
|
698
|
+
{text.trim() === "" ? (
|
|
699
|
+
<span className="text-muted-foreground">Nothing to preview</span>
|
|
700
|
+
) : (
|
|
701
|
+
<Markdown text={text} />
|
|
702
|
+
)}
|
|
703
|
+
</div>
|
|
704
|
+
)}
|
|
705
|
+
<FileList files={files} />
|
|
706
|
+
<div className="flex flex-col gap-1">
|
|
707
|
+
<InputGroup>
|
|
708
|
+
<InputGroupAddon>
|
|
709
|
+
<TagIcon />
|
|
710
|
+
</InputGroupAddon>
|
|
711
|
+
<InputGroupInput
|
|
712
|
+
aria-label="tags"
|
|
713
|
+
aria-describedby={tagsHint}
|
|
714
|
+
placeholder="uploads, merge"
|
|
715
|
+
value={tags}
|
|
716
|
+
onChange={(event) => setTags(event.target.value)}
|
|
717
|
+
/>
|
|
718
|
+
</InputGroup>
|
|
719
|
+
{/* Always shown, since the placeholder goes once a tag is typed. */}
|
|
720
|
+
<p id={tagsHint} className="text-muted-foreground text-xs">
|
|
721
|
+
Separate tags with commas.
|
|
722
|
+
</p>
|
|
723
|
+
</div>
|
|
724
|
+
</div>
|
|
725
|
+
<DialogFooter className="flex-row justify-between sm:justify-between">
|
|
726
|
+
<Button
|
|
727
|
+
variant={confirming ? "destructive" : "ghost"}
|
|
728
|
+
disabled={busy}
|
|
729
|
+
onClick={() =>
|
|
730
|
+
confirming
|
|
731
|
+
? void run(() => removeTodo(todo.id), "Not removed")
|
|
732
|
+
: setConfirming(true)
|
|
733
|
+
}
|
|
734
|
+
>
|
|
735
|
+
<Trash2Icon data-icon="inline-start" />
|
|
736
|
+
{confirming ? "Remove for good" : "Remove"}
|
|
737
|
+
</Button>
|
|
738
|
+
<Button
|
|
739
|
+
disabled={!changed || busy}
|
|
740
|
+
onClick={() =>
|
|
741
|
+
void run(
|
|
742
|
+
() =>
|
|
743
|
+
updateTodo(todo.id, {
|
|
744
|
+
subject,
|
|
745
|
+
text,
|
|
746
|
+
tags: tagged,
|
|
747
|
+
files: files.files,
|
|
748
|
+
keep: files.keep,
|
|
749
|
+
}),
|
|
750
|
+
"Not saved",
|
|
751
|
+
)
|
|
752
|
+
}
|
|
753
|
+
>
|
|
754
|
+
Save
|
|
755
|
+
</Button>
|
|
756
|
+
</DialogFooter>
|
|
757
|
+
</DialogContent>
|
|
758
|
+
);
|
|
759
|
+
}
|