@yannelli/paseo-linear-plugin 1.1.0-beta.0 → 1.1.0-beta.2

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 (44) hide show
  1. package/OVERVIEW.md +7 -3
  2. package/README.md +38 -11
  3. package/client/access.tsx +2 -1
  4. package/client/compat.ts +23 -5
  5. package/client/connect.tsx +3 -2
  6. package/client/graph-camera.tsx +219 -0
  7. package/client/graph-labels.ts +14 -4
  8. package/client/launch-guidance.tsx +86 -0
  9. package/client/launch-plan.ts +50 -2
  10. package/client/launch.tsx +10 -1
  11. package/client/live-agents.tsx +138 -23
  12. package/client/live-cursor.tsx +3 -1
  13. package/client/live-graph.tsx +159 -75
  14. package/client/live-header.tsx +29 -0
  15. package/client/live-panel.tsx +29 -8
  16. package/client/live-timeline.ts +38 -3
  17. package/client/pill.tsx +4 -3
  18. package/client/plugin-icon.tsx +43 -0
  19. package/client/settings-fields.tsx +11 -3
  20. package/client/settings-icon.tsx +228 -0
  21. package/client/settings-live.tsx +25 -1
  22. package/client/timeline-card.tsx +2 -1
  23. package/client/tool-buttons.tsx +68 -0
  24. package/client/ui.tsx +16 -7
  25. package/client/web.ts +91 -0
  26. package/index.client.tsx +9 -0
  27. package/index.server.ts +5 -1
  28. package/package.json +1 -1
  29. package/server/agent-hooks.ts +37 -0
  30. package/server/agent-runs.ts +15 -2
  31. package/server/agent-tools.ts +240 -0
  32. package/server/init.ts +1 -0
  33. package/server/live.ts +27 -1
  34. package/server/mcp-http.ts +152 -0
  35. package/server/stores.ts +8 -1
  36. package/server/subagent-logs.ts +146 -0
  37. package/shared/activity.ts +5 -1
  38. package/shared/agent-hooks.ts +68 -0
  39. package/shared/agent-tools.ts +130 -0
  40. package/shared/custom-icon.ts +148 -0
  41. package/shared/graph-camera.ts +93 -0
  42. package/shared/live.ts +43 -0
  43. package/shared/prompts.ts +49 -1
  44. package/shared/settings.ts +34 -0
package/OVERVIEW.md CHANGED
@@ -23,14 +23,18 @@ To use a different Linear key for one Paseo project, add it under **Project keys
23
23
 
24
24
  Select **Start agent** or **Start review** on an issue. The setup page shows the prompt and the composer controls for the prompt template, model, thinking level, and mode. The model picker lists providers first, and its search covers all models. Choose the project and where the agent runs: a new worktree, a pull request checkout, the issue branch, an existing agent workspace, or the current workspace or project folder. You can include comments in the prompt. For Implement, you can also move the issue to In Progress and assign it to you.
25
25
 
26
- The prompt follows the template until you edit it. **Reset prompt** restores it. Edit the templates in **Prompts**, and add project instructions in **Projects**.
26
+ **Agent guidance** switches add instructions to the prompt: keep Linear updated, have subagents state their issue key, and hand off work to Paseo agents. Each project keeps its choices. With the first and last switches, the agent gets the plugin's Linear tools. It edits the issue description, checks off task list items, and starts one Paseo agent for each sub-issue. It posts no comments. For Claude agents, the switches also add hooks that give subagents the issue keys. The prompt follows the template until you edit it. **Reset prompt** restores it. Edit the templates in **Prompts**, and add project instructions in **Projects**.
27
27
 
28
28
  ## Linear Live
29
29
 
30
- Open **Linear Live** from the Linear pill in an agent's composer. It shows the issue's sub-issues with their statuses and the agent's todos, a map of the files the agent reads and edits, or a graph of those files joined by their imports and links, with the agent gliding to what it works on, the agents it starts, and its latest commands. It works with Claude, Codex, and other providers. The map comes from paths in the ticket text, or, if you choose it in **Settings → Plugins → Linear → Live**, from an explore agent that reads the repository before the agent starts. The implement agent waits for the map. The explore agent uses provider usage. Explore and setup agents are told to only read. The plugin starts Claude in Always Ask and answers the permission requests these agents raise: it allows reads and search commands in the project folder and denies the rest. Codex has no read-only mode, so it runs in its default mode, and the plugin answers only the requests Codex raises.
30
+ Open **Linear Live** from the Linear pill in an agent's composer. It shows the issue's sub-issues with their statuses and the agent's todos, a map of the files the agent reads and edits, or a graph of those files joined by their imports and links, with the agent gliding to what it works on, the agents it starts, and its latest commands. It works with Claude, Codex, and other providers. The map comes from paths in the ticket text, or, if you choose it in **Settings → Plugins → Linear → Live**, from an explore agent that reads the repository before the agent starts. The implement agent waits for the map. The explore agent uses provider usage. Buttons on the map reload it, build it again with the explore agent, or clear it. On the graph, a button follows the running agents and zooms to their files. You can also zoom with the buttons, the wheel, or a pinch, and lock the view so the page scrolls. Agents in worktrees of the same project show on the map and the graph. Explore and setup agents are told to only read. The plugin starts Claude in Always Ask and answers the permission requests these agents raise: it allows reads and search commands in the project folder and denies the rest. Codex has no read-only mode, so it runs in its default mode, and the plugin answers only the requests Codex raises.
31
31
 
32
32
  On agents not started from an issue, the Linear pill searches Linear and sends an issue to the agent.
33
33
 
34
+ ## Plugin icon
35
+
36
+ In **Settings → Plugins → Linear → Icon**, choose an SVG file or paste SVG markup. Composer pills, the Linear sidebar row, the timeline card, and the Linear screens use it. Paseo menus and panel tabs keep the built-in icon. Keep the original colors, or paint the icon with the theme color, the theme accent, Linear indigo, or a custom color. **Solid** fills outline shapes. The plugin refuses SVGs with scripts, event handlers, or links to other files. The iOS and Android apps show the built-in icon in the chosen color.
37
+
34
38
  ## Update Linear from todos
35
39
 
36
40
  Turn on **Update Linear from agent todos** in the Live settings. After each turn, a todo that starts with a sub-issue key, such as `ENG-124: add the queue`, moves that sub-issue to In Progress, and then to Done when all its todos are done. The parent moves to In Review when every sub-issue is done, never to Done. Statuses only move forward.
