@yannelli/paseo-linear-plugin 1.0.0 → 1.1.0-alpha.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 (65) hide show
  1. package/OVERVIEW.md +32 -2
  2. package/README.md +138 -11
  3. package/client/access.tsx +67 -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/compat.ts +23 -5
  8. package/client/connect.tsx +3 -2
  9. package/client/launch-assignments.tsx +182 -0
  10. package/client/launch-choices.ts +23 -6
  11. package/client/launch-guidance.tsx +103 -0
  12. package/client/launch-plan.ts +166 -42
  13. package/client/launch.tsx +55 -9
  14. package/client/live-agents.tsx +446 -0
  15. package/client/live-feed.tsx +151 -0
  16. package/client/live-header.tsx +310 -0
  17. package/client/live-issues.tsx +345 -0
  18. package/client/live-map.tsx +351 -0
  19. package/client/live-panel.tsx +392 -0
  20. package/client/live-timeline.ts +379 -0
  21. package/client/pill.tsx +394 -0
  22. package/client/plugin-icon.tsx +75 -0
  23. package/client/project-init.tsx +192 -0
  24. package/client/project-knowledge.tsx +87 -0
  25. package/client/settings-access.tsx +56 -0
  26. package/client/settings-agents.tsx +113 -0
  27. package/client/settings-fields.tsx +11 -3
  28. package/client/settings-icon.tsx +277 -0
  29. package/client/settings-live.tsx +128 -0
  30. package/client/settings-overrides.tsx +148 -0
  31. package/client/settings-projects.tsx +26 -4
  32. package/client/settings-prompts.tsx +3 -3
  33. package/client/timeline-card.tsx +2 -1
  34. package/client/tool-buttons.tsx +68 -0
  35. package/client/ui.tsx +16 -7
  36. package/client/web.ts +81 -0
  37. package/index.client.tsx +160 -51
  38. package/index.server.ts +32 -5
  39. package/package.json +1 -1
  40. package/server/agent-hooks.ts +37 -0
  41. package/server/agent-runs.ts +312 -0
  42. package/server/agent-tools.ts +276 -0
  43. package/server/handlers.ts +12 -1
  44. package/server/init.ts +89 -0
  45. package/server/inspect.ts +392 -0
  46. package/server/knowledge.ts +157 -0
  47. package/server/live.ts +371 -0
  48. package/server/mcp-http.ts +156 -0
  49. package/server/stores.ts +126 -0
  50. package/server/subagent-logs.ts +146 -0
  51. package/server/sync.ts +133 -0
  52. package/shared/activity.ts +268 -0
  53. package/shared/agent-hooks.ts +68 -0
  54. package/shared/agent-tools.ts +157 -0
  55. package/shared/custom-icon.ts +188 -0
  56. package/shared/knowledge.ts +255 -0
  57. package/shared/live.ts +248 -0
  58. package/shared/map-model.ts +240 -0
  59. package/shared/project-setup.ts +56 -0
  60. package/shared/prompts.ts +109 -2
  61. package/shared/settings.ts +145 -3
  62. package/shared/shell-activity.ts +309 -0
  63. package/shared/shell-lexer.ts +180 -0
  64. package/shared/subagents.ts +106 -0
  65. package/shared/todo-sync.ts +158 -0
