@yannelli/paseo-linear-plugin 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/NOTICE +8 -4
  2. package/OVERVIEW.md +22 -13
  3. package/README.md +134 -29
  4. package/client/agent-options.ts +200 -0
  5. package/client/breadcrumbs.tsx +110 -0
  6. package/client/browser.tsx +275 -0
  7. package/client/compat.ts +107 -0
  8. package/client/connect.tsx +136 -0
  9. package/client/create-issue.tsx +291 -0
  10. package/client/glyphs.tsx +117 -0
  11. package/client/issue-detail.tsx +366 -0
  12. package/client/issue-list.tsx +373 -0
  13. package/client/issue-row.tsx +152 -0
  14. package/client/issue-sections.tsx +325 -0
  15. package/client/issue-tree.ts +132 -0
  16. package/client/key-scope.tsx +23 -0
  17. package/client/launch-choices.ts +125 -0
  18. package/client/launch-fields.tsx +152 -0
  19. package/client/launch-plan.ts +227 -0
  20. package/client/launch.tsx +484 -0
  21. package/client/markdown.tsx +240 -0
  22. package/client/model-browser.tsx +296 -0
  23. package/client/pickers.tsx +238 -0
  24. package/client/provider-icon.tsx +78 -0
  25. package/client/queries.ts +211 -0
  26. package/client/settings-connection.tsx +109 -0
  27. package/client/settings-fields.tsx +173 -0
  28. package/client/settings-keys.tsx +117 -0
  29. package/client/settings-projects.tsx +281 -0
  30. package/client/settings-prompts.tsx +198 -0
  31. package/client/store.ts +71 -0
  32. package/client/timeline-card.tsx +63 -0
  33. package/client/ui.tsx +352 -0
  34. package/index.client.tsx +111 -0
  35. package/index.server.ts +7 -3
  36. package/package.json +4 -3
  37. package/paseo-plugin.json +2 -2
  38. package/server/cache.ts +68 -0
  39. package/server/credentials.ts +101 -0
  40. package/server/graphql.ts +68 -0
  41. package/server/handlers.ts +172 -0
  42. package/server/linear.ts +11 -64
  43. package/server/queries.ts +280 -0
  44. package/shared/linear.ts +268 -0
  45. package/shared/markdown.ts +416 -0
  46. package/shared/prompts.ts +150 -0
  47. package/shared/settings.ts +68 -0
  48. package/index.client.ts +0 -7
  49. package/server/issues.ts +0 -10