@@ -49,4 +53,4 @@ Choose **Attach Linear issue** in the composer attachment menu. The agent gets t
49
53
 
50
54
  ## Data and permissions
51
55
 
52
- Only the daemon sends requests to `https://api.linear.app/graphql`. The daemon never sends a key back to the app. Paseo sends the prompt, with the issue snapshot, to the agent provider. Explore and setup agents read project files and send them to your agent provider. The daemon saves explore maps, and agents that wait for a map, beside the key file, and Linear Live lists and reads files only inside the agent's folder. With todo sync on, the daemon changes issue statuses in Linear. The daemon keeps recent Linear responses in memory for up to 24 hours, and the list shows them with **Showing saved results. Updating…** while it gets new data.
56
+ Only the daemon sends requests to `https://api.linear.app/graphql`. The daemon never sends a key back to the app. Paseo sends the prompt, with the issue snapshot, to the agent provider. Explore and setup agents read project files and send them to your agent provider. The daemon saves explore maps, and agents that wait for a map, beside the key file, and Linear Live lists and reads files only inside the agent's folder. For Claude agents, the daemon also reads the subagent transcripts Claude saves for the session, to show what each subagent does. With agent guidance on, it writes Claude Code hooks with the issue keys and titles beside the key file, and it serves the Linear tools on 127.0.0.1 with a token for each agent. Each token reaches only its issue and that issue's sub-issues. With todo sync on, the daemon changes issue statuses in Linear. The daemon keeps recent Linear responses in memory for up to 24 hours, and the list shows them with **Showing saved results. Updating…** while it gets new data.
package/README.md CHANGED
@@ -120,7 +120,15 @@ Select **Start agent** or **Start review** in the issue view. The agent setup pa
120
120
 
121
121
  The worktree and checkout options show only for a Git project. When agents run in worktrees (the default), a review uses the pull request, then the implementation workspace, then the issue branch.
122
122
  5. Set the options: **Include comments in the prompt**, **Move the issue to In Progress**, and **Assign the issue to me**. The last two show only for Implement, and only when they would change the issue.
123
- 6. Select **Start agent** or **Start review**.
123
+ 6. Set the **Agent guidance** switches. Each one adds instructions to the prompt. The project keeps your choices for its next launch.
124
+ - **Keep Linear updated:** the agent edits the issue description as it works. It checks off each task list item it finishes and updates the plan when it changes. It posts no comments. Todos start with their issue key, so todo sync can move each sub-issue.
125
+ - **Subagents state their issue key:** each subagent's description and prompt start with the key of the issue it works on. The subagent states the key again when its work moves to another issue.
126
+ - **Hand off work to Paseo agents:** the agent starts one Paseo agent for each sub-issue instead of its built-in subagents. Each one runs in the same workspace on the same provider and model, with thinking that fits its task, and checks off its own items. Linear Live shows their work.
127
+
128
+ **Keep Linear updated** and **Hand off work to Paseo agents** give the agent the plugin's `linear` MCP server. It has four tools: `read_issue` and `edit_issue` for the issue and its sub-issues, and `start_agent` and `wait_agent` for agents on the sub-issues. Agents that `start_agent` starts get only `read_issue` and `edit_issue`. Paseo allows these tools without a permission prompt.
129
+
130
+ For Claude agents, these switches also add Claude Code hooks. With **Subagents state their issue key** on, each subagent gets the issue keys when it starts. With any switch on, the agent gets the issue again after Claude compacts the conversation. The hooks only print that text.
131
+ 7. Select **Start agent** or **Start review**.
124
132
 
125
133
  The plugin starts the agent and then updates Linear. If the Linear update fails, the agent keeps running and a message shows the error. The agent's timeline gets a card that links to the issue. The issue view lists the agents started from it under **Agents on this issue**.
126
134
 
@@ -149,14 +157,17 @@ Linear Live is an agent panel. Open it from the Linear pill, or with **Linear: O
149
157
 
150
158
  - **Sub-issues:** each sub-issue shows its Linear status and the agent's todo progress. The todos of the current sub-issue show under it. Select a sub-issue to show only its files on the map. Hide the issues column with the button on the **Issue** card to give the map or the graph the full width. The column becomes a strip with the status of each issue. Select an issue there to show only its files.
151
159
  - **Map:** files are grouped by folder. The map marks the files the agent read, edited, or created, and the file it is on now. Files the agent touched but the map did not predict have a dashed border. A folder the agent listed or searched gets a solid border, and its header shows how many of its files the agent touched. Reads through the shell count too: `cat`, `sed -n`, `head`, `grep`, `rg`, `ls`, `find`, `git show`, and redirects to files.
