@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,312 @@
1
+ import type { PluginAgentSnapshot } from "@getpaseo/plugin";
2
+ import { Icon } from "@getpaseo/plugin/client/react-native";
3
+ import { useCallback, useEffect, useMemo, useState } from "react";
4
+ import { ActivityIndicator, Animated, Pressable, StyleSheet, Text, View } from "react-native";
5
+ import type { ActivityEvent } from "../shared/activity";
6
+ import type { IssueDetail } from "../shared/linear";
7
+ import type { LiveView, MappingMode } from "../shared/settings";
8
+ import { todoStatus } from "../shared/todo-sync";
9
+ import { eventTone, KIND_ICONS } from "./live-feed";
10
+ import { type IssueProgress, MONO, ProgressBar } from "./live-issues";
11
+ import { type LiveMap, NATIVE_DRIVER, type OwnerColors } from "./live-timeline";
12
+ import { ProviderIcon } from "./provider-icon";
13
+ import { Button, StateIcon, type Theme } from "./ui";
14
+
15
+ type AgentStatus = PluginAgentSnapshot["status"];
16
+
17
+ const STATUS_COPY: Record<AgentStatus, string> = {
18
+ initializing: "Starting",
19
+ idle: "Idle",
20
+ running: "Working",
21
+ error: "Error",
22
+ closed: "Closed",
23
+ };
24
+
25
+ function providerName(provider: string): string {
26
+ return provider ? provider.charAt(0).toUpperCase() + provider.slice(1) : "Agent";
27
+ }
28
+
29
+ function StatusChip(props: { theme: Theme; status: AgentStatus; provider: string; reduce: boolean }) {
30
+ const { theme, status } = props;
31
+ const { colors } = theme;
32
+ const running = status === "running" || status === "initializing";
33
+ const [glow] = useState(() => new Animated.Value(1));
34
+ useEffect(() => {
35
+ if (!running || props.reduce) return;
36
+ const loop = Animated.loop(
37
+ Animated.sequence([
38
+ Animated.timing(glow, { toValue: 0.35, duration: 700, useNativeDriver: NATIVE_DRIVER }),
39
+ Animated.timing(glow, { toValue: 1, duration: 700, useNativeDriver: NATIVE_DRIVER }),
40
+ ]),
41
+ );
42
+ loop.start();
43
+ return () => {
44
+ loop.stop();
45
+ glow.setValue(1);
46
+ };
47
+ }, [running, props.reduce, glow]);
48
+ let tone = colors.foregroundMuted;
49
+ if (running) tone = colors.statusSuccess;
50
+ else if (status === "error") tone = colors.statusDanger;
51
+ const styles = useMemo(
52
+ () =>
53
+ ({
54
+ chip: {
55
+ flexDirection: "row",
56
+ alignItems: "center",
57
+ gap: 7,
58
+ height: 26,
59
+ paddingHorizontal: 10,
60
+ borderRadius: 13,
61
+ borderWidth: 1,
62
+ borderColor: colors.border,
63
+ backgroundColor: colors.surface1,
64
+ },
65
+ dot: { width: 7, height: 7, borderRadius: 4, backgroundColor: tone, opacity: glow },
66
+ text: { color: colors.foreground, fontSize: 12, fontWeight: "500" },
67
+ muted: { color: colors.foregroundMuted, fontSize: 12 },
68
+ }) as const,
69
+ [colors, tone, glow],
70
+ );
71
+ return (
72
+ <View style={styles.chip}>
73
+ <Animated.View style={styles.dot} />
74
+ <Text style={styles.text}>{STATUS_COPY[status] ?? status}</Text>
75
+ <ProviderIcon provider={props.provider} size={12} color={colors.foregroundMuted} />
76
+ <Text style={styles.muted}>{providerName(props.provider)}</Text>
77
+ </View>
78
+ );
79
+ }
80
+
81
+ function NowLine(props: { theme: Theme; event: ActivityEvent; live: boolean }) {
82
+ const { theme, event } = props;
83
+ const { colors } = theme;
84
+ const tone = eventTone(theme, event);
85
+ const styles = useMemo(
86
+ () =>
87
+ ({
88
+ row: { flexDirection: "row", alignItems: "center", gap: 10, minWidth: 0 },
89
+ label: {
90
+ color: props.live ? colors.foreground : colors.foregroundMuted,
91
+ fontSize: 10,
92
+ fontWeight: "700",
93
+ letterSpacing: 0.6,
94
+ textTransform: "uppercase",
95
+ width: 34,
96
+ },
97
+ icon: {
98
+ width: 22,
99
+ height: 22,
100
+ borderRadius: 6,
101
+ alignItems: "center",
102
+ justifyContent: "center",
103
+ backgroundColor: colors.surface2,
104
+ },
105
+ tint: { ...StyleSheet.absoluteFillObject, borderRadius: 6, backgroundColor: tone, opacity: 0.14 },
106
+ text: { color: props.live ? colors.foreground : colors.foregroundMuted, fontSize: 13 },
107
+ detail: { flex: 1, color: colors.foregroundMuted, fontFamily: MONO, fontSize: 11 },
108
+ }) as const,
109
+ [colors, tone, props.live],
110
+ );
111
+ return (
112
+ <View style={styles.row} accessible accessibilityLabel={`${props.live ? "Now" : "Last"}: ${event.text}`}>
113
+ <Text style={styles.label}>{props.live ? "Now" : "Last"}</Text>
114
+ <View style={styles.icon}>
115
+ <View style={styles.tint} />
116
+ <Icon name={KIND_ICONS[event.kind]} size={12} color={tone} />
117
+ </View>
118
+ <Text numberOfLines={1} style={styles.text}>
119
+ {event.text}
120
+ </Text>
121
+ {event.detail ? (
122
+ <Text numberOfLines={1} style={styles.detail}>
123
+ {event.detail}
124
+ </Text>
125
+ ) : null}
126
+ </View>
127
+ );
128
+ }
129
+
130
+ export function LiveHeader(props: {
131
+ theme: Theme;
132
+ issue: IssueDetail;
133
+ progress: IssueProgress;
134
+ owners: OwnerColors;
135
+ status: AgentStatus;
136
+ provider: string;
137
+ current: ActivityEvent | null;
138
+ reduceMotion: boolean;
139
+ }) {
140
+ const { theme, issue, progress, owners } = props;
141
+ const { colors } = theme;
142
+ const rows = issue.children.length > 0 ? issue.children : [issue];
143
+ const segments = rows.map((row) => {
144
+ const key = row.identifier.toUpperCase();
145
+ const todos = progress.groups.get(key) ?? [];
146
+ const done = todos.filter((todo) => todoStatus(todo) === "completed").length;
147
+ const finished = row.state.type === "completed";
148
+ return {
149
+ key,
150
+ color: owners.get(key) ?? colors.accent,
151
+ value: finished ? 1 : todos.length > 0 ? done / todos.length : 0,
152
+ finished,
153
+ };
154
+ });
155
+ const allTodos = [...progress.groups.values()].flat();
156
+ const todosDone = allTodos.filter((todo) => todoStatus(todo) === "completed").length;
157
+ const issuesDone = segments.filter((segment) => segment.finished).length;
158
+ const counts = [
159
+ issue.children.length > 0 ? `${issuesDone} of ${issue.children.length} sub-issues done` : null,
160
+ allTodos.length > 0 ? `${todosDone} of ${allTodos.length} todos` : null,
161
+ ].filter(Boolean);
162
+ const styles = useMemo(
163
+ () =>
164
+ ({
165
+ root: { gap: 12 },
166
+ top: { flexDirection: "row", alignItems: "center", gap: 10, minWidth: 0 },
167
+ key: { color: colors.foregroundMuted, fontFamily: MONO, fontSize: 12 },
168
+ title: { flex: 1, color: colors.foreground, fontSize: 16, fontWeight: "600" },
169
+ strip: { flexDirection: "row", gap: 4 },
170
+ segment: { flex: 1 },
171
+ counts: { color: colors.foregroundMuted, fontSize: 12 },
172
+ }) as const,
173
+ [colors],
174
+ );
175
+ return (
176
+ <View style={styles.root}>
177
+ <View style={styles.top}>
178
+ <StateIcon state={issue.state} size={16} />
179
+ <Text style={styles.key}>{issue.identifier}</Text>
180
+ <Text numberOfLines={1} style={styles.title}>
181
+ {issue.title}
182
+ </Text>
183
+ <StatusChip
184
+ theme={theme}
185
+ status={props.status}
186
+ provider={props.provider}
187
+ reduce={props.reduceMotion}
188
+ />
189
+ </View>
190
+ <View style={styles.strip}>
191
+ {segments.map((segment) => (
192
+ <View key={segment.key} style={styles.segment}>
193
+ <ProgressBar
194
+ theme={theme}
195
+ value={segment.value}
196
+ color={segment.color}
197
+ reduce={props.reduceMotion}
198
+ height={5}
199
+ tinted
200
+ />
201
+ </View>
202
+ ))}
203
+ </View>
204
+ {counts.length > 0 ? <Text style={styles.counts}>{counts.join(" · ")}</Text> : null}
205
+ {props.current ? (
206
+ <NowLine theme={theme} event={props.current} live={props.status === "running"} />
207
+ ) : null}
208
+ </View>
209
+ );
210
+ }
211
+
212
+ /** Where the map came from, and the explore job's state, for the map card header. */
213
+ export function MapSource(props: { theme: Theme; live: LiveMap; mapping: MappingMode }) {
214
+ const { theme, live } = props;
215
+ const { colors } = theme;
216
+ const { explore } = live;
217
+ const styles = useMemo(
218
+ () =>
219
+ ({
220
+ row: { flexDirection: "row", alignItems: "center", gap: 8, flexWrap: "wrap" },
221
+ note: { color: colors.foregroundMuted, fontSize: 12 },
222
+ error: { color: colors.statusDanger, fontSize: 12, flexShrink: 1 },
223
+ }) as const,
224
+ [colors],
225
+ );
226
+ const start = useCallback(() => explore(false), [explore]);
227
+ const retry = useCallback(() => explore(true), [explore]);
228
+ const job = live.job;
229
+ const running = job?.status === "running";
230
+ const semantic = live.map.source === "semantic";
231
+ const canExplore =
232
+ props.mapping === "explore" && !live.stored && !running && job?.status !== "failed";
233
+ return (
234
+ <View style={styles.row}>
235
+ <Text style={styles.note}>{semantic ? "From ticket text" : "From explore agent"}</Text>
236
+ {running ? <ActivityIndicator size="small" color={colors.foregroundMuted} /> : null}
237
+ {running ? <Text style={styles.note}>Exploring…</Text> : null}
238
+ {job?.status === "failed" ? (
239
+ <>
240
+ <Text style={styles.error}>{job.error ?? "Explore failed"}</Text>
241
+ <Button theme={theme} size="xs" icon="RefreshCw" label="Retry" busy={live.starting} onPress={retry} />
242
+ </>
243
+ ) : null}
244
+ {canExplore ? (
245
+ <Button
246
+ theme={theme}
247
+ size="xs"
248
+ icon="Sparkles"
249
+ label="Map with explore agent"
250
+ busy={live.starting}
251
+ onPress={start}
252
+ />
253
+ ) : null}
254
+ {live.error ? <Text style={styles.error}>{live.error}</Text> : null}
255
+ </View>
256
+ );
257
+ }
258
+
259
+ export function ViewToggle(props: {
260
+ theme: Theme;
261
+ value: LiveView;
262
+ onChange(value: LiveView): void;
263
+ }) {
264
+ const { colors } = props.theme;
265
+ const styles = useMemo(() => {
266
+ const option = {
267
+ flexDirection: "row",
268
+ alignItems: "center",
269
+ gap: 5,
270
+ height: 24,
271
+ paddingHorizontal: 9,
272
+ borderRadius: 6,
273
+ } as const;
274
+ return {
275
+ track: {
276
+ flexDirection: "row",
277
+ padding: 2,
278
+ borderRadius: 8,
279
+ borderWidth: 1,
280
+ borderColor: colors.border,
281
+ backgroundColor: colors.surface1,
282
+ },
283
+ option,
284
+ on: { ...option, backgroundColor: colors.surface2 },
285
+ text: { color: colors.foregroundMuted, fontSize: 12 },
286
+ textOn: { color: colors.foreground, fontSize: 12, fontWeight: "500" },
287
+ } as const;
288
+ }, [colors]);
289
+ const options = [
290
+ { value: "map", label: "Map", icon: "LayoutGrid" },
291
+ { value: "graph", label: "Graph", icon: "Waypoints" },
292
+ ] as const;
293
+ return (
294
+ <View style={styles.track} accessibilityRole="tablist">
295
+ {options.map((option) => {
296
+ const on = props.value === option.value;
297
+ return (
298
+ <Pressable
299
+ key={option.value}
300
+ accessibilityRole="tab"
301
+ accessibilityState={{ selected: on }}
302
+ onPress={() => props.onChange(option.value)}
303
+ style={on ? styles.on : styles.option}
304
+ >
305
+ <Icon name={option.icon} size={12} color={on ? colors.foreground : colors.foregroundMuted} />
306
+ <Text style={on ? styles.textOn : styles.text}>{option.label}</Text>
307
+ </Pressable>
308
+ );
309
+ })}
310
+ </View>
311
+ );
312
+ }
@@ -0,0 +1,345 @@
1
+ import { Icon } from "@getpaseo/plugin/client/react-native";
2
+ import { useCallback, useEffect, useMemo, useState } from "react";
3
+ import { Animated, Platform, Pressable, StyleSheet, Text, View } from "react-native";
4
+ import type { IssueDetail } from "../shared/linear";
5
+ import { progressByKey, type TodoLike, todoKey, todoLabel, todoStatus } from "../shared/todo-sync";
6
+ import { NATIVE_DRIVER, type OwnerColors } from "./live-timeline";
7
+ import { IconButton, StateIcon, type Theme, usePressableStyle } from "./ui";
8
+
9
+ export const MONO = Platform.select({
10
+ ios: "Menlo",
11
+ android: "monospace",
12
+ default: "ui-monospace, SFMono-Regular, Menlo, monospace",
13
+ });
14
+ const TODO_ICONS = { pending: "Circle", in_progress: "CircleDot", completed: "CircleCheck" };
15
+
16
+ export type FocusKey = string | null;
17
+
18
+ export function ProgressBar(props: {
19
+ theme: Theme;
20
+ value: number;
21
+ color: string;
22
+ reduce: boolean;
23
+ height?: number;
24
+ /** Tints the empty track with the bar color, so an empty bar still shows whose it is. */
25
+ tinted?: boolean;
26
+ }) {
27
+ const { colors } = props.theme;
28
+ const height = props.height ?? 3;
29
+ const target = Math.max(0.001, Math.min(1, props.value));
30
+ const [scale] = useState(() => new Animated.Value(target));
31
+ useEffect(() => {
32
+ if (props.reduce) {
33
+ scale.setValue(target);
34
+ return;
35
+ }
36
+ const animation = Animated.timing(scale, {
37
+ toValue: target,
38
+ duration: 500,
39
+ useNativeDriver: NATIVE_DRIVER,
40
+ });
41
+ animation.start();
42
+ return () => animation.stop();
43
+ }, [target, props.reduce, scale]);
44
+ const styles = useMemo(
45
+ () =>
46
+ ({
47
+ track: {
48
+ height,
49
+ borderRadius: height / 2,
50
+ backgroundColor: colors.surface2,
51
+ overflow: "hidden",
52
+ },
53
+ tint: { ...StyleSheet.absoluteFillObject, backgroundColor: props.color, opacity: 0.22 },
54
+ fill: {
55
+ height,
56
+ width: "100%",
57
+ backgroundColor: props.color,
58
+ transformOrigin: "left",
59
+ transform: [{ scaleX: scale }],
60
+ },
61
+ }) as const,
62
+ [colors, props.color, scale, height],
63
+ );
64
+ return (
65
+ <View style={styles.track}>
66
+ {props.tinted ? <View style={styles.tint} /> : null}
67
+ <Animated.View style={styles.fill} />
68
+ </View>
69
+ );
70
+ }
71
+
72
+ function TodoRow({ theme, todo, color }: { theme: Theme; todo: TodoLike; color: string }) {
73
+ const { colors } = theme;
74
+ const status = todoStatus(todo);
75
+ const styles = useMemo(() => {
76
+ const done = status === "completed";
77
+ return {
78
+ row: { flexDirection: "row", alignItems: "flex-start", gap: 8, paddingVertical: 3 },
79
+ icon: { marginTop: 2 },
80
+ text: {
81
+ flex: 1,
82
+ fontSize: 12,
83
+ lineHeight: 17,
84
+ color: done || status === "pending" ? colors.foregroundMuted : colors.foreground,
85
+ textDecorationLine: done ? "line-through" : "none",
86
+ },
87
+ } as const;
88
+ }, [colors, status]);
89
+ let tone = colors.foregroundMuted;
90
+ if (status === "completed") tone = colors.statusSuccess;
91
+ else if (status === "in_progress") tone = color;
92
+ return (
93
+ <View style={styles.row}>
94
+ <View style={styles.icon}>
95
+ <Icon name={TODO_ICONS[status]} size={12} color={tone} />
96
+ </View>
97
+ <Text numberOfLines={2} style={styles.text}>
98
+ {todoLabel(todo.text)}
99
+ </Text>
100
+ </View>
101
+ );
102
+ }
103
+
104
+ interface RowData {
105
+ identifier: string;
106
+ title: string;
107
+ state: IssueDetail["state"];
108
+ }
109
+
110
+ function IssueRow(props: {
111
+ theme: Theme;
112
+ row: RowData;
113
+ color: string;
114
+ todos: readonly TodoLike[];
115
+ showTodos: boolean;
116
+ active: boolean;
117
+ focused: boolean;
118
+ parent: boolean;
119
+ reduceMotion: boolean;
120
+ onFocus(key: string): void;
121
+ }) {
122
+ const { theme, row, color, todos, active, focused, onFocus } = props;
123
+ const { colors } = theme;
124
+ const done = todos.filter((todo) => todoStatus(todo) === "completed").length;
125
+ const finished = row.state.type === "completed";
126
+ const styles = useMemo(() => {
127
+ const idle = {
128
+ flexDirection: "row",
129
+ gap: 10,
130
+ padding: 10,
131
+ borderRadius: 10,
132
+ borderWidth: 1,
133
+ borderColor: focused || active ? colors.border : "transparent",
134
+ backgroundColor: active || focused ? colors.surface1 : "transparent",
135
+ } as const;
136
+ return {
137
+ idle,
138
+ hover: { ...idle, backgroundColor: colors.surface1 },
139
+ bar: { width: 3, borderRadius: 2, backgroundColor: color, opacity: finished ? 0.4 : 1 },
140
+ body: { flex: 1, minWidth: 0, gap: 6 },
141
+ line: { flexDirection: "row", alignItems: "center", gap: 6 },
142
+ key: { color: colors.foregroundMuted, fontFamily: MONO, fontSize: 11 },
143
+ state: { color: colors.foregroundMuted, fontSize: 11 },
144
+ count: { marginLeft: "auto", color: colors.foregroundMuted, fontSize: 11 },
145
+ title: {
146
+ color: finished ? colors.foregroundMuted : colors.foreground,
147
+ fontSize: props.parent ? 14 : 13,
148
+ lineHeight: props.parent ? 19 : 18,
149
+ fontWeight: props.parent || active ? "600" : "400",
150
+ },
151
+ todos: { paddingTop: 2 },
152
+ } as const;
153
+ }, [colors, color, active, focused, finished, props.parent]);
154
+ const pressStyle = usePressableStyle(styles.idle, styles.hover);
155
+ const press = useCallback(() => onFocus(row.identifier.toUpperCase()), [onFocus, row]);
156
+ const a11yState = useMemo(() => ({ selected: focused }), [focused]);
157
+ return (
158
+ <Pressable
159
+ accessibilityRole="button"
160
+ accessibilityLabel={`${focused ? "Clear map focus on" : "Focus map on"} ${row.identifier}`}
161
+ accessibilityState={a11yState}
162
+ onPress={press}
163
+ style={pressStyle}
164
+ >
165
+ <View style={styles.bar} />
166
+ <View style={styles.body}>
167
+ <View style={styles.line}>
168
+ <StateIcon state={row.state} size={13} />
169
+ <Text style={styles.key}>{row.identifier}</Text>
170
+ <Text numberOfLines={1} style={styles.state}>
171
+ {row.state.name}
172
+ </Text>
173
+ {todos.length > 0 ? <Text style={styles.count}>{`${done}/${todos.length}`}</Text> : null}
174
+ </View>
175
+ <Text numberOfLines={2} style={styles.title}>
176
+ {row.title}
177
+ </Text>
178
+ {todos.length > 0 ? (
179
+ <ProgressBar
180
+ theme={theme}
181
+ value={finished ? 1 : done / todos.length}
182
+ color={color}
183
+ reduce={props.reduceMotion}
184
+ />
185
+ ) : null}
186
+ {props.showTodos && todos.length > 0 ? (
187
+ <View style={styles.todos}>
188
+ {todos.map((todo, index) => (
189
+ <TodoRow key={`${index}:${todo.text}`} theme={theme} todo={todo} color={color} />
190
+ ))}
191
+ </View>
192
+ ) : null}
193
+ </View>
194
+ </Pressable>
195
+ );
196
+ }
197
+
198
+ const NO_TODOS: readonly TodoLike[] = [];
199
+
200
+ export interface IssueProgress {
201
+ /** Todos grouped by the issue key their text starts with; others go to the parent. */
202
+ groups: ReadonlyMap<string, TodoLike[]>;
203
+ /** The sub-issue with a todo in progress. */
204
+ active: string | null;
205
+ }
206
+
207
+ export function issueProgress(issue: IssueDetail, todos: readonly TodoLike[]): IssueProgress {
208
+ const parentKey = issue.identifier.toUpperCase();
209
+ const children = new Set(issue.children.map((child) => child.identifier.toUpperCase()));
210
+ const groups = new Map<string, TodoLike[]>();
211
+ for (const todo of todos) {
212
+ const key = todoKey(todo.text);
213
+ const owner = key && children.has(key) ? key : parentKey;
214
+ groups.set(owner, [...(groups.get(owner) ?? []), todo]);
215
+ }
216
+ const progress = progressByKey(todos);
217
+ const active = issue.children.find(
218
+ (child) => (progress.get(child.identifier.toUpperCase())?.inProgress ?? 0) > 0,
219
+ );
220
+ return { groups, active: active?.identifier.toUpperCase() ?? null };
221
+ }
222
+
223
+ // The map legend. Selecting a row focuses the map on that issue's files.
224
+ export function SubIssues(props: {
225
+ theme: Theme;
226
+ issue: IssueDetail;
227
+ progress: IssueProgress;
228
+ owners: OwnerColors;
229
+ focus: FocusKey;
230
+ reduceMotion: boolean;
231
+ onFocus(key: string): void;
232
+ }) {
233
+ const { theme, issue, progress, owners, focus } = props;
234
+ const parentKey = issue.identifier.toUpperCase();
235
+ const fallback = theme.colors.accent;
236
+ const styles = useMemo(
237
+ () =>
238
+ ({
239
+ list: { gap: 4 },
240
+ label: {
241
+ color: theme.colors.foregroundMuted,
242
+ fontSize: 11,
243
+ fontWeight: "600",
244
+ letterSpacing: 0.4,
245
+ textTransform: "uppercase",
246
+ paddingHorizontal: 10,
247
+ paddingTop: 14,
248
+ paddingBottom: 4,
249
+ },
250
+ }) as const,
251
+ [theme],
252
+ );
253
+ return (
254
+ <View style={styles.list}>
255
+ <IssueRow
256
+ theme={theme}
257
+ row={issue}
258
+ color={owners.get(parentKey) ?? fallback}
259
+ todos={progress.groups.get(parentKey) ?? NO_TODOS}
260
+ showTodos
261
+ active={false}
262
+ focused={focus === parentKey}
263
+ parent
264
+ reduceMotion={props.reduceMotion}
265
+ onFocus={props.onFocus}
266
+ />
267
+ {issue.children.length > 0 ? <Text style={styles.label}>Sub-issues</Text> : null}
268
+ {issue.children.map((child) => {
269
+ const key = child.identifier.toUpperCase();
270
+ const active = progress.active === key;
271
+ return (
272
+ <IssueRow
273
+ key={child.id}
274
+ theme={theme}
275
+ row={child}
276
+ color={owners.get(key) ?? fallback}
277
+ todos={progress.groups.get(key) ?? NO_TODOS}
278
+ showTodos={active || focus === key}
279
+ active={active}
280
+ focused={focus === key}
281
+ parent={false}
282
+ reduceMotion={props.reduceMotion}
283
+ onFocus={props.onFocus}
284
+ />
285
+ );
286
+ })}
287
+ </View>
288
+ );
289
+ }
290
+
291
+ /** The issues column when collapsed: a button to open it and each issue's state. */
292
+ export function IssueRail(props: {
293
+ theme: Theme;
294
+ issue: IssueDetail;
295
+ owners: OwnerColors;
296
+ focus: FocusKey;
297
+ onFocus(key: string): void;
298
+ onExpand(): void;
299
+ }) {
300
+ const { theme, issue, owners, focus } = props;
301
+ const { colors } = theme;
302
+ const styles = useMemo(
303
+ () =>
304
+ ({
305
+ rail: {
306
+ width: 48,
307
+ alignItems: "center",
308
+ gap: 8,
309
+ paddingVertical: 8,
310
+ borderWidth: 1,
311
+ borderColor: colors.border,
312
+ borderRadius: 12,
313
+ backgroundColor: colors.surface0,
314
+ },
315
+ rule: { width: 24, height: 1, backgroundColor: colors.border },
316
+ dot: { width: 34, height: 38, borderRadius: 8, alignItems: "center", justifyContent: "center", gap: 4, borderWidth: 2 },
317
+ bar: { width: 14, height: 3, borderRadius: 2 },
318
+ }) as const,
319
+ [colors],
320
+ );
321
+ const rows = [issue, ...issue.children];
322
+ return (
323
+ <View style={styles.rail}>
324
+ <IconButton theme={theme} icon="PanelLeftOpen" label="Show issues" onPress={props.onExpand} />
325
+ <View style={styles.rule} />
326
+ {rows.map((row) => {
327
+ const key = row.identifier.toUpperCase();
328
+ const focused = focus === key;
329
+ return (
330
+ <Pressable
331
+ key={row.id}
332
+ accessibilityRole="button"
333
+ accessibilityLabel={`${row.identifier}: ${row.title}, ${row.state.name}`}
334
+ accessibilityState={{ selected: focused }}
335
+ onPress={() => props.onFocus(key)}
336
+ style={[styles.dot, { borderColor: focused ? (owners.get(key) ?? colors.accent) : "transparent" }]}
337
+ >
338
+ <StateIcon state={row.state} size={row === issue ? 17 : 15} />
339
+ <View style={[styles.bar, { backgroundColor: owners.get(key) ?? colors.accent }]} />
340
+ </Pressable>
341
+ );
342
+ })}
343
+ </View>
344
+ );
345
+ }