@@ -0,0 +1,275 @@
1
+ import {
2
+ type PluginSurfaceProps,
3
+ type PluginWorkspacePanelProps,
4
+ useSettings,
5
+ useWorkspace,
6
+ } from "@getpaseo/plugin/client";
7
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
8
+ import { type LayoutChangeEvent, View } from "react-native";
9
+ import { type AgentAction, linearSettings } from "../shared/settings";
10
+ import { ConnectCard } from "./connect";
11
+ import { CreateIssueModal } from "./create-issue";
12
+ import { IssueDetailView } from "./issue-detail";
13
+ import { IssueList } from "./issue-list";
14
+ import { effectiveKeyScope, KeyScopeProvider } from "./key-scope";
15
+ import { LaunchPage, type LaunchTarget } from "./launch";
16
+ import { useAuthStatus, useCatalog, useProjects } from "./queries";
17
+ import { panelScope, SCREEN_SCOPE, useBrowserState } from "./store";
18
+ import { Button, EmptyState, errorMessage, type Theme } from "./ui";
19
+
20
+ const SPLIT_WIDTH = 820;
21
+ const NARROW_WIDTH = 560;
22
+ const SCREEN_TARGET: LaunchTarget = { workspace: null };
23
+
24
+ interface BrowserProps {
25
+ theme: Theme;
26
+ compact: boolean;
27
+ navigation: PluginSurfaceProps["navigation"];
28
+ scope: string;
29
+ target: LaunchTarget;
30
+ /** The Paseo project to use the Linear key of. Null uses the default key. */
31
+ keyProjectId: string | null;
32
+ /** Shows a picker for the Linear key. The screen has one; panels follow their workspace. */
33
+ canSwitchKey: boolean;
34
+ }
35
+
36
+ type Catalog = NonNullable<ReturnType<typeof useCatalog>["data"]>;
37
+
38
+ function LoadError(props: { theme: Theme; title: string; error: unknown; retry(): unknown }) {
39
+ const { retry } = props;
40
+ const tryAgain = useCallback(() => void retry(), [retry]);
41
+ return (
42
+ <EmptyState
43
+ theme={props.theme}
44
+ icon="TriangleAlert"
45
+ tone="danger"
46
+ title={props.title}
47
+ detail={errorMessage(props.error)}
48
+ >
49
+ <Button theme={props.theme} label="Try again" icon="RefreshCw" onPress={tryAgain} />
50
+ </EmptyState>
51
+ );
52
+ }
53
+
54
+ export function IssueBrowser(props: BrowserProps) {
55
+ const { theme } = props;
56
+ const auth = useAuthStatus();
57
+ const keyScope = effectiveKeyScope(props.keyProjectId, auth.data?.projectKeys);
58
+ if (auth.isPending) {
59
+ return <EmptyState theme={theme} icon="RefreshCw" title="Connecting to Linear" />;
60
+ }
61
+ if (auth.isError) {
62
+ return (
63
+ <LoadError
64
+ theme={theme}
65
+ title="Linear plugin is not responding"
66
+ error={auth.error}
67
+ retry={auth.refetch}
68
+ />
69
+ );
70
+ }
71
+ if (keyScope === null && !auth.data.configured) {
72
+ return <ConnectCard theme={theme} compact={props.compact} />;
73
+ }
74
+ return (
75
+ <KeyScopeProvider key={keyScope ?? "default"} projectId={keyScope}>
76
+ <ScopedBrowser {...props} projectKeys={auth.data.projectKeys} />
77
+ </KeyScopeProvider>
78
+ );
79
+ }
80
+
81
+ type ProjectKeys = readonly { projectId: string }[];
82
+
83
+ function ScopedBrowser(props: BrowserProps & { projectKeys: ProjectKeys }) {
84
+ const { theme } = props;
85
+ const catalog = useCatalog();
86
+ if (catalog.isPending) {
87
+ return <EmptyState theme={theme} icon="RefreshCw" title="Loading your Linear workspace" />;
88
+ }
89
+ if (catalog.isError) {
90
+ return (
91
+ <LoadError
92
+ theme={theme}
93
+ title="Could not reach Linear"
94
+ error={catalog.error}
95
+ retry={catalog.refetch}
96
+ />
97
+ );
98
+ }
99
+ return <ConnectedBrowser {...props} catalog={catalog.data} />;
100
+ }
101
+
102
+ function useKeyOptions(enabled: boolean, projectKeys: ProjectKeys) {
103
+ const projects = useProjects();
104
+ return useMemo(() => {
105
+ if (!enabled || projectKeys.length === 0) return [];
106
+ const names = new Map(
107
+ projects.data?.map((project) => [project.projectId, project.displayName]),
108
+ );
109
+ return [
110
+ { value: "", label: "Default key" },
111
+ ...projectKeys.map((entry) => ({
112
+ value: entry.projectId,
113
+ label: names.get(entry.projectId) ?? entry.projectId,
114
+ })),
115
+ ];
116
+ }, [enabled, projectKeys, projects.data]);
117
+ }
118
+
119
+ function ConnectedBrowser(props: BrowserProps & { catalog: Catalog; projectKeys: ProjectKeys }) {
120
+ const { theme, catalog } = props;
121
+ const { colors } = theme;
122
+ const [state, update] = useBrowserState(props.scope);
123
+ const keyOptions = useKeyOptions(props.canSwitchKey, props.projectKeys);
124
+ const [width, setWidth] = useState(0);
125
+ const split = !props.compact && width >= SPLIT_WIDTH;
126
+ const showDetail = state.issueId !== null;
127
+ const showList = split || !showDetail;
128
+ const sideBySide = split && showDetail;
129
+ const narrow = props.compact || width < NARROW_WIDTH;
130
+
131
+ const styles = useMemo(
132
+ () => ({
133
+ root: { flex: 1, flexDirection: "row" as const, backgroundColor: colors.surface0 },
134
+ // Not `flex: 0`: on web it sets flex-basis 0, which overrides the width.
135
+ list: sideBySide
136
+ ? {
137
+ flexGrow: 0,
138
+ flexShrink: 0,
139
+ width: Math.min(460, Math.round(width * 0.42)),
140
+ borderRightWidth: 1,
141
+ borderRightColor: colors.border,
142
+ overflow: "hidden" as const,
143
+ }
144
+ : { flex: 1, minWidth: 0, overflow: "hidden" as const },
145
+ detail: { flex: 1, minWidth: 0, overflow: "hidden" as const },
146
+ }),
147
+ [colors, sideBySide, width],
148
+ );
149
+ const measure = useCallback(
150
+ (event: LayoutChangeEvent) => setWidth(event.nativeEvent.layout.width),
151
+ [],
152
+ );
153
+ const back = useCallback(() => update({ issueId: null }), [update]);
154
+ const openIssue = useCallback((issueId: string) => update({ issueId }), [update]);
155
+ const closeCreate = useCallback(() => update({ creating: false }), [update]);
156
+ const created = useCallback((issueId: string) => update({ creating: false, issueId }), [update]);
157
+ const openLaunch = useCallback(
158
+ (action: AgentAction) =>
159
+ state.issueId && update({ launch: { issueId: state.issueId, action } }),
160
+ [update, state.issueId],
161
+ );
162
+ const closeLaunch = useCallback(() => update({ launch: null }), [update]);
163
+
164
+ if (state.launch) {
165
+ return (
166
+ <LaunchPage
167
+ key={`${state.launch.issueId}:${state.launch.action}`}
168
+ theme={theme}
169
+ compact={narrow}
170
+ issueId={state.launch.issueId}
171
+ action={state.launch.action}
172
+ target={props.target}
173
+ navigation={props.navigation}
174
+ onBack={closeLaunch}
175
+ onStarted={closeLaunch}
176
+ />
177
+ );
178
+ }
179
+
180
+ return (
181
+ <View style={styles.root} onLayout={measure}>
182
+ {showList ? (
183
+ <View style={styles.list}>
184
+ <IssueList
185
+ theme={theme}
186
+ compact={narrow || sideBySide}
187
+ teams={catalog.teams}
188
+ state={state}
189
+ update={update}
190
+ keyOptions={keyOptions}
191
+ />
192
+ </View>
193
+ ) : null}
194
+ {state.issueId ? (
195
+ <View style={styles.detail}>
196
+ <IssueDetailView
197
+ key={state.issueId}
198
+ theme={theme}
199
+ compact={narrow}
200
+ issueId={state.issueId}
201
+ teams={catalog.teams}
202
+ users={catalog.users}
203
+ viewerId={catalog.viewer.id}
204
+ target={props.target}
205
+ navigation={props.navigation}
206
+ onBack={back}
207
+ onOpenIssue={openIssue}
208
+ onLaunch={openLaunch}
209
+ />
210
+ </View>
211
+ ) : null}
212
+ {state.creating ? (
213
+ <CreateIssueModal
214
+ theme={theme}
215
+ teams={catalog.teams}
216
+ users={catalog.users}
217
+ viewerId={catalog.viewer.id}
218
+ defaultTeamId={state.teamId}
219
+ onClose={closeCreate}
220
+ onCreated={created}
221
+ />
222
+ ) : null}
223
+ </View>
224
+ );
225
+ }
226
+
227
+ export function LinearScreen({ theme, layout, navigation }: PluginSurfaceProps) {
228
+ const [state] = useBrowserState(SCREEN_SCOPE);
229
+ return (
230
+ <IssueBrowser
231
+ theme={theme}
232
+ compact={layout.compact}
233
+ navigation={navigation}
234
+ scope={SCREEN_SCOPE}
235
+ target={SCREEN_TARGET}
236
+ keyProjectId={state.keyProjectId}
237
+ canSwitchKey
238
+ />
239
+ );
240
+ }
241
+
242
+ const selectProjectId = (workspace: { projectId: string }) => workspace.projectId;
243
+ const selectName = (workspace: { name: string }) => workspace.name;
244
+
245
+ export function LinearPanel({ theme, layout, navigation, workspaceId }: PluginWorkspacePanelProps) {
246
+ const scope = panelScope(workspaceId);
247
+ const projectId = useWorkspace(workspaceId, selectProjectId);
248
+ const name = useWorkspace(workspaceId, selectName);
249
+ const settings = useSettings(linearSettings);
250
+ const [, update] = useBrowserState(scope);
251
+ const seeded = useRef(false);
252
+ const target = useMemo(
253
+ () => ({ workspace: projectId ? { id: workspaceId, projectId, name: name ?? "" } : null }),
254
+ [workspaceId, projectId, name],
255
+ );
256
+ // Default the panel's team filter to the team mapped to this workspace's project, once.
257
+ useEffect(() => {
258
+ if (seeded.current || settings.status !== "ready" || !projectId) return;
259
+ seeded.current = true;
260
+ const mapped = settings.values.projects.find((entry) => entry.projectId === projectId);
261
+ const teamId = mapped?.teamIds[0];
262
+ if (teamId) update({ teamId, assignee: "anyone" });
263
+ }, [settings, projectId, update]);
264
+ return (
265
+ <IssueBrowser
266
+ theme={theme}
267
+ compact={layout.compact}
268
+ navigation={navigation}
269
+ scope={scope}
270
+ target={target}
271
+ keyProjectId={projectId ?? null}
272
+ canSwitchKey={false}
273
+ />
274
+ );
275
+ }
@@ -0,0 +1,107 @@
1
+ import type { PluginClientContext, PluginSurfaceProps } from "@getpaseo/plugin/client";
2
+ import * as hostUi from "@getpaseo/plugin/client/ui";
3
+ import { type ComponentType, createElement } from "react";
4
+
5
+ // Paseo 0.11 replaced surfaces with screens and sidebar header items. 0.10 only has
6
+ // addSurface/addSidebarItem; later releases drop those aliases. Detect once, register once.
7
+ type Cleanup = () => void;
8
+
9
+ interface ScreenRef {
10
+ screenId: string;
11
+ }
12
+
13
+ interface SidebarItemProps {
14
+ currentScreen: ScreenRef | null;
15
+ openScreen(input: ScreenRef): void;
16
+ }
17
+
18
+ interface ScreenClient {
19
+ addScreen(input: {
20
+ id: string;
21
+ title: string;
22
+ Component: ComponentType<PluginSurfaceProps>;
23
+ }): Cleanup;
24
+ addSidebarHeaderItem(input: {
25
+ id: string;
26
+ title: string;
27
+ Component: ComponentType<SidebarItemProps>;
28
+ }): Cleanup;
29
+ }
30
+
31
+ interface SurfaceClient {
32
+ addSurface(id: string, Component: ComponentType<PluginSurfaceProps>): Cleanup;
33
+ addSidebarItem(input: { id: string; title: string; icon: string; surface: string }): Cleanup;
34
+ }
35
+
36
+ type SidebarRowComponent = ComponentType<{
37
+ icon?: string;
38
+ label?: string;
39
+ active?: boolean;
40
+ onPress(): void;
41
+ }>;
42
+
43
+ function hasScreens(client: object): client is ScreenClient {
44
+ return "addScreen" in client && typeof client.addScreen === "function";
45
+ }
46
+
47
+ export interface ScreenRegistration {
48
+ id: string;
49
+ title: string;
50
+ icon: string;
51
+ Component: ComponentType<PluginSurfaceProps>;
52
+ }
53
+
54
+ export function registerScreen(client: PluginClientContext, screen: ScreenRegistration): Cleanup {
55
+ const SidebarRow = (hostUi as Record<string, unknown>).SidebarRow as
56
+ | SidebarRowComponent
57
+ | undefined;
58
+ if (hasScreens(client) && SidebarRow) {
59
+ const removeScreen = client.addScreen({
60
+ id: screen.id,
61
+ title: screen.title,
62
+ Component: screen.Component,
63
+ });
64
+ function SidebarItem({ currentScreen, openScreen }: SidebarItemProps) {
65
+ return createElement(SidebarRow as SidebarRowComponent, {
66
+ icon: screen.icon,
67
+ active: currentScreen?.screenId === screen.id,
68
+ onPress: () => openScreen({ screenId: screen.id }),
69
+ });
70
+ }
71
+ const removeItem = client.addSidebarHeaderItem({
72
+ id: screen.id,
73
+ title: screen.title,
74
+ Component: SidebarItem,
75
+ });
76
+ return () => {
77
+ removeItem();
78
+ removeScreen();
79
+ };
80
+ }
81
+ const legacy = client as unknown as SurfaceClient;
82
+ const removeSurface = legacy.addSurface(screen.id, screen.Component);
83
+ const removeItem = legacy.addSidebarItem({
84
+ id: screen.id,
85
+ title: screen.title,
86
+ icon: screen.icon,
87
+ surface: screen.id,
88
+ });
89
+ return () => {
90
+ removeItem();
91
+ removeSurface();
92
+ };
93
+ }
94
+
95
+ interface NavigationCapabilities {
96
+ openScreen?: (input: ScreenRef) => void;
97
+ openSurface?: (id: string) => void;
98
+ }
99
+
100
+ export function openPluginScreen(capabilities: object, screenId: string): void {
101
+ const navigation = capabilities as NavigationCapabilities;
102
+ if (typeof navigation.openScreen === "function") {
103
+ navigation.openScreen({ screenId });
104
+ return;
105
+ }
106
+ navigation.openSurface?.(screenId);
107
+ }
@@ -0,0 +1,136 @@
1
+ import { useRpc } from "@getpaseo/plugin/client";
2
+ import { Icon, TextInput, useToast } from "@getpaseo/plugin/client/react-native";
3
+ import { ExternalLink } from "@getpaseo/plugin/client/ui";
4
+ import { useMutation, useQueryClient } from "@tanstack/react-query";
5
+ import { useCallback, useMemo, useState } from "react";
6
+ import { Text, View } from "react-native";
7
+ import { authSaveRpc } from "../shared/linear";
8
+ import { Button, errorMessage, type Theme } from "./ui";
9
+
10
+ export const LINEAR_KEY_SETTINGS_URL = "https://linear.app/settings/account/security";
11
+
12
+ export function useSaveApiKey(projectId: string | null = null) {
13
+ const save = useRpc(authSaveRpc);
14
+ const queries = useQueryClient();
15
+ return useMutation({
16
+ mutationFn: (apiKey: string) => save({ apiKey, projectId }),
17
+ onSuccess: () => {
18
+ void queries.invalidateQueries({ queryKey: ["linear"] });
19
+ },
20
+ });
21
+ }
22
+
23
+ export function ApiKeyForm(props: {
24
+ theme: Theme;
25
+ /** Saves the key for this Paseo project only. Null saves the default key. */
26
+ projectId?: string | null;
27
+ submitLabel?: string;
28
+ }) {
29
+ const { theme } = props;
30
+ const [apiKey, setApiKey] = useState("");
31
+ const saveKey = useSaveApiKey(props.projectId ?? null);
32
+ const toast = useToast();
33
+ const { colors } = theme;
34
+ const { mutate } = saveKey;
35
+ const submit = useCallback(() => {
36
+ if (!apiKey.trim()) return;
37
+ mutate(apiKey, {
38
+ onSuccess: ({ viewerName, organizationName }) => {
39
+ setApiKey("");
40
+ toast.show(`Connected to ${organizationName} as ${viewerName}`, { variant: "success" });
41
+ },
42
+ });
43
+ }, [apiKey, mutate, toast]);
44
+ const styles = useMemo(
45
+ () => ({
46
+ root: { gap: 10, alignSelf: "stretch" as const },
47
+ input: {
48
+ height: 40,
49
+ paddingHorizontal: 12,
50
+ borderRadius: 8,
51
+ borderWidth: 1,
52
+ borderColor: colors.border,
53
+ backgroundColor: colors.surface2,
54
+ color: colors.foreground,
55
+ fontSize: 14,
56
+ },
57
+ error: { color: colors.statusDanger, fontSize: 13 },
58
+ }),
59
+ [colors],
60
+ );
61
+ return (
62
+ <View style={styles.root}>
63
+ <TextInput
64
+ value={apiKey}
65
+ onChangeText={setApiKey}
66
+ onSubmitEditing={submit}
67
+ placeholder="lin_api_..."
68
+ placeholderTextColor={colors.foregroundMuted}
69
+ secureTextEntry
70
+ autoCapitalize="none"
71
+ autoCorrect={false}
72
+ accessibilityLabel="Linear API key"
73
+ style={styles.input}
74
+ />
75
+ {saveKey.error ? (
76
+ <Text accessibilityRole="alert" style={styles.error}>
77
+ {errorMessage(saveKey.error)}
78
+ </Text>
79
+ ) : null}
80
+ <Button
81
+ theme={theme}
82
+ variant="primary"
83
+ size="md"
84
+ label={props.submitLabel ?? "Connect Linear"}
85
+ icon="KeyRound"
86
+ busy={saveKey.isPending}
87
+ disabled={!apiKey.trim()}
88
+ onPress={submit}
89
+ />
90
+ </View>
91
+ );
92
+ }
93
+
94
+ export function ConnectCard({ theme, compact }: { theme: Theme; compact: boolean }) {
95
+ const { colors } = theme;
96
+ const styles = useMemo(
97
+ () => ({
98
+ root: {
99
+ flex: 1,
100
+ alignItems: "center" as const,
101
+ justifyContent: "center" as const,
102
+ padding: compact ? 20 : 32,
103
+ },
104
+ card: {
105
+ width: "100%" as const,
106
+ maxWidth: 440,
107
+ gap: 16,
108
+ padding: compact ? 20 : 28,
109
+ borderRadius: 12,
110
+ borderWidth: 1,
111
+ borderColor: colors.border,
112
+ backgroundColor: colors.surface1,
113
+ },
114
+ header: { flexDirection: "row" as const, alignItems: "center" as const, gap: 10 },
115
+ title: { color: colors.foreground, fontSize: 17, fontWeight: "500" as const },
116
+ body: { color: colors.foregroundMuted, fontSize: 14, lineHeight: 20 },
117
+ }),
118
+ [colors, compact],
119
+ );
120
+ return (
121
+ <View style={styles.root}>
122
+ <View style={styles.card}>
123
+ <View style={styles.header}>
124
+ <Icon name="SquareKanban" size={22} color={colors.foreground} />
125
+ <Text style={styles.title}>Connect Linear</Text>
126
+ </View>
127
+ <Text style={styles.body}>
128
+ Create a personal API key in Linear under Settings, Security & access. Paseo stores it on
129
+ this host only, readable by the daemon user, and never sends it back to the app.
130
+ </Text>
131
+ <ExternalLink href={LINEAR_KEY_SETTINGS_URL}>Open Linear API key settings</ExternalLink>
132
+ <ApiKeyForm theme={theme} />
133
+ </View>
134
+ </View>
135
+ );
136
+ }