152
- - **Graph:** the files and the links between them. A line joins two files when one imports the other, a Markdown or style file links to the other, `package.json` names it, or the text of one names the path of the other. This works for TypeScript and JavaScript (with `tsconfig` paths and workspace packages), Python, Go, Rust, Ruby, CSS and Sass, Markdown, HTML, and config files. Files of one folder sit together under the folder name and take the color of their sub-issue. The issue is in the center, with its sub-issues joined to the files the map gives them. The agent hovers over what it works on: the file it reads or edits, or the sub-issue whose todo it just started. When the work moves, it glides along the links to the next node, and the lines at that node take its color. New files join without moving the rest. Agents that this agent started get their own cursor when they work in the same folder. Running subagents circle the agent that started them. Select a sub-issue to show only its files. Select any node to see what it is and how many files it links to. Switch between **Map** and **Graph** on the card, or set the default in **Show files as** in the Live settings.
153
- - **Agents:** the agents that work under this agent. Paseo agents that this agent started show their status, their files, and their own markers on the map when they work in the same folder. Select one to open it. Subagents that run inside the provider, such as Claude's Agent tool, show their type, description, and status. The provider does not send their file reads, so the map shows only the files named in a subagent's prompt, and only if the panel was open when it started.
160
+ - **Graph:** the files and the links between them. A line joins two files when one imports the other, a Markdown or style file links to the other, `package.json` names it, or the text of one names the path of the other. This works for TypeScript and JavaScript (with `tsconfig` paths and workspace packages), Python, Go, Rust, Ruby, CSS and Sass, Markdown, HTML, and config files. Files of one folder sit together under the folder name and take the color of their sub-issue. The issue is in the center, with its sub-issues joined to the files the map gives them. The agent hovers over what it works on: the file it reads or edits, or the sub-issue whose todo it just started. When the work moves, it glides along the links to the next node, and the lines at that node take its color. New files join without moving the rest. Agents that this agent started get their own cursor when they work in the same folder or in a worktree of the same project. An agent with no file activity yet hovers over the sub-issue whose key starts its title. Running subagents circle the agent that started them. Select a sub-issue to show only its files. Select any node to see what it is and how many files it links to. Switch between **Map** and **Graph** on the card, or set the default in **Show files as** in the Live settings.
161
+ - **Graph view:** the icon buttons at the top right of the graph move the view. **Follow the running agents** zooms to the nodes the running agents work on and the files next to them, and moves with the agents. **Show the whole graph** zooms out. **Zoom out** and **Zoom in** change the zoom in steps. Scroll to zoom at the pointer, drag to move when zoomed in, or pinch on a touch screen. Each of these stops following. The lock button lets the wheel and drags scroll the page instead. Nodes, lines, and labels keep their size when you zoom, and more file names show when there is room. The graph keeps your follow and lock choices.
162
+ - **Agents:** the agents that work under this agent. Paseo agents that this agent started show their status, their files, and their own markers on the map when they work in the same folder or in a worktree of the same project. Select one to open it. Subagents that run inside the provider, such as Claude's Agent tool, show their type, description, and status. For Claude, the daemon reads each subagent's transcript, also for subagents that run in the background, so a subagent shows what it does now, gets its own cursor, and adds its files to the map. Other providers do not send subagent file reads, so the map shows only the files named in the subagent's prompt.
154
163
  - **Activity:** the latest reads, edits, searches, and commands, with line counts and exit codes.
155
164
 
156
165
  The map comes from one of two sources. Choose it in **Settings → Plugins → Linear → Live**:
157
166
 
158
167
  - **Ticket text** (the default) finds file and folder paths in the issue title, description, and sub-issue titles. It costs nothing, but it finds only paths the issue names. Linear sends sub-issue titles but not their descriptions.
159
- - **Explore agent** maps the files first when you start an implement agent. It reads the repository and lists the files for each sub-issue. The implement agent starts when the map is ready, or with the ticket-text map if exploring fails. A saved map is used again, so each issue is explored once. The plugin archives the explore agent when it finishes. Choose its model in the Live settings. A faster, cheaper model is usually enough.
168
+ - **Explore agent** maps the files first when you start an implement agent. It reads the repository and lists the files for each sub-issue. The implement agent starts when the map is ready, or with the ticket-text map if exploring fails. A saved map is used again, so each issue is explored once. The plugin archives the explore agent when it finishes. Choose its model and effort in the Live settings. A faster, cheaper model, such as Haiku, is usually enough. It lists up to 150 files.
169
+
170
+ The buttons on the map card control the saved map. **Read files and links again** reloads the map, the folder lists, and the links. **Build** or **Rebuild the map with the explore agent** starts an explore run now and replaces the saved map when it finishes. **Clear the saved map** deletes it, so the map uses the ticket text until the next explore run. You cannot clear a map while an explore run makes it.
160
171
 
161
172
  The daemon starts the waiting agent, so you can close the app while the map is made. If the plugin reloads or the daemon restarts during the run, the plugin finishes it when the explore agent's turn ends. A waiting agent that has not started after one hour is dropped.
162
173
 
@@ -168,6 +179,15 @@ For implement agents on an issue with sub-issues, the prompt asks the agent to s
168
179
 
169
180
  Each agent's composer has a **Linear** pill. On an agent started from an issue, the pill shows the issue key. It opens the issue status, each sub-issue with its todo progress, and buttons for Linear Live, the issue, and **Sync now**. On other agents, the pill searches Linear and sends the issue text to the agent as a message.
170
181
 
182
+ ### Plugin icon
183
+
184
+ In **Settings → Plugins → Linear → Icon**, choose an SVG file or paste SVG markup. Composer pills, the Linear sidebar row on Paseo 0.11 and newer, the issue card in the timeline, and the Linear screens then use it. Paseo menus, panel tabs, and settings take only built-in icon names, so they keep the Linear board icon. The plugin accepts SVG only. It refuses scripts, event handlers, HTML content, entities, and links to other files or bitmap images. The SVG needs a viewBox, or a width and height in pixels.
185
+
186
+ - **Color:** **Original colors** keeps the SVG as drawn, and `currentColor` takes the color Paseo gives the icon. **Theme color**, **Theme accent**, **Linear indigo**, and **Custom color** paint the whole icon one color and keep its transparency. These choices also apply to the built-in icon.
187
+ - **Solid:** fills shapes that only have an outline, with their stroke color, so a line icon shows as a solid shape.
188
+
189
+ The preview shows each change before you save. The iOS and Android apps cannot draw SVG images from plugins, so they show the built-in icon in the chosen color.
190
+
171
191
  ### Update Linear from agent todos
172
192
 
173
193
  Turn on **Update Linear from agent todos** in the Live settings. After each turn of an implement agent, the daemon reads the agent's todos:
@@ -188,8 +208,11 @@ In **Settings → Plugins → Linear → Projects**, select **Start** under **Se
188
208
  - **Your key stays on the daemon host.** When you save a key, the app sends it to the daemon once. The daemon never sends a key back to the app. The app gets only the last four characters, to show which key is in use. Only the daemon sends requests to `https://api.linear.app/graphql`.
189
209
  - **Data sent to Linear:** queries for issues, teams, users, and comments, and the changes you make. When you start an agent with the options on, the plugin moves the issue to In Progress and assigns it to you.
190
210
  - **Data sent to the agent provider:** the prompt. It holds the template text and an issue snapshot: identifier, title, URL, team, status, priority, assignee, project, labels, parent, description, sub-issues, and links. It holds comments only when **Include comments in the prompt** is on. It also holds the project instructions and steps from the Projects settings. An attached issue sends the identifier, title, URL, status, priority, assignee, project, labels, and description.
