@yannelli/paseo-linear-plugin 0.1.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/NOTICE +8 -4
  2. package/OVERVIEW.md +23 -13
  3. package/README.md +138 -29
  4. package/client/agent-options.ts +200 -0
  5. package/client/autosave.ts +77 -0
  6. package/client/breadcrumbs.tsx +110 -0
  7. package/client/browser.tsx +275 -0
  8. package/client/compat.ts +107 -0
  9. package/client/connect.tsx +136 -0
  10. package/client/create-issue.tsx +291 -0
  11. package/client/glyphs.tsx +117 -0
  12. package/client/issue-description.tsx +206 -0
  13. package/client/issue-detail.tsx +358 -0
  14. package/client/issue-list.tsx +373 -0
  15. package/client/issue-row.tsx +152 -0
  16. package/client/issue-sections.tsx +325 -0
  17. package/client/issue-tree.ts +132 -0
  18. package/client/key-scope.tsx +23 -0
  19. package/client/launch-choices.ts +125 -0
  20. package/client/launch-fields.tsx +152 -0
  21. package/client/launch-plan.ts +227 -0
  22. package/client/launch.tsx +484 -0
  23. package/client/markdown.tsx +302 -0
  24. package/client/model-browser.tsx +296 -0
  25. package/client/pickers.tsx +238 -0
  26. package/client/provider-icon.tsx +78 -0
  27. package/client/queries.ts +242 -0
  28. package/client/settings-connection.tsx +109 -0
  29. package/client/settings-fields.tsx +173 -0
  30. package/client/settings-keys.tsx +117 -0
  31. package/client/settings-projects.tsx +281 -0
  32. package/client/settings-prompts.tsx +198 -0
  33. package/client/store.ts +71 -0
  34. package/client/timeline-card.tsx +63 -0
  35. package/client/ui.tsx +352 -0
  36. package/index.client.tsx +111 -0
  37. package/index.server.ts +7 -3
  38. package/package.json +4 -3
  39. package/paseo-plugin.json +2 -2
  40. package/server/cache.ts +68 -0
  41. package/server/credentials.ts +101 -0
  42. package/server/graphql.ts +68 -0
  43. package/server/handlers.ts +172 -0
  44. package/server/linear.ts +11 -64
  45. package/server/queries.ts +280 -0
  46. package/shared/linear.ts +268 -0
  47. package/shared/markdown.ts +461 -0
  48. package/shared/prompts.ts +150 -0
  49. package/shared/settings.ts +68 -0
  50. package/index.client.ts +0 -7
  51. package/server/issues.ts +0 -10