@@ -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
+ }
@@ -0,0 +1,351 @@
1
+ import { Icon } from "@getpaseo/plugin/client/react-native";
2
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
+ import { Animated, type LayoutChangeEvent, StyleSheet, Text, View } from "react-native";
4
+ import { MONO } from "./live-issues";
5
+ import {
6
+ type MapModel,
7
+ type MapTile,
8
+ type MapZone,
9
+ masonry,
10
+ ZONE_GAP,
11
+ zoneAreaLabel,
12
+ } from "../shared/map-model";
13
+ import { NATIVE_DRIVER, type OwnerColors } from "./live-timeline";
14
+ import type { AgentMarker } from "./live-agents";
15
+ import { ProviderIcon } from "./provider-icon";
16
+ import type { Theme } from "./ui";
17
+
18
+ const ZONE_ICONS = { none: "Folder", listed: "FolderOpen", searched: "FolderSearch" } as const;
19
+ const MIN_ZONE_WIDTH = 260;
20
+ const MAX_COLUMNS = 4;
21
+ interface TileProps {
22
+ theme: Theme;
23
+ tile: MapTile;
24
+ color: string | null;
25
+ dim: boolean;
26
+ /** Agents on this file now, and subagents asked about it. */
27
+ markers: readonly AgentMarker[];
28
+ reduceMotion: boolean;
29
+ }
30
+
31
+ const NO_MARKERS: readonly AgentMarker[] = [];
32
+
33
+ function MarkerBadge({ theme, marker }: { theme: Theme; marker: AgentMarker }) {
34
+ const { colors } = theme;
35
+ const sub = marker.kind === "subagent";
36
+ const style = useMemo(
37
+ () =>
38
+ ({
39
+ width: 16,
40
+ height: 16,
41
+ borderRadius: 8,
42
+ alignItems: "center",
43
+ justifyContent: "center",
44
+ borderWidth: sub ? 1 : 0,
45
+ borderColor: colors.border,
46
+ backgroundColor: sub ? colors.surface2 : marker.color,
47
+ }) as const,
48
+ [colors, sub, marker.color],
49
+ );
50
+ const tint = sub ? colors.foregroundMuted : colors.accentForeground;
51
+ return (
52
+ <View style={style} accessibilityLabel={marker.label}>
53
+ {marker.provider ? (
54
+ <ProviderIcon provider={marker.provider} size={10} color={tint} />
55
+ ) : (
56
+ <Icon name="Bot" size={10} color={tint} />
57
+ )}
58
+ </View>
59
+ );
60
+ }
61
+
62
+ function tileLabel(tile: MapTile): string {
63
+ if (tile.ghost) return `${tile.path}, not on the map`;
64
+ const where = tile.onMap ? `on the map for ${tile.owner}` : "off the map";
65
+ const touch = tile.touch === "none" ? "not touched" : tile.touch;
66
+ return `${tile.path}, ${touch}, ${where}`;
67
+ }
68
+
69
+ function Tile({ theme, tile, color, dim, markers, reduceMotion }: TileProps) {
70
+ const { colors } = theme;
71
+ const active = markers.find((marker) => marker.kind !== "subagent");
72
+ const current = active !== undefined;
73
+ const touched = tile.touch !== "none";
74
+ const offMap = touched && !tile.onMap;
75
+ const tone = offMap ? colors.statusWarning : (color ?? colors.foregroundMuted);
76
+ const filled = tile.touch === "edited" || tile.touch === "created";
77
+ const [pulse] = useState(() => new Animated.Value(1));
78
+ const [born] = useState(() => new Animated.Value(1));
79
+ const wasTouched = useRef(touched);
80
+ const styles = useMemo(() => {
81
+ let borderColor = colors.border;
82
+ if (active) borderColor = active.color;
83
+ else if (offMap) borderColor = colors.statusWarning;
84
+ else if (touched) borderColor = tone;
85
+ return {
86
+ root: {
87
+ height: 26,
88
+ paddingLeft: 8,
89
+ paddingRight: 9,
90
+ borderRadius: 7,
91
+ borderWidth: tile.ghost ? 0 : 1,
92
+ borderStyle: offMap ? "dashed" : "solid",
93
+ borderColor,
94
+ backgroundColor: tile.ghost ? "transparent" : touched ? colors.surface2 : colors.surface1,
95
+ flexDirection: "row",
96
+ alignItems: "center",
97
+ gap: 6,
98
+ maxWidth: "100%",
99
+ opacity: (tile.ghost ? 0.4 : 1) * (dim ? 0.25 : 1),
100
+ },
101
+ fill: { ...StyleSheet.absoluteFillObject, borderRadius: 6, backgroundColor: tone, opacity: 0.16 },
102
+ dot: {
103
+ width: 6,
104
+ height: 6,
105
+ borderRadius: 3,
106
+ borderWidth: 1.5,
107
+ borderColor: tile.ghost ? colors.surface2 : tone,
108
+ backgroundColor: touched ? tone : "transparent",
109
+ },
110
+ name: {
111
+ flexShrink: 1,
112
+ fontFamily: MONO,
113
+ fontSize: 11.5,
114
+ color: touched ? colors.foreground : colors.foregroundMuted,
115
+ },
116
+ chip: { color: colors.foregroundMuted, fontSize: 10 },
117
+ added: { color: colors.statusSuccess, fontSize: 10 },
118
+ removed: { color: colors.statusDanger, fontSize: 10 },
119
+ badges: { position: "absolute", top: -7, right: -7, flexDirection: "row-reverse", gap: 2 },
120
+ } as const;
121
+ }, [colors, tile.ghost, touched, offMap, tone, current, active, dim]);
122
+ const motion = useMemo(
123
+ () => ({ maxWidth: "100%", transform: [{ scale: Animated.multiply(pulse, born) }] }) as const,
124
+ [pulse, born],
125
+ );
126
+ useEffect(() => {
127
+ if (touched === wasTouched.current) return;
128
+ wasTouched.current = touched;
129
+ if (!touched || reduceMotion) return;
130
+ born.setValue(0.82);
131
+ const animation = Animated.spring(born, {
132
+ toValue: 1,
133
+ friction: 5,
134
+ tension: 160,
135
+ useNativeDriver: NATIVE_DRIVER,
136
+ });
137
+ animation.start();
138
+ return () => animation.stop();
139
+ }, [touched, reduceMotion, born]);
140
+ useEffect(() => {
141
+ if (!current || reduceMotion) return;
142
+ const animation = Animated.sequence([
143
+ Animated.timing(pulse, { toValue: 1.08, duration: 140, useNativeDriver: NATIVE_DRIVER }),
144
+ Animated.timing(pulse, { toValue: 1, duration: 220, useNativeDriver: NATIVE_DRIVER }),
145
+ ]);
146
+ animation.start();
147
+ return () => {
148
+ animation.stop();
149
+ pulse.setValue(1);
150
+ };
151
+ }, [current, reduceMotion, pulse]);
152
+ return (
153
+ <Animated.View style={motion} accessible accessibilityLabel={tileLabel(tile)}>
154
+ <View style={styles.root}>
155
+ {filled ? <View style={styles.fill} /> : null}
156
+ <View style={styles.dot} />
157
+ <Text numberOfLines={1} style={styles.name}>
158
+ {tile.name}
159
+ </Text>
160
+ {tile.touch === "created" ? <Text style={styles.chip}>new</Text> : null}
161
+ {tile.added > 0 ? <Text style={styles.added}>{`+${tile.added}`}</Text> : null}
162
+ {tile.removed > 0 ? <Text style={styles.removed}>{`-${tile.removed}`}</Text> : null}
163
+ </View>
164
+ {markers.length > 0 ? (
165
+ <View style={styles.badges} pointerEvents="none">
166
+ {markers.slice(0, 2).map((marker) => (
167
+ <MarkerBadge key={marker.key} theme={theme} marker={marker} />
168
+ ))}
169
+ </View>
170
+ ) : null}
171
+ </Animated.View>
172
+ );
173
+ }
174
+
175
+ function Zone(props: RepoMapProps & { zone: MapZone }) {
176
+ const { theme, zone, focus } = props;
177
+ const { colors } = theme;
178
+ const hot = zone.touched > 0;
179
+ const seen = hot || zone.explored !== "none";
180
+ const styles = useMemo(
181
+ () =>
182
+ ({
183
+ zone: {
184
+ minWidth: 0,
185
+ padding: 12,
186
+ gap: 10,
187
+ borderRadius: 12,
188
+ borderWidth: 1,
189
+ borderStyle: seen ? "solid" : "dashed",
190
+ borderColor: colors.border,
191
+ backgroundColor: seen ? colors.surface1 : "transparent",
192
+ },
193
+ ring: {
194
+ ...StyleSheet.absoluteFillObject,
195
+ borderRadius: 12,
196
+ borderWidth: 1,
197
+ borderColor: colors.foregroundMuted,
198
+ opacity: 0.35,
199
+ },
200
+ header: { flexDirection: "row", alignItems: "center", gap: 6 },
201
+ dir: { flexShrink: 1, color: colors.foregroundMuted, fontFamily: MONO, fontSize: 11 },
202
+ area: { flex: 1, color: colors.foregroundMuted, fontSize: 11, opacity: 0.8 },
203
+ count: { color: hot ? colors.foreground : colors.foregroundMuted, fontSize: 11 },
204
+ tiles: { flexDirection: "row", flexWrap: "wrap", gap: 6 },
205
+ }) as const,
206
+ [colors, props.compact, seen, hot],
207
+ );
208
+ const off = zone.touched - zone.hits;
209
+ let count: string | null = null;
210
+ if (zone.predicted > 0) count = `${zone.hits}/${zone.predicted}${off > 0 ? ` +${off} off` : ""}`;
211
+ else if (off > 0) count = `${off} off the map`;
212
+ const area = zoneAreaLabel(zone);
213
+ return (
214
+ <View style={styles.zone}>
215
+ {hot ? <View pointerEvents="none" style={styles.ring} /> : null}
216
+ <View style={styles.header}>
217
+ <Icon
218
+ name={ZONE_ICONS[zone.explored]}
219
+ size={12}
220
+ color={seen ? colors.foreground : colors.foregroundMuted}
221
+ />
222
+ <Text numberOfLines={1} style={styles.dir}>
223
+ {zone.dir || "./"}
224
+ </Text>
225
+ <Text numberOfLines={1} style={styles.area}>
226
+ {area ?? ""}
227
+ </Text>
228
+ {count ? <Text style={styles.count}>{count}</Text> : null}
229
+ </View>
230
+ <View style={styles.tiles}>
231
+ {zone.tiles.map((tile) => (
232
+ <Tile
233
+ key={tile.path}
234
+ theme={theme}
235
+ tile={tile}
236
+ color={tile.owner ? (props.colors.get(tile.owner) ?? null) : null}
237
+ dim={focus !== null && tile.owner !== focus}
238
+ markers={tile.ghost ? NO_MARKERS : (props.markers.get(tile.path) ?? NO_MARKERS)}
239
+ reduceMotion={props.reduceMotion}
240
+ />
241
+ ))}
242
+ </View>
243
+ </View>
244
+ );
245
+ }
246
+
247
+ export interface RepoMapProps {
248
+ theme: Theme;
249
+ compact: boolean;
250
+ model: MapModel;
251
+ colors: OwnerColors;
252
+ focus: string | null;
253
+ /** Agents on each file now, and subagents asked about it. */
254
+ markers: ReadonlyMap<string, AgentMarker[]>;
255
+ reduceMotion: boolean;
256
+ }
257
+
258
+ export function RepoMap(props: RepoMapProps) {
259
+ const { colors } = props.theme;
260
+ const [width, setWidth] = useState(0);
261
+ const measure = useCallback((event: LayoutChangeEvent) => {
262
+ setWidth(event.nativeEvent.layout.width);
263
+ }, []);
264
+ const columns = props.compact
265
+ ? 1
266
+ : Math.max(1, Math.min(MAX_COLUMNS, Math.floor((width + ZONE_GAP) / (MIN_ZONE_WIDTH + ZONE_GAP))));
267
+ // Zones keep their column while the agent works, so growing zones do not reshuffle the map.
268
+ const placed = useRef({ columns: 0, byDir: new Map<string, number>() });
269
+ const groups = useMemo(() => {
270
+ const count = width > 0 ? columns : 1;
271
+ if (placed.current.columns !== count) placed.current = { columns: count, byDir: new Map() };
272
+ return masonry(props.model.zones, count, width || MIN_ZONE_WIDTH, placed.current.byDir);
273
+ }, [props.model.zones, columns, width]);
274
+ const styles = useMemo(
275
+ () =>
276
+ ({
277
+ zones: { flexDirection: "row", gap: ZONE_GAP, alignItems: "flex-start" },
278
+ column: { flex: 1, minWidth: 0, gap: ZONE_GAP },
279
+ empty: { color: colors.foregroundMuted, fontSize: 13 },
280
+ hidden: { opacity: 0 },
281
+ }) as const,
282
+ [colors],
283
+ );
284
+ // The measured View stays mounted, so the first files do not draw in one column and then reflow.
285
+ return (
286
+ <View onLayout={measure}>
287
+ {props.model.zones.length === 0 ? (
288
+ <Text style={styles.empty}>No files on the map yet. Files the agent opens show here.</Text>
289
+ ) : (
290
+ <View style={[styles.zones, width > 0 ? null : styles.hidden]}>
291
+ {groups.map((group, index) => (
292
+ <View key={index} style={styles.column}>
293
+ {group.map((zone) => (
294
+ <Zone key={zone.dir} {...props} zone={zone} />
295
+ ))}
296
+ </View>
297
+ ))}
298
+ </View>
299
+ )}
300
+ </View>
301
+ );
302
+ }
303
+
304
+ /** Explains the tile styles; shown in the map card header. */
305
+ export function MapLegend({ theme }: { theme: Theme }) {
306
+ const { colors } = theme;
307
+ const styles = useMemo(() => {
308
+ const swatch = {
309
+ width: 18,
310
+ height: 12,
311
+ borderRadius: 4,
312
+ borderWidth: 1,
313
+ alignItems: "center",
314
+ justifyContent: "center",
315
+ } as const;
316
+ const dot = { width: 5, height: 5, borderRadius: 3, borderWidth: 1 } as const;
317
+ return {
318
+ row: { flexDirection: "row", alignItems: "center", gap: 12, flexWrap: "wrap" },
319
+ item: { flexDirection: "row", alignItems: "center", gap: 5 },
320
+ text: { color: colors.foregroundMuted, fontSize: 11 },
321
+ mapped: { ...swatch, borderColor: colors.border, backgroundColor: colors.surface1 },
322
+ touched: { ...swatch, borderColor: colors.accent, backgroundColor: colors.surface2 },
323
+ off: { ...swatch, borderStyle: "dashed", borderColor: colors.statusWarning },
324
+ hollow: { ...dot, borderColor: colors.accent },
325
+ solid: { ...dot, borderColor: colors.accent, backgroundColor: colors.accent },
326
+ warn: { ...dot, borderColor: colors.statusWarning, backgroundColor: colors.statusWarning },
327
+ } as const;
328
+ }, [colors]);
329
+ return (
330
+ <View style={styles.row}>
331
+ <View style={styles.item}>
332
+ <View style={styles.mapped}>
333
+ <View style={styles.hollow} />
334
+ </View>
335
+ <Text style={styles.text}>Predicted</Text>
336
+ </View>
337
+ <View style={styles.item}>
338
+ <View style={styles.touched}>
339
+ <View style={styles.solid} />
340
+ </View>
341
+ <Text style={styles.text}>Touched</Text>
342
+ </View>
343
+ <View style={styles.item}>
344
+ <View style={styles.off}>
345
+ <View style={styles.warn} />
346
+ </View>
347
+ <Text style={styles.text}>Off the map</Text>
348
+ </View>
349
+ </View>
350
+ );
351
+ }