191
- - **Linear Live and setup agents:** the explore agent gets the issue snapshot without comments, and both agents read files in the project. Your agent provider gets what they read. The map of an explore run is saved in `live-maps.json` beside the key file, for up to 200 issues. An agent that waits for a map is saved in `pending-launches.json` there until it starts. Linear Live lists files only in the folders it shows, and reads the files on the graph to find their links, inside the agent's own folder. It does not follow links that lead outside that folder.
211
+ - **Linear Live and setup agents:** the explore agent gets the issue snapshot without comments, and both agents read files in the project. Your agent provider gets what they read. The map of an explore run is saved in `live-maps.json` beside the key file, for up to 200 issues. An agent that waits for a map is saved in `pending-launches.json` there until it starts. Linear Live lists files only in the folders it shows, and reads the files on the graph to find their links, inside the agent's own folder. It does not follow links that lead outside that folder. For a Claude agent with subagents, the daemon reads the tool calls in the subagent transcripts that Claude saves for that session. It sends the app only the tool names, file paths, and commands, never file text.
212
+ - **Linear tools for agents:** the daemon runs the `linear` MCP server on `127.0.0.1` only, and only after an agent gets it. Each agent gets its own random token, and its tools reach only its issue and that issue's sub-issues. Edits use the Linear key of the project that loaded the issue. Requests from a web page are refused. `agent-tools.json` beside the key file keeps the port and a hash of each token, so agents keep their tools after a restart. The token itself is in the agent's Paseo config.
213
+ - **Claude hooks:** when an **Agent guidance** switch is on for a Claude agent, the daemon writes a Claude Code plugin to `claude-hooks/` beside the key file, one folder for each issue and set of hooks. It holds the issue key and title and the sub-issue keys and titles. The agent starts with `--plugin-dir` set to that folder. The hooks print that text and run no other command. The daemon does not write hooks on Windows hosts.
192
214
  - **Todo sync:** when it is on, the daemon changes issue statuses in Linear with the key of the project that loaded the issue.
215
+ - **Plugin icon:** the SVG is saved in the plugin settings on the daemon host. The app draws it as an image, which does not run scripts or load other files.
193
216
  - **Cached results:** the daemon keeps up to 300 recent Linear responses in memory for up to 24 hours. It groups them by a hash of the key, so two keys never share results. The app shows saved results at once with **Showing saved results. Updating…** while it gets new data. An edit clears the cached lists and issues for that key. Saving or removing a key clears the whole cache. The cache is not written to disk, and a daemon restart clears it.
194
217
 
195
218
  Linear errors, such as a rejected key or a rate limit, show in the panel or as a message.
@@ -198,22 +221,24 @@ Linear errors, such as a rejected key or a rate limit, show in the panel or as a
198
221
 
199
222
  | File | Runtime | Role |
200
223
  | --- | --- | --- |
201
- | `index.client.tsx` | App | Registers the Linear screen, the workspace panel, the Linear Live panel, the Account, Prompts, Live, and Projects settings, the command center items, the `/linear` command, the timeline card, the composer attachment, and the Linear pill |
224
+ | `index.client.tsx` | App | Registers the Linear screen, the workspace panel, the Linear Live panel, the Account, Prompts, Live, Icon, and Projects settings, the command center items, the `/linear` command, the timeline card, the composer attachment, and the Linear pill |
202
225
  | `index.server.ts` | Daemon | Registers the settings, the RPC handlers, and the todo sync hook |
203
226
  | `client/browser.tsx` | App | Screen and panel: picks the key, then shows the list, the issue, or the agent setup page |
204
227
  | `client/issue-list.tsx`, `client/issue-row.tsx`, `client/issue-tree.ts` | App | Search, filters, sort, rows, status groups, and sub-issue nesting |
205
228
  | `client/issue-detail.tsx`, `client/issue-sections.tsx`, `client/breadcrumbs.tsx` | App | Issue view: properties, breadcrumbs, sub-issues, links, agents, and comments |
206
229
  | `client/issue-description.tsx`, `client/autosave.ts` | App | Description editor, task list checkboxes, and debounced saves |
207
230
  | `client/markdown.tsx` | App | Renders Markdown |