@@ -0,0 +1,291 @@
1
+ import { useRpc } from "@getpaseo/plugin/client";
2
+ import { Icon, Modal, TextInput, useToast } from "@getpaseo/plugin/client/react-native";
3
+ import { useMutation, useQueryClient } from "@tanstack/react-query";
4
+ import { useCallback, useMemo, useState } from "react";
5
+ import { Text, View } from "react-native";
6
+ import { createIssueRpc, type LinearTeam, type LinearUser } from "../shared/linear";
7
+ import {
8
+ assigneeOptions,
9
+ type PickerOption,
10
+ PickerPage,
11
+ priorityOptions,
12
+ PropertyChip,
13
+ stateOptions,
14
+ UNASSIGNED,
15
+ } from "./pickers";
16
+ import { useKeyScope } from "./key-scope";
17
+ import { queryKeys } from "./queries";
18
+ import {
19
+ Avatar,
20
+ Button,
21
+ errorMessage,
22
+ priorityMeta,
23
+ PriorityIcon,
24
+ StateIcon,
25
+ type Theme,
26
+ } from "./ui";
27
+
28
+ type Picker = "team" | "state" | "priority" | "assignee";
29
+
30
+ interface CreateIssueProps {
31
+ theme: Theme;
32
+ teams: readonly LinearTeam[];
33
+ users: readonly LinearUser[];
34
+ viewerId: string | null;
35
+ defaultTeamId: string | null;
36
+ onClose(): void;
37
+ onCreated(issueId: string): void;
38
+ }
39
+
40
+ interface Draft {
41
+ teamId: string | null;
42
+ stateId: string | null;
43
+ priority: number;
44
+ assigneeId: string | null;
45
+ }
46
+
47
+ const PICKER_TITLES: Record<Picker, string> = {
48
+ team: "Team",
49
+ state: "Status",
50
+ priority: "Priority",
51
+ assignee: "Assignee",
52
+ };
53
+
54
+ function pickerOptions(kind: Picker, props: CreateIssueProps, team: LinearTeam | null) {
55
+ if (kind === "team") {
56
+ return props.teams.map((entry) => ({ value: entry.id, label: entry.name, detail: entry.key }));
57
+ }
58
+ if (kind === "state") return stateOptions(team?.states ?? []);
59
+ if (kind === "priority") return priorityOptions(props.theme);
60
+ return assigneeOptions(props.theme, props.users, props.viewerId);
61
+ }
62
+
63
+ function pickerValue(kind: Picker, draft: Draft): string | null {
64
+ if (kind === "team") return draft.teamId;
65
+ if (kind === "state") return draft.stateId;
66
+ if (kind === "priority") return String(draft.priority);
67
+ return draft.assigneeId ?? UNASSIGNED;
68
+ }
69
+
70
+ function applyPick(kind: Picker, value: string, draft: Draft): Draft {
71
+ if (kind === "team") return { ...draft, teamId: value, stateId: null };
72
+ if (kind === "state") return { ...draft, stateId: value };
73
+ if (kind === "priority") return { ...draft, priority: Number(value) };
74
+ return { ...draft, assigneeId: value === UNASSIGNED ? null : value };
75
+ }
76
+
77
+ export function CreateIssueModal(props: CreateIssueProps) {
78
+ const { theme, onClose, onCreated } = props;
79
+ const { colors } = theme;
80
+ const toast = useToast();
81
+ const queries = useQueryClient();
82
+ const createIssue = useRpc(createIssueRpc);
83
+ const scope = useKeyScope();
84
+ const [draft, setDraft] = useState<Draft>(() => ({
85
+ teamId: props.defaultTeamId ?? props.teams[0]?.id ?? null,
86
+ stateId: null,
87
+ priority: 0,
88
+ assigneeId: props.viewerId,
89
+ }));
90
+ const [title, setTitle] = useState("");
91
+ const [description, setDescription] = useState("");
92
+ const [picker, setPicker] = useState<Picker | null>(null);
93
+ const team = props.teams.find((entry) => entry.id === draft.teamId) ?? null;
94
+
95
+ const create = useMutation({
96
+ mutationFn: () => {
97
+ if (!draft.teamId) throw new Error("Choose a team");
98
+ return createIssue({
99
+ teamId: draft.teamId,
100
+ title,
101
+ description: description.trim() || undefined,
102
+ priority: draft.priority,
103
+ assigneeId: draft.assigneeId,
104
+ ...(draft.stateId ? { stateId: draft.stateId } : {}),
105
+ projectId: scope,
106
+ });
107
+ },
108
+ onSuccess: ({ issue }) => {
109
+ void queries.invalidateQueries({ queryKey: queryKeys.issues(scope) });
110
+ toast.show(`Created ${issue.identifier}`, { variant: "success" });
111
+ onCreated(issue.id);
112
+ },
113
+ });
114
+ const { mutate } = create;
115
+ const submit = useCallback(() => mutate(), [mutate]);
116
+ const icon = useMemo(() => <Icon name="Plus" size={18} color={colors.foreground} />, [colors]);
117
+ const openChange = useCallback(
118
+ (open: boolean) => {
119
+ if (!open) onClose();
120
+ },
121
+ [onClose],
122
+ );
123
+ const closePicker = useCallback(() => setPicker(null), []);
124
+ const select = useCallback(
125
+ (value: string) => {
126
+ if (!picker) return;
127
+ setDraft((current) => applyPick(picker, value, current));
128
+ setPicker(null);
129
+ },
130
+ [picker],
131
+ );
132
+ const options: PickerOption[] = useMemo(
133
+ () => (picker ? pickerOptions(picker, props, team) : []),
134
+ [picker, props, team],
135
+ );
136
+ const styles = useMemo(() => {
137
+ const input = {
138
+ paddingHorizontal: 12,
139
+ borderRadius: 8,
140
+ borderWidth: 1,
141
+ borderColor: colors.border,
142
+ backgroundColor: colors.surface2,
143
+ color: colors.foreground,
144
+ fontSize: 14,
145
+ };
146
+ return {
147
+ title: { ...input, height: 42, fontSize: 15 },
148
+ description: {
149
+ ...input,
150
+ minHeight: 120,
151
+ paddingVertical: 10,
152
+ textAlignVertical: "top" as const,
153
+ },
154
+ error: { color: colors.statusDanger, fontSize: 13 },
155
+ };
156
+ }, [colors]);
157
+
158
+ return (
159
+ <Modal title="New issue" icon={icon} open onOpenChange={openChange}>
160
+ <Modal.Content>
161
+ {picker ? (
162
+ <PickerPage
163
+ theme={theme}
164
+ title={PICKER_TITLES[picker]}
165
+ options={options}
166
+ value={pickerValue(picker, draft)}
167
+ searchable={picker === "assignee" || picker === "team"}
168
+ onBack={closePicker}
169
+ onSelect={select}
170
+ />
171
+ ) : (
172
+ <>
173
+ <DraftChips
174
+ theme={theme}
175
+ draft={draft}
176
+ team={team}
177
+ users={props.users}
178
+ onPick={setPicker}
179
+ />
180
+ <TextInput
181
+ value={title}
182
+ onChangeText={setTitle}
183
+ placeholder="Issue title"
184
+ placeholderTextColor={colors.foregroundMuted}
185
+ accessibilityLabel="Issue title"
186
+ autoFocus
187
+ style={styles.title}
188
+ />
189
+ <TextInput
190
+ value={description}
191
+ onChangeText={setDescription}
192
+ multiline
193
+ placeholder="Add a description (Markdown)"
194
+ placeholderTextColor={colors.foregroundMuted}
195
+ accessibilityLabel="Issue description"
196
+ style={styles.description}
197
+ />
198
+ {create.error ? (
199
+ <Text accessibilityRole="alert" style={styles.error}>
200
+ {errorMessage(create.error)}
201
+ </Text>
202
+ ) : null}
203
+ <Button
204
+ theme={theme}
205
+ variant="primary"
206
+ size="md"
207
+ icon="Plus"
208
+ label="Create issue"
209
+ busy={create.isPending}
210
+ disabled={!title.trim() || !draft.teamId}
211
+ onPress={submit}
212
+ />
213
+ </>
214
+ )}
215
+ </Modal.Content>
216
+ </Modal>
217
+ );
218
+ }
219
+
220
+ const CHIPS_STYLE = { flexDirection: "row", flexWrap: "wrap", gap: 8 } as const;
221
+
222
+ function DraftChips(props: {
223
+ theme: Theme;
224
+ draft: Draft;
225
+ team: LinearTeam | null;
226
+ users: readonly LinearUser[];
227
+ onPick(kind: Picker): void;
228
+ }) {
229
+ const { theme, draft, team, onPick } = props;
230
+ const { colors } = theme;
231
+ const state = team?.states.find((entry) => entry.id === draft.stateId) ?? null;
232
+ const assignee = props.users.find((user) => user.id === draft.assigneeId) ?? null;
233
+ const assigneeName = assignee?.displayName ?? null;
234
+ const teamIcon = useMemo(
235
+ () => <Icon name="Users" size={14} color={colors.foregroundMuted} />,
236
+ [colors],
237
+ );
238
+ const stateIcon = useMemo(
239
+ () =>
240
+ state ? (
241
+ <StateIcon state={state} size={14} />
242
+ ) : (
243
+ <Icon name="Circle" size={14} color={colors.foregroundMuted} />
244
+ ),
245
+ [state, colors],
246
+ );
247
+ const priorityIcon = useMemo(
248
+ () => <PriorityIcon theme={theme} priority={draft.priority} size={14} />,
249
+ [theme, draft.priority],
250
+ );
251
+ const avatar = useMemo(
252
+ () => <Avatar theme={theme} name={assigneeName} size={16} />,
253
+ [theme, assigneeName],
254
+ );
255
+ const pickTeam = useCallback(() => onPick("team"), [onPick]);
256
+ const pickState = useCallback(() => onPick("state"), [onPick]);
257
+ const pickPriority = useCallback(() => onPick("priority"), [onPick]);
258
+ const pickAssignee = useCallback(() => onPick("assignee"), [onPick]);
259
+ return (
260
+ <View style={CHIPS_STYLE}>
261
+ <PropertyChip
262
+ theme={theme}
263
+ label={team?.name ?? "Team"}
264
+ leading={teamIcon}
265
+ accessibilityLabel="Team"
266
+ onPress={pickTeam}
267
+ />
268
+ <PropertyChip
269
+ theme={theme}
270
+ label={state?.name ?? "Default status"}
271
+ leading={stateIcon}
272
+ accessibilityLabel="Status"
273
+ onPress={pickState}
274
+ />
275
+ <PropertyChip
276
+ theme={theme}
277
+ label={priorityMeta(draft.priority).label}
278
+ leading={priorityIcon}
279
+ accessibilityLabel="Priority"
280
+ onPress={pickPriority}
281
+ />
282
+ <PropertyChip
283
+ theme={theme}
284
+ label={assigneeName ?? "Unassigned"}
285
+ leading={avatar}
286
+ accessibilityLabel="Assignee"
287
+ onPress={pickAssignee}
288
+ />
289
+ </View>
290
+ );
291
+ }
@@ -0,0 +1,117 @@
1
+ import type { PluginSurfaceProps } from "@getpaseo/plugin/client";
2
+ import { Icon } from "@getpaseo/plugin/client/react-native";
3
+ import { useMemo } from "react";
4
+ import { Text, View } from "react-native";
5
+ import { PRIORITIES } from "../shared/linear";
6
+
7
+ type Theme = PluginSurfaceProps["theme"];
8
+
9
+ const STATE_ICONS: Record<string, string> = {
10
+ triage: "CircleDot",
11
+ backlog: "CircleDashed",
12
+ unstarted: "Circle",
13
+ started: "CircleDot",
14
+ completed: "CircleCheck",
15
+ canceled: "CircleX",
16
+ duplicate: "CircleX",
17
+ };
18
+
19
+ export function StateIcon({
20
+ state,
21
+ size = 16,
22
+ }: {
23
+ state: { type: string; color: string };
24
+ size?: number;
25
+ }) {
26
+ return <Icon name={STATE_ICONS[state.type] ?? "Circle"} size={size} color={state.color} />;
27
+ }
28
+
29
+ export function priorityMeta(priority: number) {
30
+ return PRIORITIES.find((entry) => entry.value === priority) ?? PRIORITIES[0];
31
+ }
32
+
33
+ export function PriorityIcon({
34
+ theme,
35
+ priority,
36
+ size = 16,
37
+ }: {
38
+ theme: Theme;
39
+ priority: number;
40
+ size?: number;
41
+ }) {
42
+ const meta = priorityMeta(priority);
43
+ const color = priority === 1 ? theme.colors.statusDanger : theme.colors.foregroundMuted;
44
+ return <Icon name={meta.icon} size={size} color={color} />;
45
+ }
46
+
47
+ function initials(name: string): string {
48
+ const parts = name.trim().split(/\s+/).filter(Boolean);
49
+ const letters =
50
+ parts.length > 1 ? `${parts[0]?.[0] ?? ""}${parts[1]?.[0] ?? ""}` : name.slice(0, 2);
51
+ return letters.toUpperCase() || "?";
52
+ }
53
+
54
+ export function Avatar({
55
+ theme,
56
+ name,
57
+ size = 20,
58
+ }: {
59
+ theme: Theme;
60
+ name: string | null;
61
+ size?: number;
62
+ }) {
63
+ const { colors } = theme;
64
+ const styles = useMemo(
65
+ () =>
66
+ ({
67
+ circle: {
68
+ width: size,
69
+ height: size,
70
+ borderRadius: size / 2,
71
+ alignItems: "center",
72
+ justifyContent: "center",
73
+ backgroundColor: colors.surface2,
74
+ borderWidth: 1,
75
+ borderColor: colors.border,
76
+ },
77
+ letters: { fontSize: Math.max(8, size * 0.42), color: colors.foreground },
78
+ }) as const,
79
+ [colors, size],
80
+ );
81
+ if (!name) return <Icon name="CircleUser" size={size} color={colors.foregroundMuted} />;
82
+ return (
83
+ <View accessibilityLabel={name} style={styles.circle}>
84
+ <Text style={styles.letters}>{initials(name)}</Text>
85
+ </View>
86
+ );
87
+ }
88
+
89
+ export function LabelPill({ theme, name, color }: { theme: Theme; name: string; color: string }) {
90
+ const { colors } = theme;
91
+ const styles = useMemo(
92
+ () =>
93
+ ({
94
+ pill: {
95
+ flexDirection: "row",
96
+ alignItems: "center",
97
+ gap: 5,
98
+ height: 22,
99
+ paddingHorizontal: 8,
100
+ borderRadius: 11,
101
+ borderWidth: 1,
102
+ borderColor: colors.border,
103
+ },
104
+ dot: { width: 7, height: 7, borderRadius: 4, backgroundColor: color },
105
+ name: { fontSize: 12, color: colors.foregroundMuted },
106
+ }) as const,
107
+ [colors, color],
108
+ );
109
+ return (
110
+ <View style={styles.pill}>
111
+ <View style={styles.dot} />
112
+ <Text numberOfLines={1} style={styles.name}>
113
+ {name}
114
+ </Text>
115
+ </View>
116
+ );
117
+ }
@@ -0,0 +1,206 @@
1
+ import { TextInput, useToast } from "@getpaseo/plugin/client/react-native";
2
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
+ import {
4
+ type NativeSyntheticEvent,
5
+ Pressable,
6
+ Text,
7
+ type TextInputKeyPressEventData,
8
+ View,
9
+ } from "react-native";
10
+ import type { IssueDetail } from "../shared/linear";
11
+ import { toggleTask } from "../shared/markdown";
12
+ import { createAutosave, type SaveStatus } from "./autosave";
13
+ import { Markdown } from "./markdown";
14
+ import { useDescriptionSaver } from "./queries";
15
+ import { Button, errorMessage, IconButton, SectionLabel, type Theme } from "./ui";
16
+
17
+ // Typing saves after a pause. Checkbox taps save sooner and coalesce rapid taps.
18
+ const EDIT_DELAY_MS = 1200;
19
+ const TOGGLE_DELAY_MS = 400;
20
+
21
+ const STATUS_COPY: Record<SaveStatus, string> = {
22
+ idle: "Saves as you type",
23
+ pending: "Unsaved changes",
24
+ saving: "Saving…",
25
+ saved: "Saved to Linear",
26
+ error: "Could not save",
27
+ };
28
+
29
+ const HEADER_STYLE = {
30
+ flexDirection: "row",
31
+ alignItems: "center",
32
+ justifyContent: "space-between",
33
+ } as const;
34
+
35
+ const noop = () => undefined;
36
+
37
+ export function IssueDescription(props: { theme: Theme; compact: boolean; issue: IssueDetail }) {
38
+ const { theme, compact, issue } = props;
39
+ const { colors } = theme;
40
+ const toast = useToast();
41
+ const saver = useDescriptionSaver(issue.id);
42
+ const [editing, setEditing] = useState(false);
43
+ const [draft, setDraft] = useState("");
44
+ const [status, setStatus] = useState<SaveStatus>("idle");
45
+ const [closing, setClosing] = useState(false);
46
+ // Read by the autosave callbacks and the unmount flush, which outlive a render.
47
+ const live = useRef({ editing, draft, toast });
48
+ useEffect(() => {
49
+ live.current = { editing, draft, toast };
50
+ });
51
+
52
+ const autosave = useMemo(() => {
53
+ let lastSaved: string | null = null;
54
+ const instance = createAutosave({
55
+ delayMs: EDIT_DELAY_MS,
56
+ save: async (value) => {
57
+ await saver.save(value);
58
+ lastSaved = value;
59
+ },
60
+ onStatus: (next, error) => {
61
+ setStatus(next);
62
+ // A refetch during the save may have brought back older text. Nothing is queued now.
63
+ if (next === "saved" && lastSaved !== null) saver.show(lastSaved);
64
+ if (next !== "error" || live.current.editing) return;
65
+ // A checkbox save failed: drop it and show what Linear has.
66
+ instance.cancel();
67
+ live.current.toast.error(`Could not update the checklist: ${errorMessage(error)}`);
68
+ saver.reload();
69
+ },
70
+ });
71
+ return instance;
72
+ }, [saver]);
73
+
74
+ // Leaving the issue saves any pending text instead of losing it.
75
+ useEffect(
76
+ () => () => {
77
+ if (!autosave.busy()) return;
78
+ const { editing: wasEditing, draft: text, toast: notify } = live.current;
79
+ if (wasEditing) saver.show(text);
80
+ autosave.flush().catch((error: unknown) => {
81
+ notify.error(`Description not saved: ${errorMessage(error)}`);
82
+ saver.reload();
83
+ });
84
+ },
85
+ [autosave, saver],
86
+ );
87
+
88
+ const description = issue.description ?? "";
89
+ const startEditing = useCallback(() => {
90
+ setDraft(description);
91
+ setStatus("idle");
92
+ setEditing(true);
93
+ }, [description]);
94
+ const change = useCallback(
95
+ (text: string) => {
96
+ setDraft(text);
97
+ autosave.schedule(text);
98
+ },
99
+ [autosave],
100
+ );
101
+ const flushQuietly = useCallback(() => void autosave.flush().catch(noop), [autosave]);
102
+ // Closes only after Linear confirms the text. A failure keeps the editor open.
103
+ const finish = useCallback(() => {
104
+ setClosing(true);
105
+ autosave
106
+ .flush()
107
+ .then(() => {
108
+ saver.show(live.current.draft);
109
+ setEditing(false);
110
+ setStatus("idle");
111
+ }, noop)
112
+ .finally(() => setClosing(false));
113
+ }, [autosave, saver]);
114
+ const keyPress = useCallback(
115
+ (event: NativeSyntheticEvent<TextInputKeyPressEventData>) => {
116
+ if (event.nativeEvent.key === "Escape") finish();
117
+ },
118
+ [finish],
119
+ );
120
+ const toggle = useCallback(
121
+ (ordinal: number) => {
122
+ const next = toggleTask(description, ordinal);
123
+ if (next === null) {
124
+ live.current.toast.error("The checklist changed. Refresh the issue and try again.");
125
+ return;
126
+ }
127
+ saver.show(next);
128
+ autosave.schedule(next, TOGGLE_DELAY_MS);
129
+ },
130
+ [description, saver, autosave],
131
+ );
132
+
133
+ const styles = useMemo(
134
+ () =>
135
+ ({
136
+ input: {
137
+ minHeight: 200,
138
+ padding: 10,
139
+ borderRadius: 8,
140
+ borderWidth: 1,
141
+ borderColor: colors.border,
142
+ backgroundColor: colors.surface1,
143
+ color: colors.foreground,
144
+ fontSize: 14,
145
+ lineHeight: 21,
146
+ textAlignVertical: "top",
147
+ },
148
+ footer: { flexDirection: "row", alignItems: "center", gap: 8, marginTop: 8 },
149
+ status: {
150
+ flex: 1,
151
+ fontSize: 12,
152
+ color: status === "error" ? colors.statusDanger : colors.foregroundMuted,
153
+ },
154
+ empty: { color: colors.foregroundMuted, fontSize: 14, lineHeight: 21 },
155
+ }) as const,
156
+ [colors, status],
157
+ );
158
+
159
+ if (editing) {
160
+ return (
161
+ <View>
162
+ <SectionLabel theme={theme}>Description</SectionLabel>
163
+ <TextInput
164
+ value={draft}
165
+ onChangeText={change}
166
+ onBlur={flushQuietly}
167
+ onKeyPress={keyPress}
168
+ multiline
169
+ autoFocus
170
+ placeholder="Add a description in Markdown…"
171
+ placeholderTextColor={colors.foregroundMuted}
172
+ accessibilityLabel="Issue description"
173
+ style={styles.input}
174
+ />
175
+ <View style={styles.footer}>
176
+ <Text accessibilityLiveRegion="polite" style={styles.status}>
177
+ {STATUS_COPY[status]}
178
+ </Text>
179
+ {status === "error" ? (
180
+ <Button theme={theme} icon="RefreshCw" label="Retry" onPress={flushQuietly} />
181
+ ) : null}
182
+ <Button theme={theme} icon="Check" label="Done" busy={closing} onPress={finish} />
183
+ </View>
184
+ </View>
185
+ );
186
+ }
187
+ return (
188
+ <View>
189
+ <View style={HEADER_STYLE}>
190
+ <SectionLabel theme={theme}>Description</SectionLabel>
191
+ <IconButton theme={theme} icon="Pencil" label="Edit description" onPress={startEditing} />
192
+ </View>
193
+ {description.trim() ? (
194
+ <Markdown theme={theme} compact={compact} source={description} onToggleTask={toggle} />
195
+ ) : (
196
+ <Pressable
197
+ accessibilityRole="button"
198
+ accessibilityLabel="Add a description"
199
+ onPress={startEditing}
200
+ >
201
+ <Text style={styles.empty}>No description. Add one…</Text>
202
+ </Pressable>
203
+ )}
204
+ </View>
205
+ );
206
+ }