@lotics/ui 5.9.0 → 5.10.1
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 +131 -44
- package/examples/tpl_approvals.tsx +3 -3
- package/examples/tpl_dossier.tsx +5 -4
- package/examples/tpl_pipeline.tsx +400 -0
- package/examples/tpl_record.tsx +5 -4
- package/examples/tpl_shifts.tsx +7 -5
- package/examples/tpl_task_board.tsx +473 -0
- package/examples/tpl_tasks.tsx +456 -0
- package/package.json +10 -5
- package/src/accordion.tsx +1 -1
- package/src/agent_progress.tsx +1 -1
- package/src/agent_run.tsx +1 -1
- package/src/breakdown.tsx +2 -2
- package/src/button.tsx +12 -3
- package/src/calendar/calendar_view.tsx +10 -10
- package/src/calendar/month_view.tsx +30 -11
- package/src/calendar/time_grid_view.tsx +25 -7
- package/src/card_select_item.tsx +11 -13
- package/src/check_circle.tsx +88 -0
- package/src/checkbox_input.tsx +5 -1
- package/src/chip.tsx +1 -1
- package/src/chip_group.tsx +1 -1
- package/src/column_filter.tsx +3 -2
- package/src/combobox.tsx +84 -219
- package/src/confidence.tsx +14 -3
- package/src/control_surface.ts +22 -7
- package/src/data_grid.tsx +159 -0
- package/src/date_calendar.tsx +46 -23
- package/src/date_field.tsx +10 -2
- package/src/date_filter.tsx +5 -9
- package/src/date_picker.tsx +78 -26
- package/src/date_picker_value.ts +11 -0
- package/src/date_range_filter_field.tsx +6 -4
- package/src/drawer.tsx +1 -1
- package/src/file_dropzone.tsx +5 -0
- package/src/file_gallery_modal.tsx +43 -42
- package/src/file_preview.web.tsx +25 -11
- package/src/file_preview_types.ts +9 -0
- package/src/file_row.tsx +5 -1
- package/src/file_rows.tsx +6 -2
- package/src/file_thumbnail.tsx +13 -4
- package/src/file_thumbnail_grid.tsx +5 -0
- package/src/files_editor.tsx +232 -0
- package/src/filter_chip.tsx +25 -12
- package/src/focus_ring_pressable.tsx +34 -0
- package/src/form_picker.tsx +3 -3
- package/src/gantt/gantt_view.tsx +25 -7
- package/src/heatmap.tsx +5 -1
- package/src/icon_button.tsx +2 -2
- package/src/image_gallery.tsx +8 -10
- package/src/index.css +8 -12
- package/src/inline_date_picker.tsx +11 -11
- package/src/inline_edit.tsx +22 -6
- package/src/inline_member_select.tsx +1 -1
- package/src/inline_select.tsx +9 -8
- package/src/inline_text_input.tsx +4 -1
- package/src/link.tsx +11 -1
- package/src/link_button.tsx +1 -1
- package/src/list_item.tsx +2 -2
- package/src/member_select.tsx +9 -4
- package/src/menu_button.tsx +2 -2
- package/src/menu_list_item.tsx +2 -2
- package/src/number_input.tsx +11 -4
- package/src/option_list.tsx +211 -0
- package/src/peek.tsx +1 -1
- package/src/picker.tsx +24 -213
- package/src/pressable_highlight.tsx +42 -23
- package/src/radio_picker.tsx +2 -2
- package/src/range_slider.tsx +35 -7
- package/src/react_native.d.ts +3 -0
- package/src/scroll_to_bottom.tsx +1 -1
- package/src/section.tsx +7 -2
- package/src/segmented_control.tsx +2 -2
- package/src/select.tsx +299 -0
- package/src/sort_header.tsx +1 -1
- package/src/sources.tsx +1 -1
- package/src/spreadsheet_view.tsx +43 -17
- package/src/status_grid.tsx +4 -3
- package/src/stepper.tsx +1 -1
- package/src/switch.tsx +5 -1
- package/src/switch_button.tsx +1 -1
- package/src/switcher.tsx +1 -1
- package/src/table.tsx +5 -1
- package/src/tabs.tsx +2 -2
- package/src/text_input_field.tsx +15 -4
- package/src/time_picker.tsx +11 -4
- package/src/timeline.tsx +1 -1
- package/src/use_focus_ring.ts +80 -0
- package/src/use_hover.ts +26 -0
- package/src/use_list_keyboard_nav.test.ts +71 -0
- package/src/use_list_keyboard_nav.ts +52 -5
- package/src/use_option_list.test.ts +193 -0
- package/src/use_option_list.ts +354 -0
- package/src/command_menu.tsx +0 -205
- package/src/picker_menu.tsx +0 -355
- package/src/tag_input.tsx +0 -203
- package/src/time_field.tsx +0 -297
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
2
|
+
import { Pressable, ScrollView, StyleSheet, View } from "react-native";
|
|
3
|
+
import { Text } from "@lotics/ui/text";
|
|
4
|
+
import { colors, solid, asColorName } from "@lotics/ui/colors";
|
|
5
|
+
import { Icon } from "@lotics/ui/icon";
|
|
6
|
+
import { CheckCircle } from "@lotics/ui/check_circle";
|
|
7
|
+
import { OptionBadge, type OptionValue } from "@lotics/ui/option_badge";
|
|
8
|
+
import { MemberChip } from "@lotics/ui/member_chip";
|
|
9
|
+
import { Avatar } from "@lotics/ui/avatar";
|
|
10
|
+
import { SearchInput } from "@lotics/ui/search_input";
|
|
11
|
+
import { FilterChip } from "@lotics/ui/filter_chip";
|
|
12
|
+
import { OptionList } from "@lotics/ui/option_list";
|
|
13
|
+
import { Button } from "@lotics/ui/button";
|
|
14
|
+
import { InlineTextInput } from "@lotics/ui/inline_text_input";
|
|
15
|
+
import { InlineMemberSelect } from "@lotics/ui/inline_member_select";
|
|
16
|
+
import { InlineDatePicker } from "@lotics/ui/inline_date_picker";
|
|
17
|
+
import { InlineSelect } from "@lotics/ui/inline_select";
|
|
18
|
+
import { Select } from "@lotics/ui/select";
|
|
19
|
+
import { TextInputField } from "@lotics/ui/text_input_field";
|
|
20
|
+
import { Popover, PopoverTrigger, PopoverContent } from "@lotics/ui/popover";
|
|
21
|
+
import { FilesEditor } from "@lotics/ui/files_editor";
|
|
22
|
+
import { FileThumbnail, type DisplayFile } from "@lotics/ui/file_thumbnail";
|
|
23
|
+
import { DataGrid, gridRowStyle, type DataGridColumn, type DataGridGroup } from "@lotics/ui/data_grid";
|
|
24
|
+
import { cycleSort, sortBy, type SortState } from "@lotics/ui/sort_header";
|
|
25
|
+
import { Composer } from "@lotics/ui/composer";
|
|
26
|
+
import { AgentRun, type AgentRunItem } from "@lotics/ui/agent_run";
|
|
27
|
+
import { ChangeReview, ChangeReviewActions, type ChangeReviewItemStatus } from "@lotics/ui/change_review";
|
|
28
|
+
import { Dialog, DialogHeader, DialogHeaderTitle, DialogScrollArea, DialogFooter } from "@lotics/ui/dialog";
|
|
29
|
+
import { CompletionState } from "@lotics/ui/completion_state";
|
|
30
|
+
import { Confidence, type ConfidenceLevel } from "@lotics/ui/confidence";
|
|
31
|
+
import { toISODate } from "@lotics/ui/format_date";
|
|
32
|
+
|
|
33
|
+
type TagOption = { value: string; label: string };
|
|
34
|
+
|
|
35
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
36
|
+
// Template · Task board — the manager's view, built on the `DataGrid` primitive
|
|
37
|
+
// (the inline-managed grouped table). The board OWNS its data, state, toolbar,
|
|
38
|
+
// columns and per-group add; `DataGrid` owns the sortable header, the collapsible
|
|
39
|
+
// grouped sections and the aligned rows. Sibling: tpl_pipeline (same shape, deals).
|
|
40
|
+
// Moderate data only — for 10k+ records you browse, use the paginated register.
|
|
41
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
const MEMBERS = [
|
|
44
|
+
{ id: "m1", name: "Mai Anh" },
|
|
45
|
+
{ id: "m2", name: "Trọng" },
|
|
46
|
+
{ id: "m3", name: "Linh" },
|
|
47
|
+
{ id: "m4", name: "Sơn" },
|
|
48
|
+
];
|
|
49
|
+
const MEMBER_OPTS = MEMBERS.map((m) => ({ value: m.id, label: m.name }));
|
|
50
|
+
const memberName = (id: string | null) => MEMBERS.find((m) => m.id === id)?.name ?? "—";
|
|
51
|
+
|
|
52
|
+
type Status = "todo" | "doing" | "done";
|
|
53
|
+
const STATUS: Record<Status, OptionValue> = {
|
|
54
|
+
todo: { key: "todo", label: "To do", color: null },
|
|
55
|
+
doing: { key: "doing", label: "Doing", color: "amber" },
|
|
56
|
+
done: { key: "done", label: "Done", color: "emerald" },
|
|
57
|
+
};
|
|
58
|
+
const STATUS_ORDER = Object.keys(STATUS) as Status[];
|
|
59
|
+
const STATUS_OPTIONS = STATUS_ORDER.map((s) => ({ value: s, label: STATUS[s].label }));
|
|
60
|
+
const renderStatusBadge = (o: { value: string }) => <OptionBadge value={STATUS[o.value as Status]} variant="dot" />;
|
|
61
|
+
|
|
62
|
+
const TAGS: Record<string, OptionValue> = {
|
|
63
|
+
client: { key: "client", label: "Client", color: "blue" },
|
|
64
|
+
design: { key: "design", label: "Design", color: "cyan" },
|
|
65
|
+
print: { key: "print", label: "Print", color: "amber" },
|
|
66
|
+
urgent: { key: "urgent", label: "Urgent", color: "rose" },
|
|
67
|
+
};
|
|
68
|
+
const TAG_OPTIONS: TagOption[] = Object.values(TAGS).map((t) => ({ value: String(t.key), label: t.label }));
|
|
69
|
+
const tagOf = (k: string): TagOption => ({ value: k, label: TAGS[k]?.label ?? k });
|
|
70
|
+
const tagBadge = (t: TagOption): OptionValue => TAGS[t.value] ?? { key: t.value, label: t.label, color: null };
|
|
71
|
+
const renderTagBadge = (o: { value: string; label?: string }) => <OptionBadge value={tagBadge({ value: o.value, label: o.label ?? o.value })} variant="dot" />;
|
|
72
|
+
|
|
73
|
+
interface Task {
|
|
74
|
+
id: string;
|
|
75
|
+
title: string;
|
|
76
|
+
ownerId: string | null;
|
|
77
|
+
due: string | null;
|
|
78
|
+
status: Status;
|
|
79
|
+
tags: TagOption[];
|
|
80
|
+
files: DisplayFile[];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const day = (n: number): string => {
|
|
84
|
+
const d = new Date();
|
|
85
|
+
d.setDate(d.getDate() + n);
|
|
86
|
+
return toISODate(d);
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
// A self-contained SVG data-URI image so seeds preview offline in the gallery.
|
|
90
|
+
const sampleImage = (label: string, fill: string): DisplayFile => ({
|
|
91
|
+
id: `seed-${label}`,
|
|
92
|
+
filename: `${label}.png`,
|
|
93
|
+
mimeType: "image/png",
|
|
94
|
+
url: "data:image/svg+xml," + encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="320" height="240"><rect width="320" height="240" fill="${fill}"/><text x="160" y="128" font-family="sans-serif" font-size="22" fill="white" text-anchor="middle">${label}</text></svg>`),
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// New attachments map a picked browser File → a DisplayFile (local object URL).
|
|
98
|
+
// A host wires its own upload here; the template just previews in place.
|
|
99
|
+
let fileSeq = 0;
|
|
100
|
+
const toDisplayFiles = (files: File[]): DisplayFile[] =>
|
|
101
|
+
files.map((f) => ({ id: `f${++fileSeq}`, filename: f.name, mimeType: f.type || "application/octet-stream", url: URL.createObjectURL(f) }));
|
|
102
|
+
|
|
103
|
+
const SEED: Task[] = [
|
|
104
|
+
{ id: "t1", title: "Send VITTORIA their quote", ownerId: "m1", due: day(-2), status: "doing", tags: [tagOf("client"), tagOf("urgent")], files: [sampleImage("quote-draft", "#3b82f6")] },
|
|
105
|
+
{ id: "t2", title: "Finalize the gift-box packaging design", ownerId: "m3", due: day(0), status: "doing", tags: [tagOf("design")], files: [] },
|
|
106
|
+
{ id: "t3", title: "Sign off the die-cut sample", ownerId: "m1", due: day(0), status: "todo", tags: [tagOf("design"), tagOf("print")], files: [sampleImage("die-cut", "#10b981"), sampleImage("sample-2", "#f59e0b")] },
|
|
107
|
+
{ id: "t4", title: "Order 300g ivory board stock", ownerId: "m2", due: day(3), status: "todo", tags: [tagOf("print")], files: [] },
|
|
108
|
+
{ id: "t5", title: "Schedule the first delivery batch", ownerId: "m4", due: day(9), status: "todo", tags: [], files: [] },
|
|
109
|
+
{ id: "t6", title: "Draft the 2026 print contract", ownerId: null, due: null, status: "todo", tags: [tagOf("client")], files: [] },
|
|
110
|
+
{ id: "t7", title: "Update the Q3 price list", ownerId: "m3", due: day(-5), status: "done", tags: [], files: [] },
|
|
111
|
+
];
|
|
112
|
+
|
|
113
|
+
type GroupBy = "status" | "assignee" | "due";
|
|
114
|
+
const GROUP_OPTIONS: { value: GroupBy; label: string }[] = [
|
|
115
|
+
{ value: "status", label: "Status" },
|
|
116
|
+
{ value: "assignee", label: "Assignee" },
|
|
117
|
+
{ value: "due", label: "Due date" },
|
|
118
|
+
];
|
|
119
|
+
|
|
120
|
+
function dueBucketKey(due: string | null): string {
|
|
121
|
+
if (!due) return "none";
|
|
122
|
+
const d = due.slice(0, 10);
|
|
123
|
+
const today = day(0);
|
|
124
|
+
if (d < today) return "overdue";
|
|
125
|
+
if (d === today) return "today";
|
|
126
|
+
if (d <= day(7)) return "week";
|
|
127
|
+
return "later";
|
|
128
|
+
}
|
|
129
|
+
const DUE_LABELS: Record<string, string> = { overdue: "Overdue", today: "Today", week: "This week", later: "Later", none: "No date" };
|
|
130
|
+
const DUE_ORDER = ["overdue", "today", "week", "later", "none"];
|
|
131
|
+
const DUE_PRESET: Record<string, string | null> = { overdue: day(-1), today: day(0), week: day(3), later: day(14), none: null };
|
|
132
|
+
|
|
133
|
+
function presetFor(groupBy: GroupBy | null, key: string): Partial<Task> {
|
|
134
|
+
if (groupBy === "status") return { status: key as Status };
|
|
135
|
+
if (groupBy === "assignee") return { ownerId: key === "none" ? null : key };
|
|
136
|
+
if (groupBy === "due") return { due: DUE_PRESET[key] ?? null };
|
|
137
|
+
return {};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function sortValue(t: Task, key: string): string | number {
|
|
141
|
+
switch (key) {
|
|
142
|
+
case "title": return t.title.toLowerCase();
|
|
143
|
+
case "assignee": return t.ownerId ? memberName(t.ownerId).toLowerCase() : "";
|
|
144
|
+
case "due": return t.due ?? "";
|
|
145
|
+
case "status": return STATUS_ORDER.indexOf(t.status);
|
|
146
|
+
case "tags": return t.tags[0]?.label.toLowerCase() ?? "";
|
|
147
|
+
default: return "";
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ── AI · draft tasks from notes — agent proposes, the manager keeps/edits/drops ──
|
|
152
|
+
type Phase = "idle" | "reading" | "review" | "done";
|
|
153
|
+
interface Proposal { id: string; title: string; due: string | null; confidence: ConfidenceLevel; source: string }
|
|
154
|
+
const DRAFT_STEPS: { label: string; detail: string }[] = [
|
|
155
|
+
{ label: "Split the notes into candidate tasks", detail: "6 lines" },
|
|
156
|
+
{ label: "Drop greetings and lines that aren't actions", detail: "kept 4" },
|
|
157
|
+
{ label: "Infer a due date from each phrase", detail: "2 dated · 2 left open" },
|
|
158
|
+
];
|
|
159
|
+
const DRAFTED: Proposal[] = [
|
|
160
|
+
{ id: "p1", title: "Send VITTORIA the revised quote", due: day(1), confidence: "high", source: "“…told VITTORIA they'd have the updated quote by tomorrow.”" },
|
|
161
|
+
{ id: "p2", title: "Re-check the die-cut sample with the workshop", due: day(3), confidence: "medium", source: "“…workshop to measure the sample again, maybe Thursday.”" },
|
|
162
|
+
{ id: "p3", title: "Order 300g ivory board for the gift-box run", due: null, confidence: "high", source: "“…order the ivory stock this week.”" },
|
|
163
|
+
{ id: "p4", title: "Sit in on the Tet carton press check", due: null, confidence: "low", source: "“…someone should be there for the Tet print run.”" },
|
|
164
|
+
];
|
|
165
|
+
|
|
166
|
+
export function TplTaskBoard() {
|
|
167
|
+
const [tasks, setTasks] = useState<Task[]>(SEED);
|
|
168
|
+
const [query, setQuery] = useState("");
|
|
169
|
+
const [groupBy, setGroupBy] = useState<GroupBy | null>("status"); // null = ungrouped (cleared)
|
|
170
|
+
const [assigneeFilter, setAssigneeFilter] = useState<string[]>([]);
|
|
171
|
+
const [tagFilter, setTagFilter] = useState<string[]>([]);
|
|
172
|
+
const [collapsed, setCollapsed] = useState<Set<string>>(() => new Set());
|
|
173
|
+
const toggleCollapse = (key: string) =>
|
|
174
|
+
setCollapsed((c) => { const n = new Set(c); if (n.has(key)) n.delete(key); else n.add(key); return n; });
|
|
175
|
+
const [sort, setSort] = useState<SortState | null>(null);
|
|
176
|
+
const onSort = (key: string) => setSort((s) => cycleSort(s, key));
|
|
177
|
+
|
|
178
|
+
const patch = (id: string, next: Partial<Task>) =>
|
|
179
|
+
setTasks((ts) => ts.map((t) => (t.id === id ? { ...t, ...next } : t)));
|
|
180
|
+
const addTask = (t: Omit<Task, "id">) =>
|
|
181
|
+
setTasks((ts) => [...ts, { id: `n${ts.length}-${t.title.length}`, ...t }]);
|
|
182
|
+
const addFiles = (id: string, picked: File[]) =>
|
|
183
|
+
setTasks((ts) => ts.map((t) => (t.id === id ? { ...t, files: [...t.files, ...toDisplayFiles(picked)] } : t)));
|
|
184
|
+
const removeFile = (id: string, fileId: string) =>
|
|
185
|
+
setTasks((ts) => ts.map((t) => (t.id === id ? { ...t, files: t.files.filter((f) => f.id !== fileId) } : t)));
|
|
186
|
+
|
|
187
|
+
// Draft-from-notes AI flow — agent proposes, manager keeps/edits/drops, commit adds tasks.
|
|
188
|
+
const [draftOpen, setDraftOpen] = useState(false);
|
|
189
|
+
const [phase, setPhase] = useState<Phase>("idle");
|
|
190
|
+
const [revealed, setRevealed] = useState(0);
|
|
191
|
+
const [proposals, setProposals] = useState<Proposal[]>([]);
|
|
192
|
+
const [decisions, setDecisions] = useState<Record<string, ChangeReviewItemStatus>>({});
|
|
193
|
+
const [addedCount, setAddedCount] = useState(0);
|
|
194
|
+
|
|
195
|
+
const startDraft = useCallback(() => { setRevealed(0); setPhase("reading"); }, []);
|
|
196
|
+
const resetDraft = useCallback(() => { setProposals([]); setDecisions({}); setRevealed(0); setPhase("idle"); }, []);
|
|
197
|
+
const closeDraft = useCallback(() => { setDraftOpen(false); resetDraft(); }, [resetDraft]);
|
|
198
|
+
const editProposal = useCallback((id: string, p: Partial<Proposal>) =>
|
|
199
|
+
setProposals((ps) => ps.map((x) => (x.id === id ? { ...x, ...p } : x))), []);
|
|
200
|
+
const decide = useCallback((id: string, status: ChangeReviewItemStatus | null) =>
|
|
201
|
+
setDecisions((dec) => { const n = { ...dec }; if (status) n[id] = status; else delete n[id]; return n; }), []);
|
|
202
|
+
const acceptedCount = proposals.filter((p) => decisions[p.id] === "accepted").length;
|
|
203
|
+
const addAccepted = useCallback(() => {
|
|
204
|
+
const accepted = proposals.filter((p) => decisions[p.id] === "accepted");
|
|
205
|
+
setTasks((ts) => [...ts, ...accepted.map((p, i) => ({ id: `d-${p.id}-${i}`, title: p.title, ownerId: null, due: p.due, status: "todo" as Status, tags: [], files: [] }))]);
|
|
206
|
+
setAddedCount(accepted.length);
|
|
207
|
+
setPhase("done");
|
|
208
|
+
}, [proposals, decisions]);
|
|
209
|
+
|
|
210
|
+
useEffect(() => {
|
|
211
|
+
if (phase !== "reading") return;
|
|
212
|
+
if (revealed >= DRAFT_STEPS.length) {
|
|
213
|
+
const t = setTimeout(() => { setProposals(DRAFTED); setPhase("review"); }, 850);
|
|
214
|
+
return () => clearTimeout(t);
|
|
215
|
+
}
|
|
216
|
+
const t = setTimeout(() => setRevealed((r) => r + 1), revealed === 0 ? 280 : 680);
|
|
217
|
+
return () => clearTimeout(t);
|
|
218
|
+
}, [phase, revealed]);
|
|
219
|
+
|
|
220
|
+
const runItems: AgentRunItem[] = [
|
|
221
|
+
{ type: "text", id: "intro", text: "I read your notes and pulled out the work." },
|
|
222
|
+
...DRAFT_STEPS.slice(0, Math.min(revealed + 1, DRAFT_STEPS.length)).map((s, i): AgentRunItem => ({
|
|
223
|
+
type: "step", id: `s${i}`, label: s.label, detail: i < revealed ? s.detail : undefined, status: i < revealed ? "done" : "running",
|
|
224
|
+
})),
|
|
225
|
+
];
|
|
226
|
+
if (revealed >= DRAFT_STEPS.length) {
|
|
227
|
+
runItems.push({ type: "text", id: "outro", text: `Drafted ${DRAFTED.length} tasks — keep the ones that look right, edit any before adding.` });
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const propertyCols: DataGridColumn<Task>[] = [
|
|
231
|
+
{ key: "assignee", label: "Assignee", width: 168, sortable: true, cell: (t) => <InlineMemberSelect members={MEMBERS} value={t.ownerId} onSave={(id) => patch(t.id, { ownerId: id })} placeholder="Unassigned" accessibilityLabel="Assignee" /> },
|
|
232
|
+
{ key: "due", label: "Due", width: 150, sortable: true, cell: (t) => <InlineDatePicker value={t.due} optionalTime onSave={(v) => patch(t.id, { due: v })} placeholder="No date" accessibilityLabel="Due date" /> },
|
|
233
|
+
{ key: "status", label: "Status", width: 132, sortable: true, cell: (t) => <InlineSelect value={t.status} options={STATUS_OPTIONS} onSave={(s) => patch(t.id, { status: s })} renderSelected={renderStatusBadge} renderOptionContent={renderStatusBadge} accessibilityLabel="Status" /> },
|
|
234
|
+
{ key: "tags", label: "Tags", width: 210, cell: (t) => <Select multi searchable allowCustom value={t.tags.map((tag) => tag.value)} onValueChange={(next) => patch(t.id, { tags: next.map(tagOf) })} options={TAG_OPTIONS} renderSelected={renderTagBadge} renderOptionContent={renderTagBadge} style={{ borderColor: "transparent" }} placeholder="Add tags" accessibilityLabel="Tags" /> },
|
|
235
|
+
];
|
|
236
|
+
const filesCol: DataGridColumn<Task> = {
|
|
237
|
+
key: "files", label: "Files", width: 116, sortable: false,
|
|
238
|
+
cell: (t) => <FilesCell files={t.files} onAdd={(fs) => addFiles(t.id, fs)} onRemove={(id) => removeFile(t.id, id)} />,
|
|
239
|
+
};
|
|
240
|
+
const visibleProps = propertyCols.filter((c) => c.key !== groupBy);
|
|
241
|
+
const addCols = [...visibleProps, filesCol];
|
|
242
|
+
const columns: DataGridColumn<Task>[] = [
|
|
243
|
+
{ key: "title", label: "Task", sortable: true, cell: (t) => <InlineTextInput value={t.title} onSave={(v) => patch(t.id, { title: v })} struck={t.status === "done"} accessibilityLabel="Title" /> },
|
|
244
|
+
...addCols,
|
|
245
|
+
];
|
|
246
|
+
|
|
247
|
+
const groups = useMemo<DataGridGroup<Task>[]>(() => {
|
|
248
|
+
const q = query.trim().toLowerCase();
|
|
249
|
+
const pool = tasks.filter((t) => {
|
|
250
|
+
if (q && !(t.title.toLowerCase().includes(q) || t.tags.some((tag) => tag.label.toLowerCase().includes(q)))) return false;
|
|
251
|
+
if (assigneeFilter.length > 0 && !(t.ownerId && assigneeFilter.includes(t.ownerId))) return false;
|
|
252
|
+
if (tagFilter.length > 0 && !t.tags.some((tag) => tagFilter.includes(tag.value))) return false;
|
|
253
|
+
return true;
|
|
254
|
+
});
|
|
255
|
+
let out: DataGridGroup<Task>[];
|
|
256
|
+
if (!groupBy) {
|
|
257
|
+
out = [{ key: "all", items: pool }];
|
|
258
|
+
} else if (groupBy === "status") {
|
|
259
|
+
out = STATUS_ORDER.map((s) => ({ key: s, header: <OptionBadge value={STATUS[s]} variant="dot" />, items: pool.filter((t) => t.status === s) }));
|
|
260
|
+
} else if (groupBy === "assignee") {
|
|
261
|
+
out = [
|
|
262
|
+
...MEMBERS.map((m) => ({ key: m.id, header: <MemberChip name={m.name} size={22} />, items: pool.filter((t) => t.ownerId === m.id) })),
|
|
263
|
+
{ key: "none", header: <Text size="sm" color="muted">Unassigned</Text>, items: pool.filter((t) => !t.ownerId) },
|
|
264
|
+
];
|
|
265
|
+
} else {
|
|
266
|
+
out = DUE_ORDER.map((k) => ({ key: k, header: <Text size="xs" weight="semibold" color="muted" style={styles.bucketHead}>{DUE_LABELS[k]}</Text>, items: pool.filter((t) => dueBucketKey(t.due) === k) }));
|
|
267
|
+
}
|
|
268
|
+
const sorted = out.map((g) => ({ ...g, items: sortBy(g.items, sort, sortValue) }));
|
|
269
|
+
return groupBy === "status" ? sorted : sorted.filter((g) => g.items.length > 0);
|
|
270
|
+
}, [tasks, query, groupBy, assigneeFilter, tagFilter, sort]);
|
|
271
|
+
|
|
272
|
+
return (
|
|
273
|
+
<>
|
|
274
|
+
<ScrollView style={{ flex: 1, backgroundColor: colors.white }} contentContainerStyle={{ paddingVertical: 28, paddingHorizontal: 24, paddingBottom: 96 }}>
|
|
275
|
+
<View style={{ maxWidth: 1040, width: "100%", alignSelf: "center", gap: 16 }}>
|
|
276
|
+
<Text size="xl" weight="semibold">Task board</Text>
|
|
277
|
+
|
|
278
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
279
|
+
<View style={{ flexDirection: "row", flexWrap: "wrap", alignItems: "center", gap: 8, flex: 1 }}>
|
|
280
|
+
<View style={{ flexGrow: 1, flexBasis: 240, minWidth: 200, maxWidth: 360 }}>
|
|
281
|
+
<SearchInput value={query} onChangeText={setQuery} placeholder="Search tasks…" accessibilityLabel="Search tasks" />
|
|
282
|
+
</View>
|
|
283
|
+
<FilterChip label="Group by" summary={groupBy ? GROUP_OPTIONS.find((g) => g.value === groupBy)?.label : undefined} onClear={() => setGroupBy(null)} clearLabel="Clear grouping">
|
|
284
|
+
{({ close }) => <OptionList search={{ mode: "none" }} options={GROUP_OPTIONS} value={groupBy ?? undefined} onValueChange={(v) => v && setGroupBy(v)} onRequestClose={close} />}
|
|
285
|
+
</FilterChip>
|
|
286
|
+
<FilterChip
|
|
287
|
+
label="Assignee"
|
|
288
|
+
summary={assigneeFilter.length > 0 ? <View style={styles.summary}>{assigneeFilter.slice(0, 4).map((id) => <Avatar key={id} name={memberName(id)} size={20} />)}</View> : undefined}
|
|
289
|
+
onClear={() => setAssigneeFilter([])}
|
|
290
|
+
clearLabel="Clear assignee filter"
|
|
291
|
+
>
|
|
292
|
+
<OptionList search={{ mode: "none" }} multi options={MEMBER_OPTS} value={assigneeFilter} onValueChange={setAssigneeFilter} renderOptionContent={(o) => <MemberChip name={o.label ?? memberName(o.value)} size={22} />} />
|
|
293
|
+
</FilterChip>
|
|
294
|
+
<FilterChip
|
|
295
|
+
label="Tag"
|
|
296
|
+
summary={tagFilter.length > 0 ? <View style={styles.summary}>{tagFilter.map((v) => <View key={v} style={[styles.summaryDot, { backgroundColor: solid(asColorName(TAGS[v]?.color)) }]} />)}</View> : undefined}
|
|
297
|
+
onClear={() => setTagFilter([])}
|
|
298
|
+
clearLabel="Clear tag filter"
|
|
299
|
+
>
|
|
300
|
+
<OptionList search={{ mode: "none" }} multi options={TAG_OPTIONS} value={tagFilter} onValueChange={setTagFilter} renderOptionContent={(o) => <OptionBadge value={tagBadge({ value: o.value, label: o.label ?? o.value })} variant="dot" />} />
|
|
301
|
+
</FilterChip>
|
|
302
|
+
</View>
|
|
303
|
+
<Button title="Draft from notes" color="primary" onPress={() => setDraftOpen(true)} />
|
|
304
|
+
</View>
|
|
305
|
+
|
|
306
|
+
<DataGrid
|
|
307
|
+
columns={columns}
|
|
308
|
+
groups={groups}
|
|
309
|
+
getRowKey={(t) => t.id}
|
|
310
|
+
leading={{ width: 20, render: (t) => <CheckCircle checked={t.status === "done"} onChange={(on) => patch(t.id, { status: on ? "done" : "todo" })} accessibilityLabel={t.title} /> }}
|
|
311
|
+
sort={sort}
|
|
312
|
+
onSort={onSort}
|
|
313
|
+
collapsed={collapsed}
|
|
314
|
+
onToggleCollapse={toggleCollapse}
|
|
315
|
+
renderGroupFooter={(g) => <TaskAddRow preset={presetFor(groupBy, g.key)} columns={addCols} onAdd={addTask} />}
|
|
316
|
+
/>
|
|
317
|
+
</View>
|
|
318
|
+
</ScrollView>
|
|
319
|
+
|
|
320
|
+
{/* Draft from notes — Composer → AgentRun → ChangeReview → commit (adds tasks). */}
|
|
321
|
+
<Dialog open={draftOpen} onOpenChange={(o) => { if (!o) closeDraft(); }} maxWidth={620}>
|
|
322
|
+
<DialogHeader><DialogHeaderTitle>Draft from notes</DialogHeaderTitle></DialogHeader>
|
|
323
|
+
<DialogScrollArea>
|
|
324
|
+
{phase === "idle" ? (
|
|
325
|
+
<View style={{ gap: 12 }}>
|
|
326
|
+
<Text size="sm" color="muted">Paste meeting notes, a brief, or an email. Lotics drafts tasks from it — nothing is added until you review and keep them.</Text>
|
|
327
|
+
<Composer onSend={startDraft} placeholder="Paste your notes…" autoFocus sendLabel="Draft tasks" />
|
|
328
|
+
</View>
|
|
329
|
+
) : null}
|
|
330
|
+
{phase === "reading" ? <AgentRun items={runItems} state={revealed >= DRAFT_STEPS.length ? "done" : "streaming"} /> : null}
|
|
331
|
+
{phase === "review" ? (
|
|
332
|
+
<ChangeReview
|
|
333
|
+
title="Drafted tasks"
|
|
334
|
+
summary={`${proposals.length} from your notes — keep, edit, or drop each. A “Low” mark is worth a glance.`}
|
|
335
|
+
items={proposals}
|
|
336
|
+
getKey={(p) => p.id}
|
|
337
|
+
statusOf={(p) => decisions[p.id] ?? "pending"}
|
|
338
|
+
renderItem={(p) => <TaskProposal proposal={p} onEdit={editProposal} />}
|
|
339
|
+
renderSummary={(p, kept) => (
|
|
340
|
+
<Text size="sm" numberOfLines={1} color={kept ? "default" : "muted"} style={kept ? undefined : { textDecorationLine: "line-through" }}>{p.title}</Text>
|
|
341
|
+
)}
|
|
342
|
+
onAcceptItem={(i) => decide(proposals[i].id, "accepted")}
|
|
343
|
+
onRejectItem={(i) => decide(proposals[i].id, "rejected")}
|
|
344
|
+
onUndoItem={(i) => decide(proposals[i].id, null)}
|
|
345
|
+
/>
|
|
346
|
+
) : null}
|
|
347
|
+
{phase === "done" ? (
|
|
348
|
+
<CompletionState title={`${addedCount} ${addedCount === 1 ? "task" : "tasks"} added`} summary="They're on the board now." />
|
|
349
|
+
) : null}
|
|
350
|
+
</DialogScrollArea>
|
|
351
|
+
{phase === "review" ? (
|
|
352
|
+
<DialogFooter>
|
|
353
|
+
<ChangeReviewActions items={proposals} statusOf={(p) => decisions[p.id] ?? "pending"} onAcceptItem={(i) => decide(proposals[i].id, "accepted")} onApply={addAccepted} onDiscard={resetDraft} applyLabel={`Add ${acceptedCount} ${acceptedCount === 1 ? "task" : "tasks"}`} />
|
|
354
|
+
</DialogFooter>
|
|
355
|
+
) : phase === "done" ? (
|
|
356
|
+
<DialogFooter>
|
|
357
|
+
<Button title="Draft more" color="secondary" onPress={resetDraft} />
|
|
358
|
+
<Button title="Done" color="primary" onPress={closeDraft} />
|
|
359
|
+
</DialogFooter>
|
|
360
|
+
) : null}
|
|
361
|
+
</Dialog>
|
|
362
|
+
</>
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function TaskProposal({ proposal, onEdit }: { proposal: Proposal; onEdit: (id: string, p: Partial<Proposal>) => void }) {
|
|
367
|
+
return (
|
|
368
|
+
<View style={{ gap: 10 }}>
|
|
369
|
+
<View style={{ flexDirection: "row", alignItems: "flex-start", gap: 12 }}>
|
|
370
|
+
<View style={{ flex: 1 }}>
|
|
371
|
+
<InlineTextInput value={proposal.title} onSave={(v) => onEdit(proposal.id, { title: v })} accessibilityLabel="Task title" />
|
|
372
|
+
</View>
|
|
373
|
+
<View style={{ paddingTop: 10 }}><Confidence level={proposal.confidence} /></View>
|
|
374
|
+
</View>
|
|
375
|
+
<View style={{ width: 220 }}>
|
|
376
|
+
<InlineDatePicker value={proposal.due} optionalTime onSave={(v) => onEdit(proposal.id, { due: v })} placeholder="No date" accessibilityLabel="Due date" />
|
|
377
|
+
</View>
|
|
378
|
+
<View style={styles.source}><Text size="xs" color="muted">{proposal.source}</Text></View>
|
|
379
|
+
</View>
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// In the grid, the cell shows a glanceable thumbnail preview (the first couple of
|
|
384
|
+
// files + a "+N" count, or a paperclip when empty); pressing reveals a popover
|
|
385
|
+
// with the full `FilesEditor` (preview, download, remove, attach, select mode).
|
|
386
|
+
function FilesCell({ files, onAdd, onRemove }: { files: DisplayFile[]; onAdd: (picked: File[]) => void; onRemove: (id: string) => void }) {
|
|
387
|
+
const [open, setOpen] = useState(false);
|
|
388
|
+
return (
|
|
389
|
+
<Popover open={open} onOpenChange={setOpen} side="bottom" align="end">
|
|
390
|
+
<PopoverTrigger>
|
|
391
|
+
<Pressable
|
|
392
|
+
onPress={() => setOpen(true)}
|
|
393
|
+
accessibilityRole="button"
|
|
394
|
+
accessibilityLabel={files.length > 0 ? `${files.length} attachments` : "Add attachment"}
|
|
395
|
+
style={({ hovered }: { hovered?: boolean }) => [styles.clip, hovered ? styles.clipHover : null]}
|
|
396
|
+
>
|
|
397
|
+
{files.length > 0 ? (
|
|
398
|
+
<View pointerEvents="none" style={styles.clipPreview}>
|
|
399
|
+
{files.slice(0, 2).map((f) => <FileThumbnail key={f.id} file={f} size={30} />)}
|
|
400
|
+
{files.length > 2 ? <Text size="xs" weight="medium" color="muted" tabular>+{files.length - 2}</Text> : null}
|
|
401
|
+
</View>
|
|
402
|
+
) : (
|
|
403
|
+
<Icon name="paperclip" size={15} color={colors.zinc[400]} />
|
|
404
|
+
)}
|
|
405
|
+
</Pressable>
|
|
406
|
+
</PopoverTrigger>
|
|
407
|
+
<PopoverContent>
|
|
408
|
+
<View style={{ width: 340, padding: 8 }}>
|
|
409
|
+
<FilesEditor files={files} itemSize={76} onAdd={onAdd} onRemove={onRemove} />
|
|
410
|
+
</View>
|
|
411
|
+
</PopoverContent>
|
|
412
|
+
</Popover>
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function TaskAddRow({ preset, columns, onAdd }: { preset: Partial<Task>; columns: DataGridColumn<Task>[]; onAdd: (t: Omit<Task, "id">) => void }) {
|
|
417
|
+
const [title, setTitle] = useState("");
|
|
418
|
+
const [ownerId, setOwnerId] = useState<string | null>(null);
|
|
419
|
+
const [due, setDue] = useState<string | null>(null);
|
|
420
|
+
const [status, setStatus] = useState<Status>("todo");
|
|
421
|
+
const [tags, setTags] = useState<TagOption[]>([]);
|
|
422
|
+
|
|
423
|
+
const submit = () => {
|
|
424
|
+
const t = title.trim();
|
|
425
|
+
if (!t) return;
|
|
426
|
+
onAdd({ title: t, ownerId, due, status, tags, files: [], ...preset });
|
|
427
|
+
setTitle("");
|
|
428
|
+
setOwnerId(null);
|
|
429
|
+
setDue(null);
|
|
430
|
+
setStatus("todo");
|
|
431
|
+
setTags([]);
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
// Files are added once a task exists (per-row paperclip), so the add row leaves
|
|
435
|
+
// that column empty — the width is still reserved so the columns stay aligned.
|
|
436
|
+
const editor = (key: string) =>
|
|
437
|
+
key === "assignee" ? <InlineMemberSelect members={MEMBERS} value={ownerId} onSave={setOwnerId} placeholder="Assignee" accessibilityLabel="Assignee" />
|
|
438
|
+
: key === "due" ? <InlineDatePicker value={due} optionalTime onSave={setDue} placeholder="No date" accessibilityLabel="Due date" />
|
|
439
|
+
: key === "status" ? <InlineSelect value={status} options={STATUS_OPTIONS} onSave={setStatus} renderSelected={renderStatusBadge} renderOptionContent={renderStatusBadge} accessibilityLabel="Status" />
|
|
440
|
+
: key === "tags" ? <Select multi searchable allowCustom value={tags.map((tag) => tag.value)} onValueChange={(next) => setTags(next.map(tagOf))} options={TAG_OPTIONS} renderSelected={renderTagBadge} renderOptionContent={renderTagBadge} style={{ borderColor: "transparent" }} placeholder="Add tags" accessibilityLabel="Tags" />
|
|
441
|
+
: null;
|
|
442
|
+
|
|
443
|
+
return (
|
|
444
|
+
<View style={[gridRowStyle, styles.add]}>
|
|
445
|
+
<View style={styles.ring} />
|
|
446
|
+
<View style={styles.addField}>
|
|
447
|
+
<View style={{ flex: 1 }}>
|
|
448
|
+
<TextInputField value={title} onChangeText={setTitle} onSubmitEditing={submit} placeholder="Add a task…" style={styles.input} accessibilityLabel="Add a task" />
|
|
449
|
+
</View>
|
|
450
|
+
{title.trim() ? <Button title="Save" color="primary" onPress={submit} /> : null}
|
|
451
|
+
</View>
|
|
452
|
+
{columns.map((c) => (
|
|
453
|
+
<View key={c.key} style={{ width: c.width }}>{editor(c.key)}</View>
|
|
454
|
+
))}
|
|
455
|
+
</View>
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const styles = StyleSheet.create({
|
|
460
|
+
bucketHead: { letterSpacing: 0.3, textTransform: "uppercase" },
|
|
461
|
+
add: { opacity: 0.9 },
|
|
462
|
+
ring: { width: 20, height: 20, borderRadius: 10, borderWidth: 1.5, borderColor: colors.zinc[300], borderStyle: "dashed" },
|
|
463
|
+
input: { borderWidth: 0, backgroundColor: "transparent" },
|
|
464
|
+
addField: { flex: 1, flexDirection: "row", alignItems: "center", gap: 8, paddingRight: 4 },
|
|
465
|
+
// Match the inline-editor cells' resting box (INLINE_CONTROL_HEIGHT = 40) so the
|
|
466
|
+
// files field lines up with the others — and that height affords a larger thumbnail.
|
|
467
|
+
clip: { flexDirection: "row", alignItems: "center", gap: 6, minHeight: 40, paddingHorizontal: 8, borderRadius: 8, cursor: "pointer" },
|
|
468
|
+
clipHover: { backgroundColor: colors.zinc[100] },
|
|
469
|
+
clipPreview: { flexDirection: "row", alignItems: "center", gap: 6 },
|
|
470
|
+
summary: { flexDirection: "row", alignItems: "center", gap: 4 },
|
|
471
|
+
summaryDot: { width: 8, height: 8, borderRadius: 999 },
|
|
472
|
+
source: { borderLeftWidth: 2, borderLeftColor: colors.zinc[200], paddingLeft: 10 },
|
|
473
|
+
});
|