@yannelli/paseo-linear-plugin 0.3.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.
package/OVERVIEW.md CHANGED
@@ -16,6 +16,7 @@ To use a different Linear key for one Paseo project, add it under **Project keys
16
16
  - Select **Sub-issues** to nest sub-issues under their parent. The breadcrumbs above an issue show its team and parent chain.
17
17
  - Change the status, priority, and assignee. Add comments, or create an issue.
18
18
  - Descriptions and comments show as Markdown.
19
+ - Edit the description in place. It saves to Linear about one second after you stop typing, and the line under the editor confirms each save. Select a task list checkbox to check or uncheck it without opening the editor.
19
20
  - `/linear` opens the panel. `/linear ENG-123` opens an issue.
20
21
 
21
22
  ## Start an agent
package/README.md CHANGED
@@ -7,6 +7,7 @@ A Paseo plugin for Linear. It adds a Linear screen and a workspace panel where y
7
7
  - Browse issues in a list next to the issue view. Search and filter the list, and change its sort order.
8
8
  - Nest sub-issues under their parent. Breadcrumbs show the parent chain of an issue.
9
9
  - Read descriptions and comments as Markdown.
10
+ - Edit the description in place. It saves as you type. Check off task list items without opening the editor.
10
11
  - Change the status, priority, and assignee. Add comments and create issues.
11
12
  - Start an agent or a review from an issue. Choose the prompt, agent, model, effort, project, and where the agent runs.
12
13
  - Use a different Linear key for a Paseo project.
@@ -87,6 +88,8 @@ The issue header shows breadcrumbs: the team, the parent chain, then the issue,
87
88
 
88
89
  - Select the status, priority, or assignee chip to change it.
89
90
  - Descriptions and comments show as Markdown: headings, lists, task lists, code, quotes, tables, and links.
91
+ - Select the pencil next to **Description** to edit it as Markdown. The plugin saves about one second after you stop typing, and when you leave the box. The line under the box shows **Saving…**, then **Saved to Linear**. If a save fails, it shows **Could not save** and a **Retry** button. **Done** (or Escape) closes the editor only after Linear accepts the text.
92
+ - Select a task list checkbox in the description to check or uncheck it. The plugin changes only that box in the Markdown and saves it to Linear. If the save fails, the issue reloads from Linear.
90
93
  - Add a comment in the **Activity** section.
91
94
  - The header buttons refresh the issue, copy its branch name, and open it in Linear.
92
95
  - Select **New issue**, or run **Linear: Create issue**, to create an issue. Set the team, status, priority, assignee, title, and a Markdown description.
@@ -137,6 +140,7 @@ Linear errors, such as a rejected key or a rate limit, show in the panel or as a
137
140
  | `client/browser.tsx` | App | Screen and panel: picks the key, then shows the list, the issue, or the agent setup page |
138
141
  | `client/issue-list.tsx`, `client/issue-row.tsx`, `client/issue-tree.ts` | App | Search, filters, sort, rows, status groups, and sub-issue nesting |
139
142
  | `client/issue-detail.tsx`, `client/issue-sections.tsx`, `client/breadcrumbs.tsx` | App | Issue view: properties, breadcrumbs, sub-issues, links, agents, and comments |
143
+ | `client/issue-description.tsx`, `client/autosave.ts` | App | Description editor, task list checkboxes, and debounced saves |
140
144
  | `client/markdown.tsx` | App | Renders Markdown |
141
145
  | `client/launch.tsx`, `client/launch-fields.tsx`, `client/launch-choices.ts`, `client/launch-plan.ts`, `client/agent-options.ts` | App | Agent setup page, Run in options, and agent start |
142
146
  | `client/model-browser.tsx`, `client/provider-icon.tsx` | App | Model picker and provider icons |
@@ -149,7 +153,7 @@ Linear errors, such as a rejected key or a rate limit, show in the panel or as a
149
153
  | `shared/issues.ts` | Both | The `issues.search` RPC and the composer attachment source |
150
154
  | `shared/settings.ts` | Both | Settings schema: templates, agent defaults, and project settings |
151
155
  | `shared/prompts.ts` | Both | Default templates and the issue snapshot |
152
- | `shared/markdown.ts` | Both | Markdown parser |
156
+ | `shared/markdown.ts` | Both | Markdown parser and task list toggle |
153
157
  | `server/handlers.ts` | Daemon | RPC handlers, key lookup, and cache use |
154
158
  | `server/credentials.ts` | Daemon | Key file and key order |
155
159
  | `server/cache.ts` | Daemon | Response cache in memory |
