@yannelli/paseo-linear-plugin 1.0.0 → 1.1.0-beta.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 (47) hide show
  1. package/OVERVIEW.md +19 -1
  2. package/README.md +77 -6
  3. package/client/access.tsx +66 -0
  4. package/client/agent-options.ts +12 -2
  5. package/client/agent-preferences.ts +112 -0
  6. package/client/browser.tsx +12 -9
  7. package/client/graph-labels.ts +137 -0
  8. package/client/launch-choices.ts +18 -5
  9. package/client/launch-plan.ts +88 -39
  10. package/client/launch.tsx +9 -4
  11. package/client/live-agents.tsx +407 -0
  12. package/client/live-cursor.tsx +291 -0
  13. package/client/live-feed.tsx +151 -0
  14. package/client/live-graph.tsx +434 -0
  15. package/client/live-header.tsx +312 -0
  16. package/client/live-issues.tsx +345 -0
  17. package/client/live-map.tsx +345 -0
  18. package/client/live-panel.tsx +444 -0
  19. package/client/live-timeline.ts +341 -0
  20. package/client/pill.tsx +393 -0
  21. package/client/project-init.tsx +168 -0
  22. package/client/settings-access.tsx +56 -0
  23. package/client/settings-live.tsx +121 -0
  24. package/client/settings-projects.tsx +9 -0
  25. package/index.client.tsx +39 -1
  26. package/index.server.ts +24 -4
  27. package/package.json +1 -1
  28. package/server/agent-runs.ts +299 -0
  29. package/server/handlers.ts +12 -1
  30. package/server/init.ts +64 -0
  31. package/server/links.ts +153 -0
  32. package/server/live.ts +334 -0
  33. package/server/stores.ts +119 -0
  34. package/server/sync.ts +127 -0
  35. package/shared/activity.ts +308 -0
  36. package/shared/graph-geometry.ts +122 -0
  37. package/shared/graph-model.ts +319 -0
  38. package/shared/links.ts +252 -0
  39. package/shared/live.ts +200 -0
  40. package/shared/map-model.ts +192 -0
  41. package/shared/project-setup.ts +55 -0
  42. package/shared/prompts.ts +16 -1
  43. package/shared/settings.ts +57 -1
  44. package/shared/shell-activity.ts +309 -0
  45. package/shared/shell-lexer.ts +180 -0
  46. package/shared/subagents.ts +106 -0
  47. package/shared/todo-sync.ts +158 -0
