@yannelli/paseo-linear-plugin 1.0.1 → 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.
- package/OVERVIEW.md +19 -1
- package/README.md +73 -3
- package/client/access.tsx +66 -0
- package/client/browser.tsx +12 -9
- package/client/graph-labels.ts +137 -0
- package/client/launch-choices.ts +15 -4
- package/client/launch-plan.ts +36 -14
- package/client/launch.tsx +9 -4
- package/client/live-agents.tsx +407 -0
- package/client/live-cursor.tsx +291 -0
- package/client/live-feed.tsx +151 -0
- package/client/live-graph.tsx +434 -0
- package/client/live-header.tsx +312 -0
- package/client/live-issues.tsx +345 -0
- package/client/live-map.tsx +345 -0
- package/client/live-panel.tsx +444 -0
- package/client/live-timeline.ts +341 -0
- package/client/pill.tsx +393 -0
- package/client/project-init.tsx +168 -0
- package/client/settings-access.tsx +56 -0
- package/client/settings-live.tsx +121 -0
- package/client/settings-projects.tsx +9 -0
- package/index.client.tsx +39 -1
- package/index.server.ts +24 -4
- package/package.json +1 -1
- package/server/agent-runs.ts +299 -0
- package/server/handlers.ts +12 -1
- package/server/init.ts +64 -0
- package/server/links.ts +153 -0
- package/server/live.ts +334 -0
- package/server/stores.ts +119 -0
- package/server/sync.ts +127 -0
- package/shared/activity.ts +308 -0
- package/shared/graph-geometry.ts +122 -0
- package/shared/graph-model.ts +319 -0
- package/shared/links.ts +252 -0
- package/shared/live.ts +200 -0
- package/shared/map-model.ts +192 -0
- package/shared/project-setup.ts +55 -0
- package/shared/prompts.ts +16 -1
- package/shared/settings.ts +57 -1
- package/shared/shell-activity.ts +309 -0
- package/shared/shell-lexer.ts +180 -0
- package/shared/subagents.ts +106 -0
- package/shared/todo-sync.ts +158 -0
package/OVERVIEW.md
CHANGED
|
@@ -25,10 +25,28 @@ Select **Start agent** or **Start review** on an issue. The setup page shows the
|
|
|
25
25
|
|
|
26
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**.
|
|
27
27
|
|
|
28
|
+
## Linear Live
|
|
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.
|
|
31
|
+
|
|
32
|
+
On agents not started from an issue, the Linear pill searches Linear and sends an issue to the agent.
|
|
33
|
+
|
|
34
|
+
## Update Linear from todos
|
|
35
|
+
|
|
36
|
+
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.
|
|
37
|
+
|
|
38
|
+
## Set up project prompts
|
|
39
|
+
|
|
40
|
+
In **Projects**, select **Start** under **Set up with an agent**. An agent reads the repository's guidelines, scripts, and CI files and proposes project instructions, steps, and prompt additions. You accept each change before it is saved.
|
|
41
|
+
|
|
42
|
+
## Choose projects
|
|
43
|
+
|
|
44
|
+
In **Projects**, **Use Linear in** turns Linear on or off for all projects, and each project has its own switch. In a project where Linear is off, its panels show that Linear is off, its agents get no Linear pill, and the agent setup page does not offer it. Todo sync and the explore agent skip it.
|
|
45
|
+
|
|
28
46
|
## Attach an issue
|
|
29
47
|
|
|
30
48
|
Choose **Attach Linear issue** in the composer attachment menu. The agent gets the issue text with your message. This uses the default key.
|
|
31
49
|
|
|
32
50
|
## Data and permissions
|
|
33
51
|
|
|
34
|
-
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. 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.
|
|
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.
|
package/README.md
CHANGED
|
@@ -13,6 +13,11 @@ A Paseo plugin for Linear. It adds a Linear screen and a workspace panel where y
|
|
|
13
13
|
- Use a different Linear key for a Paseo project.
|
|
14
14
|
- See saved results at once while the plugin gets new data from Linear.
|
|
15
15
|
- Attach a Linear issue to a message in the composer.
|
|
16
|
+
- Follow an agent in **Linear Live**: sub-issues, todos, and a map of the files it reads and edits.
|
|
17
|
+
- Open the issue from a **Linear** pill in each agent's composer, or send an issue to any agent.
|
|
18
|
+
- Let agent todos move sub-issues to In Progress and Done. This is off until you turn it on.
|
|
19
|
+
- Let an agent read a project's guidelines and propose its instructions and steps.
|
|
20
|
+
- Turn Linear on or off for all projects, or project by project.
|
|
16
21
|
|
|
17
22
|

