@lotics/ui 12.1.2 → 13.7.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 +5 -0
- package/docs/catalog.md +75 -33
- package/docs/composition.md +61 -24
- package/docs/data_entry.md +19 -17
- package/docs/templates.md +71 -76
- package/examples/tpl_item_list.tsx +6 -7
- package/examples/tpl_record.tsx +131 -188
- package/examples/tpl_task_board.tsx +20 -22
- package/package.json +3 -2
- package/src/badge.tsx +26 -10
- package/src/checklist.tsx +1 -1
- package/src/detail_row.tsx +24 -8
- package/src/drawer.tsx +10 -2
- package/src/form_date_picker.tsx +2 -1
- package/src/form_field.tsx +23 -3
- package/src/form_picker.tsx +2 -1
- package/src/form_switch.tsx +16 -2
- package/src/form_text_input.tsx +4 -4
- package/src/inline_date_picker.tsx +19 -9
- package/src/inline_edit.tsx +43 -24
- package/src/inline_member_select.tsx +43 -10
- package/src/inline_number_input.tsx +4 -4
- package/src/inline_select.tsx +193 -87
- package/src/inline_text_input.tsx +4 -4
- package/src/inline_time_picker.tsx +4 -4
- package/src/inset.tsx +38 -0
- package/src/linked_record_box.tsx +102 -0
- package/src/number_input.tsx +1 -1
- package/src/radio_picker.tsx +18 -28
- package/src/text_input_field.tsx +3 -3
- package/examples/tpl_tasks.tsx +0 -456
- package/src/inline_tag_select.tsx +0 -140
package/src/radio_picker.tsx
CHANGED
|
@@ -23,15 +23,10 @@ export interface RadioPickerProps<T extends string | number | symbol> {
|
|
|
23
23
|
options: RadioPickerOption<T>[];
|
|
24
24
|
value: T;
|
|
25
25
|
onValueChange: (value: T) => void;
|
|
26
|
-
/** "column" (default) stacks full-width rows — right when options carry
|
|
27
|
-
* descriptions. "row" wraps compact options inline — right for short,
|
|
28
|
-
* description-less choices (a quick-entry form), where a six-option
|
|
29
|
-
* column would push the rest of the form below the fold. */
|
|
30
|
-
direction?: "column" | "row";
|
|
31
26
|
}
|
|
32
27
|
|
|
33
28
|
export function RadioPicker<T extends string | number | symbol>(props: RadioPickerProps<T>) {
|
|
34
|
-
const { accessibilityLabel, options, value, onValueChange
|
|
29
|
+
const { accessibilityLabel, options, value, onValueChange } = props;
|
|
35
30
|
const itemRefs = useRef<Array<View | null>>([]);
|
|
36
31
|
|
|
37
32
|
// Roving tabindex: arrow keys move focus between options and select, matching
|
|
@@ -71,11 +66,7 @@ export function RadioPicker<T extends string | number | symbol>(props: RadioPick
|
|
|
71
66
|
const tabStopIndex = selectedIndex === -1 ? 0 : selectedIndex;
|
|
72
67
|
|
|
73
68
|
return (
|
|
74
|
-
<View
|
|
75
|
-
accessibilityRole="radiogroup"
|
|
76
|
-
accessibilityLabel={accessibilityLabel}
|
|
77
|
-
style={direction === "row" ? { flexDirection: "row", flexWrap: "wrap", gap: 4 } : undefined}
|
|
78
|
-
>
|
|
69
|
+
<View accessibilityRole="radiogroup" accessibilityLabel={accessibilityLabel}>
|
|
79
70
|
{options.map((option, index) => (
|
|
80
71
|
<RadioOption
|
|
81
72
|
ref={(node: View | null) => {
|
|
@@ -86,7 +77,6 @@ export function RadioPicker<T extends string | number | symbol>(props: RadioPick
|
|
|
86
77
|
value={option.value}
|
|
87
78
|
description={option.description}
|
|
88
79
|
testID={option.testID}
|
|
89
|
-
compact={direction === "row"}
|
|
90
80
|
selected={value === option.value}
|
|
91
81
|
isTabStop={index === tabStopIndex}
|
|
92
82
|
onSelect={() => onValueChange(option.value)}
|
|
@@ -100,14 +90,13 @@ export function RadioPicker<T extends string | number | symbol>(props: RadioPick
|
|
|
100
90
|
function RadioOption<T extends string | number | symbol>(
|
|
101
91
|
props: RadioPickerOption<T> & {
|
|
102
92
|
ref: (node: View | null) => void;
|
|
103
|
-
compact: boolean;
|
|
104
93
|
selected: boolean;
|
|
105
94
|
isTabStop: boolean;
|
|
106
95
|
onSelect: () => void;
|
|
107
96
|
onKeyDown: (event: { key: string; preventDefault?: () => void }) => void;
|
|
108
97
|
},
|
|
109
98
|
) {
|
|
110
|
-
const { ref, label, description,
|
|
99
|
+
const { ref, label, description, selected, isTabStop, onSelect, value, testID, onKeyDown } = props;
|
|
111
100
|
|
|
112
101
|
const handlePress = useCallback(() => {
|
|
113
102
|
onSelect();
|
|
@@ -116,13 +105,14 @@ function RadioOption<T extends string | number | symbol>(
|
|
|
116
105
|
return (
|
|
117
106
|
<PressableHighlight
|
|
118
107
|
focusRing
|
|
119
|
-
ref={ref}
|
|
108
|
+
ref={ref}
|
|
109
|
+
testID={testID}
|
|
120
110
|
style={{
|
|
121
111
|
flexDirection: "row",
|
|
122
112
|
alignItems: "center",
|
|
123
|
-
padding:
|
|
113
|
+
padding: 12,
|
|
124
114
|
borderRadius: CONTROL_RADIUS,
|
|
125
|
-
gap:
|
|
115
|
+
gap: 16,
|
|
126
116
|
}}
|
|
127
117
|
onPress={handlePress}
|
|
128
118
|
accessibilityRole="radio"
|
|
@@ -136,8 +126,8 @@ function RadioOption<T extends string | number | symbol>(
|
|
|
136
126
|
>
|
|
137
127
|
<View
|
|
138
128
|
style={{
|
|
139
|
-
width:
|
|
140
|
-
height:
|
|
129
|
+
width: 28,
|
|
130
|
+
height: 28,
|
|
141
131
|
borderRadius: 999,
|
|
142
132
|
borderWidth: 1,
|
|
143
133
|
borderColor: colors.border,
|
|
@@ -146,15 +136,16 @@ function RadioOption<T extends string | number | symbol>(
|
|
|
146
136
|
alignItems: "center",
|
|
147
137
|
}}
|
|
148
138
|
>
|
|
149
|
-
{selected && <Icon name="check" size={
|
|
139
|
+
{selected && <Icon name="check" size={24} color={getTextColor("inverted")} />}
|
|
150
140
|
</View>
|
|
151
|
-
{/* The text column is width-CONSTRAINED so a long
|
|
152
|
-
inside the pressable
|
|
153
|
-
the hover/press surface
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
141
|
+
{/* The text column is width-CONSTRAINED (flex:1, minWidth:0) so a long
|
|
142
|
+
description WRAPS inside the pressable — unconstrained, its intrinsic
|
|
143
|
+
width would overflow the hover/press surface. */}
|
|
144
|
+
<View style={styles.text}>
|
|
145
|
+
<Text>{label}</Text>
|
|
146
|
+
{/* the annotation standard (FormField metrics): a step smaller and
|
|
147
|
+
lighter than the label, so the choice reads first */}
|
|
148
|
+
{!!description && <Text size="sm" color="zinc-500">{description}</Text>}
|
|
158
149
|
</View>
|
|
159
150
|
</PressableHighlight>
|
|
160
151
|
);
|
|
@@ -162,5 +153,4 @@ function RadioOption<T extends string | number | symbol>(
|
|
|
162
153
|
|
|
163
154
|
const styles = StyleSheet.create({
|
|
164
155
|
text: { flex: 1, minWidth: 0 },
|
|
165
|
-
textCompact: { flexShrink: 1, minWidth: 0 },
|
|
166
156
|
});
|
package/src/text_input_field.tsx
CHANGED
|
@@ -80,9 +80,9 @@ export function TextInputField(props: TextInputFieldProps) {
|
|
|
80
80
|
// Tracked on the wrapping View (RN-Web forwards mouse events there reliably).
|
|
81
81
|
const { hovered, hoverProps } = useHover();
|
|
82
82
|
|
|
83
|
-
// Describedby chains description and error so
|
|
84
|
-
// explicitly here because React Native Web does not flatten array attrs.
|
|
85
|
-
const describedBy = [binding?.descriptionId, binding?.errorId].filter(Boolean).join(" ") || undefined;
|
|
83
|
+
// Describedby chains description, warning, and error so all are read. We join
|
|
84
|
+
// them explicitly here because React Native Web does not flatten array attrs.
|
|
85
|
+
const describedBy = [binding?.descriptionId, binding?.warningId, binding?.errorId].filter(Boolean).join(" ") || undefined;
|
|
86
86
|
|
|
87
87
|
const minHeight =
|
|
88
88
|
numberOfLines && numberOfLines > 1
|
package/examples/tpl_tasks.tsx
DELETED
|
@@ -1,456 +0,0 @@
|
|
|
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 { SearchInput } from "@lotics/ui/search_input";
|
|
9
|
-
import { FilterChip } from "@lotics/ui/filter_chip";
|
|
10
|
-
import { OptionList } from "@lotics/ui/option_list";
|
|
11
|
-
import { InlineTextInput } from "@lotics/ui/inline_text_input";
|
|
12
|
-
import { InlineDatePicker } from "@lotics/ui/inline_date_picker";
|
|
13
|
-
import { InlineSelect } from "@lotics/ui/inline_select";
|
|
14
|
-
import { Select } from "@lotics/ui/select";
|
|
15
|
-
import { CaptureRow } from "@lotics/ui/capture_row";
|
|
16
|
-
import { Button } from "@lotics/ui/button";
|
|
17
|
-
import { FilesEditor } from "@lotics/ui/files_editor";
|
|
18
|
-
import type { DisplayFile } from "@lotics/ui/file_thumbnail";
|
|
19
|
-
import { Composer } from "@lotics/ui/composer";
|
|
20
|
-
import { AgentRun, type AgentRunItem } from "@lotics/ui/agent_run";
|
|
21
|
-
import { Change, ChangeReview, ChangeReviewActions, ChangeReviewHeader, ChangeSummary, type ChangeStatus } from "@lotics/ui/change_review";
|
|
22
|
-
import { Dialog, DialogHeader, DialogHeaderTitle, DialogScrollArea, DialogFooter } from "@lotics/ui/dialog";
|
|
23
|
-
import { CompletionState } from "@lotics/ui/completion_state";
|
|
24
|
-
import { Confidence, type ConfidenceLevel } from "@lotics/ui/confidence";
|
|
25
|
-
import { formatDate, toISODate } from "@lotics/ui/format_date";
|
|
26
|
-
|
|
27
|
-
type TagOption = { value: string; label: string };
|
|
28
|
-
|
|
29
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
30
|
-
// Template · Task list — the Apple-Reminders shape for PERSONAL lists.
|
|
31
|
-
// There's no Task component: a row is composed directly from primitives
|
|
32
|
-
// (CheckCircle + an inline-editable title + a quiet meta line), which is the most
|
|
33
|
-
// flexible way to build a task surface. Quiet, scannable rows you tick to finish
|
|
34
|
-
// and tap to expand in place; the note sits below the title in a light tone. No
|
|
35
|
-
// assignee (the list is yours). The sibling tpl_task_board is the Linear columns
|
|
36
|
-
// shape for a manager. Tick the ring → done; tap the row → expand; click the title
|
|
37
|
-
// → rename in place.
|
|
38
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
39
|
-
|
|
40
|
-
type Status = "todo" | "doing" | "done";
|
|
41
|
-
const STATUS: Record<Status, OptionValue> = {
|
|
42
|
-
todo: { key: "todo", label: "To do", color: null },
|
|
43
|
-
doing: { key: "doing", label: "Doing", color: "amber" },
|
|
44
|
-
done: { key: "done", label: "Done", color: "emerald" },
|
|
45
|
-
};
|
|
46
|
-
const STATUS_OPTIONS = (Object.keys(STATUS) as Status[]).map((s) => ({ value: s, label: STATUS[s].label }));
|
|
47
|
-
|
|
48
|
-
const TAGS: Record<string, OptionValue> = {
|
|
49
|
-
client: { key: "client", label: "Client", color: "blue" },
|
|
50
|
-
design: { key: "design", label: "Design", color: "cyan" },
|
|
51
|
-
print: { key: "print", label: "Print", color: "amber" },
|
|
52
|
-
urgent: { key: "urgent", label: "Urgent", color: "rose" },
|
|
53
|
-
};
|
|
54
|
-
const TAG_OPTIONS: TagOption[] = Object.values(TAGS).map((t) => ({ value: String(t.key), label: t.label }));
|
|
55
|
-
const tagOf = (k: string): TagOption => ({ value: k, label: TAGS[k]?.label ?? k });
|
|
56
|
-
const tagBadge = (t: TagOption): OptionValue => TAGS[t.value] ?? { key: t.value, label: t.label, color: null };
|
|
57
|
-
const tagColor = (t: TagOption): string => solid(asColorName(TAGS[t.value]?.color));
|
|
58
|
-
const renderTagBadge = (o: { value: string; label?: string }) => <OptionBadge value={tagBadge({ value: o.value, label: o.label ?? o.value })} variant="dot" />;
|
|
59
|
-
|
|
60
|
-
interface Task {
|
|
61
|
-
id: string;
|
|
62
|
-
title: string;
|
|
63
|
-
due: string | null;
|
|
64
|
-
status: Status;
|
|
65
|
-
tags: TagOption[];
|
|
66
|
-
note: string;
|
|
67
|
-
files: DisplayFile[];
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
const day = (n: number): string => {
|
|
71
|
-
const d = new Date();
|
|
72
|
-
d.setDate(d.getDate() + n);
|
|
73
|
-
return toISODate(d);
|
|
74
|
-
};
|
|
75
|
-
|
|
76
|
-
// A self-contained SVG data-URI image so seeds preview offline in the gallery.
|
|
77
|
-
const sampleImage = (label: string, fill: string): DisplayFile => ({
|
|
78
|
-
id: `seed-${label}`,
|
|
79
|
-
filename: `${label}.png`,
|
|
80
|
-
mimeType: "image/png",
|
|
81
|
-
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>`),
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
// New attachments map a picked browser File → a DisplayFile (local object URL).
|
|
85
|
-
// A host wires its own upload here; the template just previews in place.
|
|
86
|
-
let fileSeq = 0;
|
|
87
|
-
const toDisplayFiles = (files: File[]): DisplayFile[] =>
|
|
88
|
-
files.map((f) => ({ id: `f${++fileSeq}`, filename: f.name, mimeType: f.type || "application/octet-stream", url: URL.createObjectURL(f) }));
|
|
89
|
-
|
|
90
|
-
const SEED: Task[] = [
|
|
91
|
-
{ id: "t1", title: "Send VITTORIA their quote", due: day(-2), status: "doing", tags: [tagOf("client"), tagOf("urgent")], note: "Waiting on production to confirm quantities.", files: [sampleImage("quote-draft", "#3b82f6")] },
|
|
92
|
-
{ id: "t2", title: "Finalize the gift-box packaging design", due: day(0), status: "doing", tags: [tagOf("design")], note: "", files: [] },
|
|
93
|
-
{ id: "t3", title: "Sign off the die-cut sample", due: `${day(0)}T15:00`, status: "todo", tags: [tagOf("design"), tagOf("print")], note: "Workshop visit booked for 3pm.", files: [sampleImage("die-cut", "#10b981"), sampleImage("sample-2", "#f59e0b")] },
|
|
94
|
-
{ id: "t4", title: "Order 300g ivory board stock", due: day(3), status: "todo", tags: [tagOf("print")], note: "", files: [] },
|
|
95
|
-
{ id: "t5", title: "Schedule the first delivery batch", due: day(9), status: "todo", tags: [], note: "", files: [] },
|
|
96
|
-
{ id: "t6", title: "Draft the 2026 print contract", due: null, status: "todo", tags: [tagOf("client")], note: "", files: [] },
|
|
97
|
-
{ id: "t7", title: "Update the Q3 price list", due: day(-5), status: "done", tags: [], note: "Sent to the sales team.", files: [] },
|
|
98
|
-
];
|
|
99
|
-
|
|
100
|
-
type Group = "due" | "status";
|
|
101
|
-
const GROUP_OPTIONS: { value: Group; label: string }[] = [
|
|
102
|
-
{ value: "due", label: "Due date" },
|
|
103
|
-
{ value: "status", label: "Status" },
|
|
104
|
-
];
|
|
105
|
-
type SectionKey = "overdue" | "today" | "week" | "later" | "none" | "done" | Status;
|
|
106
|
-
const DUE_SECTIONS: { key: SectionKey; label: string }[] = [
|
|
107
|
-
{ key: "overdue", label: "Overdue" },
|
|
108
|
-
{ key: "today", label: "Today" },
|
|
109
|
-
{ key: "week", label: "This week" },
|
|
110
|
-
{ key: "later", label: "Later" },
|
|
111
|
-
{ key: "none", label: "No date" },
|
|
112
|
-
{ key: "done", label: "Done" },
|
|
113
|
-
];
|
|
114
|
-
const STATUS_SECTIONS: { key: SectionKey; label: string }[] = [
|
|
115
|
-
{ key: "todo", label: "To do" },
|
|
116
|
-
{ key: "doing", label: "Doing" },
|
|
117
|
-
{ key: "done", label: "Done" },
|
|
118
|
-
];
|
|
119
|
-
|
|
120
|
-
function dueBucket(due: string | null): SectionKey {
|
|
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
|
-
function dueInfo(due: string | null): { label: string; overdue: boolean } | null {
|
|
130
|
-
if (!due) return null;
|
|
131
|
-
return { label: formatDate(due, { format: "dayMonth" }), overdue: due.slice(0, 10) < day(0) };
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
// ── AI · draft tasks from notes — agent proposes, you keep/edit/drop ──────────
|
|
135
|
-
type Phase = "idle" | "reading" | "review" | "done";
|
|
136
|
-
interface Proposal { id: string; title: string; due: string | null; confidence: ConfidenceLevel; source: string }
|
|
137
|
-
const DRAFT_STEPS: { label: string; detail: string }[] = [
|
|
138
|
-
{ label: "Split the notes into candidate items", detail: "6 lines" },
|
|
139
|
-
{ label: "Drop greetings and lines that aren't actions", detail: "kept 4" },
|
|
140
|
-
{ label: "Infer a due date from each phrase", detail: "2 dated · 2 left open" },
|
|
141
|
-
];
|
|
142
|
-
const DRAFTED: Proposal[] = [
|
|
143
|
-
{ id: "d1", title: "Send VITTORIA the revised quote", due: day(1), confidence: "high", source: "“…told VITTORIA they'd have the updated quote by tomorrow.”" },
|
|
144
|
-
{ id: "d2", title: "Re-check the die-cut sample size with the workshop", due: day(3), confidence: "medium", source: "“…workshop to measure the sample again, maybe Thursday.”" },
|
|
145
|
-
{ id: "d3", title: "Order 300g ivory board for the gift-box run", due: null, confidence: "high", source: "“…order the ivory stock this week.”" },
|
|
146
|
-
{ id: "d4", title: "Sit in on the Tet carton press check", due: null, confidence: "low", source: "“…someone should be there for the Tet print run.”" },
|
|
147
|
-
];
|
|
148
|
-
|
|
149
|
-
export function TplTasks() {
|
|
150
|
-
const [tasks, setTasks] = useState<Task[]>(SEED);
|
|
151
|
-
const [group, setGroup] = useState<Group | null>("due"); // null = ungrouped (cleared)
|
|
152
|
-
const [expandedIds, setExpandedIds] = useState<Set<string>>(() => new Set());
|
|
153
|
-
const toggleExpand = (id: string, open: boolean) =>
|
|
154
|
-
setExpandedIds((s) => { const n = new Set(s); if (open) n.add(id); else n.delete(id); return n; });
|
|
155
|
-
const [query, setQuery] = useState("");
|
|
156
|
-
const [tagFilter, setTagFilter] = useState<string[]>([]);
|
|
157
|
-
const [draft, setDraft] = useState("");
|
|
158
|
-
|
|
159
|
-
const [draftOpen, setDraftOpen] = useState(false);
|
|
160
|
-
const [phase, setPhase] = useState<Phase>("idle");
|
|
161
|
-
const [revealed, setRevealed] = useState(0);
|
|
162
|
-
const [proposals, setProposals] = useState<Proposal[]>([]);
|
|
163
|
-
const [decisions, setDecisions] = useState<Record<string, ChangeStatus>>({});
|
|
164
|
-
const [addedCount, setAddedCount] = useState(0);
|
|
165
|
-
|
|
166
|
-
const patch = (id: string, next: Partial<Task>) =>
|
|
167
|
-
setTasks((ts) => ts.map((t) => (t.id === id ? { ...t, ...next } : t)));
|
|
168
|
-
const add = () => {
|
|
169
|
-
const title = draft.trim();
|
|
170
|
-
if (!title) return;
|
|
171
|
-
setTasks((ts) => [...ts, { id: `n${ts.length}-${title.length}`, title, due: null, status: "todo", tags: [], note: "", files: [] }]);
|
|
172
|
-
setDraft("");
|
|
173
|
-
};
|
|
174
|
-
const addFiles = (id: string, picked: File[]) =>
|
|
175
|
-
setTasks((ts) => ts.map((t) => (t.id === id ? { ...t, files: [...t.files, ...toDisplayFiles(picked)] } : t)));
|
|
176
|
-
const removeFile = (id: string, fileId: string) =>
|
|
177
|
-
setTasks((ts) => ts.map((t) => (t.id === id ? { ...t, files: t.files.filter((f) => f.id !== fileId) } : t)));
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
const startDraft = useCallback(() => { setRevealed(0); setPhase("reading"); }, []);
|
|
181
|
-
const resetDraft = useCallback(() => { setProposals([]); setDecisions({}); setRevealed(0); setPhase("idle"); }, []);
|
|
182
|
-
const closeDraft = useCallback(() => { setDraftOpen(false); resetDraft(); }, [resetDraft]);
|
|
183
|
-
const editProposal = useCallback((id: string, p: Partial<Proposal>) =>
|
|
184
|
-
setProposals((ps) => ps.map((x) => (x.id === id ? { ...x, ...p } : x))), []);
|
|
185
|
-
const decide = useCallback((id: string, status: ChangeStatus | null) =>
|
|
186
|
-
setDecisions((dec) => { const n = { ...dec }; if (status) n[id] = status; else delete n[id]; return n; }), []);
|
|
187
|
-
const acceptedCount = proposals.filter((p) => decisions[p.id] === "accepted").length;
|
|
188
|
-
const addAccepted = useCallback(() => {
|
|
189
|
-
const accepted = proposals.filter((p) => decisions[p.id] === "accepted");
|
|
190
|
-
setTasks((ts) => [...ts, ...accepted.map((p) => ({ id: `d-${p.id}`, title: p.title, due: p.due, status: "todo" as Status, tags: [], note: "", files: [] }))]);
|
|
191
|
-
setAddedCount(accepted.length);
|
|
192
|
-
setPhase("done");
|
|
193
|
-
}, [proposals, decisions]);
|
|
194
|
-
|
|
195
|
-
useEffect(() => {
|
|
196
|
-
if (phase !== "reading") return;
|
|
197
|
-
if (revealed >= DRAFT_STEPS.length) {
|
|
198
|
-
const t = setTimeout(() => { setProposals(DRAFTED); setPhase("review"); }, 850);
|
|
199
|
-
return () => clearTimeout(t);
|
|
200
|
-
}
|
|
201
|
-
const t = setTimeout(() => setRevealed((r) => r + 1), revealed === 0 ? 280 : 680);
|
|
202
|
-
return () => clearTimeout(t);
|
|
203
|
-
}, [phase, revealed]);
|
|
204
|
-
|
|
205
|
-
const runItems: AgentRunItem[] = [
|
|
206
|
-
{ type: "text", id: "intro", text: "I read your notes and pulled out the work." },
|
|
207
|
-
...DRAFT_STEPS.slice(0, Math.min(revealed + 1, DRAFT_STEPS.length)).map((s, i): AgentRunItem => ({
|
|
208
|
-
type: "step", id: `s${i}`, label: s.label, detail: i < revealed ? s.detail : undefined, status: i < revealed ? "done" : "running",
|
|
209
|
-
})),
|
|
210
|
-
];
|
|
211
|
-
if (revealed >= DRAFT_STEPS.length) {
|
|
212
|
-
runItems.push({ type: "text", id: "outro", text: `Drafted ${DRAFTED.length} tasks — keep the ones that look right, edit any before adding.` });
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
const sections = useMemo(() => {
|
|
216
|
-
const q = query.trim().toLowerCase();
|
|
217
|
-
const pool = tasks.filter((t) => {
|
|
218
|
-
if (q && !(t.title.toLowerCase().includes(q) || t.tags.some((tag) => tag.label.toLowerCase().includes(q)))) return false;
|
|
219
|
-
if (tagFilter.length > 0 && !t.tags.some((tag) => tagFilter.includes(tag.value))) return false;
|
|
220
|
-
return true;
|
|
221
|
-
});
|
|
222
|
-
const byDue = (a: Task, b: Task) => (a.due ?? "9999").localeCompare(b.due ?? "9999");
|
|
223
|
-
if (!group) {
|
|
224
|
-
return [{ key: "all" as SectionKey, label: "", items: [...pool].sort(byDue) }];
|
|
225
|
-
}
|
|
226
|
-
const defs = group === "due" ? DUE_SECTIONS : STATUS_SECTIONS;
|
|
227
|
-
const inBucket = (t: Task, key: SectionKey) =>
|
|
228
|
-
group === "due" ? (t.status === "done" ? key === "done" : dueBucket(t.due) === key) : t.status === key;
|
|
229
|
-
return defs
|
|
230
|
-
.map((s) => ({ ...s, items: pool.filter((t) => inBucket(t, s.key)).sort(byDue) }))
|
|
231
|
-
.filter((s) => s.items.length > 0);
|
|
232
|
-
}, [tasks, group, query, tagFilter]);
|
|
233
|
-
|
|
234
|
-
return (
|
|
235
|
-
<>
|
|
236
|
-
<ScrollView style={{ flex: 1, backgroundColor: colors.white }} contentContainerStyle={{ paddingVertical: 28, paddingHorizontal: 20, paddingBottom: 96 }}>
|
|
237
|
-
<View style={{ maxWidth: 680, width: "100%", alignSelf: "center", gap: 14 }}>
|
|
238
|
-
<Text size="xxl" weight="semibold">Tasks</Text>
|
|
239
|
-
|
|
240
|
-
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
241
|
-
<View style={{ flexDirection: "row", flexWrap: "wrap", alignItems: "center", gap: 8, flex: 1 }}>
|
|
242
|
-
<View style={{ flexGrow: 1, flexBasis: 240, minWidth: 200, maxWidth: 360 }}>
|
|
243
|
-
<SearchInput value={query} onChangeText={setQuery} placeholder="Search tasks" accessibilityLabel="Search tasks" />
|
|
244
|
-
</View>
|
|
245
|
-
<FilterChip label="Group by" summary={group ? GROUP_OPTIONS.find((g) => g.value === group)?.label : undefined} onClear={() => setGroup(null)} clearLabel="Clear grouping">
|
|
246
|
-
{({ close }) => <OptionList search={{ mode: "none" }} options={GROUP_OPTIONS} value={group ?? undefined} onValueChange={(v) => v && setGroup(v)} onRequestClose={close} />}
|
|
247
|
-
</FilterChip>
|
|
248
|
-
<FilterChip
|
|
249
|
-
label="Tag"
|
|
250
|
-
summary={tagFilter.length > 0 ? <View style={styles.dots}>{tagFilter.map((v) => <View key={v} style={[styles.dot, { backgroundColor: tagColor(tagOf(v)) }]} />)}</View> : undefined}
|
|
251
|
-
onClear={() => setTagFilter([])}
|
|
252
|
-
clearLabel="Clear tag filter"
|
|
253
|
-
>
|
|
254
|
-
<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" />} />
|
|
255
|
-
</FilterChip>
|
|
256
|
-
</View>
|
|
257
|
-
<Button title="Draft from notes" color="primary" onPress={() => setDraftOpen(true)} />
|
|
258
|
-
</View>
|
|
259
|
-
|
|
260
|
-
{/* Capture — type to add; the submit button lives inside the field on the right. */}
|
|
261
|
-
<View style={styles.capture}>
|
|
262
|
-
<CaptureRow value={draft} onChangeText={setDraft} onSubmit={add} placeholder="Add a task…" accessibilityLabel="Add a task" />
|
|
263
|
-
</View>
|
|
264
|
-
|
|
265
|
-
<View>
|
|
266
|
-
{sections.map((s) => (
|
|
267
|
-
<View key={s.key} style={{ marginBottom: 16 }}>
|
|
268
|
-
{s.label ? (
|
|
269
|
-
<View style={styles.sectionHead}>
|
|
270
|
-
<Text size="xs" weight="semibold" color={s.key === "overdue" ? "danger" : "muted"} style={styles.sectionLabel}>{s.label}</Text>
|
|
271
|
-
<Text size="xs" color="muted" tabular>{s.items.length}</Text>
|
|
272
|
-
</View>
|
|
273
|
-
) : null}
|
|
274
|
-
{s.items.map((t) => {
|
|
275
|
-
const open = expandedIds.has(t.id);
|
|
276
|
-
const done = t.status === "done";
|
|
277
|
-
const d = dueInfo(t.due);
|
|
278
|
-
return (
|
|
279
|
-
<View key={t.id}>
|
|
280
|
-
{/* A row is NOT a single button: the ring, the title, and the expand
|
|
281
|
-
affordance are SIBLING controls in a plain View. Wrapping the row in a
|
|
282
|
-
Pressable accessibilityRole="button" while nesting CheckCircle +
|
|
283
|
-
InlineTextInput (each renders a <button> on web) would put <button>s
|
|
284
|
-
inside a <button> — invalid DOM + the inner controls drop out of the
|
|
285
|
-
keyboard tab order. So the expand affordance is its own Pressable. */}
|
|
286
|
-
<View style={styles.row}>
|
|
287
|
-
<CheckCircle checked={done} onChange={(on) => patch(t.id, { status: on ? "done" : "todo" })} accessibilityLabel={t.title} />
|
|
288
|
-
<View style={{ flex: 1 }}>
|
|
289
|
-
<InlineTextInput background="transparent" value={t.title} onSave={(v) => patch(t.id, { title: v })} struck={done} accessibilityLabel="Title" />
|
|
290
|
-
</View>
|
|
291
|
-
<Pressable
|
|
292
|
-
onPress={() => toggleExpand(t.id, !open)}
|
|
293
|
-
accessibilityRole="button"
|
|
294
|
-
accessibilityLabel={open ? `Collapse ${t.title}` : `Expand ${t.title}`}
|
|
295
|
-
style={({ hovered }: { hovered?: boolean }) => [styles.expand, hovered ? styles.rowHover : null]}
|
|
296
|
-
>
|
|
297
|
-
<View style={styles.meta}>
|
|
298
|
-
{t.files.length > 0 ? (
|
|
299
|
-
<View style={styles.clip}>
|
|
300
|
-
<Icon name="paperclip" size={13} color={colors.zinc[400]} />
|
|
301
|
-
<Text size="xs" color="muted" tabular>{t.files.length}</Text>
|
|
302
|
-
</View>
|
|
303
|
-
) : null}
|
|
304
|
-
{t.tags.length > 0 ? (
|
|
305
|
-
<View style={styles.dots}>{t.tags.map((tag) => <View key={tag.value} style={[styles.dot, { backgroundColor: tagColor(tag) }]} />)}</View>
|
|
306
|
-
) : null}
|
|
307
|
-
{d ? <Text size="xs" weight="medium" color={d.overdue && !done ? "danger" : "muted"}>{d.label}</Text> : null}
|
|
308
|
-
</View>
|
|
309
|
-
<Icon name={open ? "chevron-up" : "chevron-down"} size={16} color={colors.zinc[300]} />
|
|
310
|
-
</Pressable>
|
|
311
|
-
</View>
|
|
312
|
-
{open ? (
|
|
313
|
-
<View style={styles.detail}>
|
|
314
|
-
<Field label="Due">
|
|
315
|
-
<InlineDatePicker background="transparent" value={t.due} optionalTime onSave={(v) => patch(t.id, { due: v })} placeholder="Set a due date" accessibilityLabel="Due date" />
|
|
316
|
-
</Field>
|
|
317
|
-
<Field label="Status">
|
|
318
|
-
<InlineSelect background="transparent" value={t.status} options={STATUS_OPTIONS} onSave={(st) => patch(t.id, { status: st })} renderSelected={(o) => <OptionBadge value={STATUS[o.value as Status]} variant="dot" />} renderOptionContent={(o) => <OptionBadge value={STATUS[o.value as Status]} variant="dot" />} accessibilityLabel="Status" />
|
|
319
|
-
</Field>
|
|
320
|
-
<Field label="Tags">
|
|
321
|
-
<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" />
|
|
322
|
-
</Field>
|
|
323
|
-
<Field label="Note">
|
|
324
|
-
<InlineTextInput background="transparent" value={t.note} onSave={(v) => patch(t.id, { note: v })} placeholder="Add a note…" accessibilityLabel="Note" />
|
|
325
|
-
</Field>
|
|
326
|
-
<Field label="Files" top>
|
|
327
|
-
<FilesEditor files={t.files} itemSize={72} onAdd={(picked) => addFiles(t.id, picked)} onRemove={(id) => removeFile(t.id, id)} />
|
|
328
|
-
</Field>
|
|
329
|
-
</View>
|
|
330
|
-
) : null}
|
|
331
|
-
</View>
|
|
332
|
-
);
|
|
333
|
-
})}
|
|
334
|
-
</View>
|
|
335
|
-
))}
|
|
336
|
-
</View>
|
|
337
|
-
</View>
|
|
338
|
-
</ScrollView>
|
|
339
|
-
|
|
340
|
-
{/* Draft from notes — Composer → AgentRun → ChangeReview → commit. The review
|
|
341
|
-
provider wraps the WHOLE dialog so the `Change`s (scroll area) and the
|
|
342
|
-
commit bar (`DialogFooter`) share one review context. */}
|
|
343
|
-
<ChangeReview>
|
|
344
|
-
<Dialog open={draftOpen} onOpenChange={(o) => { if (!o) closeDraft(); }} maxWidth={620}>
|
|
345
|
-
<DialogHeader><DialogHeaderTitle>Draft from notes</DialogHeaderTitle></DialogHeader>
|
|
346
|
-
<DialogScrollArea>
|
|
347
|
-
{phase === "idle" ? (
|
|
348
|
-
<View style={{ gap: 12 }}>
|
|
349
|
-
<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>
|
|
350
|
-
<Composer onSend={startDraft} placeholder="Paste your notes…" autoFocus sendLabel="Draft tasks" />
|
|
351
|
-
</View>
|
|
352
|
-
) : null}
|
|
353
|
-
{phase === "reading" ? <AgentRun items={runItems} state={revealed >= DRAFT_STEPS.length ? "done" : "streaming"} /> : null}
|
|
354
|
-
{phase === "review" ? (
|
|
355
|
-
<View style={{ gap: 8 }}>
|
|
356
|
-
<ChangeReviewHeader title="Drafted tasks" />
|
|
357
|
-
<Text size="sm" color="muted">{`${proposals.length} from your notes — keep, edit, or drop each. A “Low” mark is worth a glance.`}</Text>
|
|
358
|
-
{proposals.map((p) => {
|
|
359
|
-
const status = decisions[p.id] ?? "pending";
|
|
360
|
-
const kept = status === "accepted";
|
|
361
|
-
return (
|
|
362
|
-
<Change
|
|
363
|
-
key={p.id}
|
|
364
|
-
id={p.id}
|
|
365
|
-
status={status}
|
|
366
|
-
onAccept={() => decide(p.id, "accepted")}
|
|
367
|
-
onReject={() => decide(p.id, "rejected")}
|
|
368
|
-
onUndo={() => decide(p.id, null)}
|
|
369
|
-
>
|
|
370
|
-
<TaskProposal proposal={p} onEdit={editProposal} />
|
|
371
|
-
<ChangeSummary>
|
|
372
|
-
<Text size="sm" numberOfLines={1} color={kept ? "default" : "muted"} style={kept ? undefined : { textDecorationLine: "line-through" }}>{p.title}</Text>
|
|
373
|
-
</ChangeSummary>
|
|
374
|
-
</Change>
|
|
375
|
-
);
|
|
376
|
-
})}
|
|
377
|
-
</View>
|
|
378
|
-
) : null}
|
|
379
|
-
{phase === "done" ? (
|
|
380
|
-
<CompletionState title={`${addedCount} ${addedCount === 1 ? "task" : "tasks"} added`} summary="They're in your list, grouped by due date." />
|
|
381
|
-
) : null}
|
|
382
|
-
</DialogScrollArea>
|
|
383
|
-
{phase === "review" ? (
|
|
384
|
-
<DialogFooter>
|
|
385
|
-
<ChangeReviewActions onApply={addAccepted} onDiscard={resetDraft} applyLabel={`Add ${acceptedCount} ${acceptedCount === 1 ? "task" : "tasks"}`} />
|
|
386
|
-
</DialogFooter>
|
|
387
|
-
) : phase === "done" ? (
|
|
388
|
-
<DialogFooter>
|
|
389
|
-
<Button title="Draft more" color="secondary" onPress={resetDraft} />
|
|
390
|
-
<Button title="Done" color="primary" onPress={closeDraft} />
|
|
391
|
-
</DialogFooter>
|
|
392
|
-
) : null}
|
|
393
|
-
</Dialog>
|
|
394
|
-
</ChangeReview>
|
|
395
|
-
</>
|
|
396
|
-
);
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
function TaskProposal({ proposal, onEdit }: { proposal: Proposal; onEdit: (id: string, p: Partial<Proposal>) => void }) {
|
|
400
|
-
return (
|
|
401
|
-
<View style={{ gap: 10 }}>
|
|
402
|
-
<View style={{ flexDirection: "row", alignItems: "flex-start", gap: 12 }}>
|
|
403
|
-
<View style={{ flex: 1 }}>
|
|
404
|
-
<InlineTextInput background="transparent" value={proposal.title} onSave={(v) => onEdit(proposal.id, { title: v })} accessibilityLabel="Task title" />
|
|
405
|
-
</View>
|
|
406
|
-
<View style={{ paddingTop: 10 }}><Confidence level={proposal.confidence} /></View>
|
|
407
|
-
</View>
|
|
408
|
-
<View style={{ width: 200 }}>
|
|
409
|
-
<InlineDatePicker background="transparent" value={proposal.due} onSave={(v) => onEdit(proposal.id, { due: v })} placeholder="No date" accessibilityLabel="Due date" />
|
|
410
|
-
</View>
|
|
411
|
-
<View style={styles.source}><Text size="xs" color="muted">{proposal.source}</Text></View>
|
|
412
|
-
</View>
|
|
413
|
-
);
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
function Field(props: { label: string; children: React.ReactNode; top?: boolean }) {
|
|
417
|
-
return (
|
|
418
|
-
<View style={{ flexDirection: "row", alignItems: props.top ? "flex-start" : "center", minHeight: 36 }}>
|
|
419
|
-
<Text size="sm" color="muted" style={{ width: 116, paddingTop: props.top ? 8 : 0 }}>{props.label}</Text>
|
|
420
|
-
<View style={{ flex: 1 }}>{props.children}</View>
|
|
421
|
-
</View>
|
|
422
|
-
);
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
const styles = StyleSheet.create({
|
|
426
|
-
row: {
|
|
427
|
-
flexDirection: "row",
|
|
428
|
-
alignItems: "center",
|
|
429
|
-
gap: 12,
|
|
430
|
-
minHeight: 44,
|
|
431
|
-
paddingHorizontal: 8,
|
|
432
|
-
borderRadius: 10,
|
|
433
|
-
},
|
|
434
|
-
rowHover: { backgroundColor: colors.white },
|
|
435
|
-
// The expand affordance — its own button (meta + chevron), sibling to the ring
|
|
436
|
-
// and title so no interactive control nests inside another.
|
|
437
|
-
// FIXED width so every title cell ends at the same x — the meta content
|
|
438
|
-
// (clip count · tag dots · date) varies per row and right-aligns inside;
|
|
439
|
-
// without it the title editors read ragged/misaligned across rows.
|
|
440
|
-
expand: { flexDirection: "row", alignItems: "center", justifyContent: "flex-end", gap: 10, minHeight: 36, paddingHorizontal: 8, borderRadius: 8, width: 148 },
|
|
441
|
-
capture: {
|
|
442
|
-
borderBottomWidth: 1,
|
|
443
|
-
borderBottomColor: colors.zinc[100],
|
|
444
|
-
paddingBottom: 6,
|
|
445
|
-
},
|
|
446
|
-
|
|
447
|
-
sectionHead: { flexDirection: "row", alignItems: "center", gap: 8, paddingBottom: 6 },
|
|
448
|
-
sectionLabel: { letterSpacing: 0.3, textTransform: "uppercase" },
|
|
449
|
-
meta: { flexDirection: "row", alignItems: "center", justifyContent: "flex-end", gap: 10, flex: 1 },
|
|
450
|
-
clip: { flexDirection: "row", alignItems: "center", gap: 2 },
|
|
451
|
-
dots: { flexDirection: "row", alignItems: "center", gap: 4 },
|
|
452
|
-
dot: { width: 8, height: 8, borderRadius: 999 },
|
|
453
|
-
// ring (20) + gap (12) + the title's 8 inset → labels line up with the title text
|
|
454
|
-
detail: { paddingLeft: 48, paddingRight: 8, paddingTop: 2, paddingBottom: 14, gap: 8 },
|
|
455
|
-
source: { borderLeftWidth: 2, borderLeftColor: colors.zinc[200], paddingLeft: 10 },
|
|
456
|
-
});
|