@@ -0,0 +1,341 @@
1
+ import { usePaseo, useRpc } from "@getpaseo/plugin/client";
2
+ import { keepPreviousData, useMutation, useQuery } from "@tanstack/react-query";
3
+ import { useEffect, useMemo, useState } from "react";
4
+ import { AccessibilityInfo, Platform } from "react-native";
5
+ import type { DirTouch, FileTouch, TimelineItemLike } from "../shared/activity";
6
+ import type { IssueDetail } from "../shared/linear";
7
+ import type { FileLink } from "../shared/links";
8
+ import {
9
+ exploreRpc,
10
+ type IssueMap,
11
+ type Job,
12
+ listFilesRpc,
13
+ linksRpc,
14
+ liveMapRpc,
15
+ MAX_LINK_FILES,
16
+ MAX_LIST_DIRS,
17
+ type MapFile,
18
+ semanticMap,
19
+ } from "../shared/live";
20
+ import { buildMapModel, type MapModel, zoneDirs } from "../shared/map-model";
21
+ import type { MappingMode } from "../shared/settings";
22
+ import { errorMessage } from "./ui";
23
+
24
+ export interface AgentTimeline {
25
+ items: TimelineItemLike[];
26
+ loading: boolean;
27
+ error: string | null;
28
+ }
29
+
30
+ const TAIL_LIMIT = 400;
31
+ const MAX_ITEMS = 600;
32
+ const FLUSH_MS = 150;
33
+ const TODO_SLOT = "todo";
34
+ // Assistant text and reasoning stream per token and carry no file activity.
35
+ const SKIPPED = new Set(["assistant_message", "reasoning"]);
36
+ const INITIAL: AgentTimeline = { items: [], loading: true, error: null };
37
+
38
+ function asItem(value: unknown): TimelineItemLike | null {
39
+ if (!value || typeof value !== "object") return null;
40
+ return typeof (value as { type?: unknown }).type === "string"
41
+ ? (value as TimelineItemLike)
42
+ : null;
43
+ }
44
+
45
+ // Ordered slots: a tool call keeps its first position as later copies replace it, the single
46
+ // todo slot moves to the end on each update, and the cap drops the oldest other slot.
47
+ function createSlots() {
48
+ const slots = new Map<string, TimelineItemLike>();
49
+ let counter = 0;
50
+ return {
51
+ add(item: TimelineItemLike): boolean {
52
+ if (SKIPPED.has(item.type)) return false;
53
+ let key = `item:${counter++}`;
54
+ if (item.type === "todo") {
55
+ key = TODO_SLOT;
56
+ slots.delete(TODO_SLOT);
57
+ } else if (item.type === "tool_call" && typeof item.callId === "string") {
58
+ key = `call:${item.callId}`;
59
+ }
60
+ slots.set(key, item);
61
+ for (const oldest of slots.keys()) {
62
+ if (slots.size <= MAX_ITEMS) break;
63
+ if (oldest !== TODO_SLOT) slots.delete(oldest);
64
+ }
65
+ return true;
66
+ },
67
+ clear() {
68
+ slots.clear();
69
+ },
70
+ values(): TimelineItemLike[] {
71
+ return [...slots.values()];
72
+ },
73
+ };
74
+ }
75
+
76
+ function later(run: () => void, ms: number): () => void {
77
+ const id = setTimeout(run, ms);
78
+ return () => clearTimeout(id);
79
+ }
80
+
81
+ type Paseo = ReturnType<typeof usePaseo>;
82
+ type Update = AgentTimeline | ((current: AgentTimeline) => AgentTimeline);
83
+
84
+ /** Watches one agent's tool calls and todos, batched about every 150 ms. Returns cleanup. */
85
+ function watchTimeline(paseo: Paseo, agentId: string, setState: (update: Update) => void) {
86
+ const handle = paseo.agents.ref(agentId);
87
+ const slots = createSlots();
88
+ let disposed = false;
89
+ let request = 0;
90
+ let replay: TimelineItemLike[] | null = [];
91
+ let cancelFlush: (() => void) | null = null;
92
+ let streamError: string | null = null;
93
+ setState(INITIAL);
94
+
95
+ const flush = () => {
96
+ cancelFlush?.();
97
+ cancelFlush = null;
98
+ if (!disposed) setState({ items: slots.values(), loading: false, error: streamError });
99
+ };
100
+ const schedule = () => {
101
+ if (!cancelFlush) cancelFlush = later(flush, FLUSH_MS);
102
+ };
103
+ const fail = (message: string) => {
104
+ if (disposed) return;
105
+ setState((current) => ({ ...current, loading: false, error: message }));
106
+ };
107
+ const streamFailed = (message: string) => {
108
+ streamError = message;
109
+ fail(message);
110
+ };
111
+
112
+ // Events that arrive while the tail loads are applied again on top of it.
113
+ const load = async () => {
114
+ const mine = ++request;
115
+ if (!replay) replay = [];
116
+ try {
117
+ const page = await handle.timeline.refetch({
118
+ direction: "tail",
119
+ limit: TAIL_LIMIT,
120
+ projection: "projected",
121
+ });
122
+ if (disposed || mine !== request) return;
123
+ const pending = replay ?? [];
124
+ replay = null;
125
+ slots.clear();
126
+ for (const entry of page.entries) {
127
+ const item = asItem(entry.item);
128
+ if (item) slots.add(item);
129
+ }
130
+ for (const item of pending) slots.add(item);
131
+ flush();
132
+ } catch (error) {
133
+ if (mine !== request) return;
134
+ replay = null;
135
+ fail(errorMessage(error));
136
+ }
137
+ };
138
+
139
+ const subscription = handle.timeline.subscribe((event) => {
140
+ if (disposed) return;
141
+ const inner = event.event;
142
+ if (inner.type === "timeline") {
143
+ const item = asItem(inner.item);
144
+ if (!item || !slots.add(item)) return;
145
+ replay?.push(item);
146
+ schedule();
147
+ } else if (inner.type === "replacement" || inner.type === "subscription_restored") {
148
+ streamError = null;
149
+ replay = null;
150
+ void load();
151
+ } else if (inner.type === "error") {
152
+ streamFailed(inner.error);
153
+ }
154
+ });
155
+
156
+ void subscription.ready
157
+ .catch((error: unknown) => streamFailed(errorMessage(error)))
158
+ .then(() => {
159
+ if (!disposed && request === 0) void load();
160
+ });
161
+
162
+ return () => {
163
+ disposed = true;
164
+ cancelFlush?.();
165
+ subscription();
166
+ };
167
+ }
168
+
169
+ /** Tool calls and todos of one agent, live. */
170
+ export function useAgentTimeline(agentId: string): AgentTimeline {
171
+ const paseo = usePaseo();
172
+ const [state, setState] = useState(INITIAL);
173
+ useEffect(() => watchTimeline(paseo, agentId, setState), [paseo, agentId]);
174
+ return state;
175
+ }
176
+
177
+ const NO_TIMELINES: ReadonlyMap<string, AgentTimeline> = new Map();
178
+
179
+ /** Timelines of several agents, such as a parent's child agents, keyed by agent id. */
180
+ export function useAgentTimelines(agentIds: readonly string[]): ReadonlyMap<string, AgentTimeline> {
181
+ const paseo = usePaseo();
182
+ const [state, setState] = useState(NO_TIMELINES);
183
+ const key = agentIds.join(",");
184
+ useEffect(() => {
185
+ const ids = key ? key.split(",") : [];
186
+ // Keep the timelines of agents still in the list until their new watch loads, so they do not blink.
187
+ setState((current) => new Map([...current].filter(([id]) => ids.includes(id))));
188
+ const stops = ids.map((id) =>
189
+ watchTimeline(paseo, id, (update) =>
190
+ setState((current) => {
191
+ const known = current.get(id);
192
+ if (update === INITIAL && known) return current;
193
+ const next = new Map(current);
194
+ next.set(id, typeof update === "function" ? update(known ?? INITIAL) : update);
195
+ return next;
196
+ }),
197
+ ),
198
+ );
199
+ return () => {
200
+ for (const stop of stops) stop();
201
+ };
202
+ }, [paseo, key]);
203
+ return state;
204
+ }
205
+
206
+ // Shared by the map and the panel: motion, sub-issue colors, map data, and the map model.
207
+ export const NATIVE_DRIVER = Platform.OS !== "web";
208
+ const PALETTE = ["#5e6ad2", "#26b5ce", "#f2994a", "#bb87fc", "#4cb782", "#eb5757", "#e2b93d"];
209
+
210
+ /** True until the platform answers, so nothing animates for someone who asked for less. */
211
+ export function useReduceMotion(): boolean {
212
+ const [reduced, setReduced] = useState(true);
213
+ useEffect(() => {
214
+ let active = true;
215
+ AccessibilityInfo.isReduceMotionEnabled()
216
+ .then((value) => active && setReduced(value))
217
+ .catch(() => active && setReduced(false));
218
+ return () => {
219
+ active = false;
220
+ };
221
+ }, []);
222
+ return reduced;
223
+ }
224
+
225
+ export type OwnerColors = ReadonlyMap<string, string>;
226
+
227
+ /** The parent gets the accent; each sub-issue gets a palette color by its position. */
228
+ export function ownerColors(issue: IssueDetail, accent: string): OwnerColors {
229
+ const colors = new Map([[issue.identifier.toUpperCase(), accent]]);
230
+ issue.children.forEach((child, index) => {
231
+ colors.set(child.identifier.toUpperCase(), PALETTE[index % PALETTE.length] ?? accent);
232
+ });
233
+ return colors;
234
+ }
235
+
236
+ // Map data: the stored map, else one from the ticket text. Polls fast while an explore job
237
+ // runs, and slowly while there is no map, since a launch can start a job after this mounts.
238
+ export interface LiveMap {
239
+ map: IssueMap;
240
+ stored: boolean;
241
+ /** The stored map has been read once, so the map no longer swaps from the fallback. */
242
+ ready: boolean;
243
+ job: Job | null;
244
+ starting: boolean;
245
+ error: string | null;
246
+ explore(force: boolean): void;
247
+ }
248
+
249
+ export function useLiveMap(
250
+ agentId: string,
251
+ identifier: string,
252
+ issue: IssueDetail,
253
+ mapping: MappingMode,
254
+ ): LiveMap {
255
+ const read = useRpc(liveMapRpc);
256
+ const start = useRpc(exploreRpc);
257
+ const query = useQuery({
258
+ queryKey: ["linear", "live", "map", identifier],
259
+ queryFn: () => read({ identifier }),
260
+ refetchInterval: (current) => {
261
+ const data = current.state.data;
262
+ if (data?.job?.status === "running") return 4_000;
263
+ return data?.map || mapping !== "explore" ? false : 15_000;
264
+ },
265
+ staleTime: 10_000,
266
+ });
267
+ const { refetch } = query;
268
+ const explore = useMutation({
269
+ mutationFn: (force: boolean) => start({ agentId, force }),
270
+ onSettled: () => void refetch(),
271
+ });
272
+ const { mutate } = explore;
273
+ const fallback = useMemo(() => semanticMap(issue), [issue]);
274
+ const stored = query.data?.map ?? null;
275
+ const failure = explore.error ?? query.error;
276
+ return {
277
+ map: stored ?? fallback,
278
+ stored: stored !== null,
279
+ ready: query.data !== undefined || query.isError,
280
+ job: query.data?.job ?? null,
281
+ starting: explore.isPending,
282
+ error: failure ? errorMessage(failure) : null,
283
+ explore: mutate,
284
+ };
285
+ }
286
+
287
+ const NO_LISTING: ReadonlyMap<string, readonly string[]> = new Map();
288
+
289
+ export function useMapModel(
290
+ agentId: string,
291
+ files: readonly MapFile[],
292
+ touches: readonly FileTouch[],
293
+ explored: readonly DirTouch[],
294
+ ): MapModel & { ready: boolean } {
295
+ const list = useRpc(listFilesRpc);
296
+ const dirs = useMemo(
297
+ () => zoneDirs(files, touches).slice(0, MAX_LIST_DIRS).sort(),
298
+ [files, touches],
299
+ );
300
+ const listing = useQuery({
301
+ queryKey: ["linear", "live", "files", agentId, dirs],
302
+ queryFn: () => list({ agentId, dirs }),
303
+ enabled: dirs.length > 0,
304
+ placeholderData: keepPreviousData,
305
+ staleTime: 30_000,
306
+ });
307
+ const byDir = useMemo(() => {
308
+ if (!listing.data) return NO_LISTING;
309
+ return new Map(listing.data.dirs.map((entry) => [entry.dir, entry.files]));
310
+ }, [listing.data]);
311
+ const ready = dirs.length === 0 || listing.data !== undefined || listing.isError;
312
+ return useMemo(
313
+ () => ({ ...buildMapModel(files, touches, byDir, explored), ready }),
314
+ [files, touches, byDir, explored, ready],
315
+ );
316
+ }
317
+
318
+ const NO_LINKS: readonly FileLink[] = [];
319
+
320
+ /** Links between the graph's files. `version` changes when files change, to read them again. */
321
+ export function useFileLinks(
322
+ agentId: string,
323
+ files: readonly string[],
324
+ version: number,
325
+ enabled: boolean,
326
+ ): { links: readonly FileLink[]; ready: boolean } {
327
+ const read = useRpc(linksRpc);
328
+ const sorted = useMemo(() => [...files].sort().slice(0, MAX_LINK_FILES), [files]);
329
+ const query = useQuery({
330
+ queryKey: ["linear", "live", "links", agentId, sorted, version],
331
+ queryFn: () => read({ agentId, files: sorted }),
332
+ enabled: enabled && sorted.length > 0,
333
+ placeholderData: keepPreviousData,
334
+ staleTime: 30_000,
335
+ });
336
+ return {
337
+ links: query.data?.links ?? NO_LINKS,
338
+ // With no files or a failed read the graph shows without links rather than waiting.
339
+ ready: sorted.length === 0 || query.data !== undefined || query.isError,
340
+ };
341
+ }