|
|
18
23
|
|
|
@@ -125,11 +130,66 @@ Set the default model, where agents run, the default options, and the Implement
|
|
|
125
130
|
|
|
126
131
|
In the composer, open the attachment menu and choose **Attach Linear issue**. Type an identifier such as `ENG-123`, or words from a title. An empty search lists the 20 most recently updated issues. The agent gets the issue text with your message. The plugin takes this snapshot when you select the issue.
|
|
127
132
|
|
|
133
|
+
### Turn Linear on or off per project
|
|
134
|
+
|
|
135
|
+
In **Settings → Plugins → Linear → Projects**, **Use Linear in** has an **All projects** switch and one switch per Paseo project. A project switch wins over **All projects**. A project without its own switch follows **All projects**. Save to apply.
|
|
136
|
+
|
|
137
|
+
In a project where Linear is off:
|
|
138
|
+
|
|
139
|
+
- The workspace **Linear** panel and **Linear Live** show that Linear is off.
|
|
140
|
+
- Agents get no Linear pill.
|
|
141
|
+
- The agent setup page does not offer the project.
|
|
142
|
+
- Todo sync and the explore agent skip the project's agents.
|
|
143
|
+
|
|
144
|
+
The **Linear** screen in the sidebar is not tied to a project, so it stays available. To turn off the whole plugin, use **Settings → Plugins**.
|
|
145
|
+
|
|
146
|
+
### Linear Live
|
|
147
|
+
|
|
148
|
+
Linear Live is an agent panel. Open it from the Linear pill, or with **Linear: Open Linear Live** in the command center while an agent is open. It works for agents started from an issue, with Claude, Codex, and other providers.
|
|
149
|
+
|
|
150
|
+
- **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
|
+
- **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.
|
|
154
|
+
- **Activity:** the latest reads, edits, searches, and commands, with line counts and exit codes.
|
|
155
|
+
|
|
156
|
+
The map comes from one of two sources. Choose it in **Settings → Plugins → Linear → Live**:
|
|
157
|
+
|
|
158
|
+
- **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.
|
|
160
|
+
|
|
161
|
+
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
|
+
|
|
163
|
+
Explore and setup agents are told to only read. The plugin starts them in a mode that asks before tool use when the provider has one: **Always Ask** for Claude, never a bypass mode. When such an agent asks for permission, the plugin answers. It allows file reads inside the project folder and these commands: `ls`, `find`, `rg`, `grep`, `cat`, `head`, `tail`, `wc`, `git ls-files`, and `git grep`. It denies other commands, edits, web requests, redirection, and paths outside the folder. The plugin sees only the requests the provider raises: Claude runs some read commands without asking, and Codex has no read-only mode, so it runs in its default mode and its own sandbox decides what needs a request.
|
|
164
|
+
|
|
165
|
+
For implement agents on an issue with sub-issues, the prompt asks the agent to start each todo with its sub-issue key, such as `ENG-124: add the queue`. That is how todos map to sub-issues.
|
|
166
|
+
|
|
167
|
+
### Linear pill
|
|
168
|
+
|
|
169
|
+
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
|
+
|
|
171
|
+
### Update Linear from agent todos
|
|
172
|
+
|
|
173
|
+
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:
|
|
174
|
+
|
|
175
|
+
- A sub-issue with a todo in progress or done moves to the team's first started status, such as In Progress.
|
|
176
|
+
- A sub-issue whose todos are all done moves to the team's first completed status, such as Done.
|
|
177
|
+
- The parent moves to In Progress when work starts. When every sub-issue is done, it moves to a started status whose name has "review", such as In Review. It never moves to Done by itself.
|
|
178
|
+
- Statuses only move forward. Canceled issues and issues outside the parent and its sub-issues never change.
|
|
179
|
+
|
|
180
|
+
The daemon reads the issue from Linear before each sync, so a status you changed by hand counts. **Sync now** in the pill runs the same check at once.
|
|
181
|
+
|
|
182
|
+
### Set up project prompts with an agent
|
|
183
|
+
|
|
184
|
+
In **Settings → Plugins → Linear → Projects**, select **Start** under **Set up with an agent**. An agent reads the project's AGENTS.md, CLAUDE.md, CONTRIBUTING, README, scripts, and CI files. It then proposes project instructions, steps, and text to add to the Implement and Review prompts. Each change shows the current and proposed text. Turn off the ones you do not want, select **Use accepted changes**, and then save. Nothing changes until you save.
|
|
185
|
+
|
|
128
186
|
## Data and permissions
|
|
129
187
|
|
|
130
188
|
- **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`.
|
|
131
189
|
- **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.
|
|
132
190
|
- **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.
|
|
192
|
+
- **Todo sync:** when it is on, the daemon changes issue statuses in Linear with the key of the project that loaded the issue.
|
|
133
193
|
- **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.
|
|
134
194
|
|
|
135
195
|
Linear errors, such as a rejected key or a rate limit, show in the panel or as a message.
|
|
@@ -138,8 +198,8 @@ Linear errors, such as a rejected key or a rate limit, show in the panel or as a
|
|
|
138
198
|
|
|
139
199
|
| File | Runtime | Role |
|
|
140
200
|
| --- | --- | --- |
|
|
141
|
-
| `index.client.tsx` | App | Registers the Linear screen, the workspace panel, the Account, Prompts, and Projects settings, the command center items, the `/linear` command, the timeline card,
|
|
142
|
-
| `index.server.ts` | Daemon | Registers the settings
|
|
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 |
|
|
202
|
+
| `index.server.ts` | Daemon | Registers the settings, the RPC handlers, and the todo sync hook |
|
|
143
203
|
| `client/browser.tsx` | App | Screen and panel: picks the key, then shows the list, the issue, or the agent setup page |
|
|
144
204
|
| `client/issue-list.tsx`, `client/issue-row.tsx`, `client/issue-tree.ts` | App | Search, filters, sort, rows, status groups, and sub-issue nesting |
|
|
145
205
|
| `client/issue-detail.tsx`, `client/issue-sections.tsx`, `client/breadcrumbs.tsx` | App | Issue view: properties, breadcrumbs, sub-issues, links, agents, and comments |
|
|
@@ -152,16 +212,26 @@ Linear errors, such as a rejected key or a rate limit, show in the panel or as a
|
|
|
152
212
|
| `client/queries.ts`, `client/store.ts`, `client/key-scope.tsx` | App | Data hooks with saved results, browser state, and the key in use |
|
|
153
213
|
| `client/compat.ts` | App | Registers the screen on Paseo 0.10 and on later releases |
|
|
154
214
|
| `client/timeline-card.tsx`, `client/ui.tsx`, `client/glyphs.tsx` | App | Timeline card, shared controls, and icons |
|
|
215
|
+
| `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 |
|
|
217
|
+
| `client/pill.tsx` | App | Linear pill in each agent's composer |
|
|
218
|
+
| `client/project-init.tsx` | App | Project setup proposal in the Projects settings |
|
|
155
219
|
| `shared/linear.ts` | Both | RPC contracts and issue schemas |
|
|
156
220
|
| `shared/issues.ts` | Both | The `issues.search` RPC and the composer attachment source |
|
|
157
221
|
| `shared/settings.ts` | Both | Settings schema: templates, agent defaults, and project settings |
|
|
158
222
|
| `shared/prompts.ts` | Both | Default templates and the issue snapshot |
|
|
159
223
|
| `shared/markdown.ts` | Both | Markdown parser and task list toggle |
|
|
224
|
+
| `shared/live.ts`, `shared/activity.ts`, `shared/subagents.ts` | Both | Linear Live contracts, the ticket text map, timeline activity, and subagent runs |
|
|
225
|
+
| `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 |
|
|
227
|
+
| `shared/todo-sync.ts`, `shared/project-setup.ts` | Both | Todo keys, status moves, and setup proposals |
|
|
160
228
|
| `server/handlers.ts` | Daemon | RPC handlers, key lookup, and cache use |
|
|
161
229
|
| `server/credentials.ts` | Daemon | Key file and key order |
|
|
162
230
|
| `server/cache.ts` | Daemon | Response cache in memory |
|
|
163
231
|
| `server/graphql.ts`, `server/queries.ts` | Daemon | Linear GraphQL client, queries, and changes |
|
|
164
232
|
| `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 |
|
|
234
|
+
| `server/sync.ts`, `server/init.ts` | Daemon | Todo sync after each turn, and project setup runs |
|
|
165
235
|
|
|
166
236
|
The client bundle holds no credentials and makes no calls to Linear.
|
|
167
237
|
|
|
@@ -177,7 +247,7 @@ npm run check
|
|
|
177
247
|
paseo plugin add "$PWD"
|
|
178
248
|
```
|
|
179
249
|
|
|
180
|
-
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, and the release scripts. They use a local GraphQL server and do not call Linear.
|
|
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.
|
|
181
251
|
|
|
182
252
|
## Graphics
|
|
183
253
|
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { type PluginAgentPanelProps, useSettings, useWorkspace } from "@getpaseo/plugin/client";
|
|
2
|
+
import { type ComponentType, type ReactNode, useEffect } from "react";
|
|
3
|
+
import { linearSettings, type ProjectAccess, projectEnabled } from "../shared/settings";
|
|
4
|
+
import { EmptyState, type Theme } from "./ui";
|
|
5
|
+
|
|
6
|
+
// Composer pills live outside React, so components that read settings share the project
|
|
7
|
+
// switches here, and the pill code listens for changes.
|
|
8
|
+
let current: ProjectAccess | null = null;
|
|
9
|
+
const listeners = new Set<(access: ProjectAccess) => void>();
|
|
10
|
+
|
|
11
|
+
export function publishAccess(access: ProjectAccess): void {
|
|
12
|
+
if (current && JSON.stringify(current) === JSON.stringify(access)) return;
|
|
13
|
+
current = access;
|
|
14
|
+
for (const listener of listeners) listener(access);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function currentAccess(): ProjectAccess | null {
|
|
18
|
+
return current;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function onAccessChange(listener: (access: ProjectAccess) => void): () => void {
|
|
22
|
+
listeners.add(listener);
|
|
23
|
+
return () => listeners.delete(listener);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Null while settings load or when the project is unknown, so callers can wait. */
|
|
27
|
+
export function useProjectEnabled(projectId: string | null | undefined): boolean | null {
|
|
28
|
+
const settings = useSettings(linearSettings);
|
|
29
|
+
const access = settings.status === "ready" ? settings.values.access : null;
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
if (access) publishAccess(access);
|
|
32
|
+
}, [access]);
|
|
33
|
+
if (!access || projectId === undefined) return null;
|
|
34
|
+
return projectEnabled(access, projectId);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function ProjectGate(props: {
|
|
38
|
+
theme: Theme;
|
|
39
|
+
projectId: string | null | undefined;
|
|
40
|
+
children: ReactNode;
|
|
41
|
+
}) {
|
|
42
|
+
const enabled = useProjectEnabled(props.projectId);
|
|
43
|
+
if (enabled !== false) return <>{props.children}</>;
|
|
44
|
+
return (
|
|
45
|
+
<EmptyState
|
|
46
|
+
theme={props.theme}
|
|
47
|
+
icon="SquareKanban"
|
|
48
|
+
title="Linear is off for this project"
|
|
49
|
+
detail="Turn it on in Linear settings, under Projects."
|
|
50
|
+
/>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const selectProjectId = (workspace: { projectId: string }) => workspace.projectId;
|
|
55
|
+
|
|
56
|
+
/** Wraps an agent panel so it shows the off state in projects where Linear is off. */
|
|
57
|
+
export function gateAgentPanel(Panel: ComponentType<PluginAgentPanelProps>) {
|
|
58
|
+
return function GatedAgentPanel(props: PluginAgentPanelProps) {
|
|
59
|
+
const projectId = useWorkspace(props.workspaceId, selectProjectId);
|
|
60
|
+
return (
|
|
61
|
+
<ProjectGate theme={props.theme} projectId={projectId}>
|
|
62
|
+
<Panel {...props} />
|
|
63
|
+
</ProjectGate>
|
|
64
|
+
);
|
|
65
|
+
};
|
|
66
|
+
}
|
package/client/browser.tsx
CHANGED
|
@@ -11,6 +11,7 @@ import { ConnectCard } from "./connect";
|
|
|
11
11
|
import { CreateIssueModal } from "./create-issue";
|
|
12
12
|
import { IssueDetailView } from "./issue-detail";
|
|
13
13
|
import { IssueList } from "./issue-list";
|
|
14
|
+
import { ProjectGate } from "./access";
|
|
14
15
|
import { effectiveKeyScope, KeyScopeProvider } from "./key-scope";
|
|
15
16
|
import { LaunchPage, type LaunchTarget } from "./launch";
|
|
16
17
|
import { useAuthStatus, useCatalog, useProjects } from "./queries";
|
|
@@ -262,14 +263,16 @@ export function LinearPanel({ theme, layout, navigation, workspaceId }: PluginWo
|
|
|
262
263
|
if (teamId) update({ teamId, assignee: "anyone" });
|
|
263
264
|
}, [settings, projectId, update]);
|
|
264
265
|
return (
|
|
265
|
-
<
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
266
|
+
<ProjectGate theme={theme} projectId={projectId}>
|
|
267
|
+
<IssueBrowser
|
|
268
|
+
theme={theme}
|
|
269
|
+
compact={layout.compact}
|
|
270
|
+
navigation={navigation}
|
|
271
|
+
scope={scope}
|
|
272
|
+
target={target}
|
|
273
|
+
keyProjectId={projectId ?? null}
|
|
274
|
+
canSwitchKey={false}
|
|
275
|
+
/>
|
|
276
|
+
</ProjectGate>
|
|
274
277
|
);
|
|
275
278
|
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { type LabelRequest, labelBox, labelSides, type Placed, type Point, placeLabels } from "../shared/graph-geometry";
|
|
2
|
+
import { folderCenters, type Graph, type GraphNode } from "../shared/graph-model";
|
|
3
|
+
import type { IssueDetail } from "../shared/linear";
|
|
4
|
+
import { todoStatus } from "../shared/todo-sync";
|
|
5
|
+
import type { IssueProgress } from "./live-issues";
|
|
6
|
+
|
|
7
|
+
// Which labels the graph shows and where: issues first, then folder names, then the files the
|
|
8
|
+
// agent works on, then touched files. A label that fits nowhere is hidden.
|
|
9
|
+
|
|
10
|
+
const SIZES = { issue: 46, subissue: 32 } as const;
|
|
11
|
+
export const HOVER_GAP = 22;
|
|
12
|
+
|
|
13
|
+
/** About how wide a cursor caption draws. */
|
|
14
|
+
export const captionWidth = (text: string | null) => (text ? Math.min(190, text.length * 6.2 + 18) : 0);
|
|
15
|
+
const MAX_LABEL = 170;
|
|
16
|
+
|
|
17
|
+
export function nodeSize(node: GraphNode): number {
|
|
18
|
+
if (node.kind !== "file") return SIZES[node.kind];
|
|
19
|
+
const touched = node.tile && node.tile.touch !== "none";
|
|
20
|
+
return touched ? (node.tile?.touch === "read" ? 12 : 14) : 9;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function clip(text: string, max: number): string {
|
|
24
|
+
return text.length > max ? `${text.slice(0, max - 1).trimEnd()}…` : text;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface Label {
|
|
28
|
+
left: number;
|
|
29
|
+
top: number;
|
|
30
|
+
width: number;
|
|
31
|
+
lines: readonly [string, string | null];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface FolderLabel {
|
|
35
|
+
folder: string;
|
|
36
|
+
left: number;
|
|
37
|
+
top: number;
|
|
38
|
+
width: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface LabelInput {
|
|
42
|
+
graph: Graph;
|
|
43
|
+
positions: ReadonlyMap<string, Placed>;
|
|
44
|
+
issue: IssueDetail;
|
|
45
|
+
progress: IssueProgress;
|
|
46
|
+
selected: string | null;
|
|
47
|
+
/** Nodes a running agent works on. */
|
|
48
|
+
working: ReadonlySet<string>;
|
|
49
|
+
/** Where each agent hovers, with the width of its caption and the side it shows on. */
|
|
50
|
+
cursors: readonly { at: Point; caption: number; flip: boolean }[];
|
|
51
|
+
width: number;
|
|
52
|
+
height: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function linesOf(node: GraphNode, input: LabelInput): readonly [string, string | null] {
|
|
56
|
+
if (node.kind === "issue") return [node.label, clip(input.issue.title, 34)];
|
|
57
|
+
if (node.kind === "file") return [node.label, null];
|
|
58
|
+
const child = input.issue.children.find((entry) => entry.identifier.toUpperCase() === node.label);
|
|
59
|
+
const todos = input.progress.groups.get(node.label) ?? [];
|
|
60
|
+
const done = todos.filter((todo) => todoStatus(todo) === "completed").length;
|
|
61
|
+
const counts = todos.length > 0 ? ` ${done}/${todos.length}` : "";
|
|
62
|
+
const folded = node.folded > 0 ? ` +${node.folded}` : "";
|
|
63
|
+
return [`${node.label}${counts}${folded}`, clip(child?.title ?? "", 26)];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const textWidth = (text: string, perChar: number) => Math.min(MAX_LABEL, text.length * perChar + 4);
|
|
67
|
+
|
|
68
|
+
export function graphLabels(input: LabelInput): { nodes: Map<string, Label>; folders: FolderLabel[] } {
|
|
69
|
+
const { graph, positions, selected, working } = input;
|
|
70
|
+
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));
|
|
75
|
+
const lines = new Map<string, readonly [string, string | null]>();
|
|
76
|
+
const requests: LabelRequest[] = [];
|
|
77
|
+
const push = (node: GraphNode) => {
|
|
78
|
+
const at = positions.get(node.id);
|
|
79
|
+
if (!at) return;
|
|
80
|
+
const text = linesOf(node, input);
|
|
81
|
+
const perChar = node.kind === "issue" ? 7.4 : 6.2;
|
|
82
|
+
const width = Math.max(textWidth(text[0], perChar), text[1] ? textWidth(text[1], 5.6) : 0);
|
|
83
|
+
const height = text[1] ? 28 : 14;
|
|
84
|
+
const spotsFor = (w: number, h: number) =>
|
|
85
|
+
labelSides(at.side).map((side) => labelBox({ ...at, side }, nodeSize(node) / 2, w, h));
|
|
86
|
+
const short = textWidth(text[0], perChar);
|
|
87
|
+
lines.set(node.id, text);
|
|
88
|
+
requests.push({
|
|
89
|
+
id: node.id,
|
|
90
|
+
width,
|
|
91
|
+
height,
|
|
92
|
+
spots: spotsFor(width, height),
|
|
93
|
+
fallback: text[1] ? { width: short, height: 14, spots: spotsFor(short, 14) } : undefined,
|
|
94
|
+
});
|
|
95
|
+
};
|
|
96
|
+
for (const node of wanted.filter((entry) => order(entry) <= 2)) push(node);
|
|
97
|
+
// Folder names sit above each neighborhood, after the issues and before single files.
|
|
98
|
+
const folderIds = new Map<string, string>();
|
|
99
|
+
for (const center of folderCenters(graph, positions)) {
|
|
100
|
+
const name = center.folder === "." ? "/" : `${center.folder}/`;
|
|
101
|
+
const width = textWidth(name, 6);
|
|
102
|
+
const id = `folder:${center.folder}`;
|
|
103
|
+
folderIds.set(id, center.folder);
|
|
104
|
+
requests.push({ id, width, height: 14, spots: [{ left: center.x - width / 2, top: center.top - 30 }, { left: center.x - width / 2, top: center.y - 7 }] });
|
|
105
|
+
}
|
|
106
|
+
for (const node of wanted.filter((entry) => order(entry) > 2)) push(node);
|
|
107
|
+
const shapes: { at: Point; radius: number }[] = graph.nodes.flatMap((node) => {
|
|
108
|
+
const at = positions.get(node.id);
|
|
109
|
+
return at ? [{ at, radius: nodeSize(node) / 2 }] : [];
|
|
110
|
+
});
|
|
111
|
+
// Keep labels out from under the agents and their captions, as a row of small circles.
|
|
112
|
+
for (const cursor of input.cursors) {
|
|
113
|
+
shapes.push({ at: cursor.at, radius: 16 });
|
|
114
|
+
for (let offset = 24; offset < cursor.caption + 24; offset += 14) {
|
|
115
|
+
shapes.push({ at: { x: cursor.at.x + (cursor.flip ? -offset : offset), y: cursor.at.y }, radius: 10 });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const sizes = new Map(requests.map((request) => [request.id, request]));
|
|
119
|
+
const nodes = new Map<string, Label>();
|
|
120
|
+
const folders: FolderLabel[] = [];
|
|
121
|
+
for (const [id, box] of placeLabels(requests, input.width, input.height, shapes)) {
|
|
122
|
+
const request = sizes.get(id) as LabelRequest;
|
|
123
|
+
const folder = folderIds.get(id);
|
|
124
|
+
if (folder !== undefined) {
|
|
125
|
+
folders.push({ folder, left: box.left, top: box.top, width: request.width });
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
const text = lines.get(id) ?? ["", null];
|
|
129
|
+
nodes.set(id, {
|
|
130
|
+
left: box.left,
|
|
131
|
+
top: box.top,
|
|
132
|
+
width: (box.short ? request.fallback?.width : request.width) ?? 0,
|
|
133
|
+
lines: box.short ? [text[0], null] : text,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
return { nodes, folders };
|
|
137
|
+
}
|
package/client/launch-choices.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { usePaseo } from "@getpaseo/plugin/client";
|
|
|
2
2
|
import { useQuery } from "@tanstack/react-query";
|
|
3
3
|
import { useCallback, useMemo, useState } from "react";
|
|
4
4
|
import type { IssueDetail } from "../shared/linear";
|
|
5
|
-
import type
|
|
5
|
+
import { type AgentAction, type LinearSettings, projectEnabled } from "../shared/settings";
|
|
6
6
|
import { type AgentPicks, resolveAgent } from "./agent-options";
|
|
7
7
|
import { loadAgentPreferences } from "./agent-preferences";
|
|
8
8
|
import {
|
|
@@ -41,7 +41,15 @@ export function useLaunchChoices(input: {
|
|
|
41
41
|
target: TargetWorkspace | null;
|
|
42
42
|
}) {
|
|
43
43
|
const { issue, settings, action } = input;
|
|
44
|
-
const
|
|
44
|
+
const allProjects = useProjects();
|
|
45
|
+
// Projects where Linear is off are not offered as launch targets.
|
|
46
|
+
const projects = useMemo(
|
|
47
|
+
() => ({
|
|
48
|
+
...allProjects,
|
|
49
|
+
data: allProjects.data?.filter((entry) => projectEnabled(settings.access, entry.projectId)),
|
|
50
|
+
}),
|
|
51
|
+
[allProjects, settings.access],
|
|
52
|
+
);
|
|
45
53
|
const providers = useProviders();
|
|
46
54
|
const { target } = input;
|
|
47
55
|
const prNumber = action === "review" ? pullRequestNumber(issue) : null;
|
|
@@ -54,10 +62,13 @@ export function useLaunchChoices(input: {
|
|
|
54
62
|
const [placementChoice, setPlacementChoice] = useState<Placement | null>(null);
|
|
55
63
|
const [picks, setPicks] = useState<AgentPicks>(NO_PICKS);
|
|
56
64
|
const [remembered] = useState(loadAgentPreferences);
|
|
57
|
-
const
|
|
65
|
+
const preferredId =
|
|
58
66
|
projectChoice ??
|
|
59
67
|
defaultProjectId({ target, teamId: issue.team.id, settings, projects: projects.data });
|
|
60
|
-
const project =
|
|
68
|
+
const project =
|
|
69
|
+
projects.data?.find((entry) => entry.projectId === preferredId) ??
|
|
70
|
+
(projectChoice ? null : (projects.data?.[0] ?? null));
|
|
71
|
+
const projectId = project?.projectId ?? preferredId;
|
|
61
72
|
const placements = useMemo(
|
|
62
73
|
() =>
|
|
63
74
|
placementOptions({
|
package/client/launch-plan.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
updateIssueRpc,
|
|
8
8
|
type WorkflowState,
|
|
9
9
|
} from "../shared/linear";
|
|
10
|
+
import { launchAfterExploreRpc } from "../shared/live";
|
|
10
11
|
import { AGENT_LABELS, agentTitle } from "../shared/prompts";
|
|
11
12
|
import type { AgentAction, Isolation, LinearSettings } from "../shared/settings";
|
|
12
13
|
import { type AgentSelection, agentConfig } from "./agent-options";
|
|
@@ -141,6 +142,8 @@ export interface LaunchRequest {
|
|
|
141
142
|
started: WorkflowState | null;
|
|
142
143
|
/** The Paseo project whose Linear key loaded the issue. */
|
|
143
144
|
keyScope: string | null;
|
|
145
|
+
/** Maps the files with the read-only explore agent first, then starts the agent. */
|
|
146
|
+
explore: boolean;
|
|
144
147
|
}
|
|
145
148
|
|
|
146
149
|
export type Paseo = ReturnType<typeof usePaseo>;
|
|
@@ -213,16 +216,45 @@ export function useLaunchAgent() {
|
|
|
213
216
|
const paseo = usePaseo();
|
|
214
217
|
const updateIssue = useRpc(updateIssueRpc);
|
|
215
218
|
const attachCard = useRpc(attachIssueCardRpc);
|
|
219
|
+
const launchAfterExplore = useRpc(launchAfterExploreRpc);
|
|
216
220
|
return useMutation({
|
|
217
221
|
mutationFn: async (request: LaunchRequest) => {
|
|
218
222
|
const { issue, action } = request;
|
|
219
223
|
const workspace = await openWorkspace(paseo, request);
|
|
220
|
-
const
|
|
224
|
+
const state = request.patch.stateId && request.started ? request.started : issue.state;
|
|
225
|
+
const card = {
|
|
226
|
+
identifier: issue.identifier,
|
|
227
|
+
title: issue.title,
|
|
228
|
+
url: issue.url,
|
|
229
|
+
stateName: state.name,
|
|
230
|
+
stateColor: state.color,
|
|
231
|
+
action,
|
|
232
|
+
};
|
|
233
|
+
const agent = {
|
|
221
234
|
config: agentConfig(request.agent),
|
|
222
235
|
title: agentTitle(action, issue),
|
|
223
236
|
prompt: request.prompt,
|
|
224
|
-
labels: {
|
|
225
|
-
|
|
237
|
+
labels: {
|
|
238
|
+
[AGENT_LABELS.issue]: issue.identifier,
|
|
239
|
+
[AGENT_LABELS.action]: action,
|
|
240
|
+
...(request.keyScope ? { [AGENT_LABELS.project]: request.keyScope } : {}),
|
|
241
|
+
},
|
|
242
|
+
};
|
|
243
|
+
// The daemon starts the agent once the map is ready, so agentId is null until then.
|
|
244
|
+
let agentId: string | null;
|
|
245
|
+
if (request.explore && action === "implement") {
|
|
246
|
+
const launched = await launchAfterExplore({
|
|
247
|
+
workspaceId: workspace.id,
|
|
248
|
+
identifier: issue.identifier,
|
|
249
|
+
keyScope: request.keyScope,
|
|
250
|
+
agent,
|
|
251
|
+
card,
|
|
252
|
+
});
|
|
253
|
+
agentId = launched.agentId;
|
|
254
|
+
} else {
|
|
255
|
+
agentId = (await workspace.agents.create(agent)).id;
|
|
256
|
+
void attachCard({ agentId, card }).catch(() => undefined);
|
|
257
|
+
}
|
|
226
258
|
lastProjectByTeam.set(issue.team.id, request.project.projectId);
|
|
227
259
|
rememberLaunch({
|
|
228
260
|
agent: request.agent.agent.id,
|
|
@@ -238,17 +270,7 @@ export function useLaunchAgent() {
|
|
|
238
270
|
warning = errorMessage(error);
|
|
239
271
|
}
|
|
240
272
|
}
|
|
241
|
-
|
|
242
|
-
const card = {
|
|
243
|
-
identifier: issue.identifier,
|
|
244
|
-
title: issue.title,
|
|
245
|
-
url: issue.url,
|
|
246
|
-
stateName: state.name,
|
|
247
|
-
stateColor: state.color,
|
|
248
|
-
action,
|
|
249
|
-
};
|
|
250
|
-
void attachCard({ agentId: agent.id, card }).catch(() => undefined);
|
|
251
|
-
return { agentId: agent.id, warning };
|
|
273
|
+
return { agentId, workspaceId: workspace.id, warning };
|
|
252
274
|
},
|
|
253
275
|
});
|
|
254
276
|
}
|
package/client/launch.tsx
CHANGED
|
@@ -271,15 +271,20 @@ function LaunchComposer(props: LaunchPageProps & { issue: IssueDetail; settings:
|
|
|
271
271
|
patch,
|
|
272
272
|
started,
|
|
273
273
|
keyScope,
|
|
274
|
+
explore: settings.live.enabled && settings.live.mapping === "explore",
|
|
274
275
|
},
|
|
275
276
|
{
|
|
276
|
-
onSuccess: ({ agentId, warning }) => {
|
|
277
|
+
onSuccess: ({ agentId, workspaceId, warning }) => {
|
|
277
278
|
void queries.invalidateQueries({ queryKey: ["linear"] });
|
|
279
|
+
const started = agentId
|
|
280
|
+
? "Agent started."
|
|
281
|
+
: "Mapping files first. The agent starts when the map is ready.";
|
|
278
282
|
if (warning) {
|
|
279
|
-
toast.show(
|
|
280
|
-
} else toast.show(
|
|
283
|
+
toast.show(`${started} Linear was not updated: ${warning}`, { variant: "warning" });
|
|
284
|
+
} else toast.show(started, { variant: "success" });
|
|
281
285
|
onStarted();
|
|
282
|
-
navigation?.openAgent({ agentId });
|
|
286
|
+
if (agentId) navigation?.openAgent({ agentId });
|
|
287
|
+
else navigation?.openWorkspace({ workspaceId });
|
|
283
288
|
},
|
|
284
289
|
},
|
|
285
290
|
);
|