208
- | `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 |
231
+ | `client/launch.tsx`, `client/launch-fields.tsx`, `client/launch-guidance.tsx`, `client/launch-choices.ts`, `client/launch-plan.ts`, `client/agent-options.ts` | App | Agent setup page, Run in options, agent guidance, and agent start |
209
232
  | `client/model-browser.tsx`, `client/provider-icon.tsx` | App | Model picker and provider icons |
233
+ | `client/plugin-icon.tsx`, `client/settings-icon.tsx`, `shared/custom-icon.ts` | App, Both | The plugin icon, its settings screen, and the SVG checks, colors, and fill |
210
234
  | `client/create-issue.tsx`, `client/pickers.tsx` | App | New issue form and option pickers |
211
235
  | `client/connect.tsx`, `client/settings-*.tsx` | App | Key form and settings screens |
212
236
  | `client/queries.ts`, `client/store.ts`, `client/key-scope.tsx` | App | Data hooks with saved results, browser state, and the key in use |
213
237
  | `client/compat.ts` | App | Registers the screen on Paseo 0.10 and on later releases |
214
238
  | `client/timeline-card.tsx`, `client/ui.tsx`, `client/glyphs.tsx` | App | Timeline card, shared controls, and icons |
215
239
  | `client/live-panel.tsx`, `client/live-header.tsx`, `client/live-issues.tsx`, `client/live-feed.tsx`, `client/live-timeline.ts` | App | Linear Live panel, header, sub-issues, activity, and the agent timeline feed |
216
- | `client/live-map.tsx`, `client/live-graph.tsx`, `client/graph-labels.ts`, `client/live-cursor.tsx`, `client/live-agents.tsx` | App | File map, file graph and its labels, agent cursors, and the agents list |
240
+ | `client/live-map.tsx`, `client/live-graph.tsx`, `client/graph-labels.ts`, `client/graph-camera.tsx`, `client/live-cursor.tsx`, `client/live-agents.tsx` | App | File map, file graph with its labels and camera, agent cursors, and the agents list |
241
+ | `client/tool-buttons.tsx`, `client/web.ts` | App | Icon buttons for the map and graph, and the wheel listener and file picker on the web |
217
242
  | `client/pill.tsx` | App | Linear pill in each agent's composer |
218
243
  | `client/project-init.tsx` | App | Project setup proposal in the Projects settings |
219
244
  | `shared/linear.ts` | Both | RPC contracts and issue schemas |
@@ -223,14 +248,16 @@ Linear errors, such as a rejected key or a rate limit, show in the panel or as a
223
248
  | `shared/markdown.ts` | Both | Markdown parser and task list toggle |
224
249
  | `shared/live.ts`, `shared/activity.ts`, `shared/subagents.ts` | Both | Linear Live contracts, the ticket text map, timeline activity, and subagent runs |
225
250
  | `shared/shell-lexer.ts`, `shared/shell-activity.ts` | Both | Shell command parsing for reads, edits, and searches |
226
- | `shared/map-model.ts`, `shared/graph-model.ts`, `shared/graph-geometry.ts`, `shared/links.ts` | Both | Map folders, the file graph with its layout and paths, label placement, and the parsers that find links in files |
251
+ | `shared/map-model.ts`, `shared/graph-model.ts`, `shared/graph-geometry.ts`, `shared/graph-camera.ts`, `shared/links.ts` | Both | Map folders, the file graph with its layout and paths, label placement, camera math, and the parsers that find links in files |
252
+ | `shared/agent-hooks.ts`, `server/agent-hooks.ts` | Both, Daemon | Claude Code hooks for agent guidance, and the plugin folder the daemon writes |
253
+ | `shared/agent-tools.ts`, `server/agent-tools.ts`, `server/mcp-http.ts` | Both, Daemon | The `linear` MCP server: its tools, tokens, and HTTP transport |
227
254
  | `shared/todo-sync.ts`, `shared/project-setup.ts` | Both | Todo keys, status moves, and setup proposals |
228
255
  | `server/handlers.ts` | Daemon | RPC handlers, key lookup, and cache use |
229
256
  | `server/credentials.ts` | Daemon | Key file and key order |
230
257
  | `server/cache.ts` | Daemon | Response cache in memory |
231
258
  | `server/graphql.ts`, `server/queries.ts` | Daemon | Linear GraphQL client, queries, and changes |
232
259
  | `server/linear.ts` | Daemon | Search and snapshot text for the composer attachment |
233
- | `server/live.ts`, `server/links.ts`, `server/agent-runs.ts` | Daemon | File listing, links between files, explore runs, and the saved maps |
260
+ | `server/live.ts`, `server/links.ts`, `server/subagent-logs.ts`, `server/agent-runs.ts` | Daemon | File listing, links between files, subagent transcripts, explore runs, and the saved maps |
234
261
  | `server/sync.ts`, `server/init.ts` | Daemon | Todo sync after each turn, and project setup runs |
235
262
 
236
263
  The client bundle holds no credentials and makes no calls to Linear.
@@ -247,7 +274,7 @@ npm run check
247
274
  paseo plugin add "$PWD"
248
275
  ```
249
276
 
250
- After you edit the source, run `paseo plugin reload paseo-linear-plugin`. `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, the links between files in many languages, the graph layout, and the release scripts. They use a local GraphQL server and do not call Linear.
277
+ After you edit the source, run `paseo plugin reload paseo-linear-plugin`. `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, the prompt guidance and its hooks, the MCP server and its tools, the links between files in many languages, the graph layout and camera, the SVG icon checks and colors, and the release scripts. They use a local GraphQL server and do not call Linear.
251
278
 
252
279
  ## Graphics
253
280
 
package/client/access.tsx CHANGED
@@ -1,6 +1,7 @@
1
1
  import { type PluginAgentPanelProps, useSettings, useWorkspace } from "@getpaseo/plugin/client";
2
2
  import { type ComponentType, type ReactNode, useEffect } from "react";
3
3
  import { linearSettings, type ProjectAccess, projectEnabled } from "../shared/settings";
4
+ import { PluginIcon } from "./plugin-icon";
4
5
  import { EmptyState, type Theme } from "./ui";
5
6
 
6
7
  // Composer pills live outside React, so components that read settings share the project
