@andrioid/paseo-attention-span 0.0.0-stage → 0.1.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.
@@ -0,0 +1,145 @@
1
+ import { type PluginSurfaceProps, useRpc } from "@getpaseo/plugin/client";
2
+ import { SettingsAction, SettingsCard, SettingsRow, SettingsSection, TextInput } from "@getpaseo/plugin/client/ui";
3
+ import { useQuery, useQueryClient } from "@tanstack/react-query";
4
+ import { useState } from "react";
5
+ import { Text, View } from "react-native";
6
+ import * as rpc from "../shared/rpc";
7
+ import type { ConfigStatus, CoverageKind } from "../shared/setup";
8
+ import { VIEW_QUERY_KEY } from "./data";
9
+ import { Button, StylesProvider, useStyles } from "./ui";
10
+
11
+ const COVERAGE_QUERY_KEY = ["attention-span", "coverage"] as const;
12
+
13
+ // Settings → Plugins → Attention Span: which orgs and repos the lists cover, saved to config.json [SRC-02].
14
+ export function CoverageSettings({ theme, layout }: PluginSurfaceProps) {
15
+ return (
16
+ <StylesProvider theme={theme} compact={layout.compact}>
17
+ <Coverage />
18
+ </StylesProvider>
19
+ );
20
+ }
21
+
22
+ function Coverage() {
23
+ const styles = useStyles();
24
+ const queryClient = useQueryClient();
25
+ const fetchCoverage = useRpc(rpc.getCoverage);
26
+ const save = useRpc(rpc.setCoverage);
27
+ const coverage = useQuery({ queryKey: COVERAGE_QUERY_KEY, queryFn: () => fetchCoverage({}) });
28
+ const [saving, setSaving] = useState(false);
29
+ const [error, setError] = useState<string | null>(null);
30
+
31
+ if (!coverage.data)
32
+ return (
33
+ <SettingsSection title="Orgs and repos">
34
+ <SettingsCard>
35
+ <SettingsRow label={coverage.error ? "Couldn't read the settings" : "Loading…"} error={coverage.error ? (coverage.error as Error).message : null} />
36
+ </SettingsCard>
37
+ </SettingsSection>
38
+ );
39
+ const { orgs, repos } = coverage.data;
40
+ // A file that doesn't parse is left for the person who edited it.
41
+ if (coverage.data.error)
42
+ return (
43
+ <SettingsSection title="Orgs and repos">
44
+ <SettingsCard>
45
+ <SettingsRow label="config.json can't be read" hint="Fix the file by hand. Nothing here overwrites it." error={coverage.data.error} />
46
+ </SettingsCard>
47
+ </SettingsSection>
48
+ );
49
+
50
+ // Saving refreshes the lists on the daemon; the screen picks them up on its next read.
51
+ async function persist(next: Pick<ConfigStatus, "orgs" | "repos">) {
52
+ setSaving(true);
53
+ setError(null);
54
+ try {
55
+ await save(next);
56
+ queryClient.setQueryData(COVERAGE_QUERY_KEY, { ...coverage.data!, ...next });
57
+ await queryClient.invalidateQueries({ queryKey: VIEW_QUERY_KEY });
58
+ } catch (failure) {
59
+ setError(`Saving failed: ${(failure as Error).message}`);
60
+ } finally {
61
+ setSaving(false);
62
+ }
63
+ }
64
+
65
+ return (
66
+ <View style={{ gap: 24 }}>
67
+ <SettingsSection title="Organizations">
68
+ <SettingsCard>
69
+ {orgs.map((org) => (
70
+ <SettingsAction key={org} label={org} actionLabel="Remove" disabled={saving} onPress={() => void persist({ orgs: orgs.filter((other) => other !== org), repos })} />
71
+ ))}
72
+ <AddRow kind="org" existing={orgs} disabled={saving} onAdd={(name) => persist({ orgs: [...orgs, name], repos })} />
73
+ </SettingsCard>
74
+ </SettingsSection>
75
+ <SettingsSection title="Repositories">
76
+ <SettingsCard>
77
+ {repos.map((repo) => (
78
+ <SettingsAction key={repo} label={repo} actionLabel="Remove" disabled={saving} onPress={() => void persist({ orgs, repos: repos.filter((other) => other !== repo) })} />
79
+ ))}
80
+ <AddRow kind="repo" existing={repos} disabled={saving} onAdd={(name) => persist({ orgs, repos: [...repos, name] })} />
81
+ </SettingsCard>
82
+ </SettingsSection>
83
+ <View style={{ gap: 4 }}>
84
+ {error ? <Text style={styles.danger}>{error}</Text> : null}
85
+ <Text style={styles.muted}>
86
+ {orgs.length || repos.length
87
+ ? "Attention Span only shows pull requests and issues from the organizations and repositories above. Next up suggests unclaimed issues from them too."
88
+ : "With none chosen, Attention Span shows everything on GitHub that involves you, and Next up suggests issues from your Paseo projects' repositories."}
89
+ </Text>
90
+ {/* Until more moves here, point to the file for the rest. */}
91
+ <Text style={styles.muted}>Settings are not complete yet. Until then, check out your ~/.config/paseo-attention-span/config.json for more options.</Text>
92
+ </View>
93
+ </View>
94
+ );
95
+ }
96
+
97
+ // Each name is checked on GitHub before it's saved; GitHub's exact name is what's kept.
98
+ function AddRow({ kind, existing, disabled, onAdd }: { kind: CoverageKind; existing: string[]; disabled: boolean; onAdd: (name: string) => Promise<void> }) {
99
+ const styles = useStyles();
100
+ const check = useRpc(rpc.checkCoverage);
101
+ const [text, setText] = useState("");
102
+ const [error, setError] = useState<string | null>(null);
103
+ const [checking, setChecking] = useState(false);
104
+ const label = kind === "org" ? "Add an organization" : "Add a repository";
105
+
106
+ async function add() {
107
+ if (!text.trim()) return;
108
+ setChecking(true);
109
+ setError(null);
110
+ try {
111
+ const result = await check({ kind, name: text });
112
+ if (result.error !== null) return setError(result.error);
113
+ if (existing.some((name) => name.toLowerCase() === result.name.toLowerCase())) return setError(`${result.name} is already in the list`);
114
+ await onAdd(result.name);
115
+ setText("");
116
+ } catch (failure) {
117
+ setError((failure as Error).message);
118
+ } finally {
119
+ setChecking(false);
120
+ }
121
+ }
122
+
123
+ return (
124
+ <SettingsRow label={label} error={error}>
125
+ <View style={[styles.row, { flexShrink: 1 }]}>
126
+ <TextInput
127
+ accessibilityLabel={label}
128
+ value={text}
129
+ onChangeText={(value) => {
130
+ setText(value);
131
+ setError(null);
132
+ }}
133
+ placeholder={kind === "org" ? "acme" : "acme/shop"}
134
+ placeholderTextColor={styles.c.foregroundMuted}
135
+ autoCapitalize="none"
136
+ autoCorrect={false}
137
+ editable={!disabled && !checking}
138
+ onSubmitEditing={() => void add()}
139
+ style={[styles.input, { minWidth: 160, flexShrink: 1 }]}
140
+ />
141
+ <Button label={checking ? "Checking…" : "Add"} variant="calm" disabled={disabled || checking || !text.trim()} onPress={() => void add()} />
142
+ </View>
143
+ </SettingsRow>
144
+ );
145
+ }
package/client/data.ts ADDED
@@ -0,0 +1,22 @@
1
+ import { useRpc } from "@getpaseo/plugin/client";
2
+ import { useQuery, useQueryClient } from "@tanstack/react-query";
3
+ import { useCallback } from "react";
4
+ import type { AttentionView, FeedId } from "../shared/attention";
5
+ import { getAttention } from "../shared/rpc";
6
+
7
+ export const VIEW_QUERY_KEY = ["attention-span", "view"] as const;
8
+
9
+ // The server owns the GitHub refresh schedule; the app only reads its cache [FRESH-01, GH-01].
10
+ export function useAttention() {
11
+ const fetchAttention = useRpc(getAttention);
12
+ const queryClient = useQueryClient();
13
+ const query = useQuery({
14
+ queryKey: VIEW_QUERY_KEY,
15
+ queryFn: () => fetchAttention({}),
16
+ refetchInterval: 60_000,
17
+ });
18
+ const setView = useCallback((view: AttentionView) => queryClient.setQueryData(VIEW_QUERY_KEY, view), [queryClient]);
19
+ // ⟳ fetches only the feed behind the tab I'm on.
20
+ const refreshNow = useCallback(async (feed: FeedId) => setView(await fetchAttention({ refresh: feed })), [fetchAttention, setView]);
21
+ return { ...query, setView, refreshNow };
22
+ }
@@ -0,0 +1,45 @@
1
+ // Tabler icons (MIT, tabler.io) from iconify, drawn with the theme color via currentColor.
2
+ export const ICONS = {
3
+ pr: { svg: "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\"><g fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\"><path d=\"M4 18a2 2 0 1 0 4 0a2 2 0 1 0-4 0M4 6a2 2 0 1 0 4 0a2 2 0 1 0-4 0m12 12a2 2 0 1 0 4 0a2 2 0 1 0-4 0M6 8v8\"/><path d=\"M11 6h5a2 2 0 0 1 2 2v8\"/><path d=\"m14 9l-3-3l3-3\"/></g></svg>" },
4
+ draft: { svg: "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\"><path fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M4 18a2 2 0 1 0 4 0a2 2 0 1 0-4 0M4 6a2 2 0 1 0 4 0a2 2 0 1 0-4 0m12 12a2 2 0 1 0 4 0a2 2 0 1 0-4 0M6 8v8m12-5h.01M18 6h.01\"/></svg>" },
5
+ issue: { svg: "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\"><g fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\"><path d=\"M11 12a1 1 0 1 0 2 0a1 1 0 1 0-2 0\"/><path d=\"M3 12a9 9 0 1 0 18 0a9 9 0 1 0-18 0\"/></g></svg>" },
6
+ bot: { svg: "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\"><path fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M6 6a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2zm6-4v2m-3 8v9m6-9v9M5 16l4-2m6 0l4 2M9 18h6M10 8v.01M14 8v.01\"/></svg>" },
7
+ reply: { svg: "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\"><g fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\"><path d=\"M18 4a3 3 0 0 1 3 3v8a3 3 0 0 1-3 3h-5l-5 3v-3H6a3 3 0 0 1-3-3V7a3 3 0 0 1 3-3z\"/><path d=\"m11 8l-3 3l3 3m5-3H8\"/></g></svg>" },
8
+ review: { svg: "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\"><g fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\"><path d=\"M10 12a2 2 0 1 0 4 0a2 2 0 0 0-4 0\"/><path d=\"M21 12q-3.6 6-9 6t-9-6q3.6-6 9-6t9 6\"/></g></svg>" },
9
+ ci: { svg: "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\"><path fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M12 9v4m-1.637-9.409L2.257 17.125a1.914 1.914 0 0 0 1.636 2.871h16.214a1.914 1.914 0 0 0 1.636-2.87L13.637 3.59a1.914 1.914 0 0 0-3.274 0M12 16h.01\"/></svg>" },
10
+ changes: { svg: "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\"><g fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\"><path d=\"M14 3v4a1 1 0 0 0 1 1h4\"/><path d=\"M17 21H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7l5 5v11a2 2 0 0 1-2 2m-5-11v4m-2-2h4m-4 5h4\"/></g></svg>" },
11
+ merge: { svg: "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\"><g fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\"><path d=\"M5 18a2 2 0 1 0 4 0a2 2 0 1 0-4 0M5 6a2 2 0 1 0 4 0a2 2 0 1 0-4 0m10 6a2 2 0 1 0 4 0a2 2 0 1 0-4 0M7 8v8\"/><path d=\"M7 8a4 4 0 0 0 4 4h4\"/></g></svg>" },
12
+ branch: { svg: "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\"><g fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\"><path d=\"M5 18a2 2 0 1 0 4 0a2 2 0 1 0-4 0M5 6a2 2 0 1 0 4 0a2 2 0 1 0-4 0m10 0a2 2 0 1 0 4 0a2 2 0 1 0-4 0M7 8v8m2 2h6a2 2 0 0 0 2-2v-5\"/><path d=\"m14 14l3-3l3 3\"/></g></svg>" },
13
+ hourglass: { svg: "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\"><g fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\"><path d=\"M6.5 7h11m-11 10h11M6 20v-2a6 6 0 1 1 12 0v2a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1\"/><path d=\"M6 4v2a6 6 0 1 0 12 0V4a1 1 0 0 0-1-1H7a1 1 0 0 0-1 1\"/></g></svg>" },
14
+ pencil: { svg: "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\"><path fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M4 20h4L18.5 9.5a2.828 2.828 0 1 0-4-4L4 16zm9.5-13.5l4 4\"/></svg>" },
15
+ replyArrow: { svg: "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\"><g fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\"><path d=\"m9 14l-4-4l4-4\"/><path d=\"M5 10h11a4 4 0 1 1 0 8h-1\"/></g></svg>" },
16
+ update: { svg: "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\"><path fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M20 11A8.1 8.1 0 0 0 4.5 9M4 5v4h4m-4 4a8.1 8.1 0 0 0 15.5 2m.5 4v-4h-4\"/></svg>" },
17
+ close: { svg: "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\"><path fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M18 6L6 18M6 6l12 12\"/></svg>" },
18
+ play: { svg: "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\"><path fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M7 4v16l13-8z\"/></svg>" },
19
+ github: { svg: "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\"><path fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M9 19c-4.3 1.4-4.3-2.5-6-3m12 5v-3.5c0-1 .1-1.4-.5-2c2.8-.3 5.5-1.4 5.5-6a4.6 4.6 0 0 0-1.3-3.2a4.2 4.2 0 0 0-.1-3.2s-1.1-.3-3.5 1.3a12.3 12.3 0 0 0-6.2 0C6.5 2.8 5.4 3.1 5.4 3.1a4.2 4.2 0 0 0-.1 3.2A4.6 4.6 0 0 0 4 9.5c0 4.6 2.7 5.7 5.5 6c-.6.6-.6 1.2-.5 2V21\"/></svg>" },
20
+ dismiss: { svg: "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\"><g fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\"><path d=\"M10.585 10.587a2 2 0 0 0 2.829 2.828\"/><path d=\"M16.681 16.673A8.7 8.7 0 0 1 12 18q-5.4 0-9-6q1.908-3.18 4.32-4.674m2.86-1.146A9 9 0 0 1 12 6q5.4 0 9 6q-1 1.665-2.138 2.87M3 3l18 18\"/></g></svg>" },
21
+ // The plugin's logo in one colour, for the sidebar (assets/logo.svg).
22
+ logo: { svg: "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"207 195 585 585\" fill=\"currentColor\"><path d=\"M 562.9 297.9 C 577.2 309.5 586.1 323.3 595.5 338.9 C 599.6 345.7 603.7 352.3 607.9 358.9 C 624.5 384.8 640.6 410.9 656.4 437.3 C 659.6 442.7 662.8 448.1 666.1 453.4 C 667.3 455.5 668.6 457.6 669.8 459.7 C 670.4 460.6 670.9 461.5 671.5 462.5 C 673 465 674.5 467.5 676 470 C 675.7 470.7 675.3 471.3 675 472 C 673.8 470.7 672.5 469.4 671.2 468.1 C 640.6 436.7 605.7 416.8 561.3 415 C 497.3 414.6 443.8 453.2 399 495 C 397.6 496.3 396.2 497.6 394.8 498.9 C 384.5 508.6 374.3 518.3 365 529 C 362.7 531.3 360.3 533.7 358 536 C 355.7 538.7 353.3 541.3 351 544 C 349.4 545.8 347.8 547.5 346.1 549.3 C 342 553.8 338.1 558.5 334.3 563.2 C 331.9 566.1 329.5 568.8 327 571.5 C 322.9 575.9 319.1 580.5 315.3 585.1 C 312.8 588.2 310.2 591.3 307.6 594.3 C 302.9 599.9 298.4 605.5 293.9 611.3 C 290.7 615.4 287.4 619.4 284 623.5 C 279.8 628.6 275.6 633.8 271.5 639 C 248.5 668.5 248.5 668.5 241 671 C 234.5 671.8 229.5 671.7 223.5 668.9 C 219.1 665.2 215.8 661.5 214 656 C 213.3 646.3 216.7 639.9 222.3 632.2 C 223.4 630.8 224.5 629.3 225.6 627.8 C 226.7 626.2 227.8 624.6 229 623 C 231.3 619.7 233.6 616.5 235.9 613.2 C 237.1 611.5 238.2 609.8 239.4 608.1 C 240 607.3 240.6 606.4 241.2 605.6 C 243 603 244.8 600.4 246.6 597.8 C 252 590.2 257.3 582.5 262.6 574.8 C 267.3 568 272 561.3 276.7 554.7 C 282.5 546.5 288.2 538.2 293.9 530 C 297.3 525.1 300.6 520.3 304 515.5 C 305.3 513.6 306.7 511.7 308 509.7 C 364 429.2 364 429.2 366 426.4 C 367.3 424.5 368.7 422.5 370 420.6 C 373.3 415.8 376.7 411 380 406.2 C 388.5 394.1 396.9 381.8 405.2 369.6 C 408 365.5 410.8 361.5 413.6 357.4 C 418.2 350.7 422.7 343.9 427.2 337.1 C 428.4 335.4 429.6 333.6 430.8 331.8 C 432.7 328.9 434.6 326.1 436.5 323.2 C 450.8 302 470.3 287 495.6 281.4 C 520.2 278.3 542.9 282.9 562.9 297.9 Z\"/><path d=\"M 576.8 472.7 C 577.6 472.7 577.6 472.7 581.9 472.7 C 593.2 472.8 603.9 473.3 615 476 C 616.6 476.4 618.2 476.8 619.8 477.2 C 653.1 486.3 682.9 506.7 704 534 C 705.2 535.5 706.4 537 707.7 538.5 C 718.6 552.2 728.7 566.7 739 581 C 740.1 582.5 741.2 584 742.3 585.5 C 748.6 594.2 754.8 602.9 761 611.7 C 765.2 617.8 769.6 623.8 774 629.8 C 781.8 640.7 785.2 647.6 784 661 C 780.2 671 774.4 678.5 765 683.4 C 752.7 689 740.9 689.6 728 685 C 708.8 676.4 695.5 661.4 683 645 C 682.2 644 681.4 642.9 680.5 641.8 C 673.5 632.7 666.7 623.4 660 614 C 657.8 611 655.7 608 653.5 605 C 652.4 603.5 651.4 602 650.3 600.5 C 648.1 597.5 645.9 594.4 643.6 591.4 C 638.8 584.6 633.9 577.9 629.1 571.1 C 610.5 545.1 610.5 545.1 601 535 C 600 533.7 598.9 532.4 597.8 531.1 C 584 516.2 567 505.1 548 498 C 547 497.6 546 497.2 545 496.8 C 532.1 492.2 519.1 491.7 505.6 491.9 C 504.2 491.9 502.8 491.9 501.3 491.9 C 497.9 491.9 494.4 492 491 492 C 499.6 483.4 519 480.2 530.6 477.6 C 531.6 477.3 532.6 477.1 533.6 476.9 C 548 473.7 562 472.6 576.8 472.7 Z\"/><path fill-rule=\"evenodd\" d=\"M 510 543 C 510.5 543.2 510.5 543.2 512.8 544.3 C 530.3 552.9 543.5 569.8 550.4 587.8 C 553.3 597 554.3 605.2 554.3 614.8 C 554.3 615.9 554.3 617 554.4 618.1 C 554.4 639.1 544.4 657.2 530.3 672.2 C 524.8 677.5 518.7 681.3 512 685 C 511.2 685.5 511.2 685.5 506.9 687.8 C 488.9 695.8 468.4 696.2 449.7 689.9 C 427.8 681.2 412.1 665.9 402.5 644.6 C 395.2 626.6 394.4 606.4 401 588 C 404.9 578.8 409.5 570.6 416 563 C 416.9 561.9 417.8 560.7 418.7 559.5 C 442.5 534.3 479.1 528.1 510 543 Z M 465.2 570.6 C 471.2 574.4 474.4 578.4 477 585 C 477.5 591.4 476.9 596.2 474 602 C 468.5 607.4 464.9 609.9 457.2 610.3 C 452.1 610.2 448.4 609.8 444 607 C 436.8 599.4 435.5 596.1 435.6 585.8 C 436.2 580.3 438 577.8 442 574 C 450.1 569 456.1 567.2 465.2 570.6 Z\"/></svg>" },
23
+ tray: { svg: "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\"><g fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\"><path d=\"M4 6a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2z\"/><path d=\"M4 13h3l3 3h4l3-3h3\"/></g></svg>" },
24
+ nextUp: { svg: "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\"><g fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\"><path d=\"m8 16l2-6l6-2l-2 6z\"/><path d=\"M3 12a9 9 0 1 0 18 0a9 9 0 1 0-18 0m9-9v2m0 14v2m-9-9h2m14 0h2\"/></g></svg>" },
25
+ claim: { svg: "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\"><path fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M8 7a4 4 0 1 0 8 0a4 4 0 0 0-8 0m8 12h6m-3-3v6M6 21v-2a4 4 0 0 1 4-4h4\"/></svg>" },
26
+ bug: { svg: "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\"><path fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M9 9V8a3 3 0 0 1 6 0v1M8 9h8a6 6 0 0 1 1 3v3a5 5 0 0 1-10 0v-3a6 6 0 0 1 1-3m-5 4h4m10 0h4m-9 7v-6m-8 5l3.35-2M20 19l-3.35-2M4 7l3.75 2.4M20 7l-3.75 2.4\"/></svg>" },
27
+ feature: { svg: "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\"><path fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M3 12h1m8-9v1m8 8h1M5.6 5.6l.7.7m12.1-.7l-.7.7M9 16a5 5 0 1 1 6 0a3.5 3.5 0 0 0-1 3a2 2 0 0 1-4 0a3.5 3.5 0 0 0-1-3m.7 1h4.6\"/></svg>" },
28
+ debt: { svg: "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\"><path fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M5 21V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16l-3-2l-2 2l-2-2l-2 2l-2-2zM9 7h6m-6 4h6m-2 4h2\"/></svg>" },
29
+ task: { svg: "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\"><g fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\"><path d=\"m9 11l3 3l8-8\"/><path d=\"M20 12v6a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h9\"/></g></svg>" },
30
+ epic: { svg: "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\"><path fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M12 4L4 8l8 4l8-4zm-8 8l8 4l8-4M4 16l8 4l8-4\"/></svg>" },
31
+ clock: { svg: "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\"><g fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\"><path d=\"M3 12a9 9 0 1 0 18 0a9 9 0 0 0-18 0\"/><path d=\"M12 7v5l3 3\"/></g></svg>" },
32
+ history: { svg: "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\"><g fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\"><path d=\"M12 8v4l2 2\"/><path d=\"M3.05 11a9 9 0 1 1 .5 4m-.5 5v-5h5\"/></g></svg>" },
33
+ issueClosed: { svg: "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\"><g fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\"><path d=\"M3 12a9 9 0 1 0 18 0a9 9 0 1 0-18 0\"/><path d=\"m9 12l2 2l4-4\"/></g></svg>" },
34
+ dots: { svg: "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\"><path fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M4 12a1 1 0 1 0 2 0a1 1 0 1 0-2 0m7 0a1 1 0 1 0 2 0a1 1 0 1 0-2 0m7 0a1 1 0 1 0 2 0a1 1 0 1 0-2 0\"/></svg>" },
35
+ thumbUp: { svg: "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\"><path fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M7 11v8a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1v-7a1 1 0 0 1 1-1h3a4 4 0 0 0 4-4V6a2 2 0 0 1 4 0v5h3a2 2 0 0 1 2 2l-1 5a2 3 0 0 1-2 2h-7a3 3 0 0 1-3-3\"/></svg>" },
36
+ settings: { svg: "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\"><g fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\"><path d=\"M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 0 0 2.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 0 0 1.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 0 0-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 0 0-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 0 0-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 0 0-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 0 0 1.066-2.573c-.94-1.543.826-3.31 2.37-2.37c1 .608 2.296.07 2.572-1.065\"/><path d=\"M9 12a3 3 0 1 0 6 0a3 3 0 0 0-6 0\"/></g></svg>" },
37
+ reload: { svg: "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\"><path fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M19.933 13.041a8 8 0 1 1-9.925-8.788c3.899-1 7.935 1.007 9.425 4.747M20 4v5h-5\"/></svg>" },
38
+ unclaim: { svg: "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\"><path fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M8 7a4 4 0 1 0 8 0a4 4 0 0 0-8 0M6 21v-2a4 4 0 0 1 4-4h4c.348 0 .686.045 1.009.128M16 19h6\"/></svg>" },
39
+ square: { svg: "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\"><path fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M3 5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z\"/></svg>" },
40
+ squareCheck: { svg: "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\"><path fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M3 5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2zm6 7l2 2l4-4\"/></svg>" },
41
+ // Tabler message-ai: marks actions that start or continue an agent session.
42
+ agent: { svg: "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\"><g fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\"><path d=\"M8 9h8m-8 4h3.5m-1.506 6.804L8 21v-3H6a3 3 0 0 1-3-3V7a3 3 0 0 1 3-3h12a3 3 0 0 1 3 3v4\"/><path d=\"M14 21v-4a2 2 0 1 1 4 0v4m-4-2h4m3-4v6\"/></g></svg>" },
43
+ } as const;
44
+
45
+ export type IconName = keyof typeof ICONS;
@@ -0,0 +1,161 @@
1
+ import { openExternalUrl } from "@getpaseo/plugin/client";
2
+ import { useQuery } from "@tanstack/react-query";
3
+ import { useMemo, useState } from "react";
4
+ import { Pressable, Text, View } from "react-native";
5
+ import { ago } from "../shared/format";
6
+ import type { AgentSummary, NextUpIssue, UnclaimedSource } from "../shared/attention";
7
+ import type { IconName } from "./icons";
8
+ import type { FetchDetails } from "./attention-screen";
9
+ import { ActionSheet, DetailLines, type SheetEntry } from "./sheet";
10
+ import { Button, Meta, SectionHeader, TablerIcon, Toggle, useStyles } from "./ui";
11
+
12
+ const TYPE_ICONS: Record<string, IconName> = { bug: "bug", feature: "feature", debt: "debt", task: "task", epic: "epic" };
13
+
14
+ interface Props {
15
+ assigned: NextUpIssue[];
16
+ unclaimed: NextUpIssue[];
17
+ unclaimedFrom: UnclaimedSource;
18
+ // Scope chips I picked; none means all [NXT-03].
19
+ selected: string[];
20
+ onSelect: (scopes: string[]) => void;
21
+ agents: Record<string, AgentSummary>;
22
+ busy: string | null;
23
+ fetchDetails: FetchDetails;
24
+ onStart: (issue: NextUpIssue, claim: boolean) => void;
25
+ // Claim without starting, or give an assigned issue back [NXT-07].
26
+ onClaim: (issue: NextUpIssue, on: boolean) => void;
27
+ onOpenAgent: (agentId: string) => void;
28
+ }
29
+
30
+ type RowProps = Omit<Props, "assigned" | "unclaimed" | "unclaimedFrom" | "selected" | "onSelect">;
31
+
32
+ // Assigned issues first [NXT-06], then unclaimed ones [NXT-01]; both already ranked by the server [NXT-02].
33
+ export function NextUpList({ assigned, unclaimed, unclaimedFrom, selected, onSelect, ...rowProps }: Props) {
34
+ const styles = useStyles();
35
+ const scopes = useMemo(() => [...new Set(unclaimed.map((issue) => issue.scope).filter((scope): scope is string => !!scope))].sort(), [unclaimed]);
36
+ // Filter by the scopes I picked last time [NXT-03]; a picked scope with no issues left is ignored.
37
+ const active = selected.filter((scope) => scopes.includes(scope));
38
+ const shown = active.length === 0 ? unclaimed : unclaimed.filter((issue) => issue.scope && active.includes(issue.scope));
39
+ const toggle = (scope: string) => onSelect(active.includes(scope) ? active.filter((s) => s !== scope) : [...active, scope]);
40
+
41
+ return (
42
+ <View style={{ gap: 24 }}>
43
+ {assigned.length > 0 ? (
44
+ <View>
45
+ <SectionHeader icon="claim" title="Assigned to you" count={assigned.length} />
46
+ <IssueList issues={assigned} claim={false} {...rowProps} />
47
+ </View>
48
+ ) : null}
49
+ <View>
50
+ <SectionHeader icon="nextUp" title="Unclaimed" count={shown.length} />
51
+ {scopes.length > 0 ? (
52
+ <View style={[styles.wrap, { marginBottom: 10 }]}>
53
+ <Toggle label="All" active={active.length === 0} onPress={() => onSelect([])} />
54
+ {scopes.map((scope) => (
55
+ <Toggle key={scope} label={scope} active={active.includes(scope)} onPress={() => toggle(scope)} />
56
+ ))}
57
+ </View>
58
+ ) : null}
59
+ {unclaimedFrom === "none" ? (
60
+ <Text style={styles.text}>No repos to look in. Register a project in Paseo, or set nextUp.repos in config.json.</Text>
61
+ ) : shown.length === 0 ? (
62
+ <Text style={styles.text}>No unclaimed issues{active.length ? " in these scopes" : ""}.</Text>
63
+ ) : (
64
+ <IssueList issues={shown} claim {...rowProps} />
65
+ )}
66
+ </View>
67
+ </View>
68
+ );
69
+ }
70
+
71
+ function IssueList({ issues, claim, ...rowProps }: RowProps & { issues: NextUpIssue[]; claim: boolean }) {
72
+ const styles = useStyles();
73
+ return (
74
+ <View style={styles.card}>
75
+ {issues.map((issue, index) => (
76
+ <IssueRow key={issue.key} issue={issue} claim={claim} first={index === 0} {...rowProps} />
77
+ ))}
78
+ </View>
79
+ );
80
+ }
81
+
82
+ const facts = (issue: NextUpIssue) => [`#${issue.number}`, `${ago(issue.createdAt)} old`, ...[issue.type, issue.priority, issue.effort, issue.scope].filter((v): v is string => !!v)];
83
+
84
+ // Its main action and ⋯; tapping it opens the sheet with the description, Claim or Unclaim [ATT-06, NXT-07].
85
+ function IssueRow({ issue, claim, first, agents, busy, fetchDetails, onStart, onClaim, onOpenAgent }: RowProps & { issue: NextUpIssue; claim: boolean; first: boolean }) {
86
+ const styles = useStyles();
87
+ const [open, setOpen] = useState(false);
88
+ const agent = agents[issue.key];
89
+ const startLabel = claim ? "Claim & start" : "Start";
90
+ return (
91
+ <View style={[styles.item, first ? null : styles.divider]}>
92
+ <View style={{ paddingTop: 2 }}>
93
+ <TablerIcon name={TYPE_ICONS[issue.type?.toLowerCase() ?? ""] ?? "issue"} size={18} color={issue.type === "Bug" ? styles.c.statusDanger : styles.c.foregroundMuted} />
94
+ </View>
95
+ <Pressable accessibilityRole="button" accessibilityLabel={`${issue.title}, more`} onPress={() => setOpen(true)} style={styles.body}>
96
+ <Text style={styles.title} numberOfLines={2}>
97
+ {issue.title}
98
+ </Text>
99
+ <Meta facts={facts(issue)} chips={[]} />
100
+ </Pressable>
101
+ <View style={styles.wrap}>
102
+ {busy === issue.key ? (
103
+ <Text style={styles.muted}>working…</Text>
104
+ ) : agent ? (
105
+ <Button label={styles.compact ? undefined : "Continue"} icon="agent" variant="calm" onPress={() => onOpenAgent(agent.id)} />
106
+ ) : (
107
+ // Claim first so teammates see it's taken [NXT-05].
108
+ <Button label={styles.compact ? undefined : startLabel} accessibilityLabel={`${startLabel} ${issue.title}`} icon="agent" variant="calm" onPress={() => onStart(issue, claim)} />
109
+ )}
110
+ <Button icon="dots" variant="ghost" accessibilityLabel="More actions" onPress={() => setOpen(true)} />
111
+ </View>
112
+ {open ? <IssueSheet issue={issue} claim={claim} agent={agent} fetchDetails={fetchDetails} onStart={onStart} onClaim={onClaim} onOpenAgent={onOpenAgent} onClose={() => setOpen(false)} /> : null}
113
+ </View>
114
+ );
115
+ }
116
+
117
+ function IssueSheet({
118
+ issue,
119
+ claim,
120
+ agent,
121
+ fetchDetails,
122
+ onStart,
123
+ onClaim,
124
+ onOpenAgent,
125
+ onClose,
126
+ }: Pick<Props, "fetchDetails" | "onStart" | "onClaim" | "onOpenAgent"> & { issue: NextUpIssue; claim: boolean; agent?: AgentSummary; onClose: () => void }) {
127
+ const details = useQuery({
128
+ queryKey: ["attention-span", "details", issue.key, false],
129
+ queryFn: () => fetchDetails({ subject: { kind: "issue", repo: issue.repo, number: issue.number }, checks: false }),
130
+ staleTime: 60_000,
131
+ });
132
+ const close = (then: () => void) => () => (onClose(), then());
133
+ const entries: SheetEntry[] = [
134
+ agent
135
+ ? { key: "start", icon: "agent", label: "Continue", detail: "Opens the session started on this issue", onPress: close(() => onOpenAgent(agent.id)) }
136
+ : {
137
+ key: "start",
138
+ icon: "agent",
139
+ label: claim ? "Claim & start" : "Start",
140
+ detail: `${claim ? "Assigns you, then an agent" : "An agent"} reads the issue and proposes a plan`,
141
+ onPress: close(() => onStart(issue, claim)),
142
+ },
143
+ claim
144
+ ? { key: "claim", icon: "claim", label: "Claim", detail: "Assigns you, without starting a session", onPress: close(() => onClaim(issue, true)) }
145
+ : { key: "unclaim", icon: "unclaim", label: "Unclaim", detail: "Removes you as assignee, so it goes back to Unclaimed", onPress: close(() => onClaim(issue, false)) },
146
+ { key: "github", icon: "github", label: "Open issue on GitHub", detail: `${issue.repo}#${issue.number}`, onPress: close(() => void openExternalUrl(issue.url)) },
147
+ ];
148
+ return (
149
+ <ActionSheet
150
+ title={issue.title}
151
+ onClose={onClose}
152
+ top={
153
+ <>
154
+ <Meta facts={[`${issue.repo}#${issue.number}`, ...facts(issue).slice(1)]} chips={[]} />
155
+ </>
156
+ }
157
+ entries={entries}
158
+ bottom={<DetailLines details={details.data} loading={details.isLoading} error={details.error} linkedLabel="PR" />}
159
+ />
160
+ );
161
+ }
@@ -0,0 +1,202 @@
1
+ import type { usePaseo } from "@getpaseo/plugin/client";
2
+ import type { AgentSummary, Target } from "../shared/attention";
3
+ import { type AttentionConfig, codeRepoFor, githubRepoOf, type SessionKind } from "../shared/config";
4
+
5
+ type Paseo = ReturnType<typeof usePaseo>;
6
+
7
+ // Labels tie an agent to the item it works on [ATT-07, SES-03].
8
+ export const LABEL = "attention-span";
9
+ export const ITEM_LABEL = "attention-span-item";
10
+ // Sessions started under the plugin's earlier names, "GitHub attention" and "GitHub inbox".
11
+ const OLD_LABELS = ["github-attention", "github-inbox"];
12
+
13
+ // Agents started from Attention Span, keyed by item.
14
+ export async function itemAgents(paseo: Paseo): Promise<Record<string, AgentSummary>> {
15
+ const labels = [LABEL, ...OLD_LABELS];
16
+ const lists = await Promise.all(labels.map((label) => paseo.agents.list({ filter: { labels: { [label]: "1" } } })));
17
+ const byItem: Record<string, AgentSummary> = {};
18
+ for (const { agent } of lists.flatMap((list) => list.entries)) {
19
+ const key = labels.map((label) => agent.labels?.[`${label}-item`]).find(Boolean);
20
+ if (!key || agent.archivedAt) continue;
21
+ byItem[key] = {
22
+ id: agent.id,
23
+ status: agent.status,
24
+ requiresAttention: !!agent.requiresAttention,
25
+ title: agent.title ?? null,
26
+ workspaceId: agent.workspaceId ?? null,
27
+ };
28
+ }
29
+ return byItem;
30
+ }
31
+
32
+ export interface ProjectMatch {
33
+ projectId: string;
34
+ rootPath: string;
35
+ }
36
+
37
+ export interface GitHubProject extends ProjectMatch {
38
+ repo: string;
39
+ }
40
+
41
+ // Registered Paseo projects with a GitHub remote, by "owner/name".
42
+ export async function githubProjects(paseo: Paseo): Promise<GitHubProject[]> {
43
+ const { projects } = await paseo.projects.list();
44
+ return projects.flatMap((project) => {
45
+ const repo = githubRepoOf(project.projectKey);
46
+ return repo ? [{ repo, projectId: project.projectId, rootPath: project.projectRootPath }] : [];
47
+ });
48
+ }
49
+
50
+ // The local checkout is the registered Paseo project with the repo's GitHub remote [SES-05].
51
+ export async function findProject(paseo: Paseo, repo: string): Promise<ProjectMatch | null> {
52
+ return (await githubProjects(paseo)).find((project) => project.repo.toLowerCase() === repo.toLowerCase()) ?? null;
53
+ }
54
+
55
+ export interface Checkout {
56
+ repo: string;
57
+ project: ProjectMatch | null;
58
+ // Where a new branch for an issue starts.
59
+ baseBranch?: string;
60
+ }
61
+
62
+ // The item's own repo, or for an issue the repo its code lives in [SES-05, SES-07].
63
+ export async function findCheckout(
64
+ paseo: Paseo,
65
+ config: Pick<AttentionConfig, "codeRepos">,
66
+ target: Target,
67
+ defaultBranch: (repo: string) => Promise<string | null>,
68
+ ): Promise<Checkout> {
69
+ const repo = target.kind === "issue" ? codeRepoFor(config, target.repo) : target.repo;
70
+ const project = await findProject(paseo, repo);
71
+ if (repo.toLowerCase() === target.repo.toLowerCase()) return { repo, project, baseBranch: target.defaultBranch };
72
+ return { repo, project, baseBranch: project ? ((await defaultBranch(repo)) ?? undefined) : undefined };
73
+ }
74
+
75
+ export function noProjectMessage(checkout: Checkout, target: Target): string {
76
+ const where = checkout.repo === target.repo ? checkout.repo : `${checkout.repo}, where ${target.repo}'s code lives,`;
77
+ return `No Paseo project has ${where} as its GitHub remote. Add the repository as a project first.`;
78
+ }
79
+
80
+ export interface WorkspaceChoice {
81
+ id: string;
82
+ name: string;
83
+ }
84
+
85
+ // Existing workspaces of the project that have the PR's branch checked out.
86
+ export async function branchWorkspaces(paseo: Paseo, projectId: string, branch: string | undefined): Promise<WorkspaceChoice[]> {
87
+ if (!branch) return [];
88
+ const { entries } = await paseo.workspaces.list();
89
+ return entries
90
+ .filter((workspace) => workspace.projectId === projectId && workspace.gitRuntime?.currentBranch === branch && !workspace.archivingAt)
91
+ .map((workspace) => ({ id: workspace.id, name: workspace.title ?? workspace.name }));
92
+ }
93
+
94
+ export interface ProfileChoice {
95
+ id: string;
96
+ name: string;
97
+ }
98
+
99
+ export async function listProfiles(paseo: Paseo): Promise<ProfileChoice[]> {
100
+ const { config } = await paseo.config.get();
101
+ return (config.agentProfiles ?? []).map((profile) => ({ id: profile.id, name: profile.name }));
102
+ }
103
+
104
+ type AgentConfig = Parameters<Paseo["agents"]["create"]>[0]["config"];
105
+
106
+ // The profile configured for the session type, then "default", then Paseo's defaults [SES-01, SES-06].
107
+ export async function resolveAgentConfig(
108
+ paseo: Paseo,
109
+ kind: SessionKind,
110
+ config: AttentionConfig,
111
+ profileId?: string,
112
+ ): Promise<{ agent: AgentConfig; label: string }> {
113
+ const { config: daemon } = await paseo.config.get();
114
+ const profiles = daemon.agentProfiles ?? [];
115
+ const byName = (name: string | undefined) =>
116
+ name ? profiles.find((p) => p.id === name || p.name.toLowerCase() === name.toLowerCase()) : undefined;
117
+ const profile = profileId ? byName(profileId) : (byName(config.profiles[kind]) ?? byName(config.profiles.default));
118
+
119
+ const snapshot = await paseo.providers.snapshot();
120
+ const providers = snapshot.entries.filter((entry) => entry.status === "ready" && entry.enabled);
121
+ const defaultModel = (provider: string) => {
122
+ const entry = providers.find((candidate) => candidate.provider === provider);
123
+ return (entry?.models ?? []).find((model) => model.isDefault)?.id ?? entry?.models?.[0]?.id;
124
+ };
125
+
126
+ if (profile) {
127
+ const model = profile.model ?? defaultModel(profile.provider);
128
+ return {
129
+ label: profile.name,
130
+ agent: {
131
+ provider: model ? `${profile.provider}/${model}` : profile.provider,
132
+ modeId: profile.modeId,
133
+ thinkingOptionId: profile.thinkingOptionId,
134
+ featureValues: profile.featureValues,
135
+ },
136
+ };
137
+ }
138
+ const fallback = providers.find((entry) => defaultModel(entry.provider));
139
+ if (!fallback) throw new Error("No agent provider is ready on this host.");
140
+ return {
141
+ label: `Paseo default (${fallback.label})`,
142
+ agent: { provider: `${fallback.provider}/${defaultModel(fallback.provider)}`, modeId: fallback.defaultModeId ?? undefined },
143
+ };
144
+ }
145
+
146
+ function slug(text: string): string {
147
+ return text
148
+ .toLowerCase()
149
+ .replace(/[^a-z0-9]+/g, "-")
150
+ .replace(/^-|-$/g, "")
151
+ .slice(0, 40);
152
+ }
153
+
154
+ export interface StartSession {
155
+ itemKey: string;
156
+ kind: SessionKind;
157
+ target: Target;
158
+ prompt: string;
159
+ config: AttentionConfig;
160
+ checkout: Checkout;
161
+ profileId?: string;
162
+ workspaceId?: string; // an existing workspace; otherwise a new worktree
163
+ }
164
+
165
+ // A new worktree: checkout-pr for PRs, branch-off from the default branch for issues.
166
+ export async function startSession(paseo: Paseo, input: StartSession): Promise<string> {
167
+ const { target, checkout } = input;
168
+ const project = checkout.project;
169
+ if (!project) throw new Error(noProjectMessage(checkout, target));
170
+ const { agent: agentConfig } = await resolveAgentConfig(paseo, input.kind, input.config, input.profileId);
171
+ const title = `${target.kind === "pr" ? "PR" : "Issue"} ${target.number}: ${target.title}`.slice(0, 120);
172
+
173
+ const workspace = input.workspaceId
174
+ ? paseo.workspaces.ref(input.workspaceId)
175
+ : await paseo.workspaces.create({
176
+ title,
177
+ source:
178
+ target.kind === "pr"
179
+ ? {
180
+ kind: "worktree",
181
+ cwd: project.rootPath,
182
+ projectId: project.projectId,
183
+ action: "checkout",
184
+ checkoutSource: { kind: "change_request", forge: "github", number: target.number },
185
+ }
186
+ : {
187
+ kind: "worktree",
188
+ cwd: project.rootPath,
189
+ projectId: project.projectId,
190
+ action: "branch-off",
191
+ baseBranch: checkout.baseBranch,
192
+ branchName: `issue-${target.number}-${slug(target.title)}`,
193
+ },
194
+ });
195
+ const agent = await workspace.agents.create({
196
+ config: agentConfig,
197
+ title,
198
+ prompt: input.prompt,
199
+ labels: { [LABEL]: "1", [ITEM_LABEL]: input.itemKey },
200
+ });
201
+ return agent.id;
202
+ }