@@ -170,7 +174,7 @@ npm run check
170
174
  paseo plugin add "$PWD"
171
175
  ```
172
176
 
173
- After you edit the source, run `paseo plugin reload linear`. `npm run check` runs the typecheck and the tests. The tests cover the Linear client, the key file, the cache, the handlers, sub-issue nesting, the Markdown parser, the agent options, and the release scripts. They use a local GraphQL server and do not call Linear.
177
+ After you edit the source, run `paseo plugin reload linear`. `npm run check` runs the typecheck and the tests. The tests cover the Linear client, the key file, the cache, the handlers, sub-issue nesting, the Markdown parser and task list toggle, the description autosave, the agent options, and the release scripts. They use a local GraphQL server and do not call Linear.
174
178
 
175
179
  ## Graphics
176
180
 
@@ -0,0 +1,77 @@
1
+ // Debounced, single-flight saving for one text value. Pure so the tests can drive it
2
+ // with fake timers. Only the latest value is kept, and one save runs at a time, so
3
+ // saves always reach the server in edit order.
4
+
5
+ export type SaveStatus = "idle" | "pending" | "saving" | "saved" | "error";
6
+
7
+ export interface AutosaveOptions {
8
+ delayMs: number;
9
+ save(value: string): Promise<void>;
10
+ onStatus(status: SaveStatus, error?: unknown): void;
11
+ }
12
+
13
+ export interface Autosave {
14
+ /** Queues `value` and saves it after `delayMs` (or the given delay) without changes. */
15
+ schedule(value: string, delayMs?: number): void;
16
+ /** Saves the queued value now. Resolves when nothing is left to save. */
17
+ flush(): Promise<void>;
18
+ /** Drops the queued value. A save in flight still finishes. */
19
+ cancel(): void;
20
+ /** True while a value is queued or saving. */
21
+ busy(): boolean;
22
+ }
23
+
24
+ export function createAutosave(options: AutosaveOptions): Autosave {
25
+ let queued: string | null = null;
26
+ let timer: ReturnType<typeof setTimeout> | null = null;
27
+ let inFlight: Promise<void> | null = null;
28
+
29
+ function clearTimer() {
30
+ if (timer !== null) clearTimeout(timer);
31
+ timer = null;
32
+ }
33
+
34
+ // Starts the next save unless one is running. Rejects with the save error.
35
+ function run(): Promise<void> {
36
+ clearTimer();
37
+ if (inFlight) return inFlight.then(() => (queued === null ? undefined : run()));
38
+ if (queued === null) return Promise.resolve();
39
+ const value = queued;
40
+ queued = null;
41
+ options.onStatus("saving");
42
+ inFlight = options.save(value).then(
43
+ () => {
44
+ inFlight = null;
45
+ if (queued === null) options.onStatus(timer === null ? "saved" : "pending");
46
+ else if (timer === null) return run();
47
+ },
48
+ (error: unknown) => {
49
+ inFlight = null;
50
+ // Keep the failed value for a retry unless a newer edit replaced it.
51
+ if (queued === null) queued = value;
52
+ if (timer === null) options.onStatus("error", error);
53
+ throw error;
54
+ },
55
+ );
56
+ return inFlight;
57
+ }
58
+
59
+ function background() {
60
+ run().catch(() => undefined);
61
+ }
62
+
63
+ return {
64
+ schedule(value, delayMs = options.delayMs) {
65
+ queued = value;
66
+ clearTimer();
67
+ options.onStatus("pending");
68
+ timer = setTimeout(background, delayMs);
69
+ },
70
+ flush: run,
71
+ cancel() {
72
+ clearTimer();
73
+ queued = null;
74
+ },
75
+ busy: () => queued !== null || inFlight !== null,
76
+ };
77
+ }
@@ -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
+ }
@@ -6,7 +6,7 @@ import type { IssueDetail, IssuePatch, LinearTeam, LinearUser } from "../shared/
6
6
  import { ACTION_LABELS } from "../shared/prompts";
7
7
  import type { AgentAction } from "../shared/settings";
8
8
  import { IssueBreadcrumbs } from "./breadcrumbs";
9
- import { Markdown } from "./markdown";
9
+ import { IssueDescription } from "./issue-description";
10
10
  import { Comments, IssueLinks, IssueRelations, LinkedAgents } from "./issue-sections";
11
11
  import type { LaunchTarget } from "./launch";
12
12
  import {
@@ -27,7 +27,6 @@ import {
27
27
  LabelPill,
28
28
  priorityMeta,
29
29
  PriorityIcon,
30
- SectionLabel,
31
30
  StateIcon,
32
31
  type Theme,
33
32
  } from "./ui";
@@ -90,7 +89,8 @@ export function IssueDetailView(props: IssueDetailProps) {
90
89
  </EmptyState>
91
90
  );
92
91
  }
93
- return <LoadedIssue {...props} issue={issue.data} refetch={reload} />;
92
+ // Keyed so that switching issues never carries a draft or a pending save across.
93
+ return <LoadedIssue key={issue.data.id} {...props} issue={issue.data} refetch={reload} />;
94
94
  }
95
95
 
96
96
  function LoadedIssue(props: IssueDetailProps & { issue: IssueDetail; refetch(): void }) {
@@ -108,7 +108,6 @@ function LoadedIssue(props: IssueDetailProps & { issue: IssueDetail; refetch():
108
108
  fontWeight: "500",
109
109
  lineHeight: compact ? 24 : 28,
110
110
  },
111
- description: { color: colors.foregroundMuted, fontSize: 14, lineHeight: 21 },
112
111
  } as const;
113
112
  }, [colors, compact]);
114
113
  return (
@@ -140,14 +139,7 @@ function LoadedIssue(props: IssueDetailProps & { issue: IssueDetail; refetch():
140
139
  ) : null}
141
140
  <LaunchButtons theme={theme} onLaunch={props.onLaunch} />
142
141
  <LinkedAgents theme={theme} identifier={issue.identifier} navigation={props.navigation} />
143
- <View>
144
- <SectionLabel theme={theme}>Description</SectionLabel>
145
- {issue.description?.trim() ? (
146
- <Markdown theme={theme} compact={compact} source={issue.description} />
147
- ) : (
148
- <Text style={styles.description}>No description.</Text>
149
- )}
150
- </View>
142
+ <IssueDescription theme={theme} compact={compact} issue={issue} />
151
143
  <IssueRelations theme={theme} issue={issue} onOpenIssue={props.onOpenIssue} />
152
144
  <IssueLinks theme={theme} issue={issue} />
153
145
  <Comments theme={theme} issue={issue} />
@@ -1,8 +1,22 @@
1
1
  import { openExternalUrl } from "@getpaseo/plugin/client";
2
2
  import { Icon } from "@getpaseo/plugin/client/react-native";
3
3
  import { type ReactNode, useCallback, useMemo } from "react";
4
- import { Platform, ScrollView, type StyleProp, Text, type TextStyle, View } from "react-native";
5
- import { type MarkdownBlock, type MarkdownInline, parseMarkdown } from "../shared/markdown";
4
+ import {
5
+ Platform,
6
+ Pressable,
7
+ ScrollView,
8
+ type StyleProp,
9
+ Text,
10
+ type TextStyle,
11
+ View,
12
+ } from "react-native";
13
+ import {
14
+ type MarkdownBlock,
15
+ type MarkdownInline,
16
+ type MarkdownListItem,
17
+ parseMarkdown,
18
+ taskItems,
19
+ } from "../shared/markdown";
6
20
  import type { Theme } from "./ui";
7
21
 
8
22
  type ListBlock = Extract<MarkdownBlock, { type: "list" }>;
@@ -73,11 +87,20 @@ type Styles = ReturnType<typeof createStyles>;
73
87
 
74
88
  // `s` styles the current blocks. `quoted` is the muted context for blockquotes; inside a
75
89
  // quote it is unset, so nested quotes reuse the current (already muted) context.
90
+ // `tasks` maps each task item to its `taskItems` ordinal when checkboxes are pressable.
76
91
  interface Context {
77
92
  s: Styles;
78
93
  quoted?: Context;
94
+ tasks?: TaskToggles;
79
95
  }
80
96
 
97
+ interface TaskToggles {
98
+ ordinals: Map<MarkdownListItem, number>;
99
+ onToggle(ordinal: number): void;
100
+ }
101
+
102
+ const CHECKBOX_HIT_SLOP = 8;
103
+
81
104
  function Link(props: { url: string; style: StyleProp<TextStyle>; children: ReactNode }) {
82
105
  const { url } = props;
83
106
  const open = useCallback(() => void openExternalUrl(url), [url]);
@@ -121,8 +144,37 @@ function renderBlocks(blocks: MarkdownBlock[], ctx: Context): ReactNode[] {
121
144
  return blocks.map((block, index) => <Block key={index} block={block} ctx={ctx} />);
122
145
  }
123
146
 
124
- function ListMarker(props: { list: ListBlock; index: number; checked: boolean | null; s: Styles }) {
125
- const { list, index, checked, s } = props;
147
+ function TaskBox(props: { checked: boolean; ordinal: number; tasks: TaskToggles; s: Styles }) {
148
+ const { checked, ordinal, tasks } = props;
149
+ const { onToggle } = tasks;
150
+ const toggle = useCallback(() => onToggle(ordinal), [onToggle, ordinal]);
151
+ const a11yState = useMemo(() => ({ checked }), [checked]);
152
+ return (
153
+ <Pressable
154
+ accessibilityRole="checkbox"
155
+ accessibilityState={a11yState}
156
+ accessibilityLabel={checked ? "Uncheck item" : "Check item"}
157
+ hitSlop={CHECKBOX_HIT_SLOP}
158
+ onPress={toggle}
159
+ >
160
+ <Icon name={checked ? "SquareCheck" : "Square"} size={14} color={props.s.ink} />
161
+ </Pressable>
162
+ );
163
+ }
164
+
165
+ function ListMarker(props: {
166
+ list: ListBlock;
167
+ item: MarkdownListItem;
168
+ index: number;
169
+ ctx: Context;
170
+ }) {
171
+ const { list, item, index, ctx } = props;
172
+ const { s, tasks } = ctx;
173
+ const { checked } = item;
174
+ const ordinal = tasks?.ordinals.get(item);
175
+ if (checked !== null && tasks && ordinal !== undefined) {
176
+ return <TaskBox checked={checked} ordinal={ordinal} tasks={tasks} s={s} />;
177
+ }
126
178
  if (checked !== null) {
127
179
  return <Icon name={checked ? "SquareCheck" : "Square"} size={14} color={s.ink} />;
128
180
  }
@@ -140,7 +192,7 @@ function List({ list, ctx }: { list: ListBlock; ctx: Context }) {
140
192
  {list.items.map((item, index) => (
141
193
  <View key={index} style={s.item}>
142
194
  <View style={s.marker}>
143
- <ListMarker list={list} index={index} checked={item.checked} s={s} />
195
+ <ListMarker list={list} item={item} index={index} ctx={ctx} />
144
196
  </View>
145
197
  <View style={s.itemBody}>{renderBlocks(item.children, ctx)}</View>
146
198
  </View>
@@ -209,7 +261,11 @@ function Block({ block, ctx }: { block: MarkdownBlock; ctx: Context }) {
209
261
  </View>
210
262
  );
211
263
  case "blockquote":
212
- return <View style={s.quote}>{renderBlocks(block.children, ctx.quoted ?? ctx)}</View>;
264
+ return (
265
+ <View style={s.quote}>
266
+ {renderBlocks(block.children, ctx.quoted ? { ...ctx.quoted, tasks: ctx.tasks } : ctx)}
267
+ </View>
268
+ );
213
269
  case "list":
214
270
  return <List list={block} ctx={ctx} />;
215
271
  case "table":
@@ -224,17 +280,23 @@ export interface MarkdownProps {
224
280
  source: string;
225
281
  compact?: boolean;
226
282
  muted?: boolean;
283
+ /** Makes task checkboxes pressable. Gets the item's ordinal in `taskItems` order. */
284
+ onToggleTask?(ordinal: number): void;
227
285
  }
228
286
 
229
287
  export function Markdown(props: MarkdownProps) {
230
288
  const { colors } = props.theme;
231
289
  const compact = props.compact === true;
232
290
  const muted = props.muted === true;
291
+ const { onToggleTask } = props;
233
292
  const blocks = useMemo(() => parseMarkdown(props.source), [props.source]);
234
293
  const ctx = useMemo<Context>(() => {
235
294
  const quoted: Context = { s: createStyles(colors, compact, true) };
236
- return { s: muted ? quoted.s : createStyles(colors, compact, false), quoted };
237
- }, [colors, compact, muted]);
295
+ const tasks = onToggleTask
296
+ ? { ordinals: new Map(taskItems(blocks).map((item, n) => [item, n])), onToggle: onToggleTask }
297
+ : undefined;
298
+ return { s: muted ? quoted.s : createStyles(colors, compact, false), quoted, tasks };
299
+ }, [colors, compact, muted, blocks, onToggleTask]);
238
300
  if (blocks.length === 0) return null;
239
301
  return <View style={ctx.s.root}>{renderBlocks(blocks, ctx)}</View>;
240
302
  }
package/client/queries.ts CHANGED
@@ -6,6 +6,7 @@ import {
6
6
  useQuery,
7
7
  useQueryClient,
8
8
  } from "@tanstack/react-query";
9
+ import { useMemo } from "react";
9
10
  import {
10
11
  authStatusRpc,
11
12
  cachedCatalogRpc,
@@ -128,6 +129,36 @@ export function useUpdateIssue(issueId: string) {
128
129
  });
129
130
  }
130
131
 
132
+ // Plain RPC calls, not a mutation, so a save that finishes after the view closes still
133
+ // updates the cache. The update returns a summary without the description, so callers
134
+ // show the new text with `show` and the save leaves the cached description alone.
135
+ export function useDescriptionSaver(issueId: string) {
136
+ const scope = useKeyScope();
137
+ const update = useRpc(updateIssueRpc);
138
+ const queries = useQueryClient();
139
+ return useMemo(() => {
140
+ const key = queryKeys.issue(scope, issueId);
141
+ return {
142
+ show(description: string) {
143
+ void queries.cancelQueries({ queryKey: key });
144
+ queries.setQueryData<IssueDetail>(key, (current) =>
145
+ current ? { ...current, description } : current,
146
+ );
147
+ },
148
+ async save(description: string) {
149
+ const { issue } = await update({ id: issueId, patch: { description }, projectId: scope });
150
+ queries.setQueryData<IssueDetail>(key, (current) =>
151
+ current ? { ...current, ...issue } : current,
152
+ );
153
+ void queries.invalidateQueries({ queryKey: queryKeys.issues(scope) });
154
+ },
155
+ reload() {
156
+ void queries.invalidateQueries({ queryKey: key });
157
+ },
158
+ };
159
+ }, [scope, issueId, update, queries]);
160
+ }
161
+
131
162
  export function useAddComment(issueId: string) {
132
163
  const scope = useKeyScope();
133
164
  const create = useRpc(createCommentRpc);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yannelli/paseo-linear-plugin",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Browse and triage Linear issues in Paseo and start agents from them",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -414,3 +414,48 @@ function readBareUrl(text: string, i: number): Token | null {
414
414
  if (!safe) return null;
415
415
  return { nodes: [{ type: "link", url: safe, children: label }], end: i + url.length };
416
416
  }
417
+
418
+ /** Task items in document order. Renderers number checkboxes with this walk. */
419
+ export function taskItems(blocks: MarkdownBlock[]): MarkdownListItem[] {
420
+ const out: MarkdownListItem[] = [];
421
+ const walk = (children: MarkdownBlock[]) => {
422
+ for (const block of children) {
423
+ if (block.type === "blockquote") walk(block.children);
424
+ if (block.type !== "list") continue;
425
+ for (const item of block.items) {
426
+ if (item.checked !== null) out.push(item);
427
+ walk(item.children);
428
+ }
429
+ }
430
+ };
431
+ walk(blocks);
432
+ return out;
433
+ }
434
+
435
+ // A "[ ]" or "[x]" after any quote and list markers. The parser has the final word.
436
+ const TASK_LINE = /^((?:[ \t]*(?:>[ \t]?|(?:[-*+]|\d{1,9}[.)])[ \t]+))+)\[[ xX]\](?=[ \t]|$)/;
437
+
438
+ const taskStates = (source: string) => taskItems(parseMarkdown(source)).map((item) => item.checked);
439
+
440
+ // Flips task `ordinal` (0-based, `taskItems` order) by changing only its box character.
441
+ // Returns null when no line maps to that task, so callers never save a guess.
442
+ export function toggleTask(source: string, ordinal: number): string | null {
443
+ const before = taskStates(source);
444
+ if (!Number.isInteger(ordinal) || ordinal < 0 || ordinal >= before.length) return null;
445
+ const parts = source.split(/(\r\n?|\n)/);
446
+ let offset = 0;
447
+ let candidate = 0;
448
+ for (let k = 0; k < parts.length; k += 2) {
449
+ const match = TASK_LINE.exec(parts[k]);
450
+ // Every task is a candidate and both run in document order, so earlier ones can't match.
451
+ if (match && candidate++ >= ordinal) {
452
+ const at = offset + match[1].length + 1;
453
+ const next = `${source.slice(0, at)}${source[at] === " " ? "x" : " "}${source.slice(at + 1)}`;
454
+ const after = taskStates(next);
455
+ const flipsOnly = (state: boolean | null, n: number) => (n === ordinal) !== (state === before[n]);
456
+ if (after.length === before.length && after.every(flipsOnly)) return next;
457
+ }
458
+ offset += parts[k].length + (parts[k + 1]?.length ?? 0);
459
+ }
460
+ return null;
461
+ }