@@ -44,7 +45,7 @@ export function ProjectGate(props: {
44
45
  return (
45
46
  <EmptyState
46
47
  theme={props.theme}
47
- icon="SquareKanban"
48
+ icon={PluginIcon}
48
49
  title="Linear is off for this project"
49
50
  detail="Turn it on in Linear settings, under Projects."
50
51
  />
package/client/compat.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { PluginClientContext, PluginSurfaceProps } from "@getpaseo/plugin/client";
2
2
  import * as hostUi from "@getpaseo/plugin/client/ui";
3
- import { type ComponentType, createElement } from "react";
3
+ import { type ComponentType, createContext, createElement, useContext } from "react";
4
4
 
5
5
  // Paseo 0.11 replaced surfaces with screens and sidebar header items. 0.10 only has
6
6
  // addSurface/addSidebarItem; later releases drop those aliases. Detect once, register once.
@@ -13,6 +13,7 @@ interface ScreenRef {
13
13
  interface SidebarItemProps {
14
14
  currentScreen: ScreenRef | null;
15
15
  openScreen(input: ScreenRef): void;
16
+ theme?: PluginSurfaceProps["theme"];
16
17
  }
17
18
 
18
19
  interface ScreenClient {
@@ -33,8 +34,13 @@ interface SurfaceClient {
33
34
  addSidebarItem(input: { id: string; title: string; icon: string; surface: string }): Cleanup;
34
35
  }
35
36
 
37
+ interface RowIconProps {
38
+ size: number;
39
+ color: string;
40
+ }
41
+
36
42
  type SidebarRowComponent = ComponentType<{
37
- icon?: string;
43
+ icon?: string | ComponentType<RowIconProps>;
38
44
  label?: string;
39
45
  active?: boolean;
40
46
  onPress(): void;
@@ -48,9 +54,14 @@ export interface ScreenRegistration {
48
54
  id: string;
49
55
  title: string;
50
56
  icon: string;
57
+ /** Drawn in the sidebar row where the host accepts icon components; else `icon`. */
58
+ RowIcon?: ComponentType<RowIconProps & { theme: PluginSurfaceProps["theme"] | null }>;
51
59
  Component: ComponentType<PluginSurfaceProps>;
52
60
  }
53
61
 
62
+ // The row draws its icon with a size and a color only, so the item passes its theme down.
63
+ const RowTheme = createContext<PluginSurfaceProps["theme"] | null>(null);
64
+
54
65
  export function registerScreen(client: PluginClientContext, screen: ScreenRegistration): Cleanup {
55
66
  const SidebarRow = (hostUi as Record<string, unknown>).SidebarRow as
56
67
  | SidebarRowComponent
@@ -61,12 +72,19 @@ export function registerScreen(client: PluginClientContext, screen: ScreenRegist
61
72
  title: screen.title,
62
73
  Component: screen.Component,
63
74
  });
64
- function SidebarItem({ currentScreen, openScreen }: SidebarItemProps) {
65
- return createElement(SidebarRow as SidebarRowComponent, {
66
- icon: screen.icon,
75
+ const { RowIcon } = screen;
76
+ const ThemedIcon = RowIcon
77
+ ? function ThemedIcon(props: RowIconProps) {
78
+ return createElement(RowIcon, { ...props, theme: useContext(RowTheme) });
79
+ }
80
+ : null;
81
+ function SidebarItem({ currentScreen, openScreen, theme }: SidebarItemProps) {
82
+ const row = createElement(SidebarRow as SidebarRowComponent, {
83
+ icon: ThemedIcon ?? screen.icon,
67
84
  active: currentScreen?.screenId === screen.id,
68
85
  onPress: () => openScreen({ screenId: screen.id }),
69
86
  });
87
+ return createElement(RowTheme.Provider, { value: theme ?? null }, row);
70
88
  }
71
89
  const removeItem = client.addSidebarHeaderItem({
72
90
  id: screen.id,
@@ -1,10 +1,11 @@
1
1
  import { useRpc } from "@getpaseo/plugin/client";
2
- import { Icon, TextInput, useToast } from "@getpaseo/plugin/client/react-native";
2
+ import { TextInput, useToast } from "@getpaseo/plugin/client/react-native";
3
3
  import { ExternalLink } from "@getpaseo/plugin/client/ui";
4
4
  import { useMutation, useQueryClient } from "@tanstack/react-query";
5
5
  import { useCallback, useMemo, useState } from "react";
6
6
  import { Text, View } from "react-native";
7
7
  import { authSaveRpc } from "../shared/linear";
8
+ import { PluginIcon } from "./plugin-icon";
8
9
  import { Button, errorMessage, type Theme } from "./ui";
9
10
 
10
11
  export const LINEAR_KEY_SETTINGS_URL = "https://linear.app/settings/account/security";
@@ -121,7 +122,7 @@ export function ConnectCard({ theme, compact }: { theme: Theme; compact: boolean
121
122
  <View style={styles.root}>
122
123
  <View style={styles.card}>
123
124
  <View style={styles.header}>
124
- <Icon name="SquareKanban" size={22} color={colors.foreground} />
125
+ <PluginIcon size={22} color={colors.foreground} theme={theme} />
125
126
  <Text style={styles.title}>Connect Linear</Text>
126
127
  </View>
127
128
  <Text style={styles.body}>
@@ -0,0 +1,219 @@
1
+ import { type RefObject, useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import { Animated, Easing, type GestureResponderHandlers, PanResponder, View } from "react-native";
3
+ import { type Box, type Camera, clampCamera, FIT, frameBox, MAX_ZOOM, MIN_ZOOM, zoomAt } from "../shared/graph-camera";
4
+ import { NATIVE_DRIVER } from "./live-timeline";
5
+ import { ToolButton, ToolGroup } from "./tool-buttons";
6
+ import type { Theme } from "./ui";
7
+ import { listenWheel } from "./web";
8
+
9
+ // Animated values move the graph canvas, so a wheel or a drag never renders the graph again.
10
+ // Nodes, edges, and cursors scale by the inverse zoom, so they keep their size on screen.
11
+
12
+ export interface CameraOptions {
13
+ width: number;
14
+ height: number;
15
+ /** Where auto mode looks; null shows the whole graph. */
16
+ focus: Box | null;
17
+ auto: boolean;
18
+ /** Wheel and drag move the page, not the camera. */
19
+ locked: boolean;
20
+ reduce: boolean;
21
+ /** The user moved the camera, so auto mode stops. */
22
+ onManual(): void;
23
+ }
24
+
25
+ type ZoomLimit = "min" | "max" | null;
26
+
27
+ export interface CameraControl {
28
+ ref: RefObject<View | null>;
29
+ handlers: GestureResponderHandlers;
30
+ transform: [{ translateX: Animated.Value }, { translateY: Animated.Value }, { scale: Animated.Value }];
31
+ /** One over the zoom: the scale that keeps a canvas item its own size on screen. */
32
+ inverse: Animated.AnimatedInterpolation<number>;
33
+ limit: ZoomLimit;
34
+ /** The camera once it stops moving; labels are placed for it. */
35
+ settled: Camera;
36
+ zoomBy(factor: number): void;
37
+ fit(): void;
38
+ }
39
+
40
+ const ZOOM_STEP = 1.5;
41
+ const DRAG_START = 6;
42
+ const SETTLE_MS = 160;
43
+
44
+ type Touch = { pageX: number; pageY: number };
45
+ const spreadOf = (touches: readonly Touch[]) => {
46
+ const [a, b] = touches;
47
+ return a && b ? Math.hypot(a.pageX - b.pageX, a.pageY - b.pageY) : null;
48
+ };
49
+
50
+ export function useGraphCamera(options: CameraOptions): CameraControl {
51
+ const [values] = useState(() => ({ x: new Animated.Value(0), y: new Animated.Value(0), zoom: new Animated.Value(1) }));
52
+ const [inverse] = useState(() => Animated.divide<number>(new Animated.Value(1), values.zoom));
53
+ const [limit, setLimit] = useState<ZoomLimit>("min");
54
+ const [settled, setSettled] = useState<Camera>(FIT);
55
+ const pause = useRef<ReturnType<typeof setTimeout> | null>(null);
56
+ const camera = useRef<Camera>(FIT);
57
+ const ref = useRef<View>(null);
58
+ const latest = useRef(options);
59
+ latest.current = options;
60
+
61
+ const apply = useCallback(
62
+ (next: Camera, animate: boolean) => {
63
+ const { width, height, reduce } = latest.current;
64
+ const target = clampCamera(next, width, height);
65
+ camera.current = target;
66
+ setLimit(target.zoom <= MIN_ZOOM ? "min" : target.zoom >= MAX_ZOOM ? "max" : null);
67
+ for (const value of [values.x, values.y, values.zoom]) value.stopAnimation();
68
+ if (pause.current) clearTimeout(pause.current);
69
+ if (!animate || reduce) {
70
+ values.x.setValue(target.x);
71
+ values.y.setValue(target.y);
72
+ values.zoom.setValue(target.zoom);
73
+ pause.current = setTimeout(() => setSettled(target), SETTLE_MS);
74
+ return;
75
+ }
76
+ const glide = (value: Animated.Value, toValue: number) =>
77
+ Animated.timing(value, { toValue, duration: 450, easing: Easing.inOut(Easing.cubic), useNativeDriver: NATIVE_DRIVER });
78
+ Animated.parallel([glide(values.x, target.x), glide(values.y, target.y), glide(values.zoom, target.zoom)]).start(
79
+ ({ finished }) => {
80
+ if (finished) setSettled(target);
81
+ },
82
+ );
83
+ },
84
+ [values],
85
+ );
86
+ const move = useCallback(
87
+ (next: Camera, animate: boolean) => {
88
+ if (latest.current.auto) latest.current.onManual();
89
+ apply(next, animate);
90
+ },
91
+ [apply],
92
+ );
93
+
94
+ useEffect(
95
+ () => () => {
96
+ if (pause.current) clearTimeout(pause.current);
97
+ },
98
+ [],
99
+ );
100
+
101
+ const { width, height, auto, locked, focus } = options;
102
+ const focusKey = focus ? [focus.left, focus.top, focus.right, focus.bottom].map(Math.round).join(",") : "";
103
+ useEffect(() => {
104
+ if (width === 0 || !auto) return;
105
+ const box = latest.current.focus;
106
+ apply(box ? frameBox(box, width, height) : FIT, true);
107
+ }, [auto, focusKey, width, height, apply]);
108
+ useEffect(() => {
109
+ if (width > 0 && !latest.current.auto) apply(camera.current, false);
110
+ }, [width, height, apply]);
111
+
112
+ useEffect(() => {
113
+ if (locked) return;
114
+ return listenWheel(ref.current, (point, factor) => {
115
+ const size = latest.current;
116
+ move(zoomAt(camera.current, point, factor, size.width, size.height), false);
117
+ });
118
+ }, [locked, move]);
119
+
120
+ // A drag pans once zoomed in; at full view it scrolls the page. Two fingers pinch to zoom.
121
+ const handlers = useMemo(() => {
122
+ let start = FIT;
123
+ let origin = { x: 0, y: 0 };
124
+ let fingers = 0;
125
+ let spread: number | null = null;
126
+ let lead = { x: 0, y: 0 };
127
+ const follow = (touches: readonly Touch[], gesture: { dx: number; dy: number }) => {
128
+ if (Math.min(2, touches.length) !== fingers) {
129
+ fingers = Math.min(2, touches.length);
130
+ start = camera.current;
131
+ origin = { x: gesture.dx, y: gesture.dy };
132
+ spread = spreadOf(touches);
133
+ }
134
+ const { width: w, height: h } = latest.current;
135
+ const now = spreadOf(touches);
136
+ const base = now && spread ? zoomAt(start, { x: w / 2, y: h / 2 }, now / spread, w, h) : start;
137
+ move({ ...base, x: base.x + gesture.dx - origin.x, y: base.y + gesture.dy - origin.y }, false);
138
+ };
139
+ return PanResponder.create({
140
+ onMoveShouldSetPanResponder: (_event, gesture) => {
141
+ if (latest.current.locked) return false;
142
+ lead = { x: gesture.dx, y: gesture.dy };
143
+ if (gesture.numberActiveTouches >= 2) return true;
144
+ return camera.current.zoom > MIN_ZOOM && Math.hypot(gesture.dx, gesture.dy) > DRAG_START;
145
+ },
146
+ // The grant restarts dx at zero, so the move before it is added back.
147
+ onPanResponderGrant: (event) => {
148
+ const touches = event.nativeEvent.touches;
149
+ fingers = Math.min(2, touches.length);
150
+ start = camera.current;
151
+ origin = { x: -lead.x, y: -lead.y };
152
+ spread = spreadOf(touches);
153
+ },
154
+ onPanResponderMove: (event, gesture) => follow(event.nativeEvent.touches, gesture),
155
+ onPanResponderTerminationRequest: () => false,
156
+ }).panHandlers;
157
+ }, [move]);
158
+
159
+ const transform = useMemo(
160
+ (): CameraControl["transform"] => [{ translateX: values.x }, { translateY: values.y }, { scale: values.zoom }],
161
+ [values],
162
+ );
163
+ const zoomBy = useCallback(
164
+ (factor: number) => {
165
+ const size = latest.current;
166
+ move(zoomAt(camera.current, { x: size.width / 2, y: size.height / 2 }, factor, size.width, size.height), true);
167
+ },
168
+ [move],
169
+ );
170
+ const fit = useCallback(() => move(FIT, true), [move]);
171
+ return { ref, handlers, transform, inverse, limit, settled, zoomBy, fit };
172
+ }
173
+
174
+ export function CameraButtons(props: {
175
+ theme: Theme;
176
+ camera: CameraControl;
177
+ auto: boolean;
178
+ locked: boolean;
179
+ onAuto(): void;
180
+ onLock(): void;
181
+ }) {
182
+ const { theme, camera, auto, locked } = props;
183
+ const style = useMemo(() => ({ position: "absolute", top: 8, right: 8 }) as const, []);
184
+ return (
185
+ <View style={style}>
186
+ <ToolGroup theme={theme}>
187
+ <ToolButton
188
+ theme={theme}
189
+ icon="LocateFixed"
190
+ label={auto ? "Stop following the running agents" : "Follow the running agents"}
191
+ active={auto}
192
+ onPress={props.onAuto}
193
+ />
194
+ <ToolButton theme={theme} icon="Maximize" label="Show the whole graph" onPress={camera.fit} />
195
+ <ToolButton
196
+ theme={theme}
197
+ icon="Minus"
198
+ label="Zoom out"
199
+ disabled={camera.limit === "min"}
200
+ onPress={() => camera.zoomBy(1 / ZOOM_STEP)}
201
+ />
202
+ <ToolButton
203
+ theme={theme}
204
+ icon="Plus"
205
+ label="Zoom in"
206
+ disabled={camera.limit === "max"}
207
+ onPress={() => camera.zoomBy(ZOOM_STEP)}
208
+ />
209
+ <ToolButton
210
+ theme={theme}
211
+ icon={locked ? "Lock" : "LockOpen"}
212
+ label={locked ? "Let the wheel and drags move the graph" : "Let the wheel and drags scroll the page"}
213
+ active={locked}
214
+ onPress={props.onLock}
215
+ />
216
+ </ToolGroup>
217
+ </View>
218
+ );
219
+ }
@@ -50,6 +50,8 @@ export interface LabelInput {
50
50
  cursors: readonly { at: Point; caption: number; flip: boolean }[];
51
51
  width: number;
52
52
  height: number;
53
+ /** Zoomed in, so files no agent touched get labels too when they fit. */
54
+ every?: boolean;
53
55
  }
54
56
 
55
57
  function linesOf(node: GraphNode, input: LabelInput): readonly [string, string | null] {
@@ -68,10 +70,18 @@ const textWidth = (text: string, perChar: number) => Math.min(MAX_LABEL, text.le
68
70
  export function graphLabels(input: LabelInput): { nodes: Map<string, Label>; folders: FolderLabel[] } {
69
71
  const { graph, positions, selected, working } = input;
70
72
  const order = (node: GraphNode) =>
71
- node.id === selected ? 0 : node.kind === "issue" ? 1 : node.kind === "subissue" ? 2 : working.has(node.id) ? 4 : 5;
72
- const wanted = graph.nodes
73
- .filter((node) => node.kind !== "file" || node.id === selected || working.has(node.id) || (node.tile?.touch ?? "none") !== "none")
74
- .sort((a, b) => order(a) - order(b));
73
+ node.id === selected
74
+ ? 0
75
+ : node.kind === "issue"
76
+ ? 1
77
+ : node.kind === "subissue"
78
+ ? 2
79
+ : working.has(node.id)
80
+ ? 4
81
+ : (node.tile?.touch ?? "none") !== "none"
82
+ ? 5
83
+ : 6;
84
+ const wanted = graph.nodes.filter((node) => input.every || order(node) < 6).sort((a, b) => order(a) - order(b));
75
85
  const lines = new Map<string, readonly [string, string | null]>();
76
86
  const requests: LabelRequest[] = [];
77
87
  const push = (node: GraphNode) => {
@@ -0,0 +1,86 @@
1
+ import { useSettings } from "@getpaseo/plugin/client";
2
+ import { useCallback, useMemo, useState } from "react";
3
+ import { Switch, Text, View } from "react-native";
4
+ import {
5
+ GUIDANCE_KEYS,
6
+ type Guidance,
7
+ GuidanceSchema,
8
+ linearSettings,
9
+ type ProjectConfig,
10
+ withProjectGuidance,
11
+ } from "../shared/settings";
12
+ import type { PaseoProjectOption } from "./queries";
13
+ import type { Theme } from "./ui";
14
+
15
+ // Launch options that add instructions to the prompt. Each project keeps its own choices, so
16
+ // the next launch in the project starts with them.
17
+
18
+ const DEFAULT_GUIDANCE = GuidanceSchema.parse({});
19
+
20
+ const COPY: Record<(typeof GUIDANCE_KEYS)[number], { label: string; hint: string }> = {
21
+ updateLinear: {
22
+ label: "Keep Linear updated",
23
+ hint: "The agent edits the issue description and checks off task list items as it works, with the plugin's Linear tools. It posts no comments. Todos start with issue keys, so status sync can move each sub-issue.",
24
+ },
25
+ subagentKeys: {
26
+ label: "Subagents state their issue key",
27
+ hint: "Subagents open with the key of the issue they work on and state the new key when it changes. Claude agents also get a hook that tells each subagent.",
28
+ },
29
+ paseoSubagents: {
30
+ label: "Hand off work to Paseo agents",
31
+ hint: "The agent starts one Paseo agent for each sub-issue with the plugin's tools, on its own provider and model. Each one checks off its own items, and Linear Live shows its work.",
32
+ },
33
+ };
34
+
35
+ /** The project's guidance, changed locally at once and saved to the project in the background. */
36
+ export function useProjectGuidance(project: PaseoProjectOption | null, config: ProjectConfig | null) {
37
+ const settings = useSettings(linearSettings);
38
+ const [edits, setEdits] = useState<Record<string, Guidance>>({});
39
+ const guidance = (project ? edits[project.projectId] : undefined) ?? config?.guidance ?? DEFAULT_GUIDANCE;
40
+ const change = useCallback(
41
+ (key: (typeof GUIDANCE_KEYS)[number], value: boolean) => {
42
+ if (!project) return;
43
+ const next = { ...guidance, [key]: value };
44
+ setEdits((current) => ({ ...current, [project.projectId]: next }));
45
+ if (settings.status === "ready") void settings.save(withProjectGuidance(settings.values, project, next), settings.revision);
46
+ },
47
+ [project, guidance, settings],
48
+ );
49
+ return { guidance, change };
50
+ }
51
+
52
+ export function GuidanceToggles(props: {
53
+ theme: Theme;
54
+ guidance: Guidance;
55
+ onChange(key: (typeof GUIDANCE_KEYS)[number], value: boolean): void;
56
+ }) {
57
+ const { colors } = props.theme;
58
+ const styles = useMemo(
59
+ () => ({
60
+ row: { flexDirection: "row" as const, alignItems: "center" as const, gap: 10, paddingVertical: 4 },
61
+ body: { flex: 1, gap: 2 },
62
+ label: { color: colors.foreground, fontSize: 14 },
63
+ hint: { color: colors.foregroundMuted, fontSize: 12 },
64
+ track: { true: colors.accent, false: colors.surface2 },
65
+ }),
66
+ [colors],
67
+ );
68
+ return (
69
+ <View>
70
+ {GUIDANCE_KEYS.map((key) => (
71
+ <View key={key} style={styles.row}>
72
+ <View style={styles.body}>
73
+ <Text style={styles.label}>{COPY[key].label}</Text>
74
+ <Text style={styles.hint}>{COPY[key].hint}</Text>
75
+ </View>
76
+ <Switch
77
+ accessibilityLabel={COPY[key].label}
78
+ value={props.guidance[key]}
79
+ onValueChange={(value) => props.onChange(key, value)}
80
+ trackColor={styles.track}
81
+ />
82
+ </View>
83
+ ))}
84
+ </View>
85
+ );
86
+ }